GPU Programming · Lesson 1 · ~20 minutes
Your first compute shader — and the one idea every later one rests on.
Your mission needs two things from the GPU: thousands of agents simulated at once, and procedural data generated in parallel. Underneath, those are the same problem — one tiny program, run over an enormous pile of data. Today you write that program, in GLSL, and run it from Godot. The data is ten numbers and the program doubles them. Deliberately trivial: the whole lesson is about the shape of the thing, because the shape never changes when the ten numbers become thirty thousand boids.
Here is the CPU version you already know how to write. The loop is yours: you decide the order, you decide when it ends.
for i in data.size():
data[i] = data[i] * 2.0
Here is the GPU version. The loop is gone. You wrote only the body — and there is
no i, because you are not iterating. Instead, this body is launched thousands of
times at once, and each launch asks the hardware which one am I?
void main() {
uint id = gl_GlobalInvocationID.x;
data[id] = data[id] * 2.0;
}
Each of those launches is called an invocation. Not a "thread" — GPU vocabulary reserves that word for hardware. See the glossary.
That inversion is the entire conceptual jump, and it is the only genuinely hard part for a programmer who already knows how to program. Everything else in compute shaders is bookkeeping. The body is written from the point of view of one element, and the id is the only thing that distinguishes you from your thousands of identical siblings.
The habit to build: when you look at a loop and wonder "could this run on the GPU?", the real question is does iteration N need the result of iteration N−1? If yes, it does not map. If no — doubling numbers, stepping a particle, sampling noise at a coordinate — it maps directly.
Invocations are not launched as one flat crowd. They are launched in workgroups: fixed-size blocks of invocations. You declare the block size in the shader, and you ask for a number of blocks from the CPU. Multiply them and you get your invocation count.
Every invocation can ask for three ids. Only the third one usually matters:
| Built-in | Means | Range |
|---|---|---|
gl_WorkGroupID | Which block am I in? | 0 … groups−1 |
gl_LocalInvocationID | Where am I inside my block? | 0 … local_size−1 |
gl_GlobalInvocationID | Which element am I, overall? | 0 … invocations−1 |
The third is derived from the first two, and the Khronos wiki states the relation exactly:
gl_GlobalInvocationID = gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationID Khronos OpenGL Wiki — Compute Shader
Which is just "block number × block size + offset within block" — the same arithmetic you would write to index a chunked array by hand.
Because the hardware is built from them. NVIDIA hardware executes invocations in bundles of
32 that march in lockstep — a warp, "a bundle of 32 threads with consecutive
thread indexes"
(Cornell
Virtual Workshop); AMD's equivalent is 64 wide. Your local_size is chopped
into those bundles, so a local_size of 1 wastes 31 of every 32 lanes. NVIDIA's own
guidance is to choose sizes that are "multiples of the warp size (i.e., 32 on current GPUs)"
(CUDA C++ Best
Practices Guide).
Rule of thumb for now: local_size_x = 64 for
1D work over a buffer. It is a multiple of both 32 and 64, so it wastes nothing on either
vendor. Today's example uses 8 only so the lab stays readable.
The second reason is that a workgroup is the only unit inside which invocations can cooperate — shared memory, barriers, all of it is per-group. Between groups there is nothing. Not even an order:
During execution of the work groups the order might vary arbitrarily and the program should not rely on the order in which individual groups are processed. LearnOpenGL — Compute Shaders
Below, the buffer is the data and the squares are invocations, labelled with their
gl_GlobalInvocationID.x. Press Dispatch and the groups fire in a
random order every time — because they genuinely may. The status pill turns green only when
every element is covered and nothing runs off the end.
Work through these in order
local_size_x 2. Find the dispatch count that fits exactly.
Dispatch it, and watch the group order change on each run.local_size_x to 4, leaving the buffer at 10. Try to fit it
exactly. You cannot. Settle for 3 groups and read the warning.local_size_x 6, then press Fit workgroups to buffer.
Note the number it picks, and why it is not 32 ÷ 6.The lesson those four steps teach is the one that catches everyone: you dispatch
groups, not invocations, so your invocation count is always rounded up to a whole
multiple of local_size. Exact fits are a coincidence. Therefore the guard is not
defensive style — it is a structural part of every compute shader you will ever write.
Godot exposes the GPU through RenderingDevice, a thin wrapper over Vulkan. The
ceremony below never changes — only the buffers and the shader do — so read it once as a
shape, not as seven things to memorise.
| # | Step | Why it exists |
|---|---|---|
| 1 | Get a rendering device | Your handle to the GPU. |
| 2 | Compile the shader | GLSL → SPIR-V → a GPU program object. |
| 3 | Create a storage buffer | GPU-side memory, seeded from CPU bytes. |
| 4 | Bind it to a uniform set | Tells the shader which memory binding = 0 means. |
| 5 | Create a pipeline | The compiled shader plus its state, ready to run. |
| 6 | Record and dispatch a compute list | The actual "go" — with your group count. |
| 7 | Submit, sync, read back | Hand the work to the GPU, wait, copy results home. |
res://double_it.glsl#[compute]
#version 450
// 8 invocations per workgroup. Real work wants 64; 8 keeps the lab readable.
layout(local_size_x = 8, local_size_y = 1, local_size_z = 1) in;
// set 0, binding 0 — must match the GDScript below exactly.
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
float data[];
} my_data_buffer;
void main() {
uint id = gl_GlobalInvocationID.x;
if (id >= my_data_buffer.data.length()) {
return; // the guard from step 3 of the lab
}
my_data_buffer.data[id] *= 2.0;
}
Three things that are not GDScript and will bite you: the
#[compute] tag on line 1 is Godot-specific and mandatory; the file must be saved
with the .glsl extension so Godot imports it as an RDShaderFile; and
float here is a real 32-bit float, not GDScript's 64-bit one.
extends Node
func _ready() -> void:
# 1. a rendering device of our own, separate from the one drawing the game
var rd := RenderingServer.create_local_rendering_device()
# 2. compile
var shader_file: RDShaderFile = load("res://double_it.glsl")
var shader := rd.shader_create_from_spirv(shader_file.get_spirv())
# 3. upload ten floats
var input := PackedFloat32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
var input_bytes := input.to_byte_array()
var buffer := rd.storage_buffer_create(input_bytes.size(), input_bytes)
# 4. bind it as set 0, binding 0
var uniform := RDUniform.new()
uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
uniform.binding = 0
uniform.add_id(buffer)
var uniform_set := rd.uniform_set_create([uniform], shader, 0)
# 5. pipeline
var pipeline := rd.compute_pipeline_create(shader)
# 6. dispatch ceil(10 / 8) = 2 workgroups = 16 invocations, 6 of them guarded
const LOCAL_SIZE := 8
var groups := ceili(float(input.size()) / LOCAL_SIZE)
var list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(list, pipeline)
rd.compute_list_bind_uniform_set(list, uniform_set, 0)
rd.compute_list_dispatch(list, groups, 1, 1)
rd.compute_list_end()
# 7. go, wait, read back
rd.submit()
rd.sync()
var output := rd.buffer_get_data(buffer).to_float32_array()
print("Output: ", output)
rd.free_rid(uniform_set)
rd.free_rid(pipeline)
rd.free_rid(buffer)
rd.free_rid(shader)
Expected output: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20].
Two things that will stop this running. First, the renderer: "Compute
shaders can only be used from RenderingDevice-based renderers (the Forward+ or Mobile
renderer)"
(Godot
docs) — check Project Settings → Rendering → Renderer. Second, the
#[compute] tag: without it Godot does not know what stage the file is.
And one thing that will quietly wreck your frame time. The
submit() / sync() pair above is the honest, blocking version, fine
for a one-shot at load. Godot's docs are explicit: "Ideally, you would not call
sync() to synchronize the RenderingDevice right away as it will cause the CPU to
wait for the GPU to finish working." The recommended pattern is to "wait at least 2 or 3
frames before synchronizing". Getting data off the GPU is the expensive part of GPU
programming — a later lesson is entirely about never doing it.
Delete the three guard lines from the shader and run it again with a
local_size_x of 8. It will probably still print the right answer — which is the
worst possible outcome, and exactly why the guard is worth internalising now. Six invocations
wrote past the end of a ten-element buffer; the behaviour is undefined, not safe, and on a
different driver, a different buffer size, or next to a second buffer, it corrupts data
instead of getting away with it.
Close the lesson in your head before answering — one attempt each, and a wrong answer here is worth more than a right one you looked up.
Primary source — read this one.
Godot
docs: Using compute shaders. It is short, it is the exact API you just used, and it
is maintained by the people who wrote RenderingDevice.
If you want the "why" underneath. Stephen Jones (NVIDIA), How GPU Computing Works — an hour on why the GPU is a throughput machine and the CPU is a latency machine. Nothing in it is Godot-specific and all of it will change how you read your own dispatch counts.
Keep for reference. The glossary (invocation, workgroup, warp, dispatch, SSBO) and the RenderingDevice cheat sheet — the seven steps with exact method signatures, printable.
Next lesson takes the same machinery and gives it something worth doing: GLSL as a language — vectors, swizzles, and the arithmetic a particle needs — because right now you can dispatch, but you cannot yet compute anything interesting.