Skip to content
Stylize · Toon / cel shadingBeginner

Two-band anime

A lit color and a tinted shadow color, split by one crisp line.

NormalSurface
Open in Playground
  1. 01GoalStylize
  2. 02TechniqueToon / cel shading
  3. 03ExecutionTwo-band anime
Test mesh

Parameters

#f59e6b
#9e85c7
0.10
0.020

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. dotDotProductdotdotdot
    Normal

    01 Light facing

  2. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    02 Two bands

  3. *Multiply***

    03 Shadow color

  4. mixMixmixmixmix

    04 Pick a band's color

How it works

The card lights a sphere with one sun and then refuses to let the light fade. Each pixel is either lit or in shadow, and the only thing left of the gradient is where the line between them falls.

1. How much the surface faces the light. The dot product of the surface normal and the direction toward the sun is 1 where the surface points straight at it, 0 where the light grazes it, and negative on the far side. The step preview shows this as the usual smooth falloff: this is plain Lambert diffuse lighting, before the toon part.

2. Two bands. A smoothstep around the Shadow line turns that falloff into 0 or 1. Below threshold - softness it returns 0, above threshold + softness it returns 1, and only the thin window between gets a blend. A window of zero would be a hard step, and its edge would stair-step along the pixels; a few hundredths keeps it clean without looking soft. Moving the threshold above 0 pulls the shadow toward the light, shrinking the lit side to a smaller patch facing it and giving more of the form to shadow, for a more dramatic look.

3. Shadow color. The shadow isn’t black or a darker base color. It is the base color multiplied by a tint, a cool violet by default. Multiplying keeps some of the base color’s hue in the shadow, so an orange object gets a dusky rose shadow instead of a brown one. This is how cel animators paint shadows, and it is most of what makes the result read as anime rather than as a broken light.

4. Pick a band’s color. A mix between the shadow color and the base color, driven by the band value, gives the final color: the base where the band is 1, the shadow where it is 0.

Why the sun is inside the shader

The light direction is a constant, so the bands don’t move when the camera orbits, only when the object turns. That keeps the card portable: the same code runs as a plain unlit material in any engine. The cost is that scene lights don’t affect it. In Godot, the same four steps can move into a light() function, where the direction and color come from each real light; the Godot guide says how.

Making it look intentional

Hand-drawn shadows are designed, and lighting math doesn’t know that. Studios that ship this style correct the shading per vertex or with a painted threshold map, so the shadow on a face falls where an artist wants it. The card’s threshold slider is the simplest form of that control.

Make it in GLSL

The copy button gives a complete GLSL ES 3.00 fragment shader. It expects the world-space normal in v_normal and the four params as uniforms, with the two colors uploaded decoded to linear; it encodes its own output to sRGB.

Draw your mesh with the shader as its fragment stage. Its vertex stage has to pass the world-space normal in v_normal; the other varyings the shader declares (v_uv, v_position, v_view) must still be written, but this card never reads them, so any value works as long as v_view isn’t zero (main() normalizes it).

Set p_base and p_shadowTint from a color picker, converted from sRGB to linear first, and p_threshold and p_softness as plain floats. The shader turns its result back into sRGB at the end of main(), so draw it to the default framebuffer with no further conversion.

To light it with your scene’s sun instead of the fixed one, add a uniform vec3 u_sunDirection (normalized, pointing toward the sun, world space) and use it in place of the constant in the lambert line.

Make it in Godot

The shader is unshaded, so scene lights and shadows don't touch it, exactly like the preview. StandardMaterial3D also has a built-in toon mode (Diffuse Mode set to Toon), which bands Godot's own lighting but gives no control over the shadow's color.

Create a MeshInstance3D, give it a new ShaderMaterial, and paste the shader into a new Shader for it. The four params show up in the inspector under Shader Parameters, with the colors as color pickers.

The fixed sun inside effect() keeps the material identical in every scene. To make the bands follow Godot’s lights instead, remove unshaded from the render mode, write the base color to ALBEDO, and add a light() function: there dot(NORMAL, LIGHT) is the lambert term for each light, and you add the band’s color, the shadow tint where it’s below the threshold and white above it, times LIGHT_COLOR * ATTENUATION, to DIFFUSE_LIGHT. Godot calls light() once per light, so each one draws its own bands.

In VisualShader, the same chain is the Input normal turned to world space with a TransformVecMult (Operator set to A x B (3x3), so the camera’s position isn’t added) by the Input inv_view_matrix, a DotProduct with a Vec3Constant, a SmoothStep, a Multiply for the shadow color and a Mix, wired into Albedo, with the Unshaded flag turned on in the shader’s Flags.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

two-band-anime.fragGLSL
#version 300 es// Toon / cel shading → Two-band anime, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform vec3 p_base; // Base color: vec3(0.96, 0.62, 0.42), sRGB; upload it decoded to linearuniform vec3 p_shadowTint; // Shadow tint: vec3(0.62, 0.52, 0.78), sRGB; upload it decoded to linearuniform float p_threshold; // Shadow line: 0.1 (-0.6 to 0.8)uniform float p_softness; // Edge softness: 0.02 (0.005 to 0.3)in vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// A fixed sun, the same for every pixel, so the bands stay put while the camera orbits.vec4 effect(Inputs i) {	float lambert = dot(i.normal, normalize(vec3(-0.4, 0.8, 0.45)));	float lit = smoothstep(p_threshold - p_softness, p_threshold + p_softness, lambert);	// Anime shadows are a tint of the base color, not a darker gray of it, so the shadow side keeps its hue	vec3 shadow = p_base * p_shadowTint;	vec3 color = mix(shadow, p_base, lit);	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
two-band-anime.gdshaderGodot shader
// Toon / cel shading → Two-band anime, Godot 4. From Creative Center, MIT license.shader_type spatial;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform vec3 base : source_color = vec3(0.96, 0.62, 0.42);uniform vec3 shadowTint : source_color = vec3(0.62, 0.52, 0.78);uniform float threshold : hint_range(-0.6, 0.8, 0.01) = 0.1;uniform float softness : hint_range(0.005, 0.3, 0.005) = 0.02;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// A fixed sun, the same for every pixel, so the bands stay put while the camera orbits.vec4 effect(Inputs i) {	float lambert = dot(i.normal, normalize(vec3(-0.4, 0.8, 0.45)));	float lit = smoothstep(threshold - softness, threshold + softness, lambert);	// Anime shadows are a tint of the base color, not a darker gray of it, so the shadow side keeps its hue	vec3 shadow = base * shadowTint;	vec3 color = mix(shadow, base, lit);	return vec4(color, 1.0);}void fragment() {	Inputs i;	// Godot's UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// VERTEX, NORMAL and VIEW are in view space here; the effect works in world space	i.position = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;	i.normal = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);	i.view = normalize((INV_VIEW_MATRIX * vec4(VIEW, 0.0)).xyz);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading