Builtin Library
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.createnever takes anAssetRef— 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 bymeshRef:load()).create(src, guid?) -> MeshHandle— create or fetch a GPU mesh from aMeshCpuHandle, raw geometry{positions, indices, normals?, uvs?, colors?}, compute buffers{vertexBuffer, indexBuffer, vertexCount, indexCount, aabbMin?, aabbMax?}, or aMeshHandle.geometry(mesh) -> MeshGeometry— the mesh's vertex data, the read that pairs withcreate.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.intolands 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 holds | encodeCpu(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 aTextureCpuHandle, raw pixels{rgba, width, height, srgb?}, aTextureHandle, 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-inanimatedTextureshader 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 underkey, sodescribeandliststop 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 runs0 .. laneCount() - 1.set(target, lane, x, y?, z?, w?)— write one lane of an entity's block.targetis an entity proxy or an entity-id string naming a live entity;y/z/wdefault 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-residentrgba16ftexture 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 andfilled, 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 otherconfigureemptied leavesfilledwithout it, which is what separates that from a fill that never landed. What it describes is the array a shader samples: the source texture asetLayercopied from is a GPU resource of its own and keeps the bytes it was written with, sofilledis the reading that answers whether the layer behind acookieLayeris live right now.fillscounts 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 leavesfilledas 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 withobserved = falseandoutcome = "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.nilmeans the renderer holds no material under that key, which is what writes reaching nothing on screen look like.indexis the material index its row is filed under, andtexturesthe texture key each slot of that row names, so asetTexturethe renderer has taken up reads there.materialCost() -> { MaterialObservation }— draws, instances, placeholder draws and material-owned bind groups (bindsset,bindsElidedskipped) 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_materialsserves the same document as files.
renderer.destroy
destroy(handle) -> boolean— free the GPU resource behind aMeshHandle/TextureHandle, routing by the handle'scategory.
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: thesrcshapesrenderer.mesh.create/updateandrenderer.texture.create/updatetake, and the bounds and counts raw geometry implies.renderer.shading: the readingsrenderer.shadingOfbuilds 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)