dh.map
Extension: .dh-map
Type ID: dh.map
A map places a visual GLB model as its world, binds it to its material textures, and describes the world’s collision as dh.collider components and its records as explicit, authoring-controlled primitives. It is consumed by a game engine at runtime: the client renders the textured geometry, and the server installs the colliders as static physics and every object carrying a dh.spawnPoint as a player spawn point.
Collision is deliberately not derived from the GLB. Colliders are an explicit list of primitives so map authors control exactly where the world is solid, independent of the visual mesh.
dh.mapis consumed by DigitalHeaven.Engine. See Engine Assets & Audio for how the engine loads a compiled map pallet at runtime.
Properties
Section titled “Properties”| Property | Type | Required | Description |
|---|---|---|---|
$type | "dh.map" | no | Type identifier |
name | string | no | Display name |
description | string | no | Description |
inherits | string | no | Another dh.map this map reads its look, its blocks and its records from, by barcode (see Inheritance) |
materials | object | no | Maps a glTF material name on the geometry to a dh.material reference (barcode) |
autoCollision | "mesh" | "boxes" | "hulls" | false | no | How the world model becomes collision for a part that authors no dh.collider. Default "mesh" — true-to-shape concave collision cooked from the visual triangles. "boxes"/"hulls" build per-part shapes at compile time instead; false generates none. See Automatic collision. |
hullOptions | object | no | Tuning for autoCollision: "hulls" convex decomposition (see Automatic collision) |
objects | array | no | The map’s records: folder, object, instance and the kinds, a $type-discriminated list (see Records). The map’s world is the instance named world (see The world instance), and its part overrides live on it. |
backdrop | object | no | A scene drawn behind the world, seen with parallax: Source’s 3D skybox (see Backdrop) |
The type is inferred from the file extension, so
$typeis not needed in source files. The compiler adds it automatically during builds.
Closed Field Sets
Section titled “Closed Field Sets”A map’s field set is closed, exactly as dh.material’s is, and so is every block inside it: the map itself, each record, each part override, each dh.collider and its shapes, hullOptions, render, lighting, lighting.lightmap, lighting.giVolume, backdrop, backdrop.fog, each component on an object, a dh.voxelVolume’s delta, and each connections entry and its actions. A member the schema does not define is a compile error naming the block, the member and the field set it is missing from — never a field that quietly does nothing.
The failure this exists to prevent is a dead field. A facing written as a bare "yaw" beside an object’s rotation parses fine, binds nothing, and ships a map spawning players the wrong way with no diagnostic at build or at load:
// Wrong — 'yaw' is not a field. This is now a build error.{ "$type": "object", "id": "…", "position": [0, 0, 0], "yaw": 90, "components": [ { "$type": "dh.spawnPoint" } ] }
// Right — a facing is the object's own rotation, euler degrees [pitch, yaw, roll].{ "$type": "object", "id": "…", "position": [0, 0, 0], "rotation": [0, 90, 0], "components": [ { "$type": "dh.spawnPoint" } ] }The valid-field list in the error is the one that block defines: a fov is a field of a dh.camera, so one written on the object record beside it is unknown there, and one written in a dh.spawnPoint is told the spawn point holds no fields at all.
Fields the compiler generates are part of the schema and are recognized on the way back in, so a build that writes its own output into a map never trips this: the world instance’s parts table and a collider’s generated and auto marks. A lightmap bake writes nothing into the map: it lives beside the source as a sidecar.
Inheritance
Section titled “Inheritance”A map may name another map in inherits and read everything that map states: its sky and sun, its render and look, its material slots, its bake settings and its records. The child’s own file says only where it departs. This is how every imported Minecraft world gets its look from one place, the environment template:
{ "name": "mltn city (Minecraft)", "inherits": "com.mojang.minecraft:maps/environment.dh-map", "lighting": { // The world's own sky (its biome, its time of day) and where its sun stands. The sun's color and // intensity, and anything else the template states, read through. "skyImage": { "path": "/textures/skies/mltn-city.hdr" }, "sunDirection": [-0.866, -0.499, 0] }, "objects": [ /* the spawn, the volume and the cameras, as before */ ]}The parent is named by barcode, so it may live in another pallet. That pallet must be one the child’s pallet declares in its dependencies; the build refuses an inherits into a pallet it does not, and records the parent’s pallet among the ones the child uses. The compiled child keeps its own JSON and its inherits: the chain is folded at load, the way an object’s and a material’s are, so changing the parent changes every map that inherits it on the next load (and a rebuild of the parent’s pallet makes every dependent pallet build again).
How each member folds:
| Member | Fold |
|---|---|
name, description | Not inherited. A map is named after itself. |
lighting, render, hullOptions, lighting.lightmap, lighting.giVolume | Per field. A field the child states wins; one it leaves out reads through. |
lighting.skyImage, backdrop, autoCollision, every other scalar | Whole. A sky image is one picture: a child’s new path does not keep the parent’s rotation. |
materials | Per slot. A child rebinds one slot and keeps the rest. |
lighting.bakeZones | Per zone id, a zone’s sets per set name, a set’s lights and emission per key. |
look | Level by level. Weakest to strongest: the engine defaults, the child’s pallet’s default look, the parent’s look, the parent’s fields, the child’s look, the child’s fields. A child that adopts unity gets Unity’s curve even where its parent pinned aces; the fields a replaced look set and the new one does not keep their values. |
objects | Added to. The parent’s records come first, in its order, then the child’s own. |
cookedCollision | Never. It is generated from the map’s own geometry. |
Every authored member of a map, and of every block folded per field, names its rule in one table (MapMerge.Rules), and a test fails on a member without one, so a field added later cannot quietly skip inheritance.
References mean what their own file meant. A compiled map stores its local references bare, meaning “this pallet”. Before the levels are folded, every reference a parent in another pallet states (a material slot, its look, its skyImage.path, an instance’s source and every asset field of a record’s components) is rewritten to a full barcode in the parent’s pallet, so after the fold the template’s sky is com.mojang.minecraft:textures/skies/environment.hdr and resolves there.
What is not built yet. These are refused rather than half done:
- Overriding or removing an inherited record. A child record whose
ida parent already holds is a build error for a parent in the same pallet, and is left out with a warning at load. Overrides byid,dh.removeObjectby id anddh.removePatcharrive with the next inheritance lane. - Inheriting geometry. A parent that places a model (an
instanceof a.glb) is refused, at build and at load: the parts, their ids, the cook and the bakes of an inherited model belong to a later lane. Inherit a map that places none, or place the model in the child.
Inside one pallet the build also checks the folded chain: a circular chain, a parent that is not a dh.map, a record or camera name that repeats a parent’s, and a second camera claiming mapThumbnail are errors, and a wire may name a logic record the child inherits. A parent in another pallet is checked where it lives, and the whole where it is loaded.
Full Example
Section titled “Full Example”{ "name": "Debug Map", "description": "A 32x32 m arena floor enclosed by 3 m walls, with a raised platform and a low step.", "materials": { "grid": "/materials/dev-grid.dh-mat", "orange": "/materials/dev-orange.dh-mat" }, "objects": [ { "$type": "object", "id": "3c5a0b7e-6d21-4f9a-8e14-7b2d9c1f4a60", "name": "collision", "components": [ { "$type": "dh.collider", "shapes": [ { "type": "box", "center": [0, -0.5, 0], "halfExtents": [16.5, 0.5, 16.5] }, { "type": "box", "center": [0, 1.5, -16.25], "halfExtents": [16.5, 1.5, 0.25] }, { "type": "box", "center": [5, 0.5, 5], "halfExtents": [2, 0.5, 2] } ] } ] }, { "$type": "instance", "id": "db71e214-0d00-4fde-b8e1-cd4ef06288bf", "name": "world", "source": "/meshes/debug-map.glb" }, { "$type": "object", "id": "10cd5fc6-fa9f-4faf-a968-6e159ccede2e", "position": [0, 0, 0], "rotation": [0, 0, 0], "components": [ { "$type": "dh.spawnPoint" } ] }, { "$type": "object", "id": "9f417a74-9e0c-4fbc-8c41-4668d17fd49d", "position": [10, 0, 10], "rotation": [0, 45, 0], "components": [ { "$type": "dh.spawnPoint" } ] }, { "$type": "boxBrush", "id": "a320bba5-ffa1-40e3-a87b-c7aef971daed", "position": [8, 0.75, 0], "size": [1.5, 1.5, 1.5], "rotation": [0, 30, 0], "material": "orange" } ]}Geometry
Section titled “Geometry”The world instance’s source is a path to the GLB, read from the map file’s own folder (or from the pallet root with a leading /), meshed as the map’s visual surface. glTF materials on the mesh are not honored — DigitalHeaven pallets are the material system — but glTF material names are used as slot keys for the materials map below.
The world is optional: a map made only of boxBrush entities, or of nothing at all, is a whole map (see The world instance).
A folder of pieces
Section titled “A folder of pieces”The world’s source may name a folder of the map’s own pallet instead of a file ("source": "/meshes/de_ancient", a path with no extension). Every .glb directly in that folder is a piece, and the pieces are read in path order as one model: their nodes follow one another, a material slot two pieces draw is one slot, and the part table, the collision cook and the bakes see exactly what one file holding them all would give. The pieces are joined by GlbMerge in Content, in the loader and in the compiler alike. A piece may carry meshes, nodes and materials by name; one with images, textures, skins, animations, cameras, sparse accessors or glTF extensions is refused rather than joined wrongly. A folder with no .glb is a missing geometry. The Source 2 importer uses this to keep every file under a size limit; see Source 2.
Skinned nodes, animation tracks and morph targets in a map GLB are skipped, not refused: the static map renderer has nothing to deform, play or blend them with. Skinned nodes are left out of the build entirely; a node with morph targets still draws, at its base shape. Either way the load logs one warning per model naming what it left out, and the pallet keeps the data. See what the map importer leaves out.
What the build says about the geometry
Section titled “What the build says about the geometry”dh build opens the map’s GLB and reports what the engine’s map importer would report at load, so a
map that will not load — or that will load with pieces missing — says so at the terminal instead of on
a player’s screen. Severity mirrors the runtime exactly, and that is what makes the lines trustworthy:
only what the importer actually refuses is an error, and everything it is written to survive is a
warning.
| Line | Severity | Why |
|---|---|---|
geometry: this map would fail to load… | error | A required compression extension (KHR_draco_mesh_compression, EXT_meshopt_compression, KHR_mesh_quantization), no scene, a primitive that is not TRIANGLES, a primitive with no POSITION, or a NORMAL/TEXCOORD_0/TANGENT/COLOR_0/COLOR_1 accessor that disagrees with POSITION about vertex count. The importer throws on each of these, so the map would not load at all. |
geometry: material slots with no 'materials' entry… | warning | Named slots the table does not bind. Each draws the missingMaterial checkerboard and the rest of the map loads. Reported only once the table binds something — a map that binds nothing is a blockout, not a mistake. |
geometry: skinned nodes… | warning | Dropped from the build entirely, so their geometry will be missing. |
geometry: morph targets… | warning | Loaded at their base shape; the shape keys are not driven. |
geometry: animation tracks… | warning | A static map has nothing to play them with. |
geometry: degenerate triangles… | warning | Meshes built largely of zero-area triangles, which draw nothing, light nothing and are culled by the collision cook. A mesh is named only when at least 128 of its triangles are degenerate and they are at least 2% of it, so the stray flat quad every real export carries stays quiet. |
Each line names one concern, not one node: the nodes or slots are listed inside it, up to twelve,
with the rest counted. A container that will not parse at all is left to the
autoCollision baker, which reports the same fact with far more about what the map loses.
Materials
Section titled “Materials”The materials object maps a mesh material slot name (a glTF material name on the geometry) to a reference to a dh.material asset — its path read from the map’s own folder, or a cross-pallet barcode:
"materials": { "grid": "materials/dev-grid.dh-mat", // same-pallet material "sky": "materials/sky.dh-mat", // an unlit, tinted sky slot "proto": "core:materials/dev-grid-white.dh-mat" // a cross-pallet material (engine core)}The referenced material supplies the slot’s base-color texture, tint, shader (lit/unlit), uvScale and emissiveColor — see dh.material. References are same-pallet (materials/name.dh-mat) or cross-pallet (<palletId>:materials/name.dh-mat); the engine core pallet (core:…) needs no declared dependency.
A slot the table has no entry for at all is a different case, and not a compile error — the mesh may
gain slots long before the table catches up. The map loads and the slot draws with the engine’s
missingMaterial: a glowing
red checkerboard, unmistakable on sight and never readable as an authored look. Each missing slot logs
its own console error naming the slot, the mesh and the pallet, so a port is fixed one line at a time
instead of one load failure at a time. There is no silent white fallback. Once the table binds
anything at all, the build names the leftover slots too — see
what the build says about the geometry.
The same slot keys are what solid entities (boxBrush and every dh.box, a button’s, a door’s and a plate’s included) reference by name in their material fields.
Colliders
Section titled “Colliders”Collision is one component: dh.collider. A model part’s override, a boxBrush and a plain object record each carry one in their components list, and nothing else describes collision. There is no map-wide colliders list: a source that still says colliders is a build error naming dh.collider.
{ "$type": "object", "id": "…", "name": "collision", "position": [0, 0, 0], "components": [ { "$type": "dh.collider", "shapes": [ { "type": "box", "center": [0, -0.5, 0], "halfExtents": [500, 0.5, 500] } ] }] }The component carries the owner’s state (solid, off or trigger), an optional material naming one of the map’s material slots (it paints no pixels, but it gives the bodies a surface identity, so footsteps, the landing value, wall hits and friction read that material’s surface block), and its shapes: box, sphere, capsule, hull, mesh and auto. Shapes are in the owner’s frame: a part’s own pose and scale, a brush’s center and rotation, an object’s transform. The component page is the reference for every field.
A boxBrush gets a collider for free: with none authored, the compiler generates one over its exact box with the brush’s own material.
Automatic collision
Section titled “Automatic collision”autoCollision selects how a map’s world model becomes collision for a part that authors no dh.collider. It takes one of four values:
| Value | Meaning |
|---|---|
"mesh" | True-to-shape concave collision cooked from the visual triangles — by the build when it can reach the engine, otherwise when the map loads — the map is solid exactly where its mesh is (the default, used when the field is absent). |
"boxes" | Bake one oriented bounding box per mesh node at compile time, as that part’s box shape. Cheaper collision for blockout geometry. |
"hulls" | Convex-decompose each mesh node into one or more convex hull shapes at compile time, as that part’s shapes. |
false | No automatic collision; a part collides only if it authors its own dh.collider. |
- The skybox node is always excluded: a node carrying a material named like a sky (contains
sky, case-insensitive) is never made solid, so the player is never caged by the sky dome. In"boxes"/"hulls"mode, degenerate (thinner than ~1 mm) and oversize (larger than 900 m) nodes are additionally skipped. - A map without a world instance has no automatic collision regardless.
- An authored
dh.collideralways wins over the mode: a part that carries one is never given a generated one, and a part may authorshapes: []to collide with nothing in a mode that would give it a mesh. - The compiler materializes what it built. An authored
autoshape is replaced by what it produced and marked"auto": true; a collider the compiler made for a part that authored none is marked"generated": true. A brush’sautoshape is always its exact box. The runtime reads these explicit components and never the mode.
{ // autoCollision omitted — defaults to "mesh": the whole city is solid to its own triangles "objects": [ { "$type": "instance", "id": "…", "name": "world", "source": "/meshes/city.glb" }, { "$type": "object", "id": "…", "name": "ground", "components": [ // still installed, on top of the cooked mesh { "$type": "dh.collider", "shapes": [ { "type": "box", "center": [0, -0.5, 0], "halfExtents": [500, 0.5, 500] } ] } ] } ]}Mesh (the default)
Section titled “Mesh (the default)”"mesh" makes the map solid to its own triangles — static concave collision meshes cooked from the world model and installed on both the server and the client-prediction physics world so movement matches. It is the most faithful mode: an archway keeps its opening, a stair keeps its steps, a bowl stays hollow, with no per-node approximation. The skybox triangles are dropped before cooking; everything else is kept as-is.
The build cooks it, in the engine. dh build hands the map’s triangles to the engine host named by enginePath — the cook has to be the runtime’s own, so the build runs it the way it runs Blender for an FBX — and ships the result as a <map>.dh-map.dh-colcook companion the runtime attaches instead of cooking. One blob per distinct mesh, so a prop placed a hundred times cooks once. The result is cached by the triangles and the engine install, so an unchanged map never starts the engine; on a 1.7M-triangle city a fresh cook costs about 2 s of build time, the engine’s start included, and a cached one about 50 ms. The editor’s Build cooks in its own process. A build with no enginePath, or an engine that fails, ships no companion and says so in a warning naming the cause; the map then cooks at load, exactly as it does from an older pallet. Never a failed build.
Two properties of the cook are worth knowing:
- A mesh part carries a mesh shape, not a body. In
"mesh"mode every part gets a generateddh.colliderover onemeshshape of its own triangles; the triangles themselves ship in the cook, keyed by the part’s scene id and the shape’s index. - Owner identity de-solidifies collision. The cook is divided into one body per shape, so switching an object off in a live scene edit takes its collision with it, exactly as in
"boxes"and"hulls". - A trigger node is left out of the cook entirely. A part whose collider
stateis"trigger"is excluded alongside"enabled": falseand astateof"off", rather than cooked solid and de-solidified again at load. That is what lets an imported water surface be water: a water node marked"trigger"becomes a water body the mover swims in, the buoyancy samplers read and the camera fogs from, instead of a floor a player stands on.
Boxes vs. hulls
Section titled “Boxes vs. hulls”The two compile-time baking modes write per-part shapes into each part’s generated dh.collider; they differ in shape fidelity and cost:
"boxes"wraps each node in a single oriented box. It is cheap to bake and produces the smallest output, but a box fills any concavity — a doorway, an L-shaped wall or a ramp underside is solid where the mesh is hollow. Good for blockouts and mostly-boxy geometry."hulls"runs a convex decomposition (V-HACD): each node becomes a set of convex hulls whose union approximates the mesh, so concave shapes stay concave — an archway keeps its opening, an L-wall keeps its notch. This costs far more at build time (seconds per map, versus milliseconds for boxes) and produces a much larger shape list. Unlike"mesh", each hull is convex, so a single node’s deep concavity is still only approximated.
Both modes keep the surface. Each baked box or hull takes its source node’s material, the one covering the most of the node’s triangles, as its material, so footsteps, landings and grip read the same dh.material they would in "mesh" mode. A node whose material has no entry in the map’s materials bakes with none and stands on the world default, just as an unmapped slot of the cook does. A single shape carries one surface, so a node that mixes materials takes its dominant one everywhere.
For reference, baking the prototype Night City map (86 collidable mesh nodes) yields 86 box colliders in "boxes" mode versus 893 hull colliders (8,223 total points) in "hulls" mode — roughly a 30× larger collision payload for a proportionate gain in shape accuracy. "mesh" writes no box or hull at all — one mesh shape per part, with the cooked triangles beside the map.
Nodes the bake skips
Section titled “Nodes the bake skips”Baking is per node, and a node that cannot be baked costs only itself. The skybox and nodes outside the size guards are skipped silently; a node is also skipped when its world transform has no inverse at all — an exactly zero scale axis, which Unity-sourced exports produce freely by parking prefab templates at zero scale, and which flattens every node underneath it too. Those are named in a single autoCollision: collapsed subtree... warning, and anything that throws while being read is named in a single autoCollision: node failed to bake... warning.
The collapse warning names only the topmost node of each collapsed subtree and reports two counts — how many subtrees, and how many nodes in all — because those are different questions: three subtrees is three things to go and look at, six nodes is how much of the map went with them. It is printed in every autoCollision mode, "mesh" and false included: a collapsed transform is a fact about the GLB rather than about baking, and the runtime mesh cook and the editor read that same unusable world pose. Gating it on the baking modes is what once let a real map carry six collapsed nodes through a build in silence. A collapsed subtree does not need to contain any geometry at all to be reported — a parked prefab template with nothing but a text marker under it still gets named.
A skipped node is present but unbaked, never deleted. It keeps its part table entry, its id and its objectId, so the editor still lists it and the overrides anchored to it still land — it simply contributes no collision. Only a wholesale failure, where the part table itself cannot be built, is an error that fails the build: a map with no part table has an empty hierarchy and detaches every override.
hullOptions
Section titled “hullOptions”hullOptions tunes the convex decomposition used by autoCollision: "hulls" (it is ignored in "boxes" mode). Every field is optional; an omitted field takes the decomposition’s default. Values are validated at compile time.
| Field | Type | Default | Description |
|---|---|---|---|
maxConvexHulls | int > 0 | 64 | Upper bound on the number of hulls produced per mesh node. Lower it to cap shape count at the cost of fidelity. |
resolution | int > 0 | 400000 | Voxelization budget (total voxels). Higher resolves finer concavities but costs more build time. |
maxVerticesPerHull | 4–255 | 64 | Maximum vertices in each output hull. At least 4 (a hull’s minimum), capped at the physics backend’s 255-vertex hull limit; an out-of-range value is a build error. |
minVolumePercentError | number > 0 | 1.0 | Volume-error convergence threshold (percent). Smaller values chase a tighter fit for more hulls/time. |
The voxel fill strategy is intentionally not exposed — hull baking always uses flood-fill.
{ "objects": [ { "$type": "instance", "id": "…", "name": "world", "source": "/meshes/city.glb" } ], "autoCollision": "hulls", "hullOptions": { "maxConvexHulls": 32, // fewer, coarser hulls per node "maxVerticesPerHull": 48 }}The compiler bakes a parts table into every model instance of a compiled map: one entry per mesh-bearing node of the model plus one per mesh-less structural node (the groups the scene was built with), giving every part a stable identity that its collider, overrides and engine tooling can address. It is generated output, not something you author: a parts list written in a source file is a build error. The table is baked whenever the map has a world, including when autoCollision is false, because it describes part identity, not solidity.
| Field | Type | Description |
|---|---|---|
id | int | Stable index assigned in scene-traversal order. The mesh-bearing parts are numbered first, in the same order the runtime mesh loader assigns node slots, and the structural parts take their ids after all of them: promoting the groups may not renumber the mesh parts, because those ids are the runtime’s slots. Independent of whether a part is skipped for collision. The number is the map’s: a later placement’s parts count on from where the previous placement’s stopped, so one id names one part of one placement, and its slot in its own model is the id less its placement’s first. |
localId | guid | The part’s identity inside its model, recorded in the model’s identity sidecar. What an override’s id names. |
objectId | guid | The part’s scene-object identity, in the same id space as a record’s id, so an editor selection or a session edit can address a part and a record the same way. It is SceneIds.Child(instance id, localId), a UUIDv5 under the instance’s id, so two instances of one model never share a part id and one instance’s ids never move across a rebuild. |
name | string | The node’s own name from the model (empty for an unnamed node). |
path | string | The node’s hierarchical, slash-separated name path from the scene root (e.g. buildings/block03/wall_north). Siblings that share a name are disambiguated Blender-style (wall, wall.001, wall.002). |
structural | bool | true on a mesh-less node, a group. Omitted when false. Cameras, punctual lights and skinned meshes are not structural parts and get no entry at all. |
pivot | [x, y, z] | The node’s world-space translation, its placement’s pose included. |
rotation | [x, y, z, w] | The node’s world rotation as a quaternion. Omitted at identity. |
scale | [x, y, z] | The node’s world scale. Omitted at one. A node whose world matrix carries a shear keeps the rotation and scale the matrix decomposes to; the shear is dropped and the build names every such node in one warning. |
parent | guid | The record an override files the part under. Omitted for a part in its place in the model. |
components | array | What the part carries, resolved: its dh.collider (authored, or the one autoCollision generated), its dh.bake as its override states it, and a dh.renderer on every drawn part. The renderer is written whether or not anything authored one, marked "generated": true: its materials bind each slot the part’s primitives draw (by the primitive’s material name, which is the map’s slot) to what the map’s materials table gives that slot, empty where it gives none, and its reflectionProbe is the probe the primitives name when every one of them names the same one. So a reader of a part’s materials reads one component, never the model plus an override. generated written in a source is a build error. |
aabb | { "min": [x,y,z], "max": [x,y,z] } | The node’s world-space vertex bounding box. Absent on a structural part, which has no geometry to bound, and absent is not empty: a reader that substitutes a zero box for a missing one invents a point at the world origin. |
// compiled output, inside the world instance; do not hand-author"parts": [ { "id": 0, "localId": "6f3b21c4-9d0e-5a77-8b12-4c5e7a90d3f1", "objectId": "0d6a8f31-5c2e-5b47-9e10-3a7c4b2d8e65", "name": "wall_north", "path": "buildings/block03/wall_north", "pivot": [10, 0, -4], "rotation": [0, 0.7071, 0, 0.7071], "aabb": { "min": [9, -2, -5], "max": [11, 2, -3] } }, { "id": 412, "localId": "b1c8d0a2-7e45-5c31-9f60-2a3b8d1e6c04", "objectId": "7e2c19a0-4b6d-5f83-a1c5-0d9e3f7b2a48", "name": "block03", "path": "buildings/block03", "structural": true, "pivot": [10, 0, -4] }]A part’s id and path are deterministic (the same model always bakes the same table), so a collider’s owner stays the same across rebuilds. An override names its part by localId, with path kept as the anchor for the one case an id cannot answer.
The identity sidecar
Section titled “The identity sidecar”localId is recorded, not derived. A part id is minted once, from the model’s barcode (its pallet id and its path in the pallet) and the node’s path, and then written into an identity sidecar beside the model, named by appending .dh-ids to the model’s file name (meshes/nightCity.glb.dh-ids; for a folder of pieces, meshes/de_ancient.dh-ids beside the folder). The sidecar belongs to the model rather than to the map that places it. Every later compile re-matches the scene against that file and hands each node back the Guid it already had, which is what lets a part survive a rename or a re-parent that a derived id could not. The sidecar is build input rather than shipped content: it is deliberately not a .dh-* asset extension, asset discovery ignores it, and the compiled pallet does not carry it. Keep it under version control: it is the model’s memory of its own ids.
Matching is tiered, most specific first: path plus name; content hash plus parent and sibling position; content hash alone; then name plus parent and sibling position. No tier matches on a bare name: every name-bearing key carries the node’s parent path and its position among its siblings, which is what keeps a positional rename in an exporter (Unity’s _N suffixes) from handing one object’s identity to another. A structural node has no geometry to digest, so its content hash is empty and it opts out of the two hash tiers rather than sharing one key with every other group in the scene. A tier that names more than one candidate is skipped and warned about rather than guessed at, and a node that no tier matches gets a fresh id.
An identity whose node is gone is kept as an orphan rather than dropped, so re-adding the node restores its id. When a node was replaced rather than removed, open the file in the Halcyon Studio and use Retarget onto in its Orphans view (or set that orphan’s retarget to the live node’s path by hand): it is the one field of the sidecar a person writes, it outranks every tier, and it is cleared once honored. Nothing here can fail a build: an ambiguous match and a leftover orphan are both warnings.
Part overrides
Section titled “Part overrides”A model instance’s children lists the departures of its parts, one override per part that departs and nothing for a part that does not. This is the prefab model: the model is the source, the instance places it, and an override says only what this placement changes. It is what the level editor’s Save writes, and it is equally something you can type by hand. Edits to the model’s scene graph itself (dropping, renaming, moving or folding nodes before the part table is ever baked) belong in the model’s .dh-model sidecar instead.
The compiler validates the overrides and resolves them against the part table; the engine applies them when the map installs, so one list is honored by everything that loads the map rather than by whatever baked it.
The transform is a delta, not a pose, because a part has no authored transform to override: its pose is baked into its vertices at compile time. That choice is what lets you re-export the model and keep the nudge: move a building in Blender and the override still means “and two meters further north” rather than yanking it back to a world position the artist has since abandoned.
| Field | Type | Required | Description |
|---|---|---|---|
id | guid | no | The part’s localId. The address the editor’s Save always writes. |
path | string | no | The part’s path when the override was written: the anchor an override with no id, or with an id that names no live part, resolves by. One of id and path is required. |
name | string | no | Display name the hierarchy and inspector show in place of the model’s node name. Never touches the model or the identity sidecar. |
position | [x, y, z] | no | World-space displacement in meters from the part’s compiled pose. |
rotation | [x, y, z] | no | Euler rotation delta in degrees, applied Z·Y·X about the part’s compiled pivot. |
scale | [x, y, z] or number | no | Scale multiplier about the compiled pivot; a single number means uniform. |
enabled | bool | no | Whether the part is in the world. Only false is written. A part that is not in the world is left out of the lightmap and baked-indirect bakes too. |
static | bool | no | Whether the part takes part in the lightmap and baked-indirect bakes. Only false is written. |
removed | bool | no | true takes the part out of the instance: it is never decoded. Removal is always this field; nothing else, and no null, ever removes a part. |
parent | guid | no | Another record of the map (a folder or an object) to file the part under. Naming the instance itself or a record the map lacks is a build error. Carried into the compiled table; the editor’s hierarchy does not yet re-file the part. |
components | array | no | The part’s components: a dh.collider (the part’s collision, state included) and a dh.bake (its lightmap scale, bake zone and baked emission), one of each. Any other entry is a build error. |
"objects": [ { "$type": "instance", "id": "db71e214-0d00-4fde-b8e1-cd4ef06288bf", "name": "world", "source": "/meshes/city.glb", "children": [ { "id": "6f3b21c4-9d0e-5a77-8b12-4c5e7a90d3f1", "path": "buildings/block03/wall_north", "position": [0, 0.5, -2], "rotation": [0, 90, 0], "scale": 2 }, { "path": "buildings/block03/scaffold", "removed": true } ] }]The fields the old dh.editNode patch spelled differently are build errors that name their replacement: offset is position, rename is name, and target and manifestHash are gone (a part is addressed by id and path). A map-level patches list is a build error too. So are the three bake fields an override carried until stage 3’s step 8, bakeEmission, lightmapScale and bakeZone: each names its place in the part’s dh.bake (emission, lightmapScale, zone).
The merge rule
Section titled “The merge rule”An override is laid over its part one field at a time, by one rule every reader shares:
- A field left out inherits. The part keeps whatever the model gave it.
nullmeans the same thing: it reverts the field to the part’s own value. - Scalars and lists replace whole. A
positionreplaces the whole vector and aconnectionsarray replaces the whole wiring; there is no element-wise merge of a list. - Maps merge per key, and a record nested inside one merges recursively.
removed: trueremoves, and is the only thing that does.
position, rotation and scale are independently optional. A member the override leaves out is not a zero: it is a question the override declines to answer, and the part keeps whatever the model baked into it. That is what makes the level editor’s per-row Save Position literally true: it writes a position and nothing else, the file keeps its rotation and scale exactly as they were typed, and the turn you have not saved yet stays pending and marked. A member the save does name at its identity is dropped rather than kept, because that is Revert then Save and the file must stop claiming a turn you took back. An override whose every field is at its default is removed by the editor’s Save rather than written empty.
The bytes of an unnamed member survive verbatim. A save rewrites a part’s whole override, so anything it does not state is copied back out of the file character for character: "scale": 2.0 stays 2.0 rather than becoming [2, 2, 2]. connections obeys the same rule: a save that has nothing to say about the wiring copies the array back untouched, and a save of a session rewire replaces exactly it, so editing a wire and editing a pose never overwrite one another.
How an override finds its part
Section titled “How an override finds its part”id is tried first; path is the anchor when the override has no id or its id names no live part. An override that resolves to nothing is a warning, not an error, and does nothing: a map being iterated on is precisely when an override goes stale, and refusing to open it would break the map at the one moment its author needs it. Two overrides naming one part leave one answer, and it is the last.
An orphan override is kept, warned and inert. When an override’s id is an orphan in the model’s identity sidecar and its path names no live part, the build says so with the path the part was last seen at. Nothing applies it, and nothing deletes it: it stays in the source byte for byte through any number of saves, and it applies again the moment the part comes back (re-adding the node restores its id). Re-anchoring or deleting an orphan is a person’s decision, not the writer’s.
Collider, static and enabled
Section titled “Collider, static and enabled”enabled, the collider’s state and static are orthogonal, deliberately. "enabled": false takes the part out of the world entirely: it stops being drawn, stops being solid and stops taking part in the bakes, so a hidden LOD, an old shell or a switched-off decal never shades the surfaces it overlaps. A state of "off" on the part’s dh.collider takes away only the collision: the part is still drawn, still lit, still there to look at, and you simply walk through it. That is the distinction you want for a railing you keep tripping on, or a decorative mesh whose baked hull is larger than it looks. A state of "trigger" keeps the shape but stops it blocking; it fires nothing by itself, since touch edges are a dh.trigger’s outputs. "static": false leaves the part drawn and solid exactly as before; it only takes the part’s triangles out of the lightmap and baked-indirect bakes, so it neither receives nor casts baked light.
A part is not a logic source. A part has no name in the logic namespace and carries no logic component, so it can be neither wired nor called, and a connections member on an override is an unknown field and a build error. A volume that fires touch edges is an object carrying a dh.trigger, placed where the part is.
Turning the collider off never loses trigger-ness. "solid" and "trigger" each already say their own trigger-ness, so wasTrigger beside either would be redundant, or worse, disagree with it: it exists only to give "off" somewhere to remember what to restore.
A removed part
Section titled “A removed part”A removed part is never decoded. It stays in the model and in the part table, so every other part keeps its id, its path and its share of the lightmap UVs, but no load reads its geometry: not the engine’s client, its server or its editor, and not a Unity host. A material only removed parts wear draws nothing. Restoring the part in the level editor (or with scene.enable) reads that one part back from the pallet, draws it from a mesh of its own and makes it solid again.
A switched-off part is not a removed one. A switched-off part ("enabled": false) is fully present and switched off, which is the same state scene.disable puts it in during a session, so it can be switched back on live, and doing that marks the map pending in the editor like any other change.
Records
Section titled “Records”objects is the map’s list of records, each naming its type with a $type field, the same discriminator pattern top-level DH assets use. There are three record types, and one piece of sugar:
$type | What it is |
|---|---|
folder | A label that groups other records. No transform. |
object | A posed record that owns components and connections. |
instance | A placement of a source: a dh.object or avatar, or a model (a .glb, an .fbx, or a folder of .glb pieces) whose parts become its children. |
boxBrush | Sugar for an object holding a dh.box, written back the way it was read. |
An unrecognized $type is rejected at compile time.
The world instance
Section titled “The world instance”A map’s world is an instance record named world whose source is the model: the GLB (or folder of pieces) the map is built around. Its parts are its children, its overrides are its children list, and the compiler writes its part table into it.
"objects": [ { "$type": "instance", "id": "db71e214-0d00-4fde-b8e1-cd4ef06288bf", "name": "world", "source": "/meshes/debug-map.glb" }]The world is the map’s first model instance. It takes no connections and no authored parts, and like every other model instance it may be posed and parented (see Placing models). Every reader (the compiler, the loaders, the bakes, the editor) reaches a model through its instance record and never through a map-level key, and the record never joins the logic order: its parts are installed as map geometry, not as logic.
Placing models
Section titled “Placing models”A map places any number of model instances, one model as often as it likes, each an instance record with a source naming the model and its own position, rotation, scale and parent like any record:
"objects": [ { "$type": "instance", "id": "db71e214-0d00-4fde-b8e1-cd4ef06288bf", "name": "world", "source": "/meshes/yard.glb" }, { "$type": "folder", "id": "f0000000-0000-4000-8000-0000000000aa", "name": "props" }, { "$type": "instance", "id": "8a52c1d0-77e3-4f0b-9b61-5c0f2d4e3a19", "name": "crate", "source": "/meshes/crate.glb", "position": [4, 0, 2], "rotation": [0, 90, 0], "parent": "f0000000-0000-4000-8000-0000000000aa" }, { "$type": "instance", "id": "3e9b07f4-2c51-4d86-a0e7-91b6c8d2f405", "name": "crate (big)", "source": "/meshes/crate.glb", "position": [-3, 0, 6], "scale": [2, 2, 2] }]Each placement is its own: the compiler writes it a part table standing at its pose (a part’s pose is the placement’s pose times the part’s compiled pose times its override), its parts take scene ids under the placement’s own id, its overrides resolve against its own parts only, and it is lit, collided and probed on its own:
- Lightmap. Every placement owns its texels. A map that places its one model where it was built keeps its UV set beside the map (
<map>.dh-map.lightmapUv); any other map’s bake carries one UV set per placement, keyed by the instance’s id, inside the lightmap companion (version 5). - Cooked collision. A cook entry is keyed by its part’s scene id, so each placement’s bodies are its own, and a blob is shared by content, so a model placed a hundred times still cooks once (
.dh-colcookformat 5). - GI and probes. The light probe, irradiance and reflection bakes trace every placement, and fingerprint the placements’ models and poses, so moving one is a stale bake.
- Collision at run time. A map with no cook builds each placement’s bodies from the shared mesh, at its pose.
A model placed twice with the same removals is decoded once at run time; a placement with its own lightmap UVs reads them over its own copy of the vertices. The identity sidecar belongs to the model, so every placement shares one set of local ids. A model instance’s connections are refused: wire one of its parts through its override instead.
Placing a prop as a model instance is what an importer would write instead of wrapping each in a .dh-obj: the record above, its source the prop’s model, its pose the entity’s, its part overrides keyed by path.
A map without a world instance is not a lesser map. A map whose only surfaces are boxBrush entities renders exactly as one with a model does, so a yard built entirely out of brushes, or a collision-only map, or a map with nothing in it at all, all load and all photograph. A map with neither a world nor a single brush logs one warning saying so and draws its sky; it is never an error.
Fields every record has
Section titled “Fields every record has”Every record, whatever its $type, carries the same identity and hierarchy fields: a kind adds fields, it never redefines these.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
$type | string | yes | — | Record type discriminator |
id | guid | yes | — | The record’s stable identity: what a parent, an object index or an override resolves to. Must be unique within the map and must not be the all-zeros Guid. |
name | string | varies | "" | Display name. Required and map-unique for logic entities (a connection targets one by name) and for an object carrying a dh.camera; optional and free-form elsewhere. |
parent | guid | no | — | The id of another record in the same map. Omit it to make the record a root. |
enabled | bool | no | true | Whether the record is in the world at all. false is not drawn and not solid: the same state scene.disable puts it in during a session, and what the level editor’s Save writes when you switch an authored record off. A part, which has no record of its own, says it with its override instead. |
static | bool | no | true | Whether the record counts as static for baking purposes. Has no effect on the running world. Currently only a part’s override static actually changes a bake, since boxBrush and the logic entities are not baked geometry to begin with; the field on other records is here for schema uniformity with enabled and is reserved for when they are. |
Ids are authored, not generated. The compiler will not mint one for you, because an id that only existed in the compiler’s memory would come out different on the next machine and every reference to it would rot; a missing id is a build error whose suggestion hands you a fresh Guid to paste. Duplicate ids, a parent naming a record that is not in the map, a record that is its own parent, and a parent chain that loops are all build errors too.
The object transform
Section titled “The object transform”Every record except folder is an object, and an object has an intrinsic transform with one representation for every kind:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
position | [x, y, z] | no | [0, 0, 0] | Position in meters. What “position” anchors varies by kind (a spawn’s feet, a brush’s center, a camera’s eye) but the field does not. |
rotation | [pitch, yaw, roll] | no | [0, 0, 0] | Orientation as euler degrees, applied in Z·Y·X order. pitch is positive looking up; yaw is around +Y with zero facing -Z; roll is the bank. |
scale | [x, y, z] | no | [1, 1, 1] | Per-axis scale. No axis may be zero — a zero-scaled axis collapses the object and cannot be inverted. |
connections | array | no | [] | The object’s outbound wiring: output → ordered actions. See Connections. Omitted by the great majority of objects, which wire nothing. |
components | array | no | — | The state the object owns, one { "$type": "dh.<noun>", … } per entry. Which ones an owner takes is its kind’s question; see Components. |
connections belongs to the OBJECT, not to a kind. Source’s per-entity I/O, generalized: a dh.trigger drives doors and lights on exactly the terms a dh.pressurePlate does. What an object may FIRE is its components’ own question — a logic component declares its outputs in the signal table, and an output nothing on the object declares is a build error. A box whose collider is a trigger declares none, so wiring one is refused.
Each is validated at compile time, never clamped: a component count other than three, or a non-finite number, is a build error.
Components
Section titled “Components”Every object also carries a components list: the state it owns, one entry per component, each written as { "$type": "dh.<noun>", …fields } and never carrying an address of its own (the owner is the address). This is where the entity kinds went: a door is an object with dh.box, dh.collider and dh.mover, a lamp one with dh.box and dh.light, a crate one with dh.box, dh.collider and dh.physicsProp. An object instance takes the components an object’s root takes, laid over its source’s.
A plain object takes a dh.collider (a part’s override takes one too), a dh.box and, beside a box, a dh.bake, any set of logic components, one of each type (dh.button, dh.mover, dh.pressurePlate, dh.trigger, dh.teleport, dh.timer, dh.mapStart, dh.andGate, dh.orGate, dh.notGate, dh.physicsProp), and one presentation component: dh.light, dh.camera, dh.spawnPoint, dh.reflectionProbe, dh.lightProbeVolume, dh.lightProbeGroup or dh.voxelVolume, each carrying the fields the record type of that name used to. At most one of each, and one presentation component per object: a light and a camera on one object is a build error naming both (carries both 'dh.light' and 'dh.camera'), so the second goes on an object of its own. Logic components combine freely, a button that is also a mover is one object, but a second of one type is refused (carries more than one 'dh.timer'), and so is an object whose components would replicate more than four sections. A component’s block is closed like every other, so a field it does not define is a build error listing the ones it does. Anything else is a build error:
- a
$typeno component has is reported as not a known component type, with the known types listed. An unknown component still loads, kept whole as its JSON, so a map written by a newer toolchain is not lost on the way through an older one; - a known component is reported as one the owner’s kind cannot take, until that kind converts.
A session edits a presentation or logic component’s fields, not its presence. Every field is a field edit the server decides and a late joiner hears, written back on Save into the component under its description’s key. Adding or removing one is done in the map source: what it installs (a light in the renderer’s list, a probe’s cube, a voxel volume’s stream, a node in the logic graph) is built from the compiled map.
dh.box
Section titled “dh.box”A box: a solid, textured box drawn in its owner’s frame. It is static, so it never replicates.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
size | [x, y, z] | no | [1, 1, 1] | The box’s full extents (edge-to-edge size) in meters, before the owner’s scale; every axis must be above zero |
material | string | yes | A key into the map’s materials table; selects the dh.material the box’s faces render with. In the editor it is drawn as the material field over that slot |
A box on an object that also carries a component with a logic node, a light’s fixture or a button’s, a door’s or a plate’s block, is live: the owner’s visual draws it, never the static box batch. So is a box under a mover, which the live path draws where the mover carries it. A fixture wears the light’s materials and collides through its generated collider like any other box (see dh.light); a button’s, a door’s and a plate’s collider is its logic’s body. Its size and material are edited live; it is added and removed in the source with its component.
An object holding a dh.box collides as its exact box: with no dh.collider of its own, the compiler gives it one over the box, wearing the box’s material, whatever autoCollision says. An authored collider’s auto shape is that same box. A size that is not above zero on every axis, and a material the map does not name, are refused at compile time with the component named (Map entity #3 (object dh.box) 'size' must be positive).
{ "$type": "object", "id": "a320bba5-ffa1-40e3-a87b-c7aef971daed", "name": "ledge", "position": [8, 0.75, 0], "components": [ { "$type": "dh.box", "size": [1.5, 1.5, 1.5], "material": "orange" } ] }The compact way to write the same thing is boxBrush.
A box is added, removed and reshaped live. In the level editor, a plain object can be given a dh.box and a box can be taken off its object, for everyone in the room and for whoever joins later. An added box is drawn, collides as its exact box (the collider the compiler would have given it) and is water where its material is; a removed one takes its drawing, the body it made and its water with it. A box’s water follows its size and material the same way: the swim volume, buoyancy and the underwater view change on the frame the box’s mesh is cut again. The editor’s Add → Box makes an object holding a unit dh.box in the map’s first material, saved as the boxBrush sugar.
dh.bake
Section titled “dh.bake”How a rendered owner takes part in the lightmap bake: a model part (on its override), an object standing a dh.box, and an object’s part. Static: it never replicates, and nothing changes until the next bake. One per owner.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
emission | object | no | none | Makes the owner’s triangles light the bake, Bakery’s light mesh, winning over the material’s bakeEmission: color (sRGB, default the material’s emissive color), intensity (linear, default the material’s emissiveIntensity) and samples (shadow rays per sample, default the map’s emissiveSamples). Its own presence is the switch. See emissive surfaces. |
lightmapScale | float | no | 1 | The owner’s texel density as a multiple of its zone’s, Unity’s Scale In Lightmap: 0.25 bakes it at a quarter of the density, 2 at twice. 0 takes it out of the atlas: unlike "static": false it still casts and bounces baked light, and the runtime lights it from the probes. Never negative. |
zone | string | no | its ancestor’s | The id of the bake zone the owner and everything under it bake in; a nearer owner’s own zone wins, so zones nest. Naming an id lighting.bakeZones lacks is a build error. |
{ "path": "__Areas/__mltn_tower/__Underground_Facility", "components": [ { "$type": "dh.bake", "zone": "underground", "lightmapScale": 0.5 } ] }“Under it” is the tree as filed: a part’s ancestors are the model’s own nodes above it until one of them (or the part itself) is filed under a record by its override’s parent, and from there they are that record and its parents. So a box carrying a zone zones the parts filed under it. A box itself is not in the bake (it is lit from the probes; see the caution under Lightmap), so its emission and lightmapScale bake nothing yet, and the build warns that they do not; only its zone acts. An object’s part takes the component as a map part does, and bakes nothing until objects bake.
The inspector draws it as the Bake card on a part or a box that holds one, and offers Bake in Add Component on one that does not. Its fields are session edits like any component’s, written back on Save into the owner’s components; the bake reads the map source, so they show after the next Render Bake.
An empty or absent list is the same thing, and a list with nothing in it is left out when a map is written.
A component id is a noun (dh.light, dh.mover), and never an asset type id such as dh.material or dh.object.
folder
Section titled “folder”A folder is an entity that exists only to group other entities: it has an id, a name and an optional parent, and that is all.
| Field | Type | Required | Description |
|---|---|---|---|
$type | "folder" | yes | Entity kind discriminator |
name | string | no | The folder’s label in the editor’s hierarchy |
{ "$type": "folder", "id": "bbbbbbbb-0000-4000-8000-000000000001", "name": "streetlights" }A folder deliberately has no transform. It is a distinct kind rather than an object with an empty pose so that a folder can never be wired into a field that wants something to move: the mistake is unrepresentable rather than rejected at runtime.
object
Section titled “object”An object is the plain posed record: the shared fields, the transform, connections and components, and nothing a kind adds. With no components an object is a pose in the hierarchy (a pivot to file things under); with a dh.box it is a box, with a dh.collider it collides, and with a presentation component it is a spawn point, a camera, a probe, a light or a voxel volume, and with logic components it is a button, a door, a plate, a timer, a map start or a gate, or several of them at once.
{ "$type": "object", "id": "c3a1e0d2-5b4f-4c6a-9e7d-1f2a3b4c5d6e", "name": "stage", "position": [0, 0, 12] }dh.spawnPoint
Section titled “dh.spawnPoint”A player spawn point: an object carrying a dh.spawnPoint is a feet position and a facing. The server materializes one spawn per such object. Players are placed at a random spawn no one is standing on (falling back to stacking only when all are occupied); a map with a single spawn point spreads players deterministically so they never overlap.
The component has no fields: the object’s pose is the spawn. Its position is the spawn’s feet position, and the yaw component of its rotation is the direction the player faces. pitch and roll are not read — a player spawns level. A spawn point is a pose the server reads once rather than a thing in the world, so it never joins the logic order, wired or not.
{ "$type": "object", "id": "10cd5fc6-fa9f-4faf-a968-6e159ccede2e", "position": [0, 0, 0], "rotation": [0, 90, 0], "components": [ { "$type": "dh.spawnPoint" } ] }A map that authors no spawn point still yields one spawn at the origin, so the server always has somewhere to place players.
boxBrush
Section titled “boxBrush”boxBrush is parser sugar for an object holding a dh.box: one flat record to hand-author a wall, a pedestal or a floating block without a GLB. The parser reads its top-level size and material as the dh.box, first in the object’s components, and appends any components it authors after it; nothing past the parser knows the word. A box is static map geometry and is never network-replicated.
| Field | Type | Required | Description |
|---|---|---|---|
$type | "boxBrush" | yes | The sugar’s discriminator; the record is an object |
size | [x, y, z] | no | The dh.box’s size: full extents (edge-to-edge size) in meters, [1, 1, 1] when left out; every axis must be above zero |
material | string | yes | The dh.box’s material: a key into the map’s materials table |
components | array | no | The object’s other components, after its box: a dh.collider whose state is "solid", "off" or "trigger". With none authored, the compiler generates a solid one over the exact box. |
Plus the shared object fields: position is the box’s center, rotation orients it, and connections wires it.
// authored // what the parser reads{ "$type": "boxBrush", "id": "a320…", "position": [8, 0.75, 0], { "$type": "object", "id": "a320…", "position": [8, 0.75, 0], "size": [1.5, 1.5, 1.5], "material": "orange" } "components": [ { "$type": "dh.box", "size": [1.5, 1.5, 1.5], "material": "orange" } ] }The spelling round-trips. The record remembers it was read as sugar, so the editor’s save writes its box’s size and material back at the top level and its other components into components, and a box the editor creates is written as sugar too. An object written with a dh.box in its components stays that way. A sugar record whose dh.box is taken out is rewritten as the plain object it stood for.
The box is fully oriented: its rotation rotates both the rendered mesh and the physics body — there is no silent yaw-only fallback. Note size is full edge-to-edge extents, in contrast to a collider box’s halfExtents. The material is required and must name one of the map’s material slots; a missing or unknown material is rejected at compile time.
A trigger box blocks nothing and fires nothing. A dh.collider whose state is "trigger" takes the blocking body away and keeps the shape: a player walks through it and a water body reads it, and that is all. The touch edges belong to a dh.trigger on the object, so connections on a box that carries none is a build error naming it. A state of "off" takes the collision away entirely — the box is still drawn, still lit, and you walk through it — and is orthogonal to enabled, which takes the object out of the world altogether.
dh.camera
Section titled “dh.camera”A named fixed viewpoint: an object carrying a dh.camera, whose pose is where it looks from and whose name is how a render asks for it. Cameras are an authoring aid, not gameplay: nothing in the simulation ticks them. They exist so a map can carry a set of repeatable shots that offscreen rendering can shoot on demand — which is what makes two renders of the same map comparable at all.
The camera is its object, which is what lets the level editor list one, click it, drag it and turn it: moving a camera is the ordinary authoritative transform every other authored object moves by, and a lens edit is a field edit of the component, so a shot reframed or retuned in the editor replicates, persists and undoes like anything else. A camera is static: it holds no wire id, and joins the logic order only when its object is wired.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
fov | float | no | 75 | Vertical field of view in degrees, in (0, 179]. Unread under an orthographic projection. |
projection | "perspective" | "orthographic" | no | "perspective" | How the shot projects. An orthographic camera has no vanishing point, so a top-down shot of a whole map measures the same everywhere in the frame. |
orthoSize | float | under "orthographic" | — | Half-height of the view box in meters, vertical like fov. Required when orthographic and rejected when not. |
near | float | no | engine default | Near plane override, in meters. |
far | float | no | derived | Far plane override, in meters. Must be greater than near. |
aspect | "viewport" | "w:h" | no | "viewport" | The shape of the frame. "viewport" takes whatever the output is; a w:h pair of positive numbers states one — "16:9", "4:3", "1:1" are the presets the editor offers as chips, and any other pair is legal. Under the "mapThumbnail" role thumbnailSize decides the shape and this field is unread. |
role | "none" | "mapThumbnail" | no | "none" | What the build does with this camera. "mapThumbnail" makes it the map’s thumbnail: the shot the build renders and ships beside the map. At most one camera per map may carry it. |
thumbnailSize | [width, height] | no | [512, 512] | What the picture is rendered at, in pixels. The default is SQUARE, because every surface that lists maps draws a square plate; a map that wants a wide shot states one. Each edge must be in [16, 4096]. Rejected outside the "mapThumbnail" role. |
autoCapture | bool | no | true | Whether a build re-renders the thumbnail when the map changed. With it off, only the editor’s own Capture thumbnail verb ever rewrites the file, so a shot composed by hand survives every later build. Read only under the "mapThumbnail" role. |
Those are the component’s fields. The object’s name is the shot’s name: required, non-empty and unique among the map’s cameras, since a render is asked for by name and a duplicate would make the shot ambiguous. The object’s pose is the camera’s: position is the eye position, and rotation aims the shot — pitch in the first slot (positive looks up, magnitude at most 90), yaw in the second.
{ "$type": "object", "id": "b783eabd-a4dd-4a45-87f6-59689fb74dca", "name": "skyline", "position": [-30, 26, 180], "rotation": [-14, 100, 0], "components": [ { "$type": "dh.camera", "fov": 60 } ] }Cameras carry their own name space, separate from the logic entities’ — they are never connection targets, so a camera and a door may share a name, but two cameras may not.
Every one of these is validated at compile time, never clamped: a blank or duplicate name, a position that is not three finite numbers, a rotation pitch past ±90, a fov outside (0, 179], an unknown projection, a missing or non-positive orthoSize under "orthographic", an orthoSize authored under "perspective", an unknown role, or a far that does not sit past near are all build errors.
Why the camera authors its own field of view
Section titled “Why the camera authors its own field of view”Everywhere else in the engine, FOV is the player’s comfort knob — client.fov, with an axis toggle and a speed-FOV offset on top, and a map pointedly has no say in it. A fixed camera is the opposite case. Its whole purpose is to frame the same pixels every time, and framing is exactly what the field of view decides. Inheriting the viewer’s preference would make the same named shot compose differently on two machines, which defeats the point of naming it.
It is vertical degrees, like every other FOV in the engine, so the shot frames the same content whatever aspect ratio it is rendered at. The default is 75 — a touch tighter than the client.fov default of 90, because a composed still wants less of the wide-angle spread a moving player wants.
The shape of the frame
Section titled “The shape of the frame”fov says how much the camera sees vertically; aspect says how wide the frame is beside it. The default "viewport" takes whatever the output happens to be, which is what a camera you fly to and look through wants. A stated w:h pair pins it, which is what a shot composed for one shape wants — the editor draws the frustum cone at exactly that ratio, so what the cone brackets is what a render writes.
The presets the editor offers are "16:9", "4:3" and "1:1", but nothing is closed: "21:9" and "2.39:1" are as legal as any of them. The one place the field is overruled is the "mapThumbnail" role, where thumbnailSize already states a shape and a second answer could only disagree with it.
An orthographic shot
Section titled “An orthographic shot”"projection": "orthographic" swaps the pinhole for a parallel projection. Nothing converges: two equal walls at different depths measure the same number of pixels, which is what makes a straight-down shot usable as a map rather than as a photograph of a map. orthoSize replaces fov as the framing control and is a half-height in meters, so 18 frames 36 meters vertically and as many horizontally as the aspect ratio buys. A fov alongside it is accepted and ignored rather than rejected, so a camera can be flipped between the two projections without losing its perspective framing.
A perspective frustum runs to infinity and never needs a far plane. A parallel one is a finite box, so an unstated far would be a picture of nothing. When an orthographic camera does not author one, the render derives it from the map bounds — the furthest piece of geometry along the view direction, plus a margin — and logs the number it chose. Author far yourself only to cut something out deliberately.
Two effects are perspective-only and switch themselves off for the shot, each saying so in the log: camera motion blur, whose velocity field is reconstructed through the near-plane algebra of a perspective depth buffer, and the Panini widening, which is a correction for a warp a parallel projection does not have.
The map’s thumbnail
Section titled “The map’s thumbnail”One camera in a map may set "role": "mapThumbnail", which makes it the shot every list of maps shows: the asset browser’s grid tile and the main menu’s map list both draw it. A map is a place, and a place is recognized by a picture of it long before its name is read.
{ "$type": "object", "id": "6c1f0e2b-9d4a-4a11-9f0d-2c33b2f7c001", "name": "postcard", "position": [-30, 26, 180], "rotation": [-14, 100, 0], "components": [ { "$type": "dh.camera", "fov": 60, "role": "mapThumbnail", "thumbnailSize": [1024, 1024] } ] }role is a word rather than a switch because the answer to “what is this camera for” is going to grow — a cubemap probe’s shot, a portal’s view, a security monitor’s feed are all the same authored viewpoint wearing a different job — and a second boolean beside the first would make two of those mutually exclusive by accident. The editor states it as a dropdown for the same reason.
What is in the picture is the map’s world content: its geometry and every visual entity standing in it — buttons, doors, lamps, pressure plates, crates — drawn in the rest pose an offscreen render always draws them in, lit by the map’s own look and bake. None of the editor’s furniture is: no selection outlines, no gizmos, no entity labels, no wiring lines, no HUD and no crosshair. That is the same content the camera card’s live preview shows, which is the point — the two go through one composer so the thumbnail cannot promise one picture and write another.
The photograph is a source asset. It is an ordinary PNG beside the map, at the map’s own name with its extension replaced — maps/atrium.dh-map is photographed to maps/atrium.thumbnail.png — and it is committed with the map like any other source file. A render writes that file, a person may open it in an image editor or replace it outright, and the build packs whatever is there. It is not collected as a texture of its own; the only thing that reads it is the map it is named after.
dh build packs those bytes into a .dh-mapthumb companion — a header carrying the format version, the size the PNG itself declares, and a hash of the map it ships with, over the PNG. The header describes what is actually packed rather than what the camera asked for, because a hand-replaced picture is one somebody chose at the size they chose. Nothing else in the pallet references it; it is found by its path.
The shot is taken by the build, which shells out to the engine to take it. Drawing needs a GPU and a compiler has no claim on one, so dh build launches the engine host the way it launches Blender for an FBX — the binary is named by enginePath — pointing it at the file to write:
dh render out --pallet <path> --map core:maps/render-eval.dh-map \ --captureMapThumbnails --thumbnailPng maps/render-eval.thumbnail.pngThe editor’s Capture thumbnail runs that same command, against its own binary, and writes the same PNG. A picture rendered at the camera’s authored size is not the shape of the window anybody is editing in, so the editor does not draw it in place either — it launches the render offscreen and toasts where the file landed. One command means the button and the build cannot disagree about what the camera sees.
One argument decides which camera that is. --thumbnailCamera <name-or-id> names a camera outright — by its name or by its id — and a named camera stands in for the role rather than being checked against it, so it need not carry "role": "mapThumbnail" at all. Only the editor’s camera card passes it, because there the camera is not a lookup, it is the thing on screen. A build never passes it: a compile that chose a camera of its own would write a picture the source does not describe. With no camera named and none designated the run exits 1 and says both ways out — give a camera the role, or press Capture thumbnail on a camera card.
The role stays singular. The editor’s Use for map thumbnail verb writes it on this camera and clears it from every other camera in the map in the same undoable edit, so the map never authors two answers to the question a build asks.
The render is content-addressed, so an unchanged map does not pay for one: the key folds the map document’s own hash — which already carries the camera’s pose and its thumbnailSize — with the size asked for and the container version. The engine binary is not part of it: rebuilding the engine does not photograph every map again. The PNG a build writes is recorded beside the built pallet (<id>.compiler-writes), so it does not make the pallet read as changed since its build, in the next dh build or in Studio; a PNG you replace does. An unchanged map leaves its PNG alone, so a retouched picture survives every rebuild, and packing the same PNG twice gives byte-identical entries.
A build without a GPU, or without an engine configured, still builds. Every failure walks down one rung rather than stopping:
- A capture, when the map asked for one and this machine can take it.
- Otherwise the last PNG captured, with a warning naming why there was no capture —
enginePathunset,enginePathnaming nothing, or the render’s own stderr tail. - Only when there has never been a photograph at all, a plain top-down plan of the box brushes, drawn on the CPU, with a note saying it was drawn rather than taken. It is deliberately plain: it exists so maps are distinguishable in a list on a machine that cannot photograph them, and the first real capture replaces it.
A missing or stale picture likewise never fails a load: the version or the hash disagreeing costs the picture and nothing else — the map opens, the tile falls back to its kind badge, and the client says so once in a toast rather than every frame.
autoCapture decides who rewrites the thumbnail. Left at true, a build re-renders it whenever the map changed, which is what a map whose picture is just “the place, roughly” wants. Set to false, no engine is started for that map at all: the build packs the PNG beside it exactly as it is, and the only thing that ever rewrites it is the editor’s own capture verb — which is what a shot somebody composed by hand wants, since the next build would otherwise quietly replace it.
thumbnailSize is the whole resolution story — the number lives in the map, beside the camera that frames it, rather than in a preference that would make the same map ship different pictures on two machines.
It defaults to a square, 512 × 512. Every surface that shows a map shows it in a square plate — the asset browser’s grid tile, the launch window’s row — so a wide default would ship a picture that is letterboxed into two empty bands everywhere it is looked at. A map that genuinely wants a wide shot states the pair, and it letterboxes into those same plates on purpose. Under this role the size is also what aspect would have said, so the editor’s frustum cone and the card’s ratio row read 1:1 for an unsized thumbnail camera.
A camera name can also be a backdrop
Section titled “A camera name can also be a backdrop”A render’s UI timeline can name a camera per frame, which turns a camera into something more than a viewpoint: it becomes the ground an interface surface is judged against. A scrim is an alpha, so a picture of one over a dark interior and the same scrim over a white wall are two different pieces of evidence, and the frame states which one it wants by naming the room.
The engine ships core:maps/render-eval for exactly that — four sealed single-value rooms and a row of material panels, each behind a camera named after it (white, gray, black, bloom, materials). Author order matters there for a second reason: white is first, so it is what --renderUi pins to and what the render-evaluation frames are gated on. See choosing the ground a frame is judged against.
dh.lightProbeVolume
Section titled “dh.lightProbeVolume”A box the indirect-light bake fills with probes: an object carrying a dh.lightProbeVolume. Authoring one or more of these replaces the derived whole-map grid with exactly the regions the map asked for, so a tall street canyon and a cramped interior can carry different densities instead of sharing one spacing stretched over the map’s total bounds.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
probeSpacing | float | no | inherited | Distance between adjacent probes inside this box, in meters, in 0.1–64. Omit it to inherit lighting.giVolume.probeSpacing. |
insetProbes | bool | no | true | Where the probes sit inside the box: at cell centers, half a spacing in from every face, or on the faces when false. |
bakeZone | string | no | default zone | The id of the bake zone whose sets this box’s probes switch with. Naming an id lighting.bakeZones lacks is a build error. |
dynamic | bool | no | false | Whether the box follows what carries it: parented under a door, directly or through anything the door carries, it rides along at runtime, probes and all. Off, the box stays where it was baked whatever its parents do. See Switching and moving a volume. |
Probes sit INSET by default, one per cell at the cell’s center — the layout VRC Light Volumes uses. A probe is then never buried in the wall a box is butted against, and never shared with a neighboring box’s face. Setting insetProbes to false puts them on the cell boundaries instead, so the outermost probes land exactly on the box’s faces.
The two layouts count differently, and the count is what a bake pays for: a 2 m box at a 1 m spacing holds 2³ = 8 inset probes and 3³ = 27 on its faces. Inset also keeps the authored spacing exactly, where the on-face layout stretches it slightly so the last probe reaches the far face.
The object’s transform IS the box. position is its center and scale is its full extents in meters — a light probe volume has no size field of its own, because a second representation of the same rectangle is a second thing to keep in sync. Every axis of scale must be positive.
{ "$type": "object", "id": "5f2f8a10-0f0e-4a45-9a6a-2c9d1c3f9b41", "name": "atrium", "position": [0, 6, -40], "scale": [48, 12, 64], "components": [ { "$type": "dh.lightProbeVolume", "probeSpacing": 2 } ] }rotation must stay [0, 0, 0], and a turned box is a build error rather than an approximation: probes land on an axis-aligned lattice, so a rotated box would bake somewhere other than where it is drawn. The editor’s inspector shows the rotation row on a light probe volume dead rather than absent, for the same reason — the field exists, it is simply not a thing a box gets to have.
Authoring boxes and configuring the bake are two different statements. The boxes say where probes go; lighting.giVolume says how they are gathered — samples, bounceAlbedo, intensity. The block is optional: boxes with no lighting.giVolume bake at the defaults in that table (probeSpacing 0.5, bounceAlbedo 0.5, samples 256, intensity 1), which is what a volume dropped in the editor gets. Write the block only to tune those numbers — or to set "enabled": false and switch the bake off while keeping the boxes.
Two more rules, both compile-time errors:
- At most 32 boxes. Every region is walked per fragment, so the cap is a frame-time budget rather than a storage one. Merge neighbors into one larger box.
bakeAmbientbeside probe boxes is allowed: the baked sky wins on lightmapped surfaces and the volume keeps lighting everything else, so nothing is counted twice (see Lightmap).
Once boxes exist, the derived grid stands down entirely: lighting.giVolume.padding grows bounds nobody derived, so it is ignored and the editor’s lighting window stops printing it. If the boxes’ combined probe count exceeds the total cap of 262,144 probes, every box is coarsened together rather than one of them being singled out, so their relative densities survive. The coarsening warning names the multiplier, the spacing the set landed on, and the box holding the largest share of the budget — that box is the one to raise probeSpacing on if you would rather choose the tradeoff yourself. A box too small to hold even a single probe is dropped with a diagnostic instead of failing the build. A box whose every probe baked inside geometry is a box in a wall: the bake keeps it with every probe invalid, so it lights nothing and the flat ambient stands, and says which corner it stands at.
Switching and moving a volume
Section titled “Switching and moving a volume”An object carrying a light probe volume is a logic entity a call can reach, by the object’s name. It takes the light’s switch, turnOn, turnOff and toggle, and two inputs that carry a value: setTint, an sRGB color [r, g, b] in [0, 1] its baked light is multiplied by, and setIntensity, one number it is scaled by. A volume switched off answers nothing, so whatever else covers the point (a coarser box around it, or the flat ambient) lights it instead. The server decides and every client follows, a player who joins later included; the volume replicates under its component’s wire id, and a client that predates it ignores it and keeps the volume lit as baked.
{ "output": "onPressed", "actions": [ { "call": { "target": "roomProbes", "input": "setTint", "value": [1, 0.4, 0.4] } }, { "call": { "target": "roomProbes", "input": "setIntensity", "value": 0.5 } }] }A box marked dynamic follows its parent chain with the same resolver every carried object uses, so one parented under a bouncing platform rides it up and down. Only its placement moves: the probes keep the light baked where the box was authored, and nothing rebakes. Rotation stays refused, a carried box included.
| Platform | turnOn / turnOff / toggle | setTint / setIntensity | dynamic |
|---|---|---|---|
| Engine, Windows desktop | ✅ | ✅ | ✅ |
| Engine, Linux and macOS desktop | ❔ | ❔ | ❔ |
| Engine, iOS | ❔ | ❔ | ❔ |
| Unity, BONELAB, Garry’s Mod and the other game platforms | ❌ | ❌ | ❌ |
dh.lightProbeGroup
Section titled “dh.lightProbeGroup”Probes at arbitrary points, with no lattice, read from a .dh-probegroup file beside the map by an object carrying a dh.lightProbeGroup. The file carries the probes and a partition of space into convex cells, each listing the probes it may read. A fragment walks the partition to its cell and blends that cell’s probes by inverse squared distance. An importer is the first writer: a platform’s own scattered ambient samples, carried with the cells they were lit in (a Source BSP leaf becomes a cell and its ambient samples its probes). Each probe holds the same order-2 harmonics a dh.lightProbeVolume probe holds, through the same shading path, wrap and highlight.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
probes | string | yes | — | The pallet-relative path of the group’s .dh-probegroup file, which ships byte for byte. A file that is missing, unreadable or another version is a build error. |
contents | "indirect" | "full" | no | "indirect" | What light the probes hold. indirect (sky and bounce, what an imported Source group holds) lights lightmapped surfaces wherever the lightmap does not already own the ambient or the bounce; full holds the lamps too, so a lightmapped surface never takes it. The file’s own holds-direct flag counts as full as well. |
bakeZone | string | no | default zone | The bake zone the group belongs to. An imported group carries the default set only and shows it in every set of its zone. |
bake | bool | no | false | Gather the group’s light again at a probe bake. Not built yet: a group with bake: true keeps the light its file carries and the build says so. |
dynamic | bool | no | false | Whether the group follows what carries it, as a volume’s does. |
{ "$type": "object", "id": "8a1c1b52-6c47-4d0e-a3f5-0b5b7c0f1e21", "name": "sourceAmbient", "components": [ { "$type": "dh.lightProbeGroup", "probes": "maps/gm_flatgrass.dh-probegroup", "contents": "indirect" } ] }The object’s transform is a translation only. position moves the probes; rotation and scale are build errors, because the probes belong to the geometry they were lit in. The editor refuses to move, turn or scale a group. One group a map, and it shares the 32-region cap with the volumes.
Where it applies. A box wins wherever it covers and its boundary fade crossfades into the group, not into the flat fill; the group wins over the flat ambient. Inside the group’s bounds a cell that lists a valid probe covers the fragment fully; one that lists none leaves the flat ambient standing.
The lookup is per pixel, at the shaded point moved client.render.giGroupNormalBias meters along the normal (default 2 inches), which pushes a wall’s face into its own room’s cell. Weights are 1 / (d² + s²) with s = client.render.giGroupSoftness (default one inch, so the default is Source’s own 1 / (d² + 1) in inches). A wall is never interpolated across: the cells end at it. Within one open room, a cell face in open air can show a seam, which is Source’s own lookup and the price of being leak-tight with no build-time ray work.
The walk starts close to its cell. At load the renderer lays a voxel grid over the group’s box (at most 524,288 voxels, a quarter meter or coarser) and records in each voxel the deepest partition node whose subtree holds the whole voxel, or the cell itself when one cell does. A lookup reads its voxel and walks on from there. Every point in a voxel takes the same branches from the root down to that node, so the cell is exactly the one the full walk finds, and most lookups are a single load instead of a walk up to the partition’s depth (23 levels on gm_construct). On the Steam Frame it took about 10 ms off gm_construct’s opaque pass at High.
Per leaf. client.render.giPerPixel off lights each cell as one, as Source lights a leaf: at load each cell’s list is weighted once from the cell’s center (the probes it owns, else its box, else the probes it lists), blended into one probe that rides behind the group’s own, and a pixel reads that one probe instead of blending the list. It costs one probe a pixel instead of up to 32, and shows a step where a surface crosses from one cell into the next. The quality presets turn it off below High. The leaf blend is weighted at the default client.render.giGroupSoftness; a live change to the softness reaches the per-pixel blend alone.
The file (DHPG, version 1, little-endian, meters and DH axes) is a 64-byte header (flags, the probe, cell, node and list counts, bounds, a source fingerprint), a provenance string, then the positions, the coefficients as binary16, a validity byte and an owning cell per probe, the cells (a range of the list each), optional cell boxes, the list, and the partition’s planes. The reader is strict: another version, an index out of range, a list over 32 probes or a partition deeper than 64 nodes is refused with a warning naming the file, and the map loads without the group. AmbientCube.ToShL2 in the content layer is how an importer turns six-face ambient cubes into the probes’ harmonics.
Logic. A group takes a volume’s own inputs, turnOn, turnOff, toggle, setTint and setIntensity, and replicates as a volume does.
In the editor, with the probe preview up, the group draws a dot at each probe, nearest the eye first within its share of the preview’s budget. A click within client.editor.giProbes.pickPx pixels of a dot selects the group and reads that probe: its index, position and cell, and its diffuse radiance toward the six axes, in the log and a toast. The picked probe’s cell is boxed and a line runs to every probe its cell lists. Nothing about a probe is editable: imported light is source content.
| Platform | Group lighting | Logic inputs | Editor dots and pick |
|---|---|---|---|
| Engine, Windows desktop | ✅ | ✅ | 🟡 the reading goes to the log and a toast; the inspector card is not built yet |
| Engine, Linux and macOS desktop | ❔ | ❔ | ❔ |
| Engine, iOS | ❔ | ❔ | ❌ |
| Unity, BONELAB, Garry’s Mod and the other game platforms | ❌ | ❌ | ❌ |
dh.reflectionProbe
Section titled “dh.reflectionProbe”A box a mirror-like surface asks what the room looks like: an object carrying a dh.reflectionProbe, named by the object’s name. The lightmap bake already answers that for diffuse light; a reflection probe answers it for the specular half, by capturing a cubemap from a point inside the box and prefiltering it into a roughness chain the shader reads by lobe width.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
capturePoint | [x, y, z] | no | box center | Where the cube is captured, as an offset in meters from position. An offset rather than a world point so moving the box carries its viewpoint along. |
boxProjection | bool | no | false | Parallax-correct the lookup against the box, so a reflection slides correctly along a wall as the camera moves. Opt-in, because it assumes the room really is the box. |
resolution | int | no | 128 | Cube face size in pixels: one of 64, 128, 256, 512. |
importance | int | no | 0 | Overlap tie-break. The highest importance wins outright, whatever the distances are — which is how a shop-window probe beats the district probe it sits inside. |
blendDistance | float | no | 1 | How far inside the box the probe’s weight eases in from zero, in meters over 0–64. A surface spanning two rooms cross-fades instead of snapping. |
projection | string | no | box | What the probe is projected onto. box: its transform is a box, and the surfaces inside it reflect it, blended per pixel. none: Source’s env_cubemap, a point with no box at position (plus capturePoint), reflected only by the surfaces bound to it and always along the plain mirror direction, so it takes no boxProjection. |
The object’s transform IS the box, exactly as it is for a dh.lightProbeVolume: position is the center, scale is the full extents in meters, and there is no size field.
{ "$type": "object", "id": "d2b71f08-64ac-4e15-9b30-8c5f1a7e6d30", "name": "atrium", "position": [0, 2.5, 0], "scale": [12, 6, 12], "components": [ { "$type": "dh.reflectionProbe", "capturePoint": [0, -0.6, 3], "boxProjection": true, "resolution": 128 } ] }At most 1024 probes per map, Source’s own MAX_MAP_CUBEMAPSAMPLES. Every probe is resident for as long as the map is loaded, in one cube array, so which probe a surface reflects never depends on where the camera stands. The player’s client.render.reflectionProbes (default 1024, 0 mirrors the sky alone) caps how many are kept, for memory only and in the map’s own order; the device’s cube-array layer limit caps it too (2048 layers, 341 cubes, on an RTX 4080), and the probes past either are counted in the log. A 64-pixel probe costs 0.25 MiB and a 128-pixel one 1 MiB, with its prefiltered chain; the cube array has one edge, so the engine takes the widest any probe was baked at, up to client.render.reflectionProbeMaxEdge (default 128, applies on the next load), and resamples the rest to it with their roughness chains rebuilt. A mixed set is never cut down to the probes that agree, and the log says how many were resampled from which sizes.
Which probe a surface reads. A probe box holding the pixel wins, blended per pixel by importance and blendDistance as above. Elsewhere the surface reads the probe its draw is bound to, the way vbsp binds a brush face to one env_cubemap at compile time and a static prop reflects the one nearest its lighting origin:
- a part’s own
dh.renderernaming it inreflectionProbe, by its object’sname; - else the
dh.rendereron the root of the part’s record, such as aninstance’s; - else a primitive naming it in its glTF extras,
"extras": { "dh": { "reflectionProbe": "cubemap14" } }(a face bound to another probe is another draw, even under the same material), the world model’s and an object’s model’s alike; - otherwise the probe with
projection: nonewhose capture point is nearest the draw: a sub-mesh’s bounds center, an object’s origin.
A surface neither holds nor is bound to mirrors the sky. A probe with a box is never a draw’s binding, so a map authored with boxes reads exactly as it did.
The cubes are baked in the editor, on the lightmap’s terms, and live beside the map source as <map>.dh-map.dh-reflcook. dh build packages that file unchanged as the map’s companion and never bakes. Three ways make one, and all three photograph the probes through the engine on this machine’s GPU, so a cube shows the map’s real textures, transparency and emissive surfaces, the lightmap and the look — the same pictures the world is drawn with:
- Map ▸ Bake all reflections in the level editor, or a probe card’s Bake for the selected probes. The cubes go onto the running world at once and the file is written beside the source.
- The
reflection.bakeconsole command, which is Bake all reflections. DigitalHeaven.Engine.Host --bakeReflections --map <barcode>headless (--mapSource <path>names the source to write beside;--palletan unpublished pallet). With--render <dir>it bakes first and photographs the map wearing the fresh cubes, which is the same picture a build of that file gives.
Map ▸ Clear all reflections drops the cubes from the running world and deletes the file, so the next build ships probes that mirror the sky.
A missing or stale bake degrades: the build warns and the probes mirror the sky, never a failed build or a refused load. A bake is stale when the probes it was taken for no longer match the map — a box moved, a resolution changed, a probe was added or removed — or when the map’s geometry is not the geometry it was baked in (the bake records the geometry’s hash, like the lightmap). An edit to a light, a brush or a material does not stale it; bake again after one.
A face is rendered square at the probe’s own resolution, whatever size the window or the run is, and a capture binds no probes, so a cube can never contain itself and the same map baked twice writes byte-identical files. The editor’s bake is taken of the world on screen, unsaved edits included; the file describes the map only once it is saved, so a bake of an unsaved box is on the GPU at once and picked up by a build after the save.
| Platform | Bake in the editor | --bakeReflections | Loads a packaged bake |
|---|---|---|---|
| Engine, Windows desktop | ✅ | ✅ | ✅ |
| Engine, Linux and macOS desktop | ❔ | ❔ | ❔ |
| Engine, iOS | ❔ | ❌ | ❔ |
| Unity, BONELAB, Garry’s Mod and the other game platforms | ❌ | ❌ | ❌ |
The game platforms never load a dh.map, so a probe means nothing there.
Logic entities
Section titled “Logic entities”Logic entities give a map behavior. Each carries a map-unique name and a set of outputs; you wire an output to an ordered action list that runs when the output fires. This is the Source/EntityIO model: entities fire events, and those events drive inputs on other entities. Everything is server-authoritative — the server owns the run-state and replicates only the resulting visual state to clients.
Connections and the action list
Section titled “Connections and the action list”An object’s connections array binds one of its outputs to an action list. It is an object field rather than a logic-kind field — a dh.trigger carries exactly this shape — but a logic entity is what a call can TARGET, because a target is addressed by its map-unique name and only a logic component declares inputs:
"connections": [ { "output": "onPressed", "actions": [ { "call": { "target": "mainDoor", "input": "open" } }, { "delay": { "seconds": 1.5 } }, { "call": { "target": "lamp", "input": "turnOn" } } ] }]An action list runs top-to-bottom with serial, blocking semantics. There are three action kinds, discriminated by their single wrapper key:
| Action | Shape | Behavior |
|---|---|---|
call | { "target": name, "input": input, "param"?: bool, "value"?: number | [r, g, b] } | Invokes input on the entity named target, passing a boolean param to an input that takes a level, or a value to one that takes a number (a bare number) or a color (three sRGB values in [0, 1]). Runs immediately, then the list continues. |
delay | { "seconds": float } | Blocks the rest of the list for seconds (quantized up to whole simulation ticks, so it is timescale-correct), then the following actions run. |
setBakeSet | { "zone": id, "set": name, "fade"?: float } | Switches a bake zone’s baked lighting to one of its sets (default included), crossfading over fade seconds (0 by default, at once). Runs immediately; the server holds the switch and every client follows. Switching to the set a zone already shows does nothing. The zone and the set must be ones the map declares, or the build refuses the wire. |
Several connections may share one output name; each contributes its own independent run when the output fires. The activator (the player who pressed a button, say) flows through a whole run, including across a timer’s delay, so a downstream action knows who set it off. Re-entrant chains are bounded by a fixed call-depth cap (8), so a wiring cycle can never stack-overflow the server.
Both ends of every connection are validated at compile time: the target must name an entity in the map, the input must be one a component of the target declares (qualified when it says which), a boolean param must be present exactly when the input expects one, and so must a value of the right shape. A dangling or mistyped wire is a build error, not a runtime surprise.
The same rules refuse a wire in the level editor, in the same words. The build and the editor call one validator, so a wire the wiring card accepts is a wire the build accepts — an editor that could write a map its own compiler then rejects would be a worse tool than a text editor. Rewiring in a session is a real edit: scene.wire restates one object’s whole connection list, the running logic graph obeys it on the next press with no reload, and Save writes the array back into this file.
Entity kinds
Section titled “Entity kinds”A logic entity is an object carrying a logic component, addressed by its object’s name. The runtime builds one node per object and one behavior per logic component on it, from a table keyed by the component’s type id, so an object carrying a dh.button and a dh.mover is one node that a press and a move both belong to. The presentation components with a logic node (dh.light, dh.lightProbeVolume, dh.lightProbeGroup, dh.voxelVolume) join the same node beside them. An instance of an object whose root carries logic components is a logic entity the same way: its node is built from the components the loader resolved onto it, every logic lookup (its body, its replica kind, its pressable and moving box) reads those, and its name and connections address and wire it like any other object’s, so onPressed on an instance can call toggle on the instance’s own name. Each placed copy is its own node, so pressing one moves only that copy.
A node can come and go while the map runs, for a placement streamed in by distance. While its object is absent, a call naming it does nothing and logs nothing; once the object is back, the call reaches it, and it starts from the state its record authors. See netcode → Logic runtime.
| Component | Body | Inputs | Outputs | Notes |
|---|---|---|---|---|
dh.button | static | (interacted with Use/E) | onPressed | Pressing fires onPressed with the presser as activator; a short per-button cooldown debounces rapid presses. Replicates a pressed-latch bit for client feedback. |
dh.mover | kinematic | open, close, toggle | onOpened, onClosed | Slides from closed to open over duration seconds along direction for distance m (or turns by angle, or spins endlessly at rate), with a real velocity, so a player standing on it rides it. The open fraction and its rate replicate, so clients interpolate the slide and predict it. Everything under it in the hierarchy rides along. A mover wired to reverse itself at each end is a bouncing platform. On a box, it is a door. |
dh.pressurePlate | sensor | (none) | onStartTouch, onEndTouch | A proximity trigger with no Use key: the server checks capsule-vs-box overlap against player pawns every tick and fires on the occupancy edge — onStartTouch when occupancy goes 0→1, onEndTouch when it goes back to 0 — so additional overlapping touchers never re-fire either output. The activator is the pawn that caused the edge. Occupancy count replicates for gizmo/inspector display. |
dh.trigger | sensor | enable, disable, toggle | onStartTouch, onEndTouch | A volume that is on or off. While on, the server checks capsule-vs-box overlap against player pawns every tick and fires onStartTouch for each pawn that enters and onEndTouch for each that leaves, with that pawn as the activator. Enabling counts whoever is already inside as entering; disabling forgets them without an onEndTouch. Nothing about it replicates. |
dh.mapStart | none | (none) | onMapStart | Fires onMapStart once, on the map’s first logic tick, and never again (Source’s logic_auto). One switched off on that tick misses it. |
dh.teleport | none | teleport | (none) | Moves the activator to the destination object, applying the landmark, facing and velocity rules. Nothing about it replicates. |
dh.timer | none | fire | onTimer | fire waits delaySeconds, then fires onTimer preserving the activator. Overlapping fires schedule independently. |
dh.andGate / dh.orGate | none | setA, setB | onTrue, onFalse | Boolean levels; fires only on an edge of the computed level (A AND B / A OR B). Re-writing the same level never re-fires. |
dh.notGate | none | setA | onTrue, onFalse | Boolean; edge of NOT A. |
dh.physicsProp | dynamic | (none) | (none) | A rigid body the solver owns: it falls, tumbles, slides by its surface’s grip and comes to rest, and a player walking into it shoves it. Its pose replicates, or with "sim": "client" each client runs its own. See Physics props below. |
dh.light | its box’s collider | turnOn, turnOff, toggle | (none) | A real analytic light and, when its object also carries a dh.box, a fixture that swaps between the box’s material and materialOff. Its on/off state replicates as one bit, which drives both halves — see dh.light below. |
dh.lightProbeVolume, dh.lightProbeGroup | none | turnOn, turnOff, toggle, setTint (color), setIntensity (number) | (none) | Switches and grades the box’s baked light, and a dynamic one rides whatever carries it. See Switching and moving a volume. |
instance | depends on the object | (none) | (none) | A placement of a dh.object: a source barcode, a pose, and a delta of departures from it. A rigid body is one of the things an object can carry, so a placed object can be a crate too. See instance below. An instance of a model is the map’s world instead, and is not a logic record. |
dh.voxelVolume | none | (none) | (none) | A placement of a dh.voxel: a container barcode and a delta on an object whose pose places it. The whole container, a Minecraft world included, is this one node. See dh.voxelVolume below. |
Every logic entity is an object, so every one of them carries the shared fields — id, name, parent and a transform. What a button, a door or a plate looks like is its object’s dh.box: its size and its material, drawn by the logic’s visual rather than the static batch. A timer, a map start and the gates have a transform like everything else, but nothing reads it.
{ "$type": "object", "id": "abf7be4d-39ee-446d-b516-d75f5e630959", "name": "doorButton", "position": [-3, 1, 3], "components": [ { "$type": "dh.box", "size": [0.5, 0.5, 0.3], "material": "button" }, { "$type": "dh.button" } ], "connections": [ { "output": "onPressed", "actions": [ { "call": { "target": "slidingDoor", "input": "toggle" } } ] } ]},{ "$type": "object", "id": "83b16719-721a-41b9-a6da-95ffa14c09c8", "name": "slidingDoor", "position": [-3, 1, 7], "components": [ { "$type": "dh.box", "size": [2, 2, 0.3], "material": "door" }, { "$type": "dh.mover", "direction": [0, 1, 0], "distance": 2.2, "duration": 1.2 } ] }Bodies
Section titled “Bodies”A button, a mover, a plate, a trigger and a physics prop take their body from their object’s dh.collider, which must hold exactly one box shape; that collider is the logic’s, and the map’s static install leaves it alone. A box with no collider of its own is given the exact one over its box by the compiler, as every box is, so the usual object states only its dh.box. What body it becomes is one table, read by the server that makes it, the client that mirrors it into its prediction world and the static install alike: a mover’s is kinematic (and outranks the rest, since what moves carries whatever else the object is), a button’s static, a plate’s or a trigger’s a sensor that measures overlap and blocks nothing, and a physics prop’s dynamic, the solver’s alone. A collider holding two shapes, a sphere, or no body at all is refused by name ('dh.mover' needs exactly one shape in its object's 'dh.collider', which holds 2). The one exception is a mover with nothing of its own to stand as, which is valid while something is parented under it.
dh.button
Section titled “dh.button”The interactable. It has no fields: Use on its object fires onPressed, and a replicated latch draws the press as the box depressing along its facing.
dh.mover
Section titled “dh.mover”A generic mover: whatever object it sits on moves, so a door is a dh.mover on an object with a dh.box, and there is no door component.
A mover may have no body of its own, neither a dh.box nor a dh.collider, when something rides it: any object whose parent names it, or a model part’s override in the world instance’s children with the same parent. What it moves is what it carries (see Riding a door), and it still runs its motion and fires onOpened and onClosed. It makes no body on the server or in prediction and draws nothing; the editor shows it as a gizmo. This is the shape the Source import writes for a func_door: a bodiless mover at the entity origin, with the door’s brush under it, which opens with it. A func_door_rotating is the same with a rotate motion, and a prop_door_rotating a rotating mover at the prop’s origin with the prop’s instance under it. A bodiless mover with nothing under it is a build error ('dh.mover' has no body and carries nothing), and a button or a plate on the same object still needs its body.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
motion | string | no | "slide" | How it moves between closed and open. slide is a straight line, rotate a turn about the object’s origin, and spin an endless turn about it; anything else is a build error listing the motions. |
direction | [x, y, z] | no | [0, 1, 0] | A slide’s direction, in world axes, normalized when read; a zero direction is a build error. Read by slide alone. |
distance | float | no | 2 | A slide’s travel in meters from closed to fully open; must be above zero. Read by slide alone. |
axis | [x, y, z] | no | [0, 1, 0] | A rotate’s axis in the mover object’s own frame, so a door turned on its object swings about the axis it turned with; normalized when read, and a zero axis is a build error. Read by rotate and spin. |
angle | float | no | 90 | A rotate’s turn in degrees from closed to fully open, about the object’s origin (the hinge: the Source entity’s origin, which is why the importer places the object there); negative turns the other way and zero is a build error. Read by rotate alone. |
rate | float | no | 90 | A spin’s speed in degrees a second once up to speed, about axis and the object’s origin; negative turns the other way and zero is a build error. Read by spin alone. |
duration | float | no | 1 | Seconds one full sweep takes; for a spin, the seconds it takes to reach its rate from rest and to stop again. Must be above zero. |
startOpen | bool | no | false | Spawns fully open: the object’s authored pose is still the closed one, and the mover stands one full sweep along from it (a slide’s distance, a rotate’s angle) with its open fraction at 1, replicated like any other state, so the first close or toggle closes it and nothing fires on spawning. A spin ignores it. |
A spin is for a fan, a wheel or a turntable: open starts it, close stops it, toggle does either, each over duration seconds with no snap, and onOpened fires on reaching its rate and onClosed on coming to rest. Whether it spins from the start is the ordinary convention for a mover that starts open: a dh.mapStart wired to its open. Its angle is a pure function of the shared tick and the start and stop events, wrapped so hours of turning lose no precision, so the server, a client’s prediction and a render all read the same angle; the replicated section carries the angle, the speed, the change of speed and the speed it heads for. Everything it carries turns with it, a pawn standing on it included. A --spinAt render poses one at a chosen time (offscreen rendering).
The level editor’s inspector card for a mover ends in Open, Close and Toggle buttons and prints the mover’s live state, so any door can be tried without wiring a button to it. The card states those buttons as the component’s own verb buttons, naming the same three inputs the wiring table lists.
The authored pose is the closed one. onOpened fires on arriving fully open and onClosed on arriving closed (a door wrote these onFullyOpen and onFullyClosed, and its fields moveDirection, moveDistance and moveDurationSeconds).
dh.pressurePlate
Section titled “dh.pressurePlate”A non-solid detector with no fields. Its volume is its body’s box, so a plate cannot be stood on or bumped into, and anything carrying it carries the volume too.
dh.trigger
Section titled “dh.trigger”The one way a volume fires touch edges. Its volume is its body’s box, like a plate’s, so it cannot be stood on or bumped into; its owner’s dh.collider must hold exactly one box shape. A collider whose state is "trigger" with no dh.trigger beside it fires nothing.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | bool | no | true | Whether it starts on. An off trigger is not measured at all until an enable or a toggle turns it on. |
touchedBy | string | no | "players" | Who sets it off: players, props or everything. Only players are measured today; a trigger for props alone sees no player, and a prop is measured once physics props can touch triggers. |
onStartTouch and onEndTouch fire per toucher, where a plate fires on its occupancy going 0 to 1 and back, and the toucher is the activator of everything the wire runs. A zone that lets the first through and then switches itself off wires onStartTouch to its own disable.
enableturns it on. Whoever is already inside counts as entering on the next scan, so a volume enabled around a player standing in it fires for that player.disableturns it off and forgets who was inside, with noonEndTouch. Enabling it again finds them standing there and fires for them again. This is Source’sDisable; the editor’s object switch is the other thing, and drains a trigger like a plate, firingonEndTouchfor whoever was inside.toggleflips it.
{ "$type": "object", "id": "6b1f0e42-3ac9-4d58-9e07-2f5b8c1a4d33", "name": "lobbyPad", "position": [0, 0.05, 6], "components": [ { "$type": "dh.collider", "state": "trigger", "shapes": [ { "type": "box", "halfExtents": [1.5, 0.05, 1.5] } ] }, { "$type": "dh.trigger", "enabled": false } ], "connections": [ { "output": "onStartTouch", "actions": [ { "call": { "target": "lobbyDoor", "input": "open" } } ] }, { "output": "onEndTouch", "actions": [ { "delay": { "seconds": 2 } }, { "call": { "target": "lobbyDoor", "input": "close" } } ] } ] }dh.teleport
Section titled “dh.teleport”Moves whoever set the run off, the activator, to where an object stands. It has one input, teleport, and no outputs, so anything that fires an output can teleport: a trigger’s touch, a button, a timer, a map start. A trigger teleporter is one object carrying a trigger collider, a dh.trigger and a dh.teleport, with onStartTouch wired to its own teleport. A button teleporter is a dh.button wired to a dh.teleport standing at the destination.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
destination | string | no | empty | The name of the object it arrives at. Its position is the feet, read where it stands at the moment of the teleport (a destination on a mover moves with it). Empty means this object. |
landmark | string | no | empty | The name of an object the subject keeps its offset from. The offset is carried into the destination’s frame, turning with the change from the landmark to the destination. Empty means no offset is kept. |
facing | string | no | "destination" | destination takes the destination’s yaw and pitch, keep leaves the subject’s own view, turn turns it by the frame change. |
velocity | string | no | "keep" | keep leaves the subject’s velocity as it is in world space (Source’s rule), zero stops it, turn turns it by the frame change. |
subject | string | no | empty | Who moves. Empty is the activator; a name is that object. A named subject is reserved for physics props, which teleport once props can touch triggers. |
The frame change is the turn from the source frame, the landmark or else the teleporter’s own object, to the destination. turn on both facing and velocity is the seamless, portal-like corridor.
- No activator, no teleport. A
teleportreached from adh.mapStart, or from a timer nothing started, does nothing and logs at debug. - A missing destination drops the call with a warning; the build refuses a name that matches no object.
- Stance is kept. Arriving clears the ground and keeps a crouch.
- Chains are allowed. A destination inside another enabled teleporter sends the subject on at the next tick. The build warns when a destination lies inside the teleporter’s own trigger volume, which is a loop on every tick.
- The editor wins. A pawn the editor carries is not moved; a frozen pawn is moved and stays frozen.
{ "$type": "object", "id": "c0a5a1de-4f2b-4c0e-8e54-1d1b0b7f9a10", "name": "booth", "position": [0, 1, 0], "components": [ { "$type": "dh.collider", "state": "trigger", "shapes": [ { "type": "box", "halfExtents": [1, 1, 1] } ] }, { "$type": "dh.trigger" }, { "$type": "dh.teleport", "destination": "boothExit", "velocity": "zero" } ], "connections": [ { "output": "onStartTouch", "actions": [ { "call": { "target": "booth", "input": "teleport" } } ] } ] }dh.timer
Section titled “dh.timer”| Field | Type | Required | Default | Description |
|---|---|---|---|---|
delaySeconds | float | no | 1 | Seconds between a fire and its onTimer. |
dh.mapStart
Section titled “dh.mapStart”No fields: onMapStart, once.
The gates
Section titled “The gates”dh.andGate, dh.orGate and dh.notGate have no fields; each holds its levels and fires on an edge of its result.
Signal naming
Section titled “Signal naming”A wire spells a signal plain (open), or qualified by the component that declares it, named by its type id without dh. (mover.open). A call whose target is door and whose input is mover.open is the signal door.mover.open.
- A plain input reaches every component on the target that declares it, so
lamp.toggleon an object carrying a light and a mover toggles both, andlamp.mover.togglereaches the mover alone. - A plain output is valid only when one component on the object declares it. An object carrying an and gate and an or gate declares
onTruetwice, so a wire ononTrueis refused with the qualified forms named (Qualify it with the component that fires it: 'andGate.onTrue' or 'orGate.onTrue'), andorGate.onTruefires on the or gate’s edge alone. - A qualified name that names a component the object does not carry, or a signal that component does not declare, is refused like any other unknown signal.
The build, the editor’s wiring card and the running graph read the same rule, so the editor offers a qualified output exactly where the build would ask for one.
Riding a door
Section titled “Riding a door”Everything under a mover in the hierarchy rides it, by any path: through a folder, through a box, through a plate or a button. Every tick it stands at its authored pose carried by everything above it, so a lamp on a plate in a folder on a lift goes up with the lift, and a door riding a door adds both. The carry is a whole transform composed down the parent chain from each mover’s own motion: its slide, or its turn about its origin, and the editor’s move and turn of it. A rotate carries everything under it about its object’s origin, so a door’s box, its hulls and a lamp hung on it swing together.
What a mover carries:
- Boxes. A plain
dh.boxunder a mover is live: it leaves the static box batch and is drawn at its carried pose, so a crate parented to a door rides it. In the editor it is a live object rather than map geometry, picked and outlined where it stands, and it is added or removed in the source with its parent. - Colliders. Every
dh.colliderunder a mover that is not a logic component’s body is built as kinematic bodies and moved by the carry each tick, with a real velocity, so a player standing on a carried box rides it as on the door itself. A logic body under a mover (a button’s, a plate’s volume) is moved the same way. Switching the owner off, or its collider off or to a trigger, takes its bodies away; a collider shape edit reaches them on the next load. - Model parts. A part of a model instance whose override names the mover (or anything it carries) as its
parentrides it. The part leaves the static draw and is drawn every frame at the carry, wearing the lightmap texels it was baked with at its closed pose, so a door spends its life lit as it was baked and takes its light with it as it opens. Each shape of itsdh.collideris a kinematic body on the server and in prediction: a hull is a hull, and ameshshape stands as its cooked triangles, from the same blob the static install uses. A player standing on a part rides it. The part’s lights and records follow it like any other. - Lights. A carried light’s position, and its aim, follow the carry.
- Edits. Moving or turning a mover in the editor takes everything it carries with it, and a carried object’s own move and turn compose under the carry, bodies included. A model instance moved or turned in the editor takes its parts with it: each part stands under its own edit and then the instance’s.
The server, every client’s prediction, the visuals and the editor’s gizmo all compose the carry with one resolver from the movers’ replicated state and the authored hierarchy, so a late joiner and the iPad stand on the same bodies with nothing more on the wire. A physics prop is neither carried nor carries: the solver owns its pose, it rides a door by friction, and what is parented to it keeps its authored pose. A light probe volume rides only when it is marked dynamic, because its probes hold the light of the place they were baked. A model part, and an instance, parented under a mover are carried like the rest, and the build has nothing to say about them.
{ "$type": "object", "id": "d620412a-45f8-4552-a3bb-f26ed646a774", "name": "liftPlatform", "position": [4, 0.1, -9], "components": [ { "$type": "dh.box", "size": [3, 0.2, 3], "material": "door" }, { "$type": "dh.mover", "direction": [0, 1, 0], "distance": 2.5, "duration": 2.5 } ] },{ "$type": "object", "id": "5d3ee3d8-df8d-4499-9c58-8203745e3cdf", "name": "liftPlate", "parent": "d620412a-45f8-4552-a3bb-f26ed646a774", "position": [4, 0.25, -9], "components": [ { "$type": "dh.box", "size": [1.4, 0.1, 1.4], "material": "plate" }, { "$type": "dh.pressurePlate" } ], "connections": [ { "output": "onStartTouch", "actions": [ { "call": { "target": "liftPlatform", "input": "open" } } ] }, { "output": "onEndTouch", "actions": [ { "call": { "target": "liftPlatform", "input": "close" } } ] } ] }The engine’s core pallet ships a logic-map demonstrating the whole model: a button that toggles a sliding door, and a second button wired through a dh.timer and a dh.orGate to switch a lamp (an object carrying a dh.light and its fixture’s dh.box) on — and the lamp really lights, flooding the surrounding floor and walls a second after the button is pressed. Three crates sit between the two halves — placements of core:objects/physicsCube.dh-obj — two on the floor to walk into and shove around, and a third dropped in tilted from above so it falls, lands and settles. Behind the spawn stand two platforms to ride: one a dh.mapStart sets bouncing from the moment the map loads, and the lift above, whose plate rides it so the rider stays on it all the way up and sends it home by stepping off. When the server logs at debug level, every firing is traced (doorButton fired onPressed -> slidingDoor.toggle).
instance
Section titled “instance”An instance places a dh.object in the map. It is the third of that type’s three slots — the same record a variant’s inherits and an object’s added record are, written a third way — and it is not flattened at load: the placement stands in the world as a node that still knows what it came from, with the object’s own pieces under it.
| Field | Type | Required | Meaning |
|---|---|---|---|
source | string | yes | What is placed. An object’s reference (objects/name.dh-obj or <palletId>:objects/name.dh-obj; an avatar is an object, so a .dh-avatar is a source too), or a model: a .glb, an .fbx or a folder of .glb pieces, which makes the record the map’s world instance. The extension decides which. |
components | array | no | An object placement’s components on its root, over its source’s: what the object takes on its root. |
children | array | no | Overrides of the source’s parts. A model instance’s are its part overrides; an object placement’s are the object’s part overrides, by the part’s path, and an id beside it must be one the object recorded. |
It carries the shared object fields too — id, name, parent, position, rotation, scale, enabled. The old objectInstance type and its object field are not read: object on an instance is a build error that names source.
What stands in the world is the object. An instance whose object carries a physics body is that body; one that carries none stands as the object’s own model, drawn and posed like a spawned asset — which is also what the editor’s Make Persistent writes.
The pose replaces the source root’s own transform, it does not compose with it: every placement discards whatever numbers the object’s root carried, which is why an object that poses its own root gets a compiler warning telling it to pose the node under the root instead.
Its components and children are one layer of overrides by the one merge rule. Every field they do not name inherits from the source object, so an instance’s file says exactly what it departs in and nothing else — change the object and every placement of it follows, except where the placement said otherwise. "removed": true on a component drops one the source states (a crate placed as scenery that should not fall over), and on a part’s override the part.
Which probe a placement reflects is the reflectionProbe of its root dh.renderer: the name of the object carrying the map’s reflection probe, as a Source static prop reflects the env_cubemap nearest its lighting origin. A part’s own dh.renderer may name another. Omitted, it reflects the probe with no box nearest where it stands. A name that is not exactly one probe of the map is a build error.
{ "$type": "instance", "id": "0c3a5e7d-41b2-4f6a-9d8e-2b7c1f0a6e35", "name": "bench", "source": "props/bench.dh-obj", "position": [3, 0, -2], "components": [ { "$type": "dh.renderer", "reflectionProbe": "cubemap14" } ] }dh.voxelVolume
Section titled “dh.voxelVolume”An object carrying a dh.voxelVolume places a dh.voxel container in the map, the voxel counterpart of an object instance. The container is one node: it moves, turns and scales as one, and its chunks are storage, never objects in the hierarchy.
| Field | Type | Required | Meaning |
|---|---|---|---|
volume | string | yes | The container’s reference: voxels/name.dh-vox or <palletId>:voxels/name.dh-vox. It must be a dh.voxel; anything else is a build error |
lighting | string | no | How this placement is lit: dh (DH’s own lighting), hybrid (DH’s sun and shadows, with the volume’s sky light gating the ambient and its block light filling) or minecraft (not built yet, shaded as hybrid). Left out, the container’s lighting, else its platform’s default: hybrid for Minecraft, dh for everything else. Any other value is a build error. See lighting modes |
delta | object | no | This placement’s departures from the container. Its field set is closed |
delta.grids | { string: bool } | no | Shows (true) or hides (false) a grid, by its name or as #<index>. It overrides the container’s own hidden flag, so { "minecraft:the_nether": true } shows a world’s nether. A grid under a hidden grid stays hidden |
Those are the component’s fields; the object carries the shared object fields: id, name, parent, position, rotation, scale, enabled. Its pose places the container’s origin cell, and the container’s own voxelSize and axes turn its cells into meters under it. It has no inputs and no outputs.
A volume is solid: the chunks near each player become static mesh bodies on the server and in the client’s prediction alike, and a block’s solidity comes from its platform’s block table, so water and flowers are passable and glass, leaves and barriers are not. Turning a volume off or deleting it in the editor takes its bodies away; moving, turning or scaling it cooks them again where it now stands. The details are on the voxel page.
{ "$type": "object", "id": "6a0c27e1-0000-4000-8000-000000000010", "name": "village", "position": [-4, 0, -14], "rotation": [0, 90, 0], "scale": [2, 2, 2], "components": [ { "$type": "dh.voxelVolume", "volume": "/voxels/village.dh-vox", "delta": { "grids": { "minecraft:the_nether": true } } } ] }Replication. A volume replicates under its component’s wire id, its pose, rotation and scale riding the object’s transform; what it is never rides the wire, because both ends loaded the same map and the client reads the container and the delta off its own copy. An editor-created volume reaches every client through the same created-entity message a light does, its barcode in the message’s trailing section, and a joining client is sent the container’s pallet with the map’s dependencies.
How the engine draws and streams a volume is on the voxel page.
Physics props
Section titled “Physics props”A physics body is a component, dh.physicsProp, on an object with a dh.box. The box is what it looks like and what its material weighs; its dh.collider’s one box shape is its body (the compiler gives a box with no collider the exact one, so the usual crate states only its box); and the component says how heavy it is and which machine simulates it. The Add menu’s Logic → Physics Prop makes one: a 0.5 m crate in the map’s first material.
{ "$type": "object", "id": "264da720-8d6a-4ed3-8502-a8b0fe94778c", "name": "crate", "position": [0, 4, 5], "rotation": [12, 30, 8], "components": [ { "$type": "dh.box", "size": [0.8, 0.8, 0.8], "material": "crate" }, { "$type": "dh.physicsProp", "mass": 30 } ] }| Field | Type | Required | Default | Description |
|---|---|---|---|---|
mass | float | no | (from the material) | Kilograms, at least 0.001. Without one it weighs its box material’s surface density times the body’s volume, and 35 kg where the material names no density. |
sim | string | no | "server" | Which machine runs the body: server or client (see below). |
From there the server’s rigid-body solver does the rest: it falls under the world’s gravity, lands on geometry and other bodies, tumbles, slides, and finally comes to rest and goes to sleep. The object’s position and rotation are where it starts. The body is the solver’s alone, so each of these is a build error that says so: a prop with no dh.box, a collider that is not exactly one box shape, a box shape moved or turned off the object’s origin (the solver poses the object by its body), and a prop sharing its object with a dh.mover, a dh.button or a dh.pressurePlate.
A placed object can be a crate too. An instance of an object whose root carries a body is the same body, read the same way. The core pallet ships core:objects/physicsCube.dh-obj (a 0.5 m orange crate at 30 kg, server-simulated), and a map states its own crate as the departures from it, in the instance’s own components. A session’s edit of its mass or sim saves into them.
{ "$type": "instance", "id": "1f0c4a8e-7b2d-4c5e-9a31-6d8e2f4b7c90", "name": "placedCrate", "source": "core:objects/physicsCube.dh-obj", "position": [2, 4, 5], "components": [ { "$type": "dh.box", "size": [0.8, 0.8, 0.8], "material": "/materials/crate-blue.dh-mat" }, { "$type": "dh.physicsProp", "mass": 30 } ] }It has no inputs and no outputs — no wire can start or end at one — so it never appears in a connections list. It is not a trigger and it does not fire anything; it is scenery that obeys physics.
Surface friction. Every simulation tick the server probes the surface directly under each prop and re-tunes the body’s friction from that surface’s surfaceprop grip, so the same crate slides a long way across ice and barely at all across rubber. The two coefficients combine the way the solver combines any contact pair, so a slick prop on a grippy floor still slides.
Pushing. Walking into a prop pushes it. The server resolves the overlap between each player capsule and each prop box and shoves the body along the player’s motion, capped so a sprinting player cannot launch a crate across the map. Because props are simulated server-side only, the client’s predicted movement does not collide with them — the same situation as doors and buttons — so a prop’s resistance to a walking player is felt on the server’s correction, not locally.
Replication. A prop’s position and full orientation ride the snapshot each tick and are interpolated on the client’s render timeline (the orientation by slerp), so a tumbling crate reads smoothly. Its size and material never ride the wire — both ends loaded the same map — and it is resolved back to its authored box by its identity: the snapshot carries a small object index, the client looks that up in the same author-ordered table the server minted it from, and out comes the id the map authored and the box that goes with it. A body the solver has moved is still the same object, so the lookup does not care where it ended up. A “moving/resting” readout of the body’s sleep state replicates for the entity-gizmo overlay.
Tuning. The push and friction behavior is exposed as live world preferences — world.prop.friction, world.prop.groundProbe, world.prop.pushSpeed, world.prop.pushResponse and world.prop.pushReach — so all of it is tunable from the console without a rebuild.
Buoyancy. A prop that touches a water body floats. Each body is sampled at four to eight points on a ring around its own box (world.water.samples, default 8, spread past the hull by world.water.footprint), and every point under the displaced wave surface pushes up with the weight of the water its own slab displaces — waterDensity × sampleVolume × submersion × g, applied at that point, so the righting torque of a tilted float falls out of where the points are rather than out of a separate term. A body denser than the water it is in sinks; a lighter one settles where the displaced weight matches its own. While any point is wet the body takes world.water.linearDamping and world.water.angularDamping (a roll rate past world.water.maxRollRate scales the angular term up, capping the heel a wave can throw a raft into), and both are cleared the moment it is dry, so a crate that leaves the water falls exactly as an untouched one does. Its center of mass is dropped to world.water.centerOfMassDrop of its box half-height when the body is minted, which is the metacentric half of the same story.
Riding a float. A pawn standing on a body inherits that body’s velocity at its feet for the move and has it taken back off afterward — Source’s FL_BASEVELOCITY — so a raft carries its passenger without the passenger banking the raft’s speed. The support body has to exist in the machine doing the moving: a client-simulated raft is ridden by the client’s own prediction, a server-simulated one on the server’s correction.
Not yet supported: standing on a prop (the client predictor cannot see them), reliable tall stacking, and grabbing or carrying.
Simulating a prop on the client alone
Section titled “Simulating a prop on the client alone”sim chooses which machine runs the body. It is "server" by default — everything above — and "client" moves the whole prop off the wire:
{ "$type": "instance", "id": "1b1c9e6c-2a0a-4a24-9a8c-6ad46f5f7f01", "name": "localCrate", "source": "core:objects/physicsCube.dh-obj", "position": [0, 4, 5], "components": [ { "$type": "dh.box", "size": [0.8, 0.8, 0.8], "material": "/materials/crate-blue.dh-mat" }, { "$type": "dh.physicsProp", "mass": 30, "sim": "client" } ] }The same "sim": "client" on a plain object’s dh.physicsProp does the same for a map’s own crate. A client-simulated prop is created in each client’s own physics world when the map loads, stepped there every tick, and drawn from that local pose. The server never spawns it, never gives it a body and never sends a byte about it — it is not in the snapshot at all, so it costs nothing in bandwidth and nothing in server tick time. The body itself is minted by the same code the server’s props go through, so the two differ only in which solver runs them, never in how they were built.
The trade is exactly the one the wire was paying for: every client simulates it independently, so two players watching one crate are not watching the same crate. Use "client" for scenery nobody has to agree about — and for measuring the solver against the netcode, which is what core:maps/physics-eval stands two identical piles side by side to do.
Any other value is a compile error naming the token and listing the ones that would have worked.
dh.light
Section titled “dh.light”A dh.light makes its object an emitter and, with a dh.box beside it, a visible fixture. It emits one analytic light, and the fixture’s box swaps material with its on/off state — one replicated bit drives both, so the lamp’s glass and the room light up together and can never drift apart.
{ "$type": "object", "id": "f3cf4ad5-b69c-4e42-9e7b-408dc4df418b", "name": "lamp", "position": [3, 1, 7], "components": [ { "$type": "dh.box", "size": [1, 1, 1], "material": "lampOn" }, { "$type": "dh.light", "materialOff": "lampOff", "startOn": false, "light": "point", "color": [1.0, 0.88, 0.7], "intensity": 60, "range": 9, "offset": [0, 0.6, 0], "innerConeDegrees": 30, "outerConeDegrees": 45 } ] }| Field | Default | Meaning |
|---|---|---|
light | "point" | point, spot, directional, or none (emit nothing — a pure material-swap prop). |
color | [1, 1, 1] | sRGB face values in [0, 1] — hue only, the same space a material’s tint is authored in. Linearized once, then multiplied by intensity. A component above 1 is a compile error; see Colors are sRGB, intensities are linear. |
intensity | 25 | The linear, unbounded brightness half of the pair, in the engine’s artist-facing radiance units — the same scale world.render.exposure works against, not lumens. HDR values go here. |
range | 10 | Influence radius in meters; the falloff reaches exactly zero here. Unused by directional. |
falloff | inherit | physical or unity — this light’s distance curve. Omit to follow the world’s default. Unused by directional. |
offset | [0, 0, 0] | Emission point relative to position, in world axes — so a ceiling lamp emits from its bulb, not the box’s centroid. |
innerConeDegrees | 30 | spot only: the fully-lit half-angle. |
outerConeDegrees | 45 | spot only: the half-angle the light has fallen to zero at. Must exceed the inner one. |
mode | "realtime" | Where this light is evaluated. realtime shades it every frame from the runtime light list. baked moves it out of the realtime budget and into the map’s lightmap instead — which requires an enabled lighting.lightmap block, because a baked light without one would otherwise vanish from the map entirely (a compile error). mixed is both: the lightmap bakes it and the surfaces the lightmap covers take it from there, while everything else (players, props, anything the bake left out) shades it live from the light list; a light probe bake gathers only its bounce, since it reaches a probe-lit surface live. A mixed light needs no lightmap: with none, nothing is lightmapped and it is a realtime light. Any other value is a compile error naming the three that exist. |
radius | 0.05 | A baked point or spot light’s emitter radius in meters, which is what softens its baked shadow (Bakery’s shadow spread). 0 is a hard shadow. Realtime lights ignore it. |
angularDiameter | 0.53 | A baked directional light’s angular diameter in degrees — its sun-disc equivalent of radius. |
shadowSamples | 8 | Shadow rays per lightmap sample a baked light spends across its radius, at most 64. |
startOn | unset | Whether the light starts lit. Unset, a fixture starts off (it has an off look and something to switch it) and a bare emitter starts on. |
materialOff | unset | A fixture’s look while the light is off: the slot of the map’s materials table its box wears then. Unset, the box wears its own material either way. |
How a light falls off with distance
Section titled “How a light falls off with distance”Two curves ship, and a light picks one — its own falloff, or the world’s default when it authors none.
| Mode | Curve | Reach for it when |
|---|---|---|
physical | (1 − (d/r)⁴)² / d² — true inverse-square, windowed smoothly to exactly zero at range. The engine default. | You are lighting a scene in this engine. Energy behaves the way a real lamp’s does: doubling the distance quarters the light, so the bright core is small and the falloff is fast. It is also the curve Unity’s own URP, HDRP and lightmapper use, so a port from those needs nothing. |
unity | (1/(1 + 25·(d/r)²) − 1/26) · 26/25 — bounded at exactly 1 at the bulb, exactly 0 at range. | You are reproducing a scene authored against Unity’s built-in (legacy) pipeline. Its attenuation is a curve-fit baked into a lookup texture, not inverse-square: it is bounded near the lamp, and it is much flatter. Roughly half brightness lands at a fifth of the range; at a twentieth it is 0.94, where physical is already up past 4. |
The practical difference is the near field. Under physical, a lamp is blinding right at the bulb and dies quickly; under unity, it is a soft even wash. Intensities do not transfer between the two — a lamp tuned to look right on one curve will read wrong on the other, so switch the curve first and then retune, not the other way round.
directional lights ignore both: a sun has no distance term.
A spot or directional light is aimed by its object’s rotation: it emits down the rotated local -Y, so an unrotated light shines straight down and needs no direction field. The object’s position is the emission point before offset, and a fixture’s box center.
The fixture is the object’s dh.box. A dh.light with no box beside it is a bare emitter — invisible and non-solid, which is what a ceiling light or a mood light in a corner usually wants. With a box it is a fixture: the box wears its own material while the light is on and materialOff while it is off, it is drawn by the light’s live visual rather than the static box batch, and it collides through its generated collider. A record written before the light was a component spelled the box as the light’s own size and materialOn; they are the box’s size and material now. None of this rides the wire: both ends parse the same map, so the client recovers a light’s color, range and cones from the map definition, keyed on the object identity the snapshot names. Only the on/off bit replicates.
Lights are ranked per frame against the camera and capped at 64 on the GPU — everything within reach of the eye first, brightest-nearest first inside that — so a dense scene degrades gracefully instead of popping arbitrarily.
A pixel shades only the lights that can reach it. Each view’s lights are binned on the CPU into screen clusters: client.render.lightClusterColumns × client.render.lightClusterRows screen tiles (16 × 8) times client.render.lightClusterSlices depth slices (8), spaced evenly in the logarithm of view depth out to client.render.lightClusterFar (120 m, where the last slice begins and runs to infinity), at most 1,024 clusters. A light goes into every cluster its range sphere’s box can project into, and a pixel loops its cluster’s lights in the same ascending order as before. A light left out is one whose range the pixel is outside, which shades nothing anyway, so the picture is the full loop’s. client.render.lightClusters (on) turns it off for an A/B. On the Steam Frame, gm_construct’s 56 ranked lights had cost about 43 ms of the opaque pass with every pixel looping all of them.
Tuning lights live
Section titled “Tuning lights live”lights.* console commands (admin/server, replicated to every client) retune a light for the session without editing the map. Each retune is a scene field edit of the light’s own field — the edit the level editor’s Light card makes — so a late joiner sees it too. The map’s authored values stay the base; the edits are a sparse patch on top.
] lights.list] lights.set lamp intensity 120] lights.set lamp color 1 0.6 0.2] lights.set lamp range 14] lights.set lamp falloff unity] lights.set lamp innerCone 12] lights.set lamp outerCone 40] lights.set lamp mode baked] lights.set lamp kind spot] lights.reset lampThe field token is one of color, intensity, range, falloff, innerCone, outerCone, mode or kind. falloff takes a falloff token, mode a mode token and kind one of point, spot or directional; the rest take numbers. A value set back to exactly what the map authors drops out of the patch rather than being stored. Overriding one end of a spot’s cone re-clamps the other, so the beam can never invert.
mode is the one field that decides whether the light is drawn at all: baked drops it from the runtime light list (the lightmap already carries it), and realtime shades an authored-baked light live on top of the bake it is already integrated into — an honest preview of a mode the map has not been rebuilt for, not a free relight.
lights.set ... color takes the same sRGB values the map authors, and lights.list prints them back in sRGB — so a color you dial in at the console can be pasted straight into the map’s color field, and vice versa.
lights.set <TAB> completes the loaded map’s light names.
Wildcard targets: target also accepts a single * — at the start, middle, or end of the token — matching every light whose name fits. An exact name always wins first, so a light literally named * or containing one is never shadowed by pattern expansion.
] lights.set lamp* intensity 5lights.set: intensity -> 5 on 12 lights] lights.set *Door color 1 0 0lights.set: color -> 1 0 0 (sRGB) on 4 lights] lights.reset *lights.reset: reverted 16 of 16 lightsA pattern matching exactly one light reports that light by name, same as an exact match. A pattern matching none reports the usual not-found error, naming the pattern.
An override is a session’s, not a file’s — until you keep it. A light with one standing marks as pending in the level editor, and its Save writes the overridden fields back into that light’s own entry, in sRGB, leaving every field you did not touch alone. lights.reset reverts every edited field and drops the mark with it.
The world’s sun and ambient
Section titled “The world’s sun and ambient”A map may author the world’s sun and hemisphere ambient in an optional top-level lighting block. Every field is optional; an omitted one keeps the engine default.
"lighting": { "sunDirection": [-0.35, -0.85, -0.4], "sunColor": [1, 1, 1], "sunIntensity": 1.0, "ambientSky": [0.68, 0.715, 0.786], "ambientGround": [0.618, 0.601, 0.584], "ambientIntensity": 0.35, "halfLambert": 1.0, "falloff": "physical"}sunDirection is the direction sunlight travels, so an overhead sun is [0, -1, 0]. halfLambert is the diffuse wrap: 0 is a hard Lambert terminator, 1 the full Source wrap that keeps shadowed sides softly lit. falloff is the default distance curve every point and spot light in the map takes unless it names its own.
These seed the replicated world settings world.sun.direction, world.sun.color, world.sun.intensity, world.ambient.sky, world.ambient.ground, world.ambient.intensity, world.lighting.halfLambert and world.lighting.falloff when the map installs. A console edit afterwards is a live override on top and is never written back to the map — and because they are world.*, they are server-authoritative and identical for every client of a world.
Lightmap
Section titled “Lightmap”A map can move some of its lighting off the frame budget and into a bake. lighting.lightmap says what to bake; the bake itself is made in the editor (Map ▸ Lighting ▸ Render Bake, or the lightmap.bake console command) or headless with DigitalHeaven.Engine.Host --bakeLightmaps --map <barcode>, on the machine’s GPU. It parameterizes the map’s geometry into a lightmap atlas, ray-traces the chosen lights and the sky against the map’s real triangles with soft shadows, and writes the result beside the map source as <map>.dh-map.dh-lightmap. dh build packages that file as it stands and never bakes.
"lighting": { "sunDirection": [-0.45, -0.8, -0.4], "sunIntensity": 2.2, "lightmap": { "enabled": true, "texelDensity": 4, "samples": 2, "bakeSun": false }}| Field | Default | Meaning |
|---|---|---|
enabled | false | Whether this map has a lightmap at all. Omitting the block entirely is the same as false: no atlas, no lightmap coordinate, everything realtime. |
origin | "baked" | Where the lightmap beside the source came from: "baked" (what an omitted field means) or "imported", a lightmap an importer wrote from another engine’s own bake (see An imported lightmap). Any other value is a build error. |
texelDensity | 4 | Atlas resolution as texels per meter of world surface — not an atlas size. The atlas size follows from this and the map’s surface area, so a wall and a floor of equal size always get equal detail no matter how the map grows. Must be positive. Each doubling of the density costs four times the texels, the rays and the memory. |
padding | 2 | Gutter in texels between packed charts, and the distance each chart’s edge color is dilated outward. Bilinear filtering reads across a chart edge, so a zero gutter bleeds one surface’s light onto another. |
maxSize | 2048 | The largest page edge, a power of two, at most 8192. A map whose charts fit one page of at most this size gets one page sized to fit; a larger map spills onto as many pages of exactly this size as it needs (see Charts and pages). A bake past one 2048-texel page is warned, naming both numbers, so a big bake is a visible trade rather than a silent one; one past two 8192-texel pages’ worth of texels is refused, naming texelDensity. |
samples | 2 | The square root of the surface samples per texel: 1 shades the texel center only, 2 a 2×2 grid, up to 4. Antialiases a shadow edge crossing a texel; every sample draws its own soft-shadow and sky rays. |
bakeSun | false | Bake the map’s sun instead of lighting it in real time. Its penumbra is the sun disc the sky draws, skySunAngularDiameter. "mixed" bakes it too but keeps it live everywhere the lightmap does not cover, as a mixed light does. |
sunSamples | 16 | Shadow rays per sample spread across the baked sun’s disc, at most 64 (Bakery’s default for its direct light). |
bakeAmbient | false | Bake the sky — the procedural sky, or the two-color hemisphere rig — into the atlas with occlusion, instead of adding it analytically at shading time. A corner then darkens the way a real one does, at the cost of a sky that can no longer change without a rebake. |
ambientSamples | 32 | Hemisphere rays per sample when bakeAmbient is on, at most 256. Out of range is a warning and a clamp, never a failed bake. |
bounces | 3 | How many times baked light bounces off the map’s surfaces, 0–8. 0 bakes direct light and the sky alone. Three rather than Bakery’s five: with ordinary albedos the fourth and fifth carry a few percent of the first between them, and each is a full pass. |
bounceSamples | 64 | Gather rays per texel in each bounce pass, at most 1024. It costs linearly; the denoiser takes the remaining grain. Out of range is a warning and a clamp. |
emissiveSamples | 16 | Shadow rays per sample toward each emissive surface, at most 64, unless a part’s dh.bake names its own. A group far away spends fewer, down to one. Out of range is a warning and a clamp. |
aoSamples | 16 | Rays per sample in the baked ambient occlusion, at most 256; 0 bakes none. Out of range is a warning and a clamp. |
aoRadius | 0.5 | How far an occlusion ray looks, in meters. Must be positive. |
denoise | true | Denoise the finished atlas with Intel Open Image Denoise’s lightmap filter. |
A baked light softens its shadow by its size, the quantity Bakery calls shadow spread: a point or spot light’s radius (default 0.05 m) and a baked directional light’s angularDiameter (default 0.53°), each sampled with shadowSamples rays (default 8, at most 64). 0 is a hard shadow.
What is stored: MonoSH
Section titled “What is stored: MonoSH”A bake stores Bakery’s MonoSH: per texel an HDR L0 color and one colorless direction, plus a third layer holding the baked occlusion and the share of L0 that arrived indirectly. The sidecar and the pallet store them block-compressed, three and a half bytes a texel: the L0 as BC6H, the direction’s three components and the occlusion layer’s two channels as BC4 planes. BC6H spends one pair of endpoints per channel on a 4×4 block, so a block that spans a hot spot and its dim surround would read as a lit rectangle, and a block that holds lit texels beside black ones comes back tinted pink or green. So every block a chart reaches is dilated full before it is encoded, and a block whose decode misses any of its texels — by more than half a stop of luminance, or in hue (a channel’s share of the three) by more than 0.03 at any one texel or 0.01 across the texels a draw reads on average, which is the whole block coming back tinted — is kept exactly as well, as an exception (about 12% of a city’s blocks, 3% of the yard’s), and the bake log counts them. The gutter past the ring a draw reads need not match, but a block whose gutter decodes more than four stops past its brightest texel is kept too: BCnEncoder can hand back a dim block beside exact zeros with the zeros at the format’s ceiling in one or two channels. The runtime interpolates the lightmap coordinate at the centroid, so a multisampled edge pixel whose center falls off its triangle still reads its own chart instead of extrapolating into that gutter, which lit MLTN City’s edges with single-pixel green, cyan and yellow fireflies. A device that samples BC6H and BC5 uploads the blocks as they are (the lightmap on the GPU), and a fresh bake is swapped onto a running map the same way, so what a session shows is what ships. Every light’s shadowed arrival and every unblocked sky ray is projected onto the Lambert-convolved first two spherical-harmonic bands over the hemisphere around the surface’s geometric normal. The shader rebuilds each channel’s L1 as that direction times the channel’s own L0 times two and evaluates it, non-linearly (below), against the per-pixel normal, so a normal map responds to baked light (a two-sided material’s back face reads it through its front normal, the hemisphere the bake integrated, so a leaf card or an ivy sheet seen from behind shows the light its front received rather than black, as Bakery does; realtime lights still shade the side the camera sees), and it shades a baked highlight from the dominant direction through the same GGX lobe realtime lights use (client.render.lightmapSpecular, on by default). The atlas is not multiplied by albedo: the material’s base color still supplies that at shading time, which is why a baked room re-textures without a rebake.
The bake is plain Lambert — the map’s realtime halfLambert never reaches it. The look’s bakedWrap applies the half-Lambert kernel’s harmonics as the lightmap is evaluated, 0 (the default) being Bakery-exact, so changing it never needs a rebake.
Evaluated linearly, order-one harmonics give a lone light only three quarters of its clamped cosine straight on and lift the terminator, so baked surfaces read about a quarter darker than the same light in realtime. The runtime therefore evaluates them non-linearly, as Bakery’s non-linear MonoSH does: Geomerics’ L1 reconstruction (shEvaluateDiffuseL1Geomerics), a ((1 + cos) / 2)^p lobe whose power runs from 1 for light arriving evenly to 3 for one light, normalized so its average over every direction is still L0. A lone light straight on reads its full Lambert and nothing behind it, a texel with no direction (an even sky) reads exactly as before, and the look’s bakedWrap lifts L0 before the lobe is taken, so it keeps the same energy either way. It is taken on the luminance the stored direction is relative to and scales each channel’s L0 alike. client.render.lightmapNonLinearSh (on by default) switches back to the linear evaluation live; the baked highlight’s strength follows the same switch.
Charts and pages
Section titled “Charts and pages”The atlas is cut and packed by xatlas. Its chart computation grows charts across a node’s triangles by how little they stretch, so a welded sphere is cut into pieces that flatten without overlap and a flat floor stays one piece; a crease is weighed as a seam, so a hard-edged box keeps a chart per face, the way Unity’s hard-angle unwrap does. Every node charts on its own, and no chart grows past half a page. Its packer places the charts with padding texels of gutter.
The atlas is one or more pages, ordinarily of one size. A map whose charts fit one page of at most maxSize gets one page sized to fit them; a larger one spills onto as many pages of exactly maxSize as it needs, so density is never traded away to fit. A vertex’s page rides the integer part of its lightmap u, so the shader picks the layer without a second coordinate. A chart seam cuts a few vertices in two, and the UV companion carries those as copies, never rewriting the GLB.
The lightmap on the GPU
Section titled “The lightmap on the GPU”Pages are cut to what their UVs reach, then grouped by size. When the pallet packs an imported lightmap, LightmapPageFit cuts each page, per axis, to the smallest power-of-two extent (at least 64) that holds every coordinate the UV set puts on it plus two texels of margin, and rescales the UV set to match, so every coordinate lands on the same texel. de_nuke’s 1020-texel sky chart, which spilled onto a page of its own, now takes a 1024 by 1024 page instead of a second 4096 by 4096 one. A page’s own extent is stored in the sidecar (an optional trailing table, written only when the pages are not all one size). The pages then fall in at most three size classes, merged by the cheapest added texels, and each class is its own set of arrays. A map whose pages are all one size has one class and the shader reads it directly. A baked (xatlas) lightmap keeps its pages as the packer sized them.
A device that samples BC6H and BC5 uploads the sidecar’s blocks as they are, about four bytes a texel instead of twelve: the L0 as BC6H, the five BC4 planes re-paired into three BC5 layers a page (direction x and y; direction z and the occlusion; the indirect share), which moves block bytes and decodes nothing. The blocks BC6H could not hold stay exact: each is one 4x4 tile of an exception pool of shared-exponent texels, an index texture says which block is which tile, and the shader reads a tile instead of the block wherever the bilinear tap touches one. Re-encoding at BestQuality leaves 7.7% of de_nuke’s blocks as exceptions (9.5% at the default) at nine times the encode time, so the pool, not the encoder, is what makes them go away. A lightmap with no exception blocks, or with one size class, compiles those reads out of the scene shader.
A device that cannot sample them decodes on the CPU. An Apple GPU samples no BC6H, so a lightmap there uploads as shared-exponent L0 texels with its exceptions already applied and the planes as RG8, about ten bytes a texel, and the shader reads the same channels. client.render.vramDecodeLightmaps takes this path on any device, which is how it is tested without one. A bake stored as texels (a page whose extent is not a multiple of four) is always uploaded this way. Transcoding BC6H to ASTC HDR for the Apple GPUs that sample it would bring them to about four bytes a texel too; it is not done yet.
The map load logs what the lightmap took (lightmap on the GPU: 84.5 MiB Compressed, 2 size class(es) 4096x4096 x1, 1024x1024 x1, 200,103 exception block(s) in the pool) and sets that size aside before its textures are admitted against the GPU pool.
Bake zones
Section titled “Bake zones”A map can split its lightmap into bake zones, the way Bakery splits a scene into lightmap groups: each zone packs into atlas pages of its own, at its own texel density if it names one. Zones follow the hierarchy, not a box. lighting.bakeZones defines them; a dh.bake’s zone puts its owner and everything under it in one, the nearest marked ancestor winning, so nested zones work. Geometry no zone claims stays in the map’s implicit default zone and is lightmapped as before; a dh.bake with lightmapScale: 0 is how anything opts out of the atlas.
"lighting": { "lightmap": { "enabled": true, "texelDensity": 4, "maxSize": 4096 }, "bakeZones": [ { "id": "underground", "name": "Underground", "texelDensity": 6 } ]},"objects": [ { "$type": "instance", "id": "…", "name": "world", "source": "meshes/mltnCity.glb", "children": [ { "path": "__Areas/__mltn_tower/__Underground_Facility", "components": [ { "$type": "dh.bake", "zone": "underground" } ] } ] }]| Field | Default | Meaning |
|---|---|---|
id | — | The zone’s stable id, what a dh.bake’s zone names. Required and unique in the map; renaming the zone never changes it. |
name | none | What the zone is called, for a person. |
texelDensity | inherited | Texels per meter for the zone’s nodes, replacing lighting.lightmap.texelDensity. Must be positive. |
sets | none | The zone’s switchable lighting states beyond its authored one. |
Every zone bakes against the whole map in one run: every triangle still occludes and bounces light into every zone, and each zone writes only its own receivers. Every page of the map is one size (the largest any zone needed), since the pages are layers of one array. A runtime can tell each page’s zone (the sidecar records it), and a vertex’s page is the integer part of its lightmap u, so every draw finds its zone. The inspector’s Bake card edits a part’s or a box’s zone.
Bake sets
Section titled “Bake sets”A zone can bake its lighting more than once, as named sets, and switch between them at runtime with a crossfade: a room whose lamps go off, or turn red, the way Bakery bakes a lightmap group per lighting state. The authored lighting is the implicit first set, default; every other set lists only what it overrides.
"bakeZones": [ { "id": "room", "name": "Room", "sets": [ { "name": "off", "lights": { "8396fd1c-95f8-4e1b-9d8a-5a2dc121ecd5": { "enabled": false } } }, { "name": "red", "lights": { "8396fd1c-95f8-4e1b-9d8a-5a2dc121ecd5": { "color": [1, 0.1, 0.05], "intensity": 25 } }, "emission": { "yard/roomPanel": { "color": [1, 0, 0] } } } ] }]| Field | Meaning |
|---|---|
name | The set’s name, unique in its zone, and never default, which is the authored state’s. |
lights | Overrides on baked lights, keyed by the id of the object carrying the dh.light: enabled: false leaves it out of the set; intensity and color (sRGB) replace its own. A key that names no light entity is a build error; one that names a realtime light is a warning, because a set changes only what is baked. |
emission | Overrides on baked emission, keyed by a node’s path (the anchor a part override’s path carries): enabled: false, intensity and color replace what the node’s material or its own dh.bake emission would bake. A path the geometry has no node at refuses the bake with an error. |
One bake bakes every set. Render Bake (and --bakeLightmaps) bakes the whole map in its default set first, then each zone’s receivers once per set: every other light and every other surface as authored, the set’s overrides applied to the whole map’s lights and emitters, and every triangle still occluding and bouncing. A bounce that lands outside the zone reads the default bake’s light there, pass for pass, so everything a set does not change bakes exactly as the default did; inside the zone, each bounce reads the set’s own previous pass. The rest of the map is baked once. A set’s pages are a copy of its zone’s pages on the same lightmap UVs, stored after the default’s in the sidecar (format 4 records each page’s zone and set), so each set costs its zone’s pages again on disk and on the GPU and nothing else. Each set is baked, stored and dropped before the next, under the same client.editor.lightmapBakeMemoryMb ceiling as the rest of the bake; the progress card and the log name the zone and set being baked.
Switching is a logic action, setBakeSet, or the console’s bakeSet.switch <zone> <set> [fadeSeconds] (bakeSet.list lists every zone and the set it shows). The server holds each zone’s set and replicates it with the world’s lighting, so every client, including one that joins later, shows the same set and the same point of a fade in flight. Each client crossfades a zone’s lightmap from the set it showed to the new one over fade seconds, and every light probe volume whose bakeZone names the zone crossfades with it: the bake gathers the zone’s probes once per set, and the sampler blends the two sets’ probes by the same fade, so a player standing in the room darkens with the room. A set the map declares but the bake does not hold (added since the last bake), or one whose overrides changed since, shows its zone’s default, with a warning in the log and at dh build, until the next bake.
A set changes only what the lightmap holds. A baked lamp is not in the realtime list either way, and a set does not switch a material’s own glow or a fixture’s materialOn/materialOff, which stay the light entity’s business.
Where a bake lives
Section titled “Where a bake lives”The sidecar beside the source is the map’s lightmap: it carries the atlas and the lightmap UV set it was packed through. dh build splits it into a companion beside the map in the pallet and a UV companion beside it too, <map>.dh-map.lightmapUv: the set belongs to the bake that packed it, so two maps baking one model, or a map baking a model another pallet holds, each keep their own. A map that places models anywhere but its one model where it was built carries one UV set per placement inside the companion instead, and its geometry hash covers every placement’s model and pose. A map that asks for a lightmap with no sidecar, one this build cannot read, or one baked for different geometry (the sidecar records a hash of the geometry it read) ships lit in real time with a build warning naming the file — it never fails the build — and a pallet whose lightmap cannot be read loads the same way, with the reason in the log.
Render Bake hot-swaps the result onto the running map when the map already loaded with the same lightmap UVs (a rebake at the same density); otherwise a toast says to Build, which packages the new sidecar and reloads the map. Clear Bake deletes the sidecar.
An imported lightmap
Section titled “An imported lightmap”An importer that reads another engine’s own baked lighting (a Source map’s lightmaps, say) writes it straight into the same sidecar with no bake of its own, and marks the map "origin": "imported" in its lighting.lightmap block. The sidecar is an ordinary one: raw shared-exponent texels, one default zone, no sets, its UV set indexed by the order the mesh loader expands the geometry GLB, and the hash of that GLB like any other. dh build checks that hash exactly as it does for a baked lightmap, so an imported sidecar for other geometry ships the map lit in real time with the same warning, and one with the right hash builds clean.
What origin changes is who may rewrite the file, because an import cannot be regenerated without the source file it came from:
- Render Bake and
lightmap.bakerefuse, and so does the headless--bakeLightmaps, withthis lightmap was imported (origin: imported); remove origin to bake it here, which replaces the import. Nothing is overwritten and nothing is switched for you: removeoriginfrom the block yourself and the next bake replaces the import. - Clear Bake asks first (a dialogue with Clear Bake and Keep) before it deletes the imported sidecar. Clearing only the light probes does not ask.
- The light probe bake still runs. Turn Render Bake’s Lightmap switch off and its Probes switch on.
What is baked, and what precedence means
Section titled “What is baked, and what precedence means”Precedence is structural rather than a rule to remember: a light with "mode": "baked" is dropped from the realtime light list, so it is counted in the atlas and nowhere else. The two paths cannot double up, because they never both own the same light. bakeSun: true does the same to the sun — its replicated intensity resolves to zero and its cascaded shadow maps stop mattering. A mixed light (and bakeSun: "mixed") is owned by surface instead: the lightmap owns it on every lightmapped fragment, where the shader skips the live light, and the light list owns it everywhere else, so each surface still takes it exactly once.
A part a part override marks "static": false is left out of the bake geometry entirely — it neither receives an atlas chart nor occludes anything else’s, exactly like a node that never existed at bake time. Its vertices stay in the UV companion all the same, carrying an out-of-atlas coordinate the shader reads as “not baked”, so the set still covers the whole model and a partial bake loads without complaint.
A face whose material slot resolves to a water material is excluded the same way: water draws in its own pass, not the lightmapped one, so a baked atlas on it would never be read, and a water surface baked as opaque would occlude the light meant for whatever is under it.
If the geometry changes after a build without a rebake, the counts disagree and the map still loads — with realtime light only, a warning in the log and a toast reading lightmap is stale for '<map>': rebuild the pallet. A lightmap is regenerable, so a stale one degrades rather than blocking the map.
Ambient stays live unless the map asks for bakeAmbient, so a world’s sky can change without a rebake. With it on, the lightmapped geometry drops both the analytic fill and the probe volume — the baked sky already carries its occlusion — while dynamic objects and anything the atlas does not cover keep the volume. So bakeAmbient beside a giVolume or dh.lightProbeVolume boxes is allowed: nothing is counted twice. A bake with bounce (bounces above 0) drops the probe volume on lightmapped geometry too, because the volume’s bounce of the same lights is already in the atlas; without bakeAmbient those surfaces keep the analytic sky, which has no occlusion, so a map that bakes bounce usually wants its sky baked as well. The baker reads the ambient out of the map source, so a live world.ambient.* console edit changes the analytic fill everywhere except the geometry whose fill is already in the atlas.
client.render.lightmapIntensity scales the baked contribution live (0 switches it off entirely, useful for an A/B against the same lights realtime), and client.render.debugView lightmap shows the evaluated bake on its own. client.render.debugView lightmapTexels shows the atlas’s texel density, one checker square per texel, tinted blue, green or red against the zone’s texelDensity: the view to tune lightmapScale in.
The bake’s passes
Section titled “The bake’s passes”The bake runs on the GPU in two kinds of pass. The direct pass shades every surface sample: each baked light with its soft shadow, each emissive surface, the sky when bakeAmbient is on, and the occlusion. Then each bounce pass gathers bounceSamples hemisphere rays per texel; a ray that lands on a surface reads the previous pass’s atlas at the hit’s lightmap coordinate and reflects the material’s textured base color, times its tint and less its metallic share, back along the ray. A pass reads an integrated, texel-averaged result rather than following a path, which keeps bounce smooth, and each pass reflects only the light the one before it added, so nothing is counted twice. A ray that lands on the back of a one-sided surface, or on geometry with no atlas chart, reflects nothing. Bounce is diffuse light like the rest of the atlas and joins the same MonoSH.
Alpha cutouts let light through. A shadow, sky, occlusion or bounce ray that strikes a mask material reads the base color’s alpha at the hit (times the tint’s) against the material’s alphaCutoff — the test the renderer discards by — and passes through where it fails. A blend material stops a ray with the probability of its alpha, so the rays behind one texel carry its transmission on average. The bake reads each material’s base color and emissive textures reduced to at most 256 texels on a side.
The materials come from the loaded map: the editor bakes against the session’s own materials, and --bakeLightmaps against the pallet it opened (--pallet names an unpublished one). A slot that does not resolve bakes as an opaque, one-sided mid gray, and the bake log names it. The CPU path, used only when there is no GPU, bakes the direct pass’s lights and sky alone.
A bake has a memory ceiling, client.editor.lightmapBakeMemoryMb (default 6144; 0 is none; --set passes it to --bakeLightmaps). Once the atlas is packed the bake estimates what it will hold and refuses up front when that is past the ceiling, naming the density and the knob; it then checks the process’s working set after every band, pass and page and stops the moment it crosses. Nothing is ever shrunk to fit. The phase line reports the peak and a lightmap memory by phase line where it was reached. The bake holds its three float layers for covered texels alone and lays out one page dense only while it is filtered and while it is stored, so the atlas’s gutter and empty space cost nothing between them: on the city at 4 and 6 texels/m, half of whose atlas is empty, that is about 1.3 GB less heap through the bands and the bounce passes and 0.8 GB less while pages are filtered and stored, though the working-set peak, which the loaded map and the GPU driver dominate, falls only a few hundred megabytes. The city’s peaks, for scale, with the loaded map itself about 3 GB of each: 5.0 GB at 1 texel/m, 5.3 GB at 2, 6.4 GB at 4 with its underground zone at 6 — which needs the ceiling raised to about 9000, since the estimate runs about a third over.
One GPU bake at a time on a machine. Two bakes on one GPU do not each run at half speed; they starve each other, and MLTN City’s 33-second direct pass took eight minutes beside another bake. So every bake — Render Bake and the reflection bake in the editor, --bakeLightmaps, --bakeLightProbes, --bakeReflections and --renderReflectionBake — takes one lock for the whole bake, an exclusive file under the engine’s user-data folder (locks/gpu-bake.lock) that the operating system releases when its holder exits or crashes. A bake that finds it taken waits, logs waiting for another bake (pid N), and shows the same on the progress card (a toast for the reflection bake); Cancel, or Ctrl+C headless, ends the wait. Setting client.editor.bakeLock (on by default) to false skips the lock.
Bake pace
Section titled “Bake pace”A bake runs gently unless it is asked for full speed. A GPU bake left to itself takes the whole machine, and a game or a headset running beside it lags badly, so every bake — Render Bake, lightmap.bake, --bakeLightmaps, --bakeLightProbes, --bakeReflections and --renderReflectionBake — runs at the pace client.editor.bakePace names, Gentle by default:
| Preference | Default | What the gentle pace does with it |
|---|---|---|
client.editor.bakePace | Gentle | Gentle or Full. Full is the bake as it always ran. |
client.editor.gentleBakeDispatchMs | 4 | The GPU time one dispatch aims at. |
client.editor.gentleBakeRest | 1 | The idle gap after each dispatch, as a multiple of the dispatch’s own time: 1 hands the GPU back for as long as the bake held it. |
client.editor.gentleBakeCores | 0.5 | The share of the logical cores every parallel loop of the bake shares: rasterizing, shading, dilating, encoding and the hierarchy build. |
client.editor.gentleBakeLowPriority | true | Runs the process at below-normal priority for the bake and restores it after. Windows only: elsewhere an unprivileged process cannot raise its own priority back. |
Full speed keeps client.editor.lightmapBakeDispatchMs (40 ms) and none of the rest. The pace is read at every GPU dispatch and every parallel loop, so it changes mid-bake with no restart: the editor’s pace button does exactly that, and the log says bake pace switched to …. The bake logs its pace as it starts, the phases line ends with it, the GPU’s share of the bake stage and how long its dispatches ran (pace gentle, gpu duty 39% (dispatch 43.5s in 11,619 submit(s) of 3.7 ms on average, p95 5.8 ms, max 16.1 ms, rest 43.6s of 112.6s)), and a closing bake total: line gives the same over the whole bake.
Every walk is sliced, so a dispatch can be short. One GPU invocation shades one sample, and its work is a walk: a direct sample’s lights, then its sky rays, then its occlusion rays; a bounce texel’s gather rays; a probe’s sphere. No dispatch runs a whole walk. Each runs one range of every walk’s units (one unit a ray; an analytic light’s soft-shadow set is split ray by ray), and the next picks up where it stopped, from a running record the invocation keeps on the device. A dispatch costs its slowest lane’s slice, so the tracer runs as many samples at once as the GPU holds (up to client.editor.bakeDispatchInvocations, 262,144) and sizes each slice toward the pace’s dispatch time: the walk’s own units and every run of units all walks share (a direct walk’s sky rays, which every sample starts at the same unit, past the most any sample’s lights take, and its occlusion rays) each learn a slice of their own, and a chunk’s last few long walks get a slice of their own too. The result is the same bytes however a walk is cut: the sums run in the order a whole walk takes them, and MLTN City’s sidecar comes out identical at either pace and to the unsliced tracer’s. A pass whose one-unit slices of its smallest chunk still run past the target says so once (bake pace: one direct dispatch of 64 invocation(s) ran … past the 4 ms target, so that pass keeps 40 ms dispatches and rests between them) and is paced by the rests alone. The native chart packer (xatlas) and the denoiser manage their own threads, which the core share does not reach; the lowered priority does.
| Preference | Default | What it does |
|---|---|---|
client.editor.bakeDispatchInvocations | 262144 | The most samples or probes one GPU bake dispatch runs, at either pace. Past what fills the GPU, more of them only cut each walk finer; a GPU is latency-bound on a walk, so the more there are the more rays a second it traces (MLTN City’s direct pass: 152 Mrays/s of GPU time in whole walks of about 48k samples, 342 sliced across 262k). |
MLTN City at 4 texels/m with its underground zone at 6, on an RTX 4080 SUPER with 16 logical cores, undenoised:
| Pace | Total | Load + pack | Bake stage (direct, bounce) | Encode + write | GPU dispatches: count, mean, p95, max | GPU duty in the bake stage |
|---|---|---|---|---|---|---|
| Full | 127 s | 41 s | 61 s (16 s, 31 s) | 25 s | 1,473 of 28 ms, p95 79 ms, max 146 ms | 67% |
| Gentle | 183 s | 43 s | 113 s (33 s, 60 s) | 27 s | 11,619 of 3.7 ms, p95 5.8 ms, max 16 ms | 39% |
Before the walks were sliced, the same bake took 167 s at full speed (bake stage 100 s: direct 32 s, bounce 53 s) and 286 s gentle, whose lightmap dispatches could not get under 40 ms (p95 60 ms, max 653 ms).
Emissive surfaces
Section titled “Emissive surfaces”A material’s emission lights the bake when the material sets bakeEmission: its emissive color times emissiveIntensity, masked by its emissive texture. A part’s dh.bake emission — Bakery’s light mesh — makes its triangles emit even when their material does not, and wins over the material for that node: its color (sRGB) and intensity replace the material’s where they are given, and its samples replaces emissiveSamples.
The bake gathers each node’s emissive triangles of one material into a group and samples it directly, like a light: by area, with a shadow ray to each sampled point. A group reaches as far as its whole area, face on, still delivers 0.001 in the atlas’s units, and a group far away from a texel spends fewer samples, since its penumbra is too small to see. A surface emits from its front, or from both faces when its material’s renderFace is both. The emission reaches the atlas through that direct sampling only — a bounce ray that lands on an emitter reads its lit surface, not its glow — and the emissive surface itself keeps glowing at runtime from its material as it always did, so nothing is counted twice.
Baked ambient occlusion
Section titled “Baked ambient occlusion”With aoSamples above 0 the bake measures, per texel, how much of the hemisphere is open within aoRadius, through cosine-weighted rays that pass alpha cutouts. The runtime applies it to the indirect light only: the atlas’s sky and bounce share (the third layer records what share of L0 that is) and the realtime ambient fill, never the baked direct light. It joins the material’s occlusion map and the screen-space pass by min, the way those two already combine, so no crease is darkened twice by the two measures of one thing. client.render.lightmapOcclusion (0–1, default 1) fades it toward none live.
Denoising
Section titled “Denoising”With denoise on (the default) the finished atlas goes through Intel Open Image Denoise’s RTLightmap filter before its seams are stitched, in Bakery’s order: the gutter is dilated first so the filter never pulls a chart’s edge toward black, and the stitch comes after so the filter cannot undo it. Each page’s L0 is denoised as an HDR image and its MonoSH direction in the filter’s directional mode, normalized by the L0 it came with and scaled back by the denoised one. OIDN has no ambient-occlusion filter, so the occlusion layer (visibility and indirect share, both plain values in [0, 1]) goes through the same HDR filter. The denoiser picks the fastest device it has — CUDA on an NVIDIA GPU, Metal on Apple silicon, otherwise every CPU core — and the bake log’s phase line names it and its time. A machine without the library bakes undenoised and says so in one log line; the bake never fails over it.
The procedural sky
Section titled “The procedural sky”An outdoor map can replace its flat clear color with the engine’s procedural sky — a single-scattering Rayleigh + Mie atmosphere driven by the same sun the shadows come from. It is opt-in per map: a map that says nothing keeps the clear color, which is what an interior or a night scene wants.
"lighting": { "sky": true, "skyRayleigh": 1.0, "skyMie": 1.0, "skyMieAnisotropy": 0.76, "skyHorizonSoftness": 0.15, "skyIntensity": 1.0, "skySunDisc": 30, "skySunAngularDiameter": 0.53, "skyGround": [0.22, 0.20, 0.18], "skyGroundBlend": 0.01, "ambientMode": "auto"}| Field | Meaning |
|---|---|
sky | Draws the procedural sky behind the geometry. Default off. |
skyRayleigh | Molecular scattering density; 1 is Earth’s atmosphere. Higher deepens the blue overhead and reddens the horizon harder. |
skyMie | Aerosol (haze) density. Higher washes the horizon toward white and widens the halo around the sun. |
skyMieAnisotropy | The haze lobe’s forward bias, [0, 1). Near 0.8 gives the tight halo a real sun has; 0 spreads it over the whole sky. |
skyHorizonSoftness | How fast the air mass grows toward the horizon. Small values give a hard bright band at the horizon line; larger ones spread the glow upward. |
skyIntensity | Overall multiplier on the sky’s radiance. |
skySunDisc | Brightness of the solar disc, on top of the sun’s own color and intensity. Large by default so the disc clears the bloom threshold. |
skySunAngularDiameter | The disc’s angular diameter in degrees; the real sun is 0.53. Sizes the drawn disc and the sun’s specular highlight on every surface — see below. |
skyGround | Linear RGB albedo of the ground below the horizon. |
skyGroundBlend | Width of the band the sky fades into the ground across — an antialiasing width for the horizon line, not a look knob. |
ambientMode | Where the ambient fill comes from: auto, hemisphere or sky. See below. |
The sky’s own sun direction, color and intensity are the map’s sun — there is no second sun to keep in step, and moving world.sun.direction from the console moves the sky with it.
The sun is a sphere light
Section titled “The sun is a sphere light”The sun is shaded as a disc of real angular size, not as a directional delta. Its specular lobe is evaluated from the point on that disc closest to the reflection ray and widened by the disc’s angular radius, with the energy normalized back (Karis’ representative-point approximation); the diffuse half is untouched, because a disc this small integrates to the same irradiance a delta light does. A delta light’s highlight is a pixel wide and aliases; a sphere light’s has a size, and is antialiased at the source.
The size is world.sky.sunAngularDiameter — the same number the sky dome draws its disc from, so a sun that reads wider in the sky glints wider on everything under it and there is no second knob that can disagree with the first. It applies whether or not the sky is drawn. At the default 0.53 the widening is far below what any shadeable material can show, so ordinary scenes are unchanged; widen it to a few degrees for a hazy, smeared glint.
Every field seeds a world.sky.* preference (world.sky.enabled, world.sky.rayleigh, …, world.sky.groundBlend) plus world.ambient.mode, on the same terms as the rest of the lighting block: replicated, server-authoritative, live-overridable from the console, never written back to the map.
Sky-derived ambient
Section titled “Sky-derived ambient”With the sky on, the engine projects the analytic sky radiance into order-2 spherical harmonics on the CPU and shades against that instead of the two-color hemisphere fill. Nine coefficients per channel, projected from 1024 uniformly distributed directions, convolved with the clamped-cosine lobe — so the ambient a surface receives is the actual sky above it: bluer facing up, warmer facing the sun, darker on the side away from it. The projection deliberately excludes the solar disc, because the sun is already a shadowed direct light and an order-2 fit of a disc is a smooth wash no shadow could remove.
It is re-projected only when the sky parameters or the sun change, not per frame.
ambientMode decides which fill is used, and the map’s explicit ambient wins:
- A stored console value for
world.ambient.modeoverrules everything, as everyworld.*preference does. - Otherwise an explicit
ambientModeofhemisphereorskyin the map is honored verbatim. - Otherwise (
auto, or the field omitted) the sky’s harmonics are used only if the sky is on and the map authored noambientSky/ambientGround— a map that went to the trouble of picking ambient colors keeps them. - With the sky off, the hemisphere fill is always used. There is nothing to project.
ambientIntensity scales whichever fill is active, so it remains the one knob for “how much fill light”.
The image sky
Section titled “The image sky”A map that already has the sky it wants as a picture — a captured HDRI, a night sky, a render nobody is going to reproduce out of Rayleigh coefficients — points at it instead:
"lighting": { "skyImage": { "path": "textures/nightSky.exr", "exposure": 0.1, "rotation": 225.0 }, "ambientMode": "auto"}| Field | Meaning |
|---|---|
path | Pallet-relative path to an equirectangular (lat-long) HDR image, which the compiler resamples into a cube (see below). .exr and .hdr are accepted; an 8-bit format is rejected, because a sky’s bright values are many times white and an 8-bit encoding cannot hold them at all. A source that is not 2:1 is a warning, not an error — it still maps, it just stretches. |
exposure | Linear multiplier on the image’s radiance. Default 1.0. |
rotation | Yaw about the up axis, in degrees. Default 0. |
skyImage and sky: true cannot both be set — two skies would draw over each other and project two different ambients, so naming both is a build error rather than a race. Writing sky: false beside an image is redundant but accepted.
The source is equirectangular but what ships is a cube: the compiler resamples it into six faces of width / 4 rounded to a power of two, each texel integrating the whole patch of sky it covers. That is not an optimization — a lat-long image crowds its top and bottom rows into a singularity, and a camera looking straight up fans them into radial streaks no sample-time filter can undo, while a cube face at the pole is just another square of sky.
The faces are stored as RGBA16F with a full mip chain, box-filtered in linear radiance — no block compression, because a sky is a smooth gradient and BC7 bands it visibly. That costs real memory: a 4096×2048 source becomes 1024² faces, about 67 MB in the pallet uncompressed (it packs far smaller on disk, since a night sky is mostly dark). Halve the source’s width before reaching for a second look at it.
The two fields seed world.sky.imageExposure and world.sky.imageRotation, so both are live from the console like the rest of the lighting block.
Ambient from an image sky
Section titled “Ambient from an image sky”An image sky projects into the same order-2 harmonics the procedural one does, from a small mip rather than from the analytic model, so nothing downstream can tell which sky lit the world. ambientMode resolves exactly as it does above, with “the sky is on” meaning either sky.
Baked lighting is the exception: giVolume and the lightmapper integrate the analytic sky only, so a map lit by an image sky bakes as though it had no sky at all. Its live ambient is still the image.
What water mirrors
Section titled “What water mirrors”Water’s reflection term takes whichever sky the map actually has: the image when skyImage is set, the analytic atmosphere when sky is on, and — when the map has neither — the ambient hemisphere blended by the mirror direction, rather than an atmosphere nobody asked for. An interior pool now mirrors the room’s own fill instead of a blue sky nobody can see.
Where a water body’s shore is
Section titled “Where a water body’s shore is”Waves taper to nothing over the material’s shoreFade meters of the body’s rim, and that taper rides a shore channel: each water vertex’s horizontal distance, in meters, to the boundary of its own body. A box brush gets the channel from its footprint when the brush is built; imported geometry authors nothing of the kind, so a GLB water body used to meet its bank at full amplitude and climb out of the basin holding it. The map-mesh cut now measures the channel rather than reading it: once a body’s top surface is tessellated it walks that surface’s boundary edge loop — the rim — and writes every cut vertex its XZ distance to the nearest rim segment; the body’s remaining faces (a basin’s walls, a box’s own sides and floor) are measured the same way instead of carrying whatever the source left in uv1. It is the same field in the same place a brush writes, so the shader sees one channel and never a second path, and there is no per-body switch: a body whose waves are meant to reach the edge says so with shoreFade: 0. Alongside it each vertex carries whether its face is the body’s up-facing SURFACE or one of its walls and floor: the shore distance is zero on a rim vertex and on every wall and floor vertex alike, so the waterline clip that ends the surface at the bank is applied to surface fragments only — a pool’s own sides and floor are bounded by its mesh, not by its bank.
Baked indirect light
Section titled “Baked indirect light”Both fills above are one value for the whole map: the same ambient reaches a rooftop and the back of a sealed basement. The optional lighting.giVolume block replaces that with a baked light probe volume — a regular 3D grid of spherical-harmonic probes baked in the editor, blended per fragment at runtime, so a room that cannot see the sky is genuinely dark, a wall near a lit floor picks up its bounce, and a player standing under a baked lamp is lit by it.
"lighting": { "giVolume": { "enabled": true, "probeSpacing": 4.0, "padding": 2.0, "bounceAlbedo": 0.5, "samples": 256, "intensity": 1.0 }}| Field | Range | Default | Meaning |
|---|---|---|---|
enabled | on/off | true | Whether the volume is baked. Present so a map can switch the bake off for a fast iteration build without deleting the block and losing its numbers. |
probeSpacing | 0.1–64 | 0.5 | Distance between adjacent probes, in meters, which is also how wide the edge of a baked lamp’s light on a player is. Halving it multiplies both bake time and blob size by eight. |
padding | ≥ 0 | 2 | How far past the geometry bounds the grid extends, so surfaces on the map’s outer shell sit inside the volume rather than exactly on its face. |
bounceAlbedo | 0–1 | 0.5 | The gray albedo the CPU reference bake attributes to every surface. The GPU bake reads each surface’s own material instead. 0 disables the reference’s bounce. |
samples | 16–4096 | 256 | Rays cast per probe. Trades bake time against noise; order-2 harmonics filter hard, so this can stay low. |
intensity | ≥ 0 | 1 | Artistic multiplier on every gathered probe. The physically derived answer is 1. |
The block carries no origin or extent. By default the grid is derived from the map’s own geometry bounds grown by padding — an author who moves geometry should not also have to move a box around it. If the derived grid would exceed the per-axis or total probe caps, the spacing is coarsened and the compiler says so.
A map that wants its probes somewhere more specific authors dh.lightProbeVolume boxes instead. That is the whole choice: as soon as one box exists the derivation stands down, padding stops applying, and the volume is exactly the boxes — each with its own probeSpacing, or this block’s as a fallback. The fields above still describe how every box is gathered — and a map that draws boxes and writes no block at all gathers them at the defaults in that table, so the block is worth writing only to change one.
A part a part override marks "static": false is left out of the bake entirely — its triangles neither block nor receive the rays that populate the probes, the same exclusion the lightmap honors. A water-slot face is excluded too, for the same reason it is excluded from the lightmap, and so is a skybox-slot face, a backdrop no probe’s sky may be shut out by.
Baking the probes
Section titled “Baking the probes”The probes are an editor bake, like the lightmap and the reflection probes: the Lighting window’s Render Bake makes them when its Probes switch is on, on this machine’s GPU (the CPU reference only where there is no device), and writes them beside the map source as <map>.dh-map.dh-lightprobes. That file is the volume: Build packages it byte for byte as the map’s companion and never bakes. With the Lightmap switch on too, the probes are gathered after the lightmap in the same press and take their bounce from its finished atlas. Headless, DigitalHeaven.Engine.Host --bakeLightProbes --map <id> bakes them (add --bakeLightmaps for both, --render <dir> to photograph the result), under the same client.editor.lightmapBakeMemoryMb ceiling a lightmap bake answers to.
A build ships the bake only while it still describes the map, and otherwise ships the map lit by the flat ambient where its probes would be, with a warning saying why:
| The sidecar… | What ships |
|---|---|
| is missing | no probes; warns and names the bake |
| is another format version, or unreadable | no probes; warns |
| was baked in different geometry | no probes; warns |
| covers boxes that have since moved, been resized, respaced, rezoned, added or removed | no probes; warns that it is stale |
took the direct light of a different set of lights (a lamp added, removed, or switched between realtime and baked) | no probes; warns that it is stale |
| matches | the probes, as baked |
A lamp that only moved does not stale it, exactly as it does not stale the lightmap: the light the bake holds is the light it held when you pressed Render Bake. Clear Bake deletes the sidecars its switches name and drops them from the running world.
What the probes contain
Section titled “What the probes contain”Each probe stores nine RGB coefficients in the same order-2 basis, already convolved with the clamped-cosine lobe, as the sky-derived ambient above — the two are interchangeable to the shader, which is the point. A probe holds the map’s full baked light, the model VRC Light Volumes uses:
- Every baked light’s direct arrival, shadow-traced from the probe: each
"mode": "baked"lamp, the sun when an enabled lightmap bakes it (bakeSun), and every emissive surface. The runtime shades none of these live, so the volume is the only way they reach a player, a prop or a brush. - The sky. Rays that escape the geometry collect the procedural sky if the map enables it, the hemisphere fill otherwise.
- The bounce. A ray that lands on a surface collects that surface’s light times its albedo — read from the finished lightmap where the surface is in the atlas, and shaded from the lights where it is not. A realtime light is live on everything, so it only bounces here: it lights the surfaces a probe’s rays land on, never the probe.
Each channel’s band 1 is held within twice its band 0, the bound of a single point source, so a lamp’s dark side never rings negative. A lightmapped surface never takes a volume that holds direct light: the atlas already carries that light, and taking both would count it twice. A probe-lit surface — a player, a prop, a brush, a node out of the atlas — takes it from the volume, once.
Beside the harmonics every probe stores six depth bytes: how far it sees along each axis before a surface, a fraction of its box’s spacing, saturating at one cell.
A probe that sees back faces is moved out before it is kept (virtual offset). When a quarter of its rays land on the inside of a surface, the bake moves it just past the nearest of those faces, along the ray that found it, and gathers it again there; the lattice does not move, only where its light comes from. A probe whose nearest way out is more than half a cell away is deep in a solid and is left where it is.
A probe that is still inside solid geometry is marked invalid and dropped from the blend, with the surviving corners renormalized. That is why the sampler is a hand-written trilinear blend rather than a filtered 3D texture — hardware filtering cannot skip a corner. The bake then fills it from its neighbors (dilation): each invalid probe that touches a valid one takes their mean, weighted by closeness, two rings deep, and is marked dilated but stays invalid. A dilated probe answers only where no corner of the cell is valid, so a surface buried among dropped probes reads its neighbors’ light instead of the flat fill.
A probe is judged buried by two rules, because one is not enough:
- Backfaces. Two thirds of its rays landing on the inside of a surface means the probe is behind that surface.
- Sealed and empty. No ray escaped the geometry and not one ray brought any light back. That is a probe in the dead space between two overlapping blockout solids: it sees its neighbor’s front faces over much of the sphere, so the backface ratio alone calls it usable, and it publishes an authoritative pitch black. A genuinely dark room is not affected — one lamp anywhere its rays can reach keeps the probe, and a probe that can see the sky at all keeps itself.
Where it applies
Section titled “Where it applies”Inside the volume, the blended probe replaces the flat ambient term. Outside it, the existing chain (sky harmonics or hemisphere fill) is used unchanged, so a map is never worse off at its edges. Ambient occlusion keeps multiplying whichever ambient wins.
The replacement is weighted by how much of the cell survived the validity test, not switched on it. Where every corner is valid or dilated — the ordinary case — the volume is the whole ambient; where corners were dropped and not filled, the flat fill blends back in by exactly the missing weight. Switching instead would step the ambient across a cell boundary, and a single misjudged probe would draw a hard polygon edge on lit geometry.
Overlapping boxes are a hard pick, not a blend. A fragment inside more than one region is answered by the one it sits deepest inside — the largest distance to any of that box’s six faces — so a dense box nested in a coarse one wins everywhere it covers, which is the whole reason to nest one. Blending the two would average a spacing you chose against a spacing you chose to replace.
A region’s own faces fade rather than step. Coverage ramps up over the outermost client.render.giBoundaryFade cells of whichever region answered, so a box’s wall dissolves into the flat ambient instead of drawing its own rectangle on the floor. The width is authored in cells and converted per region using that region’s shortest cell edge, because a cell is a different distance in a 1 m box and a 4 m one. 0 restores the hard edge. Inside a gap between boxes — a position in no region at all — coverage is zero and the flat ambient is the whole answer, exactly as it is outside the set.
The lookup is biased off the surface. The region is picked at the fragment itself, but the probes are read at a point moved client.render.giNormalBias cells along the surface normal and client.render.giViewBias cells toward the eye, held inside that region (Unity’s APV normal and view bias). A wall’s dark face therefore reads the probes on its own side rather than blending in the lit ones behind it. This is the first of four leak fixes; the bake’s virtual offset and dilation are the second and third.
Each corner is weighted by whether it can see the lookup (six-direction depth, the fourth). On each axis the lookup’s distance from a corner probe is compared with how far that probe saw on that side; past it, the corner’s weight falls to nearly nothing over client.render.giDepthSoftness cells. A probe on the far side of a wall thinner than a cell sees the wall before the lookup and gives its weight to the probes on the near side. The depth weight only moves weight between corners, never the coverage, so a surface beside a wall stays inside the volume.
A volume casts a highlight and takes the look’s wrap, as the lightmap does. Its dominant direction — band 1 over twice band 0, luma-weighted, so a lone lamp’s is unit length toward it and an even sky’s is none — goes through the lightmap’s own baked highlight, which dims and widens the lobe as the light gets less directional; client.render.lightmapSpecular is the one switch for both. The look’s bakedWrap lifts the volume’s band 0 exactly as it lifts a lightmap texel’s, so a baked lamp reads the same on the floor as on the player standing on it.
Each corner is clamped on its own. The eight probes around a fragment are each evaluated against the normal and clamped at zero before they are blended, so one probe’s ringing (a negative lobe on its dark side) cannot cancel its neighbor’s light.
On the GPU a probe is 64 bytes: its 27 coefficients stay binary16 exactly as the bake stored them, packed two to a word beside a validity byte and its six depth bytes, so a volume at the cap is 16 MiB and the eight corners around a fragment cost 32 loads (64 while a set fades). Each region’s header also carries its zone, where each of the zone’s sets holds its probes, and a tint and an intensity (white and one today) for the volume controls to come.
The bake is a companion blob beside the map in the compiled pallet, not part of the map JSON, and nothing about it travels over the network — a client gets it with the map. It carries every region in one file: each box’s placement and zone, then one blob of probes per (zone, set): every zone’s default set, then each zone’s other sets as copies of its probes, so a map with several boxes is still a single blob and a single read. At 61 bytes a probe on disk, a 10 × 4 × 10 m room at the default half meter is about 190 KB, and each of its zone’s other sets adds as much again; every set’s copy counts toward the 262,144-probe cap, and a set past it is left out with a warning. The blob is versioned and the reader is strict about it — a pallet compiled by an older toolchain is refused with a warning naming the file and telling you to recompile, rather than being read as if its probes landed where this build expects.
Ten client preferences ride on top:
| Preference | Default | Meaning |
|---|---|---|
client.render.giVolume | true | Whether baked volumes are sampled at all. false falls every surface back to the flat ambient — the honest A/B for judging a bake. |
client.render.giIntensity | 1 | A local multiplier on the sampled result, for eyeballing a bake without recompiling. |
client.render.giBoundaryFade | 1 | How far in from a region’s faces its fill ramps up, in cells, 0–4. |
client.render.giNormalBias | 0.3 | How far the probe lookup moves along the surface normal, in cells, 0–1. |
client.render.giViewBias | 0.1 | How far the probe lookup moves toward the eye, in cells, 0–1. |
client.render.giDepthWeight | true | Whether each corner is weighted by whether it can see the lookup. false is the A/B for a leak through a thin wall. |
client.render.giDepthSoftness | 0.25 | How far past a probe’s free distance its depth weight fades out, in cells, 0.05–1. |
client.render.giGroupNormalBias | 0.05 | How far a light probe group lookup moves along the surface normal, in meters, 0–0.5. |
client.render.giGroupSoftness | 0.0254 | The softening distance of a group’s inverse-square weighting, in meters, 0.001–4. |
client.render.giPerPixel | true | Whether a group blends its cell’s probes at every pixel. false lights each cell as one (per leaf); the Low and Medium presets set it. |
Adopting a look by name
Section titled “Adopting a look by name”Before writing any of the numbers below, consider whether a named look already is what you want. The optional top-level look field adopts a whole art-direction preset by slug:
"look": "unity"| Slug | What it reproduces |
|---|---|
neutral | The engine defaults, stated explicitly. A no-op that documents intent. |
unity | Unity’s built-in pipeline: no tone curve (its reference post profile has no Tonemapper at all), plain Lambert shading (halfLambert 0), its legacy light attenuation (falloff "unity"), and the PPv2 bloom shape — threshold 1.721, knee 0.5, diffusion 6.2, anamorphic -0.24. |
source | Source-style shading: a full half-Lambert wrap, through the engine’s default curve. |
filmic | Hable’s Uncharted 2 curve (tonemap "hable") with its published coefficients, and no exposure of its own. See The hable block. |
A look reaches both the render and lighting blocks, because a look spans them — the curve and bloom are render, the diffuse wrap and the light falloff are lighting. It carries the response chain only: it never touches your sun direction, your sun or ambient colors, or the intensity scales, because those describe a particular scene rather than a reusable look. A map ported with its source engine’s ambient values keeps them.
The field also takes a reference to a dh.look asset, so you are not limited to the three built-ins:
"look": "looks/night.dh-look" // a look in this pallet, read from the map's own folder"look": "/looks/night.dh-look" // the same path read from the pallet root instead"look": "dev.example.looks:looks/night.dh-look" // one from a dependencySlugs win: a token that names a built-in look resolves to it, which is why a pallet-authored look may not be named neutral, unity, source or filmic. A pallet can also name a look in its pallet.dh, which every map in it picks up — that layer sits below this one.
The world’s look
Section titled “The world’s look”Where lighting authors what the map is lit by, the optional top-level render block authors how that light is resolved to the screen — the map’s exposure, its tonemap curve and its bloom. Every field is optional; an omitted one keeps the engine default, or the look if the map adopts one.
"render": { "exposure": 2.0, "tonemap": "reinhardWhite", "bloom": true, "bloomIntensity": 0.30, "bloomThreshold": 1.721, "bloomSoftKnee": 0.5, "bloomDiffusion": 10, "bloomAnamorphic": -0.24}| Field | Range | Default | Meaning |
|---|---|---|---|
exposure | > 0 | 1 | Linear multiplier applied to scene radiance before the tonemap curve. |
tonemap | — | "reinhardWhite" | Curve name: reinhardWhite, aces, reinhard, hable or none. Matched case-insensitively, so "ACES" is accepted. |
hable | block | Hable’s published curve | The coefficients and white point of the hable curve: shoulderStrength, linearStrength, linearAngle, toeStrength, toeNumerator, toeDenominator and whitePoint, each optional. Ranges and defaults are in The hable block. |
bloom | on/off | true | Whether this world’s bright areas bloom at all. false is exactly the pre-bloom image, not a very faint one. |
bloomIntensity | ≥ 0 | 0.30 | Linear multiplier the resolved bloom pyramid is added back with. |
bloomThreshold | ≥ 0 | 1.721 | Linear radiance above which a pixel blooms, tested on its brightest channel. |
bloomSoftKnee | 0–1 | 0.5 | Width of the quadratic knee below the threshold, as a fraction of it. 0 is a hard cut (and pops). |
bloomDiffusion | 1–10 | 10 | How far the bloom spreads, in pyramid levels rather than pixels. At the default it saturates against the pyramid’s level cap, so the glow’s screen fraction varies mildly with resolution rather than being resolution-independent. |
bloomAnamorphic | -1–1 | -0.24 | Aspect distortion of the glow. 0 is round; away from zero it stretches — see the anamorphic ratio. |
A value outside the range above is a compile error, not a silent clamp — the compiler validates the render block the same way it validates colliders.
These are art direction, not preference — the map author picks the mood, so they live on the world rather than on each viewer. Exactly like the lighting block, they seed the replicated world settings world.render.exposure, world.render.tonemap, the seven world.render.hable* values and the six world.render.bloom* values when the map installs: a console edit afterwards is a live override on top and is never written back to the map.
A player’s own client.render.* counterpart then sits on top of that, locally only. Every one of them is unset by default and an unset one simply follows the world, so a map’s look reaches everyone who has not deliberately opted out. See the color pipeline for the resolution order and the console readouts.
Debugging with gizmos
Section titled “Debugging with gizmos”The level editor draws a debug overlay over the world for authoring and debugging logic, and the client preference client.debug.entityGizmos (off by default; client.debug.entityGizmos 1 in the console) brings the same overlay up during ordinary play. For every in-view logic entity that replicates — an object carrying a dh.button, a dh.mover, a dh.pressurePlate or a dh.light, and a body-carrying instance — it renders a colored dot at the entity’s live pivot — a door’s dot rides its slide, and a physics prop’s rides the body wherever the solver has carried it — a label above it with the entity’s name and its live replicated values, one per component in wire order (a mover’s open percentage, a light’s on/off, a button’s pressed/idle, a pressure plate’s live occupancy count, a physics prop’s moving/resting, so a button that moves reads open 40%, idle), and a wiring line from each output owner to every positioned entity its wiring reaches. Gizmos fade out with distance, and a label reaches only client.editor.gizmoLabelDistance (30 m by default, half the dot’s reach) and gives way to a nearer one it would overlap (see the gizmo labels). The non-visual components (a timer, a map start, the gates) replicate nothing, so they show no dot; a wire that passes through one is followed transitively to the placeable entity on the far side, so a button wired button → timer → gate → light draws a single line straight from the button to the light — a terminal sink like the light always has its incoming wire drawn even though its only authored source is positionless. A boxBrush whose collider state is "trigger" and that wires something is a wire end too, drawn at its authored center. In the level editor the lines are inked and filtered by the selection, and the Inspector’s Wiring card states the same wiring directly — a lamp wired from a gate names the gate there rather than the button behind it.
A second overlay, client.debug.lights, draws each lit light’s influence volume into the depth-tested line pass — a range sphere for a point light, the outer cone for a spot — tinted with that light’s own color. A light that is switched off is not drawn at all, so it doubles as a readout of which lights are actually contributing this frame; a directional light has no bounded volume and draws nothing.
The overlay draws into Halcyon’s overlay draw list: above the 3D world, and recorded beneath the widget tree — so the menu, the settings screen and the console occlude the gizmos rather than being punched through by them, and the DigitalHeaven overlay’s own windows sit in the same list at the same depth.
Both belong to the wider client.debug.* family — see Debug Overlays for the rest of it, including the axis gizmo and the position readout that make authoring coordinates readable off the screen.
Backdrop
Section titled “Backdrop”A backdrop is a small scene drawn behind the world: the distant city, the hills and the mountains past a map’s walls. Source calls it the 3D skybox: a miniature room with a sky_camera in it, drawn from sky_camera.origin + eye / scale before the world. DigitalHeaven’s backdrop is the same model, for any map:
"backdrop": { "anchor": [-41.783, 279.1765, 36.2712], "scale": 16, "parts": ["backdrop"], "folder": "b3f85aaa-4c02-0457-92cf-e60e783d2e3f", "fog": { "color": [0.5569, 0.6431, 0.698], "start": -101.6, "end": 8128, "maxDensity": 1 }}| Field | Meaning |
|---|---|
anchor | The point the eye maps to, in the map’s own meters: the world origin is seen from here, and the backdrop’s camera moves 1 / scale of every step the eye takes. Valve’s sky_camera origin. |
scale | How many times larger than it is built the backdrop appears: the parallax ratio. Default 16, Valve’s. Must be positive. |
parts | Paths of parts of the map’s world instance that belong to the backdrop, each with everything under it. They are hidden from the world and drawn behind it instead, on the map’s lightmap like any other part. A path is what a part override’s path names: the root part of meshes/x.glb is x. |
folder | The id of a folder record. Every record under it, at any depth, draws in the backdrop: today the object instances. The compiler rejects an id that names no folder. |
fog | The backdrop’s own linear distance fog, which never reaches the world: color (sRGB, [0, 1]), start and end in meters, maxDensity in [0, 1]. A surface takes clamp((distance - start) / (end - start), 0, maxDensity) of the color, measured from the eye to the surface as it appears, at full size, which is how Valve’s sky_camera fog is authored. end must exceed start; start may be negative. |
The backdrop is a map setting, like lighting and render, rather than a component: a map has one, the way Source has one sky_camera. Its parts are named by path, the anchor a part override uses, so they name parts of the world instance; its records are ordinary records with a parent, so the folder is an ordinary folder record.
How it draws. Inside the scene pass, before anything of the world: the backdrop’s opaque surfaces, then the sky behind them (the procedural sky or the image sky, whichever the map has, filling what the backdrop left), then its blended surfaces back to front, then depth is cleared, and the world draws over all of it exactly as it would over an empty sky. Every surface the world leaves open shows the backdrop, and the backdrop’s own open sky shows the sky. A parallel (orthographic) view has no eye to follow and draws no backdrop. See the engine overview for the mechanism: rather than a second camera, each backdrop draw is carried through scale · (p − anchor) and drawn from the real eye, which is the same picture.
Lighting. A part is lit by the map’s lightmap, which Source bakes the skybox room into along with everything else. An object in the backdrop is lit like any placed object: the map’s sun and lamps live, and its light probe group, which it samples where it appears (at full size, far from the room it is built in), so it usually reads the edge of the group or the sky’s ambient. A backdrop casts no shadow into the world and receives none from it, beyond what its lightmap holds.
What it is not. A backdrop has no collision and no simulation. Its records never join the logic order, so nothing stands, replicates, moves or wires them: they are scenery the client draws from the map every frame. A part listed in parts is still part of the geometry the map’s automatic collision reads, so a map with a backdrop sets autoCollision: false or gives its world its own colliders (every Source import does both).
Not yet drawn in a backdrop: water and glass surfaces (the importer’s skybox rooms rarely have them, and the engine logs any it skips), shadows of its own, and animated entities.
Planned: dh.sound
Section titled “Planned: dh.sound”A dedicated dh.sound audio asset type is designed but not yet implemented. Until it lands, audio clips are consumed as plain binary resources. See the Engine Assets & Audio page for the runtime audio stack.