From 86c0a3368757a1b7dc651f14a9fbe00afd3ea007 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Tue, 23 Jun 2026 16:08:32 +0200 Subject: [PATCH] Add metallic, smoothness, and emission painting to mesh painter Extend the painter from a single albedo map to three paint maps: albedo (_BaseMap), packed metallic+smoothness (_MetallicGlossMap, R=metallic A=smoothness), and emission (_EmissionMap/_EmissionColor). - Per-channel toggles (paint albedo / metallic+smoothness / emission) - Metallic/Smoothness sliders and emission color + intensity brush params - One brush shader drives all maps; metallic-smoothness uses a work layout (R=metallic G=smoothness A=coverage) and a pack pass (Hidden/MeshPaintPackMS) to the URP layout after each stroke - GPU and CPU backends mirror all three maps with the same mask and seam-dilation logic - Material keywords (_METALLICSPECGLOSSMAP, _EMISSION) enabled and the new maps seeded from the original material values --- Assets/Scripts/MeshPainter.cs | 677 +++++++++++++++------ Assets/Shaders/MeshPaintPackMS.shader | 44 ++ Assets/Shaders/MeshPaintPackMS.shader.meta | 9 + 3 files changed, 557 insertions(+), 173 deletions(-) create mode 100644 Assets/Shaders/MeshPaintPackMS.shader create mode 100644 Assets/Shaders/MeshPaintPackMS.shader.meta diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index f8a0813..6f42c5b 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -5,18 +5,19 @@ using UnityEngine.InputSystem; #endif /// -/// Live mesh painting in Play mode: paints the brush color onto the mesh texture -/// with the mouse. Two backends (switchable live via ): GPU -/// shader painting into a RenderTexture (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 sides of a seam at once. +/// 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 ): +/// 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 +/// sides of a seam at once. /// -/// The brush is a square stamp in the surface tangent plane whose shape is given by -/// a grayscale mask texture (white = paint, black = skip, hard threshold). A list of -/// masks is assignable in the Inspector; if it is empty a procedural circle mask is -/// used, which reproduces the classic round brush. Attach to the GameObject you want -/// to paint. +/// The brush is a square stamp in the surface tangent plane whose shape is given by a +/// grayscale mask texture (the mask value = per-texel opacity). A list of masks is +/// assignable in the Inspector; if it is empty a procedural circle mask is used, which +/// reproduces the classic round brush. Attach to the GameObject you want to paint. /// /// A non-destructive brush preview (size + mask) is shown at the hover point without /// painting (): either a flat decal quad or a real on-surface @@ -52,11 +53,39 @@ public class MeshPainter : MonoBehaviour [Tooltip("ON = GPU painting (fast), OFF = CPU painting. Can be toggled live in Play mode.")] public bool useGpu = true; - [Header("Brush")] + [Header("Brush - channels")] + [Tooltip("Paint the albedo (base color) map.")] + public bool paintAlbedo = true; + + [Tooltip("Paint the metallic + smoothness map (_MetallicGlossMap).")] + public bool paintMetallicSmoothness = false; + + [Tooltip("Paint the emission map (_EmissionMap).")] + public bool paintEmission = false; + + [Header("Brush - albedo")] [Tooltip("Color used for painting. The alpha channel is the global opacity multiplier on " + "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).")] + [Range(0f, 1f)] + public float metallic = 0f; + + [Tooltip("Smoothness value painted into the map (0 = rough, 1 = glossy).")] + [Range(0f, 1f)] + public float smoothness = 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; + + [Tooltip("Global emission intensity multiplier on the painted emission (drives the material's _EmissionColor). " + + "Above 1 makes the emission HDR/bloom-bright.")] + public float emissionIntensity = 1f; + + [Header("Brush - shape")] [Tooltip("Brush half-extent in texels (= half the square side). Converted into a 3D size per hit.")] [Range(1, 256)] public int brushSize = 16; @@ -92,10 +121,10 @@ public class MeshPainter : MonoBehaviour public float previewOpacity = 0.5f; [Header("Canvas")] - [Tooltip("Resolution of the paint texture (square).")] + [Tooltip("Resolution of the paint textures (square).")] public int textureSize = 1024; - [Tooltip("Base color of the texture at startup (if none exists).")] + [Tooltip("Base color of the albedo texture at startup (if none exists).")] public Color baseColor = Color.white; [Tooltip("Edge padding (texels) bled beyond UV island edges so bilinear sampling does not pull in the base color. 0 = off.")] @@ -109,6 +138,33 @@ public class MeshPainter : MonoBehaviour [Tooltip("Only for animated meshes: re-bake the collider every frame (expensive!).")] public bool updateColliderEveryFrame = false; + // --- paint maps --- + private const int MAP_ALBEDO = 0; + private const int MAP_MS = 1; // metallic (R) + smoothness (A) + 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 + { + public Texture2D tex; + public Color32[] pixels; + public bool[] painted; // true = painted by the brush (dilation source) + public int[] fillStamp; // per-call marker for padding texels + public int dilateStamp; // bumped per call so old padding is not a source + public int dx0, dy0, dx1, dy1; // dirty rectangle (CPU upload) + } + // --- shared references --- private Renderer _renderer; private SkinnedMeshRenderer _skinned; @@ -129,30 +185,30 @@ public class MeshPainter : MonoBehaviour private bool _gpuActive; private bool _setupValid; - // --- CPU backend --- - private Texture2D _paintTexture; - private Color32[] _pixels; - private bool[] _painted; // true = painted by the brush (dilation source) - private int[] _fillStamp; // per-call marker for padding texels - private int _dilateStamp; // bumped per call so old padding is not a source + // Seed colors per map in canonical (material) layout, built once at Start: + // albedo -> RGB color + // MS -> R = metallic, A = smoothness + // 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]; - private int _dx0, _dy0, _dx1, _dy1; // dirty rectangle (CPU upload) - // --- GPU backend --- 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; private Mesh _previewQuad; // unit quad in the tangent plane (flat decal mode) - private RenderTexture _displayRT; - private RenderTexture _tempRT; // scratch for the dilation blit (ping-pong) - private bool _drewThisStroke; - private bool _prevSRGB; // --- brush mask (shared) --- private Texture2D _proceduralMask; // built-in circle, used when the list is empty/invalid @@ -171,6 +227,11 @@ public class MeshPainter : MonoBehaviour private static readonly int MainTexId = Shader.PropertyToID("_MainTex"); private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); private static readonly int ColorId = Shader.PropertyToID("_Color"); + private static readonly int MetallicGlossMapId = Shader.PropertyToID("_MetallicGlossMap"); + private static readonly int MetallicId = Shader.PropertyToID("_Metallic"); + 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 BrushColorId = Shader.PropertyToID("_BrushColor"); private static readonly int BrushCenterId = Shader.PropertyToID("_BrushCenter"); private static readonly int BrushRadiusId = Shader.PropertyToID("_BrushRadius"); @@ -207,13 +268,15 @@ public class MeshPainter : MonoBehaviour return; } - // Own material instance so the shared asset is not modified; tint to white - // so the painted color appears unaltered. + // Own material instance so the shared asset is not modified; tint base color to + // white so the painted color appears unaltered. _materialInstance = _renderer.material; if (_materialInstance.HasProperty(BaseColorId)) _materialInstance.SetColor(BaseColorId, Color.white); else if (_materialInstance.HasProperty(ColorId)) _materialInstance.SetColor(ColorId, Color.white); - SetupBackend(useGpu, fromColor32: BuildBaseColors()); + BuildSeedColors(); + SetupBackend(useGpu, _seedCanonical); + ConfigureMaterialPbr(); EnsureProceduralMask(); ResolveActiveMask(); @@ -295,7 +358,37 @@ public class MeshPainter : MonoBehaviour if (sumAreaUv > 1e-9) _avgUvToLocal = (float)System.Math.Sqrt(sumArea3D / sumAreaUv); } - /// Builds the base-color texels from the existing material texture or baseColor. + /// 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]; @@ -324,104 +417,226 @@ public class MeshPainter : MonoBehaviour 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[] fromColor32) + private void SetupBackend(bool gpu, Color32[][] canonical) { - if (gpu) SetupGpu(fromColor32); - else SetupCpu(fromColor32); + if (gpu) SetupGpu(canonical); + else SetupCpu(canonical); _gpuActive = gpu; } - /// Switches the backend at runtime, carrying over the painted content. + /// Switches the backend at runtime, carrying over all painted maps. private void SwitchBackend(bool toGpu) { - Color32[] current = (_gpuActive ? ReadGpuPixels() : (Color32[])_pixels.Clone()); + 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, current); + 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[] initial) + private void SetupCpu(Color32[][] canonical) { - _paintTexture = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false) + _cpu = new CpuMap[MAP_COUNT]; + for (int mi = 0; mi < MAP_COUNT; mi++) { - name = "MeshPainter_Canvas", - wrapMode = TextureWrapMode.Clamp, - filterMode = FilterMode.Bilinear - }; + 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); + } + } - _pixels = (initial != null && initial.Length == textureSize * textureSize) - ? initial - : BuildBaseColors(); - - _painted = new bool[textureSize * textureSize]; - _fillStamp = new int[textureSize * textureSize]; - _dilateStamp = 0; - - _paintTexture.SetPixels32(_pixels); - _paintTexture.Apply(false); - - AssignTextureToMaterial(_paintTexture); + /// 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 (_paintTexture) { Destroy(_paintTexture); _paintTexture = null; } - _pixels = null; - _painted = null; - _fillStamp = null; + 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[] initial) + 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) + if (!brushShader || !dilateShader || !packMsShader) { - Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush or " + - "Hidden/MeshPaintDilate). Falling back to CPU painting.", this); + Debug.LogError("[MeshPainter] GPU shaders not found (Hidden/MeshPaintBrush, " + + "Hidden/MeshPaintDilate or Hidden/MeshPaintPackMS). Falling back to CPU painting.", this); useGpu = false; - SetupCpu(initial); + 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 }; - _displayRT = CreateRT("MeshPainter_DisplayRT"); - _tempRT = CreateRT("MeshPainter_TempRT"); + _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; - // Seed the display RT. Alpha = 0 marks "unpainted" so the base color is not - // mistaken for a dilation source. - var seed = initial ?? BuildBaseColors(); - UploadColorsToRT(seed, _displayRT, paintedAlpha: false); + SeedGpuMap(mi, canonical); - AssignTextureToMaterial(_displayRT); + // 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 (_displayRT) { _displayRT.Release(); Destroy(_displayRT); _displayRT = null; } - if (_tempRT) { _tempRT.Release(); Destroy(_tempRT); _tempRT = null; } + 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) + private RenderTexture CreateRT(string rtName, bool linear) { - var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB) + var rt = new RenderTexture(textureSize, textureSize, 0, RenderTextureFormat.ARGB32, + linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB) { name = rtName, wrapMode = TextureWrapMode.Clamp, @@ -435,30 +650,28 @@ public class MeshPainter : MonoBehaviour } /// Writes Color32 data into a RenderTexture via a temporary texture. - private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha) + private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool paintedAlpha, bool linear) { - var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false); + var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear); // Alpha marks painted (255) vs unpainted (0) for the dilation pass. - if (paintedAlpha) - { - for (int i = 0; i < colors.Length; i++) colors[i].a = 255; - } - else - { - for (int i = 0; i < colors.Length; i++) colors[i].a = 0; - } + 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 the display RT back as Color32 (for the backend switch). - private Color32[] ReadGpuPixels() + /// Reads a RenderTexture back as Color32 (for the backend switch). + private Color32[] ReadRtPixels(RenderTexture rt, bool linear) { var prev = RenderTexture.active; - RenderTexture.active = _displayRT; - var tex = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, false); + 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; @@ -467,13 +680,12 @@ public class MeshPainter : MonoBehaviour return px; } - // --- Material --- - - private void AssignTextureToMaterial(Texture tex) + private void PackMs(GpuMap gm) { - if (!_materialInstance) return; - if (_materialInstance.HasProperty(BaseMapId)) _materialInstance.SetTexture(BaseMapId, tex); - if (_materialInstance.HasProperty(MainTexId)) _materialInstance.SetTexture(MainTexId, tex); + bool prev = GL.sRGBWrite; + GL.sRGBWrite = false; // linear data, no conversion + Graphics.Blit(gm.display, gm.final, _packMat, 0); + GL.sRGBWrite = prev; } // --- Brush mask --- @@ -514,7 +726,7 @@ public class MeshPainter : MonoBehaviour _procH = s; } - /// Picks the active mask (list entry or procedural circle) and refreshes the CPU luminance cache on change. + /// Picks the active mask (list entry or procedural circle) and refreshes the CPU opacity cache on change. private void ResolveActiveMask() { Texture2D desired = null; @@ -747,6 +959,8 @@ public class MeshPainter : MonoBehaviour if (useGpu != _gpuActive) SwitchBackend(useGpu); + RefreshEmissionColor(); // live-tunable emission intensity + // Painting disabled by the game mode (e.g. player-control mode): do nothing. if (!PaintingEnabled) { @@ -778,16 +992,17 @@ public class MeshPainter : MonoBehaviour if (!paintCamera) return; + bool anyChannel = paintAlbedo || paintMetallicSmoothness || paintEmission; float moveDist = _hasLastScreen ? Vector2.Distance(_lastScreen, screenPos) : 0f; // Move-only mode: stamp once on mouse-down, then only when the mouse actually // moves - a held but stationary button no longer re-stamps the same spot. - bool stamp = !_hasLastScreen || !paintOnMoveOnly || moveDist > MoveOnlyMinPixels; + bool stamp = anyChannel && (!_hasLastScreen || !paintOnMoveOnly || moveDist > MoveOnlyMinPixels); if (stamp) { if (_gpuActive) BeginGpuStroke(); - else { _dx0 = textureSize; _dy0 = textureSize; _dx1 = -1; _dy1 = -1; } + else ResetCpuDirty(); if (_hasLastScreen) { @@ -803,11 +1018,7 @@ public class MeshPainter : MonoBehaviour } if (_gpuActive) EndGpuStroke(); - else - { - if (paddingTexels > 0) DilatePadding(paddingTexels); - FlushDirty(); - } + else EndCpuStroke(); } _lastScreen = screenPos; @@ -889,59 +1100,141 @@ public class MeshPainter : MonoBehaviour private void BeginGpuStroke() { - _drewThisStroke = false; _prevSRGB = GL.sRGBWrite; - GL.sRGBWrite = true; // correct sRGB encoding when writing into the sRGB RT - Graphics.SetRenderTarget(_displayRT); - _brushMat.SetColor(BrushColorId, brushColor); - _brushMat.SetTexture(MaskTexId, _resolvedMask); + 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) { - _brushMat.SetVector(BrushCenterId, pLocal); - _brushMat.SetFloat(BrushRadiusId, radiusLocal); - _brushMat.SetVector(BrushUId, u); - _brushMat.SetVector(BrushVId, v); - _brushMat.SetVector(BrushNormalId, n); - if (!_brushMat.SetPass(0)) return; + for (int mi = 0; mi < MAP_COUNT; mi++) + { + if (!IsChannelActive(mi)) continue; + var gm = _gpu[mi]; - int sub = _bakedMesh.subMeshCount; - for (int i = 0; i < sub; i++) - Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i); + Graphics.SetRenderTarget(gm.display); + GL.sRGBWrite = gm.sRGB; // sRGB color map vs linear data map (MS) - _drewThisStroke = true; + 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); - // Edge padding against UV seams: dilate the display RT into _tempRT, then swap. - if (_drewThisStroke && paddingTexels > 0) + for (int mi = 0; mi < MAP_COUNT; mi++) { - _dilateMat.SetInt(PadId, paddingTexels); - Graphics.Blit(_displayRT, _tempRT, _dilateMat, 0); + if (!IsChannelActive(mi)) continue; + var gm = _gpu[mi]; - var swap = _displayRT; _displayRT = _tempRT; _tempRT = swap; - AssignTextureToMaterial(_displayRT); + // 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; + } + } + + private bool IsChannelActive(int mi) + { + switch (mi) + { + case MAP_ALBEDO: return paintAlbedo; + case MAP_MS: return paintMetallicSmoothness; + 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 the canvas, weighted by the mask opacity. Mirrors the - /// GPU brush shader (mask value = per-texel opacity, gray = 50%). + /// 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 globalOpacity = Mathf.Clamp01(brushColor.a); + 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; @@ -1003,45 +1296,87 @@ public class MeshPainter : MonoBehaviour if (dv < -radiusLocal || dv > radiusLocal) continue; if (dn < -radiusLocal || dn > radiusLocal) continue; - // Mask -> opacity, then source-over blend (matches the GPU path). + // Mask -> opacity. float mu = du * invR * 0.5f + 0.5f; float mv = dv * invR * 0.5f + 0.5f; - float opacity = SampleMaskOpacity(mu, mv) * globalOpacity; - if (opacity <= 0f) continue; + float maskOp = SampleMaskOpacity(mu, mv); + if (maskOp <= 0f) continue; int idx = rowStart + x; - Color32 dst = _pixels[idx]; - float it = 1f - opacity; - _pixels[idx] = new Color32( - (byte)(dst.r * it + br * opacity), - (byte)(dst.g * it + bg * opacity), - (byte)(dst.b * it + bb * opacity), - 255); - _painted[idx] = true; - if (x < _dx0) _dx0 = x; - if (x > _dx1) _dx1 = x; - if (y < _dy0) _dy0 = y; - if (y > _dy1) _dy1 = y; + 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); + } + } } } } } - /// - /// CPU edge padding against UV seams: bleeds the freshly painted colors outward - /// by 'pad' texels. The dilation source is only brush-painted texels (_painted). - /// - private void DilatePadding(int pad) + private static void ExpandDirty(CpuMap cm, int x, int y) { - if (_dx1 < _dx0 || _dy1 < _dy0) return; // nothing painted + 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; + } - int x0 = Mathf.Max(0, _dx0 - pad); - int y0 = Mathf.Max(0, _dy0 - pad); - int x1 = Mathf.Min(textureSize - 1, _dx1 + pad); - int y1 = Mathf.Min(textureSize - 1, _dy1 + pad); + /// + /// 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 - _dilateStamp++; + 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++) { @@ -1053,30 +1388,26 @@ public class MeshPainter : MonoBehaviour for (int x = x0; x <= x1; x++) { int idx = row + x; - if (_painted[idx] || _fillStamp[idx] == _dilateStamp) continue; + if (cm.painted[idx] || cm.fillStamp[idx] == cm.dilateStamp) continue; - if (TryFindPaddingSource(x, y, out Color32 c)) + if (TryFindPaddingSource(cm, x, y, out Color32 c)) { - _pixels[idx] = c; + cm.pixels[idx] = c; _dilateScratch.Add(idx); - - if (x < _dx0) _dx0 = x; - if (x > _dx1) _dx1 = x; - if (y < _dy0) _dy0 = y; - if (y > _dy1) _dy1 = y; + ExpandDirty(cm, x, y); } } } for (int k = 0; k < _dilateScratch.Count; k++) - _fillStamp[_dilateScratch[k]] = _dilateStamp; + 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(int x, int y, out Color32 col) + private bool TryFindPaddingSource(CpuMap cm, int x, int y, out Color32 col) { for (int oy = -1; oy <= 1; oy++) { @@ -1089,9 +1420,9 @@ public class MeshPainter : MonoBehaviour int nx = x + ox; if (nx < 0 || nx >= textureSize) continue; int nidx = nrow + nx; - if (_painted[nidx] || _fillStamp[nidx] == _dilateStamp) + if (cm.painted[nidx] || cm.fillStamp[nidx] == cm.dilateStamp) { - col = _pixels[nidx]; + col = cm.pixels[nidx]; return true; } } @@ -1101,18 +1432,18 @@ public class MeshPainter : MonoBehaviour } /// Uploads only the region changed this frame to the GPU once (CPU backend). - private void FlushDirty() + private void FlushDirty(CpuMap cm) { - if (_dx1 < _dx0 || _dy1 < _dy0) return; // nothing painted + if (cm.dx1 < cm.dx0 || cm.dy1 < cm.dy0) return; // nothing painted - int bw = _dx1 - _dx0 + 1; - int bh = _dy1 - _dy0 + 1; + 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(_pixels, (_dy0 + y) * textureSize + _dx0, block, y * bw, bw); + System.Array.Copy(cm.pixels, (cm.dy0 + y) * textureSize + cm.dx0, block, y * bw, bw); - _paintTexture.SetPixels32(_dx0, _dy0, bw, bh, block); - _paintTexture.Apply(false); + cm.tex.SetPixels32(cm.dx0, cm.dy0, bw, bh, block); + cm.tex.Apply(false); } // --- Helpers --- diff --git a/Assets/Shaders/MeshPaintPackMS.shader b/Assets/Shaders/MeshPaintPackMS.shader new file mode 100644 index 0000000..b958868 --- /dev/null +++ b/Assets/Shaders/MeshPaintPackMS.shader @@ -0,0 +1,44 @@ +// Packs the metallic-smoothness WORK layout into the URP _MetallicGlossMap 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). +// 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). +// +// Both work and final are linear RenderTextures (metallic/smoothness are data, not +// color), so no gamma conversion happens. +Shader "Hidden/MeshPaintPackMS" +{ + Properties + { + _MainTex ("Texture", 2D) = "white" {} + } + SubShader + { + Tags { "RenderType" = "Opaque" } + Pass + { + ZTest Always + ZWrite Off + Cull Off + Blend Off + + CGPROGRAM + #pragma vertex vert_img + #pragma fragment frag + #include "UnityCG.cginc" + + sampler2D _MainTex; + + 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 + } + ENDCG + } + } + Fallback Off +} diff --git a/Assets/Shaders/MeshPaintPackMS.shader.meta b/Assets/Shaders/MeshPaintPackMS.shader.meta new file mode 100644 index 0000000..4b2bde6 --- /dev/null +++ b/Assets/Shaders/MeshPaintPackMS.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 0b7306b5c0c771147ab18037e40a820d +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: