Toon shader key lighting

Scene setup

The scene starts simply for the development of the shader. It’s a MeshInstance with a SphereMesh resource, a Camera with Current set to true, and a DirectionalLight in a Spatial scene.

Setting up light data

To set up the deferred rendering chain, we need a viewport to hold a copy of the objects we want to be toon-lit.

  1. Add a ViewportContainer named “ToonLightDataView” at the top of the scene tree.
  2. Add a Viewport as a child of that ViewportContainer.
  3. Save and reload the scene to make the ViewportContainer resize its Viewport.

Duplicate your Sphere, Camera, and Light. Add these copies as children of the Viewport then add a RemoteTransform to the sphere, camera, and light of the main scene to control those proxies.

The last two steps are to apply an appropriate material on the sphere copy to receive light and to emit color on the right channel.

  1. Set the directional light color in the data view to pure red (255, 0, 0). The key light takes up the red channel, the fill light takes the green, and the kick light takes blue. That’s how we store three light types in one texture.
  2. Apply a new SpatialMaterial to the MeshInstance’s Material 0.

If we temporarily set the ViewportContainer’s self-modulate to white, we get this result:

Coding the key light

On the sphere in the main world, add a new ShaderMaterial, assign it a new Shader, and let’s get coding. The shader is of type spatial with the unshaded render mode; all light data will come from the viewport.

We need two uniforms: one is the light data itself from the Viewport, and the other is a gradient ramp that gives control over the crispness of the shadow. We also add two uniforms that control the color of the shadow and the light independently.

shader_type spatial;
render_mode unshaded;

uniform sampler2D key_light_ramp : hint_black;
uniform sampler2D light_data : hint_black;

uniform vec4 base_color : hint_color;

uniform vec4 key_light_color : hint_color;
uniform vec4 shadow_color : hint_color;

Their defaults are black, so there’s no light data when they are missing. To access the light data, we sample the viewport texture’s fragment at the same spot as it exists on the main viewport. We do that with the SCREEN_UV builtin. That’s why the data and the main viewport should both be the same size.

This data acts as a factor with which we sample from the light ramp. A light ramp is a one pixel high, long horizontal texture. By putting in the amount of light received from the viewport as the U axis of the UV, we decide at which point the light is dark enough that it should be all black, and at which point its light enough to be all white. The area between softens the ramp.

The closer together the white and black is, the sharper the transition.

void fragment() {
    vec3 diffuse = texture(light_data, SCREEN_UV).rgb;

    float key_light_value = texture(key_light_ramp, vec2(diffuse.r, 0)).r;
}

We apply color in two steps: multiply the key light value by the key light color, then take the largest of that color and the shadow color. That way, it will never be any darker than the shadow color we specify.

vec3 out_color = base_color.rgb * key_light_value * key_light_color.rgb;
out_color = max(out_color, shadow_color.rgb);

ALBEDO = out_color;

Note on SCREEN_UV: The shader runs while in the editor, and the SCREEN_UV builtin uses the editor viewport size to calculate those values. But our light data viewport is the size of the game resolution in project settings and is from the viewpoint of the camera in the scene tree which results in a broken look in the editor.

I’ll show how you can build a Godot plugin to fix this problem in another tutorial. For now, a workaround is to set the Width and Height in Project Settings to one that matches the Viewport size in the editor, and set the Test Width and Test Height to the actual game resolution. Then select your camera and use the Preview checkbox. Or you can run the game to see the shader in action.

Once the ViewportTexture is pointing at the Viewport of the ToonLightDataView container (you may have to turn on the Local to Scene under the Resource tab in the material) and there is an appropriate GradientTexture in the Key Light Ramp parameter, you have a nicely cel-shaded sphere. You can play with the colors and the softness of the key light shadow transition without affecting anything else.

Shader code so far

shader_type spatial;
render_mode unshaded;

//Data textures
uniform sampler2D light_data : hint_black;

uniform sampler2D key_light_ramp : hint_black;

//Light colors
uniform vec4 key_light_color : hint_color;
uniform vec4 shadow_color : hint_color;

void fragment() {
    //Data
  vec3 diffuse = texture(light_data, SCREEN_UV).rgb;

    //Key light
  float key_light_value = texture(key_light_ramp, vec2(diffuse.r, 0)).r;

    vec3 out_color = key_light_value * key_light_color.rgb;
    out_color = max(out_color, shadow_color.rgb);

    ALBEDO = out_color;
}

Pro-tip: Cast shadows by enabling shadows on the key light in the light data viewport and setting the viewport’s shadow atlas size to be greater than 0.