Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Using ashlar in a game

This guide takes a game from nothing to drawing its own buildings, with levels of detail, collision proxies and rooms, from files it ships. It is the path the two crates under examples/integration-content and examples/integration-game walk, so every step here has code you can run.

The shape of it

A game at play should not bake anything. A material bake costs a fraction of a second, and meshing a building means linking a C++ geometry kernel into the game. Neither belongs in front of a frame, and neither belongs in a shipped binary.

So we split the work in two, the way a game already splits art from code:

  • A content step runs on your machine or a build server. It links the geometry kernel and the material graph engine, and writes files into your game's asset directory.
  • The game loads those files. It links ashlar-bevy with its default features, which are none, and pulls neither ashlar-manifold nor ashlar-material.

This may look like extra ceremony for something that could just run at startup. But it is the same split a game makes for textures and models, and it is what keeps a C++ toolchain out of your game's build.

The content itself is Rust in both halves. Buildings are recipes written with ashlar, and materials are graphs from ashlar-material, starting from its default library of forty surfaces. Nothing baked is ever the source; the files are regenerated whenever the Rust changes.

Dependencies

Nothing is on crates.io yet, so both crates depend on the repository through git. The content crate is a tool:

[dependencies]
ashlar = { git = "https://github.com/JedimEmO/ashlar" }
ashlar-content = { git = "https://github.com/JedimEmO/ashlar" }
ashlar-material = { git = "https://github.com/JedimEmO/ashlar" }

The game is not:

[dependencies]
ashlar-bevy = { git = "https://github.com/JedimEmO/ashlar" }
bevy = "0.19"

ashlar-bevy pairs with one Bevy release at a time, and this one is Bevy 0.19. It re-exports ashlar, so the game needs no second dependency to name a Room or a Portal.

The content crate needs what ashlar-manifold needs: cmake, a C++ compiler and Git, because its first build clones and compiles upstream Manifold. The game needs whatever Bevy needs on your platform, and nothing more.

The content step

The content binary is ordinary Rust. It builds your buildings, picks your materials, and hands both to ashlar_content::Content:

#![allow(unused)]
fn main() {
use ashlar::LodPolicy;
use ashlar_content::Content;
use ashlar_material::stdlib;

let graphs = stdlib::graphs();
let definitions = stdlib::materials();

let content = Content::new("../my-game/assets");
let shipped = content.ship(
    &graphs,
    &definitions,
    "materials",
    &[("buildings/village.ashlar".to_owned(), village()?)],
    &LodPolicy::ladder(),
)?;
}

ship does three things, in this order. It resolves per-instance material overrides into materials of their own (more on that below), bakes every material the library names into KTX2 maps, and meshes each building at every level of detail. Content::write_materials and Content::write_building do the two halves separately, when a game wants to bake materials once and buildings many times.

What lands in the asset directory:

PathWhat it is
materials/<namespace>/<name>/base.ktx2, normal.ktx2, orm.ktx2, height.ktx2One material's maps, each with its full mip chain, zstd-supercompressed
materials/<namespace>/<name>/set.strandsThe strand set of a material that grows grass or moss
materials/library.materials.ronThe material library naming those files, one definition per key
materials/graphs.ronThe graphs they were baked from, for tools; the game never reads it
buildings/<name>.ashlarA building at every level of detail, with its colliders, portals and rooms

Every write goes through a temporary file and a rename, so a game that is running while the step runs never reads half a file.

Content::resolution bakes every material at one size instead of each definition's own. A lower one is the right call while iterating, and the definition's own for a release.

The game

The game adds one plugin and spawns one entity per building:

use ashlar_bevy::prelude::{AshlarBuilding, AshlarPlugin};
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, AshlarPlugin::default()))
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands, server: Res<AssetServer>) {
    commands.spawn((
        AshlarBuilding {
            building: server.load("buildings/village.ashlar"),
            materials: server.load("materials/library.materials.ron"),
        },
        Transform::from_xyz(0.0, 0.0, 0.0),
    ));
}

That is the whole integration. When both files have loaded, the plugin spawns every piece of every level as a child of that entity, with its Mesh3d, its MeshMaterial3d<StandardMaterial>, its Transform and a VisibilityRange for its distance band. The entity's own Transform places the building in the world.

Meshes are shared per building by their contents, and materials per library and binding. So a hundred copies of one building upload its meshes once, and Bevy batches every instance of a piece into one draw. A piece a coarser level did not change is one entity whose band runs on through that level, rather than two copies crossfading into each other. A merged group, and each cell of a concatenated level, is drawn about the centre of its bounds at the first level it appears in, so it switches level by its own distance from the camera and not by the building origin's.

The library loader opens every map and strand set it names while it loads. A missing map is then a load error naming the key, rather than a black wall three rooms later. AshlarPlugin { preflight: false, .. } skips that, for a game that trusts its content step.

What a spawned building gives you

Three things arrive beside the meshes, all in the building's own frame.

Collision proxies. Each is a child entity with an AshlarCollider: the instance and element it came from, and the vertices of a convex solid. A physics crate builds a convex hull collider from those points. They are tight and convex by construction, one per straight run of wall, so a doorway is the gap between two proxies and a room is never filled by one. They are level zero's at every distance, because physics does not coarsen with the camera.

Rooms and portals. The root entity gets an AshlarSpaces, with every declared Room and every Portal: the opening a door or window cutter publishes, placed once per instance of the part. That is what AI navigation, audio occlusion and "which room is the player in" read.

Events. AshlarBuildingSpawned { entity } is sent once the children exist, and the root gets an AshlarSpawned marker. A failure puts AshlarFailed(reason) on the root instead, and never panics. Removing AshlarFailed retries. With Bevy's file watcher on, a rewritten file respawns the building, and a file that finishes loading after a failure clears the failure, so a game started before its content step ran picks the buildings up once the files arrive. The plugin's AshlarCache lets go of a building's meshes when nothing holds the building any more.

Levels of detail

A coarser level is not a decimated mesh. The recipe is procedural, so a simpler building is a simpler recipe, and every level is still a closed, correctly shaded and mapped solid. ashlar::LodPolicy says what goes at each distance:

FieldWhat it does
untilMetres from the camera where the level stops drawing; None on the last level
min_featureElements, union members and openings thinner than this are left out
segment_scaleCylinders and revolves keep this share of their segments, never fewer than eight
drop_interiorInterior elements go: liners, floors seen from inside, furniture
cellNone by default. Some(LodCell::Side(metres)) or Some(LodCell::Whole) bakes the level concatenated, joined into a few batches per cell (see below)

Thin is judged by the middle of a solid's three extents: how wide it looks from where it looks widest. That may seem strange for a sill three metres long, but twenty centimetres deep is what it is from across a street. A dropped opening is not a hole in the wall at a distance; the facade texture carries the window from there. Something thin that still reads from afar because it glows, a neon line or a crown of light, is authored Element::far() and stays at every level.

LodPolicy::ladder() is the default: as authored to 60 metres, then no interiors, nothing thinner than 35 cm and half the segments to 250 metres, then nothing thinner than 1.5 metres and a quarter of the segments beyond. It is a starting point, and the right numbers depend on your camera and your art. Measured on the showcase (triangles drawn, every instance counted):

SceneLevel 0Level 1Level 2
Metropolis, 4 x 4 blocks3,928,1561,161,406760,902
City block402,248119,42480,654
Sci-fi colony119,18248,13019,292
Corporate block41,13233,69614,116

Level zero is where the cost is, and it is only ever drawn within sixty metres of the camera.

Concatenated levels

Simplifying a level cuts its triangles and leaves its pieces almost alone, because the walls, slabs and frames a kit is made of are exactly what survives. A city's far level can be thousands of entities per building, and past a few thousand in band it is the entities, not the triangles, that cost the frame.

A policy with a cell bakes its level concatenated. The level is simplified and meshed as usual, then every placed piece is copied into building space and joined, per square cell of the building's plan and per material binding, into one batch. The level then costs entities by the area it covers, not by what stands in it:

#![allow(unused)]
fn main() {
use ashlar::{LodCell, LodPolicy};

let mut ladder = LodPolicy::ladder();
// The far level as one batch per binding for every 80 m of the building.
ladder[2].cell = Some(LodCell::Side(80.0));
// Or the whole building as one cell, a skyline:
// ladder[2].cell = Some(LodCell::Whole);
}

In RON, cell: Some(Side(80.0)) or cell: Some(Whole); a ladder written without the field is read as None and draws placed pieces as it always did. A side that is zero, negative, NaN or infinite is refused by bake before it meshes anything.

A piece is never split: it belongs to the cell its centre falls in. Each cell is drawn about the centre of its bounds, so its band is measured to the cell, and two levels concatenated at the same cell size draw each cell about the same point. A cell has no storey, so a storey cut does not reach into a concatenated level.

The handoff overlaps. A placed piece's band is measured to its instance and its cell's to the cell's centre, so on their own the two would leave a piece on the far side of its cell drawn by neither for a few metres. AshlarPlugin therefore keeps each piece drawn until its cell is sure to be: the piece's band runs on by its distance to the cell's centre. Across that extra stretch both draw, the same surface twice, never nothing. Keep the cell small against the distance its level starts at: a half-diagonal under a tenth of that distance (a side of about 35 m for a level from 250 m) overlaps no more than the crossfade already does. A skyline, one cell for a whole building, is best drawn on its own rather than handed over to from the pieces.

A skyline still carries its colliders. bake puts level zero's collision proxies and portals in every file, and AshlarPlugin spawns an AshlarCollider entity per proxy per instance whatever the levels are. For a file meant to be a few entities, clear them before writing it:

#![allow(unused)]
fn main() {
let whole = LodPolicy { cell: Some(LodCell::Whole), ..LodPolicy::default() };
let mut skyline = ashlar_manifold::bake(&city, &[whole], &mesher)?;
skyline.part_colliders.clear();
skyline.part_portals.clear();
let bytes = skyline.write()?;
}

What it costs is instancing. A placed piece shares its part's mesh with every other copy of the part; a concatenated batch is unique geometry. So in a concatenated level every triangle drawn is a triangle stored: in the file, in the upload, and in GPU memory, at about 48 bytes a vertex. A far level of a few hundred thousand triangles across a city is tens of megabytes where the placed level shared most of it. That is the right trade for a far level, where the triangles are few and the entities are what costs, and the wrong one for a near level of a repeated kit. LodPolicy::ladder() sets no cell: where to spend the memory is yours to decide. ADR 0006's amendment of 2026-09-28 has the design.

AshlarPlugin::crossfade is the fraction of a boundary's distance two levels blend over, a tenth by default.

In the browser

A game built for the web runs on WebGL2, where Bevy 0.19.1 cannot crossfade a VisibilityRange. On a device with fewer than six storage buffers per shader stage, the visibility ranges go in a uniform buffer that the shader declares as array<vec4<f32>, 64>, 1024 bytes, while the bind group layout gives it a min_binding_size of Vec4::min_size(), 16 bytes. The shader reads that binding only to dither a crossfade, so the first mesh with a crossfading range fails pipeline validation and the app quits.

AshlarPlugin::bands is Bands::Auto by default, and it decides from the render device when a building spawns. Where the device cannot crossfade, every piece gets an abrupt range instead, cut at the middle of each crossfade margin. Bevy still culls each piece to its band; a visitor loses the dithered fade, not the levels. Nothing else changes: a game that hides pieces itself, a storey cut for instance, sets their Visibility as it would natively. Bands::Crossfade and Bands::Abrupt force one or the other. The web demo runs on exactly this.

Per-instance overrides

A building may bind a slot with graph parameters of its own: a hull colour per faction, a wall colour per cottage.

#![allow(unused)]
fn main() {
Instance::new("cottage-0", "village:cottage").binding(
    "wall",
    Binding::new("library:adobe").param("color", ParamValue::Color([0.62, 0.52, 0.40])),
)
}

A file cannot take a parameter at runtime, so the content step resolves each distinct override into a material of its own before anything bakes. ashlar_content::flatten_overrides keys it by a hash of the parameters, so the same colour from a hundred buildings is one material and one bake, and rewrites the buildings to bind it plainly. ship does this for you; a game calling materials and building separately calls it first.

An override naming a parameter the graph does not have is refused by the export, with the graph and parameter in the message.

Iterating

Shipping files is the wrong loop for tuning a material or a facade. tools/ashlar-preview is the authoring tool: it bakes materials from their graphs at registration, gives every parameter a slider, and reloads a graph library when you save it. It is a library with a thin binary, so a game registers its own scenes; the recipes guide shows how, and the ashlar-materials and ashlar-geometry skills under .agents/skills/ are the workflows.

When something looks right in the preview, copy the numbers into the Rust builder, because that is what the content step bakes.

Troubleshooting

Every failure lands in AshlarFailed on the building's root entity, with the reason.

  • "the building failed to load" or "the material library failed to load", followed by the asset server's reason: the content step has not run, wrote somewhere else than the game's asset root, or wrote a file this version cannot read. The reader names what it refused. A preflight failure names the material and the map or strand set it could not open.
  • "the material library does not dress this building", followed by an instance and slot: the building binds a key the library does not hold. Bake the materials and the buildings from the same definitions, which ship does.
  • "binds no graph parameters, so an instance cannot override": a building with per-instance overrides was baked without flatten_overrides.
  • "names a material graph": a library with Surface::Graph or Surface::Shader definitions reached the game. The game path draws files; run the definitions through the content step.

What it costs

The showcase's content step writes 59 material sets and a file per scene. The 8 x 8 metropolis meshes at three levels in about a quarter of a second, to a 13 MB file; much of that is the recipe, which the manifest carries whole. Materials are the slow half, a few seconds per set at 2048 texels, which is why Content::resolution exists. just content runs it, and cargo run --example baked -p ashlar-bevy -- metropolis draws the result.

The design behind all of this is ADR 0006 for buildings and ADR 0003 for materials.