GPU-resident Enemies · Lesson 3 · ~20 minutes
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.
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:
| Offset | Bytes | GLSL field | Meaning |
|---|---|---|---|
0 | 8 | vec2 position | Current world position |
8 | 8 | vec2 velocity | Units moved per step |
16 | 4 | float radius | Collision size |
20 | 4 | float health | Remaining health |
24 | 4 | uint flags | Alive and behavior bits |
28 | 4 | uint padding | Makes the stride explicit |
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.
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.
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.
Make one real extension
uint padding with float speed_factor in GLSL.write_body to include a speed factor and write it
with encode_float(offset + 28, speed_factor).body.position += body.velocity * body.speed_factor * DT.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.
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:
| Buffer | Representative fields | Why separate it later |
|---|---|---|
| Physics | position, velocity, radius, mass | Collision passes need all of it |
| Simulation | health, lifetime, flags, timers | Damage passes avoid temporary solver data |
| Temporary | previous/predicted position, correction | Disposable per-step working state |
| Contact behavior | damage, falloff, chaining, slow | Only 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:
dt as a push constant.Answer without scrolling back. One attempt each.
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.