From 874ddccb4fd11267302224a32a42749a3a22120e Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sat, 11 Jul 2026 14:30:47 +0200 Subject: [PATCH] FurShell: per-pixel z-order between fur objects (ZWrite On + coverage clip) Transparent queue sorted fur draws per object, so overlapping fur renderers swapped in front of each other frame to frame. FurForward now writes depth; new Depth Sort Clip tunable discards low-coverage fragments before they write depth (avoids halo punch-through). Co-Authored-By: Claude Fable 5 --- Assets/Scripts/Components/FurShellRenderer.cs | 8 ++++++ Assets/Shaders/FurShell.shader | 28 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/Components/FurShellRenderer.cs b/Assets/Scripts/Components/FurShellRenderer.cs index 0686901..6b63c35 100644 --- a/Assets/Scripts/Components/FurShellRenderer.cs +++ b/Assets/Scripts/Components/FurShellRenderer.cs @@ -65,6 +65,11 @@ namespace Chameleon.Components [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; + // Fragments at/below this coverage are discarded so they don't write depth (the shader has + // ZWrite ON to get a correct per-pixel z-order between overlapping fur objects - transparents + // otherwise sort per object and pop). Higher = harder strand edges, less halo where two fur + // objects overlap. + [SerializeField, Range(0f, 1f)] float _depthSortClip = 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; @@ -108,6 +113,7 @@ namespace Chameleon.Components static readonly int FurThresholdId = Shader.PropertyToID("_FurThreshold"); static readonly int FurSoftnessId = Shader.PropertyToID("_FurSoftness"); static readonly int StrandSoftnessId = Shader.PropertyToID("_StrandSoftness"); + static readonly int DepthSortClipId = Shader.PropertyToID("_DepthSortClip"); static readonly int SolidRootId = Shader.PropertyToID("_SolidRoot"); static readonly int WindStrengthId = Shader.PropertyToID("_WindStrength"); static readonly int WindSpeedId = Shader.PropertyToID("_WindSpeed"); @@ -246,6 +252,7 @@ namespace Chameleon.Components _mpb.SetFloat(FurThresholdId, _furThreshold); _mpb.SetFloat(FurSoftnessId, _furSoftness); _mpb.SetFloat(StrandSoftnessId, _strandSoftness); + _mpb.SetFloat(DepthSortClipId, _depthSortClip); _mpb.SetFloat(SolidRootId, _solidRoot ? 1f : 0f); _mpb.SetFloat(WindStrengthId, _windStrength); @@ -306,6 +313,7 @@ namespace Chameleon.Components 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(DepthSortClipId)) _depthSortClip = mat.GetFloat(DepthSortClipId); 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/Shaders/FurShell.shader b/Assets/Shaders/FurShell.shader index 1f1edb2..4d59220 100644 --- a/Assets/Shaders/FurShell.shader +++ b/Assets/Shaders/FurShell.shader @@ -50,6 +50,12 @@ Shader "Custom/FurShell" _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 + // Fragments at/below this coverage are discarded so they don't WRITE DEPTH (ZWrite is on, see + // pass). Depth writes are what give two overlapping fur objects a correct per-pixel z-order + // (transparents otherwise sort per OBJECT and pop); the clip keeps faint edge pixels from + // occluding the other object with near-invisible fragments. Higher = harder strand edges but + // less edge halo between overlapping fur objects. + _DepthSortClip("Depth Sort Coverage Clip", 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 @@ -97,15 +103,21 @@ Shader "Custom/FurShell" Cull Back Blend SrcAlpha OneMinusSrcAlpha - ZWrite Off + // ZWrite ON although this is a transparent blend: URP sorts transparents per OBJECT, so two + // overlapping fur objects would swap their whole-object z-order as the camera moves (visible + // popping where the fur balls overlap). Writing depth resolves the occlusion per PIXEL instead: + // whichever object draws first, the second one's hidden fragments fail ZTest. Fragments below + // _DepthSortClip coverage are discarded (see frag) so faint edge pixels don't occlude. Within + // one object the shells are submitted root->tip (= back-to-front on front faces), so its own + // blending still composites correctly. + ZWrite On 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. + // fails ZTest and the body flickers through as white speckles. Offset shifts the fragment's + // depth, 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 @@ -141,6 +153,7 @@ Shader "Custom/FurShell" float _FurThreshold; float _FurSoftness; float _StrandSoftness; + float _DepthSortClip; float _SolidRoot; float _WindStrength; float _WindSpeed; @@ -344,7 +357,10 @@ Shader "Custom/FurShell" alpha = (half)lenOpacity; if (furLen <= _FurThreshold) alpha = 0.0h; - clip(alpha - 0.003h); // drop fully-transparent fragments (perf + avoids stray blend) + // Discard low-coverage fragments BEFORE they write depth (ZWrite is on for per-pixel + // sorting between overlapping fur objects): a near-invisible edge pixel must not occlude + // the fur behind it. The 0.003 floor keeps the old fully-transparent drop as a minimum. + clip(alpha - max((half)_DepthSortClip, 0.003h)); // --- shading (lightweight URP) --- // base colour is the PAINTED ALBEDO (the camo under the fur), sampled per-texel so each