// 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)] [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 _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" {} _FurThreshold("Fur Cutoff (alpha)", Range(0.0, 1.0)) = 0.0 // 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) } SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "Queue" = "Geometry" } LOD 200 Pass { Name "FurForward" Tags { "LightMode" = "UniversalForward" } Cull Back ZWrite On ZTest LEqual 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; half4 _TipColor; float _ShellCount; float _ShellLength; float _Density; float _Thickness; float _Taper; float _TipFill; float _Curvature; float _DirRandomness; float _SilhouetteStretch; half _RootAO; half _RimPower; half _RimStrength; float _FurThreshold; float _SolidRoot; float _WindStrength; float _WindSpeed; float _WindFrequency; float4 _WindDirection; 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); // 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); // 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)); VertexPositionInputs vp = GetVertexPositionInputs(posOS); VertexNormalInputs vn = GetVertexNormalInputs(input.normalOS); output.positionCS = vp.positionCS; output.positionWS = vp.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: 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; // --- 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); // 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). 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); // --- 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; 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; return half4(color, 1.0h); } ENDHLSL } } FallBack Off }