Learning

GPU Programming · Lesson 04

The buffer remembers

Create enemy state once, advance it every tick, and stop asking the CPU where it went.

In Lesson 3, one dispatch changed one array of enemy records, then GDScript immediately stopped the GPU and copied every byte back. That proved the schema. It is also exactly the pattern a real-time simulation must leave behind.

Today’s win is small but architectural: three enemies take 120 GPU steps inside the same storage buffer. The CPU creates the buffer once and sends only two changing values per step—logical count and dt. There is no sync(), no per-step readback, and no per-step buffer rebuild.

01 Residence is a lifetime, not a location

GPU-resident does not merely mean “uploaded to the GPU.” It means that the authoritative state survives there across operations:

Lifetime Created Changes each tick? Freed
Shader, pipeline Setup No Shutdown
Body buffer, uniform set Setup Contents only Shutdown
Push constants Each dispatch Yes: count and dt After commands are recorded
Compute list Each dispatch It is that tick’s commands Ends after recording
one simulation tick same buffer + new dtsame buffer, newer state

A RID is the handle that keeps each GPU resource reachable. Store those handles as long-lived fields. Godot’s low-level API does not reference-count them; the official compute tutorial requires you to call free_rid() when their owner shuts down.

The invariant: setup allocates; ticks dispatch; shutdown frees. If a frame recreates the body buffer, pipeline, or uniform set, state is not resident yet.

02 Move from a private device to the renderer’s device

The first three lessons used create_local_rendering_device(): a private device that makes tiny experiments easy. The final architecture needs the global RenderingDevice because the simulation and renderer must eventually consume the same resources.

Local device Global device
Created by your script Returned by get_rendering_device()
Private resources Renderer-owned resource world
Your code calls submit() Godot submits its render frame
sync() is available submit()/sync() are unavailable
Fine for isolated compute Required for later compute → draw sharing

There is one new rule: use the global device from the render thread. Godot documents that rendering internals may run separately and provides RenderingServer.call_on_render_thread() for safe access. It also documents submit() and sync() as local-device-only methods. (RenderingServer: call_on_render_thread, RenderingDevice: submit)

# Main / physics thread: enqueue work.
RenderingServer.call_on_render_thread(_gpu_setup)
RenderingServer.call_on_render_thread(_gpu_step.bind(step_index))

# Render thread: obtain and use the global device.
func _gpu_step(step_index: int) -> void:
    var rd := RenderingServer.get_rendering_device()
    # Record one dispatch. No rd.submit(); no rd.sync().

03 The shader: dt becomes data

Create res://resident_step.glsl. The body schema is unchanged. What used to be a hardcoded DT is now a 32-bit float in the push-constant block.

#[compute]
#version 450

layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;

const uint FLAG_ALIVE = 1u << 0;

struct Body {
    vec2 position;
    vec2 velocity;
    float radius;
    float health;
    uint flags;
    uint padding;
};

layout(set = 0, binding = 0, std430) restrict buffer BodyBuffer {
    Body data[];
} bodies;

layout(push_constant, std430) uniform Params {
    uint element_count;  // byte 0
    float dt;           // byte 4
    uint step_index;   // byte 8
    uint padding;      // byte 12; block size = 16
} params;

void main() {
    uint id = gl_GlobalInvocationID.x;
    if (id >= params.element_count) { return; }

    Body body = bodies.data[id];
    if ((body.flags & FLAG_ALIVE) == 0u) { return; }

    body.position += body.velocity * params.dt;
    bodies.data[id] = body;
}

dt changes without changing the pipeline, uniform set, or buffer. Push constants are command data, so they are ideal for a few per-dispatch parameters. The block remains 16 bytes, and GDScript’s encoders mirror its four exact offsets. See the Khronos-maintained Vulkan push-constant guide for the underlying mechanism.

04 GDScript: allocate once, dispatch 120 times

Create res://resident_bodies_demo.gd and attach it to any node. Use Forward+. The 1,024-record allocation deliberately exceeds the logical count of three; the shader still guards with BODY_COUNT.

extends Node

signal gpu_ready

const STEP_SHADER: RDShaderFile = preload("res://resident_step.glsl")
const STRIDE := 32
const CAPACITY := 1024
const BODY_COUNT := 3
const LOCAL_SIZE := 64
const STEP_COUNT := 120
const STEP_DT := 1.0 / 60.0
const FLAG_ALIVE := 1 << 0

var _shader := RID()
var _pipeline := RID()
var _body_buffer := RID()
var _uniform_set := RID()
var _step_index := 0

func _ready() -> void:
    set_physics_process(false)
    gpu_ready.connect(_start_simulation, CONNECT_DEFERRED | CONNECT_ONE_SHOT)
    RenderingServer.call_on_render_thread(_gpu_setup)

func _start_simulation() -> void:
    set_physics_process(true)

func _physics_process(_delta: float) -> void:
    _step_index += 1
    RenderingServer.call_on_render_thread(_gpu_step.bind(_step_index))
    if _step_index == STEP_COUNT:
        set_physics_process(false)

func write_body(bytes: PackedByteArray, index: int,
        position: Vector2, velocity: Vector2) -> void:
    var offset := index * STRIDE
    bytes.encode_float(offset + 0, position.x)
    bytes.encode_float(offset + 4, position.y)
    bytes.encode_float(offset + 8, velocity.x)
    bytes.encode_float(offset + 12, velocity.y)
    bytes.encode_float(offset + 16, 2.0)
    bytes.encode_float(offset + 20, 10.0)
    bytes.encode_u32(offset + 24, FLAG_ALIVE)
    bytes.encode_u32(offset + 28, 0)

func _gpu_setup() -> void:
    var rd := RenderingServer.get_rendering_device()
    if rd == null:
        push_error("Global RenderingDevice unavailable; use Forward+ or Mobile.")
        return

    _shader = rd.shader_create_from_spirv(STEP_SHADER.get_spirv())
    _pipeline = rd.compute_pipeline_create(_shader)

    var bytes := PackedByteArray()
    bytes.resize(CAPACITY * STRIDE)
    write_body(bytes, 0, Vector2(0, 0), Vector2(30, 0))
    write_body(bytes, 1, Vector2(10, 0), Vector2(-5, 8))
    write_body(bytes, 2, Vector2(20, 10), Vector2(0, -12))
    _body_buffer = rd.storage_buffer_create(bytes.size(), bytes)

    var uniform := RDUniform.new()
    uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
    uniform.binding = 0
    uniform.add_id(_body_buffer)
    _uniform_set = rd.uniform_set_create([uniform], _shader, 0)
    gpu_ready.emit()

func _gpu_step(step: int) -> void:
    if not _body_buffer.is_valid():
        return
    var rd := RenderingServer.get_rendering_device()

    var params := PackedByteArray()
    params.resize(16)
    params.encode_u32(0, BODY_COUNT)
    params.encode_float(4, STEP_DT)
    params.encode_u32(8, step)

    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_set_push_constant(list, params, params.size())
    rd.compute_list_dispatch(list, ceili(float(BODY_COUNT) / LOCAL_SIZE), 1, 1)
    rd.compute_list_end()

    if step == STEP_COUNT:
        rd.buffer_get_data_async(_body_buffer, _on_readback)

func _on_readback(bytes: PackedByteArray) -> void:
    print("120 GPU steps; one async readback:")
    for i in BODY_COUNT:
        var offset := i * STRIDE
        var position := Vector2(
            bytes.decode_float(offset + 0),
            bytes.decode_float(offset + 4))
        print(i, ": pos=", position)

func _exit_tree() -> void:
    var owned_rids := [_uniform_set, _pipeline, _body_buffer, _shader]
    RenderingServer.call_on_render_thread(func():
        var rd := RenderingServer.get_rendering_device()
        for rid in owned_rids:
            if rid.is_valid():
                rd.free_rid(rid)
    )

After roughly two seconds, the callback arrives a few render frames later:

120 GPU steps; one async readback:
0: pos=(60, 0)
1: pos=(0, 16)
2: pos=(20, -14)

Small floating-point rounding differences are normal. The important evidence is structural: storage_buffer_create() ran once, the output of step n became the input to step n+1, and buffer_get_data_async() checked the result without stalling the frame loop. Godot documents that the callback receives the requested snapshot after a number of queued frames. (RenderingDevice: buffer_get_data_async)

05 Your practice: prove the state accumulated

Predict before running

  1. Change STEP_COUNT from 120 to 180.
  2. Change only body 1's velocity from (-5, 8) to (4, -3).
  3. Write down body 1's final position before pressing Run.
  4. Update the two 120 labels in the callback, then run and compare.

Three seconds elapse in simulation time, so body 1 should finish at (22, -9): (10, 0) + (4, -3) × 3. If it instead moves by only one tick, look for accidental buffer creation inside _gpu_step(). If it never prints, keep the scene alive for the asynchronous callback and check that the project uses Forward+.

06 What crossed the boundary?

Direction Setup Each step After 120 steps
CPU → GPU 32 KiB body capacity + shader resources 16-byte push constants Nothing extra
GPU → CPU Nothing Nothing One async diagnostic snapshot

The diagnostic snapshot still copies full body state, but it is outside the loop. Lesson 6 will remove even this from normal operation by drawing directly from the buffer; Lesson 12 will read back only compact gameplay summaries. Today’s boundary is the foundation for both.

07 Retrieval

Answer without scrolling back. One attempt each.

08 Where to go from here

Primary source — read these three short method entries. RenderingServer: get_rendering_device, call_on_render_thread, and RenderingDevice: buffer_get_data_async. Together they define the device, execution context, and non-blocking inspection path used here.

Keep for reference. The RenderingDevice cheat sheet now contrasts local and global device loops; the glossary defines global device, render thread, and persistent resource.

Next lesson: give this fixed-capacity buffer an addition queue, a removal queue, and counters. The CPU will request births and deaths without rebuilding the resident state.