This chapter is about the look that makes games like Genshin Impact, Honkai: Star Rail, and Guilty Gear Xrd instantly recognizable: characters that look like a hand-drawn anime instead of a photograph. That look is not an accident of the art — it is built inside the shader (the small program that runs on the GPU and decides the final color of every pixel). In this chapter you will build that look piece by piece: flat cel shading, art-directed shadow colors, clean outlines, a glowing rim, stylized highlights, and the special tricks studios use for faces and hair. Every idea follows the same shape: a short piece of shader code, a picture of what it does on screen, then a plain explanation of why.
We write these examples in HLSL (High-Level Shading Language — the C-like language Unity uses for shaders) inside Unity's URP (Universal Render Pipeline — Unity's modern, customizable rendering setup). You already know C, so HLSL will feel familiar: it has float, if, for, functions, and structs. The difference is that a shader runs once for every vertex and once for every pixel, in parallel, thousands of times per frame.
Most modern 3D games use PBR (Physically Based Rendering — shading that tries to copy how real light physically bounces off real materials). PBR asks a physics question: given this rough metal, this light, and this camera, what brightness would a real camera actually record? The answer is a smooth, continuous gradient of light, and the result looks like a photograph.
NPR stands for Non-Photorealistic Rendering — shading that deliberately ignores physics to imitate a drawing, painting, comic, or anime cel. NPR asks a completely different question: not "what would a camera record?" but "what would an artist draw here?" An anime artist does not paint a smooth gradient across a character's cheek. They fill it with one flat skin color, then add one hard-edged shadow shape, then one bright highlight. NPR shaders reproduce those artistic decisions in code.
Why do studios like HoYoverse spend so much effort on this? Because the appeal of 2D anime is enormous, but 2D art cannot rotate freely in a 3D world. NPR is the bridge: real 3D models and animation, wearing a coat of shading that keeps the flat, clean, expressive charm of a drawing from every camera angle. The rest of this chapter is the toolbox that builds that coat.
N·L, but instead of using it directly as "how bright," we use it to look up what color the artist decided that region should be.Almost everything in this chapter rests on a single quantity you have seen before: N·L (read "N dot L"), the dot product of the surface normal and the light direction.
1 when the surface faces the light head-on, 0 when the light grazes the surface sideways, and negative when the surface faces away.Because negative light makes no sense, we clamp it to zero with saturate (a shader function that clamps a value into the range 0 to 1). Classic smooth Lambert shading (the standard diffuse lighting model) is just this:
// N and L are unit vectors. dot() is the dot product.
half ndl = saturate(dot(N, L)); // 0..1, a smooth gradient
half3 lit = albedo * lightColor * ndl; // darker as the surface turns away
Worked example: say the normal points straight up, N = (0, 1, 0), and the light comes from the upper-right, L = (0.6, 0.8, 0) (already length 1). Then dot(N, L) = 0*0.6 + 1*0.8 + 0*0 = 0.8. So this spot is 80% lit. Turn the surface to face away and the dot goes negative, saturate makes it 0, and the spot is dark.
That smooth gradient from 1.0 down to 0.0 is exactly what PBR wants and exactly what anime does not want. Our whole job in cel shading is to take this smooth number and chop it into a few flat steps. Everything else is decoration on top.
Cel shading (also called toon shading; "cel" comes from the celluloid sheets old animators painted on) means replacing the smooth light gradient with a small number of flat regions. The technical word is quantize — snap a continuous value to the nearest of a few fixed levels.
The simplest version is two-tone: shadow or light, nothing between. The tool is step: step(edge, x) returns 0 when x is below edge, and 1 otherwise.
// Two-tone toon. step(edge, x) = 0 if x < edge, else 1.
half ndl = saturate(dot(N, L));
half toon = step(0.5, ndl); // hard: 0 (shadow) or 1 (light)
// Mix a dark shade and a light shade using that 0/1 switch.
half3 cel = albedo * lerp(0.35, 1.0, toon); // 35% brightness OR 100%
lerp(a, b, t) is linear interpolation: it returns a when t is 0 and b when t is 1. Since toon is only ever 0 or 1, this picks either the dark shade (0.35) or the full color (1.0) — no in-between. Trace it:
For more than two tones, quantize into bands with floor (round down to the nearest whole number):
// Posterize the gradient into N flat bands.
half bands = 3.0;
half stepped = floor(ndl * bands) / bands; // gives 0, 0.333, 0.667
half3 cel3 = albedo * lightColor * stepped;
Multiply ndl (0..1) by 3, floor it, divide back: any ndl in [0, 0.333) becomes 0, in [0.333, 0.667) becomes 0.333, and so on. The result is three flat brightness levels with hard jumps between them. Here is the whole point in one picture:
The hard line where one band meets the next is called the terminator (the boundary between lit and shadowed areas). In anime shading the terminator is the star of the show — its clean, deliberate shape is what reads as "drawn." A smooth gradient has no terminator at all, which is why PBR never looks like a cartoon.
A pure step gives a mathematically perfect hard edge — and on screen that edge shows ugly aliasing (jagged, stair-stepped pixels along a slanted line). We want the edge to still look crisp but not be jagged. The fix is a very narrow smoothstep instead of step.
smoothstep(a, b, x) returns 0 below a, 1 above b, and a smooth S-curve in between. If a and b are close together, the transition is short — crisp to the eye, but blended over one or two pixels so it does not jag.
// Narrow blend window around the terminator (0.5).
half e = 0.015; // half-width of the blend
half lit = smoothstep(0.5 - e, 0.5 + e, ndl); // crisp but anti-aliased
There is a smarter version that keeps the softness the same thickness everywhere on screen, no matter how the surface is angled. It uses fwidth (a shader function that reports how fast a value changes from one pixel to the next — a measure of screen-space slope):
// Softness scaled to on-screen change, so the edge is uniform width.
half d = fwidth(ndl);
half litAA = smoothstep(0.5 - d, 0.5 + d, ndl);
Where ndl changes fast (a steeply angled surface), d is large and the blend widens; where it changes slowly, d is small and the blend tightens. The visible edge ends up the same crispness across the whole model. This is the same trick font renderers use to keep letters smooth at any size.
fwidth). If you widen it too much you are back to a smooth gradient — you lose the flat-band look. The goal is "sharp but not jaggy," not "soft."So far the bands come from math in code, and their colors are just darker/brighter versions of the base color. Real anime shading is more opinionated: shadows are often cool (a slight blue or purple tint) while lit areas are warm. Physics would never do that from one white light — but an artist would. The clean way to hand that decision to an artist is a ramp texture.
A ramp texture is a wide, short image — say 256 pixels wide, a few pixels tall. The artist paints it left to right: deepest shadow on the far left, full light on the far right, with the band boundaries and every color chosen by hand. At runtime the shader samples this strip using N·L as the horizontal coordinate.
// Half-Lambert remap: uses the whole ramp and lets shadow wrap softly.
half hl = ndl * 0.5 + 0.5; // maps -1..1 into 0..1, never fully 0
// u = light amount, v = which material row of the ramp to read.
half2 rampUV = half2(hl, _MaterialRow);
half3 shade = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, rampUV).rgb;
half3 color = albedo * lightColor * shade;
Two things are happening. First, the half-Lambert remap (ndl * 0.5 + 0.5, a classic trick from Valve's Half-Life 2): it stretches the raw -1..1 dot into the full 0..1 range so the ramp's whole width gets used, and the shadow side wraps around softly instead of slamming to black. Second, the hard bands are baked into the texture: if the artist paints two flat blocks with a sharp seam, you get two-tone cel shading whose exact colors and boundary position are under art control — no code change needed.
The vertical coordinate v is the quiet hero. Stack several ramps in one texture — row 0 for skin, row 1 for cloth, row 2 for metal — and pass _MaterialRow per material. Now every material on the character reads shadow color from its own painted ramp. This is exactly how Genshin and Honkai: Star Rail give skin warm soft shadows while metal gets cold sharp ones, all from one shader.
Clean dark outlines around a character are half of the anime look. The most common technique in games is the inverted hull (also called the "shell" or "backface" method). The idea: draw the character twice.
Cull Front throws away the front-facing triangles). This slightly-bigger dark shell hides behind the real model, and only its rim peeks out around the silhouette.Here is the outline pass in ShaderLab + HLSL. Culling means discarding triangles that face a chosen way; Cull Front keeps only the ones facing away from the camera — the back of the shell, which is what surrounds the silhouette.
// --- OUTLINE PASS (put this Pass FIRST in the SubShader) ---
Pass
{
Name "Outline"
Cull Front // keep only back faces of the shell
HLSLPROGRAM
#pragma vertex OutlineVert
#pragma fragment OutlineFrag
float _OutlineWidth;
half4 _OutlineColor;
Varyings OutlineVert(Attributes IN)
{
Varyings OUT;
// push the vertex OUT along its normal, in object space
float3 posOS = IN.positionOS.xyz + IN.normalOS * _OutlineWidth;
OUT.positionCS = TransformObjectToHClip(posOS);
return OUT;
}
half4 OutlineFrag(Varyings IN) : SV_Target
{
return _OutlineColor; // flat dark line, no lighting
}
ENDHLSL
}
Two practical problems and their fixes:
_OutlineWidth is measured in world units. To keep a constant thickness on screen, scale the push by the distance to the camera (multiply the offset by the clip-space w, or by the view-space depth). Studios expose a small curve so distant characters do not lose their outline entirely.Cull Front. If you push the whole mesh out and draw its front faces, the dark shell covers the entire character and you see a black blob, not an outline. You must keep only the back faces so the real model draws over everything except the thin rim.The second way to make outlines never touches the character's mesh. Instead, after the scene is rendered, a full-screen pass (a shader that runs once per screen pixel, like a photo filter) looks for edges by comparing each pixel to its neighbors in the depth and normal buffers, and paints a line wherever it finds a sudden change.
// Simplified depth-based edge detect (a "Roberts cross" style compare).
half4 EdgeFrag(Varyings IN) : SV_Target
{
float2 uv = IN.uv;
float2 texel = _CameraDepthTexture_TexelSize.xy; // size of one pixel
half d0 = SampleSceneDepth(uv); // this pixel
half d1 = SampleSceneDepth(uv + texel); // diagonal
half d2 = SampleSceneDepth(uv + float2(texel.x, -texel.y));
half edge = abs(d0 - d1) + abs(d0 - d2);
half edgeMask = step(_EdgeThreshold, edge); // 1 on an edge
half3 scene = SampleSceneColor(uv);
return half4(lerp(scene, _LineColor.rgb, edgeMask), 1);
}
Compare the two outline methods so you can choose on purpose:
Most anime games use the inverted hull as the main outline because it is cheap per character and easy for artists to control width and color per material. They sometimes add a light edge-detect pass on top to catch inner detail lines the hull cannot reach. It is common to combine both.
Try plain N·L shading on an anime face and it looks wrong: the low, soft geometry of a stylized head produces blotchy, wobbling shadows around the nose and eye sockets that crawl as the light moves. Anime faces need shadow shapes that are clean, deliberate, and flip crisply as the light swings from one side to the other. Studios solve this with an SDF face shadow map.
SDF means Signed Distance Field — here, a grayscale texture painted onto the face where the value at each pixel encodes at what light angle that pixel falls into shadow. Pixels near the nose go dark early (a small turn of the light shadows them); pixels near the ear stay lit until the light is almost sideways. It is painted once by an artist and captures the exact way an animator would draw the face shadow sweeping across.
At runtime we compare the light's horizontal angle (relative to the way the head is facing) against the SDF value. Because a painted map only covers turning one way, we mirror the texture horizontally when the light crosses to the other cheek.
// faceForward / faceRight come from the head bone (fed in as uniforms).
float3 L = normalize(_MainLightPosition.xyz);
half FdotL = dot(faceForward, L); // light in front(+) or behind(-)
half RdotL = dot(faceRight, L); // light on right(+) or left(-)
// Mirror the SDF map when the light is on the left side of the face.
float2 uv = IN.uv;
uv.x = (RdotL < 0) ? (1.0 - uv.x) : uv.x;
half sdf = SAMPLE_TEXTURE2D(_FaceSDF, sampler_FaceSDF, uv).r; // 0..1
// As the face turns toward the light, the threshold sweeps the shadow.
half threshold = 1.0 - (FdotL * 0.5 + 0.5); // remap front/back to 0..1
half lit = step(threshold, sdf); // clean binary face shadow
The result is a shadow boundary that is a single clean curve, always in an artist-approved place, that slides smoothly across the face as the light rotates — never blotchy, never crawling. This is the trick behind the "how do their faces look so clean" question people ask about Genshin, Honkai, and Arknights: Endfield. You do not need to write your own SDF at first; the takeaway is that faces get a separate, painted shadow system instead of relying on geometry lighting.
Anime hair does not get a round dot of shine like a billiard ball. It gets a long, stretched highlight band that runs across the strands and slides as the head turns. That stretched shine is an anisotropic highlight — "anisotropic" means the surface reflects differently along different directions (hair is smooth along each strand but rough across them).
The standard model is Kajiya-Kay, which bases the highlight on the hair's tangent (the direction along the strand) instead of the normal. We shift the tangent a little with the normal to move the band where the artist wants, then raise a stretched term to a power for tightness:
// T is the tangent (along the strand); H is the half vector.
float3 H = normalize(L + V);
float3 Tshift = normalize(T + N * _Shift); // slide the band up/down
half dotTH = dot(Tshift, H);
half sinTH = sqrt(1.0 - dotTH * dotTH); // stretched across strands
half spec = pow(sinTH, _HairExp) * _HairStrength;
half3 hairHi = spec * _HairColor.rgb * _HairMask; // mask into a strip
sinTH is large when the half vector lines up across the strands and small when it lines up along them — that is what stretches the dot into a band. _HairShift (fed via a small noise or flow texture) breaks the band into the slightly ragged, layered look real anime hair has, instead of one perfect stripe. Many stylized hair shaders also lay a second, brighter colored strip on top — the painted "hair band" you see as a glossy ring — using the same mask.
Rim light is the bright line that appears along the edge of a character where the surface curves away from the camera, as if a light sat behind them. It does two jobs: it adds that dreamy anime glow, and it separates the character from the background so they never look pasted flat onto the scene.
The math is a Fresnel-style term (Fresnel: surfaces reflect more at grazing angles — the reason a lake mirrors the sky near the far shore but looks clear at your feet). We only need the cheap approximation: rim is bright where the normal N is perpendicular to the view direction V, which is exactly the silhouette.
// V = direction from the surface toward the camera (unit vector).
half rim = 1.0 - saturate(dot(N, V)); // ~1 at the silhouette, ~0 facing us
rim = pow(rim, _RimPower); // higher power = thinner, tighter edge
rim *= step(0.1, ndl); // optional: only on the lit side
half3 rimColor = rim * _RimColor.rgb * _RimStrength;
color += rimColor; // add the glow on top
pow(rim, _RimPower) controls thickness: a power of 1 gives a wide, soft halo; a power of 8 gives a thin, sharp filament of light. Multiplying by step(0.1, ndl) keeps the rim only where the surface is already lit, so the glow reads as coming from the key light rather than floating everywhere. Honkai: Star Rail goes further and points the rim toward the light direction, so the bright edge always sits on the side that faces the scene's light — a small touch that sells the illusion.
PBR specular is a smooth, physically-shaped blob of shine. Anime specular is usually a hard-edged glint — a crisp shape that pops, especially on eyes, metal, and glossy accessories. We build it exactly like cel shading, but on the highlight term.
The highlight uses N·H, where H is the half vector — the direction exactly halfway between the light and the view. When the surface normal lines up with H, you are looking straight into the reflection, so N·H is near 1 right at the hotspot.
// Hard anime glint: one crisp on/off highlight.
float3 H = normalize(L + V);
half ndh = saturate(dot(N, H));
half spec = step(1.0 - _Gloss, ndh); // hard white blob where ndh is high
spec *= _SpecMask; // paint where shine is allowed
half3 specColor = spec * _SpecColor.rgb;
color += specColor;
_Gloss near 1 makes the threshold 1 - _Gloss tiny, so only the very brightest sliver passes step — a small tight glint. Lower gloss widens the blob. The _SpecMask texture is essential: it lets the artist say "shine on the belt buckle and the eyes, nowhere else," which is exactly how stylized characters get selective, deliberate sparkle instead of shine smeared over everything.
For eyes and metal, studios often skip lighting math entirely and use a MatCap (Material Capture — a small round image of a pre-lit sphere). You sample it using the surface normal transformed into view space (the camera's frame of reference), which gives an instant, art-directed metallic or glassy look that always faces the camera nicely.
// MatCap: sample a pre-lit sphere image by the view-space normal.
float3 nVS = TransformWorldToViewDir(N, true); // normal in camera space
float2 mUV = nVS.xy * 0.5 + 0.5; // map -1..1 to 0..1 UV
half3 matcap = SAMPLE_TEXTURE2D(_MatCap, sampler_MatCap, mUV).rgb;
Now we assemble the pieces into one lighting pass. Read it top to bottom — it is exactly the order we taught: gather surface data, get the main light, look up quantized color through the ramp, then add stylized specular and rim on top.
half4 LitFrag(Varyings IN) : SV_Target
{
// 1. surface data
half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv).rgb
* _BaseColor.rgb;
half3 N = normalize(IN.normalWS);
half3 V = normalize(GetWorldSpaceViewDir(IN.positionWS));
// 2. main light (direction, color, and shadow term from URP)
float4 shadowCoord = TransformWorldToShadowCoord(IN.positionWS);
Light mainLight = GetMainLight(shadowCoord);
half3 L = mainLight.direction;
half atten = mainLight.shadowAttenuation;
half ndl = saturate(dot(N, L)) * atten;
// 3. quantized lighting through the ramp (colors come from the texture)
half hl = ndl * 0.5 + 0.5;
half3 ramp = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex,
half2(hl, _MaterialRow)).rgb;
half3 color = albedo * mainLight.color * ramp;
// 4. stylized hard specular (added on top)
half3 H = normalize(L + V);
half ndh = saturate(dot(N, H));
half spec = step(1.0 - _Gloss, ndh) * _SpecMask;
color += spec * _SpecColor.rgb;
// 5. rim light (added on top, only on the lit side)
half rim = pow(1.0 - saturate(dot(N, V)), _RimPower);
rim *= step(0.1, ndl);
color += rim * _RimColor.rgb * _RimStrength;
return half4(color, 1.0);
}
And here is the overall ShaderLab skeleton that wires two passes together — outline first, then the toon lighting pass above. The full HLSL bodies live inside each HLSLPROGRAM block; the structure is what matters here.
Shader "Toon/Character"
{
Properties
{
_BaseMap ("Base", 2D) = "white" {}
_BaseColor ("Tint", Color) = (1,1,1,1)
_RampTex ("Lighting Ramp", 2D) = "white" {}
_MaterialRow ("Ramp Row", Range(0,1)) = 0.5
_Gloss ("Glossiness", Range(0,1)) = 0.8
_SpecColor ("Spec Color", Color) = (1,1,1,1)
_RimColor ("Rim Color", Color) = (1,1,1,1)
_RimPower ("Rim Power", Range(1,16)) = 4
_RimStrength ("Rim Strength", Range(0,4)) = 1
_OutlineColor ("Outline", Color) = (0,0,0,1)
_OutlineWidth ("Outline Width", Range(0,0.05)) = 0.01
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
// PASS 1: outline shell (inverted hull) -- see Section 6
Pass { Name "Outline" Cull Front /* HLSLPROGRAM ... ENDHLSL */ }
// PASS 2: toon lighting -- LitFrag above
Pass
{
Name "Forward"
Tags { "LightMode"="UniversalForward" }
Cull Back
// HLSLPROGRAM ... ENDHLSL
}
}
}
Read the fragment once more and notice the pattern from Section 10's tip: albedo * ramp is a multiply (the shadow lookup darkens and tints the base color), while specular and rim use += (added light energy). That single discipline — multiply for shadow, add for light — keeps the result readable no matter how many effects you stack.
To turn the ideas above into a working Unity project, these are the concrete pieces you touch:
.shader file with HLSLPROGRAM blocks, and include URP's core headers so helpers like GetMainLight, TransformObjectToHClip, and SampleSceneDepth exist: #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl".GetMainLight(shadowCoord) returns a Light struct with .direction, .color, and .shadowAttenuation. That one call replaces the hand-fed L and lightColor in the earlier snippets.GetAdditionalLightsCount() and GetAdditionalLight(i, positionWS). In toon shading you usually feed each additional light's N·L through the same ramp so they stay stylized instead of turning smooth.Pass. Give it a LightMode tag URP will draw (for example "SRPDefaultUnlit") or keep it as the pipeline's expected extra pass, and remember Cull Front.ScriptableRendererFeature that blits a full-screen material after opaque rendering. It needs the depth (and ideally normals) buffers, so enable Depth Texture on the URP asset and add a DepthNormals prepass if you want crease lines, not just silhouettes.N·L and Lambert working, (2) swap in the ramp, (3) add the inverted-hull outline, (4) add rim, (5) add stylized spec, (6) only then tackle face SDF and hair. Each step is visible on screen, so you always know which piece broke.albedo * saturate(N·L).floor and step do).step gives a hard 0/1 cutoff; smoothstep gives a short smooth ramp between two edges.N·L.N·L * 0.5 + 0.5 so the whole ramp is used and shadows wrap softly.Cull Front keeps back faces.N·H drives the specular highlight.floor, then (b) predict the band index for the input values below. Assume ndl is already saturated.
half ndl = saturate(dot(N, L));
half toon = step(0.5, ndl); // 2 bands: 0 or 1
half3 col = albedo * lerp(0.35, 1.0, toon);
Predict the output of your 3-band version for ndl = 0.10, 0.40, 0.70, 0.95.
Then explain in one sentence why an artist might still prefer a ramp texture over your floor code.
(a) Multiply by 3, floor, divide back:
half bands = 3.0;
half stepped = floor(ndl * bands) / bands; // gives 0, 0.333, 0.667
half3 col = albedo * lightColor * stepped;
(b) floor(ndl * 3) / 3 for each input:
Note that 0.70 and 0.95 both land in the top band — with 3 bands you never reach 1.0 from floor unless ndl hits exactly 1.0. (If you want the top band to be full brightness, use floor(ndl * (bands-1) + 0.5) / (bands-1) to round instead.)
Why a ramp is still better: floor gives evenly spaced bands in dull darker/lighter versions of one color. A ramp texture lets the artist place the band boundaries wherever they want and choose each band's exact hue — cool blue shadow, warm highlight — without touching code.
float3 posOS = IN.positionOS.xyz + IN.normalOS * _OutlineWidth;
OUT.positionCS = TransformObjectToHClip(posOS);
Explain in one or two sentences why the gaps appear, and describe the standard fix (no full shader needed — just what data you change and how the vertex line changes).
Why: At a hard edge the mesh has split normals — the same corner position stores two (or more) different normals, one per face. Pushing each copy along its own, different normal moves them apart, tearing a gap in the shell right at the corner.
Fix: Bake a set of smoothed (averaged) normals into the mesh — compute one averaged normal per position and store it in a spare vertex channel such as a UV set or the vertex color (packed to a direction). Push the outline along that smoothed normal instead of the shading normal, so both copies of the corner move the same way and stay joined:
// _smoothNormalOS was baked into a spare channel (e.g. TEXCOORD3).
float3 posOS = IN.positionOS.xyz + IN.smoothNormalOS * _OutlineWidth;
OUT.positionCS = TransformObjectToHClip(posOS);
The real shading pass still uses the original hard normals for correct lighting; only the outline push uses the smoothed ones. This is exactly what shipped anime-game characters do.
rim *= step(0.1, ndl)). A lighting artist wants the opposite: a cool rim that appears only on the character's shadow side, to fake bounced light from the environment. Write the rim term so it only appears where the surface is in shadow, pick a sensible power for a thin edge, and trace the value at a silhouette pixel that is in shadow (dot(N,V) = 0.05, ndl = 0.0).
Flip the mask: use 1 - ndl (or step(ndl, 0.1)) so the rim survives only where ndl is low — the shadow side. A power around 4 gives a thin edge.
half rim = pow(1.0 - saturate(dot(N, V)), 4.0); // thin edge
rim *= (1.0 - step(0.1, ndl)); // ONLY on the shadow side
half3 rimColor = rim * _CoolRimColor.rgb * _RimStrength;
color += rimColor;
Trace at the given pixel (dot(N,V) = 0.05, ndl = 0.0):
So this silhouette pixel, being in shadow, gets a strong cool rim (~0.81 before color). A lit silhouette pixel (ndl above 0.1) would multiply by 1 - 1 = 0 and get no rim — exactly the shadow-only effect the artist asked for.