Log inGet started

renderer

Updated October 20, 2026

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.

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

The renderer namespace: 563 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?, options: AtmosphericsHoldOptions?) -> () -> ()

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. exclusive takes the air 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 share holding it. That is what lets one caller clear the air to read a surface and keep it clear 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.atmospherics.release hands the air back by.

Parameters

  • share number (optional) — How much of the authored air reaches the image, in [0, 1]. Defaults to 0 -- no air at all.
  • options AtmosphericsHoldOptions (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 caller the call is attributed to, which names the calling session where the transport attested one and the ZeroMind account otherwise. exclusive takes the air for the stated key until the hold is released.

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

local release = renderer.atmospherics.hold() ; local picture = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()
local release = renderer.atmospherics.hold(0, { exclusive = true, owner = "albedo-read" })

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) print("atmosphere share", share) end)

globals/renderer/atmospherics/owner

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

The hold naming the share of the authored air that reaches the image right now: who took it, what share it asked for, and whether it took the air exclusively. Several agents drive one engine at once and a hold any of them takes moves the air every surface in every other agent's frame is rendered through, so this is how a caller sees that another agent holds it before its own share 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.atmospherics.release hands the air 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?, share: number, exclusive: boolean }? — { id, name, share, exclusive } for the standing hold, or nil when the authored air is what reaches the image.

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

globals/renderer/atmospherics/release

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

Hand the air 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 air pinned with nobody holding a release for it. Naming the key is how the authored air comes back, 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.atmospherics.owner reports.

Returns number — How many holds came off the stack.

renderer.atmospherics.release("albedo-read")

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.

local debugChannel = renderer.captureView.channelId("lightmap") -- a render feature passes it as an enqueued pass's `debugChannel`

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. A view that registered a ready of its own is also held to it, so a view whose feature is not running, or whose draw has not landed, reads false. 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?, ready?, 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). ready is the view's own statement of whether what it draws is in place: a function(): (boolean, string?) answering true, or false and the reason. A capture of the view waits on it after ensure and before it renders, so a view whose draw lands over the frames after ensure (an entity it spawns, a feature that reads the state ensure built on its next frame, a material still compiling) is drawn rather than captured empty, and a view that stays unready is refused with that reason. warmup is how many present frames a capture lets the view accumulate, once it is ready, 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, ready, description, warmup, renderLayers } 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 every receiver samples the level its footprint selects again.

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 pages 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/clusterErrorPx

renderer.clusterErrorPx(px: number?) -> number

The cluster cut's error threshold, in pixels. Every static mesh draws the coarsest clusters whose simplification error projects to at most this many pixels in the view drawing it — measured against that view's own pixel height and projection, so a small render target draws coarser geometry than the full-screen view does from the same place. Lower draws finer geometry at more cost; higher draws coarser geometry. A client graphics setting. Default 1.

Parameters

  • px number (optional) — A finite number above 0. Omit to read the threshold.

Returns number — The threshold in effect.

renderer.clusterErrorPx(0.5) -- finer geometry on this machine

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. A runtime mesh needs no call: once the garbage collector has taken its last handle, the engine judges it against the same holders, again whenever what displays it changes or its hold is released, and every few hundred frames while something holds it, and releases it once nothing does. Returns once what it released has left the device: the GPU objects of the textures and meshes it let go are freed over the frames after the call, and it waits for those frames (up to 60 drawn frames), so renderer.texture.list, renderer.mesh.list, renderer.material.list, renderer.textureMemory() and renderer.gpuMemory() read after it in the same script no longer count anything it reports released. landed says whether every release left the device within that wait; false names a texture or mesh something put back on the device before it could be seen gone, which renderer.references explains. Code that cannot yield (a metamethod, onDestroy, a module's top level while require runs it) is answered at once, without the wait: there landed is false while a release is still on its way, and the listings and totals stop counting it over the drawn frames that follow.

Returns RuntimeCollection — { released = { texture, material, mesh, feature }, kept, entries, landed }: the counts released per kind, how many stayed, every resource's status with action = "released" | "kept", and whether every release had left the device when the call returned.

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

globals/renderer/compiledShaders

renderer.compiledShaders() -> { string }

Every key renderer.compiledSource answers for — one per shader key (guid@checksum) or variant program a compile has run under, whether it succeeded or failed, for as long as the shader that compiled under it stands: a removed .shader takes its keys with it. What makes the composed-source surface enumerable rather than something to guess a key for.

Returns { string } — An array of keys, 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 for a shader key (guid@checksum), a program from renderer.shaderVariants(), or any reference to a shader asset (identity, path, guid), which reaches the key of the version that reference resolves to now; 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.

Parameters

  • shader string — A shader key, a shaderVariants() program name, or a reference to a shader asset.

Returns string? — The composed WGSL, or nil when no compile has run for it or its shader was removed.

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 visibility raster, 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 the shadow page cull did with the frame's casters for each light is on that light's row in renderer.shadowViews().

views is the engine cull's own count, one row per camera view that rendered: view is "main" for the main camera and the camera's entity id for an offscreen one, kind says which, and frame is the frame the counts describe. The main view's row is first. drawn is whether a render drew the view in that frame: a frame the renderer declined still ran the cull's first test, so its row carries that test's counts and no second one, for a picture nothing rasterized. The counts are read back from the GPU and handed out oldest first, one frame per render, so a script reading once a frame reads the frames in the order they ran. A frame's counts wait at most two renders to be handed out: a row describes a frame as many frames back as the read took and at most two more, and when more frames' counts come back at once than that lets wait, the oldest of them are the frames a script does not read. instancesTested renderables were tested; instancesCulled breaks the rejections down by reason (hidden, layer, range, frustum, screenSize, and occludedPass1/occludedPass2 for occlusion against the depth of the frame that last drew the view and then this frame's); instancesRecovered came back in the second test. Then the clusters of the survivors: clustersTested, rejected by the level-of-detail cut (clustersLodRejected), the frustum (clustersFrustumCulled), facing away (clustersConeCulled) or occlusion (clustersOccludedPass1, of which clustersRecovered came back), and clustersDrawn / trianglesDrawn. binsPending counts material pipelines still compiling, whose clusters wait a frame; overflow counts clusters that did not fit the view's lists. total/culled/visible and occlusionTested/occlusionCulled above are the main view's count for the renderables drawn outside the cluster lists.

Returns { total: number, culled: number, visible: number, occlusionTested: number, occlusionCulled: number, views: { { [string]: any } } }

local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")
for _, v in renderer.cullStats().views do if v.view == "main" then print(v.clustersDrawn, "clusters,", v.instancesCulled.frustum, "outside the frustum") end end

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/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 the platform has refused every quick attempt 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 every quick attempt at another device was refused (a browser whose GPU process crashed twice answers no adapter for minutes): nothing is drawn, the engine log names the platform's last answer, and the engine keeps asking at a slower pace, so the state returns to "ready" on its own once the platform hands a device over. Restarting the engine (reloading the page on the web) builds one from scratch.

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 group's textures (group 1), and materialBindsElided how many times a pass reached that decision and found the group already bound. On the bindless backend every material group binds the one texture group of the frame, so a pass binds it once; on buckets a pass binds once per group it draws (see renderer.materialTable().groups). 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. materialExtraBinds and materialExtraBindsElided are the same pair for group 2, the storage buffers a shader declares, bound per distinct buffer set, which only the shaders that declare 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. renderer.shadowViews() carries the main camera's own share of compacted and compactedDrawn.

clusters and clusterTriangles are the clusters and triangles the camera views drew from the cluster lists, from the GPU's own count.

drawsByPass splits draws by the part of the frame that submitted them — visibility (the id raster: one draw per raster bin per Hi-Z phase in each camera view, submitted whether or not the GPU cull left anything in the bin), resolve (one draw per resolve draw in each camera view: one per shader, and on the array-bucket backend one per shader and material group), legacy (geometry drawn outside cluster storage: skinned, morphed and compute-written meshes, line topology, shaders that write their own vertex stage), blended, exception (opaque materials whose depth state the visibility buffer cannot express), featureGeometry, featureDraw, shadow and splat (one draw per splat buffer a render feature draws) — and the nine sum to draws.

instances counts instances named by draws whose size the CPU decided; the visibility raster's work is clusters and clusterTriangles, which the GPU decided. pixelsResolved is how many pixels the resolve shaded, summed over camera views, and pixelsByShader the same split by shader. clusters, clusterTriangles, pixelsResolved and pixelsByShader come back from the GPU and describe the frame numbered frame, which is earlier than the frame draws describes. frame is the renderer's own frame number, counted from 0 when the renderer was built (again after a device loss), a count of its own that compares only with another drawStats().frame.

Returns { draws: number, instances: number, compacted: number, compactedDrawn: number, materialBinds: number, materialBindsElided: number, materialExtraBinds: number, materialExtraBindsElided: number, pipelineBinds: number, pipelineBindsElided: number, clusters: number, clusterTriangles: number, drawsByPass: { visibility: number, resolve: number, legacy: number, blended: number, exception: number, featureGeometry: number, featureDraw: number, shadow: number, splat: number }, pixelsResolved: number, pixelsByShader: { [string]: number }, frame: 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 group binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")
local d = renderer.drawStats(); print(d.clusters, "clusters,", d.clusterTriangles, "triangles from the cluster lists")
local d = renderer.drawStats(); print(d.drawsByPass.visibility, "id-raster draws,", d.drawsByPass.resolve, "resolve draws")
local d = renderer.drawStats(); for shader, px in d.pixelsByShader do print(shader, px) end

globals/renderer/feature/configure

renderer.feature.configure(handleOrRef: any?, settings: { [string]: any }) -> { [string]: any }

Lay settings over a live feature's knobs. Any omitted knob keeps its value; a knob the feature never declared, or a value of the wrong shape, is refused by name. The feature reads the result as ctx.settings from its next frame.

Parameters

  • handleOrRef any (optional) — A RenderFeatureHandle, its guid, or the feature's ref.
  • settings { [string]: any } — The knob values to apply.

Returns { [string]: any } — The feature's full settings now in force.

renderer.feature.configure(h, { intensity = 0.5, quality = "medium" })

globals/renderer/feature/create

renderer.feature.create(ref: any?, opts: (string | { guid: string?, settings: { [string]: any }? })?) -> 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").
  • opts (string | { guid: string?, settings: { [string]: any }? }) (optional)

Returns any — A RenderFeatureHandle.

local h = renderer.feature.create(asset.resolve("acrylic", "renderFeature"))
local h = renderer.feature.create("acrylic")
local h = renderer.feature.create("@builtin::systems.reflections.ssr", { settings = { intensity = 0.8 } })

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/find

renderer.feature.find(ref: any?) -> any

The live handle of the feature running ref — an AssetRef or a renderFeature identity — or nil when none is. The check a caller makes before creating one, so a feature already live is configured rather than stacked.

Parameters

  • ref any (optional) — An AssetRef, a renderFeature identity, or a guid.

Returns any — The RenderFeatureHandle, or nil.

local h = renderer.feature.find("@builtin::systems.reflections.ssr")

globals/renderer/feature/list

renderer.feature.list() -> { { guid: string, identity: string, settings: { [string]: any } } }

List every render feature currently live (running its render(ctx) each frame). Each entry is { guid, identity, settings } — 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), and settings is every knob the feature declares at the value in force.

Returns { { guid: string, identity: string, settings: { [string]: any } } }

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

globals/renderer/feature/schema

renderer.feature.schema(ref: any?) -> { [string]: any }

The settings a .renderFeature declares, with each knob's descriptor, read without running it: what a caller may pass to create / configure.

Parameters

  • ref any (optional) — An AssetRef or a renderFeature identity.

Returns { [string]: any } — { [name]: descriptor }, empty for a feature that declares none.

for name, desc in pairs(renderer.feature.schema("@builtin::systems.reflections.ssr")) do print(name, desc.default) end

globals/renderer/feature/settings

renderer.feature.settings(handleOrRef: any?) -> { [string]: any }?

A live feature's settings as they stand: every declared knob at its default or at what create / configure set. A copy — writing to it changes nothing; configure does.

Parameters

  • handleOrRef any (optional) — A RenderFeatureHandle, its guid, or the feature's ref.

Returns { [string]: any }? — The settings table, or nil when no such feature is live.

local s = renderer.feature.settings(h); print(s.intensity)

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.

fills counts, per layer, the setLayers that have landed in it since the array was last sized: fills[layer + 1] for a 0-based layer. A fill of a layer that already holds content leaves filled as it was, so the count moving is how a feature re-filling its own layer reads its fill arriving.

Returns { width, height, layers, filled, fills }

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
print("refill the cookie layer", myLayer)
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 the pacer has given the bound up rather than hold the image indefinitely, on either mechanism: the counting one stands down when the completion signal goes quiet across a whole run of declined frames, and the waiting one when a wait on a frame reaches its ceiling with the GPU still holding it. Both clear on the first word the GPU sends after that. A signal that is merely late — a GPU finishing frames more slowly than the loop asks for them — keeps the bound, so stalled answers for a GPU that went silent. standDowns counts those over the session, which is how a session that lost the signal and got it back (stalled = false with standDowns above zero) reads apart from one that never lost it.

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. A desktop presents mailbox unless told otherwise, and Android presents fifo, which shows each frame on a vsync of the panel and makes no frame the panel never shows.

The rest describe the rate frames run at. displayRefreshHz is the refresh rate of the display the frames are shown on, where the platform reads it: the phone's panel, the monitor a desktop window is on, the cadence of a browser's animation frames. A headless engine presents to no display and states none. frameRateCap is the cap renderer.setFrameRateCap set, targetFps the rate that cap delivers and refreshesPerFrame the refreshes each frame stays on screen under it, all three absent while no cap is set. deliveredFps is the rate the frame loop has run frames at over about the last twenty frames.

Returns FramePacing? — { framesInFlight, maxFramesInFlight, pacedFrames, submittedFrames, waitMs, mechanism, stalled, standDowns, producing, idleSkippedFrames, presentMode, presentModes, displayRefreshHz?, frameRateCap?, refreshesPerFrame?, targetFps?, deliveredFps? }, 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. textures is what the resident textures occupy, and on the texture-array backend also the slack its shared arrays hold beyond them (renderer.materialTable().bucketSlackBytes).
  • allocator is the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. A render target's colour texture is reported under what holds it now: the target drawing into it, or released_scratch[<width>x<height>].color while it waits for the next target of its size. It exceeds categorised by the per-frame render targets and the scratch nothing categorises, and on the texture-array backend, for the frame after an array grows or is repacked, by the array it replaced. 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. arenas states the part of it the pages runs stand in hold, and what those pages' bytes are spent on. 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. textureBudgetBytes is the texture budget in force and pressure the memory pressure level that shapes it: under "critical" the budget reads half the device's, and renderer.memoryPressure() says what the newest report released.

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/lighting

renderer.lighting() -> boolean

Whether the scene renders lit.

Returns boolean — true while lighting is on.

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/markBaseline

renderer.markBaseline() -> number

Record which render features are live and how the feature-texture array is configured, and make that the point renderer.resetToBaseline returns to.

A caller running work in an engine it shares — a sweep of test suites, a tool driving a session — leaves the renderer holding whatever that work set up: a feature a module created on first use, an array reconfigured to the size one pass wanted. This is how such a caller says where its own changes start. mark above the marks before it.

Returns number — The mark's generation, counting from 1 — what renderer.resetToBaseline takes to restore to this mark.

local mark = renderer.markBaseline() -- then run, then renderer.resetToBaseline(mark)

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, render, features, bindings }) 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 texture each slot of its row names (renderer.textures, the writes the renderer took up), the render state its draws are looked up under, whether a pipeline exists for that key, and the draws, instances and binds the last published frame counted for it. A frame counts those only while something observes the renderer (renderer.materialCost(), renderer.observe() and renderer.drawDiagnostics() each keep it observing for the next 120 drawn frames, and describe does not), so renderer.observed states whether they were counted: true with the counts, false with draws, instances, placeholderDraws, binds and bindsElided all nil. 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. A material that is not resident carries shaderStatus and reason, where its shader stands: "absent" when the shader it names resolves to no shader asset (nothing will compile it, so it stays unprepared until one arrives), "pending" while that shader has not compiled, "failed" with the compiler's message, or "compiled" when the shader is ready and the material has not been prepared yet. A resident material carries neither.

Parameters

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

Returns any — MaterialContent? with resident: boolean, renderer: MaterialObservation?, shaderStatus: string?, reason: string? and, for a key a .material asset filed, asset (the asset it is) 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, the shader it binds, and for a key a .material asset filed (guid@checksum) the asset it is. 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?, asset? }.

for _, m in ipairs(renderer.material.list()) do print(m.asset or 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. index is the material index its row in the material table is filed under, which an edit rewrites in place, and textures the texture each slot of that row names, by slot name, under the key the texture is resident as; a slot drawing its shader's declared default is absent, so a setTexture the renderer has taken up reads here as the key it bound. 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 published. A frame counts those only while something observes the renderer, which renderer.materialCost() and renderer.drawDiagnostics() start, so observed states whether they were counted: true with the five counts, false with all five nil. 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/setClock

renderer.material.setClock(key: string | { [string]: any } | AssetRef, seconds: number?, rate: number?) -> ()

Drive a material's surfaces from a clock of its own: the time its shader's vertex(), vertex_clip() and fragment() read. The clock reads seconds now and runs on by itself, rate of its seconds for every second of the world's gameplay time, so it is stated again only when that changes — a pause is a rate of 0, a seek is a new seconds, slow motion is a rate below 1. Every surface drawing the material follows it, motion vectors included. seconds = nil hands the material back to the world's time. A procedural graph's Generator drives the materials it realizes from its playhead this way, so an effect's shaders pause, slow and scrub with it.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.
  • seconds number (optional) — The time the material's surfaces read now, or nil to release it.
  • rate number (optional) — Seconds of the clock per second of gameplay time (default 1).

Returns ()

renderer.material.setClock(asset.resolve("frost_ice", "material"), 12.5, 0)

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) -> boolean

Push one changed texture slot to a registered material's GPU record. Keyed by the material's registry key. The slot is one the material's shader declares as a texture: a slot it does not declare is refused with a warning naming the texture slots it does declare, and the material is left as it was. While the shader's vocabulary has not been read (a custom shader that has not loaded) every slot is taken.

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 boolean — true when the slot was bound, false when the shader declares no texture slot under that name.

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

globals/renderer/material/vocabulary

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

A material's property names under props and its shader-declared and assigned texture slots under textureSlots, each a { [name] = true } set — with byRole beside them, the index a key spelled in another engine's convention resolves to a declared name through. This is what a write's key is routed against on the record, in the shape MaterialSchema.route takes. It is read off the definition this module holds and touches nothing the renderer holds, so a call costs a table lookup and belongs on a per-frame write path; describe is the read that carries the renderer's own state. One frozen table per record, shared by every key that reaches it, rebuilt on the first read after a write puts a new name on the record.

Parameters

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

Returns MaterialVocabulary? — nil for a key registered elsewhere, and for a record carrying no property, assigned texture, or declared texture slot.

local MaterialSchema = require("@builtin::modules.material_schema")
local route, name = MaterialSchema.route(assert(renderer.material.vocabulary(handle)), "color", { 1, 0, 0, 1 })

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. Those three count what this material drew itself: a batch drawing several materials of one group adds to each material the draws and instances that wore it.

group is the material group the material draws in — the unit a pass binds a material's textures (group 1) and its shader's storage buffers (group 2) by — or nil while the material resolves no group, which leaves its draws nothing to bind. binds is how many group-1 and group-2 binds the frame's passes issued for draws of this material's shader in that group, and bindsElided how many times such a draw found the group already bound, which is what draw-key sorting buys. Those binds belong to the group, so every material of one group reports the same pair; a material with no group reports 0 for both, and a draw that fell back to the placeholder counts in placeholderDraws and in neither bind count.

uniformBytes is the size of the property block in the material's row of the material table: the shader's declared properties, packed at the offsets the shader reads them from and padded to a whole 16 bytes. The row also carries a 64-byte header and a 4-byte handle per texture slot, padded to a whole 16 bytes. renderer.materialTable().rowBytes is the space the table reserves for all rows — its row capacity, free rows included — plus its directories. renderer.drawDiagnostics() names WHICH renderable is not drawing what its material says, and why. material is the key the renderer holds the material under; asset names the .material asset a guid@checksum key is a version of.

Returns { MaterialObservation }

for _, m in renderer.materialCost() do print(m.asset or 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 while the renderer holds the material, so two renderables that differ only in material read different indices, and one renderable reads the same index frame after frame. A destroyed material's name leaves the table once nothing draws with it, and the next material takes its index, so the table holds a row per name the renderer holds a material under or something draws with. 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 zero_instance(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 a name the renderer holds no index for: one it has not drawn with yet, or a destroyed material nothing draws with any more. Pass it to a shader (or compare it against what a shader read out of zero_instance(slot).material_index) to tell which material a drawing instance carries.

Parameters

  • name string — string Material name, as renderer.material.create filed it.

Returns number?

local red = renderer.materialIndex("brick_red")

globals/renderer/materialTable

renderer.materialTable() -> MaterialTable

How this device resolves material textures, and what that storage costs. backend is "bindless" (a descriptor array indexed by each material's texture handles) or "buckets" (exact-size texture arrays, one per size, format and mip count, bound per material group). Bindless is taken on a Vulkan adapter that has texture binding arrays with non-uniform indexing and partially bound arrays, and room for descriptor arrays of at least 4096 textures; every other device — the browser build, Android, Metal, DirectX 12, and Vulkan without those — takes buckets.

materials is how many materials have a row, rowBytes the space the table reserves in the scene buffer (its row capacity, free rows included, plus the material and texture directories), textures how many distinct textures the rows name, descriptorCapacity the length of each bindless array (0 on buckets), and groups how many material groups the materials form. A group is the unit a pass binds a material's textures (group 1) and a shader's own storage buffers (group 2) by, and every material of a group draws under the same binds. On bindless every group binds the one texture group of the frame, so groups differ only by the set of storage buffers they bind. On buckets a group also names, per texture slot, the bucket its members' textures live in and the sampler those textures sample through: materials of one shader split into one group per distinct combination of (bucket and sampler per texture slot, storage buffers), and each group is its own draw.

buckets lists every texture array: its format, size, mip count, the layers textures occupy, its capacity, what it costs, the slack it holds beyond its live layers, whether it is frozen (a layer's view is held by something outside the material system — a UI image, a post-process input — or a copy out of a layer is taken this frame, such as a readback, so the array neither grows nor compacts), which shard of its key it is, and whether it is external (a one-layer bucket over a texture the GPU writes — a render target, a video frame, a compute output — which its producer owns). bucketBytes is what the shared buckets occupy together, and bucketSlackBytes the slack they hold together, which renderer.setTextureBucketSlack bounds; an external bucket counts in neither. slackBudgetBytes is the budget the buckets bound growth by. On bindless buckets is empty, both totals are 0, and slackBudgetBytes is the budget recorded, which bounds nothing.

Returns MaterialTable

local t = renderer.materialTable(); print(t.backend, t.groups, "groups over", t.materials, "materials")
for _, b in renderer.materialTable().buckets do print(b.format, b.width, b.height, b.layers, "/", b.capacity) end

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/maxViewportExtent

renderer.maxViewportExtent() -> number

The largest number of pixels this device draws a surface at, per axis — the bound renderer.setViewportSize refuses past. 0 before the first frame has drawn.

Returns number — pixels per axis.

local bound = renderer.maxViewportExtent()

globals/renderer/memoryPressure

renderer.memoryPressure() -> MemoryPressureReport

What memory pressure is doing to the engine. level is the level in force: "normal", "moderate", "low" or "critical". A report puts its level in force, whatever stood before, for holdSeconds after the last report, and then the level returns to normal. Reports come from the system (on Android, onTrimMemory: RUNNING_MODERATE is moderate, RUNNING_LOW and UI_HIDDEN low, RUNNING_CRITICAL, BACKGROUND, MODERATE and COMPLETE critical; Android 14 and later report only UI_HIDDEN, when the app leaves the screen, and BACKGROUND) and from renderer.signalMemoryPressure. signals counts the reports taken; lastLevel, lastSource and lastAgeSeconds describe the newest (absent before the first).

Each report releases at once what its level releases, and released says what the newest one freed: the textures taken to the level the frame samples and the textureBytes they gave up, and the render targets whose storage was freed. Moderate frees the mip levels the frame does not sample; low also frees render-target storage the last frame did not draw into; critical also halves the texture budget. While a level stands the residency plan keeps the textures there, and textureBudgetBytes is the budget the level leaves in force. engine.onMemoryPressure(cb) fires with each report once its release is done.

Returns MemoryPressureReport — { level, holdSeconds, signals, lastLevel?, lastSource?, lastAgeSeconds?, textureBudgetBytes, released }

local mp = renderer.memoryPressure()
print(mp.level, mp.released.textureBytes / 1048576, "MiB freed")

globals/renderer/mesh/bounds

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

The mesh's extent in its own space: the box its vertices span, or the one its author stated (aabbMin / aabbMax) for a mesh whose vertices live only on the GPU. It is what the mesh is culled against and what framing reads; place it in the world through whatever draws the mesh — an entity's transform, or instanceBounds for an instanced population.

Parameters

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

Returns Aabb? — The box, or nil for a mesh nothing is registered under or whose extent nobody knows.

local box = renderer.mesh.bounds(mesh)

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 or cannot be drawn as clusters, 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. inFlight is one row per queued bake — { guid, cpuMs, frames, slices, bytes, state, levels }: 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, "baking" for the one being advanced against "queued" for the ones waiting their turn, and levels the depth the bake builds, "dag" or "single".

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/clusterStore

renderer.mesh.clusterStore() -> {

What the geometry heap holds for cluster drawing. heapCapacity and heapLive are the heap's size and the bytes in use. The heap grows to place what does not fit, and gives memory back as meshes leave it: once what it holds has packed into a quarter of its size for 60 frames in a row, it shrinks to double that, never below the size it started at. A mesh destroyed and created again inside those frames finds the heap at the size it left it. vertexBytes and lightmapBytes are the vertex pools and lightmap-UV runs of the meshes drawn through clusters. bakedBytes is the cluster sets of meshes drawing a baked set; unbakedBytes is the sets of meshes still drawing lod0 plus those meshes' vertex pools — geometry drawn at full detail while its bake is outstanding. meshes counts meshes drawing any set, unbakedMeshes those on lod0. scriptSetBytes is CPU memory beside the heap: the sets scripts installed with renderer.mesh.uploadClusters, held so each mesh draws its set again after it returns to the device.

Returns { heapCapacity: number, heapLive: number, vertexBytes: number, lightmapBytes: number, bakedBytes: number, unbakedBytes: number, meshes: number, unbakedMeshes: number, scriptSetBytes: number }

local s = renderer.mesh.clusterStore(); print(s.unbakedBytes / 1048576, "MiB drawn at full detail while bakes are outstanding")

globals/renderer/mesh/clusters

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

The cluster set a mesh draws. Every static mesh the geometry heap holds draws through clusters: kind is "lod0" while it draws the full-detail set built from its geometry as it uploaded, "single" for one baked level (clusterLod = false), and "dag" for the baked LOD hierarchy. clusterCount, levelCount and triangleCount describe that set, nodes how many node transforms its clusters lie in, and bytes its size in the heap. A mesh that is not drawn through clusters reads resident = false with reason: "heapFull" (the heap cannot grow to hold it), "spansNodes" (a triangle joins two independently moving parts) or "deformable" (a pass deforms its vertices). Nil for a mesh the renderer holds no GPU geometry for.

Parameters

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

Returns { [string]: any }? — { resident: boolean, reason: string?, kind: string?, clusterCount: number, levelCount: number, triangleCount: number, nodes: number, bytes: number }?

local c = renderer.mesh.clusters(gpu) ; if c and c.kind == "lod0" then print("still baking") 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.

A mesh this call builds lives while the returned handle, or another handle over the same guid, is reachable from a script, or while something else holds it (an entity or instanced draw displaying it, its owner, a renderer.hold). Once nothing holds it and the garbage collector has taken its last handle, the engine releases it mid-session: its definition, its buffers and its renderer.mesh.list row go, and its guid names nothing. A script that displays a mesh again later keeps its handle (or holds it) for as long as it means to.

A handle a script keeps names its mesh for the rest of the session: a module table or _SCRATCH holding it carries it across play/edit flips, scene loads and hot reloads of the components that use it, and a Model handed it after any of them draws the mesh. While nothing displays the mesh, the engine holds it off the device (renderer.mesh.heldOffDevice) and the next display puts it back. Only a destroy ends a mesh a handle still reaches (renderer.destroy, renderer.mesh.destroy): every handle a create returned over it then carries released, the verb that released it, and a point of use (ecs.Mesh, a Model's model field) refuses such a handle by name until a mesh stands under its guid again, which the handle then names and holds.

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, which is what buys that full-pass parity. Rewrite the buffer between frames and the instances move, with no re-registration and no re-upload. The copy is made on the frames that follow a write of the buffer (a :write, a compute pass whose shader binds it read_write, and every frame a material whose shader binds it read_write is live), so a population whose buffer nothing writes costs no copy per frame however many instances it holds; renderer.uploadStats() counts the copies a frame made.

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 like the transforms, on the frames that follow a write of the buffer, 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?, receivesShadows?, mobility?, parent? }. castsShadows and receivesShadows, both true when left out, state whether the copies cast shadows and whether shadows darken them. parent stands the copies in an entity's space, as setInstanceParent does.

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

Return a mesh to its lod0 set and cancel a bake still in flight for it. The inverse of renderer.mesh.uploadClusters.

Parameters

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

Returns boolean — True if the mesh drew a baked set 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 MeshGeometry — MeshGeometry — 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/heldOffDevice

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

True while the engine holds this mesh off the device: nothing displays it, so its GPU buffers were given back and the engine keeps what rebuilds them. The next Model or instanced draw that names the mesh puts it back on the device, where renderer.mesh.isResident reads true again from the frame that bind lands in. A mesh is one of three things: resident, held off the device, or gone; a mesh neither this nor isResident answers true for names nothing the engine can draw.

Parameters

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

Returns boolean

local named = renderer.mesh.isResident(h) or renderer.mesh.heldOffDevice(h)

globals/renderer/mesh/instanceBounds

renderer.mesh.instanceBounds(draw: InstancedDraw, opts: { keep: number? }?) -> (Aabb?, string?)

The world box a registration's drawn copies occupy: every copy of the mesh's extent (renderer.mesh.bounds) where the engine composed it for the last frame drawn, carried through the entity the copies stand in when setInstanceParent named one. It reads the copies back from the GPU, so it waits a few drawn frames. A copy scaled to nothing draws nothing and is left out. A mesh whose extent nobody knows answers with the box around the copies' origins, and says so.

Parameters

  • draw InstancedDraw — The InstancedDraw to measure.
  • opts { keep: number? } (optional) — { keep? } — the share of the copies the box holds along each axis (default 1, all of them). Below 1, the copies reaching furthest out on each side are left out: keep = 0.9 is the box around where the body of a spray stands, without the few copies thrown far from it.

Returns (Aabb?, string?) — The box, or nil and the reason there is none; a box around origins alone comes with the note that says so.

local box, note = renderer.mesh.instanceBounds(draw)
local body = renderer.mesh.instanceBounds(draw, { keep = 0.9 })

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/instanceWorlds

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

Read back where a registration's drawn copies stand in the world, as the engine composed them for the last frame drawn: each copy's matrix from the transform buffer, carried through the world matrix of the entity the copies stand in when they have one (setInstanceParent). instanceCount column-major 4x4 matrices, 16 floats each. Async — poll the handle it returns the same way as instanceTransforms.

Parameters

  • draw InstancedDraw — The InstancedDraw to read.

Returns any — A pending readback: :ready(), then :result() for the floats.

local pending = renderer.mesh.instanceWorlds(draw)
while not pending:ready() do task.wait() end
local x = pending:result()[13]

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 queued this frame 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 — from the frame after the upload commits: a render asked for in the same tick the upload was queued does not carry the mesh yet. renderer.mesh.onDevice is the answer that waits for that commit.

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/onDevice

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

True once the device holds this mesh's buffers — the upload queued for it has committed — so the next render asked for carries the mesh. renderer.mesh.isResident answers true one frame earlier, while the upload is still queued; a reader about to render and read the picture back waits on this one.

Parameters

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

Returns boolean

while not renderer.mesh.onDevice(guid) do task.wait() end

globals/renderer/mesh/readback

renderer.mesh.readback(mesh: string | { [string]: any } | AssetRef, into: string?) -> 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.

The CPU store is keyed by guid and shared by everything that reads the mesh, and whatever owns the mesh can retire the copy under its own guid: renderer.mesh.update from compute buffers unloads it, which an ocean surface does every frame it steps. into lands the copy under a key of the caller's own instead: the handle answers for that key, and it stays resident until the caller unloads it, whatever happens to the mesh.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — the MeshHandle renderer.mesh.create returned, a guid, or a mesh AssetRef.
  • into string (optional) — The CPU-store key the geometry lands under. Defaults to the mesh's own guid.

Returns MeshCpuHandle

local cpu = renderer.mesh.readback(handle); local zmsh = cpu:encode(); cpu:unload()
local snap = renderer.mesh.readback(handle, "bake:" .. handle.guid); local tris = snap:getTriangles(); snap: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.

The vertices read are ones the skinning pass wrote for the pose the entity is bound to. An entity bound to a pose that pass has not written yet — the frame it was posed, before the pass reached the queue — is asked again on the next frame rather than read from a slice still holding what stood there before.

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, opts: { levels: string? }?) -> boolean

Queue a cluster bake over the mesh's geometry, off the frame loop. The mesh keeps drawing its current cluster set while the bake runs, and the set the bake produces replaces it when it lands. opts.levels is "dag" for the LOD hierarchy or "single" for one level of clusters at full detail; when omitted, a mesh AssetRef bakes the depth its clusterLod setting asks for and any other argument bakes "dag". The CPU mesh may be unloaded on the very next line. Geometry with a triangle joining two independently moving parts leaves the queue unbaked with a warning; a mesh renderer.mesh.clusters reports as "deformable" or "heapFull" leaves it unbaked without one.

One bake is advanced per frame — the one at the head of the queue — and the geometry is read once a bake gets there and the mesh's GPU upload has landed, from the definition the engine holds for the mesh. The queue therefore holds one mesh's geometry rather than one per mesh, whatever its depth, and the set installs only on the geometry it was read from: a mesh whose geometry is replaced before its set lands keeps the set of its new geometry. 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 — The mesh to bake — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef, loaded into the CPU store (renderer.mesh.loadCpu).
  • opts { levels: string? } (optional) — { levels: "dag" | "single" }?

Returns boolean — True if a bake was queued; false for a mesh with no GPU mesh registered or being created under its guid.

renderer.mesh.scheduleClusters(cpu, { levels = "single" }) ; 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/setInstanceParent

renderer.mesh.setInstanceParent(draw: InstancedDraw, parent: any?) -> InstancedDraw

Stand a registration's copies in an entity's space: every copy's matrix is taken relative to the entity, so the population moves, turns and scales with it from the next frame on, with its transform buffer untouched. nil puts the copies back in world space. It is the verb for a population that belongs to something — a generator's output, an asset stood on an entity.

Parameters

  • draw InstancedDraw — The InstancedDraw to reconfigure.
  • parent any (optional) — The entity the copies stand in (a proxy or its id), or nil for world space.

Returns InstancedDraw — The same InstancedDraw.

renderer.mesh.setInstanceParent(draw, entity.find("Forest"))

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

Replace the cluster set a mesh draws with a serialized set (bytes from renderer.mesh.buildClusters), built from the geometry the mesh holds as the call is made. A set whose vertex count, vertex maps or nodes do not describe the mesh, or one whose mesh's geometry is replaced before it lands, is refused with a warning, and the mesh keeps its current set and the set it was asked for. Once the set is installed, a bake still in flight for the mesh is cancelled, and the engine keeps the bytes on the CPU (renderer.mesh.clusterStore().scriptSetBytes) so the mesh draws this set again when it returns to the device after nothing displayed it, or after the device is rebuilt. The bytes go when the mesh's geometry is redefined, when renderer.mesh.dropClusters returns it to lod0, and when the mesh is destroyed.

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; false while the mesh's GPU upload has not landed yet.

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, and deltaBufferBytes the size of that buffer, which the vertex stage binds whole.

deltaBudgetBytes is the most that buffer may hold on this device: the largest storage buffer the device binds at once (128 MiB on many mobile browsers). A block costs 32 bytes per vertex per target, over every vertex of the mesh. A mesh whose block would take the resident blocks past the budget is refused by name in the engine log and draws in its base shape, and refusedMeshes counts the meshes refused that way; the refusal holds until the mesh is uploaded again. 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, deltaBufferBytes: number, deltaBudgetBytes: number, refusedMeshes: 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/pendingWork

renderer.pendingWork() -> PendingWork?

What the renderer still owes the picture — the work it has taken on and has not yet put in the frame.

A frame draws what the renderer holds at the moment it runs, and several of the things it holds arrive over more than one frame. A pipeline is built off the frame thread and bound the frame its result is taken in; a texture is uploaded under a per-frame time budget; a released texture's GPU object is freed on the frame the release queue is drained; a streamable texture climbs toward the level its screen size asks for one level at a time. pipelineBuilds, textureUploads, textureReleases and textureResidency are those four. textureTransfers counts the textures the texture cache is still moving over several frames: a large upload written a band at a time, a promotion waiting on its finer levels to be read or decoded, a demotion waiting on its levels to be read back. Each lands as a new GPU texture on a frame of its own, and the frames between two landings replace nothing, so textureResidency alone reads zero on them. outstanding is the sum of every count.

settled is true when outstanding is zero, and says the renderer owes this picture nothing: the frames after this one draw the same picture unless something changes the scene. That is the renderer's own word for what a test measuring a picture holds for before it reads one.

epoch counts the times the renderer has gone from owing nothing to owing something since the process started. It is what a pair of readings needs: a run of work that begins AND ends between two samples leaves both of them reading settled and leaves epoch higher, so settled at both ends with the same epoch at both ends is the statement that nothing arrived across the whole span, rather than at its two endpoints alone. Test.settleReading is built on exactly that pair of conditions.

frame names the drawn frame the reading was published on. Every count is published from the frame thread on every frame the renderer runs, so a headless engine declining frames answers as readily as a windowed one. deviceRebuild is one while the engine is between render devices: the picture is owed the device itself, every frame in that window draws nothing, and what content puts back on the new device lands on the frames after it. computeCommands counts the compute commands a drain held over: a dispatch waiting on the program it names and every command over the buffers it would write queued behind it, each of which runs or drops on a later drain. clusterSets counts the meshes whose cluster set is still being built, or is owed again after a device rebuild: each lands as the set the mesh draws, and the geometry a cut of it draws changes on the frame it does. materialRows counts the materials the frame does not yet draw as they ask: one whose raster or resolve pipeline is still building draws nothing until it lands, one whose shader's resolve gate has not reached a verdict resolves through the error shader until it does, and one whose routing changed since the frame last published it is still drawn by the previous routing. Each changes the picture on the frame it lands. skippedDraws counts the renderables the last drawn frame left undrawn because a pipeline or a material group they draw with was not built yet; each appears on the frame what it waits on lands. shaderCompiles counts the shader compiles running off the frame thread that no frame has landed: each lands as the program its keys name, and the materials drawing with those keys change on the frame it does.

Returns PendingWork? — { settled, outstanding, pipelineBuilds, textureUploads, textureReleases, textureTransfers, textureResidency, deviceRebuild, computeCommands, clusterSets, materialRows, skippedDraws, shaderCompiles, epoch, frame }, or nil on a build with no renderer attached

if renderer.pendingWork().settled then print("the picture is what it is going to be") end
local w = renderer.pendingWork(); print(w.outstanding, "arrivals still owed, over", w.epoch, "runs of work")

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.

A pipeline a material, the sky, a depth view, a post-process pass or a compute program a script registers needs after boot is built off the frame thread — the shader module behind that compute program with it — on buildWorkers worker threads on native, behind the browser's asynchronous entry point on the web, where buildWorkers reads 0. So a burst of new materials costs a frame nothing beyond drawing the placeholder until each pipeline lands, and a burst of effects costs it nothing beyond each effect starting on the frame its pipeline does. offFrameBuilt and offFrameBuildMs are the pipelines and the milliseconds that never held a frame; pipelinesBuilt - offFrameBuilt is what the frame thread built, which is the renderer's own — the set it stands up before its first frame, and the few it stands up on the frame a pass first asks for them. buildsSubmitted, buildsFinished, buildsFailed and buildsInstalled count build requests (shader modules and pipelines) made, completed, refused and taken back by a frame. buildsAbandoned counts the requests a renderer still held when a device rebuild let it go, which no frame will take back, and buildsPending is what the renderer drawing now has yet to take back: asked for and not yet in use, a finished result included until a frame installs it. So every request made is installed, abandoned or pending, and a reader waiting on buildsPending to reach zero reads pipelinesBuilt with every build it waited for counted, and can tell a queue draining from one that is stuck. modulesBuilt and moduleBuildMs are the shader modules compiled off the frame thread, parse and validation included.

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, offFrameBuilt, offFrameBuildMs, buildsSubmitted, buildsFinished, buildsFailed, buildsInstalled, buildsAbandoned, buildsPending, modulesBuilt, moduleBuildMs, buildWorkers, 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/presentedFrames

renderer.presentedFrames(opts: { scope: string?, frames: number?, interval: number?, velocity: boolean? }?) -> { PresentedFrame }

Parameters

  • opts { scope: string?, frames: number?, interval: number?, velocity: boolean? } (optional)

Returns { PresentedFrame }

globals/renderer/projectionJitter

renderer.projectionJitter() -> { { number } }

The offsets renderer.setProjectionJitter stated, each {x, y} in pixels from the pixel centre.

Returns { { number } } — The offsets; empty while every render samples pixel centres.

local offsets = renderer.projectionJitter()

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/qualityTier

renderer.qualityTier() -> QualityTier

The device's quality tier and the renderer defaults it chose. The engine assigns every device one of "low", "medium" and "high" at boot, from its GPU family and limits, its memory and its display, so a world authored on a desktop plays on a phone at the phone's defaults without knowing it runs on one. A desktop, and any device whose GPU is outside the handheld families, is "high": the renderer as a desktop runs it, one scene pixel per display pixel. A phone renders the scene below a dense display's resolution with UI and text at full resolution, and spends less on shadows, textures and filtering the lower its tier. A lower tier chosen on a desktop renders the scene within that tier's pixel budget the same way, so renderer.setQualityTier alone is a whole graphics setting on every platform.

Every default the tier states stands wherever the world's settings name no value of their own (render.anisotropy), and the matching renderer call overrides any one of them: renderer.setResolutionScale, renderer.setTextureBudget, renderer.setAnisotropy. renderer.setQualityTier chooses another tier altogether.

Returns QualityTier: { tier, deviceTier, overridden, reason, tiers, device, defaults }

local q = renderer.qualityTier()
print(q.tier, q.reason, q.defaults.resolutionScale)

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 vec3 — vec3 ray start in world space
  • direction vec3 — vec3 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 entity(cameraId).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 vec3 — vec3 ray start in world space
  • direction vec3 — vec3 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. skyStale is whether the sky changed after that capture, so the slot still depicts the sky from before the change until the engine's recapture, or an explicit environment.captureSky(), retakes it. The engine retakes a sky swapped for another material or turned off on the first frame the new sky can be drawn, and a sky whose look moved once a pacing window has passed since the last capture. skyPosition is where the capture was taken from, absent before one. skyCaptures counts the sky captures that have landed since the renderer was built, so one retaken from the same position reads as new, and skyMaterial names the material the sky pass draws from, absent when no sky is drawn. 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, the slot a camera's stated environment is held in, and 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, skyStale, skyPosition?, skyCaptures, skyMaterial?, 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(). A runtime mesh no script reaches a handle to is judged again once its hold goes, and released with no collection when nothing else holds it.

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/releaseBaseline

renderer.releaseBaseline(generation: number) -> boolean

Drop the mark generation names, and every mark taken after it, once the caller that took it is done resetting to it. It touches the marks alone.

Parameters

  • generation number — The mark to drop, as renderer.markBaseline returned it.

Returns boolean — True when the mark stood.

renderer.releaseBaseline(mark) -- the run is over

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 scene's occlusion and reflection channels and its lit colour history (scene_occlusion, scene_reflection, scene_color_history) are resident at the scene's extent while a render-feature pass writes or reads them, and shadow_page_pool while a light casts into the virtual shadow map, with shadow_transmittance_pool beside it while a translucent caster casts into it. 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, reflection and colour-history channels, the scene copies and the G-buffer over them under a camera's scene render. A draw builds what it needs, or takes the scratch a released target of the same size left. A target releases its set to the next target of its size when no live camera names it and when it is destroyed or resized, and frees it once sixty frames pass without a draw, so a target nothing draws into carries no such row. Released scratch waits as released_scratch[<width>x<height>].* rows for the next target of that size, held newest first within 64 MiB and freed once sixty frames pass without a target taking it. The colour image drawn into belongs to the texture cache while its target stands; a destroyed or resized target's colour texture waits on the same terms as a released_scratch[<width>x<height>].color row for the next target made at that size and format, which starts from it cleared to transparent black. totalBytes is what the resident targets hold together. allocations counts the render-target allocations the renderer has made since it was built, the colour textures of the live targets included, each on the first frame whose report holds it: a target taken back at a size it held before adds nothing, so the difference between two readings is what the frames between them allocated. Measured at the end of the last rendered frame.

Returns { targets, totalBytes, residentCount, allocations }

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/resetToBaseline

renderer.resetToBaseline(generation: number?) -> number

Parameters

  • generation number (optional)

Returns number

globals/renderer/resolutionScale

renderer.resolutionScale() -> number

The fraction of the display resolution the scene is currently rendered at. Until something sets it, the scale the device's quality tier chose for its display (renderer.qualityTier().defaults.resolutionScale): 1 on a desktop at the high tier, below 1 on a dense handheld display and on a desktop at a lower tier.

Returns number

local s = renderer.resolutionScale()

globals/renderer/resolutionScaleMode

renderer.resolutionScaleMode() -> string

Where the render scale resolves: "present" or "reconstruct". "present" until something sets it.

Returns string

local m = renderer.resolutionScaleMode()

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 boolean — boolean

Returns ()

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

globals/renderer/setFrameRateCap

renderer.setFrameRateCap(fps: number?) -> number?

Hold the engine to at most fps frames per second, or lift the cap with 0 or nil. The whole frame waits for its turn: scripts, physics, audio mixing and networking run once a frame as the drawing does, so a game capped at 30 runs at 30, and a phone spends no battery on frames past it.

Frames come due on an even grid. Where the display's refresh rate is known (a phone's panel, a desktop window's monitor, a browser's animation frames), the grid is a whole number of refreshes, the fewest whose rate stays inside the cap, so every frame stays on screen for the same number of vsyncs: on a 120 Hz display a cap of 30 shows each frame for 4 refreshes and 60 for 2, and a cap the refresh rate does not divide evenly delivers the next rate below it (50 on a 120 Hz display delivers 40). On Android the system compositor is told the rate as well, so a panel with more than one refresh rate can run at one the cap divides; with no cap it is told the panel's highest rate.

Read renderer.framePacing() for the effect: targetFps is the rate the cap delivers, refreshesPerFrame the refreshes each frame holds, displayRefreshHz the display it was worked out against, and deliveredFps the rate frames actually ran at. Takes effect on the next frame. frame_rate_cap under [render] in the world's .world_settings seeds it (0 for no cap), so a world states its cap once and every session opens under it; a call here stays in force until that value changes.

Parameters

  • fps number (optional) — number? Frames per second, 1 through 1000; 0 or nil lifts the cap.

Returns number? — The cap in force, or nil when frames are not capped.

renderer.setFrameRateCap(30) -- a steady 30 on any display 30 divides
print(renderer.framePacing().targetFps, "fps delivered under the cap")
renderer.setFrameRateCap(nil) -- every frame the display shows

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/setLighting

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

Turn the scene's lighting on or off. Off, every lit surface renders as its own colour with no light on it, emissive included: the editor's lighting toggle, for reading a scene's materials without its lights. With no light applied there is no shadow either, so the shadow maps draw nothing while it is off, and the shadow switches apply again once lighting is back on. The sky and post-processing stay as they are.

Parameters

  • enabled boolean — true renders the scene lit, false unlit.

Returns ()

renderer.setLighting(false); print(renderer.lighting())

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/setMaxTextureSize

renderer.setMaxTextureSize(px: number?) -> { px: number, devicePx: number }

The longest side a texture loaded from an asset may be resident at on this client, in texels. Such a texture larger than the cap is served from the first level of its mip chain that fits, so a 4096² texture under a 1024 cap holds its 1024² level and the levels below it, and the texture budget never takes it finer than that level. A changed cap moves the resident textures over the next frames; a raised one reads their finer levels back from their assets. A texture made from pixels with no asset behind it keeps every level it was made with, because the device holds its only copy. The device's own maximum 2D texture dimension bounds the cap from above. Call with no argument to lift the cap to the device's limit.

Parameters

  • px number (optional) — number? — texels, a whole number of at least 1; nil for the device's own limit

Returns { px: number, devicePx: number } — { px, devicePx } — the cap in force and the device's limit

renderer.setMaxTextureSize(2048)
renderer.setMaxTextureSize()  -- the device's own limit

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 number — number — 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 depth the visibility raster lays 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.

Parameters

  • enabled boolean — boolean

Returns ()

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

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/setProjectionJitter

renderer.setProjectionJitter(offsets: { { number } })

State where inside the pixel consecutive renders of a view sample, for the frames until it is stated again: a list of {x, y} offsets in pixels from the pixel centre, each within half a pixel. Every view the renderer draws (the viewport, each render-to-texture camera, each capture) walks the list by its own count of renders in a row, so a camera drawn beside the viewport takes nothing out of the viewport's sequence: a view's first render, and its first after a cut, samples the pixel centre, and its next renders take the offsets in order, wrapping. Each offset is applied in the view's own pixels. Velocity (@scene.motion) is measured against the offset-free projection, and a history target's state buffer carries the offset each render took. renderer.setProjectionOffset, while it is not (0, 0), and an upscale backend's own jitter both stand over the list on the viewport. An empty list samples pixel centres. An offset outside half a pixel is refused, naming it, and leaves the list in force.

Parameters

  • offsets { { number } } — The offsets, each {x, y} in pixels from the pixel centre.
renderer.setProjectionJitter({ { 0.25, -0.25 }, { -0.25, 0.25 } })

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/setQualityTier

renderer.setQualityTier(tier: string?) -> QualityTier

Render at another quality tier than the one the device was assigned: "low", "medium" or "high", or nil / "default" for the device's own. A change of tier stands the new tier's defaults at once wherever the world's settings state none, replacing what an earlier renderer call set for the render scale, the shadow budgets and the anisotropy; choosing the tier already in force changes no setting. A call made after it, in the same script or later, overrides that one default again. A budget renderer.setTextureBudget names stands over the tier's texture cap until it is returned to the device's own. The choice holds for the process, across scene loads and restarts of the scene, until it is set again. A settings screen offering the player a quality choice is this call.

Parameters

  • tier string (optional) — string?: "low", "medium", "high", or nil / "default" for the device's own.

Returns QualityTier: the reading renderer.qualityTier() answers, for the tier now in force.

renderer.setQualityTier("low")
renderer.setQualityTier()  -- back to the device's own

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 boolean — boolean

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. 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. What the smaller raster MEANS for everything composed on top of it — the post chain, the UI, every post-scene pass — is renderer.setResolutionScaleMode. The scene rows in profiler.gpuFrame() are what move.

Parameters

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

Returns number — the scale in force after clamping.

renderer.setResolutionScale(0.7)

globals/renderer/setResolutionScaleMode

renderer.setResolutionScaleMode(mode: string) -> string

Where in the frame the render scale is resolved — what a smaller raster MEANS. "present" (the default) composes the whole frame at the raster: the scene, every post-process layer, the UI and every post-scene pass, carried up to the size it is shown once it is finished. A smaller raster is then a smaller picture, so an effect whose output is a function of pixel size — a dither ladder, a posterize, a pixel grid — resolves at the pitch it was authored at, and the frame carries one composite resolution throughout. "reconstruct" carries the scene up to the size it is shown BEFORE the post-scene phases and composes everything from there, which is what a temporal upsampler needs: it refines the upscale it is handed and has nowhere to put the detail it recovers unless the image it accumulates into is the display's. Either reading is honoured identically whether a viewport panel or the swapchain shows the frame, so what an editor draws and what a shipped runtime draws are the same picture of the same scene.

Parameters

  • mode string — string — "present" or "reconstruct".

Returns string — the mode in force.

renderer.setResolutionScaleMode("present")

globals/renderer/setShadowCaching

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

Whether a shadow page that nothing changed is kept rather than drawn again. On by default: every light's pages — the sun's, and each spot, area and point light's — are drawn again only where a caster that changed reaches them. Off, every requested page is invalidated and drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.

Parameters

  • enabled boolean — boolean

Returns ()

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

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 light's pages, 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: {
    distance: number?,
    fadeFraction: number?,
    softness: number?,
    punctualMipFloor: number?,
    detailBias: (number | string)?,
    budgetMegabytes: (number | string)?,
}) -> ShadowConfig

Set the sun's shadow settings. Any omitted field keeps its current value, so a call can adjust one knob without restating the rest. Values are clamped: distance >= 0, fadeFraction [0, 1], softness [0, 1]. A distance of 0 shadows out to the camera's far plane, and a positive one caps it, which is what a scene bounding its shadow cost states; the sun's clipmap holds the levels that reach it. punctualMipFloor (a whole number, 0 to 5) caps how fine a spot, area or point light's map renders; it is refused outside that range, and every punctual light's map is drawn again under a new floor. detailBias ([-8, 8] levels) trades shadow detail for pages: each level coarser asks a quarter as many. budgetMegabytes (>= 1) bounds the depth the pages hold. Each takes "default" to return to the device's bias or the quality tier's budget, and a change to either sizes the pool again, which draws every page again.

Parameters

  • cfg { distance: number?, fadeFraction: number?, softness: number?, punctualMipFloor: number?, detailBias: (number | string)?, budgetMegabytes: (number | string)?, } — The fields to change — see ShadowConfig.

Returns ShadowConfig — The full config now in force.

renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ distance = 240 })
renderer.setShadowConfig({ detailBias = 1, budgetMegabytes = 8 })
renderer.setShadowConfig({ detailBias = "default", budgetMegabytes = "default" })

globals/renderer/setShadowHero

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

Give one caster a finer sun shadow, in a box around its world bounds.

The sun's virtual shadow map resolves every receiver at the level its screen footprint selects, so a character standing in the view gets the texel density every surface at its distance gets. A registered hero widens its bounds by padding into a box, and every receiver inside it requests and samples a level renderer.shadowHero().levelBias levels finer — each level halving the world a texel covers — where that level's window holds it, crossing back to its footprint's level at the box's edge. Nothing else about the shadow changes: the same casters reach it through the same filter.

Parameters

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

Returns ()

local before = time.drawnFrameCount()
renderer.setShadowHero(player.id)
local h = renderer.shadowHero()
while h.frame <= before do
task.waitDrawnFrames(1)
h = renderer.shadowHero()
end
print(("hero shadow: %d levels finer over %.1f units"):format(h.levelBias, h.worldExtent))

globals/renderer/setShadowProxy

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

Rasterize proxy in place of mesh in every shadow page. 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 pages 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. 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 boolean — boolean

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 boolean — boolean

Returns ()

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

globals/renderer/setTextureBucketSlack

renderer.setTextureBucketSlack(bytes: number?) -> TextureBucketSlack

Bound the GPU memory texture-array buckets may hold beyond the textures in them. A bucket without room doubles while the total slack stays under this budget, and past it grows to the largest capacity whose slack the budget still holds, at least by what the arrival needs. A growth copies every layer the bucket held, so a large one that would gain few layers for what it copies opens another bucket of the same size instead. The default follows the device (a sixty-fourth of the system's RAM where the GPU draws from it, otherwise a sixteenth of the memory scale it reports; between 32 MiB and 512 MiB either way); nil restores it. bytes is a positive number of bytes, 1 or more — 0, a negative number or a non-finite one raises an error naming that range. A budget bounds the next growth; slack a bucket already holds stays until a compaction takes it. On the bindless backend no bucket exists, so the budget is recorded and reads back but bounds nothing. The textures themselves are bounded by renderer.setTextureBudget; read what the buckets hold with renderer.materialTable().

Parameters

  • bytes number (optional) — number? — the slack budget in bytes, or nil for the device's

Returns TextureBucketSlack — { slackBudgetBytes, deviceSlackBudgetBytes } — the budget now in force, and the device's own

renderer.setTextureBucketSlack(16 * 1024 * 1024)
renderer.setTextureBucketSlack(nil) -- the device's budget

globals/renderer/setTextureBudget

renderer.setTextureBudget(opts: TextureBudgetOpts?) -> TextureBudget

Bound the VRAM textures occupy, by keeping only the mip levels the frame is actually sampling once the textures outgrow the budget. Every device starts at its own budget and a bias of 0. Where the GPU draws from the system's RAM (every Android phone, an integrated GPU, a software rasterizer) and the system states how much, the budget is an eighth of that RAM; otherwise it is half the memory scale the adapter reports; between 256 MiB and 2 GiB either way, and held to 768 MiB on a medium-tier and 384 MiB on a low-tier device (renderer.qualityTier()). renderer.textureStreaming() names the rule (budgetRule) and the RAM it read (deviceMemoryBytes), and memory pressure narrows what the budget keeps (renderer.memoryPressure()). Pass { megabytes = 256 } to override the budget for the process; megabytes is a positive number, 1 to 65536, and 0 or a negative number is an error. A field set to the string "default" returns that one value to the device's own, a field the table leaves out keeps the value it has, and a field holding anything else — a boolean, another string — is an error naming what it holds. nil restores the device's budget and bias together. The budget belongs to the device the world is played on.

Below the budget every texture keeps its whole mip chain, whatever its surfaces cover. Mips stream down only while the whole chains of the streamable textures exceed the budget. Then 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 look that wants a different trade than one texel per pixel. It is clamped to [-8, 8].

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, 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 on the device alone, so it holds no copy while it is at its whole chain; the first time the budget takes it coarser its chain is read back off the device before any level is freed, and it holds that copy (renderer.textureMemory().streamSourceBytes) while it stays coarser. Once a surface has worn it and gone it leaves the set whole and stays out for the rest of the process. 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.

Parameters

  • opts TextureBudgetOpts (optional) — { megabytes: (number | "default")?, bias: (number | "default")? }? — nil for the device's budget and bias

Returns TextureBudget — { megabytes, bias, deviceMegabytes, deviceBias } — the budget and bias now in force, and the device's own

renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 })  -- one level finer everywhere
renderer.setTextureBudget({ megabytes = "default" })  -- the device's budget, the bias as it stands
renderer.setTextureBudget()  -- back to the device's budget and bias

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 transmission draws into a light-space transmittance map instead, and the colour it lets through multiplies into the directional light reaching whatever stands behind it. Stacked casters compose. A surface whose material blends writes no depth shadow at all and throws its shadow through this map alone: a transparent or premultiplied blend lets 1 - alpha of the light through untouched and its transmission of the rest tinted by its base colour, so red stained glass throws red light; a multiply blend lets its base colour through; an additive one blocks nothing. Opaque casters are unaffected, and a scene with no translucent caster allocates nothing and records no pass.

Parameters

  • enabled boolean — boolean

Returns ()

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

globals/renderer/setUpscaleBackend

renderer.setUpscaleBackend(backend: string, quality: string?) -> string

Request a backend for the upscale seam — the step that brings a scene rendered below display resolution back up to it. "blit" stretches; "dlss" reconstructs from motion and history through NGX DLSS Super Resolution on an adapter that has it. The renderer resolves the request against its own capability and reports the outcome through renderer.upscaleBackend() — a request for "dlss" on an adapter renderer.upscaleCapabilities() omits it from resolves to "blit" there, answered truthfully rather than echoed back here. quality names the reconstruction quality mode a reconstructing backend derives its render scale from; the blit keeps whatever renderer.setResolutionScale() set and reads no value from quality. @builtin::systems.temporalUpscale.temporalUpscale is the content-facing surface built on this primitive — it carries the policy this raw setter carries none of: refusing "dlss" where unavailable, refusing quality alongside an explicit render scale, and keeping its own reconstruction from stacking with a reconstructing backend.

Parameters

  • backend string — string — "blit" or "dlss".
  • quality string (optional) — string? — "quality" | "balanced" | "performance" | "ultraPerformance", read only by a reconstructing backend.

Returns string — the backend now requested, echoed back before the renderer resolves it against this adapter's capability.

renderer.setUpscaleBackend("dlss", "balanced")

globals/renderer/setViewportSize

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

Draw at this many pixels for the rest of the task that asks. The engine's drawing surface is resized to it, and everything measured against that surface follows within a frame: getViewportSize(), ui.screenSize(), the layout every UI screen rebuilds from it, each camera's aspect, and what a capture taken in that task encodes. This is how one session checks a responsive layout at a second shape: a HUD written against ui.screenSize() is re-laid-out at the new size, so an anchored element is drawn where that shape puts it rather than scaled from where the boot size put it.

The size is held for the task that asked for it (an execute call is one task), and when that task ends, however it ends, the surface goes back to the size it had before. So a check reads what it needs at the new shape inside the call that set it, and the window or browser canvas a person is looking at is handed back as it was. While several tasks hold a size the surface draws at the one asked for last, and when that task ends it draws at the size the task before it holds. Asked for outside any task, from a component's lifecycle, the size stands until another is asked for.

Who honours the size depends on who owns the surface. A headless engine and a browser canvas own theirs and are resized exactly; a browser page that lays its canvas out at a new size meanwhile ends every hold, and the page's size stands. Where an OS window owns it, the window manager is asked and has the last word: a tiled or maximized window keeps the size it has. Read renderer.surfaceSize() on a later frame for what was realized.

The new size is in force from the NEXT frame, so wait a frame before reading it back: renderer.surfaceSize() read straight after the call still reports the size the surface had.

The logical UI space is normalised to about 1280 points wide, so a resize to a size of the same aspect moves ui.pixelRatio() and leaves ui.screenSize() where it was, while a resize that changes the aspect changes that shape too. Both resize the surface.

A size larger than the device draws is refused naming the bound, which renderer.maxViewportExtent() reports.

Parameters

  • width number — number — width in pixels, at least 1.
  • height number — number — height in pixels, at least 1.

Returns { width: number, height: number } — the size asked for, in whole pixels.

renderer.setViewportSize(390, 844); task.waitFrames(2); print(ui.screenSize()) -- the phone shape, then the surface goes back when the call ends

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. The store also keeps each resolve gate pass a surface shader's resolve module earned, so a launch does not validate the same resolve text again: verdictHits counts the verdicts answered out of it and verdictMisses the ones whose gate ran, apart from the compile counts above.

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, verdictHits, verdictMisses, 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. shader is the key the program is registered under; asset names the .shader asset a guid@checksum key is a version of.

Returns { ShaderCost }

local top = renderer.shaderCost()[1]; print(top.asset or 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 main camera's newest counted run did with the shadow pages it asked for, in pages, by the kind of light that owns them. A page is drawn again only when something it holds changed: its light moved, a caster over it moved or appeared or vanished, a caster's geometry or material changed, or the caster changed pose or moved the nodes its parts are placed by. Anything else keeps the depth already in the pool, so a scene that stops moving reads rendered 0 while its pages stay cached. A caster moving on one side of a point light dirties only the pages of the faces it reaches. A mesh whose vertices a compute pass writes — a population, or a mesh built from a compute buffer — re-renders the pages it stands over every frame. spot counts spot and area lights.

These are totals over every light of a kind. renderer.shadowViews() is the same run one light at a time.

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

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

globals/renderer/shadowCaching

renderer.shadowCaching() -> boolean

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

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 page. A shadow page 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 draw per page it reaches 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 sun's shadow settings now in force. distance bounds how far from the camera the sun shadows (the world's render.shadow_distance until renderer.setShadowConfig sets it); fadeFraction and softness shape how the shadow is sampled. The sun's virtual shadow map follows each receiver's screen footprint, so its resolution follows the view. punctualMipFloor is the finest mip a spot, area or point light's map renders. detailBias and budgetMegabytes are what a caller stated, or "default"; what the renderer sized the pages with is renderer.shadowSizing(). The table is one renderer.setShadowConfig takes back.

Returns ShadowConfig — The full config — see ShadowConfig.

print(renderer.shadowConfig().distance)

globals/renderer/shadowHero

renderer.shadowHero() -> ShadowHeroReport

The registered hero caster and what the last drawn frame's box was. A frame that made no box says why in decline.

entity is the live registration; everything else is the box of the drawn frame frame names, and stands until the renderer draws another. So a reading taken straight after setShadowHero or clearShadowHero describes the registration before the call: read the drawn frame count as the change is made, and take the report once its frame is past it.

Returns ShadowHeroReport — See ShadowHeroReport.

local h = renderer.shadowHero()
print(h.active, h.levelBias, h.decline)
local before = time.drawnFrameCount()
renderer.setShadowHero(player.id)
task.waitDrawnFrames(1)
print(renderer.shadowHero().frame > before)

globals/renderer/shadowMemory

renderer.shadowMemory() -> {

How much GPU memory the virtual shadow map's pools hold right now, in bytes. One pool serves every light — the sun, and every spot, area and point light that casts. pool is the depth page pool with the sun's static cache laid out as it, held while any light casts; transmittance is the transmittance pool, held while a translucent caster casts into it (renderer.renderTargets() names them shadow_page_pool, shadow_static_cache and shadow_transmittance_pool). Both read 0 while released.

Returns { pool, transmittance, total } in bytes

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

globals/renderer/shadowPageBreakdown

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

The breakdowns renderer.watchShadowPages gathered since the last call, oldest first, one per run of the main camera. levels has a row per clipmap level: texelWorld (world units per texel), the pages it requested, held (resident), drew (rendered) and had dirtied by the frame's caster changes (invalidated), the pages those changes reach in its window (reached) and the smallest size a change projects to there (smallestRecordTexels). records lists each caster change with its light-space sizeWorld and, per level, the texels it covers and the pages it reaches. cull is what the page cull walked (slotsWalked caster slots, ncMembers casters drawn from their own vertex buffers) and kept (pairs, casters tested, admitted and cutOff, chunks, visible clusters, ncPairs, batchPages). pages has a row per batch page: its level, the casters it drew and how many changed this frame (dynamicCasters), its chunks, clusters and triangles with the changed casters' shares, and its pairs of casters drawn from their own vertex buffers (ncPairs, dynamicNcPairs).

Returns { { [string]: any } } — An array of breakdowns, empty when none was read since the last call.

renderer.watchShadowPages(30)
task.wait(1)
for _, run in renderer.shadowPageBreakdown() do print(run.frame, #run.pages) end

globals/renderer/shadowPageStats

renderer.shadowPageStats() -> {

What the sun's virtual shadow map did on the main camera's newest run the GPU counted. The sun's shadow lives in pages around the camera; a run marks the pages the receivers on screen ask for and draws the ones that are missing or that a moved caster dirtied. A still camera over a still scene runs nothing, so ran reads false and runs stands still while frames climbs. The counts come back from the GPU a few frames after the run, so frame names the frame they describe. proxyPairs counts the (caster, page) pairs a shadow proxy stood in for, proxyTriangles the triangles those pairs drew from the proxy and sourceTriangles what the casters' own meshes would have drawn there. castersDropped counts the casters drawn outside the cluster lists (a deforming mesh, a population whose mesh has no cluster set) that the newest frame could not list, which cast no shadow that frame.

Returns { frame, ran, runs, frames, requested, rendered, pending, starved, resident, clusters, batchPages, proxyPairs, proxyTriangles, sourceTriangles, invalidated, invalidateOverflow, castersDropped }, or nil before the first run is read back

local s = renderer.shadowPageStats()
if s then print(("%d pages asked for, %d drawn"):format(s.requested, s.rendered)) end

globals/renderer/shadowProxies

renderer.shadowProxies() -> ShadowProxyReport

The shadow proxies in force and what the shadow pages did with them. triangles and sourceTriangles are what the main camera's newest counted run submitted through proxies and what the same (caster, page) pairs 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/shadowSizing

renderer.shadowSizing() -> ShadowSizing

What the sun's virtual shadow map was sized with. Its pool of pages holds about twice the pages a frame at the render resolution asks for, within the depth budget (the quality tier's: 64, 12 and 8 MiB at high, medium and low, or the one renderer.setShadowConfig states). It is sized again only when the render resolution, the detail bias or the budget changes. A run that finds no slot for a page it asked for raises overflowBias by a level, up to 4, until the pool is sized again.

Returns ShadowSizing — See ShadowSizing.

print(renderer.shadowSizing().depthMegabytes)

globals/renderer/shadowViews

renderer.shadowViews() -> ShadowViewReport?

Every shadow-casting spot, area and point light's virtual shadow map on the main camera's newest counted run, one row each.

renderer.shadowCacheStats() and renderer.shadowPageStats() sum these counts over every light; this is the same run one light at a time. The sun's pages are reported by renderer.shadowPageStats().

Each row names its light and says what its pages did: how many its receivers asked for, how many it holds, how many were drawn, dirtied, starved or left for a later run, and what the page cull did with the casters it walked for the light. camera carries the main camera's share of the instanced-compaction counters.

The counts come back from the GPU a few frames after the run, and frame names the frame they belong to. A light that stops casting leaves views.

The frame names its lights only while something is reading them, so this call asks the frames after it to publish 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()
for _, view in report.views do
print(("%s %d (%s): %d of %d pages resident, %d drawn"):format(
view.role, view.index, view.light, view.pagesResident,
view.pagesRequested, view.pagesRendered))
end

globals/renderer/signalMemoryPressure

renderer.signalMemoryPressure(level: string) -> ()

Report memory pressure the way the system does: the engine releases what level releases on its next frame, and engine.onMemoryPressure subscribers hear it with source "script". Content about to need a lot of memory (a large scene load, a big decode) frees room ahead of it with this, and "normal" lifts a level a script put in force. level is "normal", "moderate", "low" or "critical"; anything else is an error naming the four. Read the outcome with renderer.memoryPressure() once a frame has passed.

Parameters

  • level string — "normal", "moderate", "low" or "critical"

Returns ()

renderer.signalMemoryPressure("moderate") -- free unsampled mips before a big load

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. A slice counts as filled once a render carrying its dispatch has been drawn: the frame's own view, or a render of the scene into a target, such as a camera drawing into its render texture. A frame whose view the renderer declines (a headless one nothing is consuming, a browser behind its GPU) and that renders the scene into no target records nothing, so a pose that arrives over such frames is counted in dispatches on each of them and reads as held from the frame after the first one that records it. A wait for a crowd to settle is therefore counted in drawn frames (task.waitDrawnFrames).

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.

The same pass draws a soft body's model through the body's elements (its cage). cages is how many soft bodies it draws that way, cageBytes what their buffers occupy, and cageUploads how many of them wrote their elements' maps to the GPU this frame: a soft body at rest writes nothing, and a removed one takes its cage with it.

Returns { instances: number, poses: number, dispatches: number, held: number, reusedSlices: number, liveBytes: number, unsharedBytes: number, poolBytes: number, cages: number, cageBytes: number, cageUploads: 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.read("/source/captures/ceramic.spz"))

globals/renderer/surfaceSize

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

The whole drawing surface in pixels — the window, the browser canvas or the headless framebuffer. This is the size renderer.setViewportSize sets and the size every UI screen is laid out over, so it is what says whether a resize was realized, on every layout including an editor one whose viewport panel holds a smaller rect than the window. { width = 0, height = 0 } before the first frame has drawn.

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

local s = renderer.surfaceSize()

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, and a deterministic capture states that key as its own owner option to be admitted under the hold and photograph the instant it pinned.

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 caller the call is attributed to, which names the calling session where the transport attested one and the ZeroMind account otherwise. 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 picture = 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) print("temporal clock", 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) — { colorSpace?, compression?, generateMipmaps?, maxDimension?, filter?, textureType?, alpha?, alphaIsTransparency? } — the opts renderer.texture.encodeFromImage takes — applied when the bytes are an encoded image and need the engine-native encode. maxDimension bounds ZTEX bytes too: a payload that stores a mip chain is read from the first level whose longest side is within it, that level alone, so a thumbnail of a 4096² texture decodes a level near the thumbnail's size.

Returns TextureCpuHandle

local cpu = renderer.texture.cpuFromBytes(assert(vfs.read(path))); local png = cpu:encodePng(); cpu:unload()
local thumb = renderer.texture.cpuFromBytes(assert(vfs.read(texPath .. "/data.ztex")), { maxDimension = 256 })

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 and a screen target takes the scene's own format ("rgba16f") — a screen-sized target holds a picture of the scene, and that is the format the scene is held in: linear light with headroom above the display's white and no 256-step floor under a dark gradient. A target that is not screen-sized and names no format takes the surface's. "rgba8" is what a mask, a weight or an occlusion term asks for, where 0..1 is the whole range; hand one back over @scene.color and the frame loses every highlight above white and bands every gradient, and the engine says so in the log. 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 screen target a live Camera draws into is that camera's output instead: the frame's other render targets neither resize nor clear it, so what the camera drew there stays for as long as the camera does. A reading that has to be the viewport's own comes from screenSpace = "composite", or from a target created without screen. history = true makes a history target instead, the target an accumulation across frames lives in: it is screen-sized and keeps one pair of images per view a frame draws (the viewport, every render-to-texture camera, every capture). For the length of each view's render, the handle's guid names the image that render writes and previous.guid the image that view's previous render wrote, so a pass binds the two like any other targets and reads its own view's history wherever it runs; no other view resizes, clears or overwrites them. Before each render of a view the engine writes the compute buffer named by the handle's state (one vec4<f32>, bound through a pass's buffers): x is 1 when previous holds that view's previous image and 0 when it does not (the view's first render, its first after a cut or a change of size, or a render of the view that ran without the target, and then previous reads transparent black), y how many renders in a row have carried the history, zw the render's sub-pixel projection offset in pixels (renderer.setProjectionJitter). A view's images are freed when the view stops drawing, and all of them with the handle. 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" })
local hist = renderer.texture.create({ width = 1, height = 1, history = true, name = "accum" })

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) — { colorSpace?, compression?, generateMipmaps?, maxDimension?, filter?, textureType?, alpha?, alphaIsTransparency? } — the opts renderer.texture.encodeFromImage takes — 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, opts: { straightAlpha: boolean? }?) -> (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).

The pixels are the texels the payload stores. With opts.straightAlpha = true, a payload encoded with alpha = "premultiplied" has its colour divided back by alpha, in the colour space it was premultiplied in — the straight-alpha pixels renderer.texture.encode takes. A texel whose alpha is 0 keeps the black premultiplying left in it.

Parameters

  • bytes buffer | string — A ZTEX payload or source image bytes.
  • opts { straightAlpha: boolean? } (optional) — { straightAlpha? }

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"))
local straight = renderer.texture.decode(vfs.read(texPath .. "/data.ztex"), { straightAlpha = true })

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. Called from a task (an execute chunk, a component hook the engine lets wait, task.spawn), a "uastc" or "bc7" encode runs where the frame loop keeps stepping: on a worker where the build has one, and otherwise a bounded slice of its blocks per frame (__texture.encodeBudget, reported by __texture.encodeJobs). The call returns on the frame the payload lands. Code that cannot yield, and a coroutine the script resumes itself (coroutine.create / coroutine.wrap), encode in place, and every other compression is encoded on the call. The payload is the same bytes either way. opts.compression selects what the texels are stored as: "rgba8" (default, 8 bits/channel, rgba is widthheight4 bytes), "uastc" (UASTC 4x4 blocks, a quarter of the RGBA8 size, transcoded on each device to the block format it holds; rgba is widthheight4 bytes and each side is resampled to the nearest power of two, halfway rounding up), "bc7" (BC7 blocks, a quarter of the RGBA8 size; rgba is still widthheight4 bytes and both sides a multiple of 4) or the high-precision data compressions "rgba16" (16-bit unsigned normalized, widthheight8 bytes) / "rgba32f" (32-bit float, widthheight16 bytes) — for height/displacement fields, baked lightmaps, and other data rasters 8 bits quantize visibly. The two high-precision compressions store rgba verbatim, reject opts.generateMipmaps / opts.maxDimension, and take opts.colorSpace = "linear" only. opts.colorSpace is "srgb" (default, colour data) or "linear" (normal maps, masks, heights). opts.input ("rgba8" / "rgba16" / "rgba32f") names the texel format rgba arrives in when it is not the one opts.compression stores — the pixels renderer.texture.decode hands back at their own width, say — and the encoder carries each channel across as the same normalized value, natively, before storing it. opts.textureType records what the texture holds — "color" (default), "normalMap" (when the encode generates mips, every level of the chain, the base included, is renormalised to unit length), "mask" or "sprite". opts.alpha is what the encode does to alpha: "input" (default, kept as given), "premultiplied" (RGB multiplied by alpha — in linear light under srgb, as stored under linear — the source a material with the premultiplied blend mode composites) or "none" (every texel opaque, RGB kept). opts.alphaIsTransparency = true fills each fully transparent texel with the average colour of its non-transparent neighbours, spreading outward, alpha kept at 0 — before the alpha mode and the mips, so filtering never pulls black into an edge. All three are recorded in the payload and reported by renderer.texture.info. The sampler opts are recorded the same way: opts.filter ("linear", the default, or "nearest"), opts.wrapU / opts.wrapV ("repeat", the default, "clamp" or "mirror" — how the texture is sampled past its edges) and opts.anisotropy ("world", the default, following the world's render.anisotropy, or a fixed "1" / "2" / "4" / "8" / "16", also taken as the number). opts.effort ("fastest", the default, "fast" or "thorough") is how long a uastc encode searches for each block: every effort writes a payload the same size on the GPU, and a higher one lands closer to the source. opts.uastcRdo ("none", the default, "low", "medium" or "high") is how strongly a uastc encode trades accuracy for a payload that compresses smaller. A block-compressed encode (uastc, bc7) called from a task that can yield runs off the frame and the task waits for it, so the world keeps running while it cooks.

Parameters

  • rgba any (optional) — Pixel payload at opts.input's (else opts.compression's) native byte width — a buffer, a binary string, or a number array.
  • width number — number
  • height number — number
  • opts any (optional) — { colorSpace?, compression?, uastcRdo?, generateMipmaps?, maxDimension?, filter?, wrapU?, wrapV?, anisotropy?, textureType?, alpha?, alphaIsTransparency?, input? }

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. A "uastc" or "bc7" encode called from code that can yield runs where the frame loop keeps stepping, as renderer.texture.encode describes. opts.compression is "rgba8" (default), "uastc", "bc7", "rgba16" or "rgba32f", opts.colorSpace "srgb" (default) or "linear", and opts.uastcRdo as renderer.texture.encode takes it. "rgba8" with generateMipmaps = false, a maxDimension the image fits within (or none), alpha = "input" and no alphaIsTransparency stores the source image bytes verbatim. "rgba16" and "rgba32f" decode a still image at its own bit depth and store one linear level: a 16-bit PNG keeps every bit, an 8-bit channel v is stored as v * 257 (rgba16) or v / 255 (rgba32f). The frames of an animated image (GIF, APNG, animated WebP) are decoded at 8 bits, so a 16-bit APNG's frames are stored as v * 257 of their 8-bit values. Like encode, they reject opts.generateMipmaps and a maxDimension the image exceeds. opts.textureType records what the texture holds — "color" (default), "normalMap" (when the encode generates mips, every level of the chain, the base included, is renormalised to unit length), "mask" or "sprite". opts.alpha is what the encode does to alpha: "input" (default, kept as given), "premultiplied" (RGB multiplied by alpha — in linear light under srgb, as stored under linear — the source a material with the premultiplied blend mode composites) or "none" (every texel opaque, RGB kept). opts.alphaIsTransparency = true fills each fully transparent texel with the average colour of its non-transparent neighbours, spreading outward, alpha kept at 0 — before the alpha mode and the mips, so filtering never pulls black into an edge. All three are recorded in the payload and reported by renderer.texture.info, as are the sampler opts filter, wrapU, wrapV and anisotropy, which take the values renderer.texture.encode takes.

Parameters

  • bytes buffer | string — source image bytes.
  • opts any (optional) — { colorSpace?, compression?, uastcRdo?, generateMipmaps?, maxDimension?, filter?, wrapU?, wrapV?, anisotropy?, textureType?, alpha?, alphaIsTransparency? }

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, its compression ("rgba8", "bc7", "rgba16" or "rgba32f" — what the texels are stored as) and colorSpace ("srgb" or "linear" — how they are read when sampled), dimensions, mip count, the sampler recorded in the payload from the asset's settings — filter ("nearest" or "linear"), wrapU and wrapV ("repeat", "clamp" or "mirror") and anisotropy ("world", or a fixed "1" / "2" / "4" / "8" / "16"); a payload written before wrap and anisotropy were recorded reads repeat / repeat / world — what the payload was encoded as — textureType ("color", "normalMap", "mask" or "sprite"), alpha ("input", "premultiplied" or "none") and alphaIsTransparency (boolean); a payload written before these were recorded reads color / input / false — 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, compression, colorSpace, textureType, alpha, alphaIsTransparency, width, height, mipCount, filter, wrapU, wrapV, anisotropy, 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/isCpuResident

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

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

Parameters

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

Returns boolean

if renderer.texture.isCpuResident(handle) then ... 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/keepCpuOnLanding

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

Set whether the decode on its way to this texture — scheduled by textureRef:handle() or renderer.texture.createFromAsset and not yet on the device — keeps its decoded pixels in the CPU store once it lands. A texture's keepCpu setting changing while its decode is in flight reaches the texture through this.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture — a TextureHandle, a TextureCpuHandle, a guid, or a texture AssetRef.
  • keep boolean — Keep the CPU copy once the decode lands.

Returns boolean — true when a decode was on its way; false changes nothing.

renderer.texture.keepCpuOnLanding(texRef, true)

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, its texel format and the mip levels the device holds (mipLevels), 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, "device" when no asset answers for it and its whole chain on the device is the only copy, "retained" when it is resident coarser and holds the levels read back off the device. A resident entry carries how a material binding it samples it, as the texture records it: filter ("linear" / "nearest"), wrapU and wrapV ("repeat" / "clamp" / "mirror") and anisotropy ("world" or a fixed "1".."16"), and samplerAnisotropy, the level the texture's sampler takes: anisotropy resolved against renderer.anisotropy() and the device's ceiling, and 1 for a nearest texture — the entry of the renderer's sampler table the texture's handle names. 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?, mipLevels?, format?, filter?, wrapU?, wrapV?, anisotropy?, samplerAnisotropy?, 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) — { colorSpace?, compression?, generateMipmaps?, maxDimension?, filter?, textureType?, alpha?, alphaIsTransparency? } — the opts renderer.texture.encodeFromImage takes — 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/reencode

renderer.texture.reencode(ztex: buffer | string, opts: any?) -> (string?, string?)

Re-encode an engine-native ZTEX payload against new opts. A "uastc" or "bc7" re-encode called from code that can yield runs where the frame loop keeps stepping, as renderer.texture.encode describes. opts is the complete encode spec, exactly as for renderer.texture.encode: every field it leaves out takes its default, so textureType, alpha, alphaIsTransparency, filter, wrapU, wrapV and anisotropy come from opts — not from the payload's header — and a payload re-encoded with { compression = "uastc" } alone loses a nearest filter or a premultiplied alpha it carried. renderer.texture.withSampler changes how a payload is sampled without re-encoding it. The payload's layers and their display times are kept, whatever compression either side is in. Each layer's texels are read back with straight alpha (a payload encoded with alpha = "premultiplied" has its colour divided back by alpha), carried to the width the new compression stores as the same normalized value, and encoded exactly as renderer.texture.encode would encode them; a payload that embeds its source image is re-encoded from that image, read at its own bit depth. What an earlier encode discarded stays discarded: after alpha = "none" the alpha is opaque. The texture assetType re-encodes a container that holds no source image through this call when its settings change. A block-compressed encode called from a task that can yield runs off the frame while the task waits.

Parameters

  • ztex buffer | string — A ZTEX payload — a buffer or a binary string.
  • opts any (optional) — { colorSpace?, compression?, uastcRdo?, generateMipmaps?, maxDimension?, filter?, wrapU?, wrapV?, anisotropy?, textureType?, alpha?, alphaIsTransparency? }

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

local wide = renderer.texture.reencode(vfs.read(texPath .. "/data.ztex"), { compression = "rgba16", colorSpace = "linear", generateMipmaps = false })

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/unloadCpu

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

Drop this texture's resident CPU copy from the guid-keyed CPU store. The explicit release for a texture asset's keepCpu setting turning off.

Parameters

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

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 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. src is raw pixels, or a TextureCpuHandle (texRef:load(), renderer.texture.loadCpu, renderer.texture.readback), whose pixels arrive with everything they carry: texel format, colour space, mip chain, layers and filter. Every material, UI image and post-process effect sampling the guid draws the new texture. 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 — or a TextureCpuHandle.

Returns TextureHandle — A TextureHandle for the updated texture.

globals/renderer/texture/withSampler

renderer.texture.withSampler(ztex: buffer | string, opts: { filter: string?, wrapU: string?, wrapV: string?, anisotropy: (string | number)? }) -> (string?, string?)

Change how an engine-native ZTEX payload is sampled without re-encoding it. opts names any of filter ("linear" / "nearest"), wrapU and wrapV ("repeat" / "clamp" / "mirror") and anisotropy ("world", or "1" / "2" / "4" / "8" / "16", also taken as the number); a field it omits keeps the payload's value. The result is the same bytes with only the sampler bits of the header replaced, so every level is byte-identical to the input — the cheap path the texture assetType takes when a settings change touches only these four fields. An unrecognised value raises.

Parameters

  • ztex buffer | string — A ZTEX payload — a buffer or a binary string.
  • opts { filter: string?, wrapU: string?, wrapV: string?, anisotropy: (string | number)? } — { filter?, wrapU?, wrapV?, anisotropy? }

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

local clamped = renderer.texture.withSampler(vfs.read(texPath .. "/data.ztex"), { wrapU = "clamp", wrapV = "clamp" })

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 compression = "uastc" appears in the compressed columns at a quarter of what the same image costs as RGBA8. blockCompressionSupported is whether this adapter samples any block-compressed format; where it is false a block-compressed payload is uploaded decoded and lands in the uncompressed columns instead, so the texture is present everywhere and compressed where the hardware allows it. blockFormat names the format block-compressed textures are resident in on this adapter ("bc7" on desktop, "astc4x4" on a phone, "etc2" on a phone without ASTC) and is nil where it samples none. A texture stored as UASTC is transcoded to blockFormat; one stored as BC7 keeps its blocks where the adapter samples BC7 and is re-encoded into blockFormat where it does not, each at the same one byte per texel. Measured at the end of the last rendered frame. streamableTextures is how many of them the 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; the rest no asset answers for, so the device holds their only copy. Such a texture holds no copy of its own while it is at its whole mip chain; the first time the budget takes it coarser its chain is read back off the device before any level is freed, and it holds that copy while it stays coarser. retainedTextures counts the ones holding a copy, and streamSourceBytes is what those copies occupy in system memory — bytes that are not VRAM. pinnedTextures counts the textures big enough to stream that stand at a level nothing can move: no asset answers for them and they left the streamable set, 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. byFormat names every GPU format a cached texture is held in, keyed by the format's name (Bc7RgbaUnormSrgb, Astc { block: B4x4, .. }, Rgba8UnormSrgb, ...), with how many textures it holds and their bytes. heldAboveCap lists the textures resident larger than renderer.setMaxTextureSize allows: one whose mip chain has no level within the cap, and one with no asset behind it, which keeps every level it was made with. decodedFallback lists the UASTC textures this device holds as texels because it samples no block format.

Returns { blockCompressionSupported, blockFormat?, compressedTextures, compressedBytes, uncompressedTextures, uncompressedBytes, streamableTextures, assetStreamedTextures, retainedTextures, pinnedTextures, streamSourceBytes, byFormat, heldAboveCap, decodedFallback }

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 budget in force: the device's own, or the one setTextureBudget names, as much of it as the memory pressure in force leaves (half at "critical"). deviceBudgetBytes is the device's own budget (what setTextureBudget names deviceMegabytes): memoryBudgetBytes, held to the quality tier's cap where the tier states one. memoryBudgetBytes is what the device's memory affords and budgetRule the rule it came from: "systemMemory", an eighth of the RAM the system states (deviceMemoryBytes, nil where it states none), on a GPU that draws from that RAM, or "bufferSize", half the adapter's max_buffer_size; between 256 MiB and 2 GiB either way. pressure is the memory pressure level in force; from "moderate" up the plan holds every texture at the level its surfaces sample, below the budget as well as past it (renderer.memoryPressure()). 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 asks for before the sweep — every texture's whole chain while those fit the budget, and the level each surface asked for once they exceed it — 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.

Returns TextureStreaming — { budgetBytes, deviceBudgetBytes, memoryBudgetBytes, budgetRule, deviceMemoryBytes?, pressure, 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 frame the renderer drew spent re-describing its renderables to the GPU, counted from the frame it drew before that, so a read after task.waitDrawnFrames describes the frame waited for even where the frame pacer declines frames between two drawn ones. 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. A renderer.mesh.drawInstanced population's slots are filled by copies from its own buffers, counted the same way: a frame that copies a population's transforms or shader data counts one write per copy and the bytes it moved. bytes is what those uploads and copies 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/upscaleBackend

renderer.upscaleBackend() -> string

The backend actually filling the upscale seam this frame — "blit" or "dlss". Reports what the renderer resolved the last renderer.setUpscaleBackend() request into, which reads "blit" after a request for "dlss" on an adapter renderer.upscaleCapabilities() omits it from.

Returns string — "blit" or "dlss".

local backend = renderer.upscaleBackend()

globals/renderer/upscaleCapabilities

renderer.upscaleCapabilities() -> { string }

The backend names this engine can actually run for the upscale seam, from a boot-time probe of the adapter. "blit" always answers, since the stretch needs no adapter capability; "dlss" joins it only where NGX DLSS Super Resolution is available. Read this before renderer.setUpscaleBackend("dlss", ...) to know ahead of the request what it resolves to.

Returns { string } — at least { "blit" }.

local caps = renderer.upscaleCapabilities()

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)

globals/renderer/viewportSize

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

The rect the scene was drawn into in the frame just drawn, in pixels. That is the whole drawing surface in a runtime window, a browser canvas and a headless engine, and the editor's viewport panel — smaller than the surface — in an editor layout. Follows a renderer.setViewportSize, a window drag and a browser page resize alike, so it reports what is realized rather than what was asked for.

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

local v = renderer.viewportSize()

globals/renderer/watchShadowPages

renderer.watchShadowPages(runs: number)

Gather a breakdown of what the sun's virtual shadow map does on each of the main camera's next runs runs. Each run's counts come back from the GPU a few frames after it, and renderer.shadowPageBreakdown() hands them over. Nothing is gathered while no reader has asked. 0 stops a gathering under way.

Parameters

  • runs number — How many of the main camera's runs to gather.
renderer.watchShadowPages(120)

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?, options: AtmosphericsHoldOptions?): () -> ()

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. exclusive takes the air 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 share holding it. That is what lets one caller clear the air to read a surface and keep it clear 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.atmospherics.release hands the air back by.

Parameters

  • share number? (optional) — How much of the authored air reaches the image, in [0, 1]. Defaults to 0 -- no air at all.
  • options AtmosphericsHoldOptions? (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 caller the call is attributed to, which names the calling session where the transport attested one and the ZeroMind account otherwise. exclusive takes the air for the stated key until the hold is released.

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

local release = renderer.atmospherics.hold() ; local picture = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()
local release = renderer.atmospherics.hold(0, { exclusive = true, owner = "albedo-read" })

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) print("atmosphere share", share) end)

modules/renderer/atmospherics.owner

atmospherics.owner(): { id: string?, name: string?, share: number, exclusive: boolean }?

The hold naming the share of the authored air that reaches the image right now: who took it, what share it asked for, and whether it took the air exclusively. Several agents drive one engine at once and a hold any of them takes moves the air every surface in every other agent's frame is rendered through, so this is how a caller sees that another agent holds it before its own share 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.atmospherics.release hands the air 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.atmospherics.owner()
if who ~= nil and who.exclusive then print(who.name, "holds the air at", who.share) end

modules/renderer/atmospherics.release

atmospherics.release(owner: string): number

Hand the air 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 air pinned with nobody holding a release for it. Naming the key is how the authored air comes back, 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.atmospherics.owner reports.
renderer.atmospherics.release("albedo-read")

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.

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.
renderer.bounds.set(id, cloud.boundsMin, cloud.boundsMax)

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.
local debugChannel = renderer.captureView.channelId("lightmap") -- a render feature passes it as an enqueued pass's `debugChannel`

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. A view that registered a ready of its own is also held to it, so a view whose feature is not running, or whose draw has not landed, reads false. 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?, ready?, 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). ready is the view's own statement of whether what it draws is in place: a function(): (boolean, string?) answering true, or false and the reason. A capture of the view waits on it after ensure and before it renders, so a view whose draw lands over the frames after ensure (an entity it spawns, a feature that reads the state ensure built on its next frame, a material still compiling) is drawn rather than captured empty, and a view that stays unready is refused with that reason. warmup is how many present frames a capture lets the view accumulate, once it is ready, 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, ready, description, warmup, renderLayers } 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 every receiver samples the level its footprint selects again.

renderer.clearShadowHero()

modules/renderer/clearShadowProxy

clearShadowProxy(mesh: string?): number

Stop proxying mesh, so it rasterizes its own geometry into shadow pages 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/clusterErrorPx

clusterErrorPx(px: number?): number

The cluster cut's error threshold, in pixels. Every static mesh draws the coarsest clusters whose simplification error projects to at most this many pixels in the view drawing it — measured against that view's own pixel height and projection, so a small render target draws coarser geometry than the full-screen view does from the same place. Lower draws finer geometry at more cost; higher draws coarser geometry. A client graphics setting. Default 1.

Parameters

  • px number? (optional) — A finite number above 0. Omit to read the threshold.
renderer.clusterErrorPx(0.5) -- finer geometry on this machine

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. A runtime mesh needs no call: once the garbage collector has taken its last handle, the engine judges it against the same holders, again whenever what displays it changes or its hold is released, and every few hundred frames while something holds it, and releases it once nothing does. Returns once what it released has left the device: the GPU objects of the textures and meshes it let go are freed over the frames after the call, and it waits for those frames (up to 60 drawn frames), so renderer.texture.list, renderer.mesh.list, renderer.material.list, renderer.textureMemory() and renderer.gpuMemory() read after it in the same script no longer count anything it reports released. landed says whether every release left the device within that wait; false names a texture or mesh something put back on the device before it could be seen gone, which renderer.references explains. Code that cannot yield (a metamethod, onDestroy, a module's top level while require runs it) is answered at once, without the wait: there landed is false while a release is still on its way, and the listings and totals stop counting it over the drawn frames that follow.

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

modules/renderer/compiledShaders

compiledShaders(): { string }

Every key renderer.compiledSource answers for — one per shader key (guid@checksum) or variant program a compile has run under, whether it succeeded or failed, for as long as the shader that compiled under it stands: a removed .shader takes its keys with it. 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 for a shader key (guid@checksum), a program from renderer.shaderVariants(), or any reference to a shader asset (identity, path, guid), which reaches the key of the version that reference resolves to now; 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.

Parameters

  • shader string — A shader key, a shaderVariants() program name, or a reference to a shader asset.
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 visibility raster, 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 the shadow page cull did with the frame's casters for each light is on that light's row in renderer.shadowViews().

views is the engine cull's own count, one row per camera view that rendered: view is "main" for the main camera and the camera's entity id for an offscreen one, kind says which, and frame is the frame the counts describe. The main view's row is first. drawn is whether a render drew the view in that frame: a frame the renderer declined still ran the cull's first test, so its row carries that test's counts and no second one, for a picture nothing rasterized. The counts are read back from the GPU and handed out oldest first, one frame per render, so a script reading once a frame reads the frames in the order they ran. A frame's counts wait at most two renders to be handed out: a row describes a frame as many frames back as the read took and at most two more, and when more frames' counts come back at once than that lets wait, the oldest of them are the frames a script does not read. instancesTested renderables were tested; instancesCulled breaks the rejections down by reason (hidden, layer, range, frustum, screenSize, and occludedPass1/occludedPass2 for occlusion against the depth of the frame that last drew the view and then this frame's); instancesRecovered came back in the second test. Then the clusters of the survivors: clustersTested, rejected by the level-of-detail cut (clustersLodRejected), the frustum (clustersFrustumCulled), facing away (clustersConeCulled) or occlusion (clustersOccludedPass1, of which clustersRecovered came back), and clustersDrawn / trianglesDrawn. binsPending counts material pipelines still compiling, whose clusters wait a frame; overflow counts clusters that did not fit the view's lists. total/culled/visible and occlusionTested/occlusionCulled above are the main view's count for the renderables drawn outside the cluster lists.

local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")
for _, v in renderer.cullStats().views do if v.view == "main" then print(v.clustersDrawn, "clusters,", v.instancesCulled.frustum, "outside the frustum") end end

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/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 the platform has refused every quick attempt 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 every quick attempt at another device was refused (a browser whose GPU process crashed twice answers no adapter for minutes): nothing is drawn, the engine log names the platform's last answer, and the engine keeps asking at a slower pace, so the state returns to "ready" on its own once the platform hands a device over. Restarting the engine (reloading the page on the web) builds one from scratch.

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 group's textures (group 1), and materialBindsElided how many times a pass reached that decision and found the group already bound. On the bindless backend every material group binds the one texture group of the frame, so a pass binds it once; on buckets a pass binds once per group it draws (see renderer.materialTable().groups). 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. materialExtraBinds and materialExtraBindsElided are the same pair for group 2, the storage buffers a shader declares, bound per distinct buffer set, which only the shaders that declare 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. renderer.shadowViews() carries the main camera's own share of compacted and compactedDrawn.

clusters and clusterTriangles are the clusters and triangles the camera views drew from the cluster lists, from the GPU's own count.

drawsByPass splits draws by the part of the frame that submitted them — visibility (the id raster: one draw per raster bin per Hi-Z phase in each camera view, submitted whether or not the GPU cull left anything in the bin), resolve (one draw per resolve draw in each camera view: one per shader, and on the array-bucket backend one per shader and material group), legacy (geometry drawn outside cluster storage: skinned, morphed and compute-written meshes, line topology, shaders that write their own vertex stage), blended, exception (opaque materials whose depth state the visibility buffer cannot express), featureGeometry, featureDraw, shadow and splat (one draw per splat buffer a render feature draws) — and the nine sum to draws.

instances counts instances named by draws whose size the CPU decided; the visibility raster's work is clusters and clusterTriangles, which the GPU decided. pixelsResolved is how many pixels the resolve shaded, summed over camera views, and pixelsByShader the same split by shader. clusters, clusterTriangles, pixelsResolved and pixelsByShader come back from the GPU and describe the frame numbered frame, which is earlier than the frame draws describes. frame is the renderer's own frame number, counted from 0 when the renderer was built (again after a device loss), a count of its own that compares only with another drawStats().frame.

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 group binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")
local d = renderer.drawStats(); print(d.clusters, "clusters,", d.clusterTriangles, "triangles from the cluster lists")
local d = renderer.drawStats(); print(d.drawsByPass.visibility, "id-raster draws,", d.drawsByPass.resolve, "resolve draws")
local d = renderer.drawStats(); for shader, px in d.pixelsByShader do print(shader, px) end

modules/renderer/editorChromeOnWindow

editorChromeOnWindow(): boolean

Read back the frames the engine presents, exactly as presented: no camera is spawned and nothing is drawn for the read, so the frames carry the viewport's real temporal history (TAA and every accumulating effect), its camera motion (motion blur), its auto exposure and its render scale and upscale. From the first frame presented after the call, every interval-th frame is copied out as it is finished, until frames are taken; the call yields until all of them have landed and returns them in the order they were presented. Each frame's pixels are its cpu field, a TextureCpuHandle (readPixel, tone, rgba, encodePng, unload).

scope = "viewport" (the default) is the game view as finished for the player: after the post chain, the upscale, the display transform, the content UI and every post-scene pass, at the size the frame was composed at (a viewport panel's own texture where a panel shows it). scope = "window" is the whole presented surface (the window, the browser canvas or the headless framebuffer) with every window-level UI pass on it, the editor's chrome included. Where no panel shows the viewport the two are the same image, and while the editor's chrome is on the window each such viewport frame carries a note saying the chrome stands over the scene in it and how to show the Scene panel.

Each frame states what it was drawn with: frame (consecutive frames carry consecutive numbers), the raster and composite sizes, renderScale and scaleMode, the projection jitter, the display exposure, and historyReset. Call frame.cpu:unload() when done with the pixels.

velocity = true also takes the viewport's velocity buffer with each frame, copied at the same moment as its pixels, as frame.velocity (a VelocityField): each pixel's travel between the previous frame and this one in pixels per frame of the scene's raster, x right and y down, signed and unclamped, measured against the projection without its jitter. It is the field motion blur, temporal antialiasing and every other temporal effect read, camera motion included; field:at(x, y), field:row(y) and field:region(rect) read it.

local f = renderer.presentedFrames({ frames = 8 }); for _, fr in f do print(fr.frame, fr.renderScale, fr.cpu:tone().mean); fr.cpu:unload() end
local fr = renderer.presentedFrames({ velocity = true })[1]; local v = fr.velocity :: any; local c = v:at(v.width // 2, v.height // 2); print(fr.frame, c.x, c.y); fr.cpu:unload()

modules/renderer/feature.configure

feature.configure(handleOrRef: any, settings: { [string]: any }): { [string]: any }

Lay settings over a live feature's knobs. Any omitted knob keeps its value; a knob the feature never declared, or a value of the wrong shape, is refused by name. The feature reads the result as ctx.settings from its next frame.

Parameters

  • handleOrRef any (optional) — A RenderFeatureHandle, its guid, or the feature's ref.
  • settings { [string]: any } — The knob values to apply.
renderer.feature.configure(h, { intensity = 0.5, quality = "medium" })

modules/renderer/feature.create

feature.create(ref: any, opts: (string | { guid: string?, settings: { [string]: any }? })?): 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").
  • opts (string | { guid: string?, settings: { [string]: any }? })? (optional)
local h = renderer.feature.create(asset.resolve("acrylic", "renderFeature"))
local h = renderer.feature.create("acrylic")
local h = renderer.feature.create("@builtin::systems.reflections.ssr", { settings = { intensity = 0.8 } })

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.find

feature.find(ref: any): any

The live handle of the feature running ref — an AssetRef or a renderFeature identity — or nil when none is. The check a caller makes before creating one, so a feature already live is configured rather than stacked.

Parameters

  • ref any (optional) — An AssetRef, a renderFeature identity, or a guid.
local h = renderer.feature.find("@builtin::systems.reflections.ssr")

modules/renderer/feature.list

feature.list(): { { guid: string, identity: string, settings: { [string]: any } } }

List every render feature currently live (running its render(ctx) each frame). Each entry is { guid, identity, settings } — 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), and settings is every knob the feature declares at the value in force.

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

modules/renderer/feature.schema

feature.schema(ref: any): { [string]: any }

The settings a .renderFeature declares, with each knob's descriptor, read without running it: what a caller may pass to create / configure.

Parameters

  • ref any (optional) — An AssetRef or a renderFeature identity.
for name, desc in pairs(renderer.feature.schema("@builtin::systems.reflections.ssr")) do print(name, desc.default) end

modules/renderer/feature.settings

feature.settings(handleOrRef: any): { [string]: any }?

A live feature's settings as they stand: every declared knob at its default or at what create / configure set. A copy — writing to it changes nothing; configure does.

Parameters

  • handleOrRef any (optional) — A RenderFeatureHandle, its guid, or the feature's ref.
local s = renderer.feature.settings(h); print(s.intensity)

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.

fills counts, per layer, the setLayers that have landed in it since the array was last sized: fills[layer + 1] for a 0-based layer. A fill of a layer that already holds content leaves filled as it was, so the count moving is how a feature re-filling its own layer reads its fill arriving.

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
print("refill the cookie layer", myLayer)
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 the pacer has given the bound up rather than hold the image indefinitely, on either mechanism: the counting one stands down when the completion signal goes quiet across a whole run of declined frames, and the waiting one when a wait on a frame reaches its ceiling with the GPU still holding it. Both clear on the first word the GPU sends after that. A signal that is merely late — a GPU finishing frames more slowly than the loop asks for them — keeps the bound, so stalled answers for a GPU that went silent. standDowns counts those over the session, which is how a session that lost the signal and got it back (stalled = false with standDowns above zero) reads apart from one that never lost it.

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. A desktop presents mailbox unless told otherwise, and Android presents fifo, which shows each frame on a vsync of the panel and makes no frame the panel never shows.

The rest describe the rate frames run at. displayRefreshHz is the refresh rate of the display the frames are shown on, where the platform reads it: the phone's panel, the monitor a desktop window is on, the cadence of a browser's animation frames. A headless engine presents to no display and states none. frameRateCap is the cap renderer.setFrameRateCap set, targetFps the rate that cap delivers and refreshesPerFrame the refreshes each frame stays on screen under it, all three absent while no cap is set. deliveredFps is the rate the frame loop has run frames at over about the last twenty frames.

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. textures is what the resident textures occupy, and on the texture-array backend also the slack its shared arrays hold beyond them (renderer.materialTable().bucketSlackBytes).
  • allocator is the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. A render target's colour texture is reported under what holds it now: the target drawing into it, or released_scratch[<width>x<height>].color while it waits for the next target of its size. It exceeds categorised by the per-frame render targets and the scratch nothing categorises, and on the texture-array backend, for the frame after an array grows or is repacked, by the array it replaced. 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. arenas states the part of it the pages runs stand in hold, and what those pages' bytes are spent on. 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. textureBudgetBytes is the texture budget in force and pressure the memory pressure level that shapes it: under "critical" the budget reads half the device's, and renderer.memoryPressure() says what the newest report released.

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.

-- 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

modules/renderer/lighting

lighting(): boolean

Whether the scene renders lit.

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/markBaseline

markBaseline(): number

Record which render features are live and how the feature-texture array is configured, and make that the point renderer.resetToBaseline returns to.

A caller running work in an engine it shares — a sweep of test suites, a tool driving a session — leaves the renderer holding whatever that work set up: a feature a module created on first use, an array reconfigured to the size one pass wanted. This is how such a caller says where its own changes start. mark above the marks before it.

local mark = renderer.markBaseline() -- then run, then renderer.resetToBaseline(mark)

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, render, features, bindings }) 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 texture each slot of its row names (renderer.textures, the writes the renderer took up), the render state its draws are looked up under, whether a pipeline exists for that key, and the draws, instances and binds the last published frame counted for it. A frame counts those only while something observes the renderer (renderer.materialCost(), renderer.observe() and renderer.drawDiagnostics() each keep it observing for the next 120 drawn frames, and describe does not), so renderer.observed states whether they were counted: true with the counts, false with draws, instances, placeholderDraws, binds and bindsElided all nil. 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. A material that is not resident carries shaderStatus and reason, where its shader stands: "absent" when the shader it names resolves to no shader asset (nothing will compile it, so it stays unprepared until one arrives), "pending" while that shader has not compiled, "failed" with the compiler's message, or "compiled" when the shader is ready and the material has not been prepared yet. A resident material carries neither.

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, the shader it binds, and for a key a .material asset filed (guid@checksum) the asset it is. 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.asset or 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. index is the material index its row in the material table is filed under, which an edit rewrites in place, and textures the texture each slot of that row names, by slot name, under the key the texture is resident as; a slot drawing its shader's declared default is absent, so a setTexture the renderer has taken up reads here as the key it bound. 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 published. A frame counts those only while something observes the renderer, which renderer.materialCost() and renderer.drawDiagnostics() start, so observed states whether they were counted: true with the five counts, false with all five nil. 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.setClock

material.setClock(key: string | { [string]: any } | AssetRef, seconds: number?, rate: number?): ()

Drive a material's surfaces from a clock of its own: the time its shader's vertex(), vertex_clip() and fragment() read. The clock reads seconds now and runs on by itself, rate of its seconds for every second of the world's gameplay time, so it is stated again only when that changes — a pause is a rate of 0, a seek is a new seconds, slow motion is a rate below 1. Every surface drawing the material follows it, motion vectors included. seconds = nil hands the material back to the world's time. A procedural graph's Generator drives the materials it realizes from its playhead this way, so an effect's shaders pause, slow and scrub with it.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.
  • seconds number? (optional) — The time the material's surfaces read now, or nil to release it.
  • rate number? (optional) — Seconds of the clock per second of gameplay time (default 1).
renderer.material.setClock(asset.resolve("frost_ice", "material"), 12.5, 0)

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): boolean

Push one changed texture slot to a registered material's GPU record. Keyed by the material's registry key. The slot is one the material's shader declares as a texture: a slot it does not declare is refused with a warning naming the texture slots it does declare, and the material is left as it was. While the shader's vocabulary has not been read (a custom shader that has not loaded) every slot is taken.

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/material.vocabulary

material.vocabulary(key: string | { [string]: any } | AssetRef): MaterialVocabulary?

A material's property names under props and its shader-declared and assigned texture slots under textureSlots, each a { [name] = true } set — with byRole beside them, the index a key spelled in another engine's convention resolves to a declared name through. This is what a write's key is routed against on the record, in the shape MaterialSchema.route takes. It is read off the definition this module holds and touches nothing the renderer holds, so a call costs a table lookup and belongs on a per-frame write path; describe is the read that carries the renderer's own state. One frozen table per record, shared by every key that reaches it, rebuilt on the first read after a write puts a new name on the record.

Parameters

  • key string | { [string]: any } | AssetRef — The material's registry key, the MaterialHandle from renderer.material.create, or an AssetRef from asset.resolve.
local MaterialSchema = require("@builtin::modules.material_schema")
local route, name = MaterialSchema.route(assert(renderer.material.vocabulary(handle)), "color", { 1, 0, 0, 1 })

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. Those three count what this material drew itself: a batch drawing several materials of one group adds to each material the draws and instances that wore it.

group is the material group the material draws in — the unit a pass binds a material's textures (group 1) and its shader's storage buffers (group 2) by — or nil while the material resolves no group, which leaves its draws nothing to bind. binds is how many group-1 and group-2 binds the frame's passes issued for draws of this material's shader in that group, and bindsElided how many times such a draw found the group already bound, which is what draw-key sorting buys. Those binds belong to the group, so every material of one group reports the same pair; a material with no group reports 0 for both, and a draw that fell back to the placeholder counts in placeholderDraws and in neither bind count.

uniformBytes is the size of the property block in the material's row of the material table: the shader's declared properties, packed at the offsets the shader reads them from and padded to a whole 16 bytes. The row also carries a 64-byte header and a 4-byte handle per texture slot, padded to a whole 16 bytes. renderer.materialTable().rowBytes is the space the table reserves for all rows — its row capacity, free rows included — plus its directories. renderer.drawDiagnostics() names WHICH renderable is not drawing what its material says, and why. material is the key the renderer holds the material under; asset names the .material asset a guid@checksum key is a version of.

for _, m in renderer.materialCost() do print(m.asset or 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 while the renderer holds the material, so two renderables that differ only in material read different indices, and one renderable reads the same index frame after frame. A destroyed material's name leaves the table once nothing draws with it, and the next material takes its index, so the table holds a row per name the renderer holds a material under or something draws with. 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 zero_instance(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 a name the renderer holds no index for: one it has not drawn with yet, or a destroyed material nothing draws with any more. Pass it to a shader (or compare it against what a shader read out of zero_instance(slot).material_index) to tell which material a drawing instance carries.

Parameters

  • name string — string Material name, as renderer.material.create filed it.
local red = renderer.materialIndex("brick_red")

modules/renderer/materialTable

materialTable(): MaterialTable

How this device resolves material textures, and what that storage costs. backend is "bindless" (a descriptor array indexed by each material's texture handles) or "buckets" (exact-size texture arrays, one per size, format and mip count, bound per material group). Bindless is taken on a Vulkan adapter that has texture binding arrays with non-uniform indexing and partially bound arrays, and room for descriptor arrays of at least 4096 textures; every other device — the browser build, Android, Metal, DirectX 12, and Vulkan without those — takes buckets.

materials is how many materials have a row, rowBytes the space the table reserves in the scene buffer (its row capacity, free rows included, plus the material and texture directories), textures how many distinct textures the rows name, descriptorCapacity the length of each bindless array (0 on buckets), and groups how many material groups the materials form. A group is the unit a pass binds a material's textures (group 1) and a shader's own storage buffers (group 2) by, and every material of a group draws under the same binds. On bindless every group binds the one texture group of the frame, so groups differ only by the set of storage buffers they bind. On buckets a group also names, per texture slot, the bucket its members' textures live in and the sampler those textures sample through: materials of one shader split into one group per distinct combination of (bucket and sampler per texture slot, storage buffers), and each group is its own draw.

buckets lists every texture array: its format, size, mip count, the layers textures occupy, its capacity, what it costs, the slack it holds beyond its live layers, whether it is frozen (a layer's view is held by something outside the material system — a UI image, a post-process input — or a copy out of a layer is taken this frame, such as a readback, so the array neither grows nor compacts), which shard of its key it is, and whether it is external (a one-layer bucket over a texture the GPU writes — a render target, a video frame, a compute output — which its producer owns). bucketBytes is what the shared buckets occupy together, and bucketSlackBytes the slack they hold together, which renderer.setTextureBucketSlack bounds; an external bucket counts in neither. slackBudgetBytes is the budget the buckets bound growth by. On bindless buckets is empty, both totals are 0, and slackBudgetBytes is the budget recorded, which bounds nothing.

local t = renderer.materialTable(); print(t.backend, t.groups, "groups over", t.materials, "materials")
for _, b in renderer.materialTable().buckets do print(b.format, b.width, b.height, b.layers, "/", b.capacity) end

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/maxViewportExtent

maxViewportExtent(): number

The largest number of pixels this device draws a surface at, per axis — the bound renderer.setViewportSize refuses past. 0 before the first frame has drawn.

local bound = renderer.maxViewportExtent()

modules/renderer/memoryPressure

memoryPressure(): MemoryPressureReport

What memory pressure is doing to the engine. level is the level in force: "normal", "moderate", "low" or "critical". A report puts its level in force, whatever stood before, for holdSeconds after the last report, and then the level returns to normal. Reports come from the system (on Android, onTrimMemory: RUNNING_MODERATE is moderate, RUNNING_LOW and UI_HIDDEN low, RUNNING_CRITICAL, BACKGROUND, MODERATE and COMPLETE critical; Android 14 and later report only UI_HIDDEN, when the app leaves the screen, and BACKGROUND) and from renderer.signalMemoryPressure. signals counts the reports taken; lastLevel, lastSource and lastAgeSeconds describe the newest (absent before the first).

Each report releases at once what its level releases, and released says what the newest one freed: the textures taken to the level the frame samples and the textureBytes they gave up, and the render targets whose storage was freed. Moderate frees the mip levels the frame does not sample; low also frees render-target storage the last frame did not draw into; critical also halves the texture budget. While a level stands the residency plan keeps the textures there, and textureBudgetBytes is the budget the level leaves in force. engine.onMemoryPressure(cb) fires with each report once its release is done.

local mp = renderer.memoryPressure()
print(mp.level, mp.released.textureBytes / 1048576, "MiB freed")

modules/renderer/mesh.bounds

mesh.bounds(mesh: string | { [string]: any } | AssetRef): Aabb?

The mesh's extent in its own space: the box its vertices span, or the one its author stated (aabbMin / aabbMax) for a mesh whose vertices live only on the GPU. It is what the mesh is culled against and what framing reads; place it in the world through whatever draws the mesh — an entity's transform, or instanceBounds for an instanced population.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
local box = renderer.mesh.bounds(mesh)

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 or cannot be drawn as clusters, 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. inFlight is one row per queued bake — { guid, cpuMs, frames, slices, bytes, state, levels }: 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, "baking" for the one being advanced against "queued" for the ones waiting their turn, and levels the depth the bake builds, "dag" or "single".

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.clusterStore

mesh.clusterStore(): {

What the geometry heap holds for cluster drawing. heapCapacity and heapLive are the heap's size and the bytes in use. The heap grows to place what does not fit, and gives memory back as meshes leave it: once what it holds has packed into a quarter of its size for 60 frames in a row, it shrinks to double that, never below the size it started at. A mesh destroyed and created again inside those frames finds the heap at the size it left it. vertexBytes and lightmapBytes are the vertex pools and lightmap-UV runs of the meshes drawn through clusters. bakedBytes is the cluster sets of meshes drawing a baked set; unbakedBytes is the sets of meshes still drawing lod0 plus those meshes' vertex pools — geometry drawn at full detail while its bake is outstanding. meshes counts meshes drawing any set, unbakedMeshes those on lod0. scriptSetBytes is CPU memory beside the heap: the sets scripts installed with renderer.mesh.uploadClusters, held so each mesh draws its set again after it returns to the device.

local s = renderer.mesh.clusterStore(); print(s.unbakedBytes / 1048576, "MiB drawn at full detail while bakes are outstanding")

modules/renderer/mesh.clusters

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

The cluster set a mesh draws. Every static mesh the geometry heap holds draws through clusters: kind is "lod0" while it draws the full-detail set built from its geometry as it uploaded, "single" for one baked level (clusterLod = false), and "dag" for the baked LOD hierarchy. clusterCount, levelCount and triangleCount describe that set, nodes how many node transforms its clusters lie in, and bytes its size in the heap. A mesh that is not drawn through clusters reads resident = false with reason: "heapFull" (the heap cannot grow to hold it), "spansNodes" (a triangle joins two independently moving parts) or "deformable" (a pass deforms its vertices). Nil for a mesh the renderer holds no GPU geometry for.

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 and c.kind == "lod0" then print("still baking") 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.

A mesh this call builds lives while the returned handle, or another handle over the same guid, is reachable from a script, or while something else holds it (an entity or instanced draw displaying it, its owner, a renderer.hold). Once nothing holds it and the garbage collector has taken its last handle, the engine releases it mid-session: its definition, its buffers and its renderer.mesh.list row go, and its guid names nothing. A script that displays a mesh again later keeps its handle (or holds it) for as long as it means to.

A handle a script keeps names its mesh for the rest of the session: a module table or _SCRATCH holding it carries it across play/edit flips, scene loads and hot reloads of the components that use it, and a Model handed it after any of them draws the mesh. While nothing displays the mesh, the engine holds it off the device (renderer.mesh.heldOffDevice) and the next display puts it back. Only a destroy ends a mesh a handle still reaches (renderer.destroy, renderer.mesh.destroy): every handle a create returned over it then carries released, the verb that released it, and a point of use (ecs.Mesh, a Model's model field) refuses such a handle by name until a mesh stands under its guid again, which the handle then names and holds.

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, which is what buys that full-pass parity. Rewrite the buffer between frames and the instances move, with no re-registration and no re-upload. The copy is made on the frames that follow a write of the buffer (a :write, a compute pass whose shader binds it read_write, and every frame a material whose shader binds it read_write is live), so a population whose buffer nothing writes costs no copy per frame however many instances it holds; renderer.uploadStats() counts the copies a frame made.

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 like the transforms, on the frames that follow a write of the buffer, 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?, receivesShadows?, mobility?, parent? }. castsShadows and receivesShadows, both true when left out, state whether the copies cast shadows and whether shadows darken them. parent stands the copies in an entity's space, as setInstanceParent does.
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

Return a mesh to its lod0 set and cancel a bake still in flight for it. The inverse of renderer.mesh.uploadClusters.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh to return — 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 MeshGeometry — MeshGeometry — 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.heldOffDevice

mesh.heldOffDevice(mesh: string | { [string]: any } | AssetRef): boolean

True while the engine holds this mesh off the device: nothing displays it, so its GPU buffers were given back and the engine keeps what rebuilds them. The next Model or instanced draw that names the mesh puts it back on the device, where renderer.mesh.isResident reads true again from the frame that bind lands in. A mesh is one of three things: resident, held off the device, or gone; a mesh neither this nor isResident answers true for names nothing the engine can draw.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh: a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
local named = renderer.mesh.isResident(h) or renderer.mesh.heldOffDevice(h)

modules/renderer/mesh.instanceBounds

mesh.instanceBounds(draw: InstancedDraw, opts: { keep: number? }?): (Aabb?, string?)

The world box a registration's drawn copies occupy: every copy of the mesh's extent (renderer.mesh.bounds) where the engine composed it for the last frame drawn, carried through the entity the copies stand in when setInstanceParent named one. It reads the copies back from the GPU, so it waits a few drawn frames. A copy scaled to nothing draws nothing and is left out. A mesh whose extent nobody knows answers with the box around the copies' origins, and says so.

Parameters

  • draw InstancedDraw — The InstancedDraw to measure.
  • opts { keep: number? }? (optional) — { keep? } — the share of the copies the box holds along each axis (default 1, all of them). Below 1, the copies reaching furthest out on each side are left out: keep = 0.9 is the box around where the body of a spray stands, without the few copies thrown far from it.
local box, note = renderer.mesh.instanceBounds(draw)
local body = renderer.mesh.instanceBounds(draw, { keep = 0.9 })

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.instanceWorlds

mesh.instanceWorlds(draw: InstancedDraw): any

Read back where a registration's drawn copies stand in the world, as the engine composed them for the last frame drawn: each copy's matrix from the transform buffer, carried through the world matrix of the entity the copies stand in when they have one (setInstanceParent). instanceCount column-major 4x4 matrices, 16 floats each. Async — poll the handle it returns the same way as instanceTransforms.

Parameters

  • draw InstancedDraw — The InstancedDraw to read.
local pending = renderer.mesh.instanceWorlds(draw)
while not pending:ready() do task.wait() end
local x = pending:result()[13]

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 queued this frame 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 — from the frame after the upload commits: a render asked for in the same tick the upload was queued does not carry the mesh yet. renderer.mesh.onDevice is the answer that waits for that commit.

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.onDevice

mesh.onDevice(mesh: string | { [string]: any } | AssetRef): boolean

True once the device holds this mesh's buffers — the upload queued for it has committed — so the next render asked for carries the mesh. renderer.mesh.isResident answers true one frame earlier, while the upload is still queued; a reader about to render and read the picture back waits on this one.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef.
while not renderer.mesh.onDevice(guid) do task.wait() end

modules/renderer/mesh.readback

mesh.readback(mesh: string | { [string]: any } | AssetRef, into: string?): 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.

The CPU store is keyed by guid and shared by everything that reads the mesh, and whatever owns the mesh can retire the copy under its own guid: renderer.mesh.update from compute buffers unloads it, which an ocean surface does every frame it steps. into lands the copy under a key of the caller's own instead: the handle answers for that key, and it stays resident until the caller unloads it, whatever happens to the mesh.

Parameters

  • mesh string | { [string]: any } | AssetRef — The mesh — the MeshHandle renderer.mesh.create returned, a guid, or a mesh AssetRef.
  • into string? (optional) — The CPU-store key the geometry lands under. Defaults to the mesh's own guid.
local cpu = renderer.mesh.readback(handle); local zmsh = cpu:encode(); cpu:unload()
local snap = renderer.mesh.readback(handle, "bake:" .. handle.guid); local tris = snap:getTriangles(); snap: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.

The vertices read are ones the skinning pass wrote for the pose the entity is bound to. An entity bound to a pose that pass has not written yet — the frame it was posed, before the pass reached the queue — is asked again on the next frame rather than read from a slice still holding what stood there before.

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, opts: { levels: string? }?): boolean

Queue a cluster bake over the mesh's geometry, off the frame loop. The mesh keeps drawing its current cluster set while the bake runs, and the set the bake produces replaces it when it lands. opts.levels is "dag" for the LOD hierarchy or "single" for one level of clusters at full detail; when omitted, a mesh AssetRef bakes the depth its clusterLod setting asks for and any other argument bakes "dag". The CPU mesh may be unloaded on the very next line. Geometry with a triangle joining two independently moving parts leaves the queue unbaked with a warning; a mesh renderer.mesh.clusters reports as "deformable" or "heapFull" leaves it unbaked without one.

One bake is advanced per frame — the one at the head of the queue — and the geometry is read once a bake gets there and the mesh's GPU upload has landed, from the definition the engine holds for the mesh. The queue therefore holds one mesh's geometry rather than one per mesh, whatever its depth, and the set installs only on the geometry it was read from: a mesh whose geometry is replaced before its set lands keeps the set of its new geometry. 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 — The mesh to bake — a MeshHandle, a MeshCpuHandle, a guid, or a mesh AssetRef, loaded into the CPU store (renderer.mesh.loadCpu).
  • opts { levels: string? }? (optional) — { levels: "dag" | "single" }?
renderer.mesh.scheduleClusters(cpu, { levels = "single" }) ; 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.setInstanceParent

mesh.setInstanceParent(draw: InstancedDraw, parent: any?): InstancedDraw

Stand a registration's copies in an entity's space: every copy's matrix is taken relative to the entity, so the population moves, turns and scales with it from the next frame on, with its transform buffer untouched. nil puts the copies back in world space. It is the verb for a population that belongs to something — a generator's output, an asset stood on an entity.

Parameters

  • draw InstancedDraw — The InstancedDraw to reconfigure.
  • parent any? (optional) — The entity the copies stand in (a proxy or its id), or nil for world space.
renderer.mesh.setInstanceParent(draw, entity.find("Forest"))

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

Replace the cluster set a mesh draws with a serialized set (bytes from renderer.mesh.buildClusters), built from the geometry the mesh holds as the call is made. A set whose vertex count, vertex maps or nodes do not describe the mesh, or one whose mesh's geometry is replaced before it lands, is refused with a warning, and the mesh keeps its current set and the set it was asked for. Once the set is installed, a bake still in flight for the mesh is cancelled, and the engine keeps the bytes on the CPU (renderer.mesh.clusterStore().scriptSetBytes) so the mesh draws this set again when it returns to the device after nothing displayed it, or after the device is rebuilt. The bytes go when the mesh's geometry is redefined, when renderer.mesh.dropClusters returns it to lod0, and when the mesh is destroyed.

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, and deltaBufferBytes the size of that buffer, which the vertex stage binds whole.

deltaBudgetBytes is the most that buffer may hold on this device: the largest storage buffer the device binds at once (128 MiB on many mobile browsers). A block costs 32 bytes per vertex per target, over every vertex of the mesh. A mesh whose block would take the resident blocks past the budget is refused by name in the engine log and draws in its base shape, and refusedMeshes counts the meshes refused that way; the refusal holds until the mesh is uploaded again. 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/pendingWork

pendingWork(): PendingWork?

What the renderer still owes the picture — the work it has taken on and has not yet put in the frame.

A frame draws what the renderer holds at the moment it runs, and several of the things it holds arrive over more than one frame. A pipeline is built off the frame thread and bound the frame its result is taken in; a texture is uploaded under a per-frame time budget; a released texture's GPU object is freed on the frame the release queue is drained; a streamable texture climbs toward the level its screen size asks for one level at a time. pipelineBuilds, textureUploads, textureReleases and textureResidency are those four. textureTransfers counts the textures the texture cache is still moving over several frames: a large upload written a band at a time, a promotion waiting on its finer levels to be read or decoded, a demotion waiting on its levels to be read back. Each lands as a new GPU texture on a frame of its own, and the frames between two landings replace nothing, so textureResidency alone reads zero on them. outstanding is the sum of every count.

settled is true when outstanding is zero, and says the renderer owes this picture nothing: the frames after this one draw the same picture unless something changes the scene. That is the renderer's own word for what a test measuring a picture holds for before it reads one.

epoch counts the times the renderer has gone from owing nothing to owing something since the process started. It is what a pair of readings needs: a run of work that begins AND ends between two samples leaves both of them reading settled and leaves epoch higher, so settled at both ends with the same epoch at both ends is the statement that nothing arrived across the whole span, rather than at its two endpoints alone. Test.settleReading is built on exactly that pair of conditions.

frame names the drawn frame the reading was published on. Every count is published from the frame thread on every frame the renderer runs, so a headless engine declining frames answers as readily as a windowed one. deviceRebuild is one while the engine is between render devices: the picture is owed the device itself, every frame in that window draws nothing, and what content puts back on the new device lands on the frames after it. computeCommands counts the compute commands a drain held over: a dispatch waiting on the program it names and every command over the buffers it would write queued behind it, each of which runs or drops on a later drain. clusterSets counts the meshes whose cluster set is still being built, or is owed again after a device rebuild: each lands as the set the mesh draws, and the geometry a cut of it draws changes on the frame it does. materialRows counts the materials the frame does not yet draw as they ask: one whose raster or resolve pipeline is still building draws nothing until it lands, one whose shader's resolve gate has not reached a verdict resolves through the error shader until it does, and one whose routing changed since the frame last published it is still drawn by the previous routing. Each changes the picture on the frame it lands. skippedDraws counts the renderables the last drawn frame left undrawn because a pipeline or a material group they draw with was not built yet; each appears on the frame what it waits on lands. shaderCompiles counts the shader compiles running off the frame thread that no frame has landed: each lands as the program its keys name, and the materials drawing with those keys change on the frame it does.

if renderer.pendingWork().settled then print("the picture is what it is going to be") end
local w = renderer.pendingWork(); print(w.outstanding, "arrivals still owed, over", w.epoch, "runs of work")

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.

A pipeline a material, the sky, a depth view, a post-process pass or a compute program a script registers needs after boot is built off the frame thread — the shader module behind that compute program with it — on buildWorkers worker threads on native, behind the browser's asynchronous entry point on the web, where buildWorkers reads 0. So a burst of new materials costs a frame nothing beyond drawing the placeholder until each pipeline lands, and a burst of effects costs it nothing beyond each effect starting on the frame its pipeline does. offFrameBuilt and offFrameBuildMs are the pipelines and the milliseconds that never held a frame; pipelinesBuilt - offFrameBuilt is what the frame thread built, which is the renderer's own — the set it stands up before its first frame, and the few it stands up on the frame a pass first asks for them. buildsSubmitted, buildsFinished, buildsFailed and buildsInstalled count build requests (shader modules and pipelines) made, completed, refused and taken back by a frame. buildsAbandoned counts the requests a renderer still held when a device rebuild let it go, which no frame will take back, and buildsPending is what the renderer drawing now has yet to take back: asked for and not yet in use, a finished result included until a frame installs it. So every request made is installed, abandoned or pending, and a reader waiting on buildsPending to reach zero reads pipelinesBuilt with every build it waited for counted, and can tell a queue draining from one that is stuck. modulesBuilt and moduleBuildMs are the shader modules compiled off the frame thread, parse and validation included.

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/presentedFrames

presentedFrames(opts: { scope: string?, frames: number?, interval: number?, velocity: boolean? }?): { PresentedFrame }

Parameters

  • opts { scope: string?, frames: number?, interval: number?, velocity: boolean? }? (optional)

modules/renderer/projectionJitter

projectionJitter(): { { number } }

The offsets renderer.setProjectionJitter stated, each {x, y} in pixels from the pixel centre.

local offsets = renderer.projectionJitter()

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/qualityTier

qualityTier(): QualityTier

The device's quality tier and the renderer defaults it chose. The engine assigns every device one of "low", "medium" and "high" at boot, from its GPU family and limits, its memory and its display, so a world authored on a desktop plays on a phone at the phone's defaults without knowing it runs on one. A desktop, and any device whose GPU is outside the handheld families, is "high": the renderer as a desktop runs it, one scene pixel per display pixel. A phone renders the scene below a dense display's resolution with UI and text at full resolution, and spends less on shadows, textures and filtering the lower its tier. A lower tier chosen on a desktop renders the scene within that tier's pixel budget the same way, so renderer.setQualityTier alone is a whole graphics setting on every platform.

Every default the tier states stands wherever the world's settings name no value of their own (render.anisotropy), and the matching renderer call overrides any one of them: renderer.setResolutionScale, renderer.setTextureBudget, renderer.setAnisotropy. renderer.setQualityTier chooses another tier altogether.

local q = renderer.qualityTier()
print(q.tier, q.reason, q.defaults.resolutionScale)

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 entity(cameraId).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. skyStale is whether the sky changed after that capture, so the slot still depicts the sky from before the change until the engine's recapture, or an explicit environment.captureSky(), retakes it. The engine retakes a sky swapped for another material or turned off on the first frame the new sky can be drawn, and a sky whose look moved once a pacing window has passed since the last capture. skyPosition is where the capture was taken from, absent before one. skyCaptures counts the sky captures that have landed since the renderer was built, so one retaken from the same position reads as new, and skyMaterial names the material the sky pass draws from, absent when no sky is drawn. 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, the slot a camera's stated environment is held in, and 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/registeredCopy

registeredCopy(content: { [string]: any }): { [string]: any }

Destroy every render feature created since renderer.markBaseline, withdraw every capture view registered since, and put the feature-texture array back to the size it recorded.

A feature the mark recorded is known by its identity: one destroyed and created again under the same identity since the mark stands, under whatever guid it now carries. A capture view is known by its name.

A feature is usually created by the module that needs it, on first use, and that module holds its own record of having done so. Drop the module's cache alongside this (engine.resetScriptingState) or the module will go on believing it has a feature this destroyed, and nothing will rebuild it.

A hold on the clock (renderer.temporal.hold) or on the air (renderer.atmospherics.hold) taken since the mark comes off; one the mark recorded stands until the caller that took it releases it.

What this asks for lands on the renderer's next frame, so a caller that must see the renderer already back — one handing the engine to somebody else — waits a frame when the answer is above zero. recorded size, from the next frame on.

Parameters

  • content { [string]: any }
if renderer.resetToBaseline(mark) > 0 then task.waitFrames(1) end

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(). A runtime mesh no script reaches a handle to is judged again once its hold goes, and released with no collection when nothing else holds it.

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/releaseBaseline

releaseBaseline(generation: number): boolean

Drop the mark generation names, and every mark taken after it, once the caller that took it is done resetting to it. It touches the marks alone.

Parameters

  • generation number — The mark to drop, as renderer.markBaseline returned it.
renderer.releaseBaseline(mark) -- the run is over

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 scene's occlusion and reflection channels and its lit colour history (scene_occlusion, scene_reflection, scene_color_history) are resident at the scene's extent while a render-feature pass writes or reads them, and shadow_page_pool while a light casts into the virtual shadow map, with shadow_transmittance_pool beside it while a translucent caster casts into it. 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, reflection and colour-history channels, the scene copies and the G-buffer over them under a camera's scene render. A draw builds what it needs, or takes the scratch a released target of the same size left. A target releases its set to the next target of its size when no live camera names it and when it is destroyed or resized, and frees it once sixty frames pass without a draw, so a target nothing draws into carries no such row. Released scratch waits as released_scratch[<width>x<height>].* rows for the next target of that size, held newest first within 64 MiB and freed once sixty frames pass without a target taking it. The colour image drawn into belongs to the texture cache while its target stands; a destroyed or resized target's colour texture waits on the same terms as a released_scratch[<width>x<height>].color row for the next target made at that size and format, which starts from it cleared to transparent black. totalBytes is what the resident targets hold together. allocations counts the render-target allocations the renderer has made since it was built, the colour textures of the live targets included, each on the first frame whose report holds it: a target taken back at a size it held before adds nothing, so the difference between two readings is what the frames between them allocated. 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/resetToBaseline

resetToBaseline(generation: number?): number

Parameters

  • generation number? (optional)

modules/renderer/resolutionScale

resolutionScale(): number

The fraction of the display resolution the scene is currently rendered at. Until something sets it, the scale the device's quality tier chose for its display (renderer.qualityTier().defaults.resolutionScale): 1 on a desktop at the high tier, below 1 on a dense handheld display and on a desktop at a lower tier.

local s = renderer.resolutionScale()

modules/renderer/resolutionScaleMode

resolutionScaleMode(): string

Where the render scale resolves: "present" or "reconstruct". "present" until something sets it.

local m = renderer.resolutionScaleMode()

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 boolean — boolean
renderer.setBlendedBatching(false)  -- a draw per blended renderable

modules/renderer/setFrameRateCap

setFrameRateCap(fps: number?): number?

Hold the engine to at most fps frames per second, or lift the cap with 0 or nil. The whole frame waits for its turn: scripts, physics, audio mixing and networking run once a frame as the drawing does, so a game capped at 30 runs at 30, and a phone spends no battery on frames past it.

Frames come due on an even grid. Where the display's refresh rate is known (a phone's panel, a desktop window's monitor, a browser's animation frames), the grid is a whole number of refreshes, the fewest whose rate stays inside the cap, so every frame stays on screen for the same number of vsyncs: on a 120 Hz display a cap of 30 shows each frame for 4 refreshes and 60 for 2, and a cap the refresh rate does not divide evenly delivers the next rate below it (50 on a 120 Hz display delivers 40). On Android the system compositor is told the rate as well, so a panel with more than one refresh rate can run at one the cap divides; with no cap it is told the panel's highest rate.

Read renderer.framePacing() for the effect: targetFps is the rate the cap delivers, refreshesPerFrame the refreshes each frame holds, displayRefreshHz the display it was worked out against, and deliveredFps the rate frames actually ran at. Takes effect on the next frame. frame_rate_cap under [render] in the world's .world_settings seeds it (0 for no cap), so a world states its cap once and every session opens under it; a call here stays in force until that value changes.

Parameters

  • fps number? (optional) — number? Frames per second, 1 through 1000; 0 or nil lifts the cap.
renderer.setFrameRateCap(30) -- a steady 30 on any display 30 divides
print(renderer.framePacing().targetFps, "fps delivered under the cap")
renderer.setFrameRateCap(nil) -- every frame the display shows

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/setLighting

setLighting(enabled: boolean): ()

Turn the scene's lighting on or off. Off, every lit surface renders as its own colour with no light on it, emissive included: the editor's lighting toggle, for reading a scene's materials without its lights. With no light applied there is no shadow either, so the shadow maps draw nothing while it is off, and the shadow switches apply again once lighting is back on. The sky and post-processing stay as they are.

Parameters

  • enabled boolean — true renders the scene lit, false unlit.
renderer.setLighting(false); print(renderer.lighting())

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/setMaxTextureSize

setMaxTextureSize(px: number?): { px: number, devicePx: number }

The longest side a texture loaded from an asset may be resident at on this client, in texels. Such a texture larger than the cap is served from the first level of its mip chain that fits, so a 4096² texture under a 1024 cap holds its 1024² level and the levels below it, and the texture budget never takes it finer than that level. A changed cap moves the resident textures over the next frames; a raised one reads their finer levels back from their assets. A texture made from pixels with no asset behind it keeps every level it was made with, because the device holds its only copy. The device's own maximum 2D texture dimension bounds the cap from above. Call with no argument to lift the cap to the device's limit.

Parameters

  • px number? (optional) — number? — texels, a whole number of at least 1; nil for the device's own limit
renderer.setMaxTextureSize(2048)
renderer.setMaxTextureSize()  -- the device's own limit

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 number — number — 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 depth the visibility raster lays 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.

Parameters

  • enabled boolean — boolean
renderer.setOcclusionCulling(true); print(renderer.cullStats().occlusionCulled)

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/setProjectionJitter

setProjectionJitter(offsets: { { number } })

State where inside the pixel consecutive renders of a view sample, for the frames until it is stated again: a list of {x, y} offsets in pixels from the pixel centre, each within half a pixel. Every view the renderer draws (the viewport, each render-to-texture camera, each capture) walks the list by its own count of renders in a row, so a camera drawn beside the viewport takes nothing out of the viewport's sequence: a view's first render, and its first after a cut, samples the pixel centre, and its next renders take the offsets in order, wrapping. Each offset is applied in the view's own pixels. Velocity (@scene.motion) is measured against the offset-free projection, and a history target's state buffer carries the offset each render took. renderer.setProjectionOffset, while it is not (0, 0), and an upscale backend's own jitter both stand over the list on the viewport. An empty list samples pixel centres. An offset outside half a pixel is refused, naming it, and leaves the list in force.

Parameters

  • offsets { { number } } — The offsets, each {x, y} in pixels from the pixel centre.
renderer.setProjectionJitter({ { 0.25, -0.25 }, { -0.25, 0.25 } })

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/setQualityTier

setQualityTier(tier: string?): QualityTier

Render at another quality tier than the one the device was assigned: "low", "medium" or "high", or nil / "default" for the device's own. A change of tier stands the new tier's defaults at once wherever the world's settings state none, replacing what an earlier renderer call set for the render scale, the shadow budgets and the anisotropy; choosing the tier already in force changes no setting. A call made after it, in the same script or later, overrides that one default again. A budget renderer.setTextureBudget names stands over the tier's texture cap until it is returned to the device's own. The choice holds for the process, across scene loads and restarts of the scene, until it is set again. A settings screen offering the player a quality choice is this call.

Parameters

  • tier string? (optional) — string?: "low", "medium", "high", or nil / "default" for the device's own.
renderer.setQualityTier("low")
renderer.setQualityTier()  -- back to the device's own

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 boolean — boolean
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. 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. What the smaller raster MEANS for everything composed on top of it — the post chain, the UI, every post-scene pass — is renderer.setResolutionScaleMode. The scene rows in profiler.gpuFrame() are what move.

Parameters

  • scale number — number — fraction of the display resolution, clamped to [0.25, 1].
renderer.setResolutionScale(0.7)

modules/renderer/setResolutionScaleMode

setResolutionScaleMode(mode: string): string

Where in the frame the render scale is resolved — what a smaller raster MEANS. "present" (the default) composes the whole frame at the raster: the scene, every post-process layer, the UI and every post-scene pass, carried up to the size it is shown once it is finished. A smaller raster is then a smaller picture, so an effect whose output is a function of pixel size — a dither ladder, a posterize, a pixel grid — resolves at the pitch it was authored at, and the frame carries one composite resolution throughout. "reconstruct" carries the scene up to the size it is shown BEFORE the post-scene phases and composes everything from there, which is what a temporal upsampler needs: it refines the upscale it is handed and has nowhere to put the detail it recovers unless the image it accumulates into is the display's. Either reading is honoured identically whether a viewport panel or the swapchain shows the frame, so what an editor draws and what a shipped runtime draws are the same picture of the same scene.

Parameters

  • mode string — string — "present" or "reconstruct".
renderer.setResolutionScaleMode("present")

modules/renderer/setShadowCaching

setShadowCaching(enabled: boolean): ()

Whether a shadow page that nothing changed is kept rather than drawn again. On by default: every light's pages — the sun's, and each spot, area and point light's — are drawn again only where a caster that changed reaches them. Off, every requested page is invalidated and drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.

Parameters

  • enabled boolean — boolean
renderer.setShadowCaching(false)  -- draw every requested shadow page, every frame

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 light's pages, 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 sun's shadow settings. Any omitted field keeps its current value, so a call can adjust one knob without restating the rest. Values are clamped: distance >= 0, fadeFraction [0, 1], softness [0, 1]. A distance of 0 shadows out to the camera's far plane, and a positive one caps it, which is what a scene bounding its shadow cost states; the sun's clipmap holds the levels that reach it. punctualMipFloor (a whole number, 0 to 5) caps how fine a spot, area or point light's map renders; it is refused outside that range, and every punctual light's map is drawn again under a new floor. detailBias ([-8, 8] levels) trades shadow detail for pages: each level coarser asks a quarter as many. budgetMegabytes (>= 1) bounds the depth the pages hold. Each takes "default" to return to the device's bias or the quality tier's budget, and a change to either sizes the pool again, which draws every page again.

renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ distance = 240 })
renderer.setShadowConfig({ detailBias = 1, budgetMegabytes = 8 })
renderer.setShadowConfig({ detailBias = "default", budgetMegabytes = "default" })

modules/renderer/setShadowHero

setShadowHero(entity: string, padding: number?): ()

Give one caster a finer sun shadow, in a box around its world bounds.

The sun's virtual shadow map resolves every receiver at the level its screen footprint selects, so a character standing in the view gets the texel density every surface at its distance gets. A registered hero widens its bounds by padding into a box, and every receiver inside it requests and samples a level renderer.shadowHero().levelBias levels finer — each level halving the world a texel covers — where that level's window holds it, crossing back to its footprint's level at the box's edge. Nothing else about the shadow changes: the same casters reach it through the same filter.

Parameters

  • entity string — The entity whose renderables the box is made around.
  • padding number? (optional) — How much room the box leaves around those bounds — for a pose that leaves the bind-pose box and for the filter that samples outside a silhouette. 1.0 holds them exactly.
local before = time.drawnFrameCount()
renderer.setShadowHero(player.id)
local h = renderer.shadowHero()
while h.frame <= before do
task.waitDrawnFrames(1)
h = renderer.shadowHero()
end
print(("hero shadow: %d levels finer over %.1f units"):format(h.levelBias, h.worldExtent))

modules/renderer/setShadowProxy

setShadowProxy(mesh: string, proxy: string): ()

Rasterize proxy in place of mesh in every shadow page. 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 pages 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. 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 boolean — boolean
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 boolean — boolean
renderer.setSkinningPoseHold(false)  -- dispatch every pose, every frame

modules/renderer/setTextureBucketSlack

setTextureBucketSlack(bytes: number?): TextureBucketSlack

Bound the GPU memory texture-array buckets may hold beyond the textures in them. A bucket without room doubles while the total slack stays under this budget, and past it grows to the largest capacity whose slack the budget still holds, at least by what the arrival needs. A growth copies every layer the bucket held, so a large one that would gain few layers for what it copies opens another bucket of the same size instead. The default follows the device (a sixty-fourth of the system's RAM where the GPU draws from it, otherwise a sixteenth of the memory scale it reports; between 32 MiB and 512 MiB either way); nil restores it. bytes is a positive number of bytes, 1 or more — 0, a negative number or a non-finite one raises an error naming that range. A budget bounds the next growth; slack a bucket already holds stays until a compaction takes it. On the bindless backend no bucket exists, so the budget is recorded and reads back but bounds nothing. The textures themselves are bounded by renderer.setTextureBudget; read what the buckets hold with renderer.materialTable().

Parameters

  • bytes number? (optional) — number? — the slack budget in bytes, or nil for the device's
renderer.setTextureBucketSlack(16 * 1024 * 1024)
renderer.setTextureBucketSlack(nil) -- the device's budget

modules/renderer/setTextureBudget

setTextureBudget(opts: TextureBudgetOpts?): TextureBudget

Bound the VRAM textures occupy, by keeping only the mip levels the frame is actually sampling once the textures outgrow the budget. Every device starts at its own budget and a bias of 0. Where the GPU draws from the system's RAM (every Android phone, an integrated GPU, a software rasterizer) and the system states how much, the budget is an eighth of that RAM; otherwise it is half the memory scale the adapter reports; between 256 MiB and 2 GiB either way, and held to 768 MiB on a medium-tier and 384 MiB on a low-tier device (renderer.qualityTier()). renderer.textureStreaming() names the rule (budgetRule) and the RAM it read (deviceMemoryBytes), and memory pressure narrows what the budget keeps (renderer.memoryPressure()). Pass { megabytes = 256 } to override the budget for the process; megabytes is a positive number, 1 to 65536, and 0 or a negative number is an error. A field set to the string "default" returns that one value to the device's own, a field the table leaves out keeps the value it has, and a field holding anything else — a boolean, another string — is an error naming what it holds. nil restores the device's budget and bias together. The budget belongs to the device the world is played on.

Below the budget every texture keeps its whole mip chain, whatever its surfaces cover. Mips stream down only while the whole chains of the streamable textures exceed the budget. Then 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 look that wants a different trade than one texel per pixel. It is clamped to [-8, 8].

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, 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 on the device alone, so it holds no copy while it is at its whole chain; the first time the budget takes it coarser its chain is read back off the device before any level is freed, and it holds that copy (renderer.textureMemory().streamSourceBytes) while it stays coarser. Once a surface has worn it and gone it leaves the set whole and stays out for the rest of the process. 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.

Parameters

  • opts TextureBudgetOpts? (optional) — { megabytes: (number | "default")?, bias: (number | "default")? }? — nil for the device's budget and bias
renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 })  -- one level finer everywhere
renderer.setTextureBudget({ megabytes = "default" })  -- the device's budget, the bias as it stands
renderer.setTextureBudget()  -- back to the device's budget and bias

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 transmission draws into a light-space transmittance map instead, and the colour it lets through multiplies into the directional light reaching whatever stands behind it. Stacked casters compose. A surface whose material blends writes no depth shadow at all and throws its shadow through this map alone: a transparent or premultiplied blend lets 1 - alpha of the light through untouched and its transmission of the rest tinted by its base colour, so red stained glass throws red light; a multiply blend lets its base colour through; an additive one blocks nothing. Opaque casters are unaffected, and a scene with no translucent caster allocates nothing and records no pass.

Parameters

  • enabled boolean — boolean
renderer.setTransmissionShadows(true)  -- stained glass tints the floor

modules/renderer/setUpscaleBackend

setUpscaleBackend(backend: string, quality: string?): string

Request a backend for the upscale seam — the step that brings a scene rendered below display resolution back up to it. "blit" stretches; "dlss" reconstructs from motion and history through NGX DLSS Super Resolution on an adapter that has it. The renderer resolves the request against its own capability and reports the outcome through renderer.upscaleBackend() — a request for "dlss" on an adapter renderer.upscaleCapabilities() omits it from resolves to "blit" there, answered truthfully rather than echoed back here. quality names the reconstruction quality mode a reconstructing backend derives its render scale from; the blit keeps whatever renderer.setResolutionScale() set and reads no value from quality. @builtin::systems.temporalUpscale.temporalUpscale is the content-facing surface built on this primitive — it carries the policy this raw setter carries none of: refusing "dlss" where unavailable, refusing quality alongside an explicit render scale, and keeping its own reconstruction from stacking with a reconstructing backend.

Parameters

  • backend string — string — "blit" or "dlss".
  • quality string? (optional) — string? — "quality" | "balanced" | "performance" | "ultraPerformance", read only by a reconstructing backend.
renderer.setUpscaleBackend("dlss", "balanced")

modules/renderer/setViewportSize

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

Draw at this many pixels for the rest of the task that asks. The engine's drawing surface is resized to it, and everything measured against that surface follows within a frame: getViewportSize(), ui.screenSize(), the layout every UI screen rebuilds from it, each camera's aspect, and what a capture taken in that task encodes. This is how one session checks a responsive layout at a second shape: a HUD written against ui.screenSize() is re-laid-out at the new size, so an anchored element is drawn where that shape puts it rather than scaled from where the boot size put it.

The size is held for the task that asked for it (an execute call is one task), and when that task ends, however it ends, the surface goes back to the size it had before. So a check reads what it needs at the new shape inside the call that set it, and the window or browser canvas a person is looking at is handed back as it was. While several tasks hold a size the surface draws at the one asked for last, and when that task ends it draws at the size the task before it holds. Asked for outside any task, from a component's lifecycle, the size stands until another is asked for.

Who honours the size depends on who owns the surface. A headless engine and a browser canvas own theirs and are resized exactly; a browser page that lays its canvas out at a new size meanwhile ends every hold, and the page's size stands. Where an OS window owns it, the window manager is asked and has the last word: a tiled or maximized window keeps the size it has. Read renderer.surfaceSize() on a later frame for what was realized.

The new size is in force from the NEXT frame, so wait a frame before reading it back: renderer.surfaceSize() read straight after the call still reports the size the surface had.

The logical UI space is normalised to about 1280 points wide, so a resize to a size of the same aspect moves ui.pixelRatio() and leaves ui.screenSize() where it was, while a resize that changes the aspect changes that shape too. Both resize the surface.

A size larger than the device draws is refused naming the bound, which renderer.maxViewportExtent() reports.

Parameters

  • width number — number — width in pixels, at least 1.
  • height number — number — height in pixels, at least 1.
renderer.setViewportSize(390, 844); task.waitFrames(2); print(ui.screenSize()) -- the phone shape, then the surface goes back when the call ends

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. The store also keeps each resolve gate pass a surface shader's resolve module earned, so a launch does not validate the same resolve text again: verdictHits counts the verdicts answered out of it and verdictMisses the ones whose gate ran, apart from the compile counts above.

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. shader is the key the program is registered under; asset names the .shader asset a guid@checksum key is a version of.

local top = renderer.shaderCost()[1]; print(top.asset or 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 main camera's newest counted run did with the shadow pages it asked for, in pages, by the kind of light that owns them. A page is drawn again only when something it holds changed: its light moved, a caster over it moved or appeared or vanished, a caster's geometry or material changed, or the caster changed pose or moved the nodes its parts are placed by. Anything else keeps the depth already in the pool, so a scene that stops moving reads rendered 0 while its pages stay cached. A caster moving on one side of a point light dirties only the pages of the faces it reaches. A mesh whose vertices a compute pass writes — a population, or a mesh built from a compute buffer — re-renders the pages it stands over every frame. spot counts spot and area lights.

These are totals over every light of a kind. renderer.shadowViews() is the same run one light at a time.

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

modules/renderer/shadowCaching

shadowCaching(): boolean

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

modules/renderer/shadowCasterCutoff

shadowCasterCutoff(): ShadowCasterCutoff

How small, and how far away, a caster may get before it stops writing depth into any shadow page. A shadow page 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 draw per page it reaches 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 sun's shadow settings now in force. distance bounds how far from the camera the sun shadows (the world's render.shadow_distance until renderer.setShadowConfig sets it); fadeFraction and softness shape how the shadow is sampled. The sun's virtual shadow map follows each receiver's screen footprint, so its resolution follows the view. punctualMipFloor is the finest mip a spot, area or point light's map renders. detailBias and budgetMegabytes are what a caller stated, or "default"; what the renderer sized the pages with is renderer.shadowSizing(). The table is one renderer.setShadowConfig takes back.

print(renderer.shadowConfig().distance)

modules/renderer/shadowHero

shadowHero(): ShadowHeroReport

The registered hero caster and what the last drawn frame's box was. A frame that made no box says why in decline.

entity is the live registration; everything else is the box of the drawn frame frame names, and stands until the renderer draws another. So a reading taken straight after setShadowHero or clearShadowHero describes the registration before the call: read the drawn frame count as the change is made, and take the report once its frame is past it.

local h = renderer.shadowHero()
print(h.active, h.levelBias, h.decline)
local before = time.drawnFrameCount()
renderer.setShadowHero(player.id)
task.waitDrawnFrames(1)
print(renderer.shadowHero().frame > before)

modules/renderer/shadowMemory

shadowMemory(): {

How much GPU memory the virtual shadow map's pools hold right now, in bytes. One pool serves every light — the sun, and every spot, area and point light that casts. pool is the depth page pool with the sun's static cache laid out as it, held while any light casts; transmittance is the transmittance pool, held while a translucent caster casts into it (renderer.renderTargets() names them shadow_page_pool, shadow_static_cache and shadow_transmittance_pool). Both read 0 while released.

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

modules/renderer/shadowPageBreakdown

shadowPageBreakdown(): { { [string]: any } }

The breakdowns renderer.watchShadowPages gathered since the last call, oldest first, one per run of the main camera. levels has a row per clipmap level: texelWorld (world units per texel), the pages it requested, held (resident), drew (rendered) and had dirtied by the frame's caster changes (invalidated), the pages those changes reach in its window (reached) and the smallest size a change projects to there (smallestRecordTexels). records lists each caster change with its light-space sizeWorld and, per level, the texels it covers and the pages it reaches. cull is what the page cull walked (slotsWalked caster slots, ncMembers casters drawn from their own vertex buffers) and kept (pairs, casters tested, admitted and cutOff, chunks, visible clusters, ncPairs, batchPages). pages has a row per batch page: its level, the casters it drew and how many changed this frame (dynamicCasters), its chunks, clusters and triangles with the changed casters' shares, and its pairs of casters drawn from their own vertex buffers (ncPairs, dynamicNcPairs).

renderer.watchShadowPages(30)
task.wait(1)
for _, run in renderer.shadowPageBreakdown() do print(run.frame, #run.pages) end

modules/renderer/shadowPageStats

shadowPageStats(): {

What the sun's virtual shadow map did on the main camera's newest run the GPU counted. The sun's shadow lives in pages around the camera; a run marks the pages the receivers on screen ask for and draws the ones that are missing or that a moved caster dirtied. A still camera over a still scene runs nothing, so ran reads false and runs stands still while frames climbs. The counts come back from the GPU a few frames after the run, so frame names the frame they describe. proxyPairs counts the (caster, page) pairs a shadow proxy stood in for, proxyTriangles the triangles those pairs drew from the proxy and sourceTriangles what the casters' own meshes would have drawn there. castersDropped counts the casters drawn outside the cluster lists (a deforming mesh, a population whose mesh has no cluster set) that the newest frame could not list, which cast no shadow that frame.

local s = renderer.shadowPageStats()
if s then print(("%d pages asked for, %d drawn"):format(s.requested, s.rendered)) end

modules/renderer/shadowProxies

shadowProxies(): ShadowProxyReport

The shadow proxies in force and what the shadow pages did with them. triangles and sourceTriangles are what the main camera's newest counted run submitted through proxies and what the same (caster, page) pairs 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/shadowSizing

shadowSizing(): ShadowSizing

What the sun's virtual shadow map was sized with. Its pool of pages holds about twice the pages a frame at the render resolution asks for, within the depth budget (the quality tier's: 64, 12 and 8 MiB at high, medium and low, or the one renderer.setShadowConfig states). It is sized again only when the render resolution, the detail bias or the budget changes. A run that finds no slot for a page it asked for raises overflowBias by a level, up to 4, until the pool is sized again.

print(renderer.shadowSizing().depthMegabytes)

modules/renderer/shadowViews

shadowViews(): ShadowViewReport?

Every shadow-casting spot, area and point light's virtual shadow map on the main camera's newest counted run, one row each.

renderer.shadowCacheStats() and renderer.shadowPageStats() sum these counts over every light; this is the same run one light at a time. The sun's pages are reported by renderer.shadowPageStats().

Each row names its light and says what its pages did: how many its receivers asked for, how many it holds, how many were drawn, dirtied, starved or left for a later run, and what the page cull did with the casters it walked for the light. camera carries the main camera's share of the instanced-compaction counters.

The counts come back from the GPU a few frames after the run, and frame names the frame they belong to. A light that stops casting leaves views.

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

local report = renderer.shadowViews()
for _, view in report.views do
print(("%s %d (%s): %d of %d pages resident, %d drawn"):format(
view.role, view.index, view.light, view.pagesResident,
view.pagesRequested, view.pagesRendered))
end

modules/renderer/signalMemoryPressure

signalMemoryPressure(level: string): ()

Report memory pressure the way the system does: the engine releases what level releases on its next frame, and engine.onMemoryPressure subscribers hear it with source "script". Content about to need a lot of memory (a large scene load, a big decode) frees room ahead of it with this, and "normal" lifts a level a script put in force. level is "normal", "moderate", "low" or "critical"; anything else is an error naming the four. Read the outcome with renderer.memoryPressure() once a frame has passed.

Parameters

  • level string — "normal", "moderate", "low" or "critical"
renderer.signalMemoryPressure("moderate") -- free unsampled mips before a big load

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. A slice counts as filled once a render carrying its dispatch has been drawn: the frame's own view, or a render of the scene into a target, such as a camera drawing into its render texture. A frame whose view the renderer declines (a headless one nothing is consuming, a browser behind its GPU) and that renders the scene into no target records nothing, so a pose that arrives over such frames is counted in dispatches on each of them and reads as held from the frame after the first one that records it. A wait for a crowd to settle is therefore counted in drawn frames (task.waitDrawnFrames).

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.

The same pass draws a soft body's model through the body's elements (its cage). cages is how many soft bodies it draws that way, cageBytes what their buffers occupy, and cageUploads how many of them wrote their elements' maps to the GPU this frame: a soft body at rest writes nothing, and a removed one takes its cage with it.

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.read("/source/captures/ceramic.spz"))

modules/renderer/surfaceSize

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

The whole drawing surface in pixels — the window, the browser canvas or the headless framebuffer. This is the size renderer.setViewportSize sets and the size every UI screen is laid out over, so it is what says whether a resize was realized, on every layout including an editor one whose viewport panel holds a smaller rect than the window. { width = 0, height = 0 } before the first frame has drawn.

local s = renderer.surfaceSize()

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, and a deterministic capture states that key as its own owner option to be admitted under the hold and photograph the instant it pinned.

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 caller the call is attributed to, which names the calling session where the transport attested one and the ZeroMind account otherwise. 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 picture = 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) print("temporal clock", 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) — { colorSpace?, compression?, generateMipmaps?, maxDimension?, filter?, textureType?, alpha?, alphaIsTransparency? } — the opts renderer.texture.encodeFromImage takes — applied when the bytes are an encoded image and need the engine-native encode. maxDimension bounds ZTEX bytes too: a payload that stores a mip chain is read from the first level whose longest side is within it, that level alone, so a thumbnail of a 4096² texture decodes a level near the thumbnail's size.
local cpu = renderer.texture.cpuFromBytes(assert(vfs.read(path))); local png = cpu:encodePng(); cpu:unload()
local thumb = renderer.texture.cpuFromBytes(assert(vfs.read(texPath .. "/data.ztex")), { maxDimension = 256 })

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 and a screen target takes the scene's own format ("rgba16f") — a screen-sized target holds a picture of the scene, and that is the format the scene is held in: linear light with headroom above the display's white and no 256-step floor under a dark gradient. A target that is not screen-sized and names no format takes the surface's. "rgba8" is what a mask, a weight or an occlusion term asks for, where 0..1 is the whole range; hand one back over @scene.color and the frame loses every highlight above white and bands every gradient, and the engine says so in the log. 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 screen target a live Camera draws into is that camera's output instead: the frame's other render targets neither resize nor clear it, so what the camera drew there stays for as long as the camera does. A reading that has to be the viewport's own comes from screenSpace = "composite", or from a target created without screen. history = true makes a history target instead, the target an accumulation across frames lives in: it is screen-sized and keeps one pair of images per view a frame draws (the viewport, every render-to-texture camera, every capture). For the length of each view's render, the handle's guid names the image that render writes and previous.guid the image that view's previous render wrote, so a pass binds the two like any other targets and reads its own view's history wherever it runs; no other view resizes, clears or overwrites them. Before each render of a view the engine writes the compute buffer named by the handle's state (one vec4<f32>, bound through a pass's buffers): x is 1 when previous holds that view's previous image and 0 when it does not (the view's first render, its first after a cut or a change of size, or a render of the view that ran without the target, and then previous reads transparent black), y how many renders in a row have carried the history, zw the render's sub-pixel projection offset in pixels (renderer.setProjectionJitter). A view's images are freed when the view stops drawing, and all of them with the handle. 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" })
local hist = renderer.texture.create({ width = 1, height = 1, history = true, name = "accum" })

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, opts: { straightAlpha: boolean? }?): (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).

The pixels are the texels the payload stores. With opts.straightAlpha = true, a payload encoded with alpha = "premultiplied" has its colour divided back by alpha, in the colour space it was premultiplied in — the straight-alpha pixels renderer.texture.encode takes. A texel whose alpha is 0 keeps the black premultiplying left in it.

Parameters

  • bytes buffer | string — A ZTEX payload or source image bytes.
  • opts { straightAlpha: boolean? }? (optional) — { straightAlpha? }
local pixels, w, h = renderer.texture.decode(vfs.read("/source/tmp/shot.png"))
local straight = renderer.texture.decode(vfs.read(texPath .. "/data.ztex"), { straightAlpha = true })

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. Called from a task (an execute chunk, a component hook the engine lets wait, task.spawn), a "uastc" or "bc7" encode runs where the frame loop keeps stepping: on a worker where the build has one, and otherwise a bounded slice of its blocks per frame (__texture.encodeBudget, reported by __texture.encodeJobs). The call returns on the frame the payload lands. Code that cannot yield, and a coroutine the script resumes itself (coroutine.create / coroutine.wrap), encode in place, and every other compression is encoded on the call. The payload is the same bytes either way. opts.compression selects what the texels are stored as: "rgba8" (default, 8 bits/channel, rgba is widthheight4 bytes), "uastc" (UASTC 4x4 blocks, a quarter of the RGBA8 size, transcoded on each device to the block format it holds; rgba is widthheight4 bytes and each side is resampled to the nearest power of two, halfway rounding up), "bc7" (BC7 blocks, a quarter of the RGBA8 size; rgba is still widthheight4 bytes and both sides a multiple of 4) or the high-precision data compressions "rgba16" (16-bit unsigned normalized, widthheight8 bytes) / "rgba32f" (32-bit float, widthheight16 bytes) — for height/displacement fields, baked lightmaps, and other data rasters 8 bits quantize visibly. The two high-precision compressions store rgba verbatim, reject opts.generateMipmaps / opts.maxDimension, and take opts.colorSpace = "linear" only. opts.colorSpace is "srgb" (default, colour data) or "linear" (normal maps, masks, heights). opts.input ("rgba8" / "rgba16" / "rgba32f") names the texel format rgba arrives in when it is not the one opts.compression stores — the pixels renderer.texture.decode hands back at their own width, say — and the encoder carries each channel across as the same normalized value, natively, before storing it. opts.textureType records what the texture holds — "color" (default), "normalMap" (when the encode generates mips, every level of the chain, the base included, is renormalised to unit length), "mask" or "sprite". opts.alpha is what the encode does to alpha: "input" (default, kept as given), "premultiplied" (RGB multiplied by alpha — in linear light under srgb, as stored under linear — the source a material with the premultiplied blend mode composites) or "none" (every texel opaque, RGB kept). opts.alphaIsTransparency = true fills each fully transparent texel with the average colour of its non-transparent neighbours, spreading outward, alpha kept at 0 — before the alpha mode and the mips, so filtering never pulls black into an edge. All three are recorded in the payload and reported by renderer.texture.info. The sampler opts are recorded the same way: opts.filter ("linear", the default, or "nearest"), opts.wrapU / opts.wrapV ("repeat", the default, "clamp" or "mirror" — how the texture is sampled past its edges) and opts.anisotropy ("world", the default, following the world's render.anisotropy, or a fixed "1" / "2" / "4" / "8" / "16", also taken as the number). opts.effort ("fastest", the default, "fast" or "thorough") is how long a uastc encode searches for each block: every effort writes a payload the same size on the GPU, and a higher one lands closer to the source. opts.uastcRdo ("none", the default, "low", "medium" or "high") is how strongly a uastc encode trades accuracy for a payload that compresses smaller. A block-compressed encode (uastc, bc7) called from a task that can yield runs off the frame and the task waits for it, so the world keeps running while it cooks.

Parameters

  • rgba any (optional) — Pixel payload at opts.input's (else opts.compression's) native byte width — a buffer, a binary string, or a number array.
  • width number — number
  • height number — number
  • opts any (optional) — { colorSpace?, compression?, uastcRdo?, generateMipmaps?, maxDimension?, filter?, wrapU?, wrapV?, anisotropy?, textureType?, alpha?, alphaIsTransparency?, input? }

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. A "uastc" or "bc7" encode called from code that can yield runs where the frame loop keeps stepping, as renderer.texture.encode describes. opts.compression is "rgba8" (default), "uastc", "bc7", "rgba16" or "rgba32f", opts.colorSpace "srgb" (default) or "linear", and opts.uastcRdo as renderer.texture.encode takes it. "rgba8" with generateMipmaps = false, a maxDimension the image fits within (or none), alpha = "input" and no alphaIsTransparency stores the source image bytes verbatim. "rgba16" and "rgba32f" decode a still image at its own bit depth and store one linear level: a 16-bit PNG keeps every bit, an 8-bit channel v is stored as v * 257 (rgba16) or v / 255 (rgba32f). The frames of an animated image (GIF, APNG, animated WebP) are decoded at 8 bits, so a 16-bit APNG's frames are stored as v * 257 of their 8-bit values. Like encode, they reject opts.generateMipmaps and a maxDimension the image exceeds. opts.textureType records what the texture holds — "color" (default), "normalMap" (when the encode generates mips, every level of the chain, the base included, is renormalised to unit length), "mask" or "sprite". opts.alpha is what the encode does to alpha: "input" (default, kept as given), "premultiplied" (RGB multiplied by alpha — in linear light under srgb, as stored under linear — the source a material with the premultiplied blend mode composites) or "none" (every texel opaque, RGB kept). opts.alphaIsTransparency = true fills each fully transparent texel with the average colour of its non-transparent neighbours, spreading outward, alpha kept at 0 — before the alpha mode and the mips, so filtering never pulls black into an edge. All three are recorded in the payload and reported by renderer.texture.info, as are the sampler opts filter, wrapU, wrapV and anisotropy, which take the values renderer.texture.encode takes.

Parameters

  • bytes buffer | string — source image bytes.
  • opts any (optional) — { colorSpace?, compression?, uastcRdo?, generateMipmaps?, maxDimension?, filter?, wrapU?, wrapV?, anisotropy?, textureType?, alpha?, alphaIsTransparency? }

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, its compression ("rgba8", "bc7", "rgba16" or "rgba32f" — what the texels are stored as) and colorSpace ("srgb" or "linear" — how they are read when sampled), dimensions, mip count, the sampler recorded in the payload from the asset's settings — filter ("nearest" or "linear"), wrapU and wrapV ("repeat", "clamp" or "mirror") and anisotropy ("world", or a fixed "1" / "2" / "4" / "8" / "16"); a payload written before wrap and anisotropy were recorded reads repeat / repeat / world — what the payload was encoded as — textureType ("color", "normalMap", "mask" or "sprite"), alpha ("input", "premultiplied" or "none") and alphaIsTransparency (boolean); a payload written before these were recorded reads color / input / false — 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.isCpuResident

texture.isCpuResident(texture: string | { [string]: any } | AssetRef): boolean

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

Parameters

  • texture string | { [string]: any } | AssetRef — The texture — a TextureHandle, a TextureCpuHandle, a guid, or a texture AssetRef.
if renderer.texture.isCpuResident(handle) then ... 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.keepCpuOnLanding

texture.keepCpuOnLanding(texture: string | { [string]: any } | AssetRef, keep: boolean): boolean

Set whether the decode on its way to this texture — scheduled by textureRef:handle() or renderer.texture.createFromAsset and not yet on the device — keeps its decoded pixels in the CPU store once it lands. A texture's keepCpu setting changing while its decode is in flight reaches the texture through this.

Parameters

  • texture string | { [string]: any } | AssetRef — The texture — a TextureHandle, a TextureCpuHandle, a guid, or a texture AssetRef.
  • keep boolean — Keep the CPU copy once the decode lands.
renderer.texture.keepCpuOnLanding(texRef, true)

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, its texel format and the mip levels the device holds (mipLevels), 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, "device" when no asset answers for it and its whole chain on the device is the only copy, "retained" when it is resident coarser and holds the levels read back off the device. A resident entry carries how a material binding it samples it, as the texture records it: filter ("linear" / "nearest"), wrapU and wrapV ("repeat" / "clamp" / "mirror") and anisotropy ("world" or a fixed "1".."16"), and samplerAnisotropy, the level the texture's sampler takes: anisotropy resolved against renderer.anisotropy() and the device's ceiling, and 1 for a nearest texture — the entry of the renderer's sampler table the texture's handle names. 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.reencode

texture.reencode(ztex: buffer | string, opts: any): (string?, string?)

Re-encode an engine-native ZTEX payload against new opts. A "uastc" or "bc7" re-encode called from code that can yield runs where the frame loop keeps stepping, as renderer.texture.encode describes. opts is the complete encode spec, exactly as for renderer.texture.encode: every field it leaves out takes its default, so textureType, alpha, alphaIsTransparency, filter, wrapU, wrapV and anisotropy come from opts — not from the payload's header — and a payload re-encoded with { compression = "uastc" } alone loses a nearest filter or a premultiplied alpha it carried. renderer.texture.withSampler changes how a payload is sampled without re-encoding it. The payload's layers and their display times are kept, whatever compression either side is in. Each layer's texels are read back with straight alpha (a payload encoded with alpha = "premultiplied" has its colour divided back by alpha), carried to the width the new compression stores as the same normalized value, and encoded exactly as renderer.texture.encode would encode them; a payload that embeds its source image is re-encoded from that image, read at its own bit depth. What an earlier encode discarded stays discarded: after alpha = "none" the alpha is opaque. The texture assetType re-encodes a container that holds no source image through this call when its settings change. A block-compressed encode called from a task that can yield runs off the frame while the task waits.

Parameters

  • ztex buffer | string — A ZTEX payload — a buffer or a binary string.
  • opts any (optional) — { colorSpace?, compression?, uastcRdo?, generateMipmaps?, maxDimension?, filter?, wrapU?, wrapV?, anisotropy?, textureType?, alpha?, alphaIsTransparency? }
local wide = renderer.texture.reencode(vfs.read(texPath .. "/data.ztex"), { compression = "rgba16", colorSpace = "linear", generateMipmaps = false })

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.unloadCpu

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

Drop this texture's resident CPU copy from the guid-keyed CPU store. The explicit release for a texture asset's keepCpu setting turning off.

Parameters

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

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 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. src is raw pixels, or a TextureCpuHandle (texRef:load(), renderer.texture.loadCpu, renderer.texture.readback), whose pixels arrive with everything they carry: texel format, colour space, mip chain, layers and filter. Every material, UI image and post-process effect sampling the guid draws the new texture. 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 — or a TextureCpuHandle.

modules/renderer/texture.withSampler

texture.withSampler(ztex: buffer | string, opts: { filter: string?, wrapU: string?, wrapV: string?, anisotropy: (string | number)? }): (string?, string?)

Change how an engine-native ZTEX payload is sampled without re-encoding it. opts names any of filter ("linear" / "nearest"), wrapU and wrapV ("repeat" / "clamp" / "mirror") and anisotropy ("world", or "1" / "2" / "4" / "8" / "16", also taken as the number); a field it omits keeps the payload's value. The result is the same bytes with only the sampler bits of the header replaced, so every level is byte-identical to the input — the cheap path the texture assetType takes when a settings change touches only these four fields. An unrecognised value raises.

Parameters

  • ztex buffer | string — A ZTEX payload — a buffer or a binary string.
  • opts { filter: string?, wrapU: string?, wrapV: string?, anisotropy: (string | number)? } — { filter?, wrapU?, wrapV?, anisotropy? }
local clamped = renderer.texture.withSampler(vfs.read(texPath .. "/data.ztex"), { wrapU = "clamp", wrapV = "clamp" })

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 compression = "uastc" appears in the compressed columns at a quarter of what the same image costs as RGBA8. blockCompressionSupported is whether this adapter samples any block-compressed format; where it is false a block-compressed payload is uploaded decoded and lands in the uncompressed columns instead, so the texture is present everywhere and compressed where the hardware allows it. blockFormat names the format block-compressed textures are resident in on this adapter ("bc7" on desktop, "astc4x4" on a phone, "etc2" on a phone without ASTC) and is nil where it samples none. A texture stored as UASTC is transcoded to blockFormat; one stored as BC7 keeps its blocks where the adapter samples BC7 and is re-encoded into blockFormat where it does not, each at the same one byte per texel. Measured at the end of the last rendered frame. streamableTextures is how many of them the 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; the rest no asset answers for, so the device holds their only copy. Such a texture holds no copy of its own while it is at its whole mip chain; the first time the budget takes it coarser its chain is read back off the device before any level is freed, and it holds that copy while it stays coarser. retainedTextures counts the ones holding a copy, and streamSourceBytes is what those copies occupy in system memory — bytes that are not VRAM. pinnedTextures counts the textures big enough to stream that stand at a level nothing can move: no asset answers for them and they left the streamable set, 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. byFormat names every GPU format a cached texture is held in, keyed by the format's name (Bc7RgbaUnormSrgb, Astc { block: B4x4, .. }, Rgba8UnormSrgb, ...), with how many textures it holds and their bytes. heldAboveCap lists the textures resident larger than renderer.setMaxTextureSize allows: one whose mip chain has no level within the cap, and one with no asset behind it, which keeps every level it was made with. decodedFallback lists the UASTC textures this device holds as texels because it samples no block format.

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 budget in force: the device's own, or the one setTextureBudget names, as much of it as the memory pressure in force leaves (half at "critical"). deviceBudgetBytes is the device's own budget (what setTextureBudget names deviceMegabytes): memoryBudgetBytes, held to the quality tier's cap where the tier states one. memoryBudgetBytes is what the device's memory affords and budgetRule the rule it came from: "systemMemory", an eighth of the RAM the system states (deviceMemoryBytes, nil where it states none), on a GPU that draws from that RAM, or "bufferSize", half the adapter's max_buffer_size; between 256 MiB and 2 GiB either way. pressure is the memory pressure level in force; from "moderate" up the plan holds every texture at the level its surfaces sample, below the budget as well as past it (renderer.memoryPressure()). 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 asks for before the sweep — every texture's whole chain while those fit the budget, and the level each surface asked for once they exceed it — 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.

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 frame the renderer drew spent re-describing its renderables to the GPU, counted from the frame it drew before that, so a read after task.waitDrawnFrames describes the frame waited for even where the frame pacer declines frames between two drawn ones. 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. A renderer.mesh.drawInstanced population's slots are filled by copies from its own buffers, counted the same way: a frame that copies a population's transforms or shader data counts one write per copy and the bytes it moved. bytes is what those uploads and copies 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/upscaleBackend

upscaleBackend(): string

The backend actually filling the upscale seam this frame — "blit" or "dlss". Reports what the renderer resolved the last renderer.setUpscaleBackend() request into, which reads "blit" after a request for "dlss" on an adapter renderer.upscaleCapabilities() omits it from.

local backend = renderer.upscaleBackend()

modules/renderer/upscaleCapabilities

upscaleCapabilities(): { string }

The backend names this engine can actually run for the upscale seam, from a boot-time probe of the adapter. "blit" always answers, since the stretch needs no adapter capability; "dlss" joins it only where NGX DLSS Super Resolution is available. Read this before renderer.setUpscaleBackend("dlss", ...) to know ahead of the request what it resolves to.

local caps = renderer.upscaleCapabilities()

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)

modules/renderer/viewportSize

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

The rect the scene was drawn into in the frame just drawn, in pixels. That is the whole drawing surface in a runtime window, a browser canvas and a headless engine, and the editor's viewport panel — smaller than the surface — in an editor layout. Follows a renderer.setViewportSize, a window drag and a browser page resize alike, so it reports what is realized rather than what was asked for.

local v = renderer.viewportSize()

modules/renderer/watchShadowPages

watchShadowPages(runs: number)

Gather a breakdown of what the sun's virtual shadow map does on each of the main camera's next runs runs. Each run's counts come back from the GPU a few frames after it, and renderer.shadowPageBreakdown() hands them over. Nothing is gathered while no reader has asked. 0 stops a gathering under way.

Parameters

  • runs number — How many of the main camera's runs to gather.
renderer.watchShadowPages(120)

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 every receiver samples the level its footprint selects again.

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 pages 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/clusterErrorPx

renderer.clusterErrorPx(px: number?) -> number

The cluster cut's error threshold, in pixels. Every static mesh draws the coarsest clusters whose simplification error projects to at most this many pixels in the view drawing it — measured against that view's own pixel height and projection, so a small render target draws coarser geometry than the full-screen view does from the same place. Lower draws finer geometry at more cost; higher draws coarser geometry. A client graphics setting. Default 1.

Parameters

  • px number (optional) — A finite number above 0. Omit to read the threshold.

Returns number — The threshold in effect.

renderer.clusterErrorPx(0.5) -- finer geometry on this machine

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. A runtime mesh needs no call: once the garbage collector has taken its last handle, the engine judges it against the same holders, again whenever what displays it changes or its hold is released, and every few hundred frames while something holds it, and releases it once nothing does. Returns once what it released has left the device: the GPU objects of the textures and meshes it let go are freed over the frames after the call, and it waits for those frames (up to 60 drawn frames), so renderer.texture.list, renderer.mesh.list, renderer.material.list, renderer.textureMemory() and renderer.gpuMemory() read after it in the same script no longer count anything it reports released. landed says whether every release left the device within that wait; false names a texture or mesh something put back on the device before it could be seen gone, which renderer.references explains. Code that cannot yield (a metamethod, onDestroy, a module's top level while require runs it) is answered at once, without the wait: there landed is false while a release is still on its way, and the listings and totals stop counting it over the drawn frames that follow.

Returns RuntimeCollection — { released = { texture, material, mesh, feature }, kept, entries, landed }: the counts released per kind, how many stayed, every resource's status with action = "released" | "kept", and whether every release had left the device when the call returned.

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

typed/builtin//modules/api/engine/renderer/renderer/compiledShaders

renderer.compiledShaders() -> { string }

Every key renderer.compiledSource answers for — one per shader key (guid@checksum) or variant program a compile has run under, whether it succeeded or failed, for as long as the shader that compiled under it stands: a removed .shader takes its keys with it. What makes the composed-source surface enumerable rather than something to guess a key for.

Returns { string } — An array of keys, 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 for a shader key (guid@checksum), a program from renderer.shaderVariants(), or any reference to a shader asset (identity, path, guid), which reaches the key of the version that reference resolves to now; 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.

Parameters

  • shader string — A shader key, a shaderVariants() program name, or a reference to a shader asset.

Returns string? — The composed WGSL, or nil when no compile has run for it or its shader was removed.

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 visibility raster, 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 the shadow page cull did with the frame's casters for each light is on that light's row in renderer.shadowViews().

views is the engine cull's own count, one row per camera view that rendered: view is "main" for the main camera and the camera's entity id for an offscreen one, kind says which, and frame is the frame the counts describe. The main view's row is first. drawn is whether a render drew the view in that frame: a frame the renderer declined still ran the cull's first test, so its row carries that test's counts and no second one, for a picture nothing rasterized. The counts are read back from the GPU and handed out oldest first, one frame per render, so a script reading once a frame reads the frames in the order they ran. A frame's counts wait at most two renders to be handed out: a row describes a frame as many frames back as the read took and at most two more, and when more frames' counts come back at once than that lets wait, the oldest of them are the frames a script does not read. instancesTested renderables were tested; instancesCulled breaks the rejections down by reason (hidden, layer, range, frustum, screenSize, and occludedPass1/occludedPass2 for occlusion against the depth of the frame that last drew the view and then this frame's); instancesRecovered came back in the second test. Then the clusters of the survivors: clustersTested, rejected by the level-of-detail cut (clustersLodRejected), the frustum (clustersFrustumCulled), facing away (clustersConeCulled) or occlusion (clustersOccludedPass1, of which clustersRecovered came back), and clustersDrawn / trianglesDrawn. binsPending counts material pipelines still compiling, whose clusters wait a frame; overflow counts clusters that did not fit the view's lists. total/culled/visible and occlusionTested/occlusionCulled above are the main view's count for the renderables drawn outside the cluster lists.

Returns { total: number, culled: number, visible: number, occlusionTested: number, occlusionCulled: number, views: { { [string]: any } } }

local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")
for _, v in renderer.cullStats().views do if v.view == "main" then print(v.clustersDrawn, "clusters,", v.instancesCulled.frustum, "outside the frustum") end end

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 the platform has refused every quick attempt 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 every quick attempt at another device was refused (a browser whose GPU process crashed twice answers no adapter for minutes): nothing is drawn, the engine log names the platform's last answer, and the engine keeps asking at a slower pace, so the state returns to "ready" on its own once the platform hands a device over. Restarting the engine (reloading the page on the web) builds one from scratch.

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 group's textures (group 1), and materialBindsElided how many times a pass reached that decision and found the group already bound. On the bindless backend every material group binds the one texture group of the frame, so a pass binds it once; on buckets a pass binds once per group it draws (see renderer.materialTable().groups). 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. materialExtraBinds and materialExtraBindsElided are the same pair for group 2, the storage buffers a shader declares, bound per distinct buffer set, which only the shaders that declare 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. renderer.shadowViews() carries the main camera's own share of compacted and compactedDrawn.

clusters and clusterTriangles are the clusters and triangles the camera views drew from the cluster lists, from the GPU's own count.

drawsByPass splits draws by the part of the frame that submitted them — visibility (the id raster: one draw per raster bin per Hi-Z phase in each camera view, submitted whether or not the GPU cull left anything in the bin), resolve (one draw per resolve draw in each camera view: one per shader, and on the array-bucket backend one per shader and material group), legacy (geometry drawn outside cluster storage: skinned, morphed and compute-written meshes, line topology, shaders that write their own vertex stage), blended, exception (opaque materials whose depth state the visibility buffer cannot express), featureGeometry, featureDraw, shadow and splat (one draw per splat buffer a render feature draws) — and the nine sum to draws.

instances counts instances named by draws whose size the CPU decided; the visibility raster's work is clusters and clusterTriangles, which the GPU decided. pixelsResolved is how many pixels the resolve shaded, summed over camera views, and pixelsByShader the same split by shader. clusters, clusterTriangles, pixelsResolved and pixelsByShader come back from the GPU and describe the frame numbered frame, which is earlier than the frame draws describes. frame is the renderer's own frame number, counted from 0 when the renderer was built (again after a device loss), a count of its own that compares only with another drawStats().frame.

Returns { draws: number, instances: number, compacted: number, compactedDrawn: number, materialBinds: number, materialBindsElided: number, materialExtraBinds: number, materialExtraBindsElided: number, pipelineBinds: number, pipelineBindsElided: number, clusters: number, clusterTriangles: number, drawsByPass: { visibility: number, resolve: number, legacy: number, blended: number, exception: number, featureGeometry: number, featureDraw: number, shadow: number, splat: number }, pixelsResolved: number, pixelsByShader: { [string]: number }, frame: 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 group binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")
local d = renderer.drawStats(); print(d.clusters, "clusters,", d.clusterTriangles, "triangles from the cluster lists")
local d = renderer.drawStats(); print(d.drawsByPass.visibility, "id-raster draws,", d.drawsByPass.resolve, "resolve draws")
local d = renderer.drawStats(); for shader, px in d.pixelsByShader do print(shader, px) end

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 the pacer has given the bound up rather than hold the image indefinitely, on either mechanism: the counting one stands down when the completion signal goes quiet across a whole run of declined frames, and the waiting one when a wait on a frame reaches its ceiling with the GPU still holding it. Both clear on the first word the GPU sends after that. A signal that is merely late — a GPU finishing frames more slowly than the loop asks for them — keeps the bound, so stalled answers for a GPU that went silent. standDowns counts those over the session, which is how a session that lost the signal and got it back (stalled = false with standDowns above zero) reads apart from one that never lost it.

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. A desktop presents mailbox unless told otherwise, and Android presents fifo, which shows each frame on a vsync of the panel and makes no frame the panel never shows.

The rest describe the rate frames run at. displayRefreshHz is the refresh rate of the display the frames are shown on, where the platform reads it: the phone's panel, the monitor a desktop window is on, the cadence of a browser's animation frames. A headless engine presents to no display and states none. frameRateCap is the cap renderer.setFrameRateCap set, targetFps the rate that cap delivers and refreshesPerFrame the refreshes each frame stays on screen under it, all three absent while no cap is set. deliveredFps is the rate the frame loop has run frames at over about the last twenty frames.

Returns FramePacing? — { framesInFlight, maxFramesInFlight, pacedFrames, submittedFrames, waitMs, mechanism, stalled, standDowns, producing, idleSkippedFrames, presentMode, presentModes, displayRefreshHz?, frameRateCap?, refreshesPerFrame?, targetFps?, deliveredFps? }, 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. textures is what the resident textures occupy, and on the texture-array backend also the slack its shared arrays hold beyond them (renderer.materialTable().bucketSlackBytes).
  • allocator is the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. A render target's colour texture is reported under what holds it now: the target drawing into it, or released_scratch[<width>x<height>].color while it waits for the next target of its size. It exceeds categorised by the per-frame render targets and the scratch nothing categorises, and on the texture-array backend, for the frame after an array grows or is repacked, by the array it replaced. 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. arenas states the part of it the pages runs stand in hold, and what those pages' bytes are spent on. 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. textureBudgetBytes is the texture budget in force and pressure the memory pressure level that shapes it: under "critical" the budget reads half the device's, and renderer.memoryPressure() says what the newest report released.

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/lighting

renderer.lighting() -> boolean

Whether the scene renders lit.

Returns boolean — true while lighting is on.

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/markBaseline

renderer.markBaseline() -> number

Record which render features are live and how the feature-texture array is configured, and make that the point renderer.resetToBaseline returns to.

A caller running work in an engine it shares — a sweep of test suites, a tool driving a session — leaves the renderer holding whatever that work set up: a feature a module created on first use, an array reconfigured to the size one pass wanted. This is how such a caller says where its own changes start. mark above the marks before it.

Returns number — The mark's generation, counting from 1 — what renderer.resetToBaseline takes to restore to this mark.

local mark = renderer.markBaseline() -- then run, then renderer.resetToBaseline(mark)

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. Those three count what this material drew itself: a batch drawing several materials of one group adds to each material the draws and instances that wore it.

group is the material group the material draws in — the unit a pass binds a material's textures (group 1) and its shader's storage buffers (group 2) by — or nil while the material resolves no group, which leaves its draws nothing to bind. binds is how many group-1 and group-2 binds the frame's passes issued for draws of this material's shader in that group, and bindsElided how many times such a draw found the group already bound, which is what draw-key sorting buys. Those binds belong to the group, so every material of one group reports the same pair; a material with no group reports 0 for both, and a draw that fell back to the placeholder counts in placeholderDraws and in neither bind count.

uniformBytes is the size of the property block in the material's row of the material table: the shader's declared properties, packed at the offsets the shader reads them from and padded to a whole 16 bytes. The row also carries a 64-byte header and a 4-byte handle per texture slot, padded to a whole 16 bytes. renderer.materialTable().rowBytes is the space the table reserves for all rows — its row capacity, free rows included — plus its directories. renderer.drawDiagnostics() names WHICH renderable is not drawing what its material says, and why. material is the key the renderer holds the material under; asset names the .material asset a guid@checksum key is a version of.

Returns { MaterialObservation }

for _, m in renderer.materialCost() do print(m.asset or 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 while the renderer holds the material, so two renderables that differ only in material read different indices, and one renderable reads the same index frame after frame. A destroyed material's name leaves the table once nothing draws with it, and the next material takes its index, so the table holds a row per name the renderer holds a material under or something draws with. 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 zero_instance(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 a name the renderer holds no index for: one it has not drawn with yet, or a destroyed material nothing draws with any more. Pass it to a shader (or compare it against what a shader read out of zero_instance(slot).material_index) to tell which material a drawing instance carries.

Parameters

  • name string — string Material name, as renderer.material.create filed it.

Returns number?

local red = renderer.materialIndex("brick_red")

typed/builtin//modules/api/engine/renderer/renderer/materialTable

renderer.materialTable() -> MaterialTable

How this device resolves material textures, and what that storage costs. backend is "bindless" (a descriptor array indexed by each material's texture handles) or "buckets" (exact-size texture arrays, one per size, format and mip count, bound per material group). Bindless is taken on a Vulkan adapter that has texture binding arrays with non-uniform indexing and partially bound arrays, and room for descriptor arrays of at least 4096 textures; every other device — the browser build, Android, Metal, DirectX 12, and Vulkan without those — takes buckets.

materials is how many materials have a row, rowBytes the space the table reserves in the scene buffer (its row capacity, free rows included, plus the material and texture directories), textures how many distinct textures the rows name, descriptorCapacity the length of each bindless array (0 on buckets), and groups how many material groups the materials form. A group is the unit a pass binds a material's textures (group 1) and a shader's own storage buffers (group 2) by, and every material of a group draws under the same binds. On bindless every group binds the one texture group of the frame, so groups differ only by the set of storage buffers they bind. On buckets a group also names, per texture slot, the bucket its members' textures live in and the sampler those textures sample through: materials of one shader split into one group per distinct combination of (bucket and sampler per texture slot, storage buffers), and each group is its own draw.

buckets lists every texture array: its format, size, mip count, the layers textures occupy, its capacity, what it costs, the slack it holds beyond its live layers, whether it is frozen (a layer's view is held by something outside the material system — a UI image, a post-process input — or a copy out of a layer is taken this frame, such as a readback, so the array neither grows nor compacts), which shard of its key it is, and whether it is external (a one-layer bucket over a texture the GPU writes — a render target, a video frame, a compute output — which its producer owns). bucketBytes is what the shared buckets occupy together, and bucketSlackBytes the slack they hold together, which renderer.setTextureBucketSlack bounds; an external bucket counts in neither. slackBudgetBytes is the budget the buckets bound growth by. On bindless buckets is empty, both totals are 0, and slackBudgetBytes is the budget recorded, which bounds nothing.

Returns MaterialTable

local t = renderer.materialTable(); print(t.backend, t.groups, "groups over", t.materials, "materials")
for _, b in renderer.materialTable().buckets do print(b.format, b.width, b.height, b.layers, "/", b.capacity) end

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/maxViewportExtent

renderer.maxViewportExtent() -> number

The largest number of pixels this device draws a surface at, per axis — the bound renderer.setViewportSize refuses past. 0 before the first frame has drawn.

Returns number — pixels per axis.

local bound = renderer.maxViewportExtent()

typed/builtin//modules/api/engine/renderer/renderer/memoryPressure

renderer.memoryPressure() -> MemoryPressureReport

What memory pressure is doing to the engine. level is the level in force: "normal", "moderate", "low" or "critical". A report puts its level in force, whatever stood before, for holdSeconds after the last report, and then the level returns to normal. Reports come from the system (on Android, onTrimMemory: RUNNING_MODERATE is moderate, RUNNING_LOW and UI_HIDDEN low, RUNNING_CRITICAL, BACKGROUND, MODERATE and COMPLETE critical; Android 14 and later report only UI_HIDDEN, when the app leaves the screen, and BACKGROUND) and from renderer.signalMemoryPressure. signals counts the reports taken; lastLevel, lastSource and lastAgeSeconds describe the newest (absent before the first).

Each report releases at once what its level releases, and released says what the newest one freed: the textures taken to the level the frame samples and the textureBytes they gave up, and the render targets whose storage was freed. Moderate frees the mip levels the frame does not sample; low also frees render-target storage the last frame did not draw into; critical also halves the texture budget. While a level stands the residency plan keeps the textures there, and textureBudgetBytes is the budget the level leaves in force. engine.onMemoryPressure(cb) fires with each report once its release is done.

Returns MemoryPressureReport — { level, holdSeconds, signals, lastLevel?, lastSource?, lastAgeSeconds?, textureBudgetBytes, released }

local mp = renderer.memoryPressure()
print(mp.level, mp.released.textureBytes / 1048576, "MiB freed")

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, and deltaBufferBytes the size of that buffer, which the vertex stage binds whole.

deltaBudgetBytes is the most that buffer may hold on this device: the largest storage buffer the device binds at once (128 MiB on many mobile browsers). A block costs 32 bytes per vertex per target, over every vertex of the mesh. A mesh whose block would take the resident blocks past the budget is refused by name in the engine log and draws in its base shape, and refusedMeshes counts the meshes refused that way; the refusal holds until the mesh is uploaded again. 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, deltaBufferBytes: number, deltaBudgetBytes: number, refusedMeshes: 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/pendingWork

renderer.pendingWork() -> PendingWork?

What the renderer still owes the picture — the work it has taken on and has not yet put in the frame.

A frame draws what the renderer holds at the moment it runs, and several of the things it holds arrive over more than one frame. A pipeline is built off the frame thread and bound the frame its result is taken in; a texture is uploaded under a per-frame time budget; a released texture's GPU object is freed on the frame the release queue is drained; a streamable texture climbs toward the level its screen size asks for one level at a time. pipelineBuilds, textureUploads, textureReleases and textureResidency are those four. textureTransfers counts the textures the texture cache is still moving over several frames: a large upload written a band at a time, a promotion waiting on its finer levels to be read or decoded, a demotion waiting on its levels to be read back. Each lands as a new GPU texture on a frame of its own, and the frames between two landings replace nothing, so textureResidency alone reads zero on them. outstanding is the sum of every count.

settled is true when outstanding is zero, and says the renderer owes this picture nothing: the frames after this one draw the same picture unless something changes the scene. That is the renderer's own word for what a test measuring a picture holds for before it reads one.

epoch counts the times the renderer has gone from owing nothing to owing something since the process started. It is what a pair of readings needs: a run of work that begins AND ends between two samples leaves both of them reading settled and leaves epoch higher, so settled at both ends with the same epoch at both ends is the statement that nothing arrived across the whole span, rather than at its two endpoints alone. Test.settleReading is built on exactly that pair of conditions.

frame names the drawn frame the reading was published on. Every count is published from the frame thread on every frame the renderer runs, so a headless engine declining frames answers as readily as a windowed one. deviceRebuild is one while the engine is between render devices: the picture is owed the device itself, every frame in that window draws nothing, and what content puts back on the new device lands on the frames after it. computeCommands counts the compute commands a drain held over: a dispatch waiting on the program it names and every command over the buffers it would write queued behind it, each of which runs or drops on a later drain. clusterSets counts the meshes whose cluster set is still being built, or is owed again after a device rebuild: each lands as the set the mesh draws, and the geometry a cut of it draws changes on the frame it does. materialRows counts the materials the frame does not yet draw as they ask: one whose raster or resolve pipeline is still building draws nothing until it lands, one whose shader's resolve gate has not reached a verdict resolves through the error shader until it does, and one whose routing changed since the frame last published it is still drawn by the previous routing. Each changes the picture on the frame it lands. skippedDraws counts the renderables the last drawn frame left undrawn because a pipeline or a material group they draw with was not built yet; each appears on the frame what it waits on lands. shaderCompiles counts the shader compiles running off the frame thread that no frame has landed: each lands as the program its keys name, and the materials drawing with those keys change on the frame it does.

Returns PendingWork? — { settled, outstanding, pipelineBuilds, textureUploads, textureReleases, textureTransfers, textureResidency, deviceRebuild, computeCommands, clusterSets, materialRows, skippedDraws, shaderCompiles, epoch, frame }, or nil on a build with no renderer attached

if renderer.pendingWork().settled then print("the picture is what it is going to be") end
local w = renderer.pendingWork(); print(w.outstanding, "arrivals still owed, over", w.epoch, "runs of work")

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.

A pipeline a material, the sky, a depth view, a post-process pass or a compute program a script registers needs after boot is built off the frame thread — the shader module behind that compute program with it — on buildWorkers worker threads on native, behind the browser's asynchronous entry point on the web, where buildWorkers reads 0. So a burst of new materials costs a frame nothing beyond drawing the placeholder until each pipeline lands, and a burst of effects costs it nothing beyond each effect starting on the frame its pipeline does. offFrameBuilt and offFrameBuildMs are the pipelines and the milliseconds that never held a frame; pipelinesBuilt - offFrameBuilt is what the frame thread built, which is the renderer's own — the set it stands up before its first frame, and the few it stands up on the frame a pass first asks for them. buildsSubmitted, buildsFinished, buildsFailed and buildsInstalled count build requests (shader modules and pipelines) made, completed, refused and taken back by a frame. buildsAbandoned counts the requests a renderer still held when a device rebuild let it go, which no frame will take back, and buildsPending is what the renderer drawing now has yet to take back: asked for and not yet in use, a finished result included until a frame installs it. So every request made is installed, abandoned or pending, and a reader waiting on buildsPending to reach zero reads pipelinesBuilt with every build it waited for counted, and can tell a queue draining from one that is stuck. modulesBuilt and moduleBuildMs are the shader modules compiled off the frame thread, parse and validation included.

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, offFrameBuilt, offFrameBuildMs, buildsSubmitted, buildsFinished, buildsFailed, buildsInstalled, buildsAbandoned, buildsPending, modulesBuilt, moduleBuildMs, buildWorkers, 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/presentedFrames

renderer.presentedFrames(opts: { scope: string?, frames: number?, interval: number?, velocity: boolean? }?) -> { PresentedFrame }

Parameters

  • opts { scope: string?, frames: number?, interval: number?, velocity: boolean? } (optional)

Returns { PresentedFrame }

typed/builtin//modules/api/engine/renderer/renderer/projectionJitter

renderer.projectionJitter() -> { { number } }

The offsets renderer.setProjectionJitter stated, each {x, y} in pixels from the pixel centre.

Returns { { number } } — The offsets; empty while every render samples pixel centres.

local offsets = renderer.projectionJitter()

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/qualityTier

renderer.qualityTier() -> QualityTier

The device's quality tier and the renderer defaults it chose. The engine assigns every device one of "low", "medium" and "high" at boot, from its GPU family and limits, its memory and its display, so a world authored on a desktop plays on a phone at the phone's defaults without knowing it runs on one. A desktop, and any device whose GPU is outside the handheld families, is "high": the renderer as a desktop runs it, one scene pixel per display pixel. A phone renders the scene below a dense display's resolution with UI and text at full resolution, and spends less on shadows, textures and filtering the lower its tier. A lower tier chosen on a desktop renders the scene within that tier's pixel budget the same way, so renderer.setQualityTier alone is a whole graphics setting on every platform.

Every default the tier states stands wherever the world's settings name no value of their own (render.anisotropy), and the matching renderer call overrides any one of them: renderer.setResolutionScale, renderer.setTextureBudget, renderer.setAnisotropy. renderer.setQualityTier chooses another tier altogether.

Returns QualityTier: { tier, deviceTier, overridden, reason, tiers, device, defaults }

local q = renderer.qualityTier()
print(q.tier, q.reason, q.defaults.resolutionScale)

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 vec3 — vec3 ray start in world space
  • direction vec3 — vec3 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 entity(cameraId).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 vec3 — vec3 ray start in world space
  • direction vec3 — vec3 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. skyStale is whether the sky changed after that capture, so the slot still depicts the sky from before the change until the engine's recapture, or an explicit environment.captureSky(), retakes it. The engine retakes a sky swapped for another material or turned off on the first frame the new sky can be drawn, and a sky whose look moved once a pacing window has passed since the last capture. skyPosition is where the capture was taken from, absent before one. skyCaptures counts the sky captures that have landed since the renderer was built, so one retaken from the same position reads as new, and skyMaterial names the material the sky pass draws from, absent when no sky is drawn. 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, the slot a camera's stated environment is held in, and 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, skyStale, skyPosition?, skyCaptures, skyMaterial?, 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(). A runtime mesh no script reaches a handle to is judged again once its hold goes, and released with no collection when nothing else holds it.

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/releaseBaseline

renderer.releaseBaseline(generation: number) -> boolean

Drop the mark generation names, and every mark taken after it, once the caller that took it is done resetting to it. It touches the marks alone.

Parameters

  • generation number — The mark to drop, as renderer.markBaseline returned it.

Returns boolean — True when the mark stood.

renderer.releaseBaseline(mark) -- the run is over

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 scene's occlusion and reflection channels and its lit colour history (scene_occlusion, scene_reflection, scene_color_history) are resident at the scene's extent while a render-feature pass writes or reads them, and shadow_page_pool while a light casts into the virtual shadow map, with shadow_transmittance_pool beside it while a translucent caster casts into it. 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, reflection and colour-history channels, the scene copies and the G-buffer over them under a camera's scene render. A draw builds what it needs, or takes the scratch a released target of the same size left. A target releases its set to the next target of its size when no live camera names it and when it is destroyed or resized, and frees it once sixty frames pass without a draw, so a target nothing draws into carries no such row. Released scratch waits as released_scratch[<width>x<height>].* rows for the next target of that size, held newest first within 64 MiB and freed once sixty frames pass without a target taking it. The colour image drawn into belongs to the texture cache while its target stands; a destroyed or resized target's colour texture waits on the same terms as a released_scratch[<width>x<height>].color row for the next target made at that size and format, which starts from it cleared to transparent black. totalBytes is what the resident targets hold together. allocations counts the render-target allocations the renderer has made since it was built, the colour textures of the live targets included, each on the first frame whose report holds it: a target taken back at a size it held before adds nothing, so the difference between two readings is what the frames between them allocated. Measured at the end of the last rendered frame.

Returns { targets, totalBytes, residentCount, allocations }

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/resetToBaseline

renderer.resetToBaseline(generation: number?) -> number

Parameters

  • generation number (optional)

Returns number

typed/builtin//modules/api/engine/renderer/renderer/resolutionScale

renderer.resolutionScale() -> number

The fraction of the display resolution the scene is currently rendered at. Until something sets it, the scale the device's quality tier chose for its display (renderer.qualityTier().defaults.resolutionScale): 1 on a desktop at the high tier, below 1 on a dense handheld display and on a desktop at a lower tier.

Returns number

local s = renderer.resolutionScale()

typed/builtin//modules/api/engine/renderer/renderer/resolutionScaleMode

renderer.resolutionScaleMode() -> string

Where the render scale resolves: "present" or "reconstruct". "present" until something sets it.

Returns string

local m = renderer.resolutionScaleMode()

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 boolean — boolean

Returns ()

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

typed/builtin//modules/api/engine/renderer/renderer/setFrameRateCap

renderer.setFrameRateCap(fps: number?) -> number?

Hold the engine to at most fps frames per second, or lift the cap with 0 or nil. The whole frame waits for its turn: scripts, physics, audio mixing and networking run once a frame as the drawing does, so a game capped at 30 runs at 30, and a phone spends no battery on frames past it.

Frames come due on an even grid. Where the display's refresh rate is known (a phone's panel, a desktop window's monitor, a browser's animation frames), the grid is a whole number of refreshes, the fewest whose rate stays inside the cap, so every frame stays on screen for the same number of vsyncs: on a 120 Hz display a cap of 30 shows each frame for 4 refreshes and 60 for 2, and a cap the refresh rate does not divide evenly delivers the next rate below it (50 on a 120 Hz display delivers 40). On Android the system compositor is told the rate as well, so a panel with more than one refresh rate can run at one the cap divides; with no cap it is told the panel's highest rate.

Read renderer.framePacing() for the effect: targetFps is the rate the cap delivers, refreshesPerFrame the refreshes each frame holds, displayRefreshHz the display it was worked out against, and deliveredFps the rate frames actually ran at. Takes effect on the next frame. frame_rate_cap under [render] in the world's .world_settings seeds it (0 for no cap), so a world states its cap once and every session opens under it; a call here stays in force until that value changes.

Parameters

  • fps number (optional) — number? Frames per second, 1 through 1000; 0 or nil lifts the cap.

Returns number? — The cap in force, or nil when frames are not capped.

renderer.setFrameRateCap(30) -- a steady 30 on any display 30 divides
print(renderer.framePacing().targetFps, "fps delivered under the cap")
renderer.setFrameRateCap(nil) -- every frame the display shows

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/setLighting

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

Turn the scene's lighting on or off. Off, every lit surface renders as its own colour with no light on it, emissive included: the editor's lighting toggle, for reading a scene's materials without its lights. With no light applied there is no shadow either, so the shadow maps draw nothing while it is off, and the shadow switches apply again once lighting is back on. The sky and post-processing stay as they are.

Parameters

  • enabled boolean — true renders the scene lit, false unlit.

Returns ()

renderer.setLighting(false); print(renderer.lighting())

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/setMaxTextureSize

renderer.setMaxTextureSize(px: number?) -> { px: number, devicePx: number }

The longest side a texture loaded from an asset may be resident at on this client, in texels. Such a texture larger than the cap is served from the first level of its mip chain that fits, so a 4096² texture under a 1024 cap holds its 1024² level and the levels below it, and the texture budget never takes it finer than that level. A changed cap moves the resident textures over the next frames; a raised one reads their finer levels back from their assets. A texture made from pixels with no asset behind it keeps every level it was made with, because the device holds its only copy. The device's own maximum 2D texture dimension bounds the cap from above. Call with no argument to lift the cap to the device's limit.

Parameters

  • px number (optional) — number? — texels, a whole number of at least 1; nil for the device's own limit

Returns { px: number, devicePx: number } — { px, devicePx } — the cap in force and the device's limit

renderer.setMaxTextureSize(2048)
renderer.setMaxTextureSize()  -- the device's own limit

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 number — number — 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 depth the visibility raster lays 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.

Parameters

  • enabled boolean — boolean

Returns ()

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

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/setProjectionJitter

renderer.setProjectionJitter(offsets: { { number } })

State where inside the pixel consecutive renders of a view sample, for the frames until it is stated again: a list of {x, y} offsets in pixels from the pixel centre, each within half a pixel. Every view the renderer draws (the viewport, each render-to-texture camera, each capture) walks the list by its own count of renders in a row, so a camera drawn beside the viewport takes nothing out of the viewport's sequence: a view's first render, and its first after a cut, samples the pixel centre, and its next renders take the offsets in order, wrapping. Each offset is applied in the view's own pixels. Velocity (@scene.motion) is measured against the offset-free projection, and a history target's state buffer carries the offset each render took. renderer.setProjectionOffset, while it is not (0, 0), and an upscale backend's own jitter both stand over the list on the viewport. An empty list samples pixel centres. An offset outside half a pixel is refused, naming it, and leaves the list in force.

Parameters

  • offsets { { number } } — The offsets, each {x, y} in pixels from the pixel centre.
renderer.setProjectionJitter({ { 0.25, -0.25 }, { -0.25, 0.25 } })

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/setQualityTier

renderer.setQualityTier(tier: string?) -> QualityTier

Render at another quality tier than the one the device was assigned: "low", "medium" or "high", or nil / "default" for the device's own. A change of tier stands the new tier's defaults at once wherever the world's settings state none, replacing what an earlier renderer call set for the render scale, the shadow budgets and the anisotropy; choosing the tier already in force changes no setting. A call made after it, in the same script or later, overrides that one default again. A budget renderer.setTextureBudget names stands over the tier's texture cap until it is returned to the device's own. The choice holds for the process, across scene loads and restarts of the scene, until it is set again. A settings screen offering the player a quality choice is this call.

Parameters

  • tier string (optional) — string?: "low", "medium", "high", or nil / "default" for the device's own.

Returns QualityTier: the reading renderer.qualityTier() answers, for the tier now in force.

renderer.setQualityTier("low")
renderer.setQualityTier()  -- back to the device's own

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 boolean — boolean

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. 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. What the smaller raster MEANS for everything composed on top of it — the post chain, the UI, every post-scene pass — is renderer.setResolutionScaleMode. The scene rows in profiler.gpuFrame() are what move.

Parameters

  • scale number — number — 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/setResolutionScaleMode

renderer.setResolutionScaleMode(mode: string) -> string

Where in the frame the render scale is resolved — what a smaller raster MEANS. "present" (the default) composes the whole frame at the raster: the scene, every post-process layer, the UI and every post-scene pass, carried up to the size it is shown once it is finished. A smaller raster is then a smaller picture, so an effect whose output is a function of pixel size — a dither ladder, a posterize, a pixel grid — resolves at the pitch it was authored at, and the frame carries one composite resolution throughout. "reconstruct" carries the scene up to the size it is shown BEFORE the post-scene phases and composes everything from there, which is what a temporal upsampler needs: it refines the upscale it is handed and has nowhere to put the detail it recovers unless the image it accumulates into is the display's. Either reading is honoured identically whether a viewport panel or the swapchain shows the frame, so what an editor draws and what a shipped runtime draws are the same picture of the same scene.

Parameters

  • mode string — string — "present" or "reconstruct".

Returns string — the mode in force.

renderer.setResolutionScaleMode("present")

typed/builtin//modules/api/engine/renderer/renderer/setShadowCaching

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

Whether a shadow page that nothing changed is kept rather than drawn again. On by default: every light's pages — the sun's, and each spot, area and point light's — are drawn again only where a caster that changed reaches them. Off, every requested page is invalidated and drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.

Parameters

  • enabled boolean — boolean

Returns ()

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

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 light's pages, 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: {
    distance: number?,
    fadeFraction: number?,
    softness: number?,
    punctualMipFloor: number?,
    detailBias: (number | string)?,
    budgetMegabytes: (number | string)?,
}) -> ShadowConfig

Set the sun's shadow settings. Any omitted field keeps its current value, so a call can adjust one knob without restating the rest. Values are clamped: distance >= 0, fadeFraction [0, 1], softness [0, 1]. A distance of 0 shadows out to the camera's far plane, and a positive one caps it, which is what a scene bounding its shadow cost states; the sun's clipmap holds the levels that reach it. punctualMipFloor (a whole number, 0 to 5) caps how fine a spot, area or point light's map renders; it is refused outside that range, and every punctual light's map is drawn again under a new floor. detailBias ([-8, 8] levels) trades shadow detail for pages: each level coarser asks a quarter as many. budgetMegabytes (>= 1) bounds the depth the pages hold. Each takes "default" to return to the device's bias or the quality tier's budget, and a change to either sizes the pool again, which draws every page again.

Parameters

  • cfg { distance: number?, fadeFraction: number?, softness: number?, punctualMipFloor: number?, detailBias: (number | string)?, budgetMegabytes: (number | string)?, } — The fields to change — see ShadowConfig.

Returns ShadowConfig — The full config now in force.

renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ distance = 240 })
renderer.setShadowConfig({ detailBias = 1, budgetMegabytes = 8 })
renderer.setShadowConfig({ detailBias = "default", budgetMegabytes = "default" })

typed/builtin//modules/api/engine/renderer/renderer/setShadowHero

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

Give one caster a finer sun shadow, in a box around its world bounds.

The sun's virtual shadow map resolves every receiver at the level its screen footprint selects, so a character standing in the view gets the texel density every surface at its distance gets. A registered hero widens its bounds by padding into a box, and every receiver inside it requests and samples a level renderer.shadowHero().levelBias levels finer — each level halving the world a texel covers — where that level's window holds it, crossing back to its footprint's level at the box's edge. Nothing else about the shadow changes: the same casters reach it through the same filter.

Parameters

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

Returns ()

local before = time.drawnFrameCount()
renderer.setShadowHero(player.id)
local h = renderer.shadowHero()
while h.frame <= before do
task.waitDrawnFrames(1)
h = renderer.shadowHero()
end
print(("hero shadow: %d levels finer over %.1f units"):format(h.levelBias, h.worldExtent))

typed/builtin//modules/api/engine/renderer/renderer/setShadowProxy

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

Rasterize proxy in place of mesh in every shadow page. 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 pages 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. 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 boolean — boolean

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 boolean — boolean

Returns ()

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

typed/builtin//modules/api/engine/renderer/renderer/setTextureBucketSlack

renderer.setTextureBucketSlack(bytes: number?) -> TextureBucketSlack

Bound the GPU memory texture-array buckets may hold beyond the textures in them. A bucket without room doubles while the total slack stays under this budget, and past it grows to the largest capacity whose slack the budget still holds, at least by what the arrival needs. A growth copies every layer the bucket held, so a large one that would gain few layers for what it copies opens another bucket of the same size instead. The default follows the device (a sixty-fourth of the system's RAM where the GPU draws from it, otherwise a sixteenth of the memory scale it reports; between 32 MiB and 512 MiB either way); nil restores it. bytes is a positive number of bytes, 1 or more — 0, a negative number or a non-finite one raises an error naming that range. A budget bounds the next growth; slack a bucket already holds stays until a compaction takes it. On the bindless backend no bucket exists, so the budget is recorded and reads back but bounds nothing. The textures themselves are bounded by renderer.setTextureBudget; read what the buckets hold with renderer.materialTable().

Parameters

  • bytes number (optional) — number? — the slack budget in bytes, or nil for the device's

Returns TextureBucketSlack — { slackBudgetBytes, deviceSlackBudgetBytes } — the budget now in force, and the device's own

renderer.setTextureBucketSlack(16 * 1024 * 1024)
renderer.setTextureBucketSlack(nil) -- the device's budget

typed/builtin//modules/api/engine/renderer/renderer/setTextureBudget

renderer.setTextureBudget(opts: TextureBudgetOpts?) -> TextureBudget

Bound the VRAM textures occupy, by keeping only the mip levels the frame is actually sampling once the textures outgrow the budget. Every device starts at its own budget and a bias of 0. Where the GPU draws from the system's RAM (every Android phone, an integrated GPU, a software rasterizer) and the system states how much, the budget is an eighth of that RAM; otherwise it is half the memory scale the adapter reports; between 256 MiB and 2 GiB either way, and held to 768 MiB on a medium-tier and 384 MiB on a low-tier device (renderer.qualityTier()). renderer.textureStreaming() names the rule (budgetRule) and the RAM it read (deviceMemoryBytes), and memory pressure narrows what the budget keeps (renderer.memoryPressure()). Pass { megabytes = 256 } to override the budget for the process; megabytes is a positive number, 1 to 65536, and 0 or a negative number is an error. A field set to the string "default" returns that one value to the device's own, a field the table leaves out keeps the value it has, and a field holding anything else — a boolean, another string — is an error naming what it holds. nil restores the device's budget and bias together. The budget belongs to the device the world is played on.

Below the budget every texture keeps its whole mip chain, whatever its surfaces cover. Mips stream down only while the whole chains of the streamable textures exceed the budget. Then 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 look that wants a different trade than one texel per pixel. It is clamped to [-8, 8].

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, 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 on the device alone, so it holds no copy while it is at its whole chain; the first time the budget takes it coarser its chain is read back off the device before any level is freed, and it holds that copy (renderer.textureMemory().streamSourceBytes) while it stays coarser. Once a surface has worn it and gone it leaves the set whole and stays out for the rest of the process. 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.

Parameters

  • opts TextureBudgetOpts (optional) — { megabytes: (number | "default")?, bias: (number | "default")? }? — nil for the device's budget and bias

Returns TextureBudget — { megabytes, bias, deviceMegabytes, deviceBias } — the budget and bias now in force, and the device's own

renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 })  -- one level finer everywhere
renderer.setTextureBudget({ megabytes = "default" })  -- the device's budget, the bias as it stands
renderer.setTextureBudget()  -- back to the device's budget and bias

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 transmission draws into a light-space transmittance map instead, and the colour it lets through multiplies into the directional light reaching whatever stands behind it. Stacked casters compose. A surface whose material blends writes no depth shadow at all and throws its shadow through this map alone: a transparent or premultiplied blend lets 1 - alpha of the light through untouched and its transmission of the rest tinted by its base colour, so red stained glass throws red light; a multiply blend lets its base colour through; an additive one blocks nothing. Opaque casters are unaffected, and a scene with no translucent caster allocates nothing and records no pass.

Parameters

  • enabled boolean — boolean

Returns ()

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

typed/builtin//modules/api/engine/renderer/renderer/setUpscaleBackend

renderer.setUpscaleBackend(backend: string, quality: string?) -> string

Request a backend for the upscale seam — the step that brings a scene rendered below display resolution back up to it. "blit" stretches; "dlss" reconstructs from motion and history through NGX DLSS Super Resolution on an adapter that has it. The renderer resolves the request against its own capability and reports the outcome through renderer.upscaleBackend() — a request for "dlss" on an adapter renderer.upscaleCapabilities() omits it from resolves to "blit" there, answered truthfully rather than echoed back here. quality names the reconstruction quality mode a reconstructing backend derives its render scale from; the blit keeps whatever renderer.setResolutionScale() set and reads no value from quality. @builtin::systems.temporalUpscale.temporalUpscale is the content-facing surface built on this primitive — it carries the policy this raw setter carries none of: refusing "dlss" where unavailable, refusing quality alongside an explicit render scale, and keeping its own reconstruction from stacking with a reconstructing backend.

Parameters

  • backend string — string — "blit" or "dlss".
  • quality string (optional) — string? — "quality" | "balanced" | "performance" | "ultraPerformance", read only by a reconstructing backend.

Returns string — the backend now requested, echoed back before the renderer resolves it against this adapter's capability.

renderer.setUpscaleBackend("dlss", "balanced")

typed/builtin//modules/api/engine/renderer/renderer/setViewportSize

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

Draw at this many pixels for the rest of the task that asks. The engine's drawing surface is resized to it, and everything measured against that surface follows within a frame: getViewportSize(), ui.screenSize(), the layout every UI screen rebuilds from it, each camera's aspect, and what a capture taken in that task encodes. This is how one session checks a responsive layout at a second shape: a HUD written against ui.screenSize() is re-laid-out at the new size, so an anchored element is drawn where that shape puts it rather than scaled from where the boot size put it.

The size is held for the task that asked for it (an execute call is one task), and when that task ends, however it ends, the surface goes back to the size it had before. So a check reads what it needs at the new shape inside the call that set it, and the window or browser canvas a person is looking at is handed back as it was. While several tasks hold a size the surface draws at the one asked for last, and when that task ends it draws at the size the task before it holds. Asked for outside any task, from a component's lifecycle, the size stands until another is asked for.

Who honours the size depends on who owns the surface. A headless engine and a browser canvas own theirs and are resized exactly; a browser page that lays its canvas out at a new size meanwhile ends every hold, and the page's size stands. Where an OS window owns it, the window manager is asked and has the last word: a tiled or maximized window keeps the size it has. Read renderer.surfaceSize() on a later frame for what was realized.

The new size is in force from the NEXT frame, so wait a frame before reading it back: renderer.surfaceSize() read straight after the call still reports the size the surface had.

The logical UI space is normalised to about 1280 points wide, so a resize to a size of the same aspect moves ui.pixelRatio() and leaves ui.screenSize() where it was, while a resize that changes the aspect changes that shape too. Both resize the surface.

A size larger than the device draws is refused naming the bound, which renderer.maxViewportExtent() reports.

Parameters

  • width number — number — width in pixels, at least 1.
  • height number — number — height in pixels, at least 1.

Returns { width: number, height: number } — the size asked for, in whole pixels.

renderer.setViewportSize(390, 844); task.waitFrames(2); print(ui.screenSize()) -- the phone shape, then the surface goes back when the call ends

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. The store also keeps each resolve gate pass a surface shader's resolve module earned, so a launch does not validate the same resolve text again: verdictHits counts the verdicts answered out of it and verdictMisses the ones whose gate ran, apart from the compile counts above.

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, verdictHits, verdictMisses, 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. shader is the key the program is registered under; asset names the .shader asset a guid@checksum key is a version of.

Returns { ShaderCost }

local top = renderer.shaderCost()[1]; print(top.asset or 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 main camera's newest counted run did with the shadow pages it asked for, in pages, by the kind of light that owns them. A page is drawn again only when something it holds changed: its light moved, a caster over it moved or appeared or vanished, a caster's geometry or material changed, or the caster changed pose or moved the nodes its parts are placed by. Anything else keeps the depth already in the pool, so a scene that stops moving reads rendered 0 while its pages stay cached. A caster moving on one side of a point light dirties only the pages of the faces it reaches. A mesh whose vertices a compute pass writes — a population, or a mesh built from a compute buffer — re-renders the pages it stands over every frame. spot counts spot and area lights.

These are totals over every light of a kind. renderer.shadowViews() is the same run one light at a time.

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

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

typed/builtin//modules/api/engine/renderer/renderer/shadowCaching

renderer.shadowCaching() -> boolean

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

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 page. A shadow page 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 draw per page it reaches 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 sun's shadow settings now in force. distance bounds how far from the camera the sun shadows (the world's render.shadow_distance until renderer.setShadowConfig sets it); fadeFraction and softness shape how the shadow is sampled. The sun's virtual shadow map follows each receiver's screen footprint, so its resolution follows the view. punctualMipFloor is the finest mip a spot, area or point light's map renders. detailBias and budgetMegabytes are what a caller stated, or "default"; what the renderer sized the pages with is renderer.shadowSizing(). The table is one renderer.setShadowConfig takes back.

Returns ShadowConfig — The full config — see ShadowConfig.

print(renderer.shadowConfig().distance)

typed/builtin//modules/api/engine/renderer/renderer/shadowHero

renderer.shadowHero() -> ShadowHeroReport

The registered hero caster and what the last drawn frame's box was. A frame that made no box says why in decline.

entity is the live registration; everything else is the box of the drawn frame frame names, and stands until the renderer draws another. So a reading taken straight after setShadowHero or clearShadowHero describes the registration before the call: read the drawn frame count as the change is made, and take the report once its frame is past it.

Returns ShadowHeroReport — See ShadowHeroReport.

local h = renderer.shadowHero()
print(h.active, h.levelBias, h.decline)
local before = time.drawnFrameCount()
renderer.setShadowHero(player.id)
task.waitDrawnFrames(1)
print(renderer.shadowHero().frame > before)

typed/builtin//modules/api/engine/renderer/renderer/shadowMemory

renderer.shadowMemory() -> {

How much GPU memory the virtual shadow map's pools hold right now, in bytes. One pool serves every light — the sun, and every spot, area and point light that casts. pool is the depth page pool with the sun's static cache laid out as it, held while any light casts; transmittance is the transmittance pool, held while a translucent caster casts into it (renderer.renderTargets() names them shadow_page_pool, shadow_static_cache and shadow_transmittance_pool). Both read 0 while released.

Returns { pool, transmittance, total } in bytes

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

typed/builtin//modules/api/engine/renderer/renderer/shadowPageBreakdown

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

The breakdowns renderer.watchShadowPages gathered since the last call, oldest first, one per run of the main camera. levels has a row per clipmap level: texelWorld (world units per texel), the pages it requested, held (resident), drew (rendered) and had dirtied by the frame's caster changes (invalidated), the pages those changes reach in its window (reached) and the smallest size a change projects to there (smallestRecordTexels). records lists each caster change with its light-space sizeWorld and, per level, the texels it covers and the pages it reaches. cull is what the page cull walked (slotsWalked caster slots, ncMembers casters drawn from their own vertex buffers) and kept (pairs, casters tested, admitted and cutOff, chunks, visible clusters, ncPairs, batchPages). pages has a row per batch page: its level, the casters it drew and how many changed this frame (dynamicCasters), its chunks, clusters and triangles with the changed casters' shares, and its pairs of casters drawn from their own vertex buffers (ncPairs, dynamicNcPairs).

Returns { { [string]: any } } — An array of breakdowns, empty when none was read since the last call.

renderer.watchShadowPages(30)
task.wait(1)
for _, run in renderer.shadowPageBreakdown() do print(run.frame, #run.pages) end

typed/builtin//modules/api/engine/renderer/renderer/shadowPageStats

renderer.shadowPageStats() -> {

What the sun's virtual shadow map did on the main camera's newest run the GPU counted. The sun's shadow lives in pages around the camera; a run marks the pages the receivers on screen ask for and draws the ones that are missing or that a moved caster dirtied. A still camera over a still scene runs nothing, so ran reads false and runs stands still while frames climbs. The counts come back from the GPU a few frames after the run, so frame names the frame they describe. proxyPairs counts the (caster, page) pairs a shadow proxy stood in for, proxyTriangles the triangles those pairs drew from the proxy and sourceTriangles what the casters' own meshes would have drawn there. castersDropped counts the casters drawn outside the cluster lists (a deforming mesh, a population whose mesh has no cluster set) that the newest frame could not list, which cast no shadow that frame.

Returns { frame, ran, runs, frames, requested, rendered, pending, starved, resident, clusters, batchPages, proxyPairs, proxyTriangles, sourceTriangles, invalidated, invalidateOverflow, castersDropped }, or nil before the first run is read back

local s = renderer.shadowPageStats()
if s then print(("%d pages asked for, %d drawn"):format(s.requested, s.rendered)) end

typed/builtin//modules/api/engine/renderer/renderer/shadowProxies

renderer.shadowProxies() -> ShadowProxyReport

The shadow proxies in force and what the shadow pages did with them. triangles and sourceTriangles are what the main camera's newest counted run submitted through proxies and what the same (caster, page) pairs 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/shadowSizing

renderer.shadowSizing() -> ShadowSizing

What the sun's virtual shadow map was sized with. Its pool of pages holds about twice the pages a frame at the render resolution asks for, within the depth budget (the quality tier's: 64, 12 and 8 MiB at high, medium and low, or the one renderer.setShadowConfig states). It is sized again only when the render resolution, the detail bias or the budget changes. A run that finds no slot for a page it asked for raises overflowBias by a level, up to 4, until the pool is sized again.

Returns ShadowSizing — See ShadowSizing.

print(renderer.shadowSizing().depthMegabytes)

typed/builtin//modules/api/engine/renderer/renderer/shadowViews

renderer.shadowViews() -> ShadowViewReport?

Every shadow-casting spot, area and point light's virtual shadow map on the main camera's newest counted run, one row each.

renderer.shadowCacheStats() and renderer.shadowPageStats() sum these counts over every light; this is the same run one light at a time. The sun's pages are reported by renderer.shadowPageStats().

Each row names its light and says what its pages did: how many its receivers asked for, how many it holds, how many were drawn, dirtied, starved or left for a later run, and what the page cull did with the casters it walked for the light. camera carries the main camera's share of the instanced-compaction counters.

The counts come back from the GPU a few frames after the run, and frame names the frame they belong to. A light that stops casting leaves views.

The frame names its lights only while something is reading them, so this call asks the frames after it to publish 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()
for _, view in report.views do
print(("%s %d (%s): %d of %d pages resident, %d drawn"):format(
view.role, view.index, view.light, view.pagesResident,
view.pagesRequested, view.pagesRendered))
end

typed/builtin//modules/api/engine/renderer/renderer/signalMemoryPressure

renderer.signalMemoryPressure(level: string) -> ()

Report memory pressure the way the system does: the engine releases what level releases on its next frame, and engine.onMemoryPressure subscribers hear it with source "script". Content about to need a lot of memory (a large scene load, a big decode) frees room ahead of it with this, and "normal" lifts a level a script put in force. level is "normal", "moderate", "low" or "critical"; anything else is an error naming the four. Read the outcome with renderer.memoryPressure() once a frame has passed.

Parameters

  • level string — "normal", "moderate", "low" or "critical"

Returns ()

renderer.signalMemoryPressure("moderate") -- free unsampled mips before a big load

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. A slice counts as filled once a render carrying its dispatch has been drawn: the frame's own view, or a render of the scene into a target, such as a camera drawing into its render texture. A frame whose view the renderer declines (a headless one nothing is consuming, a browser behind its GPU) and that renders the scene into no target records nothing, so a pose that arrives over such frames is counted in dispatches on each of them and reads as held from the frame after the first one that records it. A wait for a crowd to settle is therefore counted in drawn frames (task.waitDrawnFrames).

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.

The same pass draws a soft body's model through the body's elements (its cage). cages is how many soft bodies it draws that way, cageBytes what their buffers occupy, and cageUploads how many of them wrote their elements' maps to the GPU this frame: a soft body at rest writes nothing, and a removed one takes its cage with it.

Returns { instances: number, poses: number, dispatches: number, held: number, reusedSlices: number, liveBytes: number, unsharedBytes: number, poolBytes: number, cages: number, cageBytes: number, cageUploads: 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/surfaceSize

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

The whole drawing surface in pixels — the window, the browser canvas or the headless framebuffer. This is the size renderer.setViewportSize sets and the size every UI screen is laid out over, so it is what says whether a resize was realized, on every layout including an editor one whose viewport panel holds a smaller rect than the window. { width = 0, height = 0 } before the first frame has drawn.

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

local s = renderer.surfaceSize()

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 compression = "uastc" appears in the compressed columns at a quarter of what the same image costs as RGBA8. blockCompressionSupported is whether this adapter samples any block-compressed format; where it is false a block-compressed payload is uploaded decoded and lands in the uncompressed columns instead, so the texture is present everywhere and compressed where the hardware allows it. blockFormat names the format block-compressed textures are resident in on this adapter ("bc7" on desktop, "astc4x4" on a phone, "etc2" on a phone without ASTC) and is nil where it samples none. A texture stored as UASTC is transcoded to blockFormat; one stored as BC7 keeps its blocks where the adapter samples BC7 and is re-encoded into blockFormat where it does not, each at the same one byte per texel. Measured at the end of the last rendered frame. streamableTextures is how many of them the 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; the rest no asset answers for, so the device holds their only copy. Such a texture holds no copy of its own while it is at its whole mip chain; the first time the budget takes it coarser its chain is read back off the device before any level is freed, and it holds that copy while it stays coarser. retainedTextures counts the ones holding a copy, and streamSourceBytes is what those copies occupy in system memory — bytes that are not VRAM. pinnedTextures counts the textures big enough to stream that stand at a level nothing can move: no asset answers for them and they left the streamable set, 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. byFormat names every GPU format a cached texture is held in, keyed by the format's name (Bc7RgbaUnormSrgb, Astc { block: B4x4, .. }, Rgba8UnormSrgb, ...), with how many textures it holds and their bytes. heldAboveCap lists the textures resident larger than renderer.setMaxTextureSize allows: one whose mip chain has no level within the cap, and one with no asset behind it, which keeps every level it was made with. decodedFallback lists the UASTC textures this device holds as texels because it samples no block format.

Returns { blockCompressionSupported, blockFormat?, compressedTextures, compressedBytes, uncompressedTextures, uncompressedBytes, streamableTextures, assetStreamedTextures, retainedTextures, pinnedTextures, streamSourceBytes, byFormat, heldAboveCap, decodedFallback }

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 budget in force: the device's own, or the one setTextureBudget names, as much of it as the memory pressure in force leaves (half at "critical"). deviceBudgetBytes is the device's own budget (what setTextureBudget names deviceMegabytes): memoryBudgetBytes, held to the quality tier's cap where the tier states one. memoryBudgetBytes is what the device's memory affords and budgetRule the rule it came from: "systemMemory", an eighth of the RAM the system states (deviceMemoryBytes, nil where it states none), on a GPU that draws from that RAM, or "bufferSize", half the adapter's max_buffer_size; between 256 MiB and 2 GiB either way. pressure is the memory pressure level in force; from "moderate" up the plan holds every texture at the level its surfaces sample, below the budget as well as past it (renderer.memoryPressure()). 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 asks for before the sweep — every texture's whole chain while those fit the budget, and the level each surface asked for once they exceed it — 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.

Returns TextureStreaming — { budgetBytes, deviceBudgetBytes, memoryBudgetBytes, budgetRule, deviceMemoryBytes?, pressure, 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 frame the renderer drew spent re-describing its renderables to the GPU, counted from the frame it drew before that, so a read after task.waitDrawnFrames describes the frame waited for even where the frame pacer declines frames between two drawn ones. 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. A renderer.mesh.drawInstanced population's slots are filled by copies from its own buffers, counted the same way: a frame that copies a population's transforms or shader data counts one write per copy and the bytes it moved. bytes is what those uploads and copies 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/upscaleBackend

renderer.upscaleBackend() -> string

The backend actually filling the upscale seam this frame — "blit" or "dlss". Reports what the renderer resolved the last renderer.setUpscaleBackend() request into, which reads "blit" after a request for "dlss" on an adapter renderer.upscaleCapabilities() omits it from.

Returns string — "blit" or "dlss".

local backend = renderer.upscaleBackend()

typed/builtin//modules/api/engine/renderer/renderer/upscaleCapabilities

renderer.upscaleCapabilities() -> { string }

The backend names this engine can actually run for the upscale seam, from a boot-time probe of the adapter. "blit" always answers, since the stretch needs no adapter capability; "dlss" joins it only where NGX DLSS Super Resolution is available. Read this before renderer.setUpscaleBackend("dlss", ...) to know ahead of the request what it resolves to.

Returns { string } — at least { "blit" }.

local caps = renderer.upscaleCapabilities()

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)

typed/builtin//modules/api/engine/renderer/renderer/viewportSize

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

The rect the scene was drawn into in the frame just drawn, in pixels. That is the whole drawing surface in a runtime window, a browser canvas and a headless engine, and the editor's viewport panel — smaller than the surface — in an editor layout. Follows a renderer.setViewportSize, a window drag and a browser page resize alike, so it reports what is realized rather than what was asked for.

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

local v = renderer.viewportSize()

typed/builtin//modules/api/engine/renderer/renderer/watchShadowPages

renderer.watchShadowPages(runs: number)

Gather a breakdown of what the sun's virtual shadow map does on each of the main camera's next runs runs. Each run's counts come back from the GPU a few frames after it, and renderer.shadowPageBreakdown() hands them over. Nothing is gathered while no reader has asked. 0 stops a gathering under way.

Parameters

  • runs number — How many of the main camera's runs to gather.
renderer.watchShadowPages(120)
  • api
  • reference