ADR 0007: Props, cut-out materials and impostors
Status: accepted, 2026-09-26.
Context
ashlar bakes buildings: recipes of parts meshed by a kernel into levels of detail, dressed in opaque tiling materials. A game also draws things that are not buildings and are not made from a recipe of solids: trees, bushes, rocks, cliffs, debris. A game generates those with its own code, and what it needs from ashlar is the rest of the pipeline it already has for buildings: a baked file with levels and colliders, materials baked from graphs, a loader, a way to draw thousands of them, and something cheap to draw far away.
Four things are missing:
- Materials are opaque. A leaf card needs its alpha cut out, light through
it from behind, and both sides drawn.
PbrOutputhas no opacity,MaterialDefinitionno alpha mode, and ADR 0003 put transparency out of scope. - Meshes carry no data of their own.
TriangleMeshhas positions, normals and metre UVs. A tree's wind needs a pivot per vertex and a stylised shader wants ambient occlusion and a height gradient; there is nowhere to put either. - The only baked asset is a building. A tree dressed as a building would need a recipe of solids it does not have, and would carry rooms and portals it never uses.
- Nothing is cheap far away. A building's far level is still its geometry. A forest's far level has to be a picture.
Decision
Cut-out, two-sided and translucent materials
MaterialDefinition gains three fields, all defaulting to today's behaviour
and skipped when default so every library written before them reads and
writes byte for byte as it did:
alpha: Alpha,OpaqueorMask { cutoff }. Blending is out of scope: sorted transparency is a renderer problem, and every foliage path worth shipping cuts out.double_sided: bool, which also turns off back-face culling.diffuse_transmission: f32,0..=1, the share of diffuse light that passes through, which is how a leaf lit from behind glows.
PbrOutput gains an optional opacity input, baked into the base colour
map's alpha, with a coverage-preserving mip chain (each level's alpha is
rescaled so the fraction of texels over the cutoff matches level zero), so a
card does not thin away with distance. create_material maps all three to
StandardMaterial.
Meshes carry named vertex data
TriangleMesh gains colors: Option<Vec<[f32; 4]>> and
attributes: BTreeMap<String, VertexData>, where VertexData is one of
F32, F32x2, F32x3, F32x4 or U32, one value per position. Both
default empty, and a building never sets them. ashlar-bevy uploads colours as
Mesh::ATTRIBUTE_COLOR and each named attribute as a MeshVertexAttribute
whose id is a stable hash of its name, exposed as
ashlar_bevy::attribute(name, format), so a game's shader specialisation
names the same attribute without either side agreeing on a number.
A prop is a baked asset beside a building
ashlar::prop holds a Prop: an id, levels of detail, colliders and a
bounding box, and no recipe. A PropLevel has an until distance, whether
it casts shadows, and its PropPieces: a TriangleMesh and a material key.
Its last level may instead be an impostor (below): an ImpostorRef, the
keys of the two atlas files and the ImpostorLayout they were baked with.
A PropCollider is a
capsule, a cylinder or a convex hull, in the prop's own frame. The file is the
building file's container (identifier, version, a RON manifest, a binary mesh
table) with its own identifier, written by ashlar-content's
Content::write_prop and read by plain ashlar, so a server reads a prop's
colliders with no Bevy and no kernel.
ashlar-bevy loads .ashprop files into a PropAsset whose meshes are
labelled sub-assets, created once however many copies are drawn, and spawns
one with an AshlarProp { prop, materials } component the way it spawns a
building: one child per piece per level, each with its VisibilityRange.
A game drawing tens of thousands, such as a forest, takes the asset's mesh
and material handles and manages its own entities; the component is for the
handful of props a scene places by hand.
Impostors are baked pictures of a prop
ashlar-impostor bakes a prop's nearest level into a hemi-octahedral
impostor: a square grid of views from the upper hemisphere, more of them near
the horizon where a camera usually is, each view an albedo with alpha and a
normal with depth. It rasterises on the CPU with a depth buffer, sampling each
piece's baked maps for colour and cut-out, so CI gets the same bytes on every
machine and no GPU is needed. It is a content-step crate, like the material
graph engine.
ashlar-bevy draws an impostor as one camera-facing quad per instance with an
ImpostorMaterial that finds the three frames nearest the view direction,
blends them by their weights with a depth parallax offset, cuts out by alpha
and hands the normal to Bevy's standard lighting, so an impostor is lit by the
same sun as its prop.
The two meet in the prop file. ashlar-content's Content::write_impostor
bakes a prop's nearest level in the materials its library gives it and
answers the ImpostorRef its last level carries, and AshlarProp spawns that
level as one more child: the quad, in the level's band, casting no shadow.
ImpostorRef holds the layout whole rather than a copy of its fields, so the
file and the renderer read one type.
What this does not do
- Sorted alpha blending, and order-independent transparency.
- Non-square bakes and a general atlas packer: impostor frames and leaf sheets are square grids, which need neither.
- Skinned or vertex-animated props: wind is the game's shader, reading the vertex data above.
- Generators. ashlar does not grow trees or shape rocks; a game does, and hands ashlar the meshes.
Consequences
- A library, a building and a building file written before this ADR read and write unchanged. A prop file is a new kind with its own identifier.
ashlar-bevygains a module and a material; the featureless game path stays free of the graph engine and the kernel.ashlar-impostorjoinsashlar-manifoldandashlar-materialas a tool-only crate.- ADR 0003's exclusion of transparency is narrowed: cut-out alpha and diffuse transmission are in, blending is still out.