diff --git a/Assets/Scripts/MeshPainter.Backend.cs b/Assets/Scripts/MeshPainter.Backend.cs
new file mode 100644
index 0000000..6e77258
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Backend.cs
@@ -0,0 +1,74 @@
+using UnityEngine;
+
+public partial class MeshPainter
+{
+ // --- Backend selection / switching ---
+
+ private void SetupBackend(bool gpu, Color32[][] canonical)
+ {
+ // SetupGpu returns false if the shaders are missing and it fell back to CPU; honor
+ // the real outcome so _gpuActive never claims GPU while _gpu is null (would NRE on
+ // the next SwitchBackend / paint).
+ if (gpu)
+ {
+ _gpuActive = SetupGpu(canonical);
+ }
+ else
+ {
+ SetupCpu(canonical);
+ _gpuActive = false;
+ }
+ }
+
+ /// Switches the backend at runtime, carrying over all painted maps.
+ private void SwitchBackend(bool toGpu)
+ {
+ var canonical = new Color32[MAP_COUNT][];
+ for (int mi = 0; mi < MAP_COUNT; mi++) canonical[mi] = ReadCanonical(mi);
+
+ if (_gpuActive) TeardownGpu();
+ else TeardownCpu();
+
+ SetupBackend(toGpu, canonical);
+ _hasLastScreen = false;
+
+ Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
+ }
+
+ /// Reads a map from the active backend into canonical (material) layout.
+ private Color32[] ReadCanonical(int mi)
+ {
+ if (_gpuActive)
+ {
+ var gm = _gpu[mi];
+ // MSO: read the packed final mask RT (R=metallic, G=occlusion, A=smoothness).
+ // Others: the display RT. The mask map is linear data; color maps read sRGB.
+ if (mi == MAP_MSO) return ReadRtPixels(gm.final, linear: true);
+ return ReadRtPixels(gm.display, linear: MapIsLinear(mi));
+ }
+ return (Color32[])_cpu[mi].pixels.Clone();
+ }
+
+ private static bool MapIsLinear(int mi) => mi == MAP_MSO;
+
+ private string[] MapProps(int mi)
+ {
+ switch (mi)
+ {
+ case MAP_ALBEDO: return new[] { "_BaseMap", "_MainTex" };
+ // One mask map drives both URP slots: _MetallicGlossMap (R/A) + _OcclusionMap (G).
+ case MAP_MSO: return new[] { "_MetallicGlossMap", "_OcclusionMap" };
+ default: return new[] { "_EmissionMap" };
+ }
+ }
+
+ private void AssignMapTexture(int mi, Texture tex)
+ {
+ if (!_materialInstance) return;
+ foreach (var p in MapProps(mi))
+ {
+ int id = Shader.PropertyToID(p);
+ if (_materialInstance.HasProperty(id)) _materialInstance.SetTexture(id, tex);
+ }
+ }
+}
diff --git a/Assets/Scripts/MeshPainter.Backend.cs.meta b/Assets/Scripts/MeshPainter.Backend.cs.meta
new file mode 100644
index 0000000..3ad2fa5
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Backend.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 43a45b2bb2bb2b8499debf83ef5a7371
\ No newline at end of file
diff --git a/Assets/Scripts/MeshPainter.Cpu.cs b/Assets/Scripts/MeshPainter.Cpu.cs
new file mode 100644
index 0000000..bb7697f
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Cpu.cs
@@ -0,0 +1,338 @@
+using UnityEngine;
+
+public partial class MeshPainter
+{
+ // --- CPU backend ---
+
+ private void SetupCpu(Color32[][] canonical)
+ {
+ _cpu = new CpuMap[MAP_COUNT];
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ var cm = new CpuMap
+ {
+ tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, MapIsLinear(mi))
+ {
+ name = "MeshPainter_Canvas_" + mi,
+ wrapMode = TextureWrapMode.Clamp,
+ filterMode = FilterMode.Bilinear
+ },
+ pixels = SeedCpuPixels(mi, canonical),
+ painted = new bool[textureSize * textureSize],
+ fillStamp = new int[textureSize * textureSize],
+ dilateStamp = 0
+ };
+ cm.tex.SetPixels32(cm.pixels);
+ cm.tex.Apply(false);
+ _cpu[mi] = cm;
+ AssignMapTexture(mi, cm.tex);
+ }
+ }
+
+ /// CPU pixels are stored directly in material layout; just guarantee a sane alpha for the color maps.
+ private Color32[] SeedCpuPixels(int mi, Color32[][] canonical)
+ {
+ var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
+ ? canonical[mi] : _seedCanonical[mi];
+ var px = (Color32[])src.Clone();
+ if (mi != MAP_MSO) // albedo / emission: opaque. The mask map keeps alpha = smoothness.
+ for (int i = 0; i < px.Length; i++) px[i].a = 255;
+ return px;
+ }
+
+ private void TeardownCpu()
+ {
+ if (_cpu == null) return;
+ foreach (var cm in _cpu) if (cm != null && cm.tex) Destroy(cm.tex);
+ _cpu = null;
+ }
+
+ // --- CPU painting (rasterization + dilation) ---
+
+ private void ResetCpuDirty()
+ {
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ if (!IsChannelActive(mi)) continue;
+ var cm = _cpu[mi];
+ cm.dx0 = textureSize; cm.dy0 = textureSize; cm.dx1 = -1; cm.dy1 = -1;
+ }
+ }
+
+ private void EndCpuStroke()
+ {
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ if (!IsChannelActive(mi)) continue;
+ var cm = _cpu[mi];
+ if (paddingTexels > 0) DilatePadding(cm, paddingTexels);
+ FlushDirty(cm);
+ }
+ }
+
+ // sRGB<->linear LUT so the CPU blends the sRGB color maps (albedo/emission) in LINEAR
+ // space, matching the GPU's sRGB-framebuffer blend. Without it, partial-opacity texels
+ // (soft mask edges, brushColor.a<1) would differ between the GPU and CPU backends.
+ private static readonly float[] _srgbToLinear = BuildSrgbToLinearLut();
+
+ private static float[] BuildSrgbToLinearLut()
+ {
+ var lut = new float[256];
+ for (int i = 0; i < 256; i++) lut[i] = Mathf.GammaToLinearSpace(i / 255f);
+ return lut;
+ }
+
+ /// Blends a linear source over an sRGB-encoded destination byte in linear space, returns the sRGB byte.
+ private static byte BlendSrgb(byte dstByte, float srcLinear, float invOp, float op)
+ {
+ float outLinear = _srgbToLinear[dstByte] * invOp + srcLinear * op;
+ return (byte)(Mathf.Clamp01(Mathf.LinearToGammaSpace(outLinear)) * 255f + 0.5f);
+ }
+
+ ///
+ /// Paints a square stamp in the surface tangent plane: rasterizes triangles in range
+ /// and blends every texel whose 3D position lies inside the box (|du|,|dv|,|dn| <=
+ /// radius) over each active map, weighted by the mask opacity. Mirrors the GPU brush
+ /// path (mask value = per-texel opacity, gray = 50%).
+ ///
+ private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
+ {
+ bool aAlb = paintAlbedo, aMS = paintMetallicSmoothness, aEM = paintEmission, aAO = paintOcclusion;
+ if (!aAlb && !aMS && !aEM && !aAO) return;
+
+ // Albedo target (brush color in linear space; blended linear to match the GPU).
+ float brLin = Mathf.GammaToLinearSpace(brushColor.r);
+ float bgLin = Mathf.GammaToLinearSpace(brushColor.g);
+ float bbLin = Mathf.GammaToLinearSpace(brushColor.b);
+ float albOpMul = Mathf.Clamp01(brushColor.a);
+ CpuMap alb = _cpu[MAP_ALBEDO];
+
+ // Mask map target: R = metallic, G = occlusion, B = 0, A = smoothness (one texture
+ // bound to both _MetallicGlossMap and _OcclusionMap).
+ float msoR = Mathf.Clamp01(metallic) * 255f; // metallic
+ float msoG = Mathf.Clamp01(occlusion) * 255f; // occlusion
+ float msoA = Mathf.Clamp01(smoothness) * 255f; // smoothness
+ CpuMap mso = _cpu[MAP_MSO];
+ bool aMSO = aMS || aAO;
+
+ // Emission target (linear, like albedo).
+ float erLin = Mathf.GammaToLinearSpace(emissionColor.r);
+ float egLin = Mathf.GammaToLinearSpace(emissionColor.g);
+ float ebLin = Mathf.GammaToLinearSpace(emissionColor.b);
+ float emOpMul = Mathf.Clamp01(emissionColor.a);
+ CpuMap em = _cpu[MAP_EM];
+
+ float invR = 1f / radiusLocal;
+ int triCount = _tris.Length / 3;
+ float ts = textureSize;
+
+ for (int t = 0; t < triCount; t++)
+ {
+ // Fast culling: stamp box (corner reach = sqrt(3)*radius) vs. the triangle's circumscribed circle.
+ float reach = radiusLocal * SquareReach + _triRadius[t];
+ if ((_triCenter[t] - pLocal).sqrMagnitude > reach * reach) continue;
+
+ int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
+ Vector3 v0 = _verts[i0], v1 = _verts[i1], v2 = _verts[i2];
+ Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
+
+ // UV -> pixel.
+ 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;
+
+ // Barycentric denominator.
+ float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y);
+ if (Mathf.Abs(d) < 1e-7f) continue;
+ float invD = 1f / d;
+
+ const float eps = -1e-4f; // tolerance against 1px cracks between triangles
+
+ for (int y = minY; y <= maxY; y++)
+ {
+ float py = y + 0.5f;
+ int rowStart = 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;
+ float c = 1f - a - b;
+ if (c < eps) continue;
+
+ // 3D position of the texel via barycentric interpolation.
+ float wx = a * v0.x + b * v1.x + c * v2.x;
+ float wy = a * v0.y + b * v1.y + c * v2.y;
+ float wz = a * v0.z + b * v1.z + c * v2.z;
+
+ // Project the offset onto the stamp frame (right/up/normal).
+ float ddx = wx - pLocal.x, ddy = wy - pLocal.y, ddz = wz - pLocal.z;
+ float du = ddx * u.x + ddy * u.y + ddz * u.z;
+ float dv = ddx * v.x + ddy * v.y + ddz * v.z;
+ float dn = ddx * n.x + ddy * n.y + ddz * n.z;
+ if (du < -radiusLocal || du > radiusLocal) continue;
+ if (dv < -radiusLocal || dv > radiusLocal) continue;
+ if (dn < -radiusLocal || dn > radiusLocal) continue;
+
+ // Mask -> opacity.
+ float mu = du * invR * 0.5f + 0.5f;
+ float mv = dv * invR * 0.5f + 0.5f;
+ float maskOp = SampleMaskOpacity(mu, mv);
+ if (maskOp <= 0f) continue;
+
+ int idx = rowStart + x;
+
+ if (aAlb)
+ {
+ float op = maskOp * albOpMul;
+ if (op > 0f)
+ {
+ Color32 dst = alb.pixels[idx];
+ float it = 1f - op;
+ alb.pixels[idx] = new Color32(
+ BlendSrgb(dst.r, brLin, it, op),
+ BlendSrgb(dst.g, bgLin, it, op),
+ BlendSrgb(dst.b, bbLin, it, op),
+ 255);
+ alb.painted[idx] = true;
+ ExpandDirty(alb, x, y);
+ }
+ }
+
+ if (aMSO)
+ {
+ // Mask map blend (op = mask, no extra alpha multiplier). Metallic(R) +
+ // smoothness(A) only when painting MS; occlusion(G) only when painting AO.
+ // Untouched channels keep their previous value; B stays 0.
+ Color32 dst = mso.pixels[idx];
+ float it = 1f - maskOp;
+ byte mr = aMS ? (byte)(dst.r * it + msoR * maskOp) : dst.r;
+ byte mg = aAO ? (byte)(dst.g * it + msoG * maskOp) : dst.g;
+ byte ma = aMS ? (byte)(dst.a * it + msoA * maskOp) : dst.a;
+ mso.pixels[idx] = new Color32(mr, mg, 0, ma);
+ mso.painted[idx] = true;
+ ExpandDirty(mso, x, y);
+ }
+
+ if (aEM)
+ {
+ float op = maskOp * emOpMul;
+ if (op > 0f)
+ {
+ Color32 dst = em.pixels[idx];
+ float it = 1f - op;
+ em.pixels[idx] = new Color32(
+ BlendSrgb(dst.r, erLin, it, op),
+ BlendSrgb(dst.g, egLin, it, op),
+ BlendSrgb(dst.b, ebLin, it, op),
+ 255);
+ em.painted[idx] = true;
+ ExpandDirty(em, x, y);
+ }
+ }
+
+ }
+ }
+ }
+ }
+
+ private static void ExpandDirty(CpuMap cm, int x, int y)
+ {
+ if (x < cm.dx0) cm.dx0 = x;
+ if (x > cm.dx1) cm.dx1 = x;
+ if (y < cm.dy0) cm.dy0 = y;
+ if (y > cm.dy1) cm.dy1 = y;
+ }
+
+ ///
+ /// CPU edge padding against UV seams: bleeds the freshly painted colors outward by
+ /// 'pad' texels. The dilation source is only brush-painted texels (cm.painted).
+ ///
+ private void DilatePadding(CpuMap cm, int pad)
+ {
+ if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
+
+ int x0 = Mathf.Max(0, cm.dx0 - pad);
+ int y0 = Mathf.Max(0, cm.dy0 - pad);
+ int x1 = Mathf.Min(textureSize - 1, cm.dx1 + pad);
+ int y1 = Mathf.Min(textureSize - 1, cm.dy1 + pad);
+
+ cm.dilateStamp++;
+
+ for (int iter = 0; iter < pad; iter++)
+ {
+ _dilateScratch.Clear();
+
+ for (int y = y0; y <= y1; y++)
+ {
+ int row = y * textureSize;
+ for (int x = x0; x <= x1; x++)
+ {
+ int idx = row + x;
+ if (cm.painted[idx] || cm.fillStamp[idx] == cm.dilateStamp) continue;
+
+ if (TryFindPaddingSource(cm, x, y, out Color32 c))
+ {
+ cm.pixels[idx] = c;
+ _dilateScratch.Add(idx);
+ ExpandDirty(cm, x, y);
+ }
+ }
+ }
+
+ for (int k = 0; k < _dilateScratch.Count; k++)
+ cm.fillStamp[_dilateScratch[k]] = cm.dilateStamp;
+
+ if (_dilateScratch.Count == 0) break;
+ }
+ }
+
+ /// Searches the 8-neighborhood for a source texel (painted or already padded) and returns its color.
+ private bool TryFindPaddingSource(CpuMap cm, int x, int y, out Color32 col)
+ {
+ for (int oy = -1; oy <= 1; oy++)
+ {
+ int ny = y + oy;
+ if (ny < 0 || ny >= textureSize) continue;
+ int nrow = ny * textureSize;
+ for (int ox = -1; ox <= 1; ox++)
+ {
+ if (ox == 0 && oy == 0) continue;
+ int nx = x + ox;
+ if (nx < 0 || nx >= textureSize) continue;
+ int nidx = nrow + nx;
+ if (cm.painted[nidx] || cm.fillStamp[nidx] == cm.dilateStamp)
+ {
+ col = cm.pixels[nidx];
+ return true;
+ }
+ }
+ }
+ col = default;
+ return false;
+ }
+
+ /// Uploads only the region changed this frame to the GPU once (CPU backend).
+ private void FlushDirty(CpuMap cm)
+ {
+ if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
+
+ int bw = cm.dx1 - cm.dx0 + 1;
+ int bh = cm.dy1 - cm.dy0 + 1;
+ var block = new Color32[bw * bh];
+ for (int y = 0; y < bh; y++)
+ System.Array.Copy(cm.pixels, (cm.dy0 + y) * textureSize + cm.dx0, block, y * bw, bw);
+
+ cm.tex.SetPixels32(cm.dx0, cm.dy0, bw, bh, block);
+ cm.tex.Apply(false);
+ }
+}
diff --git a/Assets/Scripts/MeshPainter.Cpu.cs.meta b/Assets/Scripts/MeshPainter.Cpu.cs.meta
new file mode 100644
index 0000000..48708f4
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Cpu.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c651e96b15e679740a526502ded8cdc2
\ No newline at end of file
diff --git a/Assets/Scripts/MeshPainter.Gpu.cs b/Assets/Scripts/MeshPainter.Gpu.cs
new file mode 100644
index 0000000..0107177
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Gpu.cs
@@ -0,0 +1,280 @@
+using UnityEngine;
+
+public partial class MeshPainter
+{
+ // --- GPU backend ---
+
+ /// GPU backend per map: a display RenderTexture + scratch for the dilation ping-pong.
+ private class GpuMap
+ {
+ public RenderTexture display; // painted into (work RT for the mask map)
+ public RenderTexture temp; // dilation ping-pong scratch
+ public RenderTexture final; // MSO only: packed mask map (R=metallic, G=occlusion, A=smoothness); null otherwise
+ public bool drew; // anything drawn this stroke?
+ public bool sRGB; // sRGB color map (albedo/emission) vs linear data map (MSO)
+ }
+
+ public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush")
+ public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
+ public Shader packMsShader; // optional; else Shader.Find("Hidden/MeshPaintPackMS")
+ private GpuMap[] _gpu;
+ private Material _brushMat;
+ private Material _dilateMat;
+ private Material _packMat;
+ private bool _prevSRGB;
+
+ ///
+ /// Sets up the GPU backend (RenderTexture set + materials per map). Returns true on
+ /// success; on missing shaders it falls back to the CPU backend and returns false so
+ /// the caller leaves _gpuActive=false (and useGpu in sync) instead of crashing later.
+ ///
+ private bool SetupGpu(Color32[][] canonical)
+ {
+ if (!brushShader) brushShader = Shader.Find("Hidden/MeshPaintBrush");
+ if (!dilateShader) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
+ if (!packMsShader) packMsShader = Shader.Find("Hidden/MeshPaintPackMS");
+
+ if (!brushShader || !dilateShader || !packMsShader)
+ {
+ Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush, " +
+ "Hidden/MeshPaintDilate or Hidden/MeshPaintPackMS). Falling back to CPU painting.", this);
+ useGpu = false;
+ SetupCpu(canonical);
+ return false;
+ }
+
+ _brushMat = new Material(brushShader) { hideFlags = HideFlags.HideAndDontSave };
+ _dilateMat = new Material(dilateShader) { hideFlags = HideFlags.HideAndDontSave };
+ _packMat = new Material(packMsShader) { hideFlags = HideFlags.HideAndDontSave };
+
+ _gpu = new GpuMap[MAP_COUNT];
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ bool linear = MapIsLinear(mi);
+ var gm = new GpuMap
+ {
+ sRGB = !linear,
+ display = CreateRT("MeshPainter_DisplayRT_" + mi, linear),
+ temp = CreateRT("MeshPainter_TempRT_" + mi, linear),
+ final = (mi == MAP_MSO) ? CreateRT("MeshPainter_FinalRT_" + mi, linear) : null
+ };
+ _gpu[mi] = gm;
+
+ SeedGpuMap(mi, canonical);
+
+ // Color maps render to the display RT; the MS map exposes its packed final RT.
+ AssignMapTexture(mi, gm.final != null ? gm.final : gm.display);
+ }
+ return true;
+ }
+
+ /// Seeds a GPU map's display RT from canonical data (alpha = 0 = unpainted), then packs MS to final.
+ private void SeedGpuMap(int mi, Color32[][] canonical)
+ {
+ var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
+ ? canonical[mi] : _seedCanonical[mi];
+ var gm = _gpu[mi];
+
+ if (mi == MAP_MSO)
+ {
+ // Canonical (R=metallic, G=occlusion, A=smoothness) -> work layout
+ // (R=metallic, G=smoothness, B=occlusion); alpha = coverage stays 0 (unpainted).
+ var work = new Color32[src.Length];
+ for (int i = 0; i < src.Length; i++) work[i] = new Color32(src[i].r, src[i].a, src[i].g, 0);
+ UploadColorsToRT(work, gm.display, paintedAlpha: false, linear: true);
+ PackMs(gm); // build the final mask RT once so the material shows the seeded values
+ }
+ else
+ {
+ UploadColorsToRT((Color32[])src.Clone(), gm.display, paintedAlpha: false, linear: gm.sRGB == false);
+ }
+ }
+
+ private void TeardownGpu()
+ {
+ if (_gpu != null)
+ {
+ foreach (var gm in _gpu)
+ {
+ if (gm == null) continue;
+ if (gm.display) { gm.display.Release(); Destroy(gm.display); }
+ if (gm.temp) { gm.temp.Release(); Destroy(gm.temp); }
+ if (gm.final) { gm.final.Release(); Destroy(gm.final); }
+ }
+ _gpu = null;
+ }
+ if (_brushMat) { Destroy(_brushMat); _brushMat = null; }
+ if (_dilateMat) { Destroy(_dilateMat); _dilateMat = null; }
+ if (_packMat) { Destroy(_packMat); _packMat = null; }
+ }
+
+ private RenderTexture CreateRT(string rtName, bool linear)
+ {
+ var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32,
+ linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB)
+ {
+ name = rtName,
+ wrapMode = TextureWrapMode.Clamp,
+ filterMode = FilterMode.Bilinear,
+ antiAliasing = 1,
+ useMipMap = false,
+ autoGenerateMips = false
+ };
+ rt.Create();
+ return rt;
+ }
+
+ /// Writes Color32 data into a RenderTexture via a temporary texture.
+ private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha, bool linear)
+ {
+ var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
+ // Alpha marks painted (255) vs unpainted (0) for the dilation pass.
+ byte a = paintedAlpha ? (byte)255 : (byte)0;
+ for (int i = 0; i < colors.Length; i++) colors[i].a = a;
+ tmp.SetPixels32(colors);
+ tmp.Apply(false);
+
+ bool prev = GL.sRGBWrite;
+ GL.sRGBWrite = !linear;
+ Graphics.Blit(tmp, rt);
+ GL.sRGBWrite = prev;
+ Destroy(tmp);
+ }
+
+ /// Reads a RenderTexture back as Color32 (for the backend switch).
+ private Color32[] ReadRtPixels(RenderTexture rt, bool linear)
+ {
+ var prev = RenderTexture.active;
+ RenderTexture.active = rt;
+ var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
+ tex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0, false);
+ tex.Apply(false);
+ RenderTexture.active = prev;
+ var px = tex.GetPixels32();
+ Destroy(tex);
+ return px;
+ }
+
+ private void PackMs(GpuMap gm)
+ {
+ bool prev = GL.sRGBWrite;
+ GL.sRGBWrite = false; // linear data, no conversion
+ Graphics.Blit(gm.display, gm.final, _packMat, 0);
+ GL.sRGBWrite = prev;
+ }
+
+ // --- GPU painting ---
+
+ private void BeginGpuStroke()
+ {
+ _prevSRGB = GL.sRGBWrite;
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ if (IsChannelActive(mi)) _gpu[mi].drew = false;
+ }
+
+ private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
+ {
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ if (!IsChannelActive(mi)) continue;
+ var gm = _gpu[mi];
+
+ Graphics.SetRenderTarget(gm.display);
+ GL.sRGBWrite = gm.sRGB; // sRGB color map vs linear data map (MS)
+
+ SetBrushColorForMap(mi);
+ // Per-channel write mask: lets the shared mask map (R=metallic, G=smoothness work,
+ // B=occlusion) be painted by metallic/smoothness and occlusion INDEPENDENTLY -
+ // untouched channels keep their value while coverage (A) still accumulates.
+ _brushMat.SetFloat(ColorWriteMaskId, BrushColorMaskForMap(mi));
+ _brushMat.SetTexture(MaskTexId, _resolvedMask);
+ _brushMat.SetVector(BrushCenterId, pLocal);
+ _brushMat.SetFloat(BrushRadiusId, radiusLocal);
+ _brushMat.SetVector(BrushUId, u);
+ _brushMat.SetVector(BrushVId, v);
+ _brushMat.SetVector(BrushNormalId, n);
+ if (!_brushMat.SetPass(0)) continue;
+
+ int sub = _bakedMesh.subMeshCount;
+ for (int i = 0; i < sub; i++)
+ Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
+
+ gm.drew = true;
+ }
+ }
+
+ private void EndGpuStroke()
+ {
+ Graphics.SetRenderTarget(null);
+
+ for (int mi = 0; mi < MAP_COUNT; mi++)
+ {
+ if (!IsChannelActive(mi)) continue;
+ var gm = _gpu[mi];
+
+ // Edge padding against UV seams: dilate the display RT into temp, then swap.
+ if (gm.drew && paddingTexels > 0)
+ {
+ GL.sRGBWrite = gm.sRGB;
+ _dilateMat.SetInt(PadId, paddingTexels);
+ Graphics.Blit(gm.display, gm.temp, _dilateMat, 0);
+
+ var swap = gm.display; gm.display = gm.temp; gm.temp = swap;
+ if (gm.final == null) AssignMapTexture(mi, gm.display);
+ }
+
+ // MS: pack the work layout (R=metallic, G=smoothness) into the URP final layout.
+ if (gm.final != null) PackMs(gm);
+ }
+
+ GL.sRGBWrite = _prevSRGB;
+ }
+
+ /// Sets _BrushColor for the given map. Albedo/emission are colors; the mask map packs (metallic, smoothness, occlusion) as raw data.
+ private void SetBrushColorForMap(int mi)
+ {
+ switch (mi)
+ {
+ case MAP_ALBEDO:
+ _brushMat.SetColor(BrushColorId, brushColor);
+ break;
+ case MAP_MSO:
+ // Raw data via SetVector. Work layout: R=metallic, G=smoothness, B=occlusion
+ // (ColorMask picks the channels). _BrushColor is a Color property, so Unity
+ // applies sRGB->linear on upload in a linear project - we pre-apply the inverse
+ // so the data round-trips EXACTLY and matches the CPU's raw byte writes.
+ _brushMat.SetVector(BrushColorId, new Vector4(
+ EncodeData(metallic), EncodeData(smoothness), EncodeData(occlusion), 1f));
+ break;
+ default: // emission
+ _brushMat.SetColor(BrushColorId, emissionColor);
+ break;
+ }
+ }
+
+ ///
+ /// ColorMask bitmask for the brush draw (ColorWriteMask: R=8, G=4, B=2, A=1). Color maps
+ /// write everything; the mask map writes only the active sub-channels so metallic/smoothness
+ /// and occlusion can be painted independently into one texture. Coverage (A) is always written.
+ ///
+ ///
+ /// Pre-compensates a raw data value (0..1) for the brush's Color property, which Unity
+ /// converts sRGB->linear on upload in a linear project. Applying the inverse here makes
+ /// the value round-trip exactly (so metallic/smoothness/occlusion are stored faithfully
+ /// and match the CPU byte writes). In gamma color space no conversion happens, so pass through.
+ ///
+ private static float EncodeData(float v)
+ {
+ v = Mathf.Clamp01(v);
+ return QualitySettings.activeColorSpace == ColorSpace.Linear ? Mathf.LinearToGammaSpace(v) : v;
+ }
+
+ private float BrushColorMaskForMap(int mi)
+ {
+ if (mi != MAP_MSO) return 15f; // RGBA
+ bool ms = paintMetallicSmoothness, occ = paintOcclusion;
+ if (ms && occ) return 15f; // R|G|B|A
+ if (ms) return 13f; // R|G|A (skip B = occlusion)
+ return 3f; // B|A (skip R,G = metallic/smoothness) -> occlusion only
+ }
+}
diff --git a/Assets/Scripts/MeshPainter.Gpu.cs.meta b/Assets/Scripts/MeshPainter.Gpu.cs.meta
new file mode 100644
index 0000000..b83d41a
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Gpu.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0e2c632532b3a794f8a2dd6ac26a5fd7
\ No newline at end of file
diff --git a/Assets/Scripts/MeshPainter.Setup.cs b/Assets/Scripts/MeshPainter.Setup.cs
new file mode 100644
index 0000000..498a13b
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Setup.cs
@@ -0,0 +1,173 @@
+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
+
+ _bakedMesh = new Mesh { name = "MeshPainter_Baked" };
+ BakeColliderMesh();
+ }
+
+ private void BakeColliderMesh()
+ {
+ 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);
+ }
+
+ /// 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 (255 =
+ // unoccluded, so the look is unchanged until painted), B = 0, A = smoothness.
+ 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), 255, 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);
+ }
+ }
+
+ /// 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));
+ }
+}
diff --git a/Assets/Scripts/MeshPainter.Setup.cs.meta b/Assets/Scripts/MeshPainter.Setup.cs.meta
new file mode 100644
index 0000000..7d6997d
--- /dev/null
+++ b/Assets/Scripts/MeshPainter.Setup.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f3dd1a2d423bb5a46931b0e689fa779a
\ No newline at end of file
diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs
index 6f42c5b..126ae32 100644
--- a/Assets/Scripts/MeshPainter.cs
+++ b/Assets/Scripts/MeshPainter.cs
@@ -7,8 +7,10 @@ using UnityEngine.InputSystem;
///
/// Live mesh painting in Play mode: paints the brush onto the mesh textures with the
/// mouse. Three paint maps are driven at once (each can be toggled): albedo color
-/// (_BaseMap), metallic + smoothness (_MetallicGlossMap, R = metallic, A = smoothness)
-/// and emission (_EmissionMap). Two backends (switchable live via ):
+/// (_BaseMap), an HDRP-style mask map (R = metallic, G = occlusion, A = smoothness; one
+/// texture bound to BOTH _MetallicGlossMap and _OcclusionMap) and emission (_EmissionMap).
+/// Metallic/smoothness and occlusion share that mask map but stay independently paintable.
+/// Two backends (switchable live via ):
/// GPU shader painting into RenderTextures (fast, default) and CPU rasterization
/// (slower, identical result). Both paint seamlessly across UV seams because the brush
/// is evaluated in 3D local space around the hit point, capturing triangles on both
@@ -24,7 +26,7 @@ using UnityEngine.InputSystem;
/// overlay that wraps the mesh exactly like the paint would land.
///
[DisallowMultipleComponent]
-public class MeshPainter : MonoBehaviour
+public partial class MeshPainter : MonoBehaviour
{
/// How the square stamp is oriented around the surface normal.
public enum BrushAlignment
@@ -57,9 +59,14 @@ public class MeshPainter : MonoBehaviour
[Tooltip("Paint the albedo (base color) map.")]
public bool paintAlbedo = true;
- [Tooltip("Paint the metallic + smoothness map (_MetallicGlossMap).")]
+ [Tooltip("Paint metallic + smoothness into the mask map (R = metallic, A = smoothness). " +
+ "Shares one texture with occlusion but is toggled independently.")]
public bool paintMetallicSmoothness = false;
+ [Tooltip("Paint ambient occlusion into the mask map (G channel, also bound to _OcclusionMap). " +
+ "Shares one texture with metallic/smoothness but is toggled independently.")]
+ public bool paintOcclusion = false;
+
[Tooltip("Paint the emission map (_EmissionMap).")]
public bool paintEmission = false;
@@ -68,15 +75,21 @@ public class MeshPainter : MonoBehaviour
"top of the mask (alpha 1 = mask opacity as-is, lower = fainter).")]
public Color brushColor = Color.red;
- [Header("Brush - metallic / smoothness")]
- [Tooltip("Metallic value painted into the map (0 = dielectric, 1 = metal).")]
+ [Header("Brush - metallic / smoothness / occlusion")]
+ [Tooltip("Metallic value painted into the mask map (0 = dielectric, 1 = metal).")]
[Range(0f, 1f)]
public float metallic = 0f;
- [Tooltip("Smoothness value painted into the map (0 = rough, 1 = glossy).")]
+ [Tooltip("Smoothness value painted into the mask map (0 = rough, 1 = glossy).")]
[Range(0f, 1f)]
public float smoothness = 0.5f;
+ [Tooltip("Ambient occlusion painted into the mask map (1 = fully lit / no darkening, " +
+ "0 = fully occluded / dark). NOTE: URP only darkens INDIRECT light (ambient + " +
+ "reflections), so the effect is subtle under strong direct lighting. 1 = no-op.")]
+ [Range(0f, 1f)]
+ public float occlusion = 0.5f;
+
[Header("Brush - emission")]
[Tooltip("Emission color painted into the map. Alpha = global opacity multiplier on top of the mask.")]
public Color emissionColor = Color.black;
@@ -140,20 +153,12 @@ public class MeshPainter : MonoBehaviour
// --- paint maps ---
private const int MAP_ALBEDO = 0;
- private const int MAP_MS = 1; // metallic (R) + smoothness (A)
+ // Mask map: R = metallic, G = occlusion, A = smoothness. One texture bound to BOTH
+ // _MetallicGlossMap (reads R/A) and _OcclusionMap (reads G).
+ private const int MAP_MSO = 1;
private const int MAP_EM = 2; // emission (RGB)
private const int MAP_COUNT = 3;
- /// GPU backend per map: a display RenderTexture + scratch for the dilation ping-pong.
- private class GpuMap
- {
- public RenderTexture display; // painted into (work RT for the MS map)
- public RenderTexture temp; // dilation ping-pong scratch
- public RenderTexture final; // MS only: packed (R=metallic, A=smoothness) target; null otherwise
- public bool drew; // anything drawn this stroke?
- public bool sRGB; // sRGB color map (albedo/emission) vs linear data map (MS)
- }
-
/// CPU backend per map: a Texture2D mirror + rasterization/dilation state.
private class CpuMap
{
@@ -186,25 +191,16 @@ public class MeshPainter : MonoBehaviour
private bool _setupValid;
// Seed colors per map in canonical (material) layout, built once at Start:
- // albedo -> RGB color
- // MS -> R = metallic, A = smoothness
- // emission-> RGB emission
+ // albedo -> RGB color
+ // MSO -> R = metallic, G = occlusion (1 = unoccluded), A = smoothness (mask map)
+ // emission -> RGB emission
private Color32[][] _seedCanonical;
// --- backends ---
- private GpuMap[] _gpu;
private CpuMap[] _cpu;
private readonly List _dilateScratch = new List(512);
private readonly RaycastHit[] _rayHits = new RaycastHit[32];
- public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush")
- public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate")
- public Shader packMsShader; // optional; else Shader.Find("Hidden/MeshPaintPackMS")
- private Material _brushMat;
- private Material _dilateMat;
- private Material _packMat;
- private bool _prevSRGB;
-
// --- preview overlay ---
public Shader previewShader; // optional; else Shader.Find("Hidden/MeshPaintPreview")
private Material _previewMat;
@@ -232,7 +228,10 @@ public class MeshPainter : MonoBehaviour
private static readonly int SmoothnessId = Shader.PropertyToID("_Smoothness");
private static readonly int EmissionMapId = Shader.PropertyToID("_EmissionMap");
private static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor");
+ private static readonly int OcclusionMapId = Shader.PropertyToID("_OcclusionMap");
+ private static readonly int OcclusionStrengthId = Shader.PropertyToID("_OcclusionStrength");
private static readonly int BrushColorId = Shader.PropertyToID("_BrushColor");
+ private static readonly int ColorWriteMaskId = Shader.PropertyToID("_ColorWriteMask");
private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter");
private static readonly int BrushRadiusId = Shader.PropertyToID("_BrushRadius");
private static readonly int BrushUId = Shader.PropertyToID("_BrushU");
@@ -288,406 +287,6 @@ public class MeshPainter : MonoBehaviour
"Hold the left mouse button and drag over the mesh.", this);
}
- // --- Setup (shared) ---
-
- private void SetupCollider()
- {
- _collider = GetComponent();
- if (!_collider) _collider = gameObject.AddComponent();
- _collider.convex = false; // non-convex provides triangleIndex / textureCoord
-
- _bakedMesh = new Mesh { name = "MeshPainter_Baked" };
- BakeColliderMesh();
- }
-
- private void BakeColliderMesh()
- {
- 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);
- }
-
- /// 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();
-
- // Metallic / smoothness from the material scalars (no per-texel map here).
- float metallic0 = _materialInstance.HasProperty(MetallicId) ? _materialInstance.GetFloat(MetallicId) : 0f;
- float smoothness0 = _materialInstance.HasProperty(SmoothnessId) ? _materialInstance.GetFloat(SmoothnessId) : 0.5f;
- _seedCanonical[MAP_MS] = FillCanonical(new Color32(
- (byte)(Mathf.Clamp01(metallic0) * 255f), 0, 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();
- }
- }
-
- /// 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));
- }
-
- // --- Backend selection / switching ---
-
- private void SetupBackend(bool gpu, Color32[][] canonical)
- {
- if (gpu) SetupGpu(canonical);
- else SetupCpu(canonical);
- _gpuActive = gpu;
- }
-
- /// Switches the backend at runtime, carrying over all painted maps.
- private void SwitchBackend(bool toGpu)
- {
- var canonical = new Color32[MAP_COUNT][];
- for (int mi = 0; mi < MAP_COUNT; mi++) canonical[mi] = ReadCanonical(mi);
-
- if (_gpuActive) TeardownGpu();
- else TeardownCpu();
-
- SetupBackend(toGpu, canonical);
- _hasLastScreen = false;
-
- Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
- }
-
- /// Reads a map from the active backend into canonical (material) layout.
- private Color32[] ReadCanonical(int mi)
- {
- if (_gpuActive)
- {
- var gm = _gpu[mi];
- // MS: read the packed final RT (R=metallic, A=smoothness). Others: the display RT.
- return (mi == MAP_MS) ? ReadRtPixels(gm.final, linear: true)
- : ReadRtPixels(gm.display, linear: false);
- }
- return (Color32[])_cpu[mi].pixels.Clone();
- }
-
- private static bool MapIsLinear(int mi) => mi == MAP_MS;
-
- private string[] MapProps(int mi)
- {
- switch (mi)
- {
- case MAP_ALBEDO: return new[] { "_BaseMap", "_MainTex" };
- case MAP_MS: return new[] { "_MetallicGlossMap" };
- default: return new[] { "_EmissionMap" };
- }
- }
-
- private void AssignMapTexture(int mi, Texture tex)
- {
- if (!_materialInstance) return;
- foreach (var p in MapProps(mi))
- {
- int id = Shader.PropertyToID(p);
- if (_materialInstance.HasProperty(id)) _materialInstance.SetTexture(id, tex);
- }
- }
-
- // --- CPU backend ---
-
- private void SetupCpu(Color32[][] canonical)
- {
- _cpu = new CpuMap[MAP_COUNT];
- for (int mi = 0; mi < MAP_COUNT; mi++)
- {
- var cm = new CpuMap
- {
- tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, MapIsLinear(mi))
- {
- name = "MeshPainter_Canvas_" + mi,
- wrapMode = TextureWrapMode.Clamp,
- filterMode = FilterMode.Bilinear
- },
- pixels = SeedCpuPixels(mi, canonical),
- painted = new bool[textureSize * textureSize],
- fillStamp = new int[textureSize * textureSize],
- dilateStamp = 0
- };
- cm.tex.SetPixels32(cm.pixels);
- cm.tex.Apply(false);
- _cpu[mi] = cm;
- AssignMapTexture(mi, cm.tex);
- }
- }
-
- /// CPU pixels are stored directly in material layout; just guarantee a sane alpha for the color maps.
- private Color32[] SeedCpuPixels(int mi, Color32[][] canonical)
- {
- var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
- ? canonical[mi] : _seedCanonical[mi];
- var px = (Color32[])src.Clone();
- if (mi != MAP_MS) // albedo / emission: opaque (alpha unused by the material)
- for (int i = 0; i < px.Length; i++) px[i].a = 255;
- return px;
- }
-
- private void TeardownCpu()
- {
- if (_cpu == null) return;
- foreach (var cm in _cpu) if (cm != null && cm.tex) Destroy(cm.tex);
- _cpu = null;
- }
-
- // --- GPU backend ---
-
- private void SetupGpu(Color32[][] canonical)
- {
- if (!brushShader) brushShader = Shader.Find("Hidden/MeshPaintBrush");
- if (!dilateShader) dilateShader = Shader.Find("Hidden/MeshPaintDilate");
- if (!packMsShader) packMsShader = Shader.Find("Hidden/MeshPaintPackMS");
-
- if (!brushShader || !dilateShader || !packMsShader)
- {
- Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush, " +
- "Hidden/MeshPaintDilate or Hidden/MeshPaintPackMS). Falling back to CPU painting.", this);
- useGpu = false;
- SetupCpu(canonical);
- _gpuActive = false;
- return;
- }
-
- _brushMat = new Material(brushShader) { hideFlags = HideFlags.HideAndDontSave };
- _dilateMat = new Material(dilateShader) { hideFlags = HideFlags.HideAndDontSave };
- _packMat = new Material(packMsShader) { hideFlags = HideFlags.HideAndDontSave };
-
- _gpu = new GpuMap[MAP_COUNT];
- for (int mi = 0; mi < MAP_COUNT; mi++)
- {
- bool linear = MapIsLinear(mi);
- var gm = new GpuMap
- {
- sRGB = !linear,
- display = CreateRT("MeshPainter_DisplayRT_" + mi, linear),
- temp = CreateRT("MeshPainter_TempRT_" + mi, linear),
- final = (mi == MAP_MS) ? CreateRT("MeshPainter_FinalRT_" + mi, linear) : null
- };
- _gpu[mi] = gm;
-
- SeedGpuMap(mi, canonical);
-
- // Color maps render to the display RT; the MS map exposes its packed final RT.
- AssignMapTexture(mi, gm.final != null ? gm.final : gm.display);
- }
- }
-
- /// Seeds a GPU map's display RT from canonical data (alpha = 0 = unpainted), then packs MS to final.
- private void SeedGpuMap(int mi, Color32[][] canonical)
- {
- var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
- ? canonical[mi] : _seedCanonical[mi];
- var gm = _gpu[mi];
-
- if (mi == MAP_MS)
- {
- // Canonical (R=metallic, A=smoothness) -> work layout (R=metallic, G=smoothness, B=0).
- var work = new Color32[src.Length];
- for (int i = 0; i < src.Length; i++) work[i] = new Color32(src[i].r, src[i].a, 0, 0);
- UploadColorsToRT(work, gm.display, paintedAlpha: false, linear: true);
- PackMs(gm); // build the final RT once so the material shows the seeded values
- }
- else
- {
- UploadColorsToRT((Color32[])src.Clone(), gm.display, paintedAlpha: false, linear: gm.sRGB == false);
- }
- }
-
- private void TeardownGpu()
- {
- if (_gpu != null)
- {
- foreach (var gm in _gpu)
- {
- if (gm == null) continue;
- if (gm.display) { gm.display.Release(); Destroy(gm.display); }
- if (gm.temp) { gm.temp.Release(); Destroy(gm.temp); }
- if (gm.final) { gm.final.Release(); Destroy(gm.final); }
- }
- _gpu = null;
- }
- if (_brushMat) { Destroy(_brushMat); _brushMat = null; }
- if (_dilateMat) { Destroy(_dilateMat); _dilateMat = null; }
- if (_packMat) { Destroy(_packMat); _packMat = null; }
- }
-
- private RenderTexture CreateRT(string rtName, bool linear)
- {
- var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32,
- linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB)
- {
- name = rtName,
- wrapMode = TextureWrapMode.Clamp,
- filterMode = FilterMode.Bilinear,
- antiAliasing = 1,
- useMipMap = false,
- autoGenerateMips = false
- };
- rt.Create();
- return rt;
- }
-
- /// Writes Color32 data into a RenderTexture via a temporary texture.
- private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha, bool linear)
- {
- var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
- // Alpha marks painted (255) vs unpainted (0) for the dilation pass.
- byte a = paintedAlpha ? (byte)255 : (byte)0;
- for (int i = 0; i < colors.Length; i++) colors[i].a = a;
- tmp.SetPixels32(colors);
- tmp.Apply(false);
-
- bool prev = GL.sRGBWrite;
- GL.sRGBWrite = !linear;
- Graphics.Blit(tmp, rt);
- GL.sRGBWrite = prev;
- Destroy(tmp);
- }
-
- /// Reads a RenderTexture back as Color32 (for the backend switch).
- private Color32[] ReadRtPixels(RenderTexture rt, bool linear)
- {
- var prev = RenderTexture.active;
- RenderTexture.active = rt;
- var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
- tex.ReadPixels(new Rect(0, 0, textureSize, textureSize), 0, 0, false);
- tex.Apply(false);
- RenderTexture.active = prev;
- var px = tex.GetPixels32();
- Destroy(tex);
- return px;
- }
-
- private void PackMs(GpuMap gm)
- {
- bool prev = GL.sRGBWrite;
- GL.sRGBWrite = false; // linear data, no conversion
- Graphics.Blit(gm.display, gm.final, _packMat, 0);
- GL.sRGBWrite = prev;
- }
-
// --- Brush mask ---
/// Builds the built-in circle mask (white inscribed circle on black), used when no mask is assigned.
@@ -992,7 +591,7 @@ public class MeshPainter : MonoBehaviour
if (!paintCamera) return;
- bool anyChannel = paintAlbedo || paintMetallicSmoothness || paintEmission;
+ bool anyChannel = AnyChannelActive(); // includes occlusion (matches IsChannelActive / PaintStamp3D)
float moveDist = _hasLastScreen ? Vector2.Distance(_lastScreen, screenPos) : 0f;
// Move-only mode: stamp once on mouse-down, then only when the mouse actually
@@ -1096,85 +695,14 @@ public class MeshPainter : MonoBehaviour
return true;
}
- // --- GPU painting ---
+ // --- Channels (shared by both backends) ---
- private void BeginGpuStroke()
- {
- _prevSRGB = GL.sRGBWrite;
- for (int mi = 0; mi < MAP_COUNT; mi++)
- if (IsChannelActive(mi)) _gpu[mi].drew = false;
- }
-
- private void DrawBrushGpu(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
+ /// True if at least one paint channel is enabled (used to gate the per-frame stroke).
+ private bool AnyChannelActive()
{
for (int mi = 0; mi < MAP_COUNT; mi++)
- {
- if (!IsChannelActive(mi)) continue;
- var gm = _gpu[mi];
-
- Graphics.SetRenderTarget(gm.display);
- GL.sRGBWrite = gm.sRGB; // sRGB color map vs linear data map (MS)
-
- SetBrushColorForMap(mi);
- _brushMat.SetTexture(MaskTexId, _resolvedMask);
- _brushMat.SetVector(BrushCenterId, pLocal);
- _brushMat.SetFloat(BrushRadiusId, radiusLocal);
- _brushMat.SetVector(BrushUId, u);
- _brushMat.SetVector(BrushVId, v);
- _brushMat.SetVector(BrushNormalId, n);
- if (!_brushMat.SetPass(0)) continue;
-
- int sub = _bakedMesh.subMeshCount;
- for (int i = 0; i < sub; i++)
- Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
-
- gm.drew = true;
- }
- }
-
- private void EndGpuStroke()
- {
- Graphics.SetRenderTarget(null);
-
- for (int mi = 0; mi < MAP_COUNT; mi++)
- {
- if (!IsChannelActive(mi)) continue;
- var gm = _gpu[mi];
-
- // Edge padding against UV seams: dilate the display RT into temp, then swap.
- if (gm.drew && paddingTexels > 0)
- {
- GL.sRGBWrite = gm.sRGB;
- _dilateMat.SetInt(PadId, paddingTexels);
- Graphics.Blit(gm.display, gm.temp, _dilateMat, 0);
-
- var swap = gm.display; gm.display = gm.temp; gm.temp = swap;
- if (gm.final == null) AssignMapTexture(mi, gm.display);
- }
-
- // MS: pack the work layout (R=metallic, G=smoothness) into the URP final layout.
- if (gm.final != null) PackMs(gm);
- }
-
- GL.sRGBWrite = _prevSRGB;
- }
-
- /// Sets _BrushColor for the given map. Albedo/emission are colors; MS packs (metallic, smoothness) as raw data.
- private void SetBrushColorForMap(int mi)
- {
- switch (mi)
- {
- case MAP_ALBEDO:
- _brushMat.SetColor(BrushColorId, brushColor);
- break;
- case MAP_MS:
- // SetVector (not SetColor) so the raw metallic/smoothness pass through unconverted.
- _brushMat.SetVector(BrushColorId, new Vector4(Mathf.Clamp01(metallic), Mathf.Clamp01(smoothness), 0f, 1f));
- break;
- default: // emission
- _brushMat.SetColor(BrushColorId, emissionColor);
- break;
- }
+ if (IsChannelActive(mi)) return true;
+ return false;
}
private bool IsChannelActive(int mi)
@@ -1182,270 +710,11 @@ public class MeshPainter : MonoBehaviour
switch (mi)
{
case MAP_ALBEDO: return paintAlbedo;
- case MAP_MS: return paintMetallicSmoothness;
+ case MAP_MSO: return paintMetallicSmoothness || paintOcclusion;
default: return paintEmission;
}
}
- // --- CPU painting (rasterization + dilation) ---
-
- private void ResetCpuDirty()
- {
- for (int mi = 0; mi < MAP_COUNT; mi++)
- {
- if (!IsChannelActive(mi)) continue;
- var cm = _cpu[mi];
- cm.dx0 = textureSize; cm.dy0 = textureSize; cm.dx1 = -1; cm.dy1 = -1;
- }
- }
-
- private void EndCpuStroke()
- {
- for (int mi = 0; mi < MAP_COUNT; mi++)
- {
- if (!IsChannelActive(mi)) continue;
- var cm = _cpu[mi];
- if (paddingTexels > 0) DilatePadding(cm, paddingTexels);
- FlushDirty(cm);
- }
- }
-
- ///
- /// Paints a square stamp in the surface tangent plane: rasterizes triangles in range
- /// and blends every texel whose 3D position lies inside the box (|du|,|dv|,|dn| <=
- /// radius) over each active map, weighted by the mask opacity. Mirrors the GPU brush
- /// path (mask value = per-texel opacity, gray = 50%).
- ///
- private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
- {
- bool aAlb = paintAlbedo, aMS = paintMetallicSmoothness, aEM = paintEmission;
- if (!aAlb && !aMS && !aEM) return;
-
- // Albedo target.
- float br = brushColor.r * 255f, bg = brushColor.g * 255f, bb = brushColor.b * 255f;
- float albOpMul = Mathf.Clamp01(brushColor.a);
- CpuMap alb = _cpu[MAP_ALBEDO];
-
- // Metallic-smoothness target (R=metallic, A=smoothness).
- float msR = Mathf.Clamp01(metallic) * 255f, msA = Mathf.Clamp01(smoothness) * 255f;
- CpuMap ms = _cpu[MAP_MS];
-
- // Emission target.
- float er = emissionColor.r * 255f, eg = emissionColor.g * 255f, eb = emissionColor.b * 255f;
- float emOpMul = Mathf.Clamp01(emissionColor.a);
- CpuMap em = _cpu[MAP_EM];
-
- float invR = 1f / radiusLocal;
- int triCount = _tris.Length / 3;
- float ts = textureSize;
-
- for (int t = 0; t < triCount; t++)
- {
- // Fast culling: stamp box (corner reach = sqrt(3)*radius) vs. the triangle's circumscribed circle.
- float reach = radiusLocal * SquareReach + _triRadius[t];
- if ((_triCenter[t] - pLocal).sqrMagnitude > reach * reach) continue;
-
- int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
- Vector3 v0 = _verts[i0], v1 = _verts[i1], v2 = _verts[i2];
- Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
-
- // UV -> pixel.
- 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;
-
- // Barycentric denominator.
- float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y);
- if (Mathf.Abs(d) < 1e-7f) continue;
- float invD = 1f / d;
-
- const float eps = -1e-4f; // tolerance against 1px cracks between triangles
-
- for (int y = minY; y <= maxY; y++)
- {
- float py = y + 0.5f;
- int rowStart = 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;
- float c = 1f - a - b;
- if (c < eps) continue;
-
- // 3D position of the texel via barycentric interpolation.
- float wx = a * v0.x + b * v1.x + c * v2.x;
- float wy = a * v0.y + b * v1.y + c * v2.y;
- float wz = a * v0.z + b * v1.z + c * v2.z;
-
- // Project the offset onto the stamp frame (right/up/normal).
- float ddx = wx - pLocal.x, ddy = wy - pLocal.y, ddz = wz - pLocal.z;
- float du = ddx * u.x + ddy * u.y + ddz * u.z;
- float dv = ddx * v.x + ddy * v.y + ddz * v.z;
- float dn = ddx * n.x + ddy * n.y + ddz * n.z;
- if (du < -radiusLocal || du > radiusLocal) continue;
- if (dv < -radiusLocal || dv > radiusLocal) continue;
- if (dn < -radiusLocal || dn > radiusLocal) continue;
-
- // Mask -> opacity.
- float mu = du * invR * 0.5f + 0.5f;
- float mv = dv * invR * 0.5f + 0.5f;
- float maskOp = SampleMaskOpacity(mu, mv);
- if (maskOp <= 0f) continue;
-
- int idx = rowStart + x;
-
- if (aAlb)
- {
- float op = maskOp * albOpMul;
- if (op > 0f)
- {
- Color32 dst = alb.pixels[idx];
- float it = 1f - op;
- alb.pixels[idx] = new Color32(
- (byte)(dst.r * it + br * op),
- (byte)(dst.g * it + bg * op),
- (byte)(dst.b * it + bb * op),
- 255);
- alb.painted[idx] = true;
- ExpandDirty(alb, x, y);
- }
- }
-
- if (aMS)
- {
- // msOpacity = mask (no extra alpha multiplier).
- Color32 dst = ms.pixels[idx];
- float it = 1f - maskOp;
- ms.pixels[idx] = new Color32(
- (byte)(dst.r * it + msR * maskOp), // metallic
- 0, 0,
- (byte)(dst.a * it + msA * maskOp)); // smoothness
- ms.painted[idx] = true;
- ExpandDirty(ms, x, y);
- }
-
- if (aEM)
- {
- float op = maskOp * emOpMul;
- if (op > 0f)
- {
- Color32 dst = em.pixels[idx];
- float it = 1f - op;
- em.pixels[idx] = new Color32(
- (byte)(dst.r * it + er * op),
- (byte)(dst.g * it + eg * op),
- (byte)(dst.b * it + eb * op),
- 255);
- em.painted[idx] = true;
- ExpandDirty(em, x, y);
- }
- }
- }
- }
- }
- }
-
- private static void ExpandDirty(CpuMap cm, int x, int y)
- {
- if (x < cm.dx0) cm.dx0 = x;
- if (x > cm.dx1) cm.dx1 = x;
- if (y < cm.dy0) cm.dy0 = y;
- if (y > cm.dy1) cm.dy1 = y;
- }
-
- ///
- /// CPU edge padding against UV seams: bleeds the freshly painted colors outward by
- /// 'pad' texels. The dilation source is only brush-painted texels (cm.painted).
- ///
- private void DilatePadding(CpuMap cm, int pad)
- {
- if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
-
- int x0 = Mathf.Max(0, cm.dx0 - pad);
- int y0 = Mathf.Max(0, cm.dy0 - pad);
- int x1 = Mathf.Min(textureSize - 1, cm.dx1 + pad);
- int y1 = Mathf.Min(textureSize - 1, cm.dy1 + pad);
-
- cm.dilateStamp++;
-
- for (int iter = 0; iter < pad; iter++)
- {
- _dilateScratch.Clear();
-
- for (int y = y0; y <= y1; y++)
- {
- int row = y * textureSize;
- for (int x = x0; x <= x1; x++)
- {
- int idx = row + x;
- if (cm.painted[idx] || cm.fillStamp[idx] == cm.dilateStamp) continue;
-
- if (TryFindPaddingSource(cm, x, y, out Color32 c))
- {
- cm.pixels[idx] = c;
- _dilateScratch.Add(idx);
- ExpandDirty(cm, x, y);
- }
- }
- }
-
- for (int k = 0; k < _dilateScratch.Count; k++)
- cm.fillStamp[_dilateScratch[k]] = cm.dilateStamp;
-
- if (_dilateScratch.Count == 0) break;
- }
- }
-
- /// Searches the 8-neighborhood for a source texel (painted or already padded) and returns its color.
- private bool TryFindPaddingSource(CpuMap cm, int x, int y, out Color32 col)
- {
- for (int oy = -1; oy <= 1; oy++)
- {
- int ny = y + oy;
- if (ny < 0 || ny >= textureSize) continue;
- int nrow = ny * textureSize;
- for (int ox = -1; ox <= 1; ox++)
- {
- if (ox == 0 && oy == 0) continue;
- int nx = x + ox;
- if (nx < 0 || nx >= textureSize) continue;
- int nidx = nrow + nx;
- if (cm.painted[nidx] || cm.fillStamp[nidx] == cm.dilateStamp)
- {
- col = cm.pixels[nidx];
- return true;
- }
- }
- }
- col = default;
- return false;
- }
-
- /// Uploads only the region changed this frame to the GPU once (CPU backend).
- private void FlushDirty(CpuMap cm)
- {
- if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted
-
- int bw = cm.dx1 - cm.dx0 + 1;
- int bh = cm.dy1 - cm.dy0 + 1;
- var block = new Color32[bw * bh];
- for (int y = 0; y < bh; y++)
- System.Array.Copy(cm.pixels, (cm.dy0 + y) * textureSize + cm.dx0, block, y * bw, bw);
-
- cm.tex.SetPixels32(cm.dx0, cm.dy0, bw, bh, block);
- cm.tex.Apply(false);
- }
-
// --- Helpers ---
private static float Cross2D(Vector2 a, Vector2 b) => a.x * b.y - a.y * b.x;
diff --git a/Assets/Shaders/MeshPaintBrush.shader b/Assets/Shaders/MeshPaintBrush.shader
index 7b94d70..a7ff0f0 100644
--- a/Assets/Shaders/MeshPaintBrush.shader
+++ b/Assets/Shaders/MeshPaintBrush.shader
@@ -20,6 +20,7 @@ Shader "Hidden/MeshPaintBrush"
{
_BrushColor ("Brush Color", Color) = (1,0,0,1)
_MaskTex ("Brush Mask", 2D) = "white" {}
+ _ColorWriteMask ("Color Write Mask", Float) = 15
}
SubShader
{
@@ -29,6 +30,11 @@ Shader "Hidden/MeshPaintBrush"
ZTest Always
ZWrite Off
Cull Off
+ // Per-channel write mask: lets the shared mask map (R=metallic, G=smoothness,
+ // B=occlusion) be painted by metallic/smoothness and occlusion independently.
+ // ColorWriteMask bits: R=8, G=4, B=2, A=1 (15 = all). Alpha (coverage) is always
+ // written so the dilation pass still sees the freshly painted texels.
+ ColorMask [_ColorWriteMask]
// Source-over for color; alpha accumulates coverage toward 1 (painted marker
// for the dilation pass). Soft edges keep partial alpha.
Blend SrcAlpha OneMinusSrcAlpha, One OneMinusSrcAlpha
diff --git a/Assets/Shaders/MeshPaintPackMS.shader b/Assets/Shaders/MeshPaintPackMS.shader
index b958868..51a8252 100644
--- a/Assets/Shaders/MeshPaintPackMS.shader
+++ b/Assets/Shaders/MeshPaintPackMS.shader
@@ -1,14 +1,16 @@
-// Packs the metallic-smoothness WORK layout into the URP _MetallicGlossMap layout.
+// Packs the mask-map WORK layout into the URP mask-map layout.
//
-// While painting, the metallic-smoothness map is kept in a "work" RenderTexture as
-// R = metallic, G = smoothness, A = coverage (painted marker for the dilation pass).
+// While painting, the mask map is kept in a "work" RenderTexture as
+// R = metallic, G = smoothness, B = occlusion, A = coverage (painted marker for dilation).
// This lets the same brush + dilate shaders (which use alpha as the coverage marker)
-// drive it exactly like the albedo map. But URP's _MetallicGlossMap expects
-// R = metallic, A = smoothness.
-// This pass converts work -> final after each stroke: out = (work.r, 0, 0, work.g).
+// drive it exactly like the albedo map. The packed result is one texture bound to BOTH
+// _MetallicGlossMap (URP reads R = metallic, A = smoothness) and
+// _OcclusionMap (URP reads G = occlusion).
+// This pass converts work -> final after each stroke: out = (work.r, work.b, 0, work.g)
+// = (metallic, occlusion, 0, smoothness).
//
-// Both work and final are linear RenderTextures (metallic/smoothness are data, not
-// color), so no gamma conversion happens.
+// Both work and final are linear RenderTextures (these are data, not color), so no gamma
+// conversion happens.
Shader "Hidden/MeshPaintPackMS"
{
Properties
@@ -35,7 +37,8 @@ Shader "Hidden/MeshPaintPackMS"
fixed4 frag (v2f_img i) : SV_Target
{
fixed4 c = tex2D(_MainTex, i.uv);
- return fixed4(c.r, 0.0, 0.0, c.g); // R = metallic, A = smoothness
+ // work (R=metallic, G=smoothness, B=occlusion) -> (metallic, occlusion, 0, smoothness)
+ return fixed4(c.r, c.b, 0.0, c.g);
}
ENDCG
}