Hair Rendering
Hair is card geometry rendered by ARC/Avatar/HairLit (Assets/Shaders/Avatar/HairLit.shader) in
the alpha-test queue. All quality presets render deferred, so the GBuffer pass is the one that
runs; ForwardLit is kept in sync for the forward fallback.
Edge resolve
Hair cards are a stack of nearly-transparent quads. A plain alpha test on that stack gives crunchy, aliased wisps and erodes coverage as cards shrink under minification, because a fragment is either fully kept or fully thrown away regardless of how much of the pixel the strands actually cover.
The shader instead uses hashed alpha testing (Wyman & McGuire, I3D 2017): the cutoff is a per-fragment random threshold ατ ∈ [0,1) rather than a constant, so a fragment survives with probability α. Coverage is then correct in expectation — a card at α = 0.3 keeps 30% of its pixels instead of all or none — and TAA averages the stochastic result back into a smooth edge.
HairHashedAlpha.hlsl computes the threshold. Three properties make the noise usable:
- Anchored to the groom
-
The hash is seeded from bind-pose object space (
positionOSstraight out of the vertex attributes, before skinning), so the pattern sticks to the strands and does not swim as the head turns or the sim moves the cards. - Decorrelated between layers
-
Hashing 3D position rather than a screen position or a UV is what makes overlapping cards pick independent thresholds. This is the load-bearing part for hair. The previous implementation used a screen-space interleaved gradient noise, which hands every card stacked under one pixel the same threshold — when the front card fails the test, every card behind it fails too, and you see a hole straight through the groom. Hashing UVs would be just as bad: hair atlases tile, so all cards map to the same small UV region.
- Discretised at pixel scale
-
The object-space coordinate is quantised to a cell whose size tracks the screen-space derivative, so sub-pixel camera movement returns the same value. The threshold blends the two nearest power-of-two cell sizes and then runs the blend back through the inverse CDF, because lerping two uniform variables is not itself uniform and the drifting variance strobes during motion.
_HashScale sets the target noise cell size in pixels. It defaults to 0.5 — sub-pixel, so TAA has
several samples per cell to average. Push it toward 1.0 if TAA is off or the groom is a long way
from the camera.
The hash is re-seeded every frame from the global _FrameIndex (set once per frame by
QualityManager). A static hash would converge under TAA to a fixed stipple pattern rather than to
the true coverage; varying it per frame is what turns the noise into a soft edge. The seed is
global precisely so that every camera-space pass agrees within a frame.
Which passes hash
GBuffer, ForwardLit and MotionVectors share one Alpha() in HairSurfaceInput.hlsl, so they
produce identical thresholds for the same fragment. MotionVectors must match or the hair ghosts
under TAA.
ShadowCaster deliberately keeps a fixed _Cutoff. Shadow maps rasterise from the light at a
different resolution, so the derivatives — and therefore the hash — would not correspond to
anything visible, and stochastic coverage in a heavily filtered shadow map is just noise.
DepthOnly and DepthNormals also still use a fixed _Cutoff via the stock URP passes. Deferred
rendering gets its depth from the GBuffer, so these only run if a feature explicitly asks for them;
if one ever does and the silhouette disagrees with the shaded hair, they need the same treatment as
ForwardLit.
Tuning
Coverage is now honest, which means hair reads thinner than it did under the old fixed cutoff — a
card authored at α = 0.9 is drawn at 90% coverage rather than solid. Tune with the alpha channel of
the atlas or _BaseColor.a, not by raising _Cutoff; the cutoff no longer feeds the visible passes.
Keep Mip Maps Preserve Coverage on the hair atlases. Hashed alpha fixes strands vanishing with distance, but the alpha decay in the mip chain still shifts the coverage it is preserving.