T.TAO
Back to Blog
/6 min read/Technical Art

Unity Shader #7 Anime Toon Shader

#Unity#Shader#TechnicalArt

This note covers shaders for Japanese-style cartoon rendering β€” anime rendering, if you prefer. Everything here runs under URP.

Anime rendering splits into two parts: the outline (drawing the contour) and the shading. The two are independent, so you can debug them separately.

Outlines

The basic principle is to push every vertex outwards along its normal and then keep only the back faces. This is usually called the inverted hull method. The process goes roughly like this:

Extrude normals β†’ cull front faces β†’ fix the render order β†’ compensate for view distance β†’ repair broken outlines β†’ control the thickness

Extruding normals and culling front faces

The outline lives in an extra pass. Inside it, each vertex is pushed out a little along its normal and Cull Front keeps only the back faces. Because the shell is larger than the model, its back faces peek out around the silhouette β€” that ring is the outline.

HLSLPass
{
    Name "Outline"
    Tags { "LightMode" = "SRPDefaultUnlit" }

    Cull Front          // keep the back faces; the main pass draws the front
    ZWrite On

    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

    CBUFFER_START(UnityPerMaterial)
        float4 _OutlineColor;
        float  _OutlineWidth;
    CBUFFER_END

    struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 color : COLOR; };
    struct Varyings   { float4 positionCS : SV_POSITION; };

    Varyings vert(Attributes IN)
    {
        Varyings OUT;
        float3 posWS    = TransformObjectToWorld(IN.positionOS.xyz);
        float3 normalWS = TransformObjectToWorldNormal(IN.normalOS);
        posWS += normalWS * _OutlineWidth * 0.01 * IN.color.a;
        OUT.positionCS = TransformWorldToHClip(posWS);
        return OUT;
    }

    half4 frag(Varyings IN) : SV_Target { return _OutlineColor; }
    ENDHLSL
}

Note the LightMode tag: SRPDefaultUnlit. URP drops any pass whose light mode it does not recognize, so this line is not optional.

Render order

The outline pass writes depth but needs no lighting at all, so the sooner it finishes the better. Put it before the main pass: pixels it covers can then be rejected by early-Z and never reach the expensive shading. Draw it afterwards instead and you pay for shading pixels that the outline immediately overwrites.

Compensating for view distance

Extruding in world space means the outline gets thinner on screen as the model recedes, until it disappears altogether. For constant screen-space thickness you have to scale the extrusion by distance. The simplest version extrudes in clip space and multiplies by positionCS.w:

HLSLfloat3 normalCS = TransformWorldToHClipDir(normalWS);
OUT.positionCS = TransformWorldToHClip(posWS);
OUT.positionCS.xy += normalCS.xy * _OutlineWidth * 0.01 * OUT.positionCS.w;

In production you usually want a compromise: scale with distance, but clamp between a minimum and a maximum width, so a close-up does not end up wearing tyres.

Repairing broken outlines

This is the classic failure of the technique. At a hard edge β€” a UV seam, or anywhere the normals have been split β€” several vertices share a position but carry different normals. Extrude them and they separate, and the outline tears open.

The fix is not in the shader, it is in the data: bake a smoothed normal into the vertex colour or a spare UV channel, extrude the outline pass using that smoothed normal, and let the main pass keep shading with the original one. The geometry keeps its hard edges while the outline shell stays watertight.

HLSL// smoothed normal baked into uv2 (tangent-space compressed xy)
float3 smoothNormalOS = DecodeSmoothNormal(IN.uv2);
float3 normalWS = TransformObjectToWorldNormal(smoothNormalOS);

Controlling thickness

A character outline should not be one constant width. The face usually wants a far thinner line than the torso, and small structures such as fingers need narrowing of their own. Paint the thickness factor into the alpha channel of the vertex colour so that artists can brush the variation directly in their DCC tool; the shader only has to multiply by it β€” that is the IN.color.a above.

Shading

The core idea of anime shading is to give up the continuous lighting ramp and quantize it into a few bands.

From Lambert to bands

Compute half-Lambert to remap NdotL into [0, 1], then either look the result up in a ramp texture or threshold it with smoothstep:

HLSLLight mainLight = GetMainLight(shadowCoord);
half  NdotL     = dot(normalWS, mainLight.direction);
half  halfLambert = NdotL * 0.5 + 0.5;

// Option A: ramp texture, the banding is entirely the artist's to control
half3 ramp = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(halfLambert, 0.5)).rgb;

// Option B: analytic threshold, with a softness control
half  shadowMask = smoothstep(_Threshold - _Softness, _Threshold + _Softness, halfLambert);
half3 shaded     = lerp(_ShadowColor.rgb * baseColor, baseColor, shadowMask);

Option A is flexible β€” one texture swap changes the whole look. Option B has few parameters and is easy to animate. Real projects usually mix them: the ramp sets the overall relationship, the threshold handles dynamic adjustment.

One detail that is easy to miss: do not produce the shadow colour by multiplying the base colour by grey. In Japanese illustration the shadow side usually shifts towards purple or blue and is often more saturated, not less. Expose the shadow colour as its own parameter and the result improves immediately.

Speculars and rim light

Anime speculars are hard-edged too: take the Blinn-Phong NdotH and step it.

HLSLhalf3 halfDir = normalize(mainLight.direction + viewDirWS);
half  NdotH   = saturate(dot(normalWS, halfDir));
half  spec    = step(1 - _SpecSize, pow(NdotH, _SpecPower));
color += spec * _SpecColor.rgb * _SpecIntensity;

The rim light can be a plain Fresnel term, exactly as in Unity Shader #5 Rim Light β€” but here you normally multiply it by NdotL as well, otherwise the unlit side of the character lights up too and the illusion breaks.

About faces

Faces are where this approach most reliably falls apart. The normals around the nose bridge and the eye sockets scatter broken dark patches across the face, and no amount of ramp tuning makes them look intentional. The industry-standard answer is a dedicated SDF lighting texture for the face: a texture that records, for light arriving from every horizontal angle, where the light/shadow boundary should fall. You project the light direction onto the head's horizontal plane and look the boundary up, instead of using the real normals. The terminator on the face then stays a single clean curve.

That deserves a post of its own; I am leaving a marker here for now.

Wrapping up

Anime rendering looks like a question of style, but once you build it you find most of the work sits in the data and the edge cases: baking smoothed normals, painting outline widths, special-casing the face. The shader itself turns out to be the easy part.

In this series

Unity Shader β†’
  1. 01Unity Shader #0 Maths Basics
  2. 02Unity Shader #1 Render Pipeline
  3. 03Unity Shader #2 Code Basics
  4. 04Unity Shader #2.1 Basic Commands
  5. 05Unity Shader #3 URP Upgrade
  6. 06Unity Shader #4 Drawing Shapes on UV
  7. 07Unity Shader #5 Rim Light
  8. 08Unity Shader #6 Scan & Hologram
  9. 09Unity Shader #7 Anime Toon Shader
  10. 10Unity Shader #8 Dissolve
  11. 11Unity Shader #9 Raindrops on Lens
  12. 12Unity Shader #13 Post-Processing
  13. 13URP #1 Universal Lit & URP ShaderLab