The resource model: asset, CPU, and GPU
In Zero a mesh, texture, material, or shader is not a single thing. It exists in up to three forms at once, and knowing which form you are holding is the difference between code that draws and code that silently does nothing. The same split explains where memory goes and how content reaches the screen.
The three forms
Every GPU-backed resource can exist as:
- An asset. The authored, typed form, identified by a stable guid and referenced by identity.
gold.material,hero.mesh,brick.texture,water.shader. An asset can be persisted into the world's source, and from there published through ZeroMind, but it need not be:asset.create(...)at runtime mints a real asset that lives only for the session unless it is written to source. Either way it is content addressed by identity, not memory on the GPU. - A CPU form. The decoded bytes in engine memory: a mesh's vertex and index arrays, a texture's pixels. Read it to inspect content and edit it for one-off or authoring-time changes. It is not the place for per-frame mutation: editing the CPU form and re-uploading every frame is exactly the round-trip to avoid.
- A GPU form. The live resource the renderer draws from: vertex and index buffers, sampled textures, a compiled material pipeline. It is what the frame actually uses, and you can modify it in place on the GPU (a compute shader writing a mesh's vertex buffer, or
renderer.meshuploading from akind="gpu"substrate buffer). Per-frame geometry or pixel work belongs here, on the GPU, not in a CPU edit that re-uploads each frame.
The public API is organized the same way, under renderer:
| Resource | Asset | Work with it through |
|---|---|---|
| Mesh | .mesh | renderer.mesh (create, geometry, loadCpu, getVertices, setVertices, isCpuResident, isResident, unloadCpu, readback) |
| Texture | .texture | renderer.texture (create, cpuCreate, loadCpu, isResident, readback, capture) |
| Material | .material | renderer.material (create, setProperty, setTexture, describe, destroy) |
| Shader | .shader / .computeShader | dispatched or referenced by identity; see types/shader and types/computeShader |
Identity is a guid
Every renderer.* call that names a resource takes any of the forms you already hold — the handle create returned, the CPU handle loadCpu returned, the guid, or an AssetRef — and resolves it to the guid itself. So what one call hands back, the next accepts: renderer.mesh.geometry(renderer.mesh.create(geom)) reads back the mesh it just made.
You reference a resource by identity, and the engine resolves that to the resource's stable guid. Asset-backed resources are guid-keyed, so a string like @builtin::materials.gold or @builtin/textures/array_texture is a lookup that resolves to a guid. The guid is the durable identity that survives renames and travels through ZeroMind, which is why saved content stores references by guid, not by name. The plain string key you pass to renderer.material.create is the exception: a runtime name for a resource that is not asset-backed and lives only for the session.
The flow: Luau to CPU to GPU
To be drawn, content moves in one direction: authored asset, into CPU memory, uploaded to the GPU.
-- mesh: asset ref -> CPU-resident -> inspected/edited -> GPU-resident
local cpu = renderer.mesh.loadCpu(ref) -- decode into CPU memory
local verts = renderer.mesh.getVertices(cpu.guid)
renderer.mesh.setVertices(cpu.guid, positions) -- edit on the CPU
renderer.mesh.create(cpu) -- upload to the GPU; now it draws
renderer.texture and renderer.material follow the same shape. renderer.texture.cpuCreate(w, h, fill) builds a texture directly in CPU memory (no asset needed), which you then upload. Readback is the reverse direction, GPU back to CPU: renderer.mesh.readback(mesh) and renderer.texture.readback(texture).
Reading a mesh's vertex data is one call in either direction. renderer.mesh.geometry(mesh) returns the complete MeshGeometry — the same flat-array shape create takes, with every stream the mesh carries (positions, indices, normals, uvs, colors, uvs1, tangents, skinning, skins) — reading the CPU copy when one is resident and reading back off the GPU when it isn't:
-- a runtime mesh that never touched an asset, read back from the handle
local h = renderer.mesh.create({ positions = P, indices = I, tangents = T })
local geom = renderer.mesh.geometry(h)
print(#geom.positions / 3, geom.tangents ~= nil)
Residency is memory management
A resource can be CPU-resident, GPU-resident, both, or neither, and residency is the lever you use to control memory:
- Bringing it in:
renderer.mesh.loadCpu/renderer.texture.cpuCreatemake it CPU-resident; uploading makes it GPU-resident. - Querying:
renderer.mesh.isCpuResident(guid)andrenderer.mesh.isResident(guid)(GPU) report each form independently.renderer.texture.isResident(guid)is the GPU query for textures. - Freeing:
renderer.mesh.unloadCpu(guid)releases the CPU copy while the GPU copy keeps drawing. To evict the GPU resource itself,renderer.destroyis the universal call: it takes any resource handle (a mesh, texture, material or feature handle) and routes by the handle's category, and it takes the id a listing hands out — a row out ofrenderer.texture.list()whose handle nobody kept — reading the kind back off what it is holding under that id. Name the kind beside the id,renderer.destroy("mesh", guid), when one id answers for two kinds; that is the shaperenderer.holdandrenderer.referencestake. The type-specific drops take the same forms.renderer.collect()is the other way to free: it releases everything nothing holds, and the next section is the rule it goes by.
Once a resource is on the GPU it renders on its own, so the common pattern is to load it, upload it, then unload the CPU copy to reclaim memory. Load only what you need, and renderer.destroy what you are finished with, rather than keeping every form of every resource resident at once.
What keeps a runtime resource alive
A resource a script created — renderer.texture.create, renderer.material.create, renderer.mesh.create, renderer.feature.create — lives while something holds it. Five things do:
- A handle a script still reaches. A module that keeps the feature it enabled, a component that keeps the mesh it built. A handle nothing reaches any more stops holding its resource.
- Its owner. The component instance the creation ran in, the scene load its entrypoint ran under, or the render feature whose
setupmade it. Each scene load is its own owner, so a later load of the same scene is a different one. - A reference from live engine state. An entity wearing the material or mesh, an instanced draw of it, a registered material whose slot names the texture, a camera rendering into it, the sky, a lightmap, a UI screen drawing it, a post-process effect sampling it.
- An asset that backs it. The asset outlives the session and is what would bring the resource back.
- A hold.
renderer.hold(handle)— orrenderer.hold(kind, guid)— pins a resource for the session, andrenderer.releaselets it go again. It is how a resource drawn by key rather than through an entity, or built before anything wears it, says that it stays.
Two calls read that rule and act on it:
-- what is keeping this one on the GPU, right now
local s = renderer.references("texture", guid) -- or renderer.references(handle)
print(s.handleHeld, s.assetBacked, s.ownerLive, s.held)
for _, r in s.references do print(r.by, r.id) end -- "entity", "material", "camera", "ui", ...
-- free everything nothing holds
local c = renderer.collect()
print(c.released.texture, c.released.material, c.released.mesh, c.released.feature, c.kept)
for _, e in c.entries do print(e.kind, e.guid, e.action) end -- "released" | "kept"
renderer.references answers "what is keeping this on the GPU" from the world on the frame it runs, rather than from inference: references names each live consumer as { by, id }, and handleHeld / ownerLive / assetBacked / held answer the other four holders. It takes a handle, or a kind and a guid, so a row out of a listing is enough to ask about.
renderer.collect releases every runtime texture, material, mesh and render feature that has none of the five, and returns what it released per kind, how many it kept, and one entry per resource carrying that same status plus its action. Features go first, then materials, then meshes, then textures, so a texture only a released material named goes with the material. It runs a full garbage collection first, so a handle nothing reaches counts as let go — which means a resource created in the same function that calls collect is still reachable from that function and is let go by the next collection instead.
A root scene load collects
A root — non-additive — scene load runs that same collection once the new scene stands, so a texture, material, mesh or feature the previous scene's content created and nothing wears any more goes with that scene, while one the new scene wears again stays. The load report says what it did:
local report = layers.lastLoad()
print(report.collected.released.texture, report.collected.kept)
print(report.phases.collect) -- milliseconds the collection cost
An additive load adds an overlay over a root that is still standing, so it collects nothing and its report carries no collected.
A hold is what carries a runtime resource across those loads. held and GPU residency are separate answers, though: a hold keeps the resource in the registry and out of every collection, while 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. renderer.mesh.isResident(guid) is the question about the buffers.
Materials: asset vs runtime
A material is a shader plus concrete values. It comes in two forms, and picking the right one is a real decision:
Asset material (gold.material). A declarative .material asset. Its mat.yaml names a shader by identity and supplies property values and render state:
# gold.material / mat.yaml
shader: "@builtin::shaders.pbr"
floats: { roughness: 0.2, metallic: 1.0 }
colors: { base_color: { r: 1.0, g: 0.84, b: 0.0, a: 1.0 } }
textures: # each slot references a texture asset by identity
base_color_texture: { ref: "@builtin/textures/array_texture" }
normal_texture: { ref: "@builtin/textures/ScratchedGold-Normal" }
Textures live in the textures: block, keyed by the shader's texture slot names and referenced by identity (the same way the material references its shader). On a live material you bind or swap one with renderer.material.setTexture(key, slot, ref). It has a stable guid, can be saved to the world's source, and from there published. Use it for any look that is authored, shared, reused, or should survive a restart. You reference it by identity and the engine owns its GPU upload.
Runtime material (renderer.material.create). A live GPU material built imperatively in Luau and identified by a runtime key. It is not an asset: it is not saved, not published, and gone when you drop it.
local mat = renderer.material.create({
shader = "@builtin::shaders.extended_material",
properties = { roughness = 0.2, metallic = 1.0 },
}, "runtime_gold")
renderer.material.setProperty("runtime_gold", "roughness", 0.5) -- mutate the live material
renderer.material.setTexture("runtime_gold", "base_color", brickGuid)
renderer.material.destroy("runtime_gold") -- free it
Use it for a look generated at runtime. Rule of thumb: if the look should be authored, shared, or persisted, make a .material asset; if it is generated on the fly for this session only, use renderer.material.create. Both resolve to the same kind of live GPU material. The difference is who owns its lifetime and whether it persists.
When many entities want the same look but a different value, neither of those is the answer. A material per entity — an authored one each, or a runtime clone each — is a separate pipeline binding and a separate row in the material table for every one of them, and driving one property per entity per frame means one call per entity per frame. Every drawn object already carries four vec4 lanes of its own, which a surface shader reads as input.shader_data[lane]:
local DISSOLVE_LANE = 0
renderer.instanceData.laneCount() --> 4 lanes per entity
renderer.instanceData.set(subject, DISSOLVE_LANE, progress)
renderer.instanceData.clear(subject) -- back to zero lanes
Twenty entities on ONE material, each dissolving at its own rate, is one material and one lane write per entity. Reach for a material per entity when what differs is the look itself — a different shader, a different texture, a different blend — and for the lane channel when what differs is a number the shader reads. topics/rendering has the shader side of it, and the shader type README lists shader_data beside the rest of FragmentData.
The asset graph
An asset material references a shader by identity, and references its textures by identity too (in the yaml textures: block, or setTexture at runtime). So resolving one material pulls a small graph onto the GPU: the compiled shader pipeline, every referenced texture, and the property values. The shader is itself an asset (a .shader with a WGSL body); the engine compiles it to a GPU pipeline on first use. Authoring each of these is covered by their own reference: types/material, types/shader, types/texture, types/mesh, types/computeShader.
Render textures and surfaces
A render texture is a GPU texture you draw into rather than load from an asset. Create one by passing dimensions with no pixel source to renderer.texture.create:
local target = renderer.texture.create({ width = 1024, height = 1024, name = "mirror" }) -- an empty GPU texture
Point a camera or a render pass at it, then sample it like any other texture: bind it into a material's textures: slot, or with renderer.material.setTexture. That is how mirrors, security-camera feeds, portals, and multi-pass effects work. renderer.texture.capture(handle) reads a target back to the CPU. Pass a colour format for an HDR target, and screen = true (with an optional screenScale) for a full-screen render-feature target the engine resizes to match whatever it is drawing. screenSpace picks which image it follows: "scene" (the default) tracks the scene render target and is cleared before every offscreen render, while "composite" tracks the image the post-scene phases draw into and survives one — which is what lets a pass carry an accumulation across frames in it. The topics/render-textures guide covers wiring a camera to a target in full.
A surface is the shader domain that shades a mesh: a surface shader decides how a pixel of a material looks and lights, and materials wrap surface shaders. Post-process, screen, and compute are the other shader domains. See types/shader.
Seeing what is resident: the runtime VFS
Live resources show up in the engine's virtual filesystem under /zero/runtime/, which reflects the running world rather than saved source. You ls and cat it the same way you inspect entities:
/zero/runtime/layers/<layer>/entities/is the live entities and their components./zero/runtime/also carries live GPU state: generated meshes, render surfaces (render-texture targets), lighting, and logs.
So "where did my mesh or render target go" has a literal answer: a folder under /zero/runtime/. That is the fastest way to confirm a resource is actually resident, as distinct from its authored .mesh / .texture asset under /zero/source/. The core/vfs guide has the full layout.
The same pattern beyond the GPU
The asset-vs-runtime split is not GPU-specific. A .soundClip asset is the authored sound; playing it spins up a runtime voice. The persistent authored thing and the live ephemeral instance are always distinct. GPU-backed resources add the CPU and GPU residency layers on top of that same idea.
The model
For any mesh, texture, material, or shader, hold three questions. Which form am I holding: the asset, the CPU copy, or the GPU copy? Which way is the data moving: loading up into CPU memory, uploading to the GPU, or reading back down? And who owns its lifetime: an asset addressed by identity (which the world persists once it is written to source), or a runtime resource that lives while a handle, an owner, a reference, an asset or a hold keeps it and is released by renderer.collect once none of them does? Answer those three and the memory use and rendering behavior stop being surprising.