From 7536e8ec8b85c67f7d0b8f98f27252206adb38c1 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Fri, 3 Jul 2026 21:35:53 +0200 Subject: [PATCH] Fur als 4. Paint-Map + Ziel-Opacity-Blend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fur wird die vierte kanonische Paint-Map (RGB=Farbe, A=Länge). Integration über Seed (kahl), GPU/CPU-Paint, Undo, Netzwerk-Sync, Persistence (fur.png, v6) und Live-RT-Hook im FurShellRenderer. HUD-Binding (Toggle/Picker/Alpha) aktiviert. Fur-Alpha lerpt jetzt zu einer Ziel-Opacity (Slider_FurOpacity) statt additiv Richtung 1 zu akkumulieren (zwei Brush-Passes: Fade + Deposit). Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/Components/FurShellRenderer.cs | 12 +- .../MeshPainter/MeshPainter.Backend.cs | 16 +++ .../Components/MeshPainter/MeshPainter.Cpu.cs | 42 +++++- .../Components/MeshPainter/MeshPainter.Gpu.cs | 44 +++++- .../Components/MeshPainter/MeshPainter.Net.cs | 11 ++ .../MeshPainter/MeshPainter.Persistence.cs | 23 ++- .../MeshPainter/MeshPainter.Setup.cs | 7 + .../MeshPainter/MeshPainter.Undo.cs | 1 + .../Components/MeshPainter/MeshPainter.cs | 30 +++- .../Components/UI/PaintHud/PaintHud.cs | 21 ++- Assets/Shaders/MeshPaintBrush.shader | 132 ++++++++++++++++++ 11 files changed, 319 insertions(+), 20 deletions(-) diff --git a/Assets/Scripts/Components/FurShellRenderer.cs b/Assets/Scripts/Components/FurShellRenderer.cs index ab2728e..88bf33b 100644 --- a/Assets/Scripts/Components/FurShellRenderer.cs +++ b/Assets/Scripts/Components/FurShellRenderer.cs @@ -53,7 +53,9 @@ namespace Chameleon.Components [Header("Fur Map")] // RGBA fur map: RGB = per-texel strand colour, A = fur length (0 = bald, 1 = full length). Leave - // null on the standalone prototype - the shader's white default is uniform full-length fur. + // null on the standalone prototype - the shader's white default is uniform full-length fur. A + // sibling MeshPainter's live fur RT overrides this when present, so a painted avatar grows fur in + // real time (see PushProperties). [SerializeField] Texture _furMap; // Fur is fully cut where the map's alpha is at/below this - keeps stray dilation fuzz from growing // hair. 0 keeps the exact "alpha 0 = no fur" contract. @@ -101,6 +103,9 @@ namespace Chameleon.Components MeshFilter _meshFilter; SkinnedColliderBaker _baker; + // Optional sibling paint source: when present, its live fur RT (RGB = colour, A = length) overrides + // the serialized _furMap so painting the fur map drives the shells in real time. + MeshPainter _painter; Matrix4x4[] _instances; MaterialPropertyBlock _mpb; @@ -110,6 +115,7 @@ namespace Chameleon.Components // On a skinned avatar the fur borrows this component's per-frame baked pose instead of a // static MeshFilter mesh (see ResolveMesh). _baker = GetComponent(); + _painter = GetComponent(); _mpb = new MaterialPropertyBlock(); // RenderMeshInstanced requires the material's GPU-instancing flag; guarantee it here so any // assigned FurShell material just works without the manual inspector checkbox. @@ -206,7 +212,9 @@ namespace Chameleon.Components _mpb.SetFloat(SilhouetteStretchId, _silhouetteStretch); if (_baseMap) _mpb.SetTexture(BaseMapId, _baseMap); - if (_furMap) _mpb.SetTexture(FurMapId, _furMap); + // Prefer the sibling painter's live fur RT (painted colour + length) over the serialized map. + Texture furTex = _painter && _painter.FurMapTexture ? _painter.FurMapTexture : _furMap; + if (furTex) _mpb.SetTexture(FurMapId, furTex); _mpb.SetColor(BaseColorId, ToRenderColor(_rootColor)); _mpb.SetColor(TipColorId, ToRenderColor(_tipColor)); _mpb.SetFloat(RootAOId, _rootAO); diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs index 74db91d..00a822e 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs @@ -103,6 +103,7 @@ public partial class MeshPainter new[] { BaseMapId, MainTexId }, // MAP_ALBEDO new[] { MetallicGlossMapId, OcclusionMapId, ParallaxMapId }, // MAP_MSO new[] { EmissionMapId }, // MAP_EM + System.Array.Empty(), // MAP_FUR: read by FurShellRenderer, not the base material }; private void AssignMapTexture(int mi, Texture tex) @@ -111,4 +112,19 @@ public partial class MeshPainter foreach (int id in MapPropIds[mi]) if (_materialInstance.HasProperty(id)) _materialInstance.SetTexture(id, tex); } + + /// + /// The live fur map (RGB = strand colour, A = painted length), for a sibling FurShellRenderer to sample + /// as _FurMap. GPU backend: the display RT (stable - fur skips the dilation ping-pong swap); CPU backend: + /// the Texture2D mirror. Null until the backend is set up. + /// + public Texture FurMapTexture + { + get + { + if (!_setupValid) return null; + if (_gpuActive) return _gpu != null ? _gpu[MAP_FUR]?.display : null; + return _cpu != null ? _cpu[MAP_FUR]?.tex : null; + } + } } diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs index b0c3480..3d9aea4 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs @@ -36,7 +36,9 @@ public partial class MeshPainter { var src = ResolveSeed(mi, canonical); var px = (Color32[])src.Clone(); - if (mi != MAP_MSO) // albedo / emission: opaque. The mask map keeps alpha = smoothness. + // Albedo / emission are opaque. The mask map keeps alpha = smoothness; fur keeps alpha = painted + // length (0 = bald), which is data, not coverage. + if (mi != MAP_MSO && mi != MAP_FUR) for (int i = 0; i < px.Length; i++) px[i].a = 255; return px; } @@ -66,7 +68,9 @@ public partial class MeshPainter { if (!IsChannelActive(mi)) continue; var cm = _cpu[mi]; - if (paddingTexels > 0) DilatePadding(cm, paddingTexels); + // Fur skips edge padding: its alpha is length, not a coverage guard, so there is no valid + // dilation source mask (matches the GPU path, which also skips fur in EndGpuStroke). + if (paddingTexels > 0 && mi != MAP_FUR) DilatePadding(cm, paddingTexels); FlushDirty(cm); } } @@ -98,8 +102,8 @@ public partial class MeshPainter /// 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; + bool aAlb = paintAlbedo, aMS = paintMetallicSmoothness, aEM = paintEmission, aAO = paintOcclusion, aFur = fur; + if (!aAlb && !aMS && !aEM && !aAO && !aFur) return; // Albedo target (brush color in linear space; blended linear to match the GPU). float brLin = Mathf.GammaToLinearSpace(brushColor.r); @@ -125,6 +129,15 @@ public partial class MeshPainter float emOpMul = Mathf.Clamp01(emissionColor.a); CpuMap em = _cpu[MAP_EM]; + // Fur target (linear RGB like albedo; alpha = painted length that EASES toward furTargetA + // at the brush strength furColor.a, not accumulating to full - mirrors the GPU fade+deposit). + float frLin = Mathf.GammaToLinearSpace(furColor.r); + float fgLin = Mathf.GammaToLinearSpace(furColor.g); + float fbLin = Mathf.GammaToLinearSpace(furColor.b); + float furOpMul = Mathf.Clamp01(furColor.a); + float furTargetA = Mathf.Clamp01(furOpacity) * 255f; + CpuMap furm = _cpu[MAP_FUR]; + float invR = 1f / radiusLocal; int triCount = _tris.Length / 3; float ts = textureSize; @@ -243,6 +256,27 @@ public partial class MeshPainter } } + if (aFur) + { + float op = maskOp * furOpMul; + if (op > 0f) + { + Color32 dst = furm.pixels[idx]; + float it = 1f - op; + // RGB source-over toward the fur colour (linear); alpha EASES toward the target + // length at the deposit rate: lerp(dst.a, target, op) - over-writable, not + // accumulating. Mirrors the GPU fur fade (alpha *= 1-op) + deposit (+= op*target). + byte na = (byte)(dst.a * it + furTargetA * op + 0.5f); + furm.pixels[idx] = new Color32( + BlendSrgb(dst.r, frLin, it, op), + BlendSrgb(dst.g, fgLin, it, op), + BlendSrgb(dst.b, fbLin, it, op), + na); + furm.painted[idx] = true; + ExpandDirty(furm, x, y); + } + } + } } } diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs index e1aa12e..8644302 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs @@ -14,6 +14,11 @@ public partial class MeshPainter public bool sRGB; // sRGB color map (albedo/emission) vs linear data map (MSO) } + // MeshPaintBrush.shader pass indices: 0 = source-over brush, 1 = coverage seed, + // 2 = fur fade (RGB + alpha*(1-op)), 3 = fur deposit (alpha += op*target). + private const int FurFadePass = 2; + private const int FurDepositPass = 3; + 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") @@ -84,6 +89,13 @@ public partial class MeshPainter StampCoverage(gm.display); PackMs(gm); // build the final mask RT once so the material shows the seeded values } + else if (mi == MAP_FUR) + { + // Fur: alpha IS data (strand length), not coverage. Preserve the seeded/loaded alpha and skip + // the coverage stamp, so unpainted texels stay bald (alpha 0) instead of being marked full + // length. (Dilation is also skipped for fur in EndGpuStroke, so the alpha guard never matters.) + UploadColorsToRT((Color32[])src.Clone(), gm.display, linear: gm.sRGB == false, preserveAlpha: true); + } else { UploadColorsToRT((Color32[])src.Clone(), gm.display, linear: gm.sRGB == false); @@ -159,11 +171,13 @@ public partial class MeshPainter /// Writes Color32 RGB into a RenderTexture via a temporary texture. Alpha (the /// dilation coverage marker) is cleared to 0 here and stamped afterwards by StampCoverage, - /// so the guard matches the GPU rasterizer exactly (see SeedGpuMap). - private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool linear) + /// so the guard matches the GPU rasterizer exactly (see SeedGpuMap). preserveAlpha keeps the + /// source alpha instead - used by the fur map, whose alpha is painted length, not coverage. + private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool linear, bool preserveAlpha = false) { var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear); - for (int i = 0; i < colors.Length; i++) colors[i].a = 0; + if (!preserveAlpha) + for (int i = 0; i < colors.Length; i++) colors[i].a = 0; tmp.SetPixels32(colors); tmp.Apply(false); @@ -251,10 +265,24 @@ public partial class MeshPainter if (paintOcclusion) StampMso(Mathf.Clamp01(occlusionOpacity), 3f); // B|A (occlusion only) } + else if (mi == MAP_FUR) + { + // Fur alpha is DATA (strand length) that must ease toward furOpacity, not accumulate to 1. + // Fixed-function can't lerp-to-target in one draw, so it is split into two stamps into the + // SAME display RT (reference stays stable for FurMapTexture - no ping-pong): + // pass FurFade: RGB source-over toward furColor + alpha *= (1-op) + // pass FurDeposit: alpha += op * furOpacity + // Net alpha = (1-op)*dst + op*target = lerp(dst, target, op); op = luma*mask.a*furColor.a. + _brushMat.SetColor(BrushColorId, furColor); + _brushMat.SetFloat(FurTargetId, Mathf.Clamp01(furOpacity)); + if (_brushMat.SetPass(FurFadePass)) DrawBakedMeshNow(); + if (_brushMat.SetPass(FurDepositPass)) DrawBakedMeshNow(); + } else { - // Albedo / emission: full RGBA, opacity carried by the colour's own alpha. - _brushMat.SetColor(BrushColorId, mi == MAP_ALBEDO ? brushColor : emissionColor); + // Albedo / emission: full RGBA, opacity carried by the colour's own alpha (source-over). + Color paintCol = mi == MAP_ALBEDO ? brushColor : emissionColor; + _brushMat.SetColor(BrushColorId, paintCol); _brushMat.SetFloat(ColorWriteMaskId, 15f); DrawStamp(); } @@ -301,8 +329,10 @@ public partial class MeshPainter 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) + // Edge padding against UV seams: dilate the display RT into temp, then swap. Skipped for fur: + // its alpha is length (not a coverage guard), so dilation has no valid source mask and the + // display RT reference must stay stable for FurMapTexture (no ping-pong swap). + if (gm.drew && paddingTexels > 0 && mi != MAP_FUR) { GL.sRGBWrite = false; _dilateMat.SetInt(PadId, paddingTexels); diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs index 31c7ab5..62a6f26 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs @@ -18,6 +18,7 @@ public struct PaintStampNet : INetworkSerializable public const byte FlagOcclusion = 1 << 2; public const byte FlagEmission = 1 << 3; public const byte FlagSpray = 1 << 4; + public const byte FlagFur = 1 << 5; // Placement (object-local space). public Vector3 P; // hit point @@ -30,6 +31,8 @@ public struct PaintStampNet : INetworkSerializable public Color32 BrushColor; // albedo color (alpha = opacity) public Color32 EmissionColor; // emission color (alpha = opacity) + public Color32 FurColor; // fur colour (alpha = brush strength easing toward FurTarget) + public byte FurTarget; // target fur length the stroke eases toward (0..255 = 0..1) public byte Metallic, Smoothness, Occlusion; // 0..255 = 0..1 public byte MSOpacity, OccOpacity; // 0..255 = 0..1 @@ -45,6 +48,8 @@ public struct PaintStampNet : INetworkSerializable s.SerializeValue(ref SpraySeed); s.SerializeValue(ref BrushColor); s.SerializeValue(ref EmissionColor); + s.SerializeValue(ref FurColor); + s.SerializeValue(ref FurTarget); s.SerializeValue(ref Metallic); s.SerializeValue(ref Smoothness); s.SerializeValue(ref Occlusion); @@ -82,6 +87,7 @@ public partial class MeshPainter if (paintOcclusion) ch |= PaintStampNet.FlagOcclusion; if (paintEmission) ch |= PaintStampNet.FlagEmission; if (spray) ch |= PaintStampNet.FlagSpray; + if (fur) ch |= PaintStampNet.FlagFur; handler(new PaintStampNet { @@ -91,6 +97,8 @@ public partial class MeshPainter SpraySeed = _lastSpraySeed, BrushColor = brushColor, EmissionColor = emissionColor, + FurColor = furColor, + FurTarget = ToByte01(furOpacity), Metallic = ToByte01(metallic), Smoothness = ToByte01(smoothness), Occlusion = ToByte01(occlusion), @@ -126,9 +134,12 @@ public partial class MeshPainter paintOcclusion = (s.Channels & PaintStampNet.FlagOcclusion) != 0; paintEmission = (s.Channels & PaintStampNet.FlagEmission) != 0; spray = (s.Channels & PaintStampNet.FlagSpray) != 0; + fur = (s.Channels & PaintStampNet.FlagFur) != 0; brushColor = s.BrushColor; emissionColor = s.EmissionColor; + furColor = s.FurColor; + furOpacity = s.FurTarget / 255f; metallic = s.Metallic / 255f; smoothness = s.Smoothness / 255f; occlusion = s.Occlusion / 255f; diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs index 0591cf7..cf3a600 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs @@ -17,15 +17,20 @@ public partial class MeshPainter // Paintings//metallic.png greyscale = canonical MSO.R // Paintings//occlusion.png greyscale = canonical MSO.G // Paintings//smoothness.png greyscale = canonical MSO.A + // Paintings//fur.png RGBA: RGB = strand colour, A = fur length (the ONE image whose + // alpha is meaningful - painted length data, not coverage) // Paintings//painting.json display name + map size + the non-baked material scalars // The packed mask map is split into three greyscale images so every file is viewable/editable. This - // also makes the round-trip byte-exact by construction: none of the stored images carry a meaningful - // alpha channel (colour maps are forced opaque, the mask channels become opaque greyscale), so there - // is no PNG alpha / premultiply ambiguity. Canonical MSO.B is always 0 (see MeshPaintPackMS.shader) + // also makes the round-trip byte-exact by construction: the colour maps are forced opaque and the mask + // channels become opaque greyscale, so there is no PNG alpha / premultiply ambiguity (fur.png is the + // exception - its alpha is length data, stored verbatim). Canonical MSO.B is always 0 (see MeshPaintPackMS.shader) // and is reconstructed on load. The downstream reseed path (ReseedCanonical) is unchanged, so pixel // fidelity equals the previous .paint format plus exact PNG transport. - private const int PaintMetaVersion = 5; // folder format: split PNG maps + JSON scalars + // v6 adds fur.png (RGBA: strand colour + painted length). v5 = split PNG maps + JSON scalars. Loads are + // not version-gated (only the resolution is), so pre-v6 saves without a fur.png load fine - the fur map + // stays seeded (bald). + private const int PaintMetaVersion = 6; private const string MetaFileName = "painting.json"; private const string AlbedoFileName = "albedo.png"; @@ -33,6 +38,7 @@ public partial class MeshPainter private const string MetallicFileName = "metallic.png"; private const string OcclusionFileName = "occlusion.png"; private const string SmoothnessFileName = "smoothness.png"; + private const string FurFileName = "fur.png"; /// /// JSON sidecar for a slot: display name, map resolution, and the per-player material scalars that are @@ -117,6 +123,7 @@ public partial class MeshPainter Color32[] albedo = ReadCanonical(MAP_ALBEDO); Color32[] mso = ReadCanonical(MAP_MSO); Color32[] emission = ReadCanonical(MAP_EM); + Color32[] fur = ReadCanonical(MAP_FUR); // Colour maps are stored opaque (the reseed path re-derives coverage/alpha anyway). The packed // mask map is split into one opaque greyscale image per channel: R=metallic, G=occlusion, @@ -126,6 +133,9 @@ public partial class MeshPainter WriteMapPng(Path.Combine(dir, MetallicFileName), mso, c => new Color32(c.r, c.r, c.r, 255)); WriteMapPng(Path.Combine(dir, OcclusionFileName), mso, c => new Color32(c.g, c.g, c.g, 255)); WriteMapPng(Path.Combine(dir, SmoothnessFileName), mso, c => new Color32(c.a, c.a, c.a, 255)); + // Fur is the exception: RGB = strand colour, A = painted length (0 = bald). Its alpha is real + // data, so store the full RGBA verbatim instead of forcing it opaque. + WriteMapPng(Path.Combine(dir, FurFileName), fur, c => c); // The painter is ready here (_setupValid), so the material-backed camo/height getters return the // live values rather than the not-ready 0. @@ -258,6 +268,11 @@ public partial class MeshPainter if (ReadMapPng(Path.Combine(dir, EmissionFileName), out Color32[] emission)) { result[MAP_EM] = emission; loaded++; } + // Fur carries a real alpha (painted length); loaded verbatim. Missing on pre-v6 saves -> the fur + // map stays seeded (bald), the correct backward-compatible default. + if (ReadMapPng(Path.Combine(dir, FurFileName), out Color32[] fur)) + { result[MAP_FUR] = fur; loaded++; } + // Recombine the split greyscale channels into the packed canonical layout // (R=metallic, G=occlusion, B=0, A=smoothness). B is always 0 (see MeshPaintPackMS.shader). if (ReadMapPng(Path.Combine(dir, MetallicFileName), out Color32[] metallic) && diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs index 3eddd64..87ca915 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs @@ -180,6 +180,13 @@ public partial class MeshPainter (byte)(Mathf.Clamp01(emission0.r) * 255f), (byte)(Mathf.Clamp01(emission0.g) * 255f), (byte)(Mathf.Clamp01(emission0.b) * 255f), 255)); + + // Fur: RGB = the current brush colour (harmless where bald), A = 0 so a fresh avatar has NO fur - + // the alpha is painted-in length, not coverage, so it is never island-stamped (see SeedGpuMap). + _seedCanonical[MAP_FUR] = FillCanonical(new Color32( + (byte)(Mathf.Clamp01(furColor.r) * 255f), + (byte)(Mathf.Clamp01(furColor.g) * 255f), + (byte)(Mathf.Clamp01(furColor.b) * 255f), 0)); } private Color32[] FillCanonical(Color32 c) diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Undo.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Undo.cs index eb295d2..dc3d4d6 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Undo.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Undo.cs @@ -109,6 +109,7 @@ public partial class MeshPainter if (paintAlbedo) _strokeDirtyMaps |= 1 << MAP_ALBEDO; if (paintMetallicSmoothness || paintOcclusion) _strokeDirtyMaps |= 1 << MAP_MSO; if (paintEmission) _strokeDirtyMaps |= 1 << MAP_EM; + if (fur) _strokeDirtyMaps |= 1 << MAP_FUR; } /// diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs index da23a34..3ee2e50 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs @@ -194,6 +194,23 @@ public partial class MeshPainter : MonoBehaviour "Above 1 makes the emission HDR/bloom-bright.")] public float emissionIntensity = 1f; + [Header("Brush - fur")] + [Tooltip("Paint the fur map: RGB = strand colour, A = fur presence/length (accumulates toward full " + + "length where you paint, 0 = bald). Drives a sibling FurShellRenderer via FurMapTexture; the " + + "base PlayerCamoLit material is untouched.")] + public bool fur = false; + + [Tooltip("Fur brush colour: RGB = strand colour, alpha = brush strength that eases the painted length " + + "toward furOpacity (higher = reaches the target faster). The map's own alpha is the painted " + + "length (0 = bald .. 1 = full length), read by the FurShell shader as effectiveLen.")] + public Color furColor = new Color(0.42f, 0.32f, 0.20f, 0.6f); + + [Tooltip("Target fur presence/length the brush works toward (0 = bald .. 1 = full length). The painted " + + "map alpha eases toward THIS value at the brush strength (furColor.a), so repeated strokes " + + "converge to the target and over-write it instead of always accumulating to full length.")] + [Range(0f, 1f)] + public float furOpacity = 1f; + [Header("Brush - shape")] [Tooltip("Brush half-extent in texels (= half the square side). Converted into a 3D size per hit.")] [Range(1, 256)] @@ -291,7 +308,12 @@ public partial class MeshPainter : MonoBehaviour // _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; + // Fur: RGB = strand colour, A = fur presence/length (0 = bald, 1 = full). A COLOUR-style map (sRGB), + // but its alpha is DATA (length), not coverage - so it is seeded to alpha 0, never coverage-stamped, + // saved with real alpha, and skips edge-padding dilation. Not bound to the base material (no _FurMap + // slot there); a sibling FurShellRenderer reads the live RT via FurMapTexture. + private const int MAP_FUR = 3; + private const int MAP_COUNT = 4; /// CPU backend per map: a Texture2D mirror + rasterization/dilation state. private class CpuMap @@ -333,6 +355,7 @@ public partial class MeshPainter : MonoBehaviour // albedo -> RGB color // MSO -> R = metallic, G = occlusion/height (0.5 = neutral: no relief, AO = 1), A = smoothness // emission -> RGB emission + // fur -> RGB colour, A = 0 (bald; length is painted in, never coverage-stamped) private Color32[][] _seedCanonical; // --- backends --- @@ -388,6 +411,7 @@ public partial class MeshPainter : MonoBehaviour private static readonly int BrushVId = Shader.PropertyToID("_BrushV"); private static readonly int BrushNormalId = Shader.PropertyToID("_BrushNormal"); private static readonly int MaskTexId = Shader.PropertyToID("_MaskTex"); + private static readonly int FurTargetId = Shader.PropertyToID("_FurTarget"); private static readonly int PadId = Shader.PropertyToID("_Pad"); private static readonly int PreviewOpacityId = Shader.PropertyToID("_PreviewOpacity"); private static readonly int PreviewModeId = Shader.PropertyToID("_PreviewMode"); @@ -1045,7 +1069,9 @@ public partial class MeshPainter : MonoBehaviour { case MAP_ALBEDO: return paintAlbedo; case MAP_MSO: return paintMetallicSmoothness || paintOcclusion; - default: return paintEmission; + case MAP_EM: return paintEmission; + case MAP_FUR: return fur; + default: return false; } } diff --git a/Assets/Scripts/Components/UI/PaintHud/PaintHud.cs b/Assets/Scripts/Components/UI/PaintHud/PaintHud.cs index 91eee0a..8c6da7f 100644 --- a/Assets/Scripts/Components/UI/PaintHud/PaintHud.cs +++ b/Assets/Scripts/Components/UI/PaintHud/PaintHud.cs @@ -34,14 +34,17 @@ public partial class PaintHud : MonoBehaviour private const string PanelEmission = "Panel_Emission"; private const string PanelBrushSize = "Panel_BrushSize"; private const string PanelPaintings = "Panel_Paintings"; + private const string PanelFur = "Panel_Fur"; private const string ToggleAlbedo = "Toggle_Albedo"; private const string ToggleMetallicSmoothness = "Toggle_MetallicSmoothness"; private const string ToggleOcclusion = "Toggle_Occlusion"; private const string ToggleEmission = "Toggle_Emission"; private const string ToggleSpray = "Toggle_Spray"; private const string TogglePipette = "Toggle_Pipette"; + private const string ToggleFur = "Toggle_Fur"; private const string PickerAlbedo = "Picker_Albedo"; private const string PickerEmission = "Picker_Emission"; + private const string PickerFur = "Picker_Fur"; private const string SliderAlbedoAlpha = "Slider_AlbedoAlpha"; private const string SliderEmissionAlpha = "Slider_EmissionAlpha"; private const string SliderMetallic = "Slider_Metallic"; @@ -62,6 +65,8 @@ public partial class PaintHud : MonoBehaviour private const string SliderOcclusionThreshold = "Slider_OcclusionThreshold"; private const string SliderNormalIntensity = "Slider_NormalIntensity"; private const string SliderParallaxIntensity = "Slider_ParallaxIntensity"; + private const string SliderFurAlpha = "Slider_FurAlpha"; + private const string SliderFurOpacity = "Slider_FurOpacity"; private const string MaskSelector = "MaskSelector"; private const string MaskSwatchPrefix = "MaskSwatch_"; @@ -75,7 +80,8 @@ public partial class PaintHud : MonoBehaviour PanelBrushSize, PanelSpray, PanelGeneral, - PanelPaintings + PanelPaintings, + PanelFur }; private GameObject _panel; @@ -209,12 +215,14 @@ public partial class PaintHud : MonoBehaviour WireToggle(panel, ToggleEmission, painter && painter.paintEmission, v => { if (painter) painter.paintEmission = v; }); WireToggle(panel, ToggleSpray, painter && painter.spray, v => { if (painter) painter.spray = v; }); WireToggle(panel, TogglePipette, painter && painter.pipette, v => { if (painter) painter.pipette = v; }); + WireToggle(panel, ToggleFur, painter && painter.fur, v => { if (painter) painter.fur = v; }); WirePanelFade(panel, PanelAlbedo, ToggleAlbedo); WirePanelFade(panel, PanelMetallicSmoothness, ToggleMetallicSmoothness); WirePanelFade(panel, PanelOcclusion, ToggleOcclusion); WirePanelFade(panel, PanelEmission, ToggleEmission); WirePanelFade(panel, PanelSpray, ToggleSpray); + WirePanelFade(panel, PanelFur, ToggleFur); // Picker drives only RGB (keeping the painter's current alpha); a standalone slider drives alpha. WirePicker(panel, PickerAlbedo, painter ? painter.brushColor : Color.red, @@ -255,6 +263,13 @@ public partial class PaintHud : MonoBehaviour WireSlider(panel, SliderNormalIntensity, painter ? painter.HeightNormalIntensity : 1.4f, v => { if (painter) painter.HeightNormalIntensity = v; }); WireSlider(panel, SliderParallaxIntensity, painter ? painter.ParallaxIntensity : 1f, v => { if (painter) painter.ParallaxIntensity = v; }); + WirePicker(panel, PickerFur, painter ? painter.furColor : Color.blue, + c => { if (painter) painter.furColor = WithRgb(painter.furColor, c); }); + WireSlider(panel, SliderFurAlpha, painter ? painter.furColor.a : .6f, + v => { if (painter) painter.furColor = WithAlpha(painter.furColor, v); }); + WireSlider(panel, SliderFurOpacity, painter ? painter.furOpacity : 1f, + v => { if (painter) painter.furOpacity = v; }); + foreach (var name in DraggablePanels) MakeDraggable(panel, name); } @@ -276,11 +291,15 @@ public partial class PaintHud : MonoBehaviour SetToggle(panel, ToggleEmission, painter.paintEmission); SetToggle(panel, ToggleSpray, painter.spray); SetToggle(panel, TogglePipette, painter.pipette); + SetToggle(panel, ToggleFur, painter.fur); SetPicker(panel, PickerAlbedo, painter.brushColor); SetPicker(panel, PickerEmission, painter.emissionColor); + SetPicker(panel, PickerFur, painter.furColor); SetSlider(panel, SliderAlbedoAlpha, painter.brushColor.a); SetSlider(panel, SliderEmissionAlpha, painter.emissionColor.a); + SetSlider(panel, SliderFurAlpha, painter.furColor.a); + SetSlider(panel, SliderFurOpacity, painter.furOpacity); SetSlider(panel, SliderMetallic, painter.metallic); SetSlider(panel, SliderSmoothness, painter.smoothness); diff --git a/Assets/Shaders/MeshPaintBrush.shader b/Assets/Shaders/MeshPaintBrush.shader index 348f379..2c8cc73 100644 --- a/Assets/Shaders/MeshPaintBrush.shader +++ b/Assets/Shaders/MeshPaintBrush.shader @@ -139,6 +139,138 @@ Shader "Hidden/MeshPaintBrush" fixed4 frag (v2f i) : SV_Target { return fixed4(0, 0, 0, 1); } ENDCG } + + // Pass 2 - fur fade: RGB source-over toward the strand colour AND alpha faded by (1-op), + // where op = luma(mask.rgb) * mask.a * _BrushColor.a is the per-texel deposit strength. + // Paired with pass 3 (deposit): together they make alpha ease toward a target instead of + // accumulating to 1, so the fur map's alpha (strand length) is an over-writable value, not + // a coverage marker. Both fur passes clip the exact same texels, so the (1-op) fade and the + // op*target add line up per texel. Writes into the fur display RT in place (no ping-pong). + Pass + { + ZTest Always + ZWrite Off + Cull Off + ColorMask RGBA + // RGB: op*strand + (1-op)*dst (source-over). A: 0*src + (1-op)*dst (multiply by 1-op). + Blend SrcAlpha OneMinusSrcAlpha, Zero OneMinusSrcAlpha + + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + #include "UnityCG.cginc" + + float4 _BrushColor; + float3 _BrushCenter; + float3 _BrushU; + float3 _BrushV; + float3 _BrushNormal; + float _BrushRadius; + sampler2D _MaskTex; + + struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; + struct v2f { float4 pos : SV_POSITION; float3 local : TEXCOORD0; }; + + v2f vert (appdata v) + { + v2f o; + float2 p = v.uv * 2.0 - 1.0; + #if UNITY_UV_STARTS_AT_TOP + p.y = -p.y; + #endif + o.pos = float4(p, 0.0, 1.0); + o.local = v.vertex.xyz; + return o; + } + + // Per-texel deposit strength (clips texels outside the square stamp box). + float BrushOp (v2f i) + { + float3 d = i.local - _BrushCenter; + float du = dot(d, _BrushU); + float dv = dot(d, _BrushV); + float dn = dot(d, _BrushNormal); + clip(_BrushRadius - abs(du)); + clip(_BrushRadius - abs(dv)); + clip(_BrushRadius - abs(dn)); + float2 muv = float2(du, dv) / _BrushRadius * 0.5 + 0.5; + fixed4 m = tex2D(_MaskTex, muv); + float luma = dot(m.rgb, fixed3(0.299, 0.587, 0.114)); + return luma * m.a * _BrushColor.a; + } + + fixed4 frag (v2f i) : SV_Target + { + float op = BrushOp(i); + clip(op - 1e-4); + return fixed4(_BrushColor.rgb, op); + } + ENDCG + } + + // Pass 3 - fur deposit: alpha += op * _FurTarget (RGB masked out). Run AFTER pass 2's fade, + // so the alpha channel becomes (1-op)*dst + op*target = lerp(dst, target, op). _FurTarget is + // the desired strand length; _BrushColor.a is the brush strength that eases toward it. + Pass + { + ZTest Always + ZWrite Off + Cull Off + ColorMask A + Blend One One + + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + #include "UnityCG.cginc" + + float4 _BrushColor; + float _FurTarget; + float3 _BrushCenter; + float3 _BrushU; + float3 _BrushV; + float3 _BrushNormal; + float _BrushRadius; + sampler2D _MaskTex; + + struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; + struct v2f { float4 pos : SV_POSITION; float3 local : TEXCOORD0; }; + + v2f vert (appdata v) + { + v2f o; + float2 p = v.uv * 2.0 - 1.0; + #if UNITY_UV_STARTS_AT_TOP + p.y = -p.y; + #endif + o.pos = float4(p, 0.0, 1.0); + o.local = v.vertex.xyz; + return o; + } + + float BrushOp (v2f i) + { + float3 d = i.local - _BrushCenter; + float du = dot(d, _BrushU); + float dv = dot(d, _BrushV); + float dn = dot(d, _BrushNormal); + clip(_BrushRadius - abs(du)); + clip(_BrushRadius - abs(dv)); + clip(_BrushRadius - abs(dn)); + float2 muv = float2(du, dv) / _BrushRadius * 0.5 + 0.5; + fixed4 m = tex2D(_MaskTex, muv); + float luma = dot(m.rgb, fixed3(0.299, 0.587, 0.114)); + return luma * m.a * _BrushColor.a; + } + + fixed4 frag (v2f i) : SV_Target + { + float op = BrushOp(i); + clip(op - 1e-4); + return fixed4(0, 0, 0, op * _FurTarget); + } + ENDCG + } } Fallback Off }