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; } } /// Reads vertices/triangles/UVs of the collider mesh and computes culling data. private void BuildMeshCaches() { Mesh m = _collider.sharedMesh; if (!m) return; _verts = m.vertices; _tris = m.triangles; _uvs = m.uv; // Fallback to the original mesh UVs if the baked mesh dropped them. if ((_uvs == null || _uvs.Length != _verts.Length) && _skinned && _skinned.sharedMesh) _uvs = _skinned.sharedMesh.uv; if (_uvs == null || _uvs.Length != _verts.Length) return; int triCount = _tris.Length / 3; _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 (true = the texel lies /// inside a UV triangle). This is the dilation SOURCE / overwrite guard: island texels are /// real content the edge-padding pass must never overwrite, while the gutter (false) stays /// paddable. Deriving the marker from the STATIC UV layout - instead of from transient /// per-stroke coverage - makes every seed (fresh start, loaded maps, backend switch) mark /// the same real content as painted, so the first stroke after a load/switch no longer /// bleeds over the existing paint. Built once and cached (UV layout + textureSize are fixed). /// 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 = _materialInstance.HasProperty(MetallicId) ? _materialInstance.GetFloat(MetallicId) : 0f; float smoothness0 = _materialInstance.HasProperty(SmoothnessId) ? _materialInstance.GetFloat(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)); } 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() { var px = new Color32[textureSize * textureSize]; 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 */ } } Color32 c = baseColor; for (int i = 0; i < px.Length; i++) px[i] = c; return px; } /// 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"); if (_materialInstance.HasProperty(SmoothnessId)) _materialInstance.SetFloat(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; 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"); if (_materialInstance.HasProperty(OcclusionStrengthId)) _materialInstance.SetFloat(OcclusionStrengthId, 1f); } // Height: the same mask map is also bound to _ParallaxMap (see MapProps), so the painted // occlusion value (G) doubles as a 0.5-centred height. _PARALLAXMAP turns on URP's single- // step parallax (ApplyPerPixelDisplacement) AND our relief-normal/AO remap in PlayerCamoLit. // Lift _Parallax off its floor so the displacement is actually visible (the relief normal is // the dominant cue; this just adds subtle motion parallax). Live-tunable on the instance. // _NORMALMAP is REQUIRED for the relief normal to show: URP's InitializeInputData only // transforms surfaceData.normalTS into world space when _NORMALMAP (or _DETAIL) is set - // otherwise it discards normalTS and uses the geometric normal, so the relief is invisible. // _BumpMap stays unbound (= flat default normal), so it only enables the tangent->world path // and our relief blends on top of a flat base. if (_materialInstance.HasProperty(ParallaxMapId)) { _materialInstance.EnableKeyword("_PARALLAXMAP"); _materialInstance.EnableKeyword("_NORMALMAP"); if (_materialInstance.HasProperty(ParallaxId) && _materialInstance.GetFloat(ParallaxId) < 0.02f) _materialInstance.SetFloat(ParallaxId, 0.02f); } } /// Pushes the global emission intensity to the material's _EmissionColor (live-tunable). private void RefreshEmissionColor() { if (_materialInstance && _materialInstance.HasProperty(EmissionColorId)) _materialInstance.SetColor(EmissionColorId, Color.white * Mathf.Max(0f, 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); } }