Performance: run each job where it belongs
Zero executes your logic in two very different worlds. Luau is flexible, per-object, and serial. The engine core and the GPU are bulk and parallel. Almost all performance work comes down to running…
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 (thousands)
substrate.createBuffer allocates a flat, typed block the engine core reads directly, so you skip building a Lua table per record. batchWrite and batchRead accept one as their source or sink. (This is the engine's typed buffer. It is a different thing from Luau's built-in buffer library, which is raw untyped bytes.)
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()
Pass kind = "gpu" to allocate the block on the GPU instead. That is how the same data you batch onto entities is handed to 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
jobs.register({
phase = "main",
executor = { kind = "compute", shader = "@builtin::shaders.mySim", 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 the intended call site is the use_tool MCP surface:
use_tool { toolbox = "profiler", tool = "hotspots" } -- ranked list of the costliest nodes by self time
use_tool { toolbox = "profiler", tool = "frame" } -- the full attributed self / total / calls tree
use_tool { toolbox = "profiler", tool = "scripts" } -- per-component update(dt) cost, by component @ entity
hotspots and frame read a live frame; scripts lands script cost on the exact component and entity you can open and fix. 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 search_tools { query: "profiler" }.
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. 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.