Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

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 is storage the engine holds outside the Luau 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. A Luau buffer is untyped bytes held in the Luau heap, where what it holds counts in full, so it suits small amounts. A large block of data belongs in a substrate buffer and passes through Luau a chunk at a time: writeBytes places a packed chunk at a byte offset, and reading the buffer's bytes back into Luau (:bytes(), :read():resultBytes()) makes a copy in the Luau heap. 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.