Add generic shell-texturing fur shader and renderer
FurShell.shader renders N normal-extruded shells with per-strand alpha-cutout for hair/fur/grass on any mesh (needs only normals/UVs). Parameters cover length, density, thickness, taper, tip fill, curvature, per-strand direction randomness, wind sway and silhouette strand stretch. FurShellRenderer drives every parameter via MaterialPropertyBlock and draws the mesh shellCount times with RenderMeshInstanced from beginCameraRendering (works in edit, game and screenshot cameras). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
50efc7e8b3
commit
bf4297a3b1
4 files changed
+514
No files matched your search
@@ -0,0 +1,273 @@
|
||||
// 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(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 _WindStrength;
|
||||
float _WindSpeed;
|
||||
float _WindFrequency;
|
||||
float4 _WindDirection;
|
||||
CBUFFER_END
|
||||
|
||||
TEXTURE2D(_BaseMap);
|
||||
SAMPLER(sampler_BaseMap);
|
||||
|
||||
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);
|
||||
posOS += float3(0.0, -1.0, 0.0) * (_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);
|
||||
|
||||
// --- 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 root shell (h==0) stays fully solid so nothing shows through to the surface
|
||||
half alpha = 1.0h;
|
||||
if (h > 0.0)
|
||||
alpha = (dist < radius && h <= strandLen) ? 1.0h : 0.0h;
|
||||
clip(alpha - 0.5h);
|
||||
|
||||
// --- shading (lightweight URP) ---
|
||||
half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).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
|
||||
}
|
||||
Reference in new issue
Block a user