GPU Programming · Lesson 2 · ~20 minutes
Types, vectors, swizzles — and the two things you can no longer reach for.
Lesson 1 gave you the shape: one body, launched thousands of times, each invocation told only its id. Today's win is smaller in scope but more useful day to day — the handful of GLSL vocabulary you need before a shader body can do anything you'd actually call a simulation step. Every agent in your mass-sim mission has a position and a velocity; today you write the line that moves one, and it will not look like two floats and two lines of arithmetic.
GLSL's basic types are close enough to what you already know: float, int,
uint, bool
(LearnOpenGL — Shaders). The
float is the 32-bit one from lesson 1's sidenote, not GDScript's 64-bit default. Mix
an int and a float in one expression and it compiles — the language
picks the floating-point result and converts the integer for you, because "a floating-point type
is chosen if either operand has a floating-point type"
(Vulkan / GLSL
specification). You do not need a cast to write float(id) * 2.0 as
id * 2.0, though the explicit cast still reads better once id is a
uint you plan to keep using as one.
The type you will look for and not find is a pointer. The specification is blunt about it:
There are no pointer types. Vulkan / GLSL specification
Nothing is passed by reference except a whole buffer block, bound once via
layout(binding = …). You cannot take the address of a local variable, hand it to
another invocation, or build a linked structure out of them. Every value you touch is either
sitting in your own invocation's private registers, or living in a buffer that every
invocation can see by index — there is no in-between.
A position is not two floats you keep in sync by hand — it is a vec2, one value.
GLSL gives you vec2/vec3/vec4 for floats, and the same
shapes for the other scalars: ivec3 (ints), uvec3 (uints),
bvec3 (bools)
(LearnOpenGL — Shaders). Arithmetic
on them is componentwise by default — no loop, no .x and .y written out
twice:
vec2 a = vec2(1.0, 2.0);
vec2 b = vec2(3.0, 4.0);
vec2 c = a + b; // (4.0, 6.0) — both components at once
vec2 scaled = a * 2.0; // (2.0, 4.0) — scalar broadcasts to both
This is exactly the doubling from lesson 1's data[id] *= 2.0, just
with a two-component value instead of one. The shape of the shader did not change — only what one
element is did.
You address a vector's components as .x .y .z .w (position), .r .g .b .a
(color), or .s .t .p .q (texture coordinates) — three spellings for the same slots,
picked by convention to match what the vector represents
(LearnOpenGL — Shaders). Chain up to
four of them and you get a swizzle: a new vector, built by naming the components
you want, in any order, with repeats allowed.
vec3 v = vec3(1.0, 2.0, 3.0);
vec2 xy = v.xy; // (1.0, 2.0) — drop a component
vec3 zyx = v.zyx; // (3.0, 2.0, 1.0) — reversed
vec4 xxxx = v.xxxx; // (1.0, 1.0, 1.0, 1.0) — repeats are fine
A swizzle you can read, you can also write to — it is a real lvalue, not just an expression:
vec3 pos = vec3(5.0, 5.0, 5.0);
pos.xy = vec2(0.0, 0.0); // x and y become 0.0, z stays 5.0
The one rule that trips people coming from this direction: the source has to actually own every
component you ask for. v.xy on a vec2 is fine; v.z on a
vec2 does not compile, because there is no third component to read.
The other habit to unlearn is reaching for a print statement. GLSL has no console, no
stdout, nothing a compute shader can write text to. Combined with "no pointers" from
section 01, this means there is no way to inspect what one invocation saw or computed except by
writing it somewhere the CPU can later read — which you already know how to do. Lesson 1's
readback step (buffer_get_data()) is not just how you get results out; it is also
your entire debugger. Suspect a value is wrong? Write it into a spare slot of an output buffer and
read that buffer back, same as any other result.
Two buffers this time — positions and velocities, five little agents. The whole simulation step is one line, and it is vector arithmetic, not index bookkeeping:
res://integrate.glsl#[compute]
#version 450
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;
layout(set = 0, binding = 0, std430) restrict buffer PositionBuffer {
vec2 data[];
} positions;
layout(set = 0, binding = 1, std430) restrict readonly buffer VelocityBuffer {
vec2 data[];
} velocities;
// hardcoded for now — lesson 3 replaces this with a push constant
const float DT = 1.0 / 60.0;
void main() {
uint id = gl_GlobalInvocationID.x;
if (id >= positions.data.length()) {
return;
}
positions.data[id] += velocities.data[id] * DT;
}
Two buffers means two layout(binding = …) declarations and two
entries in the uniform set below. readonly on the velocity buffer costs nothing and
documents intent — this invocation never writes another agent's velocity.
extends Node
func _ready() -> void:
var rd := RenderingServer.create_local_rendering_device()
var shader_file: RDShaderFile = load("res://integrate.glsl")
var shader := rd.shader_create_from_spirv(shader_file.get_spirv())
# five agents, x/y interleaved — matches vec2's std430 layout exactly
var positions_f := PackedFloat32Array([0,0, 10,0, 20,0, 30,0, 40,0])
var velocities_f := PackedFloat32Array([1,0.5, -1,0.5, 0,1, 2,0, -2,-1])
var pos_bytes := positions_f.to_byte_array()
var vel_bytes := velocities_f.to_byte_array()
var pos_buffer := rd.storage_buffer_create(pos_bytes.size(), pos_bytes)
var vel_buffer := rd.storage_buffer_create(vel_bytes.size(), vel_bytes)
var pos_uniform := RDUniform.new()
pos_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
pos_uniform.binding = 0
pos_uniform.add_id(pos_buffer)
var vel_uniform := RDUniform.new()
vel_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
vel_uniform.binding = 1
vel_uniform.add_id(vel_buffer)
var uniform_set := rd.uniform_set_create([pos_uniform, vel_uniform], shader, 0)
var pipeline := rd.compute_pipeline_create(shader)
const LOCAL_SIZE := 64
var agent_count := positions_f.size() / 2 # 2 floats per vec2
var groups := ceili(float(agent_count) / 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()
rd.submit()
rd.sync()
var new_positions := rd.buffer_get_data(pos_buffer).to_float32_array()
print("Positions after one step: ", new_positions)
rd.free_rid(uniform_set)
rd.free_rid(pipeline)
rd.free_rid(pos_buffer)
rd.free_rid(vel_buffer)
rd.free_rid(shader)
Expected output (rounded):
[0.0167, 0.0083, 9.9833, 0.0083, 20, 0.0167, 30.0333, 0, 39.9667, -0.0167] —
every agent moved by velocity × 1/60, in one line of GLSL that never mentioned
x or y separately.
Why interleaved floats, not PackedVector2Array. Godot's
Vector2 can be built with either 32-bit or 64-bit components depending on the
engine build, and that is not something this workspace has a solid source on yet (see the
resources gaps). A PackedFloat32Array is
unambiguous: it is always 32-bit, so it always matches GLSL's float — and a plain
array of vec2 has a base alignment of 8 bytes, exactly two floats back to back, no
padding, per the buffer layout rules
(Vulkan / GLSL
specification). Structs mixing types are where padding bites — that is lesson 6.
Close the lesson in your head before answering — one attempt each.
Primary source — read this one. LearnOpenGL: Shaders. It covers types, vectors, and swizzling in about the same order as this lesson, with more examples than fit here.
If you want the "why" underneath. Vulkan / GLSL specification — interfaces chapter. Dense, but it is the actual source of the "no pointers" and buffer-layout claims above, not someone's paraphrase of them.
Keep for reference. The glossary now has a section for language vocabulary — vector, swizzle, componentwise, and the no-pointers rule.
Next lesson turns position, velocity, radius, health, and flags into one byte-exact enemy record shared by GDScript and GLSL—the data foundation of the GPU-resident simulation you chose as the course target.