From c7ec12e6d9d666f826c588f4d383e2d666572d5d Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Tue, 23 Jun 2026 19:31:52 +0200 Subject: [PATCH] Split mesh painter into partials and add occlusion via combined mask map - Split MeshPainter into partial classes (Setup/Backend/Gpu/Cpu) for maintainability - Add ambient occlusion painting, packed with metallic/smoothness into one MSO mask map (R=metallic, G=occlusion, A=smoothness) bound to both _MetallicGlossMap and _OcclusionMap, kept independently paintable via per-channel ColorMask - Gate painting from a single IsChannelActive source so AO-only strokes work - Blend albedo/emission in linear space on CPU to match the GPU framebuffer - Pre-compensate data-channel writes for sRGB->linear so GPU matches CPU - Harden GPU shader fallback to reflect real backend state Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/MeshPainter.Backend.cs | 74 ++ Assets/Scripts/MeshPainter.Backend.cs.meta | 2 + Assets/Scripts/MeshPainter.Cpu.cs | 338 +++++++++ Assets/Scripts/MeshPainter.Cpu.cs.meta | 2 + Assets/Scripts/MeshPainter.Gpu.cs | 280 +++++++ Assets/Scripts/MeshPainter.Gpu.cs.meta | 2 + Assets/Scripts/MeshPainter.Setup.cs | 173 +++++ Assets/Scripts/MeshPainter.Setup.cs.meta | 2 + Assets/Scripts/MeshPainter.cs | 803 +-------------------- Assets/Shaders/MeshPaintBrush.shader | 6 + Assets/Shaders/MeshPaintPackMS.shader | 21 +- 11 files changed, 927 insertions(+), 776 deletions(-) create mode 100644 Assets/Scripts/MeshPainter.Backend.cs create mode 100644 Assets/Scripts/MeshPainter.Backend.cs.meta create mode 100644 Assets/Scripts/MeshPainter.Cpu.cs create mode 100644 Assets/Scripts/MeshPainter.Cpu.cs.meta create mode 100644 Assets/Scripts/MeshPainter.Gpu.cs create mode 100644 Assets/Scripts/MeshPainter.Gpu.cs.meta create mode 100644 Assets/Scripts/MeshPainter.Setup.cs create mode 100644 Assets/Scripts/MeshPainter.Setup.cs.meta 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 }