substrate
Public Luau surface over the `__substrate` Internal FFI namespace — the engine's typed data buffer, on the CPU or the GPU.
substrate Module
Public Luau surface over the __substrate Internal FFI namespace — the
engine's typed data buffer, on the CPU or the GPU.
Purpose
A substrate buffer is a typed run of values you hold a handle to. The
element type (f32, vec3, vec4, quat, mat4) sets the stride, so
a buffer knows its own shape and a read comes back in the units it was
written in. kind places it: a "cpu" buffer lives in the scripting
heap, a "gpu" buffer is storage a compute shader binds.
Everything a buffer does hangs off its handle, so a buffer travels as a value rather than as a name someone else has to resolve. This is what animation poses, IK chains, particle pools, and compute-shader storage are all made of.
This is a distinct primitive from the standard Luau buffer library,
which is untyped CPU bytes and stays exactly as it is. The type here is
TypedBuffer for that reason.
Usage
-- A CPU buffer: 64 bone poses, 10 floats each
local pose = substrate.createBuffer({ type = "f32", len = 64 * 10 })
pose:write({ 0, 1, 0, 0, 0, 0, 1, 1, 1, 1 }, 0)
local values = pose:read(0, 10):result()
pose:fill() -- every value back to 0
pose:destroy()
-- A GPU buffer a compute shader reads back
local field = substrate.createBuffer({
type = "vec3", len = 4096, kind = "gpu", usage = { "readback" },
})
A buffer a compute shader works over comes from the shader, which names it for the dispatch and allocates it here:
local sim = asset.resolve("@builtin::mySim.computeShader")
local verts = sim:createBuffer("verts", { type = "vec3", len = 1024 })
sim:dispatch({ x = 16 })
local out = verts:read():result()
Reads
:read() answers with a Readback for both kinds — a CPU read is one
that is simply already done. Code that reads a buffer therefore never
branches on where the buffer lives:
local rb = field:read()
if rb:ready() then
local floats = rb:result() -- also :resultU32() / :resultBytes()
end
:state() reports "pending" / "ready" / "unknown" without draining
and without raising.
Size
A "cpu" buffer holds at most 1 GiB. A "gpu" buffer holds at most what the
device creates in one buffer, its max_buffer_size, which each GPU, driver and
browser states for itself. A len past either bound raises at createBuffer,
naming the bytes it needs, the bound, and the most records of that type one
buffer holds; nothing is allocated and no handle exists for it. Data larger
than one buffer is split across several.
Lifetime
A handle outlives the storage it points at: destroy frees the allocation, and
every other holder of that buffer keeps its handle. :alive() is what they ask,
and it answers false the moment the storage is gone, through this handle or any
other. When the render device is lost, a "gpu" buffer is allocated again on
the device built to replace it, empty, and its handles keep answering; one past
the new device's max_buffer_size is gone instead, and its handles answer
false. Binding a dead handle to a compute dispatch is refused at the call that
binds it.
Exports
substrate.createBuffer({ type, len, kind?, usage?, name? })→TypedBuffer?
On the handle: write read length type kind alive destroy, the CPU
arithmetic fill add scale addStrided axpby lerp, the indexed
accessors getVec3 setVec3 getQuat setQuat, and bytes for
per-element access through the Luau buffer library.
For element-wise NumPy-style kernels over these buffers, use the nx
module; for slice-and-target position / orientation forms, mathx.
Interface
What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.
conforms to
zero/source-extract/v2global substrate global-types module substrate Typed data buffers, on the CPU or the GPU — the engine's one buffer primitive. Public Luau surface over the `__substrate` Internal FFI namespace. require modules/api/engine/substrate
drain(self: any, kind: string, fetch: (string) → void
The engine delivers a read once. The handle keeps what it delivered, so every holder of the handle — two plays realizing one cooked frame — is answered, the later ones from what the first drained.
| arg | type | description |
|---|---|---|
| self | any | |
| kind | string | |
| fetch | (string |
ready(self: ?) → boolean
Whether this read has arrived.
| arg | type | description |
|---|---|---|
| self | ? |
state(self: ?) → string
Where this read stands, without draining it and without raising: `"pending"`, `"ready"`, `"failed"` (given up on, with the reason as a second return), or `"unknown"` (already drained). Every read leaves `"pending"` — one the GPU does not answer inside the bound the engine holds it to reaches `"failed"` naming the buffer — so a wait on `state() ~= "pending"` ends on what the read did.
| arg | type | description |
|---|---|---|
| self | ? |
result(self: ?) →
Drain this read as f32 values, nil while it is still on its way. Every later call on this handle answers the same values.
| arg | type | description |
|---|---|---|
| self | ? |
resultU32(self: ?) →
Drain this read as u32 values, nil while it is still on its way. Every later call on this handle answers the same values.
| arg | type | description |
|---|---|---|
| self | ? |
resultBytes(self: ?) → buffer
Drain this read as a Luau `buffer`, nil while it is still on its way. Every later call on this handle answers the same buffer.
| arg | type | description |
|---|---|---|
| self | ? |
settled(self: ?) → string
Wait until this read leaves `"pending"`, without draining it, and answer its state. The wait is on the read itself: where the engine can wait on the device the task resumes in the frame the device answered, so a task that reads, uses the answer and reads again does all of it in one frame.
| arg | type | description |
|---|---|---|
| self | ? |
examples
if pending:settled() == "ready" then use(pending:resultBytes()) end
await(self: ?) →
Wait for this read to arrive and drain it as f32 values. The wait runs on every frame the renderer draws, a frame drawn while pipeline builds are still landing among them, since the render system delivers a read on each of those frames. It ends on the read's own terminal state: the values once it delivered, or nil and the reason once the engine has given it up (a buffer that was not there, a device that was lost). A read that is still on its way is waited for, however long the GPU takes to answer it.
| arg | type | description |
|---|---|---|
| self | ? |
examples
local values, why = pending:await()
ready(_self: ?) → boolean
| arg | type | description |
|---|---|---|
| _self | ? |
state(_self: ?) → string
| arg | type | description |
|---|---|---|
| _self | ? |
result(self: ?) → void
| arg | type | description |
|---|---|---|
| self | ? |
resultU32(self: ?) → void
| arg | type | description |
|---|---|---|
| self | ? |
resultBytes(self: ?) → buffer
| arg | type | description |
|---|---|---|
| self | ? |
settled(_self: ?) → string
| arg | type | description |
|---|---|---|
| _self | ? |
await(self: ?) → void
| arg | type | description |
|---|---|---|
| self | ? |
gpuReadback(key: string?) → Readback
Wrap the key an FFI read handed back as the `Readback` that polls it. Every GPU→CPU read reaches the caller through this, so a texture's read and a buffer's read answer with the same thing.
| arg | type | description |
|---|---|---|
| key | string? | The key the read returned. |
examples
local pending = substrate.gpuReadback(compute.readTexture3D(handle))
wrapRead(handle: any, kind: string) → void
A read is answered by the buffer's own surface rather than by a global the caller has to know to reach for: a GPU read comes back from the FFI as a key to poll, a CPU read as the values themselves, and both leave here as a `Readback`.
| arg | type | description |
|---|---|---|
| handle | any | |
| kind | string |
read(self: ?, ...: ?) → void
| arg | type | description |
|---|---|---|
| self | ? | |
| ... | ? |
nameHeld(name: string) → boolean
Internal: whether a GPU buffer holds `name` right now. Asked by the name itself, which the substrate answers without walking every buffer it holds; the reference the lookup takes is let go at once.
| arg | type | description |
|---|---|---|
| name | string |
bufferCaller( ) → string
Internal: the chunk that called into the substrate, as `debug.info` names it: the caller of the public function whose body asks, which is the first frame above this module's own that is Luau code, past the native dispatcher a `typed` function's argument check runs that function through. It is the same frame at every optimization level the module compiles at.
createBuffer(opts: BufferOpts) → TypedBuffer
Allocate a typed buffer and return its handle. A `"gpu"` buffer is storage a compute shader binds; `usage` adds `"vertex"`, `"index"`, `"indirect"` or `"readback"` on top of the storage it always has. A `"cpu"` buffer lives in the scripting heap and reads back as a flat array of floats. The handle's `write` answers whether the words landed: a payload whose end falls past the end of the buffer is refused whole on both kinds, so the buffer keeps what it held and the call answers false. `writeU32` and `writeBytes` answer the same way, against the same extent. A `"cpu"` buffer holds at most 1 GiB of storage: 268435456 `f32` words, 16777216 `mat4` records. A `len` past that raises an error naming the bytes it needs and the limit, and a `len` within it whose storage the heap cannot supply raises an error naming the bytes. Neither allocates anything. A `"gpu"` buffer holds at most what the device creates in one buffer, its `max_buffer_size`. A `len` whose bytes pass it raises an error naming the records, the bytes they need, that limit and the most records of the type one buffer holds; nothing is allocated and the `name` stays free. `class` makes a `"gpu"` buffer a run: it stands in a range of a page the engine's device arenas share among many runs of the class, rather than in an allocation of its own, so thousands of short-lived buffers cost a handful of device allocations. `"effect"` is for runs made and let go of as an effect plays, `"persistent"` for runs held until let go of. A run binds, writes, reads and frees like any `"gpu"` buffer under the name its handle carries, which the engine mints, so a run takes no `name`; a compute program declared with `ranges = true` binds any number of runs of one page through one bind group. A run carries `"readback"` usage and no other: a vertex, index or indirect buffer is one of its own. `owner` names whom a run is held for, and `substrate.releaseOwner(owner)` lets go of every run created with it at once. A job on a worker thread makes a run the same way. The handle answers at once, with the type and length asked for and a name the worker mints; `substrate.listBuffers` lists the run at once; and the engine thread places it in its arenas ahead of every write, dispatch and read the job makes over it afterwards, so one job can make a run, fill it, dispatch over it and read it back. A class, usage or `len` the engine thread refuses raises at the call in the job too. When the engine thread cannot place the run, the handle's next method call raises why. `substrate.setOwner` and `substrate.releaseOwner` answer in a job as they do on the engine thread, counting the job's own changes at once. `"f32"`, `"vec3"`, `"vec4"`, `"quat"` or `"mat4"`; `kind` is `"cpu"` (the default) or `"gpu"`. `name` is the name a dispatch binds a `"gpu"` buffer by, and the name `substrate.getBuffer` and `substrate.destroyBuffer` reach it under. `class` (`"effect"` or `"persistent"`) makes a `"gpu"` buffer a run; `owner` names whom the run is held for. `type` or `kind`, a zero length, or a `name` that already holds a GPU buffer of another shape. A `name` holding a buffer of the SAME type and length hands that buffer back, contents and all; `substrate.destroyBuffer` frees a name whose buffer is the wrong shape.
| arg | type | description |
|---|---|---|
| opts | BufferOpts | `{ type, len, kind?, usage?, name?, class?, owner? }` — `type` is |
examples
local pose = substrate.createBuffer({ type = "mat4", len = boneCount })local field = substrate.createBuffer({ type = "vec3", len = 4096, kind = "gpu" })local column = substrate.createBuffer({ type = "f32", len = n, kind = "gpu", class = "effect", owner = "burst:" .. id })local values = pose:read(0, 16):result()
bufferSource(name: string) → string
The chunk that created the GPU buffer `name` holds, as `debug.info` names a chunk — a module's identity, a test suite's, an `execute` body's. The record is the name's for as long as a buffer holds the name, across a device rebuilt underneath it. A name created again after its buffer went answers for the call that created it again. A buffer the engine made itself, or one made through another surface, has no record.
| arg | type | description |
|---|---|---|
| name | string | The name the buffer was created under. |
examples
if substrate.bufferSource("debug_viz.vtx") then print("made through the substrate") endgetBuffer(name: string) → TypedBuffer
The GPU buffer `name` denotes, as a handle you now hold. A name is how a dispatch binds a buffer, so the name is what an owner asks by once the handle it created with has gone out of scope — a `.module` that hot-reloaded, a build that ran in an earlier `execute`. The handle carries everything `createBuffer`'s does and releases its reference with `:destroy()`.
| arg | type | description |
|---|---|---|
| name | string | The name the buffer was created under. |
examples
local xf = substrate.getBuffer("env.town.xf")local shape = xf and { xf:type(), xf:length() }destroyBuffer(name: string) → boolean
Free the GPU buffer `name` denotes, whatever else still holds a handle to it. The allocation goes and the name is free to be created again at any type and length; every handle that pointed at it answers `:alive()` false. This is what releases a name whose creating handle is gone, so a build that re-runs at a different size gets its name back.
| arg | type | description |
|---|---|---|
| name | string | The name the buffer was created under. |
examples
substrate.destroyBuffer("env.town.xf")losses( ) → number
How many times a buffer has been freed by name or by owner: `destroyBuffer`, `releaseOwner` and the reset to a baseline. A holder that checked that the buffers it names exist at one count knows they still do while the count is the same, whatever else it asks.
examples
local at = substrate.losses()
releaseOwner(owner: string) → number
Let go of every run created with `owner` (`createBuffer`'s `class` and `owner`), whatever else still holds a handle to it. Each run's range goes back to its arena and every handle to it answers `:alive()` false. This is what something that makes runs as it plays — an effect, a cook, a cache — calls when it ends, so everything it made goes with it at once, runs it handed on to others included.
| arg | type | description |
|---|---|---|
| owner | string | The owner the runs were created with. |
examples
substrate.releaseOwner("burst:" .. id)setOwner(name: string, owner: string?) → boolean
Hold the run `name` names for `owner` from now on, in place of the owner it was created with, or for no one when `owner` is nil. `substrate.releaseOwner(owner)` then lets go of it with the rest of what `owner` holds, and releasing the owner it was made for leaves it standing. What hands a run on to whatever keeps it longer than the one that made it — a cache several makers read from.
| arg | type | description |
|---|---|---|
| name | string | The name the run binds under (its handle's `name`). |
| owner | string? | The owner to hold it for, or nil. |
examples
substrate.setOwner(column.name, "cache#3")
listBuffers( ) →
Every named GPU buffer the engine holds, in name order. Each record states `id`, `name`, `type` (`"F32"`, `"Vec3"`, `"Vec4"`, `"Quat"`, `"Mat4"`), `len` in records, `refs` — how many holders it has — `holder`, the owner a run is held for when one was named (`createBuffer`'s `owner`), `class`, the lifetime class of a run (`"effect"` or `"persistent"`; absent for a buffer of its own), and `bytes`, the device bytes it takes: the range a run stands in, or the extent of a buffer of its own. This is what states which names are taken, at what shape, and what an owner holds.
examples
for _, b in ipairs(substrate.listBuffers()) do print(b.name, b.type, b.len) end
substrateMark(generation: number?) → void
Internal: the mark `generation` names, with its index, or the newest.
| arg | type | description |
|---|---|---|
| generation | number? |
markBaseline( ) → number
Record which named GPU buffers the engine holds right now, and make that the point `substrate.resetToBaseline` returns to. A caller running work in an engine it shares — a sweep of test suites, a tool driving a session — leaves buffers behind under the names the work chose: a view's geometry built on its first capture, a kernel's storage. This is how such a caller says where its own allocations start. pass The names held now are recorded as a mark above the marks before it. `substrate.resetToBaseline` takes to restore to this mark.
examples
local mark = substrate.markBaseline() -- then run, then substrate.resetToBaseline(mark)
resetToBaseline(generation: number?) → number
Free every named GPU buffer created since `substrate.markBaseline`, leaving the ones it recorded where they are. A module that created one of these holds its own handle to it. Put that module's state back alongside this (`engine.restoreModuleState`) or it goes on addressing storage this freed. pass Every buffer named since the mark is freed; nothing the mark recorded is touched. it; the newest mark when omitted. The marks taken after it go with the reset.
| arg | type | description |
|---|---|---|
| generation | number? | The mark to restore to, as `substrate.markBaseline` returned |
examples
local freed = substrate.resetToBaseline(mark)
scatterTransforms(key: string, entityIds: { string }?, positions: string, rotations: string?, scales: string?, lanes: { string? }?, interval: number?, identities: string?) → boolean
Place entities from the buffers that already hold their transform, on the device, instead of from a `Transform` written per entity. `entityIds[i]` is placed from row `i` of every named buffer, so the order is the buffers' own. Pass nil for `entityIds` to keep the entities a binding already has and replace only the buffers — what a re-cook does, since a field chain mints a fresh run each time while its membership stands still, and sending the element list again each time would put back the per-element crossing the binding exists to remove. Re-binding the same `key` with a list replaces the previous binding. Queued: it takes effect on the next frame, and an entity whose renderable has not landed yet is placed on the frame it does. holding one vec4 per element, written into lane `k` of each element's block — what its material reads back as `input.shader_data[k]`. A lane left out is left to whatever else writes it. Given, every frame draws each element between the last two presents — position, rotation, scale and lanes blended — the way a fixed-rate simulation is drawn between its ticks, so its motion is continuous at any frame rate while the domain presents at its own. An element new to a slot starts where it was presented. Omitted or 0, each present is drawn as it lands. element stands in that row at each present — for a domain whose rows are taken by different elements over time, as a particle's slot is when it dies and another is born into it. A row whose identity moved since the last present starts where it was presented rather than blending from the element that held it before. -1 names a row no element stands in. Omitted, each row's element is the entity placed from it.
| arg | type | description |
|---|---|---|
| key | string | Stable identity of the binding. |
| entityIds | { string }? | The entities in buffer order, or nil to rebind the buffers alone. |
| positions | string | Name of a buffer holding three f32 per element. |
| rotations | string? | Name of a buffer holding four f32 per element. |
| scales | string? | Name of a buffer holding three f32 per element. |
| lanes | { string? }? | Per-instance shader data, by lane: `lanes[k + 1]` names a buffer |
| interval | number? | Seconds between the presents of the domain the buffers hold. |
| identities | string? | Name of a buffer holding one f32 per element naming which |
examples
substrate.scatterTransforms("wave", ids, positions:bufferName())substrate.scatterTransforms("wave", nil, positions:bufferName())substrate.scatterTransforms("wave", nil, positions:bufferName(), nil, nil, { colors:bufferName() })substrate.scatterTransforms("sparks", ids, positions:bufferName(), nil, nil, nil, 1 / 30)substrate.scatterTransforms("sparks", nil, positions:bufferName(), nil, scales:bufferName(), nil, 1 / 30, ids:bufferName())scatterTransformsInto(key: string, into: { transforms: string, data: string?, count: number }, positions: string, rotations: string?, scales: string?, lanes: { string? }?, interval: number?, identities: string?) → boolean
Place elements that stand in no entity from the substrate buffers that hold their transform, on the device: row i of the columns is composed into matrix i of `into.transforms`, and lane k of block i of `into.data` — the two buffers an instanced draw (`renderer.mesh.drawInstanced` with `transformBuffer` and `instanceDataBuffer`) copies into its slots every frame. It is `scatterTransforms` for a population: the same columns, the same blend between presents when `interval` is given, the same `identities`. Binding the same `key` into the same buffers again replaces only the columns it reads. Queued: it takes effect on the next frame, ahead of the instanced copies that frame records. mat4, a buffer of `count` 64-byte blocks of four vec4 lanes when lanes are bound, and how many elements there are. written into lane `k` of each element's block in `into.data`. drawn between the last two presents. element stands in that row at each present; -1 names a row no element stands in.
| arg | type | description |
|---|---|---|
| key | string | Stable identity of the binding. |
| into | { transforms: string, data: string?, count: number } | `{ transforms, data?, count }`: a buffer of `count` column-major |
| positions | string | Name of a buffer holding three f32 per element. |
| rotations | string? | Name of a buffer holding four f32 per element. |
| scales | string? | Name of a buffer holding three f32 per element. |
| lanes | { string? }? | `lanes[k + 1]` names a buffer holding one vec4 per element, |
| interval | number? | Seconds between the domain's presents; given, each element is |
| identities | string? | Name of a buffer holding one f32 per element naming which |
examples
substrate.scatterTransformsInto("sparks", { transforms = "sparks.xf", count = 4096 }, P:bufferName())scatterWorlds(key: string) → any
Read back where the device drew the elements of the transform binding under `key` in the last frame drawn — the device's own answer, since the CPU never learns where an element it places stands. 16 floats per placed element, column-major world matrices, in the binding's element order, leaving out elements whose renderable has not landed. A binding drawn between its presents answers the blended pose that frame drew.
| arg | type | description |
|---|---|---|
| key | string | The key the binding was made under. |
examples
local pending = substrate.scatterWorlds("sparks")while not pending:ready() do task.wait() end
local x = pending:result()[13]
clearScatterTransforms(key: string) → boolean
Drop the device transform binding under `key`. Its entities go back to being placed by whatever writes their `Transform`. Queued.
| arg | type | description |
|---|---|---|
| key | string | The key the binding was made under. |
examples
substrate.clearScatterTransforms("wave")defineFrames(id: string, frames: { any }) →
Hold a sequence of frames on the engine under `id`, for plays bound to it (`bindFrames`) to show by index (`showFrame`) with no script running per frame. `frames[f]` is `{ scatters = {...}, fields = {...} }`. A scatter `{ slot, positions, rotations?, scales?, lanes?, identities?, count }` names the buffers the slot's elements are placed from in that frame and how many elements they hold; `lanes[k]` is a buffer name or false. A field `{ slot, component, field, values }` gives the value the field takes on each of the slot's entities, in binding order. Slots are numbered from 1. The buffers stay the caller's and must stand for as long as the sequence is held. Defining `id` again replaces it.
| arg | type | description |
|---|---|---|
| id | string | The sequence's name. |
| frames | { any } | The frames, in order. |
examples
substrate.defineFrames("spark", { { scatters = { { slot = 1, positions = P:bufferName(), count = 64 } } } })forgetFrames(id: string) → boolean
Drop the sequence held under `id`. A play still bound to it shows nothing further.
| arg | type | description |
|---|---|---|
| id | string | The sequence's name. |
examples
substrate.forgetFrames("spark")bindFrames(key: string, id: string, slots: { any }) →
Bind the play `key` to the sequence `id` through `slots`, so `showFrame(key, f)` places its elements from frame f. `slots[s]` is `{ kind = "instances", key, transforms, data?, capacity, draw, interval? }` for elements an instanced draw copies from `transforms` — the scatter binding `key` places up to `capacity` of them there and the draw `draw` draws the frame's count — or `{ kind = "entities", key, binding?, interval? }` for elements standing in entities: the scatter binding `key`, whose entities `scatterTransforms` has already set, places them, and the frame's fields are written across the entity binding `binding`.
| arg | type | description |
|---|---|---|
| key | string | The play's name. |
| id | string | The sequence the play shows. |
| slots | { any } | What each numbered slot places, for this play. |
examples
substrate.bindFrames("spark#1", "spark", { { kind = "instances", key = "spark#1", transforms = "spark#1.xf", capacity = 64, draw = drawId } })showFrame(key: string, frame: number) → boolean
Show frame `frame` (from 1) of the sequence the play `key` is bound to: each slot is placed from that frame's buffers, each instanced draw draws that frame's count, and that frame's fields are written. Showing the frame already shown changes nothing. Queued.
| arg | type | description |
|---|---|---|
| key | string | The play's name. |
| frame | number | The frame, from 1. |
examples
substrate.showFrame("spark#1", 12)unbindFrames(key: string) → boolean
Drop the play `key`'s binding to its sequence. What it placed stays as the last frame shown left it.
| arg | type | description |
|---|---|---|
| key | string | The play's name. |
examples
substrate.unbindFrames("spark#1")releaseBaseline(generation: number) → boolean
Drop the mark `generation` names, and every mark taken after it, once the caller that took it is done resetting to it. It touches the marks alone.
| arg | type | description |
|---|---|---|
| generation | number | The mark to drop, as `substrate.markBaseline` returned it. |
examples
substrate.releaseBaseline(mark) -- the run is over
BufferElement = "f32" | "vec3" | "vec4" | "quat" | "mat4"BufferKind = "cpu" | "gpu"BufferUsage = "vertex" | "index" | "indirect" | "readback"BufferOpts = {TypedBuffer = {NamedBuffer = {Readback = {Sub-parts
Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.
Showing up to 3 levels deep. Open a part to see what it holds further down.
Problems
Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.
agent_score is exposed.+ quality × 0.35
+ performance × 0.25
± compat factor
Usability ratings
Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".
Scoped to this part · feeds back into the world's score.