FurShell now reads an RGBA fur map (RGB = per-texel strand colour, A = fur length) so the standalone look can be driven per-texel. Alpha 0 clips the whole shell stack (root included) so a painted body shows through; _SolidRoot keeps the opaque base shell only for the full-coverage prototype. White map default keeps the standalone prototype unchanged. FurShellRenderer drops the MeshFilter requirement and resolves its mesh per frame: on a SkinnedMeshRenderer it borrows the SkinnedColliderBaker's per-frame baked pose so the fur follows the animation with no extra bake. Curvature/gravity now bends strands toward WORLD down (converted into object space via unity_WorldToObject) instead of object-local down, so a rotated player mesh droops downward correctly. Identity rotation is unchanged, so the test sphere is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
306 lines
15 KiB
GLSL
306 lines
15 KiB
GLSL
// 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
|
|
}
|