~2 min read

gdshader in depth — vertex, fragment, light

Spatial shader structure, built-in functions, uniforms, varyings, optimization steps.

In the rendering chapter we saw a basic ShaderMaterial. Here, in more detail: the structure of a 3D shader, what the vertex/fragment/light functions are, and how to write your own lighting.

Full structure of a spatial shader

shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;

// Uniforms — material parameters available from the Inspector
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap;
uniform vec4 tint : source_color = vec4(1.0);
uniform float roughness : hint_range(0.0, 1.0) = 0.5;

// Varyings — data passed from vertex to fragment
varying vec3 world_pos;
varying float vertical_factor;

void vertex() {
    // Here we modify vertex positions and pass data downstream
    world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
    vertical_factor = clamp(VERTEX.y * 0.1, 0.0, 1.0);

    // Simple wave: sine displacement based on world time
    VERTEX.y += sin(TIME + world_pos.x) * 0.1;
}

void fragment() {
    // Here we compute the color of each pixel
    vec4 tex = texture(albedo_tex, UV);
    ALBEDO = tex.rgb * tint.rgb;
    ALPHA = tex.a * tint.a;
    ROUGHNESS = roughness;
    METALLIC = 0.0;
    EMISSION = vec3(0.0, 0.5 * vertical_factor, 0.0);  // green gradient by height
}

void light() {
    // (optional) custom lighting, replaces the built-in one
    // LIGHT — final color, added to ALBEDO
    DIFFUSE_LIGHT += LIGHT_COLOR * dot(NORMAL, LIGHT) * ATTENUATION;
}

What’s inside:

  • shader_type spatial; — this is for a 3D mesh. For 2D use canvas_item, for a skybox use sky, and so on.
  • render_mode — flags: blend_* (transparency), cull_* (culling), depth_* (Z-buffer), diffuse_* (diffuse lighting model), specular_* (specular), unshaded (no lighting).
  • uniform — a parameter visible in the material Inspector and accessible from code via set_shader_parameter.
  • varying — a variable passed from vertex to fragment (interpolated across the triangle).
  • vertex() — modifies vertices before rasterization.
  • fragment() — computes surface properties (ALBEDO, NORMAL, ROUGHNESS, METALLIC, EMISSION, ALPHA).
  • light() — custom lighting function (if you want your own non-PBR look).

Built-in variables

The main in/out variables:

In vertex():

  • VERTEX — vertex position in object space (in/out).
  • NORMAL — normal (in/out).
  • TANGENT, BINORMAL — tangents.
  • UV, UV2 — texture coordinates.
  • COLOR — vertex color.
  • MODEL_MATRIX, VIEW_MATRIX, PROJECTION_MATRIX — standard matrices.
  • TIME — game time in seconds.
  • INSTANCE_ID, VERTEX_ID — indices (for MultiMesh).

In fragment():

  • ALBEDO — diffuse color (out, vec3).
  • ALPHA — transparency (out, float).
  • METALLIC, ROUGHNESS, SPECULAR — PBR parameters (out).
  • EMISSION — self-illumination (out, vec3).
  • NORMAL, NORMAL_MAP — normals (out).
  • AO, AO_LIGHT_AFFECT — ambient occlusion (out).
  • RIM, RIM_TINT — rim light (out).
  • CLEARCOAT, CLEARCOAT_GLOSS.
  • UV, FRAGCOORD, VIEW, VERTEX — input.
  • DEPTH — out (you can write to the depth buffer).

In light():

  • LIGHT — direction toward the light source (in, vec3).
  • LIGHT_COLOR — color with intensity (in, vec3).
  • ATTENUATION — attenuation (in, float).
  • DIFFUSE_LIGHT, SPECULAR_LIGHT — final results (out, vec3).
  • NORMAL, VIEW — for calculations.

Hints for uniforms

// Source: color texture (applies inverse gamma — sRGB → linear automatically)
uniform sampler2D albedo : source_color, filter_linear_mipmap, repeat_enable;

// Source: normal map (unpacked correctly)
uniform sampler2D normal_map : hint_normal;

// Range slider in the Inspector
uniform float intensity : hint_range(0.0, 5.0, 0.01) = 1.0;

// Color picker
uniform vec4 emission_color : source_color = vec4(1.0, 0.5, 0.0, 1.0);

// Cubemap for skybox / reflection
uniform samplerCube environment : source_color, filter_linear_mipmap;

The main hints:

  • source_color — for color textures (sRGB conversion).
  • hint_normal — for a normal map.
  • hint_roughness_r / _g / _b / _gray — channel pack for PBR.
  • hint_range(min, max, step) — slider.
  • filter_* — filtering: nearest / linear / linear_mipmap / linear_mipmap_anisotropic.
  • repeat_* — wrap: enable / disable.

Example: dissolve

The classic “object dissolves into a noise pattern” effect:

shader_type spatial;

uniform sampler2D albedo : source_color, filter_linear_mipmap;
uniform sampler2D noise : filter_linear, repeat_enable;
uniform float threshold : hint_range(0.0, 1.0) = 0.0;
uniform vec3 edge_color : source_color = vec3(1.0, 0.5, 0.0);
uniform float edge_width : hint_range(0.0, 0.1) = 0.03;

void fragment() {
    vec4 tex = texture(albedo, UV);
    float n = texture(noise, UV).r;

    if (n < threshold) {
        discard;  // discard this pixel
    }

    ALBEDO = tex.rgb;

    if (n < threshold + edge_width) {
        EMISSION = edge_color * 3.0;
        ALBEDO = edge_color;
    }
}

Usage:

var mat = $Mesh.material_override as ShaderMaterial
var tween = create_tween()
tween.tween_property(mat, "shader_parameter/threshold", 1.0, 2.0)
tween.tween_callback(queue_free)

Sub-pass: Cull back and transparency

Transparent objects often require manual tuning:

shader_type spatial;
render_mode blend_mix, depth_draw_always, cull_disabled;

uniform vec4 color : source_color = vec4(1.0, 0.5, 0.5, 0.4);

void fragment() {
    ALBEDO = color.rgb;
    ALPHA = color.a;
}
  • blend_mix — standard alpha blending.
  • depth_draw_always — always write to the Z-buffer (for correct intersections).
  • cull_disabled — draw both sides (for glass, foliage).

Your own light() — toon shading

shader_type spatial;
render_mode unshaded;  // fully disable built-in lighting

uniform sampler2D albedo : source_color, filter_linear_mipmap;
uniform float bands : hint_range(2, 8, 1) = 3;

varying vec3 world_normal;

void vertex() {
    world_normal = (MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz;
}

void light() {
    float diffuse = dot(normalize(world_normal), normalize(LIGHT));
    float quantized = floor(diffuse * bands) / bands;
    DIFFUSE_LIGHT += LIGHT_COLOR * max(quantized, 0.0);
}

void fragment() {
    ALBEDO = texture(albedo, UV).rgb;
}

render_mode unshaded disables the built-in PBR lighting; we write our own in light().

Performance: branching in a shader

if (n < threshold) { discard; } is a branch in the fragment shader. It’s cheap on its own, but massive branching destroys performance. Usually smoothstep or a mask via step() is better — without ifs.

Particle shader

Particles use shader_type particles; with their own semantics:

shader_type particles;
render_mode keep_data;

uniform vec3 attract_center;
uniform float attract_strength : hint_range(0.0, 10.0) = 1.0;

void process() {
    vec3 to_center = attract_center - TRANSFORM[3].xyz;
    float dist = length(to_center);
    VELOCITY += normalize(to_center) * attract_strength * DELTA;
    TRANSFORM[3].xyz += VELOCITY * DELTA;
}

Applied as process_material on GPUParticles3D (replaces ParticleProcessMaterial).

What not to do in shaders

  1. Heavy computations in fragment(). Every pixel invokes the fragment shader. One extra texture sample at 1080p is 2 million samples per frame.
  2. Large loops. The GPU handles divergent branching poorly. A loop for (int i = 0; i < 100; i++) is acceptable, but for (int i = 0; i < N; i++) if (cond[i]) ... is not.
  3. Pixel-perfect logic in the fragment shader. If it can be done in vertex (N times fewer calls), do it there.
  4. dynamic texture sampling via a variable index — the GPU dislikes it. One atlas is better than an array of textures.

Further reading

  • godotshaders.com — a community site with ready-made shaders.
  • Documentation — the Shading reference in the Godot Docs (docs.godotengine.org/en/stable/tutorials/shaders/).
  • Sources of the built-in shaders — in the Godot repository at servers/rendering/renderer_rd/shaders/.

The next chapter is a glossary.