7.4 Stylized / Toon / NPR (anime look)

Phase 7 · Graphics & Rendering · Study time: 40–80 h

Non-photorealistic rendering — cel shading, outlines, anime skin and hair shading, ramp lighting. This is HoYoverse's signature; a strong portfolio piece here is gold.

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.

1. What NPR Means, and Why Anime Games Look Like That

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.

PBR (photo look) NPR (anime look) smooth gradient of light a few flat regions of color many in-between tones hard edges between shadow and light colors come from physics colors chosen by an artist no outlines clean dark outlines realistic soft highlights sharp stylized glints goal: "looks real" goal: "looks drawn"

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.

Tip Keep one sentence in mind for the whole chapter: in NPR, the lighting value is not a brightness — it is an index into an artist's choice. We will compute a number like 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.

2. Recap: The One Piece of Lighting Math We Build On

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.

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.

light L rays | v +-----------------------------------------------+ | surface piece normal N NdotL look | | top points up 1.0 bright| | tilted at 37 deg 0.8 bright| | sideways grazing 0.0 edge | | back away < 0 => 0 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.

3. Cel / Toon Shading: Quantizing N·L Into Hard Bands

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:

ndl step(0.5, ndl) lerp(0.35, 1.0, ...) result 0.10 0 0.35 shadow 0.40 0 0.35 shadow 0.55 1 1.00 light 0.90 1 1.00 light

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:

Lambert (smooth gradient) Cel / Toon (3 hard bands) 1.0 |* 1.0 |******* | * | | * |------- hard jump (the terminator) 0.5 | * ===> 0.5 |******* | * | | * |------- hard jump 0.0 |______*_____ 0.0 |*******_____ +-----------> NdotL +-----------> NdotL

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.

Common mistake Beginners crank the band count up to 8 or 10 "to be safe." That just recreates the smooth gradient and throws away the anime look. Real anime shading usually uses two bands (light + shadow), sometimes three (adding a mid-tone or a bright edge). Fewer bands, chosen carefully, look better than many.

4. Softening the Band Edges Just a Little

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
step(0.5, x) smoothstep(0.485, 0.515, x) 1 | ______ 1 | __--- | | | / | | (a vertical cliff) | / (a short smooth ramp) 0 |_____| 0 |___--/ +-----+------ x +-----+------ x 0.5 0.5

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.

Tip Keep the window tiny (a few percent, or one 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."

5. Ramp Textures: Art-Directed Shadow Colors

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.

_RampTex (a wide, short strip; painted by an artist) u = 0.0 u = 1.0 +-------+-------+---------+----------+-----------+ | deep | cool | mid | warm | bright | | blue | shadow| tone | light | highlight| +-------+-------+---------+----------+-----------+ shadow colors (cool) | lit colors (warm) sample horizontally at u = NdotL the vertical row v picks the material: skin / cloth / metal

// 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.

Tip Set the ramp texture to Clamp wrap mode (not Repeat) and turn off mip maps and sRGB if your pipeline expects linear ramps. Repeat wrapping makes the far-left shadow color bleed into the far-right highlight and causes a flicker at the terminator.

6. Outlines, Method 1: The Inverted-Hull Shell

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.

(side view, camera to the left looking right) STEP 1: normal mesh STEP 2: shell pushed out along drawn with toon shading normals, drawn DARK, only BACK faces kept (Cull Front) .-"""-. .-"""""-. ( body ) (( body )) the dark ring '-...-' '-.....-' peeking out = line On screen: a clean dark outline hugs 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:

Common mistake Forgetting 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.

7. Outlines, Method 2: Post-Process Edge Detection

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.

Full-screen edge detect: compare a pixel to its neighbors +----+----+----+ | d | d | d | +----+----+----+ | d | C | d | C = current pixel +----+----+----+ d = neighbor depth/normal | d | d | d | +----+----+----+ edge = sum of |C - neighbor| differences big difference (depth cliff or normal flip) => paint a line pixel

// 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:

Inverted hull Post-process edge detect where per-object mesh pass one full-screen pass cost draws character twice one pass, whole screen line style hugs silhouette only silhouette + inner creases art control per-material width/color global, harder to tune needs smoothed normals baked depth + normal buffers breaks on hard edges (needs fix) flat details at same depth

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.

8. Faces: SDF Shadow Maps for Clean Anime Cheeks

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.

Face SDF map (grayscale; darker = falls into shadow sooner) light from LEFT light from FRONT light from RIGHT +------------+ +------------+ +------------+ |#### o o | | o o | | o o ####| |#### __ | | __ | | __ ####| |#### |__| | | |__| | | |__| ####| +------------+ +------------+ +------------+ left half in almost no right half in shadow shadow shadow lit = step(threshold, sdf); threshold comes from the light angle. Flip the map left/right when the light crosses to the other side.

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.

Tip The face usually also ignores received shadows from hair and the environment (or receives a soft, art-directed version), because a hair shadow slicing across an anime eye looks like a bug, not a feature. Faces are treated as a special material with their own rules.

9. Hair: Anisotropic Highlights and the Hair Band

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).

Normal specular (round) Anime hair highlight (a band) . ============ ..... stretched across the strands, ....... vs follows the tangent T (not N), ..... shifted and masked into a strip . a tight round dot that a long shine that slides moves like a mirror reflection sideways as the head turns

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.

10. Rim Light: The Glowing Edge

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.

Rim light = bright where the normal is sideways to the view. V (direction to camera) ^ | edge | edge N | N center of the shape: \ | / N points toward the camera, N | N dot(N,V) is near 1, N | N rim = 1 - 1 = 0 (no rim) N | N ...... surface ...... silhouette edge: N is nearly sideways to V, dot(N,V) is near 0, rim = 1 - 0 = 1 (bright glow)

// 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.

Tip Rim light stacks with the ramp and specular by adding, not multiplying. Shadow color is a lookup (multiply the albedo); rim and specular are added light (extra energy on top). Keeping that distinction straight is the difference between a clean shader and a muddy one.

11. Stylized Specular Highlights (and a Word on MatCap)

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;

12. Putting It All Together: One Character Shader

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.

13. Practical Unity / URP Notes

To turn the ideas above into a working Unity project, these are the concrete pieces you touch:

Tip Build it in this order: (1) get 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.

14. Glossary

15. Exercises

Exercise 1 — From two bands to three, then to a ramp Start from this two-tone toon term and (a) rewrite it to produce three flat bands using 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.
Show answer

(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:

ndl ndl*3 floor /3 band 0.10 0.30 0 0.000 darkest 0.40 1.20 1 0.333 mid 0.70 2.10 2 0.667 bright 0.95 2.85 2 0.667 bright

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.

Exercise 2 — Fix a broken outline An artist reports that the inverted-hull outline on a hard-edged sword looks fine along smooth areas but splits into gaps at the corners. The outline vertex code is:

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).
Show answer

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.

Exercise 3 — A shadow-side rim light The default rim in Section 10 shows on the lit side (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).
Show answer

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):

1 - dot(N,V) = 1 - 0.05 = 0.95 pow(0.95, 4) = 0.95*0.95*0.95*0.95 ~ 0.81 (strong rim) step(0.1, ndl=0) = 0 1 - step(...) = 1 (shadow side: keep it) rim final = 0.81 * 1 = 0.81 bright cool edge

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.

← Back to all chapters