From b4e4c19e54576590351ca0734218e66f2f347ae4 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sat, 11 Jul 2026 16:59:38 +0200 Subject: [PATCH] FurShell: Performance-Umbau (Distanz-LOD, Cutout+Early-Z, Tip-to-Root) + Deferred+/HTrace-Fixes - Distanz-LOD am Renderer (Start/End/Min Shells, pro Kamera), Rendering Layer Mask wie beim MeshRenderer - Cutout in der AlphaTest-Queue statt Transparent-Blend (Early-Z killt Overdraw), Shells tip-to-root submitted - LightMode UniversalForwardOnly (Deferred+ hatte den Opaque-Pass sonst nicht gezeichnet) - FurGBuffer-Pass (ComplexLit-Muster): HTrace WSGI sieht das Fell, kein Durchscheinen mehr - Root-AO-Fix: Grading straehnen-relativ (gradeH) statt absoluter Shell-Hoehe -- schwarze Streifen weg - Distanz-Aggregations-Fade wieder entfernt (auf Wunsch, Fern-Straehnen bleiben voll) Co-Authored-By: Claude Fable 5 --- Assets/Scripts/Components/FurShellRenderer.cs | 42 +- Assets/Shaders/FurShell.shader | 628 +++++++++++------- 2 files changed, 408 insertions(+), 262 deletions(-) diff --git a/Assets/Scripts/Components/FurShellRenderer.cs b/Assets/Scripts/Components/FurShellRenderer.cs index 6b63c35..dc1a4c4 100644 --- a/Assets/Scripts/Components/FurShellRenderer.cs +++ b/Assets/Scripts/Components/FurShellRenderer.cs @@ -23,6 +23,14 @@ namespace Chameleon.Components [SerializeField] Material _furMaterial; [SerializeField, Range(1, 64)] int _shellCount = 32; + [Header("Distance LOD")] + // Shell cost is linear in the shell count and nobody can count 32 shells from a few meters away: + // full _shellCount up to Start, then a linear fade down to Min Shells at End (per camera, computed + // at draw time). Min Shells 0 stops drawing the fur entirely beyond End. + [SerializeField, Min(0f)] float _lodStartDistance = 8f; + [SerializeField, Min(0f)] float _lodEndDistance = 30f; + [SerializeField, Range(0, 64)] int _lodMinShells = 8; + [Header("Shape")] [SerializeField, Range(0f, 10f)] float _shellLength = 0.15f; [SerializeField, Range(1f, 1000f)] float _density = 250f; @@ -65,10 +73,9 @@ 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. + // Alpha-cutout threshold: fragments at/below this coverage are discarded, everything above draws + // fully opaque (the fur renders on the AlphaTest queue - opaque + ZWrite gives per-pixel z-order + // between fur objects AND early-Z overdraw rejection). Higher = thinner/harder strands. [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. @@ -82,14 +89,16 @@ namespace Chameleon.Components [SerializeField] Vector2 _windDirection = new Vector2(1f, 0f); [Header("X-Ray Reveal")] - // The fur draws up to _shellCount stacked TRANSPARENT shells, so the mirrored X-ray glow composites - // once per overlapping layer and blows out; this scales the per-shell glow down so the total reads - // at roughly the body's intensity. Lower = subtler. - [SerializeField, Range(0f, 1f)] float _xrayFurStrength = 0.2f; + // Fur-vs-body glow intensity match. Since the fur went opaque cutout only ONE fur fragment + // survives per pixel (no shell stacking anymore), so this typically wants ~1 to match the body + // instead of the old stack-taming ~0.2. + [SerializeField, Range(0f, 1f)] float _xrayFurStrength = 1f; [Header("Rendering")] [SerializeField] ShadowCastingMode _shadowMode = ShadowCastingMode.Off; [SerializeField] bool _receiveShadows = true; + // Same dropdown/semantics as MeshRenderer's Rendering Layer Mask (decal/light layer filtering). + [SerializeField] RenderingLayerMask _renderingLayerMask = RenderingLayerMask.defaultRenderingLayerMask; // Hide the plain MeshRenderer on this object so it doesn't double-draw under the fur (the root // shell already covers the surface). Leave off if you want a separately-shaded base mesh. [SerializeField] bool _hideSourceRenderer = true; @@ -197,9 +206,19 @@ namespace Chameleon.Components // to the on-disk state after OnEnable ran, and RenderMeshInstanced throws without it. if (!_furMaterial.enableInstancing) _furMaterial.enableInstancing = true; - int count = Mathf.Max(1, _shellCount); - if (_instances == null || _instances.Length != count) - _instances = new Matrix4x4[count]; + // Distance LOD: scale the shell count down with camera distance (full count inside Start, + // Min Shells at/after End). Per camera on purpose - the draw is per camera anyway, so the + // Scene View and a distant player camera each get their own appropriate count. + int maxCount = Mathf.Max(1, _shellCount); + float lodEnd = Mathf.Max(_lodEndDistance, _lodStartDistance + 0.001f); + float lodT = Mathf.InverseLerp(_lodStartDistance, lodEnd, Vector3.Distance(cam.transform.position, transform.position)); + int count = Mathf.RoundToInt(Mathf.Lerp(maxCount, Mathf.Min(_lodMinShells, maxCount), lodT)); + if (count <= 0) return; // Min Shells 0 => beyond End the fur is culled entirely + + // The array stays at the max size; RenderMeshInstanced only reads the first `count` entries, + // so LOD changes don't reallocate per frame. + if (_instances == null || _instances.Length != maxCount) + _instances = new Matrix4x4[maxCount]; // all shells share the object's transform; they differ only by SV_InstanceID in the shader Matrix4x4 m = transform.localToWorldMatrix; @@ -214,6 +233,7 @@ namespace Chameleon.Components matProps = _mpb, shadowCastingMode = _shadowMode, receiveShadows = _receiveShadows, + renderingLayerMask = _renderingLayerMask, }; for (int sub = 0; sub < mesh.subMeshCount; sub++) diff --git a/Assets/Shaders/FurShell.shader b/Assets/Shaders/FurShell.shader index 4d59220..93f73e2 100644 --- a/Assets/Shaders/FurShell.shader +++ b/Assets/Shaders/FurShell.shader @@ -48,14 +48,14 @@ Shader "Custom/FurShell" // 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. + // Per-strand anti-aliasing: softens the coverage falloff BEFORE the cutout clip, so it now mostly + // shapes where the cutoff lands rather than blending an edge (the fur is opaque cutout). _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 + // Alpha-cutout threshold: fragments at/below this coverage are discarded, everything else draws + // FULLY OPAQUE. Opaque + ZWrite means early-Z rejects hidden fur fragments before the fragment + // shader runs (the main fur cost is overdraw), and overlapping fur objects sort per PIXEL via the + // depth buffer. Higher = thinner/harder strands, lower = fuller strands. + _DepthSortClip("Alpha Cutoff (coverage)", 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 @@ -90,26 +90,298 @@ Shader "Custom/FurShell" { Tags { - "RenderType" = "Transparent" + "RenderType" = "TransparentCutout" "RenderPipeline" = "UniversalPipeline" - "Queue" = "Transparent" + "Queue" = "AlphaTest" + // ComplexLit pattern (same as PlayerCamoLit / PictureTessellationLit): on the Deferred+ + // renderer URP keeps SHADING the fur in its forward-only pass and stencils these pixels + // out of the deferred lighting - the GBuffer pass below only feeds data consumers + // (HTrace WSGI), it never causes double lighting. + "UniversalMaterialType" = "ComplexLit" } LOD 200 + // Shared by the FurForward and GBuffer passes so both extrude the SAME shells and clip the + // SAME strand silhouette - any drift between them would show as GI ghosting (HTrace reads the + // GBuffer pass, the eye sees the forward pass) or depth mismatches. + HLSLINCLUDE + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + + CBUFFER_START(UnityPerMaterial) + float4 _BaseMap_ST; + half4 _BaseColor; + float _ShellCount; + float _ShellLength; + float _Density; + float _Thickness; + float _Taper; + float _TipFill; + float _Curvature; + float _DirRandomness; + float _SilhouetteStretch; + half _RootAO; + half _TipShade; + half _RimPower; + half _RimStrength; + float _FurThreshold; + float _FurSoftness; + float _StrandSoftness; + float _DepthSortClip; + float _SolidRoot; + float _WindStrength; + float _WindSpeed; + float _WindFrequency; + float4 _WindDirection; + float _FurObjScale; + half4 _XRayColor; + half _XRayPulseSpeed; + half _XRayPulseStrength; + half _XRayPulseShape; + half _XRayFurStrength; + CBUFFER_END + + TEXTURE2D(_BaseMap); + SAMPLER(sampler_BaseMap); + TEXTURE2D(_FurMap); + SAMPLER(sampler_FurMap); + + struct Attributes + { + float4 positionOS : POSITION; + float3 normalOS : NORMAL; + float2 uv : TEXCOORD0; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct Varyings + { + float4 positionCS : SV_POSITION; + float2 uv : TEXCOORD0; + float3 normalWS : TEXCOORD1; + float3 positionWS : TEXCOORD2; + float shellH : TEXCOORD3; // 0 = root, 1 = tip + }; + + // cheap deterministic 2D hash -> [0,1) + float Hash21(float2 p) + { + p = frac(p * float2(123.34, 345.45)); + p += dot(p, p + 34.345); + return frac(p.x * p.y); + } + + Varyings vert(Attributes input) + { + Varyings output = (Varyings)0; + UNITY_SETUP_INSTANCE_ID(input); + + // shell layer index -> normalized height (guarded so the non-instanced variant compiles) + #if UNITY_ANY_INSTANCING_ENABLED + uint shellIndex = input.instanceID; + #else + uint shellIndex = 0u; + #endif + // Instance 0 = TIP, last instance = root: instances rasterize in submission order, so + // tip-first is front-to-back on front faces - the nearest (tip) shells fill the depth + // buffer first and early-Z rejects the root shells behind them. Order is free to choose + // because the fur is opaque cutout (no blending to keep back-to-front). + float count = max(_ShellCount, 1.0); + float h = (count <= 1.0) ? 0.0 : 1.0 - (float)shellIndex / (count - 1.0); + + // The instance matrix multiplies the object-space fur displacement by the avatar's world scale + // (objScale), so a naive extrusion shrinks/grows 1:1 with the player - too short on the small + // hider, too long on the big hunter. To keep the fur a CONSTANT world length (same absolute + // length at any size) we pre-divide the object-space offset by objScale; after the matrix's + // *objScale the net world displacement is then 1x. Wind sway shares this so it stays a constant + // world size too. (The gravity droop below intentionally does NOT - it scales with objScale.) + float objScale = max(_FurObjScale, 1e-4); + float dispComp = 1.0 / objScale; + + // 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. 0.02 of the shell length + // is invisible but separates the root cleanly. + float3 posOS = input.positionOS.xyz + input.normalOS * (_ShellLength * (h + 0.02) * dispComp); + + // 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, + // and the h^2 weight keeps roots planted while tips travel most => the stacked shells read + // as strands bending in the wind. + float2 wdir = _WindDirection.xy; + float wlen = length(wdir); + wdir = (wlen > 1e-4) ? (wdir / wlen) : float2(1.0, 0.0); + float phase = _Time.y * _WindSpeed + (input.positionOS.x + input.positionOS.z) * _WindFrequency; + posOS.xz += wdir * (_WindStrength * _ShellLength * h * h * sin(phase) * dispComp); + + // 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. + float3 positionWS = TransformObjectToWorld(posOS); + // Curl droop in world down, scaled by the avatar's world size (objScale) so the bend tracks the + // player: a bigger avatar gets a proportionally bigger droop, a smaller one a smaller one. The + // fur length above is a CONSTANT world size, so scaling only the droop keeps the bend reading + // right relative to the body at every player scale (hider 0.5 / hunter 1.5). Applied after + // moving to world space, so the droop follows true world down regardless of player rotation. + positionWS.y -= _Curvature * _ShellLength * h * h * objScale; + + 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; + return output; + } + + // The full strand coverage (radial + height + painted-length opacity), shared so the forward + // and GBuffer passes threshold IDENTICAL silhouettes. Returns the coverage scalar the cutout + // clip thresholds; fragment stage only (uses screen-space derivatives). + // gradeH: the root->tip grading height for FurAlbedo. RELATIVE to this strand's own length - + // grading by the absolute shell height painted short strands root-dark across their whole + // visible run, which read as black strand streaks that scaled with _RootAO ("root AO is + // buggy"). Relative grading makes every strand bright at ITS tip and dark only where actual + // roots are visible. + half FurCoverage(Varyings input, float3 N, float3 V, out float gradeH) + { + float h = input.shellH; + + // 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; + float2 cell = floor(g); + float strandLen = Hash21(cell); // this strand's random max height (0..1) + + // per-strand lean: shift the cell-local strand centre by a random direction that grows + // with height, so each strand tilts its own way. This is a fragment-space lean - geometry + // is shared per vertex while strands are per cell, so the tilt has to live here, not in the + // extrusion. + float rndAngle = Hash21(cell + 3.7) * 6.2831853; + float2 leanDir = float2(cos(rndAngle), sin(rndAngle)); + float2 local = (frac(g) * 2.0 - 1.0) - leanDir * (_DirRandomness * h); + + // silhouette stretch: at grazing angles the flat shell slices are viewed edge-on, so a + // strand breaks into squished, separated dots. Elongate the strand footprint along the + // tangent-space view direction - the axis the extruded shells drift apart on screen - so + // consecutive slices overlap into a continuous strand near the rim. The tangent frame is + // rebuilt from screen-space derivatives (Schueler's cotangent trick), so this stays generic: + // no per-mesh tangents required. graze^2 confines the effect to the silhouette, leaving the + // head-on look untouched. The branch is on a uniform, so the derivatives stay quad-uniform + // (safe) and the cost is skipped when the feature is off; at stretch==1 the basis rotation is + // a pure rotation => identity, so ramping it in is continuous. + if (_SilhouetteStretch > 0.0) + { + float graze = 1.0 - saturate(dot(N, V)); + graze *= graze; + + float3 dp1 = ddx(input.positionWS); + float3 dp2 = ddy(input.positionWS); + float2 duv1 = ddx(input.uv); + float2 duv2 = ddy(input.uv); + float3 dp2perp = cross(dp2, N); + float3 dp1perp = cross(N, dp1); + float3 T = dp2perp * duv1.x + dp1perp * duv2.x; + float3 B = dp2perp * duv1.y + dp1perp * duv2.y; + float invmax = rsqrt(max(dot(T, T), dot(B, B))); + T *= invmax; B *= invmax; + + float2 vt = float2(dot(V, T), dot(V, B)); + float vlen = length(vt); + float2 sdir = (vlen > 1e-4) ? (vt / vlen) : float2(1.0, 0.0); + + float stretch = 1.0 + _SilhouetteStretch * graze; + float along = dot(local, sdir) / stretch; // compress coord => elongate footprint along sdir + float perp = dot(local, float2(-sdir.y, sdir.x)); + local = float2(along, perp); + } + + float dist = length(local); + + // allowed radius shrinks with height => the strand narrows toward its tip. _TipFill is a + // floor on that radius (as a fraction of the base thickness): the taper only thins a strand + // down to _TipFill, never to a point. With few shells the pointy tip breaks into floating + // discs ("dots") because consecutive shell slices stop overlapping; holding a minimum radius + // keeps neighbouring slices wide enough to bridge the inter-shell gap. 0 = sharp needles + // (dots return), higher = fuller, slightly rounded tips. + float taperedR = pow(saturate(1.0 - h), _Taper * 0.5); + float radius = _Thickness * max(taperedR, _TipFill); + + // --- coverage: three factors multiply into a scalar that the CUTOUT clip thresholds. + // radialCov : strand silhouette falloff (soft ramp -> the cutoff lands softly). + // heightCov : each strand thins out toward its own random tip (_TipFill caps the height). + // lenOpacity : LOW painted alpha => LOW coverage, so lightly-painted texels lose their + // strands at the cutoff earlier -> short, sparse fuzz at paint borders. + // The fur map's alpha still also caps the strand HEIGHT (effectiveLen). + // The fur is opaque cutout: coverage no longer blends - it only decides clip-or-draw. + float effectiveLen = strandLen * furLen; + 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); + + gradeH = saturate(h / max(effectiveLen, 1e-3)); + + // _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; + return alpha; + } + + // 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). Root/tip grading is + // shared so HTrace's injected GI (from GBuffer albedo) matches the visible strand colour. + half3 FurAlbedo(float2 uv, float h) + { + half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, 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); + return albedo * (ao * lerp(1.0h, 1.0h + _TipShade, h)); + } + + // Shading normals of at/beyond-the-horizon shell fragments point away from (or barely toward) + // the camera and shade darker than the fur right next to them - the silhouette band showed + // scattered dark strand streaks (verified with an N*V debug render). Nudge such normals toward + // the view until they face it by at least MIN_NV, so limb strands light like the surface they + // visually belong to. Shading-only; coverage/silhouette math keeps the raw normal. + float3 ClampNormalToHorizon(float3 N, float3 V) + { + const float MIN_NV = 0.2; + float nv = dot(N, V); + return (nv >= MIN_NV) ? N : normalize(N + (MIN_NV - nv) * V); + } + ENDHLSL + Pass { Name "FurForward" - Tags { "LightMode" = "UniversalForward" } + // UniversalForwardOnly, NOT UniversalForward: since the cutout rework the fur renders in the + // OPAQUE pass (AlphaTest queue), and the Deferred+ renderer draws opaques via the GBuffer + // pass - a shader with only a UniversalForward pass is skipped entirely there (invisible). + // ForwardOnly is drawn by both the deferred renderer's forward-only pass AND the plain + // forward renderer, so this works on either path (same ComplexLit pattern as PlayerCamoLit). + Tags { "LightMode" = "UniversalForwardOnly" } Cull Back - Blend SrcAlpha OneMinusSrcAlpha - // 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. + // Opaque alpha-cutout (no blending): every surviving fragment writes depth, so + // 1) overlapping fur objects sort per PIXEL via the depth buffer (no per-object popping), and + // 2) early-Z rejects hidden fur fragments BEFORE the fragment shader runs - shell fur's main + // cost is overdraw (shell behind shell, fur behind walls), which this cuts massively. + // To maximise the early-Z win the shells are submitted TIP->ROOT (front-to-back on front + // faces, see the h mapping in vert); with no blending the draw order is free to choose. ZWrite On ZTest LEqual // Depth bias toward the camera so the fur wins the depth test against the body. The fur @@ -131,136 +403,8 @@ Shader "Custom/FurShell" #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS - #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" - CBUFFER_START(UnityPerMaterial) - float4 _BaseMap_ST; - half4 _BaseColor; - float _ShellCount; - float _ShellLength; - float _Density; - float _Thickness; - float _Taper; - float _TipFill; - float _Curvature; - float _DirRandomness; - float _SilhouetteStretch; - half _RootAO; - half _TipShade; - half _RimPower; - half _RimStrength; - float _FurThreshold; - float _FurSoftness; - float _StrandSoftness; - float _DepthSortClip; - float _SolidRoot; - float _WindStrength; - float _WindSpeed; - float _WindFrequency; - float4 _WindDirection; - float _FurObjScale; - half4 _XRayColor; - half _XRayPulseSpeed; - half _XRayPulseStrength; - half _XRayPulseShape; - half _XRayFurStrength; - CBUFFER_END - - TEXTURE2D(_BaseMap); - SAMPLER(sampler_BaseMap); - TEXTURE2D(_FurMap); - SAMPLER(sampler_FurMap); - - struct Attributes - { - float4 positionOS : POSITION; - float3 normalOS : NORMAL; - float2 uv : TEXCOORD0; - UNITY_VERTEX_INPUT_INSTANCE_ID - }; - - struct Varyings - { - float4 positionCS : SV_POSITION; - float2 uv : TEXCOORD0; - float3 normalWS : TEXCOORD1; - float3 positionWS : TEXCOORD2; - float shellH : TEXCOORD3; // 0 = root, 1 = tip - }; - - // cheap deterministic 2D hash -> [0,1) - float Hash21(float2 p) - { - p = frac(p * float2(123.34, 345.45)); - p += dot(p, p + 34.345); - return frac(p.x * p.y); - } - - Varyings vert(Attributes input) - { - Varyings output = (Varyings)0; - UNITY_SETUP_INSTANCE_ID(input); - - // shell layer index -> normalized height (guarded so the non-instanced variant compiles) - #if UNITY_ANY_INSTANCING_ENABLED - uint shellIndex = input.instanceID; - #else - uint shellIndex = 0u; - #endif - float count = max(_ShellCount, 1.0); - float h = (count <= 1.0) ? 0.0 : (float)shellIndex / (count - 1.0); - - // The instance matrix multiplies the object-space fur displacement by the avatar's world scale - // (objScale), so a naive extrusion shrinks/grows 1:1 with the player - too short on the small - // hider, too long on the big hunter. To keep the fur a CONSTANT world length (same absolute - // length at any size) we pre-divide the object-space offset by objScale; after the matrix's - // *objScale the net world displacement is then 1x. Wind sway shares this so it stays a constant - // world size too. (The gravity droop below intentionally does NOT - it scales with objScale.) - float objScale = max(_FurObjScale, 1e-4); - float dispComp = 1.0 / objScale; - - // 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) * dispComp); - - // 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, - // and the h^2 weight keeps roots planted while tips travel most => the stacked shells read - // as strands bending in the wind. - float2 wdir = _WindDirection.xy; - float wlen = length(wdir); - wdir = (wlen > 1e-4) ? (wdir / wlen) : float2(1.0, 0.0); - float phase = _Time.y * _WindSpeed + (input.positionOS.x + input.positionOS.z) * _WindFrequency; - posOS.xz += wdir * (_WindStrength * _ShellLength * h * h * sin(phase) * dispComp); - - // 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. - float3 positionWS = TransformObjectToWorld(posOS); - // Curl droop in world down, scaled by the avatar's world size (objScale) so the bend tracks the - // player: a bigger avatar gets a proportionally bigger droop, a smaller one a smaller one. The - // fur length above is a CONSTANT world size, so scaling only the droop keeps the bend reading - // right relative to the body at every player scale (hider 0.5 / hunter 1.5). Applied after - // moving to world space, so the droop follows true world down regardless of player rotation. - positionWS.y -= _Curvature * _ShellLength * h * h * objScale; - - 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; - return output; - } - half4 frag(Varyings input) : SV_Target { float h = input.shellH; @@ -268,109 +412,20 @@ Shader "Custom/FurShell" float3 N = normalize(input.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(input.positionWS); - // 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; - float2 cell = floor(g); - float strandLen = Hash21(cell); // this strand's random max height (0..1) - - // per-strand lean: shift the cell-local strand centre by a random direction that grows - // with height, so each strand tilts its own way. This is a fragment-space lean - geometry - // is shared per vertex while strands are per cell, so the tilt has to live here, not in the - // extrusion. - float rndAngle = Hash21(cell + 3.7) * 6.2831853; - float2 leanDir = float2(cos(rndAngle), sin(rndAngle)); - float2 local = (frac(g) * 2.0 - 1.0) - leanDir * (_DirRandomness * h); - - // silhouette stretch: at grazing angles the flat shell slices are viewed edge-on, so a - // strand breaks into squished, separated dots. Elongate the strand footprint along the - // tangent-space view direction - the axis the extruded shells drift apart on screen - so - // consecutive slices overlap into a continuous strand near the rim. The tangent frame is - // rebuilt from screen-space derivatives (Schueler's cotangent trick), so this stays generic: - // no per-mesh tangents required. graze^2 confines the effect to the silhouette, leaving the - // head-on look untouched. The branch is on a uniform, so the derivatives stay quad-uniform - // (safe) and the cost is skipped when the feature is off; at stretch==1 the basis rotation is - // a pure rotation => identity, so ramping it in is continuous. - if (_SilhouetteStretch > 0.0) - { - float graze = 1.0 - saturate(dot(N, V)); - graze *= graze; - - float3 dp1 = ddx(input.positionWS); - float3 dp2 = ddy(input.positionWS); - float2 duv1 = ddx(input.uv); - float2 duv2 = ddy(input.uv); - float3 dp2perp = cross(dp2, N); - float3 dp1perp = cross(N, dp1); - float3 T = dp2perp * duv1.x + dp1perp * duv2.x; - float3 B = dp2perp * duv1.y + dp1perp * duv2.y; - float invmax = rsqrt(max(dot(T, T), dot(B, B))); - T *= invmax; B *= invmax; - - float2 vt = float2(dot(V, T), dot(V, B)); - float vlen = length(vt); - float2 sdir = (vlen > 1e-4) ? (vt / vlen) : float2(1.0, 0.0); - - float stretch = 1.0 + _SilhouetteStretch * graze; - float along = dot(local, sdir) / stretch; // compress coord => elongate footprint along sdir - float perp = dot(local, float2(-sdir.y, sdir.x)); - local = float2(along, perp); - } - - float dist = length(local); - - // allowed radius shrinks with height => the strand narrows toward its tip. _TipFill is a - // floor on that radius (as a fraction of the base thickness): the taper only thins a strand - // down to _TipFill, never to a point. With few shells the pointy tip breaks into floating - // discs ("dots") because consecutive shell slices stop overlapping; holding a minimum radius - // keeps neighbouring slices wide enough to bridge the inter-shell gap. 0 = sharp needles - // (dots return), higher = fuller, slightly rounded tips. - float taperedR = pow(saturate(1.0 - h), _Taper * 0.5); - float radius = _Thickness * max(taperedR, _TipFill); - - // --- 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; - 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; - // 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. + float gradeH; + half alpha = FurCoverage(input, N, V, gradeH); + // Alpha cutout: below the threshold the fragment is gone (writes neither color nor depth), + // above it the fragment draws fully opaque. The 0.003 floor keeps zero-coverage fragments + // dropping even at cutoff 0. clip(alpha - max((half)_DepthSortClip, 0.003h)); + // Keep the raw grazing term for the rim highlight below (the clamp would dim it), + // then clamp the shading normal so silhouette strands don't go dark (see helper). + half rawGraze = saturate(1.0h - saturate(dot(N, V))); + N = ClampNormalToHorizon(N, V); + // --- shading (lightweight URP) --- - // 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); + half3 albedo = FurAlbedo(input.uv, gradeH); float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS); Light mainLight = GetMainLight(shadowCoord); @@ -391,7 +446,8 @@ Shader "Custom/FurShell" #endif // rim / backlight scatter: brighter fuzz at grazing angles, strongest at the tips - half rim = pow(saturate(1.0h - saturate(dot(N, V))), _RimPower) * _RimStrength * h; + // (rawGraze = pre-clamp grazing so the horizon clamp doesn't dim the rim glow) + half rim = pow(rawGraze, _RimPower) * _RimStrength * h; color += rim * mainLight.color * albedo; // X-Ray reveal glow on the VISIBLE fur, mirroring the body's forward half (PlayerCamo.hlsl) @@ -401,12 +457,82 @@ Shader "Custom/FurShell" // MeshPainter drives on the body, so on/occluded stays consistent between fur and body. half xrayCurve = pow(0.5h - 0.5h * cos(_Time.y * _XRayPulseSpeed), _XRayPulseShape); half xrayPulse = 1.0h - _XRayPulseStrength * (1.0h - xrayCurve); - // _XRayFurStrength tames the per-shell accumulation: the body adds this term once (opaque), - // but the fur stacks _ShellCount transparent shells that each composite it, so the raw term - // over-brightens. Scaling it per shell brings the stacked total back near the body's glow. + // Now that the fur is opaque cutout, only ONE fur fragment survives per pixel - the glow no + // longer stacks per shell. _XRayFurStrength stays as the fur-vs-body intensity match knob; + // with no stacking to tame it typically wants ~1 instead of the old ~0.2. color += _XRayColor.rgb * (_XRayColor.a * xrayPulse * _XRayFurStrength); - return half4(color, alpha); + // opaque cutout: surviving fragments are fully opaque, alpha only fed the clip above + return half4(color, 1.0h); + } + ENDHLSL + } + + Pass + { + // The project renders Deferred+ but the fur is SHADED forward-only (ComplexLit stencil); + // the ONLY consumer of this pass is HTrace WSGI, which renders its thin GBuffer through the + // "UniversalGBuffer" tag and composites its traced irradiance from GBuffer0.rgb (albedo) + // AFTER post-processing. Without it the fur pixels keep the GBuffer data of whatever is + // BEHIND the fur, so HTrace injected the background's GI over the strands - the fur read as + // half transparent (same failure class as the HeroCutout / PictureTessellationLit fixes). + // Same shells, same cutout clip, same graded albedo as the forward pass via the shared code. + Name "FurGBuffer" + Tags { "LightMode" = "UniversalGBuffer" } + + Cull Back + ZWrite On + ZTest LEqual + // Same depth bias as FurForward so both passes agree on depth against the body. + Offset -1, -1 + + HLSLPROGRAM + #pragma target 4.5 + #pragma exclude_renderers gles3 glcore + + #pragma vertex vert + #pragma fragment GBufferFrag + + #pragma multi_compile_instancing + #pragma multi_compile_fragment _ _GBUFFER_NORMALS_OCT + #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl" + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/GBufferOutput.hlsl" + + GBufferFragOutput GBufferFrag(Varyings input) + { + float3 N = normalize(input.normalWS); + float3 V = GetWorldSpaceNormalizeViewDir(input.positionWS); + + float gradeH; + half alpha = FurCoverage(input, N, V, gradeH); + clip(alpha - max((half)_DepthSortClip, 0.003h)); + + // Same horizon clamp as the forward pass - away-facing normals in the GBuffer would + // make HTrace inject correspondingly dark GI onto the same silhouette strands. + N = ClampNormalToHorizon(N, V); + + // GBuffer0 is UNORM; HTrace multiplies its GI by it, so the graded strand albedo here + // makes the injected indirect light match the visible fur colour. + half3 albedo = saturate(FurAlbedo(input.uv, gradeH)); + + InputData inputData = (InputData)0; + inputData.positionWS = input.positionWS; + inputData.positionCS = input.positionCS; + inputData.normalWS = N; + inputData.viewDirectionWS = V; + inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS); + + // Fur is pure diffuse (metallic 0, smoothness 0) - matches the forward shading model. + BRDFData brdfData; + half brdfAlpha = 1.0h; + InitializeBRDFData(albedo, 0.0h, half3(0.0h, 0.0h, 0.0h), 0.0h, brdfAlpha, brdfData); + + // Baked-GI slot mirrors the forward ambient term (SampleSH); deferred lighting never + // reads it here (stenciled out) - it only completes the GBuffer for HTrace. + half3 gi = SampleSH(N) * brdfData.diffuse; + return PackGBuffersBRDFData(brdfData, inputData, 0.0h, gi, 1.0h); } ENDHLSL }