resource_handle
Recognises a **live GPU resource handle** — the value `renderer.mesh.create`, `renderer.material.create` and `renderer.texture.create` answer with.
resource_handle
Recognises a live GPU resource handle — the value renderer.mesh.create,
renderer.material.create and renderer.texture.create answer with.
A handle is a table of the shape { kind = "<Category>Handle", category, guid, name }, and its guid names a slot in the running process's GPU registry.
It is minted when the resource is created and identifies that resource for as
long as the registry lives. An AssetRef's guid is content identity — it names
a stored asset, and any session that can reach the world's content resolves it.
The two look alike at a glance and answer differently at every session boundary, which is what this module exists to tell apart:
- The replication wire. A peer runs its own GPU registry, so a handle's guid means nothing there. The sync layer drops such a value and the receiver falls back to the field's declared default.
- Durable content. A scene record outlives the session that wrote it. The next session — a peer loading the world, or the same engine after a restart — mints its own registry, so a recorded handle names a resource that session never created.
asset.create(category, name, handle) is the crossing: it stores the resource
and hands back a ref whose guid does survive both boundaries.
A record is also the medium a scene reloads through inside one session: a
play flip unloads the live entities and rebuilds them from the same bytes a peer
receives, and the handle is valid there. So a handle a record leaves out is
parked in modules.session under the entity and component it came from —
park as the record is written, withParked as it is applied. A load inside
the minting session puts it back; every other session opens an empty store and
the field takes its declared default.
API
local ResourceHandle = require("modules.resource_handle")
local mesh = renderer.mesh.create(geometry)
ResourceHandle.isLive(mesh) -- true
ResourceHandle.isLive(asset.resolve("wall.texture")) -- false (an AssetRef)
ResourceHandle.isLive("cube") -- false
ResourceHandle.label(mesh) -- "mesh handle 'msh_grass_1'"
-- Component field maps: `data` unchanged when it holds no handle.
local data, dropped = ResourceHandle.withoutLive({ model = mesh, visible = true })
-- data -> { visible = true }
-- dropped -> { model = "mesh handle 'msh_grass_1'" }
-- What a record leaves out, held for the rest of this session.
ResourceHandle.park(entityId, "@builtin::components.Model", { model = mesh })
local applyData, restored = ResourceHandle.withParked(data, entityId, "@builtin::components.Model")
-- applyData -> { visible = true, model = mesh }
-- restored -> 1
withoutLive returns the same table when there is nothing to leave out, so
the common case allocates nothing. When it does drop, the second return names
each field and the handle it stood for, so the caller reports what happened at
the point it happens rather than leaving the field to fail later.
park replaces whatever that entity-and-component pair held before, so a field
that stops holding a handle stops being restored with the ones that do.
withParked fills only the fields the record has no value for — a record that
carries a value is the authority on it.
Consumers
scene_saver asks per field as it serialises an entity, reports the entity,
component and field once per session, and parks the handles it left out.
scene_loader asks per component as it applies a record: it reports the whole
scene's count of recorded handles in one line — one generator run writes the
same field on every entity it made, so a per-entity report would say the same
thing hundreds of times — and puts the parked ones back. dirty_hot_reload
applies a peer's body to a live entity and leaves the handles in it out.
Scoped to this part · feeds back into the world's score.