Tutorial2 min read
Writing Custom Cel-Shading & Rim Lighting in HLSL for Unreal Engine 5
A step-by-step custom lighting model for stylized games — multi-band toon ramps and controllable rim light using a Custom material node.
Getting a clean anime look in Unreal means stepping outside the default physically based lighting. In this tutorial we replace the standard diffuse response with a custom toon ramp and add a rim light you can actually control.
Why default PBR fights stylized art
Unreal’s deferred renderer is built for physical correctness. That’s great for realism, but it causes problems for stylized projects:
- Muddy shadows. PBR diffuse fades smoothly across curved surfaces, when stylized characters need sharp, deliberate shade lines.
- Uncontrollable rim light. Fresnel depends on roughness rather than the view and light angle, so rim light bleeds into places you don’t want it.
- Fragile outlines. Post-process outlines alias at a distance and can’t be tinted per material.
The ramp formula
Instead of a hard threshold (which crawls during camera motion), we use a smoothstep with a small feather around the threshold:
Ramp(N, L) = smoothstep(threshold - feather, threshold + feather, dot(N, L))
threshold sets where light turns to shadow, and feather is a tiny blur that keeps the edge anti-aliased.
The HLSL
Create a Custom material expression and add inputs for WorldNormal, LightDir, ViewDir, and the parameters below. Paste this into the Code field:
float3 N = normalize(WorldNormal);
float3 L = normalize(LightDir);
float3 V = normalize(ViewDir);
// 1. Half-Lambert diffuse
float NdotL = dot(N, L);
float halfLambert = NdotL * 0.5 + 0.5;
// 2. Two-step ramp with a small anti-aliasing feather
float feather = max(0.001, FeatherRadius);
float tone1 = smoothstep(Threshold1 - feather, Threshold1 + feather, halfLambert);
float tone2 = smoothstep(Threshold2 - feather, Threshold2 + feather, halfLambert);
// 3. Blend deep shadow → shadow → lit
float3 baseTone = lerp(DeepShadowColor.rgb, ShadowColor.rgb, tone1);
float3 litTone = lerp(baseTone, AlbedoColor.rgb, tone2);
// 4. Rim light, masked to the lit side
float rimDot = 1.0 - saturate(dot(N, V));
float rimMask = pow(rimDot, RimExponent) * saturate(NdotL + RimLightWrap);
float3 finalRim = rimMask * RimColor.rgb * RimIntensity;
return float4(litTone + finalRim, 1.0);
Parameters
| Parameter | Range | Start with | What it does |
|---|---|---|---|
Threshold1 |
0–1 | 0.32 | Separates deep shadow from mid shadow. |
Threshold2 |
0–1 | 0.58 | The main light/shadow line. |
FeatherRadius |
0–0.1 | 0.015 | Softens the edge to prevent stair-stepping. |
RimExponent |
1–16 | 4.5 | Higher values give a thinner rim. |
That’s the whole shader. From here you can swap the lerp calls for a ramp texture lookup if you want hand-painted color bands.