using UnityEngine; public partial class MeshPainter { // --- Setup (shared) --- private void SetupCollider() { _collider = GetComponent(); if (!_collider) _collider = gameObject.AddComponent(); _collider.convex = false; // non-convex provides triangleIndex / textureCoord // An always-on SkinnedColliderBaker (skinned rigs only) owns the bake even outside paint // mode; we delegate to it and only borrow its mesh. Without one we keep our own legacy bake. _baker = _skinned ? GetComponent() : null; if (!_baker) _bakedMesh = new Mesh { name = "MeshPainter_Baked" }; BakeColliderMesh(); } private void BakeColliderMesh() { if (_baker) { // Baker already baked (negative execution order) and keeps _collider.sharedMesh fresh; // we only track its mesh for the GPU raster path. No bake here - that's the whole point. _bakedMesh = _baker.BakedMesh; } else if (_skinned) { // Static skinned mesh: bake into local coordinates (scale = 1). _skinned.BakeMesh(_bakedMesh); _collider.sharedMesh = null; _collider.sharedMesh = _bakedMesh; } else { var mf = GetComponent(); if (mf) _collider.sharedMesh = mf.sharedMesh; } } // BuildMeshCaches scratch: refilled per bake without allocating (with updateColliderEveryFrame this // runs per frame while painting; mesh.vertices/.triangles/.uv return fresh arrays per call). private readonly System.Collections.Generic.List _vertScratch = new System.Collections.Generic.List(); private int _meshCacheId; // mesh instance the topology/UV caches were built for (0 = none) /// Reads vertices/triangles/UVs of the collider mesh and computes culling data. /// Only the vertices change across re-bakes of the same mesh; topology/UVs are fetched once. private void BuildMeshCaches() { Mesh m = _collider.sharedMesh; if (!m) return; m.GetVertices(_vertScratch); int vCount = _vertScratch.Count; if (_verts == null || _verts.Length != vCount) _verts = new Vector3[vCount]; _vertScratch.CopyTo(_verts); if (_meshCacheId != m.GetInstanceID()) { _meshCacheId = m.GetInstanceID(); _tris = m.triangles; _uvs = m.uv; // Fallback to the original mesh UVs if the baked mesh dropped them. if ((_uvs == null || _uvs.Length != vCount) && _skinned && _skinned.sharedMesh) _uvs = _skinned.sharedMesh.uv; } if (_uvs == null || _uvs.Length != _verts.Length) return; int triCount = _tris.Length / 3; if (_triCenter == null || _triCenter.Length != triCount) { _triCenter = new Vector3[triCount]; _triRadius = new float[triCount]; } double sumArea3D = 0.0, sumAreaUv = 0.0; for (int t = 0; t < triCount; t++) { int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2]; Vector3 a = _verts[i0], b = _verts[i1], c = _verts[i2]; Vector3 center = (a + b + c) / 3f; _triCenter[t] = center; float ra = (a - center).sqrMagnitude; float rb = (b - center).sqrMagnitude; float rc = (c - center).sqrMagnitude; _triRadius[t] = Mathf.Sqrt(Mathf.Max(ra, Mathf.Max(rb, rc))); sumArea3D += 0.5 * Vector3.Cross(b - a, c - a).magnitude; Vector2 ua = _uvs[i0], ub = _uvs[i1], uc = _uvs[i2]; sumAreaUv += 0.5 * Mathf.Abs(Cross2D(ub - ua, uc - ua)); } if (sumAreaUv > 1e-9) _avgUvToLocal = (float)System.Math.Sqrt(sumArea3D / sumAreaUv); } /// /// Rasterizes the mesh UV islands into a per-texel coverage marker - the dilation SOURCE / overwrite /// guard (island texels are real content edge padding must never overwrite; the gutter stays /// paddable). Derived from the STATIC UV layout instead of transient per-stroke coverage, so every /// seed (fresh start, load, backend switch) marks the same content and the first stroke after a /// load/switch no longer bleeds over existing paint. Built once and cached. /// private bool[] GetIslandMask() { int n = textureSize * textureSize; if (_islandMask != null && _islandMask.Length == n) return _islandMask; var mask = new bool[n]; if (_tris == null || _uvs == null) { _islandMask = mask; return mask; } float ts = textureSize; const float eps = -1e-4f; // matches PaintStamp3D: tolerate 1px cracks between triangles int triCount = _tris.Length / 3; for (int t = 0; t < triCount; t++) { int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2]; Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2]; float p0x = uv0.x * ts, p0y = uv0.y * ts; float p1x = uv1.x * ts, p1y = uv1.y * ts; float p2x = uv2.x * ts, p2y = uv2.y * ts; int minX = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0x, Mathf.Min(p1x, p2x))), 0, textureSize - 1); int maxX = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0x, Mathf.Max(p1x, p2x))), 0, textureSize - 1); int minY = Mathf.Clamp(Mathf.FloorToInt(Mathf.Min(p0y, Mathf.Min(p1y, p2y))), 0, textureSize - 1); int maxY = Mathf.Clamp(Mathf.CeilToInt(Mathf.Max(p0y, Mathf.Max(p1y, p2y))), 0, textureSize - 1); if (maxX < minX || maxY < minY) continue; float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y); if (Mathf.Abs(d) < 1e-7f) continue; float invD = 1f / d; for (int y = minY; y <= maxY; y++) { float py = y + 0.5f; int row = y * textureSize; for (int x = minX; x <= maxX; x++) { float px = x + 0.5f; float a = ((p1y - p2y) * (px - p2x) + (p2x - p1x) * (py - p2y)) * invD; if (a < eps) continue; float b = ((p2y - p0y) * (px - p2x) + (p0x - p2x) * (py - p2y)) * invD; if (b < eps) continue; if (1f - a - b < eps) continue; mask[row + x] = true; } } } _islandMask = mask; return mask; } /// Builds the per-map seed colors (in canonical/material layout) from the existing material. private void BuildSeedColors() { _seedCanonical = new Color32[MAP_COUNT][]; _seedCanonical[MAP_ALBEDO] = BuildBaseColors(); // Mask map seed from the material scalars: R = metallic, G = occlusion/height, B = 0, // A = smoothness. G = 128 (= 0.5) is the NEUTRAL height: no parallax/relief and AO = // saturate(2*0.5) = 1 (no darkening), so the unpainted look is unchanged. Painting G // below 0.5 carves a hole (and darkens), above 0.5 raises a hill (see PlayerCamoLit). float metallic0 = GetMatFloat(MetallicId, 0f); float smoothness0 = GetMatFloat(SmoothnessId, 0.5f); _seedCanonical[MAP_MSO] = FillCanonical(new Color32( (byte)(Mathf.Clamp01(metallic0) * 255f), 128, 0, (byte)(Mathf.Clamp01(smoothness0) * 255f))); // Emission: keep the existing look (black if the material's emission is off). Color emission0 = Color.black; if (_materialInstance.HasProperty(EmissionColorId) && (_materialInstance.globalIlluminationFlags & MaterialGlobalIlluminationFlags.EmissiveIsBlack) == 0) emission0 = _materialInstance.GetColor(EmissionColorId); _seedCanonical[MAP_EM] = FillCanonical(new Color32( (byte)(Mathf.Clamp01(emission0.r) * 255f), (byte)(Mathf.Clamp01(emission0.g) * 255f), (byte)(Mathf.Clamp01(emission0.b) * 255f), 255)); // Fur: R = 0 so a fresh avatar has NO fur (R is painted-in length, not coverage, so it is never // island-stamped - see SeedGpuMap). G/B/A unused. The strand colour comes from the albedo map. _seedCanonical[MAP_FUR] = FillCanonical(new Color32(0, 0, 0, 0)); } private Color32[] FillCanonical(Color32 c) { var px = new Color32[textureSize * textureSize]; for (int i = 0; i < px.Length; i++) px[i] = c; return px; } /// Builds the albedo base-color texels from the existing material texture or baseColor. private Color32[] BuildBaseColors() { Texture sourceTex = null; var shared = _renderer.sharedMaterial; if (shared) { if (shared.HasProperty(BaseMapId)) sourceTex = shared.GetTexture(BaseMapId); if (!sourceTex && shared.HasProperty(MainTexId)) sourceTex = shared.GetTexture(MainTexId); } var src = sourceTex as Texture2D; if (src) { try { if (src.width == textureSize && src.height == textureSize && src.isReadable) return src.GetPixels32(); } catch { /* not readable -> fall back to baseColor */ } } return FillCanonical(baseColor); } /// Enables the URP keywords for the painted PBR maps and seeds the material scalars. private void ConfigureMaterialPbr() { if (!_materialInstance) return; // Metallic-smoothness map: metallic from R, smoothness from the map's alpha // (NOT the albedo alpha) scaled by _Smoothness (= 1 so the map drives it as-is). if (_materialInstance.HasProperty(MetallicGlossMapId)) { _materialInstance.EnableKeyword("_METALLICSPECGLOSSMAP"); _materialInstance.DisableKeyword("_SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A"); SetMatFloat(SmoothnessId, 1f); } // Emission map x _EmissionColor (white * intensity). Clear the "emissive is black" // GI flag so the emission is actually used. if (_materialInstance.HasProperty(EmissionMapId)) { _materialInstance.EnableKeyword("_EMISSION"); _materialInstance.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive; _lastEmissionIntensity = float.NaN; // fresh material instance -> force the first write RefreshEmissionColor(); } // Occlusion map: URP samples the G channel (LerpWhiteTo(map.g, _OcclusionStrength)). // Strength 1 so the painted value drives the occlusion as-is. if (_materialInstance.HasProperty(OcclusionMapId)) { _materialInstance.EnableKeyword("_OCCLUSIONMAP"); SetMatFloat(OcclusionStrengthId, 1f); } // Height: the same mask map is also bound to _ParallaxMap, so the painted occlusion (G) doubles // as a 0.5-centred height. _PARALLAXMAP enables URP's parallax AND our relief-normal/AO remap in // PlayerCamoLit; _Parallax is lifted off its floor so the displacement is visible. _NORMALMAP is // REQUIRED for the relief normal to show - URP only transforms normalTS into world space when it // (or _DETAIL) is set, otherwise the relief is invisible. _BumpMap stays unbound (flat default // normal), so the keyword only enables the tangent->world path. if (_materialInstance.HasProperty(ParallaxMapId)) { _materialInstance.EnableKeyword("_PARALLAXMAP"); _materialInstance.EnableKeyword("_NORMALMAP"); if (GetMatFloat(ParallaxId, ParallaxBase) < ParallaxBase) SetMatFloat(ParallaxId, ParallaxBase); } } private float _lastEmissionIntensity = float.NaN; /// Pushes the global emission intensity to the material's _EmissionColor (live-tunable). /// Runs per frame, so it only writes the material when the intensity actually changed. private void RefreshEmissionColor() { if (emissionIntensity == _lastEmissionIntensity) return; if (_materialInstance && _materialInstance.HasProperty(EmissionColorId)) { _materialInstance.SetColor(EmissionColorId, Color.white * Mathf.Max(0f, emissionIntensity)); _lastEmissionIntensity = emissionIntensity; } } /// Eases the see-through X-ray glow's alpha toward its on/off target () /// at a constant rate, so the reveal fades in/out over xrayFadeDuration seconds instead of popping. /// Rgb is left untouched (the authored glow colour). Only writes the material on an actual change, and /// snaps instantly when the duration is 0. private void TickXRayFade() { if (!_materialInstance || !_materialInstance.HasProperty(XRayColorId)) return; Color c = _materialInstance.GetColor(XRayColorId); float step = xrayFadeDuration > 0f ? Time.deltaTime / xrayFadeDuration : 1f; float a = Mathf.MoveTowards(c.a, _xrayTarget, step); if (a == c.a) return; // already at target -> no material write c.a = a; _materialInstance.SetColor(XRayColorId, c); } /// Applies the current hit-flash state to the X-ray material: flash mode boosts the authored glow /// rgb by xrayFlashBoost, turns the breathing pulse off (steady), and snaps the reveal alpha on for an /// immediate pop; leaving flash restores the authored rgb + pulse (alpha keeps easing via TickXRayFade). The /// pulse plateau otherwise leaves a short hit window looking dim, so a steady boosted glow reads far stronger. private void ApplyXRayFlashState() { if (!_materialInstance || !_materialInstance.HasProperty(XRayColorId)) return; float boost = _xrayFlash ? Mathf.Max(1f, xrayFlashBoost) : 1f; Color c = _materialInstance.GetColor(XRayColorId); c.r = _xrayBaseColor.r * boost; c.g = _xrayBaseColor.g * boost; c.b = _xrayBaseColor.b * boost; if (_xrayFlash) c.a = _xrayTarget; // snap the glow on so the hit reads instantly (no ramp-in) _materialInstance.SetColor(XRayColorId, c); if (_materialInstance.HasProperty(XRayPulseStrengthId)) _materialInstance.SetFloat(XRayPulseStrengthId, _xrayFlash ? 0f : _xrayBasePulseStrength); } }