Godot · GLSL · Shader Deep Dive

I Stared at 60 Lines of Shader Code Until It Finally Made Sense

A plain breakdown of my Voronoi cellular noise shader — what every line does, why it exists, and how it all connects.

The first time I read a shader I felt like I was looking at a math textbook written in a language I didn't speak. Numbers everywhere. Functions inside functions. A loop that somehow produces a glowing cell pattern on screen.

But here's the thing — shaders aren't as chaotic as they look. Once you understand the design behind them, the code starts to read like a story. Each section has a job. Each line is answering one specific question.

Let's use this Voronoi cellular noise shader as our subject. We'll go through the whole thing — not just what the code does, but why it's written this way.

"You don't need to understand the math. You need to understand the structure."

First — What Even Is a Voronoi Pattern?

Imagine dropping a bunch of seeds randomly onto a field. Every point on that field gets "claimed" by whichever seed is closest to it. The boundaries between claimed regions form a network of organic-looking cells. That's Voronoi.

You've seen it everywhere — cracked mud, giraffe spots, broken glass, biological tissue. It's one of those patterns nature uses constantly. And in a shader, we recreate it purely through math. No textures. No prebuilt data. Just coordinates and distance calculations.

Pixel position (UV) Find nearest seed Measure distance Map to color
runs for every single pixel on screen, every frame

The Structure — Before We Touch Any Code

Good code is organized. This shader is split into three distinct pieces, each with one job:

The uniforms — the public configuration layer. What you can tweak without touching the logic. Colors, speed, scale, thickness. Think of these like props in a component or environment variables in a backend service.

hash2() — a utility function. Its only job is to generate randomness. Since GPUs have no built-in random number generator, you have to fake it with math.

voronoi() — the core algorithm. Takes a position, figures out the nearest cell center, returns distances.

fragment() — the entry point. Thin by design. It takes the results from everything above and assembles the final color.

Every shader worth reading follows this kind of pattern: configure → compute → output. Now let's walk through each piece.

Part 1 — The Configuration Layer

shader_type canvas_item;

uniform highp float cell_scale  = 8.0;
uniform highp float speed       = 0.5;
uniform highp float border_thickness = 0.05;
uniform highp float glow_strength  = 1.5;
uniform highp vec4  colour_bg     : source_color = vec4(0.05, 0.05, 0.15, 1.0);
uniform highp vec4  colour_cell   : source_color = vec4(0.2,  0.6,  1.0,  1.0);
uniform highp vec4  colour_border : source_color = vec4(0.0,  1.0,  0.8,  1.0);
uniform highp float pulse_speed   = 1.2;
uniform highp float pulse_amount  = 0.15;

shader_type canvas_item is the declaration. It tells Godot this shader lives in 2D space, which unlocks the built-in variables like UV, TIME, and COLOR.

Every uniform is a knob exposed to the Inspector. The keyword highp forces high-precision floating point — on desktop it makes no visual difference, but on mobile GPUs low precision is the default and causes color banding. It's defensive programming.

float is a single decimal number. vec4 is four floats packed together — in color context that's red, green, blue, and alpha, each from 0.0 to 1.0. Not 0–255 like you might know from CSS. So vec4(1.0, 0.0, 0.0, 1.0) is pure red, fully opaque.

Why : source_color on the vec4 uniforms?
That's a Godot hint. Without it, the color you pick in the Inspector gets passed to the GPU as-is. With it, Godot knows to show a color picker widget and to convert from sRGB (what your monitor displays) to linear color space (what the GPU does math in internally). Skip it and your colors will look slightly washed out.

The cell_scale one is worth pausing on. By itself it just sits there as the number 8. The reason it works is what happens later — when we multiply UV * cell_scale in the fragment function. UV normally goes from 0 to 1 across the whole panel. Stretch it by 8 and it goes from 0 to 8, giving the math room to produce 8 cells across. Change it to 20 and you get a tighter grid of 20 cells. It's just scaling.

Part 2 — hash2() : Faking Randomness

vec2 hash2(vec2 p) {
    p = vec2(dot(p, vec2(127.1, 311.7)),
             dot(p, vec2(269.5, 183.3)));
    return fract(sin(p) * 43758.5453123);
}

GPUs don't have a built-in random function. You can't call random() like you would in Python or JavaScript. So shader developers build their own — a mathematical function that looks random but is fully deterministic. Give it the same input, get the same output every single time.

This matters a lot for Voronoi. Every cell needs its own random position — but that position has to stay consistent. If it changed every frame, the whole pattern would flicker like static. Deterministic randomness solves this. Same grid coordinate in, same random offset out, every frame.

Here's how it actually works. dot(p, vec2(127.1, 311.7)) is the dot product — it multiplies each component and sums them: p.x × 127.1 + p.y × 311.7. This mixes both coordinates into one number. The large, irrational-looking values (127.1, 311.7, 269.5, 183.3) are chosen because they produce good spread — no obvious repeating patterns. Two separate dot products give you X and Y outputs that are independent of each other.

Then sin(p) * 43758.5453123. Normally sin produces a smooth wave between -1 and 1, which sounds like the opposite of random. But when the input is a very large number, sin oscillates so rapidly that tiny changes produce wildly different outputs. That giant multiplier makes the number large enough for this to kick in.

Finally fract() strips everything except the decimal part. fract(6291.847) becomes 0.847. Now the output is clamped between 0 and 1, no matter how chaotic the intermediate value was.

The Big Idea hash2 is a lookup table disguised as math. Grid cell (3, 2) always gets the same random position. Grid cell (7, 5) always gets its own different one. Every frame, every pixel, reliably.

Part 3 — voronoi() : The Core Algorithm

vec2 voronoi(vec2 uv, float t) {
    vec2 i_uv = floor(uv);
    vec2 f_uv = fract(uv);

The function takes a UV position and time t. It returns a vec2 — two values packed into one. The first (.x) is how far you are from the nearest cell center. The second (.y) is how far you are from the nearest edge. Returning two things in one vector is a common GLSL trick since functions can only return one value.

The first two lines split the UV into two parts. floor(uv) rounds down to the nearest whole number — so (3.7, 2.1) becomes (3.0, 2.0). That's your grid cell ID: which cell are you in? fract(uv) gives the leftover decimal — (0.7, 0.1). That's your position within that cell.

They serve completely different purposes. The integer part identifies the cell (used to generate its random center via hash2). The fractional part measures where you are inside it (used to calculate distance to that center). That's why they're stored separately.

    float min_dist  = 8.0;
    float min_dist2 = 8.0;
    vec2  min_point = vec2(0.0);

Tracking variables before the search loop. min_dist is the distance to the closest cell center found so far. It starts at 8.0 — deliberately large so any real distance will be smaller and replace it immediately. min_dist2 tracks the second closest distance. You need that second one specifically to calculate edge distance later. vec2(0.0) is shorthand for vec2(0.0, 0.0).

    for (int y = -1; y <= 1; y++) {
        for (int x = -1; x <= 1; x++) {
            vec2 neighbour = vec2(float(x), float(y));
            vec2 rand_off  = hash2(i_uv + neighbour);
            vec2 point = neighbour + rand_off + 0.5 * sin(t + 6.2831 * rand_off);
            float d = length(point - f_uv);

A 3×3 loop checking the current grid cell and all 8 surrounding neighbors. Why neighbors? Because a cell center in an adjacent square might actually be closer to your pixel than the center of your own square. If you only checked your own cell, pixels near borders would look broken. This is the standard fix.

int is used for the loop counters because we're counting whole steps: -1, 0, 1. GLSL is strict about types — you can't mix int and float in math directly. That's why float(x) is needed to convert before building the vec2.

hash2(i_uv + neighbour) gets the random offset for this specific neighbor cell. Same input, same output — always consistent. rand_off is a vec2 between (0,0) and (1,1) representing where inside the cell its center lives.

The point line adds animation. Without the sin part it's just a static random position. The sin(t + 6.2831 * rand_off) makes each center drift smoothly over time. t is time. 6.2831 is 2π — multiplying rand_off by it means every cell starts at a different phase, so they move independently instead of all pulsing in sync.

length(point - f_uv) is just Pythagoras. Distance between two points. This is the distance from the current pixel to this particular cell center.

            if (d < min_dist) {
                min_dist2 = min_dist;
                min_dist  = d;
                min_point = point;
            } else if (d < min_dist2) {
                min_dist2 = d;
            }
        }
    }
    float edge = min_dist2 - min_dist;
    return vec2(min_dist, edge);
}

Classic "find the two smallest values" logic. If the new distance is the best so far, demote the old best to second place and record the new one. If it's not the best but beats second place, update second place only.

After all 9 neighbors are checked, min_dist and min_dist2 hold the two shortest distances to any cell center.

min_dist2 - min_dist is the edge distance. When this is close to zero, two cell centers are almost equidistant — you're sitting right on a border. When it's large, one center clearly dominates — you're deep inside a cell. This single subtraction is the mathematical definition of a Voronoi edge.

Part 4 — fragment() : Assembling the Final Color

void fragment() {
    vec2 uv = UV * cell_scale;
    float t  = TIME * speed;

The entry point. Notice how thin it is. It doesn't implement anything — it orchestrates. This is the same principle as a controller in MVC or a Lambda handler. The entry point calls things. It doesn't do the work itself.

UV * cell_scale is why cell_scale works. UV goes 0 to 1. Multiply by 8 and it goes 0 to 8. Now voronoi()'s floor operation sees 8 distinct integer values across the panel = 8 columns of cells. Simple scaling, big visual impact.

TIME * speed slows the animation down. TIME always increases in seconds. Multiplying by 0.5 means for every real second, the animation only progresses half a second's worth. Bigger speed = faster drift.

    vec2  vor       = voronoi(uv, t);
    float cell_dist = vor.x;
    float edge_dist = vor.y;

Calls voronoi() and immediately unpacks the returned vec2 into named variables. vor.x and vor.y access the first and second components. The naming matters — cell_dist and edge_dist tell any reader instantly what these numbers represent. This is the same reason you name variables well in any language. The code documents itself.

    float pulse = 1.0 + pulse_amount * sin(TIME * pulse_speed + cell_dist * 20.0);

Creates an animated brightness multiplier. sin() oscillates between -1 and 1. Multiplying by pulse_amount (0.15) scales that to ±0.15. Adding 1.0 shifts it to pulse between 0.85 and 1.15 — never negative, just gently varying around 1.

The + cell_dist * 20.0 is the clever part. Adding a different value per pixel gives every pixel its own phase offset. Pixels near cell centers pulse at a different moment than pixels at the edges. The result is a ripple-like wave radiating from each center, not a flat blink.

    float glow = pow(max(0.0, 1.0 - cell_dist * 1.5), 2.0) * glow_strength * pulse;

Glow intensity. 1.0 - cell_dist * 1.5 inverts the distance — bright at center, fades out as you move away. max(0.0, ...) clamps it so negative values (far from center) become zero instead of dark. pow(..., 2.0) squares the result, making the falloff curve non-linear. A linear fade looks flat. A squared fade looks like a real light source — bright hot center, quick sharp dropoff.

    float border = 1.0 - smoothstep(0.0, border_thickness, edge_dist);

smoothstep(a, b, x) returns 0 when x is below a, 1 when x is above b, and a smooth S-curve in between. Not a sharp step — an eased transition.

When edge_dist is near 0 (you're on a border), smoothstep returns 0, and 1.0 - 0 = 1.0 — full border color. When edge_dist exceeds border_thickness, smoothstep returns 1, and 1.0 - 1.0 = 0 — no border. border_thickness controls how wide the soft glow extends from the edge line.

    vec4 col = colour_bg;
    col = mix(col, colour_cell,   clamp(glow,   0.0, 1.0));
    col = mix(col, colour_border, clamp(border, 0.0, 1.0));

Layered compositing. Start with background. Then blend the cell glow color on top. Then blend the border color on top of that. mix(a, b, t) is linear interpolation — t=0 gives a, t=1 gives b, t=0.5 is half of each. Think Photoshop layer blending.

clamp(glow, 0.0, 1.0) forces the value into a valid range. mix() expects its third argument between 0 and 1. If glow_strength is cranked up, glow could exceed 1.0 and cause unexpected color artifacts. Clamping is defensive.

Order matters here. Border is applied last, so it draws on top of the glow. Swap the two lines and the glow would paint over the borders — visually valid, just different.

    col.a = COLOR.a;
    COLOR = col;
}

COLOR coming in still holds the node's original alpha — whatever opacity you set on the Panel in the Godot Inspector. Copying that to col.a before writing means the shader respects the node's transparency settings. Skip this and the panel will always be fully opaque regardless of what you set outside the shader. Small line, real-world consequence.

COLOR = col is the final write. Whatever you assign to COLOR is what appears on screen for this pixel. Everything before this line was calculation. This line is the output.

The Full Picture — One Pixel's Journey

Every frame, for every single pixel on your Panel, this runs from top to bottom:

UV × cell_scaleSplit into grid ID + position

Check 9 neighborshash2 per cellAnimate with sin(TIME)

Track 2 closest distancescell_dist + edge_dist

Calculate glow + border + pulseLayer with mix()Write to COLOR

No textures were loaded. No images were sampled. Just a position, some math, and three colors. That's the whole shader.

The pattern emerges not from data — but from the geometry of distance itself.

What This Teaches You Beyond Shaders

The Voronoi shader is a good teacher because it uses patterns you'll see everywhere in software.

The uniforms are a clean configuration layer — same idea as environment variables, props, or settings files. The logic shouldn't know or care about specific values. It should receive them from outside.

hash2() is a pure utility function. No side effects. Same input, same output. Completely testable in isolation. This is what people mean when they talk about writing functions that "do one thing."

voronoi() is the core algorithm, separated from presentation. It doesn't decide colors. It doesn't know about glow or borders. It just computes distances and returns them. The rendering concerns live in fragment(), not here.

fragment() is a thin entry point that orchestrates without implementing. If you asked someone to describe this shader's architecture in one sentence, the answer is in those four lines at the bottom.

These aren't shader-specific ideas. They're just good software design. And recognizing them in unfamiliar code — whatever the language, whatever the domain — is exactly the skill that scales.