ADR 0006: Baked buildings and levels of detail
Status: accepted, 2026-09-24.
Context
A production game ships baked resources and draws distant things cheaply.
Materials already do the first: ashlar_material::export writes KTX2 maps and
strand sets in a content step, and the featureless ashlar-bevy loads them
with no graph engine in the game (ADR 0003, 2026-09-24 amendment). Geometry
did not. The only way to get a building's triangles was to run
ashlar-manifold, which is the Manifold C++ kernel, in the game at startup,
and there was one level of detail: the building as authored, drawn at every
distance, so a metropolis of ten thousand instances drew twenty million
triangles.
Decision
A building is baked like a material
ashlar::BakedBuilding is what a mesher made of a recipe, plus coarser levels
of the same building. It holds the validated recipe, one BakedLevel per level
of detail (element meshes per part and merged groups, exactly the fields of a
MeshedBuilding), and level zero's collision proxies and portals.
BakedBuilding::level(n) answers a MeshedBuilding, so every consumer of a
freshly meshed building — pieces(), colliders(), portals(),
ashlar_bevy::drawables — reads a baked one unchanged.
It lives in ashlar, not in the mesher, because it names nothing a kernel
owns; that is the same reason MeshedBuilding does.
The file
BakedBuilding::write and read: twelve identifying bytes shaped as KTX2's
and the strand set's, a version, a length-prefixed RON manifest, and a
length-prefixed binary mesh table. The manifest carries the recipe and every
level's structure with each mesh replaced by an index; the table carries the
meshes as little-endian arrays. The manifest is indented RON, so
structure stays readable and diffable, bulk
stays compact, and the reader refuses what it cannot account for — identifier,
version, every length, every index — by kind rather than panicking.
Positions, normals and UVs narrow to f32 on disk. They narrow to f32 on
their way to the GPU anyway, and building-local metres at that precision are
tens of microns across a city. Indices and face provenance are stored as
written. A read revalidates the recipe, so a file written by one version of the
rules and read by another says so.
No new dependency: ron was already in the workspace, and the container is a
few dozen lines beside the strand file's.
Levels of detail are the recipe, simplified
A coarser level is not a decimated mesh. The recipe is procedural, so a
simpler building is a simpler recipe, and ashlar::LodPolicy says how:
min_feature(metres): an element or union member thinner than this is left out (amended 2026-09-25, see below), and so is a cutter whose opening is narrower than this. An opening is judged by the middle of the cutter's three extents, because a cutter runs past both faces it opens and its depth says nothing about the hole: a vent's 20 cm, a door's metre. A hole that is no longer cut is carried by the facade texture at the distance the level draws from.segment_scale: every cylinder and revolve keeps this share of its segments, never fewer thanMIN_SEGMENTS(eight) and never more than were authored.drop_interior: everySide::Interiorelement goes.until(metres): where the level stops drawing;Noneon the last level.
Geometry::simplified, Part::simplified and Building::simplified apply a
policy. A part nothing of which survives is removed, and so are the instances
that placed it; surviving instances keep their resolved poses with their
attachments cleared, and only the slot overrides their parts still use. A
baked level simply has no entry for a vanished part, and
MeshedBuilding::pieces already skips a part it has no meshes for.
ashlar_manifold::bake(building, ladder, mesher) meshes level zero exactly as
mesh_building does, colliders and portals included, and each later level by
meshing the simplified parts. A part whose simplification equals the previous
level's is not meshed again, and a merge group is re-meshed only when one of
its members changed. The mesher evaluates simplified parts exactly as it
evaluates authored ones, so every level is a closed, correctly shaded and
correctly mapped solid, and a level's texture lines up with the next one's.
Collision proxies and portals are level zero's at every level: physics and visibility run at every distance.
LodPolicy::ladder() is a default of three levels: as authored to 60 m; no
interior, nothing under 35 cm and half the segments to 250 m; nothing under
1.5 m and a quarter of the segments beyond. Measured on the showcase
(triangles drawn, every instance counted, per level):
| Scene | Level 0 | Level 1 | Level 2 | Bake | File |
|---|---|---|---|---|---|
| metropolis (4 x 4 blocks) | 3,813,034 | 1,351,694 | 914,074 | 0.25 s | 9.1 MB |
| city block | 306,442 | 108,066 | 71,508 | 0.10 s | 4.8 MB |
| sci-fi colony | 119,182 | 53,718 | 25,088 | 0.26 s | 6.7 MB |
| corporate block | 41,132 | 41,000 | 40,208 | 0.01 s | 0.3 MB |
The corporate kit barely coarsens: it is chamfered boxes with storey-sized openings, and nothing in it is small. The 8 x 8 metropolis bakes in 0.3 s to 12.4 MB. Much of a file is its RON manifest, since the recipe is in it whole. File sizes are as of the 2026-09-25 review fixes, which store a mesh that two levels share once and indent the manifest.
A game draws levels with VisibilityRange
Bevy 0.19's VisibilityRange gives each level a distance band with a
crossfade margin, per entity, so instances of one part at one level still batch.
The featureless ashlar-bevy loads a baked building as an asset and spawns
each level's pieces with their band.
Two details keep the bands honest. The distance is Bevy's, camera to the
piece's origin, and a merged group's mesh is in building space, so it is drawn
about the centre of its bounds at the first level it appears in (level zero
for a declared group; see the amendment of 2026-09-28 for the cells of a
concatenated level); otherwise a group a
block long would switch level by the camera's distance to the building origin.
We did not use VisibilityRange::use_aabb for this, because Bevy documents it
as breaking the crossfade unless every level has the same bounds. And a piece
a coarser level did not change is one entity whose band spans both levels,
not two identical entities crossfading into one another.
The file stores such a piece once as well: the mesh table writes identical records once, and every level that draws them names the same index.
Consequences
- A game links
ashlar,ashlar-surfaceand the featurelessashlar-bevy: no Manifold, no graph engine, no C++ toolchain in its build. - A content step runs
ashlar-manifoldandashlar-materialonce, and writes.ashlarbuildings beside the KTX2 materials. - Damage stays a runtime-kernel feature (ADR 0005): a game that cuts holes in play links the mesher, and bakes its undamaged state all the same.
Amendment, 2026-09-25: thin, not small, and Element::far
The first rule judged an element by its largest side, and the far levels hardly coarsened: a sill three metres long and twenty centimetres deep, a mullion, a strip light, a rail, all survived to any distance, well under a pixel wide. The 4 x 4 metropolis of the dark city drew 1.59 M triangles at its far level against 3.93 M at level zero.
An element or union member is now judged by the middle of its three extents, the narrower side of its broadest face: how wide it looks from where it looks widest. That is the rule cutters already had. A plate a finger thick but four metres square stays; a rail as long stays only as long as it is thick enough.
What glows is the exception, because a lit line reads from afar at a fraction
of a pixel. Element::far keeps an element and every member of it at every
level; only its segments coarsen. The dark city marks its crowns, corner
lines, neon signs, lanterns and lamps far.
Measured the same way as the table above:
| Scene | Level 0 | Level 1 | Level 2 |
|---|---|---|---|
| metropolis (4 x 4, the dark city) | 3,928,156 | 1,161,406 | 760,902 |
| city block (the dark city) | 402,248 | 119,424 | 80,654 |
| sci-fi colony | 119,182 | 48,130 | 19,292 |
| corporate block | 41,132 | 33,696 | 14,116 |
The corporate kit now coarsens too: its fins, mullions, sills and rails go.
Amendment, 2026-09-28: concatenated levels
A level simplifies triangles and leaves the number of pieces almost alone. The
game this library grew in draws a city of 32 baked buildings, and their
coarsest levels, from 250 m to any distance, are still 7,272 placed pieces
between them (the game's own count over its baked files, 2026-09-28, against
15,072 at level zero): the kits' walls, slabs and frames survive
min_feature by design, and each is an entity. The game measured a frame's
CPU cost there scaling with the entities in band, about half a millisecond
per thousand on the render thread. The far level was cheap in triangles and
expensive in everything else.
Decision
LodPolicy gains cell: Option<LodCell>, defaulted to None, so every
ladder written before it reads unchanged and draws placed pieces as it did. A
level whose policy has a cell is baked concatenated: bake meshes it
exactly as before, then MeshedBuilding::concatenated copies every piece of
the level (standalone elements and merge-group batches alike) into building
space by its pose and joins the triangles per square cell of the building's
XZ plane and per binding and side. The level is stored with no parts and one
MergedGroup per occupied cell. LodCell::Side(metres) is a grid from the
building's origin; LodCell::Whole is one cell for the building, which makes
a whole city a few tens of entities: a skyline file.
It is not a merge group (ADR 0005). A merge group is a boolean union through
the kernel, declared in the recipe, and exists so damage has a unit to
re-mesh. Concatenation copies vertices, needs no kernel, and belongs to a level
of detail. It reuses the merge group's output types because they already say
what a concatenated batch is: building-space triangles sharing a binding and a
side, drawn about the centre of their bounds. So the file format and its
version are unchanged, and a reader from before this amendment reads a
concatenated level as a level of groups without an error. It does not draw it
well: an ashlar-bevy from before this amendment took group pivots from level
zero alone, where no cell appears, so it draws every cell about the building's
origin and switches it by the camera's distance to that origin, and it has
none of the handoff below. A game that bakes a concatenated level ships the
ashlar-bevy that came with it.
The details that make it safe to use:
- A piece is never split. It belongs to the cell its placed bounds'
centre falls in,
floor(x / side)andfloor(z / side), so a wall across a cell line stays whole in one cell. A cell's bounds may therefore overhang its square. - Nothing is lost. Positions are placed, normals turned, UVs and winding
kept, and no weld runs, so every output triangle is one input triangle. Each
face's
FaceSource::operandis renumbered into the new group'soperands, which name the instance and element it came from, and its origin is carried, so a cut face is still cut.ashlar-bevymarks cut faces per face fromsourcesfor element pieces and group batches alike (the per-pieceis_cutis identity, not shading), so a concatenated level shades its cut faces as the placed pieces did. - Stable ids. A cell's id is
cell:{side}m:{i},{j}orcell:whole, lengthened if it would equal a declared merge group's id or the building's. Two levels concatenated at one side length share ids; different side lengths do not. - Deterministic bytes. Groups are ordered by cell, batches by binding and
side, and pieces appended in the order
MeshedBuilding::piecesyields them. - Reuse survives.
bakekeeps every level as placed pieces while it meshes, and joins at the end, so a level after a concatenated one still reuses the parts it did not change. A policy with a cell is notis_identity()(its level is drawn differently), but it may still notsimplifies(), and level zero takes the authored meshes whenever it does not. - A bad cell is refused.
LodPolicy::check, whichbakeruns over the ladder before meshing anything, refuses a side that is zero, negative, NaN or infinite, atlevels[n].cell. The whole building has one spelling. - Bands measure to the cell.
ashlar-bevynow draws a group about the centre of its bounds at the first level it appears in, not only level zero. Before, a group that first appeared at a later level was drawn about the building's origin, and its band was measured to that origin. Two levels joined at one cell size share each cell's pivot, so their crossfade is between two views of one thing in one place, and a batch the coarser level left unchanged is one entity whose band runs on. - No hole at a handoff. Bevy measures each band to its own entity's
origin: a placed piece's to its instance, a cell's to its centre. Handing
one to the other at 250 m with nothing more, a piece on the far side of its
cell leaves its band before the cell enters its own, and for those metres
nothing draws it; one on the near side is drawn by both. So
ashlar-bevypushes the end of every band that hands over at a boundary out by the distance from that piece's origin to the farthest origin of the pieces that draw the same placed elements at the next level (found through the cells' and groups'operands). By the triangle inequality the camera is then never past the one band and short of the other, and across a crossfade the next level's dither is always at least as far in as this one's is out. The same rule covers a cell handing over to cells of another size, a piece whose simplified centre crosses a cell line, and a concatenated level followed by a placed one. Where the two origins are one point, as an instance or a merge group's pivot is at every level, the push is zero and nothing changed. A test inashlar-bevysweeps cameras past a row of walls under four ladders and asserts, with Bevy's own cull and dither rule, that every wall is wholly drawn from everywhere.
A cell's group has no storey, because a cell spans storeys: a game that hides
storeys by AshlarPiece::storey cannot do so in a concatenated level. It has
no collider, because collision is level zero's at every level. That does not
make collision free: bake carries level zero's collision proxies and portals
into every file, a skyline too, and ashlar-bevy spawns one AshlarCollider
entity per proxy per instance and puts every room and portal in
AshlarSpaces, whatever the levels are. A skyline meant to be a few tens of
entities is baked from a recipe without them, or has
BakedBuilding::part_colliders and part_portals cleared before it is
written; the rooms are the recipe's and cost no entities.
What it costs
Instancing, in that level. A placed piece shares its part's mesh with
every other placement of the part; a concatenated batch is unique geometry.
So in a concatenated level unique triangles equal drawn triangles: the level
stores, uploads and keeps in GPU memory one copy of every triangle it draws.
A building that places one window a thousand times stores the window once in
a placed level and a thousand times in a concatenated one.
BakedBuilding::triangles(), which counts unique triangles, counts every
placed copy in such a level.
Put in bytes: ashlar-bevy uploads a position, a normal, a UV and a tangent
per vertex, 48 bytes, plus 4 per index (and 8 more per vertex for a mesh with
cut faces); the file stores 32 bytes per vertex and 20 per triangle,
indices and face source. A far
level of a few hundred thousand triangles is tens of megabytes of vertex
memory for a city, where the placed level shared most of it.
The handoff is by cell, and it overlaps. A cell switches level as a whole, at its own centre's distance, and the handoff above keeps a placed piece drawn until its cell is sure to be: its band runs on by its distance to the cell's centre. Across that extra band both are drawn, the same surface twice under the same material, which costs draws and shows nothing where the level left the piece unchanged, and shows the finer piece where it did not. A piece at the corner of a 160 m cell stands over a hundred metres from its cell's centre, so it is drawn twice for over a hundred metres of approach. Pick a side that is small against the distance the level starts at: with the default crossfade of a tenth, a cell whose half-diagonal is under a tenth of that distance (about 35 m of side at 250 m) overlaps by no more than the crossfade margin already does.
The choice is per level. It is the right trade for a far level, where the
triangles are few and the pieces are what costs, and the wrong one for a near
level of a repeated kit, where instancing is what makes the triangles
affordable. The game's census of that city's far level (2026-09-28, its own
tool over its baked files, not measured here) found 45 distinct bindings, and
851 batches at 80 m cells or 350 at 160 m, against 7,272 placed pieces. It
counted cells in the settlement's frame, shared between buildings; bake
concatenates each building in its own frame from its own origin, so a city
baked building by building has at least that many.
LodPolicy::ladder() sets no cell; a game chooses where it wants one.
The cooked file (version 2)
The full file is what a content step caches and a tool inspects: the recipe,
every triangle's provenance, meshes in full precision. A game that draws a
building pays for all of it, and pays on the thread that spawns it, so a
second file is cut from the first at bake time. It holds the structure the
renderer and the runtime ask about (placements, the palettes their slots wear,
levels, groups with bounds and operands, rooms, portals, collision hulls) and
a table of meshes made ready for a GPU: quantised against their own bounds,
tangents generated once (by a generator the caller passes: mikktspace's Rust
crate is named for Bevy and ashlar links none), reordered for the vertex
cache and entropy-coded.
There is no recipe in it, so a game that reads it links no kernel and cannot
edit a building back; the content cache keeps version 1 for that.
Three choices are worth saying why. UVs are quantised with the positions and
not stored as half floats, because they are metres and run to hundreds on a
wall, where a half float's step is centimetres and a texture would swim.
Normal and tangent are octahedral i16, which holds the axes exactly, so a
flat face is one repeated value and the coder takes it for almost nothing.
And a mesh no coarser level changed is one record at every level, which the
full file already did and the cooked one keeps.
Both versions read. The loader chooses by the version word, a cooked file's meshes are made in the loader (an asset loader runs on the IO pool) and live in the render world alone, and since a mesh with no CPU copy cannot be measured the spawned piece carries the box the file recorded.