Skip to content

Runtime Assets

The engine consumes the same compiled .pallet artifact the rest of the toolchain produces. This page is what happens to one between the file on disk and a mesh standing in a world.

The engine consumes the same compiled .pallet artifact that DigitalHeaven.Unity does — it is a content consumer of the DigitalHeaven toolchain, not a second asset format. Portable DigitalHeaven.Content reads a compiled pallet through DigitalHeaven.Core’s LoadedCompiledPallet and interprets it as DH-owned CPU assets. A host supplies an immutable image/model decoder composition; the engine uses DigitalHeaven.Content.Decoders, the standard SharpGLTF/StbImageSharp adapter. Content lives in Content/, below the engine, the toolchain and Studio; the engine reaches it through DigitalHeaven.Engine.Assets, which adds only the two types that speak engine vocabulary (the water field and the surface slot table):

  • PNG blobs → textures. IImageDecoder supplies CPU RGBA8 pixels; the engine composition uses StbImageSharp before GPU upload.
  • GLB blobs → meshes. IModelDecoder supplies the engine’s own MeshAsset/ModelHierarchy shapes. The engine composition uses SharpGLTF only to project parser data into DH-owned ImportedModel records; portable ImportedModelBuilder owns the shared static-mesh and ordered-hierarchy topology behavior.
  • Materials are resolved through semantic texture slots, mirroring how the rest of the DigitalHeaven material system works.

MeshVertex is a fixed 72-byte record — there is no vertex-format negotiation, and every mesh the engine draws has exactly these seven attributes (what the GPU is handed is this record split into separate streams):

AttributeTypeOffsetNotes
Positionfloat30Meters, world-handed (Y-up, right-handed, −Z forward)
Normalfloat312Unit length
Tangentfloat424glTF convention: xyz is the unit tangent along +U, w is the handedness ±1, so bitangent = cross(normal, tangent.xyz) × tangent.w
UV0float240The material UV. TEXCOORD_0 from the GLB
UV1float248TEXCOORD_1, zero when the source authored none
UV2float256TEXCOORD_2, zero when the source authored none
UV3float264TEXCOORD_3, zero when the source authored none

The tangent is read from the GLB’s TANGENT attribute when the exporter wrote one (Blender does) and derived deterministically from UV deltas when it did not, so normal mapping never depends on the exporter’s settings. The loader reads TEXCOORD_0 through TEXCOORD_3 verbatim, each into its own lane, and records on the mesh how many sets the source actually authored; a source carrying more than four is read up to four and no further. Every material channel samples UV0 — the further sets are there so a model can carry decal islands or a second unwrap without one set’s coordinates overwriting another’s.

The baked lightmap coordinate is not one of them, and is not a vertex field at all: it rides a stream of its own, allocated only on a mesh a bake covered. No authored TEXCOORD is ever promoted into it, because a lightmap UV has to be a non-overlapping atlas parameterization and no exporter can be trusted to have produced one.

The lightmap UV instead arrives from a companion blob the compiler writes next to the map whose bake packed it — <map>.dh-map.lightmapUv, magic DHLU — which dh build splits out of the map’s lightmap bake. It belongs to the bake, not the model: two maps baking one GLB, or a map baking a GLB another pallet holds, each keep their own. The mesh bytes are untouched, so the same GLB loads identically with or without a bake; the map load hands the companion to the mesh read, which builds the mesh’s LightmapUv stream from it when one is present and leaves the mesh without a stream at all when it is not.

A coordinate’s page rides its integer part: u = page + x, so floor(u) picks the page of the lightmap array and the fraction is the place on it. Every corner of a triangle is on one page.

The bake’s charts (xatlas) cut some vertices in two — a chart seam needs one coordinate per side. The companion carries each such split as a copy: the loader vertex it duplicates, its own coordinate, and the index corners (counted in the loader’s own triangle stream, before it groups anything by material) that move to it. The loader appends the copies after its own vertices and repoints those corners, so a vertex still has exactly one lightmap coordinate. Every moved corner must still name the vertex its copy duplicates; one that does not is a model that changed under the bake, and the mesh loads unlit exactly like a count mismatch.

The companion is indexed by the model’s full vertex range even when the bake covered only part of it: a node marked "static": false still gets an entry per vertex, carrying (-1, -1) — a coordinate outside the atlas that no chart can produce, which the fragment shader reads as “this vertex is not in the bake”. Keeping the array full-length is what makes the loader’s vertex count check mean anything, and it is why the exclusion can be per vertex at all: a map’s draws are coalesced by material, so one draw routinely spans many nodes and the per-draw lightmapped flag could never say that one of them was left out.

A genuinely stale companion — the mesh changed and the bake did not, so the counts disagree — does not fail the load. The mesh loads with realtime light only, the log carries '<mesh>': stale lightmap UV set — …. The model loaded with realtime light only; rebuild the pallet to get the bake back, and the map raises a toast reading lightmap is stale for '<map>': rebuild the pallet. A companion is regenerable, and an unlit map you can walk around in is worth more than a map that refuses to open. The same holds on a hot reload, which re-enters the same load path.

The level editor’s Render Bake runs that same baker without leaving the world, but what it produces is a preview: the atlas is uploaded onto the running map so it can be judged, and nothing is written to the pallet. The one thing that press can write is into the map source, not the pallet: a map that never asked for a lightmap gains a minimal lighting.lightmap block so the bake it just asked for has something to run against. The shipped atlas — and the lightmap UV companion the bake is packed against — comes only from a pallet build, which is why a bake in the editor raises a note asking for one.

MeshVertex is the CPU record. What reaches the GPU is that record split into separate streams, and the split exists for one reason: a depth-only pass reads positions and nothing else, so paying 72 bytes of memory bandwidth per vertex to write a depth value is 60 bytes of waste on every shadow cascade, every prepass and every outline mask.

StreamBindingStrideCarries
Position012 Bfloat3, verbatim from MeshVertex.Position
Basis18 BThe normal and the tangent, octahedral, two R16G16Uint attributes
Attributes232 BThe four authored UV sets, each a float2 at its own attribute location
Lightmap38 BThe baked atlas coordinate, float2 — allocated only on a mesh a bake covered
Color412 BCOLOR_0 as four unorm16, then COLOR_1 as four unorm8 — allocated only on a mesh that authored a vertex color off its default
Skinning—12 BFour unorm16 bone weights, then four u8 joint indices

The color run is optional on the lightmap’s terms: a mesh without one aims its color binding at its own attribute run, which is long enough for any vertex, and the draw’s first instance carries one bit per set (MeshVertexLayout.BaseColorInstanceBit, LayerWeightInstanceBit) beside the reflection probe index, so dev_opaque.vert reads only the sets the mesh has. A mesh with no colors therefore allocates nothing and shades exactly as before; the bits ride the instance rather than the material flag word because they belong to the geometry and the word has no bit to spare. COLOR_0 is 16-bit because it multiplies albedo in linear light, where eight bits band in the darks; COLOR_1 is a blend weight and eight bits are plenty.

The normal and the tangent are each folded onto the octahedron and quantized to a pair of 16-bit values; the tangent’s handedness rides in the low bit of its second component, which costs one quantization step and round-trips exactly. The worst-case direction error over the whole sphere is under a twentieth of a degree. MeshVertexPacking is the encoder and the reference decode; vertex_layout.glsl is the same decode in GLSL, and the two are pinned against each other by test.

The skinning run has no binding because no draw ever reads it. It is present only on a skinned mesh whose skin binds at most 256 joints — the range an 8-bit index covers — and the GPU skinner reads it as a storage buffer, writing plain position and basis streams that the ordinary pipelines bind unchanged. That is what lets skinning arrive without touching a single shading pass. A skin past the joint cap gets no run at all and is deformed on the CPU instead, which is also what client.render.gpuSkinning turns off. The four weights are quantized by largest remainder so they sum to exactly one whole scale: the shader divides and has a partition of unity, with no vertex drifting toward the origin because its weights rounded down together.

The dispatch runs once a frame, before the shadow cascades and the depth prepass, over every skinned drawable the frame submitted — one dispatch across every avatar, because each instance names its source and destination buffers by device address rather than by descriptor. It writes into a per-frame ring of skinned streams, raises one barrier, and every later pass binds those streams in place of the mesh’s own. With client.render.gpuSkinning off — or on a device that cannot address buffers — nothing is dispatched and the frame draws the CPU bind pose the object importer freezes at load, exactly as before. See Engine/design-notes/avatar-rigging.md for the buffer lifetimes and what is still open.

One VkBuffer still backs a mesh — the streams are laid out back to back inside it, each on a 16-byte boundary, and bound by offset. A pipeline declares which streams it reads by naming MeshVertexLayout.PositionOnly or MeshVertexLayout.Full; there is exactly one description of the layout and every pipeline points at it, so a pass cannot drift from what the meshes it draws contain.

A map that marks a thumbnail camera ships one more companion: <map>.dh-map.dh-mapthumb, magic DHMTHUMB — a PNG of the shot, with the size it was taken at and a hash of the map source it was taken from in the header. It is found by its path, exactly as the lightmap UV set and the cooked collision are, so nothing in the map’s own JSON has to name it.

It behaves like every other regenerable companion, only more so, because a picture is decoration rather than geometry: a missing one is silent, and a stale or unreadable one — an older format version, a hash from a map that has since been edited, bytes no decoder accepts — costs the picture and nothing else. The map opens, the browser tile and the main menu’s list fall back to what they showed before pictures existed, and the client says so once in a toast. Nothing about a listing may fail because one picture will not load.

The picture itself is a source asset: a plain PNG beside the map, at the map’s name with its extension replaced — maps/atrium.thumbnail.png — committed with the map and replaceable by hand. The companion is packed from those bytes, so the build never invents a picture nobody can open.

Drawing needs a GPU and the compiler owns no device, so dh build shells out to the engine host the way an FBX conversion shells out to Blender — the binary is named by enginePath in dh-config.jsonc — photographs the map through its own thumbnail camera at its authored thumbnailSize, and points the child at the file to write: dh render <dir> --pallet <path> --map <path> --captureMapThumbnails --thumbnailPng <png>. The capture also lands a plain thumbnail.png in the run directory, the same bytes, so a render can be judged where it ran.

The photograph is content-addressed, so an unchanged map does not pay for a run: the key folds the map document’s hash (which already carries the camera’s pose and its thumbnailSize), the size asked for, and the container version. The engine binary is not in it, so rebuilding the engine does not photograph every map again; only a change to the map does. The PNG the build writes beside the map is recorded next to the built pallet (<id>.compiler-writes), so neither the next build nor Studio reads it as an edit to the source; a PNG anyone else replaces counts. A cache hit never overwrites the PNG on disk, which is what lets a retouched picture survive; packing the same PNG twice gives byte-identical entries, which is what lets the pallet’s content fingerprint decide not to rewrite the file at all.

A machine that cannot photograph still builds. A capture, else the last PNG captured with a warning naming why — enginePath unset, enginePath naming nothing, or the render’s stderr tail — and only when there has never been one, a plain CPU-drawn plan of the box brushes with a note saying it was drawn rather than taken. autoCapture: false on the camera skips the render outright and packs the file as it is.

A picture rebuilt while a client is up does not wait for a relaunch: the interface’s picture memo re-reads a map whose pallet has changed underneath it, on a throttle set by client.ui.mapThumbnailRefresh seconds — zero never checks. A picture that comes back the same size reuses its texture slot rather than claiming a second one.

A map list never waits on a picture. Reading a companion checks its header hash against the map document, which for an imported map is a pass over tens of megabytes, so the live client reads and decodes pictures off the frame, at most client.ui.mapThumbnailReaders at a time: a row draws its kind badge until its picture lands a frame or so later. The check’s answer is a fact about one build of the pallet, so it is kept against the pallet’s content hash and a rebuild is what asks it again. An offscreen capture still reads on the spot, since it photographs one frame.

Loaded assets are cached keyed by their CRC32, so hot rebuilds only re-upload what actually changed. That checksum covers more than the file’s bytes: the compiler folds each entry’s out-of-band metadata — MIME type, filter, wrap, color space — into it, because those fields live in the pallet header rather than in the compressed blob. Without the fold, flipping a texture’s color space, filter or wrap left the bytes identical and every cache keyed on the checksum went on serving the texture built from the old settings.

The pallet’s own buildInfo.contentChecksum is the same idea one level up: a hash over the manifest, the used dependencies, and every entry’s path, size and folded checksum. The build timestamp, the compiler version and the blob layout are deliberately left out, so a rebuild that changed nothing produces the same value — which is what makes it usable as a change signal at all. Both hashes fold a formula version, so a future change to what they cover invalidates every existing pallet exactly once instead of two formulas alternating.

A map’s GLB is collapsed into one world-space buffer, so three things in a real export have nothing to become and are skipped rather than refused:

  • Skinned nodes are dropped from the build. The static map renderer cannot deform a skin, and a bind-pose fallback is worse than nothing — an undeformed skin lands wherever its joint chain left it, at whatever scale that chain implies. A node carrying per-vertex JOINTS_/WEIGHTS_ counts as skinned even when the export lost the skin binding itself.
  • Animation tracks are read past. A static build has nothing to play them with.
  • Morph targets are ignored, but their node still draws. This is the one that goes the other way: an un-morphed mesh is simply the mesh at rest, so the base shape loads exactly where it was authored and only the shape keys are missing. Nothing lands in the wrong place, so there is no reason to drop the geometry with them.

Failing the whole model over any of this would be the wrong trade by a wide margin: ten preview-avatar statues that rode along in a Unity export are not a reason to lose a 3,000-mesh city, and neither are four blendshaped heads. Each concern logs one warning per model load, naming every node or track it left out, so the whole story reads at once instead of one fix-and-re-export round per node. A model that is nothing but skinned nodes still errors — the build ends up empty, and an empty build has nothing to draw.

This is a runtime accommodation only. The compiler still validates and ships the pallet’s skinning, blendshape and animation data untouched, so nothing has to be re-exported when the engine learns to draw them — and the object loader already reads skinned and blendshaped models for avatars, at their bind pose and base shape.

What still refuses a model is data the importer cannot read rather than data it declines to use: compressed or quantized geometry (KHR_draco_mesh_compression, EXT_meshopt_compression, KHR_mesh_quantization), a primitive that is not TRIANGLES, a missing POSITION, attribute counts that disagree with it, and indices that point past the vertices. Those are broken or unreadable files, not features waiting on the renderer.

A slot the map’s material table does not cover

Section titled “A slot the map’s material table does not cover”

A mesh material slot with no entry in the map’s materials table draws with the built-in missingMaterial: a glowing red checkerboard, the Source missing-texture idiom. The map loads, and every missing slot logs its own console error naming the slot, the mesh and the pallet — one line per material, so each is greppable on its own name and fixable one at a time.

Being unmapped is the normal state during a port: a re-export routinely adds two dozen slots against a table that covers a hundred and ninety. Losing the whole map over it teaches nothing that a visible surface does not teach better. The look is deliberately impossible to mistake for a decision — a blockout white reads as an authored choice; this does not. It is generated procedurally rather than authored in the core pallet, because the fallback for a material that could not be resolved must never itself depend on resolving anything.

A placed voxel container is not meshed when the map loads. The client opens it on a worker the first time a dh.voxelVolume is drawn, reading only its header and region index by range from the compiled pallet, and from then on meshes the chunks near the camera on client.voxel.workers threads: one mesh per chunk, faces between hidden cells culled, chunk borders read from the neighbor. Finished meshes upload under client.voxel.uploadBudgetMs a frame, and chunks that drift past the stream radius are freed, so a whole world never sits in VRAM. A chunk mesh uses the vertex format every other mesh does: UV 0 picks the block’s color from a per-volume palette atlas, UV 1 is the face’s own cell coordinates, and UV 2 is left free for occlusion and light. Each chunk draws its surfaces as sub-meshes in the scene’s dynamic tail, so it is lit, casts shadows and receives them like map geometry. Translucent faces sort with the transparent pass, and a block nothing can name a color for wears the missing-material checker. The preferences and the look tables are on the voxel page.

On a synthetic 512 x 128 x 512 world chunked at 16, a 128 m radius wants 936 chunks, 664 of which draw faces. They come to about 213 thousand quads and 47 MiB of vertex and index buffers. On the CPU that is 0.2 to 0.3 ms per chunk, or 35 ms of wall time on eight threads, with about 18 MiB of decoded cells cached.

Derived, rebuildable artifacts (such as hashed mipmap chains) are cached on disk under %LocalAppData%\DigitalHeaven\Engine\cache — never the install directory or the user’s workspace, so the folder is always safe to wipe. Mip chains live in a mipmaps/ subfolder, each file (<hash>.dhmip) keyed by a content hash (SHA-256 of the source pixels, dimensions and filter), so an unchanged texture reuses its chain across runs and two identical textures share one entry. The System section of the in-game settings screen shows the current cache size, offers a Clear cache button (which removes the cache contents only), and exposes a Mipmap cache toggle (client.cache.mipmaps, on by default).

The toggle gates only whether chains are persisted to (and read from) disk — it never affects correctness. With it off, chains are still generated in memory each load, so textures are always mipped; they just aren’t written to disk. Turning it back on takes effect immediately (the change is observed live).

A mesh-mode map compiled by a build that could reach the engine ships its cook in the pallet: <map>.dh-map.dh-colcook, the welded meshes as the physics backend serialized them, one blob per distinct mesh, attached at load with no cook at all. The build makes it by running DigitalHeaven.Engine.Host --cookCollision <dir> on a job file it wrote there, so the blob comes from the very cooker that would otherwise run at load; the editor’s Build cooks the same job in its own process. See Mesh (the default). A map whose pallet ships no cook, or one this build refuses as stale, is cooked at load as below.

The same cache holds cooked map collision in a collision/ subfolder (<hash>.dhcol), for maps using autoCollision: "mesh". Extracting a large map’s collision triangles out of its GLB is the dominant cost of the building the collision mesh load step — on a 1.7M-triangle map it is around 2.1 s, essentially all of it the glTF decode — and it is a pure function of the geometry, so it is worth keeping. A warm load reads the cook back in ~17 ms instead.

The key is a SHA-256 over a cook format version, the geometry entry’s path, and the compiler’s recorded content signature for that entry (its CRC32 and its exact uncompressed length). The signature comes from the pallet’s file table, so a hit costs no read and no decompress — which is the entire point. The length is folded in alongside the checksum deliberately: a CRC32 alone is a 32-bit claim, and the failure that would license here is serving one map’s collision for another’s. Bumping the format version renames every entry, so a cook written by an older algorithm is never read back.

Only the geometry half of the cook is stored — vertices, the culled index list, per-triangle material indices, slot names, and the provenance ranges. Each slot’s dh.material barcode and base grip are re-resolved on every load, so editing a material never invalidates a geometry cook it did not change. Entries are written to a temp file and renamed, and any missing, truncated, or structurally inconsistent entry reads as a miss rather than as collision geometry.

The Collision cache toggle under System → Asset cache (client.cache.collision, on by default) gates persistence only; with it off the mesh is still extracted in memory each load, so the cooked geometry never depends on the flag.

What the cache does not cover is the physics backend’s own step — welding the vertices and building the BVH — which is one indivisible native call (~1.3 s on that same map) producing opaque native state with no serializable form. That call already runs on a worker with the load walk yielding the frame back, so it does not block the frame loop.

The engine’s default content is consolidated into a single core pallet — the debug map and its Source-style dev textures — authored under Engine/content/core/ and compiled to Engine/content/core.pallet. It is loaded at runtime: the server installs the map’s colliders and spawns while the client renders the textured GLB. If the compiled pallet is missing, the engine falls back to a procedural gray-box map so the game always boots.

spawn <asset> [x y z] places a workspace model in the world, just in front of the calling player’s eye — or at a stated position, which is how the editor spawns while its camera is detached from the body. The argument is forgiving: a bare leaf name (crate), the pallet-relative path (props/crate), or the full barcode (dev.example.props:props/crate.dh-obj) all resolve, and tab-completion offers the shortest spelling that is unambiguous — the same short-first ladder the map command uses, down to how it handles a name two assets share: both are offered, each drawn under the least of its folders (or its pallet) with that qualifier dimmed, and each inserts a spelling that resolves to one of them. Completion is computed entirely client-side from the pallets this machine already has, so it costs no round trip; the server does its own resolution against its own catalog, because the barcode it puts on the wire is a promise that the asset exists.

What is offered is object definitions — .dh-obj and .dh-avatar — not the .glb files they import. A GLB is a mesh; a definition is a thing, naming its model, its materials and whatever it inherits, and it is the definition’s path that ObjectLoader takes. Offering raw meshes would also lead the list for “spawn me a crate” with the level geometry a map happens to be built from.

With no position stated, placement is read from the caller’s view (PlayerInput yaw and pitch), not from the map-authored spawn rotation, and starts at the eye rather than the feet:

PreferenceDefaultMeaning
world.spawn.distance2.5Meters along the view ray from the eye
world.spawn.height0Vertical offset applied after the forward step
client.spawn.placeholderSize0.5Half-extent of the placeholder cube (client-side)

Gating is cheats or admin. spawn declares CVarFlags.Cheat | CVarFlags.AdminOverride: the second flag is what turns the cheat gate’s AND into an OR, and it is opt-in per command precisely so that widening it here cannot widen it for a command that does not carry it, which stays gated exactly as it was before the override existed. teleport carries it too, so an operator editing a cheats-off server can move its own body; noclip is not cheat-flagged at all and answers to world.noclipAllowed. One predicate (CheatGate.Allows) decides this for both the local server console and the forwarded remote path.

A caller may state a request token as a trailing argument (spawn crate 0 1 0 7). The server answers that spawn with the reliable spawnResult message, carrying the token back beside the wire id it minted — including on a refusal, where the id is none, so nothing waits forever for an answer that is never coming. The console reply names the id in prose, which is right for a person and useless for a program; the token is how the editor learns what its own drop became, and is what lets it select that entity, delete it and undo the spawn. A line with no token is answered with nothing at all, so spawn crate typed by a person costs no extra traffic.

spawn.remove <id> takes a spawned entity back out of the world entirely — the counterpart to spawn, and the one verb of the family that is not about the asset slot. It carries the same Cheat | AdminOverride gate and the same kind check the slot commands do: a pawn or a map’s logic entity is refused by name rather than quietly, so a despawn verb can never delete a person out from under themselves.

spawn.persist <id> <objectId> turns a spawned object or avatar into an unsaved map instance: an instance of the asset it wears, created under objectId at the pose the spawn stands at, with the spawn removed in the same step. It is what the editor’s Make Persistent sends. It carries the spawn verbs’ Cheat | AdminOverride gate and kind check, and refuses a spawn with no asset loaded or one wearing a session layer — a bone pose, a hidden renderer, a rebound material, a driven blendshape or a switched-off state — naming which. The drag lock and the conflict notice cover it as they cover spawn.remove.

A map instance whose object carries no physics body stands as its asset: the server replicates it as a NetKind.SpawnedAsset bound to its map object, so every client draws it through the path a spawn is drawn through, and the scene editor’s moves ride the scene state beside it exactly as they do for every other map entity. The spawn verbs refuse it — it is the map’s, edited through scene.* — and a map switch’s sweep of the spawns leaves it to the map.

A spawned entity replicates as NetKind.SpawnedAsset. Its barcode does not ride the snapshot — the server interns each barcode once into a revisioned asset table, broadcast reliably (and sent in full to every joining client), and only the table index rides the snapshot’s kind payload. The table only ever grows within a session and an index is never reused, so a client that resolved one keeps it; a snapshot that outruns the table simply names an index the client cannot explain yet, and the entity still draws while it waits.

prop.drop [barcode] [distance] [size] mints a server-authoritative physics box in front of the caller’s eye, for testing buoyancy by watching a crate fall into water and float. It shares its placement math with spawn (SpawnPlacement, forward from the eye rather than the feet) and its gate (CVarFlags.Cheat | CVarFlags.AdminOverride), but the entity it mints is a NetKind.PhysicsProp with a live Dynamic body — the same kind and buoyancy path (NetLogicHost.SimulatePhysicsProps) the physics-evaluation map’s own crates run through — not a SpawnedAsset.

PreferenceDefaultMeaning
server.prop.dropDistance2Meters in front of the eye the box is placed
server.prop.dropSize0.5Edge length of the dropped box (meters)

The barcode argument names what to drop, but only one candidate resolves. dh.object has no physics component yet, so prop.drop cannot mint an arbitrary authored object — only the physics-eval crate’s inline box-and-material definition (crate, or its placeholder barcode) is accepted; any other spelling is refused with a message saying objects are not yet droppable. The hook is already shaped as a resolver-and-completion pair (PropDropCatalog, wired the way SpawnableResolver wires spawn) so a real object catalog widens the candidate list later without renaming the command or moving the argument.

A dropped prop is fleeting: it is never attached to the map (no AttachMapObject call), so it never dirties the map and is never saved. It replicates through the same PhysicsProp snapshot slice the map-authored crates use; the client falls back to a placeholder box, sized from the replicated scale, for exactly this scene-identity-less case.

A spawned entity’s asset is a mutable slot, not a fact settled when it was made. Three commands aim at one entity by its packed id and carry the same Cheat | AdminOverride gate spawn does:

CommandWhat it does
spawn.load <id> <asset>Points the slot at another asset, resolving the barcode the way spawn does
spawn.reload <id>Re-reads the loaded barcode, picking up a pallet rebuilt since
spawn.unload <id>Empties the slot; the entity stands where it is, drawing the placeholder

All three are refused on an entity that is not a SpawnedAsset, so an unload aimed at a pawn cannot write an asset schema over a body. Nothing is renumbered by any of them: the entity keeps its id, its position and everything else about it across a load, which is what makes the editor’s card an edit rather than a delete and a respawn.

pallet.reload <palletId> is the same reload aimed at a pallet rather than an entity: every spawned instance loaded out of it, and every worn avatar whose closure touches it, in one pass. It is what the client’s own watcher submits when a rebuild lands — see avatar and spawned-object hot reload.

The slot rides the kind payload in two channels: the asset-table index, and a revision the server bumps on every reload. Without the second, a reload would be no change at all on the wire — the index is the same — and a client would go on drawing the geometry it already cached. -1 is an index the client cannot explain yet; -2 is the empty slot.

A spawned asset draws its real geometry: the client resolves the barcode against its own pallet discovery, loads the definition through the object loader, uploads it, and submits one draw per drawable at the replicated position.

The placeholder cube is what it draws until then, and only until then. That window is real and has two causes the visual deliberately does not distinguish, because the answer is the same for both: the asset table has not caught up with the snapshot, so the entity has no name yet; or the name is known and the model is still being read, parsed and uploaded. Refusing to draw would make a spawn flicker into existence a round trip late, and a spawn a person just typed has to appear where they typed it. client.spawn.placeholderSize sizes the cube and never reaches real geometry — a definition already says how big it is.

Once the name is known, the cube gives way to the object’s stand-in: the bounds and the tiny silhouette the compiler shipped beside each model it imports (a .dh-proxy companion). A stand-in is read on a thread of its own, from the definition and a few kilobytes of companion, never the GLB, so it arrives long before the model does even while other models are loading. It is drawn at the entity’s own transform, in the model’s root frame, so it stands exactly where the model will and at its size; when the model lands it simply replaces the stand-in, with nothing moving or resizing. An avatar worn by a pawn is fitted from the eye height its definition was stamped with, read with the stand-in, so the body does not jump when the model arrives.

What the pallet shipsWhat is drawn while the model loads
A silhouette (the common case)The silhouette, in the stand-in look
Bounds only (a model the compiler could measure but not simplify)A box of exactly those bounds, in the stand-in look
Nothing (a pallet built before stand-ins)The placeholder cube, as before

The look is a neutral gray, lightly lit, see-through by screen-door dither (it stays an opaque draw, never an alpha blend) and moving in a way you choose, so it reads as “on its way” rather than as a gray object or a broken one. It is decided once per frame by the shared presentation, so the desktop and the phone draw the same stand-in, and every part of it is a preference:

PreferenceDefaultMeaning
client.loading.standInstrueDraw stand-ins; off draws the placeholder cube in their place
client.loading.standInShade0.4The linear gray it is shaded with
client.loading.standInOpacity0.55Its opacity at rest, and at the top of its pulse
client.loading.standInAnimationsweepHow it moves: none, pulse, shift, sweep or shimmer, below
client.loading.standInAnimationAmplitude0.3How strongly it moves, 0 to 1; each mode reads it its own way
client.loading.standInAnimationSpeed0.625Cycles (pulse, sweep) or steps (shift, shimmer) per second; the default is a pulse every 1.6 seconds
client.loading.standInBandMeters2Meters between the sweep’s bands
client.loading.standInGlow0.15The fraction of its gray it emits, so it is never black in shadow
client.loading.standInPreviewWidth320Width, in logical pixels, of the live preview under the animation choice in Options
client.loading.standInPreviewHeight160Its height, in logical pixels
client.loading.standInPreviewFps30Redraws a second while the preview is on screen
client.debug.holdObjectLoads0Fraction of objects, by barcode, whose model is held back so the stand-in stays up (dev tool)

Every mode is the same dither the scene pass already uses (interleaved gradient noise, not an ordered Bayer tile), and every mode costs a handful of ALU ops a pixel and no texture fetch; the fragment stage reads the frame’s shader clock, so a stand-in moves with the world’s time and a capture pins it.

ModeWhat movesAmplitude means
noneNothing: a fixed coverage and a fixed patternnothing
pulseThe coverage breathes on a cosine; the pattern holds. It is the draw’s own opacity, moved by the CPUthe fraction of the opacity the pulse dips by
shiftThe same coverage, with the pattern stepping across the screenpixels per step, sixteen at amplitude 1
sweepA soft band of higher coverage travels up the world, in world space, so a tall object shows it climbingthe coverage the band adds
shimmerThe threshold is re-randomized every tick: a television-static fizz at constant coveragehow far a tick moves each pixel’s threshold

Options > Video > Loading stand-ins holds the animation as a dropdown (None, Pulse, Shift, Sweep, Shimmer), with a live preview directly under it and speed and strength sliders below. The preview is a sphere, a box and a pillar on a neutral floor, drawn with the same fragment shader, the same material parameters and the same StandInLook the world’s stand-ins use, so a change in the dropdown moves the preview and every stand-in already in the world on the next frame. The speed and strength sliders are hidden while the animation is None, because neither does anything then.

The preview is one more instance of the material preview pass (the editor’s material balls and the inspector thumbnails), created with CreateStandIn and bound, sized and destroyed through the same handle space; only what it records differs. It renders at the box’s size times the interface scale (so a 2x iPad gets twice the pixels for the same box) and at most client.loading.standInPreviewFps times a second: the look carries its own clock, stamped on a step of one over the cap, and the pass redraws only when the look it is handed has changed.

It exists only while it can be seen. The box is wrapped in a Halcyon DrawProbe, which reports on every draw how much of the box survives the clips in force. StandInPreviewDriver runs once a frame after the interface was drawn and holds a render target exactly while the last draw left some of the box on screen. A different page, a closed Options window, a hidden overlay and a scroll past the box all end the same way: the draw is cut away entirely or never happens, and the driver releases the target, its depth buffer, its uniforms and its interface texture slot that frame. Nothing is guessed from layout, which a scroll view does for content it has scrolled away. The driver and the options model live in Engine.Client, so the phone and tablet shells get the same box.

The animation rides one frame lane (FrameUbo.StandInAnim: mode, speed, amplitude, band spacing) and a flag on the draw: the dither lane of the material block carries a stand-in bias beside the first-person head cut’s, so the two compose. The occlusion prepass has no clock and draws a stand-in at rest.

client.debug.holdObjectLoads gives each barcode a fixed place in [0, 1) and holds every model whose place is below the fraction; lowering it lets the held models load. It is how an offscreen render photographs a world mid-load.

Loading splits the way the texture registry’s does. Reading the pallet, flattening the inherits chain, parsing the GLBs and decoding the images all happen on a worker thread; creating buffers and descriptor sets is an UploadContext, a single unsynchronized command pool submitting on the shared graphics queue, so it is the render thread’s alone and happens in a per-frame pump. Nothing blocks the frame loop waiting for a load. The upload itself is a real stall — UploadContext waits on a fence for every buffer and image it stages — so the frame an asset becomes ready is longer by however long its geometry and textures take to copy. That is once per asset per session.

Geometry is cached by barcode for the lifetime of the session, with no eviction, because spawning the same avatar twice is the ordinary case and an LRU would spend its complexity evicting things about to be asked for again. A map switch or a disconnect drops the whole cache: no entity survives either, so every entry is dead weight at that moment, and keeping them would leak a pallet’s worth of GPU memory per map a long session visits.

The object loader is a bind-pose importer with a retained skeleton: every node of the resolved hierarchy survives as a bone (LoadedObject.Skeleton), skinned meshes keep their joints, weights and inverse binds beside the vertices, and a drawable’s matrix is derived from the skeleton rather than flattened away. The LOAD is bind pose: a skinned mesh is deformed once on the CPU to wherever its joints stood when the load finished (which is exactly where Unity’s SkinnedMeshRenderer shows it after the same components), and what happens after that is the skin dispatch above. The loader builds the object as the compiler resolved it (ObjectBuilder, over one ResolvedObject), in one fixed order: the model, part parents and transforms, removals, skeletons, renderers and spring chains, then the added records with their armature links. What each piece does:

PieceEngineNotes
model, added records in objectsAppliedAn unreadable model or accessory is one diagnostic, not a failed load.
A part’s parent, transform and removedAppliedTransforms are read in the frame Unity wrote them in (Z negated on import, a half-turn on every GLB root that a full rig moves to Hips), so an authored offset lands where it does in Unity — see Engine/design-notes/avatar-rigging.md.
dh.skeletonAppliedModel bone names resolve by recursive name search under the owner; the standard-bone map is registered on the skeleton. Partial rigs (an accessory mapping only Neck/Head) are ordinary. Bones the rig names that the model lacks are one warning. The rig’s face (eye bones, eyeRotationLimits, the eyelids shapes on the skeleton’s mesh or any skinned mesh) resolves onto the skeleton, behaving as the avatar’s resolved dh.eyes says, for the client’s eyes and blinks; a shape that resolves to nothing is one warning.
dh.armatureLinkAppliedBone-to-bone when the owner carries its own rig (each owner bone is reparented onto the target bone of the same name and its local reset); single-bone otherwise (align true resets the local, align: false keeps it as the bone-relative offset). A missing target is a diagnostic.
dh.rendererAppliedMaterials bind by slot name. Blendshape weights are applied once at load, before skinning; a weight of 1.0 is the full delta. The targets stay on the mesh, and the skin dispatch morphs from them before it skins.
dh.springBoneApplied (simulated by the client)The load resolves the chain onto bone indices and leaves the skeleton in bind pose, which is where the chain would rest. A running client simulates it per entity, after animation and IK, on a fixed 1/64 s tick (client.anim.spring*); the simulation is purely local and never replicated.
dh.eyesRead from the rootRead once off the resolved root, where every layer of the inherits chain has already merged field by field (EyeBehavior.Of), and the rig’s face is resolved under it. An added record’s own dh.eyes does not speak for the avatar wearing it.

A slot no dh.renderer binds draws flat white: the loader records the source glTF’s material name as provenance, and the engine cannot turn a name into a dh.material. Materials with alphaMode: "blend" or a translucent tint draw opaque — the renderer has cutout and dither fades, not alpha blending — so a transparent lens or eye-gloss shell covers what sits under it.

The design note at Engine/design-notes/avatar-rigging.md names the seam the GPU skinner plugs into (ObjectDrawable.Skin).

--render --spawn photographs any of this offscreen, with no session.

A spawn is an object somebody can move, and moving the whole of one is a different verb from posing a bone inside it:

spawn.transform <entity> [offsetX offsetY offsetZ pitch yaw roll scaleX scaleY scaleZ]

The nine numbers are the entity’s absolute world placement — position in meters, rotation in degrees, per-axis scale — rather than a delta, because nothing authored a spawned entity and so there is no pose for a delta to be measured against. It is all nine or none, exactly as spawn.pose is, and for the same reason: a partial line is a typo and filling in the rest would silently move an axis nobody named. Omitting the numbers puts the entity at the world origin at unit size. The command carries the same Cheat | AdminOverride gate the rest of the spawn.* family does and is refused on an entity that is not a SpawnedAsset.

The entity is named by the wire id the server minted for it, which is the whole reason this verb exists beside scene.transform*: those name a map object by its compiled index, and a spawn has none.

Placement rides the snapshot rather than a message of its own. Rotation replicates for a spawned asset the way it always has for a physics prop, and every record gained an optional scale section — a presence byte always, three floats only for an entity somebody has actually resized — so an unresized spawn still costs a single byte and an absent scale reads as unit size rather than as zero. Both the real geometry and the placeholder cube wear the result, so an entity somebody scaled does not shrink back to a default box while its model is still loading.

Placement is session-only, like a pose: nothing writes it into the map. The editor’s transform handles on a selected spawn are the interactive front end of this one command.

A spawned asset draws through a mesh renderer, and three verbs say what that renderer is doing. Every one of them is per entity: two spawns of one avatar can wear different faces and different expressions, where a map’s material binding rewrites the table every surface in the world shares.

spawn.renderer <entity> [<node>] <0|1>
spawn.material <entity> <node> <slot> <material|->
spawn.blendshape <entity> <node> <shape> <weight|->

spawn.renderer … 0 stops the entity drawing without taking it out of the world — the spawn form of scene.hide, and the counterpart to spawn.remove, which is the one that ends it.

A renderer is addressed by its node. An object has as many renderers as it has nodes carrying one, so every verb here names a <node> — the renderer’s node path in the object tree, / for the object root — and then the slot ordinal into that node’s own renderer. The node is the part a dh.renderer sits on, and the ordinal reads back to the slot’s name in the model, which is the key a dh.renderer’s materials binds it by.

spawn.material binds one slot of one node to a dh.material, and - clears the binding so the slot goes back to what the asset authored. An ordinal naming no slot of that node rides through untouched, because an entity whose asset has not finished loading must keep the rebind it was given.

spawn.blendshape drives one morph target of the skinned mesh at that node, addressed by name or by ordinal, with - returning it to its authored weight. The weight is applied where the draw is submitted rather than by mutating the cached object, for the reason a pose is: the object cache holds one resolved object per barcode and every entity wearing that barcode shares it. Spring bones are simulated on each client and are untouched.

All three ride one revisioned full-state message, the same pattern poses use: it carries every entity with a renderer state, keyed by that pair, sparse in both directions, so an untouched spawn costs an id and two zero counts. Nothing is merged, and nothing is written into the map — the whole layer is session-only. The editor’s Mesh Renderer card is the interactive front end of all three.

A spawned asset that carries a skeleton can be posed, and the pose is the server’s:

spawn.pose <entity> <bone> [offsetX offsetY offsetZ pitch yaw roll scaleX scaleY scaleZ]

The nine numbers are that bone’s total delta over the pose the asset was authored in, in the bone’s parent frame — absolute rather than incremental for the reason scene.transform is: a dropped or reordered line cannot accumulate error when the last one to arrive is the answer. It is all nine or none, because a partial line is a typo and guessing the rest would silently reset an axis nobody mentioned. Omitting the numbers returns that bone to rest; omitting the bone as well returns the whole entity to rest. The command carries the same Cheat | AdminOverride gate spawn does, and is refused on an entity that is not a SpawnedAsset.

A bone is named by its node path (Armature/Hips/Spine/Head), never by its index. An index is a position in a list the compiler rebuilt; a path is what the author called the bone — which is what lets a pose survive a spawn.reload that renumbered the skeleton. The server never resolves a path, because it holds no parsed asset: an orphaned path is discovered where the skeleton is, on the client that draws it, reported once and kept rather than dropped, since the asset may be rebuilt with the bone back.

Poses replicate as their own reliable message rather than riding the snapshot. A payload is a fixed set of continuous channels sized for a snapshot; a rig is a variable-length list of nine floats per bone. The message is revisioned full state, the same pattern the created-entity set uses: it carries every posed entity, so an entity absent from it is an entity at rest and a bone missing from an entry is that bone at rest. Nothing is merged — merging is what would let a dropped “back to rest” leave a bone turned forever. It is sent on every accepted spawn.pose and once to every joining client, and it is sparse in both directions: an untouched avatar costs one id and a zero count, and a posed one costs only the bones somebody actually turned.

Pose is session-only. It is not written into the map source and nothing saves it. The editor’s bone tools are the interactive front end of this one command.

The client never poses the cached skeleton to draw a pose. The object cache holds one resolved object per barcode, shared by every entity wearing it, so mutating it would pose every clone at once. The pose is solved into caller-owned scratch instead and applied per submission, by overriding each rigid drawable’s LocalToRoot with the matrix that came out of the solve. Rigid drawables under a moved bone follow it live on every client, and a skinned mesh takes the same solve as a bone palette and bends with it — or falls back to its frozen bind pose with client.render.gpuSkinning off.