Shaders - Cel Shading

If you just want the complete shader code or want to support me to create more tutorials like this one, you can consider:
Supporting me on patreon (Complete shader code is avaliable there): patreon.com/c/SaturnsMind
Buying my asset packs on itch.io: oddpotatodev.itch.io
An improved and more feature-rich version of this shader is avaliable on my Patreon
Introduction
Cel shading, also known as toon shading, is a non-photorealistic rendering technique that gives 3D models a flat, cartoon-like appearance. It mimics the look of hand-drawn animation or comic books, characterized by sharp transitions between light and shadow, rather than smooth gradients.

One of the earliest notable uses was in Jet Set Radio (2000) for the Sega Dreamcast, which showcased its distinct visual style. Games like The Legend of Zelda: The Wind Waker (2002) further popularized cel shading.
Pros and Cons of Cel Shading
| PROS |
| Distinct Visual Style: Creates a unique, eye-catching look that stands out from realistic rendering. |
| Timeless Appeal: Less prone to aging compared to photorealistic graphics. |
| Performance Efficiency: Simplified lighting calculations can be less resource-intensive. |
| CONS |
| Limited Realism: Not suitable for projects aiming for photorealistic visuals. |
| Artistic Skill Required: Achieving the desired look often requires careful tuning and artistic direction. |
| Edge Cases: Complex geometry or lighting scenarios can require additional tweaking to avoid artifacts. |
What are “Shaders”?
Shaders are programs that run on the GPU to control how 3D objects are rendered, including their color, lighting, and texture. They allow developers to define custom visual effects by manipulating vertex positions, colors, and lighting calculations. In this tutorial, we’ll use Godot Shader Language, a high-level shading language specific to the Godot game engine, which is similar to GLSL but tailored for Godot’s rendering pipeline.
Tutorial Overview
This tutorial will guide you through creating a cel-shaded material in Godot using the provided shader code. The shader implements a toon-like effect with customizable diffuse, specular, and rim lighting, along with optional texture ramps and borders.
Prerequisites
Godot Engine (version 4.x recommended).
A 3D scene with a mesh (e.g., a cube or character model).
Basic understanding of Godot’s material system and 3D rendering concepts.
Hands-on Tutorial
Step 1: Create a Shader Material
In Godot, create a new ShaderMaterial for your 3D mesh:
Select your mesh in the scene tree.
In the Inspector, under Material, click “New ShaderMaterial.”
Click the Shader property and select “New Shader.”
Open the shader editor and follow along to build the shader.

This step sets up a custom material that uses our shader to control how the mesh is rendered. The ShaderMaterial allows us to override Godot’s default rendering with our cel-shading logic, enabling fine-grained control over the visual style. By assigning it to a mesh, we ensure the shader processes every pixel of that mesh.
Step 2: Define Shader Type and Render Modes
This step defines the shader’s type and rendering behavior, setting up a minimal, testable shader with a basic color output.
shader_type spatial;
render_mode depth_draw_opaque, specular_schlick_ggx;
void vertex() {
// Placeholder for potential vertex transformations
}
void fragment() {
ALBEDO = vec3(1.0, 0.5, 0.5); // Default pink color for testing
}
shader_type spatial: Specifies that the shader is for 3D objects in Godot’s spatial rendering pipeline. This means shader_type spatial tells Godot to process this shader for 3D meshes, enabling features like 3D lighting and depth testing, ensuring the shader works with 3D scenes.
render_mode depth_draw_opaque, specular_schlick_ggx: Configures rendering settings. depth_draw_opaque ensures the shader draws opaque objects with depth testing, meaning objects closer to the camera obscure those farther away, preventing visual artifacts like incorrect object overlap. specular_schlick_ggx selects the Schlick-GGX model for specular highlights, meaning render_mode ... specular_schlick_ggx applies a physically based rendering (PBR) method to calculate reflections, which combines with later cel-shading logic for stylized highlights.
void vertex() { // Placeholder ... }: Defines an empty vertex function, meaning vertex() processes vertex data (e.g., positions, normals) but does nothing here. This ensures the shader compiles, as Godot requires a vertex() function, and serves as a placeholder for potential future transformations like scaling vertices for outlines.
void fragment() { ALBEDO = vec3(1.0, 0.5, 0.5); }: Sets the output color of each pixel. ALBEDO = vec3(1.0, 0.5, 0.5) assigns a pink color (RGB: 1.0, 0.5, 0.5) to the mesh’s surface, meaning ALBEDO defines the base color before lighting, making the shader testable by rendering a visible, solid-colored mesh.
This shader is minimal but functional, meaning shader_type spatial + render_mode + vertex() + fragment() creates a basic 3D shader that renders a pink mesh under Godot’s default lighting, allowing immediate testing while setting the foundation for cel-shading.

Step 3: Set Up Material Properties
This step adds material properties to control the mesh’s appearance, building on Step 2 with a fragment function that uses these properties.
shader_type spatial;
render_mode depth_draw_opaque, specular_schlick_ggx;
group_uniforms Material;
uniform vec3 albedo : source_color = vec3(1.0);
uniform bool use_texture_albedo = false;
uniform sampler2D albedo_texture : source_color, filter_linear, repeat_enable;
uniform float metallic : hint_range(0.0, 1.0) = 0.0;
uniform float roughness : hint_range(0.0, 1.0) = 0.5;
void vertex() {
// Placeholder for potential vertex transformations
}
void fragment() {
vec3 base_color = use_texture_albedo ? texture(albedo_texture, UV).rgb : albedo;
ALBEDO = base_color;
METALLIC = metallic;
ROUGHNESS = roughness;
}
shader_type spatial, render_mode depth_draw_opaque, specular_schlick_ggx: Carried from Step 2, meaning shader_type spatial + render_mode ensures 3D compatibility and PBR specular highlights, as described previously.
group_uniforms Material: Groups material-related uniforms in Godot’s inspector, meaning group_uniforms Material organizes albedo, use_texture_albedo, etc., into a “Material” category for easier tweaking.
uniform vec3 albedo : source_color = vec3(1.0): Defines the base color (default white, RGB: 1.0, 1.0, 1.0), meaning albedo sets the mesh’s color when no texture is used, serving as a fallback for untextured surfaces.
uniform bool use_texture_albedo = false: A toggle to choose between a solid color or texture, meaning use_texture_albedo determines whether base_color uses albedo or albedo_texture, offering flexibility for simple or detailed models.
uniform sampler2D albedo_texture : source_color, filter_linear, repeat_enable: Specifies a 2D texture for coloring, meaning albedo_texture provides pixel colors from an image, with filter_linear enabling smooth interpolation and repeat_enable allowing the texture to tile across the mesh.
uniform float metallic : hint_range(0.0, 1.0) = 0.0: Controls how metallic the surface appears, meaning metallic (0 = non-metallic, 1 = fully metallic) affects how much light is reflected versus absorbed, integrating with Godot’s PBR pipeline.
uniform float roughness : hint_range(0.0, 1.0) = 0.5: Adjusts surface smoothness, meaning roughness (0 = smooth, 1 = rough) determines how sharp or diffuse reflections are, enhancing the cel-shaded aesthetic.
void vertex() { // Placeholder ... }: Empty, as in Step 2, ensuring compilation.
vec3 base_color = use_texture_albedo ? texture(albedo_texture, UV).rgb : albedo: Selects the pixel color, meaning use_texture_albedo ? texture(...) : albedo checks the toggle and either samples albedo_texture at UV coordinates (mapping the texture to the mesh) or uses albedo, resulting in base_color as the final color source.
ALBEDO = base_color: Sets the mesh’s base color, meaning ALBEDO = base_color passes base_color to Godot’s rendering pipeline for lighting calculations.
METALLIC = metallic; ROUGHNESS = roughness: Applies PBR properties, meaning METALLIC = metallic + ROUGHNESS = roughness configures how the surface interacts with light, ensuring compatibility with Godot’s PBR lighting.
This shader, meaning Step 2 code + material uniforms + fragment(), renders a mesh with customizable color or texture and PBR properties, testable by assigning a texture in Godot or adjusting albedo in the inspector.
If the material appears too bright, try lowering the albedo color values in the inspector (e.g., vec3(0.8, 0.8, 0.8)). This can help balance the material’s appearance under default lighting. I’m still figuring out a solution for this.

Step 4: Lighting settings are grouped under Lighting:
This step adds lighting parameters to control ambient, specular, and rim lighting, with a minimal light function for testing.
shader_type spatial;
render_mode depth_draw_opaque, specular_schlick_ggx;
group_uniforms Material;
uniform vec3 albedo : source_color = vec3(1.0);
uniform bool use_texture_albedo = false;
uniform sampler2D albedo_texture : source_color, filter_linear, repeat_enable;
uniform float metallic : hint_range(0.0, 1.0) = 0.0;
uniform float roughness : hint_range(0.0, 1.0) = 0.5;
group_uniforms Lighting;
uniform vec3 ambient_light : source_color = vec3(0.1);
uniform bool use_ambient_light = true;
uniform float shininess : hint_range(1.0, 128.0) = 32.0;
uniform float specular_intensity : hint_range(0.0, 2.0) = 1.0;
uniform float rim_strength : hint_range(0.0, 1.0) = 1.0;
void vertex() {
// Placeholder for potential vertex transformations
}
void fragment() {
vec3 base_color = use_texture_albedo ? texture(albedo_texture, UV).rgb : albedo;
ALBEDO = base_color;
if (use_ambient_light) {
EMISSION = base_color * ambient_light;
} else {
EMISSION = vec3(0.0);
}
METALLIC = metallic;
ROUGHNESS = roughness;
}
shader_type, render_mode, Material uniforms, vertex(), fragment(): Carried from Step 3, meaning Step 3 code provides the base material setup for albedo, texture, metallic, and roughness.
group_uniforms Lighting: Organizes lighting uniforms in the inspector, meaning group_uniforms Lighting groups ambient_light, shininess, etc., for easy access.
uniform vec3 ambient_light : source_color = vec3(0.1): Sets a low-intensity background light, meaning ambient_light = vec3(0.1) ensures unlit areas have a subtle glow (RGB: 0.1, 0.1, 0.1), preventing complete darkness.
uniform bool use_ambient_light = true: Toggles ambient light, meaning use_ambient_light allows artists to enable (true) or disable (false) ambient lighting, controlling whether unlit areas are softly lit or black.
uniform float shininess : hint_range(1.0, 128.0) = 32.0: Controls specular highlight size, meaning shininess (higher = smaller, sharper highlights) prepares for later specular calculations, critical for cel-shaded “pop” (used in Step 8).
uniform float specular_intensity : hint_range(0.0, 2.0) = 1.0: Scales specular highlight brightness, meaning specular_intensity adjusts the intensity of shiny spots for stylistic control (used later).
uniform float rim_strength : hint_range(0.0, 1.0) = 1.0: Adjusts rim lighting intensity, meaning rim_strength controls the brightness of edge highlights, enhancing the cartoon look (used in Step 8).
**if (use_ambient_light) { EMISSION = base_color ambient_light; } else { EMISSION = vec3(0.0); }**: Applies ambient lighting, meaning base_color ambient_light multiplies the material’s color by ambient_light to create a glow in unlit areas, and EMISSION = vec3(0.0) disables it if use_ambient_light is false, allowing control over dark regions.
This shader, meaning Step 3 code + lighting uniforms + ambient in fragment(), renders a mesh with customizable material properties and ambient lighting.

Step 5: Implement Cel Shading Logic and Light function.
This step adds cel-shading parameters to control lighting transitions, with a basic light function for diffuse cel-shading.
shader_type spatial;
render_mode depth_draw_opaque, specular_schlick_ggx;
group_uniforms Material;
uniform vec3 albedo : source_color = vec3(1.0);
uniform bool use_texture_albedo = false;
uniform sampler2D albedo_texture : source_color, filter_linear, repeat_enable;
uniform float metallic : hint_range(0.0, 1.0) = 0.0;
uniform float roughness : hint_range(0.0, 1.0) = 0.5;
group_uniforms Lighting;
uniform vec3 ambient_light : source_color = vec3(0.1);
uniform bool use_ambient_light = true;
uniform float shininess : hint_range(1.0, 128.0) = 32.0;
uniform float specular_intensity : hint_range(0.0, 2.0) = 1.0;
uniform float rim_strength : hint_range(0.0, 1.0) = 1.0;
group_uniforms CelShading;
uniform float diffuse_threshold : hint_range(0.0, 1.0) = 0.5;
uniform float diffuse_smoothness : hint_range(0.0, 1.0) = 0.5;
uniform float specular_threshold : hint_range(0.0, 1.0) = 0.5;
uniform float specular_smoothness : hint_range(0.0, 1.0) = 0.0;
uniform float rim_threshold : hint_range(0.0, 1.0) = 0.8;
uniform float rim_smoothness : hint_range(0.0, 1.0) = 0.0;
uniform int directional_cuts : hint_range(1, 10) = 3;
uniform int non_directional_cuts : hint_range(1, 10) = 3;
uniform float wrap : hint_range(-2.0, 2.0) = 0.0;
uniform float steepness : hint_range(1.0, 8.0) = 1.0;
uniform bool use_ramp = false;
uniform sampler2D ramp : source_color;
uniform bool use_borders = false;
uniform float border_width : hint_range(0.0, 0.1) = 0.01;
uniform bool use_half_lambert = false;
void vertex() {
// Placeholder for potential vertex transformations
}
void fragment() {
vec3 base_color = use_texture_albedo ? texture(albedo_texture, UV).rgb : albedo;
ALBEDO = base_color;
if (use_ambient_light) {
EMISSION = base_color * ambient_light;
} else {
EMISSION = vec3(0.0);
}
METALLIC = metallic;
ROUGHNESS = roughness;
}
void light() {
float NdotL = dot(NORMAL, LIGHT);
float diffuse = clamp(NdotL + wrap, 0.0, 1.0) * steepness;
diffuse *= ATTENUATION;
int cuts = LIGHT_IS_DIRECTIONAL ? directional_cuts : non_directional_cuts;
float cuts_inv = 1.0 / float(cuts);
float diffuse_cell = floor(diffuse * float(cuts)) * cuts_inv;
diffuse_cell = smoothstep(
diffuse_threshold - diffuse_smoothness,
diffuse_threshold + diffuse_smoothness,
diffuse_cell
);
vec3 diffuse_contrib = ALBEDO * LIGHT_COLOR / 3.14159;
DIFFUSE_LIGHT += diffuse_contrib * diffuse_cell;
}
Previous uniforms, vertex(), fragment(): Includes Steps 3 and 4, meaning Step 4 code provides material and lighting setup for albedo, ambient, and PBR properties.
group_uniforms CelShading: Organizes cel-shading parameters, meaning group_uniforms CelShading groups diffuse_threshold, directional_cuts, etc., in the inspector.
uniform float diffuse_threshold : hint_range(0.0, 1.0) = 0.5: Sets the cutoff for diffuse light-to-shadow transitions, meaning diffuse_threshold determines where the lighting switches from bright to dark, controlling the cel-shaded banding effect.
uniform float diffuse_smoothness : hint_range(0.0, 1.0) = 0.5: Smooths diffuse transitions, meaning diffuse_smoothness softens the edges of lighting bands, reducing harshness.
uniform float specular_threshold : hint_range(0.0, 1.0) = 0.5, specular_smoothness : hint_range(0.0, 1.0) = 0.0: Prepare for specular highlights, meaning specular_threshold and specular_smoothness will control the cutoff and smoothness of shiny spots (used in Step 8).
uniform float rim_threshold : hint_range(0.0, 1.0) = 0.8, rim_smoothness : hint_range(0.0, 1.0) = 0.0: Prepare for rim lighting, meaning rim_threshold and rim_smoothness will define edge highlight cutoffs and smoothness (used later).
uniform int directional_cuts : hint_range(1, 10) = 3, non_directional_cuts : hint_range(1, 10) = 3: Set the number of lighting bands, meaning directional_cuts for sunlight and non_directional_cuts for point lights control how many discrete light levels appear, creating the toon style.
uniform float wrap : hint_range(-2.0, 2.0) = 0.0: Offsets diffuse lighting, meaning wrap extends light around edges, softening transitions from lit to shadowed areas.
uniform float steepness : hint_range(1.0, 8.0) = 1.0: Scales diffuse intensity, meaning steepness sharpens or softens lighting transitions for stylistic control.
uniform bool use_ramp = false, sampler2D ramp : source_color: Enable a 1D texture for color grading, meaning use_ramp and ramp allow mapping light intensity to colors (not used here but included).
uniform bool use_borders = false, border_width : hint_range(0.0, 0.1) = 0.01: Prepare for dark outlines, meaning use_borders and border_width will add comic-style lines (used later).
uniform bool use_half_lambert = false: Toggles lighting style, meaning use_half_lambert allows switching to softer Half-Lambert lighting (used in Step 8).
float NdotL = dot(NORMAL, LIGHT): Computes diffuse lighting angle, meaning dot(NORMAL, LIGHT) calculates how directly the surface faces the light, where NORMAL is the surface direction and LIGHT is the light direction, resulting in NdotL (1 = fully lit, -1 = fully shadowed).
float diffuse = clamp(NdotL + wrap, 0.0, 1.0) steepness*: Calculates diffuse lighting, meaning NdotL + wrap offsets the light angle, clamp(..., 0.0, 1.0) ensures it stays between 0 and 1, and* steepness scales intensity, resulting in diffuse as the base lighting value.
diffuse \= ATTENUATION*: Reduces light intensity with distance, meaning diffuse* ATTENUATION applies falloff for non-directional lights, ensuring realistic point or spot light behavior.
int cuts = LIGHT_IS_DIRECTIONAL ? directional_cuts : non_directional_cuts: Selects banding levels, meaning LIGHT_IS_DIRECTIONAL ? ... checks if the light is directional (e.g., sunlight) or not (e.g., point light), assigning directional_cuts or non_directional_cuts to cuts.
float cuts_inv = 1.0 / float(cuts): Prepares for quantization, meaning 1.0 / float(cuts) calculates the step size for lighting bands, enabling discrete light levels.
float diffuse_cell = floor(diffuse float(cuts)) cuts_inv: Quantizes diffuse lighting, meaning diffuse float(cuts) scales the light value, floor(...) rounds down to create bands, and cuts_inv normalizes it, resulting in diffuse_cell as a stepped lighting value for the cel-shaded look.
diffuse_cell = smoothstep(diffuse_threshold - diffuse_smoothness, diffuse_threshold + diffuse_smoothness, diffuse_cell): Smooths transitions, meaning smoothstep(...) interpolates diffuse_cell between diffuse_threshold - diffuse_smoothness and diffuse_threshold + diffuse_smoothness, softening band edges.
vec3 diffuse_contrib = ALBEDO LIGHT_COLOR / 3.14159*: Computes diffuse color, meaning ALBEDO* LIGHT_COLOR combines the material’s color with the light’s color, and / 3.14159 normalizes for energy conservation, resulting in diffuse_contrib as the colored lighting contribution.
DIFFUSE_LIGHT += diffuse_contrib diffuse_cell*: Applies cel-shaded lighting, meaning diffuse_contrib* diffuse_cell combines the color and quantized lighting, setting DIFFUSE_LIGHT as the final diffuse output for Godot’s pipeline.
This shader, meaning Step 4 code + cel-shading uniforms + diffuse light(), renders a mesh with a basic cel-shaded diffuse effect, testable with adjustable banding and smoothness under Godot lights.

Step 6: Write the Vertex Function
The vertex function remains a placeholder, as no transformations are needed:
- void vertex() { // Placeholder ... }: Processes vertex data but does nothing, meaning vertex() is included in all shaders to ensure compilation and serves as a placeholder for potential extensions like vertex-based outlines. It’s empty, so vertex() = no change keeps the focus on pixel-based cel-shading.
Step 7: Write the Light Function
This step implements the complete cel-shading light function, adding specular and rim lighting to the diffuse effect. The shader includes all prior steps, making it fully functional and testable with the complete cel-shaded effect.
shader_type spatial;
render_mode depth_draw_opaque, specular_schlick_ggx;
group_uniforms Material;
uniform vec3 albedo : source_color = vec3(1.0);
uniform bool use_texture_albedo = false;
uniform sampler2D albedo_texture : source_color, filter_linear, repeat_enable;
uniform float metallic : hint_range(0.0, 1.0) = 0.0;
uniform float roughness : hint_range(0.0, 1.0) = 0.5;
group_uniforms Lighting;
uniform vec3 ambient_light : source_color = vec3(0.1);
uniform bool use_ambient_light = true;
uniform float shininess : hint_range(1.0, 128.0) = 32.0;
uniform float specular_intensity : hint_range(0.0, 2.0) = 1.0;
uniform float rim_strength : hint_range(0.0, 1.0) = 1.0;
group_uniforms CelShading;
uniform float diffuse_threshold : hint_range(0.0, 1.0) = 0.5;
uniform float diffuse_smoothness : hint_range(0.0, 1.0) = 0.5;
uniform float specular_threshold : hint_range(0.0, 1.0) = 0.5;
uniform float specular_smoothness : hint_range(0.0, 1.0) = 0.0;
uniform float rim_threshold : hint_range(0.0, 1.0) = 0.8;
uniform float rim_smoothness : hint_range(0.0, 1.0) = 0.0;
uniform int directional_cuts : hint_range(1, 10) = 3;
uniform int non_directional_cuts : hint_range(1, 10) = 3;
uniform float wrap : hint_range(-2.0, 2.0) = 0.0;
uniform float steepness : hint_range(1.0, 8.0) = 1.0;
uniform bool use_ramp = false;
uniform sampler2D ramp : source_color;
uniform bool use_borders = false;
uniform float border_width : hint_range(0.0, 0.1) = 0.01;
uniform bool use_half_lambert = false;
void vertex() {
// Placeholder for potential vertex transformations
}
void fragment() {
vec3 base_color = use_texture_albedo ? texture(albedo_texture, UV).rgb : albedo;
ALBEDO = base_color;
if (use_ambient_light) {
EMISSION = base_color * ambient_light;
} else {
EMISSION = vec3(0.0);
}
METALLIC = metallic;
ROUGHNESS = roughness;
}
void light() {
float NdotL = dot(NORMAL, LIGHT);
float diffuse;
if (use_half_lambert) {
diffuse = pow(0.5 * NdotL + 0.5, 2.0);
} else {
diffuse = clamp(NdotL + wrap, 0.0, 1.0) * steepness;
}
diffuse *= ATTENUATION;
int cuts = LIGHT_IS_DIRECTIONAL ? directional_cuts : non_directional_cuts;
float cuts_inv = 1.0 / float(cuts);
float diffuse_cell = floor(diffuse * float(cuts)) * cuts_inv;
diffuse_cell = smoothstep(
diffuse_threshold - diffuse_smoothness,
diffuse_threshold + diffuse_smoothness,
diffuse_cell
);
float border = 0.0;
if (use_borders) {
float corr_border_width = length(cross(NORMAL, LIGHT)) * border_width * steepness;
border = step(diffuse_cell - corr_border_width, diffuse)
- step(1.0 - corr_border_width, diffuse);
}
vec3 diffuse_contrib = ALBEDO * LIGHT_COLOR / 3.14159;
if (use_ramp) {
diffuse_contrib *= texture(ramp, vec2(diffuse_cell * (1.0 - border), 0.0)).rgb;
} else {
diffuse_contrib *= diffuse_cell * (1.0 - border);
}
float dist_atten = LIGHT_IS_DIRECTIONAL ? 1.0 : ATTENUATION;
vec3 halfway = normalize(LIGHT + VIEW);
float specular = pow(clamp(dot(NORMAL, halfway), 0.0, 1.0), shininess) * specular_intensity;
float specular_cell = smoothstep(
specular_threshold - specular_smoothness,
specular_threshold + specular_smoothness,
specular
);
float rim = clamp(1.0 - dot(NORMAL, VIEW), 0.0, 1.0);
float rim_cell = smoothstep(
rim_threshold - rim_smoothness,
rim_threshold + rim_smoothness,
rim
);
float highlight = max(specular_cell, rim_cell * rim_strength);
float light_contrib = diffuse_cell + highlight * diffuse_cell;
DIFFUSE_LIGHT += light_contrib * diffuse_contrib * dist_atten;
}
Previous uniforms, vertex(), fragment(): Includes all from Steps 3, 4, 5, and 7, meaning Step 7 code provides material, lighting, and cel-shading parameters, plus the fragment function for albedo, ambient, and PBR properties.
void light() { ... }: Implements the full cel-shading effect, meaning diffuse + specular + rim + borders + ramp combines multiple lighting components for a complete toon-shaded look:
float NdotL = dot(NORMAL, LIGHT): Calculates the diffuse lighting angle, meaning dot(NORMAL, LIGHT) computes NdotL as the cosine of the angle between the surface normal (NORMAL) and light direction (LIGHT), where NdotL = 1 means fully lit and NdotL = -1 means fully shadowed.
if (use_half_lambert) { diffuse = pow(0.5 NdotL + 0.5, 2.0); } else { diffuse = clamp(NdotL + wrap, 0.0, 1.0) steepness; }: Computes diffuse lighting, meaning use_half_lambert ? pow(...) : clamp(...) selects either Half-Lambert (pow(0.5 * NdotL + 0.5, 2.0)) for softer transitions or standard Lambertian (NdotL + wrap) adjusted by wrap and scaled by steepness, resulting in diffuse as the raw diffuse value.
diffuse \= ATTENUATION*: Applies distance falloff, meaning diffuse* ATTENUATION reduces intensity for non-directional lights (e.g., point lights), ensuring realistic light decay.
int cuts = LIGHT_IS_DIRECTIONAL ? directional_cuts : non_directional_cuts; float cuts_inv = 1.0 / float(cuts): Sets quantization levels, meaning LIGHT_IS_DIRECTIONAL ? ... assigns directional_cuts or non_directional_cuts to cuts, and cuts_inv = 1.0 / float(cuts) computes the step size for discrete bands.
float diffuse_cell = floor(diffuse float(cuts)) cuts_inv: Quantizes diffuse lighting, meaning diffuse float(cuts) scales the light, floor(...) rounds down to create bands, and cuts_inv normalizes it, resulting in diffuse_cell as a stepped, cel-shaded light value.
diffuse_cell = smoothstep(diffuse_threshold - diffuse_smoothness, diffuse_threshold + diffuse_smoothness, diffuse_cell): Smooths diffuse bands, meaning smoothstep(...) interpolates diffuse_cell between diffuse_threshold - diffuse_smoothness and diffuse_threshold + diffuse_smoothness, softening transitions for a refined look.
float border = 0.0; if (use_borders) { float corr_border_width = length(cross(NORMAL, LIGHT)) border_width steepness; border = step(diffuse_cell - corr_border_width, diffuse) - step(1.0 - corr_border_width, diffuse); }: Adds comic-style outlines, meaning use_borders ? ... : 0.0 computes corr_border_width as length(cross(NORMAL, LIGHT)) border_width steepness to scale border thickness, and step(...) - step(...) creates a binary mask for dark lines between light and shadow, resulting in border as the outline contribution.
vec3 diffuse_contrib = ALBEDO LIGHT_COLOR / 3.14159*: Computes diffuse color, meaning ALBEDO* LIGHT_COLOR combines material and light colors, and / 3.14159 normalizes for energy conservation, resulting in diffuse_contrib as the colored diffuse lighting.
if (use_ramp) { diffuse_contrib \= texture(ramp, vec2(diffuse_cell (1.0 - border), 0.0)).rgb; } else { diffuse_contrib \= diffuse_cell (1.0 - border); }: Applies color grading, meaning use_ramp ? texture(ramp, ...).rgb : diffuse_cell (1.0 - border) either samples a 1D ramp texture at diffuse_cell (1.0 - border) for stylized colors or scales diffuse_contrib by diffuse_cell * (1.0 - border), excluding border areas, resulting in the final diffuse contribution.
float dist_atten = LIGHT_IS_DIRECTIONAL ? 1.0 : ATTENUATION: Sets attenuation, meaning LIGHT_IS_DIRECTIONAL ? 1.0 : ATTENUATION uses no falloff for directional lights or ATTENUATION for others, ensuring appropriate light decay.
vec3 halfway = normalize(LIGHT + VIEW): Computes the halfway vector, meaning normalize(LIGHT + VIEW) averages the light and view directions, resulting in halfway for specular calculations.
float specular = pow(clamp(dot(NORMAL, halfway), 0.0, 1.0), shininess) specular_intensity*: Calculates specular highlights, meaning dot(NORMAL, halfway) gets the reflection angle, clamp(..., 0.0, 1.0) ensures it’s positive, pow(..., shininess) shapes the highlight size, and* specular_intensity scales brightness, resulting in specular as the raw highlight value.
float specular_cell = smoothstep(specular_threshold - specular_smoothness, specular_threshold + specular_smoothness, specular): Quantizes specular highlights, meaning smoothstep(...) interpolates specular between specular_threshold - specular_smoothness and specular_threshold + specular_smoothness, resulting in specular_cell as a stepped, toon-like highlight.
float rim = clamp(1.0 - dot(NORMAL, VIEW), 0.0, 1.0): Computes rim lighting, meaning 1.0 - dot(NORMAL, VIEW) calculates edge visibility (higher when the normal faces away from the viewer), and clamp(..., 0.0, 1.0) ensures it’s positive, resulting in rim as the raw rim value.
float rim_cell = smoothstep(rim_threshold - rim_smoothness, rim_threshold + rim_smoothness, rim): Quantizes rim lighting, meaning smoothstep(...) interpolates rim between rim_threshold - rim_smoothness and rim_threshold + rim_smoothness, resulting in rim_cell as a stepped edge highlight.
**float highlight = max(specular_cell, rim_cell rim_strength)**: Combines highlights, meaning max(specular_cell, rim_cell rim_strength) takes the stronger of specular_cell or rim_cell scaled by rim_strength, resulting in highlight as the combined highlight contribution.
float light_contrib = diffuse_cell + highlight diffuse_cell*: Combines lighting, meaning diffuse_cell + highlight* diffuse_cell adds diffuse and highlight contributions, with highlight * diffuse_cell ensuring highlights appear only in lit areas, resulting in light_contrib as the total lighting effect.
DIFFUSE_LIGHT += light_contrib diffuse_contrib dist_atten: Applies final lighting, meaning light_contrib diffuse_contrib dist_atten combines the lighting effect, colored diffuse contribution, and attenuation, adding to DIFFUSE_LIGHT for Godot’s rendering pipeline.
This shader, meaning Step 7 code + full light(), renders a mesh with the complete cel-shading effect, including quantized diffuse, specular, and rim lighting, plus optional borders and ramp textures, fully testable in Godot with adjustable parameters.

Step 8 (Final): Test and Tweak
Apply the shader material to a 3D mesh in your scene (e.g., a MeshInstance3D with a CubeMesh).
In the Inspector, adjust parameters like diffuse_threshold, directional_cuts, rim_strength, or specular_intensity to fine-tune the cel-shaded appearance.
(Optional) Create a 1D ramp texture (e.g., a 256x1 pixel gradient image) and assign it to the ramp uniform for custom color grading, meaning ramp maps light intensity to specific colors for stylized effects.
Test with different lighting setups (e.g., DirectionalLight3D or PointLight3D) to observe how directional_cuts and non_directional_cuts affect banding under various light types.
Ensure a light source is present in the scene, meaning a DirectionalLight3D or PointLight3D is needed for LIGHT and LIGHT_COLOR to function, as the light() function depends on light interactions.
This step ensures the features of the cel-shading effect are working, meaning shader + inspector tweaks enables artists to adjust diffuse_threshold for light-to-shadow transitions, directional_cuts for banding, or rim_strength for edge highlights. The ramp texture option, meaning use_ramp + ramp, supports advanced stylization, and testing with different lights ensures versatility across scene conditions.



