Toon Shader
Cel shading for 3D meshes: every light cut into flat bands, a rim light on the lit edge and a highlight with a hard edge, with real shadows and as many lights as your renderer allows.
Drop it into your project
- Copy
toon.gdshader,toon.gdshader.uidandtoon_material.tresinto your project, for example intores://toon/. Keep the.uidfile next to the shader: the material finds the shader by that ID, so the three files can live in any folder. - Select a
MeshInstance3Dand dragtoon_material.tresinto its Surface Material Override slot, or into Geometry > Material Override to cover every surface of the mesh at once. - For a second colour, click the arrow next to the material in the slot and pick Make Unique, then change
albedo_color. Without that step, every mesh that uses the same.treschanges colour together. For an imported model, put its colour texture inalbedo_textureand setalbedo_colorto white. - Light the scene with a
DirectionalLight3D,OmniLight3DorSpotLight3D. Each one is banded on its own and they add up, so a surface lit by two lights can show more steps thanbands. Area lights are the exception, see the pitfalls. - Give the scene a
WorldEnvironmentand set Ambient Light > Source to Sky (or Background with a sky as the background). The unlit band then keeps a lighter top and a darker bottom instead of one flat colour. To make the unlit band darker, lower Sky Contribution below 1 with a dark ambient colour.
Parameters
| Name | Type | Default | What it does |
|---|---|---|---|
albedo_color | vec4 (source_color) | Color(1, 1, 1, 1); Color(0.91, 0.36, 0.3, 1) in toon_material.tres | Base colour of the surface. A fully lit band shows this colour, plus ambient, under a white light of energy 1. |
albedo_texture | sampler2D (source_color, hint_default_white) | none, reads as white | Optional colour texture, read with the mesh's first UV and multiplied with albedo_color. The bands are laid on top, so painted detail stays visible in every band. |
bands | int (2 to 6) | 3 | Light levels from unlit to fully lit, the unlit one included. 2 is the classic two-tone look. More bands put the top step further towards the light: with 6, a surface needs N·L × ATTENUATION of at least 5/6 under a light of energy 1, and the demo's ground, which the sun reaches at N·L 0.82, drops one step. Energy above 1 moves every step further round the object. A light below energy 1 keeps the same steps and only gets dimmer. |
band_softness | float (0 to 0.2, step 0.005) | 0.02 | Width of the blend at each band edge. 0.02 keeps the edges crisp and smooth. At 0 the edges show stair steps, like aliasing; at 0.1 they look blurred. It also softens the rim and the highlight edges. |
rim_strength | float (0 to 1) | 0.5 | How much brighter the rim is than the band under it. 0.5 makes it 1.5 times as bright. 0 turns it off; the demo's ground uses 0, see the pitfalls. |
rim_width | float (0 to 1) | 0.3 | How far the rim reaches in from the silhouette. The rim covers the part of the surface where N·V, the facing towards the camera, is below this value, so 0.3 means the outer edge that turns more than about 73° away from you. |
specular | bool | true | The hard-edged highlight. Off gives a matte, painted look. The highlight takes the light's colour, not the surface's, so an orange lamp makes an orange glint on a blue figure. |
glossiness | float (1 to 256) | 40.0 | Size of the highlight: higher is smaller. The highlight covers pixels whose half vector is within about 10.6° of the normal at 40, and about 4.8° at 200. |
How it works
A toon shader keeps the shape of the lighting but throws away its gradients. Instead of light fading smoothly from the lit side to the dark side, it jumps between a few flat levels. Godot lets a spatial shader replace its whole lighting model with a light() function, and that is where this shader does the work: it decides how much of each light reaches a pixel, cuts that amount into bands, and adds a rim and a highlight on top.
What light() is and when Godot calls it
The spatial shader docs say light() is called for every light in every pixel, inside a loop for each light type. Leave it out and Godot lights the material itself from what fragment() wrote. Write it and Godot hands you one light at a time: LIGHT is the direction to that light and NORMAL the surface normal, both in view space, VIEW points to the camera, LIGHT_COLOR is the light's colour times its energy times π, and ATTENUATION is how much of it survives the distance and the shadows. You add your result to DIFFUSE_LIGHT and SPECULAR_LIGHT. The docs say to use += so the lights add together; with =, each light overwrites the ones before it.
Godot does one more thing afterwards: it multiplies DIFFUSE_LIGHT by the ALBEDO from fragment(), but not SPECULAR_LIGHT. You can see both in the demo: the white sun gives each shape its own colour, while every highlight stays white. So DIFFUSE_LIGHT is "how lit is this pixel", not a colour.
Cutting light into bands
The shader first works out an amount of light from 0 to 1 (more on that below), then band() turns it into one of bands flat levels. It cuts 0 to 1 into equal slices and puts one step at each cut. With 3 bands the steps sit at 1/3 and 2/3, so the levels are 0, 0.5 and 1. Each step is a smoothstep over band_softness, which is only 0.02 wide. A hard step() would flip from one pixel to the next and show stair steps along every curved edge; the narrow blend gives the edge one or two in-between pixels, which reads as sharp but not jagged.
float band(float amount) {
float slices = float(bands);
float half_soft = max(band_softness, 0.0001) * 0.5;
float level = 0.0;
for (int i = 1; i < bands; i++) {
float edge = float(i) / slices;
level += smoothstep(edge - half_soft, edge + half_soft, amount);
}
// max() keeps a script that sets bands to 1 from dividing by zero.
return level / max(slices - 1.0, 1.0);
}The max(..., 0.0001) is there because smoothstep with both edges equal is undefined in GLSL, and band_softness can be set to 0. The Inspector keeps bands between 2 and 6, but a script can set any value with set_shader_parameter(), so the last line guards the division too.
Shadows, falloff and light strength in one number
The amount that gets banded is a product of three things. N·L, the plain Lambert term, is 1 where the surface faces the light and 0 at a right angle. ATTENUATION is 0 in shadow and, for omni and spot lights, falls off with distance. The strength is the light's brightest colour channel times its energy. Because LIGHT_COLOR already holds energy × π (the docs say the π is there because physically based lighting divides by π), dividing by π gives colour × energy. The shader splits that into the strength and a tint whose brightest channel is 1.
vec3 light_rgb = LIGHT_COLOR / PI;
float strength = max(max(light_rgb.r, light_rgb.g), light_rgb.b);
vec3 tint = light_rgb / max(strength, 0.0001);
float n_dot_l = clamp(dot(NORMAL, LIGHT), 0.0, 1.0);
float level = band(n_dot_l * ATTENUATION * max(strength, 1.0)) * min(strength, 1.0);Banding the whole product is what makes it look drawn. A shadow is a drop in ATTENUATION, so its edge becomes a band edge. An omni light's falloff becomes rings: in the demo the orange lamp throws two nested ovals on the ground, a half-lit one and a fully lit one inside it. And because energy is in the product, a stronger lamp throws bigger pools. An early version banded only N·L × ATTENUATION, and a spot light 4 m above the scene lit nothing at all, because the distance falloff never got past the first step whatever its energy. The cost of the choice: a fully lit pixel shows the surface colour times the tint and no more, so raising a light's energy above 1 widens its lit area instead of brightening it.
Below 1 the shader splits the strength the other way, which is what the max() and min() in that line do. A strength above 1 goes into the banded amount; a strength below 1 multiplies the banded result. Put a weak light straight into the product and it would never reach the first step: at energy 0.3 with 3 bands, 0.3 never gets to 1/3, so the light would add nothing at all. With the split, a 0.3-energy light bands N·L × ATTENUATION as a light of energy 1 would and then gives 0.3 of that. In a test with only such a light and ambient off, the ball showed the same two lit bands and the highlight, just dimmer. At energy 1 and above both forms give the same result, and the demo's frames came out identical, pixel for pixel.
Why plain Lambert and not half-Lambert
Half-Lambert, dot(N, L) * 0.5 + 0.5, is a common toon trick because it wraps light round to the back of the object and keeps the dark side small. With real shadows it fights the shadow map. The shadow map says the far side of a ball is in its own shadow, while half-Lambert still lights it, so the dark band stops where the shadow map cuts it off, with a ragged, stair-stepped edge. That is exactly what a test with half-Lambert showed on the demo's ball. Plain Lambert reaches 0 at the same line where the shadow starts. With 3 bands and a light of energy 1, the first step is at 1/3, about 70° from the light, so the shadow's own edge falls inside the unlit band and does not show.
The rim light: in light() or in fragment()
A rim light brightens the silhouette. N·V is 1 where the surface faces the camera and drops towards 0 at the outline, so 1.0 - N·V above 1.0 - rim_width picks out a thin outer ring. This shader computes it in light() and multiplies it by the band level, so the rim only appears where that light reaches, takes the light's tint, and goes away in shadow. In the demo it shows on the sun side of the ball, the figure and the torus, and the orange lamp adds a rim of its own where it lights an edge, such as the inside of the torus.
float n_dot_v = clamp(dot(NORMAL, VIEW), 0.0, 1.0);
float rim = smoothstep(1.0 - rim_width, 1.0 - rim_width + max(band_softness, 0.0001), 1.0 - n_dot_v);
DIFFUSE_LIGHT += tint * level * (1.0 + rim * rim_strength);The other place is fragment(), writing the rim to EMISSION. That rim ignores the lights: it glows on the dark side and in total darkness too, which suits a hit flash or a ghost but not a lit edge. In a test with every light hidden, a fragment() rim still glowed round the ball and the torus.
A highlight with a hard edge
The highlight uses the Blinn-Phong half vector, the direction halfway between the light and the camera. Where the normal lines up with it, the surface reflects the light towards you. pow(N·H, glossiness) gives the usual soft blob, and a smoothstep at 0.5 cuts it into a flat disc. It is multiplied by the band level, so there is no glint in shadow, and by SPECULAR_AMOUNT, which the docs give as 1 for a DirectionalLight3D and 2 × Specular for omni and spot lights (1 at their default of 0.5). The fragment function also sets SPECULAR = 0.0, which the docs say turns off reflections, so the sky does not paint a soft reflection over the flat bands.
vec3 half_vector = normalize(LIGHT + VIEW);
float blinn = pow(clamp(dot(NORMAL, half_vector), 0.0, 1.0), glossiness);
float half_soft = max(band_softness, 0.0001) * 0.5;
float highlight = smoothstep(0.5 - half_soft, 0.5 + half_soft, blinn);
SPECULAR_LIGHT += tint * highlight * level * SPECULAR_AMOUNT;Where ambient light fits
light() only runs for lights. Ambient light from the WorldEnvironment is added by Godot outside it, times the albedo, under every band. That is why the unlit band is not black. How it looks depends on Ambient Light > Source. With Sky, the ambient is read from the sky in the direction of each normal, so normals that face up get more than normals that face down. On the demo's ball the unlit band measured 55 at the top and 43 at the bottom (average brightness out of 255), so the dark side still shows it is round. With Color it measured 63 at both ends: one flat colour. With ambient off it was black.
Ambient also lifts the lit bands, which narrows the gap between them: the ball's lit band measured 133 with sky ambient and 122 without. To get a darker shadow side and keep the shape, lower Sky Contribution. At 0.5, with the default black ambient colour, the unlit band dropped to 38 at the top and 29 at the bottom.
diffuse_toon, specular_toon and why this shader skips them
Godot has toon lighting built in. The spatial shader docs list diffuse_toon ("Toon shading for diffuse") and specular_toon ("Toon for direct light specular lobes") as render modes, and a StandardMaterial3D has the same choices under Shading. They give one step from dark to lit, and its softness follows ROUGHNESS. In a test on the demo's ball with a StandardMaterial3D set to both, Roughness 1 gave an almost smooth gradient with no visible band, and Roughness 0.3 gave one soft step and a blurred highlight. There is no setting for the number of bands, no rim tied to each light, and the edge softness cannot be set apart from the highlight size. A custom light() gives you all of that, and it is the only way to change how shadows and falloff are banded. The price is that the diffuse_* and specular_* render modes no longer do anything, because they only pick Godot's own lighting function, which yours replaces.
A ramp texture instead of bands
The other common approach looks the light amount up in a gradient texture, often called a ramp. It is not in this shader, but swapping it in takes three lines: a sampler2D uniform, and in light() a texture read in place of band(), with the same split of the strength.
uniform sampler2D ramp : repeat_disable, filter_nearest;
// in light(), instead of band():
float amount = n_dot_l * ATTENUATION * max(strength, 1.0);
float level = texture(ramp, vec2(clamp(amount, 0.0, 1.0), 0.5)).r * min(strength, 1.0);In a test with a GradientTexture1D set to Constant interpolation and stops at 0, 0.2 and 0.75, the ball showed three bands with the edges where the stops were. A ramp is better when an artist wants to draw the bands: uneven widths, a lit band that is wider than the others, or a soft gradient in one place and a hard edge in another. repeat_disable stops the fully lit end wrapping round to the dark end, and filter_nearest keeps Constant stops hard. Bands from code are better when you want to change the count from a script or the Inspector without making a new texture, and they skip the texture read.
How it builds on the lighting lesson
The lighting and materials guide builds a room with a sun at a rotation of (-55, -25, 0), compares ambient light from a Color at Energy 0 and 0.3, then switches to a sky, and warns about shadow acne and bias. This shader reacts to all of those settings, just more sharply. The demo's sun uses the same -55° pitch, which lights flat ground at N·L 0.82, well inside the top band. The guide's ambient comparison is the one in the section above: Color ambient flattens the dark side of a toon object even more than a smooth one, because the bands remove the last gradient. And shadow acne is far easier to see on hard bands, as the pitfalls below measure.
// Toon Shader
// Cel shading for 3D meshes: light in hard bands, a rim light and a hard-edged
// highlight, all worked out per light in light().
// Godot 4.7 · CC0 1.0 · godotlearning.com/shaders/toon-shader
shader_type spatial;
// cull_back: back faces are not drawn, as with a StandardMaterial3D. The
// diffuse_* and specular_* render modes only pick Godot's own lighting models,
// and this shader replaces those with its own light() function.
render_mode cull_back;
// Base colour. Multiplied with albedo_texture.
uniform vec4 albedo_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
// Optional colour texture, read with the mesh's UV. White when left empty.
uniform sampler2D albedo_texture : source_color, hint_default_white, filter_linear_mipmap, repeat_enable;
// Number of light levels from unlit to fully lit, the unlit one included.
// 2 is the classic two-tone look.
uniform int bands : hint_range(2, 6) = 3;
// Width of the blend at each band edge, in the same units as the light amount
// that band() cuts up. Small values keep the edges hard without stair steps
// along them. 0 gives the hardest edge, which shows stair steps.
uniform float band_softness : hint_range(0.0, 0.2, 0.005) = 0.02;
// Brightness of the rim light on the lit side of the silhouette. 0 turns it off.
uniform float rim_strength : hint_range(0.0, 1.0) = 0.5;
// How far the rim reaches in from the silhouette. 0 is none, 1 the whole face.
uniform float rim_width : hint_range(0.0, 1.0) = 0.3;
// Hard-edged highlight. Off gives a matte look.
uniform bool specular = true;
// Size of the highlight: higher is smaller and tighter.
uniform float glossiness : hint_range(1.0, 256.0) = 40.0;
void fragment() {
ALBEDO = albedo_color.rgb * texture(albedo_texture, UV).rgb;
// No reflections of the sky or of reflection probes. Ambient light still
// arrives and lifts every band, the unlit one included.
SPECULAR = 0.0;
}
// Turns an amount of light into one of `bands` flat levels. 0 to 1 is cut
// into `bands` equal slices: with 3 bands, below 1/3 is unlit, 1/3 to 2/3 is
// half lit and 2/3 or more is fully lit. Anything above 1 is fully lit too.
float band(float amount) {
float slices = float(bands);
float half_soft = max(band_softness, 0.0001) * 0.5;
float level = 0.0;
for (int i = 1; i < bands; i++) {
float edge = float(i) / slices;
level += smoothstep(edge - half_soft, edge + half_soft, amount);
}
// max() keeps a script that sets bands to 1 from dividing by zero.
return level / max(slices - 1.0, 1.0);
}
void light() {
// LIGHT_COLOR is the light's colour × energy × PI. Split it into a
// strength (its brightest channel) and a tint whose brightest channel is 1.
vec3 light_rgb = LIGHT_COLOR / PI;
float strength = max(max(light_rgb.r, light_rgb.g), light_rgb.b);
vec3 tint = light_rgb / max(strength, 0.0001);
// How much of this light reaches the pixel: the Lambert term (N·L) times
// ATTENUATION, which holds the shadow and, for omni and spot lights, the
// distance falloff, times the strength. Banding the whole product puts
// shadow edges and the edge of an omni light's pool on band edges too.
// A strength above 1 goes into the product, so the lit area grows. A
// strength below 1 scales the banded result instead, so a weak light keeps
// the same band shapes and is only dimmer.
float n_dot_l = clamp(dot(NORMAL, LIGHT), 0.0, 1.0);
float level = band(n_dot_l * ATTENUATION * max(strength, 1.0)) * min(strength, 1.0);
// Rim: 1 - N·V grows towards the silhouette. It only shows where this light
// reaches, and as bright as the band under it.
float n_dot_v = clamp(dot(NORMAL, VIEW), 0.0, 1.0);
float rim = smoothstep(1.0 - rim_width, 1.0 - rim_width + max(band_softness, 0.0001), 1.0 - n_dot_v);
// Godot multiplies DIFFUSE_LIGHT by ALBEDO afterwards, so a fully lit pixel
// shows the surface colour times the light's tint.
DIFFUSE_LIGHT += tint * level * (1.0 + rim * rim_strength);
if (specular) {
// Blinn-Phong half vector, cut at 0.5 into a highlight with a hard edge.
// SPECULAR_LIGHT is not multiplied by ALBEDO, so the highlight takes the
// light's tint, not the surface colour.
vec3 half_vector = normalize(LIGHT + VIEW);
float blinn = pow(clamp(dot(NORMAL, half_vector), 0.0, 1.0), glossiness);
float half_soft = max(band_softness, 0.0001) * 0.5;
float highlight = smoothstep(0.5 - half_soft, 0.5 + half_soft, blinn);
SPECULAR_LIGHT += tint * highlight * level * SPECULAR_AMOUNT;
}
}
Pitfalls and renderer notes
- Light energy does not brighten the bands the way it does on a StandardMaterial3D. Above energy 1 the lit area grows instead: the steps move further round the object, and an omni or spot light throws a bigger pool the more energy it has. Below energy 1 the bands keep their shapes and get dimmer: a fill light at energy 0.3 shows the same bands as one at energy 1, at 0.3 of the light. So energy above 1 will not make the lit band brighter; a lighter
albedo_coloror more ambient light will. The strength the shader uses is the light's brightest colour channel times its energy, so a grey light at energy 1 counts as a weak one. - The dark side goes flat or black. Ambient light is the only thing in the unlit band, so it decides how that band looks. With Ambient Light > Source set to Color, the demo ball's unlit band was one flat value (63 out of 255 at the top and the bottom). With Sky it shaded from 55 to 43, and with ambient off it was black. Note that Energy does nothing while Sky Contribution is 1: the Environment docs say Energy and Color only apply below 1, and in a test Energy 0.5 changed no pixel at all. Lower Sky Contribution instead.
- Shadow acne is much worse on hard bands. Acne is the shadow map shadowing the surface it was drawn from, and it shows as speckles. With the sun's Shadow > Bias and Normal Bias both at 0, 61% of the ball's pixels turned darker, by 45 out of 255 on average: whole speckles dropped from the lit band to the unlit one. A StandardMaterial3D in the same test had more speckled pixels (80%) but each one only dropped by 24, so the acne showed far less. The DirectionalLight3D defaults (Bias 0.1, Normal Bias 2.0) were clean. In this scene Bias mattered most: Bias 0 with Normal Bias 2 still darkened 25,169 pixels, while Normal Bias 0 with Bias 0.1 changed only about 1,000. The lights and shadows docs say too much bias makes shadows float off their casters (peter-panning), so raise it in small steps.
- Low-poly and flat-shaded meshes change the band shape. The bands follow the normals, so they follow the mesh. A low-poly sphere with smooth normals showed band edges that wobble along its faces, and the same sphere with flat faces lit each face in a single band, a faceted look that some styles want. If band edges look wavy on a model, add geometry or smooth its normals in your modelling tool.
- Normal maps break the bands into blotches. This shader has no normal map slot on purpose. In a test that added one (a noise texture set to As Normal Map) every bump crossed band edges, and the shapes and the ground came out mottled. If you add
NORMAL_MAPfor a material, keep the map gentle or lowerNORMAL_MAP_DEPTH. - Many lights cost more than in a StandardMaterial3D.
light()runs once per light per pixel, and this one does more work per light than Godot's default. On an RTX 3060 at 1280 by 720 under Forward+, the demo cost 0.37 ms of GPU time with both shaders; with 16 extra omni lights the toon shader took 0.54 ms against 0.50 ms, and with 64 it took 1.42 ms against 1.05 ms. Forward+ also caps the omni lights, spot lights, decals and reflection probes in view at 512 by default (Rendering > Limits > Cluster Builder > Max Clustered Elements). - Mobile and Compatibility drop lights on big meshes. The lights and shadows docs give Mobile a fixed limit of 8 omni and 8 spot lights per mesh, and Compatibility 8 of each per object by default. In a test with 12 small omni lights over the demo's single ground plane, Forward+ drew 12 pools of light, Mobile and Compatibility 8. Splitting the ground into 4 m tiles brought all 12 back on both. Split large floors into pieces if you use many small lights there.
- Lit bands blow out under Compatibility with shadows. A shadowed sun made every lit band in the demo much brighter under Compatibility, the ground almost white, while the band positions did not move: on the ball, 1 pixel out of 28,666 changed band. The docs say Compatibility renders lights with shadows in sRGB space instead of linear space and suggest adjusting the light's energy. The demo turns the sun's shadows off there instead; the renderer notes have the numbers.
- Area lights are not banded. An
AreaLight3Ddoes go throughlight(), but in a test it lit the demo's shapes and ground with a smooth gradient on all three renderers, so it does not fit this look. Use omni, spot and directional lights with this shader. - The rim lights up big flat surfaces. Seen at a low angle, a floor faces away from the camera, so N·V is small across its far part and the rim covers it. With
rim_strength0.5 on the demo's ground, 335,586 pixels of the far ground got brighter. The demo's ground material usesrim_strength0; do the same for floors, walls and terrain. - Sometimes
light()does not run at all. The docs say it is skipped withrender_mode unshaded, withrender_mode vertex_lighting, and when Rendering > Shading > Overrides > Force Vertex Shading is on. In tests,unshadeddrew flat colours with no shadows andvertex_lightingdrew smooth vertex lighting with no bands. The same docs page adds that Force Vertex Shading is on by default on mobile platforms, but in Godot 4.7.2 the setting is off and has no mobile override, so it only applies if your project turns it on. Check it before you test on a phone.
The demo scene was run under Forward+, Mobile and Compatibility with no errors or warnings. Mobile matches Forward+: 144 of 921,600 pixels differ by more than 8 of 255, and on the red ball every pixel that could be sorted into a band landed in the same band. Compatibility puts the bands in the same places too, and with the sun's shadows off its picture is within 22 of 255 of Forward+ everywhere below the sky. With the sun's shadows on, Compatibility drew every lit band much brighter: the ball's lit side went from 246, 103, 94 to 255, 141, 135 and the ground nearly to white, while the unlit band stayed the same. Godot's lights and shadows docs explain why: Compatibility renders lights that cast shadows in an extra pass, in sRGB space instead of linear space. So the demo turns the sun's shadows off under Compatibility. If you ship a web build, which uses Compatibility, check your lit bands there, and lower the energy of shadowed lights if they blow out.
Pairs with
Learn the idea behind it
Questions
How do I make a toon shader in Godot 4?
Write a spatial shader with a light() function. Godot calls it once per light for each pixel. Take clamp(dot(NORMAL, LIGHT), 0.0, 1.0), multiply it by ATTENUATION so shadows and falloff count, cut the result into a few levels with step() or a narrow smoothstep(), and add it to DIFFUSE_LIGHT with +=. Godot multiplies DIFFUSE_LIGHT by your ALBEDO afterwards. This shader does that with 2 to 6 bands, a rim light and a hard highlight.
What is the difference between diffuse_toon and a custom light() function?
diffuse_toon and specular_toon are built-in render modes (also on StandardMaterial3D under Shading) that give one dark-to-lit step whose softness follows ROUGHNESS. In a test at Roughness 1 the step was nearly invisible. A custom light() lets you choose the number of bands, how hard the edges are, how shadows and light falloff are banded, and add a rim per light.
Why is the dark side of my toon shader flat or black?
The unlit band only gets ambient light, which Godot adds outside light(). With ambient off it is black. With Ambient Light > Source set to Color it is one flat colour. Set the Source to Sky so normals that face up get more light than normals that face down, and lower Sky Contribution to darken it. Ambient Energy has no effect while Sky Contribution is 1.
Does this toon shader work with shadows, several lights and the Compatibility renderer?
Yes. Shadows come in through ATTENUATION and get a hard edge, and every light is banded and added on its own. The demo runs with no errors under Forward+, Mobile and Compatibility, with the bands in the same places. Under Compatibility a light that casts shadows makes the lit bands much brighter, so the demo turns the sun's shadows off there. Mobile and Compatibility also light each mesh with at most 8 omni and 8 spot lights by default, and the Mobile limit cannot be changed.