Outlines and light-driven outline

It’s not a cel-shaded image unless there’s a controllable outline. We have two outlines: a thin constant outline on the outside of the model, and one that follows responds to the key light. It results in an outline that’s thicker in shadow and thinner in light.

Constant outline

We can use the shader created for the 3D outline tutorial. The theory is to draw the object twice; once as normal, and a second time unshaded, culled from the front and slightly bigger.

shader_type spatial;
render_mode unshaded, cull_front, depth_draw_always;

uniform float thickness = 0.1;
uniform vec4 outline_color : hint_color = vec4(1.0);

void vertex() {
    VERTEX += (NORMAL * thickness);
}

void fragment() {
    ALBEDO = outline_color.rgb;
    ALPHA = outline_color.a;
}

Apply the outline shader to a ShaderMaterial assigned to the Next Pass of the toon shader material.

Dynamic toon outline

We create the dynamic outline using a Fresnel effect. A fresnel effect is one where the edges become brighter the further along the edge of the object, perpendicular to the camera, you get. In Godot’s spatial shader, this feature is Rim lighting.

We use a bit of math to generate the effect in the shader and use smoothstep to sharpen it. It’s then mixed into the key light’s diffuse so where it’s dark (near 0), the effect is gone, and where it’s light (near 1), it’s pronounced.

We need some parameters we can play with: the size of the outline in the shadow, and the outline color. As we’re using smoothstep, we can add const variables to control the outline’s minimum and maximum.

const float OUTLINE_MIN = 0.45;
const float OUTLINE_MAX = 0.47;

uniform vec4 outline_color : hint_color = vec4(0, 0, 0, 1.0);
uniform float outline_size : hint_range(0, 1) = 0.5;

The first step to achieve the effect is to generate the fresnel effect. We achieve fresnel effects with the dot product of the camera’s eye against the normal of the current fragment. In vector math, the dot product of two normal vectors is equal to less than 0 when pointing away from each other (with -1 being opposite), 0 when perpendicular, and greater than 0 when pointing towards the same direction (with 1 being parallel).

In other words, fragments that are at the edge are 0, and fragments that face the camera are 1. The back face culling removes fragments that face away, and they’re not visible. To control how strong the effect is and how the light affects it, we use a pow function based on the outline size and the light direction.

By default, fresnel math causes the edges to light up, but we want the opposite. We subtract the key light data from 1 to invert the effect.

float outline_factor = outline_size * (1.0 - diffuse.r);

Now we generate the fresnel. On its own, the value is weak. We boost it with the pow function and use the newly calculated outline factor as the factor. Wherever the light is weak, the fresnel factor will be even weaker, but wherever it’s strong, the fresnel factor ends up magnified.

float rim_value = pow(dot(NORMAL, VIEW), outline_factor);

Finally, we use smoothstep to get an outline amount at a given fragment’s rim value.

float outline_amount = smoothstep(OUTLINE_MIN, OUTLINE_MAX, rim_value);

Unlike the bright specular or kick light, the outline color should replace the color wherever it happens. We do that with mix so wherever it’s shadowed and at the edge of the model, it will be the outline color. Everywhere else will be the regular color.

out_color = mix(outline_color.rgb, out_color, outline_amount);

Shader code so far

shader_type spatial;
render_mode unshaded;

//Specular constants
const float SPECULAR_SOFT_MIN = 0.0;
const float SPECULAR_SOFT_MAX = 0.64;
const float SPECULAR_HARD_MIN = 0.17;
const float SPECULAR_HARD_MAX = 0.18;
//Outline constants
const float OUTLINE_MIN = 0.45;
const float OUTLINE_MAX = 0.47;

//Data textures
uniform sampler2D light_data : hint_black;
uniform sampler2D specular_data : hint_black;

uniform sampler2D key_light_ramp : hint_black;
uniform sampler2D fill_light_ramp : hint_black;
uniform sampler2D kick_light_ramp : hint_black;

//Outline
uniform vec4 outline_color : hint_color = vec4(0, 0, 0, 1.0);
uniform float outline_size : hint_range(0, 1) = 0.5;

//Specular
uniform float specular_softness : hint_range(0, 1);
uniform float specular_size : hint_range(0, 4);
uniform vec4 specular_color : hint_color;

//Light colors
uniform vec4 key_light_color : hint_color;
uniform vec4 shadow_color : hint_color;
uniform vec4 fill_light_color : hint_color;
uniform vec4 kick_light_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);

    //Fill light
  float fill_light_value = texture(fill_light_ramp, vec2(diffuse.g, 0)).r;
    out_color += fill_light_value * fill_light_color.rgb;

    //Kick light
  float kick_light_value = texture(kick_light_ramp, vec2(diffuse.b, 0)).r;
    out_color += kick_light_value * kick_light_color.rgb;

    //Specular
  float specular = texture(specular_data, SCREEN_UV).r;
    float soft_specular = smoothstep(SPECULAR_SOFT_MIN, SPECULAR_SOFT_MAX, specular * specular_size);
    float hard_specular = smoothstep(SPECULAR_HARD_MIN, SPECULAR_HARD_MAX, specular * specular_size);

    vec3 specular_out = mix(hard_specular, soft_specular, specular_softness) * specular_color.rgb;

    out_color += specular_out;

    //Outline
  float outline_factor = outline_size * (1.0 - diffuse.r);
    float rim_value = pow(dot(NORMAL, VIEW), outline_factor);

    float outline_amount = smoothstep(OUTLINE_MIN, OUTLINE_MAX, rim_value);

    out_color = mix(outline_color.rgb, out_color, outline_amount);

    ALBEDO = out_color;
}

Put the outline before or after the specular and kick light? That’s a decision you make based on your stylistic choice. If you do it after like the above, it will show up even if the kick light is there. Otherwise, the kick light will be on top of the dynamic outline.