off
ikonic>
OUT 0 B

Glass

Cell Shading and Non-Photorealistic Rendering

Both terrain and trees use custom shader modifications via Three.js's onBeforeCompile hook. This allows us to modify the shader code at runtime, injecting custom lighting calculations without creating entirely custom shaders. The technique is inspired by cel-shading (also known as toon shading) used in games and animation.

Overview

The shader analyzes light intensity and quantizes it into discrete levels, creating sharp transitions between light and shadow. This gives everything a stylized, illustrated appearance reminiscent of Chinese ink paintings or Japanese animation.

Interactive Demo

Try this simplified cel-shading effect. The shader quantizes light intensity into discrete levels, creating the characteristic sharp transitions:

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;
    uv = uv * 2.0 - 1.0;
    uv.x *= iResolution.x / iResolution.y;

    // Create a sphere using ray marching (approximate)
    vec3 ro = vec3(0.0, 0.0, 3.0);
    vec2 screenUV = uv;
    vec3 rd = normalize(vec3(screenUV, -1.0));

    // Simple sphere intersection
    float t = dot(ro, rd);
    float d = t * t - dot(ro, ro) + 1.0;

    vec3 color = vec3(0.1);

    if (d > 0.0) {
        float tt = -t - sqrt(d);
        if (tt > 0.0) {
            vec3 pos = ro + rd * tt;
            vec3 normal = normalize(pos);

            // Light direction (move the mouse to swing it)
            vec3 lightDir = normalize(vec3(0.5, 0.8, 0.7));
            if (iMouse.x > 0.0) {
                vec2 mm = iMouse.xy / iResolution.xy * 2.0 - 1.0;
                lightDir = normalize(vec3(mm, 0.7));
            }

            // Calculate light intensity
            float lightIntensity = max(dot(normal, lightDir), 0.0);

            // Cel-shading quantization
            float cellLevel;
            if (lightIntensity > 0.7) cellLevel = 1.0;
            else if (lightIntensity > 0.4) cellLevel = 0.6;
            else if (lightIntensity > 0.2) cellLevel = 0.3;
            else cellLevel = 0.15;

            // Apply cell-shaded color
            color = vec3(0.2, 0.4, 0.8) * cellLevel;
        }
    }

    fragColor = vec4(color, 1.0);
}

Move your mouse to see how the light intensity changes and creates discrete shading levels. This demonstrates the core concept of cel-shading: quantizing continuous light values into discrete steps.

Implementation

The cell shading is implemented by modifying the shader code at runtime:


float lightIntensity = length(reflectedLight.directDiffuse);

  

float cellLevel;

if (lightIntensity > 0.7) cellLevel = 1.0;

else if (lightIntensity > 0.4) cellLevel = 0.6;

else if (lightIntensity > 0.2) cellLevel = 0.3;

else cellLevel = 0.15;

This creates four distinct lighting levels:

  • Bright: Light intensity > 0.7 → Full brightness (1.0)
  • Medium: Light intensity > 0.4 → 60% brightness
  • Dim: Light intensity > 0.2 → 30% brightness
  • Dark: Light intensity ≤ 0.2 → 15% brightness

Three.js Integration

The shader modification is done using Three.js's onBeforeCompile hook:


material.onBeforeCompile = (shader) => {

shader.fragmentShader = shader.fragmentShader.replace(

'#include <output_fragment>',

`

float lightIntensity = length(reflectedLight.directDiffuse);

float cellLevel;

if (lightIntensity > 0.7) cellLevel = 1.0;

else if (lightIntensity > 0.4) cellLevel = 0.6;

else if (lightIntensity > 0.2) cellLevel = 0.3;

else cellLevel = 0.15;

reflectedLight.directDiffuse *= cellLevel;

#include <output_fragment>

`

);

};

This approach allows us to:

  • Use standard Three.js materials as a base
  • Inject custom lighting calculations
  • Maintain compatibility with Three.js lighting system
  • Avoid writing entire custom shaders from scratch

Visual Style

The cell-shaded aesthetic creates:

  • Sharp transitions between light and shadow areas
  • Discrete lighting levels instead of smooth gradients
  • Stylized appearance that fits the minimalist design
  • Consistent look across terrain, trees, and other objects

This technique is commonly used in non-photorealistic rendering (NPR) to achieve a cartoon or cel-shaded aesthetic.

Performance Considerations

The cell shading implementation is highly performant:

  • Uses simple if/else chains, not complex calculations
  • Runs entirely on the GPU in the fragment shader
  • No additional CPU overhead
  • Minimal impact on frame rate

References

Related Articles