GPU Programming · Lesson 2 · ~20 minutes

GLSL as a language

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.

01The scalar types, and one they took away

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.

02Vectors are not an afterthought

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.

03Swizzles: naming components by asking for them

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.

04No pointers, no printf

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.

05Put it to work: a one-line integrator

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:

The shader — 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.

The driver — attach to any node and run the scene

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.

06Retrieval

Close the lesson in your head before answering — one attempt each.

07Where to go from here

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.