Cel shading is one of the fastest ways to give a small project a strong, consistent art style. This Unity toon shader guide for URP builds a ramp-based cel shader in Shader Graph that reads the real main light and its shadows, then covers three ways to draw outlines: the classic inverted hull, a full-screen edge detection pass, and a layer-based Render Objects approach that outlines only the things you choose. Each has different strengths, and most shipped toon games combine two of them.
How Toon Shading Works
Normal lighting is smooth: brightness fades gradually as a surface turns away from the light. Toon shading takes that same brightness value and snaps it into a few flat bands. The standard brightness value is the N dot L term: the dot product of the surface normal and the direction to the light, which is 1 facing the light, 0 side-on, and negative facing away.
Shader Graph’s Lit target does its lighting internally, so you can’t intercept N dot L there. Toon shaders are therefore built on the Unlit target, with lighting calculated by hand. That sounds scary, but it’s one small Custom Function node. If Shader Graph itself is unfamiliar, read our Shader Graph beginner’s guide first.
Reading the Main Light in Shader Graph
Create Create > Shader Graph > URP > Unlit Shader Graph and name it Toon. Then create a text file named ToonLighting.hlsl in your Assets folder with this content:
// ToonLighting.hlsl
void MainLight_float(float3 WorldPos, out float3 Direction, out float3 Color,
out float ShadowAtten)
{
#ifdef SHADERGRAPH_PREVIEW
Direction = normalize(float3(0.5, 0.5, -0.5));
Color = float3(1, 1, 1);
ShadowAtten = 1;
#else
float4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
Light mainLight = GetMainLight(shadowCoord);
Direction = mainLight.direction;
Color = mainLight.color;
ShadowAtten = mainLight.shadowAttenuation;
#endif
}
The SHADERGRAPH_PREVIEW branch exists because the node previews inside the graph editor have no scene light. Without it, the graph fails to compile with errors about GetMainLight.
In the graph, add a Custom Function node. Set Type to File, assign the HLSL file, and set Name to MainLight (no _float suffix, Shader Graph adds it). Add one input, WorldPos (Vector 3), and three outputs: Direction (Vector 3), Color (Vector 3), ShadowAtten (Float). Feed a Position node in World space into WorldPos.
Receiving shadows in an Unlit graph
Unlit graphs don’t compile URP’s shadow variants, so shadowAttenuation is always 1 until you add the keywords yourself. On the Blackboard, add three Boolean Keywords with these reference names, each set to Multi Compile and Global, with “Generate Material Property” unticked:
_MAIN_LIGHT_SHADOWS_MAIN_LIGHT_SHADOWS_CASCADE_SHADOWS_SOFT
The keyword nodes don’t need to be wired into anything. Their presence makes Unity compile the shadow variants. Casting shadows onto other objects is a separate matter: the Unlit target includes a ShadowCaster pass by default in recent URP versions, so the object will still cast.
Building the Lighting Ramp
- Add a Normal Vector node (World) and a Dot Product with the Custom Function’s Direction. That’s N dot L.
- Multiply it by ShadowAtten so cast shadows darken the surface too.
- Remap from -1..1 to 0..1: Multiply by 0.5, Add 0.5. This is sometimes called “half Lambert” and gives softer, more usable bands.
Now pick a banding method:
- Two tones with Step: a Step node with Edge 0.5. Hard, classic anime look. Swap Step for Smoothstep (Edge1 0.48, Edge2 0.52) to anti-alias the band edge, which looks much cleaner in motion.
- Ramp texture: create a small texture (256×1 or 256×4 pixels) painted with hard colour bands. Feed the 0..1 lighting value into a Vector 2 as X (Y 0.5), then Sample Texture 2D with it as UV. Set the ramp’s Wrap Mode to Clamp and Filter Mode to Point in its import settings so bands stay crisp and the ends don’t wrap around.
Ramp textures are the most art-directable option: a warm shadow band, a cool dark band, as many steps as you like, with no graph changes. Multiply the ramp result by your BaseColor (and an optional albedo texture) and by the light Color, then add a small ambient term so shadowed sides aren’t pitch black. A simple ShadowColor property lerped in works, or use the Baked GI node for proper ambient from the scene. Plug the final colour into Base Color.
Rim Light and Specular
Two optional touches sell the look:
- Rim light: a Fresnel Effect node (Power around 4), multiplied by the lit ramp value so the rim only appears on the lit side, then stepped for a hard edge and added to the colour.
- Toon specular: Normalize(View Direction + Light Direction) gives the half vector. Dot it with the normal, raise it to a Power (32 to 64), then Step it for a single sharp highlight blob.
Additional lights (point and spot) need a loop over GetAdditionalLightsCount() and GetAdditionalLight(i, WorldPos) in the same HLSL file. For many stylized games one directional light plus ambient is enough, so add that only when you need it.
🎨 Want a deeper grounding in stylized art?
Shaders are easier when you understand light, colour and shape language. Our book list includes the art and game design titles we keep coming back to.
Outline 1: Inverted Hull
The oldest trick in the book, used by countless console games: draw the mesh a second time, slightly inflated, showing only its back faces, in a flat colour. The front-facing original covers the middle, leaving a rim of the inflated back faces visible around the silhouette.
- Create an Unlit Shader Graph called Outline. In Graph Settings set Render Face: Back.
- In the Vertex stage: Position (Object) + Normal Vector (Object) multiplied by an
OutlineWidthproperty (0.01 to 0.03). Plug into Vertex Position. - Base Color: an
OutlineColorproperty. - Add the Outline material as a second material on the Mesh Renderer (increase the Materials list size to 2). Unity draws the whole mesh again with it.
Two common fixes. First, hard-edged meshes (cubes, low-poly props) have split normals at the corners, so the hull tears open into gaps. Fix it by baking smoothed normals into a spare channel such as vertex colours or UV3 in your DCC tool, and using that channel instead of the normal. Second, the outline gets thinner on screen as objects move away. For a constant pixel width, scale the offset by distance to the camera, or do the offset in clip space.
Pros: cheap, works everywhere including mobile, outlines only the silhouette and folds you control. Cons: no inner creases, needs per-mesh care, and doubles draw calls for outlined meshes.
Outline 2: Screen-Space Edge Detection
A full-screen pass finds edges by comparing neighbouring pixels in the depth and normals buffers. Large jumps mean an edge. This outlines everything, including inner creases, with no per-mesh setup.
- Create Create > Shader Graph > URP > Fullscreen Shader Graph.
- Use a URP Sample Buffer node set to BlitSource to read the current screen colour, and a second one set to NormalWorldSpace to read normals. Read depth with the Scene Depth node (Eye mode).
- Sample depth and normals at the pixel and at offsets of one or two pixels up, down, left and right (offset the Screen Position UV by multiples of 1/screen width and height from the Screen node). This is a Roberts Cross or Sobel pattern.
- Sum the absolute differences, Step them against thresholds, take the Maximum of the depth and normal edges, and Lerp the BlitSource colour toward black using that edge mask.
- In your URP Renderer asset, click Add Renderer Feature > Full Screen Pass Renderer Feature, assign a material made from the graph, set Injection Point to After Rendering Opaques or Before Post Processing, and tick the Depth and Normal requirements so URP generates those buffers.
Depth thresholds should scale with distance, otherwise faraway terrain lights up with false edges and nearby surfaces at grazing angles do too. Divide the depth difference by the centre depth for a simple fix.
Pros: zero per-object work, inner lines, consistent pixel width. Cons: costs a full-screen pass plus a normals prepass (noticeable on mobile), outlines everything whether you want it or not, and transparent objects don’t take part because they aren’t in the depth and normals buffers.
Outline 3: The Layer Approach
Often you want outlines only on specific things: the player, interactables, a selected unit. URP’s Render Objects renderer feature can redraw only objects on a chosen layer with an override material, so you can apply the inverted hull without touching any mesh renderer.
- Create a layer called Outlined (Tags and Layers) and put the target objects on it.
- In the URP Renderer asset, Add Renderer Feature > Render Objects. Set Event to After Rendering Opaques, Queue to Opaque, and Layer Mask to Outlined.
- Under Overrides, set Material to your inverted hull Outline material.
Now any object you move onto the Outlined layer gets an outline automatically, and taking it off removes it. That makes it perfect for hover or selection highlights driven from code:
using UnityEngine;
public class OutlineOnHover : MonoBehaviour
{
int defaultLayer;
int outlinedLayer;
void Awake()
{
defaultLayer = gameObject.layer;
outlinedLayer = LayerMask.NameToLayer("Outlined");
}
void OnMouseEnter() => gameObject.layer = outlinedLayer;
void OnMouseExit() => gameObject.layer = defaultLayer;
}
OnMouseEnter requires a Collider and only works with the old Input Manager or “Both” active input handling. With the Input System only, do a raycast from the mouse position instead. Remember the layer also affects physics and camera culling, so make sure your collision matrix treats Outlined like the default layer, or use child objects.
A variation for “see-through-walls” outlines: add a second Render Objects feature for the same layer with Depth Test set to Greater and a flat silhouette material, so the object shows through walls as a coloured shape.
Which Outline Should You Use?
- Mobile or many characters: inverted hull, applied via the layer approach.
- Comic or sketch look with inner lines: screen-space edge detection on desktop or console.
- Selection and highlight: the layer approach, toggled from code.
- Best of both: edge detection for environment creases plus inverted hull on characters for thick silhouettes.
Troubleshooting
Custom Function errors: undeclared identifier GetMainLight
You’re missing the SHADERGRAPH_PREVIEW guard, or the graph isn’t a URP target. The preview build has no URP lighting library.
The toon shader ignores shadows
The shadow keywords aren’t on the Blackboard, the light has shadows disabled, or the URP asset’s Main Light Cast Shadows is off. Check all three.
Outline has gaps at hard edges
Split normals on the mesh. Bake smoothed normals into a spare channel and offset along those.
Edge detection outlines nothing
The Full Screen Pass feature’s Normal and Depth requirements are unticked, or the material isn’t assigned. Also check the renderer you edited is the one your camera uses.
Outline renders on top of everything
The Render Objects depth test is set to Always, or the outline material is Transparent with depth writes off. Use Less Equal and an opaque outline.
Bands look jagged
Swap Step for Smoothstep with a tiny range, or enable MSAA in the URP asset.
FAQ
Can I make a toon shader with the Lit Shader Graph?
Not properly. Lit computes lighting internally, so you can’t band it. Use the Unlit target with a Custom Function for the light.
Do I need a ramp texture?
No. A Step or Smoothstep node gives two clean tones. Ramp textures are better when artists want more bands or coloured shadows.
Which outline method is cheapest?
Inverted hull. It’s one extra draw of each outlined mesh with a trivial shader, with no full-screen passes.
Why are my outlines different thicknesses?
Inverted hull widths are in object space, so they shrink with distance and scale with the object’s scale. Compensate for camera distance or use screen-space edges.
Does toon shading work on mobile?
Yes. A one-light toon shader is often cheaper than URP Lit. Avoid full-screen edge detection on low-end devices.
Reader questions
Asked by readers of this article and answered by AI. Behavior can differ between Unity versions, so test in yours.




Leave a Reply