Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

renderer

GPU-resource factory plus CPU codec/store wrappers — the GPU/CPU half of the asset↔resource split. Moves a resource Disk → CPU → GPU in explicit steps and returns inert handles describing each live resource. assetType behaviours, components, and every other module call renderer.*.

A resource moves through three states, each an explicit call:

  • ref:load() → renderer.<res>.loadCpu(ref) decodes the asset's primary content into the guid-keyed CPU store and returns a CPU handle (holds only a guid plus dims/counts and the read/encode/unload ops).
  • renderer.<res>.create(cpuHandle | rawData, guid?) uploads CPU → GPU, keyed by guid, and returns a GPU handle. create never takes an AssetRef — load the CPU first.
  • renderer.destroy(handle) frees the GPU resource; a CPU handle's :unload() frees the CPU copy.

A MeshHandle / TextureHandle / MaterialHandle is an inert data record ({ kind, category, guid, name, ... }) describing one live GPU resource. Every GPU op lives on renderer.* and takes the handle.

Anywhere a call names a resource it takes any of the forms you already hold — the handle create returned, the CPU handle loadCpu returned, the guid, or the AssetRef asset.resolve returned. Handles compose: what one call hands back, the next call accepts.

Types

  • Aabb — { min = {x,y,z}, max = {x,y,z} }.
  • MeshHandle / MeshCpuHandle / MeshGeometry / MeshBuffers.
  • TextureHandle / TextureCpuHandle / TexturePixels.
  • MaterialHandle / MaterialContent.

renderer.mesh

  • encode(geom) -> string? / decode(zmsh) -> (geom?, errmsg?) — the ZMSH CPU codec.
  • loadCpu(ref) -> MeshCpuHandle — Disk → CPU load (called by meshRef:load()).
  • create(src, guid?) -> MeshHandle — create or fetch a GPU mesh from a MeshCpuHandle, raw geometry {positions, indices, normals?, uvs?, colors?}, compute buffers {vertexBuffer, indexBuffer, vertexCount, indexCount, aabbMin?, aabbMax?}, or a MeshHandle.
  • geometry(mesh) -> MeshGeometry — the mesh's vertex data, the read that pairs with create.
  • update(handle, src) -> MeshHandle — overwrite the GPU resource in place under the same guid.
  • readback(mesh, into?) -> MeshCpuHandle: GPU → CPU recovery of a runtime mesh's geometry. into lands the copy under a CPU-store key of the caller's own, which stays resident until the caller unloads it, whatever re-registers or unloads the mesh itself.
  • isResident(mesh) -> boolean / isCpuResident(mesh) -> boolean.

Geometry moves in four directions, two per axis — DATA the caller holds, and the geometry of a mesh the ENGINE holds:

From→ bytes→ geometry
geometry (data the caller holds)encode(geom)—
ZMSH bytes (data the caller holds)—decode(zmsh)
a mesh the engine holdsencodeCpu(mesh)geometry(mesh)

geometry(mesh) reads the resident CPU copy when there is one and reads the geometry back off the GPU when there isn't (yielding a frame or two), so a mesh straight out of create answers it:

local h = renderer.mesh.create({ positions = P, indices = I, tangents = T })
local geom = renderer.mesh.geometry(h)   -- { positions, indices, tangents, ... }

An optional stream is present only when the mesh carries one. The GPU vertex layout has no "unset" — it holds a zeroed tangent, a zeroed skin binding, and a second UV set copied from the texture UVs for every mesh — so a stream saying only what its absence already says is reported absent. geom.tangents == nil is therefore the answer to whether a mesh has a tangent basis, and geom.uvs1 == nil to whether it has a second UV set.

renderer.texture

  • encode(rgba, width, height, opts) -> (string?, errmsg?) / encodeFromImage(bytes, opts) -> (string?, errmsg?) / decode(ztex) -> (rgba?, width?, height?) — the ZTEX CPU codec.
  • withSampler(ztex, { filter?, wrapU?, wrapV?, anisotropy? }) -> (string?, errmsg?) — the same payload with only its recorded sampler state changed; every level is byte-identical.
  • loadCpu(ref, encodeOpts?) -> TextureCpuHandle — Disk → CPU load with per-pixel access.
  • create(src, guid?) -> TextureHandle — from a TextureCpuHandle, raw pixels {rgba, width, height, srgb?}, a TextureHandle, or render-target dimensions {width, height, name?} (an empty GPU texture a render pass writes into).
  • update(handle, src) -> TextureHandle, destroy(handle), capture(handle) -> string (CPU readback result key), readback(texture) -> TextureCpuHandle, isResident(texture) -> boolean.

renderer.material

  • create(content, key) -> MaterialHandle — register or update a material's GPU record (content = { shader, properties, textures, aliases?, name? }) under its canonical registry key.
  • animatedTexture(texture, opts?) -> MaterialHandle — a material that PLAYS a layered texture: its layers bound as the frames, its timing beside them, the built-in animatedTexture shader turning the clock into the layer showing now. One call from an imported GIF / APNG / animated WebP to a material an entity can wear. guides { path: "topics/animated-images" } is the whole surface.
  • setProperty(key, name, value) — push one changed uniform.
  • setTexture(key, slot, ref) — push one changed texture slot (the texture must already be GPU-resident).
  • destroy(key) -> boolean — drop the definition filed under key, so describe and list stop answering for it. A surface already wearing the handle goes on drawing it.

A MaterialHandle and its guid are two different currencies. What a surface wears is the HANDLE — Model:applySessionMaterial(handle), or a Model's material field. The handle's guid is the material's registry key, which is what setProperty, setTexture, describe and destroy take. A component field resolves an asset, so a bare registry key in one leaves the component waiting for an asset to register under that name.

renderer.instanceData

Four vec4 lanes of generic shader data per entity — the channel that lets ONE material serve many entities that differ in a value, instead of one material per entity. A surface shader reads lane i back as input.shader_data[i], and the engine attaches no meaning to what a lane holds.

  • laneCount() -> number — lanes per entity, so a lane index runs 0 .. laneCount() - 1.
  • set(target, lane, x, y?, z?, w?) — write one lane of an entity's block. target is an entity proxy or an entity-id string naming a live entity; y / z / w default to 0. The lane reaches the block where the call is made, and the draw on the next frame.
  • clear(target) — drop the entity's whole block, so its draws read zero again. Takes an entity that has already gone, and does nothing for one holding no block.

A lane index outside the block, or a target no live entity answers to, is an error at the call rather than a write that lands nowhere. A feature picks the lane indices it owns and names them where it writes them: @builtin::systems.globalIllumination.lightmap holds lane 0 for a receiver's atlas rect and lane 1 for its layer and enable flag, which is the shape to copy.

The same lanes reach a GPU-driven population through renderer.mesh.drawInstanced's instanceDataBuffer, on slots no entity owns.

renderer.featureTexture

The texture analogue: a shared rgba16f 2D array every surface shader can sample as zero_feature_texture(uv, layer), and the array a SpotLight projects a layer of through its cone via cookieLayer.

  • configure(width, height, layers) — size the array. A call for the size it already has is left alone; one that changes the size reallocates, and the replacement is zeroed, so it empties every layer in the array — the layers other features and other cookies own along with your own.
  • setLayer(layer, textureKey, x, y) — copy a GPU-resident rgba16f texture into one layer at that offset, GPU to GPU with no readback. Several small images pack into one layer at different offsets.
  • state() -> { width, height, layers, filled, fills }: the extent the array carries and filled, the ascending 0-based indices of the layers a fill has landed in since it was last sized. One array is shared by every feature and every light cookie, and it has no allocator, so this is what a feature asks to tell whether the array it sized and filled is still the array it is writing into. A layer some other configure emptied leaves filled without it, which is what separates that from a fill that never landed. What it describes is the array a shader samples: the source texture a setLayer copied from is a GPU resource of its own and keeps the bytes it was written with, so filled is the reading that answers whether the layer behind a cookieLayer is live right now. fills counts the fills that have landed in each layer since the array was last sized, fills[layer + 1] for a 0-based layer: a fill of a layer already holding content leaves filled as it was and moves this count, so a feature re-filling its own layer reads its fill arriving here. Measured at the end of the last rendered frame, like every reader below.
renderer.featureTexture.configure(256, 256, 4)
renderer.featureTexture.setLayer(0, "my_gobo", 0, 0)
-- ... elsewhere in the scene, another feature sizes the array ...
local ft = renderer.featureTexture.state()
if ft.width ~= 256 or table.find(ft.filled, 0) == nil then
    -- layer 0 was emptied under us: size it back and re-fill.
end

Reading back what the renderer did

Every call above states an intention. What the renderer made of it is a separate document, published once per frame and read through these:

  • drawDiagnostics() -> { DrawDiagnostic } — every renderable that is NOT drawing what its material says. A surface rendering as the magenta placeholder, one the renderer could bind nothing for, and one drawing a program whose most recent compile failed all land here, each naming the entity, the program asked for, the program bound, and the one cause. It covers every renderable the renderer holds, whether or not a camera reached it — a row with observed = false and outcome = "notDrawn" is the renderer's own resolution for one this frame drew nowhere, so a broken surface off-screen reads the same as one in frame. An empty result means every renderable the renderer holds is drawing the program its material named. Start here when a surface looks wrong.
  • material.renderState(key) -> MaterialObservation? — the blend / cull / topology / queue / depth key the renderer holds a material under, and whether a pipeline exists for it. nil means the renderer holds no material under that key, which is what writes reaching nothing on screen look like. index is the material index its row is filed under, and textures the texture key each slot of that row names, so a setTexture the renderer has taken up reads there.
  • materialCost() -> { MaterialObservation } — draws, instances, placeholder draws and material-owned bind groups (binds set, bindsElided skipped) per material, over the published frame.
  • shaderCost() -> { ShaderCost } — pipeline build time and permutation count per program, summed since engine start, beside the compile gate's word.
  • observe() -> RenderObservation — the whole document the four above read. /zero/runtime/generated_materials serves the same document as files.

renderer.destroy

  • destroy(handle) -> boolean — free the GPU resource behind a MeshHandle / TextureHandle, routing by the handle's category.

Parts

The module is a folder of parts, each a module of its own that init.luau requires:

  • renderer.refs: guid minting and the coercion from whatever names a material, mesh or texture to the key the renderer indexes it by.
  • renderer.sources: the src shapes renderer.mesh.create / update and renderer.texture.create / update take, and the bounds and counts raw geometry implies.
  • renderer.shading: the readings renderer.shadingOf builds out of the document the renderer publishes.

Usage

local renderer = require("@builtin::modules.api.engine.renderer")
local gpu = renderer.mesh.create({ positions = { ... }, indices = { ... } })
renderer.destroy(gpu)