Files
chameleon/Assets/Shaders/FurShell.shader
T
adminandClaude 9dc8f21528 FurShell: scale-aware length/curvature + X-ray glow mirror
The fur is drawn from a CPU-baked unit-scale mesh whose instance matrix
carries the avatar's world scale, so the extrusion scaled fully 1:1 with
the player (hider 0.5 / hunter 1.5). That read as too-short fur on the
small hider and too-long on the big hunter, and the curvature droop was
sized off the raw unity_ObjectToWorld which doesn't carry the per-instance
scale in the RenderMeshInstanced path.

- FurShellRenderer pushes the avatar's lossyScale (_FurObjScale) plus a new
  Fur Scale Influence (0..1, default 0.5) that blends the fur SIZE between
  fully proportional (1) and a constant world length (0). The shader
  pre-divides the object-space displacement so the net world length follows
  effScale; curl droop and wind use the same effScale, keeping the curl
  SHAPE invariant across avatar sizes and influence settings.
- The body's see-through X-ray reveal (forward half) is mirrored onto the
  visible fur so strands glow WITH the reveal instead of hiding it under
  opaque fur. FurShellRenderer pulls the live colour/pulse/flash from the
  sibling MeshPainter (new getters), in phase with the body. A new X-Ray
  Fur Strength (default 0.2) tames the per-shell accumulation, since the
  body writes the glow once (opaque) but the fur stacks N transparent
  shells that each composite it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 16:33:21 +02:00

406 lines
24 KiB
Plaintext

// Generic shell-texturing fur/hair/grass shader (URP). STANDALONE prototype - NOT wired into the
// MeshPainter paint pipeline yet. The illusion of strands comes from rendering the mesh many times
// ("shells"), each extruded a little further along its normals, and alpha-clipping a per-cell strand
// mask that thins toward the tip. It is generic because it needs nothing but positions + normals + a
// UV set - it works on any mesh.
//
// DISPATCH: the shells are drawn by FurShellRenderer.cs via Graphics.RenderMeshInstanced. Each
// instance is one shell layer; the layer index arrives as SV_InstanceID and drives the extrusion
// height. Put this material on FurShellRenderer, NOT on a plain MeshRenderer (a MeshRenderer draws a
// single instance => only the root shell would show). The renderer is the single source of truth: it
// pushes EVERY tunable parameter below via a MaterialPropertyBlock, so the values authored on the
// material are only template defaults (they get overridden live at render time).
//
// hair vs fur vs grass = the same technique, only parameters differ (length, density, taper, bend).
Shader "Custom/FurShell"
{
Properties
{
[Header(Fur Shape)]
_ShellCount("Shell Count (driven by FurShellRenderer)", Range(1, 64)) = 32
_ShellLength("Fur Length", Range(0.0, 2.0)) = 0.15
_Density("Strand Density", Range(1.0, 1000.0)) = 250
_Thickness("Strand Thickness", Range(0.0, 1.0)) = 0.5
_Taper("Tip Taper", Range(0.1, 8.0)) = 2.0
_TipFill("Tip Fill (bridge shell gaps)", Range(0.0, 1.0)) = 0.35
_Curvature("Gravity / Curl Bend", Range(0.0, 1.0)) = 0.25
_DirRandomness("Direction Randomness (per strand)", Range(0.0, 1.0)) = 0.3
_SilhouetteStretch("Silhouette Strand Stretch", Range(0.0, 5.0)) = 2.0
[Header(Fur Shading)]
// 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)]
// 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
[Header(Wind)]
_WindStrength("Wind Strength", Range(0.0, 1.0)) = 0.1
_WindSpeed("Wind Speed", Range(0.0, 10.0)) = 2.0
_WindFrequency("Wind Spatial Frequency", Range(0.0, 10.0)) = 1.0
_WindDirection("Wind Direction (object XZ)", Vector) = (1, 0, 0, 0)
// Avatar world scale, pushed by FurShellRenderer (transform.lossyScale). Drives the curvature droop
// so the curl stays proportional at any player size; 1 = authored size (see the vertex shader).
[HideInInspector] _FurObjScale("Object Scale (driven by FurShellRenderer)", Float) = 1
// How strongly the avatar scale drives the fur SIZE: 1 = fully proportional (fur shrinks/grows with the
// avatar), 0 = constant world size (same absolute fur length at any avatar scale). Proportional fur
// reads as too short on the small hider and too long on the big hunter, so this blends between them.
[HideInInspector] _FurScaleInfluence("Scale Influence (driven by FurShellRenderer)", Range(0, 1)) = 0.5
[Header(XRay Reveal)]
// See-through reveal glow mirrored from the sibling MeshPainter's body X-ray so the fur strands glow
// WITH the reveal instead of hiding it under opaque fur. rgb = HDR glow colour, a = fade master.
// Default (0,0,0,0) => additive term is zero, so the fur looks unchanged until a reveal fades in.
[HDR] _XRayColor("X-Ray Overlay Color", Color) = (0, 0, 0, 0)
// Breathing pulse kept identical to the body halves (PlayerCamo.hlsl / XRayOverlay) so they stay in phase.
_XRayPulseSpeed("X-Ray Pulse Speed", Float) = 3
_XRayPulseStrength("X-Ray Pulse Strength", Range(0, 1)) = 0.5
_XRayPulseShape("X-Ray Pulse Shape", Range(1, 8)) = 3
// The fur draws _ShellCount stacked TRANSPARENT shells; the additive glow below would composite once
// PER overlapping layer and blow out (the body writes it once, opaque). This scales the per-shell glow
// down so the stacked total reads at roughly the body's intensity. Tune to match the body glow.
_XRayFurStrength("X-Ray Fur Strength (tames shell stacking)", Range(0, 1)) = 0.2
}
SubShader
{
Tags
{
"RenderType" = "Transparent"
"RenderPipeline" = "UniversalPipeline"
"Queue" = "Transparent"
}
LOD 200
Pass
{
Name "FurForward"
Tags { "LightMode" = "UniversalForward" }
Cull Back
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
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_instancing
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile_fragment _ _SHADOWS_SOFT
#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 _SolidRoot;
float _WindStrength;
float _WindSpeed;
float _WindFrequency;
float4 _WindDirection;
float _FurObjScale;
float _FurScaleInfluence;
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);
// Effective fur-size scale. The instance matrix already multiplies the object-space fur
// displacement by the avatar's world scale (_FurObjScale), so at full influence the fur is
// fully proportional (small avatar => small fur, like the body). _FurScaleInfluence blends
// that toward a CONSTANT world size (influence 0 => same absolute fur length at any avatar
// size), because fully proportional fur reads as too short on the small hider and too long on
// the big hunter. dispComp pre-divides the object-space offset so that after the matrix's
// *_FurObjScale the NET world displacement equals effScale, not _FurObjScale.
float objScale = max(_FurObjScale, 1e-4);
float effScale = lerp(1.0, objScale, _FurScaleInfluence);
float dispComp = effScale / 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. It uses the SAME effScale as the extrusion above (not the raw
// _FurObjScale), so the curl keeps its SHAPE relative to the fur length at any avatar size and
// at any influence setting - dialing the length toward constant carries the droop with it
// instead of leaving it too strong on the shrunk hider / too weak on the grown hunter. Applied
// after moving to world space, so the droop follows true world down regardless of player rotation.
positionWS.y -= _Curvature * _ShellLength * h * h * effScale;
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;
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;
clip(alpha - 0.003h); // drop fully-transparent fragments (perf + avoids stray blend)
// --- 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);
float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS);
Light mainLight = GetMainLight(shadowCoord);
half ndotl = saturate(dot(N, mainLight.direction)) * 0.5h + 0.5h; // soft half-lambert
half3 lighting = mainLight.color * (ndotl * mainLight.shadowAttenuation);
half3 ambient = SampleSH(N);
half3 color = albedo * (lighting + ambient);
#ifdef _ADDITIONAL_LIGHTS
uint addCount = GetAdditionalLightsCount();
for (uint i = 0u; i < addCount; i++)
{
Light lt = GetAdditionalLight(i, input.positionWS);
half nl = saturate(dot(N, lt.direction)) * 0.5h + 0.5h;
color += albedo * lt.color * (nl * lt.distanceAttenuation * lt.shadowAttenuation);
}
#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;
color += rim * mainLight.color * albedo;
// X-Ray reveal glow on the VISIBLE fur, mirroring the body's forward half (PlayerCamo.hlsl)
// EXACTLY so the strands glow IN PHASE with the body instead of hiding its reveal under
// opaque fur. Additive HDR term; _XRayColor.a is the eased fade master (0 when off => fur
// unchanged). FurShellRenderer pushes the same colour/pulse/flash state the sibling
// 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.
color += _XRayColor.rgb * (_XRayColor.a * xrayPulse * _XRayFurStrength);
return half4(color, alpha);
}
ENDHLSL
}
}
FallBack Off
}