Performance: run each job where it belongs
The sections below are in priority order. Start at the top.
1. Heavy parallel work goes on the GPU (compute shaders)
If a workload is data-parallel and not trivially small, it belongs in a compute shader: the same operation applied independently across many elements. A particle system, a physics or fluid or cellular simulation, transforming or culling thousands of instances, procedural generation, image processing as a pass, deforming a mesh's vertices, or simply running the same math over every element of a large array. A GPU runs thousands of lanes at once, so moving this work off the CPU is not a modest gain. For data-parallel load a compute pass is faster than anything you can write on the CPU by orders of magnitude. This is the default for that kind of work, not a last resort gated behind some size threshold: if the operation is independent per element and you have more than a handful of elements, reach for compute.
You write a compute shader as a .computeShader asset: a shader.wgsl body (just @compute fn main) plus a declarative bindings.yaml. You dispatch it by identity and the engine compiles it on first use. The complete authoring contract, the binding schema, dispatch, and readback all live in one place:
Authoring and dispatching compute shaders:
guides { path: "types/computeShader" }.
The CPU tiers below are for the work that genuinely cannot go to the GPU: N is small, the logic is inherently serial, or it needs CPU-only engine APIs. They are ordered by how much work each Luau-to-engine crossing carries.
2. The cost that shapes the CPU side: the Luau-to-engine crossing
Luau and the engine core run in separate worlds, and every call across that boundary has a fixed cost. One crossing is microseconds, cheap. A million crossings is not. The whole CPU-side story is granularity: do N things in one crossing instead of one thing in N crossings. For small N (a handful of entities, one-off updates) the per-entity API is exactly right. The tiers below only start to matter once N gets large.
3. Per-entity (small N)
local e = entity(id)
e.position = { x, y, z }
One crossing each. Fine until you are doing it hundreds of times a frame.
4. Batch IO (tens to thousands)
One crossing for the whole set, sourced from a Lua table:
entity.batchWrite(ids, "Transform", "position", values) -- (target, component, field, source) -> count
local ps = entity.batchRead(ids, "Transform", "position") -- (target, component?, field?, sink?)
When the same set repeats every frame, bind it once so the ids are resolved once:
local binding = ecs.bindEntities(ids) -- once
entity.batchWrite(binding, "Transform", "position", values) -- per frame
5. Typed buffers — the substrate (thousands)
substrate is the engine's one buffer primitive, and it is what makes this tier and the GPU tier the same system rather than two. A substrate buffer is a typed run of values you hold a handle to: you say what one element is and how many there are, and the engine core reads the block directly, so you skip building a Lua table per record.
local buf = substrate.createBuffer({ kind = "cpu", type = "vec3", len = #ids })
buf:write({ x1,y1,z1, x2,y2,z2 }) -- flat floats, not per-record tables
entity.batchWrite(binding, "Transform", "position", buf) -- buffer as the source
local back = buf:read():result()
type is "f32", "vec3", "vec4", "quat" or "mat4" — the stride follows from it, so a read comes back in the units it was written in. kind is "cpu" (the scripting heap, the default) or "gpu" (storage a compute shader binds), and usage adds "readback", "vertex" or "index" on top of the storage a buffer always has. "readback" is what makes a GPU-to-CPU read possible at all.
Everything a buffer does hangs off its handle, so a buffer travels as a value rather than as a name something else has to resolve: :write (numbers as floats), :writeU32 (integer bit patterns), :writeBytes (an already-packed pool — vertices, an index run), :read, :length, :type, :kind, :destroy. A "cpu" buffer also answers :bytes() for per-element access through the Luau buffer library, along with in-place arithmetic (:fill :add :scale :axpby :lerp) and indexed accessors (:getVec3 :setVec3 :getQuat :setQuat). A "gpu" buffer is transformed by dispatching a compute shader over it instead.
:read() answers with a readback rather than a value, because a GPU read takes frames to arrive: hold it and ask (:ready(), :state()), then drain it with :result() (nil until it lands), :resultU32() or :resultBytes() — the same bytes read three ways, matching the writes. A CPU read is already done by the time it returns, and answers the same shape, which is what lets code that reads a buffer not care where it lives.
This is the engine's typed buffer. Luau's built-in buffer library is a different thing — raw untyped CPU bytes — and stays exactly as it is.
Which call allocates it. A buffer a compute shader owns is made through that shader, so the two travel together: shaderRef:createBuffer(name, { type, len }). substrate.createBuffer is for a buffer nothing computes — a vertex or index run that is only ever drawn, or a CPU block feeding batchWrite. Either way you hold the handle and pass it where it is needed; kind = "gpu" is how the same data you batch onto entities reaches a compute dispatch without a second copy back through Luau.
6. nx numerical kernels
When you need math across an array (sin over every element, integrate velocity into position, normalize a list of vec3s, dot products, FFTs), nx runs the whole pass in one crossing. It has its own buffer type via nx.create / nx.fromTable.
local b = nx.create("vec3", 1024) -- also nx.zeros / nx.ones / nx.full / nx.fromTable
nx.integratePosition(posBuf, velBuf, dt) -- fused mover, one crossing for the whole array
The kernel set is large: in-place unary math (nx.applySin / applyCos / applySqrt / ...), element-wise binary ops (nx.add / mul / sub / div), reductions (nx.sum / mean / dot / normL2), fused movers (nx.axpby / lerpTo / integratePosition / normalizeVec3), and FFTs (nx.fft1d / rfft1d / ...). It is the densest namespace in the engine. Read the nx module under modules/api/engine for exact signatures.
Script components vs raw ECS: which to choose
One of the biggest performance decisions in a world, and it is orthogonal to the tiers above. The same entity behavior can be built two ways:
- Raw ECS components, driven in bulk. State lives in native components (
Transform,Mesh, and your own marker or data components). You drive it withentity.batchRead/entity.batchWriteinside a singletask.loop, or let an engine system or a job process the whole set. A thousand entities cost one or two crossings a frame no matter how many there are. This is the model in the ECS guide,core/ecs. - Script components. Each entity carries a Luau component with an
update(dt)lifecycle the engine ticks every frame. Self-contained and convenient, but every instance is one Luau-to-engine crossing per frame, so cost scales with entity count. A thousand ticking script components is a thousand crossings a frame; the same thousand driven by one batched loop is one crossing.
Which to choose:
- Many entities running the same logic (troops, projectiles, particles, anything you sweep at scale) go to raw ECS components driven by one batched loop or a job. Do not give each one its own script lifecycle.
- A handful of entities, or genuinely bespoke per-entity behavior (a boss with unique logic, a door, a one-off interactable) is what a script component is for; the per-entity cost is nothing at that count.
The tipping point is entity count times per-frame crossings. When in doubt, the scripts profiler tool (below) attributes exactly what each script component's update costs, by component and entity, so you can see when a script component has outgrown its lifecycle and should become a batched loop or job. The full model is in core/ecs (native components and batch loops), with core/components for authoring script components and core/entities for the entity itself.
Running it every frame: jobs
If the work runs continuously, register it as a job so the engine scheduler owns it. The scheduler can pause, reorder, and inspect your work instead of you driving a loose loop. A job dispatches by its executor kind:
-- luau: an arbitrary closure, ticked once per frame
local job = jobs.register({
phase = "main",
executor = { kind = "luau", run = function()
nx.integratePosition(posBuf, velBuf, dt)
entity.batchWrite(binding, "Transform", "position", posBuf)
end },
})
-- kernel: a named built-in kernel over declared buffers, with no Luau in the loop.
-- Built-ins: copy_buffer, fill_buffer, gather_components, scatter_components.
-- gather/scatter are the scheduler-owned equivalents of batchRead / batchWrite.
-- (src and dst here are substrate.createBuffer handles.)
jobs.register({
phase = "main",
reads = { { resource = { kind = "buffer", id = src.id }, mode = "r" } },
writes = { { resource = { kind = "buffer", id = dst.id }, mode = "w" } },
executor = { kind = "kernel", kernel = "copy_buffer" },
})
-- compute: a compute shader dispatch the scheduler owns as a first-class node.
-- Resolving the shader is what records the dependency, so it travels with the
-- world the job lives in.
jobs.register({
phase = "main",
executor = {
kind = "compute",
shader = asset.resolve("@builtin::shaders.mySim", "computeShader"),
workgroups = { 256, 1, 1 },
},
})
-- stub: a no-op node, useful purely as an ordering anchor between other jobs
jobs.register({ phase = "main", executor = { kind = "stub" } })
Every handle has job:pause(), job:resume(), job:cancel(), plus job:status() and job:info(). Introspect the registry with jobs.list("main"), jobs.find(name), and jobs.inspect(target). Mark a job pure = true (when it is a pure function of its declared reads / writes) to let the scheduler reorder it.
Measure, do not guess
Find where the milliseconds actually go before you climb any tier. The profiler toolbox attributes frame time down to the component and entity, and it is an agent-facing toolbox: call it from the shell, or as use_tool { toolbox = "profiler", tool = "hotspots" } over MCP.
zero profiler hotspots # ranked list of the costliest nodes by self time
zero profiler frame # the full attributed self / total / calls tree
zero profiler scripts # per-component update(dt) cost, by component @ entity
zero profiler gpu # per-pass GPU cost, from timestamp queries
zero scene cost # per-scene entrypoint tick cost, by loaded layer
hotspots and frame read a live frame; scripts lands script cost on the exact component and entity you can open and fix. scripts counts COMPONENT update loops; a scene entrypoint's update / editorUpdate runs on the scheduler instead, so it is absent there and lands in the generic Luau bucket hotspots shows — scene cost is the reading that attributes it to the layer that owns it, and it answers in edit mode as well as play. For hitches a single live frame misses, ring on keeps an always-recording history and retro reads it back after the fact, so it does not matter that a tool call lands seconds after the spike. The toolbox has more (record, watch / hits, compare, flamegraph); browse it with zero profiler, and read one verb's arguments with zero profiler <verb> --help.
When frame puts the time under present / idle, the frame is waiting on the GPU and gpu is the one that answers where. Read its rows as a distribution rather than a cost: each is the median of that pass's per-frame span over a window of frames, with the min and max it ranged over, because a single frame's two timestamps also bracket whatever the queue was waiting on. A row that reads floor ran, and the device resolved no duration for it — its two timestamps retired within a few ticks of each other, which the header states the size of. Passes that small are measured by making them bigger: raise the resolution or the sample count until the cost lands above the floor, and scale back down.
The model
Two ideas carry the whole guide. First, heavy parallel work belongs on the GPU: a compute shader beats any CPU approach for GPU-suited load, so reach for it first, not last. Second, for the work that must stay on the CPU, the cost is the Luau-to-engine crossing, so make the crossings fewer and fatter: per-entity, then batch, then typed buffer, then nx. The substrate buffer is what joins the two halves — the same handle a batched write reads from is the one a compute dispatch binds, so moving work to the GPU does not mean moving the data twice. Keep uniform state in data components rather than per-entity scripts, wrap continuous work in a job, and let the profiler tell you where the time actually goes.