GPU-resident Enemies · Lesson 3 · ~20 minutes

An enemy is 32 bytes

Replace the node with a record that GDScript and GLSL agree on exactly.

The orc project gets its scale from one architectural decision: a live enemy is not a Node2D. It is bytes in a GPU storage buffer. The CPU creates an initialization record once; compute shaders mutate that record; a draw shader reads it without asking the CPU where the enemy moved.

Lesson 2 already gave you the update rule position += velocity × dt. Today's win is making one complete enemy record cross the CPU/GPU boundary intact. Five bodies go in, one compute step runs, and five correctly decoded bodies come back. This is the smallest honest ancestor of the project's 262,144-body simulation.

01The object did not disappear; its memory became explicit

A Godot object hides its memory layout. A GPU buffer cannot. GDScript and GLSL are two programs looking at the same anonymous byte array, so they need a contract:

OffsetBytesGLSL fieldMeaning
08vec2 positionCurrent world position
88vec2 velocityUnits moved per step
164float radiusCollision size
204float healthRemaining health
244uint flagsAlive and behavior bits
284uint paddingMakes the stride explicit
address of body i buffer start + i × 32 bytes

Stride is the distance from the start of one array element to the next. Every field offset above is relative to that start. If GDScript advances 28 bytes while GLSL advances 32, body zero looks fine and every later body is progressively misread.

The governing rules are std430. Scalars align to 4 bytes, vec2 to 8, and a struct's array stride is rounded to its largest member alignment. The official Vulkan Shader Memory Layout guide gives worked offset examples. We use explicit padding even where the calculated stride would already be 32; visible contracts are easier to audit.

The invariant: field order, field type, byte offset, and record stride must match on both sides. Matching names do nothing. The GPU never sees your GDScript names.

02The shader's view

Create res://step_bodies.glsl. One invocation owns one body index. The shader moves living bodies for one deliberately large one-second step, deals two damage, and clears the alive bit when health reaches zero.

#[compute]
#version 450

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

const uint FLAG_ALIVE = 1u << 0;
const float DT = 1.0;

struct Body {
    vec2 position;  // offset  0
    vec2 velocity;  // offset  8
    float radius;   // offset 16
    float health;   // offset 20
    uint flags;     // offset 24
    uint padding;   // offset 28; next body starts at 32
};

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

void main() {
    uint id = gl_GlobalInvocationID.x;
    if (id >= bodies.data.length()) { return; }

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

    body.position += body.velocity * DT;
    body.health -= 2.0;
    if (body.health <= 0.0) {
        body.flags &= ~FLAG_ALIVE;
    }
    bodies.data[id] = body;
}

A flag is one bit inside an integer. The project uses the same technique for alive, flow-following, kill-counting, explosive-on-death, and golden state. Five booleans occupy one 32-bit word and travel together through every compute pass.

03GDScript's view: write the same bytes

Create res://body_layout_demo.gd and attach it to any node. The important code is write_body: it is your serializer, and its offsets are the CPU half of the contract.

extends Node

const STRIDE := 32
const FLAG_ALIVE := 1 << 0

func write_body(bytes: PackedByteArray, index: int,
        position: Vector2, velocity: Vector2,
        radius: float, health: float, flags: int) -> 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, radius)
    bytes.encode_float(offset + 20, health)
    bytes.encode_u32(offset + 24, flags)
    bytes.encode_u32(offset + 28, 0)

func _ready() -> void:
    var rd := RenderingServer.create_local_rendering_device()

    var shader_file: RDShaderFile = load("res://step_bodies.glsl")
    var shader := rd.shader_create_from_spirv(shader_file.get_spirv())

    const BODY_COUNT := 5
    var bytes := PackedByteArray()
    bytes.resize(BODY_COUNT * STRIDE)

    write_body(bytes, 0, Vector2(0, 0),  Vector2(1, 0),  2.0, 8.0, FLAG_ALIVE)
    write_body(bytes, 1, Vector2(10, 0), Vector2(0, 2),  2.0, 2.0, FLAG_ALIVE)
    write_body(bytes, 2, Vector2(20, 0), Vector2(-1, 0), 3.0, 5.0, FLAG_ALIVE)
    write_body(bytes, 3, Vector2(30, 0), Vector2(9, 9),  2.0, 9.0, 0)
    write_body(bytes, 4, Vector2(40, 0), Vector2(0, -1), 1.0, 3.0, FLAG_ALIVE)

    var 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(buffer)
    var uniform_set := rd.uniform_set_create([uniform], shader, 0)
    var pipeline := rd.compute_pipeline_create(shader)

    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, 1, 1, 1)
    rd.compute_list_end()
    rd.submit()
    rd.sync()

    var result := rd.buffer_get_data(buffer)
    for i in BODY_COUNT:
        var offset := i * STRIDE
        var position := Vector2(
            result.decode_float(offset + 0),
            result.decode_float(offset + 4))
        var health := result.decode_float(offset + 20)
        var flags := result.decode_u32(offset + 24)
        print(i, ": pos=", position,
            " health=", health,
            " alive=", (flags & FLAG_ALIVE) != 0)

    rd.free_rid(uniform_set)
    rd.free_rid(pipeline)
    rd.free_rid(buffer)
    rd.free_rid(shader)

Expected output:

0: pos=(1, 0)  health=6  alive=true
1: pos=(10, 2) health=0  alive=false
2: pos=(19, 0) health=3  alive=true
3: pos=(30, 0) health=9  alive=false
4: pos=(40, -1) health=1 alive=true

Body 3 proves the flag guard worked: it had a huge velocity, but it was already dead and did not move. Body 1 proves the shader can change behavior metadata, not just arithmetic values.

04Your practice: spend the padding

Make one real extension

  1. Replace uint padding with float speed_factor in GLSL.
  2. Rename the last argument of write_body to include a speed factor and write it with encode_float(offset + 28, speed_factor).
  3. Change movement to body.position += body.velocity * body.speed_factor * DT.
  4. Give body 0 a factor of 0.5 and body 2 a factor of 2.0. Predict their positions before running.

The feedback is immediate: body 0 should end at (0.5, 0); body 2 at (18, 0). If either is wrong, inspect offset 28 on both sides before touching the shader math. You have just extended an entity schema without changing its stride.

05How this grows into the orc architecture

Your one-buffer record is an array of structures: every body's fields sit together. The project accepts an 80-byte initialization structure, then a GPU addition pass splits live state into four parallel buffers:

BufferRepresentative fieldsWhy separate it later
Physicsposition, velocity, radius, massCollision passes need all of it
Simulationhealth, lifetime, flags, timersDamage passes avoid temporary solver data
Temporaryprevious/predicted position, correctionDisposable per-step working state
Contact behaviordamage, falloff, chaining, slowOnly contact handling pays to read it

That is a structure-of-arrays family: passes bind only the streams they need. Do not optimize into that yet. First earn a correct record; later profiling and access patterns justify splitting it.

The revised path from here is concrete:

  1. Keep this buffer alive across frames and send dt as a push constant.
  2. Add and remove bodies without rebuilding the full buffer.
  3. Draw directly from this buffer so positions never return to GDScript.
  4. Build a spatial grid, then contacts, damage, death, and compaction.
  5. Read back summaries only: alive count, kills, base damage, and audio density.

06Retrieval

Answer without scrolling back. One attempt each.

07Where to go from here

Primary source — keep this one open. Vulkan Guide: Shader Memory Layout. It is the clearest primary reference for offsets, alignment, and array stride under std430.

Case study in the game. Compare today's serializer with gpu_sim/rigidbody.gd, then compare today's GLSL structure with shaders/bindings.gdshaderinc. The larger record is the same idea, not a new one.

Keep for reference. The glossary now defines stride, alignment, padding, flags, AoS, and SoA.

Next lesson: keep the body buffer resident across frames and replace the hardcoded DT with a 16-byte push-constant block. That removes the readback from the simulation loop—the defining boundary of GPU-resident enemies.