renderer
The renderer namespace — 462 functions.
globals/renderer/anisotropy
renderer.anisotropy() -> number
The maximum anisotropy material textures are sampled with right now — the requested level clamped to what this device honours.
Returns number — The effective level, 1 through 16.
if renderer.anisotropy() < 4 then ... end
globals/renderer/atmospherics/held
renderer.atmospherics.held() -> boolean
Whether a hold is standing on the air right now.
Returns boolean — True while at least one renderer.atmospherics.hold stands.
if renderer.atmospherics.held() then print("clear air") end
globals/renderer/atmospherics/hold
renderer.atmospherics.hold(share: number?) -> () -> ()
Hold the air between the camera and every surface at a stated share of what the scene authored, and return the release. At the default 0 the media contribute nothing and a surface renders in its own colour, which is what lets a reader judge an albedo, a tint or a material while another slice of a shared world drives the weather. The share reaches aerial perspective, height fog and volumetric light scattering; the sky, the sun and the light they put on a surface are untouched, because those are what the surface's colour is made of. Holds nest: the innermost names the share, and the authored air is back once the last release is called. Each release ends its own hold whatever order the releases come in, so two callers holding at once each end their own.
Parameters
sharenumber(optional) — How much of the authored air reaches the image, in [0, 1]. Defaults to 0 — no air at all.
Returns () -> () — A function that releases this hold. Calling it twice releases once.
local release = renderer.atmospherics.hold() ; local png = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()
globals/renderer/atmospherics/onChange
renderer.atmospherics.onChange(listener: (number) -> ()) -> () -> ()
Register a listener called with the share now in force whenever it changes — a hold taken, a hold released — and return the unsubscribe. A system that packs a medium into a GPU buffer registers here and re-packs what it has already pushed, so the buffer carries the share before the frame the hold was taken on is drawn rather than a frame later.
Parameters
listener(number) -> ()— Called with the share now in force, in [0, 1].
Returns () -> () — A function that removes this listener.
local stop = renderer.atmospherics.onChange(function(share) pushParams() end)
globals/renderer/atmospherics/share
renderer.atmospherics.share() -> number
The share of the authored air that reaches the image: the innermost
hold's share while one stands, and 1 otherwise. A system that packs a
medium multiplies its extinction — aerial, a fog density — by this,
and a hold then reaches that medium however it is being driven.
Returns number — A number in [0, 1]. 1 when nothing holds.
local density = state.density * renderer.atmospherics.share()
globals/renderer/blendedBatching
renderer.blendedBatching() -> boolean
Whether blended neighbours sharing a draw key draw together.
Returns boolean
globals/renderer/bounds/clear
renderer.bounds.clear(id: string) -> boolean
Withdraw the box an entity published, so it stops contributing to the entity's reported extent.
Parameters
idstring— 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
idstring— Entity id.minany(optional) — Local-space minimum corner — a Vec3 table or a 3-element array.maxany(optional) — Local-space maximum corner, same shape asmin.
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
namestring— The view name.
Returns number? — The channel number or nil.
ctx.enqueue { ..., debugChannel = renderer.captureView.channelId("lightmap") }
globals/renderer/captureView/list
renderer.captureView.list() -> { any }
Every registered capture view as { name, channel, description } records
— what backs the discoverability of capture pass=<name> and the
unknown-view error's suggestion list.
Returns { any } — An array of view records.
for _, v in ipairs(renderer.captureView.list()) do ... end
globals/renderer/captureView/ready
renderer.captureView.ready(name: string) -> boolean
Whether a registered view can draw yet. A view's passes are enqueued from the moment its render feature first runs, but they are skipped while the materials they name have no pipeline — their shader is still compiling — so for the first frames of a session a camera bound to the view renders the ORDINARY view into its target, and the image gives no sign of it. This reports the difference, and reports it before any camera is on the view, so it is answerable for the first camera bound to one. False for an unregistered name.
Parameters
namestring— 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
namestring— The view name, selected viacapture pass=<name>.configany(optional) —{ description?, ensure?, warmup?, renderLayers? }.ensureis called before a capture of this view so the feature that draws it is live (e.g. create it on demand).warmupis how many present frames a capture lets the view accumulate before it reads — set it when the feature retains prior-frame state (a temporal diff) so the first capture reads a warm result.renderLayersis the layer spec a capture of this view uses when the caller named none — a view that draws its own geometry and wants the scene's kept out of the frame (and out of the depth buffer it tests against) names only its own layer.
Returns number — The channel number assigned to the view.
local ch = renderer.captureView.register("lightmap", { description = "...", ensure = fn })
globals/renderer/captureView/resolve
renderer.captureView.resolve(name: string) -> any
Resolve a capture view by name to its { channel, ensure, description, warmup } record, or nil when no view is registered under name.
Parameters
namestring— 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
namestring— The view name.
Returns boolean — True when a view was registered under name.
renderer.captureView.unregister("lightmap")
globals/renderer/clearShadowHero
renderer.clearShadowHero() -> boolean
Release the hero caster, so the directional shadow is the cascades' alone again and the layer the hero view rendered into is given back.
Returns boolean — Whether a caster was registered.
renderer.clearShadowHero()
globals/renderer/clearShadowProxy
renderer.clearShadowProxy(mesh: string?) -> number
Stop proxying mesh, so it rasterizes its own geometry into shadow
views again. Called with no argument, drops every registration.
Parameters
meshstring(optional) — The mesh to stop proxying. Omit to clear all of them.
Returns number — How many registrations were removed.
renderer.clearShadowProxy(statueMesh)
print(renderer.clearShadowProxy(), "proxies dropped")
globals/renderer/collect
renderer.collect() -> RuntimeCollection
Release every runtime texture, material, mesh and render feature nothing holds: no handle a script still reaches, no live owner, no reference from live engine state, no asset backing it, no hold. A root scene load runs this once the new scene stands, so what the previous scene's content created and nothing still wears goes with that scene; calling it directly collects at any other moment. A session material's handle counts as reached while the entity it was keyed for stands, and stops counting once that entity is gone. It reaches the GPU textures the device holds beside the registry's own: a texture the cache loaded for an asset goes once nothing live names it and is read back from that asset the next time something asks for it, while one no asset answers for stays, there being nothing to read it back from — a render pass's own target, a colour swatch, an atlas the engine built. A texture the ASSET path uploaded and whose asset has since been removed has nothing to come back from either, and the collection decides about it from its holders the way it does about every other resource: a handle a script still reaches, a live owner, a reference from live engine state, a hold. Features go first, then materials, then meshes, then textures, so a texture only a released material named goes with the material. Runs a full garbage collection first, so a handle nothing reaches counts as let go, and yields for the frame the census runs on. A handle the calling function still has in a variable — or in a temporary it has not overwritten — is one a script reaches, so a resource created in the function that collects is let go by the next collection rather than this one.
Returns RuntimeCollection — { released = { texture, material, mesh, feature }, kept, entries } — the counts released per kind, how many stayed, and every resource's status with action = "released" | "kept".
local c = renderer.collect() print(c.released.texture, c.kept)
globals/renderer/compiledShaders
renderer.compiledShaders() -> { string }
Every name renderer.compiledSource answers for — one per name a
shader compile has run under this session, whether it succeeded or failed.
What makes the composed-source surface enumerable rather than something to
guess a key for.
Returns { string } — An array of shader names, sorted.
for _, name in renderer.compiledShaders() do print(name) end
globals/renderer/compiledSource
renderer.compiledSource(shader: string) -> string?
The WGSL the shader compiler received under one name, exactly as it
received it — the composed module, which is what a compile error's line
numbers and handle indices are positions in. Answers under any name a
compile ran under (identity, guid, alias, or a program from
renderer.shaderVariants()), for a shader that declares no features, and
for a shader whose compile FAILED, which is the case it exists for: a
message about a function body carries a position and nothing else, and the
text that position is in is this. The failed text stands for as long as
shaderRef:compileStatus() reports that failure under the same name.
Parameters
shaderstring— Any name a shader compiled under — identity, guid, alias, or ashaderVariants()program name.
Returns string? — The composed WGSL, or nil for a name no compile has run under.
local status = asset.resolve("myShader", "shader"):compileStatus()
if status.status == "failed" then
print(status.error)
print(renderer.compiledSource("myShader"))
end
globals/renderer/compositeSize
renderer.compositeSize() -> { width: number, height: number }
The size of the image the post-scene phases worked on in the last
presented frame — the target the UI composites onto, which every pass
after the scene reads as @scene.color and writes into, and which a
screenSpace = "composite" render target follows. While the renderer
presents the viewport itself that is the display's own size, whatever
fraction of it the scene rasterized at; while a UI viewport panel owns
the presentation it is the size the scene rasterized at, since the panel
draws the scene target at its own rect and nothing upscales before the
composite. Both read 0 before a frame has drawn.
Returns { width: number, height: number } in pixels.
local c = renderer.compositeSize()
globals/renderer/cullStats
renderer.cullStats() -> {
What the last completed frame decided to draw. total renderables went
into the frustum test, culled fell outside it and visible survived. Of
those, occlusion culling measured occlusionTested against the depth
pyramid and proved occlusionCulled were entirely behind other geometry —
both 0 while renderer.occlusionCulling() is false. A renderable the
pyramid has no say over — one that laid no depth in the pre-pass, one whose
bounds were never recorded, one straddling the near plane — is measured
against nothing and counted in neither, so the gap between visible and
occlusionTested reads how much of the frame the test could speak for.
This answers for the main camera. What a shadow view's own volume did with
the frame's casters is on that view's row in renderer.shadowViews().
Returns { total: number, culled: number, visible: number, occlusionTested: number, occlusionCulled: number }
local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")
globals/renderer/debugPass/builtins
renderer.debugPass.builtins() -> { string }
The built-in debug-pass names, one per channel in channel order — the engine's built-in pass vocabulary (final, albedo, normal, depth, …).
Returns { string } — An array of built-in pass names.
for _, n in ipairs(renderer.debugPass.builtins()) do ... end
globals/renderer/debugPass/channel
renderer.debugPass.channel(name: string) -> number?
The channel a debug-pass NAME renders on: a built-in pass, else a content
capture view registered via renderer.captureView. Nil when the name is
neither — the signal a selector uses to reject an unknown pass.
Parameters
namestring— 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
channelnumber— The channel number.
Returns string? — The pass name, or nil.
local name = renderer.debugPass.name(7) -- "normal"
globals/renderer/depthPrepass
renderer.depthPrepass() -> boolean
Whether the opaque depth pre-pass is currently enabled.
Returns boolean
globals/renderer/depthPrepassOrder
renderer.depthPrepassOrder() -> { runs: number, reordered: number }
What the last frame's depth pre-passes planned, and how far their
sequences were from near-to-far before they ordered. runs counts the
instanced draws planned; reordered counts the adjacent pairs the sort
moved past each other, taken before it ran. Both are summed over every
pre-pass the frame ran — the window plus each render-target camera, each
ordering against its own camera. Both read 0 while the pre-pass or the
ordering is off, and reordered reads 0 for a frame that already stood
in order. The ordering leaves no other trace — the draws, the depth and the
image are the same either way.
Returns { runs: number, reordered: number }
local o = renderer.depthPrepassOrder() -- o.reordered > 0 → it sorted
globals/renderer/depthPrepassOrdering
renderer.depthPrepassOrdering() -> boolean
Whether the depth pre-pass is submitted nearest-first.
Returns boolean
globals/renderer/destroy
renderer.destroy(handleOrKind: any?, id: string?) -> boolean
Free the GPU resource a renderer resource holds (the GPU-destroy verb).
Takes any of the forms that name it: the handle a create returned, routed
by its category so one call releases a mixed set of handles; the id a
listing hands out, whose kind is read back off what the renderer holds
under it — the runtime registry, the material definitions, the live
features, and the device itself for an asset's own texture or mesh; or the
kind with the id beside it, the shape renderer.hold and
renderer.references take, which is what names the kind for an id two of
them answer to. An id nothing holds anything under releases nothing and
answers false. The on-disk asset, if any, is untouched. A CPU handle's
:unload() frees the CPU copy separately.
Parameters
handleOrKindany(optional) — AMeshHandle,TextureHandle,MaterialHandleor feature handle; the id itself; or the kind ("texture","material","mesh","feature") with the id as the second argument.idstring(optional) — The guid or registry key, when the first argument is a kind.
Returns boolean true if a GPU resource was known under the id.
renderer.destroy(meshHandle); renderer.destroy(materialHandle)
renderer.destroy(renderer.texture.list()[1].guid)
renderer.destroy("mesh", guid)
globals/renderer/deviceGeneration
renderer.deviceGeneration() -> number
Which render device this process is on, counted from the first.
A render device is lost when a driver resets, when the GPU is taken away,
or when a browser reclaims a WebGPU context. The engine answers by building
another device and re-deriving this session's resources onto it, and this
number moves by one each time it does. Anything held across frames that was
built from a GPU resource records this beside it and remakes it when the two
differ; engine.onDeviceRebuilt is the hook that fires when it moves.
Returns number — The current device generation, counting from 1.
local generation = renderer.deviceGeneration()
engine.onDeviceRebuilt(function(g) print("device " .. g) end)
globals/renderer/deviceState
renderer.deviceState() -> string
Whether the render device this process draws through is the one it is using, one it is replacing, or one it has stopped trying to replace.
"ready" is a live device. "rebuilding" is the window between a device
reporting itself lost and another being in place: every GPU resource built
from the old one is invalid, the frames in that window draw nothing, and
anything reaching the GPU refuses. "abandoned" is after the engine gave
up — the adapter refused every attempt, so this session draws no more
frames.
Work that spans the device — build a render target, draw into it, read it
back — reads this to tell an operation that failed because the device went
out from under it, which is worth doing again once
renderer.deviceGeneration() moves, from one that failed on its own terms.
The loss is reported before the next device exists, so the two readings
answer different halves: this one says a replacement is coming, the
generation says it arrived.
Returns string — "ready" | "rebuilding" | "abandoned".
if renderer.deviceState() == "rebuilding" then return end
globals/renderer/drawDiagnostics
renderer.drawDiagnostics() -> { DrawDiagnostic }
Every renderable that is NOT drawing what its material says — the one
call for "why does this surface look wrong". Three states land here: a
surface rendering as the magenta placeholder (substituted), one the
renderer could bind nothing for at all (outcome = "skipped"), and one
drawing a program whose most recent compile FAILED (stale), which is what
a shader edited into brokenness looks like — the pipeline its last good
compile built keeps drawing, so the picture is intact and answers to none of
the edits since. Each row names the entity, the program asked for, the
program bound, programStatus — the compile gate's word about the program
the material NAMED — and the one cause
from shaderCompileFailed / shaderNotRegistered / shaderNotCompiledYet
/ noGbufferEntry / renderStateKeyNotBuilt / noPipelineForTarget /
unshaded, with the compiler's own message in detail or programError.
Covers every renderable the renderer holds, whether or not a camera reached
it: a row with observed = false and outcome = "notDrawn" carries the
renderer's own resolution for one this frame drew nowhere, so a broken
surface off-screen is reported the same as one in frame. An empty result
means every renderable the renderer holds is drawing the program its
material named and that program compiles. Answers on the deferred path as
well as forward, and in edit mode as well as play.
Returns { DrawDiagnostic }
for _, d in renderer.drawDiagnostics() do print(d.entity, d.reason, d.detail) end
if #renderer.drawDiagnostics() == 0 then print("every surface is drawing what it says") end
globals/renderer/drawStats
renderer.drawStats() -> {
What the last completed frame actually submitted. draws counts every
geometry draw call the frame issued — the camera's passes, each shadow
view a shadow-casting light adds, and whatever a render feature draws —
and instances counts the instances those draws covered. The pair is what
separates one draw carrying five hundred instances from five hundred draws
carrying one each, so it reads how well the scene batches rather than how
many objects are in it.
compacted is how many of those instances the frame planned through draws
whose instance count the GPU decides: the culler's own per-object answers
packed into a dense run, so an object it rejects is absent from the draw
instead of collapsing to nothing in the vertex stage. compactedDrawn is
how many of them survived, counted on the GPU as it packed them — a pass
that then skips a whole draw over its own layer or visibility answer
leaves that draw's instances in both numbers.
The plan is made over the populations the frame draws, and the tests
answer which of their instances the packing keeps. That packing runs
before any pass has resolved the depth occlusion culling is tested
against, so on its own it reads the frustum and screen-size answers
alone. With setOcclusionCulling armed the frame packs the same plan a
second time once the test has answered, and compactedDrawn then counts
what came through occlusion as well.
compactedDrawn comes back from the buffer the GPU wrote, so it describes
a frame that has finished while compacted describes the most recent
plan, and it holds the last count the GPU wrote until another arrives — a
frame that compacts nothing reads compacted 0 beside the count from the
last frame that did. In a scene standing still the gap between the two is
the front-end work culling removed.
materialBinds is how many times the frame's geometry passes set a
material's parameter group, and materialBindsElided how many times a
pass reached that decision and found the group already bound. Their sum
is how many times the decision was reached — once per unit of geometry
submitted, which sits at or below draws, since a mesh of several
primitives draws once per primitive under one set of binds. The ratio
inside the pair is what material binding costs the frame: the batched
opaque geometry is gathered into runs sharing a material, so a frame of
many such draws over few materials binds about once per material rather
than once per unit. materialExtraBinds and materialExtraBindsElided
are the same pair for the second group, the storage bindings a shader
declares for itself, which only the shaders that have them ever bind.
pipelineBinds and pipelineBindsElided are the same pair for the
pipeline itself: how many times the frame's geometry passes set one, and
how many times a pass reached that decision and found the pipeline it
wanted already bound. Which pipeline a unit needs follows its shader, its
material's render state and its mesh's vertex layout together, so a scene
whose units share all three costs one set for the run of them, while units
differing in any one of the three each pay their own. Their sum is how
many units reached the pipeline decision, which sits at or above what the
material pair reports: a unit the pass settles a pipeline for and then
abandons — one whose material group resolved to nothing — counts here and
never reaches the material decision.
Every figure here is the whole frame's, the main camera's draws and every
shadow view's summed together. renderer.shadowViews() splits compacted
and compactedDrawn across the views that made them, and carries the
camera's own share beside them.
Returns { draws: number, instances: number, compacted: number, compactedDrawn: number, materialBinds: number, materialBindsElided: number, materialExtraBinds: number, materialExtraBindsElided: number, pipelineBinds: number, pipelineBindsElided: number }
local d = renderer.drawStats(); print(d.instances / math.max(d.draws, 1), "instances per draw")
print(renderer.drawStats().compactedDrawn, "of", renderer.drawStats().compacted, "survived the cull")
local d = renderer.drawStats(); print(d.materialBinds, "material binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")
globals/renderer/feature/create
renderer.feature.create(ref: any?, guid: string?) -> any
Instantiate a render feature so the engine calls its render(ctx) hook
every frame. ref is an AssetRef<renderFeature> whose init.luau returns
{ setup?, render, teardown? }. Returns a live RenderFeatureHandle (its
guid is the stable id, same as mesh/texture handles); tear it down with
renderer:destroy(handle). Pass guid to assign a specific id.
Parameters
refany(optional) — AnAssetRef<renderFeature>, or a string identity/guid resolved viaasset.resolve(ref, "renderFeature").guidstring(optional) — Optional explicit handle guid (minted when omitted).
Returns any — A RenderFeatureHandle.
local h = renderer.feature.create(asset.resolve("acrylic", "renderFeature"))
local h = renderer.feature.create("acrylic")
globals/renderer/feature/destroy
renderer.feature.destroy(handleOrGuid: any?) -> boolean
Tear down a live render feature by its RenderFeatureHandle OR its guid
string — the by-id path for when the handle was lost (e.g. across execute
calls). Same effect as renderer.destroy(handle). Returns true if a feature
was live under that id.
Parameters
handleOrGuidany(optional) — ARenderFeatureHandleor itsguidstring.
Returns boolean
renderer.feature.destroy("eb623975-d8bd-47a1-a0a4-5c0e56238e2f")
globals/renderer/feature/list
renderer.feature.list() -> { { guid: string, identity: string } }
List every render feature currently live (running its render(ctx) each
frame). Each entry is { guid, identity } — the guid is the same id a
RenderFeatureHandle carries, so you can tear a feature down by guid even
after losing its handle (e.g. across separate execute calls).
Returns { { guid: string, identity: string } }
for _, f in renderer.feature.list() do print(f.identity, f.guid) end
globals/renderer/feature/shaded
renderer.feature.shaded() -> { [string]: number }
How many pixels each fragment pass a render feature enqueued shaded on
the last drawn frame, keyed by the pass's shader/effect name. A fragment
pass draws one triangle over its target, so it shades the whole screen
whatever its effect actually reaches — unless it declares bounds on the
pass spec, the world-space box its effect stays inside, in which case it
shades the rectangle that box projects into for the camera drawing it and
is skipped for a camera that cannot see the box at all. This is the reading
that says which of the two a pass is: it moves when the effect moves, and a
pass absent from it shaded nothing. Summed over every camera the frame drew.
Returns { [string]: number } — { [shader: string]: number } — pixels shaded, last drawn frame.
local px = renderer.feature.shaded(); for name, n in pairs(px) do print(name, n) end
globals/renderer/featureTexture/configure
renderer.featureTexture.configure(width: number, height: number, layers: number)
Size the shared feature-texture array — the layers a surface shader reads
through zero_feature_texture(uv, layer), and the layers a SpotLight
projects through its cone via cookieLayer. Layers are rgba16f.
A call for the size the array already has is left alone. One that changes
the size reallocates, and the replacement is zeroed — so it empties every
layer in the array, including the layers other features and other cookies
own. renderer.featureTexture.state() reports the extent and the layers
holding content, which is how a feature re-fills the layer a resize took
from it.
Parameters
widthnumber— Layer width in pixels.heightnumber— Layer height in pixels.layersnumber— 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
layernumber— Which layer of the array to write into.textureKeystring— The source texture's name — the one it was created under. Acompute.createStorageTexture2Dtarget, acompute.createTextureHistorypair (its current side), and a texture acompute.copyBufferToTexturewrote all answer to the name they were given.xnumber— Left edge of the destination rectangle, in pixels.ynumber— Top edge of the destination rectangle, in pixels.
compute.createStorageTexture2D("gobo", { width = 256, height = 256, format = "rgba16f" })
renderer.featureTexture.setLayer(0, "gobo", 0, 0)
globals/renderer/featureTexture/state
renderer.featureTexture.state() -> {
What the shared feature-texture array is right now: the extent every
layer carries, and filled, the ascending 0-based indices of the layers a
setLayer has landed in since the array was last sized. One array is
shared by every feature and every light cookie in the scene, and it has no
allocator, so this is the call that tells a feature whether the array it
sized and filled is still the array it is writing into — a configure that
changed the size reallocates and zeroes every layer, and the layer it
emptied leaves filled without it. Measured off the renderer at the end of
the last rendered frame, so a configure or setLayer issued this frame
reads back on a later one.
What this describes is the array a shader samples. The source texture a
setLayer copied FROM is a GPU resource of its own and keeps the bytes it
was written with for as long as it lives, so filled is the reading that
answers whether the layer behind a cookieLayer is live right now.
Returns { width, height, layers, filled }
local ft = renderer.featureTexture.state()
print(("feature textures: %dx%d over %d layers"):format(ft.width, ft.height, ft.layers))
-- Re-fill the cookie layer this module owns if anything emptied it.
if ft.width ~= myWidth or table.find(ft.filled, myLayer) == nil then
refillMyCookie()
end
globals/renderer/framePacing
renderer.framePacing() -> FramePacing?
How far the CPU is allowed to run ahead of the GPU, and what holding it there cost the frame just finished. Submitting work to the GPU returns before the GPU has done it, and everything that submission holds — its staging allocations, its bind groups, its command buffer — stays alive until it completes. A frame that asks for more work than the GPU finishes in a frame's time therefore leaves that behind it, and unbounded that is memory growth rather than a lower frame rate.
framesInFlight is how many submitted frames have not reported done
through the queue's completion signal, held under maxFramesInFlight: a
device that keeps up reads under the bound, one that is behind reads at it.
It counts submissions, which is its own quantity — how many presented
images the swapchain permits in flight is a separate setting.
mechanism names how that bound is enforced
here: submission-wait waits for the frame that many frames back and
reports the wait in waitMs, so a paced frame costs latency and still
draws; submitted-work-done counts outstanding frames off the queue's
completion signal and declines to start a frame while the bound is met,
counting those in pacedFrames and leaving the last presented image up.
submittedFrames counts the frames that were admitted and submitted, so it
rises for as long as the renderer is producing frames — which is what tells
a renderer running slowly under a tight bound from one that has stopped.
stalled reads true while that completion signal has stopped arriving and
the pacer stood down rather than hold the image indefinitely; it clears on
the first frame that finds the count back under the bound.
producing is whether the renderer is drawing frames at all. A headless
renderer draws into an offscreen framebuffer that nothing presents, so its
image reaches a reader only through something that copies it out: it draws
while a consumer is asking — an MCP call in flight, a queued texture
readback, a recording, a frame-egress session — and declines the frames
between two asks, counting them in idleSkippedFrames. Every other
renderer stat answers with the last frame that drew, so producing is what
separates a live reading from a frozen one. A windowed renderer presents
every frame it draws and reads producing = true throughout.
presentMode is what the surface presents with and presentModes what it
offers; both are empty of meaning on a headless renderer, which never
presents.
Returns FramePacing? — { framesInFlight, maxFramesInFlight, pacedFrames, submittedFrames, waitMs, mechanism, stalled, producing, idleSkippedFrames, presentMode, presentModes }, or nil before the renderer has drawn a frame
local p = renderer.framePacing()
print(("%d of %d frames in flight, paced by %s"):format(p.framesInFlight, p.maxFramesInFlight, p.mechanism))
globals/renderer/getRaytrace
renderer.getRaytrace() -> boolean
Whether ray tracing is currently enabled.
Returns boolean
globals/renderer/gpuMemory
renderer.gpuMemory() -> GpuMemory
Where the renderer's GPU memory went at the last completed frame — the call to reach for when something is holding memory and you do not know what.
Three figures answer three different questions, and they are meant to be read against each other:
- The categories —
shadow,textures,meshes,instances,compute, summing tocategorised— are the renderer's own accounting of what it asked for on purpose. Always present, on every backend. allocatoris the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. It exceedscategorisedby the per-frame render targets and the scratch nothing categorises. The allocator hands memory out from blocks it reserves whole from the device and returns a block only once nothing is left in it, soreservedBytesruns aboveallocatedBytesby what those blocks hold unused;blockslists them emptiest first with the labels that keep each one alive, andemptyBytesplusslackBytesis that distance exactly — the pool held in empty blocks, and the room pinned inside blocks something still sits in.driver.deviceLocalBytesis 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 reportsavailable = falseand the reason.driver.outsideAllocatorBytesis 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 andrenderer.collect()all leave it where it is, and it falls when the device is destroyed. Read it when a session's device memory has grown and no ledger row accounts for the growth.
compute is what the compute subsystem holds; compute.observe() names
each of those resources and what it costs. renderTargets counts the
offscreen render targets the renderer holds at that frame, which is what
says a renderer.destroy has been applied rather than queued.
Returns GpuMemory — The accounting — see GpuMemory. The category figures are zeroed until the renderer has published its first frame; driver is read as the call runs and answers from the first.
local m = renderer.gpuMemory()
print(("shadows %.1f MiB"):format(m.shadow.total / 1024 / 1024))
-- how much of the card this engine is holding:
if m.driver.available then
print(("driver %.1f MiB"):format(m.driver.deviceLocalBytes / 1024 / 1024))
else
print("no driver figure: " .. m.driver.reason)
end
-- the biggest allocation in the engine:
local top = m.allocator and m.allocator.byLabel[1]
print(top and top.label, top and top.bytes)
-- the block held for the least, and what pins it:
local block = m.allocator and m.allocator.blocks[1]
if block and block.allocationCount > 0 then
print(block.size, block.allocatedBytes, block.byLabel[1].label)
end
globals/renderer/hold
renderer.hold(handleOrKind: any?, id: string?) -> boolean
Pin a runtime resource for the session. A held texture, material, mesh
or render feature survives every collection — the one a root scene load
runs and a direct renderer.collect() alike — until renderer.release
lets it go or its destroy frees it. It is the way to keep an ad-hoc
resource across the scenes that come and go under it. A hold keeps the
resource in the registry; a mesh's GPU buffers are governed by what draws
it, parked as a CPU definition when the last instance naming it goes and
brought back when one names it again, so renderer.mesh.isResident(guid)
is the separate question about the buffers.
Parameters
handleOrKindany(optional) — The resource's handle, or its kind ("texture","material","mesh","feature") with the guid or key as the second argument.idstring(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
targetstring | entityRef— The entity — a proxy fromentity(...)/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
targetstring | entityRef— The entity — a proxy fromentity(...)/entity.spawn(...), or an entity-id string. It must name a live entity.lanenumber— Whichvec4lane to write,0 .. laneCount() - 1.xnumber— The lane's.x.ynumber(optional) — The lane's.y. Defaults to 0.znumber(optional) — The lane's.z. Defaults to 0.wnumber(optional) — The lane's.w. Defaults to 0.
-- One dissolve material, each subject at its own progress.
local DISSOLVE_LANE = 0
for _, subject in ipairs(dying) do
renderer.instanceData.set(subject.entity, DISSOLVE_LANE, subject.progress)
end
globals/renderer/loseDevice
renderer.loseDevice()
Destroy the render device on the next frame, so the engine meets a real device loss.
This is the one loss that can be caused on purpose, and it travels the same
path a driver reset does: frames draw nothing until the rebuild lands,
GET /engine/status reports the renderer as recovering while it does,
engine.onDeviceRebuilt fires afterwards, and renderer.deviceGeneration()
moves. Use it to prove that a world's content survives a device loss —
anything it holds only on the GPU has to be remade from the rebuild hook, and
this is how you find out whether it is.
renderer.loseDevice()
-- some frames later:
print(renderer.deviceGeneration()) -- one higher than before
globals/renderer/mainCameraView
renderer.mainCameraView() -> { number }?
The main camera's inverse view-projection (column-major, 16 numbers)
followed by its world position (3 numbers) — {m0..m15, px,py,pz} — for
reconstructing world positions from the depth buffer in a ray-tracing pass.
Nil before the first render.
Returns { number }? 19 numbers, or nil.
globals/renderer/material/animatedTexture
renderer.material.animatedTexture(texture: string | AssetRef, opts: { [string]: any }?) -> MaterialHandle
Build a material that PLAYS a layered texture: its layers bound as the
frames, its timing bound beside them, and the engine's animatedTexture
shader turning the clock into the layer showing now. One call from an
imported animated image to a material an entity can wear.
The layer showing is resolved per pixel against the texture's own schedule,
so frames of unequal length are shown for the lengths they were authored
with, and the sequence loops. speed scales the clock (2 plays twice as
fast, 0 holds the frame startTime lands in) and startTime offsets into
the sequence, so two surfaces sharing one texture can run out of phase.
The clock is the engine's, and it runs in edit mode as much as in play and
through a pause, so two screenshots of one surface taken moments apart are
two different frames of it. speed = 0 holds one frame for as long as it
is set, which is the state to compare two screenshots in.
The returned handle is what a surface wears — Model:applySessionMaterial
takes it, and so does a Model's material field. The handle's guid is
this material's REGISTRY KEY, the currency of setProperty, describe and
destroy; a component field resolves an asset, so a bare key in one leaves
the component waiting for an asset to register under that name.
The builtin plane mesh emits uv = (u, v) with v along its own +Z, so
a quad pitched +90° about X (Transform.eulerToQuat(0, math.pi / 2)) shows
the image upright to a camera on +Z, and -90° shows it first-row-last.
A texture whose layers carry no timing is rejected — there is nothing to
play. renderer.texture.info(bytes).animated is the test.
Parameters
texturestring | AssetRef— The texture — a guid, an identity, a name, a path, or a textureAssetRef.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
contentMaterialContentkeystring
Returns MaterialHandle
globals/renderer/material/describe
renderer.material.describe(key: string | { [string]: any } | AssetRef) -> any
The recoverable definition ({ shader, properties, textures, name })
this module registered under key via renderer.material.create, or nil
for keys registered elsewhere (e.g. material assets resolved by the
assetType). properties and textures carry the material's current values:
each setProperty / setTexture write lands on this record, a texture slot
under the GPU key the slot binds by — these are the WRITES, held here
whether or not the renderer took them up. renderer beside them is what the
renderer holds for the same key: the program its prepared bind group was
built against, the render state its draws are looked up under, whether a
pipeline exists for that key, and how many draws the observed frame gave
it. renderer is nil when the renderer holds no material under this key at
all, and resident states the same fact as a boolean. Writes reach the
screen through both halves: resident = false says the renderer holds
nothing to put them in, and renderer.draws = 0 on a resident material
says it holds them and no renderable is drawing with it. For a material
that is resident AND drawn and still looks wrong,
renderer.drawDiagnostics() names the renderable and the cause.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key, theMaterialHandlefromrenderer.material.create, or anAssetReffromasset.resolve.
Returns any — MaterialContent? with resident: boolean and renderer: MaterialObservation? fields
globals/renderer/material/destroy
renderer.material.destroy(key: string | { [string]: any } | AssetRef) -> boolean
Drop a runtime material registered via renderer.material.create: clears
its recoverable definition, unregisters its runtime-resource stamp so it is no
longer swept into the material freeze/save flow, and frees the GPU record. Use
for transient materials (e.g. a preview swatch) that must not outlive their use.
The on-disk asset, if any, is untouched.
The reach is the registry: after this, describe and list stop answering
for the key. A surface already wearing the handle goes on drawing what it
was given — Model:restoreSessionMaterial is what puts a Model back on its
authored material.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key (the one passed tocreate), theMaterialHandlecreatereturned, or anAssetReffromasset.resolve.
Returns boolean true when a definition was known under key.
renderer.material.destroy("__preview_swatch_" .. texGuid)
globals/renderer/material/list
renderer.material.list() -> { any }
Every runtime material currently registered, ordered by registry key.
Each entry carries the key, where it came from, and the shader it binds.
renderer.references("material", key) says what is still holding a row,
and renderer.collect() releases the rows nothing holds.
Returns { any } — Array of { guid, origin, owner?, shader? }.
for _, m in ipairs(renderer.material.list()) do print(m.guid, m.shader) end
globals/renderer/material/renderState
renderer.material.renderState(key: string | { [string]: any } | AssetRef) -> MaterialObservation?
What the renderer holds for a material, which is a different document
from the values written to it. shader is the program its prepared bind
group was built against, renderState the blend / cull / topology / queue /
depth key its draws are looked up under, keyBuilt whether a pipeline
exists for that key, and draws / instances / placeholderDraws /
binds / bindsElided what it cost in the frame the renderer last
observed — those five read 0 until something arms per-draw recording, which
renderer.materialCost() and
renderer.drawDiagnostics() do. nil means the renderer holds no material
under this key at all — the writes landed on a record nothing is drawing
with.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key, theMaterialHandlefromrenderer.material.create, or anAssetReffromasset.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
entityIdstring— The entity carrying the material.
Returns string — The registry key string.
local key = renderer.material.sessionKeyFor(entityId)
globals/renderer/material/setProperty
renderer.material.setProperty(key: string | { [string]: any } | AssetRef, name: string, value: any?) -> ()
Push one changed uniform property to a registered material's GPU record
(frame-fast incremental update; no re-register). Keyed by the material's
registry key. The value written becomes the material's current one: it is
what describe reports, and — for a property the material's shader
declares, which is what the uniform buffer is packed by — what a material
AssetRef reads back through getProperty / getProperties and what the
surface is drawn with. A write under any other name reaches the record
describe reports, which is where it reads back.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key, theMaterialHandlefromrenderer.material.create, or anAssetReffromasset.resolve.namestring— Property name.valueany(optional) — New value.
Returns ()
renderer.material.setProperty(asset.resolve("wall", "material"), "roughness", 0.2)
globals/renderer/material/setTexture
renderer.material.setTexture(key: string | { [string]: any } | AssetRef, slot: string, ref: string | { [string]: any } | AssetRef) -> ()
Push one changed texture slot to a registered material's GPU record. Keyed by the material's registry key.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key, theMaterialHandlefromrenderer.material.create, or anAssetReffromasset.resolve.slotstring— Texture slot name ("base_color_texture", …).refstring | { [string]: any } | AssetRef— Texture reference — a.textureguid / identity / name / path, the image path it was imported from, acolor:/default:form, a live GPU handle, or a textureAssetRefcarrying one. An asset reference is materialised (Disk→CPU→GPU) and bound by the key the upload lands under.
Returns ()
renderer.material.setTexture("sky", "sky_texture", "panorama.texture")
globals/renderer/materialCost
renderer.materialCost() -> { MaterialObservation }
What each material cost the frame the renderer last drew, and the state
it holds each one under. One row per material the renderer holds a prepared
bind group for — a material an author wrote and the renderer never prepared
is absent, which is itself the answer to "why is nothing I set reaching the
screen". draws and instances cover that one frame; placeholderDraws
is how many of those draws bound the magenta placeholder instead of this
material's own program; binds is how many material-owned bind groups the
frame's passes SET for it and bindsElided how many of its draws wanted a
group the pass already held, which is what draw-key sorting buys; a draw
that fell back to the placeholder bound the placeholder's group, so it
counts in placeholderDraws and in neither bind count. uniformBytes is
the GPU uniform buffer's own size,
which is the reflected property block raised to the 16-byte floor and
rounded up to the copy alignment. renderer.drawDiagnostics() names WHICH
renderable is not drawing what its material says, and why.
Returns { MaterialObservation }
for _, m in renderer.materialCost() do print(m.material, m.draws, m.binds, m.bindsElided) end
globals/renderer/materialIdentity
renderer.materialIdentity() -> MaterialIdentity
Which material each renderable draws with, as a number a shader can
carry. A material is authored and bound by name, and no shader can read a
string — so every renderable's per-instance record holds a material index
instead. slots is the name → index table those indices are drawn from: an
index is assigned the first time the renderer draws with that material and
does not move afterwards, so two renderables that differ only in material
read different indices, and one renderable reads the same index frame after
frame. It follows that the table keeps a row for every material name drawn
this session, whether or not anything still draws with it. renderables is
a row per renderable that owns a GPU slot — the entity it belongs to, that
slot, and the index the record at it carries; populations is the same for
an instanced draw, whose whole reserved run of slots carries the one
material its registration named. That index is what a shader reads as
instance_data[slot].material_index, and the row a ray hit resolves
through zeroMaterial(). A renderable draws with the material its entity
references, so one whose entity names none carries index 0.
Returns MaterialIdentity — { slots: { [string]: number }, renderables: { { entity: string, slot: number, index: number, material: string } }, populations: { { slot: number, count: number, index: number, material: string } } }
local id = renderer.materialIdentity()
for _, r in id.renderables do print(r.entity, r.slot, r.index, r.material) end
globals/renderer/materialIndex
renderer.materialIndex(name: string) -> number?
The index standing for a material, or nil for one the renderer has not
drawn with yet. Pass it to a shader (or compare it against what a shader
read out of instance_data[slot].material_index) to tell which material a
drawing instance carries.
Parameters
namestring—stringMaterial name, asrenderer.material.createfiled it.
Returns number?
local red = renderer.materialIndex("brick_red")
globals/renderer/maxAnisotropy
renderer.maxAnisotropy() -> number
The highest anisotropy this device honours: 16 on hardware that filters
anisotropically, 1 on hardware that does not, where a higher request would
be downgraded to trilinear regardless. Read it to report quality honestly —
renderer.setAnisotropy clamps for you, so a request never needs guarding.
Returns number — The device ceiling, 1 or 16.
local best = renderer.maxAnisotropy()
globals/renderer/mesh/boundsSource
renderer.mesh.boundsSource(mesh: string | { [string]: any } | AssetRef) -> string
Where this mesh's culling bounds come from. "compute" once a compute
pass has written its vertices: the engine reduces those vertices to an AABB
every frame, so the mesh is culled against the geometry the pass produced
wherever it puts it. "geometry" otherwise: the AABB of the geometry the
mesh was created with.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— A mesh loaded into the CPU store (renderer.mesh.loadCpu) — theMeshCpuHandle, aMeshHandle, a guid, or a meshAssetRef.
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
msnumber(optional) — New per-frame budget in milliseconds, capped at 1000. A value that is not a positive, finite number raises.
Returns number — The budget in force after the call.
renderer.mesh.clusterBakeBudget(2)
globals/renderer/mesh/clusterBakes
renderer.mesh.clusterBakes() -> { [string]: any }
What the scheduled cluster bakes are costing. budgetMs is the slice a
frame may spend, pending how many bakes are queued, completed how many
have finished since the engine started, dropped how many left the queue
because the geometry they were scheduled over stopped being readable, and
heldBytes the source geometry the queue is holding across all of them —
the vertex pool and index run the bake at the head is reading, plus a copy
for each queued mesh the engine holds no definition for.
inFlight is one row per queued bake —
{ guid, cpuMs, frames, slices, bytes, state }: the wall time spent
advancing it, the frames it has been queued for, the slices it has been
advanced by, the geometry it is holding, and "baking" for the one being
advanced against "queued" for the ones waiting their turn.
Returns { [string]: any } — { budgetMs, pending, completed, dropped, heldBytes, inFlight }.
print(renderer.mesh.clusterBakes().heldBytes)
globals/renderer/mesh/clusterComponents
renderer.mesh.clusterComponents(clusterBytes: buffer | string) -> (ClusterComponents?, string?)
Split a cluster blob (from renderer.mesh.buildClusters) into its
GPU-ready component byte pools — the cluster vertex pool, the
geometry-addressing pool (every cluster's local→global vertex map, then
every cluster's triangle bytes), and the per-cluster record array — plus
their counts. A cluster's triangles address positions inside its own vertex
map one byte at a time, and a record's vertexOffset indexes the geometry
pool in u32 elements while its indexOffset indexes it in bytes, so ONE
binding resolves a corner. A pure decode (no GPU work): upload the pools
into buffers a compute shader owns (shaderRef:createBuffer +
buf:writeBytes) to drive a cluster draw from Luau.
Parameters
clusterBytesbuffer | string— Serialized cluster bytes (binary-safe).
Returns (ClusterComponents?, string?) — { vertices, geometry, records, vertexCount, vertexRefCount, triangleBytes, indexCount, clusterCount }, or (nil, err).
local c = renderer.mesh.clusterComponents(cb)
globals/renderer/mesh/clusters
renderer.mesh.clusters(mesh: string | { [string]: any } | AssetRef) -> { [string]: any }?
The shape of the cluster-LOD hierarchy the renderer holds for a mesh:
clusterCount across every level, levelCount with the finest counted as
one, and triangleCount across every cluster. The renderer keys one entry
per mesh that carries a hierarchy, so this answers whether the mesh has
clusters as well as what they are — nil for a mesh that carries none.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh to read — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
Returns { [string]: any }? — { clusterCount: number, levelCount: number, triangleCount: number }?
local c = renderer.mesh.clusters(gpu) ; if c then print(c.levelCount) end
globals/renderer/mesh/create
renderer.mesh.create(src: any?, guid: string?) -> MeshHandle
Create (or fetch) a GPU mesh resource and return its MeshHandle. src:
a MeshCpuHandle from meshRef:load() (CPU→GPU upload under the asset's
guid, idempotent — returns the resident handle if already uploaded); raw
geometry {positions, indices, normals?, uvs?, colors?, uvs1?, unwrapUvs?, tangents?, skinning?, skins?} (a new runtime mesh — uvs1 is the lightmap
UV set, unwrapUvs generates one, skinning/skins bind a skeleton); GPU
compute buffers {vertexBuffer, indexBuffer, vertexCount, indexCount, aabbMin?, aabbMax?, prevVertexBuffer?} (size the vertex buffer at
vertexCount * engine.vertexStride bytes, the engine's standard Vertex
layout); or a MeshHandle (returned as-is). NEVER takes an AssetRef —
load the CPU first.
prevVertexBuffer is a second buffer of the same size and layout holding
those vertices as they stood on the previous frame. Naming it is what makes
geometry a compute pass moves report a motion vector: the surface
differences the two streams, so every consumer of screen-space velocity —
motion blur, temporal reprojection — sees the movement. The engine fills it
from the current vertices once per frame, ahead of that frame's compute
dispatches, so a frame in which the pass does not run leaves the two
streams equal and the geometry reports standing still.
morphTargets are the shapes the mesh can blend towards: a list of
{ name?, positions, normals? } records, each holding one offset per vertex
from the base geometry, in the mesh's own vertex order. An entity blends
them with ecs.MorphWeights, weight i scaling target i. A name makes
the shape addressable as itself — renderer.mesh.morphTargets reads the
names back and renderer.mesh.morphWeights drives them by name.
Raw geometry is read against the mesh type's conventions: indices count
vertices from 0, and a triangle's FRONT face is the one whose vertices turn
counter-clockwise as the viewer sees them — cross(v1 - v0, v2 - v0) points
out of it. A material culls its back faces by default, so a triangle wound
the other way draws nothing where it stands; reverse the index triple, or
give the material render = { cull = "none" }, to draw that side. normals
give the surface its outward direction and shade the face; the side that
draws comes from the index order alone. uvs sample (0,0) at the image's
top-left. Model space carries the world's basis: +X right, +Y up, -Z the
direction transform.forward points. guides { path = "types/mesh" } has
the whole table.
A geometry src carrying keepCpu = true also keeps its geometry in the
guid-keyed CPU store, so renderer.mesh.getVertices reads it and
renderer.mesh.setVertices rewrites its positions in place — the per-frame
deformation path, which sends positions alone where renderer.mesh.update
re-sends the whole geometry. renderer.mesh.unloadCpu(mesh) releases that
copy. Without it the geometry lives on the GPU alone and
renderer.mesh.readback(mesh) is what brings it back.
Parameters
srcany(optional) — A MeshCpuHandle, geometry, compute buffers, or a MeshHandle.guidstring(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
zmshbuffer | 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
meshstring | { [string]: any } | AssetRef— The mesh to release — aMeshHandle, a guid, aMeshCpuHandleor a meshAssetRef.
Returns boolean true if a GPU mesh was known under the guid.
local m = renderer.mesh.create(cpu); renderer.mesh.destroy(m)
renderer.mesh.destroy(renderer.mesh.list()[1].guid)
globals/renderer/mesh/drawInstanced
renderer.mesh.drawInstanced(mesh: string | { [string]: any } | AssetRef, opts: any?) -> InstancedDraw
Draw one mesh instanceCount times in a single call, each copy placed by
a world matrix read from a GPU buffer. The population is a renderable in its
own right — it goes through the mesh's ordinary pipeline and the material's
ordinary bind groups, so it appears in the deferred pass, the forward passes
and the shadow maps exactly as an entity-backed draw of that mesh does.
The buffer holds instanceCount column-major 4x4 matrices, 64 bytes
each, tightly packed — the layout a vertex shader reads as
array<mat4x4<f32>>, which puts each matrix's translation in its LAST four
floats (Lua indices 13/14/15 for x/y/z). Packing row-major transposes every
instance.
The matrices are COPIED into the engine's transform slots once per frame, which is what buys that full-pass parity. Rewrite the buffer between frames and the instances move — no re-registration, no re-upload.
material is what the population draws with, and it is required: a
MaterialHandle (matRef:handle()), an AssetRef, or a registry key.
instanceDataBuffer names a second buffer, holding 64 bytes per instance —
four vec4 lanes, tightly packed, in instance order. Those lanes arrive in
the fragment stage as zero_object_data(in.instance_id, lane), the same
read a per-entity __instancedata block answers, so the members of one
population can differ in whatever their material's shader agrees the lanes
carry. Copied every frame like the transforms, from a buffer a compute pass
writes: the values never touch the CPU. Omit it and the lanes read zero.
reserveCount sizes the reservation above instanceCount so
renderer.mesh.setInstanceCount can raise the drawn count later without
re-registering; both buffers must back the reservation, not just the count.
mobility states whether the copies stand still — "static", or
"movable" when it is left out. It is what a scene gather collecting
geometry for precomputed lighting admits a population on, the same
declaration Model.mobility makes for an entity: the transforms live in a
buffer anything may rewrite between frames, so a population that says
nothing is taken as one that moves.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh the population draws — aMeshHandle, the guidrenderer.mesh.listhands out, aMeshCpuHandleor a meshAssetRef. 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.optsany(optional) —{ transformBuffer, instanceCount, material, instanceDataBuffer?, reserveCount?, renderLayer?, castsShadows?, mobility? }.
Returns InstancedDraw — An InstancedDraw handle for instanceInfo / setInstanceCount / dropInstanced.
local m = renderer.mesh.create({ positions = ..., indices = ... })
local buf = substrate.createBuffer({
name = "crowd.xf", type = "mat4", len = 64, kind = "gpu",
})
-- Column-major: translation lives at indices 13/14/15.
local xf = {}
for i = 0, 63 do
local m4 = { 1,0,0,0, 0,1,0,0, 0,0,1,0, i * 2, 0, 0, 1 }
for _, v in ipairs(m4) do xf[#xf + 1] = v end
end
buf:write(xf)
local rock = asset.resolve("rock", "material"):handle()
local draw = renderer.mesh.drawInstanced(m, { transformBuffer = "crowd.xf", instanceCount = 64, material = rock })
globals/renderer/mesh/dropClusters
renderer.mesh.dropClusters(mesh: string | { [string]: any } | AssetRef) -> boolean
Detach a mesh's cluster-LOD hierarchy and cancel a bake still in flight
for it, so the renderer holds none for it. The inverse of
renderer.mesh.uploadClusters.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh to detach — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
Returns boolean — True if a hierarchy was attached or a bake was in flight.
renderer.mesh.dropClusters(gpu)
globals/renderer/mesh/dropInstanced
renderer.mesh.dropInstanced(draw: InstancedDraw) -> boolean
Release an instanced-draw registration and the transform slots it
reserved. The mesh and the transform buffer outlive it — destroy those
through renderer.destroy and the buffer handle's :destroy().
Parameters
drawInstancedDraw— TheInstancedDrawto 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
geomMeshGeometry—MeshGeometry— flat per-vertex float / u32 arrays plus optionalskinningandskins.
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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
Returns { any } — { { pos: {x,y,z}, normal: {x,y,z}, uv: {u,v} }, ... } Each record carries pos, normal, uv and uv1 as named channels ({ x = , y = , z = }). Geometry going the other way — into renderer.mesh.create — is parallel flat arrays (positions, normals, uvs), and create accepts this record list under vertices so a mesh read back here can go straight into a new one.
globals/renderer/mesh/instanceInfo
renderer.mesh.instanceInfo(draw: InstancedDraw) -> InstancedDrawInfo?
What a live instanced-draw registration is drawing: which mesh, which transform buffer, which per-instance data buffer if it named one, how many instances, and how many slots it reserved. Returns nil once the registration has been dropped.
status is what the renderer did with it. The fields above it are the
request, made a stage before the renderer sees it; status is the answer:
"drawing" for a registration the renderer is drawing, "refused" for one
it turned away — error carries its reason — and "pending" for the frame
between the call and the renderer answering. So a registration whose copies
are not being drawn says so here.
Parameters
drawInstancedDraw— TheInstancedDrawto 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
drawInstancedDraw— TheInstancedDrawwhose copies to locate.
Returns any — A Readback to poll — :ready() then :result() — or nil for a registration that is no longer live.
local pending = renderer.mesh.instanceTransforms(draw)
while not pending:ready() do task.wait() end
local floats = pending:result()
globals/renderer/mesh/isCpuResident
renderer.mesh.isCpuResident(mesh: string | { [string]: any } | AssetRef) -> boolean
True if this mesh has a resident CPU copy in the guid-keyed CPU store.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
Returns boolean
if renderer.mesh.isCpuResident(handle) then ... end
globals/renderer/mesh/isResident
renderer.mesh.isResident(mesh: string | { [string]: any } | AssetRef) -> boolean
True if a GPU mesh is resident under this mesh's guid — the device
holds its buffers, or the upload pass is still going to hand them over.
This is the store the draw paths are gated on, so a mesh this reports
resident is one renderer.mesh.drawInstanced and a Model can draw.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
refstring | AssetRef— A meshAssetRef(carries.guidand reads its primary via getBytes), or any stringasset.refresolves to one — the guidencodeCputakes, 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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.weights{ [string]: number }—{ [string]: number }— how strongly to blend each named shape.
Returns { number } one weight per morph target, in target order.
local w = renderer.mesh.morphWeights(meshRef, { Eyes_Blink = 1, Mouth_Smile = 0.4 })
ecs.set(face, ecs.MorphWeights { weights = w })
globals/renderer/mesh/readback
renderer.mesh.readback(mesh: string | { [string]: any } | AssetRef) -> MeshCpuHandle
Read a runtime GPU mesh's geometry back to CPU and return a
MeshCpuHandle for it — the GPU→CPU half of the runtime-mesh freeze path. A
mesh made with renderer.mesh.create keeps no CPU copy, so persisting it
(:encode() → asset.create("mesh", …)) reads it back here first. Yields
until the readback completes (a frame or two). After it returns the geometry
is resident in the guid-keyed CPU store: :getTriangles, :getVertices,
:getBounds, :geometry, :encode, :unload all work. Errors if the mesh
never becomes resident in the vertex pool.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh — theMeshHandlerenderer.mesh.createreturned, a guid, or a meshAssetRef.
Returns MeshCpuHandle
local cpu = renderer.mesh.readback(handle); local zmsh = cpu:encode(); cpu:unload()
globals/renderer/mesh/readbackPosed
renderer.mesh.readbackPosed(requests: { { entity: string, mesh: any } }) -> { [string]: MeshCpuHandle }
Read the POSED geometry of skinned entities back to CPU: for each request, the vertices the skinning pass wrote for that entity this frame, joined by the indices of the mesh it is posed from. A skinned surface's world-space triangles are produced on the GPU from the entity's joint matrices, so the mesh asset holds the bind pose and only this reads where the surface actually is. The posed vertices are in model space, so the entity's own world transform still places them — the same transform the raster draw uses.
Takes a LIST and answers a map, because the readbacks are queued together
and polled together: a scene's worth of characters costs the frames of one
readback rather than one entity's after another. Each posed mesh lands in
the CPU store under a guid of its own, derived from the entity, so
compute.buildBvh, meshcpu.* and every other guid-keyed reader takes it
like any other mesh. Call handle:unload() when done with it.
An entity the map omits holds no live pose — nothing skinned it this frame, which is also what makes its draws read the source mesh, so its bind-pose geometry is what stands for it.
Parameters
requests{ { entity: string, mesh: any } }—{ { entity = <id>, mesh = <mesh> } }— the entity to read, and the mesh it is posed from (a guid,MeshHandleor meshAssetRef).
Returns { [string]: MeshCpuHandle } — A map from entity id to the MeshCpuHandle holding that entity's posed geometry.
local posed = renderer.mesh.readbackPosed({ { entity = id, mesh = ecs.get(id, ecs.Mesh).mesh } })
local tris = posed[id]:getTriangles()
globals/renderer/mesh/scheduleClusters
renderer.mesh.scheduleClusters(mesh: string | { [string]: any } | AssetRef) -> boolean
Queue a cluster-LOD bake for the static CPU mesh held under guid, and
attach the DAG to the GPU mesh of that same guid on the frame it finishes.
The CPU mesh may be unloaded on the very next line; the DAG is then built
one bounded slice per frame, so a dense mesh virtualizes without the frame
loop stopping for the whole bake.
One bake is advanced per frame — the one at the head of the queue — and the
geometry is read on the frame a bake gets there, from the definition the
engine holds for the mesh. A queue of meshes the engine holds definitions
for therefore holds one mesh's geometry rather than one per mesh, whatever
its depth. A mesh the engine holds no definition for is copied into the
queue as it is scheduled, since the CPU store is then the only thing
holding it. renderer.mesh.clusterBakes().heldBytes reports what the queue
is holding, and its inFlight rows report which bakes it is holding for.
This is what the .mesh assetType materialisation path uses; reach for
renderer.mesh.buildClusters when you want the bytes in hand instead.
Scheduling the same mesh again replaces the bake already in flight for it.
Parameters
meshstring | { [string]: any } | AssetRef— A mesh loaded into the CPU store (renderer.mesh.loadCpu) — theMeshCpuHandle, aMeshHandle, a guid, or a meshAssetRef.
Returns boolean — True if a bake was queued.
renderer.mesh.scheduleClusters(cpu) ; cpu:unload()
globals/renderer/mesh/setInstanceCount
renderer.mesh.setInstanceCount(draw: InstancedDraw, count: number) -> InstancedDraw
Change how many of a registration's instances draw. Constant time — the
reservation, the transform buffer and the pipeline all stay put, so this is
the verb for a population whose size changes per frame. The new count must
fit the reservation drawInstanced was given.
Parameters
drawInstancedDraw— TheInstancedDrawto reconfigure.countnumber— Instances to draw, at least 1 and within the reservation.
Returns InstancedDraw — The same InstancedDraw.
renderer.mesh.setInstanceCount(draw, visibleCount)
globals/renderer/mesh/setInstanceRenderLayer
renderer.mesh.setInstanceRenderLayer(draw: InstancedDraw, renderLayer: number) -> InstancedDraw
Change which render layers a registration's copies belong to. Constant time — the reservation, the transform buffer and the pipeline all stay put, and the next frame drawn tests the copies against the new membership. It is the verb for a population that follows something whose membership moves: a camera or a capture including the layer draws the copies, one excluding it does not.
Parameters
drawInstancedDraw— TheInstancedDrawto reconfigure.renderLayernumber— The membership bitmask, the same valuedrawInstancedtakes asrenderLayer. 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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— The mesh to update — aMeshHandle, a guid, aMeshCpuHandleor a meshAssetRef.srcany(optional) — New geometry{positions, indices, ...}or compute buffers{vertexBuffer, indexBuffer, vertexCount, indexCount, prevVertexBuffer?}.
Returns MeshHandle — A MeshHandle for the updated mesh.
globals/renderer/mesh/uploadClusters
renderer.mesh.uploadClusters(mesh: string | { [string]: any } | AssetRef, clusters: string) -> boolean
Attach a cluster-LOD DAG (bytes from renderer.mesh.buildClusters) to
the GPU mesh keyed by guid, enabling the continuous-cut cluster draw path
for that mesh.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh the clusters belong to — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.clustersstring— Serialized cluster bytes (binary-safe).
Returns boolean — True if the upload was queued.
renderer.mesh.uploadClusters(gpu, cb)
globals/renderer/minScreenSize
renderer.minScreenSize() -> number
The on-screen radius, in pixels, an object must reach to be drawn. 0
while the cutoff is off.
Returns number
local px = renderer.minScreenSize()
globals/renderer/morphStats
renderer.morphStats() -> {
The morph state the last frame drew with. A mesh carries the shapes it
can blend towards and an entity carries how strongly each is blended
(ecs.MorphWeights); where both are present, the vertex stage adds the
weighted deltas to the base geometry.
instances is how many render slots that happened at, and blends how
many single-target blends those slots carry between them: a slot
contributes one per target its weights move, or that they moved the frame
before, so the number of targets a mesh can be given is bounded by the
buffer the blends live in. meshes is how
many meshes hold a delta block and targets how many targets those blocks
cover between them; deltaBytes is what the shared buffer they are
appended into holds. A morph-target mesh whose weights are all zero reads a
meshes above zero beside an instances and blends of zero.
Returns { instances: number, blends: number, meshes: number, targets: number, deltaBytes: number }
local m = renderer.morphStats()
print(("%d instances carrying %d blends over %d meshes"):format(m.instances, m.blends, m.meshes))
globals/renderer/observe
renderer.observe() -> RenderObservation
Everything the renderer knows about the frame it last drew: what
program it bound for each renderable, the render state it holds each
material under, and what each program has cost in pipeline builds.
renderables is one row per renderable in the renderer's draw list,
carrying the program its material named (requestedProgram) beside the one
that was bound (boundProgram) — __error__ wherever the lookup missed
and the draw went ahead on the magenta placeholder — plus substituted,
the outcome (drew / drewPlaceholder / skipped / notDrawn), the
reason that forced it and the compiler's own detail for a failed
compile. observed says which of two answers a row is: true for a
resolution a geometry pass took as it drew, false for the renderer's own
resolution of a renderable this frame drew nowhere, which is what a
renderable outside every camera's frustum or layer mask reports.
materials is one row per
material the renderer holds a prepared bind group for; shaders is one row
per program pipelines have been built for. frame names the frame every
per-frame count covers; retainedFrames how many frames a resolution a
pass took is kept for after the last frame that drew it; window and
costWindow state both in the document itself. Recording is armed by the
first read, so this waits for the frame that first records rather than
answering empty.
Returns RenderObservation
local o = renderer.observe(); print(o.placeholderDraws, "renderables drew the placeholder in frame", o.frame)
globals/renderer/occlusionCulling
renderer.occlusionCulling() -> boolean
Whether occlusion culling is currently enabled.
Returns boolean
globals/renderer/passSchedule
renderer.passSchedule() -> {
The schedule check over this frame's enqueued render passes. Passes
declare what they read (inputs) and what they write (output /
outputs / storage), and the frame runs them in phase order and, inside
a phase, in order order. violations holds every input bound to a
resource the frame produces LATER: that read samples the resource as it
stands ahead of that pass, which is the previous frame's contents for a
render target that persists, an empty target for one just created, and the
scene draw's own output for a @scene.* buffer — and the pass renders
either way. The frame's own buffers are checked on the same terms as a
render target: bind @scene.motion at a phase ahead of the pass that
writes it and the read is reported, naming the buffer and its writer.
A pass reading a resource ahead of that write on purpose declares that slot
in its enqueue's readsPrevious and drops out of the list;
unboundPrevious holds declared slots the pass binds no such resource to,
which cover nothing.
A resource no queued pass writes is not reported — a camera rendering to
texture and compute.dispatch both fill targets outside the pass queue,
and the scene draw fills the @scene.* buffers every frame.
A read the frame has only one order for is not reported either: where the
writing pass consumes something the reading pass produces, the reader runs
first or the writer has nothing to write, which is what a pass reading a
buffer into a target of its own and a second pass copying that target back
over the buffer forms.
unreachable holds passes at a phase that does not run their kind: every
phase drains its fragment and compute passes, while afterLighting is the
one that draws geometry, draw and splat passes, so one of those enqueued
elsewhere sits in the queue and never runs.
Each finding is also stated in the engine log the first time it appears.
Returns { violations, unboundPrevious, unreachable }
local s = renderer.passSchedule()
for _, v in s.violations do print(v.message) end
globals/renderer/pipelineCache
renderer.pipelineCache() -> PipelineCache?
What the driver's compiled-pipeline store held, built, and wrote back. A pipeline is machine code the GPU driver compiles from the shader bound into it, and that compile is what a launch pays before the first frame drawing with each pipeline can appear. The store keeps that compiled code across runs, so a launch whose shaders have not changed reads back what the previous one compiled.
restoredBytes is what a previous run left for this GPU and this launch
read; pipelinesBuilt counts the pipelines built since startup and
buildMs is what they cost together, which is the number the store lowers.
saves and savedBytes describe writing it back — deferred until a burst
of builds settles, so one launch is one write — and dirty is true while
pipelines have been built that the file does not hold, including after a
write that failed, which lastError then names. path is the file, named
after the GPU it belongs to.
supported is false where the platform holds no store a program can carry:
a browser keeps its own and hands none out, and an adapter can lack the
capability. reason says which, and the build count and timing still read
true there. lastError names a read or write failure; a failed store costs
the saved compile and never the frame, since every pipeline is built from
its source either way.
pipelinesBuilt and buildMs are engine-wide totals; renderer.shaderCost()
is the same cost broken down per program, with each one's permutation count.
Returns PipelineCache? — { supported, reason, path, restoredBytes, pipelinesBuilt, buildMs, saves, savedBytes, dirty, lastError }, or nil before the renderer has drawn a frame
local c = renderer.pipelineCache()
print(("%d pipelines in %.1f ms, %d bytes restored"):format(c.pipelinesBuilt, c.buildMs, c.restoredBytes))
globals/renderer/pointShadowBudget
renderer.pointShadowBudget() -> PointShadowBudget
The point-light shadow pool now in force. A point light with
castsShadows renders an omnidirectional cube map, six faces of depth,
and slots is how many of them fit — a further caster is lit but throws
no shadow, and the engine log names how many were turned away. The slot
count is bought rather than authored: megabytes of VRAM at resolution
texels per face is what decides it.
Returns PointShadowBudget — The pool — see PointShadowBudget.
local p = renderer.pointShadowBudget()
print(("%d shadowed point lights, %.1f MiB"):format(p.slots, p.bytes / 1024 / 1024))
globals/renderer/projectionOffset
renderer.projectionOffset() -> (number, number)
The sub-pixel projection offset in force for the main camera, in NDC.
Returns (number, number) — The x and y offset, both 0 when the projection samples pixel centres.
local ox, oy = renderer.projectionOffset()
globals/renderer/raycast
renderer.raycast(origin: vec3, direction: vec3, maxDistance: number?, exclude: (string | { string })?) -> RenderRayHit?
Cast a ray against the geometry the renderer DRAWS and return the
nearest surface it meets. Every visible mesh answers, whether or not
anything gave it a rigid body — so a terrain, a procedurally generated
mesh, or any plain Model reports the surface at a point, which is what a
camera station, a prop, a sound source or a scatter standing on the ground
needs to know. The answer is the nearest triangle of the mesh, so a sloped
or terraced surface reports its height where it was asked rather than the
extent of its bounding box.
distance is measured from origin along the direction given, so it is a
world-space distance whenever that direction is a unit vector, and it is
directly comparable to a physics.raycast distance along the same ray.
normal is a unit vector turned to face back along the ray. exact is
true when the answer is a triangle and false when it is the object's
bounding box, which is what a mesh whose vertices live only in GPU buffers
answers with. The triangles are the mesh's own, placed by the entity's
transform and by the mesh's bind pose, so a surface a skinning or morph
pass deforms on the GPU answers as the geometry the mesh holds.
EVERYTHING drawn is in scope — the ground you meant, and equally a
character standing on it, a prop, a placeholder floor. The hit names its
entity in entityId, exclude steps over the ones you do not want, and
renderer.raycastAll hands back the whole column so you can pick the
surface yourself. A height you did not expect is usually a nearer surface
you did not mean to ask about, so read entityId before trusting a number.
Parameters
originvec3—vec3ray start in world spacedirectionvec3—vec3ray direction; any length, the engine normalisesmaxDistancenumber(optional) —number?how far the ray reaches, in world units. Default 1000exclude(string | { string })(optional) —(string | { string })?entity id, or ids, to step over
Returns RenderRayHit?
local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then camera.position = { x, hit.point.y + 1.7, z } end
globals/renderer/raycastAll
renderer.raycastAll(origin: vec3, direction: vec3, maxDistance: number?, maxHits: number?, exclude: (string | { string })?) -> { RenderRayHit }
Cast a ray against the geometry the renderer draws and return every
surface along it, nearest first. One entry per renderable the ray crosses —
the nearest intersection with each — so a stack of surfaces reads as the
order they stand in, and a caller after one particular surface finds it by
entityId rather than hoping it is the nearest. Each entry carries the
fields renderer.raycast returns.
Parameters
originvec3—vec3ray start in world spacedirectionvec3—vec3ray direction; any length, the engine normalisesmaxDistancenumber(optional) —number?how far the ray reaches, in world units. Default 1000maxHitsnumber(optional) —number?how many surfaces to return. Default 32exclude(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
handleOrKindany(optional) — The resource's handle, or its kind with the id second.idstring(optional) — The guid or key, when the first argument is a kind.
Returns RuntimeResourceStatus? — The resource's status, or nil for a key the registry does not record and the device holds no texture under.
local s = renderer.references(mat) for _, r in s.references do print(r.by, r.id) end
globals/renderer/reflectionEnvironment
renderer.reflectionEnvironment() -> {
What a reflective surface is reflecting. probes is how many reflection
probes the shading blends; they are gathered highest priority first, each
rank taking the coverage the ranks above it left, so a small interior probe
ranked above the large exterior one it sits inside wins outright wherever it
reaches full weight. ranks is the priority each of those probe slots was
published with, in slot order. sky is whether the sky fallback is armed:
with it, coverage no probe claims reflects the captured sky, and without it
a surface outside every probe's radius falls back to the nearest probe
alone. skyCaptured is whether the sky slot holds a capture — arming is
refused until it does, since an uncaptured slot reflects black.
slots is how many cube slots the environment array holds right now: the
sky's alone, at index skySlot, until a probe is captured into it, then
that one plus one per probe. maxProbes is how many of them probes may
take, and resident whether the array has grown past the sky's single
slot. Capture the sky with environment.captureSky().
Returns { probes, ranks, sky, skyCaptured, resident, slots, skySlot, maxProbes }
local env = renderer.reflectionEnvironment()
print(("%d probes, sky fallback %s"):format(env.probes, tostring(env.sky)))
globals/renderer/release
renderer.release(handleOrKind: any?, id: string?) -> boolean
Let go of the hold renderer.hold placed. The resource stays until
nothing else holds it and a collection releases it — the one a root scene
load runs, or a direct renderer.collect().
Parameters
handleOrKindany(optional) — The resource's handle, or its kind with the id second.idstring(optional) — The guid or key, when the first argument is a kind.
Returns boolean true when the registry knows the resource.
renderer.release(tex)
globals/renderer/renderTargetLimits
renderer.renderTargetLimits() -> {
The size a render target may be on this device. maxDimension is the
device's own maximum 2D texture dimension — the largest either side of a
render target may take. maxPixels is how many pixels one render target
may hold, so the RGBA8 image it reads back as fits in a single buffer on
every platform the engine runs on, and maxSquare is the largest square
that budget buys. A capture, a renderer.texture.create({ width, height })
or a render-to-texture camera past either bound is refused at the call with
the reason, so ask here for the size to request.
Returns { maxDimension, maxPixels, maxSquare }
local lim = renderer.renderTargetLimits()
local w = math.min(want, lim.maxSquare)
globals/renderer/renderTargets
renderer.renderTargets() -> {
Every render target the renderer owns and what each one costs, measured
from the texture that is allocated. One row per target, each carrying its
name, whether it is resident, the bytes it holds while it is, its
width/height/layers/mipLevels, and onDemand.
An onDemand target exists only while something needs it: a target nothing
writes into reads resident = false and bytes = 0 and appears again the
frame something writes it, and one sized by content — the reflection-probe
cube array — holds the slots content asked for. The scratch the draws into
a render target have needed is reported as camera[<handle>].* rows:
depth and motion vectors under any rasterized pass, and the occlusion
channel and G-buffer over them under a camera's scene render. A draw builds
what it needs, and the set goes once no live camera names the target and
sixty frames have passed without a draw, so a target nothing draws into
carries no such row; the colour image drawn into belongs to the texture
cache and outlives every one of those releases.
totalBytes is what the resident targets hold together. Measured at the
end of the last rendered frame.
Returns { targets, totalBytes, residentCount }
local rt = renderer.renderTargets()
print(("render targets: %.1f MiB over %d resident"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
if t.onDemand then print(t.name, t.resident, t.bytes) end
end
globals/renderer/resolutionScale
renderer.resolutionScale() -> number
The fraction of the display resolution the scene is currently rendered
at. 1 until something sets it.
Returns number
local s = renderer.resolutionScale()
globals/renderer/setAnisotropy
renderer.setAnisotropy(level: number) -> number
Set the maximum anisotropy material textures are sampled with. Takes effect on the next frame for content already on screen — no reload, no texture re-upload. 1 is plain trilinear.
Parameters
levelnumber— 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
enabledboolean—boolean
Returns ()
renderer.setBlendedBatching(false) -- a draw per blended renderable
globals/renderer/setDepthPrepass
renderer.setDepthPrepass(enabled: boolean) -> ()
Enable or disable the opaque depth pre-pass. While enabled the renderer resolves opaque depth in its own pass before shading, so each shaded pixel runs its material once instead of once per surface stacked behind it, and the resolved depth is what occlusion culling reads. Enabled by default.
Parameters
enabledboolean—boolean
Returns ()
renderer.setDepthPrepass(false) -- shade every layer, for comparison
globals/renderer/setDepthPrepassOrdering
renderer.setDepthPrepassOrdering(enabled: boolean) -> ()
Submit the depth pre-pass nearest-first. Renderables reach the pre-pass
in the order they were registered, which stands in no relation to where the
camera is: a scene built back-to-front makes every layer write depth and be
overwritten by the layer in front of it. Ordered, the nearest surface
writes first and the surfaces behind it are rejected by the depth test
before they write. The same draws go out either way and the depth that
comes out is the same, so scene.depth_prepass in profiler.gpuFrame() is
what moves. Enabled by default.
Parameters
enabledboolean—boolean
Returns ()
renderer.setDepthPrepassOrdering(false) -- submit in registration order
globals/renderer/setGpuMemoryTracking
renderer.setGpuMemoryTracking(frames: number?) -> number
Set how often the GPU allocator sampler reads — one reading every
frames frames — or turn it off with 0. It starts at 60, a reading a
second at 60 Hz, so renderer.gpuMemory().allocator answers without
anything arming it. Building the ledger walks every live allocation, which
is why it is sampled rather than read every frame; the category figures
cost nothing either way, and a reader between samples sees the most recent
ledger, so a slow interval still answers.
Called with no argument it reports the interval in force and changes nothing, which is how something that retimes the sampler puts it back afterwards instead of restoring a number it assumed was the default.
Parameters
framesnumber(optional) —number?Frames between readings; 0 turns the sampler off. Omit to read the interval without changing it.
Returns number — The interval now in force.
renderer.setGpuMemoryTracking(60)
local was = renderer.setGpuMemoryTracking()
renderer.setGpuMemoryTracking(1)
-- ... take a tight reading ...
renderer.setGpuMemoryTracking(was)
globals/renderer/setMaxFramesInFlight
renderer.setMaxFramesInFlight(frames: number) -> number
Set how many frames of GPU work may be outstanding before the renderer stops running ahead. One is the least overlap this can express — a frame's work is waited for as soon as the next frame has been submitted — which is the lowest latency and the lowest throughput; higher values let a slow frame build a longer backlog, and that backlog is memory. Takes effect on the next frame.
Answers the bound after clamping to [1, 8], so asking for more than the renderer honours reports what you actually got.
Parameters
framesnumber— number Frames of GPU work that may be outstanding, 1 through 8.
Returns number — The bound that took effect, after clamping.
renderer.setMaxFramesInFlight(1) -- lowest latency
print(renderer.setMaxFramesInFlight(99), "was clamped")
globals/renderer/setMinScreenSize
renderer.setMinScreenSize(pixels: number) -> ()
Stop drawing an object once its on-screen radius falls below this many
pixels. A few pixels across, an object carries no detail a viewer can
resolve while still costing a full vertex and submission pass, and the
cutoff drops it from the camera's draws entirely — 0, the default,
keeps every object however small it lands. Measured from the object's own
bounds against the camera's projection, so the same threshold means the
same apparent size at any distance or field of view. Shadow casters have
their own threshold in renderer.setShadowCasterCutoff.
Parameters
pixelsnumber—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 pre-pass depth into a pyramid each frame and tests every renderable that cleared the frustum against it, dropping the ones another surface entirely covers before their geometry is submitted. The pyramid describes the frame being drawn, so an object that becomes visible this frame is never held back a frame. Requires the depth pre-pass.
Parameters
enabledboolean—boolean
Returns ()
renderer.setOcclusionCulling(true); print(renderer.cullStats().occlusionCulled)
globals/renderer/setPointShadowBudget
renderer.setPointShadowBudget(cfg: {
megabytes: number?,
resolution: number?,
}) -> number
Set how much VRAM the point-light shadow atlas may hold, and at what
per-face resolution. An omitted field keeps its current value. The atlas
is reallocated on the next frame, so renderer.pointShadowBudget().slots
reports the new pool one frame later; the returned number is what this
budget buys. Raising resolution sharpens every point shadow and spends
the same memory on fewer of them — doubling it quarters the slot count.
Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the
pool never exceeds renderer.pointShadowBudget().maxSlots. One slot is
always granted, so a budget too small for a single cube shadows one light
and the pool costs what that slot costs rather than what was asked for —
{ megabytes = 1, resolution = 4096 } buys 384 MiB of ceiling. Read
pointShadowBudget().bytes back to see what a budget actually bought, and
renderer.shadowMemory().point to see what the scene has made resident.
Parameters
cfg{ megabytes: number?, resolution: number?, }— The fields to change —megabytesand/orresolution.
Returns number — Cube slots this budget buys.
renderer.setPointShadowBudget({ megabytes = 96 })
renderer.setPointShadowBudget({ resolution = 1024, megabytes = 96 })
globals/renderer/setPresentMode
renderer.setPresentMode(mode: string) -> string
Set how a presented frame reaches the display. fifo queues every frame
and shows it on a vertical blank, which never tears and never drops one;
mailbox replaces the queued frame with the newest, which does not tear
and does not hold the renderer to the refresh rate; immediate presents as
soon as a frame is ready and can tear; fifo_relaxed is fifo that tears
rather than stall when a frame misses its blank; auto_vsync and
auto_no_vsync leave the choice to the backend.
A surface that does not offer the mode presents fifo instead, so read
renderer.framePacing().presentMode for what took effect and
.presentModes for what this surface offers. Takes effect on the next
frame.
Parameters
modestring— string One of "fifo", "fifo_relaxed", "mailbox", "immediate", "auto_vsync", "auto_no_vsync".
Returns string — The canonical spelling of the request — renderer.framePacing().presentMode is what the surface presents with, and differs when the surface does not offer the request.
renderer.setPresentMode("mailbox")
print(renderer.framePacing().presentMode, "is what the surface took")
globals/renderer/setProjectionOffset
renderer.setProjectionOffset(x: number, y: number)
Offset the main camera's projection by a sub-pixel amount, in NDC, for
the frames until it is set again. The offset is in NDC because that is the
space it is constant in: one pixel is 2.0 / width across, so half a pixel
is 1.0 / width. Velocity (@scene.motion) is measured against the
offset-free projection, so a still scene reports no motion however the
samples are placed — and picking resolves a click to the same ray either
way. (0, 0) samples pixel centres.
Parameters
xnumber— Horizontal offset in NDC. One pixel is2.0 / width.ynumber— Vertical offset in NDC. One pixel is2.0 / height.
renderer.setProjectionOffset(0.5 * 2 / w, -0.25 * 2 / h)
globals/renderer/setRaytrace
renderer.setRaytrace(enabled: boolean) -> ()
Enable or disable GPU ray tracing. While enabled the engine builds the scene acceleration structure each frame so ray-tracing render features can trace against it; disabling stops the build (so it costs nothing until a ray-traced effect is active). Required before any ray-traced shadows / AO / reflections render.
Parameters
enabledboolean—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 and present
it at the display's own size. Shading cost scales with pixel count and with
nothing else, so this trades sharpness for frame time without taking
anything out of the scene: at 0.5 the scene rasterizes a quarter of the
pixels. UI and text are unaffected — they are drawn after the scene is
brought back up to size. The scene rows in profiler.gpuFrame() are what
move.
Parameters
scalenumber—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/setShadowCaching
renderer.setShadowCaching(enabled: boolean) -> ()
Whether a shadow map that nothing changed is kept rather than drawn again. On by default: a shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is rasterized on the frames its own inputs change and holds the depth it drew on the ones they do not. Off, every view is drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.
Parameters
enabledboolean—boolean
Returns ()
renderer.setShadowCaching(false) -- draw every shadow view, every frame
globals/renderer/setShadowCasterBatching
renderer.setShadowCasterBatching(enabled: boolean) -> ()
Whether a shadow view draws every caster of one mesh together. On by default: a view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — submits one draw per geometry over every caster of it the view admits, wherever those casters sit in render order and whatever transform slots they hold. Off, a view draws the runs of render-order neighbours that share a mesh AND hold consecutive slots, so a scene that has spawned and despawned anything fragments into many more draws. The image is the same either way, which is what makes this the comparison a shadow suspected of being placed by the batching is made against.
Parameters
enabledboolean—boolean
Returns ()
renderer.setShadowCasterBatching(false) -- draw the runs the scene presents
globals/renderer/setShadowCasterCutoff
renderer.setShadowCasterCutoff(cfg: {
minRadiusPx: number?,
maxDistance: number?,
}) -> ShadowCasterCutoff
Set the shadow-caster cutoff. An omitted field keeps its current value,
so a call can adjust one threshold without restating the other. Both are
measured against the camera the frame draws from rather than against each
light, so one setting covers every cascade, spot and cube face, and a
caster that stops casting is one whose shadow the viewer could not have
resolved. maxDistance is measured to the near side of the caster's
bounding sphere, so a large object keeps casting while any part of it is in
range. 0 releases a threshold; releasing both draws the casters the frame
drew before either was set.
Parameters
cfg{ minRadiusPx: number?, maxDistance: number?, }— The fields to change —minRadiusPxand/ormaxDistance.
Returns ShadowCasterCutoff — The cutoff now in force.
renderer.setShadowCasterCutoff({ minRadiusPx = 2 })
renderer.setShadowCasterCutoff({ minRadiusPx = 1.5, maxDistance = 120 })
globals/renderer/setShadowConfig
renderer.setShadowConfig(cfg: {
resolution: number?,
cascades: number?,
distance: number?,
splitLambda: number?,
fadeFraction: number?,
softness: number?,
}) -> ShadowConfig
Set the directional shadow quality. Any omitted field keeps its current
value, so a call can adjust one knob without restating the rest. Values are
clamped: resolution [64, 8192], cascades [1, 4], distance >= 0, splitLambda
[0, 1], fadeFraction [0, 1], softness [0, 1]. Changing resolution or
cascades reallocates the depth array; the rest are per-frame values. A
distance of 0 hands the range to the frame — the splits are cut over the
depth its own shadow-taking renderables reach — and a positive one caps it,
which is what a scene bounding its shadow cost states.
Parameters
cfg{ resolution: number?, cascades: number?, distance: number?, splitLambda: number?, fadeFraction: number?, softness: number?, }— The fields to change — seeShadowConfig.
Returns ShadowConfig — The full config now in force.
renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ cascades = 4, resolution = 2048, distance = 240 })
globals/renderer/setShadowHero
renderer.setShadowHero(entity: string, padding: number?) -> ()
Give one caster a directional shadow view of its own, fit to its world bounds.
A cascade covers the slab of world the camera sees, so its texels are spread
over tens of metres and one character standing in the middle of it is
resolved by a handful of them. The hero view is the same light and the same
depth range zoomed onto that entity's bounds, so the whole map goes into the
shadow it and the ground under it carry — renderer.shadowHero().zoom is
the factor its texel density gains.
It renders beside the cascades, into a layer of the same texture allocated while a hero is registered, and every surface inside it reads it in place of the cascade, crossing back at its edge. Nothing else about the shadow changes: the same casters reach it, at the same depth range, through the same filter.
Parameters
entitystring— The entity whose renderables the view is fit around.paddingnumber(optional) — How much room the fit leaves around those bounds — for a pose that leaves the bind-pose box and for the filter that samples outside a silhouette. 1.0 fits them exactly.
Returns ()
renderer.setShadowHero(player.id)
print(("hero shadow: %.1f texels/unit vs %.1f"):format(
renderer.shadowHero().texelsPerUnit, renderer.shadowHero().cascadeTexelsPerUnit))
globals/renderer/setShadowProxy
renderer.setShadowProxy(mesh: string, proxy: string) -> ()
Rasterize proxy in place of mesh in every shadow view. A shadow is a
silhouette resolved at the resolution of a shadow map, so the triangles that
carry a mesh's close-up detail write depth no reader can resolve — a
decimated version of the shape, a level of its own LOD chain, or a
hand-built hull casts the same shadow for a fraction of the geometry.
The registration is keyed by MESH, so one call covers every instance of it — entities and GPU-driven populations alike — and a crowd sharing that mesh stays one draw. The proxy is placed by whatever places the caster, its instance's own transforms, so it stands where the caster stands, at the caster's scale.
An entity caster keeps its own geometry where a stand-in could not be placed
or deformed correctly: it is skinned (it rasterizes the post-skinned
vertices written for its own mesh), it blends morph targets (whose deltas
describe its own mesh and are read by vertex id), or its proxy would be
placed by a different node of its model than the source mesh is. Either
caster keeps it where the renderer holds no geometry under the proxy's
guid. Each of those is counted in renderer.shadowProxies().
Nothing else in the scene draws a proxy, so this call is what brings it onto the GPU, and it raises where it cannot. A proxy already resident there is registered as it stands.
Parameters
meshstring— The mesh a caster draws, as a guid or any mesh reference.proxystring— The mesh it rasterizes into shadow views instead.
Returns ()
renderer.setShadowProxy(statueMesh, statueHullMesh)
print(renderer.shadowProxies().triangles, "vs", renderer.shadowProxies().sourceTriangles)
globals/renderer/setSkinnedBatching
renderer.setSkinnedBatching(enabled: boolean) -> ()
Whether skinned instances holding one pose draw together. On by default:
instances of one mesh wearing one material and posed alike read the same
post-skinned vertices, so the camera's colour passes submit them as a single
instanced draw, and so does each shadow view and the velocity pass while
renderer.shadowCasterBatching() is on — that switch is what makes a depth
view form its draws by geometry at all. The camera depth pre-pass submits
its casters nearest-first, which is a run per span of neighbours rather than
a draw per geometry, so a crowd costs a draw per member there. Off, each
skinned instance draws on its own at its own slot in every pass that
rasterizes it. The image is the same either way, which is what makes this
the comparison a frame suspected of being formed by the batching is made
against — renderer.drawStats().draws counts the difference and
renderer.skinningStats().poses says how many distinct poses it holds.
Parameters
enabledboolean—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
enabledboolean—boolean
Returns ()
renderer.setSkinningPoseHold(false) -- dispatch every pose, every frame
globals/renderer/setSpotShadowBudget
renderer.setSpotShadowBudget(cfg: {
megabytes: number?,
resolution: number?,
}) -> number
Set how much VRAM the spot/area shadow atlas may hold, and the per-side
resolution of one layer. An omitted field keeps its current value. The
atlas is reallocated on the next frame, so renderer.spotShadowBudget()
reports it one frame later; the returned number is what this budget buys.
Raising resolution sharpens the lights that cover the most screen and
spends the same memory on fewer layers — doubling it quarters the layer
count. Raising megabytes buys layers, which is what lets several lights
hold a large tile at once. Values are clamped: megabytes [1, 1024],
resolution [64, 4096], and the atlas never exceeds
spotShadowBudget().maxLayers. One layer is always granted, so a budget
too small for one still shadows lights and the atlas costs what that layer
costs rather than what was asked for.
Parameters
cfg{ megabytes: number?, resolution: number?, }— The fields to change —megabytesand/orresolution.
Returns number — Atlas layers this budget buys.
renderer.setSpotShadowBudget({ megabytes = 64 })
renderer.setSpotShadowBudget({ resolution = 2048, megabytes = 64 })
globals/renderer/setTextureBudget
renderer.setTextureBudget(opts: TextureBudgetOpts) -> TextureBudget
Bound the VRAM a world's textures occupy, by keeping only the mip levels
the frame is actually sampling. Pass { megabytes = 256 }; 0 — the
default — leaves texture residency alone and every texture stays fully
resident the way it uploaded.
With a budget armed, each frame measures how many screen pixels ONE
traversal of a texture's coordinate range covers on the surface that spans
it widest, and asks for the mip level that serves that span one texel per
pixel — the level the GPU picks from the fragment's own derivatives. A
material with uvScale = 8 lays eight copies of its texture across a
surface, so each copy spans an eighth of the surface and asks for three
levels coarser than the surface's own size would. A shader that declares
// @uv_space: world advances its coordinate over world units rather than
over the mesh's UVs, so how many copies a surface carries follows how large
that surface is. The textures whose surfaces cover the fewest pixels give
up levels until the set fits. Detail climbs one level per frame, from the
image already on screen, so a surface the camera approaches sharpens rather
than popping, and no texture is taken below the level whose longest side is
64 texels.
bias shifts every measurement by whole mip levels either way — negative
for finer than the sampling implies, positive for coarser — over a world
whose look wants a different trade than one texel per pixel.
The plan moves a texture whose demand the frame can measure: one at least 256 texels on its narrowest side, worn by a surface an entity draws. A texture a UI image, a post-process property or a render feature holds a view of stays whole, because nothing measures how much of the screen those cover.
Which textures the budget governs follows the surfaces the frame draws. A
texture whose asset still holds its bytes is enrolled the frame a measured
surface wears it — whenever it loaded, and whenever the budget was armed —
because a level change reads the levels it needs back from the asset; when
the last such surface goes it leaves the set whole, at the level it
uploaded at, and a surface reaching it again takes it back up. A texture a
script uploaded has its pixels nowhere else, so one enrolled while it is
resident holds them in system memory
(renderer.textureMemory().streamSourceBytes) from the upload until a
surface has worn it and gone, and releases them then, which is what keeps
it out for the rest of the session; one whose pixels were already released
when the budget was armed is out from the start.
renderer.textureMemory().pinnedTextures counts those, together with the
textures whose asset could not be read back and the ones a UI image, a
post-process property or a render feature holds a view of. Disarming
returns every texture to the level it uploaded at, and arming again governs
the textures the frame's surfaces are wearing then.
Parameters
optsTextureBudgetOpts—{ megabytes: number?, bias: number? }
Returns TextureBudget — { megabytes, bias } — the budget now in force
renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 }) -- one level finer everywhere
renderer.setTextureBudget({ megabytes = 0 }) -- leave residency alone
globals/renderer/setTransmissionShadows
renderer.setTransmissionShadows(enabled: boolean) -> ()
Let translucent casters tint the sunlight they block instead of blocking
it outright. A shadow map holds one depth per texel and is compared as a
yes-or-no test, so stained glass, water and thin fabric all project the same
black silhouette a wall does. With this on, a caster whose material declares
opacity (base_color alpha under a transparent blend) or transmission
also draws into a light-space transmittance map, and the colour it lets
through multiplies into the directional light reaching whatever stands
behind it. Stacked casters compose. Opaque casters are unaffected, and a
scene with no translucent caster allocates nothing and records no pass.
Parameters
enabledboolean—boolean
Returns ()
renderer.setTransmissionShadows(true) -- stained glass tints the floor
globals/renderer/shaderCache
renderer.shaderCache() -> ShaderCache?
What the shader compile gate's store of baked WGSL held, answered and
wrote back. Compiling a .shader wraps the author's body in its framework,
expands every #include, and hands the result to naga to parse and
validate — work that is a pure function of the text going in, and that a
launch would otherwise repeat for every shader it draws with. The store
keeps that baked text across launches.
restoredEntries and restoredBytes are what a previous launch left that
this one read back. hits counts the compiles answered out of the store
and misses those that ran in full; savedMs sums what each hit's own
recorded compile had cost, against compileMs, what the misses spent.
stale counts the misses whose key was held but whose #included modules
had changed underneath — an entry records every module its expansion
consumed, so editing a module invalidates exactly the shaders that included
it and leaves the rest.
entries and bytes are what the store now holds, evictions how many a
write dropped to stay inside its bounds, and saves / savedBytes /
dirty describe writing it back, deferred until a burst of compiles
settles. persistent is false where a launch has nowhere to keep
artifacts and reason says why; location is the file, or the browser
store, they are kept in. restoreState is how the read of what a previous
launch left has gone — pending while it is still out (a browser answers
through a promise, so a launch reaches its first frames before it lands),
restored once entries came back, empty when there were none to come
back, failed when what was there could not be read, and none where a
launch keeps nothing. A cold, missing or corrupt store leaves every
shader compiling from source with identical output, and lastError then
names what went wrong.
Returns ShaderCache? — { persistent, reason, restoreState, location, restoredEntries, restoredBytes, hits, misses, stale, entries, bytes, compileMs, savedMs, saves, savedBytes, dirty, evictions, lastError }, or nil on a build with no renderer
local c = renderer.shaderCache()
print(("%d hits, %d misses, %.1f ms saved"):format(c.hits, c.misses, c.savedMs))
globals/renderer/shaderCost
renderer.shaderCost() -> { ShaderCost }
What each program has cost in pipeline builds, beside the compile
gate's most recent word about it. variants is how many pipelines this
engine has built for it — one per (target format, vertex layout,
render-state key) permutation reached — and buildMs what those builds
cost, both summed since engine start. A pipeline the driver's own store
restored is not built and so is not counted, so a second launch on the same
adapter reports less than the first. status is compiled, failed or
pending, and error carries the compiler's message for a failure.
Ordered by cost, most expensive first.
Returns { ShaderCost }
local top = renderer.shaderCost()[1]; print(top.shader, top.variants, top.buildMs)
globals/renderer/shaderVariants
renderer.shaderVariants() -> { ShaderVariants }
Every shader that declares optional features, and the programs its
materials have made it compile. Each row carries the features the shader
declares, the base program it ships as, and one entry per variant with the
features that variant holds — so the permutation count a scene's materials
are spending is a number to read rather than something to infer from
compile time. A shader whose variants reach budget compiles no more; the
materials asking for further feature sets draw with the base program.
Returns { ShaderVariants } — An array of ShaderVariants, one per feature-declaring shader.
for _, s in renderer.shaderVariants() do print(s.shader, #s.variants, s.budget) end
globals/renderer/shadingOf
renderer.shadingOf(subject: string | { [string]: any }) -> ShadingReading
What the renderer is shading ONE subject with, taken from the document
the renderer publishes — the call a system holding a handle makes to find
out whether what reaches the screen is its own material or the magenta
placeholder standing in for it, without reading the engine log. subject is
an entity that draws or the registry key of a material. state reads
itsMaterial where the renderer bound the program the material names,
errorMaterial where it bound the placeholder instead, stalePipeline
where the pipeline drawing it was built before that program's most recent
compile, nothingBound where the renderer resolved no pipeline for it,
pending where this call is the one that armed per-draw recording and the
frame after it publishes, and unknown where the renderer holds a
resolution under no such subject. A fault state carries the renderer's own
reason from the closed set renderer.drawDiagnostics() names — plus
materialNotPrepared, which a material subject reads where the renderer
prepared nothing under that key — the compiler's detail, the program
the material asked for and the bound one; means states the reading in a
sentence. A material subject answers from the renderables drawing with it,
and from the renderer's record for the material itself where a draw
registered against the material carries no row of its own; a subject that
several renderables draw answers with a refused one wherever there is one.
The reading follows the renderer, so a program that compiles on a later
edit puts the subject back on itsMaterial from the frame the renderer
draws it with again.
Parameters
subjectstring | { [string]: any }— The entity — a proxy fromentity(...)or an entity-id string — or the material, as its registry key or theMaterialHandlerenderer.material.createreturned.
Returns ShadingReading
local s = renderer.shadingOf(emitter); if s.state ~= "itsMaterial" then print(s.means) end
globals/renderer/shadowCacheStats
renderer.shadowCacheStats() -> {
What the last frame did with the shadow maps it already had. A shadow
view — one directional cascade, one atlas layer of spot tiles, one face of
a point light's cube — is drawn again only when something it draws from
changed:
its light moved, a caster it can see moved or appeared or vanished, a
caster's geometry or material changed, a caster changed pose or moved the
nodes its parts are placed by, or the map it writes into was reallocated.
Anything else keeps the depth already in the texture, so a scene that stops
moving reads rendered 0 while cached keeps climbing. A mesh whose
vertices a compute pass writes — a population, or a mesh built from a
compute buffer — re-renders the views it stands in every frame. A shadowed
point light contributes six views, one per cube face, so a caster moving on
one side of it re-renders the face that can see it and leaves the other
five holding what they have. Counted per light kind, plus the totals across
all three.
These are totals over every view of a kind. renderer.shadowViews() is the
same frame one view at a time, each row naming the light that owns it and
what it drew.
Returns { directionalRendered, directionalCached, spotRendered, spotCached, pointRendered, pointCached, rendered, cached }
local s = renderer.shadowCacheStats()
print(("shadow views: %d drawn, %d cached"):format(s.rendered, s.cached))
globals/renderer/shadowCaching
renderer.shadowCaching() -> boolean
Whether a shadow view may keep the depth it already holds.
Returns boolean
globals/renderer/shadowCasterBatching
renderer.shadowCasterBatching() -> boolean
Whether a shadow view draws every caster of one mesh together.
Returns boolean
globals/renderer/shadowCasterCutoff
renderer.shadowCasterCutoff() -> ShadowCasterCutoff
How small, and how far away, a caster may get before it stops writing depth into any shadow view. A shadow view rasterizes a caster's whole triangle count whatever the shadow it produces ends up covering, so an object the viewer resolves a fraction of a pixel of, and one past the range the scene cares about, each cost a full depth pass per shadowed light for detail nothing reads. Both thresholds are 0 — released — until something sets them.
Returns ShadowCasterCutoff — The cutoff in force — see ShadowCasterCutoff.
local c = renderer.shadowCasterCutoff(); print(c.minRadiusPx, c.maxDistance)
globals/renderer/shadowConfig
renderer.shadowConfig() -> ShadowConfig
The directional shadow quality now in force. resolution and cascades
size the cascade depth array; distance and splitLambda place the splits
along the view; fadeFraction and softness shape how the result is
sampled.
Returns ShadowConfig — The full config — see ShadowConfig.
print(renderer.shadowConfig().cascades)
globals/renderer/shadowHero
renderer.shadowHero() -> ShadowHeroReport
The registered hero caster and what the last frame's fit produced. A
frame that fit nothing says why in decline.
Returns ShadowHeroReport — See ShadowHeroReport.
local h = renderer.shadowHero()
print(h.active, h.zoom, h.decline)
globals/renderer/shadowMemory
renderer.shadowMemory() -> {
How much GPU memory the shadow maps hold right now, in bytes, by the
light kind that owns them. The spot atlas and the point pool are sized to
the casters in the scene rather than to the budget, so spot and point
move as lights that cast shadows appear and leave, and a scene with one
shadowed light holds far less than one that fills every slot. A budget is
the ceiling they grow within — renderer.spotShadowBudget().layers and
renderer.pointShadowBudget().slots report that ceiling, unmoved by how
many casters exist. Raising shadow resolution costs the square of the
change across every cascade.
Returns { directional, spot, point, total } in bytes
local m = renderer.shadowMemory()
print(("shadows: %.1f MiB"):format(m.total / 1024 / 1024))
globals/renderer/shadowProxies
renderer.shadowProxies() -> ShadowProxyReport
The shadow proxies in force and what the last frame's shadow passes did
with them. triangles and sourceTriangles are what those passes
submitted and what they would have submitted from the source meshes — the
before/after of every registration, equal while nothing is proxied.
Returns ShadowProxyReport — See ShadowProxyReport.
local p = renderer.shadowProxies()
print(("shadow triangles: %d of %d"):format(p.triangles, p.sourceTriangles))
globals/renderer/shadowViews
renderer.shadowViews() -> ShadowViewReport?
Every shadow view the last rendered frame considered, and what each one cost.
A frame rasterizes a depth view per directional cascade, one for the hero
caster, one per shadow-casting spot and six per shadow-casting point.
renderer.shadowCacheStats() counts those views by light kind,
renderer.drawStats() sums their draws with the camera's, and
profiler.gpuFrame() carries one scene.shadow span across all of them.
This is the same frame read one view at a time.
Each row names the view and the light that owns it, says whether it drew or
kept the depth it already held, and carries the draws, the instances and the
casters that went into it. span is the label the view's pass is timed
under, so its GPU time is a lookup in profiler.gpuFrame(); every one of
those labels is a variant of scene.shadow, which still carries their
total. camera carries the same instance counters for the main camera, so
the camera's share of a frame-wide total is a read rather than a measurement
taken by turning every light's shadow off.
A cascade's near and far are where the split scheme cut its slice, not
the world it covers: the fit takes the bounding sphere of that slice and
rasterizes the ortho box around it, and both reach past far. What the
cascade covers is center and radius, with viewProj the exact test;
coversNear and coversFar read that volume back along one ray, the
camera's view axis. directional states the axis reading for the set —
how far it reaches (coversFar), the range the splits were run over
(distance), how far the camera draws (cameraFar), and the
depth past the reach the camera still draws (uncovered). A receiver
further along the axis than coversFar has no directional depth map over
it and is shaded as if the sun reached it, so uncovered is the room a
missing shadow has and a surface standing in that room is what makes one;
@builtin::systems.proxyOcclusion occludes past the cascades. The box is
bounded in every direction, so a receiver standing wide of the axis leaves
it at its own distance even where uncovered is 0 — viewProj is what
answers for that receiver.
The list is rebuilt every frame: a view whose light stopped casting is
absent from the next report rather than standing at the numbers it last had,
and a frame that drew no shadow view answers a report whose views is
empty. views grouped the way the shadow cache decides — a row per cascade,
per spot atlas layer, per point cube — counts what
renderer.shadowCacheStats() reports as rendered + cached.
The frame names its views only while something is reading them, so this call asks the frames after it to name theirs and waits out the first one. Nil on an engine that renders no frame at all.
Returns ShadowViewReport? — See ShadowViewReport.
local report = renderer.shadowViews()
print(("camera submitted %d of %d instances"):format(
report.camera.submittedInstances, renderer.drawStats().compactedDrawn))
for _, view in report.views do
print(("%s %d (%s): %d draws, %d instances, %s"):format(
view.role, view.index, view.light, view.draws, view.instances,
view.rendered and "drew" or "cached"))
end
local d = report.directional
if d ~= nil and d.uncovered > 0 then
print(("sun shadows stop at %.0f; the camera draws to %.0f"):format(
d.coversFar, d.cameraFar))
end
globals/renderer/skinnedBatching
renderer.skinnedBatching() -> boolean
Whether skinned instances holding one pose draw together.
Returns boolean
globals/renderer/skinningPoseHold
renderer.skinningPoseHold() -> boolean
Whether a pose already written into its slice skips its dispatch.
Returns boolean
globals/renderer/skinningStats
renderer.skinningStats() -> {
What the last frame's skinned instances cost. A skinned instance is
posed by a compute pass that writes its vertices into a shared pool, and
instances holding the same pose read one slice of that pool and the single
dispatch that fills it. instances is how many were posed, poses how
many distinct poses they held, and dispatches how many dispatches those
poses cost this frame — so a crowd whose members move together costs what
its poses cost rather than what its head count does, while members at
different animation times each hold their own pose and pay for it.
held is how many of the frame's poses cost no dispatch at all. The pass
produces a slice from what the pose is made of, so a slice an earlier frame
filled already holds what running it again would write, and a pose still
wearing that slice is read as it stands. Skinning is paid for by the poses
that CHANGED: a cast standing still reads dispatches 0 beside a held
equal to its poses, and the two add up to poses in any frame.
reusedSlices is how many of the frame's poses took a slice the pool
already held — one a retired pose gave back, or one a pose nothing has
asked for this frame was holding — rather than one cut from pool the
engine had never used. A scene whose poses keep changing reads a non-zero
count beside a poolBytes that stays where it was.
liveBytes is what the slices holding this frame's poses occupy, against
unsharedBytes — what the same instances would occupy with a slice each.
poolBytes is what the pool holds; a previous-position buffer of the same
size rides alongside it so skinned deformation reaches motion vectors.
Returns { instances: number, poses: number, dispatches: number, held: number, reusedSlices: number, liveBytes: number, unsharedBytes: number, poolBytes: number }
local s = renderer.skinningStats()
print(("%d instances over %d poses"):format(s.instances, s.poses))
print(("%d poses dispatched, %d read as they stood"):format(s.dispatches, s.held))
print(("skinned vertex storage: %d B of an unshared %d B"):format(s.liveBytes, s.unsharedBytes))
globals/renderer/splat/components
renderer.splat.components(bytes: any?, convention: string?) -> (SplatComponents?, string?)
Decode a Gaussian splat capture — a Niantic .spz (gzipped or raw) or a
3DGS .ply — into the GPU-ready byte pools a render feature uploads.
records is the packed splat array at recordBytes per splat (position,
log scale, quaternion, DC colour + opacity); sh is the quantized
higher-order spherical-harmonics pool at shStrideWords u32 words per
splat, empty at degree 0. A pure decode (no GPU work): upload the pools with
shaderRef:createBuffer + buf:writeBytes and draw them with a
kind = "splat", channel = "gaussian" pass.
Parameters
bytesany(optional) — Capture bytes —.spzor.ply, as abufferor a binary string.conventionstring(optional) — Source axis convention:"rightDownFront"(the default, what COLMAP-trained captures use) or"engineNative"for a capture already in engine space.
Returns (SplatComponents?, string?) — { records, sh, count, shDegree, shStrideWords, recordBytes, boundsMin?, boundsMax?, antialiased, format }, or (nil, err).
local c = renderer.splat.components(vfs.readBytes("captures/ceramic.spz"))
globals/renderer/spotShadowBudget
renderer.spotShadowBudget() -> SpotShadowBudget
The spot and area-light shadow atlas now in force. Each shadow-casting
spot is given a tile of it every frame, sized to what the camera can
resolve: a light filling the view gets a whole layer at resolution, one
far away gets a minResolution tile, and the atlas holds tiles of the
smallest kind. That is what lets one budget serve a close hero light and a
street of distant ones without either the memory or the sharpness being set
for the worst case.
Returns SpotShadowBudget — The atlas — see SpotShadowBudget.
local s = renderer.spotShadowBudget()
print(("%d layers of %d, %.1f MiB"):format(s.layers, s.resolution, s.bytes / 1024 / 1024))
globals/renderer/temporal/held
renderer.temporal.held() -> boolean
Whether a hold is pinning the per-frame clock right now.
Returns boolean — True while at least one renderer.temporal.hold stands.
if renderer.temporal.held() then print("frame is pinned") end
globals/renderer/temporal/hold
renderer.temporal.hold(at: number?, options: TemporalHoldOptions?) -> () -> ()
Pin the clock every per-frame effect draws itself against, and return
the release. While the hold stands, renderer.temporal.now answers at
instead of the running clock, so film grain and every other field redrawn
each frame is redrawn as the same field. Two renders taken under holds at
the same instant therefore agree pixel for pixel wherever the scene itself
has not moved, which is what makes one frame comparable with another.
Holds nest: the innermost names the instant, and the clock runs again once
the last release is called. Each release takes its own hold off the stack
whatever order the releases come in, so two callers holding at once — two
captures in flight together — each end their own hold and the clock runs
again when both have.
exclusive takes the clock for the owner key the call states: while
that hold stands, a hold is admitted only when it states the same key, and
every other one is refused with an error naming the key and the instant
holding it. That is what lets one caller wind the clock to the second it
means to photograph and keep it there while another agent drives the same
engine. The key is what an owner presents to take a nested hold of its
own, and what renderer.temporal.release hands the clock back by. A
capture taken while the hold stands renders at the held instant; a
deterministic capture takes a hold of its own that states no key, so it
runs once the clock is handed back.
Parameters
atnumber(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.optionsTemporalHoldOptions(optional) —owneris the key this hold is taken under, and an exclusive hold states one. A hold that states no key is labelled with the agent the call is attributed to, which is the account the caller presented a token for and is shared by every session driving this engine under it.exclusivetakes the clock for the stated key until the hold is released.
Returns () -> () — A function that releases this hold. Calling it twice releases once.
local release = renderer.temporal.hold() ; local png = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()
local release = renderer.temporal.hold(46.0, { exclusive = true, owner = "stage-air" })
globals/renderer/temporal/now
renderer.temporal.now() -> number
The instant a per-frame effect should draw itself at: the innermost hold's instant while one stands, and seconds since boot otherwise. A system that redraws a field every frame reads this rather than the running clock, and a capture asking for a repeatable frame then gets one.
Returns number — Seconds — pinned while a hold stands, running otherwise.
local params = { grainTime = renderer.temporal.now() }
globals/renderer/temporal/onChange
renderer.temporal.onChange(listener: (number) -> ()) -> () -> ()
Register a listener called with the pinned instant whenever it changes — a hold taken, a hold released — and return the unsubscribe. A system whose shader reads the clock out of a GPU buffer registers here, so the buffer carries the pinned instant before the frame that hold was taken on is drawn rather than a frame later.
Parameters
listener(number) -> ()— Called with the instant now in force, in seconds.
Returns () -> () — A function that removes this listener.
local stop = renderer.temporal.onChange(function(t) pushClock(t) end)
globals/renderer/temporal/owner
renderer.temporal.owner() -> { id: string?, name: string?, at: number, exclusive: boolean }?
The hold naming the instant the clock answers right now: who took it,
what instant it pinned, and whether it took the clock exclusively. Several
agents drive one engine at once and a hold any of them takes moves the
clock every registered field is redrawn against, so this is how a caller
sees that another agent holds it before its own instant is quietly
replaced — and, when exclusive is true, id is the key a hold of its
own states to be admitted, and the key renderer.temporal.release hands
the clock back by. id and name are nil for a hold that stated no key
and that the engine attributes to no agent.
Returns { id: string?, name: string?, at: number, exclusive: boolean }? — { id, name, at, exclusive } for the standing hold, or nil when the clock is running.
local who = renderer.temporal.owner()
if who ~= nil and who.exclusive then print(who.name, "holds the clock at", who.at) end
globals/renderer/temporal/release
renderer.temporal.release(owner: string) -> number
Hand the clock back by the key its holds were taken under, and report how many came off. A hold stands until its release is called, and the release is a closure the call that took the hold holds: a caller that takes a hold in one call and comes back in another, and a task that ends between the two, both leave the clock pinned with nobody holding a release for it. Naming the key is how the clock runs again, and how a caller refused by an exclusive hold takes one over.
Parameters
ownerstring— The key the holds to release were taken under — whatownerstated when they were taken, whichrenderer.temporal.ownerreports.
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
texturestring | { [string]: any } | AssetRef— The texture to read back — aTextureHandle, a guid, aTextureCpuHandleor a textureAssetRef.
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
widthnumber— number Canvas width in pixels.heightnumber— number Canvas height in pixels.fillany(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
bytesbuffer | string— The ZTEX or image bytes.encodeOptsany(optional) —{ format?, srgb?, generateMipmaps?, maxDimension? }applied when the bytes are an encoded image and need the engine-native encode.
Returns TextureCpuHandle
local cpu = renderer.texture.cpuFromBytes(vfs.read(path)); local png = cpu:encodePng(); cpu:unload()
globals/renderer/texture/create
renderer.texture.create(src: any?, guid: string?) -> TextureHandle
Create (or fetch) a GPU texture resource and return its TextureHandle.
src: a TextureCpuHandle from texRef:load() (CPU→GPU under the asset's
guid, idempotent); raw pixels {rgba, width, height, srgb?, format?} (a
flat widthheight4 byte payload, 0-255, row-major, top-to-bottom, RGBA —
a buffer, a binary string, or a number array; format = "rgba16f"
uploads an HDR texture instead, where rgba carries float channel
values); a TextureHandle (returned as-is);
or render-target dimensions {width, height, name?, format?} with no pixel
source — an empty GPU texture a render pass writes into (camera output,
UI surface) and that samples like any other texture. format names the
colour format the target is allocated in, and the passes drawing into it
are built for that format: "rgba8unorm" / "bgra8unorm" (the two
eight-bit channel orders, either of which a surface may carry),
"rgba16f" / "rgba32f", "rg16f" / "rg32f", "r16f" / "r32f".
Each also answers to its spelled-out width ("rgba16float", "r32float",
and so on), in any case. Omit it to take the surface's own. A float format
carries what eight bits quantize — positions, velocities, HDR. Any other
format raises an error naming every name that works, so a target is
allocated in the format it was asked for or not at all. A render target
takes filter the way raw pixels do: "nearest" keeps its own pixels square
wherever something draws it larger than it is — a viewport widget, a
magnified capture — which is what an image whose pixels ARE the subject
needs, since a 64x32 panel holds no detail between its pixels to
interpolate; "linear" (the default) smooths between them. It also
takes screen (the engine keeps it the size of the image being drawn),
screenScale (the fraction of that size it takes) and screenSpace
("scene", the default, or "composite" — the image the post-scene
phases draw into, which is the display's own resolution while the renderer
presents the viewport itself and the scene's size while a UI viewport panel
owns the presentation). A scene-space target is resized for every render
target drawn and cleared before an offscreen one; a composite-space target
follows the presented frame alone, which is what lets a pass keep an
accumulation in it. One scene-space screen target is therefore one
resource every render target draws through in turn, so its guid holds the
last one's image at the last one's size, and a value read back from it
belongs to whichever render target was drawn last. A reading that has to
be the viewport's own comes from screenSpace = "composite", or from a
target created without screen. NEVER takes an AssetRef — load the CPU
first.
Parameters
srcany(optional) — A TextureCpuHandle, raw pixels, a TextureHandle, or render-target dimensions.guidstring(optional) — Optional v4 guid for a NEW runtime texture — the asset identity the texture is filed under, which a material's texture slot resolves through. Minted when absent. Ignored for the CPU-handle and render-target paths.
Returns TextureHandle
local gpu = renderer.texture.create(texRef:load())
local gpu = renderer.texture.create({ rgba = pixels, width = 16, height = 16 })
local px = buffer.create(16 * 16 * 4); local gpu = renderer.texture.create({ rgba = px, width = 16, height = 16 })
local rt = renderer.texture.create({ width = 512, height = 256, name = "panel_rt" })
local hdr = renderer.texture.create({ width = 512, height = 256, name = "cam_rt", format = "rgba16f" })
local led = renderer.texture.create({ width = 64, height = 32, name = "panel", filter = "nearest" })
globals/renderer/texture/createFromAsset
renderer.texture.createFromAsset(ref: string | AssetRef, encodeOpts: any?, keepCpu: boolean?) -> TextureHandle
Put a .texture asset on the GPU under its own guid and answer its
handle at once. The asset's bytes are decoded off the frame and the
texture lands on the device when the decode finishes, a frame or more
later: a material naming the guid draws the shader's default for that
slot until then and rebinds when it arrives, and
renderer.texture.isResident reports the arrival. The decoded pixels are
dropped once uploaded unless keepCpu holds them in the CPU store for
textureRef:load()-style reads. An asset the device already holds is
answered from the shape the device reports, without reading the asset's
bytes and without a second decode.
Parameters
refstring | AssetRef— A textureAssetRef, or a string naming one (guid, identity, name or source path).encodeOptsany(optional) —{ format?, srgb?, generateMipmaps?, maxDimension?, filter? }applied when the primary is an encoded source image and needs the engine-native encode (a.ztexprimary is decoded as-is).keepCpuboolean(optional) — Keep the decoded pixels in the CPU store after the upload.
Returns TextureHandle
local h = renderer.texture.createFromAsset(texRef) -- bind h.guid on a material; it draws once resident
globals/renderer/texture/decode
renderer.texture.decode(bytes: buffer | string) -> (any, any, any, any)
Decode a texture payload to its pixel buffer. Takes the two shapes the renderer's own texture loader takes, told apart by their leading bytes:
- an engine-native
ZTEXpayload — 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. AZTEXholding block-compressed or verbatim source-image levels decodes to"rgba8". - source image bytes — png, jpeg, gif or webp, straight off disk or out of
a
capture— decoded to"rgba8"at whatever colour type, bit depth or interlacing the file was written with. This is the call that reads the pixels of a screenshot.
The fourth return names the format the buffer came back in: "rgba8" (4
bytes/texel, channels 0-255), "rgba16" (8 bytes/texel, 16-bit unsigned
normalized channels 0-65535) or "rgba32f" (16 bytes/texel, float
channels).
Parameters
bytesbuffer | string— AZTEXpayload or source image bytes.
Returns (any, any, any, any) — (string?, number?, number?, string?) pixels, width, height, format — or (nil, errmsg) where errmsg is in the 2nd slot.
local pixels, w, h = renderer.texture.decode(vfs.read("/source/tmp/shot.png"))
globals/renderer/texture/destroy
renderer.texture.destroy(texture: string | { [string]: any } | AssetRef) -> boolean
Release the GPU texture texture names. For an empty render-into texture
(camera output, UI surface) this also frees its render scratch; for an
uploaded runtime texture it drops the GPU resource (and any CPU shadow).
After this, renderer.texture.list stops answering for the guid. Takes
every form that names a texture — the TextureHandle create returned, the
guid the listing hands out, a TextureCpuHandle or a texture AssetRef.
Parameters
texturestring | { [string]: any } | AssetRef— The texture to release — aTextureHandle, a guid, aTextureCpuHandleor a textureAssetRef.
Returns boolean true when a texture was known under the guid.
renderer.texture.destroy(rt)
renderer.texture.destroy(renderer.texture.list()[1].guid)
globals/renderer/texture/encode
renderer.texture.encode(rgba: any?, width: number, height: number, opts: any?) -> (string?, string?)
Encode raw pixels into an engine-native ZTEX payload (the on-disk
texture content). The CPU codec behind the texture assetType's onCreate.
opts.format selects the on-disk precision: "rgba8" / "srgb" (default,
8 bits/channel, rgba is widthheight4 bytes) or the high-precision data
formats "rgba16" (16-bit unsigned normalized, widthheight8 bytes) /
"rgba32f" (32-bit float, widthheight16 bytes) — for height/displacement
fields, baked lightmaps, and other data rasters an 8-bit format quantizes
visibly. The two high-precision formats store rgba verbatim and reject
opts.generateMipmaps / opts.maxDimension.
Parameters
rgbaany(optional) — Pixel payload atopts.format's native byte width — abuffer, a binary string, or a number array.widthnumber— numberheightnumber— numberoptsany(optional) —{ format?, srgb?, generateMipmaps?, maxDimension? }
Returns (string?, string?) ZTEX bytes, or (nil, errmsg).
globals/renderer/texture/encodeFromImage
renderer.texture.encodeFromImage(bytes: buffer | string, opts: any?) -> (string?, string?)
Encode source image bytes (png/jpg/webp/…) into an engine-native ZTEX
payload. Used by the texture importer / assetType onChange.
Parameters
bytesbuffer | string— source image bytes.optsany(optional) —{ format?, srgb?, generateMipmaps?, maxDimension? }
Returns (string?, string?) ZTEX bytes, or (nil, errmsg).
globals/renderer/texture/frameSchedule
renderer.texture.frameSchedule(texture: string | AssetRef) -> { number }?
The times at which each layer of a timed texture stops being shown, in seconds from the start of the sequence — the running total of the layer display times, so the last entry is the length of one pass.
This is the form a sampler reads a sequence through: a time is turned into
a layer by finding the first entry it has not passed, whatever the
individual layer times are. It is what the schedule slot of the builtin
animatedTexture shader holds, one entry per layer.
A texture whose layers carry no timing — a still image, a sprite sheet, a LUT stack — has no schedule and answers nil.
Parameters
texturestring | AssetRef— The texture — a guid, an identity, a name, a path, or a textureAssetRef.
Returns { number }? one cumulative end time per layer, or nil.
local ends = renderer.texture.frameSchedule(texRef) -- {0.1, 0.15, 0.35}
globals/renderer/texture/info
renderer.texture.info(ztex: buffer | string) -> (any, any)
Read the header of an engine-native ZTEX payload without copying the
pixels. Returns its format, dimensions, mip count, filter ("nearest"
or "linear" — the sampler baked into the blob from the asset's
settings.filter), and the payload's layer shape.
layers counts the array layers the payload carries and isArray is true
past one — the answer to "am I about to sample a texture_2d_array?",
available before anything samples it. animated is true when those layers
are a sequence in time; then frameDelaysMs lists each layer's display
time in milliseconds in display order, and durationMs totals one pass.
An animated image imports as one layer per frame, so layers is its frame
count. A still texture reports layers = 1, isArray = false.
Parameters
ztexbuffer | string— ZTEX bytes.
Returns (any, any) — (table?, string?) { format, width, height, mipCount, filter, layers, isArray, animated, frameDelaysMs?, durationMs? }, or (nil, errmsg).
local i = renderer.texture.info(bytes); if i.animated then print(i.layers, "frames", i.durationMs, "ms") end
globals/renderer/texture/isResident
renderer.texture.isResident(texture: string | { [string]: any } | AssetRef) -> boolean
True if a GPU texture is resident under this texture's guid.
Parameters
texturestring | { [string]: any } | AssetRef— The texture — aTextureHandle, aTextureCpuHandle, a guid, or a textureAssetRef.
Returns boolean
print(renderer.texture.isResident(handle))
globals/renderer/texture/list
renderer.texture.list() -> { any }
Every texture currently registered, ordered by guid — the ones a script
created and the ones that reached the device through an asset alike. Each
entry carries the guid, where it came from (origin is "asset" for a
texture the asset path uploaded), and whether the GPU still holds it. A
resident entry also carries the bytes it costs, its dimensions and its
texel format, so the listing sums to renderer.textureMemory(). A
streamable one carries streamOrigin — "asset" when a level change reads
the levels it needs back from the asset, "retained" when the cache holds
the pixels for it.
A script-created entry also carries held — whether renderer.hold pins
it for the session — and scene, the load that created it.
renderer.references("texture", guid) says what is still holding a row,
and renderer.collect() releases the rows nothing holds.
Returns { any } — Array of { guid, origin, owner?, scene?, held?, resident, bytes?, width?, height?, format?, compressed?, streamable?, streamOrigin? }.
for _, t in ipairs(renderer.texture.list()) do print(t.guid, t.bytes) end
globals/renderer/texture/loadCpu
renderer.texture.loadCpu(ref: string | AssetRef, encodeOpts: any?) -> TextureCpuHandle
Load a .texture asset's pixels into the ONE guid-keyed CPU store (the
Disk→CPU step) and return a CPU handle for per-pixel access (no GPU
readback). The handle holds NO pixels — only the guid, dims and texel
format plus the read/write/encode/unload ops (which read the Rust store).
The pixels stay at the format they were authored in: handle.format is
"rgba8", "rgba16" or "rgba32f", and :readPixel reports channels in
that format's own units. Called by texRef:load(). DEFAULT: upload to the
GPU then handle:unload().
Parameters
refstring | AssetRef— A textureAssetRef(carries.guid, reads its primary via getBytes), or any stringasset.refresolves to one — a guid, an identity, a name or a source path.encodeOptsany(optional) —{ format?, srgb?, generateMipmaps?, maxDimension? }applied when the primary is an encoded source image and needs the engine-native encode (a.ztexprimary 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
texturestring | { [string]: any } | AssetRef— The texture — theTextureHandlerenderer.texture.createreturned, a guid, or a textureAssetRef.
Returns TextureCpuHandle
local cpu = renderer.texture.readback(handle); local ztex = cpu:encode(); cpu:unload()
globals/renderer/texture/tone
renderer.texture.tone(histogram: any?) -> TextureTone
Reduce a histogram to what the picture's tone IS: where its darkest and brightest pixels sit, where the body of it sits, and how much of it is standing on the floor or the ceiling — all in code values on the 0-255 scale the pixels were delivered at.
span (max - min) is the whole range including a single stray pixel;
spread (p95 - p5) is the range the body of the picture occupies, which
is the reading that says whether a shot is legible. A frame whose subject is
modelled and shaded but delivered inside a few code values reads a large
mean and a tiny spread, and no mean alone can tell that apart from a
frame with a subject in it.
crushed and clipped are the shares of the picture at code 0 and at code
255, each 0..1 — what a shot loses to the floor and to the ceiling.
Parameters
histogramany(optional) — A histogram fromcpu:histogram().
Returns TextureTone
local t = cpu:tone(); if t.spread < 24 then error("the shot is flat") end
globals/renderer/texture/update
renderer.texture.update(texture: string | { [string]: any } | AssetRef, src: any?) -> TextureHandle
Overwrite the GPU texture texture names IN PLACE, under the same guid,
from new raw pixels. Never writes a .texture file — the play-mode mutate
path. Takes every form that names a texture — the TextureHandle create
returned, the guid renderer.texture.list hands out, a TextureCpuHandle
or a texture AssetRef. Returns a handle carrying the new dimensions: the
handle it was given, refreshed, and a handle over the guid otherwise.
Parameters
texturestring | { [string]: any } | AssetRef— The texture to update — aTextureHandle, a guid, aTextureCpuHandleor a textureAssetRef.srcany(optional) — New raw pixels{rgba, width, height, srgb?, format?}—rgbaas abuffer, a binary string, or a number array.
Returns TextureHandle — A TextureHandle for the updated texture.
globals/renderer/textureMemory
renderer.textureMemory() -> {
What the GPU texture cache holds, split by whether the texture is
block-compressed. compressedBytes and uncompressedBytes are what those
textures cost in VRAM, measured from each texture's own format and mip
chain — so a .texture whose settings name format = "bc7" appears in the
compressed columns at a quarter of what the same image costs as RGBA8.
blockCompressionSupported is whether this adapter can hold
block-compressed textures at all; where it is false a BC7 payload is
uploaded decoded and lands in the uncompressed columns instead, so the
texture is present everywhere and compressed where the hardware allows it.
Measured at the end of the last rendered frame.
streamableTextures is how many of them a texture budget can move the
base mip level of, split by where a level change reads the levels it needs
from: assetStreamedTextures are read back from the asset they came from
and hold nothing in system memory, retainedTextures hold the payload
because a script uploaded their pixels and the GPU copy is the only other
one there is. streamSourceBytes is what those held payloads occupy in
system memory — bytes that are not VRAM — so it is a reading on the
retained half alone. pinnedTextures counts the textures big enough to
stream that stand at a level nothing can move: their pixels were released
and no asset holds them, the asset behind them could not be read back, or a
UI image, a post-process property or a render feature holds a view of them.
A texture out of the streamable set only because no measured surface wears
it stands in neither count: a surface reaching it takes it back up, so its
level moves again as soon as there is a footprint to move it by. It reads 0
while no budget is armed.
Returns { blockCompressionSupported, compressedTextures, compressedBytes, uncompressedTextures, uncompressedBytes, streamableTextures, assetStreamedTextures, retainedTextures, pinnedTextures, streamSourceBytes }
local tm = renderer.textureMemory()
print(("%d compressed textures hold %.1f MiB"):format(tm.compressedTextures, tm.compressedBytes / 1048576))
print(("%d textures stream from their asset, %d hold %.1f MiB of pixels"):format(
tm.assetStreamedTextures, tm.retainedTextures, tm.streamSourceBytes / 1048576))
globals/renderer/textureStreaming
renderer.textureStreaming() -> TextureStreaming
What the last frame's texture-residency plan decided. budgetBytes is
the armed budget, and 0 means residency is left alone. streamable is
how many textures the plan can move. residentBytes is what those textures
occupy now, measured from the textures that are allocated; demandedBytes
is what the frame's demand alone would have cost, so the two part exactly
where the budget is doing something. starved counts the textures left
coarser than the frame asked for, promoted the ones that climbed a level
this frame, and changed the ones whose GPU texture was replaced. A camera
approaching a surface reads promoted above zero for a few frames and then
zero once it settles.
textures is one row per streamable texture, ordered by key, carrying the
level each one was asked for and the measurement that asked. Two byte
totals can agree while a single texture sits several levels off what its
surface samples, so read the row when the question is which level a texture
holds and why.
With budgetBytes at 0 nothing holds a level back, so residentBytes,
plannedBytes and demandedBytes all read the whole chain of every
texture still enrolled and textures is empty — which is how a session
that armed a budget and dropped it reads back that the levels came home.
Returns TextureStreaming — { budgetBytes, streamable, residentBytes, plannedBytes, demandedBytes, starved, promoted, changed, textures }
local ts = renderer.textureStreaming()
print(("textures: %.1f MiB resident of %.1f MiB demanded, %d starved"):format(
ts.residentBytes / 1048576, ts.demandedBytes / 1048576, ts.starved))
globals/renderer/transmissionShadows
renderer.transmissionShadows() -> boolean
Whether translucent casters tint the directional light they block.
Returns boolean
globals/renderer/uploadStats
renderer.uploadStats() -> {
What the last completed frame spent re-describing its renderables to the
GPU. Every renderable owns a slot in the per-instance data a draw reads —
its world matrix, the bounds the culler tests it by, and the flags that
decide which passes and which culling stages see it — and a frame uploads
only the slots whose contents changed. bytes is what those uploads
carried, fullBytes what re-sending every slot would have cost, and
writes how many buffer writes carried it. The three numbers cover that
per-renderable data alone, so a scene standing still reads bytes = 0
against a fullBytes that grows with the scene, and the ratio says how much
of it the scene's own churn — rather than its size — is paying for.
Returns { writes: number, bytes: number, fullBytes: number }
local u = renderer.uploadStats()
print(("instance upload: %d B of %d B in %d writes"):format(u.bytes, u.fullBytes, u.writes))
globals/renderer/variantSource
renderer.variantSource(program: string) -> string?
The WGSL one of the programs renderer.shaderVariants() lists holds,
exactly as the shader compiler received it. program is the program
field of a row's base or of one of its variants. Reading a base
alongside a variant shows what a feature set selected: each program's text
holds the code its own features guard. The variant-report spelling of
renderer.compiledSource, which answers the same for every other shader.
Parameters
programstring— Aprogramname fromrenderer.shaderVariants().
Returns string? — The compiled WGSL, or nil for a name no compile has run under.
local row = renderer.shaderVariants()[1]
local base = renderer.variantSource(row.base.program)
modules/renderer/README
require("@builtin/modules/api/engine/renderer") -- renderer (also available as global 'renderer')
GPU-resource factory + CPU codec/store wrappers — the GPU/CPU half of the asset↔resource split. This module (under modules/api/) is the SOLE caller of the internal __mesh / __meshcpu / __meshgpu / __splat / __texture / __texturecpu / __texturegpu / __instancedata FFI; assetType behaviours, components, and every other module call renderer.*, never the __ internals.
Usage: local renderer = require("@builtin/modules/api/engine/renderer") Also available as global: renderer
modules/renderer/anisotropy
anisotropy(): number
The maximum anisotropy material textures are sampled with right now — the requested level clamped to what this device honours.
if renderer.anisotropy() < 4 then ... end
modules/renderer/atmospherics.held
atmospherics.held(): boolean
Whether a hold is standing on the air right now.
if renderer.atmospherics.held() then print("clear air") end
modules/renderer/atmospherics.hold
atmospherics.hold(share: number?): () -> ()
Hold the air between the camera and every surface at a stated share of what the scene authored, and return the release. At the default 0 the media contribute nothing and a surface renders in its own colour, which is what lets a reader judge an albedo, a tint or a material while another slice of a shared world drives the weather. The share reaches aerial perspective, height fog and volumetric light scattering; the sky, the sun and the light they put on a surface are untouched, because those are what the surface's colour is made of. Holds nest: the innermost names the share, and the authored air is back once the last release is called. Each release ends its own hold whatever order the releases come in, so two callers holding at once each end their own.
Parameters
sharenumber?(optional) — How much of the authored air reaches the image, in [0, 1]. Defaults to 0 — no air at all.
Returns () — A function that releases this hold. Calling it twice releases once.
local release = renderer.atmospherics.hold() ; local png = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()
modules/renderer/atmospherics.onChange
atmospherics.onChange(listener: (number) -> ()): () -> ()
Register a listener called with the share now in force whenever it changes — a hold taken, a hold released — and return the unsubscribe. A system that packs a medium into a GPU buffer registers here and re-packs what it has already pushed, so the buffer carries the share before the frame the hold was taken on is drawn rather than a frame later.
Parameters
listener(number) -> ()— Called with the share now in force, in [0, 1].
Returns () — A function that removes this listener.
local stop = renderer.atmospherics.onChange(function(share) pushParams() end)
modules/renderer/atmospherics.share
atmospherics.share(): number
The share of the authored air that reaches the image: the innermost
hold's share while one stands, and 1 otherwise. A system that packs a
medium multiplies its extinction — aerial, a fog density — by this,
and a hold then reaches that medium however it is being driven.
local density = state.density * renderer.atmospherics.share()
modules/renderer/blendedBatching
blendedBatching(): boolean
Whether blended neighbours sharing a draw key draw together.
modules/renderer/bounds.clear
bounds.clear(id: string): boolean
Withdraw the box an entity published, so it stops contributing to the entity's reported extent.
Parameters
idstring— Entity id.
renderer.bounds.clear(id)
modules/renderer/bounds.set
bounds.set(id: string, min: any, max: any): boolean
Publish the local-space box an entity's content-drawn geometry occupies.
entity:bounds() and entity:hierarchyBounds() union it with whatever
mesh geometry the entity has, each carried out of its own local space, so
framing a camera on the entity frames what a feature actually draws.
modules/renderer/captureView.channelId
captureView.channelId(name: string): number?
The debug channel a registered view draws on — what a feature passes as
its pass debugChannel. Nil when no view is registered under name.
Parameters
namestring— The view name.
ctx.enqueue { ..., debugChannel = renderer.captureView.channelId("lightmap") }
modules/renderer/captureView.list
captureView.list(): { any }
Every registered capture view as { name, channel, description } records
— what backs the discoverability of capture pass=<name> and the
unknown-view error's suggestion list.
for _, v in ipairs(renderer.captureView.list()) do ... end
modules/renderer/captureView.ready
captureView.ready(name: string): boolean
Whether a registered view can draw yet. A view's passes are enqueued from the moment its render feature first runs, but they are skipped while the materials they name have no pipeline — their shader is still compiling — so for the first frames of a session a camera bound to the view renders the ORDINARY view into its target, and the image gives no sign of it. This reports the difference, and reports it before any camera is on the view, so it is answerable for the first camera bound to one. False for an unregistered name.
Parameters
namestring— 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
namestring— The view name, selected viacapture pass=<name>.configany(optional) —{ description?, ensure?, warmup?, renderLayers? }.ensureis called before a capture of this view so the feature that draws it is live (e.g. create it on demand).warmupis how many present frames a capture lets the view accumulate before it reads — set it when the feature retains prior-frame state (a temporal diff) so the first capture reads a warm result.renderLayersis the layer spec a capture of this view uses when the caller named none — a view that draws its own geometry and wants the scene's kept out of the frame (and out of the depth buffer it tests against) names only its own layer.
local ch = renderer.captureView.register("lightmap", { description = "...", ensure = fn })
modules/renderer/captureView.resolve
captureView.resolve(name: string): any
Resolve a capture view by name to its { channel, ensure, description, warmup } record, or nil when no view is registered under name.
Parameters
namestring— 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
namestring— The view name.
renderer.captureView.unregister("lightmap")
modules/renderer/clearShadowHero
clearShadowHero(): boolean
Release the hero caster, so the directional shadow is the cascades' alone again and the layer the hero view rendered into is given back.
renderer.clearShadowHero()
modules/renderer/clearShadowProxy
clearShadowProxy(mesh: string?): number
Stop proxying mesh, so it rasterizes its own geometry into shadow
views again. Called with no argument, drops every registration.
Parameters
meshstring?(optional) — The mesh to stop proxying. Omit to clear all of them.
renderer.clearShadowProxy(statueMesh)
print(renderer.clearShadowProxy(), "proxies dropped")
modules/renderer/collect
collect(): RuntimeCollection
Release every runtime texture, material, mesh and render feature nothing holds: no handle a script still reaches, no live owner, no reference from live engine state, no asset backing it, no hold. A root scene load runs this once the new scene stands, so what the previous scene's content created and nothing still wears goes with that scene; calling it directly collects at any other moment. A session material's handle counts as reached while the entity it was keyed for stands, and stops counting once that entity is gone. It reaches the GPU textures the device holds beside the registry's own: a texture the cache loaded for an asset goes once nothing live names it and is read back from that asset the next time something asks for it, while one no asset answers for stays, there being nothing to read it back from — a render pass's own target, a colour swatch, an atlas the engine built. A texture the ASSET path uploaded and whose asset has since been removed has nothing to come back from either, and the collection decides about it from its holders the way it does about every other resource: a handle a script still reaches, a live owner, a reference from live engine state, a hold. Features go first, then materials, then meshes, then textures, so a texture only a released material named goes with the material. Runs a full garbage collection first, so a handle nothing reaches counts as let go, and yields for the frame the census runs on. A handle the calling function still has in a variable — or in a temporary it has not overwritten — is one a script reaches, so a resource created in the function that collects is let go by the next collection rather than this one.
local c = renderer.collect() print(c.released.texture, c.kept)
modules/renderer/compiledShaders
compiledShaders(): { string }
Every name renderer.compiledSource answers for — one per name a
shader compile has run under this session, whether it succeeded or failed.
What makes the composed-source surface enumerable rather than something to
guess a key for.
for _, name in renderer.compiledShaders() do print(name) end
modules/renderer/compiledSource
compiledSource(shader: string): string?
The WGSL the shader compiler received under one name, exactly as it
received it — the composed module, which is what a compile error's line
numbers and handle indices are positions in. Answers under any name a
compile ran under (identity, guid, alias, or a program from
renderer.shaderVariants()), for a shader that declares no features, and
for a shader whose compile FAILED, which is the case it exists for: a
message about a function body carries a position and nothing else, and the
text that position is in is this. The failed text stands for as long as
shaderRef:compileStatus() reports that failure under the same name.
Parameters
shaderstring— Any name a shader compiled under — identity, guid, alias, or ashaderVariants()program name.
local status = asset.resolve("myShader", "shader"):compileStatus()
if status.status == "failed" then
print(status.error)
print(renderer.compiledSource("myShader"))
end
modules/renderer/compositeSize
compositeSize(): { width: number, height: number }
The size of the image the post-scene phases worked on in the last
presented frame — the target the UI composites onto, which every pass
after the scene reads as @scene.color and writes into, and which a
screenSpace = "composite" render target follows. While the renderer
presents the viewport itself that is the display's own size, whatever
fraction of it the scene rasterized at; while a UI viewport panel owns
the presentation it is the size the scene rasterized at, since the panel
draws the scene target at its own rect and nothing upscales before the
composite. Both read 0 before a frame has drawn.
local c = renderer.compositeSize()
modules/renderer/cullStats
cullStats(): {
What the last completed frame decided to draw. total renderables went
into the frustum test, culled fell outside it and visible survived. Of
those, occlusion culling measured occlusionTested against the depth
pyramid and proved occlusionCulled were entirely behind other geometry —
both 0 while renderer.occlusionCulling() is false. A renderable the
pyramid has no say over — one that laid no depth in the pre-pass, one whose
bounds were never recorded, one straddling the near plane — is measured
against nothing and counted in neither, so the gap between visible and
occlusionTested reads how much of the frame the test could speak for.
This answers for the main camera. What a shadow view's own volume did with
the frame's casters is on that view's row in renderer.shadowViews().
local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")
modules/renderer/debugPass.builtins
debugPass.builtins(): { string }
The built-in debug-pass names, one per channel in channel order — the engine's built-in pass vocabulary (final, albedo, normal, depth, …).
for _, n in ipairs(renderer.debugPass.builtins()) do ... end
modules/renderer/debugPass.channel
debugPass.channel(name: string): number?
The channel a debug-pass NAME renders on: a built-in pass, else a content
capture view registered via renderer.captureView. Nil when the name is
neither — the signal a selector uses to reject an unknown pass.
Parameters
namestring— 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
channelnumber— The channel number.
local name = renderer.debugPass.name(7) -- "normal"
modules/renderer/depthPrepass
depthPrepass(): boolean
Whether the opaque depth pre-pass is currently enabled.
modules/renderer/depthPrepassOrder
depthPrepassOrder(): { runs: number, reordered: number }
What the last frame's depth pre-passes planned, and how far their
sequences were from near-to-far before they ordered. runs counts the
instanced draws planned; reordered counts the adjacent pairs the sort
moved past each other, taken before it ran. Both are summed over every
pre-pass the frame ran — the window plus each render-target camera, each
ordering against its own camera. Both read 0 while the pre-pass or the
ordering is off, and reordered reads 0 for a frame that already stood
in order. The ordering leaves no other trace — the draws, the depth and the
image are the same either way.
local o = renderer.depthPrepassOrder() -- o.reordered > 0 → it sorted
modules/renderer/depthPrepassOrdering
depthPrepassOrdering(): boolean
Whether the depth pre-pass is submitted nearest-first.
modules/renderer/destroy
destroy(handleOrKind: any, id: string?): boolean
Free the GPU resource a renderer resource holds (the GPU-destroy verb).
Takes any of the forms that name it: the handle a create returned, routed
by its category so one call releases a mixed set of handles; the id a
listing hands out, whose kind is read back off what the renderer holds
under it — the runtime registry, the material definitions, the live
features, and the device itself for an asset's own texture or mesh; or the
kind with the id beside it, the shape renderer.hold and
renderer.references take, which is what names the kind for an id two of
them answer to. An id nothing holds anything under releases nothing and
answers false. The on-disk asset, if any, is untouched. A CPU handle's
:unload() frees the CPU copy separately.
Parameters
handleOrKindany(optional) — AMeshHandle,TextureHandle,MaterialHandleor feature handle; the id itself; or the kind ("texture","material","mesh","feature") with the id as the second argument.idstring?(optional) — The guid or registry key, when the first argument is a kind.
renderer.destroy(meshHandle); renderer.destroy(materialHandle)
renderer.destroy(renderer.texture.list()[1].guid)
renderer.destroy("mesh", guid)
modules/renderer/deviceGeneration
deviceGeneration(): number
Which render device this process is on, counted from the first.
A render device is lost when a driver resets, when the GPU is taken away,
or when a browser reclaims a WebGPU context. The engine answers by building
another device and re-deriving this session's resources onto it, and this
number moves by one each time it does. Anything held across frames that was
built from a GPU resource records this beside it and remakes it when the two
differ; engine.onDeviceRebuilt is the hook that fires when it moves.
local generation = renderer.deviceGeneration()
engine.onDeviceRebuilt(function(g) print("device " .. g) end)
modules/renderer/deviceState
deviceState(): string
Whether the render device this process draws through is the one it is using, one it is replacing, or one it has stopped trying to replace.
"ready" is a live device. "rebuilding" is the window between a device
reporting itself lost and another being in place: every GPU resource built
from the old one is invalid, the frames in that window draw nothing, and
anything reaching the GPU refuses. "abandoned" is after the engine gave
up — the adapter refused every attempt, so this session draws no more
frames.
Work that spans the device — build a render target, draw into it, read it
back — reads this to tell an operation that failed because the device went
out from under it, which is worth doing again once
renderer.deviceGeneration() moves, from one that failed on its own terms.
The loss is reported before the next device exists, so the two readings
answer different halves: this one says a replacement is coming, the
generation says it arrived.
if renderer.deviceState() == "rebuilding" then return end
modules/renderer/drawDiagnostics
drawDiagnostics(): { DrawDiagnostic }
Every renderable that is NOT drawing what its material says — the one
call for "why does this surface look wrong". Three states land here: a
surface rendering as the magenta placeholder (substituted), one the
renderer could bind nothing for at all (outcome = "skipped"), and one
drawing a program whose most recent compile FAILED (stale), which is what
a shader edited into brokenness looks like — the pipeline its last good
compile built keeps drawing, so the picture is intact and answers to none of
the edits since. Each row names the entity, the program asked for, the
program bound, programStatus — the compile gate's word about the program
the material NAMED — and the one cause
from shaderCompileFailed / shaderNotRegistered / shaderNotCompiledYet
/ noGbufferEntry / renderStateKeyNotBuilt / noPipelineForTarget /
unshaded, with the compiler's own message in detail or programError.
Covers every renderable the renderer holds, whether or not a camera reached
it: a row with observed = false and outcome = "notDrawn" carries the
renderer's own resolution for one this frame drew nowhere, so a broken
surface off-screen is reported the same as one in frame. An empty result
means every renderable the renderer holds is drawing the program its
material named and that program compiles. Answers on the deferred path as
well as forward, and in edit mode as well as play.
for _, d in renderer.drawDiagnostics() do print(d.entity, d.reason, d.detail) end
if #renderer.drawDiagnostics() == 0 then print("every surface is drawing what it says") end
modules/renderer/drawStats
drawStats(): {
What the last completed frame actually submitted. draws counts every
geometry draw call the frame issued — the camera's passes, each shadow
view a shadow-casting light adds, and whatever a render feature draws —
and instances counts the instances those draws covered. The pair is what
separates one draw carrying five hundred instances from five hundred draws
carrying one each, so it reads how well the scene batches rather than how
many objects are in it.
compacted is how many of those instances the frame planned through draws
whose instance count the GPU decides: the culler's own per-object answers
packed into a dense run, so an object it rejects is absent from the draw
instead of collapsing to nothing in the vertex stage. compactedDrawn is
how many of them survived, counted on the GPU as it packed them — a pass
that then skips a whole draw over its own layer or visibility answer
leaves that draw's instances in both numbers.
The plan is made over the populations the frame draws, and the tests
answer which of their instances the packing keeps. That packing runs
before any pass has resolved the depth occlusion culling is tested
against, so on its own it reads the frustum and screen-size answers
alone. With setOcclusionCulling armed the frame packs the same plan a
second time once the test has answered, and compactedDrawn then counts
what came through occlusion as well.
compactedDrawn comes back from the buffer the GPU wrote, so it describes
a frame that has finished while compacted describes the most recent
plan, and it holds the last count the GPU wrote until another arrives — a
frame that compacts nothing reads compacted 0 beside the count from the
last frame that did. In a scene standing still the gap between the two is
the front-end work culling removed.
materialBinds is how many times the frame's geometry passes set a
material's parameter group, and materialBindsElided how many times a
pass reached that decision and found the group already bound. Their sum
is how many times the decision was reached — once per unit of geometry
submitted, which sits at or below draws, since a mesh of several
primitives draws once per primitive under one set of binds. The ratio
inside the pair is what material binding costs the frame: the batched
opaque geometry is gathered into runs sharing a material, so a frame of
many such draws over few materials binds about once per material rather
than once per unit. materialExtraBinds and materialExtraBindsElided
are the same pair for the second group, the storage bindings a shader
declares for itself, which only the shaders that have them ever bind.
pipelineBinds and pipelineBindsElided are the same pair for the
pipeline itself: how many times the frame's geometry passes set one, and
how many times a pass reached that decision and found the pipeline it
wanted already bound. Which pipeline a unit needs follows its shader, its
material's render state and its mesh's vertex layout together, so a scene
whose units share all three costs one set for the run of them, while units
differing in any one of the three each pay their own. Their sum is how
many units reached the pipeline decision, which sits at or above what the
material pair reports: a unit the pass settles a pipeline for and then
abandons — one whose material group resolved to nothing — counts here and
never reaches the material decision.
Every figure here is the whole frame's, the main camera's draws and every
shadow view's summed together. renderer.shadowViews() splits compacted
and compactedDrawn across the views that made them, and carries the
camera's own share beside them.
local d = renderer.drawStats(); print(d.instances / math.max(d.draws, 1), "instances per draw")
print(renderer.drawStats().compactedDrawn, "of", renderer.drawStats().compacted, "survived the cull")
local d = renderer.drawStats(); print(d.materialBinds, "material binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")
modules/renderer/feature.create
feature.create(ref: any, guid: string?): any
Instantiate a render feature so the engine calls its render(ctx) hook
every frame. ref is an AssetRef<renderFeature> whose init.luau returns
{ setup?, render, teardown? }. Returns a live RenderFeatureHandle (its
guid is the stable id, same as mesh/texture handles); tear it down with
renderer:destroy(handle). Pass guid to assign a specific id.
Parameters
refany(optional) — AnAssetRef<renderFeature>, or a string identity/guid resolved viaasset.resolve(ref, "renderFeature").guidstring?(optional) — Optional explicit handle guid (minted when omitted).
local h = renderer.feature.create(asset.resolve("acrylic", "renderFeature"))
local h = renderer.feature.create("acrylic")
modules/renderer/feature.destroy
feature.destroy(handleOrGuid: any): boolean
Tear down a live render feature by its RenderFeatureHandle OR its guid
string — the by-id path for when the handle was lost (e.g. across execute
calls). Same effect as renderer.destroy(handle). Returns true if a feature
was live under that id.
Parameters
handleOrGuidany(optional) — ARenderFeatureHandleor itsguidstring.
renderer.feature.destroy("eb623975-d8bd-47a1-a0a4-5c0e56238e2f")
modules/renderer/feature.list
feature.list(): { { guid: string, identity: string } }
List every render feature currently live (running its render(ctx) each
frame). Each entry is { guid, identity } — the guid is the same id a
RenderFeatureHandle carries, so you can tear a feature down by guid even
after losing its handle (e.g. across separate execute calls).
for _, f in renderer.feature.list() do print(f.identity, f.guid) end
modules/renderer/feature.shaded
feature.shaded(): { [string]: number }
How many pixels each fragment pass a render feature enqueued shaded on
the last drawn frame, keyed by the pass's shader/effect name. A fragment
pass draws one triangle over its target, so it shades the whole screen
whatever its effect actually reaches — unless it declares bounds on the
pass spec, the world-space box its effect stays inside, in which case it
shades the rectangle that box projects into for the camera drawing it and
is skipped for a camera that cannot see the box at all. This is the reading
that says which of the two a pass is: it moves when the effect moves, and a
pass absent from it shaded nothing. Summed over every camera the frame drew.
local px = renderer.feature.shaded(); for name, n in pairs(px) do print(name, n) end
modules/renderer/featureTexture.configure
featureTexture.configure(width: number, height: number, layers: number)
Size the shared feature-texture array — the layers a surface shader reads
through zero_feature_texture(uv, layer), and the layers a SpotLight
projects through its cone via cookieLayer. Layers are rgba16f.
A call for the size the array already has is left alone. One that changes
the size reallocates, and the replacement is zeroed — so it empties every
layer in the array, including the layers other features and other cookies
own. renderer.featureTexture.state() reports the extent and the layers
holding content, which is how a feature re-fills the layer a resize took
from it.
Parameters
widthnumber— Layer width in pixels.heightnumber— Layer height in pixels.layersnumber— 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
layernumber— Which layer of the array to write into.textureKeystring— The source texture's name — the one it was created under. Acompute.createStorageTexture2Dtarget, acompute.createTextureHistorypair (its current side), and a texture acompute.copyBufferToTexturewrote all answer to the name they were given.xnumber— Left edge of the destination rectangle, in pixels.ynumber— Top edge of the destination rectangle, in pixels.
compute.createStorageTexture2D("gobo", { width = 256, height = 256, format = "rgba16f" })
renderer.featureTexture.setLayer(0, "gobo", 0, 0)
modules/renderer/featureTexture.state
featureTexture.state(): {
What the shared feature-texture array is right now: the extent every
layer carries, and filled, the ascending 0-based indices of the layers a
setLayer has landed in since the array was last sized. One array is
shared by every feature and every light cookie in the scene, and it has no
allocator, so this is the call that tells a feature whether the array it
sized and filled is still the array it is writing into — a configure that
changed the size reallocates and zeroes every layer, and the layer it
emptied leaves filled without it. Measured off the renderer at the end of
the last rendered frame, so a configure or setLayer issued this frame
reads back on a later one.
What this describes is the array a shader samples. The source texture a
setLayer copied FROM is a GPU resource of its own and keeps the bytes it
was written with for as long as it lives, so filled is the reading that
answers whether the layer behind a cookieLayer is live right now.
local ft = renderer.featureTexture.state()
print(("feature textures: %dx%d over %d layers"):format(ft.width, ft.height, ft.layers))
-- Re-fill the cookie layer this module owns if anything emptied it.
if ft.width ~= myWidth or table.find(ft.filled, myLayer) == nil then
refillMyCookie()
end
modules/renderer/framePacing
framePacing(): FramePacing?
How far the CPU is allowed to run ahead of the GPU, and what holding it there cost the frame just finished. Submitting work to the GPU returns before the GPU has done it, and everything that submission holds — its staging allocations, its bind groups, its command buffer — stays alive until it completes. A frame that asks for more work than the GPU finishes in a frame's time therefore leaves that behind it, and unbounded that is memory growth rather than a lower frame rate.
framesInFlight is how many submitted frames have not reported done
through the queue's completion signal, held under maxFramesInFlight: a
device that keeps up reads under the bound, one that is behind reads at it.
It counts submissions, which is its own quantity — how many presented
images the swapchain permits in flight is a separate setting.
mechanism names how that bound is enforced
here: submission-wait waits for the frame that many frames back and
reports the wait in waitMs, so a paced frame costs latency and still
draws; submitted-work-done counts outstanding frames off the queue's
completion signal and declines to start a frame while the bound is met,
counting those in pacedFrames and leaving the last presented image up.
submittedFrames counts the frames that were admitted and submitted, so it
rises for as long as the renderer is producing frames — which is what tells
a renderer running slowly under a tight bound from one that has stopped.
stalled reads true while that completion signal has stopped arriving and
the pacer stood down rather than hold the image indefinitely; it clears on
the first frame that finds the count back under the bound.
producing is whether the renderer is drawing frames at all. A headless
renderer draws into an offscreen framebuffer that nothing presents, so its
image reaches a reader only through something that copies it out: it draws
while a consumer is asking — an MCP call in flight, a queued texture
readback, a recording, a frame-egress session — and declines the frames
between two asks, counting them in idleSkippedFrames. Every other
renderer stat answers with the last frame that drew, so producing is what
separates a live reading from a frozen one. A windowed renderer presents
every frame it draws and reads producing = true throughout.
presentMode is what the surface presents with and presentModes what it
offers; both are empty of meaning on a headless renderer, which never
presents.
local p = renderer.framePacing()
print(("%d of %d frames in flight, paced by %s"):format(p.framesInFlight, p.maxFramesInFlight, p.mechanism))
modules/renderer/getRaytrace
getRaytrace(): boolean
Whether ray tracing is currently enabled.
modules/renderer/gpuMemory
gpuMemory(): GpuMemory
Where the renderer's GPU memory went at the last completed frame — the call to reach for when something is holding memory and you do not know what.
Three figures answer three different questions, and they are meant to be read against each other:
- The categories —
shadow,textures,meshes,instances,compute, summing tocategorised— are the renderer's own accounting of what it asked for on purpose. Always present, on every backend. allocatoris the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. It exceedscategorisedby the per-frame render targets and the scratch nothing categorises. The allocator hands memory out from blocks it reserves whole from the device and returns a block only once nothing is left in it, soreservedBytesruns aboveallocatedBytesby what those blocks hold unused;blockslists them emptiest first with the labels that keep each one alive, andemptyBytesplusslackBytesis that distance exactly — the pool held in empty blocks, and the room pinned inside blocks something still sits in.driver.deviceLocalBytesis 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 reportsavailable = falseand the reason.driver.outsideAllocatorBytesis 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 andrenderer.collect()all leave it where it is, and it falls when the device is destroyed. Read it when a session's device memory has grown and no ledger row accounts for the growth.
compute is what the compute subsystem holds; compute.observe() names
each of those resources and what it costs. renderTargets counts the
offscreen render targets the renderer holds at that frame, which is what
says a renderer.destroy has been applied rather than queued.
local m = renderer.gpuMemory()
print(("shadows %.1f MiB"):format(m.shadow.total / 1024 / 1024))
-- how much of the card this engine is holding:
if m.driver.available then
print(("driver %.1f MiB"):format(m.driver.deviceLocalBytes / 1024 / 1024))
else
print("no driver figure: " .. m.driver.reason)
end
-- the biggest allocation in the engine:
local top = m.allocator and m.allocator.byLabel[1]
print(top and top.label, top and top.bytes)
-- the block held for the least, and what pins it:
local block = m.allocator and m.allocator.blocks[1]
if block and block.allocationCount > 0 then
print(block.size, block.allocatedBytes, block.byLabel[1].label)
end
modules/renderer/hold
hold(handleOrKind: any, id: string?): boolean
Pin a runtime resource for the session. A held texture, material, mesh
or render feature survives every collection — the one a root scene load
runs and a direct renderer.collect() alike — until renderer.release
lets it go or its destroy frees it. It is the way to keep an ad-hoc
resource across the scenes that come and go under it. A hold keeps the
resource in the registry; a mesh's GPU buffers are governed by what draws
it, parked as a CPU definition when the last instance naming it goes and
brought back when one names it again, so renderer.mesh.isResident(guid)
is the separate question about the buffers.
Parameters
handleOrKindany(optional) — The resource's handle, or its kind ("texture","material","mesh","feature") with the guid or key as the second argument.idstring?(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
targetstring | entityRef— The entity — a proxy fromentity(...)/entity.spawn(...), or an entity-id string.
renderer.instanceData.clear(subject)
modules/renderer/instanceData.laneCount
instanceData.laneCount(): number
How many vec4 lanes each entity's per-instance block holds, so a lane
index runs 0 .. laneCount() - 1. The same count a surface shader indexes
input.shader_data against.
for lane = 0, renderer.instanceData.laneCount() - 1 do
renderer.instanceData.set(subject, lane, 0)
end
modules/renderer/instanceData.set
instanceData.set(
Write one vec4 lane of an entity's per-instance shader data — the
channel that lets ONE material serve many entities that differ in a value.
A surface shader reads the lane back as input.shader_data[lane], so a
dissolve at its own progress per subject, an effect at its own age per
firing, or a per-entity mask costs one material rather than one material
per entity.
The engine attaches no meaning to a lane: a feature picks the lane indices it owns and packs whatever its shader agrees they carry. Name those indices in the module that writes them, so the writer and the shader read the block the same way.
The write reaches the block where it is called, so the entity it names is the one holding that id at that point in the tick, and the value is on the draw from the next frame. It is held until the lane is written again, the entity's block is cleared, or the entity is despawned — a despawned entity releases its whole block. A lane an entity was never given reads zero.
modules/renderer/loseDevice
loseDevice()
Destroy the render device on the next frame, so the engine meets a real device loss.
This is the one loss that can be caused on purpose, and it travels the same
path a driver reset does: frames draw nothing until the rebuild lands,
GET /engine/status reports the renderer as recovering while it does,
engine.onDeviceRebuilt fires afterwards, and renderer.deviceGeneration()
moves. Use it to prove that a world's content survives a device loss —
anything it holds only on the GPU has to be remade from the rebuild hook, and
this is how you find out whether it is.
renderer.loseDevice()
-- some frames later:
print(renderer.deviceGeneration()) -- one higher than before
modules/renderer/mainCameraView
mainCameraView(): { number }?
The main camera's inverse view-projection (column-major, 16 numbers)
followed by its world position (3 numbers) — {m0..m15, px,py,pz} — for
reconstructing world positions from the depth buffer in a ray-tracing pass.
Nil before the first render.
modules/renderer/material.animatedTexture
material.animatedTexture(
Build a material that PLAYS a layered texture: its layers bound as the
frames, its timing bound beside them, and the engine's animatedTexture
shader turning the clock into the layer showing now. One call from an
imported animated image to a material an entity can wear.
The layer showing is resolved per pixel against the texture's own schedule,
so frames of unequal length are shown for the lengths they were authored
with, and the sequence loops. speed scales the clock (2 plays twice as
fast, 0 holds the frame startTime lands in) and startTime offsets into
the sequence, so two surfaces sharing one texture can run out of phase.
The clock is the engine's, and it runs in edit mode as much as in play and
through a pause, so two screenshots of one surface taken moments apart are
two different frames of it. speed = 0 holds one frame for as long as it
is set, which is the state to compare two screenshots in.
The returned handle is what a surface wears — Model:applySessionMaterial
takes it, and so does a Model's material field. The handle's guid is
this material's REGISTRY KEY, the currency of setProperty, describe and
destroy; a component field resolves an asset, so a bare key in one leaves
the component waiting for an asset to register under that name.
The builtin plane mesh emits uv = (u, v) with v along its own +Z, so
a quad pitched +90° about X (Transform.eulerToQuat(0, math.pi / 2)) shows
the image upright to a camera on +Z, and -90° shows it first-row-last.
A texture whose layers carry no timing is rejected — there is nothing to
play. renderer.texture.info(bytes).animated is the test.
local mat = renderer.material.animatedTexture("banner.texture")
local id = entity.spawn("billboard", { rotation = { Transform.eulerToQuat(0, math.pi / 2) } })
entity(id).component.add("Model", { model = "plane" })
entity(id).component.get("Model"):applySessionMaterial(mat)
renderer.material.setProperty(mat.guid, "speed", 2)
modules/renderer/material.create
material.create(content: MaterialContent, key: string): MaterialHandle
Parameters
contentMaterialContentkeystring
modules/renderer/material.describe
material.describe(key: string | { [string]: any } | AssetRef): any
The recoverable definition ({ shader, properties, textures, name })
this module registered under key via renderer.material.create, or nil
for keys registered elsewhere (e.g. material assets resolved by the
assetType). properties and textures carry the material's current values:
each setProperty / setTexture write lands on this record, a texture slot
under the GPU key the slot binds by — these are the WRITES, held here
whether or not the renderer took them up. renderer beside them is what the
renderer holds for the same key: the program its prepared bind group was
built against, the render state its draws are looked up under, whether a
pipeline exists for that key, and how many draws the observed frame gave
it. renderer is nil when the renderer holds no material under this key at
all, and resident states the same fact as a boolean. Writes reach the
screen through both halves: resident = false says the renderer holds
nothing to put them in, and renderer.draws = 0 on a resident material
says it holds them and no renderable is drawing with it. For a material
that is resident AND drawn and still looks wrong,
renderer.drawDiagnostics() names the renderable and the cause.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key, theMaterialHandlefromrenderer.material.create, or anAssetReffromasset.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
keystring | { [string]: any } | AssetRef— The material's registry key (the one passed tocreate), theMaterialHandlecreatereturned, or anAssetReffromasset.resolve.
renderer.material.destroy("__preview_swatch_" .. texGuid)
modules/renderer/material.list
material.list(): { any }
Every runtime material currently registered, ordered by registry key.
Each entry carries the key, where it came from, and the shader it binds.
renderer.references("material", key) says what is still holding a row,
and renderer.collect() releases the rows nothing holds.
for _, m in ipairs(renderer.material.list()) do print(m.guid, m.shader) end
modules/renderer/material.renderState
material.renderState(key: string | { [string]: any } | AssetRef): MaterialObservation?
What the renderer holds for a material, which is a different document
from the values written to it. shader is the program its prepared bind
group was built against, renderState the blend / cull / topology / queue /
depth key its draws are looked up under, keyBuilt whether a pipeline
exists for that key, and draws / instances / placeholderDraws /
binds / bindsElided what it cost in the frame the renderer last
observed — those five read 0 until something arms per-draw recording, which
renderer.materialCost() and
renderer.drawDiagnostics() do. nil means the renderer holds no material
under this key at all — the writes landed on a record nothing is drawing
with.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key, theMaterialHandlefromrenderer.material.create, or anAssetReffromasset.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
entityIdstring— The entity carrying the material.
local key = renderer.material.sessionKeyFor(entityId)
modules/renderer/material.setProperty
material.setProperty(key: string | { [string]: any } | AssetRef, name: string, value: any): ()
Push one changed uniform property to a registered material's GPU record
(frame-fast incremental update; no re-register). Keyed by the material's
registry key. The value written becomes the material's current one: it is
what describe reports, and — for a property the material's shader
declares, which is what the uniform buffer is packed by — what a material
AssetRef reads back through getProperty / getProperties and what the
surface is drawn with. A write under any other name reaches the record
describe reports, which is where it reads back.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key, theMaterialHandlefromrenderer.material.create, or anAssetReffromasset.resolve.namestring— Property name.valueany(optional) — New value.
renderer.material.setProperty(asset.resolve("wall", "material"), "roughness", 0.2)
modules/renderer/material.setTexture
material.setTexture(key: string | { [string]: any } | AssetRef, slot: string, ref: string | { [string]: any } | AssetRef): ()
Push one changed texture slot to a registered material's GPU record. Keyed by the material's registry key.
Parameters
keystring | { [string]: any } | AssetRef— The material's registry key, theMaterialHandlefromrenderer.material.create, or anAssetReffromasset.resolve.slotstring— Texture slot name ("base_color_texture", …).refstring | { [string]: any } | AssetRef— Texture reference — a.textureguid / identity / name / path, the image path it was imported from, acolor:/default:form, a live GPU handle, or a textureAssetRefcarrying one. An asset reference is materialised (Disk→CPU→GPU) and bound by the key the upload lands under.
renderer.material.setTexture("sky", "sky_texture", "panorama.texture")
modules/renderer/materialCost
materialCost(): { MaterialObservation }
What each material cost the frame the renderer last drew, and the state
it holds each one under. One row per material the renderer holds a prepared
bind group for — a material an author wrote and the renderer never prepared
is absent, which is itself the answer to "why is nothing I set reaching the
screen". draws and instances cover that one frame; placeholderDraws
is how many of those draws bound the magenta placeholder instead of this
material's own program; binds is how many material-owned bind groups the
frame's passes SET for it and bindsElided how many of its draws wanted a
group the pass already held, which is what draw-key sorting buys; a draw
that fell back to the placeholder bound the placeholder's group, so it
counts in placeholderDraws and in neither bind count. uniformBytes is
the GPU uniform buffer's own size,
which is the reflected property block raised to the 16-byte floor and
rounded up to the copy alignment. renderer.drawDiagnostics() names WHICH
renderable is not drawing what its material says, and why.
for _, m in renderer.materialCost() do print(m.material, m.draws, m.binds, m.bindsElided) end
modules/renderer/materialIdentity
materialIdentity(): MaterialIdentity
Which material each renderable draws with, as a number a shader can
carry. A material is authored and bound by name, and no shader can read a
string — so every renderable's per-instance record holds a material index
instead. slots is the name → index table those indices are drawn from: an
index is assigned the first time the renderer draws with that material and
does not move afterwards, so two renderables that differ only in material
read different indices, and one renderable reads the same index frame after
frame. It follows that the table keeps a row for every material name drawn
this session, whether or not anything still draws with it. renderables is
a row per renderable that owns a GPU slot — the entity it belongs to, that
slot, and the index the record at it carries; populations is the same for
an instanced draw, whose whole reserved run of slots carries the one
material its registration named. That index is what a shader reads as
instance_data[slot].material_index, and the row a ray hit resolves
through zeroMaterial(). A renderable draws with the material its entity
references, so one whose entity names none carries index 0.
local id = renderer.materialIdentity()
for _, r in id.renderables do print(r.entity, r.slot, r.index, r.material) end
modules/renderer/materialIndex
materialIndex(name: string): number?
The index standing for a material, or nil for one the renderer has not
drawn with yet. Pass it to a shader (or compare it against what a shader
read out of instance_data[slot].material_index) to tell which material a
drawing instance carries.
Parameters
namestring—stringMaterial name, asrenderer.material.createfiled it.
local red = renderer.materialIndex("brick_red")
modules/renderer/maxAnisotropy
maxAnisotropy(): number
The highest anisotropy this device honours: 16 on hardware that filters
anisotropically, 1 on hardware that does not, where a higher request would
be downgraded to trilinear regardless. Read it to report quality honestly —
renderer.setAnisotropy clamps for you, so a request never needs guarding.
local best = renderer.maxAnisotropy()
modules/renderer/mesh.boundsSource
mesh.boundsSource(mesh: string | { [string]: any } | AssetRef): string
Where this mesh's culling bounds come from. "compute" once a compute
pass has written its vertices: the engine reduces those vertices to an AABB
every frame, so the mesh is culled against the geometry the pass produced
wherever it puts it. "geometry" otherwise: the AABB of the geometry the
mesh was created with.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— A mesh loaded into the CPU store (renderer.mesh.loadCpu) — theMeshCpuHandle, aMeshHandle, a guid, or a meshAssetRef.
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
msnumber?(optional) — New per-frame budget in milliseconds, capped at 1000. A value that is not a positive, finite number raises.
renderer.mesh.clusterBakeBudget(2)
modules/renderer/mesh.clusterBakes
mesh.clusterBakes(): { [string]: any }
What the scheduled cluster bakes are costing. budgetMs is the slice a
frame may spend, pending how many bakes are queued, completed how many
have finished since the engine started, dropped how many left the queue
because the geometry they were scheduled over stopped being readable, and
heldBytes the source geometry the queue is holding across all of them —
the vertex pool and index run the bake at the head is reading, plus a copy
for each queued mesh the engine holds no definition for.
inFlight is one row per queued bake —
{ guid, cpuMs, frames, slices, bytes, state }: the wall time spent
advancing it, the frames it has been queued for, the slices it has been
advanced by, the geometry it is holding, and "baking" for the one being
advanced against "queued" for the ones waiting their turn.
print(renderer.mesh.clusterBakes().heldBytes)
modules/renderer/mesh.clusterComponents
mesh.clusterComponents(clusterBytes: buffer | string): (ClusterComponents?, string?)
Split a cluster blob (from renderer.mesh.buildClusters) into its
GPU-ready component byte pools — the cluster vertex pool, the
geometry-addressing pool (every cluster's local→global vertex map, then
every cluster's triangle bytes), and the per-cluster record array — plus
their counts. A cluster's triangles address positions inside its own vertex
map one byte at a time, and a record's vertexOffset indexes the geometry
pool in u32 elements while its indexOffset indexes it in bytes, so ONE
binding resolves a corner. A pure decode (no GPU work): upload the pools
into buffers a compute shader owns (shaderRef:createBuffer +
buf:writeBytes) to drive a cluster draw from Luau.
Parameters
clusterBytesbuffer | string— Serialized cluster bytes (binary-safe).
local c = renderer.mesh.clusterComponents(cb)
modules/renderer/mesh.clusters
mesh.clusters(mesh: string | { [string]: any } | AssetRef): { [string]: any }?
The shape of the cluster-LOD hierarchy the renderer holds for a mesh:
clusterCount across every level, levelCount with the finest counted as
one, and triangleCount across every cluster. The renderer keys one entry
per mesh that carries a hierarchy, so this answers whether the mesh has
clusters as well as what they are — nil for a mesh that carries none.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh to read — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
local c = renderer.mesh.clusters(gpu) ; if c then print(c.levelCount) end
modules/renderer/mesh.create
mesh.create(src: any, guid: string?): MeshHandle
Create (or fetch) a GPU mesh resource and return its MeshHandle. src:
a MeshCpuHandle from meshRef:load() (CPU→GPU upload under the asset's
guid, idempotent — returns the resident handle if already uploaded); raw
geometry {positions, indices, normals?, uvs?, colors?, uvs1?, unwrapUvs?, tangents?, skinning?, skins?} (a new runtime mesh — uvs1 is the lightmap
UV set, unwrapUvs generates one, skinning/skins bind a skeleton); GPU
compute buffers {vertexBuffer, indexBuffer, vertexCount, indexCount, aabbMin?, aabbMax?, prevVertexBuffer?} (size the vertex buffer at
vertexCount * engine.vertexStride bytes, the engine's standard Vertex
layout); or a MeshHandle (returned as-is). NEVER takes an AssetRef —
load the CPU first.
prevVertexBuffer is a second buffer of the same size and layout holding
those vertices as they stood on the previous frame. Naming it is what makes
geometry a compute pass moves report a motion vector: the surface
differences the two streams, so every consumer of screen-space velocity —
motion blur, temporal reprojection — sees the movement. The engine fills it
from the current vertices once per frame, ahead of that frame's compute
dispatches, so a frame in which the pass does not run leaves the two
streams equal and the geometry reports standing still.
morphTargets are the shapes the mesh can blend towards: a list of
{ name?, positions, normals? } records, each holding one offset per vertex
from the base geometry, in the mesh's own vertex order. An entity blends
them with ecs.MorphWeights, weight i scaling target i. A name makes
the shape addressable as itself — renderer.mesh.morphTargets reads the
names back and renderer.mesh.morphWeights drives them by name.
Raw geometry is read against the mesh type's conventions: indices count
vertices from 0, and a triangle's FRONT face is the one whose vertices turn
counter-clockwise as the viewer sees them — cross(v1 - v0, v2 - v0) points
out of it. A material culls its back faces by default, so a triangle wound
the other way draws nothing where it stands; reverse the index triple, or
give the material render = { cull = "none" }, to draw that side. normals
give the surface its outward direction and shade the face; the side that
draws comes from the index order alone. uvs sample (0,0) at the image's
top-left. Model space carries the world's basis: +X right, +Y up, -Z the
direction transform.forward points. guides { path = "types/mesh" } has
the whole table.
A geometry src carrying keepCpu = true also keeps its geometry in the
guid-keyed CPU store, so renderer.mesh.getVertices reads it and
renderer.mesh.setVertices rewrites its positions in place — the per-frame
deformation path, which sends positions alone where renderer.mesh.update
re-sends the whole geometry. renderer.mesh.unloadCpu(mesh) releases that
copy. Without it the geometry lives on the GPU alone and
renderer.mesh.readback(mesh) is what brings it back.
Parameters
srcany(optional) — A MeshCpuHandle, geometry, compute buffers, or a MeshHandle.guidstring?(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
zmshbuffer | 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
meshstring | { [string]: any } | AssetRef— The mesh to release — aMeshHandle, a guid, aMeshCpuHandleor a meshAssetRef.
local m = renderer.mesh.create(cpu); renderer.mesh.destroy(m)
renderer.mesh.destroy(renderer.mesh.list()[1].guid)
modules/renderer/mesh.drawInstanced
mesh.drawInstanced(mesh: string | { [string]: any } | AssetRef, opts: any): InstancedDraw
Draw one mesh instanceCount times in a single call, each copy placed by
a world matrix read from a GPU buffer. The population is a renderable in its
own right — it goes through the mesh's ordinary pipeline and the material's
ordinary bind groups, so it appears in the deferred pass, the forward passes
and the shadow maps exactly as an entity-backed draw of that mesh does.
The buffer holds instanceCount column-major 4x4 matrices, 64 bytes
each, tightly packed — the layout a vertex shader reads as
array<mat4x4<f32>>, which puts each matrix's translation in its LAST four
floats (Lua indices 13/14/15 for x/y/z). Packing row-major transposes every
instance.
The matrices are COPIED into the engine's transform slots once per frame, which is what buys that full-pass parity. Rewrite the buffer between frames and the instances move — no re-registration, no re-upload.
material is what the population draws with, and it is required: a
MaterialHandle (matRef:handle()), an AssetRef, or a registry key.
instanceDataBuffer names a second buffer, holding 64 bytes per instance —
four vec4 lanes, tightly packed, in instance order. Those lanes arrive in
the fragment stage as zero_object_data(in.instance_id, lane), the same
read a per-entity __instancedata block answers, so the members of one
population can differ in whatever their material's shader agrees the lanes
carry. Copied every frame like the transforms, from a buffer a compute pass
writes: the values never touch the CPU. Omit it and the lanes read zero.
reserveCount sizes the reservation above instanceCount so
renderer.mesh.setInstanceCount can raise the drawn count later without
re-registering; both buffers must back the reservation, not just the count.
mobility states whether the copies stand still — "static", or
"movable" when it is left out. It is what a scene gather collecting
geometry for precomputed lighting admits a population on, the same
declaration Model.mobility makes for an entity: the transforms live in a
buffer anything may rewrite between frames, so a population that says
nothing is taken as one that moves.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh the population draws — aMeshHandle, the guidrenderer.mesh.listhands out, aMeshCpuHandleor a meshAssetRef. 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.optsany(optional) —{ transformBuffer, instanceCount, material, instanceDataBuffer?, reserveCount?, renderLayer?, castsShadows?, mobility? }.
local m = renderer.mesh.create({ positions = ..., indices = ... })
local buf = substrate.createBuffer({
name = "crowd.xf", type = "mat4", len = 64, kind = "gpu",
})
-- Column-major: translation lives at indices 13/14/15.
local xf = {}
for i = 0, 63 do
local m4 = { 1,0,0,0, 0,1,0,0, 0,0,1,0, i * 2, 0, 0, 1 }
for _, v in ipairs(m4) do xf[#xf + 1] = v end
end
buf:write(xf)
local rock = asset.resolve("rock", "material"):handle()
local draw = renderer.mesh.drawInstanced(m, { transformBuffer = "crowd.xf", instanceCount = 64, material = rock })
modules/renderer/mesh.dropClusters
mesh.dropClusters(mesh: string | { [string]: any } | AssetRef): boolean
Detach a mesh's cluster-LOD hierarchy and cancel a bake still in flight
for it, so the renderer holds none for it. The inverse of
renderer.mesh.uploadClusters.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh to detach — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
drawInstancedDraw— TheInstancedDrawto 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
geomMeshGeometry—MeshGeometry— flat per-vertex float / u32 arrays plus optionalskinningandskins.
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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
drawInstancedDraw— TheInstancedDrawto 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
drawInstancedDraw— TheInstancedDrawwhose copies to locate.
local pending = renderer.mesh.instanceTransforms(draw)
while not pending:ready() do task.wait() end
local floats = pending:result()
modules/renderer/mesh.isCpuResident
mesh.isCpuResident(mesh: string | { [string]: any } | AssetRef): boolean
True if this mesh has a resident CPU copy in the guid-keyed CPU store.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
if renderer.mesh.isCpuResident(handle) then ... end
modules/renderer/mesh.isResident
mesh.isResident(mesh: string | { [string]: any } | AssetRef): boolean
True if a GPU mesh is resident under this mesh's guid — the device
holds its buffers, or the upload pass is still going to hand them over.
This is the store the draw paths are gated on, so a mesh this reports
resident is one renderer.mesh.drawInstanced and a Model can draw.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
refstring | AssetRef— A meshAssetRef(carries.guidand reads its primary via getBytes), or any stringasset.refresolves to one — the guidencodeCputakes, 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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
for i, name in renderer.mesh.morphTargets(meshRef) do print(i, name) end
modules/renderer/mesh.morphWeights
mesh.morphWeights(
Turn weights named by shape into the ordered weight array
ecs.MorphWeights takes — the drive-a-face-by-name call. Every target the
mesh carries gets a slot; the ones weights names take their value and the
rest are 0, so the returned array always describes the whole mesh and a
shape left out is a shape at rest.
A name the mesh does not carry is an error listing the names it does: a mistyped viseme that silently moved nothing would be indistinguishable from a rig that never had it.
local w = renderer.mesh.morphWeights(meshRef, { Eyes_Blink = 1, Mouth_Smile = 0.4 })
ecs.set(face, ecs.MorphWeights { weights = w })
modules/renderer/mesh.readback
mesh.readback(mesh: string | { [string]: any } | AssetRef): MeshCpuHandle
Read a runtime GPU mesh's geometry back to CPU and return a
MeshCpuHandle for it — the GPU→CPU half of the runtime-mesh freeze path. A
mesh made with renderer.mesh.create keeps no CPU copy, so persisting it
(:encode() → asset.create("mesh", …)) reads it back here first. Yields
until the readback completes (a frame or two). After it returns the geometry
is resident in the guid-keyed CPU store: :getTriangles, :getVertices,
:getBounds, :geometry, :encode, :unload all work. Errors if the mesh
never becomes resident in the vertex pool.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh — theMeshHandlerenderer.mesh.createreturned, a guid, or a meshAssetRef.
local cpu = renderer.mesh.readback(handle); local zmsh = cpu:encode(); cpu:unload()
modules/renderer/mesh.readbackPosed
mesh.readbackPosed(requests: { { entity: string, mesh: any } }): { [string]: MeshCpuHandle }
Read the POSED geometry of skinned entities back to CPU: for each request, the vertices the skinning pass wrote for that entity this frame, joined by the indices of the mesh it is posed from. A skinned surface's world-space triangles are produced on the GPU from the entity's joint matrices, so the mesh asset holds the bind pose and only this reads where the surface actually is. The posed vertices are in model space, so the entity's own world transform still places them — the same transform the raster draw uses.
Takes a LIST and answers a map, because the readbacks are queued together
and polled together: a scene's worth of characters costs the frames of one
readback rather than one entity's after another. Each posed mesh lands in
the CPU store under a guid of its own, derived from the entity, so
compute.buildBvh, meshcpu.* and every other guid-keyed reader takes it
like any other mesh. Call handle:unload() when done with it.
An entity the map omits holds no live pose — nothing skinned it this frame, which is also what makes its draws read the source mesh, so its bind-pose geometry is what stands for it.
Parameters
requests{ { entity: string, mesh: any } }—{ { entity = <id>, mesh = <mesh> } }— the entity to read, and the mesh it is posed from (a guid,MeshHandleor meshAssetRef).
local posed = renderer.mesh.readbackPosed({ { entity = id, mesh = ecs.get(id, ecs.Mesh).mesh } })
local tris = posed[id]:getTriangles()
modules/renderer/mesh.scheduleClusters
mesh.scheduleClusters(mesh: string | { [string]: any } | AssetRef): boolean
Queue a cluster-LOD bake for the static CPU mesh held under guid, and
attach the DAG to the GPU mesh of that same guid on the frame it finishes.
The CPU mesh may be unloaded on the very next line; the DAG is then built
one bounded slice per frame, so a dense mesh virtualizes without the frame
loop stopping for the whole bake.
One bake is advanced per frame — the one at the head of the queue — and the
geometry is read on the frame a bake gets there, from the definition the
engine holds for the mesh. A queue of meshes the engine holds definitions
for therefore holds one mesh's geometry rather than one per mesh, whatever
its depth. A mesh the engine holds no definition for is copied into the
queue as it is scheduled, since the CPU store is then the only thing
holding it. renderer.mesh.clusterBakes().heldBytes reports what the queue
is holding, and its inFlight rows report which bakes it is holding for.
This is what the .mesh assetType materialisation path uses; reach for
renderer.mesh.buildClusters when you want the bytes in hand instead.
Scheduling the same mesh again replaces the bake already in flight for it.
Parameters
meshstring | { [string]: any } | AssetRef— A mesh loaded into the CPU store (renderer.mesh.loadCpu) — theMeshCpuHandle, aMeshHandle, a guid, or a meshAssetRef.
renderer.mesh.scheduleClusters(cpu) ; cpu:unload()
modules/renderer/mesh.setInstanceCount
mesh.setInstanceCount(draw: InstancedDraw, count: number): InstancedDraw
Change how many of a registration's instances draw. Constant time — the
reservation, the transform buffer and the pipeline all stay put, so this is
the verb for a population whose size changes per frame. The new count must
fit the reservation drawInstanced was given.
Parameters
drawInstancedDraw— TheInstancedDrawto reconfigure.countnumber— Instances to draw, at least 1 and within the reservation.
renderer.mesh.setInstanceCount(draw, visibleCount)
modules/renderer/mesh.setInstanceRenderLayer
mesh.setInstanceRenderLayer(draw: InstancedDraw, renderLayer: number): InstancedDraw
Change which render layers a registration's copies belong to. Constant time — the reservation, the transform buffer and the pipeline all stay put, and the next frame drawn tests the copies against the new membership. It is the verb for a population that follows something whose membership moves: a camera or a capture including the layer draws the copies, one excluding it does not.
Parameters
drawInstancedDraw— TheInstancedDrawto reconfigure.renderLayernumber— The membership bitmask, the same valuedrawInstancedtakes asrenderLayer. 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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.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
meshstring | { [string]: any } | AssetRef— The mesh — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.
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
meshstring | { [string]: any } | AssetRef— The mesh to update — aMeshHandle, a guid, aMeshCpuHandleor a meshAssetRef.srcany(optional) — New geometry{positions, indices, ...}or compute buffers{vertexBuffer, indexBuffer, vertexCount, indexCount, prevVertexBuffer?}.
modules/renderer/mesh.uploadClusters
mesh.uploadClusters(mesh: string | { [string]: any } | AssetRef, clusters: string): boolean
Attach a cluster-LOD DAG (bytes from renderer.mesh.buildClusters) to
the GPU mesh keyed by guid, enabling the continuous-cut cluster draw path
for that mesh.
Parameters
meshstring | { [string]: any } | AssetRef— The mesh the clusters belong to — aMeshHandle, aMeshCpuHandle, a guid, or a meshAssetRef.clustersstring— Serialized cluster bytes (binary-safe).
renderer.mesh.uploadClusters(gpu, cb)
modules/renderer/minScreenSize
minScreenSize(): number
The on-screen radius, in pixels, an object must reach to be drawn. 0
while the cutoff is off.
local px = renderer.minScreenSize()
modules/renderer/morphStats
morphStats(): {
The morph state the last frame drew with. A mesh carries the shapes it
can blend towards and an entity carries how strongly each is blended
(ecs.MorphWeights); where both are present, the vertex stage adds the
weighted deltas to the base geometry.
instances is how many render slots that happened at, and blends how
many single-target blends those slots carry between them: a slot
contributes one per target its weights move, or that they moved the frame
before, so the number of targets a mesh can be given is bounded by the
buffer the blends live in. meshes is how
many meshes hold a delta block and targets how many targets those blocks
cover between them; deltaBytes is what the shared buffer they are
appended into holds. A morph-target mesh whose weights are all zero reads a
meshes above zero beside an instances and blends of zero.
local m = renderer.morphStats()
print(("%d instances carrying %d blends over %d meshes"):format(m.instances, m.blends, m.meshes))
modules/renderer/observe
observe(): RenderObservation
Everything the renderer knows about the frame it last drew: what
program it bound for each renderable, the render state it holds each
material under, and what each program has cost in pipeline builds.
renderables is one row per renderable in the renderer's draw list,
carrying the program its material named (requestedProgram) beside the one
that was bound (boundProgram) — __error__ wherever the lookup missed
and the draw went ahead on the magenta placeholder — plus substituted,
the outcome (drew / drewPlaceholder / skipped / notDrawn), the
reason that forced it and the compiler's own detail for a failed
compile. observed says which of two answers a row is: true for a
resolution a geometry pass took as it drew, false for the renderer's own
resolution of a renderable this frame drew nowhere, which is what a
renderable outside every camera's frustum or layer mask reports.
materials is one row per
material the renderer holds a prepared bind group for; shaders is one row
per program pipelines have been built for. frame names the frame every
per-frame count covers; retainedFrames how many frames a resolution a
pass took is kept for after the last frame that drew it; window and
costWindow state both in the document itself. Recording is armed by the
first read, so this waits for the frame that first records rather than
answering empty.
local o = renderer.observe(); print(o.placeholderDraws, "renderables drew the placeholder in frame", o.frame)
modules/renderer/occlusionCulling
occlusionCulling(): boolean
Whether occlusion culling is currently enabled.
modules/renderer/passSchedule
passSchedule(): {
The schedule check over this frame's enqueued render passes. Passes
declare what they read (inputs) and what they write (output /
outputs / storage), and the frame runs them in phase order and, inside
a phase, in order order. violations holds every input bound to a
resource the frame produces LATER: that read samples the resource as it
stands ahead of that pass, which is the previous frame's contents for a
render target that persists, an empty target for one just created, and the
scene draw's own output for a @scene.* buffer — and the pass renders
either way. The frame's own buffers are checked on the same terms as a
render target: bind @scene.motion at a phase ahead of the pass that
writes it and the read is reported, naming the buffer and its writer.
A pass reading a resource ahead of that write on purpose declares that slot
in its enqueue's readsPrevious and drops out of the list;
unboundPrevious holds declared slots the pass binds no such resource to,
which cover nothing.
A resource no queued pass writes is not reported — a camera rendering to
texture and compute.dispatch both fill targets outside the pass queue,
and the scene draw fills the @scene.* buffers every frame.
A read the frame has only one order for is not reported either: where the
writing pass consumes something the reading pass produces, the reader runs
first or the writer has nothing to write, which is what a pass reading a
buffer into a target of its own and a second pass copying that target back
over the buffer forms.
unreachable holds passes at a phase that does not run their kind: every
phase drains its fragment and compute passes, while afterLighting is the
one that draws geometry, draw and splat passes, so one of those enqueued
elsewhere sits in the queue and never runs.
Each finding is also stated in the engine log the first time it appears.
local s = renderer.passSchedule()
for _, v in s.violations do print(v.message) end
modules/renderer/pipelineCache
pipelineCache(): PipelineCache?
What the driver's compiled-pipeline store held, built, and wrote back. A pipeline is machine code the GPU driver compiles from the shader bound into it, and that compile is what a launch pays before the first frame drawing with each pipeline can appear. The store keeps that compiled code across runs, so a launch whose shaders have not changed reads back what the previous one compiled.
restoredBytes is what a previous run left for this GPU and this launch
read; pipelinesBuilt counts the pipelines built since startup and
buildMs is what they cost together, which is the number the store lowers.
saves and savedBytes describe writing it back — deferred until a burst
of builds settles, so one launch is one write — and dirty is true while
pipelines have been built that the file does not hold, including after a
write that failed, which lastError then names. path is the file, named
after the GPU it belongs to.
supported is false where the platform holds no store a program can carry:
a browser keeps its own and hands none out, and an adapter can lack the
capability. reason says which, and the build count and timing still read
true there. lastError names a read or write failure; a failed store costs
the saved compile and never the frame, since every pipeline is built from
its source either way.
pipelinesBuilt and buildMs are engine-wide totals; renderer.shaderCost()
is the same cost broken down per program, with each one's permutation count.
local c = renderer.pipelineCache()
print(("%d pipelines in %.1f ms, %d bytes restored"):format(c.pipelinesBuilt, c.buildMs, c.restoredBytes))
modules/renderer/pointShadowBudget
pointShadowBudget(): PointShadowBudget
The point-light shadow pool now in force. A point light with
castsShadows renders an omnidirectional cube map, six faces of depth,
and slots is how many of them fit — a further caster is lit but throws
no shadow, and the engine log names how many were turned away. The slot
count is bought rather than authored: megabytes of VRAM at resolution
texels per face is what decides it.
local p = renderer.pointShadowBudget()
print(("%d shadowed point lights, %.1f MiB"):format(p.slots, p.bytes / 1024 / 1024))
modules/renderer/projectionOffset
projectionOffset(): (number, number)
The sub-pixel projection offset in force for the main camera, in NDC.
local ox, oy = renderer.projectionOffset()
modules/renderer/raycast
raycast(
Cast a ray against the geometry the renderer DRAWS and return the
nearest surface it meets. Every visible mesh answers, whether or not
anything gave it a rigid body — so a terrain, a procedurally generated
mesh, or any plain Model reports the surface at a point, which is what a
camera station, a prop, a sound source or a scatter standing on the ground
needs to know. The answer is the nearest triangle of the mesh, so a sloped
or terraced surface reports its height where it was asked rather than the
extent of its bounding box.
distance is measured from origin along the direction given, so it is a
world-space distance whenever that direction is a unit vector, and it is
directly comparable to a physics.raycast distance along the same ray.
normal is a unit vector turned to face back along the ray. exact is
true when the answer is a triangle and false when it is the object's
bounding box, which is what a mesh whose vertices live only in GPU buffers
answers with. The triangles are the mesh's own, placed by the entity's
transform and by the mesh's bind pose, so a surface a skinning or morph
pass deforms on the GPU answers as the geometry the mesh holds.
EVERYTHING drawn is in scope — the ground you meant, and equally a
character standing on it, a prop, a placeholder floor. The hit names its
entity in entityId, exclude steps over the ones you do not want, and
renderer.raycastAll hands back the whole column so you can pick the
surface yourself. A height you did not expect is usually a nearer surface
you did not mean to ask about, so read entityId before trusting a number.
local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then camera.position = { x, hit.point.y + 1.7, z } end
modules/renderer/raycastAll
raycastAll(
Cast a ray against the geometry the renderer draws and return every
surface along it, nearest first. One entry per renderable the ray crosses —
the nearest intersection with each — so a stack of surfaces reads as the
order they stand in, and a caller after one particular surface finds it by
entityId rather than hoping it is the nearest. Each entry carries the
fields renderer.raycast returns.
for _, hit in renderer.raycastAll(eye, look, 500) do print(hit.entityId, hit.distance) end
modules/renderer/raytraceCapability
raytraceCapability(): string
The active ray-tracing backend: "hardware" (GPU ray query) or
"compute" (software traversal — the path on devices without hardware ray
query, e.g. the web). The same ray-tracing features work on both.
if renderer.raytraceCapability() == "hardware" then ... end
modules/renderer/raytraceStats
raytraceStats(): { [string]: any }
What the ray-tracing acceleration structure holds, and what this
session's frames have spent building it. A ray walks a structure built over
the scene's geometry, and keeping it current is work a frame pays before it
traces anything. On the "compute" backend geometry that has stood still
long enough is filed under a static partition the frames after it leave
alone: staticTriangles + dynamicTriangles = triangles, nodes is the
hierarchy over them, fullRebuilds / partialRebuilds / reusedFrames
count what the session's frames did, and trianglesRebuilt is what those
rebuilds re-emitted, summed. On the "hardware" backend blas is the
bottom-level structures cached, blasBuilt how many the last frame built,
and tlasInstances what the top-level structure names. The counters are
cumulative — sample, run the scene, sample again.
local before = renderer.raytraceStats().trianglesRebuilt
modules/renderer/references
references(handleOrKind: any, id: string?): RuntimeResourceStatus?
What holds a runtime resource right now — the answer a root scene load
reads before releasing it. references names each live consumer the engine
found: { by = "entity", id } for an entity wearing the material or mesh,
"material" for a material whose slot names the texture, "instancedDraw",
"camera", "sky", "lightmap", "ui" (a screen drawing it) and
"postProcess" (an effect sampling it). handleHeld says whether a script
still reaches a handle to it, assetBacked whether an asset stands behind
it, ownerLive whether the component instance, scene load or feature that
created it still stands, and held whether a hold pins it. origin reads
"device" for a GPU texture the device holds that no script created — the
one the cache loaded for an asset, the atlas the engine built — whose
holders are the references, a handle and the asset. Runs a full garbage
collection first, the same one renderer.collect runs, so a handle nothing
reaches counts as let go and the row says what the next collection does
with the resource. Yields for the frame the census runs on.
Parameters
handleOrKindany(optional) — The resource's handle, or its kind with the id second.idstring?(optional) — The guid or key, when the first argument is a kind.
local s = renderer.references(mat) for _, r in s.references do print(r.by, r.id) end
modules/renderer/reflectionEnvironment
reflectionEnvironment(): {
What a reflective surface is reflecting. probes is how many reflection
probes the shading blends; they are gathered highest priority first, each
rank taking the coverage the ranks above it left, so a small interior probe
ranked above the large exterior one it sits inside wins outright wherever it
reaches full weight. ranks is the priority each of those probe slots was
published with, in slot order. sky is whether the sky fallback is armed:
with it, coverage no probe claims reflects the captured sky, and without it
a surface outside every probe's radius falls back to the nearest probe
alone. skyCaptured is whether the sky slot holds a capture — arming is
refused until it does, since an uncaptured slot reflects black.
slots is how many cube slots the environment array holds right now: the
sky's alone, at index skySlot, until a probe is captured into it, then
that one plus one per probe. maxProbes is how many of them probes may
take, and resident whether the array has grown past the sky's single
slot. Capture the sky with environment.captureSky().
local env = renderer.reflectionEnvironment()
print(("%d probes, sky fallback %s"):format(env.probes, tostring(env.sky)))
modules/renderer/release
release(handleOrKind: any, id: string?): boolean
Let go of the hold renderer.hold placed. The resource stays until
nothing else holds it and a collection releases it — the one a root scene
load runs, or a direct renderer.collect().
Parameters
handleOrKindany(optional) — The resource's handle, or its kind with the id second.idstring?(optional) — The guid or key, when the first argument is a kind.
renderer.release(tex)
modules/renderer/renderTargetLimits
renderTargetLimits(): {
The size a render target may be on this device. maxDimension is the
device's own maximum 2D texture dimension — the largest either side of a
render target may take. maxPixels is how many pixels one render target
may hold, so the RGBA8 image it reads back as fits in a single buffer on
every platform the engine runs on, and maxSquare is the largest square
that budget buys. A capture, a renderer.texture.create({ width, height })
or a render-to-texture camera past either bound is refused at the call with
the reason, so ask here for the size to request.
local lim = renderer.renderTargetLimits()
local w = math.min(want, lim.maxSquare)
modules/renderer/renderTargets
renderTargets(): {
Every render target the renderer owns and what each one costs, measured
from the texture that is allocated. One row per target, each carrying its
name, whether it is resident, the bytes it holds while it is, its
width/height/layers/mipLevels, and onDemand.
An onDemand target exists only while something needs it: a target nothing
writes into reads resident = false and bytes = 0 and appears again the
frame something writes it, and one sized by content — the reflection-probe
cube array — holds the slots content asked for. The scratch the draws into
a render target have needed is reported as camera[<handle>].* rows:
depth and motion vectors under any rasterized pass, and the occlusion
channel and G-buffer over them under a camera's scene render. A draw builds
what it needs, and the set goes once no live camera names the target and
sixty frames have passed without a draw, so a target nothing draws into
carries no such row; the colour image drawn into belongs to the texture
cache and outlives every one of those releases.
totalBytes is what the resident targets hold together. Measured at the
end of the last rendered frame.
local rt = renderer.renderTargets()
print(("render targets: %.1f MiB over %d resident"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
if t.onDemand then print(t.name, t.resident, t.bytes) end
end
modules/renderer/resolutionScale
resolutionScale(): number
The fraction of the display resolution the scene is currently rendered
at. 1 until something sets it.
local s = renderer.resolutionScale()
modules/renderer/setAnisotropy
setAnisotropy(level: number): number
Set the maximum anisotropy material textures are sampled with. Takes effect on the next frame for content already on screen — no reload, no texture re-upload. 1 is plain trilinear.
Parameters
levelnumber— 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
enabledboolean—boolean
renderer.setBlendedBatching(false) -- a draw per blended renderable
modules/renderer/setDepthPrepass
setDepthPrepass(enabled: boolean): ()
Enable or disable the opaque depth pre-pass. While enabled the renderer resolves opaque depth in its own pass before shading, so each shaded pixel runs its material once instead of once per surface stacked behind it, and the resolved depth is what occlusion culling reads. Enabled by default.
Parameters
enabledboolean—boolean
renderer.setDepthPrepass(false) -- shade every layer, for comparison
modules/renderer/setDepthPrepassOrdering
setDepthPrepassOrdering(enabled: boolean): ()
Submit the depth pre-pass nearest-first. Renderables reach the pre-pass
in the order they were registered, which stands in no relation to where the
camera is: a scene built back-to-front makes every layer write depth and be
overwritten by the layer in front of it. Ordered, the nearest surface
writes first and the surfaces behind it are rejected by the depth test
before they write. The same draws go out either way and the depth that
comes out is the same, so scene.depth_prepass in profiler.gpuFrame() is
what moves. Enabled by default.
Parameters
enabledboolean—boolean
renderer.setDepthPrepassOrdering(false) -- submit in registration order
modules/renderer/setGpuMemoryTracking
setGpuMemoryTracking(frames: number?): number
Set how often the GPU allocator sampler reads — one reading every
frames frames — or turn it off with 0. It starts at 60, a reading a
second at 60 Hz, so renderer.gpuMemory().allocator answers without
anything arming it. Building the ledger walks every live allocation, which
is why it is sampled rather than read every frame; the category figures
cost nothing either way, and a reader between samples sees the most recent
ledger, so a slow interval still answers.
Called with no argument it reports the interval in force and changes nothing, which is how something that retimes the sampler puts it back afterwards instead of restoring a number it assumed was the default.
Parameters
framesnumber?(optional) —number?Frames between readings; 0 turns the sampler off. Omit to read the interval without changing it.
renderer.setGpuMemoryTracking(60)
local was = renderer.setGpuMemoryTracking()
renderer.setGpuMemoryTracking(1)
-- ... take a tight reading ...
renderer.setGpuMemoryTracking(was)
modules/renderer/setMaxFramesInFlight
setMaxFramesInFlight(frames: number): number
Set how many frames of GPU work may be outstanding before the renderer stops running ahead. One is the least overlap this can express — a frame's work is waited for as soon as the next frame has been submitted — which is the lowest latency and the lowest throughput; higher values let a slow frame build a longer backlog, and that backlog is memory. Takes effect on the next frame.
Answers the bound after clamping to [1, 8], so asking for more than the renderer honours reports what you actually got.
Parameters
framesnumber— number Frames of GPU work that may be outstanding, 1 through 8.
renderer.setMaxFramesInFlight(1) -- lowest latency
print(renderer.setMaxFramesInFlight(99), "was clamped")
modules/renderer/setMinScreenSize
setMinScreenSize(pixels: number): ()
Stop drawing an object once its on-screen radius falls below this many
pixels. A few pixels across, an object carries no detail a viewer can
resolve while still costing a full vertex and submission pass, and the
cutoff drops it from the camera's draws entirely — 0, the default,
keeps every object however small it lands. Measured from the object's own
bounds against the camera's projection, so the same threshold means the
same apparent size at any distance or field of view. Shadow casters have
their own threshold in renderer.setShadowCasterCutoff.
Parameters
pixelsnumber—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 pre-pass depth into a pyramid each frame and tests every renderable that cleared the frustum against it, dropping the ones another surface entirely covers before their geometry is submitted. The pyramid describes the frame being drawn, so an object that becomes visible this frame is never held back a frame. Requires the depth pre-pass.
Parameters
enabledboolean—boolean
renderer.setOcclusionCulling(true); print(renderer.cullStats().occlusionCulled)
modules/renderer/setPointShadowBudget
setPointShadowBudget(cfg: {
Set how much VRAM the point-light shadow atlas may hold, and at what
per-face resolution. An omitted field keeps its current value. The atlas
is reallocated on the next frame, so renderer.pointShadowBudget().slots
reports the new pool one frame later; the returned number is what this
budget buys. Raising resolution sharpens every point shadow and spends
the same memory on fewer of them — doubling it quarters the slot count.
Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the
pool never exceeds renderer.pointShadowBudget().maxSlots. One slot is
always granted, so a budget too small for a single cube shadows one light
and the pool costs what that slot costs rather than what was asked for —
{ megabytes = 1, resolution = 4096 } buys 384 MiB of ceiling. Read
pointShadowBudget().bytes back to see what a budget actually bought, and
renderer.shadowMemory().point to see what the scene has made resident.
renderer.setPointShadowBudget({ megabytes = 96 })
renderer.setPointShadowBudget({ resolution = 1024, megabytes = 96 })
modules/renderer/setPresentMode
setPresentMode(mode: string): string
Set how a presented frame reaches the display. fifo queues every frame
and shows it on a vertical blank, which never tears and never drops one;
mailbox replaces the queued frame with the newest, which does not tear
and does not hold the renderer to the refresh rate; immediate presents as
soon as a frame is ready and can tear; fifo_relaxed is fifo that tears
rather than stall when a frame misses its blank; auto_vsync and
auto_no_vsync leave the choice to the backend.
A surface that does not offer the mode presents fifo instead, so read
renderer.framePacing().presentMode for what took effect and
.presentModes for what this surface offers. Takes effect on the next
frame.
Parameters
modestring— string One of "fifo", "fifo_relaxed", "mailbox", "immediate", "auto_vsync", "auto_no_vsync".
renderer.setPresentMode("mailbox")
print(renderer.framePacing().presentMode, "is what the surface took")
modules/renderer/setProjectionOffset
setProjectionOffset(x: number, y: number)
Offset the main camera's projection by a sub-pixel amount, in NDC, for
the frames until it is set again. The offset is in NDC because that is the
space it is constant in: one pixel is 2.0 / width across, so half a pixel
is 1.0 / width. Velocity (@scene.motion) is measured against the
offset-free projection, so a still scene reports no motion however the
samples are placed — and picking resolves a click to the same ray either
way. (0, 0) samples pixel centres.
Parameters
xnumber— Horizontal offset in NDC. One pixel is2.0 / width.ynumber— Vertical offset in NDC. One pixel is2.0 / height.
renderer.setProjectionOffset(0.5 * 2 / w, -0.25 * 2 / h)
modules/renderer/setRaytrace
setRaytrace(enabled: boolean): ()
Enable or disable GPU ray tracing. While enabled the engine builds the scene acceleration structure each frame so ray-tracing render features can trace against it; disabling stops the build (so it costs nothing until a ray-traced effect is active). Required before any ray-traced shadows / AO / reflections render.
Parameters
enabledboolean—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 and present
it at the display's own size. Shading cost scales with pixel count and with
nothing else, so this trades sharpness for frame time without taking
anything out of the scene: at 0.5 the scene rasterizes a quarter of the
pixels. UI and text are unaffected — they are drawn after the scene is
brought back up to size. The scene rows in profiler.gpuFrame() are what
move.
Parameters
scalenumber—number— fraction of the display resolution, clamped to [0.25, 1].
renderer.setResolutionScale(0.7)
modules/renderer/setShadowCaching
setShadowCaching(enabled: boolean): ()
Whether a shadow map that nothing changed is kept rather than drawn again. On by default: a shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is rasterized on the frames its own inputs change and holds the depth it drew on the ones they do not. Off, every view is drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.
Parameters
enabledboolean—boolean
renderer.setShadowCaching(false) -- draw every shadow view, every frame
modules/renderer/setShadowCasterBatching
setShadowCasterBatching(enabled: boolean): ()
Whether a shadow view draws every caster of one mesh together. On by default: a view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — submits one draw per geometry over every caster of it the view admits, wherever those casters sit in render order and whatever transform slots they hold. Off, a view draws the runs of render-order neighbours that share a mesh AND hold consecutive slots, so a scene that has spawned and despawned anything fragments into many more draws. The image is the same either way, which is what makes this the comparison a shadow suspected of being placed by the batching is made against.
Parameters
enabledboolean—boolean
renderer.setShadowCasterBatching(false) -- draw the runs the scene presents
modules/renderer/setShadowCasterCutoff
setShadowCasterCutoff(cfg: {
Set the shadow-caster cutoff. An omitted field keeps its current value,
so a call can adjust one threshold without restating the other. Both are
measured against the camera the frame draws from rather than against each
light, so one setting covers every cascade, spot and cube face, and a
caster that stops casting is one whose shadow the viewer could not have
resolved. maxDistance is measured to the near side of the caster's
bounding sphere, so a large object keeps casting while any part of it is in
range. 0 releases a threshold; releasing both draws the casters the frame
drew before either was set.
renderer.setShadowCasterCutoff({ minRadiusPx = 2 })
renderer.setShadowCasterCutoff({ minRadiusPx = 1.5, maxDistance = 120 })
modules/renderer/setShadowConfig
setShadowConfig(cfg: {
Set the directional shadow quality. Any omitted field keeps its current
value, so a call can adjust one knob without restating the rest. Values are
clamped: resolution [64, 8192], cascades [1, 4], distance >= 0, splitLambda
[0, 1], fadeFraction [0, 1], softness [0, 1]. Changing resolution or
cascades reallocates the depth array; the rest are per-frame values. A
distance of 0 hands the range to the frame — the splits are cut over the
depth its own shadow-taking renderables reach — and a positive one caps it,
which is what a scene bounding its shadow cost states.
renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ cascades = 4, resolution = 2048, distance = 240 })
modules/renderer/setShadowHero
setShadowHero(entity: string, padding: number?): ()
Give one caster a directional shadow view of its own, fit to its world bounds.
A cascade covers the slab of world the camera sees, so its texels are spread
over tens of metres and one character standing in the middle of it is
resolved by a handful of them. The hero view is the same light and the same
depth range zoomed onto that entity's bounds, so the whole map goes into the
shadow it and the ground under it carry — renderer.shadowHero().zoom is
the factor its texel density gains.
It renders beside the cascades, into a layer of the same texture allocated while a hero is registered, and every surface inside it reads it in place of the cascade, crossing back at its edge. Nothing else about the shadow changes: the same casters reach it, at the same depth range, through the same filter.
Parameters
entitystring— The entity whose renderables the view is fit around.paddingnumber?(optional) — How much room the fit leaves around those bounds — for a pose that leaves the bind-pose box and for the filter that samples outside a silhouette. 1.0 fits them exactly.
renderer.setShadowHero(player.id)
print(("hero shadow: %.1f texels/unit vs %.1f"):format(
renderer.shadowHero().texelsPerUnit, renderer.shadowHero().cascadeTexelsPerUnit))
modules/renderer/setShadowProxy
setShadowProxy(mesh: string, proxy: string): ()
Rasterize proxy in place of mesh in every shadow view. A shadow is a
silhouette resolved at the resolution of a shadow map, so the triangles that
carry a mesh's close-up detail write depth no reader can resolve — a
decimated version of the shape, a level of its own LOD chain, or a
hand-built hull casts the same shadow for a fraction of the geometry.
The registration is keyed by MESH, so one call covers every instance of it — entities and GPU-driven populations alike — and a crowd sharing that mesh stays one draw. The proxy is placed by whatever places the caster, its instance's own transforms, so it stands where the caster stands, at the caster's scale.
An entity caster keeps its own geometry where a stand-in could not be placed
or deformed correctly: it is skinned (it rasterizes the post-skinned
vertices written for its own mesh), it blends morph targets (whose deltas
describe its own mesh and are read by vertex id), or its proxy would be
placed by a different node of its model than the source mesh is. Either
caster keeps it where the renderer holds no geometry under the proxy's
guid. Each of those is counted in renderer.shadowProxies().
Nothing else in the scene draws a proxy, so this call is what brings it onto the GPU, and it raises where it cannot. A proxy already resident there is registered as it stands.
Parameters
meshstring— The mesh a caster draws, as a guid or any mesh reference.proxystring— The mesh it rasterizes into shadow views instead.
renderer.setShadowProxy(statueMesh, statueHullMesh)
print(renderer.shadowProxies().triangles, "vs", renderer.shadowProxies().sourceTriangles)
modules/renderer/setSkinnedBatching
setSkinnedBatching(enabled: boolean): ()
Whether skinned instances holding one pose draw together. On by default:
instances of one mesh wearing one material and posed alike read the same
post-skinned vertices, so the camera's colour passes submit them as a single
instanced draw, and so does each shadow view and the velocity pass while
renderer.shadowCasterBatching() is on — that switch is what makes a depth
view form its draws by geometry at all. The camera depth pre-pass submits
its casters nearest-first, which is a run per span of neighbours rather than
a draw per geometry, so a crowd costs a draw per member there. Off, each
skinned instance draws on its own at its own slot in every pass that
rasterizes it. The image is the same either way, which is what makes this
the comparison a frame suspected of being formed by the batching is made
against — renderer.drawStats().draws counts the difference and
renderer.skinningStats().poses says how many distinct poses it holds.
Parameters
enabledboolean—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
enabledboolean—boolean
renderer.setSkinningPoseHold(false) -- dispatch every pose, every frame
modules/renderer/setSpotShadowBudget
setSpotShadowBudget(cfg: {
Set how much VRAM the spot/area shadow atlas may hold, and the per-side
resolution of one layer. An omitted field keeps its current value. The
atlas is reallocated on the next frame, so renderer.spotShadowBudget()
reports it one frame later; the returned number is what this budget buys.
Raising resolution sharpens the lights that cover the most screen and
spends the same memory on fewer layers — doubling it quarters the layer
count. Raising megabytes buys layers, which is what lets several lights
hold a large tile at once. Values are clamped: megabytes [1, 1024],
resolution [64, 4096], and the atlas never exceeds
spotShadowBudget().maxLayers. One layer is always granted, so a budget
too small for one still shadows lights and the atlas costs what that layer
costs rather than what was asked for.
renderer.setSpotShadowBudget({ megabytes = 64 })
renderer.setSpotShadowBudget({ resolution = 2048, megabytes = 64 })
modules/renderer/setTextureBudget
setTextureBudget(opts: TextureBudgetOpts): TextureBudget
Bound the VRAM a world's textures occupy, by keeping only the mip levels
the frame is actually sampling. Pass { megabytes = 256 }; 0 — the
default — leaves texture residency alone and every texture stays fully
resident the way it uploaded.
With a budget armed, each frame measures how many screen pixels ONE
traversal of a texture's coordinate range covers on the surface that spans
it widest, and asks for the mip level that serves that span one texel per
pixel — the level the GPU picks from the fragment's own derivatives. A
material with uvScale = 8 lays eight copies of its texture across a
surface, so each copy spans an eighth of the surface and asks for three
levels coarser than the surface's own size would. A shader that declares
// @uv_space: world advances its coordinate over world units rather than
over the mesh's UVs, so how many copies a surface carries follows how large
that surface is. The textures whose surfaces cover the fewest pixels give
up levels until the set fits. Detail climbs one level per frame, from the
image already on screen, so a surface the camera approaches sharpens rather
than popping, and no texture is taken below the level whose longest side is
64 texels.
bias shifts every measurement by whole mip levels either way — negative
for finer than the sampling implies, positive for coarser — over a world
whose look wants a different trade than one texel per pixel.
The plan moves a texture whose demand the frame can measure: one at least 256 texels on its narrowest side, worn by a surface an entity draws. A texture a UI image, a post-process property or a render feature holds a view of stays whole, because nothing measures how much of the screen those cover.
Which textures the budget governs follows the surfaces the frame draws. A
texture whose asset still holds its bytes is enrolled the frame a measured
surface wears it — whenever it loaded, and whenever the budget was armed —
because a level change reads the levels it needs back from the asset; when
the last such surface goes it leaves the set whole, at the level it
uploaded at, and a surface reaching it again takes it back up. A texture a
script uploaded has its pixels nowhere else, so one enrolled while it is
resident holds them in system memory
(renderer.textureMemory().streamSourceBytes) from the upload until a
surface has worn it and gone, and releases them then, which is what keeps
it out for the rest of the session; one whose pixels were already released
when the budget was armed is out from the start.
renderer.textureMemory().pinnedTextures counts those, together with the
textures whose asset could not be read back and the ones a UI image, a
post-process property or a render feature holds a view of. Disarming
returns every texture to the level it uploaded at, and arming again governs
the textures the frame's surfaces are wearing then.
Parameters
optsTextureBudgetOpts—{ megabytes: number?, bias: number? }
renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 }) -- one level finer everywhere
renderer.setTextureBudget({ megabytes = 0 }) -- leave residency alone
modules/renderer/setTransmissionShadows
setTransmissionShadows(enabled: boolean): ()
Let translucent casters tint the sunlight they block instead of blocking
it outright. A shadow map holds one depth per texel and is compared as a
yes-or-no test, so stained glass, water and thin fabric all project the same
black silhouette a wall does. With this on, a caster whose material declares
opacity (base_color alpha under a transparent blend) or transmission
also draws into a light-space transmittance map, and the colour it lets
through multiplies into the directional light reaching whatever stands
behind it. Stacked casters compose. Opaque casters are unaffected, and a
scene with no translucent caster allocates nothing and records no pass.
Parameters
enabledboolean—boolean
renderer.setTransmissionShadows(true) -- stained glass tints the floor
modules/renderer/shaderCache
shaderCache(): ShaderCache?
What the shader compile gate's store of baked WGSL held, answered and
wrote back. Compiling a .shader wraps the author's body in its framework,
expands every #include, and hands the result to naga to parse and
validate — work that is a pure function of the text going in, and that a
launch would otherwise repeat for every shader it draws with. The store
keeps that baked text across launches.
restoredEntries and restoredBytes are what a previous launch left that
this one read back. hits counts the compiles answered out of the store
and misses those that ran in full; savedMs sums what each hit's own
recorded compile had cost, against compileMs, what the misses spent.
stale counts the misses whose key was held but whose #included modules
had changed underneath — an entry records every module its expansion
consumed, so editing a module invalidates exactly the shaders that included
it and leaves the rest.
entries and bytes are what the store now holds, evictions how many a
write dropped to stay inside its bounds, and saves / savedBytes /
dirty describe writing it back, deferred until a burst of compiles
settles. persistent is false where a launch has nowhere to keep
artifacts and reason says why; location is the file, or the browser
store, they are kept in. restoreState is how the read of what a previous
launch left has gone — pending while it is still out (a browser answers
through a promise, so a launch reaches its first frames before it lands),
restored once entries came back, empty when there were none to come
back, failed when what was there could not be read, and none where a
launch keeps nothing. A cold, missing or corrupt store leaves every
shader compiling from source with identical output, and lastError then
names what went wrong.
local c = renderer.shaderCache()
print(("%d hits, %d misses, %.1f ms saved"):format(c.hits, c.misses, c.savedMs))
modules/renderer/shaderCost
shaderCost(): { ShaderCost }
What each program has cost in pipeline builds, beside the compile
gate's most recent word about it. variants is how many pipelines this
engine has built for it — one per (target format, vertex layout,
render-state key) permutation reached — and buildMs what those builds
cost, both summed since engine start. A pipeline the driver's own store
restored is not built and so is not counted, so a second launch on the same
adapter reports less than the first. status is compiled, failed or
pending, and error carries the compiler's message for a failure.
Ordered by cost, most expensive first.
local top = renderer.shaderCost()[1]; print(top.shader, top.variants, top.buildMs)
modules/renderer/shaderVariants
shaderVariants(): { ShaderVariants }
Every shader that declares optional features, and the programs its
materials have made it compile. Each row carries the features the shader
declares, the base program it ships as, and one entry per variant with the
features that variant holds — so the permutation count a scene's materials
are spending is a number to read rather than something to infer from
compile time. A shader whose variants reach budget compiles no more; the
materials asking for further feature sets draw with the base program.
for _, s in renderer.shaderVariants() do print(s.shader, #s.variants, s.budget) end
modules/renderer/shadingOf
shadingOf(subject: string | { [string]: any }): ShadingReading
What the renderer is shading ONE subject with, taken from the document
the renderer publishes — the call a system holding a handle makes to find
out whether what reaches the screen is its own material or the magenta
placeholder standing in for it, without reading the engine log. subject is
an entity that draws or the registry key of a material. state reads
itsMaterial where the renderer bound the program the material names,
errorMaterial where it bound the placeholder instead, stalePipeline
where the pipeline drawing it was built before that program's most recent
compile, nothingBound where the renderer resolved no pipeline for it,
pending where this call is the one that armed per-draw recording and the
frame after it publishes, and unknown where the renderer holds a
resolution under no such subject. A fault state carries the renderer's own
reason from the closed set renderer.drawDiagnostics() names — plus
materialNotPrepared, which a material subject reads where the renderer
prepared nothing under that key — the compiler's detail, the program
the material asked for and the bound one; means states the reading in a
sentence. A material subject answers from the renderables drawing with it,
and from the renderer's record for the material itself where a draw
registered against the material carries no row of its own; a subject that
several renderables draw answers with a refused one wherever there is one.
The reading follows the renderer, so a program that compiles on a later
edit puts the subject back on itsMaterial from the frame the renderer
draws it with again.
Parameters
subjectstring | { [string]: any }— The entity — a proxy fromentity(...)or an entity-id string — or the material, as its registry key or theMaterialHandlerenderer.material.createreturned.
local s = renderer.shadingOf(emitter); if s.state ~= "itsMaterial" then print(s.means) end
modules/renderer/shadowCacheStats
shadowCacheStats(): {
What the last frame did with the shadow maps it already had. A shadow
view — one directional cascade, one atlas layer of spot tiles, one face of
a point light's cube — is drawn again only when something it draws from
changed:
its light moved, a caster it can see moved or appeared or vanished, a
caster's geometry or material changed, a caster changed pose or moved the
nodes its parts are placed by, or the map it writes into was reallocated.
Anything else keeps the depth already in the texture, so a scene that stops
moving reads rendered 0 while cached keeps climbing. A mesh whose
vertices a compute pass writes — a population, or a mesh built from a
compute buffer — re-renders the views it stands in every frame. A shadowed
point light contributes six views, one per cube face, so a caster moving on
one side of it re-renders the face that can see it and leaves the other
five holding what they have. Counted per light kind, plus the totals across
all three.
These are totals over every view of a kind. renderer.shadowViews() is the
same frame one view at a time, each row naming the light that owns it and
what it drew.
local s = renderer.shadowCacheStats()
print(("shadow views: %d drawn, %d cached"):format(s.rendered, s.cached))
modules/renderer/shadowCaching
shadowCaching(): boolean
Whether a shadow view may keep the depth it already holds.
modules/renderer/shadowCasterBatching
shadowCasterBatching(): boolean
Whether a shadow view draws every caster of one mesh together.
modules/renderer/shadowCasterCutoff
shadowCasterCutoff(): ShadowCasterCutoff
How small, and how far away, a caster may get before it stops writing depth into any shadow view. A shadow view rasterizes a caster's whole triangle count whatever the shadow it produces ends up covering, so an object the viewer resolves a fraction of a pixel of, and one past the range the scene cares about, each cost a full depth pass per shadowed light for detail nothing reads. Both thresholds are 0 — released — until something sets them.
local c = renderer.shadowCasterCutoff(); print(c.minRadiusPx, c.maxDistance)
modules/renderer/shadowConfig
shadowConfig(): ShadowConfig
The directional shadow quality now in force. resolution and cascades
size the cascade depth array; distance and splitLambda place the splits
along the view; fadeFraction and softness shape how the result is
sampled.
print(renderer.shadowConfig().cascades)
modules/renderer/shadowHero
shadowHero(): ShadowHeroReport
The registered hero caster and what the last frame's fit produced. A
frame that fit nothing says why in decline.
local h = renderer.shadowHero()
print(h.active, h.zoom, h.decline)
modules/renderer/shadowMemory
shadowMemory(): {
How much GPU memory the shadow maps hold right now, in bytes, by the
light kind that owns them. The spot atlas and the point pool are sized to
the casters in the scene rather than to the budget, so spot and point
move as lights that cast shadows appear and leave, and a scene with one
shadowed light holds far less than one that fills every slot. A budget is
the ceiling they grow within — renderer.spotShadowBudget().layers and
renderer.pointShadowBudget().slots report that ceiling, unmoved by how
many casters exist. Raising shadow resolution costs the square of the
change across every cascade.
local m = renderer.shadowMemory()
print(("shadows: %.1f MiB"):format(m.total / 1024 / 1024))
modules/renderer/shadowProxies
shadowProxies(): ShadowProxyReport
The shadow proxies in force and what the last frame's shadow passes did
with them. triangles and sourceTriangles are what those passes
submitted and what they would have submitted from the source meshes — the
before/after of every registration, equal while nothing is proxied.
local p = renderer.shadowProxies()
print(("shadow triangles: %d of %d"):format(p.triangles, p.sourceTriangles))
modules/renderer/shadowViews
shadowViews(): ShadowViewReport?
Every shadow view the last rendered frame considered, and what each one cost.
A frame rasterizes a depth view per directional cascade, one for the hero
caster, one per shadow-casting spot and six per shadow-casting point.
renderer.shadowCacheStats() counts those views by light kind,
renderer.drawStats() sums their draws with the camera's, and
profiler.gpuFrame() carries one scene.shadow span across all of them.
This is the same frame read one view at a time.
Each row names the view and the light that owns it, says whether it drew or
kept the depth it already held, and carries the draws, the instances and the
casters that went into it. span is the label the view's pass is timed
under, so its GPU time is a lookup in profiler.gpuFrame(); every one of
those labels is a variant of scene.shadow, which still carries their
total. camera carries the same instance counters for the main camera, so
the camera's share of a frame-wide total is a read rather than a measurement
taken by turning every light's shadow off.
A cascade's near and far are where the split scheme cut its slice, not
the world it covers: the fit takes the bounding sphere of that slice and
rasterizes the ortho box around it, and both reach past far. What the
cascade covers is center and radius, with viewProj the exact test;
coversNear and coversFar read that volume back along one ray, the
camera's view axis. directional states the axis reading for the set —
how far it reaches (coversFar), the range the splits were run over
(distance), how far the camera draws (cameraFar), and the
depth past the reach the camera still draws (uncovered). A receiver
further along the axis than coversFar has no directional depth map over
it and is shaded as if the sun reached it, so uncovered is the room a
missing shadow has and a surface standing in that room is what makes one;
@builtin::systems.proxyOcclusion occludes past the cascades. The box is
bounded in every direction, so a receiver standing wide of the axis leaves
it at its own distance even where uncovered is 0 — viewProj is what
answers for that receiver.
The list is rebuilt every frame: a view whose light stopped casting is
absent from the next report rather than standing at the numbers it last had,
and a frame that drew no shadow view answers a report whose views is
empty. views grouped the way the shadow cache decides — a row per cascade,
per spot atlas layer, per point cube — counts what
renderer.shadowCacheStats() reports as rendered + cached.
The frame names its views only while something is reading them, so this call asks the frames after it to name theirs and waits out the first one. Nil on an engine that renders no frame at all.
local report = renderer.shadowViews()
print(("camera submitted %d of %d instances"):format(
report.camera.submittedInstances, renderer.drawStats().compactedDrawn))
for _, view in report.views do
print(("%s %d (%s): %d draws, %d instances, %s"):format(
view.role, view.index, view.light, view.draws, view.instances,
view.rendered and "drew" or "cached"))
end
local d = report.directional
if d ~= nil and d.uncovered > 0 then
print(("sun shadows stop at %.0f; the camera draws to %.0f"):format(
d.coversFar, d.cameraFar))
end
modules/renderer/skinnedBatching
skinnedBatching(): boolean
Whether skinned instances holding one pose draw together.
modules/renderer/skinningPoseHold
skinningPoseHold(): boolean
Whether a pose already written into its slice skips its dispatch.
modules/renderer/skinningStats
skinningStats(): {
What the last frame's skinned instances cost. A skinned instance is
posed by a compute pass that writes its vertices into a shared pool, and
instances holding the same pose read one slice of that pool and the single
dispatch that fills it. instances is how many were posed, poses how
many distinct poses they held, and dispatches how many dispatches those
poses cost this frame — so a crowd whose members move together costs what
its poses cost rather than what its head count does, while members at
different animation times each hold their own pose and pay for it.
held is how many of the frame's poses cost no dispatch at all. The pass
produces a slice from what the pose is made of, so a slice an earlier frame
filled already holds what running it again would write, and a pose still
wearing that slice is read as it stands. Skinning is paid for by the poses
that CHANGED: a cast standing still reads dispatches 0 beside a held
equal to its poses, and the two add up to poses in any frame.
reusedSlices is how many of the frame's poses took a slice the pool
already held — one a retired pose gave back, or one a pose nothing has
asked for this frame was holding — rather than one cut from pool the
engine had never used. A scene whose poses keep changing reads a non-zero
count beside a poolBytes that stays where it was.
liveBytes is what the slices holding this frame's poses occupy, against
unsharedBytes — what the same instances would occupy with a slice each.
poolBytes is what the pool holds; a previous-position buffer of the same
size rides alongside it so skinned deformation reaches motion vectors.
local s = renderer.skinningStats()
print(("%d instances over %d poses"):format(s.instances, s.poses))
print(("%d poses dispatched, %d read as they stood"):format(s.dispatches, s.held))
print(("skinned vertex storage: %d B of an unshared %d B"):format(s.liveBytes, s.unsharedBytes))
modules/renderer/splat.components
splat.components(bytes: any, convention: string?): (SplatComponents?, string?)
Decode a Gaussian splat capture — a Niantic .spz (gzipped or raw) or a
3DGS .ply — into the GPU-ready byte pools a render feature uploads.
records is the packed splat array at recordBytes per splat (position,
log scale, quaternion, DC colour + opacity); sh is the quantized
higher-order spherical-harmonics pool at shStrideWords u32 words per
splat, empty at degree 0. A pure decode (no GPU work): upload the pools with
shaderRef:createBuffer + buf:writeBytes and draw them with a
kind = "splat", channel = "gaussian" pass.
Parameters
bytesany(optional) — Capture bytes —.spzor.ply, as abufferor a binary string.conventionstring?(optional) — Source axis convention:"rightDownFront"(the default, what COLMAP-trained captures use) or"engineNative"for a capture already in engine space.
local c = renderer.splat.components(vfs.readBytes("captures/ceramic.spz"))
modules/renderer/spotShadowBudget
spotShadowBudget(): SpotShadowBudget
The spot and area-light shadow atlas now in force. Each shadow-casting
spot is given a tile of it every frame, sized to what the camera can
resolve: a light filling the view gets a whole layer at resolution, one
far away gets a minResolution tile, and the atlas holds tiles of the
smallest kind. That is what lets one budget serve a close hero light and a
street of distant ones without either the memory or the sharpness being set
for the worst case.
local s = renderer.spotShadowBudget()
print(("%d layers of %d, %.1f MiB"):format(s.layers, s.resolution, s.bytes / 1024 / 1024))
modules/renderer/temporal.held
temporal.held(): boolean
Whether a hold is pinning the per-frame clock right now.
if renderer.temporal.held() then print("frame is pinned") end
modules/renderer/temporal.hold
temporal.hold(at: number?, options: TemporalHoldOptions?): () -> ()
Pin the clock every per-frame effect draws itself against, and return
the release. While the hold stands, renderer.temporal.now answers at
instead of the running clock, so film grain and every other field redrawn
each frame is redrawn as the same field. Two renders taken under holds at
the same instant therefore agree pixel for pixel wherever the scene itself
has not moved, which is what makes one frame comparable with another.
Holds nest: the innermost names the instant, and the clock runs again once
the last release is called. Each release takes its own hold off the stack
whatever order the releases come in, so two callers holding at once — two
captures in flight together — each end their own hold and the clock runs
again when both have.
exclusive takes the clock for the owner key the call states: while
that hold stands, a hold is admitted only when it states the same key, and
every other one is refused with an error naming the key and the instant
holding it. That is what lets one caller wind the clock to the second it
means to photograph and keep it there while another agent drives the same
engine. The key is what an owner presents to take a nested hold of its
own, and what renderer.temporal.release hands the clock back by. A
capture taken while the hold stands renders at the held instant; a
deterministic capture takes a hold of its own that states no key, so it
runs once the clock is handed back.
Parameters
atnumber?(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.optionsTemporalHoldOptions?(optional) —owneris the key this hold is taken under, and an exclusive hold states one. A hold that states no key is labelled with the agent the call is attributed to, which is the account the caller presented a token for and is shared by every session driving this engine under it.exclusivetakes the clock for the stated key until the hold is released.
Returns () — A function that releases this hold. Calling it twice releases once.
local release = renderer.temporal.hold() ; local png = tools.use("capture", "fromPosition", { position = { 0, 2, 8 } }) ; release()
local release = renderer.temporal.hold(46.0, { exclusive = true, owner = "stage-air" })
modules/renderer/temporal.now
temporal.now(): number
The instant a per-frame effect should draw itself at: the innermost hold's instant while one stands, and seconds since boot otherwise. A system that redraws a field every frame reads this rather than the running clock, and a capture asking for a repeatable frame then gets one.
local params = { grainTime = renderer.temporal.now() }
modules/renderer/temporal.onChange
temporal.onChange(listener: (number) -> ()): () -> ()
Register a listener called with the pinned instant whenever it changes — a hold taken, a hold released — and return the unsubscribe. A system whose shader reads the clock out of a GPU buffer registers here, so the buffer carries the pinned instant before the frame that hold was taken on is drawn rather than a frame later.
Parameters
listener(number) -> ()— Called with the instant now in force, in seconds.
Returns () — A function that removes this listener.
local stop = renderer.temporal.onChange(function(t) pushClock(t) end)
modules/renderer/temporal.owner
temporal.owner(): { id: string?, name: string?, at: number, exclusive: boolean }?
The hold naming the instant the clock answers right now: who took it,
what instant it pinned, and whether it took the clock exclusively. Several
agents drive one engine at once and a hold any of them takes moves the
clock every registered field is redrawn against, so this is how a caller
sees that another agent holds it before its own instant is quietly
replaced — and, when exclusive is true, id is the key a hold of its
own states to be admitted, and the key renderer.temporal.release hands
the clock back by. id and name are nil for a hold that stated no key
and that the engine attributes to no agent.
local who = renderer.temporal.owner()
if who ~= nil and who.exclusive then print(who.name, "holds the clock at", who.at) end
modules/renderer/temporal.release
temporal.release(owner: string): number
Hand the clock back by the key its holds were taken under, and report how many came off. A hold stands until its release is called, and the release is a closure the call that took the hold holds: a caller that takes a hold in one call and comes back in another, and a task that ends between the two, both leave the clock pinned with nobody holding a release for it. Naming the key is how the clock runs again, and how a caller refused by an exclusive hold takes one over.
Parameters
ownerstring— The key the holds to release were taken under — whatownerstated when they were taken, whichrenderer.temporal.ownerreports.
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
texturestring | { [string]: any } | AssetRef— The texture to read back — aTextureHandle, a guid, aTextureCpuHandleor a textureAssetRef.
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
widthnumber— number Canvas width in pixels.heightnumber— number Canvas height in pixels.fillany?(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
bytesbuffer | string— The ZTEX or image bytes.encodeOptsany?(optional) —{ format?, srgb?, generateMipmaps?, maxDimension? }applied when the bytes are an encoded image and need the engine-native encode.
local cpu = renderer.texture.cpuFromBytes(vfs.read(path)); local png = cpu:encodePng(); cpu:unload()
modules/renderer/texture.create
texture.create(src: any, guid: string?): TextureHandle
Create (or fetch) a GPU texture resource and return its TextureHandle.
src: a TextureCpuHandle from texRef:load() (CPU→GPU under the asset's
guid, idempotent); raw pixels {rgba, width, height, srgb?, format?} (a
flat widthheight4 byte payload, 0-255, row-major, top-to-bottom, RGBA —
a buffer, a binary string, or a number array; format = "rgba16f"
uploads an HDR texture instead, where rgba carries float channel
values); a TextureHandle (returned as-is);
or render-target dimensions {width, height, name?, format?} with no pixel
source — an empty GPU texture a render pass writes into (camera output,
UI surface) and that samples like any other texture. format names the
colour format the target is allocated in, and the passes drawing into it
are built for that format: "rgba8unorm" / "bgra8unorm" (the two
eight-bit channel orders, either of which a surface may carry),
"rgba16f" / "rgba32f", "rg16f" / "rg32f", "r16f" / "r32f".
Each also answers to its spelled-out width ("rgba16float", "r32float",
and so on), in any case. Omit it to take the surface's own. A float format
carries what eight bits quantize — positions, velocities, HDR. Any other
format raises an error naming every name that works, so a target is
allocated in the format it was asked for or not at all. A render target
takes filter the way raw pixels do: "nearest" keeps its own pixels square
wherever something draws it larger than it is — a viewport widget, a
magnified capture — which is what an image whose pixels ARE the subject
needs, since a 64x32 panel holds no detail between its pixels to
interpolate; "linear" (the default) smooths between them. It also
takes screen (the engine keeps it the size of the image being drawn),
screenScale (the fraction of that size it takes) and screenSpace
("scene", the default, or "composite" — the image the post-scene
phases draw into, which is the display's own resolution while the renderer
presents the viewport itself and the scene's size while a UI viewport panel
owns the presentation). A scene-space target is resized for every render
target drawn and cleared before an offscreen one; a composite-space target
follows the presented frame alone, which is what lets a pass keep an
accumulation in it. One scene-space screen target is therefore one
resource every render target draws through in turn, so its guid holds the
last one's image at the last one's size, and a value read back from it
belongs to whichever render target was drawn last. A reading that has to
be the viewport's own comes from screenSpace = "composite", or from a
target created without screen. NEVER takes an AssetRef — load the CPU
first.
Parameters
srcany(optional) — A TextureCpuHandle, raw pixels, a TextureHandle, or render-target dimensions.guidstring?(optional) — Optional v4 guid for a NEW runtime texture — the asset identity the texture is filed under, which a material's texture slot resolves through. Minted when absent. Ignored for the CPU-handle and render-target paths.
local gpu = renderer.texture.create(texRef:load())
local gpu = renderer.texture.create({ rgba = pixels, width = 16, height = 16 })
local px = buffer.create(16 * 16 * 4); local gpu = renderer.texture.create({ rgba = px, width = 16, height = 16 })
local rt = renderer.texture.create({ width = 512, height = 256, name = "panel_rt" })
local hdr = renderer.texture.create({ width = 512, height = 256, name = "cam_rt", format = "rgba16f" })
local led = renderer.texture.create({ width = 64, height = 32, name = "panel", filter = "nearest" })
modules/renderer/texture.createFromAsset
texture.createFromAsset(
Put a .texture asset on the GPU under its own guid and answer its
handle at once. The asset's bytes are decoded off the frame and the
texture lands on the device when the decode finishes, a frame or more
later: a material naming the guid draws the shader's default for that
slot until then and rebinds when it arrives, and
renderer.texture.isResident reports the arrival. The decoded pixels are
dropped once uploaded unless keepCpu holds them in the CPU store for
textureRef:load()-style reads. An asset the device already holds is
answered from the shape the device reports, without reading the asset's
bytes and without a second decode.
local h = renderer.texture.createFromAsset(texRef) -- bind h.guid on a material; it draws once resident
modules/renderer/texture.decode
texture.decode(bytes: buffer | string): (any, any, any, any)
Decode a texture payload to its pixel buffer. Takes the two shapes the renderer's own texture loader takes, told apart by their leading bytes:
- an engine-native
ZTEXpayload — 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. AZTEXholding block-compressed or verbatim source-image levels decodes to"rgba8". - source image bytes — png, jpeg, gif or webp, straight off disk or out of
a
capture— decoded to"rgba8"at whatever colour type, bit depth or interlacing the file was written with. This is the call that reads the pixels of a screenshot.
The fourth return names the format the buffer came back in: "rgba8" (4
bytes/texel, channels 0-255), "rgba16" (8 bytes/texel, 16-bit unsigned
normalized channels 0-65535) or "rgba32f" (16 bytes/texel, float
channels).
Parameters
bytesbuffer | string— AZTEXpayload or source image bytes.
local pixels, w, h = renderer.texture.decode(vfs.read("/source/tmp/shot.png"))
modules/renderer/texture.destroy
texture.destroy(texture: string | { [string]: any } | AssetRef): boolean
Release the GPU texture texture names. For an empty render-into texture
(camera output, UI surface) this also frees its render scratch; for an
uploaded runtime texture it drops the GPU resource (and any CPU shadow).
After this, renderer.texture.list stops answering for the guid. Takes
every form that names a texture — the TextureHandle create returned, the
guid the listing hands out, a TextureCpuHandle or a texture AssetRef.
Parameters
texturestring | { [string]: any } | AssetRef— The texture to release — aTextureHandle, a guid, aTextureCpuHandleor a textureAssetRef.
renderer.texture.destroy(rt)
renderer.texture.destroy(renderer.texture.list()[1].guid)
modules/renderer/texture.encode
texture.encode(rgba: any, width: number, height: number, opts: any): (string?, string?)
Encode raw pixels into an engine-native ZTEX payload (the on-disk
texture content). The CPU codec behind the texture assetType's onCreate.
opts.format selects the on-disk precision: "rgba8" / "srgb" (default,
8 bits/channel, rgba is widthheight4 bytes) or the high-precision data
formats "rgba16" (16-bit unsigned normalized, widthheight8 bytes) /
"rgba32f" (32-bit float, widthheight16 bytes) — for height/displacement
fields, baked lightmaps, and other data rasters an 8-bit format quantizes
visibly. The two high-precision formats store rgba verbatim and reject
opts.generateMipmaps / opts.maxDimension.
Parameters
rgbaany(optional) — Pixel payload atopts.format's native byte width — abuffer, a binary string, or a number array.widthnumber— numberheightnumber— numberoptsany(optional) —{ format?, srgb?, generateMipmaps?, maxDimension? }
modules/renderer/texture.encodeFromImage
texture.encodeFromImage(bytes: buffer | string, opts: any): (string?, string?)
Encode source image bytes (png/jpg/webp/…) into an engine-native ZTEX
payload. Used by the texture importer / assetType onChange.
Parameters
bytesbuffer | string— source image bytes.optsany(optional) —{ format?, srgb?, generateMipmaps?, maxDimension? }
modules/renderer/texture.frameSchedule
texture.frameSchedule(texture: string | AssetRef): { number }?
The times at which each layer of a timed texture stops being shown, in seconds from the start of the sequence — the running total of the layer display times, so the last entry is the length of one pass.
This is the form a sampler reads a sequence through: a time is turned into
a layer by finding the first entry it has not passed, whatever the
individual layer times are. It is what the schedule slot of the builtin
animatedTexture shader holds, one entry per layer.
A texture whose layers carry no timing — a still image, a sprite sheet, a LUT stack — has no schedule and answers nil.
Parameters
texturestring | AssetRef— The texture — a guid, an identity, a name, a path, or a textureAssetRef.
local ends = renderer.texture.frameSchedule(texRef) -- {0.1, 0.15, 0.35}
modules/renderer/texture.info
texture.info(ztex: buffer | string): (any, any)
Read the header of an engine-native ZTEX payload without copying the
pixels. Returns its format, dimensions, mip count, filter ("nearest"
or "linear" — the sampler baked into the blob from the asset's
settings.filter), and the payload's layer shape.
layers counts the array layers the payload carries and isArray is true
past one — the answer to "am I about to sample a texture_2d_array?",
available before anything samples it. animated is true when those layers
are a sequence in time; then frameDelaysMs lists each layer's display
time in milliseconds in display order, and durationMs totals one pass.
An animated image imports as one layer per frame, so layers is its frame
count. A still texture reports layers = 1, isArray = false.
Parameters
ztexbuffer | string— ZTEX bytes.
local i = renderer.texture.info(bytes); if i.animated then print(i.layers, "frames", i.durationMs, "ms") end
modules/renderer/texture.isResident
texture.isResident(texture: string | { [string]: any } | AssetRef): boolean
True if a GPU texture is resident under this texture's guid.
Parameters
texturestring | { [string]: any } | AssetRef— The texture — aTextureHandle, aTextureCpuHandle, a guid, or a textureAssetRef.
print(renderer.texture.isResident(handle))
modules/renderer/texture.list
texture.list(): { any }
Every texture currently registered, ordered by guid — the ones a script
created and the ones that reached the device through an asset alike. Each
entry carries the guid, where it came from (origin is "asset" for a
texture the asset path uploaded), and whether the GPU still holds it. A
resident entry also carries the bytes it costs, its dimensions and its
texel format, so the listing sums to renderer.textureMemory(). A
streamable one carries streamOrigin — "asset" when a level change reads
the levels it needs back from the asset, "retained" when the cache holds
the pixels for it.
A script-created entry also carries held — whether renderer.hold pins
it for the session — and scene, the load that created it.
renderer.references("texture", guid) says what is still holding a row,
and renderer.collect() releases the rows nothing holds.
for _, t in ipairs(renderer.texture.list()) do print(t.guid, t.bytes) end
modules/renderer/texture.loadCpu
texture.loadCpu(
Load a .texture asset's pixels into the ONE guid-keyed CPU store (the
Disk→CPU step) and return a CPU handle for per-pixel access (no GPU
readback). The handle holds NO pixels — only the guid, dims and texel
format plus the read/write/encode/unload ops (which read the Rust store).
The pixels stay at the format they were authored in: handle.format is
"rgba8", "rgba16" or "rgba32f", and :readPixel reports channels in
that format's own units. Called by texRef:load(). DEFAULT: upload to the
GPU then handle:unload().
local cpu = texRef:load(); local r,g,b,a = cpu:readPixel(3, 4); cpu:unload()
modules/renderer/texture.readback
texture.readback(texture: string | { [string]: any } | AssetRef): TextureCpuHandle
Read a runtime GPU texture's pixels back to CPU and return a
TextureCpuHandle for them — the GPU→CPU half of the runtime-texture freeze
path. A texture made with renderer.texture.create keeps no CPU copy, so
persisting it (:encode() → asset.create("texture", …)) reads it back
here first. Yields until the readback completes (a frame or two). After it
returns the pixels are resident in the guid-keyed CPU store: :readPixel,
:writePixel, :getInfo, :encode, :unload all work. Errors if the
texture never becomes GPU-resident.
A SCENE-space screen-sized render target is one resource shared by every
render target drawn — the viewport, an offscreen capture, a camera
rendering into a texture — resized and re-derived for each of them in
turn. The copy is taken ahead of all of them for the frame, so what a
readback of its guid answers is the content of the last frame the renderer
drew: the presented view's own image at the presented resolution, since
the presented view is the sink that draws last. A request made while the
renderer is holding frames back is carried to the next frame it draws
rather than being answered from a target another sink left standing, so a
readback can wait a frame longer than the copy itself takes.
Parameters
texturestring | { [string]: any } | AssetRef— The texture — theTextureHandlerenderer.texture.createreturned, a guid, or a textureAssetRef.
local cpu = renderer.texture.readback(handle); local ztex = cpu:encode(); cpu:unload()
modules/renderer/texture.tone
texture.tone(histogram: any): TextureTone
Reduce a histogram to what the picture's tone IS: where its darkest and brightest pixels sit, where the body of it sits, and how much of it is standing on the floor or the ceiling — all in code values on the 0-255 scale the pixels were delivered at.
span (max - min) is the whole range including a single stray pixel;
spread (p95 - p5) is the range the body of the picture occupies, which
is the reading that says whether a shot is legible. A frame whose subject is
modelled and shaded but delivered inside a few code values reads a large
mean and a tiny spread, and no mean alone can tell that apart from a
frame with a subject in it.
crushed and clipped are the shares of the picture at code 0 and at code
255, each 0..1 — what a shot loses to the floor and to the ceiling.
Parameters
histogramany(optional) — A histogram fromcpu:histogram().
local t = cpu:tone(); if t.spread < 24 then error("the shot is flat") end
modules/renderer/texture.update
texture.update(texture: string | { [string]: any } | AssetRef, src: any): TextureHandle
Overwrite the GPU texture texture names IN PLACE, under the same guid,
from new raw pixels. Never writes a .texture file — the play-mode mutate
path. Takes every form that names a texture — the TextureHandle create
returned, the guid renderer.texture.list hands out, a TextureCpuHandle
or a texture AssetRef. Returns a handle carrying the new dimensions: the
handle it was given, refreshed, and a handle over the guid otherwise.
Parameters
texturestring | { [string]: any } | AssetRef— The texture to update — aTextureHandle, a guid, aTextureCpuHandleor a textureAssetRef.srcany(optional) — New raw pixels{rgba, width, height, srgb?, format?}—rgbaas abuffer, a binary string, or a number array.
modules/renderer/textureMemory
textureMemory(): {
What the GPU texture cache holds, split by whether the texture is
block-compressed. compressedBytes and uncompressedBytes are what those
textures cost in VRAM, measured from each texture's own format and mip
chain — so a .texture whose settings name format = "bc7" appears in the
compressed columns at a quarter of what the same image costs as RGBA8.
blockCompressionSupported is whether this adapter can hold
block-compressed textures at all; where it is false a BC7 payload is
uploaded decoded and lands in the uncompressed columns instead, so the
texture is present everywhere and compressed where the hardware allows it.
Measured at the end of the last rendered frame.
streamableTextures is how many of them a texture budget can move the
base mip level of, split by where a level change reads the levels it needs
from: assetStreamedTextures are read back from the asset they came from
and hold nothing in system memory, retainedTextures hold the payload
because a script uploaded their pixels and the GPU copy is the only other
one there is. streamSourceBytes is what those held payloads occupy in
system memory — bytes that are not VRAM — so it is a reading on the
retained half alone. pinnedTextures counts the textures big enough to
stream that stand at a level nothing can move: their pixels were released
and no asset holds them, the asset behind them could not be read back, or a
UI image, a post-process property or a render feature holds a view of them.
A texture out of the streamable set only because no measured surface wears
it stands in neither count: a surface reaching it takes it back up, so its
level moves again as soon as there is a footprint to move it by. It reads 0
while no budget is armed.
local tm = renderer.textureMemory()
print(("%d compressed textures hold %.1f MiB"):format(tm.compressedTextures, tm.compressedBytes / 1048576))
print(("%d textures stream from their asset, %d hold %.1f MiB of pixels"):format(
tm.assetStreamedTextures, tm.retainedTextures, tm.streamSourceBytes / 1048576))
modules/renderer/textureStreaming
textureStreaming(): TextureStreaming
What the last frame's texture-residency plan decided. budgetBytes is
the armed budget, and 0 means residency is left alone. streamable is
how many textures the plan can move. residentBytes is what those textures
occupy now, measured from the textures that are allocated; demandedBytes
is what the frame's demand alone would have cost, so the two part exactly
where the budget is doing something. starved counts the textures left
coarser than the frame asked for, promoted the ones that climbed a level
this frame, and changed the ones whose GPU texture was replaced. A camera
approaching a surface reads promoted above zero for a few frames and then
zero once it settles.
textures is one row per streamable texture, ordered by key, carrying the
level each one was asked for and the measurement that asked. Two byte
totals can agree while a single texture sits several levels off what its
surface samples, so read the row when the question is which level a texture
holds and why.
With budgetBytes at 0 nothing holds a level back, so residentBytes,
plannedBytes and demandedBytes all read the whole chain of every
texture still enrolled and textures is empty — which is how a session
that armed a budget and dropped it reads back that the levels came home.
local ts = renderer.textureStreaming()
print(("textures: %.1f MiB resident of %.1f MiB demanded, %d starved"):format(
ts.residentBytes / 1048576, ts.demandedBytes / 1048576, ts.starved))
modules/renderer/transmissionShadows
transmissionShadows(): boolean
Whether translucent casters tint the directional light they block.
modules/renderer/uploadStats
uploadStats(): {
What the last completed frame spent re-describing its renderables to the
GPU. Every renderable owns a slot in the per-instance data a draw reads —
its world matrix, the bounds the culler tests it by, and the flags that
decide which passes and which culling stages see it — and a frame uploads
only the slots whose contents changed. bytes is what those uploads
carried, fullBytes what re-sending every slot would have cost, and
writes how many buffer writes carried it. The three numbers cover that
per-renderable data alone, so a scene standing still reads bytes = 0
against a fullBytes that grows with the scene, and the ratio says how much
of it the scene's own churn — rather than its size — is paying for.
local u = renderer.uploadStats()
print(("instance upload: %d B of %d B in %d writes"):format(u.bytes, u.fullBytes, u.writes))
modules/renderer/variantSource
variantSource(program: string): string?
The WGSL one of the programs renderer.shaderVariants() lists holds,
exactly as the shader compiler received it. program is the program
field of a row's base or of one of its variants. Reading a base
alongside a variant shows what a feature set selected: each program's text
holds the code its own features guard. The variant-report spelling of
renderer.compiledSource, which answers the same for every other shader.
Parameters
programstring— Aprogramname fromrenderer.shaderVariants().
local row = renderer.shaderVariants()[1]
local base = renderer.variantSource(row.base.program)
typed/builtin//modules/api/engine/renderer/renderer/anisotropy
renderer.anisotropy() -> number
The maximum anisotropy material textures are sampled with right now — the requested level clamped to what this device honours.
Returns number — The effective level, 1 through 16.
if renderer.anisotropy() < 4 then ... end
typed/builtin//modules/api/engine/renderer/renderer/blendedBatching
renderer.blendedBatching() -> boolean
Whether blended neighbours sharing a draw key draw together.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/clearShadowHero
renderer.clearShadowHero() -> boolean
Release the hero caster, so the directional shadow is the cascades' alone again and the layer the hero view rendered into is given back.
Returns boolean — Whether a caster was registered.
renderer.clearShadowHero()
typed/builtin//modules/api/engine/renderer/renderer/clearShadowProxy
renderer.clearShadowProxy(mesh: string?) -> number
Stop proxying mesh, so it rasterizes its own geometry into shadow
views again. Called with no argument, drops every registration.
Parameters
meshstring(optional) — The mesh to stop proxying. Omit to clear all of them.
Returns number — How many registrations were removed.
renderer.clearShadowProxy(statueMesh)
print(renderer.clearShadowProxy(), "proxies dropped")
typed/builtin//modules/api/engine/renderer/renderer/collect
renderer.collect() -> RuntimeCollection
Release every runtime texture, material, mesh and render feature nothing holds: no handle a script still reaches, no live owner, no reference from live engine state, no asset backing it, no hold. A root scene load runs this once the new scene stands, so what the previous scene's content created and nothing still wears goes with that scene; calling it directly collects at any other moment. A session material's handle counts as reached while the entity it was keyed for stands, and stops counting once that entity is gone. It reaches the GPU textures the device holds beside the registry's own: a texture the cache loaded for an asset goes once nothing live names it and is read back from that asset the next time something asks for it, while one no asset answers for stays, there being nothing to read it back from — a render pass's own target, a colour swatch, an atlas the engine built. A texture the ASSET path uploaded and whose asset has since been removed has nothing to come back from either, and the collection decides about it from its holders the way it does about every other resource: a handle a script still reaches, a live owner, a reference from live engine state, a hold. Features go first, then materials, then meshes, then textures, so a texture only a released material named goes with the material. Runs a full garbage collection first, so a handle nothing reaches counts as let go, and yields for the frame the census runs on. A handle the calling function still has in a variable — or in a temporary it has not overwritten — is one a script reaches, so a resource created in the function that collects is let go by the next collection rather than this one.
Returns RuntimeCollection — { released = { texture, material, mesh, feature }, kept, entries } — the counts released per kind, how many stayed, and every resource's status with action = "released" | "kept".
local c = renderer.collect() print(c.released.texture, c.kept)
typed/builtin//modules/api/engine/renderer/renderer/compiledShaders
renderer.compiledShaders() -> { string }
Every name renderer.compiledSource answers for — one per name a
shader compile has run under this session, whether it succeeded or failed.
What makes the composed-source surface enumerable rather than something to
guess a key for.
Returns { string } — An array of shader names, sorted.
for _, name in renderer.compiledShaders() do print(name) end
typed/builtin//modules/api/engine/renderer/renderer/compiledSource
renderer.compiledSource(shader: string) -> string?
The WGSL the shader compiler received under one name, exactly as it
received it — the composed module, which is what a compile error's line
numbers and handle indices are positions in. Answers under any name a
compile ran under (identity, guid, alias, or a program from
renderer.shaderVariants()), for a shader that declares no features, and
for a shader whose compile FAILED, which is the case it exists for: a
message about a function body carries a position and nothing else, and the
text that position is in is this. The failed text stands for as long as
shaderRef:compileStatus() reports that failure under the same name.
Parameters
shaderstring— Any name a shader compiled under — identity, guid, alias, or ashaderVariants()program name.
Returns string? — The composed WGSL, or nil for a name no compile has run under.
local status = asset.resolve("myShader", "shader"):compileStatus()
if status.status == "failed" then
print(status.error)
print(renderer.compiledSource("myShader"))
end
typed/builtin//modules/api/engine/renderer/renderer/compositeSize
renderer.compositeSize() -> { width: number, height: number }
The size of the image the post-scene phases worked on in the last
presented frame — the target the UI composites onto, which every pass
after the scene reads as @scene.color and writes into, and which a
screenSpace = "composite" render target follows. While the renderer
presents the viewport itself that is the display's own size, whatever
fraction of it the scene rasterized at; while a UI viewport panel owns
the presentation it is the size the scene rasterized at, since the panel
draws the scene target at its own rect and nothing upscales before the
composite. Both read 0 before a frame has drawn.
Returns { width: number, height: number } in pixels.
local c = renderer.compositeSize()
typed/builtin//modules/api/engine/renderer/renderer/cullStats
renderer.cullStats() -> {
What the last completed frame decided to draw. total renderables went
into the frustum test, culled fell outside it and visible survived. Of
those, occlusion culling measured occlusionTested against the depth
pyramid and proved occlusionCulled were entirely behind other geometry —
both 0 while renderer.occlusionCulling() is false. A renderable the
pyramid has no say over — one that laid no depth in the pre-pass, one whose
bounds were never recorded, one straddling the near plane — is measured
against nothing and counted in neither, so the gap between visible and
occlusionTested reads how much of the frame the test could speak for.
This answers for the main camera. What a shadow view's own volume did with
the frame's casters is on that view's row in renderer.shadowViews().
Returns { total: number, culled: number, visible: number, occlusionTested: number, occlusionCulled: number }
local s = renderer.cullStats(); print(s.visible - s.occlusionCulled, "drawn")
typed/builtin//modules/api/engine/renderer/renderer/depthPrepass
renderer.depthPrepass() -> boolean
Whether the opaque depth pre-pass is currently enabled.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/depthPrepassOrder
renderer.depthPrepassOrder() -> { runs: number, reordered: number }
What the last frame's depth pre-passes planned, and how far their
sequences were from near-to-far before they ordered. runs counts the
instanced draws planned; reordered counts the adjacent pairs the sort
moved past each other, taken before it ran. Both are summed over every
pre-pass the frame ran — the window plus each render-target camera, each
ordering against its own camera. Both read 0 while the pre-pass or the
ordering is off, and reordered reads 0 for a frame that already stood
in order. The ordering leaves no other trace — the draws, the depth and the
image are the same either way.
Returns { runs: number, reordered: number }
local o = renderer.depthPrepassOrder() -- o.reordered > 0 → it sorted
typed/builtin//modules/api/engine/renderer/renderer/depthPrepassOrdering
renderer.depthPrepassOrdering() -> boolean
Whether the depth pre-pass is submitted nearest-first.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/destroy
renderer.destroy(handleOrKind: any?, id: string?) -> boolean
Free the GPU resource a renderer resource holds (the GPU-destroy verb).
Takes any of the forms that name it: the handle a create returned, routed
by its category so one call releases a mixed set of handles; the id a
listing hands out, whose kind is read back off what the renderer holds
under it — the runtime registry, the material definitions, the live
features, and the device itself for an asset's own texture or mesh; or the
kind with the id beside it, the shape renderer.hold and
renderer.references take, which is what names the kind for an id two of
them answer to. An id nothing holds anything under releases nothing and
answers false. The on-disk asset, if any, is untouched. A CPU handle's
:unload() frees the CPU copy separately.
Parameters
handleOrKindany(optional) — AMeshHandle,TextureHandle,MaterialHandleor feature handle; the id itself; or the kind ("texture","material","mesh","feature") with the id as the second argument.idstring(optional) — The guid or registry key, when the first argument is a kind.
Returns boolean true if a GPU resource was known under the id.
renderer.destroy(meshHandle); renderer.destroy(materialHandle)
renderer.destroy(renderer.texture.list()[1].guid)
renderer.destroy("mesh", guid)
typed/builtin//modules/api/engine/renderer/renderer/deviceGeneration
renderer.deviceGeneration() -> number
Which render device this process is on, counted from the first.
A render device is lost when a driver resets, when the GPU is taken away,
or when a browser reclaims a WebGPU context. The engine answers by building
another device and re-deriving this session's resources onto it, and this
number moves by one each time it does. Anything held across frames that was
built from a GPU resource records this beside it and remakes it when the two
differ; engine.onDeviceRebuilt is the hook that fires when it moves.
Returns number — The current device generation, counting from 1.
local generation = renderer.deviceGeneration()
engine.onDeviceRebuilt(function(g) print("device " .. g) end)
typed/builtin//modules/api/engine/renderer/renderer/deviceState
renderer.deviceState() -> string
Whether the render device this process draws through is the one it is using, one it is replacing, or one it has stopped trying to replace.
"ready" is a live device. "rebuilding" is the window between a device
reporting itself lost and another being in place: every GPU resource built
from the old one is invalid, the frames in that window draw nothing, and
anything reaching the GPU refuses. "abandoned" is after the engine gave
up — the adapter refused every attempt, so this session draws no more
frames.
Work that spans the device — build a render target, draw into it, read it
back — reads this to tell an operation that failed because the device went
out from under it, which is worth doing again once
renderer.deviceGeneration() moves, from one that failed on its own terms.
The loss is reported before the next device exists, so the two readings
answer different halves: this one says a replacement is coming, the
generation says it arrived.
Returns string — "ready" | "rebuilding" | "abandoned".
if renderer.deviceState() == "rebuilding" then return end
typed/builtin//modules/api/engine/renderer/renderer/drawDiagnostics
renderer.drawDiagnostics() -> { DrawDiagnostic }
Every renderable that is NOT drawing what its material says — the one
call for "why does this surface look wrong". Three states land here: a
surface rendering as the magenta placeholder (substituted), one the
renderer could bind nothing for at all (outcome = "skipped"), and one
drawing a program whose most recent compile FAILED (stale), which is what
a shader edited into brokenness looks like — the pipeline its last good
compile built keeps drawing, so the picture is intact and answers to none of
the edits since. Each row names the entity, the program asked for, the
program bound, programStatus — the compile gate's word about the program
the material NAMED — and the one cause
from shaderCompileFailed / shaderNotRegistered / shaderNotCompiledYet
/ noGbufferEntry / renderStateKeyNotBuilt / noPipelineForTarget /
unshaded, with the compiler's own message in detail or programError.
Covers every renderable the renderer holds, whether or not a camera reached
it: a row with observed = false and outcome = "notDrawn" carries the
renderer's own resolution for one this frame drew nowhere, so a broken
surface off-screen is reported the same as one in frame. An empty result
means every renderable the renderer holds is drawing the program its
material named and that program compiles. Answers on the deferred path as
well as forward, and in edit mode as well as play.
Returns { DrawDiagnostic }
for _, d in renderer.drawDiagnostics() do print(d.entity, d.reason, d.detail) end
if #renderer.drawDiagnostics() == 0 then print("every surface is drawing what it says") end
typed/builtin//modules/api/engine/renderer/renderer/drawStats
renderer.drawStats() -> {
What the last completed frame actually submitted. draws counts every
geometry draw call the frame issued — the camera's passes, each shadow
view a shadow-casting light adds, and whatever a render feature draws —
and instances counts the instances those draws covered. The pair is what
separates one draw carrying five hundred instances from five hundred draws
carrying one each, so it reads how well the scene batches rather than how
many objects are in it.
compacted is how many of those instances the frame planned through draws
whose instance count the GPU decides: the culler's own per-object answers
packed into a dense run, so an object it rejects is absent from the draw
instead of collapsing to nothing in the vertex stage. compactedDrawn is
how many of them survived, counted on the GPU as it packed them — a pass
that then skips a whole draw over its own layer or visibility answer
leaves that draw's instances in both numbers.
The plan is made over the populations the frame draws, and the tests
answer which of their instances the packing keeps. That packing runs
before any pass has resolved the depth occlusion culling is tested
against, so on its own it reads the frustum and screen-size answers
alone. With setOcclusionCulling armed the frame packs the same plan a
second time once the test has answered, and compactedDrawn then counts
what came through occlusion as well.
compactedDrawn comes back from the buffer the GPU wrote, so it describes
a frame that has finished while compacted describes the most recent
plan, and it holds the last count the GPU wrote until another arrives — a
frame that compacts nothing reads compacted 0 beside the count from the
last frame that did. In a scene standing still the gap between the two is
the front-end work culling removed.
materialBinds is how many times the frame's geometry passes set a
material's parameter group, and materialBindsElided how many times a
pass reached that decision and found the group already bound. Their sum
is how many times the decision was reached — once per unit of geometry
submitted, which sits at or below draws, since a mesh of several
primitives draws once per primitive under one set of binds. The ratio
inside the pair is what material binding costs the frame: the batched
opaque geometry is gathered into runs sharing a material, so a frame of
many such draws over few materials binds about once per material rather
than once per unit. materialExtraBinds and materialExtraBindsElided
are the same pair for the second group, the storage bindings a shader
declares for itself, which only the shaders that have them ever bind.
pipelineBinds and pipelineBindsElided are the same pair for the
pipeline itself: how many times the frame's geometry passes set one, and
how many times a pass reached that decision and found the pipeline it
wanted already bound. Which pipeline a unit needs follows its shader, its
material's render state and its mesh's vertex layout together, so a scene
whose units share all three costs one set for the run of them, while units
differing in any one of the three each pay their own. Their sum is how
many units reached the pipeline decision, which sits at or above what the
material pair reports: a unit the pass settles a pipeline for and then
abandons — one whose material group resolved to nothing — counts here and
never reaches the material decision.
Every figure here is the whole frame's, the main camera's draws and every
shadow view's summed together. renderer.shadowViews() splits compacted
and compactedDrawn across the views that made them, and carries the
camera's own share beside them.
Returns { draws: number, instances: number, compacted: number, compactedDrawn: number, materialBinds: number, materialBindsElided: number, materialExtraBinds: number, materialExtraBindsElided: number, pipelineBinds: number, pipelineBindsElided: number }
local d = renderer.drawStats(); print(d.instances / math.max(d.draws, 1), "instances per draw")
print(renderer.drawStats().compactedDrawn, "of", renderer.drawStats().compacted, "survived the cull")
local d = renderer.drawStats(); print(d.materialBinds, "material binds over", d.materialBinds + d.materialBindsElided, "units")
local d = renderer.drawStats(); print(d.pipelineBinds, "pipeline sets over", d.pipelineBinds + d.pipelineBindsElided, "units")
typed/builtin//modules/api/engine/renderer/renderer/framePacing
renderer.framePacing() -> FramePacing?
How far the CPU is allowed to run ahead of the GPU, and what holding it there cost the frame just finished. Submitting work to the GPU returns before the GPU has done it, and everything that submission holds — its staging allocations, its bind groups, its command buffer — stays alive until it completes. A frame that asks for more work than the GPU finishes in a frame's time therefore leaves that behind it, and unbounded that is memory growth rather than a lower frame rate.
framesInFlight is how many submitted frames have not reported done
through the queue's completion signal, held under maxFramesInFlight: a
device that keeps up reads under the bound, one that is behind reads at it.
It counts submissions, which is its own quantity — how many presented
images the swapchain permits in flight is a separate setting.
mechanism names how that bound is enforced
here: submission-wait waits for the frame that many frames back and
reports the wait in waitMs, so a paced frame costs latency and still
draws; submitted-work-done counts outstanding frames off the queue's
completion signal and declines to start a frame while the bound is met,
counting those in pacedFrames and leaving the last presented image up.
submittedFrames counts the frames that were admitted and submitted, so it
rises for as long as the renderer is producing frames — which is what tells
a renderer running slowly under a tight bound from one that has stopped.
stalled reads true while that completion signal has stopped arriving and
the pacer stood down rather than hold the image indefinitely; it clears on
the first frame that finds the count back under the bound.
producing is whether the renderer is drawing frames at all. A headless
renderer draws into an offscreen framebuffer that nothing presents, so its
image reaches a reader only through something that copies it out: it draws
while a consumer is asking — an MCP call in flight, a queued texture
readback, a recording, a frame-egress session — and declines the frames
between two asks, counting them in idleSkippedFrames. Every other
renderer stat answers with the last frame that drew, so producing is what
separates a live reading from a frozen one. A windowed renderer presents
every frame it draws and reads producing = true throughout.
presentMode is what the surface presents with and presentModes what it
offers; both are empty of meaning on a headless renderer, which never
presents.
Returns FramePacing? — { framesInFlight, maxFramesInFlight, pacedFrames, submittedFrames, waitMs, mechanism, stalled, producing, idleSkippedFrames, presentMode, presentModes }, or nil before the renderer has drawn a frame
local p = renderer.framePacing()
print(("%d of %d frames in flight, paced by %s"):format(p.framesInFlight, p.maxFramesInFlight, p.mechanism))
typed/builtin//modules/api/engine/renderer/renderer/getRaytrace
renderer.getRaytrace() -> boolean
Whether ray tracing is currently enabled.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/gpuMemory
renderer.gpuMemory() -> GpuMemory
Where the renderer's GPU memory went at the last completed frame — the call to reach for when something is holding memory and you do not know what.
Three figures answer three different questions, and they are meant to be read against each other:
- The categories —
shadow,textures,meshes,instances,compute, summing tocategorised— are the renderer's own accounting of what it asked for on purpose. Always present, on every backend. allocatoris the device allocator's ledger, with a row per creation label largest first, which is what names an allocation no category claims. It exceedscategorisedby the per-frame render targets and the scratch nothing categorises. The allocator hands memory out from blocks it reserves whole from the device and returns a block only once nothing is left in it, soreservedBytesruns aboveallocatedBytesby what those blocks hold unused;blockslists them emptiest first with the labels that keep each one alive, andemptyBytesplusslackBytesis that distance exactly — the pool held in empty blocks, and the room pinned inside blocks something still sits in.driver.deviceLocalBytesis 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 reportsavailable = falseand the reason.driver.outsideAllocatorBytesis 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 andrenderer.collect()all leave it where it is, and it falls when the device is destroyed. Read it when a session's device memory has grown and no ledger row accounts for the growth.
compute is what the compute subsystem holds; compute.observe() names
each of those resources and what it costs. renderTargets counts the
offscreen render targets the renderer holds at that frame, which is what
says a renderer.destroy has been applied rather than queued.
Returns GpuMemory — The accounting — see GpuMemory. The category figures are zeroed until the renderer has published its first frame; driver is read as the call runs and answers from the first.
local m = renderer.gpuMemory()
print(("shadows %.1f MiB"):format(m.shadow.total / 1024 / 1024))
-- how much of the card this engine is holding:
if m.driver.available then
print(("driver %.1f MiB"):format(m.driver.deviceLocalBytes / 1024 / 1024))
else
print("no driver figure: " .. m.driver.reason)
end
-- the biggest allocation in the engine:
local top = m.allocator and m.allocator.byLabel[1]
print(top and top.label, top and top.bytes)
-- the block held for the least, and what pins it:
local block = m.allocator and m.allocator.blocks[1]
if block and block.allocationCount > 0 then
print(block.size, block.allocatedBytes, block.byLabel[1].label)
end
typed/builtin//modules/api/engine/renderer/renderer/hold
renderer.hold(handleOrKind: any?, id: string?) -> boolean
Pin a runtime resource for the session. A held texture, material, mesh
or render feature survives every collection — the one a root scene load
runs and a direct renderer.collect() alike — until renderer.release
lets it go or its destroy frees it. It is the way to keep an ad-hoc
resource across the scenes that come and go under it. A hold keeps the
resource in the registry; a mesh's GPU buffers are governed by what draws
it, parked as a CPU definition when the last instance naming it goes and
brought back when one names it again, so renderer.mesh.isResident(guid)
is the separate question about the buffers.
Parameters
handleOrKindany(optional) — The resource's handle, or its kind ("texture","material","mesh","feature") with the guid or key as the second argument.idstring(optional) — The guid or key, when the first argument is a kind.
Returns boolean true when the registry knows the resource.
renderer.hold(tex)
renderer.hold("material", "swatch")
typed/builtin//modules/api/engine/renderer/renderer/loseDevice
renderer.loseDevice()
Destroy the render device on the next frame, so the engine meets a real device loss.
This is the one loss that can be caused on purpose, and it travels the same
path a driver reset does: frames draw nothing until the rebuild lands,
GET /engine/status reports the renderer as recovering while it does,
engine.onDeviceRebuilt fires afterwards, and renderer.deviceGeneration()
moves. Use it to prove that a world's content survives a device loss —
anything it holds only on the GPU has to be remade from the rebuild hook, and
this is how you find out whether it is.
renderer.loseDevice()
-- some frames later:
print(renderer.deviceGeneration()) -- one higher than before
typed/builtin//modules/api/engine/renderer/renderer/mainCameraView
renderer.mainCameraView() -> { number }?
The main camera's inverse view-projection (column-major, 16 numbers)
followed by its world position (3 numbers) — {m0..m15, px,py,pz} — for
reconstructing world positions from the depth buffer in a ray-tracing pass.
Nil before the first render.
Returns { number }? 19 numbers, or nil.
typed/builtin//modules/api/engine/renderer/renderer/materialCost
renderer.materialCost() -> { MaterialObservation }
What each material cost the frame the renderer last drew, and the state
it holds each one under. One row per material the renderer holds a prepared
bind group for — a material an author wrote and the renderer never prepared
is absent, which is itself the answer to "why is nothing I set reaching the
screen". draws and instances cover that one frame; placeholderDraws
is how many of those draws bound the magenta placeholder instead of this
material's own program; binds is how many material-owned bind groups the
frame's passes SET for it and bindsElided how many of its draws wanted a
group the pass already held, which is what draw-key sorting buys; a draw
that fell back to the placeholder bound the placeholder's group, so it
counts in placeholderDraws and in neither bind count. uniformBytes is
the GPU uniform buffer's own size,
which is the reflected property block raised to the 16-byte floor and
rounded up to the copy alignment. renderer.drawDiagnostics() names WHICH
renderable is not drawing what its material says, and why.
Returns { MaterialObservation }
for _, m in renderer.materialCost() do print(m.material, m.draws, m.binds, m.bindsElided) end
typed/builtin//modules/api/engine/renderer/renderer/materialIdentity
renderer.materialIdentity() -> MaterialIdentity
Which material each renderable draws with, as a number a shader can
carry. A material is authored and bound by name, and no shader can read a
string — so every renderable's per-instance record holds a material index
instead. slots is the name → index table those indices are drawn from: an
index is assigned the first time the renderer draws with that material and
does not move afterwards, so two renderables that differ only in material
read different indices, and one renderable reads the same index frame after
frame. It follows that the table keeps a row for every material name drawn
this session, whether or not anything still draws with it. renderables is
a row per renderable that owns a GPU slot — the entity it belongs to, that
slot, and the index the record at it carries; populations is the same for
an instanced draw, whose whole reserved run of slots carries the one
material its registration named. That index is what a shader reads as
instance_data[slot].material_index, and the row a ray hit resolves
through zeroMaterial(). A renderable draws with the material its entity
references, so one whose entity names none carries index 0.
Returns MaterialIdentity — { slots: { [string]: number }, renderables: { { entity: string, slot: number, index: number, material: string } }, populations: { { slot: number, count: number, index: number, material: string } } }
local id = renderer.materialIdentity()
for _, r in id.renderables do print(r.entity, r.slot, r.index, r.material) end
typed/builtin//modules/api/engine/renderer/renderer/materialIndex
renderer.materialIndex(name: string) -> number?
The index standing for a material, or nil for one the renderer has not
drawn with yet. Pass it to a shader (or compare it against what a shader
read out of instance_data[slot].material_index) to tell which material a
drawing instance carries.
Parameters
namestring—stringMaterial name, asrenderer.material.createfiled it.
Returns number?
local red = renderer.materialIndex("brick_red")
typed/builtin//modules/api/engine/renderer/renderer/maxAnisotropy
renderer.maxAnisotropy() -> number
The highest anisotropy this device honours: 16 on hardware that filters
anisotropically, 1 on hardware that does not, where a higher request would
be downgraded to trilinear regardless. Read it to report quality honestly —
renderer.setAnisotropy clamps for you, so a request never needs guarding.
Returns number — The device ceiling, 1 or 16.
local best = renderer.maxAnisotropy()
typed/builtin//modules/api/engine/renderer/renderer/minScreenSize
renderer.minScreenSize() -> number
The on-screen radius, in pixels, an object must reach to be drawn. 0
while the cutoff is off.
Returns number
local px = renderer.minScreenSize()
typed/builtin//modules/api/engine/renderer/renderer/morphStats
renderer.morphStats() -> {
The morph state the last frame drew with. A mesh carries the shapes it
can blend towards and an entity carries how strongly each is blended
(ecs.MorphWeights); where both are present, the vertex stage adds the
weighted deltas to the base geometry.
instances is how many render slots that happened at, and blends how
many single-target blends those slots carry between them: a slot
contributes one per target its weights move, or that they moved the frame
before, so the number of targets a mesh can be given is bounded by the
buffer the blends live in. meshes is how
many meshes hold a delta block and targets how many targets those blocks
cover between them; deltaBytes is what the shared buffer they are
appended into holds. A morph-target mesh whose weights are all zero reads a
meshes above zero beside an instances and blends of zero.
Returns { instances: number, blends: number, meshes: number, targets: number, deltaBytes: number }
local m = renderer.morphStats()
print(("%d instances carrying %d blends over %d meshes"):format(m.instances, m.blends, m.meshes))
typed/builtin//modules/api/engine/renderer/renderer/observe
renderer.observe() -> RenderObservation
Everything the renderer knows about the frame it last drew: what
program it bound for each renderable, the render state it holds each
material under, and what each program has cost in pipeline builds.
renderables is one row per renderable in the renderer's draw list,
carrying the program its material named (requestedProgram) beside the one
that was bound (boundProgram) — __error__ wherever the lookup missed
and the draw went ahead on the magenta placeholder — plus substituted,
the outcome (drew / drewPlaceholder / skipped / notDrawn), the
reason that forced it and the compiler's own detail for a failed
compile. observed says which of two answers a row is: true for a
resolution a geometry pass took as it drew, false for the renderer's own
resolution of a renderable this frame drew nowhere, which is what a
renderable outside every camera's frustum or layer mask reports.
materials is one row per
material the renderer holds a prepared bind group for; shaders is one row
per program pipelines have been built for. frame names the frame every
per-frame count covers; retainedFrames how many frames a resolution a
pass took is kept for after the last frame that drew it; window and
costWindow state both in the document itself. Recording is armed by the
first read, so this waits for the frame that first records rather than
answering empty.
Returns RenderObservation
local o = renderer.observe(); print(o.placeholderDraws, "renderables drew the placeholder in frame", o.frame)
typed/builtin//modules/api/engine/renderer/renderer/occlusionCulling
renderer.occlusionCulling() -> boolean
Whether occlusion culling is currently enabled.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/passSchedule
renderer.passSchedule() -> {
The schedule check over this frame's enqueued render passes. Passes
declare what they read (inputs) and what they write (output /
outputs / storage), and the frame runs them in phase order and, inside
a phase, in order order. violations holds every input bound to a
resource the frame produces LATER: that read samples the resource as it
stands ahead of that pass, which is the previous frame's contents for a
render target that persists, an empty target for one just created, and the
scene draw's own output for a @scene.* buffer — and the pass renders
either way. The frame's own buffers are checked on the same terms as a
render target: bind @scene.motion at a phase ahead of the pass that
writes it and the read is reported, naming the buffer and its writer.
A pass reading a resource ahead of that write on purpose declares that slot
in its enqueue's readsPrevious and drops out of the list;
unboundPrevious holds declared slots the pass binds no such resource to,
which cover nothing.
A resource no queued pass writes is not reported — a camera rendering to
texture and compute.dispatch both fill targets outside the pass queue,
and the scene draw fills the @scene.* buffers every frame.
A read the frame has only one order for is not reported either: where the
writing pass consumes something the reading pass produces, the reader runs
first or the writer has nothing to write, which is what a pass reading a
buffer into a target of its own and a second pass copying that target back
over the buffer forms.
unreachable holds passes at a phase that does not run their kind: every
phase drains its fragment and compute passes, while afterLighting is the
one that draws geometry, draw and splat passes, so one of those enqueued
elsewhere sits in the queue and never runs.
Each finding is also stated in the engine log the first time it appears.
Returns { violations, unboundPrevious, unreachable }
local s = renderer.passSchedule()
for _, v in s.violations do print(v.message) end
typed/builtin//modules/api/engine/renderer/renderer/pipelineCache
renderer.pipelineCache() -> PipelineCache?
What the driver's compiled-pipeline store held, built, and wrote back. A pipeline is machine code the GPU driver compiles from the shader bound into it, and that compile is what a launch pays before the first frame drawing with each pipeline can appear. The store keeps that compiled code across runs, so a launch whose shaders have not changed reads back what the previous one compiled.
restoredBytes is what a previous run left for this GPU and this launch
read; pipelinesBuilt counts the pipelines built since startup and
buildMs is what they cost together, which is the number the store lowers.
saves and savedBytes describe writing it back — deferred until a burst
of builds settles, so one launch is one write — and dirty is true while
pipelines have been built that the file does not hold, including after a
write that failed, which lastError then names. path is the file, named
after the GPU it belongs to.
supported is false where the platform holds no store a program can carry:
a browser keeps its own and hands none out, and an adapter can lack the
capability. reason says which, and the build count and timing still read
true there. lastError names a read or write failure; a failed store costs
the saved compile and never the frame, since every pipeline is built from
its source either way.
pipelinesBuilt and buildMs are engine-wide totals; renderer.shaderCost()
is the same cost broken down per program, with each one's permutation count.
Returns PipelineCache? — { supported, reason, path, restoredBytes, pipelinesBuilt, buildMs, saves, savedBytes, dirty, lastError }, or nil before the renderer has drawn a frame
local c = renderer.pipelineCache()
print(("%d pipelines in %.1f ms, %d bytes restored"):format(c.pipelinesBuilt, c.buildMs, c.restoredBytes))
typed/builtin//modules/api/engine/renderer/renderer/pointShadowBudget
renderer.pointShadowBudget() -> PointShadowBudget
The point-light shadow pool now in force. A point light with
castsShadows renders an omnidirectional cube map, six faces of depth,
and slots is how many of them fit — a further caster is lit but throws
no shadow, and the engine log names how many were turned away. The slot
count is bought rather than authored: megabytes of VRAM at resolution
texels per face is what decides it.
Returns PointShadowBudget — The pool — see PointShadowBudget.
local p = renderer.pointShadowBudget()
print(("%d shadowed point lights, %.1f MiB"):format(p.slots, p.bytes / 1024 / 1024))
typed/builtin//modules/api/engine/renderer/renderer/projectionOffset
renderer.projectionOffset() -> (number, number)
The sub-pixel projection offset in force for the main camera, in NDC.
Returns (number, number) — The x and y offset, both 0 when the projection samples pixel centres.
local ox, oy = renderer.projectionOffset()
typed/builtin//modules/api/engine/renderer/renderer/raycast
renderer.raycast(origin: vec3, direction: vec3, maxDistance: number?, exclude: (string | { string })?) -> RenderRayHit?
Cast a ray against the geometry the renderer DRAWS and return the
nearest surface it meets. Every visible mesh answers, whether or not
anything gave it a rigid body — so a terrain, a procedurally generated
mesh, or any plain Model reports the surface at a point, which is what a
camera station, a prop, a sound source or a scatter standing on the ground
needs to know. The answer is the nearest triangle of the mesh, so a sloped
or terraced surface reports its height where it was asked rather than the
extent of its bounding box.
distance is measured from origin along the direction given, so it is a
world-space distance whenever that direction is a unit vector, and it is
directly comparable to a physics.raycast distance along the same ray.
normal is a unit vector turned to face back along the ray. exact is
true when the answer is a triangle and false when it is the object's
bounding box, which is what a mesh whose vertices live only in GPU buffers
answers with. The triangles are the mesh's own, placed by the entity's
transform and by the mesh's bind pose, so a surface a skinning or morph
pass deforms on the GPU answers as the geometry the mesh holds.
EVERYTHING drawn is in scope — the ground you meant, and equally a
character standing on it, a prop, a placeholder floor. The hit names its
entity in entityId, exclude steps over the ones you do not want, and
renderer.raycastAll hands back the whole column so you can pick the
surface yourself. A height you did not expect is usually a nearer surface
you did not mean to ask about, so read entityId before trusting a number.
Parameters
originvec3—vec3ray start in world spacedirectionvec3—vec3ray direction; any length, the engine normalisesmaxDistancenumber(optional) —number?how far the ray reaches, in world units. Default 1000exclude(string | { string })(optional) —(string | { string })?entity id, or ids, to step over
Returns RenderRayHit?
local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then camera.position = { x, hit.point.y + 1.7, z } end
typed/builtin//modules/api/engine/renderer/renderer/raycastAll
renderer.raycastAll(origin: vec3, direction: vec3, maxDistance: number?, maxHits: number?, exclude: (string | { string })?) -> { RenderRayHit }
Cast a ray against the geometry the renderer draws and return every
surface along it, nearest first. One entry per renderable the ray crosses —
the nearest intersection with each — so a stack of surfaces reads as the
order they stand in, and a caller after one particular surface finds it by
entityId rather than hoping it is the nearest. Each entry carries the
fields renderer.raycast returns.
Parameters
originvec3—vec3ray start in world spacedirectionvec3—vec3ray direction; any length, the engine normalisesmaxDistancenumber(optional) —number?how far the ray reaches, in world units. Default 1000maxHitsnumber(optional) —number?how many surfaces to return. Default 32exclude(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
handleOrKindany(optional) — The resource's handle, or its kind with the id second.idstring(optional) — The guid or key, when the first argument is a kind.
Returns RuntimeResourceStatus? — The resource's status, or nil for a key the registry does not record and the device holds no texture under.
local s = renderer.references(mat) for _, r in s.references do print(r.by, r.id) end
typed/builtin//modules/api/engine/renderer/renderer/reflectionEnvironment
renderer.reflectionEnvironment() -> {
What a reflective surface is reflecting. probes is how many reflection
probes the shading blends; they are gathered highest priority first, each
rank taking the coverage the ranks above it left, so a small interior probe
ranked above the large exterior one it sits inside wins outright wherever it
reaches full weight. ranks is the priority each of those probe slots was
published with, in slot order. sky is whether the sky fallback is armed:
with it, coverage no probe claims reflects the captured sky, and without it
a surface outside every probe's radius falls back to the nearest probe
alone. skyCaptured is whether the sky slot holds a capture — arming is
refused until it does, since an uncaptured slot reflects black.
slots is how many cube slots the environment array holds right now: the
sky's alone, at index skySlot, until a probe is captured into it, then
that one plus one per probe. maxProbes is how many of them probes may
take, and resident whether the array has grown past the sky's single
slot. Capture the sky with environment.captureSky().
Returns { probes, ranks, sky, skyCaptured, resident, slots, skySlot, maxProbes }
local env = renderer.reflectionEnvironment()
print(("%d probes, sky fallback %s"):format(env.probes, tostring(env.sky)))
typed/builtin//modules/api/engine/renderer/renderer/release
renderer.release(handleOrKind: any?, id: string?) -> boolean
Let go of the hold renderer.hold placed. The resource stays until
nothing else holds it and a collection releases it — the one a root scene
load runs, or a direct renderer.collect().
Parameters
handleOrKindany(optional) — The resource's handle, or its kind with the id second.idstring(optional) — The guid or key, when the first argument is a kind.
Returns boolean true when the registry knows the resource.
renderer.release(tex)
typed/builtin//modules/api/engine/renderer/renderer/renderTargetLimits
renderer.renderTargetLimits() -> {
The size a render target may be on this device. maxDimension is the
device's own maximum 2D texture dimension — the largest either side of a
render target may take. maxPixels is how many pixels one render target
may hold, so the RGBA8 image it reads back as fits in a single buffer on
every platform the engine runs on, and maxSquare is the largest square
that budget buys. A capture, a renderer.texture.create({ width, height })
or a render-to-texture camera past either bound is refused at the call with
the reason, so ask here for the size to request.
Returns { maxDimension, maxPixels, maxSquare }
local lim = renderer.renderTargetLimits()
local w = math.min(want, lim.maxSquare)
typed/builtin//modules/api/engine/renderer/renderer/renderTargets
renderer.renderTargets() -> {
Every render target the renderer owns and what each one costs, measured
from the texture that is allocated. One row per target, each carrying its
name, whether it is resident, the bytes it holds while it is, its
width/height/layers/mipLevels, and onDemand.
An onDemand target exists only while something needs it: a target nothing
writes into reads resident = false and bytes = 0 and appears again the
frame something writes it, and one sized by content — the reflection-probe
cube array — holds the slots content asked for. The scratch the draws into
a render target have needed is reported as camera[<handle>].* rows:
depth and motion vectors under any rasterized pass, and the occlusion
channel and G-buffer over them under a camera's scene render. A draw builds
what it needs, and the set goes once no live camera names the target and
sixty frames have passed without a draw, so a target nothing draws into
carries no such row; the colour image drawn into belongs to the texture
cache and outlives every one of those releases.
totalBytes is what the resident targets hold together. Measured at the
end of the last rendered frame.
Returns { targets, totalBytes, residentCount }
local rt = renderer.renderTargets()
print(("render targets: %.1f MiB over %d resident"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
if t.onDemand then print(t.name, t.resident, t.bytes) end
end
typed/builtin//modules/api/engine/renderer/renderer/resolutionScale
renderer.resolutionScale() -> number
The fraction of the display resolution the scene is currently rendered
at. 1 until something sets it.
Returns number
local s = renderer.resolutionScale()
typed/builtin//modules/api/engine/renderer/renderer/setAnisotropy
renderer.setAnisotropy(level: number) -> number
Set the maximum anisotropy material textures are sampled with. Takes effect on the next frame for content already on screen — no reload, no texture re-upload. 1 is plain trilinear.
Parameters
levelnumber— 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
enabledboolean—boolean
Returns ()
renderer.setBlendedBatching(false) -- a draw per blended renderable
typed/builtin//modules/api/engine/renderer/renderer/setDepthPrepass
renderer.setDepthPrepass(enabled: boolean) -> ()
Enable or disable the opaque depth pre-pass. While enabled the renderer resolves opaque depth in its own pass before shading, so each shaded pixel runs its material once instead of once per surface stacked behind it, and the resolved depth is what occlusion culling reads. Enabled by default.
Parameters
enabledboolean—boolean
Returns ()
renderer.setDepthPrepass(false) -- shade every layer, for comparison
typed/builtin//modules/api/engine/renderer/renderer/setDepthPrepassOrdering
renderer.setDepthPrepassOrdering(enabled: boolean) -> ()
Submit the depth pre-pass nearest-first. Renderables reach the pre-pass
in the order they were registered, which stands in no relation to where the
camera is: a scene built back-to-front makes every layer write depth and be
overwritten by the layer in front of it. Ordered, the nearest surface
writes first and the surfaces behind it are rejected by the depth test
before they write. The same draws go out either way and the depth that
comes out is the same, so scene.depth_prepass in profiler.gpuFrame() is
what moves. Enabled by default.
Parameters
enabledboolean—boolean
Returns ()
renderer.setDepthPrepassOrdering(false) -- submit in registration order
typed/builtin//modules/api/engine/renderer/renderer/setGpuMemoryTracking
renderer.setGpuMemoryTracking(frames: number?) -> number
Set how often the GPU allocator sampler reads — one reading every
frames frames — or turn it off with 0. It starts at 60, a reading a
second at 60 Hz, so renderer.gpuMemory().allocator answers without
anything arming it. Building the ledger walks every live allocation, which
is why it is sampled rather than read every frame; the category figures
cost nothing either way, and a reader between samples sees the most recent
ledger, so a slow interval still answers.
Called with no argument it reports the interval in force and changes nothing, which is how something that retimes the sampler puts it back afterwards instead of restoring a number it assumed was the default.
Parameters
framesnumber(optional) —number?Frames between readings; 0 turns the sampler off. Omit to read the interval without changing it.
Returns number — The interval now in force.
renderer.setGpuMemoryTracking(60)
local was = renderer.setGpuMemoryTracking()
renderer.setGpuMemoryTracking(1)
-- ... take a tight reading ...
renderer.setGpuMemoryTracking(was)
typed/builtin//modules/api/engine/renderer/renderer/setMaxFramesInFlight
renderer.setMaxFramesInFlight(frames: number) -> number
Set how many frames of GPU work may be outstanding before the renderer stops running ahead. One is the least overlap this can express — a frame's work is waited for as soon as the next frame has been submitted — which is the lowest latency and the lowest throughput; higher values let a slow frame build a longer backlog, and that backlog is memory. Takes effect on the next frame.
Answers the bound after clamping to [1, 8], so asking for more than the renderer honours reports what you actually got.
Parameters
framesnumber— number Frames of GPU work that may be outstanding, 1 through 8.
Returns number — The bound that took effect, after clamping.
renderer.setMaxFramesInFlight(1) -- lowest latency
print(renderer.setMaxFramesInFlight(99), "was clamped")
typed/builtin//modules/api/engine/renderer/renderer/setMinScreenSize
renderer.setMinScreenSize(pixels: number) -> ()
Stop drawing an object once its on-screen radius falls below this many
pixels. A few pixels across, an object carries no detail a viewer can
resolve while still costing a full vertex and submission pass, and the
cutoff drops it from the camera's draws entirely — 0, the default,
keeps every object however small it lands. Measured from the object's own
bounds against the camera's projection, so the same threshold means the
same apparent size at any distance or field of view. Shadow casters have
their own threshold in renderer.setShadowCasterCutoff.
Parameters
pixelsnumber—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 pre-pass depth into a pyramid each frame and tests every renderable that cleared the frustum against it, dropping the ones another surface entirely covers before their geometry is submitted. The pyramid describes the frame being drawn, so an object that becomes visible this frame is never held back a frame. Requires the depth pre-pass.
Parameters
enabledboolean—boolean
Returns ()
renderer.setOcclusionCulling(true); print(renderer.cullStats().occlusionCulled)
typed/builtin//modules/api/engine/renderer/renderer/setPointShadowBudget
renderer.setPointShadowBudget(cfg: {
megabytes: number?,
resolution: number?,
}) -> number
Set how much VRAM the point-light shadow atlas may hold, and at what
per-face resolution. An omitted field keeps its current value. The atlas
is reallocated on the next frame, so renderer.pointShadowBudget().slots
reports the new pool one frame later; the returned number is what this
budget buys. Raising resolution sharpens every point shadow and spends
the same memory on fewer of them — doubling it quarters the slot count.
Values are clamped: megabytes [1, 1024], resolution [64, 4096], and the
pool never exceeds renderer.pointShadowBudget().maxSlots. One slot is
always granted, so a budget too small for a single cube shadows one light
and the pool costs what that slot costs rather than what was asked for —
{ megabytes = 1, resolution = 4096 } buys 384 MiB of ceiling. Read
pointShadowBudget().bytes back to see what a budget actually bought, and
renderer.shadowMemory().point to see what the scene has made resident.
Parameters
cfg{ megabytes: number?, resolution: number?, }— The fields to change —megabytesand/orresolution.
Returns number — Cube slots this budget buys.
renderer.setPointShadowBudget({ megabytes = 96 })
renderer.setPointShadowBudget({ resolution = 1024, megabytes = 96 })
typed/builtin//modules/api/engine/renderer/renderer/setPresentMode
renderer.setPresentMode(mode: string) -> string
Set how a presented frame reaches the display. fifo queues every frame
and shows it on a vertical blank, which never tears and never drops one;
mailbox replaces the queued frame with the newest, which does not tear
and does not hold the renderer to the refresh rate; immediate presents as
soon as a frame is ready and can tear; fifo_relaxed is fifo that tears
rather than stall when a frame misses its blank; auto_vsync and
auto_no_vsync leave the choice to the backend.
A surface that does not offer the mode presents fifo instead, so read
renderer.framePacing().presentMode for what took effect and
.presentModes for what this surface offers. Takes effect on the next
frame.
Parameters
modestring— string One of "fifo", "fifo_relaxed", "mailbox", "immediate", "auto_vsync", "auto_no_vsync".
Returns string — The canonical spelling of the request — renderer.framePacing().presentMode is what the surface presents with, and differs when the surface does not offer the request.
renderer.setPresentMode("mailbox")
print(renderer.framePacing().presentMode, "is what the surface took")
typed/builtin//modules/api/engine/renderer/renderer/setProjectionOffset
renderer.setProjectionOffset(x: number, y: number)
Offset the main camera's projection by a sub-pixel amount, in NDC, for
the frames until it is set again. The offset is in NDC because that is the
space it is constant in: one pixel is 2.0 / width across, so half a pixel
is 1.0 / width. Velocity (@scene.motion) is measured against the
offset-free projection, so a still scene reports no motion however the
samples are placed — and picking resolves a click to the same ray either
way. (0, 0) samples pixel centres.
Parameters
xnumber— Horizontal offset in NDC. One pixel is2.0 / width.ynumber— Vertical offset in NDC. One pixel is2.0 / height.
renderer.setProjectionOffset(0.5 * 2 / w, -0.25 * 2 / h)
typed/builtin//modules/api/engine/renderer/renderer/setRaytrace
renderer.setRaytrace(enabled: boolean) -> ()
Enable or disable GPU ray tracing. While enabled the engine builds the scene acceleration structure each frame so ray-tracing render features can trace against it; disabling stops the build (so it costs nothing until a ray-traced effect is active). Required before any ray-traced shadows / AO / reflections render.
Parameters
enabledboolean—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 and present
it at the display's own size. Shading cost scales with pixel count and with
nothing else, so this trades sharpness for frame time without taking
anything out of the scene: at 0.5 the scene rasterizes a quarter of the
pixels. UI and text are unaffected — they are drawn after the scene is
brought back up to size. The scene rows in profiler.gpuFrame() are what
move.
Parameters
scalenumber—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/setShadowCaching
renderer.setShadowCaching(enabled: boolean) -> ()
Whether a shadow map that nothing changed is kept rather than drawn again. On by default: a shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — is rasterized on the frames its own inputs change and holds the depth it drew on the ones they do not. Off, every view is drawn on every pass, which is what a shadow suspected of holding a stale image is compared against.
Parameters
enabledboolean—boolean
Returns ()
renderer.setShadowCaching(false) -- draw every shadow view, every frame
typed/builtin//modules/api/engine/renderer/renderer/setShadowCasterBatching
renderer.setShadowCasterBatching(enabled: boolean) -> ()
Whether a shadow view draws every caster of one mesh together. On by default: a view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — submits one draw per geometry over every caster of it the view admits, wherever those casters sit in render order and whatever transform slots they hold. Off, a view draws the runs of render-order neighbours that share a mesh AND hold consecutive slots, so a scene that has spawned and despawned anything fragments into many more draws. The image is the same either way, which is what makes this the comparison a shadow suspected of being placed by the batching is made against.
Parameters
enabledboolean—boolean
Returns ()
renderer.setShadowCasterBatching(false) -- draw the runs the scene presents
typed/builtin//modules/api/engine/renderer/renderer/setShadowCasterCutoff
renderer.setShadowCasterCutoff(cfg: {
minRadiusPx: number?,
maxDistance: number?,
}) -> ShadowCasterCutoff
Set the shadow-caster cutoff. An omitted field keeps its current value,
so a call can adjust one threshold without restating the other. Both are
measured against the camera the frame draws from rather than against each
light, so one setting covers every cascade, spot and cube face, and a
caster that stops casting is one whose shadow the viewer could not have
resolved. maxDistance is measured to the near side of the caster's
bounding sphere, so a large object keeps casting while any part of it is in
range. 0 releases a threshold; releasing both draws the casters the frame
drew before either was set.
Parameters
cfg{ minRadiusPx: number?, maxDistance: number?, }— The fields to change —minRadiusPxand/ormaxDistance.
Returns ShadowCasterCutoff — The cutoff now in force.
renderer.setShadowCasterCutoff({ minRadiusPx = 2 })
renderer.setShadowCasterCutoff({ minRadiusPx = 1.5, maxDistance = 120 })
typed/builtin//modules/api/engine/renderer/renderer/setShadowConfig
renderer.setShadowConfig(cfg: {
resolution: number?,
cascades: number?,
distance: number?,
splitLambda: number?,
fadeFraction: number?,
softness: number?,
}) -> ShadowConfig
Set the directional shadow quality. Any omitted field keeps its current
value, so a call can adjust one knob without restating the rest. Values are
clamped: resolution [64, 8192], cascades [1, 4], distance >= 0, splitLambda
[0, 1], fadeFraction [0, 1], softness [0, 1]. Changing resolution or
cascades reallocates the depth array; the rest are per-frame values. A
distance of 0 hands the range to the frame — the splits are cut over the
depth its own shadow-taking renderables reach — and a positive one caps it,
which is what a scene bounding its shadow cost states.
Parameters
cfg{ resolution: number?, cascades: number?, distance: number?, splitLambda: number?, fadeFraction: number?, softness: number?, }— The fields to change — seeShadowConfig.
Returns ShadowConfig — The full config now in force.
renderer.setShadowConfig({ softness = 0.65, fadeFraction = 0.28 })
renderer.setShadowConfig({ cascades = 4, resolution = 2048, distance = 240 })
typed/builtin//modules/api/engine/renderer/renderer/setShadowHero
renderer.setShadowHero(entity: string, padding: number?) -> ()
Give one caster a directional shadow view of its own, fit to its world bounds.
A cascade covers the slab of world the camera sees, so its texels are spread
over tens of metres and one character standing in the middle of it is
resolved by a handful of them. The hero view is the same light and the same
depth range zoomed onto that entity's bounds, so the whole map goes into the
shadow it and the ground under it carry — renderer.shadowHero().zoom is
the factor its texel density gains.
It renders beside the cascades, into a layer of the same texture allocated while a hero is registered, and every surface inside it reads it in place of the cascade, crossing back at its edge. Nothing else about the shadow changes: the same casters reach it, at the same depth range, through the same filter.
Parameters
entitystring— The entity whose renderables the view is fit around.paddingnumber(optional) — How much room the fit leaves around those bounds — for a pose that leaves the bind-pose box and for the filter that samples outside a silhouette. 1.0 fits them exactly.
Returns ()
renderer.setShadowHero(player.id)
print(("hero shadow: %.1f texels/unit vs %.1f"):format(
renderer.shadowHero().texelsPerUnit, renderer.shadowHero().cascadeTexelsPerUnit))
typed/builtin//modules/api/engine/renderer/renderer/setShadowProxy
renderer.setShadowProxy(mesh: string, proxy: string) -> ()
Rasterize proxy in place of mesh in every shadow view. A shadow is a
silhouette resolved at the resolution of a shadow map, so the triangles that
carry a mesh's close-up detail write depth no reader can resolve — a
decimated version of the shape, a level of its own LOD chain, or a
hand-built hull casts the same shadow for a fraction of the geometry.
The registration is keyed by MESH, so one call covers every instance of it — entities and GPU-driven populations alike — and a crowd sharing that mesh stays one draw. The proxy is placed by whatever places the caster, its instance's own transforms, so it stands where the caster stands, at the caster's scale.
An entity caster keeps its own geometry where a stand-in could not be placed
or deformed correctly: it is skinned (it rasterizes the post-skinned
vertices written for its own mesh), it blends morph targets (whose deltas
describe its own mesh and are read by vertex id), or its proxy would be
placed by a different node of its model than the source mesh is. Either
caster keeps it where the renderer holds no geometry under the proxy's
guid. Each of those is counted in renderer.shadowProxies().
Nothing else in the scene draws a proxy, so this call is what brings it onto the GPU, and it raises where it cannot. A proxy already resident there is registered as it stands.
Parameters
meshstring— The mesh a caster draws, as a guid or any mesh reference.proxystring— The mesh it rasterizes into shadow views instead.
Returns ()
renderer.setShadowProxy(statueMesh, statueHullMesh)
print(renderer.shadowProxies().triangles, "vs", renderer.shadowProxies().sourceTriangles)
typed/builtin//modules/api/engine/renderer/renderer/setSkinnedBatching
renderer.setSkinnedBatching(enabled: boolean) -> ()
Whether skinned instances holding one pose draw together. On by default:
instances of one mesh wearing one material and posed alike read the same
post-skinned vertices, so the camera's colour passes submit them as a single
instanced draw, and so does each shadow view and the velocity pass while
renderer.shadowCasterBatching() is on — that switch is what makes a depth
view form its draws by geometry at all. The camera depth pre-pass submits
its casters nearest-first, which is a run per span of neighbours rather than
a draw per geometry, so a crowd costs a draw per member there. Off, each
skinned instance draws on its own at its own slot in every pass that
rasterizes it. The image is the same either way, which is what makes this
the comparison a frame suspected of being formed by the batching is made
against — renderer.drawStats().draws counts the difference and
renderer.skinningStats().poses says how many distinct poses it holds.
Parameters
enabledboolean—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
enabledboolean—boolean
Returns ()
renderer.setSkinningPoseHold(false) -- dispatch every pose, every frame
typed/builtin//modules/api/engine/renderer/renderer/setSpotShadowBudget
renderer.setSpotShadowBudget(cfg: {
megabytes: number?,
resolution: number?,
}) -> number
Set how much VRAM the spot/area shadow atlas may hold, and the per-side
resolution of one layer. An omitted field keeps its current value. The
atlas is reallocated on the next frame, so renderer.spotShadowBudget()
reports it one frame later; the returned number is what this budget buys.
Raising resolution sharpens the lights that cover the most screen and
spends the same memory on fewer layers — doubling it quarters the layer
count. Raising megabytes buys layers, which is what lets several lights
hold a large tile at once. Values are clamped: megabytes [1, 1024],
resolution [64, 4096], and the atlas never exceeds
spotShadowBudget().maxLayers. One layer is always granted, so a budget
too small for one still shadows lights and the atlas costs what that layer
costs rather than what was asked for.
Parameters
cfg{ megabytes: number?, resolution: number?, }— The fields to change —megabytesand/orresolution.
Returns number — Atlas layers this budget buys.
renderer.setSpotShadowBudget({ megabytes = 64 })
renderer.setSpotShadowBudget({ resolution = 2048, megabytes = 64 })
typed/builtin//modules/api/engine/renderer/renderer/setTextureBudget
renderer.setTextureBudget(opts: TextureBudgetOpts) -> TextureBudget
Bound the VRAM a world's textures occupy, by keeping only the mip levels
the frame is actually sampling. Pass { megabytes = 256 }; 0 — the
default — leaves texture residency alone and every texture stays fully
resident the way it uploaded.
With a budget armed, each frame measures how many screen pixels ONE
traversal of a texture's coordinate range covers on the surface that spans
it widest, and asks for the mip level that serves that span one texel per
pixel — the level the GPU picks from the fragment's own derivatives. A
material with uvScale = 8 lays eight copies of its texture across a
surface, so each copy spans an eighth of the surface and asks for three
levels coarser than the surface's own size would. A shader that declares
// @uv_space: world advances its coordinate over world units rather than
over the mesh's UVs, so how many copies a surface carries follows how large
that surface is. The textures whose surfaces cover the fewest pixels give
up levels until the set fits. Detail climbs one level per frame, from the
image already on screen, so a surface the camera approaches sharpens rather
than popping, and no texture is taken below the level whose longest side is
64 texels.
bias shifts every measurement by whole mip levels either way — negative
for finer than the sampling implies, positive for coarser — over a world
whose look wants a different trade than one texel per pixel.
The plan moves a texture whose demand the frame can measure: one at least 256 texels on its narrowest side, worn by a surface an entity draws. A texture a UI image, a post-process property or a render feature holds a view of stays whole, because nothing measures how much of the screen those cover.
Which textures the budget governs follows the surfaces the frame draws. A
texture whose asset still holds its bytes is enrolled the frame a measured
surface wears it — whenever it loaded, and whenever the budget was armed —
because a level change reads the levels it needs back from the asset; when
the last such surface goes it leaves the set whole, at the level it
uploaded at, and a surface reaching it again takes it back up. A texture a
script uploaded has its pixels nowhere else, so one enrolled while it is
resident holds them in system memory
(renderer.textureMemory().streamSourceBytes) from the upload until a
surface has worn it and gone, and releases them then, which is what keeps
it out for the rest of the session; one whose pixels were already released
when the budget was armed is out from the start.
renderer.textureMemory().pinnedTextures counts those, together with the
textures whose asset could not be read back and the ones a UI image, a
post-process property or a render feature holds a view of. Disarming
returns every texture to the level it uploaded at, and arming again governs
the textures the frame's surfaces are wearing then.
Parameters
optsTextureBudgetOpts—{ megabytes: number?, bias: number? }
Returns TextureBudget — { megabytes, bias } — the budget now in force
renderer.setTextureBudget({ megabytes = 128 })
renderer.setTextureBudget({ megabytes = 128, bias = -1 }) -- one level finer everywhere
renderer.setTextureBudget({ megabytes = 0 }) -- leave residency alone
typed/builtin//modules/api/engine/renderer/renderer/setTransmissionShadows
renderer.setTransmissionShadows(enabled: boolean) -> ()
Let translucent casters tint the sunlight they block instead of blocking
it outright. A shadow map holds one depth per texel and is compared as a
yes-or-no test, so stained glass, water and thin fabric all project the same
black silhouette a wall does. With this on, a caster whose material declares
opacity (base_color alpha under a transparent blend) or transmission
also draws into a light-space transmittance map, and the colour it lets
through multiplies into the directional light reaching whatever stands
behind it. Stacked casters compose. Opaque casters are unaffected, and a
scene with no translucent caster allocates nothing and records no pass.
Parameters
enabledboolean—boolean
Returns ()
renderer.setTransmissionShadows(true) -- stained glass tints the floor
typed/builtin//modules/api/engine/renderer/renderer/shaderCache
renderer.shaderCache() -> ShaderCache?
What the shader compile gate's store of baked WGSL held, answered and
wrote back. Compiling a .shader wraps the author's body in its framework,
expands every #include, and hands the result to naga to parse and
validate — work that is a pure function of the text going in, and that a
launch would otherwise repeat for every shader it draws with. The store
keeps that baked text across launches.
restoredEntries and restoredBytes are what a previous launch left that
this one read back. hits counts the compiles answered out of the store
and misses those that ran in full; savedMs sums what each hit's own
recorded compile had cost, against compileMs, what the misses spent.
stale counts the misses whose key was held but whose #included modules
had changed underneath — an entry records every module its expansion
consumed, so editing a module invalidates exactly the shaders that included
it and leaves the rest.
entries and bytes are what the store now holds, evictions how many a
write dropped to stay inside its bounds, and saves / savedBytes /
dirty describe writing it back, deferred until a burst of compiles
settles. persistent is false where a launch has nowhere to keep
artifacts and reason says why; location is the file, or the browser
store, they are kept in. restoreState is how the read of what a previous
launch left has gone — pending while it is still out (a browser answers
through a promise, so a launch reaches its first frames before it lands),
restored once entries came back, empty when there were none to come
back, failed when what was there could not be read, and none where a
launch keeps nothing. A cold, missing or corrupt store leaves every
shader compiling from source with identical output, and lastError then
names what went wrong.
Returns ShaderCache? — { persistent, reason, restoreState, location, restoredEntries, restoredBytes, hits, misses, stale, entries, bytes, compileMs, savedMs, saves, savedBytes, dirty, evictions, lastError }, or nil on a build with no renderer
local c = renderer.shaderCache()
print(("%d hits, %d misses, %.1f ms saved"):format(c.hits, c.misses, c.savedMs))
typed/builtin//modules/api/engine/renderer/renderer/shaderCost
renderer.shaderCost() -> { ShaderCost }
What each program has cost in pipeline builds, beside the compile
gate's most recent word about it. variants is how many pipelines this
engine has built for it — one per (target format, vertex layout,
render-state key) permutation reached — and buildMs what those builds
cost, both summed since engine start. A pipeline the driver's own store
restored is not built and so is not counted, so a second launch on the same
adapter reports less than the first. status is compiled, failed or
pending, and error carries the compiler's message for a failure.
Ordered by cost, most expensive first.
Returns { ShaderCost }
local top = renderer.shaderCost()[1]; print(top.shader, top.variants, top.buildMs)
typed/builtin//modules/api/engine/renderer/renderer/shaderVariants
renderer.shaderVariants() -> { ShaderVariants }
Every shader that declares optional features, and the programs its
materials have made it compile. Each row carries the features the shader
declares, the base program it ships as, and one entry per variant with the
features that variant holds — so the permutation count a scene's materials
are spending is a number to read rather than something to infer from
compile time. A shader whose variants reach budget compiles no more; the
materials asking for further feature sets draw with the base program.
Returns { ShaderVariants } — An array of ShaderVariants, one per feature-declaring shader.
for _, s in renderer.shaderVariants() do print(s.shader, #s.variants, s.budget) end
typed/builtin//modules/api/engine/renderer/renderer/shadingOf
renderer.shadingOf(subject: string | { [string]: any }) -> ShadingReading
What the renderer is shading ONE subject with, taken from the document
the renderer publishes — the call a system holding a handle makes to find
out whether what reaches the screen is its own material or the magenta
placeholder standing in for it, without reading the engine log. subject is
an entity that draws or the registry key of a material. state reads
itsMaterial where the renderer bound the program the material names,
errorMaterial where it bound the placeholder instead, stalePipeline
where the pipeline drawing it was built before that program's most recent
compile, nothingBound where the renderer resolved no pipeline for it,
pending where this call is the one that armed per-draw recording and the
frame after it publishes, and unknown where the renderer holds a
resolution under no such subject. A fault state carries the renderer's own
reason from the closed set renderer.drawDiagnostics() names — plus
materialNotPrepared, which a material subject reads where the renderer
prepared nothing under that key — the compiler's detail, the program
the material asked for and the bound one; means states the reading in a
sentence. A material subject answers from the renderables drawing with it,
and from the renderer's record for the material itself where a draw
registered against the material carries no row of its own; a subject that
several renderables draw answers with a refused one wherever there is one.
The reading follows the renderer, so a program that compiles on a later
edit puts the subject back on itsMaterial from the frame the renderer
draws it with again.
Parameters
subjectstring | { [string]: any }— The entity — a proxy fromentity(...)or an entity-id string — or the material, as its registry key or theMaterialHandlerenderer.material.createreturned.
Returns ShadingReading
local s = renderer.shadingOf(emitter); if s.state ~= "itsMaterial" then print(s.means) end
typed/builtin//modules/api/engine/renderer/renderer/shadowCacheStats
renderer.shadowCacheStats() -> {
What the last frame did with the shadow maps it already had. A shadow
view — one directional cascade, one atlas layer of spot tiles, one face of
a point light's cube — is drawn again only when something it draws from
changed:
its light moved, a caster it can see moved or appeared or vanished, a
caster's geometry or material changed, a caster changed pose or moved the
nodes its parts are placed by, or the map it writes into was reallocated.
Anything else keeps the depth already in the texture, so a scene that stops
moving reads rendered 0 while cached keeps climbing. A mesh whose
vertices a compute pass writes — a population, or a mesh built from a
compute buffer — re-renders the views it stands in every frame. A shadowed
point light contributes six views, one per cube face, so a caster moving on
one side of it re-renders the face that can see it and leaves the other
five holding what they have. Counted per light kind, plus the totals across
all three.
These are totals over every view of a kind. renderer.shadowViews() is the
same frame one view at a time, each row naming the light that owns it and
what it drew.
Returns { directionalRendered, directionalCached, spotRendered, spotCached, pointRendered, pointCached, rendered, cached }
local s = renderer.shadowCacheStats()
print(("shadow views: %d drawn, %d cached"):format(s.rendered, s.cached))
typed/builtin//modules/api/engine/renderer/renderer/shadowCaching
renderer.shadowCaching() -> boolean
Whether a shadow view may keep the depth it already holds.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/shadowCasterBatching
renderer.shadowCasterBatching() -> boolean
Whether a shadow view draws every caster of one mesh together.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/shadowCasterCutoff
renderer.shadowCasterCutoff() -> ShadowCasterCutoff
How small, and how far away, a caster may get before it stops writing depth into any shadow view. A shadow view rasterizes a caster's whole triangle count whatever the shadow it produces ends up covering, so an object the viewer resolves a fraction of a pixel of, and one past the range the scene cares about, each cost a full depth pass per shadowed light for detail nothing reads. Both thresholds are 0 — released — until something sets them.
Returns ShadowCasterCutoff — The cutoff in force — see ShadowCasterCutoff.
local c = renderer.shadowCasterCutoff(); print(c.minRadiusPx, c.maxDistance)
typed/builtin//modules/api/engine/renderer/renderer/shadowConfig
renderer.shadowConfig() -> ShadowConfig
The directional shadow quality now in force. resolution and cascades
size the cascade depth array; distance and splitLambda place the splits
along the view; fadeFraction and softness shape how the result is
sampled.
Returns ShadowConfig — The full config — see ShadowConfig.
print(renderer.shadowConfig().cascades)
typed/builtin//modules/api/engine/renderer/renderer/shadowHero
renderer.shadowHero() -> ShadowHeroReport
The registered hero caster and what the last frame's fit produced. A
frame that fit nothing says why in decline.
Returns ShadowHeroReport — See ShadowHeroReport.
local h = renderer.shadowHero()
print(h.active, h.zoom, h.decline)
typed/builtin//modules/api/engine/renderer/renderer/shadowMemory
renderer.shadowMemory() -> {
How much GPU memory the shadow maps hold right now, in bytes, by the
light kind that owns them. The spot atlas and the point pool are sized to
the casters in the scene rather than to the budget, so spot and point
move as lights that cast shadows appear and leave, and a scene with one
shadowed light holds far less than one that fills every slot. A budget is
the ceiling they grow within — renderer.spotShadowBudget().layers and
renderer.pointShadowBudget().slots report that ceiling, unmoved by how
many casters exist. Raising shadow resolution costs the square of the
change across every cascade.
Returns { directional, spot, point, total } in bytes
local m = renderer.shadowMemory()
print(("shadows: %.1f MiB"):format(m.total / 1024 / 1024))
typed/builtin//modules/api/engine/renderer/renderer/shadowProxies
renderer.shadowProxies() -> ShadowProxyReport
The shadow proxies in force and what the last frame's shadow passes did
with them. triangles and sourceTriangles are what those passes
submitted and what they would have submitted from the source meshes — the
before/after of every registration, equal while nothing is proxied.
Returns ShadowProxyReport — See ShadowProxyReport.
local p = renderer.shadowProxies()
print(("shadow triangles: %d of %d"):format(p.triangles, p.sourceTriangles))
typed/builtin//modules/api/engine/renderer/renderer/shadowViews
renderer.shadowViews() -> ShadowViewReport?
Every shadow view the last rendered frame considered, and what each one cost.
A frame rasterizes a depth view per directional cascade, one for the hero
caster, one per shadow-casting spot and six per shadow-casting point.
renderer.shadowCacheStats() counts those views by light kind,
renderer.drawStats() sums their draws with the camera's, and
profiler.gpuFrame() carries one scene.shadow span across all of them.
This is the same frame read one view at a time.
Each row names the view and the light that owns it, says whether it drew or
kept the depth it already held, and carries the draws, the instances and the
casters that went into it. span is the label the view's pass is timed
under, so its GPU time is a lookup in profiler.gpuFrame(); every one of
those labels is a variant of scene.shadow, which still carries their
total. camera carries the same instance counters for the main camera, so
the camera's share of a frame-wide total is a read rather than a measurement
taken by turning every light's shadow off.
A cascade's near and far are where the split scheme cut its slice, not
the world it covers: the fit takes the bounding sphere of that slice and
rasterizes the ortho box around it, and both reach past far. What the
cascade covers is center and radius, with viewProj the exact test;
coversNear and coversFar read that volume back along one ray, the
camera's view axis. directional states the axis reading for the set —
how far it reaches (coversFar), the range the splits were run over
(distance), how far the camera draws (cameraFar), and the
depth past the reach the camera still draws (uncovered). A receiver
further along the axis than coversFar has no directional depth map over
it and is shaded as if the sun reached it, so uncovered is the room a
missing shadow has and a surface standing in that room is what makes one;
@builtin::systems.proxyOcclusion occludes past the cascades. The box is
bounded in every direction, so a receiver standing wide of the axis leaves
it at its own distance even where uncovered is 0 — viewProj is what
answers for that receiver.
The list is rebuilt every frame: a view whose light stopped casting is
absent from the next report rather than standing at the numbers it last had,
and a frame that drew no shadow view answers a report whose views is
empty. views grouped the way the shadow cache decides — a row per cascade,
per spot atlas layer, per point cube — counts what
renderer.shadowCacheStats() reports as rendered + cached.
The frame names its views only while something is reading them, so this call asks the frames after it to name theirs and waits out the first one. Nil on an engine that renders no frame at all.
Returns ShadowViewReport? — See ShadowViewReport.
local report = renderer.shadowViews()
print(("camera submitted %d of %d instances"):format(
report.camera.submittedInstances, renderer.drawStats().compactedDrawn))
for _, view in report.views do
print(("%s %d (%s): %d draws, %d instances, %s"):format(
view.role, view.index, view.light, view.draws, view.instances,
view.rendered and "drew" or "cached"))
end
local d = report.directional
if d ~= nil and d.uncovered > 0 then
print(("sun shadows stop at %.0f; the camera draws to %.0f"):format(
d.coversFar, d.cameraFar))
end
typed/builtin//modules/api/engine/renderer/renderer/skinnedBatching
renderer.skinnedBatching() -> boolean
Whether skinned instances holding one pose draw together.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/skinningPoseHold
renderer.skinningPoseHold() -> boolean
Whether a pose already written into its slice skips its dispatch.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/skinningStats
renderer.skinningStats() -> {
What the last frame's skinned instances cost. A skinned instance is
posed by a compute pass that writes its vertices into a shared pool, and
instances holding the same pose read one slice of that pool and the single
dispatch that fills it. instances is how many were posed, poses how
many distinct poses they held, and dispatches how many dispatches those
poses cost this frame — so a crowd whose members move together costs what
its poses cost rather than what its head count does, while members at
different animation times each hold their own pose and pay for it.
held is how many of the frame's poses cost no dispatch at all. The pass
produces a slice from what the pose is made of, so a slice an earlier frame
filled already holds what running it again would write, and a pose still
wearing that slice is read as it stands. Skinning is paid for by the poses
that CHANGED: a cast standing still reads dispatches 0 beside a held
equal to its poses, and the two add up to poses in any frame.
reusedSlices is how many of the frame's poses took a slice the pool
already held — one a retired pose gave back, or one a pose nothing has
asked for this frame was holding — rather than one cut from pool the
engine had never used. A scene whose poses keep changing reads a non-zero
count beside a poolBytes that stays where it was.
liveBytes is what the slices holding this frame's poses occupy, against
unsharedBytes — what the same instances would occupy with a slice each.
poolBytes is what the pool holds; a previous-position buffer of the same
size rides alongside it so skinned deformation reaches motion vectors.
Returns { instances: number, poses: number, dispatches: number, held: number, reusedSlices: number, liveBytes: number, unsharedBytes: number, poolBytes: number }
local s = renderer.skinningStats()
print(("%d instances over %d poses"):format(s.instances, s.poses))
print(("%d poses dispatched, %d read as they stood"):format(s.dispatches, s.held))
print(("skinned vertex storage: %d B of an unshared %d B"):format(s.liveBytes, s.unsharedBytes))
typed/builtin//modules/api/engine/renderer/renderer/spotShadowBudget
renderer.spotShadowBudget() -> SpotShadowBudget
The spot and area-light shadow atlas now in force. Each shadow-casting
spot is given a tile of it every frame, sized to what the camera can
resolve: a light filling the view gets a whole layer at resolution, one
far away gets a minResolution tile, and the atlas holds tiles of the
smallest kind. That is what lets one budget serve a close hero light and a
street of distant ones without either the memory or the sharpness being set
for the worst case.
Returns SpotShadowBudget — The atlas — see SpotShadowBudget.
local s = renderer.spotShadowBudget()
print(("%d layers of %d, %.1f MiB"):format(s.layers, s.resolution, s.bytes / 1024 / 1024))
typed/builtin//modules/api/engine/renderer/renderer/textureMemory
renderer.textureMemory() -> {
What the GPU texture cache holds, split by whether the texture is
block-compressed. compressedBytes and uncompressedBytes are what those
textures cost in VRAM, measured from each texture's own format and mip
chain — so a .texture whose settings name format = "bc7" appears in the
compressed columns at a quarter of what the same image costs as RGBA8.
blockCompressionSupported is whether this adapter can hold
block-compressed textures at all; where it is false a BC7 payload is
uploaded decoded and lands in the uncompressed columns instead, so the
texture is present everywhere and compressed where the hardware allows it.
Measured at the end of the last rendered frame.
streamableTextures is how many of them a texture budget can move the
base mip level of, split by where a level change reads the levels it needs
from: assetStreamedTextures are read back from the asset they came from
and hold nothing in system memory, retainedTextures hold the payload
because a script uploaded their pixels and the GPU copy is the only other
one there is. streamSourceBytes is what those held payloads occupy in
system memory — bytes that are not VRAM — so it is a reading on the
retained half alone. pinnedTextures counts the textures big enough to
stream that stand at a level nothing can move: their pixels were released
and no asset holds them, the asset behind them could not be read back, or a
UI image, a post-process property or a render feature holds a view of them.
A texture out of the streamable set only because no measured surface wears
it stands in neither count: a surface reaching it takes it back up, so its
level moves again as soon as there is a footprint to move it by. It reads 0
while no budget is armed.
Returns { blockCompressionSupported, compressedTextures, compressedBytes, uncompressedTextures, uncompressedBytes, streamableTextures, assetStreamedTextures, retainedTextures, pinnedTextures, streamSourceBytes }
local tm = renderer.textureMemory()
print(("%d compressed textures hold %.1f MiB"):format(tm.compressedTextures, tm.compressedBytes / 1048576))
print(("%d textures stream from their asset, %d hold %.1f MiB of pixels"):format(
tm.assetStreamedTextures, tm.retainedTextures, tm.streamSourceBytes / 1048576))
typed/builtin//modules/api/engine/renderer/renderer/textureStreaming
renderer.textureStreaming() -> TextureStreaming
What the last frame's texture-residency plan decided. budgetBytes is
the armed budget, and 0 means residency is left alone. streamable is
how many textures the plan can move. residentBytes is what those textures
occupy now, measured from the textures that are allocated; demandedBytes
is what the frame's demand alone would have cost, so the two part exactly
where the budget is doing something. starved counts the textures left
coarser than the frame asked for, promoted the ones that climbed a level
this frame, and changed the ones whose GPU texture was replaced. A camera
approaching a surface reads promoted above zero for a few frames and then
zero once it settles.
textures is one row per streamable texture, ordered by key, carrying the
level each one was asked for and the measurement that asked. Two byte
totals can agree while a single texture sits several levels off what its
surface samples, so read the row when the question is which level a texture
holds and why.
With budgetBytes at 0 nothing holds a level back, so residentBytes,
plannedBytes and demandedBytes all read the whole chain of every
texture still enrolled and textures is empty — which is how a session
that armed a budget and dropped it reads back that the levels came home.
Returns TextureStreaming — { budgetBytes, streamable, residentBytes, plannedBytes, demandedBytes, starved, promoted, changed, textures }
local ts = renderer.textureStreaming()
print(("textures: %.1f MiB resident of %.1f MiB demanded, %d starved"):format(
ts.residentBytes / 1048576, ts.demandedBytes / 1048576, ts.starved))
typed/builtin//modules/api/engine/renderer/renderer/transmissionShadows
renderer.transmissionShadows() -> boolean
Whether translucent casters tint the directional light they block.
Returns boolean
typed/builtin//modules/api/engine/renderer/renderer/uploadStats
renderer.uploadStats() -> {
What the last completed frame spent re-describing its renderables to the
GPU. Every renderable owns a slot in the per-instance data a draw reads —
its world matrix, the bounds the culler tests it by, and the flags that
decide which passes and which culling stages see it — and a frame uploads
only the slots whose contents changed. bytes is what those uploads
carried, fullBytes what re-sending every slot would have cost, and
writes how many buffer writes carried it. The three numbers cover that
per-renderable data alone, so a scene standing still reads bytes = 0
against a fullBytes that grows with the scene, and the ratio says how much
of it the scene's own churn — rather than its size — is paying for.
Returns { writes: number, bytes: number, fullBytes: number }
local u = renderer.uploadStats()
print(("instance upload: %d B of %d B in %d writes"):format(u.bytes, u.fullBytes, u.writes))
typed/builtin//modules/api/engine/renderer/renderer/variantSource
renderer.variantSource(program: string) -> string?
The WGSL one of the programs renderer.shaderVariants() lists holds,
exactly as the shader compiler received it. program is the program
field of a row's base or of one of its variants. Reading a base
alongside a variant shows what a feature set selected: each program's text
holds the code its own features guard. The variant-report spelling of
renderer.compiledSource, which answers the same for every other shader.
Parameters
programstring— Aprogramname fromrenderer.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)