Log inGet started

renderer

Updated 6 September 2026

The renderer namespace — 462 functions.

globals/renderer/anisotropy

renderer.anisotropy() -> number

The maximum anisotropy material textures are sampled with right now — the requested level clamped to what this device honours.

Returns number — The effective level, 1 through 16.

if renderer.anisotropy() < 4 then ... end

globals/renderer/atmospherics/held

renderer.atmospherics.held() -> boolean

Whether a hold is standing on the air right now.

Returns boolean — True while at least one renderer.atmospherics.hold stands.

if renderer.atmospherics.held() then print("clear air") end

globals/renderer/atmospherics/hold

renderer.atmospherics.hold(share: number?) -> () -> ()

Hold the air between the camera and every surface at a stated share of what the scene authored, and return the release. At the default 0 the media contribute nothing and a surface renders in its own colour, which is what lets a reader judge an albedo, a tint or a material while another slice of a shared world drives the weather. The share reaches aerial perspective, height fog and volumetric light scattering; the sky, the sun and the light they put on a surface are untouched, because those are what the surface's colour is made of. Holds nest: the innermost names the share, and the authored air is back once the last release is called. Each release ends its own hold whatever order the releases come in, so two callers holding at once each end their own.

Parameters

  • share number (optional) — How much of the authored air reaches the image, in [0, 1]. Defaults to 0 — no air at all.

Returns () -> () — A function that releases this hold. Calling it twice releases once.

local release = renderer.atmospherics.hold() ; local png = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()

globals/renderer/atmospherics/onChange

renderer.atmospherics.onChange(listener: (number) -> ()) -> () -> ()

Register a listener called with the share now in force whenever it changes — a hold taken, a hold released — and return the unsubscribe. A system that packs a medium into a GPU buffer registers here and re-packs what it has already pushed, so the buffer carries the share before the frame the hold was taken on is drawn rather than a frame later.

Parameters

  • listener (number) -> () — Called with the share now in force, in [0, 1].

Returns () -> () — A function that removes this listener.

local stop = renderer.atmospherics.onChange(function(share) pushParams() end)

globals/renderer/atmospherics/share

renderer.atmospherics.share() -> number

The share of the authored air that reaches the image: the innermost hold's share while one stands, and 1 otherwise. A system that packs a medium multiplies its extinction — aerial, a fog density — by this, and a hold then reaches that medium however it is being driven.

Returns number — A number in [0, 1]. 1 when nothing holds.

local density = state.density * renderer.atmospherics.share()

globals/renderer/blendedBatching

renderer.blendedBatching() -> boolean

Whether blended neighbours sharing a draw key draw together.

Returns boolean

globals/renderer/bounds/clear

renderer.bounds.clear(id: string) -> boolean

Withdraw the box an entity published, so it stops contributing to the entity's reported extent.

Parameters

  • id string — Entity id.

Returns boolean — True when there was a published box to withdraw.

renderer.bounds.clear(id)

globals/renderer/bounds/set

renderer.bounds.set(id: string, min: any?, max: any?) -> boolean

Publish the local-space box an entity's content-drawn geometry occupies. entity:bounds() and entity:hierarchyBounds() union it with whatever mesh geometry the entity has, each carried out of its own local space, so framing a camera on the entity frames what a feature actually draws.

Parameters

  • id string — Entity id.
  • min any (optional) — Local-space minimum corner — a Vec3 table or a 3-element array.
  • max any (optional) — Local-space maximum corner, same shape as min.

Returns boolean — True when the box was stored; false for a non-finite or inverted box.

renderer.bounds.set(id, cloud.boundsMin, cloud.boundsMax)

globals/renderer/captureView/channelId

renderer.captureView.channelId(name: string) -> number?

The debug channel a registered view draws on — what a feature passes as its pass debugChannel. Nil when no view is registered under name.

Parameters

  • name string — The view name.

Returns number? — The channel number or nil.

ctx.enqueue { ..., debugChannel = renderer.captureView.channelId("lightmap") }

globals/renderer/captureView/list

renderer.captureView.list() -> { any }

Every registered capture view as { name, channel, description } records — what backs the discoverability of capture pass=<name> and the unknown-view error's suggestion list.

Returns { any } — An array of view records.

for _, v in ipairs(renderer.captureView.list()) do ... end

globals/renderer/captureView/ready

renderer.captureView.ready(name: string) -> boolean

Whether a registered view can draw yet. A view's passes are enqueued from the moment its render feature first runs, but they are skipped while the materials they name have no pipeline — their shader is still compiling — so for the first frames of a session a camera bound to the view renders the ORDINARY view into its target, and the image gives no sign of it. This reports the difference, and reports it before any camera is on the view, so it is answerable for the first camera bound to one. False for an unregistered name.

Parameters

  • name string — The view name.

Returns boolean — Whether this view's passes have resolved everything drawing needs.

repeat task.wait() until renderer.captureView.ready("zfighting")

globals/renderer/captureView/register

renderer.captureView.register(name: string, config: any?) -> number

Register (or update) a content capture view under name and return the debug CHANNEL number assigned to it. A render feature gates its pass to this channel (debugChannel = channel) so the pass draws only when a capture selects the view. Idempotent: re-registering the same name keeps its channel.

Parameters

  • name string — The view name, selected via capture pass=<name>.
  • config any (optional){ description?, ensure?, warmup?, renderLayers? }. ensure is called before a capture of this view so the feature that draws it is live (e.g. create it on demand). warmup is how many present frames a capture lets the view accumulate before it reads — set it when the feature retains prior-frame state (a temporal diff) so the first capture reads a warm result. renderLayers is the layer spec a capture of this view uses when the caller named none — a view that draws its own geometry and wants the scene's kept out of the frame (and out of the depth buffer it tests against) names only its own layer.

Returns number — The channel number assigned to the view.

local ch = renderer.captureView.register("lightmap", { description = "...", ensure = fn })

globals/renderer/captureView/resolve

renderer.captureView.resolve(name: string) -> any

Resolve a capture view by name to its { channel, ensure, description, warmup } record, or nil when no view is registered under name.

Parameters

  • name string — The view name.

Returns any — The view record or nil.

local v = renderer.captureView.resolve("lightmap")

globals/renderer/captureView/unregister

renderer.captureView.unregister(name: string) -> boolean

Withdraw a capture view. A subsequent capture pass=<name> no longer resolves to it (falls through to the unknown-view error).

Parameters

  • name string — The view name.

Returns boolean — True when a view was registered under name.

renderer.captureView.unregister("lightmap")

globals/renderer/clearShadowHero

renderer.clearShadowHero() -> boolean

Release the hero caster, so the directional shadow is the cascades' alone again and the layer the hero view rendered into is given back.

Returns boolean — Whether a caster was registered.

renderer.clearShadowHero()

globals/renderer/clearShadowProxy

renderer.clearShadowProxy(mesh: string?) -> number

Stop proxying mesh, so it rasterizes its own geometry into shadow views again. Called with no argument, drops every registration.

Parameters

  • mesh string (optional) — The mesh to stop proxying. Omit to clear all of them.

Returns number — How many registrations were removed.

renderer.clearShadowProxy(statueMesh)
print(renderer.clearShadowProxy(), "proxies dropped")

globals/renderer/collect

renderer.collect() -> RuntimeCollection

Release every runtime texture, material, mesh and render feature nothing holds: no handle a script still reaches, no live owner, no reference from live engine state, no asset backing it, no hold. A root scene load runs this once the new scene stands, so what the previous scene's content created and nothing still wears goes with that scene; calling it directly collects at any other moment. A session material's handle counts as reached while the entity it was keyed for stands, and stops counting once that entity is gone. It reaches the GPU textures the device holds beside the registry's own: a texture the cache loaded for an asset goes once nothing live names it and is read back from that asset the next time something asks for it, while one no asset answers for stays, there being nothing to read it back from — a render pass's own target, a colour swatch, an atlas the engine built. A texture the ASSET path uploaded and whose asset has since been removed has nothing to come back from either, and the collection decides about it from its holders the way it does about every other resource: a handle a script still reaches, a live owner, a reference from live engine state, a hold. Features go first, then materials, then meshes, then textures, so a texture only a released material named goes with the material. Runs a full garbage collection first, so a handle nothing reaches counts as let go, and yields for the frame the census runs on. A handle the calling function still has in a variable — or in a temporary it has not overwritten — is one a script reaches, so a resource created in the function that collects is let go by the next collection rather than this one.

Returns RuntimeCollection{ released = { texture, material, mesh, feature }, kept, entries } — the counts released per kind, how many stayed, and every resource's status with action = "released" | "kept".

local c = renderer.collect() print(c.released.texture, c.kept)

globals/renderer/compiledShaders

renderer.compiledShaders() -> { string }

Every name renderer.compiledSource answers for — one per name a shader compile has run under this session, whether it succeeded or failed. What makes the composed-source surface enumerable rather than something to guess a key for.

Returns { string } — An array of shader names, sorted.

for _, name in renderer.compiledShaders() do print(name) end

globals/renderer/compiledSource

renderer.compiledSource(shader: string) -> string?

The WGSL the shader compiler received under one name, exactly as it received it — the composed module, which is what a compile error's line numbers and handle indices are positions in. Answers under any name a compile ran under (identity, guid, alias, or a program from renderer.shaderVariants()), for a shader that declares no features, and for a shader whose compile FAILED, which is the case it exists for: a message about a function body carries a position and nothing else, and the text that position is in is this. The failed text stands for as long as shaderRef:compileStatus() reports that failure under the same name.

Parameters

  • shader string — Any name a shader compiled under — identity, guid, alias, or a shaderVariants() program name.

Returns string? — The composed WGSL, or nil for a name no compile has run under.

local status = asset.resolve("myShader", "shader"):compileStatus()
if status.status == "failed" then
print(status.error)
print(renderer.compiledSource("myShader"))
end

globals/renderer/compositeSize

renderer.compositeSize() -> { width: number, height: number }

The size of the image the post-scene phases worked on in the last presented frame — the target the UI composites onto, which every pass after the scene reads as @scene.color and writes into, and which a screenSpace = "composite" render target follows. While the renderer presents the viewport itself that is the display's own size, whatever fraction of it the scene rasterized at; while a UI viewport panel owns the presentation it is the size the scene rasterized at, since the panel draws the scene target at its own rect and nothing upscales before the composite. Both read 0 before a frame has drawn.

Returns { width: number, height: number } in pixels.

local c = renderer.compositeSize()

globals/renderer/cullStats

renderer.cullStats() -> {

What the last completed frame decided to draw. total renderables went into the frustum test, culled fell outside it and visible survived. Of those, occlusion culling measured occlusionTested against the depth pyramid and proved occlusionCulled were entirely behind other geometry — both 0 while renderer.occlusionCulling() is false. A renderable the pyramid has no say over — one that laid no depth in the pre-pass, one whose bounds were never recorded, one straddling the near plane — is measured against nothing and counted in neither, so the gap between visible and occlusionTested reads how much of the frame the test could speak for.

This answers for the main camera. What a shadow view's own volume did with the frame's casters is on that view's row in renderer.shadowViews().

Returns { total: number, culled: number, visible: number, occlusionTested: number, occlusionCulled: number }

local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")

globals/renderer/debugPass/builtins

renderer.debugPass.builtins() -> { string }

The built-in debug-pass names, one per channel in channel order — the engine's built-in pass vocabulary (final, albedo, normal, depth, …).

Returns { string } — An array of built-in pass names.

for _, n in ipairs(renderer.debugPass.builtins()) do ... end

globals/renderer/debugPass/channel

renderer.debugPass.channel(name: string) -> number?

The channel a debug-pass NAME renders on: a built-in pass, else a content capture view registered via renderer.captureView. Nil when the name is neither — the signal a selector uses to reject an unknown pass.

Parameters

  • name string — A debug-pass name (e.g. "normal", "depth", "lightmap").

Returns number? — The channel number, or nil for an unknown name.

local ch = renderer.debugPass.channel("normal")   -- 7

globals/renderer/debugPass/list

renderer.debugPass.list() -> { string }

Every selectable debug-pass name: the built-in passes plus every registered content capture view. What a debug-pass selector offers.

Returns { string } — An array of pass names.

local passes = renderer.debugPass.list()

globals/renderer/debugPass/name

renderer.debugPass.name(channel: number) -> string?

The canonical NAME for a debug channel: a built-in pass name for a built-in channel, else a registered capture view's name. Channel 0 is "final" (the lit image). Nil when no pass owns the channel.

Parameters

  • channel number — The channel number.

Returns string? — The pass name, or nil.

local name = renderer.debugPass.name(7)   -- "normal"

globals/renderer/depthPrepass

renderer.depthPrepass() -> boolean

Whether the opaque depth pre-pass is currently enabled.

Returns boolean

globals/renderer/depthPrepassOrder

renderer.depthPrepassOrder() -> { runs: number, reordered: number }

What the last frame's depth pre-passes planned, and how far their sequences were from near-to-far before they ordered. runs counts the instanced draws planned; reordered counts the adjacent pairs the sort moved past each other, taken before it ran. Both are summed over every pre-pass the frame ran — the window plus each render-target camera, each ordering against its own camera. Both read 0 while the pre-pass or the ordering is off, and reordered reads 0 for a frame that already stood in order. The ordering leaves no other trace — the draws, the depth and the image are the same either way.

Returns { runs: number, reordered: number }

local o = renderer.depthPrepassOrder()  -- o.reordered > 0 → it sorted

globals/renderer/depthPrepassOrdering

renderer.depthPrepassOrdering() -> boolean

Whether the depth pre-pass is submitted nearest-first.

Returns boolean

globals/renderer/destroy

renderer.destroy(handleOrKind: any?, id: string?) -> boolean

Free the GPU resource a renderer resource holds (the GPU-destroy verb). Takes any of the forms that name it: the handle a create returned, routed by its category so one call releases a mixed set of handles; the id a listing hands out, whose kind is read back off what the renderer holds under it — the runtime registry, the material definitions, the live features, and the device itself for an asset's own texture or mesh; or the kind with the id beside it, the shape renderer.hold and renderer.references take, which is what names the kind for an id two of them answer to. An id nothing holds anything under releases nothing and answers false. The on-disk asset, if any, is untouched. A CPU handle's :unload() frees the CPU copy separately.

Parameters

  • handleOrKind any (optional) — A MeshHandle, TextureHandle, MaterialHandle or feature handle; the id itself; or the kind ("texture", "material", "mesh", "feature") with the id as the second argument.
  • id string (optional) — The guid or registry key, when the first argument is a kind.

Returns boolean true if a GPU resource was known under the id.

renderer.destroy(meshHandle); renderer.destroy(materialHandle)
renderer.destroy(renderer.texture.list()[1].guid)
renderer.destroy("mesh", guid)

globals/renderer/deviceGeneration

renderer.deviceGeneration() -> number

Which render device this process is on, counted from the first.

A render device is lost when a driver resets, when the GPU is taken away, or when a browser reclaims a WebGPU context. The engine answers by building another device and re-deriving this session's resources onto it, and this number moves by one each time it does. Anything held across frames that was built from a GPU resource records this beside it and remakes it when the two differ; engine.onDeviceRebuilt is the hook that fires when it moves.

Returns number — The current device generation, counting from 1.

local generation = renderer.deviceGeneration()
engine.onDeviceRebuilt(function(g) print("device " .. g) end)

globals/renderer/deviceState

renderer.deviceState() -> string

Whether the render device this process draws through is the one it is using, one it is replacing, or one it has stopped trying to replace.

"ready" is a live device. "rebuilding" is the window between a device reporting itself lost and another being in place: every GPU resource built from the old one is invalid, the frames in that window draw nothing, and anything reaching the GPU refuses. "abandoned" is after the engine gave up — the adapter refused every attempt, so this session draws no more frames.

Work that spans the device — build a render target, draw into it, read it back — reads this to tell an operation that failed because the device went out from under it, which is worth doing again once renderer.deviceGeneration() moves, from one that failed on its own terms. The loss is reported before the next device exists, so the two readings answer different halves: this one says a replacement is coming, the generation says it arrived.

Returns string"ready" | "rebuilding" | "abandoned".

if renderer.deviceState() == "rebuilding" then return end

globals/renderer/drawDiagnostics

renderer.drawDiagnostics() -> { DrawDiagnostic }

Every renderable that is NOT drawing what its material says — the one call for "why does this surface look wrong". Three states land here: a surface rendering as the magenta placeholder (substituted), one the renderer could bind nothing for at all (outcome = "skipped"), and one drawing a program whose most recent compile FAILED (stale), which is what a shader edited into brokenness looks like — the pipeline its last good compile built keeps drawing, so the picture is intact and answers to none of the edits since. Each row names the entity, the program asked for, the program bound, programStatus — the compile gate's word about the program the material NAMED — and the one cause from shaderCompileFailed / shaderNotRegistered / shaderNotCompiledYet / noGbufferEntry / renderStateKeyNotBuilt / noPipelineForTarget / unshaded, with the compiler's own message in detail or programError. Covers every renderable the renderer holds, whether or not a camera reached it: a row with observed = false and outcome = "notDrawn" carries the renderer's own resolution for one this frame drew nowhere, so a broken surface off-screen is reported the same as one in frame. An empty result means every renderable the renderer holds is drawing the program its material named and that program compiles. Answers on the deferred path as well as forward, and in edit mode as well as play.

Returns { DrawDiagnostic }

for _, d in renderer.drawDiagnostics() do print(d.entity, d.reason, d.detail) end
if #renderer.drawDiagnostics() == 0 then print("every surface is drawing what it says") end

globals/renderer/drawStats

renderer.drawStats() -> {

What the last completed frame actually submitted. draws counts every geometry draw call the frame issued — the camera's passes, each shadow view a shadow-casting light adds, and whatever a render feature draws — and instances counts the instances those draws covered. The pair is what separates one draw carrying five hundred instances from five hundred draws carrying one each, so it reads how well the scene batches rather than how many objects are in it.

compacted is how many of those instances the frame planned through draws whose instance count the GPU decides: the culler's own per-object answers packed into a dense run, so an object it rejects is absent from the draw instead of collapsing to nothing in the vertex stage. compactedDrawn is how many of them survived, counted on the GPU as it packed them — a pass that then skips a whole draw over its own layer or visibility answer leaves that draw's instances in both numbers.

The plan is made over the populations the frame draws, and the tests answer which of their instances the packing keeps. That packing runs before any pass has resolved the depth occlusion culling is tested against, so on its own it reads the frustum and screen-size answers alone. With setOcclusionCulling armed the frame packs the same plan a second time once the test has answered, and compactedDrawn then counts what came through occlusion as well.

compactedDrawn comes back from the buffer the GPU wrote, so it describes a frame that has finished while compacted describes the most recent plan, and it holds the last count the GPU wrote until another arrives — a frame that compacts nothing reads compacted 0 beside the count from the last frame that did. In a scene standing still the gap between the two is the front-end work culling removed.

materialBinds is how many times the frame's geometry passes set a material's parameter group, and materialBindsElided how many times a pass reached that decision and found the group already bound. Their sum is how many times the decision was reached — once per unit of geometry submitted, which sits at or below draws, since a mesh of several primitives draws once per primitive under one set of binds. The ratio inside the pair is what material binding costs the frame: the batched opaque geometry is gathered into runs sharing a material, so a frame of many such draws over few materials binds about once per material rather than once per unit. materialExtraBinds and materialExtraBindsElided are the same pair for the second group, the storage bindings a shader declares for itself, which only the shaders that have them ever bind.

pipelineBinds and pipelineBindsElided are the same pair for the pipeline itself: how many times the frame's geometry passes set one, and how many times a pass reached that decision and found the pipeline it wanted already bound. Which pipeline a unit needs follows its shader, its material's render state and its mesh's vertex layout together, so a scene whose units share all three costs one set for the run of them, while units differing in any one of the three each pay their own. Their sum is how many units reached the pipeline decision, which sits at or above what the material pair reports: a unit the pass settles a pipeline for and then abandons — one whose material group resolved to nothing — counts here and never reaches the material decision.

Every figure here is the whole frame's, the main camera's draws and every shadow view's summed together. renderer.shadowViews() splits compacted and compactedDrawn across the views that made them, and carries the camera's own share beside them.

Returns { draws: number, instances: number, compacted: number, compactedDrawn: number, materialBinds: number, materialBindsElided: number, materialExtraBinds: number, materialExtraBindsElided: number, pipelineBinds: number, pipelineBindsElided: number }

local d = renderer.drawStats(); print(d.instances / math.max(d.draws, 1), "instances per draw")
print(renderer.drawStats().compactedDrawn, "of", renderer.drawStats().compacted, "survived the cull")
local d = renderer.drawStats(); print(d.materialBinds, "material binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")

globals/renderer/feature/create

renderer.feature.create(ref: any?, guid: string?) -> any

Instantiate a render feature so the engine calls its render(ctx) hook every frame. ref is an AssetRef<renderFeature> whose init.luau returns { setup?, render, teardown? }. Returns a live RenderFeatureHandle (its guid is the stable id, same as mesh/texture handles); tear it down with renderer:destroy(handle). Pass guid to assign a specific id.

Parameters

  • ref any (optional) — An AssetRef<renderFeature>, or a string identity/guid resolved via asset.resolve(ref, "renderFeature").
  • guid string (optional) — Optional explicit handle guid (minted when omitted).

Returns any — A RenderFeatureHandle.

local h = renderer.feature.create(asset.resolve("acrylic", "renderFeature"))
local h = renderer.feature.create("acrylic")

globals/renderer/feature/destroy

renderer.feature.destroy(handleOrGuid: any?) -> boolean

Tear down a live render feature by its RenderFeatureHandle OR its guid string — the by-id path for when the handle was lost (e.g. across execute calls). Same effect as renderer.destroy(handle). Returns true if a feature was live under that id.

Parameters

  • handleOrGuid any (optional) — A RenderFeatureHandle or its guid string.

Returns boolean

renderer.feature.destroy("eb623975-d8bd-47a1-a0a4-5c0e56238e2f")

globals/renderer/feature/list

renderer.feature.list() -> { { guid: string, identity: string } }

List every render feature currently live (running its render(ctx) each frame). Each entry is { guid, identity } — the guid is the same id a RenderFeatureHandle carries, so you can tear a feature down by guid even after losing its handle (e.g. across separate execute calls).

Returns { { guid: string, identity: string } }

for _, f in renderer.feature.list() do print(f.identity, f.guid) end

globals/renderer/feature/shaded

renderer.feature.shaded() -> { [string]: number }

How many pixels each fragment pass a render feature enqueued shaded on the last drawn frame, keyed by the pass's shader/effect name. A fragment pass draws one triangle over its target, so it shades the whole screen whatever its effect actually reaches — unless it declares bounds on the pass spec, the world-space box its effect stays inside, in which case it shades the rectangle that box projects into for the camera drawing it and is skipped for a camera that cannot see the box at all. This is the reading that says which of the two a pass is: it moves when the effect moves, and a pass absent from it shaded nothing. Summed over every camera the frame drew.

Returns { [string]: number }{ [shader: string]: number } — pixels shaded, last drawn frame.

local px = renderer.feature.shaded(); for name, n in pairs(px) do print(name, n) end

globals/renderer/featureTexture/configure

renderer.featureTexture.configure(width: number, height: number, layers: number)

Size the shared feature-texture array — the layers a surface shader reads through zero_feature_texture(uv, layer), and the layers a SpotLight projects through its cone via cookieLayer. Layers are rgba16f. A call for the size the array already has is left alone. One that changes the size reallocates, and the replacement is zeroed — so it empties every layer in the array, including the layers other features and other cookies own. renderer.featureTexture.state() reports the extent and the layers holding content, which is how a feature re-fills the layer a resize took from it.

Parameters

  • width number — Layer width in pixels.
  • height number — Layer height in pixels.
  • layers number — How many layers the array holds.
renderer.featureTexture.configure(512, 512, 4)

globals/renderer/featureTexture/setLayer

renderer.featureTexture.setLayer(layer: number, textureKey: string, x: number, y: number)

Copy a texture already on the GPU into one layer of the shared array, its top-left corner at (x, y) — GPU to GPU, with no readback. Several small images pack into one layer by calling this once per image at different offsets. The source must be rgba16f and fit at that offset.

Parameters

  • layer number — Which layer of the array to write into.
  • textureKey string — The source texture's name — the one it was created under. A compute.createStorageTexture2D target, a compute.createTextureHistory pair (its current side), and a texture a compute.copyBufferToTexture wrote all answer to the name they were given.
  • x number — Left edge of the destination rectangle, in pixels.
  • y number — Top edge of the destination rectangle, in pixels.
compute.createStorageTexture2D("gobo", { width = 256, height = 256, format = "rgba16f" })
renderer.featureTexture.setLayer(0, "gobo", 0, 0)

globals/renderer/featureTexture/state

renderer.featureTexture.state() -> {

What the shared feature-texture array is right now: the extent every layer carries, and filled, the ascending 0-based indices of the layers a setLayer has landed in since the array was last sized. One array is shared by every feature and every light cookie in the scene, and it has no allocator, so this is the call that tells a feature whether the array it sized and filled is still the array it is writing into — a configure that changed the size reallocates and zeroes every layer, and the layer it emptied leaves filled without it. Measured off the renderer at the end of the last rendered frame, so a configure or setLayer issued this frame reads back on a later one.

What this 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 for as long as it lives, so filled is the reading that answers whether the layer behind a cookieLayer is live right now.

Returns { width, height, layers, filled }

local ft = renderer.featureTexture.state()
print(("feature textures: %dx%d over %d layers"):format(ft.width, ft.height, ft.layers))
-- Re-fill the cookie layer this module owns if anything emptied it.
if ft.width ~= myWidth or table.find(ft.filled, myLayer) == nil then
refillMyCookie()
end

globals/renderer/framePacing

renderer.framePacing() -> FramePacing?

How far the CPU is allowed to run ahead of the GPU, and what holding it there cost the frame just finished. Submitting work to the GPU returns before the GPU has done it, and everything that submission holds — its staging allocations, its bind groups, its command buffer — stays alive until it completes. A frame that asks for more work than the GPU finishes in a frame's time therefore leaves that behind it, and unbounded that is memory growth rather than a lower frame rate.

framesInFlight is how many submitted frames have not reported done through the queue's completion signal, held under maxFramesInFlight: a device that keeps up reads under the bound, one that is behind reads at it. It counts submissions, which is its own quantity — how many presented images the swapchain permits in flight is a separate setting. mechanism names how that bound is enforced here: submission-wait waits for the frame that many frames back and reports the wait in waitMs, so a paced frame costs latency and still draws; submitted-work-done counts outstanding frames off the queue's completion signal and declines to start a frame while the bound is met, counting those in pacedFrames and leaving the last presented image up. submittedFrames counts the frames that were admitted and submitted, so it rises for as long as the renderer is producing frames — which is what tells a renderer running slowly under a tight bound from one that has stopped. stalled reads true while that completion signal has stopped arriving and the pacer stood down rather than hold the image indefinitely; it clears on the first frame that finds the count back under the bound.

producing is whether the renderer is drawing frames at all. A headless renderer draws into an offscreen framebuffer that nothing presents, so its image reaches a reader only through something that copies it out: it draws while a consumer is asking — an MCP call in flight, a queued texture readback, a recording, a frame-egress session — and declines the frames between two asks, counting them in idleSkippedFrames. Every other renderer stat answers with the last frame that drew, so producing is what separates a live reading from a frozen one. A windowed renderer presents every frame it draws and reads producing = true throughout.

presentMode is what the surface presents with and presentModes what it offers; both are empty of meaning on a headless renderer, which never presents.

Returns FramePacing?{ framesInFlight, maxFramesInFlight, pacedFrames, submittedFrames, waitMs, mechanism, stalled, producing, idleSkippedFrames, presentMode, presentModes }, or nil before the renderer has drawn a frame

local p = renderer.framePacing()
print(("%d of %d frames in flight, paced by %s"):format(p.framesInFlight, p.maxFramesInFlight, p.mechanism))

globals/renderer/getRaytrace

renderer.getRaytrace() -> boolean

Whether ray tracing is currently enabled.

Returns boolean

globals/renderer/gpuMemory

renderer.gpuMemory() -> GpuMemory

Where the renderer's GPU memory went at the last completed frame — the call to reach for when something is holding memory and you do not know what.

Three figures answer three different questions, and they are meant to be read against each other:

  • The categories — shadow, textures, meshes, instances, compute, summing to categorised — are the renderer's own accounting of what it asked for on purpose. Always present, on every backend.
  • allocator is the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. It exceeds categorised by the per-frame render targets and the scratch nothing categorises. The allocator hands memory out from blocks it reserves whole from the device and returns a block only once nothing is left in it, so reservedBytes runs above allocatedBytes by what those blocks hold unused; blocks lists them emptiest first with the labels that keep each one alive, and emptyBytes plus slackBytes is that distance exactly — the pool held in empty blocks, and the room pinned inside blocks something still sits in.
  • driver.deviceLocalBytes is what the graphics driver charges this process, out of the kernel's own accounting. It is the biggest of the three and the one that fills a card, because it also holds the swapchain, the images the driver keeps on the renderer's behalf, and the rounding to whole pages and heap blocks that neither figure above sees. Read it when the question is how much of the machine's GPU this engine is using; read the two above when the question is what the engine spent it on. A platform with no per-process accounting reports available = false and the reason.
  • driver.outsideAllocatorBytes is that charge less everything the allocator reserved — what the driver holds on its own account, and the one figure here nothing releases: a dropped pipeline, another scene and renderer.collect() all leave it where it is, and it falls when the device is destroyed. Read it when a session's device memory has grown and no ledger row accounts for the growth.

compute is what the compute subsystem holds; compute.observe() names each of those resources and what it costs. renderTargets counts the offscreen render targets the renderer holds at that frame, which is what says a renderer.destroy has been applied rather than queued.

Returns GpuMemory — The accounting — see GpuMemory. The category figures are zeroed until the renderer has published its first frame; driver is read as the call runs and answers from the first.

local m = renderer.gpuMemory()
print(("shadows %.1f MiB"):format(m.shadow.total / 1024 / 1024))
-- how much of the card this engine is holding:
if m.driver.available then
print(("driver %.1f MiB"):format(m.driver.deviceLocalBytes / 1024 / 1024))
else
print("no driver figure: " .. m.driver.reason)
end
-- the biggest allocation in the engine:
local top = m.allocator and m.allocator.byLabel[1]
print(top and top.label, top and top.bytes)
-- the block held for the least, and what pins it:
local block = m.allocator and m.allocator.blocks[1]
if block and block.allocationCount > 0 then
print(block.size, block.allocatedBytes, block.byLabel[1].label)
end

globals/renderer/hold

renderer.hold(handleOrKind: any?, id: string?) -> boolean

Pin a runtime resource for the session. A held texture, material, mesh or render feature survives every collection — the one a root scene load runs and a direct renderer.collect() alike — until renderer.release lets it go or its destroy frees it. It is the way to keep an ad-hoc resource across the scenes that come and go under it. A hold keeps the resource in the registry; a mesh's GPU buffers are governed by what draws it, parked as a CPU definition when the last instance naming it goes and brought back when one names it again, so renderer.mesh.isResident(guid) is the separate question about the buffers.

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind ("texture", "material", "mesh", "feature") with the guid or key as the second argument.
  • id string (optional) — The guid or key, when the first argument is a kind.

Returns boolean true when the registry knows the resource.

renderer.hold(tex)
renderer.hold("material", "swatch")

globals/renderer/instanceData/clear

renderer.instanceData.clear(target: string | entityRef)

Drop every lane of an entity's per-instance shader data, so its draws read zero again — how a feature releases a subject it is still holding. Despawning an entity releases its block too, so this is for a subject that stays. It takes an entity that has already gone, which is when a feature releasing its subjects often runs, and does nothing for an entity holding no block.

Parameters

  • target string | entityRef — The entity — a proxy from entity(...) / entity.spawn(...), or an entity-id string.
renderer.instanceData.clear(subject)

globals/renderer/instanceData/laneCount

renderer.instanceData.laneCount() -> number

How many vec4 lanes each entity's per-instance block holds, so a lane index runs 0 .. laneCount() - 1. The same count a surface shader indexes input.shader_data against.

Returns number — lanes per entity.

for lane = 0, renderer.instanceData.laneCount() - 1 do
renderer.instanceData.set(subject, lane, 0)
end

globals/renderer/instanceData/set

renderer.instanceData.set(target: string | entityRef, lane: number, x: number, y: number?, z: number?, w: number?)

Write one vec4 lane of an entity's per-instance shader data — the channel that lets ONE material serve many entities that differ in a value. A surface shader reads the lane back as input.shader_data[lane], so a dissolve at its own progress per subject, an effect at its own age per firing, or a per-entity mask costs one material rather than one material per entity.

The engine attaches no meaning to a lane: a feature picks the lane indices it owns and packs whatever its shader agrees they carry. Name those indices in the module that writes them, so the writer and the shader read the block the same way.

The write reaches the block where it is called, so the entity it names is the one holding that id at that point in the tick, and the value is on the draw from the next frame. It is held until the lane is written again, the entity's block is cleared, or the entity is despawned — a despawned entity releases its whole block. A lane an entity was never given reads zero.

Parameters

  • target string | entityRef — The entity — a proxy from entity(...) / entity.spawn(...), or an entity-id string. It must name a live entity.
  • lane number — Which vec4 lane to write, 0 .. laneCount() - 1.
  • x number — The lane's .x.
  • y number (optional) — The lane's .y. Defaults to 0.
  • z number (optional) — The lane's .z. Defaults to 0.
  • w number (optional) — The lane's .w. Defaults to 0.
-- One dissolve material, each subject at its own progress.
local DISSOLVE_LANE = 0
for _, subject in ipairs(dying) do
renderer.instanceData.set(subject.entity, DISSOLVE_LANE, subject.progress)
end

globals/renderer/loseDevice

renderer.loseDevice()

Destroy the render device on the next frame, so the engine meets a real device loss.

This is the one loss that can be caused on purpose, and it travels the same path a driver reset does: frames draw nothing until the rebuild lands, GET /engine/status reports the renderer as recovering while it does, engine.onDeviceRebuilt fires afterwards, and renderer.deviceGeneration() moves. Use it to prove that a world's content survives a device loss — anything it holds only on the GPU has to be remade from the rebuild hook, and this is how you find out whether it is.

renderer.loseDevice()
-- some frames later:
print(renderer.deviceGeneration()) -- one higher than before

globals/renderer/mainCameraView

renderer.mainCameraView() -> { number }?

The main camera's inverse view-projection (column-major, 16 numbers) followed by its world position (3 numbers) — {m0..m15, px,py,pz} — for reconstructing world positions from the depth buffer in a ray-tracing pass. Nil before the first render.

Returns { number }? 19 numbers, or nil.

globals/renderer/material/animatedTexture

renderer.material.animatedTexture(texture: string | AssetRef, opts: { [string]: any }?) -> MaterialHandle

Build a material that PLAYS a layered texture: its layers bound as the frames, its timing bound beside them, and the engine's animatedTexture shader turning the clock into the layer showing now. One call from an imported animated image to a material an entity can wear.

The layer showing is resolved per pixel against the texture's own schedule, so frames of unequal length are shown for the lengths they were authored with, and the sequence loops. speed scales the clock (2 plays twice as fast, 0 holds the frame startTime lands in) and startTime offsets into the sequence, so two surfaces sharing one texture can run out of phase.

The clock is the engine's, and it runs in edit mode as much as in play and through a pause, so two screenshots of one surface taken moments apart are two different frames of it. speed = 0 holds one frame for as long as it is set, which is the state to compare two screenshots in.

The returned handle is what a surface wears — Model:applySessionMaterial takes it, and so does a Model's material field. The handle's guid is this material's REGISTRY KEY, the currency of setProperty, describe and destroy; a component field resolves an asset, so a bare key in one leaves the component waiting for an asset to register under that name.

The builtin plane mesh emits uv = (u, v) with v along its own +Z, so a quad pitched +90° about X (Transform.eulerToQuat(0, math.pi / 2)) shows the image upright to a camera on +Z, and -90° shows it first-row-last.

A texture whose layers carry no timing is rejected — there is nothing to play. renderer.texture.info(bytes).animated is the test.

Parameters

  • texture string | AssetRef — The texture — a guid, an identity, a name, a path, or a texture AssetRef.
  • opts { [string]: any } (optional){ key?, speed?, startTime?, alphaCutoff?, baseColor?, uvScale?, uvOffset? }.

Returns MaterialHandle

local mat = renderer.material.animatedTexture("banner.texture")
local id = entity.spawn("billboard", { rotation = { Transform.eulerToQuat(0, math.pi / 2) } })
entity(id).component.add("Model", { model = "plane" })
entity(id).component.get("Model"):applySessionMaterial(mat)
renderer.material.setProperty(mat.guid, "speed", 2)

globals/renderer/material/create

renderer.material.create(content: MaterialContent, key: string) -> MaterialHandle

Parameters

  • content MaterialContent
  • key string

Returns MaterialHandle

globals/renderer/material/describe

renderer.material.describe(key: string | { [string]: any } | AssetRef) -> any

The recoverable definition ({ shader, properties, textures, name }) this module registered under key via renderer.material.create, or nil for keys registered elsewhere (e.g. material assets resolved by the assetType). properties and textures carry the material's current values: each setProperty / setTexture write lands on this record, a texture slot under the GPU key the slot binds by — these are the WRITES, held here whether or not the renderer took them up. renderer beside them is what the renderer holds for the same key: the program its prepared bind group was built against, the render state its draws are looked up under, whether a pipeline exists for that key, and how many draws the observed frame gave it. renderer is nil when the renderer holds no material under this key at all, and resident states the same fact as a boolean. Writes reach the screen through both halves: resident = false says the renderer holds nothing to put them in, and renderer.draws = 0 on a resident material says it holds them and no renderable is drawing with it. For a material that is resident AND drawn and still looks wrong, renderer.drawDiagnostics() names the renderable and the cause.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.

Returns anyMaterialContent? with resident: boolean and renderer: MaterialObservation? fields

globals/renderer/material/destroy

renderer.material.destroy(key: string | { [string]: any } | AssetRef) -> boolean

Drop a runtime material registered via renderer.material.create: clears its recoverable definition, unregisters its runtime-resource stamp so it is no longer swept into the material freeze/save flow, and frees the GPU record. Use for transient materials (e.g. a preview swatch) that must not outlive their use. The on-disk asset, if any, is untouched.

The reach is the registry: after this, describe and list stop answering for the key. A surface already wearing the handle goes on drawing what it was given — Model:restoreSessionMaterial is what puts a Model back on its authored material.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key (the one passed to create), the MaterialHandle create returned, or an AssetRef from asset.resolve.

Returns boolean true when a definition was known under key.

renderer.material.destroy("__preview_swatch_" .. texGuid)

globals/renderer/material/list

renderer.material.list() -> { any }

Every runtime material currently registered, ordered by registry key. Each entry carries the key, where it came from, and the shader it binds. renderer.references("material", key) says what is still holding a row, and renderer.collect() releases the rows nothing holds.

Returns { any } — Array of { guid, origin, owner?, shader? }.

for _, m in ipairs(renderer.material.list()) do print(m.guid, m.shader) end

globals/renderer/material/renderState

renderer.material.renderState(key: string | { [string]: any } | AssetRef) -> MaterialObservation?

What the renderer holds for a material, which is a different document from the values written to it. shader is the program its prepared bind group was built against, renderState the blend / cull / topology / queue / depth key its draws are looked up under, keyBuilt whether a pipeline exists for that key, and draws / instances / placeholderDraws / binds / bindsElided what it cost in the frame the renderer last observed — those five read 0 until something arms per-draw recording, which renderer.materialCost() and renderer.drawDiagnostics() do. nil means the renderer holds no material under this key at all — the writes landed on a record nothing is drawing with.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.

Returns MaterialObservation?

local r = renderer.material.renderState("water"); print(r.renderState.blend, r.keyBuilt)

globals/renderer/material/sessionKeyFor

renderer.material.sessionKeyFor(entityId: string) -> string

The canonical registry key for an entity's SESSION material — the runtime material a system (e.g. GI baking) shows on an entity in place of its authored material for the lifetime of the engine session. One session material per entity: create it under this key, hand the handle to Model:applySessionMaterial, and the component re-adopts it across VM reloads by probing this key with describe. The key names the entity for as long as the entity stands: once it is gone the session store lets the handle go, and a collection releases the material and whatever its bindings were the last to hold.

Parameters

  • entityId string — The entity carrying the material.

Returns string — The registry key string.

local key = renderer.material.sessionKeyFor(entityId)

globals/renderer/material/setProperty

renderer.material.setProperty(key: string | { [string]: any } | AssetRef, name: string, value: any?) -> ()

Push one changed uniform property to a registered material's GPU record (frame-fast incremental update; no re-register). Keyed by the material's registry key. The value written becomes the material's current one: it is what describe reports, and — for a property the material's shader declares, which is what the uniform buffer is packed by — what a material AssetRef reads back through getProperty / getProperties and what the surface is drawn with. A write under any other name reaches the record describe reports, which is where it reads back.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.
  • name string — Property name.
  • value any (optional) — New value.

Returns ()

renderer.material.setProperty(asset.resolve("wall", "material"), "roughness", 0.2)

globals/renderer/material/setTexture

renderer.material.setTexture(key: string | { [string]: any } | AssetRef, slot: string, ref: string | { [string]: any } | AssetRef) -> ()

Push one changed texture slot to a registered material's GPU record. Keyed by the material's registry key.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.
  • slot string — Texture slot name ("base_color_texture", …).
  • ref string | { [string]: any } | AssetRef — Texture reference — a .texture guid / identity / name / path, the image path it was imported from, a color: / default: form, a live GPU handle, or a texture AssetRef carrying one. An asset reference is materialised (Disk→CPU→GPU) and bound by the key the upload lands under.

Returns ()

renderer.material.setTexture("sky", "sky_texture", "panorama.texture")

globals/renderer/materialCost

renderer.materialCost() -> { MaterialObservation }

What each material cost the frame the renderer last drew, and the state it holds each one under. One row per material the renderer holds a prepared bind group for — a material an author wrote and the renderer never prepared is absent, which is itself the answer to "why is nothing I set reaching the screen". draws and instances cover that one frame; placeholderDraws is how many of those draws bound the magenta placeholder instead of this material's own program; binds is how many material-owned bind groups the frame's passes SET for it and bindsElided how many of its draws wanted a group the pass already held, which is what draw-key sorting buys; a draw that fell back to the placeholder bound the placeholder's group, so it counts in placeholderDraws and in neither bind count. uniformBytes is the GPU uniform buffer's own size, which is the reflected property block raised to the 16-byte floor and rounded up to the copy alignment. renderer.drawDiagnostics() names WHICH renderable is not drawing what its material says, and why.

Returns { MaterialObservation }

for _, m in renderer.materialCost() do print(m.material, m.draws, m.binds, m.bindsElided) end

globals/renderer/materialIdentity

renderer.materialIdentity() -> MaterialIdentity

Which material each renderable draws with, as a number a shader can carry. A material is authored and bound by name, and no shader can read a string — so every renderable's per-instance record holds a material index instead. slots is the name → index table those indices are drawn from: an index is assigned the first time the renderer draws with that material and does not move afterwards, so two renderables that differ only in material read different indices, and one renderable reads the same index frame after frame. It follows that the table keeps a row for every material name drawn this session, whether or not anything still draws with it. renderables is a row per renderable that owns a GPU slot — the entity it belongs to, that slot, and the index the record at it carries; populations is the same for an instanced draw, whose whole reserved run of slots carries the one material its registration named. That index is what a shader reads as instance_data[slot].material_index, and the row a ray hit resolves through zeroMaterial(). A renderable draws with the material its entity references, so one whose entity names none carries index 0.

Returns MaterialIdentity{ slots: { [string]: number }, renderables: { { entity: string, slot: number, index: number, material: string } }, populations: { { slot: number, count: number, index: number, material: string } } }

local id = renderer.materialIdentity()
for _, r in id.renderables do print(r.entity, r.slot, r.index, r.material) end

globals/renderer/materialIndex

renderer.materialIndex(name: string) -> number?

The index standing for a material, or nil for one the renderer has not drawn with yet. Pass it to a shader (or compare it against what a shader read out of instance_data[slot].material_index) to tell which material a drawing instance carries.

Parameters

  • name stringstring Material name, as renderer.material.create filed it.

Returns number?

local red = renderer.materialIndex("brick_red")

globals/renderer/maxAnisotropy

renderer.maxAnisotropy() -> number

The highest anisotropy this device honours: 16 on hardware that filters anisotropically, 1 on hardware that does not, where a higher request would be downgraded to trilinear regardless. Read it to report quality honestly — renderer.setAnisotropy clamps for you, so a request never needs guarding.

Returns number — The device ceiling, 1 or 16.

local best = renderer.maxAnisotropy()

globals/renderer/mesh/boundsSource

renderer.mesh.boundsSource(mesh: string | { [string]: any } | AssetRef) -> string

Where this mesh's culling bounds come from. "compute" once a compute pass has written its vertices: the engine reduces those vertices to an AABB every frame, so the mesh is culled against the geometry the pass produced wherever it puts it. "geometry" otherwise: the AABB of the geometry the mesh was created with.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns string"compute" or "geometry".

print(renderer.mesh.boundsSource(mesh))

globals/renderer/mesh/buildClusters

renderer.mesh.buildClusters(mesh: string | { [string]: any } | AssetRef) -> string?

Build a cluster-LOD DAG (Nanite-style virtualized geometry) for the static CPU mesh held under guid and return its serialized data.clusters bytes. Returns nil when the mesh is degenerate, and on an engine whose renderer.mesh.canBuildClusters reports false. Pair with renderer.mesh.uploadClusters.

Parameters

  • mesh string | { [string]: any } | AssetRef — A mesh loaded into the CPU store (renderer.mesh.loadCpu) — the MeshCpuHandle, a MeshHandle, a guid, or a mesh AssetRef.

Returns string? — Serialized cluster bytes, or nil.

local cb = renderer.mesh.buildClusters(cpu)

globals/renderer/mesh/canBuildClusters

renderer.mesh.canBuildClusters() -> boolean

Whether this engine bakes cluster-LOD hierarchies. It reads the binding the running engine registered: every target the engine ships on carries the builder, so a mesh loaded in a browser bakes its own clusters the same way one loaded natively does, and an engine built without it reports false and answers nil from renderer.mesh.buildClusters.

Returns boolean — True if renderer.mesh.buildClusters can bake on this platform.

if renderer.mesh.canBuildClusters() then ... end

globals/renderer/mesh/clusterBakeBudget

renderer.mesh.clusterBakeBudget(ms: number?) -> number

The wall time one frame may spend advancing scheduled cluster bakes, in milliseconds — set first when ms is given. A slice always runs at least one unit of the build, so the budget bounds what a frame spends by choice and the largest single unit a mesh imposes sets the floor under it.

Parameters

  • ms number (optional) — New per-frame budget in milliseconds, capped at 1000. A value that is not a positive, finite number raises.

Returns number — The budget in force after the call.

renderer.mesh.clusterBakeBudget(2)

globals/renderer/mesh/clusterBakes

renderer.mesh.clusterBakes() -> { [string]: any }

What the scheduled cluster bakes are costing. budgetMs is the slice a frame may spend, pending how many bakes are queued, completed how many have finished since the engine started, dropped how many left the queue because the geometry they were scheduled over stopped being readable, and heldBytes the source geometry the queue is holding across all of them — the vertex pool and index run the bake at the head is reading, plus a copy for each queued mesh the engine holds no definition for. inFlight is one row per queued bake — { guid, cpuMs, frames, slices, bytes, state }: the wall time spent advancing it, the frames it has been queued for, the slices it has been advanced by, the geometry it is holding, and "baking" for the one being advanced against "queued" for the ones waiting their turn.

Returns { [string]: any }{ budgetMs, pending, completed, dropped, heldBytes, inFlight }.

print(renderer.mesh.clusterBakes().heldBytes)

globals/renderer/mesh/clusterComponents

renderer.mesh.clusterComponents(clusterBytes: buffer | string) -> (ClusterComponents?, string?)

Split a cluster blob (from renderer.mesh.buildClusters) into its GPU-ready component byte pools — the cluster vertex pool, the geometry-addressing pool (every cluster's local→global vertex map, then every cluster's triangle bytes), and the per-cluster record array — plus their counts. A cluster's triangles address positions inside its own vertex map one byte at a time, and a record's vertexOffset indexes the geometry pool in u32 elements while its indexOffset indexes it in bytes, so ONE binding resolves a corner. A pure decode (no GPU work): upload the pools into buffers a compute shader owns (shaderRef:createBuffer + buf:writeBytes) to drive a cluster draw from Luau.

Parameters

  • clusterBytes buffer | string — Serialized cluster bytes (binary-safe).

Returns (ClusterComponents?, string?){ vertices, geometry, records, vertexCount, vertexRefCount, triangleBytes, indexCount, clusterCount }, or (nil, err).

local c = renderer.mesh.clusterComponents(cb)

globals/renderer/mesh/clusters

renderer.mesh.clusters(mesh: string | { [string]: any } | AssetRef) -> { [string]: any }?

The shape of the cluster-LOD hierarchy the renderer holds for a mesh: clusterCount across every level, levelCount with the finest counted as one, and triangleCount across every cluster. The renderer keys one entry per mesh that carries a hierarchy, so this answers whether the mesh has clusters as well as what they are — nil for a mesh that carries none.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to read — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns { [string]: any }?{ clusterCount: number, levelCount: number, triangleCount: number }?

local c = renderer.mesh.clusters(gpu) ; if c then print(c.levelCount) end

globals/renderer/mesh/create

renderer.mesh.create(src: any?, guid: string?) -> MeshHandle

Create (or fetch) a GPU mesh resource and return its MeshHandle. src: a MeshCpuHandle from meshRef:load() (CPU→GPU upload under the asset's guid, idempotent — returns the resident handle if already uploaded); raw geometry {positions, indices, normals?, uvs?, colors?, uvs1?, unwrapUvs?, tangents?, skinning?, skins?} (a new runtime mesh — uvs1 is the lightmap UV set, unwrapUvs generates one, skinning/skins bind a skeleton); GPU compute buffers {vertexBuffer, indexBuffer, vertexCount, indexCount, aabbMin?, aabbMax?, prevVertexBuffer?} (size the vertex buffer at vertexCount * engine.vertexStride bytes, the engine's standard Vertex layout); or a MeshHandle (returned as-is). NEVER takes an AssetRef — load the CPU first.

prevVertexBuffer is a second buffer of the same size and layout holding those vertices as they stood on the previous frame. Naming it is what makes geometry a compute pass moves report a motion vector: the surface differences the two streams, so every consumer of screen-space velocity — motion blur, temporal reprojection — sees the movement. The engine fills it from the current vertices once per frame, ahead of that frame's compute dispatches, so a frame in which the pass does not run leaves the two streams equal and the geometry reports standing still.

morphTargets are the shapes the mesh can blend towards: a list of { name?, positions, normals? } records, each holding one offset per vertex from the base geometry, in the mesh's own vertex order. An entity blends them with ecs.MorphWeights, weight i scaling target i. A name makes the shape addressable as itself — renderer.mesh.morphTargets reads the names back and renderer.mesh.morphWeights drives them by name.

Raw geometry is read against the mesh type's conventions: indices count vertices from 0, and a triangle's FRONT face is the one whose vertices turn counter-clockwise as the viewer sees them — cross(v1 - v0, v2 - v0) points out of it. A material culls its back faces by default, so a triangle wound the other way draws nothing where it stands; reverse the index triple, or give the material render = { cull = "none" }, to draw that side. normals give the surface its outward direction and shade the face; the side that draws comes from the index order alone. uvs sample (0,0) at the image's top-left. Model space carries the world's basis: +X right, +Y up, -Z the direction transform.forward points. guides { path = "types/mesh" } has the whole table. A geometry src carrying keepCpu = true also keeps its geometry in the guid-keyed CPU store, so renderer.mesh.getVertices reads it and renderer.mesh.setVertices rewrites its positions in place — the per-frame deformation path, which sends positions alone where renderer.mesh.update re-sends the whole geometry. renderer.mesh.unloadCpu(mesh) releases that copy. Without it the geometry lives on the GPU alone and renderer.mesh.readback(mesh) is what brings it back.

Parameters

  • src any (optional) — A MeshCpuHandle, geometry, compute buffers, or a MeshHandle.
  • guid string (optional) — Optional v4 guid for a NEW runtime mesh (minted Luau-side when absent). Ignored for the CPU-handle path (the asset's guid is used).

Returns MeshHandle

local gpu = renderer.mesh.create(meshRef:load())
local gpu = renderer.mesh.create({ positions = {...}, indices = {...} })
-- a runtime mesh whose positions are rewritten in place each frame
local m = renderer.mesh.create({ positions = P, indices = I, keepCpu = true })
renderer.mesh.setVertices(m, P)
-- a runtime mesh carrying a lightmap UV set (unwrapped at creation)
local gpu = renderer.mesh.create({ positions = {...}, indices = {...}, unwrapUvs = true })
-- a mesh with one shape to blend towards, driven by ecs.MorphWeights
local gpu = renderer.mesh.create({ positions = P, indices = I, morphTargets = { { positions = D } } })

globals/renderer/mesh/decode

renderer.mesh.decode(zmsh: buffer | string) -> (MeshGeometry?, string?)

Decode engine-native ZMSH bytes back into a MeshGeometry. Inverse of renderer.mesh.encode; each optional stream is present only when the blob carries it. Takes the bytes themselves — the geometry of a mesh the engine is holding comes from renderer.mesh.geometry(mesh).

Parameters

  • zmsh buffer | string — Engine-native ZMSH bytes (binary-safe).

Returns (MeshGeometry?, string?) the geometry, or (nil, errmsg).

local geom = renderer.mesh.decode(meshRef:getBytes())

globals/renderer/mesh/destroy

renderer.mesh.destroy(mesh: string | { [string]: any } | AssetRef) -> boolean

Release the GPU mesh mesh names, the release that pairs with renderer.mesh.create. Takes every form that names a mesh — the MeshHandle create returned, the guid renderer.mesh.list hands out, a MeshCpuHandle or a mesh AssetRef — and routes through renderer.destroy, the verb that releases any renderer resource by its kind. The CPU copy, if one was loaded, is freed separately by the CPU handle's :unload().

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to release — a MeshHandle, a guid, a MeshCpuHandle or a mesh AssetRef.

Returns boolean true if a GPU mesh was known under the guid.

local m = renderer.mesh.create(cpu); renderer.mesh.destroy(m)
renderer.mesh.destroy(renderer.mesh.list()[1].guid)

globals/renderer/mesh/drawInstanced

renderer.mesh.drawInstanced(mesh: string | { [string]: any } | AssetRef, opts: any?) -> InstancedDraw

Draw one mesh instanceCount times in a single call, each copy placed by a world matrix read from a GPU buffer. The population is a renderable in its own right — it goes through the mesh's ordinary pipeline and the material's ordinary bind groups, so it appears in the deferred pass, the forward passes and the shadow maps exactly as an entity-backed draw of that mesh does.

The buffer holds instanceCount column-major 4x4 matrices, 64 bytes each, tightly packed — the layout a vertex shader reads as array<mat4x4<f32>>, which puts each matrix's translation in its LAST four floats (Lua indices 13/14/15 for x/y/z). Packing row-major transposes every instance.

The matrices are COPIED into the engine's transform slots once per frame, which is what buys that full-pass parity. Rewrite the buffer between frames and the instances move — no re-registration, no re-upload.

material is what the population draws with, and it is required: a MaterialHandle (matRef:handle()), an AssetRef, or a registry key.

instanceDataBuffer names a second buffer, holding 64 bytes per instance — four vec4 lanes, tightly packed, in instance order. Those lanes arrive in the fragment stage as zero_object_data(in.instance_id, lane), the same read a per-entity __instancedata block answers, so the members of one population can differ in whatever their material's shader agrees the lanes carry. Copied every frame like the transforms, from a buffer a compute pass writes: the values never touch the CPU. Omit it and the lanes read zero.

reserveCount sizes the reservation above instanceCount so renderer.mesh.setInstanceCount can raise the drawn count later without re-registering; both buffers must back the reservation, not just the count.

mobility states whether the copies stand still — "static", or "movable" when it is left out. It is what a scene gather collecting geometry for precomputed lighting admits a population on, the same declaration Model.mobility makes for an entity: the transforms live in a buffer anything may rewrite between frames, so a population that says nothing is taken as one that moves.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh the population draws — a MeshHandle, the guid renderer.mesh.list hands out, a MeshCpuHandle or a mesh AssetRef. A registration holds the mesh on the device for as long as it lives, and takes a mesh that is currently held off the device — one nothing displays — back onto it.
  • opts any (optional){ transformBuffer, instanceCount, material, instanceDataBuffer?, reserveCount?, renderLayer?, castsShadows?, mobility? }.

Returns InstancedDraw — An InstancedDraw handle for instanceInfo / setInstanceCount / dropInstanced.

local m = renderer.mesh.create({ positions = ..., indices = ... })
local buf = substrate.createBuffer({
name = "crowd.xf", type = "mat4", len = 64, kind = "gpu",
})
-- Column-major: translation lives at indices 13/14/15.
local xf = {}
for i = 0, 63 do
local m4 = { 1,0,0,0, 0,1,0,0, 0,0,1,0, i * 2, 0, 0, 1 }
for _, v in ipairs(m4) do xf[#xf + 1] = v end
end
buf:write(xf)
local rock = asset.resolve("rock", "material"):handle()
local draw = renderer.mesh.drawInstanced(m, { transformBuffer = "crowd.xf", instanceCount = 64, material = rock })

globals/renderer/mesh/dropClusters

renderer.mesh.dropClusters(mesh: string | { [string]: any } | AssetRef) -> boolean

Detach a mesh's cluster-LOD hierarchy and cancel a bake still in flight for it, so the renderer holds none for it. The inverse of renderer.mesh.uploadClusters.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to detach — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns boolean — True if a hierarchy was attached or a bake was in flight.

renderer.mesh.dropClusters(gpu)

globals/renderer/mesh/dropInstanced

renderer.mesh.dropInstanced(draw: InstancedDraw) -> boolean

Release an instanced-draw registration and the transform slots it reserved. The mesh and the transform buffer outlive it — destroy those through renderer.destroy and the buffer handle's :destroy().

Parameters

  • draw InstancedDraw — The InstancedDraw to release.

Returns boolean — True if a registration was live under the handle.

renderer.mesh.dropInstanced(draw)

globals/renderer/mesh/encode

renderer.mesh.encode(geom: MeshGeometry) -> (string?, string?)

Encode raw geometry into engine-native ZMSH bytes (the on-disk mesh payload). The CPU codec behind the mesh assetType's onCreate. Every stream the format carries — including tangents, per-vertex skinning, and the skeleton — round-trips back through renderer.mesh.decode. This pair moves DATA the caller is holding; the geometry of a mesh the ENGINE is holding comes from renderer.mesh.geometry(mesh).

Parameters

  • geom MeshGeometryMeshGeometry — flat per-vertex float / u32 arrays plus optional skinning and skins.

Returns (string?, string?) engine-native ZMSH bytes (binary-safe), or (nil, errmsg) naming what the geometry could not describe.

local bytes = renderer.mesh.encode({ positions = {...}, indices = {...} })
local bytes = renderer.mesh.encode(renderer.mesh.geometry(meshHandle))

globals/renderer/mesh/encodeCpu

renderer.mesh.encodeCpu(mesh: string | { [string]: any } | AssetRef) -> string

Encode a mesh's resident CPU copy into ZMSH bytes. Reads the ONE guid-keyed CPU store — meshRef:load() populates it for assets, and renderer.mesh.readback(mesh) populates it for a runtime mesh. Errors loudly when the mesh has no resident CPU copy.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns string ZMSH bytes.

local bytes = renderer.mesh.encodeCpu(handle)

globals/renderer/mesh/geometry

renderer.mesh.geometry(mesh: string | { [string]: any } | AssetRef) -> MeshGeometry

The complete geometry of a mesh the engine is holding, as a MeshGeometry — the same shape renderer.mesh.create and renderer.mesh.encode take, carrying every stream the mesh has (positions, indices, and whichever of normals, uvs, colors, uvs1, tangents, skinning, skins it was built with). The read that pairs with create: hand it the MeshHandle create returned and get the vertex data back. Reads the resident CPU copy when there is one; for a runtime mesh that lives only on the GPU it reads the geometry back off the GPU first (yielding a frame or two) and leaves CPU residency as it found it. An optional stream is present only when the mesh carries one, so uvs1 == nil is the answer to whether it has a second UV set. The drawable mesh the renderer holds carries the tangent basis its positions, uvs and normals determine — supplied by the caller, or derived at the ingest that made it drawable — and that is what the GPU read gives back. The CPU store answers with the streams the bytes it decoded hold, so a .mesh written without a tangent stream reads back tangents == nil for as long as a CPU copy of it is resident.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns MeshGeometry

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

globals/renderer/mesh/getVertices

renderer.mesh.getVertices(mesh: string | { [string]: any } | AssetRef) -> { any }

Read the vertices of a mesh's resident CPU copy — one entry per vertex, { pos = {x,y,z}, normal = {x,y,z}, uv = {u,v} }. Reads the resident CPU store directly (no re-decode). Errors when the mesh has no resident CPU copy — renderer.mesh.geometry(mesh) is the read that works wherever the mesh lives, and returns the tangent, colour and skinning streams too.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns { any }{ { pos: {x,y,z}, normal: {x,y,z}, uv: {u,v} }, ... } Each record carries pos, normal, uv and uv1 as named channels ({ x = , y = , z = }). Geometry going the other way — into renderer.mesh.create — is parallel flat arrays (positions, normals, uvs), and create accepts this record list under vertices so a mesh read back here can go straight into a new one.

globals/renderer/mesh/instanceInfo

renderer.mesh.instanceInfo(draw: InstancedDraw) -> InstancedDrawInfo?

What a live instanced-draw registration is drawing: which mesh, which transform buffer, which per-instance data buffer if it named one, how many instances, and how many slots it reserved. Returns nil once the registration has been dropped.

status is what the renderer did with it. The fields above it are the request, made a stage before the renderer sees it; status is the answer: "drawing" for a registration the renderer is drawing, "refused" for one it turned away — error carries its reason — and "pending" for the frame between the call and the renderer answering. So a registration whose copies are not being drawn says so here.

Parameters

  • draw InstancedDraw — The InstancedDraw to report on.

Returns InstancedDrawInfo? — The registration record, or nil.

print(renderer.mesh.instanceInfo(draw).instanceCount)
local info = renderer.mesh.instanceInfo(draw)
if info.status == "refused" then error(info.error) end

globals/renderer/mesh/instanceTransforms

renderer.mesh.instanceTransforms(draw: InstancedDraw) -> any

Read back the world matrices a registration's drawn copies are placed by: instanceCount matrices of 16 floats, column-major and tightly packed, in the layout the transform buffer holds them. The read is of the buffer as it stands when it runs, so a population a compute pass rewrites every frame answers with the placement of the frame the read lands in.

Parameters

  • draw InstancedDraw — The InstancedDraw whose copies to locate.

Returns any — A Readback to poll — :ready() then :result() — or nil for a registration that is no longer live.

local pending = renderer.mesh.instanceTransforms(draw)
while not pending:ready() do task.wait() end
local floats = pending:result()

globals/renderer/mesh/isCpuResident

renderer.mesh.isCpuResident(mesh: string | { [string]: any } | AssetRef) -> boolean

True if this mesh has a resident CPU copy in the guid-keyed CPU store.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns boolean

if renderer.mesh.isCpuResident(handle) then ... end

globals/renderer/mesh/isResident

renderer.mesh.isResident(mesh: string | { [string]: any } | AssetRef) -> boolean

True if a GPU mesh is resident under this mesh's guid — the device holds its buffers, or the upload pass is still going to hand them over. This is the store the draw paths are gated on, so a mesh this reports resident is one renderer.mesh.drawInstanced and a Model can draw.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns boolean

print(renderer.mesh.isResident(handle))

globals/renderer/mesh/list

renderer.mesh.list() -> { any }

Every mesh currently registered, ordered by guid — the ones a script created and the ones that reached the device through an asset alike. Answers "which mesh is this?" when all that is known is a size: each entry carries the guid, the vertex/index counts it was created with, where it came from (origin is "asset" for a mesh the asset path uploaded), whether the GPU still holds it, and where its culling bounds come from (boundsFrom is "compute" for a mesh a compute pass writes). A resident entry also carries the bytes its buffers cost. bytes is the mesh's whole VRAM footprint and is the sum of the THREE buffer columns beside it — vertexBytes + vertexStorageBytes + indexBytes, where the storage column is the same vertices bound as a storage buffer for the passes that read them that way. Summing only the vertex and index columns understates a mesh by its vertex size. The bytes column is what sums to the meshes category of renderer.gpuMemory(). renderer.references("mesh", guid) says what is still holding a row, and renderer.collect() releases the rows nothing holds.

Returns { any } — Array of { guid, vertexCount?, indexCount?, origin, owner?, resident, boundsFrom, bytes?, vertexBytes?, vertexStorageBytes?, indexBytes?, primitives?, revision? }.

for _, m in ipairs(renderer.mesh.list()) do print(m.guid, m.bytes) end

globals/renderer/mesh/listInstanced

renderer.mesh.listInstanced() -> { InstancedDrawInfo }

Every instanced-draw registration this engine is drawing, in registration order. Each record is what instanceInfo answers with, and carries a draw handle of its own — so a population whose handle its caller no longer holds is reached here and released, resized or read like any other.

Returns { InstancedDrawInfo } — An array of registration records; empty when nothing is registered.

for _, pop in ipairs(renderer.mesh.listInstanced()) do
renderer.mesh.dropInstanced(pop.draw)
end

globals/renderer/mesh/loadCpu

renderer.mesh.loadCpu(ref: string | AssetRef) -> MeshCpuHandle

Load a .mesh asset's geometry into the ONE guid-keyed CPU store (the Disk→CPU step) and return a CPU handle. The handle holds NO geometry — only the guid plus counts and the per-handle read/encode/unload ops (which read the Rust-side store). Called by meshRef:load(). DEFAULT lifecycle: upload to the GPU then handle:unload(); the store is populated only by this call.

Parameters

  • ref string | AssetRef — A mesh AssetRef (carries .guid and reads its primary via getBytes), or any string asset.ref resolves to one — the guid encodeCpu takes, an identity, a name or a source path.

Returns MeshCpuHandle

local cpu = meshRef:load(); local gpu = renderer.mesh.create(cpu); cpu:unload()
local cpu = renderer.mesh.loadCpu(gpuMesh.guid)

globals/renderer/mesh/morphTargets

renderer.mesh.morphTargets(mesh: string | { [string]: any } | AssetRef) -> { string }

The names of the shapes this mesh blends towards, in the order an entity's ecs.MorphWeights addresses them — weight i drives the target named at i. An imported model carries the names its source file gave its blend shapes, so content drives a face by the shape it means rather than by the ordinal that shape happened to import at (which moves when the model is re-exported). A target the source never named reads as an empty string.

Empty for a mesh with no morph targets. Errors when the mesh is neither GPU- nor CPU-resident — materialise it first (meshRef:handle()).

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

Returns { string } one name per morph target, in target order.

for i, name in renderer.mesh.morphTargets(meshRef) do print(i, name) end

globals/renderer/mesh/morphWeights

renderer.mesh.morphWeights(mesh: string | { [string]: any } | AssetRef, weights: { [string]: number }) -> { number }

Turn weights named by shape into the ordered weight array ecs.MorphWeights takes — the drive-a-face-by-name call. Every target the mesh carries gets a slot; the ones weights names take their value and the rest are 0, so the returned array always describes the whole mesh and a shape left out is a shape at rest.

A name the mesh does not carry is an error listing the names it does: a mistyped viseme that silently moved nothing would be indistinguishable from a rig that never had it.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
  • weights { [string]: number }{ [string]: number } — how strongly to blend each named shape.

Returns { number } one weight per morph target, in target order.

local w = renderer.mesh.morphWeights(meshRef, { Eyes_Blink = 1, Mouth_Smile = 0.4 })
ecs.set(face, ecs.MorphWeights { weights = w })

globals/renderer/mesh/readback

renderer.mesh.readback(mesh: string | { [string]: any } | AssetRef) -> MeshCpuHandle

Read a runtime GPU mesh's geometry back to CPU and return a MeshCpuHandle for it — the GPU→CPU half of the runtime-mesh freeze path. A mesh made with renderer.mesh.create keeps no CPU copy, so persisting it (:encode()asset.create("mesh", …)) reads it back here first. Yields until the readback completes (a frame or two). After it returns the geometry is resident in the guid-keyed CPU store: :getTriangles, :getVertices, :getBounds, :geometry, :encode, :unload all work. Errors if the mesh never becomes resident in the vertex pool.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — the MeshHandle renderer.mesh.create returned, a guid, or a mesh AssetRef.

Returns MeshCpuHandle

local cpu = renderer.mesh.readback(handle); local zmsh = cpu:encode(); cpu:unload()

globals/renderer/mesh/readbackPosed

renderer.mesh.readbackPosed(requests: { { entity: string, mesh: any } }) -> { [string]: MeshCpuHandle }

Read the POSED geometry of skinned entities back to CPU: for each request, the vertices the skinning pass wrote for that entity this frame, joined by the indices of the mesh it is posed from. A skinned surface's world-space triangles are produced on the GPU from the entity's joint matrices, so the mesh asset holds the bind pose and only this reads where the surface actually is. The posed vertices are in model space, so the entity's own world transform still places them — the same transform the raster draw uses.

Takes a LIST and answers a map, because the readbacks are queued together and polled together: a scene's worth of characters costs the frames of one readback rather than one entity's after another. Each posed mesh lands in the CPU store under a guid of its own, derived from the entity, so compute.buildBvh, meshcpu.* and every other guid-keyed reader takes it like any other mesh. Call handle:unload() when done with it.

An entity the map omits holds no live pose — nothing skinned it this frame, which is also what makes its draws read the source mesh, so its bind-pose geometry is what stands for it.

Parameters

  • requests { { entity: string, mesh: any } }{ { entity = <id>, mesh = <mesh> } } — the entity to read, and the mesh it is posed from (a guid, MeshHandle or mesh AssetRef).

Returns { [string]: MeshCpuHandle } — A map from entity id to the MeshCpuHandle holding that entity's posed geometry.

local posed = renderer.mesh.readbackPosed({ { entity = id, mesh = ecs.get(id, ecs.Mesh).mesh } })
local tris = posed[id]:getTriangles()

globals/renderer/mesh/scheduleClusters

renderer.mesh.scheduleClusters(mesh: string | { [string]: any } | AssetRef) -> boolean

Queue a cluster-LOD bake for the static CPU mesh held under guid, and attach the DAG to the GPU mesh of that same guid on the frame it finishes. The CPU mesh may be unloaded on the very next line; the DAG is then built one bounded slice per frame, so a dense mesh virtualizes without the frame loop stopping for the whole bake.

One bake is advanced per frame — the one at the head of the queue — and the geometry is read on the frame a bake gets there, from the definition the engine holds for the mesh. A queue of meshes the engine holds definitions for therefore holds one mesh's geometry rather than one per mesh, whatever its depth. A mesh the engine holds no definition for is copied into the queue as it is scheduled, since the CPU store is then the only thing holding it. renderer.mesh.clusterBakes().heldBytes reports what the queue is holding, and its inFlight rows report which bakes it is holding for. This is what the .mesh assetType materialisation path uses; reach for renderer.mesh.buildClusters when you want the bytes in hand instead. Scheduling the same mesh again replaces the bake already in flight for it.

Parameters

  • mesh string | { [string]: any } | AssetRef — A mesh loaded into the CPU store (renderer.mesh.loadCpu) — the MeshCpuHandle, a MeshHandle, a guid, or a mesh AssetRef.

Returns boolean — True if a bake was queued.

renderer.mesh.scheduleClusters(cpu) ; cpu:unload()

globals/renderer/mesh/setInstanceCount

renderer.mesh.setInstanceCount(draw: InstancedDraw, count: number) -> InstancedDraw

Change how many of a registration's instances draw. Constant time — the reservation, the transform buffer and the pipeline all stay put, so this is the verb for a population whose size changes per frame. The new count must fit the reservation drawInstanced was given.

Parameters

  • draw InstancedDraw — The InstancedDraw to reconfigure.
  • count number — Instances to draw, at least 1 and within the reservation.

Returns InstancedDraw — The same InstancedDraw.

renderer.mesh.setInstanceCount(draw, visibleCount)

globals/renderer/mesh/setInstanceRenderLayer

renderer.mesh.setInstanceRenderLayer(draw: InstancedDraw, renderLayer: number) -> InstancedDraw

Change which render layers a registration's copies belong to. Constant time — the reservation, the transform buffer and the pipeline all stay put, and the next frame drawn tests the copies against the new membership. It is the verb for a population that follows something whose membership moves: a camera or a capture including the layer draws the copies, one excluding it does not.

Parameters

  • draw InstancedDraw — The InstancedDraw to reconfigure.
  • renderLayer number — The membership bitmask, the same value drawInstanced takes as renderLayer. At least one bit must be set.

Returns InstancedDraw — The same InstancedDraw.

renderer.mesh.setInstanceRenderLayer(draw, mask)

globals/renderer/mesh/setVertices

renderer.mesh.setVertices(mesh: string | { [string]: any } | AssetRef, positions: { number })

Replace a mesh's resident CPU vertex positions (flat { x,y,z, ... }) IN PLACE — indices, normals/uvs, and skinning are preserved, the AABB recomputes, and the GPU re-fetches the new geometry so it shows on screen. The positions alone travel, so this is the per-frame deformation path where renderer.mesh.update re-sends the whole geometry. The mesh must be CPU-resident: renderer.mesh.create({ ..., keepCpu = true }) keeps a copy from the start, renderer.mesh.readback(mesh) recovers one from the GPU, and meshRef:load() loads one for a .mesh asset. Errors with the reason otherwise, or when the vertex count doesn't match.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
  • positions { number } — Flat { x,y,z, ... } — one xyz per vertex; count must match the mesh.
local m = renderer.mesh.create({ positions = P, indices = I, keepCpu = true })
renderer.mesh.setVertices(m, P) -- P mutated in place each frame

globals/renderer/mesh/unloadCpu

renderer.mesh.unloadCpu(mesh: string | { [string]: any } | AssetRef)

Drop a mesh's resident CPU copy from the guid-keyed CPU store. The explicit release for a runtime geometry mesh's recoverable definition.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

globals/renderer/mesh/update

renderer.mesh.update(mesh: string | { [string]: any } | AssetRef, src: any?) -> MeshHandle

Overwrite the GPU resource mesh names IN PLACE, under the same guid, from new geometry or compute buffers. Never writes a .mesh file — the play-mode mutate path. A Model bound to the guid reflects the change with no re-bind. Takes every form that names a mesh — the MeshHandle create returned, the guid renderer.mesh.list hands out, a MeshCpuHandle or a mesh AssetRef. Returns a handle carrying the bounds the new geometry has: the handle it was given, refreshed, and a handle over the guid otherwise.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to update — a MeshHandle, a guid, a MeshCpuHandle or a mesh AssetRef.
  • src any (optional) — New geometry {positions, indices, ...} or compute buffers {vertexBuffer, indexBuffer, vertexCount, indexCount, prevVertexBuffer?}.

Returns MeshHandle — A MeshHandle for the updated mesh.

globals/renderer/mesh/uploadClusters

renderer.mesh.uploadClusters(mesh: string | { [string]: any } | AssetRef, clusters: string) -> boolean

Attach a cluster-LOD DAG (bytes from renderer.mesh.buildClusters) to the GPU mesh keyed by guid, enabling the continuous-cut cluster draw path for that mesh.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh the clusters belong to — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
  • clusters string — Serialized cluster bytes (binary-safe).

Returns boolean — True if the upload was queued.

renderer.mesh.uploadClusters(gpu, cb)

globals/renderer/minScreenSize

renderer.minScreenSize() -> number

The on-screen radius, in pixels, an object must reach to be drawn. 0 while the cutoff is off.

Returns number

local px = renderer.minScreenSize()

globals/renderer/morphStats

renderer.morphStats() -> {

The morph state the last frame drew with. A mesh carries the shapes it can blend towards and an entity carries how strongly each is blended (ecs.MorphWeights); where both are present, the vertex stage adds the weighted deltas to the base geometry.

instances is how many render slots that happened at, and blends how many single-target blends those slots carry between them: a slot contributes one per target its weights move, or that they moved the frame before, so the number of targets a mesh can be given is bounded by the buffer the blends live in. meshes is how many meshes hold a delta block and targets how many targets those blocks cover between them; deltaBytes is what the shared buffer they are appended into holds. A morph-target mesh whose weights are all zero reads a meshes above zero beside an instances and blends of zero.

Returns { instances: number, blends: number, meshes: number, targets: number, deltaBytes: number }

local m = renderer.morphStats()
print(("%d instances carrying %d blends over %d meshes"):format(m.instances, m.blends, m.meshes))

globals/renderer/observe

renderer.observe() -> RenderObservation

Everything the renderer knows about the frame it last drew: what program it bound for each renderable, the render state it holds each material under, and what each program has cost in pipeline builds. renderables is one row per renderable in the renderer's draw list, carrying the program its material named (requestedProgram) beside the one that was bound (boundProgram) — __error__ wherever the lookup missed and the draw went ahead on the magenta placeholder — plus substituted, the outcome (drew / drewPlaceholder / skipped / notDrawn), the reason that forced it and the compiler's own detail for a failed compile. observed says which of two answers a row is: true for a resolution a geometry pass took as it drew, false for the renderer's own resolution of a renderable this frame drew nowhere, which is what a renderable outside every camera's frustum or layer mask reports. materials is one row per material the renderer holds a prepared bind group for; shaders is one row per program pipelines have been built for. frame names the frame every per-frame count covers; retainedFrames how many frames a resolution a pass took is kept for after the last frame that drew it; window and costWindow state both in the document itself. Recording is armed by the first read, so this waits for the frame that first records rather than answering empty.

Returns RenderObservation

local o = renderer.observe(); print(o.placeholderDraws, "renderables drew the placeholder in frame", o.frame)

globals/renderer/occlusionCulling

renderer.occlusionCulling() -> boolean

Whether occlusion culling is currently enabled.

Returns boolean

globals/renderer/passSchedule

renderer.passSchedule() -> {

The schedule check over this frame's enqueued render passes. Passes declare what they read (inputs) and what they write (output / outputs / storage), and the frame runs them in phase order and, inside a phase, in order order. violations holds every input bound to a resource the frame produces LATER: that read samples the resource as it stands ahead of that pass, which is the previous frame's contents for a render target that persists, an empty target for one just created, and the scene draw's own output for a @scene.* buffer — and the pass renders either way. The frame's own buffers are checked on the same terms as a render target: bind @scene.motion at a phase ahead of the pass that writes it and the read is reported, naming the buffer and its writer. A pass reading a resource ahead of that write on purpose declares that slot in its enqueue's readsPrevious and drops out of the list; unboundPrevious holds declared slots the pass binds no such resource to, which cover nothing. A resource no queued pass writes is not reported — a camera rendering to texture and compute.dispatch both fill targets outside the pass queue, and the scene draw fills the @scene.* buffers every frame. A read the frame has only one order for is not reported either: where the writing pass consumes something the reading pass produces, the reader runs first or the writer has nothing to write, which is what a pass reading a buffer into a target of its own and a second pass copying that target back over the buffer forms. unreachable holds passes at a phase that does not run their kind: every phase drains its fragment and compute passes, while afterLighting is the one that draws geometry, draw and splat passes, so one of those enqueued elsewhere sits in the queue and never runs. Each finding is also stated in the engine log the first time it appears.

Returns { violations, unboundPrevious, unreachable }

local s = renderer.passSchedule()
for _, v in s.violations do print(v.message) end

globals/renderer/pipelineCache

renderer.pipelineCache() -> PipelineCache?

What the driver's compiled-pipeline store held, built, and wrote back. A pipeline is machine code the GPU driver compiles from the shader bound into it, and that compile is what a launch pays before the first frame drawing with each pipeline can appear. The store keeps that compiled code across runs, so a launch whose shaders have not changed reads back what the previous one compiled.

restoredBytes is what a previous run left for this GPU and this launch read; pipelinesBuilt counts the pipelines built since startup and buildMs is what they cost together, which is the number the store lowers. saves and savedBytes describe writing it back — deferred until a burst of builds settles, so one launch is one write — and dirty is true while pipelines have been built that the file does not hold, including after a write that failed, which lastError then names. path is the file, named after the GPU it belongs to.

supported is false where the platform holds no store a program can carry: a browser keeps its own and hands none out, and an adapter can lack the capability. reason says which, and the build count and timing still read true there. lastError names a read or write failure; a failed store costs the saved compile and never the frame, since every pipeline is built from its source either way. pipelinesBuilt and buildMs are engine-wide totals; renderer.shaderCost() is the same cost broken down per program, with each one's permutation count.

Returns PipelineCache?{ supported, reason, path, restoredBytes, pipelinesBuilt, buildMs, saves, savedBytes, dirty, lastError }, or nil before the renderer has drawn a frame

local c = renderer.pipelineCache()
print(("%d pipelines in %.1f ms, %d bytes restored"):format(c.pipelinesBuilt, c.buildMs, c.restoredBytes))

globals/renderer/pointShadowBudget

renderer.pointShadowBudget() -> PointShadowBudget

The point-light shadow pool now in force. A point light with castsShadows renders an omnidirectional cube map, six faces of depth, and slots is how many of them fit — a further caster is lit but throws no shadow, and the engine log names how many were turned away. The slot count is bought rather than authored: megabytes of VRAM at resolution texels per face is what decides it.

Returns PointShadowBudget — The pool — see PointShadowBudget.

local p = renderer.pointShadowBudget()
print(("%d shadowed point lights, %.1f MiB"):format(p.slots, p.bytes / 1024 / 1024))

globals/renderer/projectionOffset

renderer.projectionOffset() -> (number, number)

The sub-pixel projection offset in force for the main camera, in NDC.

Returns (number, number) — The x and y offset, both 0 when the projection samples pixel centres.

local ox, oy = renderer.projectionOffset()

globals/renderer/raycast

renderer.raycast(origin: vec3, direction: vec3, maxDistance: number?, exclude: (string | { string })?) -> RenderRayHit?

Cast a ray against the geometry the renderer DRAWS and return the nearest surface it meets. Every visible mesh answers, whether or not anything gave it a rigid body — so a terrain, a procedurally generated mesh, or any plain Model reports the surface at a point, which is what a camera station, a prop, a sound source or a scatter standing on the ground needs to know. The answer is the nearest triangle of the mesh, so a sloped or terraced surface reports its height where it was asked rather than the extent of its bounding box.

distance is measured from origin along the direction given, so it is a world-space distance whenever that direction is a unit vector, and it is directly comparable to a physics.raycast distance along the same ray. normal is a unit vector turned to face back along the ray. exact is true when the answer is a triangle and false when it is the object's bounding box, which is what a mesh whose vertices live only in GPU buffers answers with. The triangles are the mesh's own, placed by the entity's transform and by the mesh's bind pose, so a surface a skinning or morph pass deforms on the GPU answers as the geometry the mesh holds.

EVERYTHING drawn is in scope — the ground you meant, and equally a character standing on it, a prop, a placeholder floor. The hit names its entity in entityId, exclude steps over the ones you do not want, and renderer.raycastAll hands back the whole column so you can pick the surface yourself. A height you did not expect is usually a nearer surface you did not mean to ask about, so read entityId before trusting a number.

Parameters

  • origin vec3vec3 ray start in world space
  • direction vec3vec3 ray direction; any length, the engine normalises
  • maxDistance number (optional)number? how far the ray reaches, in world units. Default 1000
  • exclude (string | { string }) (optional)(string | { string })? entity id, or ids, to step over

Returns RenderRayHit?

local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then camera.position = { x, hit.point.y + 1.7, z } end

globals/renderer/raycastAll

renderer.raycastAll(origin: vec3, direction: vec3, maxDistance: number?, maxHits: number?, exclude: (string | { string })?) -> { RenderRayHit }

Cast a ray against the geometry the renderer draws and return every surface along it, nearest first. One entry per renderable the ray crosses — the nearest intersection with each — so a stack of surfaces reads as the order they stand in, and a caller after one particular surface finds it by entityId rather than hoping it is the nearest. Each entry carries the fields renderer.raycast returns.

Parameters

  • origin vec3vec3 ray start in world space
  • direction vec3vec3 ray direction; any length, the engine normalises
  • maxDistance number (optional)number? how far the ray reaches, in world units. Default 1000
  • maxHits number (optional)number? how many surfaces to return. Default 32
  • exclude (string | { string }) (optional)(string | { string })? entity id, or ids, to step over

Returns { RenderRayHit }

for _, hit in renderer.raycastAll(eye, look, 500) do print(hit.entityId, hit.distance) end

globals/renderer/raytraceCapability

renderer.raytraceCapability() -> string

The active ray-tracing backend: "hardware" (GPU ray query) or "compute" (software traversal — the path on devices without hardware ray query, e.g. the web). The same ray-tracing features work on both.

Returns string "hardware" | "compute"

if renderer.raytraceCapability() == "hardware" then ... end

globals/renderer/raytraceStats

renderer.raytraceStats() -> { [string]: any }

What the ray-tracing acceleration structure holds, and what this session's frames have spent building it. A ray walks a structure built over the scene's geometry, and keeping it current is work a frame pays before it traces anything. On the "compute" backend geometry that has stood still long enough is filed under a static partition the frames after it leave alone: staticTriangles + dynamicTriangles = triangles, nodes is the hierarchy over them, fullRebuilds / partialRebuilds / reusedFrames count what the session's frames did, and trianglesRebuilt is what those rebuilds re-emitted, summed. On the "hardware" backend blas is the bottom-level structures cached, blasBuilt how many the last frame built, and tlasInstances what the top-level structure names. The counters are cumulative — sample, run the scene, sample again.

Returns { [string]: any }table {backend, triangles, staticTriangles, dynamicTriangles, nodes, fullRebuilds, partialRebuilds, reusedFrames, trianglesRebuilt, blas, blasBuilt, tlasInstances}

local before = renderer.raytraceStats().trianglesRebuilt

globals/renderer/references

renderer.references(handleOrKind: any?, id: string?) -> RuntimeResourceStatus?

What holds a runtime resource right now — the answer a root scene load reads before releasing it. references names each live consumer the engine found: { by = "entity", id } for an entity wearing the material or mesh, "material" for a material whose slot names the texture, "instancedDraw", "camera", "sky", "lightmap", "ui" (a screen drawing it) and "postProcess" (an effect sampling it). handleHeld says whether a script still reaches a handle to it, assetBacked whether an asset stands behind it, ownerLive whether the component instance, scene load or feature that created it still stands, and held whether a hold pins it. origin reads "device" for a GPU texture the device holds that no script created — the one the cache loaded for an asset, the atlas the engine built — whose holders are the references, a handle and the asset. Runs a full garbage collection first, the same one renderer.collect runs, so a handle nothing reaches counts as let go and the row says what the next collection does with the resource. Yields for the frame the census runs on.

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind with the id second.
  • id string (optional) — The guid or key, when the first argument is a kind.

Returns RuntimeResourceStatus? — The resource's status, or nil for a key the registry does not record and the device holds no texture under.

local s = renderer.references(mat) for _, r in s.references do print(r.by, r.id) end

globals/renderer/reflectionEnvironment

renderer.reflectionEnvironment() -> {

What a reflective surface is reflecting. probes is how many reflection probes the shading blends; they are gathered highest priority first, each rank taking the coverage the ranks above it left, so a small interior probe ranked above the large exterior one it sits inside wins outright wherever it reaches full weight. ranks is the priority each of those probe slots was published with, in slot order. sky is whether the sky fallback is armed: with it, coverage no probe claims reflects the captured sky, and without it a surface outside every probe's radius falls back to the nearest probe alone. skyCaptured is whether the sky slot holds a capture — arming is refused until it does, since an uncaptured slot reflects black. slots is how many cube slots the environment array holds right now: the sky's alone, at index skySlot, until a probe is captured into it, then that one plus one per probe. maxProbes is how many of them probes may take, and resident whether the array has grown past the sky's single slot. Capture the sky with environment.captureSky().

Returns { probes, ranks, sky, skyCaptured, resident, slots, skySlot, maxProbes }

local env = renderer.reflectionEnvironment()
print(("%d probes, sky fallback %s"):format(env.probes, tostring(env.sky)))

globals/renderer/release

renderer.release(handleOrKind: any?, id: string?) -> boolean

Let go of the hold renderer.hold placed. The resource stays until nothing else holds it and a collection releases it — the one a root scene load runs, or a direct renderer.collect().

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind with the id second.
  • id string (optional) — The guid or key, when the first argument is a kind.

Returns boolean true when the registry knows the resource.

renderer.release(tex)

globals/renderer/renderTargetLimits

renderer.renderTargetLimits() -> {

The size a render target may be on this device. maxDimension is the device's own maximum 2D texture dimension — the largest either side of a render target may take. maxPixels is how many pixels one render target may hold, so the RGBA8 image it reads back as fits in a single buffer on every platform the engine runs on, and maxSquare is the largest square that budget buys. A capture, a renderer.texture.create({ width, height }) or a render-to-texture camera past either bound is refused at the call with the reason, so ask here for the size to request.

Returns { maxDimension, maxPixels, maxSquare }

local lim = renderer.renderTargetLimits()
local w = math.min(want, lim.maxSquare)

globals/renderer/renderTargets

renderer.renderTargets() -> {

Every render target the renderer owns and what each one costs, measured from the texture that is allocated. One row per target, each carrying its name, whether it is resident, the bytes it holds while it is, its width/height/layers/mipLevels, and onDemand. An onDemand target exists only while something needs it: a target nothing writes into reads resident = false and bytes = 0 and appears again the frame something writes it, and one sized by content — the reflection-probe cube array — holds the slots content asked for. The scratch the draws into a render target have needed is reported as camera[<handle>].* rows: depth and motion vectors under any rasterized pass, and the occlusion channel and G-buffer over them under a camera's scene render. A draw builds what it needs, and the set goes once no live camera names the target and sixty frames have passed without a draw, so a target nothing draws into carries no such row; the colour image drawn into belongs to the texture cache and outlives every one of those releases. totalBytes is what the resident targets hold together. Measured at the end of the last rendered frame.

Returns { targets, totalBytes, residentCount }

local rt = renderer.renderTargets()
print(("render targets: %.1f MiB over %d resident"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
if t.onDemand then print(t.name, t.resident, t.bytes) end
end

globals/renderer/resolutionScale

renderer.resolutionScale() -> number

The fraction of the display resolution the scene is currently rendered at. 1 until something sets it.

Returns number

local s = renderer.resolutionScale()

globals/renderer/setAnisotropy

renderer.setAnisotropy(level: number) -> number

Set the maximum anisotropy material textures are sampled with. Takes effect on the next frame for content already on screen — no reload, no texture re-upload. 1 is plain trilinear.

Parameters

  • level number — One of 1, 2, 4, 8, 16. Any other value is an error.

Returns number — The EFFECTIVE level after clamping to renderer.maxAnisotropy(), so asking for more than the device offers reports what was actually applied.

renderer.setAnisotropy(16)

globals/renderer/setBlendedBatching

renderer.setBlendedBatching(enabled: boolean) -> ()

Whether neighbours in a view's back-to-front blended order draw together. On by default: alpha-blended geometry is submitted farthest-first, and a stretch of neighbours in that order sharing a mesh, a material, a shader and a pose is submitted as one instanced draw over those neighbours, which puts the same members on screen in the same order out of a single submission. A run stops wherever a differently-drawn renderable sorts between two of its members, and a mesh of several primitives keeps a draw per renderable — both would otherwise move fragments through each other. Off, every blended renderable draws on its own at its own slot, so a transparent crowd costs a draw per member. The image is the same either way, which is what makes this the comparison a frame suspected of being formed by the batching is made against; renderer.drawStats().draws counts the difference.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setBlendedBatching(false)  -- a draw per blended renderable

globals/renderer/setDepthPrepass

renderer.setDepthPrepass(enabled: boolean) -> ()

Enable or disable the opaque depth pre-pass. While enabled the renderer resolves opaque depth in its own pass before shading, so each shaded pixel runs its material once instead of once per surface stacked behind it, and the resolved depth is what occlusion culling reads. Enabled by default.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setDepthPrepass(false) -- shade every layer, for comparison

globals/renderer/setDepthPrepassOrdering

renderer.setDepthPrepassOrdering(enabled: boolean) -> ()

Submit the depth pre-pass nearest-first. Renderables reach the pre-pass in the order they were registered, which stands in no relation to where the camera is: a scene built back-to-front makes every layer write depth and be overwritten by the layer in front of it. Ordered, the nearest surface writes first and the surfaces behind it are rejected by the depth test before they write. The same draws go out either way and the depth that comes out is the same, so scene.depth_prepass in profiler.gpuFrame() is what moves. Enabled by default.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setDepthPrepassOrdering(false) -- submit in registration order

globals/renderer/setGpuMemoryTracking

renderer.setGpuMemoryTracking(frames: number?) -> number

Set how often the GPU allocator sampler reads — one reading every frames frames — or turn it off with 0. It starts at 60, a reading a second at 60 Hz, so renderer.gpuMemory().allocator answers without anything arming it. Building the ledger walks every live allocation, which is why it is sampled rather than read every frame; the category figures cost nothing either way, and a reader between samples sees the most recent ledger, so a slow interval still answers.

Called with no argument it reports the interval in force and changes nothing, which is how something that retimes the sampler puts it back afterwards instead of restoring a number it assumed was the default.

Parameters

  • frames number (optional)number? Frames between readings; 0 turns the sampler off. Omit to read the interval without changing it.

Returns number — The interval now in force.

renderer.setGpuMemoryTracking(60)
local was = renderer.setGpuMemoryTracking()
renderer.setGpuMemoryTracking(1)
-- ... take a tight reading ...
renderer.setGpuMemoryTracking(was)

globals/renderer/setMaxFramesInFlight

renderer.setMaxFramesInFlight(frames: number) -> number

Set how many frames of GPU work may be outstanding before the renderer stops running ahead. One is the least overlap this can express — a frame's work is waited for as soon as the next frame has been submitted — which is the lowest latency and the lowest throughput; higher values let a slow frame build a longer backlog, and that backlog is memory. Takes effect on the next frame.

Answers the bound after clamping to [1, 8], so asking for more than the renderer honours reports what you actually got.

Parameters

  • frames number — number Frames of GPU work that may be outstanding, 1 through 8.

Returns number — The bound that took effect, after clamping.

renderer.setMaxFramesInFlight(1) -- lowest latency
print(renderer.setMaxFramesInFlight(99), "was clamped")

globals/renderer/setMinScreenSize

renderer.setMinScreenSize(pixels: number) -> ()

Stop drawing an object once its on-screen radius falls below this many pixels. A few pixels across, an object carries no detail a viewer can resolve while still costing a full vertex and submission pass, and the cutoff drops it from the camera's draws entirely — 0, the default, keeps every object however small it lands. Measured from the object's own bounds against the camera's projection, so the same threshold means the same apparent size at any distance or field of view. Shadow casters have their own threshold in renderer.setShadowCasterCutoff.

Parameters

  • pixels numbernumber — smallest on-screen radius still drawn; 0 disables.

Returns ()

renderer.setMinScreenSize(4) -- drop anything under 4 px of radius
print(renderer.drawStats().compactedDrawn, "instances survived it")

globals/renderer/setOcclusionCulling

renderer.setOcclusionCulling(enabled: boolean) -> ()

Enable or disable occlusion culling. While enabled the renderer reduces the pre-pass depth into a pyramid each frame and tests every renderable that cleared the frustum against it, dropping the ones another surface entirely covers before their geometry is submitted. The pyramid describes the frame being drawn, so an object that becomes visible this frame is never held back a frame. Requires the depth pre-pass.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setOcclusionCulling(true); print(renderer.cullStats().occlusionCulled)

globals/renderer/setPointShadowBudget

renderer.setPointShadowBudget(cfg: {
    megabytes: number?,
    resolution: number?,
}) -> number

Set how much VRAM the point-light shadow atlas may hold, and at what per-face resolution. An omitted field keeps its current value. The atlas is reallocated on the next frame, so renderer.pointShadowBudget().slots reports the new pool one frame later; the returned number is what this budget buys. Raising resolution sharpens every point shadow and spends the same memory on fewer of them — doubling it quarters the slot count. Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the pool never exceeds renderer.pointShadowBudget().maxSlots. One slot is always granted, so a budget too small for a single cube shadows one light and the pool costs what that slot costs rather than what was asked for — { megabytes = 1, resolution = 4096 } buys 384 MiB of ceiling. Read pointShadowBudget().bytes back to see what a budget actually bought, and renderer.shadowMemory().point to see what the scene has made resident.

Parameters

  • cfg { megabytes: number?, resolution: number?, } — The fields to change — megabytes and/or resolution.

Returns number — Cube slots this budget buys.

renderer.setPointShadowBudget({ megabytes = 96 })
renderer.setPointShadowBudget({ resolution = 1024, megabytes = 96 })

globals/renderer/setPresentMode

renderer.setPresentMode(mode: string) -> string

Set how a presented frame reaches the display. fifo queues every frame and shows it on a vertical blank, which never tears and never drops one; mailbox replaces the queued frame with the newest, which does not tear and does not hold the renderer to the refresh rate; immediate presents as soon as a frame is ready and can tear; fifo_relaxed is fifo that tears rather than stall when a frame misses its blank; auto_vsync and auto_no_vsync leave the choice to the backend.

A surface that does not offer the mode presents fifo instead, so read renderer.framePacing().presentMode for what took effect and .presentModes for what this surface offers. Takes effect on the next frame.

Parameters

  • mode string — string One of "fifo", "fifo_relaxed", "mailbox", "immediate", "auto_vsync", "auto_no_vsync".

Returns string — The canonical spelling of the request — renderer.framePacing().presentMode is what the surface presents with, and differs when the surface does not offer the request.

renderer.setPresentMode("mailbox")
print(renderer.framePacing().presentMode, "is what the surface took")

globals/renderer/setProjectionOffset

renderer.setProjectionOffset(x: number, y: number)

Offset the main camera's projection by a sub-pixel amount, in NDC, for the frames until it is set again. The offset is in NDC because that is the space it is constant in: one pixel is 2.0 / width across, so half a pixel is 1.0 / width. Velocity (@scene.motion) is measured against the offset-free projection, so a still scene reports no motion however the samples are placed — and picking resolves a click to the same ray either way. (0, 0) samples pixel centres.

Parameters

  • x number — Horizontal offset in NDC. One pixel is 2.0 / width.
  • y number — Vertical offset in NDC. One pixel is 2.0 / height.
renderer.setProjectionOffset(0.5 * 2 / w, -0.25 * 2 / h)

globals/renderer/setRaytrace

renderer.setRaytrace(enabled: boolean) -> ()

Enable or disable GPU ray tracing. While enabled the engine builds the scene acceleration structure each frame so ray-tracing render features can trace against it; disabling stops the build (so it costs nothing until a ray-traced effect is active). Required before any ray-traced shadows / AO / reflections render.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setRaytrace(true); renderer.feature.create("rt_shadows")

globals/renderer/setResolutionScale

renderer.setResolutionScale(scale: number) -> number

Render the scene at a fraction of the display's resolution and present it at the display's own size. Shading cost scales with pixel count and with nothing else, so this trades sharpness for frame time without taking anything out of the scene: at 0.5 the scene rasterizes a quarter of the pixels. UI and text are unaffected — they are drawn after the scene is brought back up to size. The scene rows in profiler.gpuFrame() are what move.

Parameters

  • scale numbernumber — fraction of the display resolution, clamped to [0.25, 1].

Returns number — the scale in force after clamping.

renderer.setResolutionScale(0.7)

globals/renderer/setShadowCaching

renderer.setShadowCaching(enabled: boolean) -> ()

Whether a shadow map that nothing changed is kept rather than drawn again. On by default: a shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is rasterized on the frames its own inputs change and holds the depth it drew on the ones they do not. Off, every view is drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setShadowCaching(false)  -- draw every shadow view, every frame

globals/renderer/setShadowCasterBatching

renderer.setShadowCasterBatching(enabled: boolean) -> ()

Whether a shadow view draws every caster of one mesh together. On by default: a view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — submits one draw per geometry over every caster of it the view admits, wherever those casters sit in render order and whatever transform slots they hold. Off, a view draws the runs of render-order neighbours that share a mesh AND hold consecutive slots, so a scene that has spawned and despawned anything fragments into many more draws. The image is the same either way, which is what makes this the comparison a shadow suspected of being placed by the batching is made against.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setShadowCasterBatching(false)  -- draw the runs the scene presents

globals/renderer/setShadowCasterCutoff

renderer.setShadowCasterCutoff(cfg: {
    minRadiusPx: number?,
    maxDistance: number?,
}) -> ShadowCasterCutoff

Set the shadow-caster cutoff. An omitted field keeps its current value, so a call can adjust one threshold without restating the other. Both are measured against the camera the frame draws from rather than against each light, so one setting covers every cascade, spot and cube face, and a caster that stops casting is one whose shadow the viewer could not have resolved. maxDistance is measured to the near side of the caster's bounding sphere, so a large object keeps casting while any part of it is in range. 0 releases a threshold; releasing both draws the casters the frame drew before either was set.

Parameters

  • cfg { minRadiusPx: number?, maxDistance: number?, } — The fields to change — minRadiusPx and/or maxDistance.

Returns ShadowCasterCutoff — The cutoff now in force.

renderer.setShadowCasterCutoff({ minRadiusPx = 2 })
renderer.setShadowCasterCutoff({ minRadiusPx = 1.5, maxDistance = 120 })

globals/renderer/setShadowConfig

renderer.setShadowConfig(cfg: {
    resolution: number?,
    cascades: number?,
    distance: number?,
    splitLambda: number?,
    fadeFraction: number?,
    softness: number?,
}) -> ShadowConfig

Set the directional shadow quality. Any omitted field keeps its current value, so a call can adjust one knob without restating the rest. Values are clamped: resolution [64, 8192], cascades [1, 4], distance >= 0, splitLambda [0, 1], fadeFraction [0, 1], softness [0, 1]. Changing resolution or cascades reallocates the depth array; the rest are per-frame values. A distance of 0 hands the range to the frame — the splits are cut over the depth its own shadow-taking renderables reach — and a positive one caps it, which is what a scene bounding its shadow cost states.

Parameters

  • cfg { resolution: number?, cascades: number?, distance: number?, splitLambda: number?, fadeFraction: number?, softness: number?, } — The fields to change — see ShadowConfig.

Returns ShadowConfig — The full config now in force.

renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ cascades = 4, resolution = 2048, distance = 240 })

globals/renderer/setShadowHero

renderer.setShadowHero(entity: string, padding: number?) -> ()

Give one caster a directional shadow view of its own, fit to its world bounds.

A cascade covers the slab of world the camera sees, so its texels are spread over tens of metres and one character standing in the middle of it is resolved by a handful of them. The hero view is the same light and the same depth range zoomed onto that entity's bounds, so the whole map goes into the shadow it and the ground under it carry — renderer.shadowHero().zoom is the factor its texel density gains.

It renders beside the cascades, into a layer of the same texture allocated while a hero is registered, and every surface inside it reads it in place of the cascade, crossing back at its edge. Nothing else about the shadow changes: the same casters reach it, at the same depth range, through the same filter.

Parameters

  • entity string — The entity whose renderables the view is fit around.
  • padding number (optional) — How much room the fit leaves around those bounds — for a pose that leaves the bind-pose box and for the filter that samples outside a silhouette. 1.0 fits them exactly.

Returns ()

renderer.setShadowHero(player.id)
print(("hero shadow: %.1f texels/unit vs %.1f"):format(
renderer.shadowHero().texelsPerUnit, renderer.shadowHero().cascadeTexelsPerUnit))

globals/renderer/setShadowProxy

renderer.setShadowProxy(mesh: string, proxy: string) -> ()

Rasterize proxy in place of mesh in every shadow view. A shadow is a silhouette resolved at the resolution of a shadow map, so the triangles that carry a mesh's close-up detail write depth no reader can resolve — a decimated version of the shape, a level of its own LOD chain, or a hand-built hull casts the same shadow for a fraction of the geometry.

The registration is keyed by MESH, so one call covers every instance of it — entities and GPU-driven populations alike — and a crowd sharing that mesh stays one draw. The proxy is placed by whatever places the caster, its instance's own transforms, so it stands where the caster stands, at the caster's scale.

An entity caster keeps its own geometry where a stand-in could not be placed or deformed correctly: it is skinned (it rasterizes the post-skinned vertices written for its own mesh), it blends morph targets (whose deltas describe its own mesh and are read by vertex id), or its proxy would be placed by a different node of its model than the source mesh is. Either caster keeps it where the renderer holds no geometry under the proxy's guid. Each of those is counted in renderer.shadowProxies().

Nothing else in the scene draws a proxy, so this call is what brings it onto the GPU, and it raises where it cannot. A proxy already resident there is registered as it stands.

Parameters

  • mesh string — The mesh a caster draws, as a guid or any mesh reference.
  • proxy string — The mesh it rasterizes into shadow views instead.

Returns ()

renderer.setShadowProxy(statueMesh, statueHullMesh)
print(renderer.shadowProxies().triangles, "vs", renderer.shadowProxies().sourceTriangles)

globals/renderer/setSkinnedBatching

renderer.setSkinnedBatching(enabled: boolean) -> ()

Whether skinned instances holding one pose draw together. On by default: instances of one mesh wearing one material and posed alike read the same post-skinned vertices, so the camera's colour passes submit them as a single instanced draw, and so does each shadow view and the velocity pass while renderer.shadowCasterBatching() is on — that switch is what makes a depth view form its draws by geometry at all. The camera depth pre-pass submits its casters nearest-first, which is a run per span of neighbours rather than a draw per geometry, so a crowd costs a draw per member there. Off, each skinned instance draws on its own at its own slot in every pass that rasterizes it. The image is the same either way, which is what makes this the comparison a frame suspected of being formed by the batching is made against — renderer.drawStats().draws counts the difference and renderer.skinningStats().poses says how many distinct poses it holds.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setSkinnedBatching(false)  -- a draw per skinned instance

globals/renderer/setSkinningPoseHold

renderer.setSkinningPoseHold(enabled: boolean) -> ()

Whether a pose the skinning pass already wrote is read as it stands. On by default: the pass produces an instance's vertices from its joint matrices, its node transforms, its blend weight and its blend model, so the slice holding a pose already holds what running the pass over those same inputs would write. A frame binding a pose whose slice still holds it reads the slice and dispatches nothing, and skinning costs what the frame's poses CHANGED — a paused clip, a skeleton nothing drives, a cast standing in one pose, each cost compute the frame the pose arrived and nothing after it. Off, every pose a frame binds is dispatched again, which is the comparison a frame suspected of reading a slice that no longer holds its pose is made against; the image is the same either way and renderer.skinningStats() counts the difference as dispatches against held. A mesh whose vertices a compute pass writes is dispatched every frame however this stands.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setSkinningPoseHold(false)  -- dispatch every pose, every frame

globals/renderer/setSpotShadowBudget

renderer.setSpotShadowBudget(cfg: {
    megabytes: number?,
    resolution: number?,
}) -> number

Set how much VRAM the spot/area shadow atlas may hold, and the per-side resolution of one layer. An omitted field keeps its current value. The atlas is reallocated on the next frame, so renderer.spotShadowBudget() reports it one frame later; the returned number is what this budget buys. Raising resolution sharpens the lights that cover the most screen and spends the same memory on fewer layers — doubling it quarters the layer count. Raising megabytes buys layers, which is what lets several lights hold a large tile at once. Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the atlas never exceeds spotShadowBudget().maxLayers. One layer is always granted, so a budget too small for one still shadows lights and the atlas costs what that layer costs rather than what was asked for.

Parameters

  • cfg { megabytes: number?, resolution: number?, } — The fields to change — megabytes and/or resolution.

Returns number — Atlas layers this budget buys.

renderer.setSpotShadowBudget({ megabytes = 64 })
renderer.setSpotShadowBudget({ resolution = 2048, megabytes = 64 })

globals/renderer/setTextureBudget

renderer.setTextureBudget(opts: TextureBudgetOpts) -> TextureBudget

Bound the VRAM a world's textures occupy, by keeping only the mip levels the frame is actually sampling. Pass { megabytes = 256 }; 0 — the default — leaves texture residency alone and every texture stays fully resident the way it uploaded.

With a budget armed, each frame measures how many screen pixels ONE traversal of a texture's coordinate range covers on the surface that spans it widest, and asks for the mip level that serves that span one texel per pixel — the level the GPU picks from the fragment's own derivatives. A material with uvScale = 8 lays eight copies of its texture across a surface, so each copy spans an eighth of the surface and asks for three levels coarser than the surface's own size would. A shader that declares // @uv_space: world advances its coordinate over world units rather than over the mesh's UVs, so how many copies a surface carries follows how large that surface is. The textures whose surfaces cover the fewest pixels give up levels until the set fits. Detail climbs one level per frame, from the image already on screen, so a surface the camera approaches sharpens rather than popping, and no texture is taken below the level whose longest side is 64 texels.

bias shifts every measurement by whole mip levels either way — negative for finer than the sampling implies, positive for coarser — over a world whose look wants a different trade than one texel per pixel.

The plan moves a texture whose demand the frame can measure: one at least 256 texels on its narrowest side, worn by a surface an entity draws. A texture a UI image, a post-process property or a render feature holds a view of stays whole, because nothing measures how much of the screen those cover.

Which textures the budget governs follows the surfaces the frame draws. A texture whose asset still holds its bytes is enrolled the frame a measured surface wears it — whenever it loaded, and whenever the budget was armed — because a level change reads the levels it needs back from the asset; when the last such surface goes it leaves the set whole, at the level it uploaded at, and a surface reaching it again takes it back up. A texture a script uploaded has its pixels nowhere else, so one enrolled while it is resident holds them in system memory (renderer.textureMemory().streamSourceBytes) from the upload until a surface has worn it and gone, and releases them then, which is what keeps it out for the rest of the session; one whose pixels were already released when the budget was armed is out from the start. renderer.textureMemory().pinnedTextures counts those, together with the textures whose asset could not be read back and the ones a UI image, a post-process property or a render feature holds a view of. Disarming returns every texture to the level it uploaded at, and arming again governs the textures the frame's surfaces are wearing then.

Parameters

  • opts TextureBudgetOpts{ megabytes: number?, bias: number? }

Returns TextureBudget{ megabytes, bias } — the budget now in force

renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 })  -- one level finer everywhere
renderer.setTextureBudget({ megabytes = 0 })               -- leave residency alone

globals/renderer/setTransmissionShadows

renderer.setTransmissionShadows(enabled: boolean) -> ()

Let translucent casters tint the sunlight they block instead of blocking it outright. A shadow map holds one depth per texel and is compared as a yes-or-no test, so stained glass, water and thin fabric all project the same black silhouette a wall does. With this on, a caster whose material declares opacity (base_color alpha under a transparent blend) or transmission also draws into a light-space transmittance map, and the colour it lets through multiplies into the directional light reaching whatever stands behind it. Stacked casters compose. Opaque casters are unaffected, and a scene with no translucent caster allocates nothing and records no pass.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setTransmissionShadows(true)  -- stained glass tints the floor

globals/renderer/shaderCache

renderer.shaderCache() -> ShaderCache?

What the shader compile gate's store of baked WGSL held, answered and wrote back. Compiling a .shader wraps the author's body in its framework, expands every #include, and hands the result to naga to parse and validate — work that is a pure function of the text going in, and that a launch would otherwise repeat for every shader it draws with. The store keeps that baked text across launches.

restoredEntries and restoredBytes are what a previous launch left that this one read back. hits counts the compiles answered out of the store and misses those that ran in full; savedMs sums what each hit's own recorded compile had cost, against compileMs, what the misses spent. stale counts the misses whose key was held but whose #included modules had changed underneath — an entry records every module its expansion consumed, so editing a module invalidates exactly the shaders that included it and leaves the rest.

entries and bytes are what the store now holds, evictions how many a write dropped to stay inside its bounds, and saves / savedBytes / dirty describe writing it back, deferred until a burst of compiles settles. persistent is false where a launch has nowhere to keep artifacts and reason says why; location is the file, or the browser store, they are kept in. restoreState is how the read of what a previous launch left has gone — pending while it is still out (a browser answers through a promise, so a launch reaches its first frames before it lands), restored once entries came back, empty when there were none to come back, failed when what was there could not be read, and none where a launch keeps nothing. A cold, missing or corrupt store leaves every shader compiling from source with identical output, and lastError then names what went wrong.

Returns ShaderCache?{ persistent, reason, restoreState, location, restoredEntries, restoredBytes, hits, misses, stale, entries, bytes, compileMs, savedMs, saves, savedBytes, dirty, evictions, lastError }, or nil on a build with no renderer

local c = renderer.shaderCache()
print(("%d hits, %d misses, %.1f ms saved"):format(c.hits, c.misses, c.savedMs))

globals/renderer/shaderCost

renderer.shaderCost() -> { ShaderCost }

What each program has cost in pipeline builds, beside the compile gate's most recent word about it. variants is how many pipelines this engine has built for it — one per (target format, vertex layout, render-state key) permutation reached — and buildMs what those builds cost, both summed since engine start. A pipeline the driver's own store restored is not built and so is not counted, so a second launch on the same adapter reports less than the first. status is compiled, failed or pending, and error carries the compiler's message for a failure. Ordered by cost, most expensive first.

Returns { ShaderCost }

local top = renderer.shaderCost()[1]; print(top.shader, top.variants, top.buildMs)

globals/renderer/shaderVariants

renderer.shaderVariants() -> { ShaderVariants }

Every shader that declares optional features, and the programs its materials have made it compile. Each row carries the features the shader declares, the base program it ships as, and one entry per variant with the features that variant holds — so the permutation count a scene's materials are spending is a number to read rather than something to infer from compile time. A shader whose variants reach budget compiles no more; the materials asking for further feature sets draw with the base program.

Returns { ShaderVariants } — An array of ShaderVariants, one per feature-declaring shader.

for _, s in renderer.shaderVariants() do print(s.shader, #s.variants, s.budget) end

globals/renderer/shadingOf

renderer.shadingOf(subject: string | { [string]: any }) -> ShadingReading

What the renderer is shading ONE subject with, taken from the document the renderer publishes — the call a system holding a handle makes to find out whether what reaches the screen is its own material or the magenta placeholder standing in for it, without reading the engine log. subject is an entity that draws or the registry key of a material. state reads itsMaterial where the renderer bound the program the material names, errorMaterial where it bound the placeholder instead, stalePipeline where the pipeline drawing it was built before that program's most recent compile, nothingBound where the renderer resolved no pipeline for it, pending where this call is the one that armed per-draw recording and the frame after it publishes, and unknown where the renderer holds a resolution under no such subject. A fault state carries the renderer's own reason from the closed set renderer.drawDiagnostics() names — plus materialNotPrepared, which a material subject reads where the renderer prepared nothing under that key — the compiler's detail, the program the material asked for and the bound one; means states the reading in a sentence. A material subject answers from the renderables drawing with it, and from the renderer's record for the material itself where a draw registered against the material carries no row of its own; a subject that several renderables draw answers with a refused one wherever there is one. The reading follows the renderer, so a program that compiles on a later edit puts the subject back on itsMaterial from the frame the renderer draws it with again.

Parameters

  • subject string | { [string]: any } — The entity — a proxy from entity(...) or an entity-id string — or the material, as its registry key or the MaterialHandle renderer.material.create returned.

Returns ShadingReading

local s = renderer.shadingOf(emitter); if s.state ~= "itsMaterial" then print(s.means) end

globals/renderer/shadowCacheStats

renderer.shadowCacheStats() -> {

What the last frame did with the shadow maps it already had. A shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is drawn again only when something it draws from changed: its light moved, a caster it can see moved or appeared or vanished, a caster's geometry or material changed, a caster changed pose or moved the nodes its parts are placed by, or the map it writes into was reallocated. Anything else keeps the depth already in the texture, so a scene that stops moving reads rendered 0 while cached keeps climbing. A mesh whose vertices a compute pass writes — a population, or a mesh built from a compute buffer — re-renders the views it stands in every frame. A shadowed point light contributes six views, one per cube face, so a caster moving on one side of it re-renders the face that can see it and leaves the other five holding what they have. Counted per light kind, plus the totals across all three.

These are totals over every view of a kind. renderer.shadowViews() is the same frame one view at a time, each row naming the light that owns it and what it drew.

Returns { directionalRendered, directionalCached, spotRendered, spotCached, pointRendered, pointCached, rendered, cached }

local s = renderer.shadowCacheStats()
print(("shadow views: %d drawn, %d cached"):format(s.rendered, s.cached))

globals/renderer/shadowCaching

renderer.shadowCaching() -> boolean

Whether a shadow view may keep the depth it already holds.

Returns boolean

globals/renderer/shadowCasterBatching

renderer.shadowCasterBatching() -> boolean

Whether a shadow view draws every caster of one mesh together.

Returns boolean

globals/renderer/shadowCasterCutoff

renderer.shadowCasterCutoff() -> ShadowCasterCutoff

How small, and how far away, a caster may get before it stops writing depth into any shadow view. A shadow view rasterizes a caster's whole triangle count whatever the shadow it produces ends up covering, so an object the viewer resolves a fraction of a pixel of, and one past the range the scene cares about, each cost a full depth pass per shadowed light for detail nothing reads. Both thresholds are 0 — released — until something sets them.

Returns ShadowCasterCutoff — The cutoff in force — see ShadowCasterCutoff.

local c = renderer.shadowCasterCutoff(); print(c.minRadiusPx, c.maxDistance)

globals/renderer/shadowConfig

renderer.shadowConfig() -> ShadowConfig

The directional shadow quality now in force. resolution and cascades size the cascade depth array; distance and splitLambda place the splits along the view; fadeFraction and softness shape how the result is sampled.

Returns ShadowConfig — The full config — see ShadowConfig.

print(renderer.shadowConfig().cascades)

globals/renderer/shadowHero

renderer.shadowHero() -> ShadowHeroReport

The registered hero caster and what the last frame's fit produced. A frame that fit nothing says why in decline.

Returns ShadowHeroReport — See ShadowHeroReport.

local h = renderer.shadowHero()
print(h.active, h.zoom, h.decline)

globals/renderer/shadowMemory

renderer.shadowMemory() -> {

How much GPU memory the shadow maps hold right now, in bytes, by the light kind that owns them. The spot atlas and the point pool are sized to the casters in the scene rather than to the budget, so spot and point move as lights that cast shadows appear and leave, and a scene with one shadowed light holds far less than one that fills every slot. A budget is the ceiling they grow within — renderer.spotShadowBudget().layers and renderer.pointShadowBudget().slots report that ceiling, unmoved by how many casters exist. Raising shadow resolution costs the square of the change across every cascade.

Returns { directional, spot, point, total } in bytes

local m = renderer.shadowMemory()
print(("shadows: %.1f MiB"):format(m.total / 1024 / 1024))

globals/renderer/shadowProxies

renderer.shadowProxies() -> ShadowProxyReport

The shadow proxies in force and what the last frame's shadow passes did with them. triangles and sourceTriangles are what those passes submitted and what they would have submitted from the source meshes — the before/after of every registration, equal while nothing is proxied.

Returns ShadowProxyReport — See ShadowProxyReport.

local p = renderer.shadowProxies()
print(("shadow triangles: %d of %d"):format(p.triangles, p.sourceTriangles))

globals/renderer/shadowViews

renderer.shadowViews() -> ShadowViewReport?

Every shadow view the last rendered frame considered, and what each one cost.

A frame rasterizes a depth view per directional cascade, one for the hero caster, one per shadow-casting spot and six per shadow-casting point. renderer.shadowCacheStats() counts those views by light kind, renderer.drawStats() sums their draws with the camera's, and profiler.gpuFrame() carries one scene.shadow span across all of them. This is the same frame read one view at a time.

Each row names the view and the light that owns it, says whether it drew or kept the depth it already held, and carries the draws, the instances and the casters that went into it. span is the label the view's pass is timed under, so its GPU time is a lookup in profiler.gpuFrame(); every one of those labels is a variant of scene.shadow, which still carries their total. camera carries the same instance counters for the main camera, so the camera's share of a frame-wide total is a read rather than a measurement taken by turning every light's shadow off.

A cascade's near and far are where the split scheme cut its slice, not the world it covers: the fit takes the bounding sphere of that slice and rasterizes the ortho box around it, and both reach past far. What the cascade covers is center and radius, with viewProj the exact test; coversNear and coversFar read that volume back along one ray, the camera's view axis. directional states the axis reading for the set — how far it reaches (coversFar), the range the splits were run over (distance), how far the camera draws (cameraFar), and the depth past the reach the camera still draws (uncovered). A receiver further along the axis than coversFar has no directional depth map over it and is shaded as if the sun reached it, so uncovered is the room a missing shadow has and a surface standing in that room is what makes one; @builtin::systems.proxyOcclusion occludes past the cascades. The box is bounded in every direction, so a receiver standing wide of the axis leaves it at its own distance even where uncovered is 0 — viewProj is what answers for that receiver.

The list is rebuilt every frame: a view whose light stopped casting is absent from the next report rather than standing at the numbers it last had, and a frame that drew no shadow view answers a report whose views is empty. views grouped the way the shadow cache decides — a row per cascade, per spot atlas layer, per point cube — counts what renderer.shadowCacheStats() reports as rendered + cached.

The frame names its views only while something is reading them, so this call asks the frames after it to name theirs and waits out the first one. Nil on an engine that renders no frame at all.

Returns ShadowViewReport? — See ShadowViewReport.

local report = renderer.shadowViews()
print(("camera submitted %d of %d instances"):format(
report.camera.submittedInstances, renderer.drawStats().compactedDrawn))
for _, view in report.views do
print(("%s %d (%s): %d draws, %d instances, %s"):format(
view.role, view.index, view.light, view.draws, view.instances,
view.rendered and "drew" or "cached"))
end
local d = report.directional
if d ~= nil and d.uncovered > 0 then
print(("sun shadows stop at %.0f; the camera draws to %.0f"):format(
d.coversFar, d.cameraFar))
end

globals/renderer/skinnedBatching

renderer.skinnedBatching() -> boolean

Whether skinned instances holding one pose draw together.

Returns boolean

globals/renderer/skinningPoseHold

renderer.skinningPoseHold() -> boolean

Whether a pose already written into its slice skips its dispatch.

Returns boolean

globals/renderer/skinningStats

renderer.skinningStats() -> {

What the last frame's skinned instances cost. A skinned instance is posed by a compute pass that writes its vertices into a shared pool, and instances holding the same pose read one slice of that pool and the single dispatch that fills it. instances is how many were posed, poses how many distinct poses they held, and dispatches how many dispatches those poses cost this frame — so a crowd whose members move together costs what its poses cost rather than what its head count does, while members at different animation times each hold their own pose and pay for it.

held is how many of the frame's poses cost no dispatch at all. The pass produces a slice from what the pose is made of, so a slice an earlier frame filled already holds what running it again would write, and a pose still wearing that slice is read as it stands. Skinning is paid for by the poses that CHANGED: a cast standing still reads dispatches 0 beside a held equal to its poses, and the two add up to poses in any frame.

reusedSlices is how many of the frame's poses took a slice the pool already held — one a retired pose gave back, or one a pose nothing has asked for this frame was holding — rather than one cut from pool the engine had never used. A scene whose poses keep changing reads a non-zero count beside a poolBytes that stays where it was.

liveBytes is what the slices holding this frame's poses occupy, against unsharedBytes — what the same instances would occupy with a slice each. poolBytes is what the pool holds; a previous-position buffer of the same size rides alongside it so skinned deformation reaches motion vectors.

Returns { instances: number, poses: number, dispatches: number, held: number, reusedSlices: number, liveBytes: number, unsharedBytes: number, poolBytes: number }

local s = renderer.skinningStats()
print(("%d instances over %d poses"):format(s.instances, s.poses))
print(("%d poses dispatched, %d read as they stood"):format(s.dispatches, s.held))
print(("skinned vertex storage: %d B of an unshared %d B"):format(s.liveBytes, s.unsharedBytes))

globals/renderer/splat/components

renderer.splat.components(bytes: any?, convention: string?) -> (SplatComponents?, string?)

Decode a Gaussian splat capture — a Niantic .spz (gzipped or raw) or a 3DGS .ply — into the GPU-ready byte pools a render feature uploads. records is the packed splat array at recordBytes per splat (position, log scale, quaternion, DC colour + opacity); sh is the quantized higher-order spherical-harmonics pool at shStrideWords u32 words per splat, empty at degree 0. A pure decode (no GPU work): upload the pools with shaderRef:createBuffer + buf:writeBytes and draw them with a kind = "splat", channel = "gaussian" pass.

Parameters

  • bytes any (optional) — Capture bytes — .spz or .ply, as a buffer or a binary string.
  • convention string (optional) — Source axis convention: "rightDownFront" (the default, what COLMAP-trained captures use) or "engineNative" for a capture already in engine space.

Returns (SplatComponents?, string?){ records, sh, count, shDegree, shStrideWords, recordBytes, boundsMin?, boundsMax?, antialiased, format }, or (nil, err).

local c = renderer.splat.components(vfs.readBytes("captures/ceramic.spz"))

globals/renderer/spotShadowBudget

renderer.spotShadowBudget() -> SpotShadowBudget

The spot and area-light shadow atlas now in force. Each shadow-casting spot is given a tile of it every frame, sized to what the camera can resolve: a light filling the view gets a whole layer at resolution, one far away gets a minResolution tile, and the atlas holds tiles of the smallest kind. That is what lets one budget serve a close hero light and a street of distant ones without either the memory or the sharpness being set for the worst case.

Returns SpotShadowBudget — The atlas — see SpotShadowBudget.

local s = renderer.spotShadowBudget()
print(("%d layers of %d, %.1f MiB"):format(s.layers, s.resolution, s.bytes / 1024 / 1024))

globals/renderer/temporal/held

renderer.temporal.held() -> boolean

Whether a hold is pinning the per-frame clock right now.

Returns boolean — True while at least one renderer.temporal.hold stands.

if renderer.temporal.held() then print("frame is pinned") end

globals/renderer/temporal/hold

renderer.temporal.hold(at: number?, options: TemporalHoldOptions?) -> () -> ()

Pin the clock every per-frame effect draws itself against, and return the release. While the hold stands, renderer.temporal.now answers at instead of the running clock, so film grain and every other field redrawn each frame is redrawn as the same field. Two renders taken under holds at the same instant therefore agree pixel for pixel wherever the scene itself has not moved, which is what makes one frame comparable with another. Holds nest: the innermost names the instant, and the clock runs again once the last release is called. Each release takes its own hold off the stack whatever order the releases come in, so two callers holding at once — two captures in flight together — each end their own hold and the clock runs again when both have. exclusive takes the clock for the owner key the call states: while that hold stands, a hold is admitted only when it states the same key, and every other one is refused with an error naming the key and the instant holding it. That is what lets one caller wind the clock to the second it means to photograph and keep it there while another agent drives the same engine. The key is what an owner presents to take a nested hold of its own, and what renderer.temporal.release hands the clock back by. A capture taken while the hold stands renders at the held instant; a deterministic capture takes a hold of its own that states no key, so it runs once the clock is handed back.

Parameters

  • at number (optional) — The instant to pin the clock at, in seconds. Two holds that state the same instant produce the same field; the default 0 is that shared instant.
  • options TemporalHoldOptions (optional)owner is the key this hold is taken under, and an exclusive hold states one. A hold that states no key is labelled with the agent the call is attributed to, which is the account the caller presented a token for and is shared by every session driving this engine under it. exclusive takes the clock for the stated key until the hold is released.

Returns () -> () — A function that releases this hold. Calling it twice releases once.

local release = renderer.temporal.hold() ; local png = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()
local release = renderer.temporal.hold(46.0, { exclusive = true, owner = "stage-air" })

globals/renderer/temporal/now

renderer.temporal.now() -> number

The instant a per-frame effect should draw itself at: the innermost hold's instant while one stands, and seconds since boot otherwise. A system that redraws a field every frame reads this rather than the running clock, and a capture asking for a repeatable frame then gets one.

Returns number — Seconds — pinned while a hold stands, running otherwise.

local params = { grainTime = renderer.temporal.now() }

globals/renderer/temporal/onChange

renderer.temporal.onChange(listener: (number) -> ()) -> () -> ()

Register a listener called with the pinned instant whenever it changes — a hold taken, a hold released — and return the unsubscribe. A system whose shader reads the clock out of a GPU buffer registers here, so the buffer carries the pinned instant before the frame that hold was taken on is drawn rather than a frame later.

Parameters

  • listener (number) -> () — Called with the instant now in force, in seconds.

Returns () -> () — A function that removes this listener.

local stop = renderer.temporal.onChange(function(t) pushClock(t) end)

globals/renderer/temporal/owner

renderer.temporal.owner() -> { id: string?, name: string?, at: number, exclusive: boolean }?

The hold naming the instant the clock answers right now: who took it, what instant it pinned, and whether it took the clock exclusively. Several agents drive one engine at once and a hold any of them takes moves the clock every registered field is redrawn against, so this is how a caller sees that another agent holds it before its own instant is quietly replaced — and, when exclusive is true, id is the key a hold of its own states to be admitted, and the key renderer.temporal.release hands the clock back by. id and name are nil for a hold that stated no key and that the engine attributes to no agent.

Returns { id: string?, name: string?, at: number, exclusive: boolean }?{ id, name, at, exclusive } for the standing hold, or nil when the clock is running.

local who = renderer.temporal.owner()
if who ~= nil and who.exclusive then print(who.name, "holds the clock at", who.at) end

globals/renderer/temporal/release

renderer.temporal.release(owner: string) -> number

Hand the clock back by the key its holds were taken under, and report how many came off. A hold stands until its release is called, and the release is a closure the call that took the hold holds: a caller that takes a hold in one call and comes back in another, and a task that ends between the two, both leave the clock pinned with nobody holding a release for it. Naming the key is how the clock runs again, and how a caller refused by an exclusive hold takes one over.

Parameters

  • owner string — The key the holds to release were taken under — what owner stated when they were taken, which renderer.temporal.owner reports.

Returns number — How many holds came off the stack.

renderer.temporal.release("stage-air")

globals/renderer/texture/capture

renderer.texture.capture(texture: string | { [string]: any } | AssetRef) -> string

Request a CPU readback of the GPU texture texture names (e.g. a camera's rendered output). Returns a result key to pass to a TextureCpuHandle's :encode() once the readback completes. Takes every form that names a texture — the TextureHandle create returned, the guid renderer.texture.list hands out, a TextureCpuHandle or a texture AssetRef.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture to read back — a TextureHandle, a guid, a TextureCpuHandle or a texture AssetRef.

Returns string The capture result key.

local key = renderer.texture.capture(cameraTarget)

globals/renderer/texture/cpuCreate

renderer.texture.cpuCreate(width: number, height: number, fill: any?) -> TextureCpuHandle

Allocate a blank CPU image (RGBA8) filled with a solid colour and return a TextureCpuHandle. Compose into it with canvas:blit(src, x, y, w, h), then canvas:encodeJpeg() / :encodePng() for the bytes; :unload() drops it.

Parameters

  • width number — number Canvas width in pixels.
  • height number — number Canvas height in pixels.
  • fill any (optional) — Optional { r, g, b, a } (0-255) solid fill; defaults to opaque white.

Returns TextureCpuHandle

local c = renderer.texture.cpuCreate(1024, 576, { 255, 255, 255, 255 })

globals/renderer/texture/cpuFromBytes

renderer.texture.cpuFromBytes(bytes: buffer | string, encodeOpts: any?) -> TextureCpuHandle

Load engine-native ZTEX bytes — or an encoded image (png / jpg / webp) — into the CPU store under a fresh guid and answer the CPU handle, for pixels that come from somewhere other than a texture asset: a data.ztex read as a file, a payload held in memory. The pixels stay at the format they were encoded in. DEFAULT: handle:unload() once done with them.

Parameters

  • bytes buffer | string — The ZTEX or image bytes.
  • encodeOpts any (optional){ format?, srgb?, generateMipmaps?, maxDimension? } applied when the bytes are an encoded image and need the engine-native encode.

Returns TextureCpuHandle

local cpu = renderer.texture.cpuFromBytes(vfs.read(path)); local png = cpu:encodePng(); cpu:unload()

globals/renderer/texture/create

renderer.texture.create(src: any?, guid: string?) -> TextureHandle

Create (or fetch) a GPU texture resource and return its TextureHandle. src: a TextureCpuHandle from texRef:load() (CPU→GPU under the asset's guid, idempotent); raw pixels {rgba, width, height, srgb?, format?} (a flat widthheight4 byte payload, 0-255, row-major, top-to-bottom, RGBA — a buffer, a binary string, or a number array; format = "rgba16f" uploads an HDR texture instead, where rgba carries float channel values); a TextureHandle (returned as-is); or render-target dimensions {width, height, name?, format?} with no pixel source — an empty GPU texture a render pass writes into (camera output, UI surface) and that samples like any other texture. format names the colour format the target is allocated in, and the passes drawing into it are built for that format: "rgba8unorm" / "bgra8unorm" (the two eight-bit channel orders, either of which a surface may carry), "rgba16f" / "rgba32f", "rg16f" / "rg32f", "r16f" / "r32f". Each also answers to its spelled-out width ("rgba16float", "r32float", and so on), in any case. Omit it to take the surface's own. A float format carries what eight bits quantize — positions, velocities, HDR. Any other format raises an error naming every name that works, so a target is allocated in the format it was asked for or not at all. A render target takes filter the way raw pixels do: "nearest" keeps its own pixels square wherever something draws it larger than it is — a viewport widget, a magnified capture — which is what an image whose pixels ARE the subject needs, since a 64x32 panel holds no detail between its pixels to interpolate; "linear" (the default) smooths between them. It also takes screen (the engine keeps it the size of the image being drawn), screenScale (the fraction of that size it takes) and screenSpace ("scene", the default, or "composite" — the image the post-scene phases draw into, which is the display's own resolution while the renderer presents the viewport itself and the scene's size while a UI viewport panel owns the presentation). A scene-space target is resized for every render target drawn and cleared before an offscreen one; a composite-space target follows the presented frame alone, which is what lets a pass keep an accumulation in it. One scene-space screen target is therefore one resource every render target draws through in turn, so its guid holds the last one's image at the last one's size, and a value read back from it belongs to whichever render target was drawn last. A reading that has to be the viewport's own comes from screenSpace = "composite", or from a target created without screen. NEVER takes an AssetRef — load the CPU first.

Parameters

  • src any (optional) — A TextureCpuHandle, raw pixels, a TextureHandle, or render-target dimensions.
  • guid string (optional) — Optional v4 guid for a NEW runtime texture — the asset identity the texture is filed under, which a material's texture slot resolves through. Minted when absent. Ignored for the CPU-handle and render-target paths.

Returns TextureHandle

local gpu = renderer.texture.create(texRef:load())
local gpu = renderer.texture.create({ rgba = pixels, width = 16, height = 16 })
local px = buffer.create(16 * 16 * 4); local gpu = renderer.texture.create({ rgba = px, width = 16, height = 16 })
local rt = renderer.texture.create({ width = 512, height = 256, name = "panel_rt" })
local hdr = renderer.texture.create({ width = 512, height = 256, name = "cam_rt", format = "rgba16f" })
local led = renderer.texture.create({ width = 64, height = 32, name = "panel", filter = "nearest" })

globals/renderer/texture/createFromAsset

renderer.texture.createFromAsset(ref: string | AssetRef, encodeOpts: any?, keepCpu: boolean?) -> TextureHandle

Put a .texture asset on the GPU under its own guid and answer its handle at once. The asset's bytes are decoded off the frame and the texture lands on the device when the decode finishes, a frame or more later: a material naming the guid draws the shader's default for that slot until then and rebinds when it arrives, and renderer.texture.isResident reports the arrival. The decoded pixels are dropped once uploaded unless keepCpu holds them in the CPU store for textureRef:load()-style reads. An asset the device already holds is answered from the shape the device reports, without reading the asset's bytes and without a second decode.

Parameters

  • ref string | AssetRef — A texture AssetRef, or a string naming one (guid, identity, name or source path).
  • encodeOpts any (optional){ format?, srgb?, generateMipmaps?, maxDimension?, filter? } applied when the primary is an encoded source image and needs the engine-native encode (a .ztex primary is decoded as-is).
  • keepCpu boolean (optional) — Keep the decoded pixels in the CPU store after the upload.

Returns TextureHandle

local h = renderer.texture.createFromAsset(texRef) -- bind h.guid on a material; it draws once resident

globals/renderer/texture/decode

renderer.texture.decode(bytes: buffer | string) -> (any, any, any, any)

Decode a texture payload to its pixel buffer. Takes the two shapes the renderer's own texture loader takes, told apart by their leading bytes:

  • an engine-native ZTEX payload — handed back at the texel format the payload was written in, so a height field read back here keeps every bit it was authored with. A ZTEX holding block-compressed or verbatim source-image levels decodes to "rgba8".
  • source image bytes — png, jpeg, gif or webp, straight off disk or out of a capture — decoded to "rgba8" at whatever colour type, bit depth or interlacing the file was written with. This is the call that reads the pixels of a screenshot.

The fourth return names the format the buffer came back in: "rgba8" (4 bytes/texel, channels 0-255), "rgba16" (8 bytes/texel, 16-bit unsigned normalized channels 0-65535) or "rgba32f" (16 bytes/texel, float channels).

Parameters

  • bytes buffer | string — A ZTEX payload or source image bytes.

Returns (any, any, any, any)(string?, number?, number?, string?) pixels, width, height, format — or (nil, errmsg) where errmsg is in the 2nd slot.

local pixels, w, h = renderer.texture.decode(vfs.read("/source/tmp/shot.png"))

globals/renderer/texture/destroy

renderer.texture.destroy(texture: string | { [string]: any } | AssetRef) -> boolean

Release the GPU texture texture names. For an empty render-into texture (camera output, UI surface) this also frees its render scratch; for an uploaded runtime texture it drops the GPU resource (and any CPU shadow). After this, renderer.texture.list stops answering for the guid. Takes every form that names a texture — the TextureHandle create returned, the guid the listing hands out, a TextureCpuHandle or a texture AssetRef.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture to release — a TextureHandle, a guid, a TextureCpuHandle or a texture AssetRef.

Returns boolean true when a texture was known under the guid.

renderer.texture.destroy(rt)
renderer.texture.destroy(renderer.texture.list()[1].guid)

globals/renderer/texture/encode

renderer.texture.encode(rgba: any?, width: number, height: number, opts: any?) -> (string?, string?)

Encode raw pixels into an engine-native ZTEX payload (the on-disk texture content). The CPU codec behind the texture assetType's onCreate. opts.format selects the on-disk precision: "rgba8" / "srgb" (default, 8 bits/channel, rgba is widthheight4 bytes) or the high-precision data formats "rgba16" (16-bit unsigned normalized, widthheight8 bytes) / "rgba32f" (32-bit float, widthheight16 bytes) — for height/displacement fields, baked lightmaps, and other data rasters an 8-bit format quantizes visibly. The two high-precision formats store rgba verbatim and reject opts.generateMipmaps / opts.maxDimension.

Parameters

  • rgba any (optional) — Pixel payload at opts.format's native byte width — a buffer, a binary string, or a number array.
  • width number — number
  • height number — number
  • opts any (optional){ format?, srgb?, generateMipmaps?, maxDimension? }

Returns (string?, string?) ZTEX bytes, or (nil, errmsg).

globals/renderer/texture/encodeFromImage

renderer.texture.encodeFromImage(bytes: buffer | string, opts: any?) -> (string?, string?)

Encode source image bytes (png/jpg/webp/…) into an engine-native ZTEX payload. Used by the texture importer / assetType onChange.

Parameters

  • bytes buffer | string — source image bytes.
  • opts any (optional){ format?, srgb?, generateMipmaps?, maxDimension? }

Returns (string?, string?) ZTEX bytes, or (nil, errmsg).

globals/renderer/texture/frameSchedule

renderer.texture.frameSchedule(texture: string | AssetRef) -> { number }?

The times at which each layer of a timed texture stops being shown, in seconds from the start of the sequence — the running total of the layer display times, so the last entry is the length of one pass.

This is the form a sampler reads a sequence through: a time is turned into a layer by finding the first entry it has not passed, whatever the individual layer times are. It is what the schedule slot of the builtin animatedTexture shader holds, one entry per layer.

A texture whose layers carry no timing — a still image, a sprite sheet, a LUT stack — has no schedule and answers nil.

Parameters

  • texture string | AssetRef — The texture — a guid, an identity, a name, a path, or a texture AssetRef.

Returns { number }? one cumulative end time per layer, or nil.

local ends = renderer.texture.frameSchedule(texRef) -- {0.1, 0.15, 0.35}

globals/renderer/texture/info

renderer.texture.info(ztex: buffer | string) -> (any, any)

Read the header of an engine-native ZTEX payload without copying the pixels. Returns its format, dimensions, mip count, filter ("nearest" or "linear" — the sampler baked into the blob from the asset's settings.filter), and the payload's layer shape.

layers counts the array layers the payload carries and isArray is true past one — the answer to "am I about to sample a texture_2d_array?", available before anything samples it. animated is true when those layers are a sequence in time; then frameDelaysMs lists each layer's display time in milliseconds in display order, and durationMs totals one pass. An animated image imports as one layer per frame, so layers is its frame count. A still texture reports layers = 1, isArray = false.

Parameters

  • ztex buffer | string — ZTEX bytes.

Returns (any, any)(table?, string?) { format, width, height, mipCount, filter, layers, isArray, animated, frameDelaysMs?, durationMs? }, or (nil, errmsg).

local i = renderer.texture.info(bytes); if i.animated then print(i.layers, "frames", i.durationMs, "ms") end

globals/renderer/texture/isResident

renderer.texture.isResident(texture: string | { [string]: any } | AssetRef) -> boolean

True if a GPU texture is resident under this texture's guid.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture — a TextureHandle, a TextureCpuHandle, a guid, or a texture AssetRef.

Returns boolean

print(renderer.texture.isResident(handle))

globals/renderer/texture/list

renderer.texture.list() -> { any }

Every texture currently registered, ordered by guid — the ones a script created and the ones that reached the device through an asset alike. Each entry carries the guid, where it came from (origin is "asset" for a texture the asset path uploaded), and whether the GPU still holds it. A resident entry also carries the bytes it costs, its dimensions and its texel format, so the listing sums to renderer.textureMemory(). A streamable one carries streamOrigin"asset" when a level change reads the levels it needs back from the asset, "retained" when the cache holds the pixels for it. A script-created entry also carries held — whether renderer.hold pins it for the session — and scene, the load that created it. renderer.references("texture", guid) says what is still holding a row, and renderer.collect() releases the rows nothing holds.

Returns { any } — Array of { guid, origin, owner?, scene?, held?, resident, bytes?, width?, height?, format?, compressed?, streamable?, streamOrigin? }.

for _, t in ipairs(renderer.texture.list()) do print(t.guid, t.bytes) end

globals/renderer/texture/loadCpu

renderer.texture.loadCpu(ref: string | AssetRef, encodeOpts: any?) -> TextureCpuHandle

Load a .texture asset's pixels into the ONE guid-keyed CPU store (the Disk→CPU step) and return a CPU handle for per-pixel access (no GPU readback). The handle holds NO pixels — only the guid, dims and texel format plus the read/write/encode/unload ops (which read the Rust store). The pixels stay at the format they were authored in: handle.format is "rgba8", "rgba16" or "rgba32f", and :readPixel reports channels in that format's own units. Called by texRef:load(). DEFAULT: upload to the GPU then handle:unload().

Parameters

  • ref string | AssetRef — A texture AssetRef (carries .guid, reads its primary via getBytes), or any string asset.ref resolves to one — a guid, an identity, a name or a source path.
  • encodeOpts any (optional){ format?, srgb?, generateMipmaps?, maxDimension? } applied when the primary is an encoded source image and needs the engine-native encode (a .ztex primary is loaded as-is).

Returns TextureCpuHandle

local cpu = texRef:load(); local r,g,b,a = cpu:readPixel(3, 4); cpu:unload()

globals/renderer/texture/readback

renderer.texture.readback(texture: string | { [string]: any } | AssetRef) -> TextureCpuHandle

Read a runtime GPU texture's pixels back to CPU and return a TextureCpuHandle for them — the GPU→CPU half of the runtime-texture freeze path. A texture made with renderer.texture.create keeps no CPU copy, so persisting it (:encode()asset.create("texture", …)) reads it back here first. Yields until the readback completes (a frame or two). After it returns the pixels are resident in the guid-keyed CPU store: :readPixel, :writePixel, :getInfo, :encode, :unload all work. Errors if the texture never becomes GPU-resident.

A SCENE-space screen-sized render target is one resource shared by every render target drawn — the viewport, an offscreen capture, a camera rendering into a texture — resized and re-derived for each of them in turn. The copy is taken ahead of all of them for the frame, so what a readback of its guid answers is the content of the last frame the renderer drew: the presented view's own image at the presented resolution, since the presented view is the sink that draws last. A request made while the renderer is holding frames back is carried to the next frame it draws rather than being answered from a target another sink left standing, so a readback can wait a frame longer than the copy itself takes.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture — the TextureHandle renderer.texture.create returned, a guid, or a texture AssetRef.

Returns TextureCpuHandle

local cpu = renderer.texture.readback(handle); local ztex = cpu:encode(); cpu:unload()

globals/renderer/texture/tone

renderer.texture.tone(histogram: any?) -> TextureTone

Reduce a histogram to what the picture's tone IS: where its darkest and brightest pixels sit, where the body of it sits, and how much of it is standing on the floor or the ceiling — all in code values on the 0-255 scale the pixels were delivered at.

span (max - min) is the whole range including a single stray pixel; spread (p95 - p5) is the range the body of the picture occupies, which is the reading that says whether a shot is legible. A frame whose subject is modelled and shaded but delivered inside a few code values reads a large mean and a tiny spread, and no mean alone can tell that apart from a frame with a subject in it.

crushed and clipped are the shares of the picture at code 0 and at code 255, each 0..1 — what a shot loses to the floor and to the ceiling.

Parameters

  • histogram any (optional) — A histogram from cpu:histogram().

Returns TextureTone

local t = cpu:tone(); if t.spread < 24 then error("the shot is flat") end

globals/renderer/texture/update

renderer.texture.update(texture: string | { [string]: any } | AssetRef, src: any?) -> TextureHandle

Overwrite the GPU texture texture names IN PLACE, under the same guid, from new raw pixels. Never writes a .texture file — the play-mode mutate path. Takes every form that names a texture — the TextureHandle create returned, the guid renderer.texture.list hands out, a TextureCpuHandle or a texture AssetRef. Returns a handle carrying the new dimensions: the handle it was given, refreshed, and a handle over the guid otherwise.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture to update — a TextureHandle, a guid, a TextureCpuHandle or a texture AssetRef.
  • src any (optional) — New raw pixels {rgba, width, height, srgb?, format?}rgba as a buffer, a binary string, or a number array.

Returns TextureHandle — A TextureHandle for the updated texture.

globals/renderer/textureMemory

renderer.textureMemory() -> {

What the GPU texture cache holds, split by whether the texture is block-compressed. compressedBytes and uncompressedBytes are what those textures cost in VRAM, measured from each texture's own format and mip chain — so a .texture whose settings name format = "bc7" appears in the compressed columns at a quarter of what the same image costs as RGBA8. blockCompressionSupported is whether this adapter can hold block-compressed textures at all; where it is false a BC7 payload is uploaded decoded and lands in the uncompressed columns instead, so the texture is present everywhere and compressed where the hardware allows it. Measured at the end of the last rendered frame. streamableTextures is how many of them a texture budget can move the base mip level of, split by where a level change reads the levels it needs from: assetStreamedTextures are read back from the asset they came from and hold nothing in system memory, retainedTextures hold the payload because a script uploaded their pixels and the GPU copy is the only other one there is. streamSourceBytes is what those held payloads occupy in system memory — bytes that are not VRAM — so it is a reading on the retained half alone. pinnedTextures counts the textures big enough to stream that stand at a level nothing can move: their pixels were released and no asset holds them, the asset behind them could not be read back, or a UI image, a post-process property or a render feature holds a view of them. A texture out of the streamable set only because no measured surface wears it stands in neither count: a surface reaching it takes it back up, so its level moves again as soon as there is a footprint to move it by. It reads 0 while no budget is armed.

Returns { blockCompressionSupported, compressedTextures, compressedBytes, uncompressedTextures, uncompressedBytes, streamableTextures, assetStreamedTextures, retainedTextures, pinnedTextures, streamSourceBytes }

local tm = renderer.textureMemory()
print(("%d compressed textures hold %.1f MiB"):format(tm.compressedTextures, tm.compressedBytes / 1048576))
print(("%d textures stream from their asset, %d hold %.1f MiB of pixels"):format(
tm.assetStreamedTextures, tm.retainedTextures, tm.streamSourceBytes / 1048576))

globals/renderer/textureStreaming

renderer.textureStreaming() -> TextureStreaming

What the last frame's texture-residency plan decided. budgetBytes is the armed budget, and 0 means residency is left alone. streamable is how many textures the plan can move. residentBytes is what those textures occupy now, measured from the textures that are allocated; demandedBytes is what the frame's demand alone would have cost, so the two part exactly where the budget is doing something. starved counts the textures left coarser than the frame asked for, promoted the ones that climbed a level this frame, and changed the ones whose GPU texture was replaced. A camera approaching a surface reads promoted above zero for a few frames and then zero once it settles.

textures is one row per streamable texture, ordered by key, carrying the level each one was asked for and the measurement that asked. Two byte totals can agree while a single texture sits several levels off what its surface samples, so read the row when the question is which level a texture holds and why.

With budgetBytes at 0 nothing holds a level back, so residentBytes, plannedBytes and demandedBytes all read the whole chain of every texture still enrolled and textures is empty — which is how a session that armed a budget and dropped it reads back that the levels came home.

Returns TextureStreaming{ budgetBytes, streamable, residentBytes, plannedBytes, demandedBytes, starved, promoted, changed, textures }

local ts = renderer.textureStreaming()
print(("textures: %.1f MiB resident of %.1f MiB demanded, %d starved"):format(
ts.residentBytes / 1048576, ts.demandedBytes / 1048576, ts.starved))

globals/renderer/transmissionShadows

renderer.transmissionShadows() -> boolean

Whether translucent casters tint the directional light they block.

Returns boolean

globals/renderer/uploadStats

renderer.uploadStats() -> {

What the last completed frame spent re-describing its renderables to the GPU. Every renderable owns a slot in the per-instance data a draw reads — its world matrix, the bounds the culler tests it by, and the flags that decide which passes and which culling stages see it — and a frame uploads only the slots whose contents changed. bytes is what those uploads carried, fullBytes what re-sending every slot would have cost, and writes how many buffer writes carried it. The three numbers cover that per-renderable data alone, so a scene standing still reads bytes = 0 against a fullBytes that grows with the scene, and the ratio says how much of it the scene's own churn — rather than its size — is paying for.

Returns { writes: number, bytes: number, fullBytes: number }

local u = renderer.uploadStats()
print(("instance upload: %d B of %d B in %d writes"):format(u.bytes, u.fullBytes, u.writes))

globals/renderer/variantSource

renderer.variantSource(program: string) -> string?

The WGSL one of the programs renderer.shaderVariants() lists holds, exactly as the shader compiler received it. program is the program field of a row's base or of one of its variants. Reading a base alongside a variant shows what a feature set selected: each program's text holds the code its own features guard. The variant-report spelling of renderer.compiledSource, which answers the same for every other shader.

Parameters

  • program string — A program name from renderer.shaderVariants().

Returns string? — The compiled WGSL, or nil for a name no compile has run under.

local row = renderer.shaderVariants()[1]
local base = renderer.variantSource(row.base.program)

modules/renderer/README

require("@builtin/modules/api/engine/renderer") -- renderer (also available as global 'renderer')

GPU-resource factory + CPU codec/store wrappers — the GPU/CPU half of the asset↔resource split. This module (under modules/api/) is the SOLE caller of the internal __mesh / __meshcpu / __meshgpu / __splat / __texture / __texturecpu / __texturegpu / __instancedata FFI; assetType behaviours, components, and every other module call renderer.*, never the __ internals.

Usage: local renderer = require("@builtin/modules/api/engine/renderer") Also available as global: renderer

modules/renderer/anisotropy

anisotropy(): number

The maximum anisotropy material textures are sampled with right now — the requested level clamped to what this device honours.

if renderer.anisotropy() < 4 then ... end

modules/renderer/atmospherics.held

atmospherics.held(): boolean

Whether a hold is standing on the air right now.

if renderer.atmospherics.held() then print("clear air") end

modules/renderer/atmospherics.hold

atmospherics.hold(share: number?): () -> ()

Hold the air between the camera and every surface at a stated share of what the scene authored, and return the release. At the default 0 the media contribute nothing and a surface renders in its own colour, which is what lets a reader judge an albedo, a tint or a material while another slice of a shared world drives the weather. The share reaches aerial perspective, height fog and volumetric light scattering; the sky, the sun and the light they put on a surface are untouched, because those are what the surface's colour is made of. Holds nest: the innermost names the share, and the authored air is back once the last release is called. Each release ends its own hold whatever order the releases come in, so two callers holding at once each end their own.

Parameters

  • share number? (optional) — How much of the authored air reaches the image, in [0, 1]. Defaults to 0 — no air at all.

Returns () — A function that releases this hold. Calling it twice releases once.

local release = renderer.atmospherics.hold() ; local png = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()

modules/renderer/atmospherics.onChange

atmospherics.onChange(listener: (number) -> ()): () -> ()

Register a listener called with the share now in force whenever it changes — a hold taken, a hold released — and return the unsubscribe. A system that packs a medium into a GPU buffer registers here and re-packs what it has already pushed, so the buffer carries the share before the frame the hold was taken on is drawn rather than a frame later.

Parameters

  • listener (number) -> () — Called with the share now in force, in [0, 1].

Returns () — A function that removes this listener.

local stop = renderer.atmospherics.onChange(function(share) pushParams() end)

modules/renderer/atmospherics.share

atmospherics.share(): number

The share of the authored air that reaches the image: the innermost hold's share while one stands, and 1 otherwise. A system that packs a medium multiplies its extinction — aerial, a fog density — by this, and a hold then reaches that medium however it is being driven.

local density = state.density * renderer.atmospherics.share()

modules/renderer/blendedBatching

blendedBatching(): boolean

Whether blended neighbours sharing a draw key draw together.

modules/renderer/bounds.clear

bounds.clear(id: string): boolean

Withdraw the box an entity published, so it stops contributing to the entity's reported extent.

Parameters

  • id string — Entity id.
renderer.bounds.clear(id)

modules/renderer/bounds.set

bounds.set(id: string, min: any, max: any): boolean

Publish the local-space box an entity's content-drawn geometry occupies. entity:bounds() and entity:hierarchyBounds() union it with whatever mesh geometry the entity has, each carried out of its own local space, so framing a camera on the entity frames what a feature actually draws.

modules/renderer/captureView.channelId

captureView.channelId(name: string): number?

The debug channel a registered view draws on — what a feature passes as its pass debugChannel. Nil when no view is registered under name.

Parameters

  • name string — The view name.
ctx.enqueue { ..., debugChannel = renderer.captureView.channelId("lightmap") }

modules/renderer/captureView.list

captureView.list(): { any }

Every registered capture view as { name, channel, description } records — what backs the discoverability of capture pass=<name> and the unknown-view error's suggestion list.

for _, v in ipairs(renderer.captureView.list()) do ... end

modules/renderer/captureView.ready

captureView.ready(name: string): boolean

Whether a registered view can draw yet. A view's passes are enqueued from the moment its render feature first runs, but they are skipped while the materials they name have no pipeline — their shader is still compiling — so for the first frames of a session a camera bound to the view renders the ORDINARY view into its target, and the image gives no sign of it. This reports the difference, and reports it before any camera is on the view, so it is answerable for the first camera bound to one. False for an unregistered name.

Parameters

  • name string — The view name.
repeat task.wait() until renderer.captureView.ready("zfighting")

modules/renderer/captureView.register

captureView.register(name: string, config: any): number

Register (or update) a content capture view under name and return the debug CHANNEL number assigned to it. A render feature gates its pass to this channel (debugChannel = channel) so the pass draws only when a capture selects the view. Idempotent: re-registering the same name keeps its channel.

Parameters

  • name string — The view name, selected via capture pass=<name>.
  • config any (optional){ description?, ensure?, warmup?, renderLayers? }. ensure is called before a capture of this view so the feature that draws it is live (e.g. create it on demand). warmup is how many present frames a capture lets the view accumulate before it reads — set it when the feature retains prior-frame state (a temporal diff) so the first capture reads a warm result. renderLayers is the layer spec a capture of this view uses when the caller named none — a view that draws its own geometry and wants the scene's kept out of the frame (and out of the depth buffer it tests against) names only its own layer.
local ch = renderer.captureView.register("lightmap", { description = "...", ensure = fn })

modules/renderer/captureView.resolve

captureView.resolve(name: string): any

Resolve a capture view by name to its { channel, ensure, description, warmup } record, or nil when no view is registered under name.

Parameters

  • name string — The view name.
local v = renderer.captureView.resolve("lightmap")

modules/renderer/captureView.unregister

captureView.unregister(name: string): boolean

Withdraw a capture view. A subsequent capture pass=<name> no longer resolves to it (falls through to the unknown-view error).

Parameters

  • name string — The view name.
renderer.captureView.unregister("lightmap")

modules/renderer/clearShadowHero

clearShadowHero(): boolean

Release the hero caster, so the directional shadow is the cascades' alone again and the layer the hero view rendered into is given back.

renderer.clearShadowHero()

modules/renderer/clearShadowProxy

clearShadowProxy(mesh: string?): number

Stop proxying mesh, so it rasterizes its own geometry into shadow views again. Called with no argument, drops every registration.

Parameters

  • mesh string? (optional) — The mesh to stop proxying. Omit to clear all of them.
renderer.clearShadowProxy(statueMesh)
print(renderer.clearShadowProxy(), "proxies dropped")

modules/renderer/collect

collect(): RuntimeCollection

Release every runtime texture, material, mesh and render feature nothing holds: no handle a script still reaches, no live owner, no reference from live engine state, no asset backing it, no hold. A root scene load runs this once the new scene stands, so what the previous scene's content created and nothing still wears goes with that scene; calling it directly collects at any other moment. A session material's handle counts as reached while the entity it was keyed for stands, and stops counting once that entity is gone. It reaches the GPU textures the device holds beside the registry's own: a texture the cache loaded for an asset goes once nothing live names it and is read back from that asset the next time something asks for it, while one no asset answers for stays, there being nothing to read it back from — a render pass's own target, a colour swatch, an atlas the engine built. A texture the ASSET path uploaded and whose asset has since been removed has nothing to come back from either, and the collection decides about it from its holders the way it does about every other resource: a handle a script still reaches, a live owner, a reference from live engine state, a hold. Features go first, then materials, then meshes, then textures, so a texture only a released material named goes with the material. Runs a full garbage collection first, so a handle nothing reaches counts as let go, and yields for the frame the census runs on. A handle the calling function still has in a variable — or in a temporary it has not overwritten — is one a script reaches, so a resource created in the function that collects is let go by the next collection rather than this one.

local c = renderer.collect() print(c.released.texture, c.kept)

modules/renderer/compiledShaders

compiledShaders(): { string }

Every name renderer.compiledSource answers for — one per name a shader compile has run under this session, whether it succeeded or failed. What makes the composed-source surface enumerable rather than something to guess a key for.

for _, name in renderer.compiledShaders() do print(name) end

modules/renderer/compiledSource

compiledSource(shader: string): string?

The WGSL the shader compiler received under one name, exactly as it received it — the composed module, which is what a compile error's line numbers and handle indices are positions in. Answers under any name a compile ran under (identity, guid, alias, or a program from renderer.shaderVariants()), for a shader that declares no features, and for a shader whose compile FAILED, which is the case it exists for: a message about a function body carries a position and nothing else, and the text that position is in is this. The failed text stands for as long as shaderRef:compileStatus() reports that failure under the same name.

Parameters

  • shader string — Any name a shader compiled under — identity, guid, alias, or a shaderVariants() program name.
local status = asset.resolve("myShader", "shader"):compileStatus()
if status.status == "failed" then
print(status.error)
print(renderer.compiledSource("myShader"))
end

modules/renderer/compositeSize

compositeSize(): { width: number, height: number }

The size of the image the post-scene phases worked on in the last presented frame — the target the UI composites onto, which every pass after the scene reads as @scene.color and writes into, and which a screenSpace = "composite" render target follows. While the renderer presents the viewport itself that is the display's own size, whatever fraction of it the scene rasterized at; while a UI viewport panel owns the presentation it is the size the scene rasterized at, since the panel draws the scene target at its own rect and nothing upscales before the composite. Both read 0 before a frame has drawn.

local c = renderer.compositeSize()

modules/renderer/cullStats

cullStats(): {

What the last completed frame decided to draw. total renderables went into the frustum test, culled fell outside it and visible survived. Of those, occlusion culling measured occlusionTested against the depth pyramid and proved occlusionCulled were entirely behind other geometry — both 0 while renderer.occlusionCulling() is false. A renderable the pyramid has no say over — one that laid no depth in the pre-pass, one whose bounds were never recorded, one straddling the near plane — is measured against nothing and counted in neither, so the gap between visible and occlusionTested reads how much of the frame the test could speak for.

This answers for the main camera. What a shadow view's own volume did with the frame's casters is on that view's row in renderer.shadowViews().

local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")

modules/renderer/debugPass.builtins

debugPass.builtins(): { string }

The built-in debug-pass names, one per channel in channel order — the engine's built-in pass vocabulary (final, albedo, normal, depth, …).

for _, n in ipairs(renderer.debugPass.builtins()) do ... end

modules/renderer/debugPass.channel

debugPass.channel(name: string): number?

The channel a debug-pass NAME renders on: a built-in pass, else a content capture view registered via renderer.captureView. Nil when the name is neither — the signal a selector uses to reject an unknown pass.

Parameters

  • name string — A debug-pass name (e.g. "normal", "depth", "lightmap").
local ch = renderer.debugPass.channel("normal")   -- 7

modules/renderer/debugPass.list

debugPass.list(): { string }

Every selectable debug-pass name: the built-in passes plus every registered content capture view. What a debug-pass selector offers.

local passes = renderer.debugPass.list()

modules/renderer/debugPass.name

debugPass.name(channel: number): string?

The canonical NAME for a debug channel: a built-in pass name for a built-in channel, else a registered capture view's name. Channel 0 is "final" (the lit image). Nil when no pass owns the channel.

Parameters

  • channel number — The channel number.
local name = renderer.debugPass.name(7)   -- "normal"

modules/renderer/depthPrepass

depthPrepass(): boolean

Whether the opaque depth pre-pass is currently enabled.

modules/renderer/depthPrepassOrder

depthPrepassOrder(): { runs: number, reordered: number }

What the last frame's depth pre-passes planned, and how far their sequences were from near-to-far before they ordered. runs counts the instanced draws planned; reordered counts the adjacent pairs the sort moved past each other, taken before it ran. Both are summed over every pre-pass the frame ran — the window plus each render-target camera, each ordering against its own camera. Both read 0 while the pre-pass or the ordering is off, and reordered reads 0 for a frame that already stood in order. The ordering leaves no other trace — the draws, the depth and the image are the same either way.

local o = renderer.depthPrepassOrder()  -- o.reordered > 0 → it sorted

modules/renderer/depthPrepassOrdering

depthPrepassOrdering(): boolean

Whether the depth pre-pass is submitted nearest-first.

modules/renderer/destroy

destroy(handleOrKind: any, id: string?): boolean

Free the GPU resource a renderer resource holds (the GPU-destroy verb). Takes any of the forms that name it: the handle a create returned, routed by its category so one call releases a mixed set of handles; the id a listing hands out, whose kind is read back off what the renderer holds under it — the runtime registry, the material definitions, the live features, and the device itself for an asset's own texture or mesh; or the kind with the id beside it, the shape renderer.hold and renderer.references take, which is what names the kind for an id two of them answer to. An id nothing holds anything under releases nothing and answers false. The on-disk asset, if any, is untouched. A CPU handle's :unload() frees the CPU copy separately.

Parameters

  • handleOrKind any (optional) — A MeshHandle, TextureHandle, MaterialHandle or feature handle; the id itself; or the kind ("texture", "material", "mesh", "feature") with the id as the second argument.
  • id string? (optional) — The guid or registry key, when the first argument is a kind.
renderer.destroy(meshHandle); renderer.destroy(materialHandle)
renderer.destroy(renderer.texture.list()[1].guid)
renderer.destroy("mesh", guid)

modules/renderer/deviceGeneration

deviceGeneration(): number

Which render device this process is on, counted from the first.

A render device is lost when a driver resets, when the GPU is taken away, or when a browser reclaims a WebGPU context. The engine answers by building another device and re-deriving this session's resources onto it, and this number moves by one each time it does. Anything held across frames that was built from a GPU resource records this beside it and remakes it when the two differ; engine.onDeviceRebuilt is the hook that fires when it moves.

local generation = renderer.deviceGeneration()
engine.onDeviceRebuilt(function(g) print("device " .. g) end)

modules/renderer/deviceState

deviceState(): string

Whether the render device this process draws through is the one it is using, one it is replacing, or one it has stopped trying to replace.

"ready" is a live device. "rebuilding" is the window between a device reporting itself lost and another being in place: every GPU resource built from the old one is invalid, the frames in that window draw nothing, and anything reaching the GPU refuses. "abandoned" is after the engine gave up — the adapter refused every attempt, so this session draws no more frames.

Work that spans the device — build a render target, draw into it, read it back — reads this to tell an operation that failed because the device went out from under it, which is worth doing again once renderer.deviceGeneration() moves, from one that failed on its own terms. The loss is reported before the next device exists, so the two readings answer different halves: this one says a replacement is coming, the generation says it arrived.

if renderer.deviceState() == "rebuilding" then return end

modules/renderer/drawDiagnostics

drawDiagnostics(): { DrawDiagnostic }

Every renderable that is NOT drawing what its material says — the one call for "why does this surface look wrong". Three states land here: a surface rendering as the magenta placeholder (substituted), one the renderer could bind nothing for at all (outcome = "skipped"), and one drawing a program whose most recent compile FAILED (stale), which is what a shader edited into brokenness looks like — the pipeline its last good compile built keeps drawing, so the picture is intact and answers to none of the edits since. Each row names the entity, the program asked for, the program bound, programStatus — the compile gate's word about the program the material NAMED — and the one cause from shaderCompileFailed / shaderNotRegistered / shaderNotCompiledYet / noGbufferEntry / renderStateKeyNotBuilt / noPipelineForTarget / unshaded, with the compiler's own message in detail or programError. Covers every renderable the renderer holds, whether or not a camera reached it: a row with observed = false and outcome = "notDrawn" carries the renderer's own resolution for one this frame drew nowhere, so a broken surface off-screen is reported the same as one in frame. An empty result means every renderable the renderer holds is drawing the program its material named and that program compiles. Answers on the deferred path as well as forward, and in edit mode as well as play.

for _, d in renderer.drawDiagnostics() do print(d.entity, d.reason, d.detail) end
if #renderer.drawDiagnostics() == 0 then print("every surface is drawing what it says") end

modules/renderer/drawStats

drawStats(): {

What the last completed frame actually submitted. draws counts every geometry draw call the frame issued — the camera's passes, each shadow view a shadow-casting light adds, and whatever a render feature draws — and instances counts the instances those draws covered. The pair is what separates one draw carrying five hundred instances from five hundred draws carrying one each, so it reads how well the scene batches rather than how many objects are in it.

compacted is how many of those instances the frame planned through draws whose instance count the GPU decides: the culler's own per-object answers packed into a dense run, so an object it rejects is absent from the draw instead of collapsing to nothing in the vertex stage. compactedDrawn is how many of them survived, counted on the GPU as it packed them — a pass that then skips a whole draw over its own layer or visibility answer leaves that draw's instances in both numbers.

The plan is made over the populations the frame draws, and the tests answer which of their instances the packing keeps. That packing runs before any pass has resolved the depth occlusion culling is tested against, so on its own it reads the frustum and screen-size answers alone. With setOcclusionCulling armed the frame packs the same plan a second time once the test has answered, and compactedDrawn then counts what came through occlusion as well.

compactedDrawn comes back from the buffer the GPU wrote, so it describes a frame that has finished while compacted describes the most recent plan, and it holds the last count the GPU wrote until another arrives — a frame that compacts nothing reads compacted 0 beside the count from the last frame that did. In a scene standing still the gap between the two is the front-end work culling removed.

materialBinds is how many times the frame's geometry passes set a material's parameter group, and materialBindsElided how many times a pass reached that decision and found the group already bound. Their sum is how many times the decision was reached — once per unit of geometry submitted, which sits at or below draws, since a mesh of several primitives draws once per primitive under one set of binds. The ratio inside the pair is what material binding costs the frame: the batched opaque geometry is gathered into runs sharing a material, so a frame of many such draws over few materials binds about once per material rather than once per unit. materialExtraBinds and materialExtraBindsElided are the same pair for the second group, the storage bindings a shader declares for itself, which only the shaders that have them ever bind.

pipelineBinds and pipelineBindsElided are the same pair for the pipeline itself: how many times the frame's geometry passes set one, and how many times a pass reached that decision and found the pipeline it wanted already bound. Which pipeline a unit needs follows its shader, its material's render state and its mesh's vertex layout together, so a scene whose units share all three costs one set for the run of them, while units differing in any one of the three each pay their own. Their sum is how many units reached the pipeline decision, which sits at or above what the material pair reports: a unit the pass settles a pipeline for and then abandons — one whose material group resolved to nothing — counts here and never reaches the material decision.

Every figure here is the whole frame's, the main camera's draws and every shadow view's summed together. renderer.shadowViews() splits compacted and compactedDrawn across the views that made them, and carries the camera's own share beside them.

local d = renderer.drawStats(); print(d.instances / math.max(d.draws, 1), "instances per draw")
print(renderer.drawStats().compactedDrawn, "of", renderer.drawStats().compacted, "survived the cull")
local d = renderer.drawStats(); print(d.materialBinds, "material binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")

modules/renderer/feature.create

feature.create(ref: any, guid: string?): any

Instantiate a render feature so the engine calls its render(ctx) hook every frame. ref is an AssetRef<renderFeature> whose init.luau returns { setup?, render, teardown? }. Returns a live RenderFeatureHandle (its guid is the stable id, same as mesh/texture handles); tear it down with renderer:destroy(handle). Pass guid to assign a specific id.

Parameters

  • ref any (optional) — An AssetRef<renderFeature>, or a string identity/guid resolved via asset.resolve(ref, "renderFeature").
  • guid string? (optional) — Optional explicit handle guid (minted when omitted).
local h = renderer.feature.create(asset.resolve("acrylic", "renderFeature"))
local h = renderer.feature.create("acrylic")

modules/renderer/feature.destroy

feature.destroy(handleOrGuid: any): boolean

Tear down a live render feature by its RenderFeatureHandle OR its guid string — the by-id path for when the handle was lost (e.g. across execute calls). Same effect as renderer.destroy(handle). Returns true if a feature was live under that id.

Parameters

  • handleOrGuid any (optional) — A RenderFeatureHandle or its guid string.
renderer.feature.destroy("eb623975-d8bd-47a1-a0a4-5c0e56238e2f")

modules/renderer/feature.list

feature.list(): { { guid: string, identity: string } }

List every render feature currently live (running its render(ctx) each frame). Each entry is { guid, identity } — the guid is the same id a RenderFeatureHandle carries, so you can tear a feature down by guid even after losing its handle (e.g. across separate execute calls).

for _, f in renderer.feature.list() do print(f.identity, f.guid) end

modules/renderer/feature.shaded

feature.shaded(): { [string]: number }

How many pixels each fragment pass a render feature enqueued shaded on the last drawn frame, keyed by the pass's shader/effect name. A fragment pass draws one triangle over its target, so it shades the whole screen whatever its effect actually reaches — unless it declares bounds on the pass spec, the world-space box its effect stays inside, in which case it shades the rectangle that box projects into for the camera drawing it and is skipped for a camera that cannot see the box at all. This is the reading that says which of the two a pass is: it moves when the effect moves, and a pass absent from it shaded nothing. Summed over every camera the frame drew.

local px = renderer.feature.shaded(); for name, n in pairs(px) do print(name, n) end

modules/renderer/featureTexture.configure

featureTexture.configure(width: number, height: number, layers: number)

Size the shared feature-texture array — the layers a surface shader reads through zero_feature_texture(uv, layer), and the layers a SpotLight projects through its cone via cookieLayer. Layers are rgba16f. A call for the size the array already has is left alone. One that changes the size reallocates, and the replacement is zeroed — so it empties every layer in the array, including the layers other features and other cookies own. renderer.featureTexture.state() reports the extent and the layers holding content, which is how a feature re-fills the layer a resize took from it.

Parameters

  • width number — Layer width in pixels.
  • height number — Layer height in pixels.
  • layers number — How many layers the array holds.
renderer.featureTexture.configure(512, 512, 4)

modules/renderer/featureTexture.setLayer

featureTexture.setLayer(layer: number, textureKey: string, x: number, y: number)

Copy a texture already on the GPU into one layer of the shared array, its top-left corner at (x, y) — GPU to GPU, with no readback. Several small images pack into one layer by calling this once per image at different offsets. The source must be rgba16f and fit at that offset.

Parameters

  • layer number — Which layer of the array to write into.
  • textureKey string — The source texture's name — the one it was created under. A compute.createStorageTexture2D target, a compute.createTextureHistory pair (its current side), and a texture a compute.copyBufferToTexture wrote all answer to the name they were given.
  • x number — Left edge of the destination rectangle, in pixels.
  • y number — Top edge of the destination rectangle, in pixels.
compute.createStorageTexture2D("gobo", { width = 256, height = 256, format = "rgba16f" })
renderer.featureTexture.setLayer(0, "gobo", 0, 0)

modules/renderer/featureTexture.state

featureTexture.state(): {

What the shared feature-texture array is right now: the extent every layer carries, and filled, the ascending 0-based indices of the layers a setLayer has landed in since the array was last sized. One array is shared by every feature and every light cookie in the scene, and it has no allocator, so this is the call that tells a feature whether the array it sized and filled is still the array it is writing into — a configure that changed the size reallocates and zeroes every layer, and the layer it emptied leaves filled without it. Measured off the renderer at the end of the last rendered frame, so a configure or setLayer issued this frame reads back on a later one.

What this 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 for as long as it lives, so filled is the reading that answers whether the layer behind a cookieLayer is live right now.

local ft = renderer.featureTexture.state()
print(("feature textures: %dx%d over %d layers"):format(ft.width, ft.height, ft.layers))
-- Re-fill the cookie layer this module owns if anything emptied it.
if ft.width ~= myWidth or table.find(ft.filled, myLayer) == nil then
refillMyCookie()
end

modules/renderer/framePacing

framePacing(): FramePacing?

How far the CPU is allowed to run ahead of the GPU, and what holding it there cost the frame just finished. Submitting work to the GPU returns before the GPU has done it, and everything that submission holds — its staging allocations, its bind groups, its command buffer — stays alive until it completes. A frame that asks for more work than the GPU finishes in a frame's time therefore leaves that behind it, and unbounded that is memory growth rather than a lower frame rate.

framesInFlight is how many submitted frames have not reported done through the queue's completion signal, held under maxFramesInFlight: a device that keeps up reads under the bound, one that is behind reads at it. It counts submissions, which is its own quantity — how many presented images the swapchain permits in flight is a separate setting. mechanism names how that bound is enforced here: submission-wait waits for the frame that many frames back and reports the wait in waitMs, so a paced frame costs latency and still draws; submitted-work-done counts outstanding frames off the queue's completion signal and declines to start a frame while the bound is met, counting those in pacedFrames and leaving the last presented image up. submittedFrames counts the frames that were admitted and submitted, so it rises for as long as the renderer is producing frames — which is what tells a renderer running slowly under a tight bound from one that has stopped. stalled reads true while that completion signal has stopped arriving and the pacer stood down rather than hold the image indefinitely; it clears on the first frame that finds the count back under the bound.

producing is whether the renderer is drawing frames at all. A headless renderer draws into an offscreen framebuffer that nothing presents, so its image reaches a reader only through something that copies it out: it draws while a consumer is asking — an MCP call in flight, a queued texture readback, a recording, a frame-egress session — and declines the frames between two asks, counting them in idleSkippedFrames. Every other renderer stat answers with the last frame that drew, so producing is what separates a live reading from a frozen one. A windowed renderer presents every frame it draws and reads producing = true throughout.

presentMode is what the surface presents with and presentModes what it offers; both are empty of meaning on a headless renderer, which never presents.

local p = renderer.framePacing()
print(("%d of %d frames in flight, paced by %s"):format(p.framesInFlight, p.maxFramesInFlight, p.mechanism))

modules/renderer/getRaytrace

getRaytrace(): boolean

Whether ray tracing is currently enabled.

modules/renderer/gpuMemory

gpuMemory(): GpuMemory

Where the renderer's GPU memory went at the last completed frame — the call to reach for when something is holding memory and you do not know what.

Three figures answer three different questions, and they are meant to be read against each other:

  • The categories — shadow, textures, meshes, instances, compute, summing to categorised — are the renderer's own accounting of what it asked for on purpose. Always present, on every backend.
  • allocator is the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. It exceeds categorised by the per-frame render targets and the scratch nothing categorises. The allocator hands memory out from blocks it reserves whole from the device and returns a block only once nothing is left in it, so reservedBytes runs above allocatedBytes by what those blocks hold unused; blocks lists them emptiest first with the labels that keep each one alive, and emptyBytes plus slackBytes is that distance exactly — the pool held in empty blocks, and the room pinned inside blocks something still sits in.
  • driver.deviceLocalBytes is what the graphics driver charges this process, out of the kernel's own accounting. It is the biggest of the three and the one that fills a card, because it also holds the swapchain, the images the driver keeps on the renderer's behalf, and the rounding to whole pages and heap blocks that neither figure above sees. Read it when the question is how much of the machine's GPU this engine is using; read the two above when the question is what the engine spent it on. A platform with no per-process accounting reports available = false and the reason.
  • driver.outsideAllocatorBytes is that charge less everything the allocator reserved — what the driver holds on its own account, and the one figure here nothing releases: a dropped pipeline, another scene and renderer.collect() all leave it where it is, and it falls when the device is destroyed. Read it when a session's device memory has grown and no ledger row accounts for the growth.

compute is what the compute subsystem holds; compute.observe() names each of those resources and what it costs. renderTargets counts the offscreen render targets the renderer holds at that frame, which is what says a renderer.destroy has been applied rather than queued.

local m = renderer.gpuMemory()
print(("shadows %.1f MiB"):format(m.shadow.total / 1024 / 1024))
-- how much of the card this engine is holding:
if m.driver.available then
print(("driver %.1f MiB"):format(m.driver.deviceLocalBytes / 1024 / 1024))
else
print("no driver figure: " .. m.driver.reason)
end
-- the biggest allocation in the engine:
local top = m.allocator and m.allocator.byLabel[1]
print(top and top.label, top and top.bytes)
-- the block held for the least, and what pins it:
local block = m.allocator and m.allocator.blocks[1]
if block and block.allocationCount > 0 then
print(block.size, block.allocatedBytes, block.byLabel[1].label)
end

modules/renderer/hold

hold(handleOrKind: any, id: string?): boolean

Pin a runtime resource for the session. A held texture, material, mesh or render feature survives every collection — the one a root scene load runs and a direct renderer.collect() alike — until renderer.release lets it go or its destroy frees it. It is the way to keep an ad-hoc resource across the scenes that come and go under it. A hold keeps the resource in the registry; a mesh's GPU buffers are governed by what draws it, parked as a CPU definition when the last instance naming it goes and brought back when one names it again, so renderer.mesh.isResident(guid) is the separate question about the buffers.

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind ("texture", "material", "mesh", "feature") with the guid or key as the second argument.
  • id string? (optional) — The guid or key, when the first argument is a kind.
renderer.hold(tex)
renderer.hold("material", "swatch")

modules/renderer/instanceData.clear

instanceData.clear(target: string | entityRef)

Drop every lane of an entity's per-instance shader data, so its draws read zero again — how a feature releases a subject it is still holding. Despawning an entity releases its block too, so this is for a subject that stays. It takes an entity that has already gone, which is when a feature releasing its subjects often runs, and does nothing for an entity holding no block.

Parameters

  • target string | entityRef — The entity — a proxy from entity(...) / entity.spawn(...), or an entity-id string.
renderer.instanceData.clear(subject)

modules/renderer/instanceData.laneCount

instanceData.laneCount(): number

How many vec4 lanes each entity's per-instance block holds, so a lane index runs 0 .. laneCount() - 1. The same count a surface shader indexes input.shader_data against.

for lane = 0, renderer.instanceData.laneCount() - 1 do
renderer.instanceData.set(subject, lane, 0)
end

modules/renderer/instanceData.set

instanceData.set(

Write one vec4 lane of an entity's per-instance shader data — the channel that lets ONE material serve many entities that differ in a value. A surface shader reads the lane back as input.shader_data[lane], so a dissolve at its own progress per subject, an effect at its own age per firing, or a per-entity mask costs one material rather than one material per entity.

The engine attaches no meaning to a lane: a feature picks the lane indices it owns and packs whatever its shader agrees they carry. Name those indices in the module that writes them, so the writer and the shader read the block the same way.

The write reaches the block where it is called, so the entity it names is the one holding that id at that point in the tick, and the value is on the draw from the next frame. It is held until the lane is written again, the entity's block is cleared, or the entity is despawned — a despawned entity releases its whole block. A lane an entity was never given reads zero.

modules/renderer/loseDevice

loseDevice()

Destroy the render device on the next frame, so the engine meets a real device loss.

This is the one loss that can be caused on purpose, and it travels the same path a driver reset does: frames draw nothing until the rebuild lands, GET /engine/status reports the renderer as recovering while it does, engine.onDeviceRebuilt fires afterwards, and renderer.deviceGeneration() moves. Use it to prove that a world's content survives a device loss — anything it holds only on the GPU has to be remade from the rebuild hook, and this is how you find out whether it is.

renderer.loseDevice()
-- some frames later:
print(renderer.deviceGeneration()) -- one higher than before

modules/renderer/mainCameraView

mainCameraView(): { number }?

The main camera's inverse view-projection (column-major, 16 numbers) followed by its world position (3 numbers) — {m0..m15, px,py,pz} — for reconstructing world positions from the depth buffer in a ray-tracing pass. Nil before the first render.

modules/renderer/material.animatedTexture

material.animatedTexture(

Build a material that PLAYS a layered texture: its layers bound as the frames, its timing bound beside them, and the engine's animatedTexture shader turning the clock into the layer showing now. One call from an imported animated image to a material an entity can wear.

The layer showing is resolved per pixel against the texture's own schedule, so frames of unequal length are shown for the lengths they were authored with, and the sequence loops. speed scales the clock (2 plays twice as fast, 0 holds the frame startTime lands in) and startTime offsets into the sequence, so two surfaces sharing one texture can run out of phase.

The clock is the engine's, and it runs in edit mode as much as in play and through a pause, so two screenshots of one surface taken moments apart are two different frames of it. speed = 0 holds one frame for as long as it is set, which is the state to compare two screenshots in.

The returned handle is what a surface wears — Model:applySessionMaterial takes it, and so does a Model's material field. The handle's guid is this material's REGISTRY KEY, the currency of setProperty, describe and destroy; a component field resolves an asset, so a bare key in one leaves the component waiting for an asset to register under that name.

The builtin plane mesh emits uv = (u, v) with v along its own +Z, so a quad pitched +90° about X (Transform.eulerToQuat(0, math.pi / 2)) shows the image upright to a camera on +Z, and -90° shows it first-row-last.

A texture whose layers carry no timing is rejected — there is nothing to play. renderer.texture.info(bytes).animated is the test.

local mat = renderer.material.animatedTexture("banner.texture")
local id = entity.spawn("billboard", { rotation = { Transform.eulerToQuat(0, math.pi / 2) } })
entity(id).component.add("Model", { model = "plane" })
entity(id).component.get("Model"):applySessionMaterial(mat)
renderer.material.setProperty(mat.guid, "speed", 2)

modules/renderer/material.create

material.create(content: MaterialContent, key: string): MaterialHandle

Parameters

  • content MaterialContent
  • key string

modules/renderer/material.describe

material.describe(key: string | { [string]: any } | AssetRef): any

The recoverable definition ({ shader, properties, textures, name }) this module registered under key via renderer.material.create, or nil for keys registered elsewhere (e.g. material assets resolved by the assetType). properties and textures carry the material's current values: each setProperty / setTexture write lands on this record, a texture slot under the GPU key the slot binds by — these are the WRITES, held here whether or not the renderer took them up. renderer beside them is what the renderer holds for the same key: the program its prepared bind group was built against, the render state its draws are looked up under, whether a pipeline exists for that key, and how many draws the observed frame gave it. renderer is nil when the renderer holds no material under this key at all, and resident states the same fact as a boolean. Writes reach the screen through both halves: resident = false says the renderer holds nothing to put them in, and renderer.draws = 0 on a resident material says it holds them and no renderable is drawing with it. For a material that is resident AND drawn and still looks wrong, renderer.drawDiagnostics() names the renderable and the cause.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.

modules/renderer/material.destroy

material.destroy(key: string | { [string]: any } | AssetRef): boolean

Drop a runtime material registered via renderer.material.create: clears its recoverable definition, unregisters its runtime-resource stamp so it is no longer swept into the material freeze/save flow, and frees the GPU record. Use for transient materials (e.g. a preview swatch) that must not outlive their use. The on-disk asset, if any, is untouched.

The reach is the registry: after this, describe and list stop answering for the key. A surface already wearing the handle goes on drawing what it was given — Model:restoreSessionMaterial is what puts a Model back on its authored material.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key (the one passed to create), the MaterialHandle create returned, or an AssetRef from asset.resolve.
renderer.material.destroy("__preview_swatch_" .. texGuid)

modules/renderer/material.list

material.list(): { any }

Every runtime material currently registered, ordered by registry key. Each entry carries the key, where it came from, and the shader it binds. renderer.references("material", key) says what is still holding a row, and renderer.collect() releases the rows nothing holds.

for _, m in ipairs(renderer.material.list()) do print(m.guid, m.shader) end

modules/renderer/material.renderState

material.renderState(key: string | { [string]: any } | AssetRef): MaterialObservation?

What the renderer holds for a material, which is a different document from the values written to it. shader is the program its prepared bind group was built against, renderState the blend / cull / topology / queue / depth key its draws are looked up under, keyBuilt whether a pipeline exists for that key, and draws / instances / placeholderDraws / binds / bindsElided what it cost in the frame the renderer last observed — those five read 0 until something arms per-draw recording, which renderer.materialCost() and renderer.drawDiagnostics() do. nil means the renderer holds no material under this key at all — the writes landed on a record nothing is drawing with.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.
local r = renderer.material.renderState("water"); print(r.renderState.blend, r.keyBuilt)

modules/renderer/material.sessionKeyFor

material.sessionKeyFor(entityId: string): string

The canonical registry key for an entity's SESSION material — the runtime material a system (e.g. GI baking) shows on an entity in place of its authored material for the lifetime of the engine session. One session material per entity: create it under this key, hand the handle to Model:applySessionMaterial, and the component re-adopts it across VM reloads by probing this key with describe. The key names the entity for as long as the entity stands: once it is gone the session store lets the handle go, and a collection releases the material and whatever its bindings were the last to hold.

Parameters

  • entityId string — The entity carrying the material.
local key = renderer.material.sessionKeyFor(entityId)

modules/renderer/material.setProperty

material.setProperty(key: string | { [string]: any } | AssetRef, name: string, value: any): ()

Push one changed uniform property to a registered material's GPU record (frame-fast incremental update; no re-register). Keyed by the material's registry key. The value written becomes the material's current one: it is what describe reports, and — for a property the material's shader declares, which is what the uniform buffer is packed by — what a material AssetRef reads back through getProperty / getProperties and what the surface is drawn with. A write under any other name reaches the record describe reports, which is where it reads back.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.
  • name string — Property name.
  • value any (optional) — New value.
renderer.material.setProperty(asset.resolve("wall", "material"), "roughness", 0.2)

modules/renderer/material.setTexture

material.setTexture(key: string | { [string]: any } | AssetRef, slot: string, ref: string | { [string]: any } | AssetRef): ()

Push one changed texture slot to a registered material's GPU record. Keyed by the material's registry key.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.
  • slot string — Texture slot name ("base_color_texture", …).
  • ref string | { [string]: any } | AssetRef — Texture reference — a .texture guid / identity / name / path, the image path it was imported from, a color: / default: form, a live GPU handle, or a texture AssetRef carrying one. An asset reference is materialised (Disk→CPU→GPU) and bound by the key the upload lands under.
renderer.material.setTexture("sky", "sky_texture", "panorama.texture")

modules/renderer/materialCost

materialCost(): { MaterialObservation }

What each material cost the frame the renderer last drew, and the state it holds each one under. One row per material the renderer holds a prepared bind group for — a material an author wrote and the renderer never prepared is absent, which is itself the answer to "why is nothing I set reaching the screen". draws and instances cover that one frame; placeholderDraws is how many of those draws bound the magenta placeholder instead of this material's own program; binds is how many material-owned bind groups the frame's passes SET for it and bindsElided how many of its draws wanted a group the pass already held, which is what draw-key sorting buys; a draw that fell back to the placeholder bound the placeholder's group, so it counts in placeholderDraws and in neither bind count. uniformBytes is the GPU uniform buffer's own size, which is the reflected property block raised to the 16-byte floor and rounded up to the copy alignment. renderer.drawDiagnostics() names WHICH renderable is not drawing what its material says, and why.

for _, m in renderer.materialCost() do print(m.material, m.draws, m.binds, m.bindsElided) end

modules/renderer/materialIdentity

materialIdentity(): MaterialIdentity

Which material each renderable draws with, as a number a shader can carry. A material is authored and bound by name, and no shader can read a string — so every renderable's per-instance record holds a material index instead. slots is the name → index table those indices are drawn from: an index is assigned the first time the renderer draws with that material and does not move afterwards, so two renderables that differ only in material read different indices, and one renderable reads the same index frame after frame. It follows that the table keeps a row for every material name drawn this session, whether or not anything still draws with it. renderables is a row per renderable that owns a GPU slot — the entity it belongs to, that slot, and the index the record at it carries; populations is the same for an instanced draw, whose whole reserved run of slots carries the one material its registration named. That index is what a shader reads as instance_data[slot].material_index, and the row a ray hit resolves through zeroMaterial(). A renderable draws with the material its entity references, so one whose entity names none carries index 0.

local id = renderer.materialIdentity()
for _, r in id.renderables do print(r.entity, r.slot, r.index, r.material) end

modules/renderer/materialIndex

materialIndex(name: string): number?

The index standing for a material, or nil for one the renderer has not drawn with yet. Pass it to a shader (or compare it against what a shader read out of instance_data[slot].material_index) to tell which material a drawing instance carries.

Parameters

  • name stringstring Material name, as renderer.material.create filed it.
local red = renderer.materialIndex("brick_red")

modules/renderer/maxAnisotropy

maxAnisotropy(): number

The highest anisotropy this device honours: 16 on hardware that filters anisotropically, 1 on hardware that does not, where a higher request would be downgraded to trilinear regardless. Read it to report quality honestly — renderer.setAnisotropy clamps for you, so a request never needs guarding.

local best = renderer.maxAnisotropy()

modules/renderer/mesh.boundsSource

mesh.boundsSource(mesh: string | { [string]: any } | AssetRef): string

Where this mesh's culling bounds come from. "compute" once a compute pass has written its vertices: the engine reduces those vertices to an AABB every frame, so the mesh is culled against the geometry the pass produced wherever it puts it. "geometry" otherwise: the AABB of the geometry the mesh was created with.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
print(renderer.mesh.boundsSource(mesh))

modules/renderer/mesh.buildClusters

mesh.buildClusters(mesh: string | { [string]: any } | AssetRef): string?

Build a cluster-LOD DAG (Nanite-style virtualized geometry) for the static CPU mesh held under guid and return its serialized data.clusters bytes. Returns nil when the mesh is degenerate, and on an engine whose renderer.mesh.canBuildClusters reports false. Pair with renderer.mesh.uploadClusters.

Parameters

  • mesh string | { [string]: any } | AssetRef — A mesh loaded into the CPU store (renderer.mesh.loadCpu) — the MeshCpuHandle, a MeshHandle, a guid, or a mesh AssetRef.
local cb = renderer.mesh.buildClusters(cpu)

modules/renderer/mesh.canBuildClusters

mesh.canBuildClusters(): boolean

Whether this engine bakes cluster-LOD hierarchies. It reads the binding the running engine registered: every target the engine ships on carries the builder, so a mesh loaded in a browser bakes its own clusters the same way one loaded natively does, and an engine built without it reports false and answers nil from renderer.mesh.buildClusters.

if renderer.mesh.canBuildClusters() then ... end

modules/renderer/mesh.clusterBakeBudget

mesh.clusterBakeBudget(ms: number?): number

The wall time one frame may spend advancing scheduled cluster bakes, in milliseconds — set first when ms is given. A slice always runs at least one unit of the build, so the budget bounds what a frame spends by choice and the largest single unit a mesh imposes sets the floor under it.

Parameters

  • ms number? (optional) — New per-frame budget in milliseconds, capped at 1000. A value that is not a positive, finite number raises.
renderer.mesh.clusterBakeBudget(2)

modules/renderer/mesh.clusterBakes

mesh.clusterBakes(): { [string]: any }

What the scheduled cluster bakes are costing. budgetMs is the slice a frame may spend, pending how many bakes are queued, completed how many have finished since the engine started, dropped how many left the queue because the geometry they were scheduled over stopped being readable, and heldBytes the source geometry the queue is holding across all of them — the vertex pool and index run the bake at the head is reading, plus a copy for each queued mesh the engine holds no definition for. inFlight is one row per queued bake — { guid, cpuMs, frames, slices, bytes, state }: the wall time spent advancing it, the frames it has been queued for, the slices it has been advanced by, the geometry it is holding, and "baking" for the one being advanced against "queued" for the ones waiting their turn.

print(renderer.mesh.clusterBakes().heldBytes)

modules/renderer/mesh.clusterComponents

mesh.clusterComponents(clusterBytes: buffer | string): (ClusterComponents?, string?)

Split a cluster blob (from renderer.mesh.buildClusters) into its GPU-ready component byte pools — the cluster vertex pool, the geometry-addressing pool (every cluster's local→global vertex map, then every cluster's triangle bytes), and the per-cluster record array — plus their counts. A cluster's triangles address positions inside its own vertex map one byte at a time, and a record's vertexOffset indexes the geometry pool in u32 elements while its indexOffset indexes it in bytes, so ONE binding resolves a corner. A pure decode (no GPU work): upload the pools into buffers a compute shader owns (shaderRef:createBuffer + buf:writeBytes) to drive a cluster draw from Luau.

Parameters

  • clusterBytes buffer | string — Serialized cluster bytes (binary-safe).
local c = renderer.mesh.clusterComponents(cb)

modules/renderer/mesh.clusters

mesh.clusters(mesh: string | { [string]: any } | AssetRef): { [string]: any }?

The shape of the cluster-LOD hierarchy the renderer holds for a mesh: clusterCount across every level, levelCount with the finest counted as one, and triangleCount across every cluster. The renderer keys one entry per mesh that carries a hierarchy, so this answers whether the mesh has clusters as well as what they are — nil for a mesh that carries none.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to read — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
local c = renderer.mesh.clusters(gpu) ; if c then print(c.levelCount) end

modules/renderer/mesh.create

mesh.create(src: any, guid: string?): MeshHandle

Create (or fetch) a GPU mesh resource and return its MeshHandle. src: a MeshCpuHandle from meshRef:load() (CPU→GPU upload under the asset's guid, idempotent — returns the resident handle if already uploaded); raw geometry {positions, indices, normals?, uvs?, colors?, uvs1?, unwrapUvs?, tangents?, skinning?, skins?} (a new runtime mesh — uvs1 is the lightmap UV set, unwrapUvs generates one, skinning/skins bind a skeleton); GPU compute buffers {vertexBuffer, indexBuffer, vertexCount, indexCount, aabbMin?, aabbMax?, prevVertexBuffer?} (size the vertex buffer at vertexCount * engine.vertexStride bytes, the engine's standard Vertex layout); or a MeshHandle (returned as-is). NEVER takes an AssetRef — load the CPU first.

prevVertexBuffer is a second buffer of the same size and layout holding those vertices as they stood on the previous frame. Naming it is what makes geometry a compute pass moves report a motion vector: the surface differences the two streams, so every consumer of screen-space velocity — motion blur, temporal reprojection — sees the movement. The engine fills it from the current vertices once per frame, ahead of that frame's compute dispatches, so a frame in which the pass does not run leaves the two streams equal and the geometry reports standing still.

morphTargets are the shapes the mesh can blend towards: a list of { name?, positions, normals? } records, each holding one offset per vertex from the base geometry, in the mesh's own vertex order. An entity blends them with ecs.MorphWeights, weight i scaling target i. A name makes the shape addressable as itself — renderer.mesh.morphTargets reads the names back and renderer.mesh.morphWeights drives them by name.

Raw geometry is read against the mesh type's conventions: indices count vertices from 0, and a triangle's FRONT face is the one whose vertices turn counter-clockwise as the viewer sees them — cross(v1 - v0, v2 - v0) points out of it. A material culls its back faces by default, so a triangle wound the other way draws nothing where it stands; reverse the index triple, or give the material render = { cull = "none" }, to draw that side. normals give the surface its outward direction and shade the face; the side that draws comes from the index order alone. uvs sample (0,0) at the image's top-left. Model space carries the world's basis: +X right, +Y up, -Z the direction transform.forward points. guides { path = "types/mesh" } has the whole table. A geometry src carrying keepCpu = true also keeps its geometry in the guid-keyed CPU store, so renderer.mesh.getVertices reads it and renderer.mesh.setVertices rewrites its positions in place — the per-frame deformation path, which sends positions alone where renderer.mesh.update re-sends the whole geometry. renderer.mesh.unloadCpu(mesh) releases that copy. Without it the geometry lives on the GPU alone and renderer.mesh.readback(mesh) is what brings it back.

Parameters

  • src any (optional) — A MeshCpuHandle, geometry, compute buffers, or a MeshHandle.
  • guid string? (optional) — Optional v4 guid for a NEW runtime mesh (minted Luau-side when absent). Ignored for the CPU-handle path (the asset's guid is used).
local gpu = renderer.mesh.create(meshRef:load())
local gpu = renderer.mesh.create({ positions = {...}, indices = {...} })
-- a runtime mesh whose positions are rewritten in place each frame
local m = renderer.mesh.create({ positions = P, indices = I, keepCpu = true })
renderer.mesh.setVertices(m, P)
-- a runtime mesh carrying a lightmap UV set (unwrapped at creation)
local gpu = renderer.mesh.create({ positions = {...}, indices = {...}, unwrapUvs = true })
-- a mesh with one shape to blend towards, driven by ecs.MorphWeights
local gpu = renderer.mesh.create({ positions = P, indices = I, morphTargets = { { positions = D } } })

modules/renderer/mesh.decode

mesh.decode(zmsh: buffer | string): (MeshGeometry?, string?)

Decode engine-native ZMSH bytes back into a MeshGeometry. Inverse of renderer.mesh.encode; each optional stream is present only when the blob carries it. Takes the bytes themselves — the geometry of a mesh the engine is holding comes from renderer.mesh.geometry(mesh).

Parameters

  • zmsh buffer | string — Engine-native ZMSH bytes (binary-safe).
local geom = renderer.mesh.decode(meshRef:getBytes())

modules/renderer/mesh.destroy

mesh.destroy(mesh: string | { [string]: any } | AssetRef): boolean

Release the GPU mesh mesh names, the release that pairs with renderer.mesh.create. Takes every form that names a mesh — the MeshHandle create returned, the guid renderer.mesh.list hands out, a MeshCpuHandle or a mesh AssetRef — and routes through renderer.destroy, the verb that releases any renderer resource by its kind. The CPU copy, if one was loaded, is freed separately by the CPU handle's :unload().

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to release — a MeshHandle, a guid, a MeshCpuHandle or a mesh AssetRef.
local m = renderer.mesh.create(cpu); renderer.mesh.destroy(m)
renderer.mesh.destroy(renderer.mesh.list()[1].guid)

modules/renderer/mesh.drawInstanced

mesh.drawInstanced(mesh: string | { [string]: any } | AssetRef, opts: any): InstancedDraw

Draw one mesh instanceCount times in a single call, each copy placed by a world matrix read from a GPU buffer. The population is a renderable in its own right — it goes through the mesh's ordinary pipeline and the material's ordinary bind groups, so it appears in the deferred pass, the forward passes and the shadow maps exactly as an entity-backed draw of that mesh does.

The buffer holds instanceCount column-major 4x4 matrices, 64 bytes each, tightly packed — the layout a vertex shader reads as array<mat4x4<f32>>, which puts each matrix's translation in its LAST four floats (Lua indices 13/14/15 for x/y/z). Packing row-major transposes every instance.

The matrices are COPIED into the engine's transform slots once per frame, which is what buys that full-pass parity. Rewrite the buffer between frames and the instances move — no re-registration, no re-upload.

material is what the population draws with, and it is required: a MaterialHandle (matRef:handle()), an AssetRef, or a registry key.

instanceDataBuffer names a second buffer, holding 64 bytes per instance — four vec4 lanes, tightly packed, in instance order. Those lanes arrive in the fragment stage as zero_object_data(in.instance_id, lane), the same read a per-entity __instancedata block answers, so the members of one population can differ in whatever their material's shader agrees the lanes carry. Copied every frame like the transforms, from a buffer a compute pass writes: the values never touch the CPU. Omit it and the lanes read zero.

reserveCount sizes the reservation above instanceCount so renderer.mesh.setInstanceCount can raise the drawn count later without re-registering; both buffers must back the reservation, not just the count.

mobility states whether the copies stand still — "static", or "movable" when it is left out. It is what a scene gather collecting geometry for precomputed lighting admits a population on, the same declaration Model.mobility makes for an entity: the transforms live in a buffer anything may rewrite between frames, so a population that says nothing is taken as one that moves.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh the population draws — a MeshHandle, the guid renderer.mesh.list hands out, a MeshCpuHandle or a mesh AssetRef. A registration holds the mesh on the device for as long as it lives, and takes a mesh that is currently held off the device — one nothing displays — back onto it.
  • opts any (optional){ transformBuffer, instanceCount, material, instanceDataBuffer?, reserveCount?, renderLayer?, castsShadows?, mobility? }.
local m = renderer.mesh.create({ positions = ..., indices = ... })
local buf = substrate.createBuffer({
name = "crowd.xf", type = "mat4", len = 64, kind = "gpu",
})
-- Column-major: translation lives at indices 13/14/15.
local xf = {}
for i = 0, 63 do
local m4 = { 1,0,0,0, 0,1,0,0, 0,0,1,0, i * 2, 0, 0, 1 }
for _, v in ipairs(m4) do xf[#xf + 1] = v end
end
buf:write(xf)
local rock = asset.resolve("rock", "material"):handle()
local draw = renderer.mesh.drawInstanced(m, { transformBuffer = "crowd.xf", instanceCount = 64, material = rock })

modules/renderer/mesh.dropClusters

mesh.dropClusters(mesh: string | { [string]: any } | AssetRef): boolean

Detach a mesh's cluster-LOD hierarchy and cancel a bake still in flight for it, so the renderer holds none for it. The inverse of renderer.mesh.uploadClusters.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to detach — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
renderer.mesh.dropClusters(gpu)

modules/renderer/mesh.dropInstanced

mesh.dropInstanced(draw: InstancedDraw): boolean

Release an instanced-draw registration and the transform slots it reserved. The mesh and the transform buffer outlive it — destroy those through renderer.destroy and the buffer handle's :destroy().

Parameters

  • draw InstancedDraw — The InstancedDraw to release.
renderer.mesh.dropInstanced(draw)

modules/renderer/mesh.encode

mesh.encode(geom: MeshGeometry): (string?, string?)

Encode raw geometry into engine-native ZMSH bytes (the on-disk mesh payload). The CPU codec behind the mesh assetType's onCreate. Every stream the format carries — including tangents, per-vertex skinning, and the skeleton — round-trips back through renderer.mesh.decode. This pair moves DATA the caller is holding; the geometry of a mesh the ENGINE is holding comes from renderer.mesh.geometry(mesh).

Parameters

  • geom MeshGeometryMeshGeometry — flat per-vertex float / u32 arrays plus optional skinning and skins.
local bytes = renderer.mesh.encode({ positions = {...}, indices = {...} })
local bytes = renderer.mesh.encode(renderer.mesh.geometry(meshHandle))

modules/renderer/mesh.encodeCpu

mesh.encodeCpu(mesh: string | { [string]: any } | AssetRef): string

Encode a mesh's resident CPU copy into ZMSH bytes. Reads the ONE guid-keyed CPU store — meshRef:load() populates it for assets, and renderer.mesh.readback(mesh) populates it for a runtime mesh. Errors loudly when the mesh has no resident CPU copy.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
local bytes = renderer.mesh.encodeCpu(handle)

modules/renderer/mesh.geometry

mesh.geometry(mesh: string | { [string]: any } | AssetRef): MeshGeometry

The complete geometry of a mesh the engine is holding, as a MeshGeometry — the same shape renderer.mesh.create and renderer.mesh.encode take, carrying every stream the mesh has (positions, indices, and whichever of normals, uvs, colors, uvs1, tangents, skinning, skins it was built with). The read that pairs with create: hand it the MeshHandle create returned and get the vertex data back. Reads the resident CPU copy when there is one; for a runtime mesh that lives only on the GPU it reads the geometry back off the GPU first (yielding a frame or two) and leaves CPU residency as it found it. An optional stream is present only when the mesh carries one, so uvs1 == nil is the answer to whether it has a second UV set. The drawable mesh the renderer holds carries the tangent basis its positions, uvs and normals determine — supplied by the caller, or derived at the ingest that made it drawable — and that is what the GPU read gives back. The CPU store answers with the streams the bytes it decoded hold, so a .mesh written without a tangent stream reads back tangents == nil for as long as a CPU copy of it is resident.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
local geom = renderer.mesh.geometry(renderer.mesh.create({ positions = P, indices = I }))
local tangents = renderer.mesh.geometry(handle).tangents

modules/renderer/mesh.getVertices

mesh.getVertices(mesh: string | { [string]: any } | AssetRef): { any }

Read the vertices of a mesh's resident CPU copy — one entry per vertex, { pos = {x,y,z}, normal = {x,y,z}, uv = {u,v} }. Reads the resident CPU store directly (no re-decode). Errors when the mesh has no resident CPU copy — renderer.mesh.geometry(mesh) is the read that works wherever the mesh lives, and returns the tangent, colour and skinning streams too.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

modules/renderer/mesh.instanceInfo

mesh.instanceInfo(draw: InstancedDraw): InstancedDrawInfo?

What a live instanced-draw registration is drawing: which mesh, which transform buffer, which per-instance data buffer if it named one, how many instances, and how many slots it reserved. Returns nil once the registration has been dropped.

status is what the renderer did with it. The fields above it are the request, made a stage before the renderer sees it; status is the answer: "drawing" for a registration the renderer is drawing, "refused" for one it turned away — error carries its reason — and "pending" for the frame between the call and the renderer answering. So a registration whose copies are not being drawn says so here.

Parameters

  • draw InstancedDraw — The InstancedDraw to report on.
print(renderer.mesh.instanceInfo(draw).instanceCount)
local info = renderer.mesh.instanceInfo(draw)
if info.status == "refused" then error(info.error) end

modules/renderer/mesh.instanceTransforms

mesh.instanceTransforms(draw: InstancedDraw): any

Read back the world matrices a registration's drawn copies are placed by: instanceCount matrices of 16 floats, column-major and tightly packed, in the layout the transform buffer holds them. The read is of the buffer as it stands when it runs, so a population a compute pass rewrites every frame answers with the placement of the frame the read lands in.

Parameters

  • draw InstancedDraw — The InstancedDraw whose copies to locate.
local pending = renderer.mesh.instanceTransforms(draw)
while not pending:ready() do task.wait() end
local floats = pending:result()

modules/renderer/mesh.isCpuResident

mesh.isCpuResident(mesh: string | { [string]: any } | AssetRef): boolean

True if this mesh has a resident CPU copy in the guid-keyed CPU store.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
if renderer.mesh.isCpuResident(handle) then ... end

modules/renderer/mesh.isResident

mesh.isResident(mesh: string | { [string]: any } | AssetRef): boolean

True if a GPU mesh is resident under this mesh's guid — the device holds its buffers, or the upload pass is still going to hand them over. This is the store the draw paths are gated on, so a mesh this reports resident is one renderer.mesh.drawInstanced and a Model can draw.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
print(renderer.mesh.isResident(handle))

modules/renderer/mesh.list

mesh.list(): { any }

Every mesh currently registered, ordered by guid — the ones a script created and the ones that reached the device through an asset alike. Answers "which mesh is this?" when all that is known is a size: each entry carries the guid, the vertex/index counts it was created with, where it came from (origin is "asset" for a mesh the asset path uploaded), whether the GPU still holds it, and where its culling bounds come from (boundsFrom is "compute" for a mesh a compute pass writes). A resident entry also carries the bytes its buffers cost. bytes is the mesh's whole VRAM footprint and is the sum of the THREE buffer columns beside it — vertexBytes + vertexStorageBytes + indexBytes, where the storage column is the same vertices bound as a storage buffer for the passes that read them that way. Summing only the vertex and index columns understates a mesh by its vertex size. The bytes column is what sums to the meshes category of renderer.gpuMemory(). renderer.references("mesh", guid) says what is still holding a row, and renderer.collect() releases the rows nothing holds.

for _, m in ipairs(renderer.mesh.list()) do print(m.guid, m.bytes) end

modules/renderer/mesh.listInstanced

mesh.listInstanced(): { InstancedDrawInfo }

Every instanced-draw registration this engine is drawing, in registration order. Each record is what instanceInfo answers with, and carries a draw handle of its own — so a population whose handle its caller no longer holds is reached here and released, resized or read like any other.

for _, pop in ipairs(renderer.mesh.listInstanced()) do
renderer.mesh.dropInstanced(pop.draw)
end

modules/renderer/mesh.loadCpu

mesh.loadCpu(ref: string | AssetRef): MeshCpuHandle

Load a .mesh asset's geometry into the ONE guid-keyed CPU store (the Disk→CPU step) and return a CPU handle. The handle holds NO geometry — only the guid plus counts and the per-handle read/encode/unload ops (which read the Rust-side store). Called by meshRef:load(). DEFAULT lifecycle: upload to the GPU then handle:unload(); the store is populated only by this call.

Parameters

  • ref string | AssetRef — A mesh AssetRef (carries .guid and reads its primary via getBytes), or any string asset.ref resolves to one — the guid encodeCpu takes, an identity, a name or a source path.
local cpu = meshRef:load(); local gpu = renderer.mesh.create(cpu); cpu:unload()
local cpu = renderer.mesh.loadCpu(gpuMesh.guid)

modules/renderer/mesh.morphTargets

mesh.morphTargets(mesh: string | { [string]: any } | AssetRef): { string }

The names of the shapes this mesh blends towards, in the order an entity's ecs.MorphWeights addresses them — weight i drives the target named at i. An imported model carries the names its source file gave its blend shapes, so content drives a face by the shape it means rather than by the ordinal that shape happened to import at (which moves when the model is re-exported). A target the source never named reads as an empty string.

Empty for a mesh with no morph targets. Errors when the mesh is neither GPU- nor CPU-resident — materialise it first (meshRef:handle()).

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
for i, name in renderer.mesh.morphTargets(meshRef) do print(i, name) end

modules/renderer/mesh.morphWeights

mesh.morphWeights(

Turn weights named by shape into the ordered weight array ecs.MorphWeights takes — the drive-a-face-by-name call. Every target the mesh carries gets a slot; the ones weights names take their value and the rest are 0, so the returned array always describes the whole mesh and a shape left out is a shape at rest.

A name the mesh does not carry is an error listing the names it does: a mistyped viseme that silently moved nothing would be indistinguishable from a rig that never had it.

local w = renderer.mesh.morphWeights(meshRef, { Eyes_Blink = 1, Mouth_Smile = 0.4 })
ecs.set(face, ecs.MorphWeights { weights = w })

modules/renderer/mesh.readback

mesh.readback(mesh: string | { [string]: any } | AssetRef): MeshCpuHandle

Read a runtime GPU mesh's geometry back to CPU and return a MeshCpuHandle for it — the GPU→CPU half of the runtime-mesh freeze path. A mesh made with renderer.mesh.create keeps no CPU copy, so persisting it (:encode()asset.create("mesh", …)) reads it back here first. Yields until the readback completes (a frame or two). After it returns the geometry is resident in the guid-keyed CPU store: :getTriangles, :getVertices, :getBounds, :geometry, :encode, :unload all work. Errors if the mesh never becomes resident in the vertex pool.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — the MeshHandle renderer.mesh.create returned, a guid, or a mesh AssetRef.
local cpu = renderer.mesh.readback(handle); local zmsh = cpu:encode(); cpu:unload()

modules/renderer/mesh.readbackPosed

mesh.readbackPosed(requests: { { entity: string, mesh: any } }): { [string]: MeshCpuHandle }

Read the POSED geometry of skinned entities back to CPU: for each request, the vertices the skinning pass wrote for that entity this frame, joined by the indices of the mesh it is posed from. A skinned surface's world-space triangles are produced on the GPU from the entity's joint matrices, so the mesh asset holds the bind pose and only this reads where the surface actually is. The posed vertices are in model space, so the entity's own world transform still places them — the same transform the raster draw uses.

Takes a LIST and answers a map, because the readbacks are queued together and polled together: a scene's worth of characters costs the frames of one readback rather than one entity's after another. Each posed mesh lands in the CPU store under a guid of its own, derived from the entity, so compute.buildBvh, meshcpu.* and every other guid-keyed reader takes it like any other mesh. Call handle:unload() when done with it.

An entity the map omits holds no live pose — nothing skinned it this frame, which is also what makes its draws read the source mesh, so its bind-pose geometry is what stands for it.

Parameters

  • requests { { entity: string, mesh: any } }{ { entity = <id>, mesh = <mesh> } } — the entity to read, and the mesh it is posed from (a guid, MeshHandle or mesh AssetRef).
local posed = renderer.mesh.readbackPosed({ { entity = id, mesh = ecs.get(id, ecs.Mesh).mesh } })
local tris = posed[id]:getTriangles()

modules/renderer/mesh.scheduleClusters

mesh.scheduleClusters(mesh: string | { [string]: any } | AssetRef): boolean

Queue a cluster-LOD bake for the static CPU mesh held under guid, and attach the DAG to the GPU mesh of that same guid on the frame it finishes. The CPU mesh may be unloaded on the very next line; the DAG is then built one bounded slice per frame, so a dense mesh virtualizes without the frame loop stopping for the whole bake.

One bake is advanced per frame — the one at the head of the queue — and the geometry is read on the frame a bake gets there, from the definition the engine holds for the mesh. A queue of meshes the engine holds definitions for therefore holds one mesh's geometry rather than one per mesh, whatever its depth. A mesh the engine holds no definition for is copied into the queue as it is scheduled, since the CPU store is then the only thing holding it. renderer.mesh.clusterBakes().heldBytes reports what the queue is holding, and its inFlight rows report which bakes it is holding for. This is what the .mesh assetType materialisation path uses; reach for renderer.mesh.buildClusters when you want the bytes in hand instead. Scheduling the same mesh again replaces the bake already in flight for it.

Parameters

  • mesh string | { [string]: any } | AssetRef — A mesh loaded into the CPU store (renderer.mesh.loadCpu) — the MeshCpuHandle, a MeshHandle, a guid, or a mesh AssetRef.
renderer.mesh.scheduleClusters(cpu) ; cpu:unload()

modules/renderer/mesh.setInstanceCount

mesh.setInstanceCount(draw: InstancedDraw, count: number): InstancedDraw

Change how many of a registration's instances draw. Constant time — the reservation, the transform buffer and the pipeline all stay put, so this is the verb for a population whose size changes per frame. The new count must fit the reservation drawInstanced was given.

Parameters

  • draw InstancedDraw — The InstancedDraw to reconfigure.
  • count number — Instances to draw, at least 1 and within the reservation.
renderer.mesh.setInstanceCount(draw, visibleCount)

modules/renderer/mesh.setInstanceRenderLayer

mesh.setInstanceRenderLayer(draw: InstancedDraw, renderLayer: number): InstancedDraw

Change which render layers a registration's copies belong to. Constant time — the reservation, the transform buffer and the pipeline all stay put, and the next frame drawn tests the copies against the new membership. It is the verb for a population that follows something whose membership moves: a camera or a capture including the layer draws the copies, one excluding it does not.

Parameters

  • draw InstancedDraw — The InstancedDraw to reconfigure.
  • renderLayer number — The membership bitmask, the same value drawInstanced takes as renderLayer. At least one bit must be set.
renderer.mesh.setInstanceRenderLayer(draw, mask)

modules/renderer/mesh.setVertices

mesh.setVertices(mesh: string | { [string]: any } | AssetRef, positions: { number })

Replace a mesh's resident CPU vertex positions (flat { x,y,z, ... }) IN PLACE — indices, normals/uvs, and skinning are preserved, the AABB recomputes, and the GPU re-fetches the new geometry so it shows on screen. The positions alone travel, so this is the per-frame deformation path where renderer.mesh.update re-sends the whole geometry. The mesh must be CPU-resident: renderer.mesh.create({ ..., keepCpu = true }) keeps a copy from the start, renderer.mesh.readback(mesh) recovers one from the GPU, and meshRef:load() loads one for a .mesh asset. Errors with the reason otherwise, or when the vertex count doesn't match.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
  • positions { number } — Flat { x,y,z, ... } — one xyz per vertex; count must match the mesh.
local m = renderer.mesh.create({ positions = P, indices = I, keepCpu = true })
renderer.mesh.setVertices(m, P) -- P mutated in place each frame

modules/renderer/mesh.unloadCpu

mesh.unloadCpu(mesh: string | { [string]: any } | AssetRef)

Drop a mesh's resident CPU copy from the guid-keyed CPU store. The explicit release for a runtime geometry mesh's recoverable definition.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.

modules/renderer/mesh.update

mesh.update(mesh: string | { [string]: any } | AssetRef, src: any): MeshHandle

Overwrite the GPU resource mesh names IN PLACE, under the same guid, from new geometry or compute buffers. Never writes a .mesh file — the play-mode mutate path. A Model bound to the guid reflects the change with no re-bind. Takes every form that names a mesh — the MeshHandle create returned, the guid renderer.mesh.list hands out, a MeshCpuHandle or a mesh AssetRef. Returns a handle carrying the bounds the new geometry has: the handle it was given, refreshed, and a handle over the guid otherwise.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to update — a MeshHandle, a guid, a MeshCpuHandle or a mesh AssetRef.
  • src any (optional) — New geometry {positions, indices, ...} or compute buffers {vertexBuffer, indexBuffer, vertexCount, indexCount, prevVertexBuffer?}.

modules/renderer/mesh.uploadClusters

mesh.uploadClusters(mesh: string | { [string]: any } | AssetRef, clusters: string): boolean

Attach a cluster-LOD DAG (bytes from renderer.mesh.buildClusters) to the GPU mesh keyed by guid, enabling the continuous-cut cluster draw path for that mesh.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh the clusters belong to — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
  • clusters string — Serialized cluster bytes (binary-safe).
renderer.mesh.uploadClusters(gpu, cb)

modules/renderer/minScreenSize

minScreenSize(): number

The on-screen radius, in pixels, an object must reach to be drawn. 0 while the cutoff is off.

local px = renderer.minScreenSize()

modules/renderer/morphStats

morphStats(): {

The morph state the last frame drew with. A mesh carries the shapes it can blend towards and an entity carries how strongly each is blended (ecs.MorphWeights); where both are present, the vertex stage adds the weighted deltas to the base geometry.

instances is how many render slots that happened at, and blends how many single-target blends those slots carry between them: a slot contributes one per target its weights move, or that they moved the frame before, so the number of targets a mesh can be given is bounded by the buffer the blends live in. meshes is how many meshes hold a delta block and targets how many targets those blocks cover between them; deltaBytes is what the shared buffer they are appended into holds. A morph-target mesh whose weights are all zero reads a meshes above zero beside an instances and blends of zero.

local m = renderer.morphStats()
print(("%d instances carrying %d blends over %d meshes"):format(m.instances, m.blends, m.meshes))

modules/renderer/observe

observe(): RenderObservation

Everything the renderer knows about the frame it last drew: what program it bound for each renderable, the render state it holds each material under, and what each program has cost in pipeline builds. renderables is one row per renderable in the renderer's draw list, carrying the program its material named (requestedProgram) beside the one that was bound (boundProgram) — __error__ wherever the lookup missed and the draw went ahead on the magenta placeholder — plus substituted, the outcome (drew / drewPlaceholder / skipped / notDrawn), the reason that forced it and the compiler's own detail for a failed compile. observed says which of two answers a row is: true for a resolution a geometry pass took as it drew, false for the renderer's own resolution of a renderable this frame drew nowhere, which is what a renderable outside every camera's frustum or layer mask reports. materials is one row per material the renderer holds a prepared bind group for; shaders is one row per program pipelines have been built for. frame names the frame every per-frame count covers; retainedFrames how many frames a resolution a pass took is kept for after the last frame that drew it; window and costWindow state both in the document itself. Recording is armed by the first read, so this waits for the frame that first records rather than answering empty.

local o = renderer.observe(); print(o.placeholderDraws, "renderables drew the placeholder in frame", o.frame)

modules/renderer/occlusionCulling

occlusionCulling(): boolean

Whether occlusion culling is currently enabled.

modules/renderer/passSchedule

passSchedule(): {

The schedule check over this frame's enqueued render passes. Passes declare what they read (inputs) and what they write (output / outputs / storage), and the frame runs them in phase order and, inside a phase, in order order. violations holds every input bound to a resource the frame produces LATER: that read samples the resource as it stands ahead of that pass, which is the previous frame's contents for a render target that persists, an empty target for one just created, and the scene draw's own output for a @scene.* buffer — and the pass renders either way. The frame's own buffers are checked on the same terms as a render target: bind @scene.motion at a phase ahead of the pass that writes it and the read is reported, naming the buffer and its writer. A pass reading a resource ahead of that write on purpose declares that slot in its enqueue's readsPrevious and drops out of the list; unboundPrevious holds declared slots the pass binds no such resource to, which cover nothing. A resource no queued pass writes is not reported — a camera rendering to texture and compute.dispatch both fill targets outside the pass queue, and the scene draw fills the @scene.* buffers every frame. A read the frame has only one order for is not reported either: where the writing pass consumes something the reading pass produces, the reader runs first or the writer has nothing to write, which is what a pass reading a buffer into a target of its own and a second pass copying that target back over the buffer forms. unreachable holds passes at a phase that does not run their kind: every phase drains its fragment and compute passes, while afterLighting is the one that draws geometry, draw and splat passes, so one of those enqueued elsewhere sits in the queue and never runs. Each finding is also stated in the engine log the first time it appears.

local s = renderer.passSchedule()
for _, v in s.violations do print(v.message) end

modules/renderer/pipelineCache

pipelineCache(): PipelineCache?

What the driver's compiled-pipeline store held, built, and wrote back. A pipeline is machine code the GPU driver compiles from the shader bound into it, and that compile is what a launch pays before the first frame drawing with each pipeline can appear. The store keeps that compiled code across runs, so a launch whose shaders have not changed reads back what the previous one compiled.

restoredBytes is what a previous run left for this GPU and this launch read; pipelinesBuilt counts the pipelines built since startup and buildMs is what they cost together, which is the number the store lowers. saves and savedBytes describe writing it back — deferred until a burst of builds settles, so one launch is one write — and dirty is true while pipelines have been built that the file does not hold, including after a write that failed, which lastError then names. path is the file, named after the GPU it belongs to.

supported is false where the platform holds no store a program can carry: a browser keeps its own and hands none out, and an adapter can lack the capability. reason says which, and the build count and timing still read true there. lastError names a read or write failure; a failed store costs the saved compile and never the frame, since every pipeline is built from its source either way. pipelinesBuilt and buildMs are engine-wide totals; renderer.shaderCost() is the same cost broken down per program, with each one's permutation count.

local c = renderer.pipelineCache()
print(("%d pipelines in %.1f ms, %d bytes restored"):format(c.pipelinesBuilt, c.buildMs, c.restoredBytes))

modules/renderer/pointShadowBudget

pointShadowBudget(): PointShadowBudget

The point-light shadow pool now in force. A point light with castsShadows renders an omnidirectional cube map, six faces of depth, and slots is how many of them fit — a further caster is lit but throws no shadow, and the engine log names how many were turned away. The slot count is bought rather than authored: megabytes of VRAM at resolution texels per face is what decides it.

local p = renderer.pointShadowBudget()
print(("%d shadowed point lights, %.1f MiB"):format(p.slots, p.bytes / 1024 / 1024))

modules/renderer/projectionOffset

projectionOffset(): (number, number)

The sub-pixel projection offset in force for the main camera, in NDC.

local ox, oy = renderer.projectionOffset()

modules/renderer/raycast

raycast(

Cast a ray against the geometry the renderer DRAWS and return the nearest surface it meets. Every visible mesh answers, whether or not anything gave it a rigid body — so a terrain, a procedurally generated mesh, or any plain Model reports the surface at a point, which is what a camera station, a prop, a sound source or a scatter standing on the ground needs to know. The answer is the nearest triangle of the mesh, so a sloped or terraced surface reports its height where it was asked rather than the extent of its bounding box.

distance is measured from origin along the direction given, so it is a world-space distance whenever that direction is a unit vector, and it is directly comparable to a physics.raycast distance along the same ray. normal is a unit vector turned to face back along the ray. exact is true when the answer is a triangle and false when it is the object's bounding box, which is what a mesh whose vertices live only in GPU buffers answers with. The triangles are the mesh's own, placed by the entity's transform and by the mesh's bind pose, so a surface a skinning or morph pass deforms on the GPU answers as the geometry the mesh holds.

EVERYTHING drawn is in scope — the ground you meant, and equally a character standing on it, a prop, a placeholder floor. The hit names its entity in entityId, exclude steps over the ones you do not want, and renderer.raycastAll hands back the whole column so you can pick the surface yourself. A height you did not expect is usually a nearer surface you did not mean to ask about, so read entityId before trusting a number.

local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then camera.position = { x, hit.point.y + 1.7, z } end

modules/renderer/raycastAll

raycastAll(

Cast a ray against the geometry the renderer draws and return every surface along it, nearest first. One entry per renderable the ray crosses — the nearest intersection with each — so a stack of surfaces reads as the order they stand in, and a caller after one particular surface finds it by entityId rather than hoping it is the nearest. Each entry carries the fields renderer.raycast returns.

for _, hit in renderer.raycastAll(eye, look, 500) do print(hit.entityId, hit.distance) end

modules/renderer/raytraceCapability

raytraceCapability(): string

The active ray-tracing backend: "hardware" (GPU ray query) or "compute" (software traversal — the path on devices without hardware ray query, e.g. the web). The same ray-tracing features work on both.

if renderer.raytraceCapability() == "hardware" then ... end

modules/renderer/raytraceStats

raytraceStats(): { [string]: any }

What the ray-tracing acceleration structure holds, and what this session's frames have spent building it. A ray walks a structure built over the scene's geometry, and keeping it current is work a frame pays before it traces anything. On the "compute" backend geometry that has stood still long enough is filed under a static partition the frames after it leave alone: staticTriangles + dynamicTriangles = triangles, nodes is the hierarchy over them, fullRebuilds / partialRebuilds / reusedFrames count what the session's frames did, and trianglesRebuilt is what those rebuilds re-emitted, summed. On the "hardware" backend blas is the bottom-level structures cached, blasBuilt how many the last frame built, and tlasInstances what the top-level structure names. The counters are cumulative — sample, run the scene, sample again.

local before = renderer.raytraceStats().trianglesRebuilt

modules/renderer/references

references(handleOrKind: any, id: string?): RuntimeResourceStatus?

What holds a runtime resource right now — the answer a root scene load reads before releasing it. references names each live consumer the engine found: { by = "entity", id } for an entity wearing the material or mesh, "material" for a material whose slot names the texture, "instancedDraw", "camera", "sky", "lightmap", "ui" (a screen drawing it) and "postProcess" (an effect sampling it). handleHeld says whether a script still reaches a handle to it, assetBacked whether an asset stands behind it, ownerLive whether the component instance, scene load or feature that created it still stands, and held whether a hold pins it. origin reads "device" for a GPU texture the device holds that no script created — the one the cache loaded for an asset, the atlas the engine built — whose holders are the references, a handle and the asset. Runs a full garbage collection first, the same one renderer.collect runs, so a handle nothing reaches counts as let go and the row says what the next collection does with the resource. Yields for the frame the census runs on.

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind with the id second.
  • id string? (optional) — The guid or key, when the first argument is a kind.
local s = renderer.references(mat) for _, r in s.references do print(r.by, r.id) end

modules/renderer/reflectionEnvironment

reflectionEnvironment(): {

What a reflective surface is reflecting. probes is how many reflection probes the shading blends; they are gathered highest priority first, each rank taking the coverage the ranks above it left, so a small interior probe ranked above the large exterior one it sits inside wins outright wherever it reaches full weight. ranks is the priority each of those probe slots was published with, in slot order. sky is whether the sky fallback is armed: with it, coverage no probe claims reflects the captured sky, and without it a surface outside every probe's radius falls back to the nearest probe alone. skyCaptured is whether the sky slot holds a capture — arming is refused until it does, since an uncaptured slot reflects black. slots is how many cube slots the environment array holds right now: the sky's alone, at index skySlot, until a probe is captured into it, then that one plus one per probe. maxProbes is how many of them probes may take, and resident whether the array has grown past the sky's single slot. Capture the sky with environment.captureSky().

local env = renderer.reflectionEnvironment()
print(("%d probes, sky fallback %s"):format(env.probes, tostring(env.sky)))

modules/renderer/release

release(handleOrKind: any, id: string?): boolean

Let go of the hold renderer.hold placed. The resource stays until nothing else holds it and a collection releases it — the one a root scene load runs, or a direct renderer.collect().

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind with the id second.
  • id string? (optional) — The guid or key, when the first argument is a kind.
renderer.release(tex)

modules/renderer/renderTargetLimits

renderTargetLimits(): {

The size a render target may be on this device. maxDimension is the device's own maximum 2D texture dimension — the largest either side of a render target may take. maxPixels is how many pixels one render target may hold, so the RGBA8 image it reads back as fits in a single buffer on every platform the engine runs on, and maxSquare is the largest square that budget buys. A capture, a renderer.texture.create({ width, height }) or a render-to-texture camera past either bound is refused at the call with the reason, so ask here for the size to request.

local lim = renderer.renderTargetLimits()
local w = math.min(want, lim.maxSquare)

modules/renderer/renderTargets

renderTargets(): {

Every render target the renderer owns and what each one costs, measured from the texture that is allocated. One row per target, each carrying its name, whether it is resident, the bytes it holds while it is, its width/height/layers/mipLevels, and onDemand. An onDemand target exists only while something needs it: a target nothing writes into reads resident = false and bytes = 0 and appears again the frame something writes it, and one sized by content — the reflection-probe cube array — holds the slots content asked for. The scratch the draws into a render target have needed is reported as camera[<handle>].* rows: depth and motion vectors under any rasterized pass, and the occlusion channel and G-buffer over them under a camera's scene render. A draw builds what it needs, and the set goes once no live camera names the target and sixty frames have passed without a draw, so a target nothing draws into carries no such row; the colour image drawn into belongs to the texture cache and outlives every one of those releases. totalBytes is what the resident targets hold together. Measured at the end of the last rendered frame.

local rt = renderer.renderTargets()
print(("render targets: %.1f MiB over %d resident"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
if t.onDemand then print(t.name, t.resident, t.bytes) end
end

modules/renderer/resolutionScale

resolutionScale(): number

The fraction of the display resolution the scene is currently rendered at. 1 until something sets it.

local s = renderer.resolutionScale()

modules/renderer/setAnisotropy

setAnisotropy(level: number): number

Set the maximum anisotropy material textures are sampled with. Takes effect on the next frame for content already on screen — no reload, no texture re-upload. 1 is plain trilinear.

Parameters

  • level number — One of 1, 2, 4, 8, 16. Any other value is an error.
renderer.setAnisotropy(16)

modules/renderer/setBlendedBatching

setBlendedBatching(enabled: boolean): ()

Whether neighbours in a view's back-to-front blended order draw together. On by default: alpha-blended geometry is submitted farthest-first, and a stretch of neighbours in that order sharing a mesh, a material, a shader and a pose is submitted as one instanced draw over those neighbours, which puts the same members on screen in the same order out of a single submission. A run stops wherever a differently-drawn renderable sorts between two of its members, and a mesh of several primitives keeps a draw per renderable — both would otherwise move fragments through each other. Off, every blended renderable draws on its own at its own slot, so a transparent crowd costs a draw per member. The image is the same either way, which is what makes this the comparison a frame suspected of being formed by the batching is made against; renderer.drawStats().draws counts the difference.

Parameters

  • enabled booleanboolean
renderer.setBlendedBatching(false)  -- a draw per blended renderable

modules/renderer/setDepthPrepass

setDepthPrepass(enabled: boolean): ()

Enable or disable the opaque depth pre-pass. While enabled the renderer resolves opaque depth in its own pass before shading, so each shaded pixel runs its material once instead of once per surface stacked behind it, and the resolved depth is what occlusion culling reads. Enabled by default.

Parameters

  • enabled booleanboolean
renderer.setDepthPrepass(false) -- shade every layer, for comparison

modules/renderer/setDepthPrepassOrdering

setDepthPrepassOrdering(enabled: boolean): ()

Submit the depth pre-pass nearest-first. Renderables reach the pre-pass in the order they were registered, which stands in no relation to where the camera is: a scene built back-to-front makes every layer write depth and be overwritten by the layer in front of it. Ordered, the nearest surface writes first and the surfaces behind it are rejected by the depth test before they write. The same draws go out either way and the depth that comes out is the same, so scene.depth_prepass in profiler.gpuFrame() is what moves. Enabled by default.

Parameters

  • enabled booleanboolean
renderer.setDepthPrepassOrdering(false) -- submit in registration order

modules/renderer/setGpuMemoryTracking

setGpuMemoryTracking(frames: number?): number

Set how often the GPU allocator sampler reads — one reading every frames frames — or turn it off with 0. It starts at 60, a reading a second at 60 Hz, so renderer.gpuMemory().allocator answers without anything arming it. Building the ledger walks every live allocation, which is why it is sampled rather than read every frame; the category figures cost nothing either way, and a reader between samples sees the most recent ledger, so a slow interval still answers.

Called with no argument it reports the interval in force and changes nothing, which is how something that retimes the sampler puts it back afterwards instead of restoring a number it assumed was the default.

Parameters

  • frames number? (optional)number? Frames between readings; 0 turns the sampler off. Omit to read the interval without changing it.
renderer.setGpuMemoryTracking(60)
local was = renderer.setGpuMemoryTracking()
renderer.setGpuMemoryTracking(1)
-- ... take a tight reading ...
renderer.setGpuMemoryTracking(was)

modules/renderer/setMaxFramesInFlight

setMaxFramesInFlight(frames: number): number

Set how many frames of GPU work may be outstanding before the renderer stops running ahead. One is the least overlap this can express — a frame's work is waited for as soon as the next frame has been submitted — which is the lowest latency and the lowest throughput; higher values let a slow frame build a longer backlog, and that backlog is memory. Takes effect on the next frame.

Answers the bound after clamping to [1, 8], so asking for more than the renderer honours reports what you actually got.

Parameters

  • frames number — number Frames of GPU work that may be outstanding, 1 through 8.
renderer.setMaxFramesInFlight(1) -- lowest latency
print(renderer.setMaxFramesInFlight(99), "was clamped")

modules/renderer/setMinScreenSize

setMinScreenSize(pixels: number): ()

Stop drawing an object once its on-screen radius falls below this many pixels. A few pixels across, an object carries no detail a viewer can resolve while still costing a full vertex and submission pass, and the cutoff drops it from the camera's draws entirely — 0, the default, keeps every object however small it lands. Measured from the object's own bounds against the camera's projection, so the same threshold means the same apparent size at any distance or field of view. Shadow casters have their own threshold in renderer.setShadowCasterCutoff.

Parameters

  • pixels numbernumber — smallest on-screen radius still drawn; 0 disables.
renderer.setMinScreenSize(4) -- drop anything under 4 px of radius
print(renderer.drawStats().compactedDrawn, "instances survived it")

modules/renderer/setOcclusionCulling

setOcclusionCulling(enabled: boolean): ()

Enable or disable occlusion culling. While enabled the renderer reduces the pre-pass depth into a pyramid each frame and tests every renderable that cleared the frustum against it, dropping the ones another surface entirely covers before their geometry is submitted. The pyramid describes the frame being drawn, so an object that becomes visible this frame is never held back a frame. Requires the depth pre-pass.

Parameters

  • enabled booleanboolean
renderer.setOcclusionCulling(true); print(renderer.cullStats().occlusionCulled)

modules/renderer/setPointShadowBudget

setPointShadowBudget(cfg: {

Set how much VRAM the point-light shadow atlas may hold, and at what per-face resolution. An omitted field keeps its current value. The atlas is reallocated on the next frame, so renderer.pointShadowBudget().slots reports the new pool one frame later; the returned number is what this budget buys. Raising resolution sharpens every point shadow and spends the same memory on fewer of them — doubling it quarters the slot count. Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the pool never exceeds renderer.pointShadowBudget().maxSlots. One slot is always granted, so a budget too small for a single cube shadows one light and the pool costs what that slot costs rather than what was asked for — { megabytes = 1, resolution = 4096 } buys 384 MiB of ceiling. Read pointShadowBudget().bytes back to see what a budget actually bought, and renderer.shadowMemory().point to see what the scene has made resident.

renderer.setPointShadowBudget({ megabytes = 96 })
renderer.setPointShadowBudget({ resolution = 1024, megabytes = 96 })

modules/renderer/setPresentMode

setPresentMode(mode: string): string

Set how a presented frame reaches the display. fifo queues every frame and shows it on a vertical blank, which never tears and never drops one; mailbox replaces the queued frame with the newest, which does not tear and does not hold the renderer to the refresh rate; immediate presents as soon as a frame is ready and can tear; fifo_relaxed is fifo that tears rather than stall when a frame misses its blank; auto_vsync and auto_no_vsync leave the choice to the backend.

A surface that does not offer the mode presents fifo instead, so read renderer.framePacing().presentMode for what took effect and .presentModes for what this surface offers. Takes effect on the next frame.

Parameters

  • mode string — string One of "fifo", "fifo_relaxed", "mailbox", "immediate", "auto_vsync", "auto_no_vsync".
renderer.setPresentMode("mailbox")
print(renderer.framePacing().presentMode, "is what the surface took")

modules/renderer/setProjectionOffset

setProjectionOffset(x: number, y: number)

Offset the main camera's projection by a sub-pixel amount, in NDC, for the frames until it is set again. The offset is in NDC because that is the space it is constant in: one pixel is 2.0 / width across, so half a pixel is 1.0 / width. Velocity (@scene.motion) is measured against the offset-free projection, so a still scene reports no motion however the samples are placed — and picking resolves a click to the same ray either way. (0, 0) samples pixel centres.

Parameters

  • x number — Horizontal offset in NDC. One pixel is 2.0 / width.
  • y number — Vertical offset in NDC. One pixel is 2.0 / height.
renderer.setProjectionOffset(0.5 * 2 / w, -0.25 * 2 / h)

modules/renderer/setRaytrace

setRaytrace(enabled: boolean): ()

Enable or disable GPU ray tracing. While enabled the engine builds the scene acceleration structure each frame so ray-tracing render features can trace against it; disabling stops the build (so it costs nothing until a ray-traced effect is active). Required before any ray-traced shadows / AO / reflections render.

Parameters

  • enabled booleanboolean
renderer.setRaytrace(true); renderer.feature.create("rt_shadows")

modules/renderer/setResolutionScale

setResolutionScale(scale: number): number

Render the scene at a fraction of the display's resolution and present it at the display's own size. Shading cost scales with pixel count and with nothing else, so this trades sharpness for frame time without taking anything out of the scene: at 0.5 the scene rasterizes a quarter of the pixels. UI and text are unaffected — they are drawn after the scene is brought back up to size. The scene rows in profiler.gpuFrame() are what move.

Parameters

  • scale numbernumber — fraction of the display resolution, clamped to [0.25, 1].
renderer.setResolutionScale(0.7)

modules/renderer/setShadowCaching

setShadowCaching(enabled: boolean): ()

Whether a shadow map that nothing changed is kept rather than drawn again. On by default: a shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is rasterized on the frames its own inputs change and holds the depth it drew on the ones they do not. Off, every view is drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.

Parameters

  • enabled booleanboolean
renderer.setShadowCaching(false)  -- draw every shadow view, every frame

modules/renderer/setShadowCasterBatching

setShadowCasterBatching(enabled: boolean): ()

Whether a shadow view draws every caster of one mesh together. On by default: a view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — submits one draw per geometry over every caster of it the view admits, wherever those casters sit in render order and whatever transform slots they hold. Off, a view draws the runs of render-order neighbours that share a mesh AND hold consecutive slots, so a scene that has spawned and despawned anything fragments into many more draws. The image is the same either way, which is what makes this the comparison a shadow suspected of being placed by the batching is made against.

Parameters

  • enabled booleanboolean
renderer.setShadowCasterBatching(false)  -- draw the runs the scene presents

modules/renderer/setShadowCasterCutoff

setShadowCasterCutoff(cfg: {

Set the shadow-caster cutoff. An omitted field keeps its current value, so a call can adjust one threshold without restating the other. Both are measured against the camera the frame draws from rather than against each light, so one setting covers every cascade, spot and cube face, and a caster that stops casting is one whose shadow the viewer could not have resolved. maxDistance is measured to the near side of the caster's bounding sphere, so a large object keeps casting while any part of it is in range. 0 releases a threshold; releasing both draws the casters the frame drew before either was set.

renderer.setShadowCasterCutoff({ minRadiusPx = 2 })
renderer.setShadowCasterCutoff({ minRadiusPx = 1.5, maxDistance = 120 })

modules/renderer/setShadowConfig

setShadowConfig(cfg: {

Set the directional shadow quality. Any omitted field keeps its current value, so a call can adjust one knob without restating the rest. Values are clamped: resolution [64, 8192], cascades [1, 4], distance >= 0, splitLambda [0, 1], fadeFraction [0, 1], softness [0, 1]. Changing resolution or cascades reallocates the depth array; the rest are per-frame values. A distance of 0 hands the range to the frame — the splits are cut over the depth its own shadow-taking renderables reach — and a positive one caps it, which is what a scene bounding its shadow cost states.

renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ cascades = 4, resolution = 2048, distance = 240 })

modules/renderer/setShadowHero

setShadowHero(entity: string, padding: number?): ()

Give one caster a directional shadow view of its own, fit to its world bounds.

A cascade covers the slab of world the camera sees, so its texels are spread over tens of metres and one character standing in the middle of it is resolved by a handful of them. The hero view is the same light and the same depth range zoomed onto that entity's bounds, so the whole map goes into the shadow it and the ground under it carry — renderer.shadowHero().zoom is the factor its texel density gains.

It renders beside the cascades, into a layer of the same texture allocated while a hero is registered, and every surface inside it reads it in place of the cascade, crossing back at its edge. Nothing else about the shadow changes: the same casters reach it, at the same depth range, through the same filter.

Parameters

  • entity string — The entity whose renderables the view is fit around.
  • padding number? (optional) — How much room the fit leaves around those bounds — for a pose that leaves the bind-pose box and for the filter that samples outside a silhouette. 1.0 fits them exactly.
renderer.setShadowHero(player.id)
print(("hero shadow: %.1f texels/unit vs %.1f"):format(
renderer.shadowHero().texelsPerUnit, renderer.shadowHero().cascadeTexelsPerUnit))

modules/renderer/setShadowProxy

setShadowProxy(mesh: string, proxy: string): ()

Rasterize proxy in place of mesh in every shadow view. A shadow is a silhouette resolved at the resolution of a shadow map, so the triangles that carry a mesh's close-up detail write depth no reader can resolve — a decimated version of the shape, a level of its own LOD chain, or a hand-built hull casts the same shadow for a fraction of the geometry.

The registration is keyed by MESH, so one call covers every instance of it — entities and GPU-driven populations alike — and a crowd sharing that mesh stays one draw. The proxy is placed by whatever places the caster, its instance's own transforms, so it stands where the caster stands, at the caster's scale.

An entity caster keeps its own geometry where a stand-in could not be placed or deformed correctly: it is skinned (it rasterizes the post-skinned vertices written for its own mesh), it blends morph targets (whose deltas describe its own mesh and are read by vertex id), or its proxy would be placed by a different node of its model than the source mesh is. Either caster keeps it where the renderer holds no geometry under the proxy's guid. Each of those is counted in renderer.shadowProxies().

Nothing else in the scene draws a proxy, so this call is what brings it onto the GPU, and it raises where it cannot. A proxy already resident there is registered as it stands.

Parameters

  • mesh string — The mesh a caster draws, as a guid or any mesh reference.
  • proxy string — The mesh it rasterizes into shadow views instead.
renderer.setShadowProxy(statueMesh, statueHullMesh)
print(renderer.shadowProxies().triangles, "vs", renderer.shadowProxies().sourceTriangles)

modules/renderer/setSkinnedBatching

setSkinnedBatching(enabled: boolean): ()

Whether skinned instances holding one pose draw together. On by default: instances of one mesh wearing one material and posed alike read the same post-skinned vertices, so the camera's colour passes submit them as a single instanced draw, and so does each shadow view and the velocity pass while renderer.shadowCasterBatching() is on — that switch is what makes a depth view form its draws by geometry at all. The camera depth pre-pass submits its casters nearest-first, which is a run per span of neighbours rather than a draw per geometry, so a crowd costs a draw per member there. Off, each skinned instance draws on its own at its own slot in every pass that rasterizes it. The image is the same either way, which is what makes this the comparison a frame suspected of being formed by the batching is made against — renderer.drawStats().draws counts the difference and renderer.skinningStats().poses says how many distinct poses it holds.

Parameters

  • enabled booleanboolean
renderer.setSkinnedBatching(false)  -- a draw per skinned instance

modules/renderer/setSkinningPoseHold

setSkinningPoseHold(enabled: boolean): ()

Whether a pose the skinning pass already wrote is read as it stands. On by default: the pass produces an instance's vertices from its joint matrices, its node transforms, its blend weight and its blend model, so the slice holding a pose already holds what running the pass over those same inputs would write. A frame binding a pose whose slice still holds it reads the slice and dispatches nothing, and skinning costs what the frame's poses CHANGED — a paused clip, a skeleton nothing drives, a cast standing in one pose, each cost compute the frame the pose arrived and nothing after it. Off, every pose a frame binds is dispatched again, which is the comparison a frame suspected of reading a slice that no longer holds its pose is made against; the image is the same either way and renderer.skinningStats() counts the difference as dispatches against held. A mesh whose vertices a compute pass writes is dispatched every frame however this stands.

Parameters

  • enabled booleanboolean
renderer.setSkinningPoseHold(false)  -- dispatch every pose, every frame

modules/renderer/setSpotShadowBudget

setSpotShadowBudget(cfg: {

Set how much VRAM the spot/area shadow atlas may hold, and the per-side resolution of one layer. An omitted field keeps its current value. The atlas is reallocated on the next frame, so renderer.spotShadowBudget() reports it one frame later; the returned number is what this budget buys. Raising resolution sharpens the lights that cover the most screen and spends the same memory on fewer layers — doubling it quarters the layer count. Raising megabytes buys layers, which is what lets several lights hold a large tile at once. Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the atlas never exceeds spotShadowBudget().maxLayers. One layer is always granted, so a budget too small for one still shadows lights and the atlas costs what that layer costs rather than what was asked for.

renderer.setSpotShadowBudget({ megabytes = 64 })
renderer.setSpotShadowBudget({ resolution = 2048, megabytes = 64 })

modules/renderer/setTextureBudget

setTextureBudget(opts: TextureBudgetOpts): TextureBudget

Bound the VRAM a world's textures occupy, by keeping only the mip levels the frame is actually sampling. Pass { megabytes = 256 }; 0 — the default — leaves texture residency alone and every texture stays fully resident the way it uploaded.

With a budget armed, each frame measures how many screen pixels ONE traversal of a texture's coordinate range covers on the surface that spans it widest, and asks for the mip level that serves that span one texel per pixel — the level the GPU picks from the fragment's own derivatives. A material with uvScale = 8 lays eight copies of its texture across a surface, so each copy spans an eighth of the surface and asks for three levels coarser than the surface's own size would. A shader that declares // @uv_space: world advances its coordinate over world units rather than over the mesh's UVs, so how many copies a surface carries follows how large that surface is. The textures whose surfaces cover the fewest pixels give up levels until the set fits. Detail climbs one level per frame, from the image already on screen, so a surface the camera approaches sharpens rather than popping, and no texture is taken below the level whose longest side is 64 texels.

bias shifts every measurement by whole mip levels either way — negative for finer than the sampling implies, positive for coarser — over a world whose look wants a different trade than one texel per pixel.

The plan moves a texture whose demand the frame can measure: one at least 256 texels on its narrowest side, worn by a surface an entity draws. A texture a UI image, a post-process property or a render feature holds a view of stays whole, because nothing measures how much of the screen those cover.

Which textures the budget governs follows the surfaces the frame draws. A texture whose asset still holds its bytes is enrolled the frame a measured surface wears it — whenever it loaded, and whenever the budget was armed — because a level change reads the levels it needs back from the asset; when the last such surface goes it leaves the set whole, at the level it uploaded at, and a surface reaching it again takes it back up. A texture a script uploaded has its pixels nowhere else, so one enrolled while it is resident holds them in system memory (renderer.textureMemory().streamSourceBytes) from the upload until a surface has worn it and gone, and releases them then, which is what keeps it out for the rest of the session; one whose pixels were already released when the budget was armed is out from the start. renderer.textureMemory().pinnedTextures counts those, together with the textures whose asset could not be read back and the ones a UI image, a post-process property or a render feature holds a view of. Disarming returns every texture to the level it uploaded at, and arming again governs the textures the frame's surfaces are wearing then.

Parameters

  • opts TextureBudgetOpts{ megabytes: number?, bias: number? }
renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 })  -- one level finer everywhere
renderer.setTextureBudget({ megabytes = 0 })               -- leave residency alone

modules/renderer/setTransmissionShadows

setTransmissionShadows(enabled: boolean): ()

Let translucent casters tint the sunlight they block instead of blocking it outright. A shadow map holds one depth per texel and is compared as a yes-or-no test, so stained glass, water and thin fabric all project the same black silhouette a wall does. With this on, a caster whose material declares opacity (base_color alpha under a transparent blend) or transmission also draws into a light-space transmittance map, and the colour it lets through multiplies into the directional light reaching whatever stands behind it. Stacked casters compose. Opaque casters are unaffected, and a scene with no translucent caster allocates nothing and records no pass.

Parameters

  • enabled booleanboolean
renderer.setTransmissionShadows(true)  -- stained glass tints the floor

modules/renderer/shaderCache

shaderCache(): ShaderCache?

What the shader compile gate's store of baked WGSL held, answered and wrote back. Compiling a .shader wraps the author's body in its framework, expands every #include, and hands the result to naga to parse and validate — work that is a pure function of the text going in, and that a launch would otherwise repeat for every shader it draws with. The store keeps that baked text across launches.

restoredEntries and restoredBytes are what a previous launch left that this one read back. hits counts the compiles answered out of the store and misses those that ran in full; savedMs sums what each hit's own recorded compile had cost, against compileMs, what the misses spent. stale counts the misses whose key was held but whose #included modules had changed underneath — an entry records every module its expansion consumed, so editing a module invalidates exactly the shaders that included it and leaves the rest.

entries and bytes are what the store now holds, evictions how many a write dropped to stay inside its bounds, and saves / savedBytes / dirty describe writing it back, deferred until a burst of compiles settles. persistent is false where a launch has nowhere to keep artifacts and reason says why; location is the file, or the browser store, they are kept in. restoreState is how the read of what a previous launch left has gone — pending while it is still out (a browser answers through a promise, so a launch reaches its first frames before it lands), restored once entries came back, empty when there were none to come back, failed when what was there could not be read, and none where a launch keeps nothing. A cold, missing or corrupt store leaves every shader compiling from source with identical output, and lastError then names what went wrong.

local c = renderer.shaderCache()
print(("%d hits, %d misses, %.1f ms saved"):format(c.hits, c.misses, c.savedMs))

modules/renderer/shaderCost

shaderCost(): { ShaderCost }

What each program has cost in pipeline builds, beside the compile gate's most recent word about it. variants is how many pipelines this engine has built for it — one per (target format, vertex layout, render-state key) permutation reached — and buildMs what those builds cost, both summed since engine start. A pipeline the driver's own store restored is not built and so is not counted, so a second launch on the same adapter reports less than the first. status is compiled, failed or pending, and error carries the compiler's message for a failure. Ordered by cost, most expensive first.

local top = renderer.shaderCost()[1]; print(top.shader, top.variants, top.buildMs)

modules/renderer/shaderVariants

shaderVariants(): { ShaderVariants }

Every shader that declares optional features, and the programs its materials have made it compile. Each row carries the features the shader declares, the base program it ships as, and one entry per variant with the features that variant holds — so the permutation count a scene's materials are spending is a number to read rather than something to infer from compile time. A shader whose variants reach budget compiles no more; the materials asking for further feature sets draw with the base program.

for _, s in renderer.shaderVariants() do print(s.shader, #s.variants, s.budget) end

modules/renderer/shadingOf

shadingOf(subject: string | { [string]: any }): ShadingReading

What the renderer is shading ONE subject with, taken from the document the renderer publishes — the call a system holding a handle makes to find out whether what reaches the screen is its own material or the magenta placeholder standing in for it, without reading the engine log. subject is an entity that draws or the registry key of a material. state reads itsMaterial where the renderer bound the program the material names, errorMaterial where it bound the placeholder instead, stalePipeline where the pipeline drawing it was built before that program's most recent compile, nothingBound where the renderer resolved no pipeline for it, pending where this call is the one that armed per-draw recording and the frame after it publishes, and unknown where the renderer holds a resolution under no such subject. A fault state carries the renderer's own reason from the closed set renderer.drawDiagnostics() names — plus materialNotPrepared, which a material subject reads where the renderer prepared nothing under that key — the compiler's detail, the program the material asked for and the bound one; means states the reading in a sentence. A material subject answers from the renderables drawing with it, and from the renderer's record for the material itself where a draw registered against the material carries no row of its own; a subject that several renderables draw answers with a refused one wherever there is one. The reading follows the renderer, so a program that compiles on a later edit puts the subject back on itsMaterial from the frame the renderer draws it with again.

Parameters

  • subject string | { [string]: any } — The entity — a proxy from entity(...) or an entity-id string — or the material, as its registry key or the MaterialHandle renderer.material.create returned.
local s = renderer.shadingOf(emitter); if s.state ~= "itsMaterial" then print(s.means) end

modules/renderer/shadowCacheStats

shadowCacheStats(): {

What the last frame did with the shadow maps it already had. A shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is drawn again only when something it draws from changed: its light moved, a caster it can see moved or appeared or vanished, a caster's geometry or material changed, a caster changed pose or moved the nodes its parts are placed by, or the map it writes into was reallocated. Anything else keeps the depth already in the texture, so a scene that stops moving reads rendered 0 while cached keeps climbing. A mesh whose vertices a compute pass writes — a population, or a mesh built from a compute buffer — re-renders the views it stands in every frame. A shadowed point light contributes six views, one per cube face, so a caster moving on one side of it re-renders the face that can see it and leaves the other five holding what they have. Counted per light kind, plus the totals across all three.

These are totals over every view of a kind. renderer.shadowViews() is the same frame one view at a time, each row naming the light that owns it and what it drew.

local s = renderer.shadowCacheStats()
print(("shadow views: %d drawn, %d cached"):format(s.rendered, s.cached))

modules/renderer/shadowCaching

shadowCaching(): boolean

Whether a shadow view may keep the depth it already holds.

modules/renderer/shadowCasterBatching

shadowCasterBatching(): boolean

Whether a shadow view draws every caster of one mesh together.

modules/renderer/shadowCasterCutoff

shadowCasterCutoff(): ShadowCasterCutoff

How small, and how far away, a caster may get before it stops writing depth into any shadow view. A shadow view rasterizes a caster's whole triangle count whatever the shadow it produces ends up covering, so an object the viewer resolves a fraction of a pixel of, and one past the range the scene cares about, each cost a full depth pass per shadowed light for detail nothing reads. Both thresholds are 0 — released — until something sets them.

local c = renderer.shadowCasterCutoff(); print(c.minRadiusPx, c.maxDistance)

modules/renderer/shadowConfig

shadowConfig(): ShadowConfig

The directional shadow quality now in force. resolution and cascades size the cascade depth array; distance and splitLambda place the splits along the view; fadeFraction and softness shape how the result is sampled.

print(renderer.shadowConfig().cascades)

modules/renderer/shadowHero

shadowHero(): ShadowHeroReport

The registered hero caster and what the last frame's fit produced. A frame that fit nothing says why in decline.

local h = renderer.shadowHero()
print(h.active, h.zoom, h.decline)

modules/renderer/shadowMemory

shadowMemory(): {

How much GPU memory the shadow maps hold right now, in bytes, by the light kind that owns them. The spot atlas and the point pool are sized to the casters in the scene rather than to the budget, so spot and point move as lights that cast shadows appear and leave, and a scene with one shadowed light holds far less than one that fills every slot. A budget is the ceiling they grow within — renderer.spotShadowBudget().layers and renderer.pointShadowBudget().slots report that ceiling, unmoved by how many casters exist. Raising shadow resolution costs the square of the change across every cascade.

local m = renderer.shadowMemory()
print(("shadows: %.1f MiB"):format(m.total / 1024 / 1024))

modules/renderer/shadowProxies

shadowProxies(): ShadowProxyReport

The shadow proxies in force and what the last frame's shadow passes did with them. triangles and sourceTriangles are what those passes submitted and what they would have submitted from the source meshes — the before/after of every registration, equal while nothing is proxied.

local p = renderer.shadowProxies()
print(("shadow triangles: %d of %d"):format(p.triangles, p.sourceTriangles))

modules/renderer/shadowViews

shadowViews(): ShadowViewReport?

Every shadow view the last rendered frame considered, and what each one cost.

A frame rasterizes a depth view per directional cascade, one for the hero caster, one per shadow-casting spot and six per shadow-casting point. renderer.shadowCacheStats() counts those views by light kind, renderer.drawStats() sums their draws with the camera's, and profiler.gpuFrame() carries one scene.shadow span across all of them. This is the same frame read one view at a time.

Each row names the view and the light that owns it, says whether it drew or kept the depth it already held, and carries the draws, the instances and the casters that went into it. span is the label the view's pass is timed under, so its GPU time is a lookup in profiler.gpuFrame(); every one of those labels is a variant of scene.shadow, which still carries their total. camera carries the same instance counters for the main camera, so the camera's share of a frame-wide total is a read rather than a measurement taken by turning every light's shadow off.

A cascade's near and far are where the split scheme cut its slice, not the world it covers: the fit takes the bounding sphere of that slice and rasterizes the ortho box around it, and both reach past far. What the cascade covers is center and radius, with viewProj the exact test; coversNear and coversFar read that volume back along one ray, the camera's view axis. directional states the axis reading for the set — how far it reaches (coversFar), the range the splits were run over (distance), how far the camera draws (cameraFar), and the depth past the reach the camera still draws (uncovered). A receiver further along the axis than coversFar has no directional depth map over it and is shaded as if the sun reached it, so uncovered is the room a missing shadow has and a surface standing in that room is what makes one; @builtin::systems.proxyOcclusion occludes past the cascades. The box is bounded in every direction, so a receiver standing wide of the axis leaves it at its own distance even where uncovered is 0 — viewProj is what answers for that receiver.

The list is rebuilt every frame: a view whose light stopped casting is absent from the next report rather than standing at the numbers it last had, and a frame that drew no shadow view answers a report whose views is empty. views grouped the way the shadow cache decides — a row per cascade, per spot atlas layer, per point cube — counts what renderer.shadowCacheStats() reports as rendered + cached.

The frame names its views only while something is reading them, so this call asks the frames after it to name theirs and waits out the first one. Nil on an engine that renders no frame at all.

local report = renderer.shadowViews()
print(("camera submitted %d of %d instances"):format(
report.camera.submittedInstances, renderer.drawStats().compactedDrawn))
for _, view in report.views do
print(("%s %d (%s): %d draws, %d instances, %s"):format(
view.role, view.index, view.light, view.draws, view.instances,
view.rendered and "drew" or "cached"))
end
local d = report.directional
if d ~= nil and d.uncovered > 0 then
print(("sun shadows stop at %.0f; the camera draws to %.0f"):format(
d.coversFar, d.cameraFar))
end

modules/renderer/skinnedBatching

skinnedBatching(): boolean

Whether skinned instances holding one pose draw together.

modules/renderer/skinningPoseHold

skinningPoseHold(): boolean

Whether a pose already written into its slice skips its dispatch.

modules/renderer/skinningStats

skinningStats(): {

What the last frame's skinned instances cost. A skinned instance is posed by a compute pass that writes its vertices into a shared pool, and instances holding the same pose read one slice of that pool and the single dispatch that fills it. instances is how many were posed, poses how many distinct poses they held, and dispatches how many dispatches those poses cost this frame — so a crowd whose members move together costs what its poses cost rather than what its head count does, while members at different animation times each hold their own pose and pay for it.

held is how many of the frame's poses cost no dispatch at all. The pass produces a slice from what the pose is made of, so a slice an earlier frame filled already holds what running it again would write, and a pose still wearing that slice is read as it stands. Skinning is paid for by the poses that CHANGED: a cast standing still reads dispatches 0 beside a held equal to its poses, and the two add up to poses in any frame.

reusedSlices is how many of the frame's poses took a slice the pool already held — one a retired pose gave back, or one a pose nothing has asked for this frame was holding — rather than one cut from pool the engine had never used. A scene whose poses keep changing reads a non-zero count beside a poolBytes that stays where it was.

liveBytes is what the slices holding this frame's poses occupy, against unsharedBytes — what the same instances would occupy with a slice each. poolBytes is what the pool holds; a previous-position buffer of the same size rides alongside it so skinned deformation reaches motion vectors.

local s = renderer.skinningStats()
print(("%d instances over %d poses"):format(s.instances, s.poses))
print(("%d poses dispatched, %d read as they stood"):format(s.dispatches, s.held))
print(("skinned vertex storage: %d B of an unshared %d B"):format(s.liveBytes, s.unsharedBytes))

modules/renderer/splat.components

splat.components(bytes: any, convention: string?): (SplatComponents?, string?)

Decode a Gaussian splat capture — a Niantic .spz (gzipped or raw) or a 3DGS .ply — into the GPU-ready byte pools a render feature uploads. records is the packed splat array at recordBytes per splat (position, log scale, quaternion, DC colour + opacity); sh is the quantized higher-order spherical-harmonics pool at shStrideWords u32 words per splat, empty at degree 0. A pure decode (no GPU work): upload the pools with shaderRef:createBuffer + buf:writeBytes and draw them with a kind = "splat", channel = "gaussian" pass.

Parameters

  • bytes any (optional) — Capture bytes — .spz or .ply, as a buffer or a binary string.
  • convention string? (optional) — Source axis convention: "rightDownFront" (the default, what COLMAP-trained captures use) or "engineNative" for a capture already in engine space.
local c = renderer.splat.components(vfs.readBytes("captures/ceramic.spz"))

modules/renderer/spotShadowBudget

spotShadowBudget(): SpotShadowBudget

The spot and area-light shadow atlas now in force. Each shadow-casting spot is given a tile of it every frame, sized to what the camera can resolve: a light filling the view gets a whole layer at resolution, one far away gets a minResolution tile, and the atlas holds tiles of the smallest kind. That is what lets one budget serve a close hero light and a street of distant ones without either the memory or the sharpness being set for the worst case.

local s = renderer.spotShadowBudget()
print(("%d layers of %d, %.1f MiB"):format(s.layers, s.resolution, s.bytes / 1024 / 1024))

modules/renderer/temporal.held

temporal.held(): boolean

Whether a hold is pinning the per-frame clock right now.

if renderer.temporal.held() then print("frame is pinned") end

modules/renderer/temporal.hold

temporal.hold(at: number?, options: TemporalHoldOptions?): () -> ()

Pin the clock every per-frame effect draws itself against, and return the release. While the hold stands, renderer.temporal.now answers at instead of the running clock, so film grain and every other field redrawn each frame is redrawn as the same field. Two renders taken under holds at the same instant therefore agree pixel for pixel wherever the scene itself has not moved, which is what makes one frame comparable with another. Holds nest: the innermost names the instant, and the clock runs again once the last release is called. Each release takes its own hold off the stack whatever order the releases come in, so two callers holding at once — two captures in flight together — each end their own hold and the clock runs again when both have. exclusive takes the clock for the owner key the call states: while that hold stands, a hold is admitted only when it states the same key, and every other one is refused with an error naming the key and the instant holding it. That is what lets one caller wind the clock to the second it means to photograph and keep it there while another agent drives the same engine. The key is what an owner presents to take a nested hold of its own, and what renderer.temporal.release hands the clock back by. A capture taken while the hold stands renders at the held instant; a deterministic capture takes a hold of its own that states no key, so it runs once the clock is handed back.

Parameters

  • at number? (optional) — The instant to pin the clock at, in seconds. Two holds that state the same instant produce the same field; the default 0 is that shared instant.
  • options TemporalHoldOptions? (optional)owner is the key this hold is taken under, and an exclusive hold states one. A hold that states no key is labelled with the agent the call is attributed to, which is the account the caller presented a token for and is shared by every session driving this engine under it. exclusive takes the clock for the stated key until the hold is released.

Returns () — A function that releases this hold. Calling it twice releases once.

local release = renderer.temporal.hold() ; local png = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()
local release = renderer.temporal.hold(46.0, { exclusive = true, owner = "stage-air" })

modules/renderer/temporal.now

temporal.now(): number

The instant a per-frame effect should draw itself at: the innermost hold's instant while one stands, and seconds since boot otherwise. A system that redraws a field every frame reads this rather than the running clock, and a capture asking for a repeatable frame then gets one.

local params = { grainTime = renderer.temporal.now() }

modules/renderer/temporal.onChange

temporal.onChange(listener: (number) -> ()): () -> ()

Register a listener called with the pinned instant whenever it changes — a hold taken, a hold released — and return the unsubscribe. A system whose shader reads the clock out of a GPU buffer registers here, so the buffer carries the pinned instant before the frame that hold was taken on is drawn rather than a frame later.

Parameters

  • listener (number) -> () — Called with the instant now in force, in seconds.

Returns () — A function that removes this listener.

local stop = renderer.temporal.onChange(function(t) pushClock(t) end)

modules/renderer/temporal.owner

temporal.owner(): { id: string?, name: string?, at: number, exclusive: boolean }?

The hold naming the instant the clock answers right now: who took it, what instant it pinned, and whether it took the clock exclusively. Several agents drive one engine at once and a hold any of them takes moves the clock every registered field is redrawn against, so this is how a caller sees that another agent holds it before its own instant is quietly replaced — and, when exclusive is true, id is the key a hold of its own states to be admitted, and the key renderer.temporal.release hands the clock back by. id and name are nil for a hold that stated no key and that the engine attributes to no agent.

local who = renderer.temporal.owner()
if who ~= nil and who.exclusive then print(who.name, "holds the clock at", who.at) end

modules/renderer/temporal.release

temporal.release(owner: string): number

Hand the clock back by the key its holds were taken under, and report how many came off. A hold stands until its release is called, and the release is a closure the call that took the hold holds: a caller that takes a hold in one call and comes back in another, and a task that ends between the two, both leave the clock pinned with nobody holding a release for it. Naming the key is how the clock runs again, and how a caller refused by an exclusive hold takes one over.

Parameters

  • owner string — The key the holds to release were taken under — what owner stated when they were taken, which renderer.temporal.owner reports.
renderer.temporal.release("stage-air")

modules/renderer/texture.capture

texture.capture(texture: string | { [string]: any } | AssetRef): string

Request a CPU readback of the GPU texture texture names (e.g. a camera's rendered output). Returns a result key to pass to a TextureCpuHandle's :encode() once the readback completes. Takes every form that names a texture — the TextureHandle create returned, the guid renderer.texture.list hands out, a TextureCpuHandle or a texture AssetRef.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture to read back — a TextureHandle, a guid, a TextureCpuHandle or a texture AssetRef.
local key = renderer.texture.capture(cameraTarget)

modules/renderer/texture.cpuCreate

texture.cpuCreate(width: number, height: number, fill: any?): TextureCpuHandle

Allocate a blank CPU image (RGBA8) filled with a solid colour and return a TextureCpuHandle. Compose into it with canvas:blit(src, x, y, w, h), then canvas:encodeJpeg() / :encodePng() for the bytes; :unload() drops it.

Parameters

  • width number — number Canvas width in pixels.
  • height number — number Canvas height in pixels.
  • fill any? (optional) — Optional { r, g, b, a } (0-255) solid fill; defaults to opaque white.
local c = renderer.texture.cpuCreate(1024, 576, { 255, 255, 255, 255 })

modules/renderer/texture.cpuFromBytes

texture.cpuFromBytes(bytes: buffer | string, encodeOpts: any?): TextureCpuHandle

Load engine-native ZTEX bytes — or an encoded image (png / jpg / webp) — into the CPU store under a fresh guid and answer the CPU handle, for pixels that come from somewhere other than a texture asset: a data.ztex read as a file, a payload held in memory. The pixels stay at the format they were encoded in. DEFAULT: handle:unload() once done with them.

Parameters

  • bytes buffer | string — The ZTEX or image bytes.
  • encodeOpts any? (optional){ format?, srgb?, generateMipmaps?, maxDimension? } applied when the bytes are an encoded image and need the engine-native encode.
local cpu = renderer.texture.cpuFromBytes(vfs.read(path)); local png = cpu:encodePng(); cpu:unload()

modules/renderer/texture.create

texture.create(src: any, guid: string?): TextureHandle

Create (or fetch) a GPU texture resource and return its TextureHandle. src: a TextureCpuHandle from texRef:load() (CPU→GPU under the asset's guid, idempotent); raw pixels {rgba, width, height, srgb?, format?} (a flat widthheight4 byte payload, 0-255, row-major, top-to-bottom, RGBA — a buffer, a binary string, or a number array; format = "rgba16f" uploads an HDR texture instead, where rgba carries float channel values); a TextureHandle (returned as-is); or render-target dimensions {width, height, name?, format?} with no pixel source — an empty GPU texture a render pass writes into (camera output, UI surface) and that samples like any other texture. format names the colour format the target is allocated in, and the passes drawing into it are built for that format: "rgba8unorm" / "bgra8unorm" (the two eight-bit channel orders, either of which a surface may carry), "rgba16f" / "rgba32f", "rg16f" / "rg32f", "r16f" / "r32f". Each also answers to its spelled-out width ("rgba16float", "r32float", and so on), in any case. Omit it to take the surface's own. A float format carries what eight bits quantize — positions, velocities, HDR. Any other format raises an error naming every name that works, so a target is allocated in the format it was asked for or not at all. A render target takes filter the way raw pixels do: "nearest" keeps its own pixels square wherever something draws it larger than it is — a viewport widget, a magnified capture — which is what an image whose pixels ARE the subject needs, since a 64x32 panel holds no detail between its pixels to interpolate; "linear" (the default) smooths between them. It also takes screen (the engine keeps it the size of the image being drawn), screenScale (the fraction of that size it takes) and screenSpace ("scene", the default, or "composite" — the image the post-scene phases draw into, which is the display's own resolution while the renderer presents the viewport itself and the scene's size while a UI viewport panel owns the presentation). A scene-space target is resized for every render target drawn and cleared before an offscreen one; a composite-space target follows the presented frame alone, which is what lets a pass keep an accumulation in it. One scene-space screen target is therefore one resource every render target draws through in turn, so its guid holds the last one's image at the last one's size, and a value read back from it belongs to whichever render target was drawn last. A reading that has to be the viewport's own comes from screenSpace = "composite", or from a target created without screen. NEVER takes an AssetRef — load the CPU first.

Parameters

  • src any (optional) — A TextureCpuHandle, raw pixels, a TextureHandle, or render-target dimensions.
  • guid string? (optional) — Optional v4 guid for a NEW runtime texture — the asset identity the texture is filed under, which a material's texture slot resolves through. Minted when absent. Ignored for the CPU-handle and render-target paths.
local gpu = renderer.texture.create(texRef:load())
local gpu = renderer.texture.create({ rgba = pixels, width = 16, height = 16 })
local px = buffer.create(16 * 16 * 4); local gpu = renderer.texture.create({ rgba = px, width = 16, height = 16 })
local rt = renderer.texture.create({ width = 512, height = 256, name = "panel_rt" })
local hdr = renderer.texture.create({ width = 512, height = 256, name = "cam_rt", format = "rgba16f" })
local led = renderer.texture.create({ width = 64, height = 32, name = "panel", filter = "nearest" })

modules/renderer/texture.createFromAsset

texture.createFromAsset(

Put a .texture asset on the GPU under its own guid and answer its handle at once. The asset's bytes are decoded off the frame and the texture lands on the device when the decode finishes, a frame or more later: a material naming the guid draws the shader's default for that slot until then and rebinds when it arrives, and renderer.texture.isResident reports the arrival. The decoded pixels are dropped once uploaded unless keepCpu holds them in the CPU store for textureRef:load()-style reads. An asset the device already holds is answered from the shape the device reports, without reading the asset's bytes and without a second decode.

local h = renderer.texture.createFromAsset(texRef) -- bind h.guid on a material; it draws once resident

modules/renderer/texture.decode

texture.decode(bytes: buffer | string): (any, any, any, any)

Decode a texture payload to its pixel buffer. Takes the two shapes the renderer's own texture loader takes, told apart by their leading bytes:

  • an engine-native ZTEX payload — handed back at the texel format the payload was written in, so a height field read back here keeps every bit it was authored with. A ZTEX holding block-compressed or verbatim source-image levels decodes to "rgba8".
  • source image bytes — png, jpeg, gif or webp, straight off disk or out of a capture — decoded to "rgba8" at whatever colour type, bit depth or interlacing the file was written with. This is the call that reads the pixels of a screenshot.

The fourth return names the format the buffer came back in: "rgba8" (4 bytes/texel, channels 0-255), "rgba16" (8 bytes/texel, 16-bit unsigned normalized channels 0-65535) or "rgba32f" (16 bytes/texel, float channels).

Parameters

  • bytes buffer | string — A ZTEX payload or source image bytes.
local pixels, w, h = renderer.texture.decode(vfs.read("/source/tmp/shot.png"))

modules/renderer/texture.destroy

texture.destroy(texture: string | { [string]: any } | AssetRef): boolean

Release the GPU texture texture names. For an empty render-into texture (camera output, UI surface) this also frees its render scratch; for an uploaded runtime texture it drops the GPU resource (and any CPU shadow). After this, renderer.texture.list stops answering for the guid. Takes every form that names a texture — the TextureHandle create returned, the guid the listing hands out, a TextureCpuHandle or a texture AssetRef.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture to release — a TextureHandle, a guid, a TextureCpuHandle or a texture AssetRef.
renderer.texture.destroy(rt)
renderer.texture.destroy(renderer.texture.list()[1].guid)

modules/renderer/texture.encode

texture.encode(rgba: any, width: number, height: number, opts: any): (string?, string?)

Encode raw pixels into an engine-native ZTEX payload (the on-disk texture content). The CPU codec behind the texture assetType's onCreate. opts.format selects the on-disk precision: "rgba8" / "srgb" (default, 8 bits/channel, rgba is widthheight4 bytes) or the high-precision data formats "rgba16" (16-bit unsigned normalized, widthheight8 bytes) / "rgba32f" (32-bit float, widthheight16 bytes) — for height/displacement fields, baked lightmaps, and other data rasters an 8-bit format quantizes visibly. The two high-precision formats store rgba verbatim and reject opts.generateMipmaps / opts.maxDimension.

Parameters

  • rgba any (optional) — Pixel payload at opts.format's native byte width — a buffer, a binary string, or a number array.
  • width number — number
  • height number — number
  • opts any (optional){ format?, srgb?, generateMipmaps?, maxDimension? }

modules/renderer/texture.encodeFromImage

texture.encodeFromImage(bytes: buffer | string, opts: any): (string?, string?)

Encode source image bytes (png/jpg/webp/…) into an engine-native ZTEX payload. Used by the texture importer / assetType onChange.

Parameters

  • bytes buffer | string — source image bytes.
  • opts any (optional){ format?, srgb?, generateMipmaps?, maxDimension? }

modules/renderer/texture.frameSchedule

texture.frameSchedule(texture: string | AssetRef): { number }?

The times at which each layer of a timed texture stops being shown, in seconds from the start of the sequence — the running total of the layer display times, so the last entry is the length of one pass.

This is the form a sampler reads a sequence through: a time is turned into a layer by finding the first entry it has not passed, whatever the individual layer times are. It is what the schedule slot of the builtin animatedTexture shader holds, one entry per layer.

A texture whose layers carry no timing — a still image, a sprite sheet, a LUT stack — has no schedule and answers nil.

Parameters

  • texture string | AssetRef — The texture — a guid, an identity, a name, a path, or a texture AssetRef.
local ends = renderer.texture.frameSchedule(texRef) -- {0.1, 0.15, 0.35}

modules/renderer/texture.info

texture.info(ztex: buffer | string): (any, any)

Read the header of an engine-native ZTEX payload without copying the pixels. Returns its format, dimensions, mip count, filter ("nearest" or "linear" — the sampler baked into the blob from the asset's settings.filter), and the payload's layer shape.

layers counts the array layers the payload carries and isArray is true past one — the answer to "am I about to sample a texture_2d_array?", available before anything samples it. animated is true when those layers are a sequence in time; then frameDelaysMs lists each layer's display time in milliseconds in display order, and durationMs totals one pass. An animated image imports as one layer per frame, so layers is its frame count. A still texture reports layers = 1, isArray = false.

Parameters

  • ztex buffer | string — ZTEX bytes.
local i = renderer.texture.info(bytes); if i.animated then print(i.layers, "frames", i.durationMs, "ms") end

modules/renderer/texture.isResident

texture.isResident(texture: string | { [string]: any } | AssetRef): boolean

True if a GPU texture is resident under this texture's guid.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture — a TextureHandle, a TextureCpuHandle, a guid, or a texture AssetRef.
print(renderer.texture.isResident(handle))

modules/renderer/texture.list

texture.list(): { any }

Every texture currently registered, ordered by guid — the ones a script created and the ones that reached the device through an asset alike. Each entry carries the guid, where it came from (origin is "asset" for a texture the asset path uploaded), and whether the GPU still holds it. A resident entry also carries the bytes it costs, its dimensions and its texel format, so the listing sums to renderer.textureMemory(). A streamable one carries streamOrigin"asset" when a level change reads the levels it needs back from the asset, "retained" when the cache holds the pixels for it. A script-created entry also carries held — whether renderer.hold pins it for the session — and scene, the load that created it. renderer.references("texture", guid) says what is still holding a row, and renderer.collect() releases the rows nothing holds.

for _, t in ipairs(renderer.texture.list()) do print(t.guid, t.bytes) end

modules/renderer/texture.loadCpu

texture.loadCpu(

Load a .texture asset's pixels into the ONE guid-keyed CPU store (the Disk→CPU step) and return a CPU handle for per-pixel access (no GPU readback). The handle holds NO pixels — only the guid, dims and texel format plus the read/write/encode/unload ops (which read the Rust store). The pixels stay at the format they were authored in: handle.format is "rgba8", "rgba16" or "rgba32f", and :readPixel reports channels in that format's own units. Called by texRef:load(). DEFAULT: upload to the GPU then handle:unload().

local cpu = texRef:load(); local r,g,b,a = cpu:readPixel(3, 4); cpu:unload()

modules/renderer/texture.readback

texture.readback(texture: string | { [string]: any } | AssetRef): TextureCpuHandle

Read a runtime GPU texture's pixels back to CPU and return a TextureCpuHandle for them — the GPU→CPU half of the runtime-texture freeze path. A texture made with renderer.texture.create keeps no CPU copy, so persisting it (:encode()asset.create("texture", …)) reads it back here first. Yields until the readback completes (a frame or two). After it returns the pixels are resident in the guid-keyed CPU store: :readPixel, :writePixel, :getInfo, :encode, :unload all work. Errors if the texture never becomes GPU-resident.

A SCENE-space screen-sized render target is one resource shared by every render target drawn — the viewport, an offscreen capture, a camera rendering into a texture — resized and re-derived for each of them in turn. The copy is taken ahead of all of them for the frame, so what a readback of its guid answers is the content of the last frame the renderer drew: the presented view's own image at the presented resolution, since the presented view is the sink that draws last. A request made while the renderer is holding frames back is carried to the next frame it draws rather than being answered from a target another sink left standing, so a readback can wait a frame longer than the copy itself takes.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture — the TextureHandle renderer.texture.create returned, a guid, or a texture AssetRef.
local cpu = renderer.texture.readback(handle); local ztex = cpu:encode(); cpu:unload()

modules/renderer/texture.tone

texture.tone(histogram: any): TextureTone

Reduce a histogram to what the picture's tone IS: where its darkest and brightest pixels sit, where the body of it sits, and how much of it is standing on the floor or the ceiling — all in code values on the 0-255 scale the pixels were delivered at.

span (max - min) is the whole range including a single stray pixel; spread (p95 - p5) is the range the body of the picture occupies, which is the reading that says whether a shot is legible. A frame whose subject is modelled and shaded but delivered inside a few code values reads a large mean and a tiny spread, and no mean alone can tell that apart from a frame with a subject in it.

crushed and clipped are the shares of the picture at code 0 and at code 255, each 0..1 — what a shot loses to the floor and to the ceiling.

Parameters

  • histogram any (optional) — A histogram from cpu:histogram().
local t = cpu:tone(); if t.spread < 24 then error("the shot is flat") end

modules/renderer/texture.update

texture.update(texture: string | { [string]: any } | AssetRef, src: any): TextureHandle

Overwrite the GPU texture texture names IN PLACE, under the same guid, from new raw pixels. Never writes a .texture file — the play-mode mutate path. Takes every form that names a texture — the TextureHandle create returned, the guid renderer.texture.list hands out, a TextureCpuHandle or a texture AssetRef. Returns a handle carrying the new dimensions: the handle it was given, refreshed, and a handle over the guid otherwise.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture to update — a TextureHandle, a guid, a TextureCpuHandle or a texture AssetRef.
  • src any (optional) — New raw pixels {rgba, width, height, srgb?, format?}rgba as a buffer, a binary string, or a number array.

modules/renderer/textureMemory

textureMemory(): {

What the GPU texture cache holds, split by whether the texture is block-compressed. compressedBytes and uncompressedBytes are what those textures cost in VRAM, measured from each texture's own format and mip chain — so a .texture whose settings name format = "bc7" appears in the compressed columns at a quarter of what the same image costs as RGBA8. blockCompressionSupported is whether this adapter can hold block-compressed textures at all; where it is false a BC7 payload is uploaded decoded and lands in the uncompressed columns instead, so the texture is present everywhere and compressed where the hardware allows it. Measured at the end of the last rendered frame. streamableTextures is how many of them a texture budget can move the base mip level of, split by where a level change reads the levels it needs from: assetStreamedTextures are read back from the asset they came from and hold nothing in system memory, retainedTextures hold the payload because a script uploaded their pixels and the GPU copy is the only other one there is. streamSourceBytes is what those held payloads occupy in system memory — bytes that are not VRAM — so it is a reading on the retained half alone. pinnedTextures counts the textures big enough to stream that stand at a level nothing can move: their pixels were released and no asset holds them, the asset behind them could not be read back, or a UI image, a post-process property or a render feature holds a view of them. A texture out of the streamable set only because no measured surface wears it stands in neither count: a surface reaching it takes it back up, so its level moves again as soon as there is a footprint to move it by. It reads 0 while no budget is armed.

local tm = renderer.textureMemory()
print(("%d compressed textures hold %.1f MiB"):format(tm.compressedTextures, tm.compressedBytes / 1048576))
print(("%d textures stream from their asset, %d hold %.1f MiB of pixels"):format(
tm.assetStreamedTextures, tm.retainedTextures, tm.streamSourceBytes / 1048576))

modules/renderer/textureStreaming

textureStreaming(): TextureStreaming

What the last frame's texture-residency plan decided. budgetBytes is the armed budget, and 0 means residency is left alone. streamable is how many textures the plan can move. residentBytes is what those textures occupy now, measured from the textures that are allocated; demandedBytes is what the frame's demand alone would have cost, so the two part exactly where the budget is doing something. starved counts the textures left coarser than the frame asked for, promoted the ones that climbed a level this frame, and changed the ones whose GPU texture was replaced. A camera approaching a surface reads promoted above zero for a few frames and then zero once it settles.

textures is one row per streamable texture, ordered by key, carrying the level each one was asked for and the measurement that asked. Two byte totals can agree while a single texture sits several levels off what its surface samples, so read the row when the question is which level a texture holds and why.

With budgetBytes at 0 nothing holds a level back, so residentBytes, plannedBytes and demandedBytes all read the whole chain of every texture still enrolled and textures is empty — which is how a session that armed a budget and dropped it reads back that the levels came home.

local ts = renderer.textureStreaming()
print(("textures: %.1f MiB resident of %.1f MiB demanded, %d starved"):format(
ts.residentBytes / 1048576, ts.demandedBytes / 1048576, ts.starved))

modules/renderer/transmissionShadows

transmissionShadows(): boolean

Whether translucent casters tint the directional light they block.

modules/renderer/uploadStats

uploadStats(): {

What the last completed frame spent re-describing its renderables to the GPU. Every renderable owns a slot in the per-instance data a draw reads — its world matrix, the bounds the culler tests it by, and the flags that decide which passes and which culling stages see it — and a frame uploads only the slots whose contents changed. bytes is what those uploads carried, fullBytes what re-sending every slot would have cost, and writes how many buffer writes carried it. The three numbers cover that per-renderable data alone, so a scene standing still reads bytes = 0 against a fullBytes that grows with the scene, and the ratio says how much of it the scene's own churn — rather than its size — is paying for.

local u = renderer.uploadStats()
print(("instance upload: %d B of %d B in %d writes"):format(u.bytes, u.fullBytes, u.writes))

modules/renderer/variantSource

variantSource(program: string): string?

The WGSL one of the programs renderer.shaderVariants() lists holds, exactly as the shader compiler received it. program is the program field of a row's base or of one of its variants. Reading a base alongside a variant shows what a feature set selected: each program's text holds the code its own features guard. The variant-report spelling of renderer.compiledSource, which answers the same for every other shader.

Parameters

  • program string — A program name from renderer.shaderVariants().
local row = renderer.shaderVariants()[1]
local base = renderer.variantSource(row.base.program)

typed/builtin//modules/api/engine/renderer/renderer/anisotropy

renderer.anisotropy() -> number

The maximum anisotropy material textures are sampled with right now — the requested level clamped to what this device honours.

Returns number — The effective level, 1 through 16.

if renderer.anisotropy() < 4 then ... end

typed/builtin//modules/api/engine/renderer/renderer/blendedBatching

renderer.blendedBatching() -> boolean

Whether blended neighbours sharing a draw key draw together.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/clearShadowHero

renderer.clearShadowHero() -> boolean

Release the hero caster, so the directional shadow is the cascades' alone again and the layer the hero view rendered into is given back.

Returns boolean — Whether a caster was registered.

renderer.clearShadowHero()

typed/builtin//modules/api/engine/renderer/renderer/clearShadowProxy

renderer.clearShadowProxy(mesh: string?) -> number

Stop proxying mesh, so it rasterizes its own geometry into shadow views again. Called with no argument, drops every registration.

Parameters

  • mesh string (optional) — The mesh to stop proxying. Omit to clear all of them.

Returns number — How many registrations were removed.

renderer.clearShadowProxy(statueMesh)
print(renderer.clearShadowProxy(), "proxies dropped")

typed/builtin//modules/api/engine/renderer/renderer/collect

renderer.collect() -> RuntimeCollection

Release every runtime texture, material, mesh and render feature nothing holds: no handle a script still reaches, no live owner, no reference from live engine state, no asset backing it, no hold. A root scene load runs this once the new scene stands, so what the previous scene's content created and nothing still wears goes with that scene; calling it directly collects at any other moment. A session material's handle counts as reached while the entity it was keyed for stands, and stops counting once that entity is gone. It reaches the GPU textures the device holds beside the registry's own: a texture the cache loaded for an asset goes once nothing live names it and is read back from that asset the next time something asks for it, while one no asset answers for stays, there being nothing to read it back from — a render pass's own target, a colour swatch, an atlas the engine built. A texture the ASSET path uploaded and whose asset has since been removed has nothing to come back from either, and the collection decides about it from its holders the way it does about every other resource: a handle a script still reaches, a live owner, a reference from live engine state, a hold. Features go first, then materials, then meshes, then textures, so a texture only a released material named goes with the material. Runs a full garbage collection first, so a handle nothing reaches counts as let go, and yields for the frame the census runs on. A handle the calling function still has in a variable — or in a temporary it has not overwritten — is one a script reaches, so a resource created in the function that collects is let go by the next collection rather than this one.

Returns RuntimeCollection{ released = { texture, material, mesh, feature }, kept, entries } — the counts released per kind, how many stayed, and every resource's status with action = "released" | "kept".

local c = renderer.collect() print(c.released.texture, c.kept)

typed/builtin//modules/api/engine/renderer/renderer/compiledShaders

renderer.compiledShaders() -> { string }

Every name renderer.compiledSource answers for — one per name a shader compile has run under this session, whether it succeeded or failed. What makes the composed-source surface enumerable rather than something to guess a key for.

Returns { string } — An array of shader names, sorted.

for _, name in renderer.compiledShaders() do print(name) end

typed/builtin//modules/api/engine/renderer/renderer/compiledSource

renderer.compiledSource(shader: string) -> string?

The WGSL the shader compiler received under one name, exactly as it received it — the composed module, which is what a compile error's line numbers and handle indices are positions in. Answers under any name a compile ran under (identity, guid, alias, or a program from renderer.shaderVariants()), for a shader that declares no features, and for a shader whose compile FAILED, which is the case it exists for: a message about a function body carries a position and nothing else, and the text that position is in is this. The failed text stands for as long as shaderRef:compileStatus() reports that failure under the same name.

Parameters

  • shader string — Any name a shader compiled under — identity, guid, alias, or a shaderVariants() program name.

Returns string? — The composed WGSL, or nil for a name no compile has run under.

local status = asset.resolve("myShader", "shader"):compileStatus()
if status.status == "failed" then
print(status.error)
print(renderer.compiledSource("myShader"))
end

typed/builtin//modules/api/engine/renderer/renderer/compositeSize

renderer.compositeSize() -> { width: number, height: number }

The size of the image the post-scene phases worked on in the last presented frame — the target the UI composites onto, which every pass after the scene reads as @scene.color and writes into, and which a screenSpace = "composite" render target follows. While the renderer presents the viewport itself that is the display's own size, whatever fraction of it the scene rasterized at; while a UI viewport panel owns the presentation it is the size the scene rasterized at, since the panel draws the scene target at its own rect and nothing upscales before the composite. Both read 0 before a frame has drawn.

Returns { width: number, height: number } in pixels.

local c = renderer.compositeSize()

typed/builtin//modules/api/engine/renderer/renderer/cullStats

renderer.cullStats() -> {

What the last completed frame decided to draw. total renderables went into the frustum test, culled fell outside it and visible survived. Of those, occlusion culling measured occlusionTested against the depth pyramid and proved occlusionCulled were entirely behind other geometry — both 0 while renderer.occlusionCulling() is false. A renderable the pyramid has no say over — one that laid no depth in the pre-pass, one whose bounds were never recorded, one straddling the near plane — is measured against nothing and counted in neither, so the gap between visible and occlusionTested reads how much of the frame the test could speak for.

This answers for the main camera. What a shadow view's own volume did with the frame's casters is on that view's row in renderer.shadowViews().

Returns { total: number, culled: number, visible: number, occlusionTested: number, occlusionCulled: number }

local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")

typed/builtin//modules/api/engine/renderer/renderer/depthPrepass

renderer.depthPrepass() -> boolean

Whether the opaque depth pre-pass is currently enabled.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/depthPrepassOrder

renderer.depthPrepassOrder() -> { runs: number, reordered: number }

What the last frame's depth pre-passes planned, and how far their sequences were from near-to-far before they ordered. runs counts the instanced draws planned; reordered counts the adjacent pairs the sort moved past each other, taken before it ran. Both are summed over every pre-pass the frame ran — the window plus each render-target camera, each ordering against its own camera. Both read 0 while the pre-pass or the ordering is off, and reordered reads 0 for a frame that already stood in order. The ordering leaves no other trace — the draws, the depth and the image are the same either way.

Returns { runs: number, reordered: number }

local o = renderer.depthPrepassOrder()  -- o.reordered > 0 → it sorted

typed/builtin//modules/api/engine/renderer/renderer/depthPrepassOrdering

renderer.depthPrepassOrdering() -> boolean

Whether the depth pre-pass is submitted nearest-first.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/destroy

renderer.destroy(handleOrKind: any?, id: string?) -> boolean

Free the GPU resource a renderer resource holds (the GPU-destroy verb). Takes any of the forms that name it: the handle a create returned, routed by its category so one call releases a mixed set of handles; the id a listing hands out, whose kind is read back off what the renderer holds under it — the runtime registry, the material definitions, the live features, and the device itself for an asset's own texture or mesh; or the kind with the id beside it, the shape renderer.hold and renderer.references take, which is what names the kind for an id two of them answer to. An id nothing holds anything under releases nothing and answers false. The on-disk asset, if any, is untouched. A CPU handle's :unload() frees the CPU copy separately.

Parameters

  • handleOrKind any (optional) — A MeshHandle, TextureHandle, MaterialHandle or feature handle; the id itself; or the kind ("texture", "material", "mesh", "feature") with the id as the second argument.
  • id string (optional) — The guid or registry key, when the first argument is a kind.

Returns boolean true if a GPU resource was known under the id.

renderer.destroy(meshHandle); renderer.destroy(materialHandle)
renderer.destroy(renderer.texture.list()[1].guid)
renderer.destroy("mesh", guid)

typed/builtin//modules/api/engine/renderer/renderer/deviceGeneration

renderer.deviceGeneration() -> number

Which render device this process is on, counted from the first.

A render device is lost when a driver resets, when the GPU is taken away, or when a browser reclaims a WebGPU context. The engine answers by building another device and re-deriving this session's resources onto it, and this number moves by one each time it does. Anything held across frames that was built from a GPU resource records this beside it and remakes it when the two differ; engine.onDeviceRebuilt is the hook that fires when it moves.

Returns number — The current device generation, counting from 1.

local generation = renderer.deviceGeneration()
engine.onDeviceRebuilt(function(g) print("device " .. g) end)

typed/builtin//modules/api/engine/renderer/renderer/deviceState

renderer.deviceState() -> string

Whether the render device this process draws through is the one it is using, one it is replacing, or one it has stopped trying to replace.

"ready" is a live device. "rebuilding" is the window between a device reporting itself lost and another being in place: every GPU resource built from the old one is invalid, the frames in that window draw nothing, and anything reaching the GPU refuses. "abandoned" is after the engine gave up — the adapter refused every attempt, so this session draws no more frames.

Work that spans the device — build a render target, draw into it, read it back — reads this to tell an operation that failed because the device went out from under it, which is worth doing again once renderer.deviceGeneration() moves, from one that failed on its own terms. The loss is reported before the next device exists, so the two readings answer different halves: this one says a replacement is coming, the generation says it arrived.

Returns string"ready" | "rebuilding" | "abandoned".

if renderer.deviceState() == "rebuilding" then return end

typed/builtin//modules/api/engine/renderer/renderer/drawDiagnostics

renderer.drawDiagnostics() -> { DrawDiagnostic }

Every renderable that is NOT drawing what its material says — the one call for "why does this surface look wrong". Three states land here: a surface rendering as the magenta placeholder (substituted), one the renderer could bind nothing for at all (outcome = "skipped"), and one drawing a program whose most recent compile FAILED (stale), which is what a shader edited into brokenness looks like — the pipeline its last good compile built keeps drawing, so the picture is intact and answers to none of the edits since. Each row names the entity, the program asked for, the program bound, programStatus — the compile gate's word about the program the material NAMED — and the one cause from shaderCompileFailed / shaderNotRegistered / shaderNotCompiledYet / noGbufferEntry / renderStateKeyNotBuilt / noPipelineForTarget / unshaded, with the compiler's own message in detail or programError. Covers every renderable the renderer holds, whether or not a camera reached it: a row with observed = false and outcome = "notDrawn" carries the renderer's own resolution for one this frame drew nowhere, so a broken surface off-screen is reported the same as one in frame. An empty result means every renderable the renderer holds is drawing the program its material named and that program compiles. Answers on the deferred path as well as forward, and in edit mode as well as play.

Returns { DrawDiagnostic }

for _, d in renderer.drawDiagnostics() do print(d.entity, d.reason, d.detail) end
if #renderer.drawDiagnostics() == 0 then print("every surface is drawing what it says") end

typed/builtin//modules/api/engine/renderer/renderer/drawStats

renderer.drawStats() -> {

What the last completed frame actually submitted. draws counts every geometry draw call the frame issued — the camera's passes, each shadow view a shadow-casting light adds, and whatever a render feature draws — and instances counts the instances those draws covered. The pair is what separates one draw carrying five hundred instances from five hundred draws carrying one each, so it reads how well the scene batches rather than how many objects are in it.

compacted is how many of those instances the frame planned through draws whose instance count the GPU decides: the culler's own per-object answers packed into a dense run, so an object it rejects is absent from the draw instead of collapsing to nothing in the vertex stage. compactedDrawn is how many of them survived, counted on the GPU as it packed them — a pass that then skips a whole draw over its own layer or visibility answer leaves that draw's instances in both numbers.

The plan is made over the populations the frame draws, and the tests answer which of their instances the packing keeps. That packing runs before any pass has resolved the depth occlusion culling is tested against, so on its own it reads the frustum and screen-size answers alone. With setOcclusionCulling armed the frame packs the same plan a second time once the test has answered, and compactedDrawn then counts what came through occlusion as well.

compactedDrawn comes back from the buffer the GPU wrote, so it describes a frame that has finished while compacted describes the most recent plan, and it holds the last count the GPU wrote until another arrives — a frame that compacts nothing reads compacted 0 beside the count from the last frame that did. In a scene standing still the gap between the two is the front-end work culling removed.

materialBinds is how many times the frame's geometry passes set a material's parameter group, and materialBindsElided how many times a pass reached that decision and found the group already bound. Their sum is how many times the decision was reached — once per unit of geometry submitted, which sits at or below draws, since a mesh of several primitives draws once per primitive under one set of binds. The ratio inside the pair is what material binding costs the frame: the batched opaque geometry is gathered into runs sharing a material, so a frame of many such draws over few materials binds about once per material rather than once per unit. materialExtraBinds and materialExtraBindsElided are the same pair for the second group, the storage bindings a shader declares for itself, which only the shaders that have them ever bind.

pipelineBinds and pipelineBindsElided are the same pair for the pipeline itself: how many times the frame's geometry passes set one, and how many times a pass reached that decision and found the pipeline it wanted already bound. Which pipeline a unit needs follows its shader, its material's render state and its mesh's vertex layout together, so a scene whose units share all three costs one set for the run of them, while units differing in any one of the three each pay their own. Their sum is how many units reached the pipeline decision, which sits at or above what the material pair reports: a unit the pass settles a pipeline for and then abandons — one whose material group resolved to nothing — counts here and never reaches the material decision.

Every figure here is the whole frame's, the main camera's draws and every shadow view's summed together. renderer.shadowViews() splits compacted and compactedDrawn across the views that made them, and carries the camera's own share beside them.

Returns { draws: number, instances: number, compacted: number, compactedDrawn: number, materialBinds: number, materialBindsElided: number, materialExtraBinds: number, materialExtraBindsElided: number, pipelineBinds: number, pipelineBindsElided: number }

local d = renderer.drawStats(); print(d.instances / math.max(d.draws, 1), "instances per draw")
print(renderer.drawStats().compactedDrawn, "of", renderer.drawStats().compacted, "survived the cull")
local d = renderer.drawStats(); print(d.materialBinds, "material binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")

typed/builtin//modules/api/engine/renderer/renderer/framePacing

renderer.framePacing() -> FramePacing?

How far the CPU is allowed to run ahead of the GPU, and what holding it there cost the frame just finished. Submitting work to the GPU returns before the GPU has done it, and everything that submission holds — its staging allocations, its bind groups, its command buffer — stays alive until it completes. A frame that asks for more work than the GPU finishes in a frame's time therefore leaves that behind it, and unbounded that is memory growth rather than a lower frame rate.

framesInFlight is how many submitted frames have not reported done through the queue's completion signal, held under maxFramesInFlight: a device that keeps up reads under the bound, one that is behind reads at it. It counts submissions, which is its own quantity — how many presented images the swapchain permits in flight is a separate setting. mechanism names how that bound is enforced here: submission-wait waits for the frame that many frames back and reports the wait in waitMs, so a paced frame costs latency and still draws; submitted-work-done counts outstanding frames off the queue's completion signal and declines to start a frame while the bound is met, counting those in pacedFrames and leaving the last presented image up. submittedFrames counts the frames that were admitted and submitted, so it rises for as long as the renderer is producing frames — which is what tells a renderer running slowly under a tight bound from one that has stopped. stalled reads true while that completion signal has stopped arriving and the pacer stood down rather than hold the image indefinitely; it clears on the first frame that finds the count back under the bound.

producing is whether the renderer is drawing frames at all. A headless renderer draws into an offscreen framebuffer that nothing presents, so its image reaches a reader only through something that copies it out: it draws while a consumer is asking — an MCP call in flight, a queued texture readback, a recording, a frame-egress session — and declines the frames between two asks, counting them in idleSkippedFrames. Every other renderer stat answers with the last frame that drew, so producing is what separates a live reading from a frozen one. A windowed renderer presents every frame it draws and reads producing = true throughout.

presentMode is what the surface presents with and presentModes what it offers; both are empty of meaning on a headless renderer, which never presents.

Returns FramePacing?{ framesInFlight, maxFramesInFlight, pacedFrames, submittedFrames, waitMs, mechanism, stalled, producing, idleSkippedFrames, presentMode, presentModes }, or nil before the renderer has drawn a frame

local p = renderer.framePacing()
print(("%d of %d frames in flight, paced by %s"):format(p.framesInFlight, p.maxFramesInFlight, p.mechanism))

typed/builtin//modules/api/engine/renderer/renderer/getRaytrace

renderer.getRaytrace() -> boolean

Whether ray tracing is currently enabled.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/gpuMemory

renderer.gpuMemory() -> GpuMemory

Where the renderer's GPU memory went at the last completed frame — the call to reach for when something is holding memory and you do not know what.

Three figures answer three different questions, and they are meant to be read against each other:

  • The categories — shadow, textures, meshes, instances, compute, summing to categorised — are the renderer's own accounting of what it asked for on purpose. Always present, on every backend.
  • allocator is the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. It exceeds categorised by the per-frame render targets and the scratch nothing categorises. The allocator hands memory out from blocks it reserves whole from the device and returns a block only once nothing is left in it, so reservedBytes runs above allocatedBytes by what those blocks hold unused; blocks lists them emptiest first with the labels that keep each one alive, and emptyBytes plus slackBytes is that distance exactly — the pool held in empty blocks, and the room pinned inside blocks something still sits in.
  • driver.deviceLocalBytes is what the graphics driver charges this process, out of the kernel's own accounting. It is the biggest of the three and the one that fills a card, because it also holds the swapchain, the images the driver keeps on the renderer's behalf, and the rounding to whole pages and heap blocks that neither figure above sees. Read it when the question is how much of the machine's GPU this engine is using; read the two above when the question is what the engine spent it on. A platform with no per-process accounting reports available = false and the reason.
  • driver.outsideAllocatorBytes is that charge less everything the allocator reserved — what the driver holds on its own account, and the one figure here nothing releases: a dropped pipeline, another scene and renderer.collect() all leave it where it is, and it falls when the device is destroyed. Read it when a session's device memory has grown and no ledger row accounts for the growth.

compute is what the compute subsystem holds; compute.observe() names each of those resources and what it costs. renderTargets counts the offscreen render targets the renderer holds at that frame, which is what says a renderer.destroy has been applied rather than queued.

Returns GpuMemory — The accounting — see GpuMemory. The category figures are zeroed until the renderer has published its first frame; driver is read as the call runs and answers from the first.

local m = renderer.gpuMemory()
print(("shadows %.1f MiB"):format(m.shadow.total / 1024 / 1024))
-- how much of the card this engine is holding:
if m.driver.available then
print(("driver %.1f MiB"):format(m.driver.deviceLocalBytes / 1024 / 1024))
else
print("no driver figure: " .. m.driver.reason)
end
-- the biggest allocation in the engine:
local top = m.allocator and m.allocator.byLabel[1]
print(top and top.label, top and top.bytes)
-- the block held for the least, and what pins it:
local block = m.allocator and m.allocator.blocks[1]
if block and block.allocationCount > 0 then
print(block.size, block.allocatedBytes, block.byLabel[1].label)
end

typed/builtin//modules/api/engine/renderer/renderer/hold

renderer.hold(handleOrKind: any?, id: string?) -> boolean

Pin a runtime resource for the session. A held texture, material, mesh or render feature survives every collection — the one a root scene load runs and a direct renderer.collect() alike — until renderer.release lets it go or its destroy frees it. It is the way to keep an ad-hoc resource across the scenes that come and go under it. A hold keeps the resource in the registry; a mesh's GPU buffers are governed by what draws it, parked as a CPU definition when the last instance naming it goes and brought back when one names it again, so renderer.mesh.isResident(guid) is the separate question about the buffers.

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind ("texture", "material", "mesh", "feature") with the guid or key as the second argument.
  • id string (optional) — The guid or key, when the first argument is a kind.

Returns boolean true when the registry knows the resource.

renderer.hold(tex)
renderer.hold("material", "swatch")

typed/builtin//modules/api/engine/renderer/renderer/loseDevice

renderer.loseDevice()

Destroy the render device on the next frame, so the engine meets a real device loss.

This is the one loss that can be caused on purpose, and it travels the same path a driver reset does: frames draw nothing until the rebuild lands, GET /engine/status reports the renderer as recovering while it does, engine.onDeviceRebuilt fires afterwards, and renderer.deviceGeneration() moves. Use it to prove that a world's content survives a device loss — anything it holds only on the GPU has to be remade from the rebuild hook, and this is how you find out whether it is.

renderer.loseDevice()
-- some frames later:
print(renderer.deviceGeneration()) -- one higher than before

typed/builtin//modules/api/engine/renderer/renderer/mainCameraView

renderer.mainCameraView() -> { number }?

The main camera's inverse view-projection (column-major, 16 numbers) followed by its world position (3 numbers) — {m0..m15, px,py,pz} — for reconstructing world positions from the depth buffer in a ray-tracing pass. Nil before the first render.

Returns { number }? 19 numbers, or nil.

typed/builtin//modules/api/engine/renderer/renderer/materialCost

renderer.materialCost() -> { MaterialObservation }

What each material cost the frame the renderer last drew, and the state it holds each one under. One row per material the renderer holds a prepared bind group for — a material an author wrote and the renderer never prepared is absent, which is itself the answer to "why is nothing I set reaching the screen". draws and instances cover that one frame; placeholderDraws is how many of those draws bound the magenta placeholder instead of this material's own program; binds is how many material-owned bind groups the frame's passes SET for it and bindsElided how many of its draws wanted a group the pass already held, which is what draw-key sorting buys; a draw that fell back to the placeholder bound the placeholder's group, so it counts in placeholderDraws and in neither bind count. uniformBytes is the GPU uniform buffer's own size, which is the reflected property block raised to the 16-byte floor and rounded up to the copy alignment. renderer.drawDiagnostics() names WHICH renderable is not drawing what its material says, and why.

Returns { MaterialObservation }

for _, m in renderer.materialCost() do print(m.material, m.draws, m.binds, m.bindsElided) end

typed/builtin//modules/api/engine/renderer/renderer/materialIdentity

renderer.materialIdentity() -> MaterialIdentity

Which material each renderable draws with, as a number a shader can carry. A material is authored and bound by name, and no shader can read a string — so every renderable's per-instance record holds a material index instead. slots is the name → index table those indices are drawn from: an index is assigned the first time the renderer draws with that material and does not move afterwards, so two renderables that differ only in material read different indices, and one renderable reads the same index frame after frame. It follows that the table keeps a row for every material name drawn this session, whether or not anything still draws with it. renderables is a row per renderable that owns a GPU slot — the entity it belongs to, that slot, and the index the record at it carries; populations is the same for an instanced draw, whose whole reserved run of slots carries the one material its registration named. That index is what a shader reads as instance_data[slot].material_index, and the row a ray hit resolves through zeroMaterial(). A renderable draws with the material its entity references, so one whose entity names none carries index 0.

Returns MaterialIdentity{ slots: { [string]: number }, renderables: { { entity: string, slot: number, index: number, material: string } }, populations: { { slot: number, count: number, index: number, material: string } } }

local id = renderer.materialIdentity()
for _, r in id.renderables do print(r.entity, r.slot, r.index, r.material) end

typed/builtin//modules/api/engine/renderer/renderer/materialIndex

renderer.materialIndex(name: string) -> number?

The index standing for a material, or nil for one the renderer has not drawn with yet. Pass it to a shader (or compare it against what a shader read out of instance_data[slot].material_index) to tell which material a drawing instance carries.

Parameters

  • name stringstring Material name, as renderer.material.create filed it.

Returns number?

local red = renderer.materialIndex("brick_red")

typed/builtin//modules/api/engine/renderer/renderer/maxAnisotropy

renderer.maxAnisotropy() -> number

The highest anisotropy this device honours: 16 on hardware that filters anisotropically, 1 on hardware that does not, where a higher request would be downgraded to trilinear regardless. Read it to report quality honestly — renderer.setAnisotropy clamps for you, so a request never needs guarding.

Returns number — The device ceiling, 1 or 16.

local best = renderer.maxAnisotropy()

typed/builtin//modules/api/engine/renderer/renderer/minScreenSize

renderer.minScreenSize() -> number

The on-screen radius, in pixels, an object must reach to be drawn. 0 while the cutoff is off.

Returns number

local px = renderer.minScreenSize()

typed/builtin//modules/api/engine/renderer/renderer/morphStats

renderer.morphStats() -> {

The morph state the last frame drew with. A mesh carries the shapes it can blend towards and an entity carries how strongly each is blended (ecs.MorphWeights); where both are present, the vertex stage adds the weighted deltas to the base geometry.

instances is how many render slots that happened at, and blends how many single-target blends those slots carry between them: a slot contributes one per target its weights move, or that they moved the frame before, so the number of targets a mesh can be given is bounded by the buffer the blends live in. meshes is how many meshes hold a delta block and targets how many targets those blocks cover between them; deltaBytes is what the shared buffer they are appended into holds. A morph-target mesh whose weights are all zero reads a meshes above zero beside an instances and blends of zero.

Returns { instances: number, blends: number, meshes: number, targets: number, deltaBytes: number }

local m = renderer.morphStats()
print(("%d instances carrying %d blends over %d meshes"):format(m.instances, m.blends, m.meshes))

typed/builtin//modules/api/engine/renderer/renderer/observe

renderer.observe() -> RenderObservation

Everything the renderer knows about the frame it last drew: what program it bound for each renderable, the render state it holds each material under, and what each program has cost in pipeline builds. renderables is one row per renderable in the renderer's draw list, carrying the program its material named (requestedProgram) beside the one that was bound (boundProgram) — __error__ wherever the lookup missed and the draw went ahead on the magenta placeholder — plus substituted, the outcome (drew / drewPlaceholder / skipped / notDrawn), the reason that forced it and the compiler's own detail for a failed compile. observed says which of two answers a row is: true for a resolution a geometry pass took as it drew, false for the renderer's own resolution of a renderable this frame drew nowhere, which is what a renderable outside every camera's frustum or layer mask reports. materials is one row per material the renderer holds a prepared bind group for; shaders is one row per program pipelines have been built for. frame names the frame every per-frame count covers; retainedFrames how many frames a resolution a pass took is kept for after the last frame that drew it; window and costWindow state both in the document itself. Recording is armed by the first read, so this waits for the frame that first records rather than answering empty.

Returns RenderObservation

local o = renderer.observe(); print(o.placeholderDraws, "renderables drew the placeholder in frame", o.frame)

typed/builtin//modules/api/engine/renderer/renderer/occlusionCulling

renderer.occlusionCulling() -> boolean

Whether occlusion culling is currently enabled.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/passSchedule

renderer.passSchedule() -> {

The schedule check over this frame's enqueued render passes. Passes declare what they read (inputs) and what they write (output / outputs / storage), and the frame runs them in phase order and, inside a phase, in order order. violations holds every input bound to a resource the frame produces LATER: that read samples the resource as it stands ahead of that pass, which is the previous frame's contents for a render target that persists, an empty target for one just created, and the scene draw's own output for a @scene.* buffer — and the pass renders either way. The frame's own buffers are checked on the same terms as a render target: bind @scene.motion at a phase ahead of the pass that writes it and the read is reported, naming the buffer and its writer. A pass reading a resource ahead of that write on purpose declares that slot in its enqueue's readsPrevious and drops out of the list; unboundPrevious holds declared slots the pass binds no such resource to, which cover nothing. A resource no queued pass writes is not reported — a camera rendering to texture and compute.dispatch both fill targets outside the pass queue, and the scene draw fills the @scene.* buffers every frame. A read the frame has only one order for is not reported either: where the writing pass consumes something the reading pass produces, the reader runs first or the writer has nothing to write, which is what a pass reading a buffer into a target of its own and a second pass copying that target back over the buffer forms. unreachable holds passes at a phase that does not run their kind: every phase drains its fragment and compute passes, while afterLighting is the one that draws geometry, draw and splat passes, so one of those enqueued elsewhere sits in the queue and never runs. Each finding is also stated in the engine log the first time it appears.

Returns { violations, unboundPrevious, unreachable }

local s = renderer.passSchedule()
for _, v in s.violations do print(v.message) end

typed/builtin//modules/api/engine/renderer/renderer/pipelineCache

renderer.pipelineCache() -> PipelineCache?

What the driver's compiled-pipeline store held, built, and wrote back. A pipeline is machine code the GPU driver compiles from the shader bound into it, and that compile is what a launch pays before the first frame drawing with each pipeline can appear. The store keeps that compiled code across runs, so a launch whose shaders have not changed reads back what the previous one compiled.

restoredBytes is what a previous run left for this GPU and this launch read; pipelinesBuilt counts the pipelines built since startup and buildMs is what they cost together, which is the number the store lowers. saves and savedBytes describe writing it back — deferred until a burst of builds settles, so one launch is one write — and dirty is true while pipelines have been built that the file does not hold, including after a write that failed, which lastError then names. path is the file, named after the GPU it belongs to.

supported is false where the platform holds no store a program can carry: a browser keeps its own and hands none out, and an adapter can lack the capability. reason says which, and the build count and timing still read true there. lastError names a read or write failure; a failed store costs the saved compile and never the frame, since every pipeline is built from its source either way. pipelinesBuilt and buildMs are engine-wide totals; renderer.shaderCost() is the same cost broken down per program, with each one's permutation count.

Returns PipelineCache?{ supported, reason, path, restoredBytes, pipelinesBuilt, buildMs, saves, savedBytes, dirty, lastError }, or nil before the renderer has drawn a frame

local c = renderer.pipelineCache()
print(("%d pipelines in %.1f ms, %d bytes restored"):format(c.pipelinesBuilt, c.buildMs, c.restoredBytes))

typed/builtin//modules/api/engine/renderer/renderer/pointShadowBudget

renderer.pointShadowBudget() -> PointShadowBudget

The point-light shadow pool now in force. A point light with castsShadows renders an omnidirectional cube map, six faces of depth, and slots is how many of them fit — a further caster is lit but throws no shadow, and the engine log names how many were turned away. The slot count is bought rather than authored: megabytes of VRAM at resolution texels per face is what decides it.

Returns PointShadowBudget — The pool — see PointShadowBudget.

local p = renderer.pointShadowBudget()
print(("%d shadowed point lights, %.1f MiB"):format(p.slots, p.bytes / 1024 / 1024))

typed/builtin//modules/api/engine/renderer/renderer/projectionOffset

renderer.projectionOffset() -> (number, number)

The sub-pixel projection offset in force for the main camera, in NDC.

Returns (number, number) — The x and y offset, both 0 when the projection samples pixel centres.

local ox, oy = renderer.projectionOffset()

typed/builtin//modules/api/engine/renderer/renderer/raycast

renderer.raycast(origin: vec3, direction: vec3, maxDistance: number?, exclude: (string | { string })?) -> RenderRayHit?

Cast a ray against the geometry the renderer DRAWS and return the nearest surface it meets. Every visible mesh answers, whether or not anything gave it a rigid body — so a terrain, a procedurally generated mesh, or any plain Model reports the surface at a point, which is what a camera station, a prop, a sound source or a scatter standing on the ground needs to know. The answer is the nearest triangle of the mesh, so a sloped or terraced surface reports its height where it was asked rather than the extent of its bounding box.

distance is measured from origin along the direction given, so it is a world-space distance whenever that direction is a unit vector, and it is directly comparable to a physics.raycast distance along the same ray. normal is a unit vector turned to face back along the ray. exact is true when the answer is a triangle and false when it is the object's bounding box, which is what a mesh whose vertices live only in GPU buffers answers with. The triangles are the mesh's own, placed by the entity's transform and by the mesh's bind pose, so a surface a skinning or morph pass deforms on the GPU answers as the geometry the mesh holds.

EVERYTHING drawn is in scope — the ground you meant, and equally a character standing on it, a prop, a placeholder floor. The hit names its entity in entityId, exclude steps over the ones you do not want, and renderer.raycastAll hands back the whole column so you can pick the surface yourself. A height you did not expect is usually a nearer surface you did not mean to ask about, so read entityId before trusting a number.

Parameters

  • origin vec3vec3 ray start in world space
  • direction vec3vec3 ray direction; any length, the engine normalises
  • maxDistance number (optional)number? how far the ray reaches, in world units. Default 1000
  • exclude (string | { string }) (optional)(string | { string })? entity id, or ids, to step over

Returns RenderRayHit?

local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then camera.position = { x, hit.point.y + 1.7, z } end

typed/builtin//modules/api/engine/renderer/renderer/raycastAll

renderer.raycastAll(origin: vec3, direction: vec3, maxDistance: number?, maxHits: number?, exclude: (string | { string })?) -> { RenderRayHit }

Cast a ray against the geometry the renderer draws and return every surface along it, nearest first. One entry per renderable the ray crosses — the nearest intersection with each — so a stack of surfaces reads as the order they stand in, and a caller after one particular surface finds it by entityId rather than hoping it is the nearest. Each entry carries the fields renderer.raycast returns.

Parameters

  • origin vec3vec3 ray start in world space
  • direction vec3vec3 ray direction; any length, the engine normalises
  • maxDistance number (optional)number? how far the ray reaches, in world units. Default 1000
  • maxHits number (optional)number? how many surfaces to return. Default 32
  • exclude (string | { string }) (optional)(string | { string })? entity id, or ids, to step over

Returns { RenderRayHit }

for _, hit in renderer.raycastAll(eye, look, 500) do print(hit.entityId, hit.distance) end

typed/builtin//modules/api/engine/renderer/renderer/raytraceCapability

renderer.raytraceCapability() -> string

The active ray-tracing backend: "hardware" (GPU ray query) or "compute" (software traversal — the path on devices without hardware ray query, e.g. the web). The same ray-tracing features work on both.

Returns string "hardware" | "compute"

if renderer.raytraceCapability() == "hardware" then ... end

typed/builtin//modules/api/engine/renderer/renderer/raytraceStats

renderer.raytraceStats() -> { [string]: any }

What the ray-tracing acceleration structure holds, and what this session's frames have spent building it. A ray walks a structure built over the scene's geometry, and keeping it current is work a frame pays before it traces anything. On the "compute" backend geometry that has stood still long enough is filed under a static partition the frames after it leave alone: staticTriangles + dynamicTriangles = triangles, nodes is the hierarchy over them, fullRebuilds / partialRebuilds / reusedFrames count what the session's frames did, and trianglesRebuilt is what those rebuilds re-emitted, summed. On the "hardware" backend blas is the bottom-level structures cached, blasBuilt how many the last frame built, and tlasInstances what the top-level structure names. The counters are cumulative — sample, run the scene, sample again.

Returns { [string]: any }table {backend, triangles, staticTriangles, dynamicTriangles, nodes, fullRebuilds, partialRebuilds, reusedFrames, trianglesRebuilt, blas, blasBuilt, tlasInstances}

local before = renderer.raytraceStats().trianglesRebuilt

typed/builtin//modules/api/engine/renderer/renderer/references

renderer.references(handleOrKind: any?, id: string?) -> RuntimeResourceStatus?

What holds a runtime resource right now — the answer a root scene load reads before releasing it. references names each live consumer the engine found: { by = "entity", id } for an entity wearing the material or mesh, "material" for a material whose slot names the texture, "instancedDraw", "camera", "sky", "lightmap", "ui" (a screen drawing it) and "postProcess" (an effect sampling it). handleHeld says whether a script still reaches a handle to it, assetBacked whether an asset stands behind it, ownerLive whether the component instance, scene load or feature that created it still stands, and held whether a hold pins it. origin reads "device" for a GPU texture the device holds that no script created — the one the cache loaded for an asset, the atlas the engine built — whose holders are the references, a handle and the asset. Runs a full garbage collection first, the same one renderer.collect runs, so a handle nothing reaches counts as let go and the row says what the next collection does with the resource. Yields for the frame the census runs on.

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind with the id second.
  • id string (optional) — The guid or key, when the first argument is a kind.

Returns RuntimeResourceStatus? — The resource's status, or nil for a key the registry does not record and the device holds no texture under.

local s = renderer.references(mat) for _, r in s.references do print(r.by, r.id) end

typed/builtin//modules/api/engine/renderer/renderer/reflectionEnvironment

renderer.reflectionEnvironment() -> {

What a reflective surface is reflecting. probes is how many reflection probes the shading blends; they are gathered highest priority first, each rank taking the coverage the ranks above it left, so a small interior probe ranked above the large exterior one it sits inside wins outright wherever it reaches full weight. ranks is the priority each of those probe slots was published with, in slot order. sky is whether the sky fallback is armed: with it, coverage no probe claims reflects the captured sky, and without it a surface outside every probe's radius falls back to the nearest probe alone. skyCaptured is whether the sky slot holds a capture — arming is refused until it does, since an uncaptured slot reflects black. slots is how many cube slots the environment array holds right now: the sky's alone, at index skySlot, until a probe is captured into it, then that one plus one per probe. maxProbes is how many of them probes may take, and resident whether the array has grown past the sky's single slot. Capture the sky with environment.captureSky().

Returns { probes, ranks, sky, skyCaptured, resident, slots, skySlot, maxProbes }

local env = renderer.reflectionEnvironment()
print(("%d probes, sky fallback %s"):format(env.probes, tostring(env.sky)))

typed/builtin//modules/api/engine/renderer/renderer/release

renderer.release(handleOrKind: any?, id: string?) -> boolean

Let go of the hold renderer.hold placed. The resource stays until nothing else holds it and a collection releases it — the one a root scene load runs, or a direct renderer.collect().

Parameters

  • handleOrKind any (optional) — The resource's handle, or its kind with the id second.
  • id string (optional) — The guid or key, when the first argument is a kind.

Returns boolean true when the registry knows the resource.

renderer.release(tex)

typed/builtin//modules/api/engine/renderer/renderer/renderTargetLimits

renderer.renderTargetLimits() -> {

The size a render target may be on this device. maxDimension is the device's own maximum 2D texture dimension — the largest either side of a render target may take. maxPixels is how many pixels one render target may hold, so the RGBA8 image it reads back as fits in a single buffer on every platform the engine runs on, and maxSquare is the largest square that budget buys. A capture, a renderer.texture.create({ width, height }) or a render-to-texture camera past either bound is refused at the call with the reason, so ask here for the size to request.

Returns { maxDimension, maxPixels, maxSquare }

local lim = renderer.renderTargetLimits()
local w = math.min(want, lim.maxSquare)

typed/builtin//modules/api/engine/renderer/renderer/renderTargets

renderer.renderTargets() -> {

Every render target the renderer owns and what each one costs, measured from the texture that is allocated. One row per target, each carrying its name, whether it is resident, the bytes it holds while it is, its width/height/layers/mipLevels, and onDemand. An onDemand target exists only while something needs it: a target nothing writes into reads resident = false and bytes = 0 and appears again the frame something writes it, and one sized by content — the reflection-probe cube array — holds the slots content asked for. The scratch the draws into a render target have needed is reported as camera[<handle>].* rows: depth and motion vectors under any rasterized pass, and the occlusion channel and G-buffer over them under a camera's scene render. A draw builds what it needs, and the set goes once no live camera names the target and sixty frames have passed without a draw, so a target nothing draws into carries no such row; the colour image drawn into belongs to the texture cache and outlives every one of those releases. totalBytes is what the resident targets hold together. Measured at the end of the last rendered frame.

Returns { targets, totalBytes, residentCount }

local rt = renderer.renderTargets()
print(("render targets: %.1f MiB over %d resident"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
if t.onDemand then print(t.name, t.resident, t.bytes) end
end

typed/builtin//modules/api/engine/renderer/renderer/resolutionScale

renderer.resolutionScale() -> number

The fraction of the display resolution the scene is currently rendered at. 1 until something sets it.

Returns number

local s = renderer.resolutionScale()

typed/builtin//modules/api/engine/renderer/renderer/setAnisotropy

renderer.setAnisotropy(level: number) -> number

Set the maximum anisotropy material textures are sampled with. Takes effect on the next frame for content already on screen — no reload, no texture re-upload. 1 is plain trilinear.

Parameters

  • level number — One of 1, 2, 4, 8, 16. Any other value is an error.

Returns number — The EFFECTIVE level after clamping to renderer.maxAnisotropy(), so asking for more than the device offers reports what was actually applied.

renderer.setAnisotropy(16)

typed/builtin//modules/api/engine/renderer/renderer/setBlendedBatching

renderer.setBlendedBatching(enabled: boolean) -> ()

Whether neighbours in a view's back-to-front blended order draw together. On by default: alpha-blended geometry is submitted farthest-first, and a stretch of neighbours in that order sharing a mesh, a material, a shader and a pose is submitted as one instanced draw over those neighbours, which puts the same members on screen in the same order out of a single submission. A run stops wherever a differently-drawn renderable sorts between two of its members, and a mesh of several primitives keeps a draw per renderable — both would otherwise move fragments through each other. Off, every blended renderable draws on its own at its own slot, so a transparent crowd costs a draw per member. The image is the same either way, which is what makes this the comparison a frame suspected of being formed by the batching is made against; renderer.drawStats().draws counts the difference.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setBlendedBatching(false)  -- a draw per blended renderable

typed/builtin//modules/api/engine/renderer/renderer/setDepthPrepass

renderer.setDepthPrepass(enabled: boolean) -> ()

Enable or disable the opaque depth pre-pass. While enabled the renderer resolves opaque depth in its own pass before shading, so each shaded pixel runs its material once instead of once per surface stacked behind it, and the resolved depth is what occlusion culling reads. Enabled by default.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setDepthPrepass(false) -- shade every layer, for comparison

typed/builtin//modules/api/engine/renderer/renderer/setDepthPrepassOrdering

renderer.setDepthPrepassOrdering(enabled: boolean) -> ()

Submit the depth pre-pass nearest-first. Renderables reach the pre-pass in the order they were registered, which stands in no relation to where the camera is: a scene built back-to-front makes every layer write depth and be overwritten by the layer in front of it. Ordered, the nearest surface writes first and the surfaces behind it are rejected by the depth test before they write. The same draws go out either way and the depth that comes out is the same, so scene.depth_prepass in profiler.gpuFrame() is what moves. Enabled by default.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setDepthPrepassOrdering(false) -- submit in registration order

typed/builtin//modules/api/engine/renderer/renderer/setGpuMemoryTracking

renderer.setGpuMemoryTracking(frames: number?) -> number

Set how often the GPU allocator sampler reads — one reading every frames frames — or turn it off with 0. It starts at 60, a reading a second at 60 Hz, so renderer.gpuMemory().allocator answers without anything arming it. Building the ledger walks every live allocation, which is why it is sampled rather than read every frame; the category figures cost nothing either way, and a reader between samples sees the most recent ledger, so a slow interval still answers.

Called with no argument it reports the interval in force and changes nothing, which is how something that retimes the sampler puts it back afterwards instead of restoring a number it assumed was the default.

Parameters

  • frames number (optional)number? Frames between readings; 0 turns the sampler off. Omit to read the interval without changing it.

Returns number — The interval now in force.

renderer.setGpuMemoryTracking(60)
local was = renderer.setGpuMemoryTracking()
renderer.setGpuMemoryTracking(1)
-- ... take a tight reading ...
renderer.setGpuMemoryTracking(was)

typed/builtin//modules/api/engine/renderer/renderer/setMaxFramesInFlight

renderer.setMaxFramesInFlight(frames: number) -> number

Set how many frames of GPU work may be outstanding before the renderer stops running ahead. One is the least overlap this can express — a frame's work is waited for as soon as the next frame has been submitted — which is the lowest latency and the lowest throughput; higher values let a slow frame build a longer backlog, and that backlog is memory. Takes effect on the next frame.

Answers the bound after clamping to [1, 8], so asking for more than the renderer honours reports what you actually got.

Parameters

  • frames number — number Frames of GPU work that may be outstanding, 1 through 8.

Returns number — The bound that took effect, after clamping.

renderer.setMaxFramesInFlight(1) -- lowest latency
print(renderer.setMaxFramesInFlight(99), "was clamped")

typed/builtin//modules/api/engine/renderer/renderer/setMinScreenSize

renderer.setMinScreenSize(pixels: number) -> ()

Stop drawing an object once its on-screen radius falls below this many pixels. A few pixels across, an object carries no detail a viewer can resolve while still costing a full vertex and submission pass, and the cutoff drops it from the camera's draws entirely — 0, the default, keeps every object however small it lands. Measured from the object's own bounds against the camera's projection, so the same threshold means the same apparent size at any distance or field of view. Shadow casters have their own threshold in renderer.setShadowCasterCutoff.

Parameters

  • pixels numbernumber — smallest on-screen radius still drawn; 0 disables.

Returns ()

renderer.setMinScreenSize(4) -- drop anything under 4 px of radius
print(renderer.drawStats().compactedDrawn, "instances survived it")

typed/builtin//modules/api/engine/renderer/renderer/setOcclusionCulling

renderer.setOcclusionCulling(enabled: boolean) -> ()

Enable or disable occlusion culling. While enabled the renderer reduces the pre-pass depth into a pyramid each frame and tests every renderable that cleared the frustum against it, dropping the ones another surface entirely covers before their geometry is submitted. The pyramid describes the frame being drawn, so an object that becomes visible this frame is never held back a frame. Requires the depth pre-pass.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setOcclusionCulling(true); print(renderer.cullStats().occlusionCulled)

typed/builtin//modules/api/engine/renderer/renderer/setPointShadowBudget

renderer.setPointShadowBudget(cfg: {
    megabytes: number?,
    resolution: number?,
}) -> number

Set how much VRAM the point-light shadow atlas may hold, and at what per-face resolution. An omitted field keeps its current value. The atlas is reallocated on the next frame, so renderer.pointShadowBudget().slots reports the new pool one frame later; the returned number is what this budget buys. Raising resolution sharpens every point shadow and spends the same memory on fewer of them — doubling it quarters the slot count. Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the pool never exceeds renderer.pointShadowBudget().maxSlots. One slot is always granted, so a budget too small for a single cube shadows one light and the pool costs what that slot costs rather than what was asked for — { megabytes = 1, resolution = 4096 } buys 384 MiB of ceiling. Read pointShadowBudget().bytes back to see what a budget actually bought, and renderer.shadowMemory().point to see what the scene has made resident.

Parameters

  • cfg { megabytes: number?, resolution: number?, } — The fields to change — megabytes and/or resolution.

Returns number — Cube slots this budget buys.

renderer.setPointShadowBudget({ megabytes = 96 })
renderer.setPointShadowBudget({ resolution = 1024, megabytes = 96 })

typed/builtin//modules/api/engine/renderer/renderer/setPresentMode

renderer.setPresentMode(mode: string) -> string

Set how a presented frame reaches the display. fifo queues every frame and shows it on a vertical blank, which never tears and never drops one; mailbox replaces the queued frame with the newest, which does not tear and does not hold the renderer to the refresh rate; immediate presents as soon as a frame is ready and can tear; fifo_relaxed is fifo that tears rather than stall when a frame misses its blank; auto_vsync and auto_no_vsync leave the choice to the backend.

A surface that does not offer the mode presents fifo instead, so read renderer.framePacing().presentMode for what took effect and .presentModes for what this surface offers. Takes effect on the next frame.

Parameters

  • mode string — string One of "fifo", "fifo_relaxed", "mailbox", "immediate", "auto_vsync", "auto_no_vsync".

Returns string — The canonical spelling of the request — renderer.framePacing().presentMode is what the surface presents with, and differs when the surface does not offer the request.

renderer.setPresentMode("mailbox")
print(renderer.framePacing().presentMode, "is what the surface took")

typed/builtin//modules/api/engine/renderer/renderer/setProjectionOffset

renderer.setProjectionOffset(x: number, y: number)

Offset the main camera's projection by a sub-pixel amount, in NDC, for the frames until it is set again. The offset is in NDC because that is the space it is constant in: one pixel is 2.0 / width across, so half a pixel is 1.0 / width. Velocity (@scene.motion) is measured against the offset-free projection, so a still scene reports no motion however the samples are placed — and picking resolves a click to the same ray either way. (0, 0) samples pixel centres.

Parameters

  • x number — Horizontal offset in NDC. One pixel is 2.0 / width.
  • y number — Vertical offset in NDC. One pixel is 2.0 / height.
renderer.setProjectionOffset(0.5 * 2 / w, -0.25 * 2 / h)

typed/builtin//modules/api/engine/renderer/renderer/setRaytrace

renderer.setRaytrace(enabled: boolean) -> ()

Enable or disable GPU ray tracing. While enabled the engine builds the scene acceleration structure each frame so ray-tracing render features can trace against it; disabling stops the build (so it costs nothing until a ray-traced effect is active). Required before any ray-traced shadows / AO / reflections render.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setRaytrace(true); renderer.feature.create("rt_shadows")

typed/builtin//modules/api/engine/renderer/renderer/setResolutionScale

renderer.setResolutionScale(scale: number) -> number

Render the scene at a fraction of the display's resolution and present it at the display's own size. Shading cost scales with pixel count and with nothing else, so this trades sharpness for frame time without taking anything out of the scene: at 0.5 the scene rasterizes a quarter of the pixels. UI and text are unaffected — they are drawn after the scene is brought back up to size. The scene rows in profiler.gpuFrame() are what move.

Parameters

  • scale numbernumber — fraction of the display resolution, clamped to [0.25, 1].

Returns number — the scale in force after clamping.

renderer.setResolutionScale(0.7)

typed/builtin//modules/api/engine/renderer/renderer/setShadowCaching

renderer.setShadowCaching(enabled: boolean) -> ()

Whether a shadow map that nothing changed is kept rather than drawn again. On by default: a shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is rasterized on the frames its own inputs change and holds the depth it drew on the ones they do not. Off, every view is drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setShadowCaching(false)  -- draw every shadow view, every frame

typed/builtin//modules/api/engine/renderer/renderer/setShadowCasterBatching

renderer.setShadowCasterBatching(enabled: boolean) -> ()

Whether a shadow view draws every caster of one mesh together. On by default: a view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — submits one draw per geometry over every caster of it the view admits, wherever those casters sit in render order and whatever transform slots they hold. Off, a view draws the runs of render-order neighbours that share a mesh AND hold consecutive slots, so a scene that has spawned and despawned anything fragments into many more draws. The image is the same either way, which is what makes this the comparison a shadow suspected of being placed by the batching is made against.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setShadowCasterBatching(false)  -- draw the runs the scene presents

typed/builtin//modules/api/engine/renderer/renderer/setShadowCasterCutoff

renderer.setShadowCasterCutoff(cfg: {
    minRadiusPx: number?,
    maxDistance: number?,
}) -> ShadowCasterCutoff

Set the shadow-caster cutoff. An omitted field keeps its current value, so a call can adjust one threshold without restating the other. Both are measured against the camera the frame draws from rather than against each light, so one setting covers every cascade, spot and cube face, and a caster that stops casting is one whose shadow the viewer could not have resolved. maxDistance is measured to the near side of the caster's bounding sphere, so a large object keeps casting while any part of it is in range. 0 releases a threshold; releasing both draws the casters the frame drew before either was set.

Parameters

  • cfg { minRadiusPx: number?, maxDistance: number?, } — The fields to change — minRadiusPx and/or maxDistance.

Returns ShadowCasterCutoff — The cutoff now in force.

renderer.setShadowCasterCutoff({ minRadiusPx = 2 })
renderer.setShadowCasterCutoff({ minRadiusPx = 1.5, maxDistance = 120 })

typed/builtin//modules/api/engine/renderer/renderer/setShadowConfig

renderer.setShadowConfig(cfg: {
    resolution: number?,
    cascades: number?,
    distance: number?,
    splitLambda: number?,
    fadeFraction: number?,
    softness: number?,
}) -> ShadowConfig

Set the directional shadow quality. Any omitted field keeps its current value, so a call can adjust one knob without restating the rest. Values are clamped: resolution [64, 8192], cascades [1, 4], distance >= 0, splitLambda [0, 1], fadeFraction [0, 1], softness [0, 1]. Changing resolution or cascades reallocates the depth array; the rest are per-frame values. A distance of 0 hands the range to the frame — the splits are cut over the depth its own shadow-taking renderables reach — and a positive one caps it, which is what a scene bounding its shadow cost states.

Parameters

  • cfg { resolution: number?, cascades: number?, distance: number?, splitLambda: number?, fadeFraction: number?, softness: number?, } — The fields to change — see ShadowConfig.

Returns ShadowConfig — The full config now in force.

renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ cascades = 4, resolution = 2048, distance = 240 })

typed/builtin//modules/api/engine/renderer/renderer/setShadowHero

renderer.setShadowHero(entity: string, padding: number?) -> ()

Give one caster a directional shadow view of its own, fit to its world bounds.

A cascade covers the slab of world the camera sees, so its texels are spread over tens of metres and one character standing in the middle of it is resolved by a handful of them. The hero view is the same light and the same depth range zoomed onto that entity's bounds, so the whole map goes into the shadow it and the ground under it carry — renderer.shadowHero().zoom is the factor its texel density gains.

It renders beside the cascades, into a layer of the same texture allocated while a hero is registered, and every surface inside it reads it in place of the cascade, crossing back at its edge. Nothing else about the shadow changes: the same casters reach it, at the same depth range, through the same filter.

Parameters

  • entity string — The entity whose renderables the view is fit around.
  • padding number (optional) — How much room the fit leaves around those bounds — for a pose that leaves the bind-pose box and for the filter that samples outside a silhouette. 1.0 fits them exactly.

Returns ()

renderer.setShadowHero(player.id)
print(("hero shadow: %.1f texels/unit vs %.1f"):format(
renderer.shadowHero().texelsPerUnit, renderer.shadowHero().cascadeTexelsPerUnit))

typed/builtin//modules/api/engine/renderer/renderer/setShadowProxy

renderer.setShadowProxy(mesh: string, proxy: string) -> ()

Rasterize proxy in place of mesh in every shadow view. A shadow is a silhouette resolved at the resolution of a shadow map, so the triangles that carry a mesh's close-up detail write depth no reader can resolve — a decimated version of the shape, a level of its own LOD chain, or a hand-built hull casts the same shadow for a fraction of the geometry.

The registration is keyed by MESH, so one call covers every instance of it — entities and GPU-driven populations alike — and a crowd sharing that mesh stays one draw. The proxy is placed by whatever places the caster, its instance's own transforms, so it stands where the caster stands, at the caster's scale.

An entity caster keeps its own geometry where a stand-in could not be placed or deformed correctly: it is skinned (it rasterizes the post-skinned vertices written for its own mesh), it blends morph targets (whose deltas describe its own mesh and are read by vertex id), or its proxy would be placed by a different node of its model than the source mesh is. Either caster keeps it where the renderer holds no geometry under the proxy's guid. Each of those is counted in renderer.shadowProxies().

Nothing else in the scene draws a proxy, so this call is what brings it onto the GPU, and it raises where it cannot. A proxy already resident there is registered as it stands.

Parameters

  • mesh string — The mesh a caster draws, as a guid or any mesh reference.
  • proxy string — The mesh it rasterizes into shadow views instead.

Returns ()

renderer.setShadowProxy(statueMesh, statueHullMesh)
print(renderer.shadowProxies().triangles, "vs", renderer.shadowProxies().sourceTriangles)

typed/builtin//modules/api/engine/renderer/renderer/setSkinnedBatching

renderer.setSkinnedBatching(enabled: boolean) -> ()

Whether skinned instances holding one pose draw together. On by default: instances of one mesh wearing one material and posed alike read the same post-skinned vertices, so the camera's colour passes submit them as a single instanced draw, and so does each shadow view and the velocity pass while renderer.shadowCasterBatching() is on — that switch is what makes a depth view form its draws by geometry at all. The camera depth pre-pass submits its casters nearest-first, which is a run per span of neighbours rather than a draw per geometry, so a crowd costs a draw per member there. Off, each skinned instance draws on its own at its own slot in every pass that rasterizes it. The image is the same either way, which is what makes this the comparison a frame suspected of being formed by the batching is made against — renderer.drawStats().draws counts the difference and renderer.skinningStats().poses says how many distinct poses it holds.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setSkinnedBatching(false)  -- a draw per skinned instance

typed/builtin//modules/api/engine/renderer/renderer/setSkinningPoseHold

renderer.setSkinningPoseHold(enabled: boolean) -> ()

Whether a pose the skinning pass already wrote is read as it stands. On by default: the pass produces an instance's vertices from its joint matrices, its node transforms, its blend weight and its blend model, so the slice holding a pose already holds what running the pass over those same inputs would write. A frame binding a pose whose slice still holds it reads the slice and dispatches nothing, and skinning costs what the frame's poses CHANGED — a paused clip, a skeleton nothing drives, a cast standing in one pose, each cost compute the frame the pose arrived and nothing after it. Off, every pose a frame binds is dispatched again, which is the comparison a frame suspected of reading a slice that no longer holds its pose is made against; the image is the same either way and renderer.skinningStats() counts the difference as dispatches against held. A mesh whose vertices a compute pass writes is dispatched every frame however this stands.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setSkinningPoseHold(false)  -- dispatch every pose, every frame

typed/builtin//modules/api/engine/renderer/renderer/setSpotShadowBudget

renderer.setSpotShadowBudget(cfg: {
    megabytes: number?,
    resolution: number?,
}) -> number

Set how much VRAM the spot/area shadow atlas may hold, and the per-side resolution of one layer. An omitted field keeps its current value. The atlas is reallocated on the next frame, so renderer.spotShadowBudget() reports it one frame later; the returned number is what this budget buys. Raising resolution sharpens the lights that cover the most screen and spends the same memory on fewer layers — doubling it quarters the layer count. Raising megabytes buys layers, which is what lets several lights hold a large tile at once. Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the atlas never exceeds spotShadowBudget().maxLayers. One layer is always granted, so a budget too small for one still shadows lights and the atlas costs what that layer costs rather than what was asked for.

Parameters

  • cfg { megabytes: number?, resolution: number?, } — The fields to change — megabytes and/or resolution.

Returns number — Atlas layers this budget buys.

renderer.setSpotShadowBudget({ megabytes = 64 })
renderer.setSpotShadowBudget({ resolution = 2048, megabytes = 64 })

typed/builtin//modules/api/engine/renderer/renderer/setTextureBudget

renderer.setTextureBudget(opts: TextureBudgetOpts) -> TextureBudget

Bound the VRAM a world's textures occupy, by keeping only the mip levels the frame is actually sampling. Pass { megabytes = 256 }; 0 — the default — leaves texture residency alone and every texture stays fully resident the way it uploaded.

With a budget armed, each frame measures how many screen pixels ONE traversal of a texture's coordinate range covers on the surface that spans it widest, and asks for the mip level that serves that span one texel per pixel — the level the GPU picks from the fragment's own derivatives. A material with uvScale = 8 lays eight copies of its texture across a surface, so each copy spans an eighth of the surface and asks for three levels coarser than the surface's own size would. A shader that declares // @uv_space: world advances its coordinate over world units rather than over the mesh's UVs, so how many copies a surface carries follows how large that surface is. The textures whose surfaces cover the fewest pixels give up levels until the set fits. Detail climbs one level per frame, from the image already on screen, so a surface the camera approaches sharpens rather than popping, and no texture is taken below the level whose longest side is 64 texels.

bias shifts every measurement by whole mip levels either way — negative for finer than the sampling implies, positive for coarser — over a world whose look wants a different trade than one texel per pixel.

The plan moves a texture whose demand the frame can measure: one at least 256 texels on its narrowest side, worn by a surface an entity draws. A texture a UI image, a post-process property or a render feature holds a view of stays whole, because nothing measures how much of the screen those cover.

Which textures the budget governs follows the surfaces the frame draws. A texture whose asset still holds its bytes is enrolled the frame a measured surface wears it — whenever it loaded, and whenever the budget was armed — because a level change reads the levels it needs back from the asset; when the last such surface goes it leaves the set whole, at the level it uploaded at, and a surface reaching it again takes it back up. A texture a script uploaded has its pixels nowhere else, so one enrolled while it is resident holds them in system memory (renderer.textureMemory().streamSourceBytes) from the upload until a surface has worn it and gone, and releases them then, which is what keeps it out for the rest of the session; one whose pixels were already released when the budget was armed is out from the start. renderer.textureMemory().pinnedTextures counts those, together with the textures whose asset could not be read back and the ones a UI image, a post-process property or a render feature holds a view of. Disarming returns every texture to the level it uploaded at, and arming again governs the textures the frame's surfaces are wearing then.

Parameters

  • opts TextureBudgetOpts{ megabytes: number?, bias: number? }

Returns TextureBudget{ megabytes, bias } — the budget now in force

renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 })  -- one level finer everywhere
renderer.setTextureBudget({ megabytes = 0 })               -- leave residency alone

typed/builtin//modules/api/engine/renderer/renderer/setTransmissionShadows

renderer.setTransmissionShadows(enabled: boolean) -> ()

Let translucent casters tint the sunlight they block instead of blocking it outright. A shadow map holds one depth per texel and is compared as a yes-or-no test, so stained glass, water and thin fabric all project the same black silhouette a wall does. With this on, a caster whose material declares opacity (base_color alpha under a transparent blend) or transmission also draws into a light-space transmittance map, and the colour it lets through multiplies into the directional light reaching whatever stands behind it. Stacked casters compose. Opaque casters are unaffected, and a scene with no translucent caster allocates nothing and records no pass.

Parameters

  • enabled booleanboolean

Returns ()

renderer.setTransmissionShadows(true)  -- stained glass tints the floor

typed/builtin//modules/api/engine/renderer/renderer/shaderCache

renderer.shaderCache() -> ShaderCache?

What the shader compile gate's store of baked WGSL held, answered and wrote back. Compiling a .shader wraps the author's body in its framework, expands every #include, and hands the result to naga to parse and validate — work that is a pure function of the text going in, and that a launch would otherwise repeat for every shader it draws with. The store keeps that baked text across launches.

restoredEntries and restoredBytes are what a previous launch left that this one read back. hits counts the compiles answered out of the store and misses those that ran in full; savedMs sums what each hit's own recorded compile had cost, against compileMs, what the misses spent. stale counts the misses whose key was held but whose #included modules had changed underneath — an entry records every module its expansion consumed, so editing a module invalidates exactly the shaders that included it and leaves the rest.

entries and bytes are what the store now holds, evictions how many a write dropped to stay inside its bounds, and saves / savedBytes / dirty describe writing it back, deferred until a burst of compiles settles. persistent is false where a launch has nowhere to keep artifacts and reason says why; location is the file, or the browser store, they are kept in. restoreState is how the read of what a previous launch left has gone — pending while it is still out (a browser answers through a promise, so a launch reaches its first frames before it lands), restored once entries came back, empty when there were none to come back, failed when what was there could not be read, and none where a launch keeps nothing. A cold, missing or corrupt store leaves every shader compiling from source with identical output, and lastError then names what went wrong.

Returns ShaderCache?{ persistent, reason, restoreState, location, restoredEntries, restoredBytes, hits, misses, stale, entries, bytes, compileMs, savedMs, saves, savedBytes, dirty, evictions, lastError }, or nil on a build with no renderer

local c = renderer.shaderCache()
print(("%d hits, %d misses, %.1f ms saved"):format(c.hits, c.misses, c.savedMs))

typed/builtin//modules/api/engine/renderer/renderer/shaderCost

renderer.shaderCost() -> { ShaderCost }

What each program has cost in pipeline builds, beside the compile gate's most recent word about it. variants is how many pipelines this engine has built for it — one per (target format, vertex layout, render-state key) permutation reached — and buildMs what those builds cost, both summed since engine start. A pipeline the driver's own store restored is not built and so is not counted, so a second launch on the same adapter reports less than the first. status is compiled, failed or pending, and error carries the compiler's message for a failure. Ordered by cost, most expensive first.

Returns { ShaderCost }

local top = renderer.shaderCost()[1]; print(top.shader, top.variants, top.buildMs)

typed/builtin//modules/api/engine/renderer/renderer/shaderVariants

renderer.shaderVariants() -> { ShaderVariants }

Every shader that declares optional features, and the programs its materials have made it compile. Each row carries the features the shader declares, the base program it ships as, and one entry per variant with the features that variant holds — so the permutation count a scene's materials are spending is a number to read rather than something to infer from compile time. A shader whose variants reach budget compiles no more; the materials asking for further feature sets draw with the base program.

Returns { ShaderVariants } — An array of ShaderVariants, one per feature-declaring shader.

for _, s in renderer.shaderVariants() do print(s.shader, #s.variants, s.budget) end

typed/builtin//modules/api/engine/renderer/renderer/shadingOf

renderer.shadingOf(subject: string | { [string]: any }) -> ShadingReading

What the renderer is shading ONE subject with, taken from the document the renderer publishes — the call a system holding a handle makes to find out whether what reaches the screen is its own material or the magenta placeholder standing in for it, without reading the engine log. subject is an entity that draws or the registry key of a material. state reads itsMaterial where the renderer bound the program the material names, errorMaterial where it bound the placeholder instead, stalePipeline where the pipeline drawing it was built before that program's most recent compile, nothingBound where the renderer resolved no pipeline for it, pending where this call is the one that armed per-draw recording and the frame after it publishes, and unknown where the renderer holds a resolution under no such subject. A fault state carries the renderer's own reason from the closed set renderer.drawDiagnostics() names — plus materialNotPrepared, which a material subject reads where the renderer prepared nothing under that key — the compiler's detail, the program the material asked for and the bound one; means states the reading in a sentence. A material subject answers from the renderables drawing with it, and from the renderer's record for the material itself where a draw registered against the material carries no row of its own; a subject that several renderables draw answers with a refused one wherever there is one. The reading follows the renderer, so a program that compiles on a later edit puts the subject back on itsMaterial from the frame the renderer draws it with again.

Parameters

  • subject string | { [string]: any } — The entity — a proxy from entity(...) or an entity-id string — or the material, as its registry key or the MaterialHandle renderer.material.create returned.

Returns ShadingReading

local s = renderer.shadingOf(emitter); if s.state ~= "itsMaterial" then print(s.means) end

typed/builtin//modules/api/engine/renderer/renderer/shadowCacheStats

renderer.shadowCacheStats() -> {

What the last frame did with the shadow maps it already had. A shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is drawn again only when something it draws from changed: its light moved, a caster it can see moved or appeared or vanished, a caster's geometry or material changed, a caster changed pose or moved the nodes its parts are placed by, or the map it writes into was reallocated. Anything else keeps the depth already in the texture, so a scene that stops moving reads rendered 0 while cached keeps climbing. A mesh whose vertices a compute pass writes — a population, or a mesh built from a compute buffer — re-renders the views it stands in every frame. A shadowed point light contributes six views, one per cube face, so a caster moving on one side of it re-renders the face that can see it and leaves the other five holding what they have. Counted per light kind, plus the totals across all three.

These are totals over every view of a kind. renderer.shadowViews() is the same frame one view at a time, each row naming the light that owns it and what it drew.

Returns { directionalRendered, directionalCached, spotRendered, spotCached, pointRendered, pointCached, rendered, cached }

local s = renderer.shadowCacheStats()
print(("shadow views: %d drawn, %d cached"):format(s.rendered, s.cached))

typed/builtin//modules/api/engine/renderer/renderer/shadowCaching

renderer.shadowCaching() -> boolean

Whether a shadow view may keep the depth it already holds.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/shadowCasterBatching

renderer.shadowCasterBatching() -> boolean

Whether a shadow view draws every caster of one mesh together.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/shadowCasterCutoff

renderer.shadowCasterCutoff() -> ShadowCasterCutoff

How small, and how far away, a caster may get before it stops writing depth into any shadow view. A shadow view rasterizes a caster's whole triangle count whatever the shadow it produces ends up covering, so an object the viewer resolves a fraction of a pixel of, and one past the range the scene cares about, each cost a full depth pass per shadowed light for detail nothing reads. Both thresholds are 0 — released — until something sets them.

Returns ShadowCasterCutoff — The cutoff in force — see ShadowCasterCutoff.

local c = renderer.shadowCasterCutoff(); print(c.minRadiusPx, c.maxDistance)

typed/builtin//modules/api/engine/renderer/renderer/shadowConfig

renderer.shadowConfig() -> ShadowConfig

The directional shadow quality now in force. resolution and cascades size the cascade depth array; distance and splitLambda place the splits along the view; fadeFraction and softness shape how the result is sampled.

Returns ShadowConfig — The full config — see ShadowConfig.

print(renderer.shadowConfig().cascades)

typed/builtin//modules/api/engine/renderer/renderer/shadowHero

renderer.shadowHero() -> ShadowHeroReport

The registered hero caster and what the last frame's fit produced. A frame that fit nothing says why in decline.

Returns ShadowHeroReport — See ShadowHeroReport.

local h = renderer.shadowHero()
print(h.active, h.zoom, h.decline)

typed/builtin//modules/api/engine/renderer/renderer/shadowMemory

renderer.shadowMemory() -> {

How much GPU memory the shadow maps hold right now, in bytes, by the light kind that owns them. The spot atlas and the point pool are sized to the casters in the scene rather than to the budget, so spot and point move as lights that cast shadows appear and leave, and a scene with one shadowed light holds far less than one that fills every slot. A budget is the ceiling they grow within — renderer.spotShadowBudget().layers and renderer.pointShadowBudget().slots report that ceiling, unmoved by how many casters exist. Raising shadow resolution costs the square of the change across every cascade.

Returns { directional, spot, point, total } in bytes

local m = renderer.shadowMemory()
print(("shadows: %.1f MiB"):format(m.total / 1024 / 1024))

typed/builtin//modules/api/engine/renderer/renderer/shadowProxies

renderer.shadowProxies() -> ShadowProxyReport

The shadow proxies in force and what the last frame's shadow passes did with them. triangles and sourceTriangles are what those passes submitted and what they would have submitted from the source meshes — the before/after of every registration, equal while nothing is proxied.

Returns ShadowProxyReport — See ShadowProxyReport.

local p = renderer.shadowProxies()
print(("shadow triangles: %d of %d"):format(p.triangles, p.sourceTriangles))

typed/builtin//modules/api/engine/renderer/renderer/shadowViews

renderer.shadowViews() -> ShadowViewReport?

Every shadow view the last rendered frame considered, and what each one cost.

A frame rasterizes a depth view per directional cascade, one for the hero caster, one per shadow-casting spot and six per shadow-casting point. renderer.shadowCacheStats() counts those views by light kind, renderer.drawStats() sums their draws with the camera's, and profiler.gpuFrame() carries one scene.shadow span across all of them. This is the same frame read one view at a time.

Each row names the view and the light that owns it, says whether it drew or kept the depth it already held, and carries the draws, the instances and the casters that went into it. span is the label the view's pass is timed under, so its GPU time is a lookup in profiler.gpuFrame(); every one of those labels is a variant of scene.shadow, which still carries their total. camera carries the same instance counters for the main camera, so the camera's share of a frame-wide total is a read rather than a measurement taken by turning every light's shadow off.

A cascade's near and far are where the split scheme cut its slice, not the world it covers: the fit takes the bounding sphere of that slice and rasterizes the ortho box around it, and both reach past far. What the cascade covers is center and radius, with viewProj the exact test; coversNear and coversFar read that volume back along one ray, the camera's view axis. directional states the axis reading for the set — how far it reaches (coversFar), the range the splits were run over (distance), how far the camera draws (cameraFar), and the depth past the reach the camera still draws (uncovered). A receiver further along the axis than coversFar has no directional depth map over it and is shaded as if the sun reached it, so uncovered is the room a missing shadow has and a surface standing in that room is what makes one; @builtin::systems.proxyOcclusion occludes past the cascades. The box is bounded in every direction, so a receiver standing wide of the axis leaves it at its own distance even where uncovered is 0 — viewProj is what answers for that receiver.

The list is rebuilt every frame: a view whose light stopped casting is absent from the next report rather than standing at the numbers it last had, and a frame that drew no shadow view answers a report whose views is empty. views grouped the way the shadow cache decides — a row per cascade, per spot atlas layer, per point cube — counts what renderer.shadowCacheStats() reports as rendered + cached.

The frame names its views only while something is reading them, so this call asks the frames after it to name theirs and waits out the first one. Nil on an engine that renders no frame at all.

Returns ShadowViewReport? — See ShadowViewReport.

local report = renderer.shadowViews()
print(("camera submitted %d of %d instances"):format(
report.camera.submittedInstances, renderer.drawStats().compactedDrawn))
for _, view in report.views do
print(("%s %d (%s): %d draws, %d instances, %s"):format(
view.role, view.index, view.light, view.draws, view.instances,
view.rendered and "drew" or "cached"))
end
local d = report.directional
if d ~= nil and d.uncovered > 0 then
print(("sun shadows stop at %.0f; the camera draws to %.0f"):format(
d.coversFar, d.cameraFar))
end

typed/builtin//modules/api/engine/renderer/renderer/skinnedBatching

renderer.skinnedBatching() -> boolean

Whether skinned instances holding one pose draw together.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/skinningPoseHold

renderer.skinningPoseHold() -> boolean

Whether a pose already written into its slice skips its dispatch.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/skinningStats

renderer.skinningStats() -> {

What the last frame's skinned instances cost. A skinned instance is posed by a compute pass that writes its vertices into a shared pool, and instances holding the same pose read one slice of that pool and the single dispatch that fills it. instances is how many were posed, poses how many distinct poses they held, and dispatches how many dispatches those poses cost this frame — so a crowd whose members move together costs what its poses cost rather than what its head count does, while members at different animation times each hold their own pose and pay for it.

held is how many of the frame's poses cost no dispatch at all. The pass produces a slice from what the pose is made of, so a slice an earlier frame filled already holds what running it again would write, and a pose still wearing that slice is read as it stands. Skinning is paid for by the poses that CHANGED: a cast standing still reads dispatches 0 beside a held equal to its poses, and the two add up to poses in any frame.

reusedSlices is how many of the frame's poses took a slice the pool already held — one a retired pose gave back, or one a pose nothing has asked for this frame was holding — rather than one cut from pool the engine had never used. A scene whose poses keep changing reads a non-zero count beside a poolBytes that stays where it was.

liveBytes is what the slices holding this frame's poses occupy, against unsharedBytes — what the same instances would occupy with a slice each. poolBytes is what the pool holds; a previous-position buffer of the same size rides alongside it so skinned deformation reaches motion vectors.

Returns { instances: number, poses: number, dispatches: number, held: number, reusedSlices: number, liveBytes: number, unsharedBytes: number, poolBytes: number }

local s = renderer.skinningStats()
print(("%d instances over %d poses"):format(s.instances, s.poses))
print(("%d poses dispatched, %d read as they stood"):format(s.dispatches, s.held))
print(("skinned vertex storage: %d B of an unshared %d B"):format(s.liveBytes, s.unsharedBytes))

typed/builtin//modules/api/engine/renderer/renderer/spotShadowBudget

renderer.spotShadowBudget() -> SpotShadowBudget

The spot and area-light shadow atlas now in force. Each shadow-casting spot is given a tile of it every frame, sized to what the camera can resolve: a light filling the view gets a whole layer at resolution, one far away gets a minResolution tile, and the atlas holds tiles of the smallest kind. That is what lets one budget serve a close hero light and a street of distant ones without either the memory or the sharpness being set for the worst case.

Returns SpotShadowBudget — The atlas — see SpotShadowBudget.

local s = renderer.spotShadowBudget()
print(("%d layers of %d, %.1f MiB"):format(s.layers, s.resolution, s.bytes / 1024 / 1024))

typed/builtin//modules/api/engine/renderer/renderer/textureMemory

renderer.textureMemory() -> {

What the GPU texture cache holds, split by whether the texture is block-compressed. compressedBytes and uncompressedBytes are what those textures cost in VRAM, measured from each texture's own format and mip chain — so a .texture whose settings name format = "bc7" appears in the compressed columns at a quarter of what the same image costs as RGBA8. blockCompressionSupported is whether this adapter can hold block-compressed textures at all; where it is false a BC7 payload is uploaded decoded and lands in the uncompressed columns instead, so the texture is present everywhere and compressed where the hardware allows it. Measured at the end of the last rendered frame. streamableTextures is how many of them a texture budget can move the base mip level of, split by where a level change reads the levels it needs from: assetStreamedTextures are read back from the asset they came from and hold nothing in system memory, retainedTextures hold the payload because a script uploaded their pixels and the GPU copy is the only other one there is. streamSourceBytes is what those held payloads occupy in system memory — bytes that are not VRAM — so it is a reading on the retained half alone. pinnedTextures counts the textures big enough to stream that stand at a level nothing can move: their pixels were released and no asset holds them, the asset behind them could not be read back, or a UI image, a post-process property or a render feature holds a view of them. A texture out of the streamable set only because no measured surface wears it stands in neither count: a surface reaching it takes it back up, so its level moves again as soon as there is a footprint to move it by. It reads 0 while no budget is armed.

Returns { blockCompressionSupported, compressedTextures, compressedBytes, uncompressedTextures, uncompressedBytes, streamableTextures, assetStreamedTextures, retainedTextures, pinnedTextures, streamSourceBytes }

local tm = renderer.textureMemory()
print(("%d compressed textures hold %.1f MiB"):format(tm.compressedTextures, tm.compressedBytes / 1048576))
print(("%d textures stream from their asset, %d hold %.1f MiB of pixels"):format(
tm.assetStreamedTextures, tm.retainedTextures, tm.streamSourceBytes / 1048576))

typed/builtin//modules/api/engine/renderer/renderer/textureStreaming

renderer.textureStreaming() -> TextureStreaming

What the last frame's texture-residency plan decided. budgetBytes is the armed budget, and 0 means residency is left alone. streamable is how many textures the plan can move. residentBytes is what those textures occupy now, measured from the textures that are allocated; demandedBytes is what the frame's demand alone would have cost, so the two part exactly where the budget is doing something. starved counts the textures left coarser than the frame asked for, promoted the ones that climbed a level this frame, and changed the ones whose GPU texture was replaced. A camera approaching a surface reads promoted above zero for a few frames and then zero once it settles.

textures is one row per streamable texture, ordered by key, carrying the level each one was asked for and the measurement that asked. Two byte totals can agree while a single texture sits several levels off what its surface samples, so read the row when the question is which level a texture holds and why.

With budgetBytes at 0 nothing holds a level back, so residentBytes, plannedBytes and demandedBytes all read the whole chain of every texture still enrolled and textures is empty — which is how a session that armed a budget and dropped it reads back that the levels came home.

Returns TextureStreaming{ budgetBytes, streamable, residentBytes, plannedBytes, demandedBytes, starved, promoted, changed, textures }

local ts = renderer.textureStreaming()
print(("textures: %.1f MiB resident of %.1f MiB demanded, %d starved"):format(
ts.residentBytes / 1048576, ts.demandedBytes / 1048576, ts.starved))

typed/builtin//modules/api/engine/renderer/renderer/transmissionShadows

renderer.transmissionShadows() -> boolean

Whether translucent casters tint the directional light they block.

Returns boolean

typed/builtin//modules/api/engine/renderer/renderer/uploadStats

renderer.uploadStats() -> {

What the last completed frame spent re-describing its renderables to the GPU. Every renderable owns a slot in the per-instance data a draw reads — its world matrix, the bounds the culler tests it by, and the flags that decide which passes and which culling stages see it — and a frame uploads only the slots whose contents changed. bytes is what those uploads carried, fullBytes what re-sending every slot would have cost, and writes how many buffer writes carried it. The three numbers cover that per-renderable data alone, so a scene standing still reads bytes = 0 against a fullBytes that grows with the scene, and the ratio says how much of it the scene's own churn — rather than its size — is paying for.

Returns { writes: number, bytes: number, fullBytes: number }

local u = renderer.uploadStats()
print(("instance upload: %d B of %d B in %d writes"):format(u.bytes, u.fullBytes, u.writes))

typed/builtin//modules/api/engine/renderer/renderer/variantSource

renderer.variantSource(program: string) -> string?

The WGSL one of the programs renderer.shaderVariants() lists holds, exactly as the shader compiler received it. program is the program field of a row's base or of one of its variants. Reading a base alongside a variant shows what a feature set selected: each program's text holds the code its own features guard. The variant-report spelling of renderer.compiledSource, which answers the same for every other shader.

Parameters

  • program string — A program name from renderer.shaderVariants().

Returns string? — The compiled WGSL, or nil for a name no compile has run under.

local row = renderer.shaderVariants()[1]
local base = renderer.variantSource(row.base.program)
  • api
  • reference