diff --git a/Assets/Scripts/Components/Controller/FirstPersonController.cs b/Assets/Scripts/Components/Controller/FirstPersonController.cs index 7ec7d27..7bd5367 100644 --- a/Assets/Scripts/Components/Controller/FirstPersonController.cs +++ b/Assets/Scripts/Components/Controller/FirstPersonController.cs @@ -1,5 +1,6 @@ using UnityEngine; using Unity.Cinemachine; +using Chameleon.Components; /// /// First-person camera. Drives its OWN dedicated : each frame it snaps @@ -24,6 +25,9 @@ public class FirstPersonController : MonoBehaviour [Tooltip("Renderers hidden while first-person is active (so you don't see your own body). Empty = the player's SkinnedMeshRenderers, auto-resolved.")] public Renderer[] hiddenRenderers; + [Tooltip("Fur renderers disabled while first-person is active (they draw via GPU instancing, not a Renderer, so they need their own toggle). Empty = the player's FurShellRenderers, auto-resolved.")] + public FurShellRenderer[] hiddenFurRenderers; + [Header("Look")] [Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")] public float horizontalSensitivity = 0.2f; @@ -139,9 +143,15 @@ public class FirstPersonController : MonoBehaviour void SetMeshHidden(bool hidden) { - if (hiddenRenderers == null) return; - foreach (var r in hiddenRenderers) - if (r) r.enabled = !hidden; + if (hiddenRenderers != null) + foreach (var r in hiddenRenderers) + if (r) r.enabled = !hidden; + + // The fur draws from RenderPipelineManager.beginCameraRendering, not through a Renderer, so hiding + // the SkinnedMeshRenderer above doesn't stop it - disabling the component unsubscribes that hook. + if (hiddenFurRenderers != null) + foreach (var f in hiddenFurRenderers) + if (f) f.enabled = !hidden; } /// @@ -157,6 +167,7 @@ public class FirstPersonController : MonoBehaviour Transform t = p.transform.Find("FirstPersonTarget"); if (t) cameraTarget = t; hiddenRenderers = p.GetComponentsInChildren(true); + hiddenFurRenderers = p.GetComponentsInChildren(true); _resolved = true; } @@ -180,5 +191,8 @@ public class FirstPersonController : MonoBehaviour if (hiddenRenderers == null || hiddenRenderers.Length == 0) hiddenRenderers = player.GetComponentsInChildren(true); + + if (hiddenFurRenderers == null || hiddenFurRenderers.Length == 0) + hiddenFurRenderers = player.GetComponentsInChildren(true); } } diff --git a/Assets/Scripts/Components/FurShellRenderer.cs b/Assets/Scripts/Components/FurShellRenderer.cs index 88bf33b..8c8d344 100644 --- a/Assets/Scripts/Components/FurShellRenderer.cs +++ b/Assets/Scripts/Components/FurShellRenderer.cs @@ -43,23 +43,31 @@ namespace Chameleon.Components [SerializeField, Range(0f, 5f)] float _silhouetteStretch = 2f; [Header("Shading")] - // Optional per-instance albedo override; leave null to keep the material's own texture. + // Optional albedo (strand-colour) override for the standalone prototype; a sibling MeshPainter's + // live albedo RT overrides this when present, so the fur wears the painted camo per texel. [SerializeField] Texture _baseMap; - [SerializeField] Color _rootColor = new Color(0.42f, 0.32f, 0.20f, 1f); - [SerializeField] Color _tipColor = new Color(0.62f, 0.52f, 0.36f, 1f); - [SerializeField, Range(0f, 1f)] float _rootAO = 0.45f; + // Overall tint multiplied onto the PAINTED fur colour; white leaves the painted colour untouched. + [SerializeField] Color _furTint = Color.white; + // Root/tip are two shades of the same painted colour: roots a touch darker, tips a touch lighter. + [SerializeField, Range(0f, 1f)] float _rootAO = 0.3f; + [SerializeField, Range(0f, 1f)] float _tipShade = 0.2f; [SerializeField, Range(0.5f, 8f)] float _rimPower = 3f; [SerializeField, Range(0f, 2f)] float _rimStrength = 0.4f; [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. A - // sibling MeshPainter's live fur RT overrides this when present, so a painted avatar grows fur in - // real time (see PushProperties). + // Fur map: R = fur length (0 = bald, 1 = full length); G/B/A unused. The strand colour comes from + // the albedo map, not here. Leave 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. [SerializeField, Range(0f, 1f)] float _furThreshold = 0f; + // Low painted alpha => low fur opacity, so a lightly-painted region fades in as faint short fuzz + // instead of hard-cutting to full-opacity fur. Larger = softer/longer fade. + [SerializeField, Range(0.01f, 1f)] float _furSoftness = 0.5f; + // Per-strand edge anti-aliasing (soft strand silhouette + tip instead of hard clipped discs). + [SerializeField, Range(0f, 1f)] float _strandSoftness = 0.3f; // Opaque base shell. On for the standalone prototype (full coverage); turn OFF on the painted // avatar so the camo body shows between strands instead of being sealed under a solid root. [SerializeField] bool _solidRoot = true; @@ -89,12 +97,14 @@ namespace Chameleon.Components static readonly int SilhouetteStretchId = Shader.PropertyToID("_SilhouetteStretch"); static readonly int BaseMapId = Shader.PropertyToID("_BaseMap"); static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); - static readonly int TipColorId = Shader.PropertyToID("_TipColor"); static readonly int RootAOId = Shader.PropertyToID("_RootAO"); + static readonly int TipShadeId = Shader.PropertyToID("_TipShade"); static readonly int RimPowerId = Shader.PropertyToID("_RimPower"); static readonly int RimStrengthId = Shader.PropertyToID("_RimStrength"); static readonly int FurMapId = Shader.PropertyToID("_FurMap"); static readonly int FurThresholdId = Shader.PropertyToID("_FurThreshold"); + static readonly int FurSoftnessId = Shader.PropertyToID("_FurSoftness"); + static readonly int StrandSoftnessId = Shader.PropertyToID("_StrandSoftness"); static readonly int SolidRootId = Shader.PropertyToID("_SolidRoot"); static readonly int WindStrengthId = Shader.PropertyToID("_WindStrength"); static readonly int WindSpeedId = Shader.PropertyToID("_WindSpeed"); @@ -211,16 +221,22 @@ namespace Chameleon.Components _mpb.SetFloat(DirRandomnessId, _directionRandomness); _mpb.SetFloat(SilhouetteStretchId, _silhouetteStretch); - if (_baseMap) _mpb.SetTexture(BaseMapId, _baseMap); - // Prefer the sibling painter's live fur RT (painted colour + length) over the serialized map. + // Strand colour = the painted albedo: prefer the sibling painter's live albedo RT over the + // serialized override, so the fur wears the camo per texel. Re-fetched each frame - the RT + // reference ping-pongs on the dilation swap. + Texture albedoTex = _painter && _painter.AlbedoMapTexture ? _painter.AlbedoMapTexture : _baseMap; + if (albedoTex) _mpb.SetTexture(BaseMapId, albedoTex); + // Prefer the sibling painter's live fur RT (R = painted 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.SetColor(BaseColorId, ToRenderColor(_furTint)); _mpb.SetFloat(RootAOId, _rootAO); + _mpb.SetFloat(TipShadeId, _tipShade); _mpb.SetFloat(RimPowerId, _rimPower); _mpb.SetFloat(RimStrengthId, _rimStrength); _mpb.SetFloat(FurThresholdId, _furThreshold); + _mpb.SetFloat(FurSoftnessId, _furSoftness); + _mpb.SetFloat(StrandSoftnessId, _strandSoftness); _mpb.SetFloat(SolidRootId, _solidRoot ? 1f : 0f); _mpb.SetFloat(WindStrengthId, _windStrength); @@ -253,12 +269,14 @@ namespace Chameleon.Components if (mat.HasProperty(CurvatureId)) _curvature = mat.GetFloat(CurvatureId); if (mat.HasProperty(DirRandomnessId)) _directionRandomness = mat.GetFloat(DirRandomnessId); if (mat.HasProperty(SilhouetteStretchId)) _silhouetteStretch = mat.GetFloat(SilhouetteStretchId); - if (mat.HasProperty(BaseColorId)) _rootColor = FromRenderColor(mat.GetColor(BaseColorId)); - if (mat.HasProperty(TipColorId)) _tipColor = FromRenderColor(mat.GetColor(TipColorId)); + if (mat.HasProperty(BaseColorId)) _furTint = FromRenderColor(mat.GetColor(BaseColorId)); if (mat.HasProperty(RootAOId)) _rootAO = mat.GetFloat(RootAOId); + if (mat.HasProperty(TipShadeId)) _tipShade = mat.GetFloat(TipShadeId); if (mat.HasProperty(RimPowerId)) _rimPower = mat.GetFloat(RimPowerId); if (mat.HasProperty(RimStrengthId)) _rimStrength = mat.GetFloat(RimStrengthId); if (mat.HasProperty(FurThresholdId)) _furThreshold = mat.GetFloat(FurThresholdId); + if (mat.HasProperty(FurSoftnessId)) _furSoftness = mat.GetFloat(FurSoftnessId); + if (mat.HasProperty(StrandSoftnessId)) _strandSoftness = mat.GetFloat(StrandSoftnessId); if (mat.HasProperty(SolidRootId)) _solidRoot = mat.GetFloat(SolidRootId) > 0.5f; if (mat.HasProperty(WindStrengthId)) _windStrength = mat.GetFloat(WindStrengthId); if (mat.HasProperty(WindSpeedId)) _windSpeed = mat.GetFloat(WindSpeedId); diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs index 00a822e..da5cd9a 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Backend.cs @@ -88,7 +88,9 @@ public partial class MeshPainter return (Color32[])_cpu[mi].pixels.Clone(); } - private static bool MapIsLinear(int mi) => mi == MAP_MSO; + // MSO and fur are DATA maps (raw values, no sRGB): MSO packs metallic/occlusion/smoothness, fur's R is + // painted strand length. Storing them linearly keeps the values undistorted for the shaders that read them. + private static bool MapIsLinear(int mi) => mi == MAP_MSO || mi == MAP_FUR; // Material slots per map, as pre-hashed IDs (this runs per stroke end - no per-call allocations). // MSO: one mask map drives three URP slots: _MetallicGlossMap (R/A) + _OcclusionMap (G) + @@ -114,7 +116,7 @@ public partial class MeshPainter } /// - /// The live fur map (RGB = strand colour, A = painted length), for a sibling FurShellRenderer to sample + /// The live fur map (R = painted strand length, linear data), 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. /// @@ -127,4 +129,20 @@ public partial class MeshPainter return _cpu != null ? _cpu[MAP_FUR]?.tex : null; } } + + /// + /// The live albedo map (the painted camo), for a sibling FurShellRenderer to sample as the fur strand + /// colour so the fur wears the camo per texel. GPU backend: the current display RT (ping-pongs on the + /// dilation swap, so callers must re-fetch each frame); CPU backend: the Texture2D mirror. Null until the + /// backend is set up. + /// + public Texture AlbedoMapTexture + { + get + { + if (!_setupValid) return null; + if (_gpuActive) return _gpu != null ? _gpu[MAP_ALBEDO]?.display : null; + return _cpu != null ? _cpu[MAP_ALBEDO]?.tex : null; + } + } } diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs index 3d9aea4..f63899d 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Cpu.cs @@ -36,8 +36,8 @@ public partial class MeshPainter { var src = ResolveSeed(mi, canonical); var px = (Color32[])src.Clone(); - // Albedo / emission are opaque. The mask map keeps alpha = smoothness; fur keeps alpha = painted - // length (0 = bald), which is data, not coverage. + // Albedo / emission are opaque. The mask map keeps alpha = smoothness; fur is a data map (R = + // length), so leave its channels as seeded rather than forcing alpha opaque. if (mi != MAP_MSO && mi != MAP_FUR) for (int i = 0; i < px.Length; i++) px[i].a = 255; return px; @@ -102,6 +102,8 @@ public partial class MeshPainter /// private void PaintStamp3D(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n) { + // Fur is gated by its own toggle (see IsChannelActive), reusing the brush's alpha as the length + // deposit weight; the strand colour still comes from the albedo map at render time. bool aAlb = paintAlbedo, aMS = paintMetallicSmoothness, aEM = paintEmission, aAO = paintOcclusion, aFur = fur; if (!aAlb && !aMS && !aEM && !aAO && !aFur) return; @@ -129,13 +131,9 @@ 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; + // Fur target: only R = length, eased toward furLength*255 at the brush's alpha (albOpMul) as the + // deposit weight - a linear data lerp (no sRGB), mirroring the GPU source-over on R. + float furTargetR = Mathf.Clamp01(furLength) * 255f; CpuMap furm = _cpu[MAP_FUR]; float invR = 1f / radiusLocal; @@ -258,20 +256,14 @@ public partial class MeshPainter if (aFur) { - float op = maskOp * furOpMul; + // Length (R) eases toward furLength at the brush weight: lerp(dst.R, target, op) - + // over-writable, not accumulating. Linear data, so no sRGB blend. G/B/A stay 0. + float op = maskOp * albOpMul; 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); + byte nr = (byte)(dst.r * (1f - op) + furTargetR * op + 0.5f); + furm.pixels[idx] = new Color32(nr, 0, 0, 0); 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 8644302..7b1ff18 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Gpu.cs @@ -14,10 +14,7 @@ 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; + // MeshPaintBrush.shader pass indices: 0 = source-over brush, 1 = coverage seed. public Shader brushShader; // optional; else Shader.Find("Hidden/MeshPaintBrush") public Shader dilateShader; // optional; else Shader.Find("Hidden/MeshPaintDilate") @@ -91,10 +88,10 @@ public partial class MeshPainter } 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); + // Fur R IS data (strand length), not coverage. Upload the seeded/loaded R verbatim (linear + // data map) and skip the coverage stamp, so unpainted texels stay bald (R 0) instead of being + // marked full length. (Dilation is also skipped for fur in EndGpuStroke.) + UploadColorsToRT((Color32[])src.Clone(), gm.display, linear: true); } else { @@ -169,15 +166,14 @@ public partial class MeshPainter return rt; } - /// 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). 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) + /// 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). Fur carries no coverage (its length lives in R), so a + /// zero alpha is correct there too. + private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool linear) { var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear); - if (!preserveAlpha) - for (int i = 0; i < colors.Length; i++) colors[i].a = 0; + for (int i = 0; i < colors.Length; i++) colors[i].a = 0; tmp.SetPixels32(colors); tmp.Apply(false); @@ -267,16 +263,15 @@ public partial class MeshPainter } 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(); + // Fur stores ONLY strand length in R (the colour comes from the albedo map at render). Gated by + // its own toggle: the length eases toward furLength with the brush's alpha as the deposit + // weight. Reuse the source-over pass (0) on R only, so R = lerp(dst.R, furLength, op) with + // op = luma*mask.a*brushColor.a. SetVector + EncodeData (not SetColor) keeps furLength as raw + // linear data - the fur RT is a linear data map the shell samples directly for length. + float fl = EncodeData(furLength); + _brushMat.SetVector(BrushColorId, new Vector4(fl, fl, fl, Mathf.Clamp01(brushColor.a))); + _brushMat.SetFloat(ColorWriteMaskId, 8f); // R only (bits: R=8, G=4, B=2, A=1) + DrawStamp(); } else { diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs index 62a6f26..b97b53a 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Net.cs @@ -18,7 +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; + public const byte FlagFur = 1 << 5; // fur has its own toggle -> replay must carry it explicitly // Placement (object-local space). public Vector3 P; // hit point @@ -29,10 +29,9 @@ public struct PaintStampNet : INetworkSerializable public int BrushIndex; // active brush mask index (resolved-mask painting) public int SpraySeed; // RNG seed so a spray stamp scatters identically on every peer - public Color32 BrushColor; // albedo color (alpha = opacity) + public Color32 BrushColor; // albedo color (alpha = opacity; also the fur length deposit weight) 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 FurLength; // 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 @@ -48,8 +47,7 @@ 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 FurLength); s.SerializeValue(ref Metallic); s.SerializeValue(ref Smoothness); s.SerializeValue(ref Occlusion); @@ -97,8 +95,7 @@ public partial class MeshPainter SpraySeed = _lastSpraySeed, BrushColor = brushColor, EmissionColor = emissionColor, - FurColor = furColor, - FurTarget = ToByte01(furOpacity), + FurLength = ToByte01(furLength), Metallic = ToByte01(metallic), Smoothness = ToByte01(smoothness), Occlusion = ToByte01(occlusion), @@ -138,8 +135,7 @@ public partial class MeshPainter brushColor = s.BrushColor; emissionColor = s.EmissionColor; - furColor = s.FurColor; - furOpacity = s.FurTarget / 255f; + furLength = s.FurLength / 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 cf3a600..55956a6 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Persistence.cs @@ -17,19 +17,19 @@ 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//fur.png R = fur length (painted length data, not coverage); G/B/A unused // 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: 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. + // channels become opaque greyscale, so there is no PNG alpha / premultiply ambiguity. fur.png is a data + // map (R = length), 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. - // 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). + // v6 adds fur.png. v5 = split PNG maps + JSON scalars. Loads are not version-gated (only the resolution + // is), so saves without a fur.png load fine - the fur map stays seeded (bald). NOTE: the fur.png layout + // changed to R = length (was RGB colour + A length); pre-change fur.png files load stale length data and + // should be repainted. private const int PaintMetaVersion = 6; private const string MetaFileName = "painting.json"; @@ -133,8 +133,8 @@ 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. + // Fur is data, not colour: R = painted length (0 = bald), G/B/A unused. Store verbatim so the + // length round-trips exactly instead of being forced opaque. WriteMapPng(Path.Combine(dir, FurFileName), fur, c => c); // The painter is ready here (_setupValid), so the material-backed camo/height getters return the @@ -268,8 +268,8 @@ 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. + // Fur carries painted length in R; loaded verbatim. Missing on saves without a fur map -> it stays + // seeded (bald), the correct backward-compatible default. if (ReadMapPng(Path.Combine(dir, FurFileName), out Color32[] fur)) { result[MAP_FUR] = fur; loaded++; } diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Pick.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Pick.cs index f6d4e7f..9ea6e28 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Pick.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Pick.cs @@ -84,6 +84,9 @@ public partial class MeshPainter paintMetallicSmoothness = true; paintOcclusion = pickedHeight; paintEmission = true; + // Fur toggle always turns on with a pick; the length is only adopted off our own paint mesh + // (pickedHeight), so a foreign-surface pick enables fur but leaves furLength at its current value. + fur = true; pipette = false; @@ -125,7 +128,8 @@ public partial class MeshPainter Color32[] alb = ReadCanonical(MAP_ALBEDO); Color32[] mso = ReadCanonical(MAP_MSO); Color32[] em = ReadCanonical(MAP_EM); - if (idx < 0 || idx >= alb.Length || idx >= mso.Length || idx >= em.Length) return false; + Color32[] fur = ReadCanonical(MAP_FUR); + if (idx < 0 || idx >= alb.Length || idx >= mso.Length || idx >= em.Length || idx >= fur.Length) return false; Color32 a = alb[idx]; Color32 m = mso[idx]; @@ -138,6 +142,7 @@ public partial class MeshPainter smoothness = m.a / 255f; occlusion = m.g / 255f; // the painted HEIGHT - adopted only off our own mesh (FinalizePick) emissionColor = new Color(e.r / 255f, e.g / 255f, e.b / 255f, emissionColor.a); + furLength = fur[idx].r / 255f; // fur map carries only R = painted length (linear data) return true; } diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs index 87ca915..cd98e97 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Setup.cs @@ -181,12 +181,9 @@ public partial class MeshPainter (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)); + // Fur: R = 0 so a fresh avatar has NO fur (R is painted-in length, not coverage, so it is never + // island-stamped - see SeedGpuMap). G/B/A unused. The strand colour comes from the albedo map. + _seedCanonical[MAP_FUR] = FillCanonical(new Color32(0, 0, 0, 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 dc3d4d6..9dba708 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.Undo.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.Undo.cs @@ -109,7 +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; + if (fur) _strokeDirtyMaps |= 1 << MAP_FUR; // fur has its own toggle gate } /// diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs index 3ee2e50..17683f9 100644 --- a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs +++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs @@ -195,21 +195,19 @@ public partial class MeshPainter : MonoBehaviour 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.")] + [Tooltip("Enables fur painting. When on, each stroke eases the fur map's length (channel R) toward " + + "furLength using the brush's alpha as the deposit weight; when off the fur map is left untouched. " + + "The strand COLOUR comes from the albedo map, so the fur wears the painted camo. The pipette turns " + + "this on when it samples fur length.")] 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.")] + [Tooltip("Target fur length the stroke eases toward (0 = bald .. 1 = full length). Painting over-writes the " + + "map's length toward THIS value (raise it to grow fur, drop to 0 to shave it), weighted by the " + + "brush's alpha. Drives a sibling FurShellRenderer via FurMapTexture; the base PlayerCamoLit " + + "material is untouched.")] [Range(0f, 1f)] - public float furOpacity = 1f; + [UnityEngine.Serialization.FormerlySerializedAs("furOpacity")] + public float furLength = 0.5f; [Header("Brush - shape")] [Tooltip("Brush half-extent in texels (= half the square side). Converted into a 3D size per hit.")] @@ -308,10 +306,10 @@ 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) - // 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. + // Fur: R = fur length (0 = bald, 1 = full); G/B/A unused. A DATA map (linear, like MSO): seeded to 0, + // never coverage-stamped, and skips edge-padding dilation. Length is painted whenever the fur toggle is + // on (own gate); the strand colour comes from the albedo map. Not bound to the base material; a sibling + // FurShellRenderer reads the live RTs via FurMapTexture (length) + AlbedoMapTexture (colour). private const int MAP_FUR = 3; private const int MAP_COUNT = 4; @@ -355,7 +353,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) + // fur -> R = length (0 = bald; painted in, never coverage-stamped), G/B/A unused private Color32[][] _seedCanonical; // --- backends --- @@ -411,7 +409,6 @@ 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"); @@ -1070,7 +1067,7 @@ public partial class MeshPainter : MonoBehaviour case MAP_ALBEDO: return paintAlbedo; case MAP_MSO: return paintMetallicSmoothness || paintOcclusion; case MAP_EM: return paintEmission; - case MAP_FUR: return fur; + case MAP_FUR: return fur; // fur has its own toggle gate (strand colour still comes from the albedo map) default: return false; } } diff --git a/Assets/Scripts/Components/UI/PaintHud/PaintHud.Persistence.cs b/Assets/Scripts/Components/UI/PaintHud/PaintHud.Persistence.cs index 2b17307..2fda77f 100644 --- a/Assets/Scripts/Components/UI/PaintHud/PaintHud.Persistence.cs +++ b/Assets/Scripts/Components/UI/PaintHud/PaintHud.Persistence.cs @@ -20,7 +20,9 @@ public partial class PaintHud "defaults every session.")] public bool persistHud = true; - private const int HudFileVersion = 1; + // v2: HudValues gained fur (toggle) + furLength. Bumping discards pre-v2 saves so the new fur fields + // fall back to the painter's prefab defaults instead of deserializing as 0/false. + private const int HudFileVersion = 2; // Auto-save bookkeeping. Brush edits are debounced (sliders fire every frame while dragged): the due // time is pushed forward and written once they settle. @@ -83,11 +85,12 @@ public partial class PaintHud [Serializable] private class HudValues { - public bool paintAlbedo, paintMetallicSmoothness, paintOcclusion, paintEmission, spray; + public bool paintAlbedo, paintMetallicSmoothness, paintOcclusion, paintEmission, spray, fur; public Color brushColor; public float metallic, smoothness, metallicSmoothnessOpacity, occlusion, occlusionOpacity; public Color emissionColor; public float emissionIntensity; + public float furLength; public int brushSize; public int activeBrushIndex; public int sprayDots; @@ -275,6 +278,8 @@ public partial class PaintHud && p.paintOcclusion == v.paintOcclusion && p.paintEmission == v.paintEmission && p.spray == v.spray + && p.fur == v.fur + && p.furLength == v.furLength && p.brushColor == v.brushColor && p.metallic == v.metallic && p.smoothness == v.smoothness @@ -335,6 +340,8 @@ public partial class PaintHud v.paintOcclusion = from.paintOcclusion; v.paintEmission = from.paintEmission; v.spray = from.spray; + v.fur = from.fur; + v.furLength = from.furLength; v.brushColor = from.brushColor; v.metallic = from.metallic; v.smoothness = from.smoothness; @@ -371,6 +378,8 @@ public partial class PaintHud to.paintOcclusion = v.paintOcclusion; to.paintEmission = v.paintEmission; to.spray = v.spray; + to.fur = v.fur; + to.furLength = v.furLength; to.brushColor = v.brushColor; to.metallic = v.metallic; to.smoothness = v.smoothness; diff --git a/Assets/Scripts/Components/UI/PaintHud/PaintHud.cs b/Assets/Scripts/Components/UI/PaintHud/PaintHud.cs index 8c6da7f..c56d0d3 100644 --- a/Assets/Scripts/Components/UI/PaintHud/PaintHud.cs +++ b/Assets/Scripts/Components/UI/PaintHud/PaintHud.cs @@ -44,7 +44,6 @@ public partial class PaintHud : MonoBehaviour 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"; @@ -65,8 +64,7 @@ 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 SliderFurLength = "Slider_FurLength"; private const string MaskSelector = "MaskSelector"; private const string MaskSwatchPrefix = "MaskSwatch_"; @@ -263,12 +261,10 @@ 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; }); + // Fur has its own Toggle_Fur gate (wired above); the colour comes from the albedo map and the deposit + // weight from the brush alpha, so the only extra fur control is the target length. + WireSlider(panel, SliderFurLength, painter ? painter.furLength : 0.5f, + v => { if (painter) painter.furLength = v; }); foreach (var name in DraggablePanels) MakeDraggable(panel, name); @@ -295,11 +291,9 @@ public partial class PaintHud : MonoBehaviour 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, SliderFurLength, painter.furLength); SetSlider(panel, SliderMetallic, painter.metallic); SetSlider(panel, SliderSmoothness, painter.smoothness); diff --git a/Assets/Shaders/FurShell.shader b/Assets/Shaders/FurShell.shader index 11f2a54..e76ab6c 100644 --- a/Assets/Shaders/FurShell.shader +++ b/Assets/Shaders/FurShell.shader @@ -28,18 +28,28 @@ Shader "Custom/FurShell" _SilhouetteStretch("Silhouette Strand Stretch", Range(0.0, 5.0)) = 2.0 [Header(Fur Shading)] - [MainTexture] _BaseMap("Albedo", 2D) = "white" {} - [MainColor] _BaseColor("Root Tint", Color) = (0.42, 0.32, 0.20, 1) - _TipColor("Tip Tint", Color) = (0.62, 0.52, 0.36, 1) - _RootAO("Root Darkening (fake AO)", Range(0.0, 1.0)) = 0.45 + // The strand colour: the painted ALBEDO map (the camo under the fur). FurShellRenderer binds the + // sibling painter's live albedo RT here, so each strand wears the colour beneath it. + [MainTexture] _BaseMap("Albedo (strand colour)", 2D) = "white" {} + // Overall tint multiplied onto the painted fur colour; white leaves the painted colour untouched. + [MainColor] _BaseColor("Fur Tint", Color) = (1, 1, 1, 1) + // Root/tip are two shades of the SAME painted colour: roots a touch darker, tips a touch lighter. + _RootAO("Root Darkening", Range(0.0, 1.0)) = 0.3 + _TipShade("Tip Lightening", Range(0.0, 1.0)) = 0.2 _RimPower("Rim / Backlight Power", Range(0.5, 8.0)) = 3.0 _RimStrength("Rim / Backlight Strength", Range(0.0, 2.0)) = 0.4 [Header(Fur Map)] - // RGB = per-texel strand colour, A = fur length (0 = bald here, 1 = full length). White default - // means "full-length fur everywhere" so the standalone prototype behaves as before with no map. - _FurMap("Fur Map (RGB color, A length)", 2D) = "white" {} + // R = fur length (0 = bald here, 1 = full length); G/B/A unused/reserved. The strand COLOUR now + // comes from the albedo map (_BaseMap), so fur wears the painted camo. White default = full-length + // fur everywhere, so the standalone prototype (no map) is unchanged. + _FurMap("Fur Map (R = length)", 2D) = "white" {} _FurThreshold("Fur Cutoff (alpha)", Range(0.0, 1.0)) = 0.0 + // Low painted alpha => low opacity, so a lightly-painted texel fades in as faint short fuzz + // instead of full-opacity fur with a hard border. Larger = softer/longer fade. + _FurSoftness("Edge Softness (length fade)", Range(0.01, 1.0)) = 0.5 + // Per-strand anti-aliasing: soft strand silhouette + tip instead of hard alpha-clipped discs. + _StrandSoftness("Strand Edge Softness", Range(0.0, 1.0)) = 0.3 // Opaque base shell. On = solid root (full-coverage prototype); Off = the root also clips to the // map so the painted body shows between strands (avatar integration). [ToggleUI] _SolidRoot("Solid Root", Float) = 1 @@ -55,9 +65,9 @@ Shader "Custom/FurShell" { Tags { - "RenderType" = "Opaque" + "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" - "Queue" = "Geometry" + "Queue" = "Transparent" } LOD 200 @@ -67,8 +77,17 @@ Shader "Custom/FurShell" Tags { "LightMode" = "UniversalForward" } Cull Back - ZWrite On + Blend SrcAlpha OneMinusSrcAlpha + ZWrite Off ZTest LEqual + // Depth bias toward the camera so the fur wins the depth test against the body. The fur + // extrudes a CPU-baked mesh while the body uses GPU skinning, so their surfaces are not + // bit-identical: where the body pokes a hair in front of a shell layer, that shell fragment + // fails ZTest (ZWrite Off => no depth write to win the tie) and the body flickers through as + // white speckles. Offset shifts the fragment's tested depth (respected even with ZWrite Off), + // slope-scaled + a constant unit, so the fur reliably passes without moving geometry (no + // visible root gap). Small enough not to punch through objects genuinely in front. + Offset -1, -1 HLSLPROGRAM #pragma target 3.5 @@ -87,7 +106,6 @@ Shader "Custom/FurShell" CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; - half4 _TipColor; float _ShellCount; float _ShellLength; float _Density; @@ -98,9 +116,12 @@ Shader "Custom/FurShell" float _DirRandomness; float _SilhouetteStretch; half _RootAO; + half _TipShade; half _RimPower; half _RimStrength; float _FurThreshold; + float _FurSoftness; + float _StrandSoftness; float _SolidRoot; float _WindStrength; float _WindSpeed; @@ -152,14 +173,13 @@ Shader "Custom/FurShell" float count = max(_ShellCount, 1.0); float h = (count <= 1.0) ? 0.0 : (float)shellIndex / (count - 1.0); - // extrude along the normal; bend the tips down with h^2 for a gravity/curl droop - float3 posOS = input.positionOS.xyz + input.normalOS * (_ShellLength * h); - // gravity pulls toward WORLD down, so strands hang downward no matter how the mesh is - // oriented - a rotated player mesh would otherwise droop along its own local down. Convert - // world down into object space (the extrusion space) so the droop magnitude stays consistent - // with _ShellLength under the object-to-world transform. - float3 downOS = normalize(mul((float3x3)unity_WorldToObject, float3(0.0, -1.0, 0.0))); - posOS += downOS * (_Curvature * _ShellLength * h * h); + // extrude along the normal; bend the tips down with h^2 for a gravity/curl droop. + // The (h + 0.02) lifts the whole stack a hair off the surface: the root (h=0) would else + // sit exactly on the opaque body and z-fight it - the fur draws a CPU-baked mesh while the + // body uses GPU skinning, so their depths are not bit-identical, and now that fur is + // transparent (ZWrite Off) it can't rely on a depth write to win the tie. 0.02 of the shell + // length is invisible but separates the root cleanly. + float3 posOS = input.positionOS.xyz + input.normalOS * (_ShellLength * (h + 0.02)); // wind sway: nudge each shell along the wind direction (object XZ) by a time-varying // amount. The phase is offset by object-space position so a gust rolls across the surface, @@ -171,11 +191,20 @@ Shader "Custom/FurShell" float phase = _Time.y * _WindSpeed + (input.positionOS.x + input.positionOS.z) * _WindFrequency; posOS.xz += wdir * (_WindStrength * _ShellLength * h * h * sin(phase)); - VertexPositionInputs vp = GetVertexPositionInputs(posOS); - VertexNormalInputs vn = GetVertexNormalInputs(input.normalOS); + // Gravity/curl bends toward WORLD down. Applied AFTER moving to world space and using ONLY + // unity_ObjectToWorld (positions/normals already prove that matrix correct). The previous + // object-space approach relied on unity_WorldToObject, which is NOT reliably populated in the + // RenderMeshInstanced path - when it falls back to identity the droop follows the mesh's own + // local down, so on the rotated player the fur hung sideways instead of toward true down. + // objScale keeps the droop magnitude consistent with the normal extrusion (object units). + float3 positionWS = TransformObjectToWorld(posOS); + float objScale = length(mul((float3x3)unity_ObjectToWorld, float3(0.0, 1.0, 0.0))); + positionWS.y -= _Curvature * _ShellLength * h * h * objScale; - output.positionCS = vp.positionCS; - output.positionWS = vp.positionWS; + VertexNormalInputs vn = GetVertexNormalInputs(input.normalOS); + + output.positionCS = TransformWorldToHClip(positionWS); + output.positionWS = positionWS; output.normalWS = vn.normalWS; output.uv = TRANSFORM_TEX(input.uv, _BaseMap); output.shellH = h; @@ -189,10 +218,10 @@ Shader "Custom/FurShell" float3 N = normalize(input.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(input.positionWS); - // fur map: RGB = strand colour, A = fur length. White default (no map) => full-length fur - // everywhere, so the standalone prototype is unchanged. - half4 furSample = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.uv); - float furLen = furSample.a; + // fur map: R = fur length (G/B/A unused). White default (no map) => full-length fur + // everywhere, so the standalone prototype is unchanged. The strand colour comes from the + // albedo map (_BaseMap) below, not from here. + float furLen = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.uv).r; // --- strand mask: one strand per cell, random length, radially tapered toward the tip --- float2 g = input.uv * _Density; @@ -253,26 +282,42 @@ Shader "Custom/FurShell" float taperedR = pow(saturate(1.0 - h), _Taper * 0.5); float radius = _Thickness * max(taperedR, _TipFill); - // The fur map's alpha caps this strand's height: 0 => bald, so the whole shell stack - // (root included) clips and the body shows through; 1 => the strand reaches its full - // random length. _SolidRoot keeps the base shell opaque for the full-coverage prototype; - // on the painted avatar it is turned OFF so the camo body stays visible between strands - // instead of being sealed under a solid root (which would also Z-fight the body). + // --- soft coverage (replaces the old binary alpha-clip) so the fur fades IN with alpha + // instead of a hard on/off edge. Three factors multiply into the output alpha: + // radialCov : soft, anti-aliased strand edge instead of a hard disc. + // heightCov : each strand fades out toward its own random tip (_TipFill caps the height). + // lenOpacity : LOW painted alpha => LOW opacity. A lightly-painted texel reads as faint, + // short fuzz that blends over the body -> the soft transition asked for, + // instead of full-opacity fur with a hard border. The fur map's alpha still + // also caps the strand HEIGHT (effectiveLen), so low alpha is short AND faint. + // The mesh is drawn on the Transparent queue and the shells are submitted root->tip, i.e. + // back-to-front on front faces, so these alphas composite correctly over the camo body. float effectiveLen = strandLen * furLen; - half alpha; - if (furLen <= _FurThreshold) - alpha = 0.0h; - else if (h <= 0.0 && _SolidRoot > 0.5) - alpha = 1.0h; - else - alpha = (dist < radius && h <= effectiveLen) ? 1.0h : 0.0h; - clip(alpha - 0.5h); + float radialCov = 1.0 - smoothstep(radius - max(radius * _StrandSoftness, 1e-4), radius, dist); + float heightCov = 1.0 - smoothstep(effectiveLen * (1.0 - _StrandSoftness), effectiveLen + 1e-4, h); + float lenOpacity = saturate((furLen - _FurThreshold) / max(_FurSoftness, 1e-4)); + + half alpha = (half)(radialCov * heightCov * lenOpacity); + + // _SolidRoot keeps the base shell fully covering (full-coverage prototype), but still honours + // the painted length so the boundary keeps its soft fade instead of a hard opaque ring. On + // the painted avatar it is turned OFF so the root also thins to the strands and the camo body + // stays visible between them. + if (_SolidRoot > 0.5 && h <= 0.0) + alpha = (half)lenOpacity; + + if (furLen <= _FurThreshold) alpha = 0.0h; + clip(alpha - 0.003h); // drop fully-transparent fragments (perf + avoids stray blend) // --- shading (lightweight URP) --- - half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * furSample.rgb * _BaseColor.rgb; - albedo = lerp(albedo, _TipColor.rgb, h); - half ao = lerp(1.0h - _RootAO, 1.0h, h); // roots darker (fake self-occlusion) - albedo *= ao; + // base colour is the PAINTED ALBEDO (the camo under the fur), sampled per-texel so each + // strand wears the colour beneath it, times the global tint (white by default). The fur map + // no longer carries colour - only length. + half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb; + // two shades of that one colour: roots a touch darker (fake self-occlusion), tips a touch + // lighter - a subtle root->tip gradient rather than two independent colours. + half ao = lerp(1.0h - _RootAO, 1.0h, h); + albedo *= ao * lerp(1.0h, 1.0h + _TipShade, h); float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS); Light mainLight = GetMainLight(shadowCoord); @@ -296,7 +341,7 @@ Shader "Custom/FurShell" half rim = pow(saturate(1.0h - saturate(dot(N, V))), _RimPower) * _RimStrength * h; color += rim * mainLight.color * albedo; - return half4(color, 1.0h); + return half4(color, alpha); } ENDHLSL } diff --git a/Assets/Shaders/MeshPaintBrush.shader b/Assets/Shaders/MeshPaintBrush.shader index 2c8cc73..348f379 100644 --- a/Assets/Shaders/MeshPaintBrush.shader +++ b/Assets/Shaders/MeshPaintBrush.shader @@ -139,138 +139,6 @@ 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 }