Back in January I read a writeup on dither-based occlusion fading in UE5 and committed a placeholder README to my rendering sandbox the same day. The placeholder sat there for five months. In June I finally built the thing — twice, once in hand-written HLSL and once in Shader Graph, specifically to see where the two approaches diverge.
The sandbox is public, so everything in this post is in the repo:
https://github.com/tang3cko/Sandbox_001
The problem dithering solves
The setup is the usual third-person camera complaint: a wall slides between the camera and the player, and the player disappears. The naive fix — swap the wall’s material to alpha-blended transparency — drags in the whole sorting mess: the wall leaves the opaque queue, stops writing depth, and starts fighting every other transparent in the scene for draw order.
Dither transparency sidesteps all of it. The surface stays in the opaque queue and clip()s a screen-space pattern of its own pixels instead — at 50% fade, half the pixels are simply gone. Depth writes stay correct, sorting stays opaque, and as a bonus the shadow pass can apply the same dither so a faded wall also casts a lighter shadow. From a normal viewing distance the checkerboard reads as translucency.
The shared C# side
Both shader versions are driven by the same component, DitherOcclusionFader. Each frame it sphere-casts from the camera to the target, and any renderer in the way whose material exposes _DitherAlpha gets faded down through a MaterialPropertyBlock:
var hits = Physics.SphereCastAll(
origin, castRadius, toTarget / distance, distance, occluderMask);
foreach (var hit in hits)
{
if (hit.transform == target || hit.transform.IsChildOf(target)) continue;
var renderer = hit.collider.GetComponentInChildren<Renderer>();
if (renderer == null || renderer.sharedMaterial == null) continue;
if (!renderer.sharedMaterial.HasProperty(DitherAlphaId)) continue;
occludedThisFrame.Add(renderer);
alphaByRenderer.TryAdd(renderer, 1f);
}
The MaterialPropertyBlock is the important choice. Several walls share one material, and only the wall actually blocking the camera should fade — per-renderer property blocks give that without instantiating materials. Alpha animates with Mathf.MoveTowards, and once a renderer is fully restored its block is cleared with SetPropertyBlock(null) so it drops back to the shared material untouched.
The component runs under [ExecuteAlways], with one edit-mode catch: outside Play Mode the physics scene isn’t ticked, so the fader calls Physics.SyncTransforms() before the cast. Without it, dragging a wall around in the Scene view queries yesterday’s collider positions.
Version one — HLSL
The hand-written shader is a 4x4 ordered Bayer matrix and a clip():
// Ordered 4x4 Bayer matrix, normalized to thresholds in (0, 1).
static const half BAYER_4X4[16] =
{
0.5 / 16, 8.5 / 16, 2.5 / 16, 10.5 / 16,
12.5 / 16, 4.5 / 16, 14.5 / 16, 6.5 / 16,
3.5 / 16, 11.5 / 16, 1.5 / 16, 9.5 / 16,
15.5 / 16, 7.5 / 16, 13.5 / 16, 5.5 / 16
};
void ApplyDither(float4 positionCS, half alpha)
{
uint2 pixel = uint2(positionCS.xy);
uint index = (pixel.y % 4) * 4 + (pixel.x % 4);
clip(alpha - BAYER_4X4[index]);
}
Three passes — ForwardLit, ShadowCaster, DepthOnly — and all three apply the dither, which is what keeps depth and shadows consistent with what’s visibly left of the wall. The lighting is deliberately minimal: main light Lambert plus spherical-harmonics ambient, nothing else. The whole file is 216 lines.
The part I actually wanted hand-written control for is the second fade mode. Instead of dithering the whole wall uniformly, CircularHole cuts a screen-space circle around the player’s viewport position:
half ComputeCameraDitherAlpha(float4 positionCS)
{
half alpha = _DitherAlpha;
if (_HoleRadius > 0)
{
float2 uv = GetNormalizedScreenSpaceUV(positionCS);
float2 offset = uv - _HoleCenter.xy;
offset.x *= _ScreenParams.x / _ScreenParams.y;
half edge = smoothstep(_HoleRadius - _HoleSoftness, _HoleRadius, length(offset));
alpha = min(alpha, lerp(_HoleAlpha, 1.0, edge));
}
return alpha;
}
The C# side feeds _HoleCenter from WorldToViewportPoint and animates the radius with the fade progress, so the hole grows open around the player. One subtlety that took a moment of thought: the hole is view-dependent, so the ShadowCaster pass deliberately ignores it — a hole that exists only from the camera’s perspective shouldn’t punch through the shadow map.
Version two — Shader Graph
The Shader Graph version is about 10 nodes: a Lit target, the built-in Dither node feeding Alpha, Alpha Clip Threshold near zero. Same clip(alpha - threshold) structure, a fraction of the authoring effort, and it took minutes rather than an evening.
Then I compared what the two versions actually generate, and wrote the numbers into the project README:
| Metric | HLSL | Shader Graph |
|---|---|---|
| Passes | 3 | 10 |
| Shader keywords | 7 | 50 |
| Source | 216 lines | 878 lines of JSON |
The Shader Graph output isn’t bloat for bloat’s sake — it’s the full URP Lit feature set. The extra passes are capabilities my HLSL version simply doesn’t have: a GBuffer pass means the material works in Deferred (mine is forward-only), DepthNormals enables SSAO, MotionVectors enables TAA and motion blur. The 50 keywords are the price: a much larger variant space, slower in-editor compiles, and per-pixel lighting that does PBR, GI, additional lights, fog, and decals whether or not the scene uses them.
A detail that surprised me: the SG Dither node normalizes its Bayer matrix by /17 where my hand-written version uses /16. Same visual idea, slightly different thresholds — the two walls don’t dissolve pixel-identically at the same alpha.
Where Shader Graph couldn’t follow
The circular hole is where the comparison stopped being apples to apples. CircularHole mode drives _HoleRadius, _HoleCenter, and friends — properties that only exist in the HLSL shader. The wall using the Shader Graph material doesn’t fail loudly in that mode; it just stays opaque, because the property block writes land on properties the shader never declared.
Reproducing the hole in Shader Graph is possible in principle — screen-position UV, aspect correction, a smoothstep, a min — but that’s a node forest for four lines of HLSL, or a Custom Function node, at which point I’m writing HLSL anyway with extra steps. I left the SG version without it and documented the limitation in the project README instead.
That asymmetry is the honest takeaway of the whole experiment. Shader Graph wins for a standard surface that should keep tracking URP’s feature set without me maintaining boilerplate. HLSL wins the moment the effect is non-standard — and my HLSL version now owns the URP boilerplate forever, which is why it lacks MotionVectors today.
Things still bugging me
- The HLSL version has no GPU instancing support for the hole properties — per-instance coordinates are on the README’s concept list but not implemented.
SphereCastAllevery frame inLateUpdateis fine for one camera and a handful of walls, and unmeasured beyond that.- The /16 vs /17 Bayer mismatch means mixed HLSL and SG walls in one scene dissolve subtly differently. Nobody would notice in gameplay. It bugs me anyway.
Wrap-up
The five-month-old placeholder folder turned out to be the right shape for this sandbox: each numbered subproject answers one question, and this one’s question — “where exactly does Shader Graph stop being enough?” — now has a concrete answer I can point at. The next time someone asks why I’d ever hand-write a shader in 2026, the circular hole is the example.
The repo with both versions, the comparison notes, and the fader component is public:
https://github.com/tang3cko/Sandbox_001
References
- ディザ抜きで一度に複数モデルが透過するのを防ぐには? (gamemakers.jp) — the UE5 writeup that kicked this off
- Unity — Shader Graph: Dither Node
- Unity — MaterialPropertyBlock
- Unity — Physics.SyncTransforms
- Sandbox_001 (GitHub)