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

Introduction

ashlar is a toolkit for procedural buildings and materials in Rust games. We describe a building as ordinary Rust data, and its materials as graphs of tiled fields, and a content step turns both into files a Bevy game loads.

An ashlar is a squared, dressed block of stone. That is roughly what these crates hand you: blocks you assemble into whatever your game needs.

The problem

A game with a city in it needs a lot of buildings, and every one of them has to be modelled, textured, given collision, cut into levels of detail and exported. Doing that by hand does not scale, and doing it with a node editor in a separate tool means the buildings live somewhere our code cannot reach.

ashlar takes the other road. A building is a Building value: parts made of solids, placed as instances, with material slots and sockets. Because it is data, a loop places a hundred storeys and a seed lays out a city, and the same recipe is validated, meshed, baked and shipped by code we can read and test.

Two halves

Everything in ashlar belongs to one of two halves, and it helps to keep them apart from the start.

The content step is a small binary in your game's repository that runs when content changes. It links the geometry kernel and the material graph engine, meshes every building at every level of detail, bakes every material to KTX2 texture sets, and writes files.

The game links none of that. It adds AshlarPlugin, spawns an AshlarBuilding with a building file and a material library, and gets meshes, levels of detail, collision proxies and rooms back. This may seem like a lot of ceremony for a game that could bake at startup, but the split is the point: a shipped game has no C++ toolchain in its build and spends no frame time baking.

Where to go from here

  • Getting started takes you from a clone to a building of your own on screen.
  • Building recipes and Materials are the two authoring guides.
  • Shipping to a game is the production path: the content step and the plugin.
  • A tour of the kits walks the showcase's buildings, which are examples to copy, not a style you are stuck with.
  • The live demo runs the showcase in the browser, with a material gallery whose parameters you can move and watch re-bake.

The API documentation has every type, and the decisions say why things are the way they are.

Getting started

This chapter takes you from a fresh clone to a building of your own, meshed and on screen. We will look at the showcase first, because it shows what the pieces add up to, then write a small building, give it a material, and look at it.

What you need

Rust 1.96 or newer and just. Anything that meshes, which is the content step and this workspace, also needs cmake, a C++ compiler and Git, because the first build of ashlar-manifold clones and compiles the Manifold kernel. On Linux, Bevy needs its usual system packages: libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev pkg-config.

The first build takes a while for exactly that reason. Luckily, it happens once; after that Manifold is a cached static library like any other. cargo fetch does not cache Manifold's sources, so an offline build points MANIFOLD_CSG_LIB_DIR at a prebuilt install.

Compiler selection follows your platform and environment. If an activated Anaconda environment supplies CC and CXX, use the system compiler:

export CC=/usr/bin/gcc CXX=/usr/bin/g++   # Linux; use your platform's compiler paths
cargo clean -p manifold-csg-sys          # only if the kernel was already built with Anaconda

Anaconda's GCC can build a kernel whose static initialisers never run under rust-lld, causing SIGFPE. Native build prerequisite has the diagnosis and an alternative linker workaround.

Inside this workspace, .cargo/config.toml enables +sse4.1,+fma on x86-64, which takes about 14% off a bake and changes no baked byte. This needs roughly a 2013 Intel or 2015 AMD CPU; RUSTFLAGS="-C target-feature=-sse4.1,-fma" builds for an older one.

Look at the showcase

The preview is the authoring tool, and the quickest way to see what ashlar does:

just preview --scene corporate-block
just preview --scene metropolis --night --focus 30,2,10 --zoom 0.03 --pitch 0.1
just preview --scene city-tower --hide-exterior --max-storey 3
just preview --scene sheet-brick

The first is the corporate kit, the second a street in the dark city by night, the third the inside of a tower with everything above its third storey cut away, and the last the brick material on a sheet of specimens. Drag to orbit, scroll to zoom, and press M for the material panel. The preview chapter has every key and flag.

Every one of those buildings is a Rust function in examples/showcase, and every surface is a material graph in ashlar-material's default library. Nothing in them is a model file.

A building of our own

Let's make the smallest building worth the name: a wall of two bays, each with a door-sized opening. A new binary crate needs ashlar for the recipe and ashlar-manifold to mesh it:

[dependencies]
ashlar = { git = "https://github.com/JedimEmO/ashlar" }
ashlar-manifold = { git = "https://github.com/JedimEmO/ashlar" }
use ashlar::{Building, Collision, Element, Geometry, Instance, Part, Pose};
use ashlar_manifold::{ManifoldMesher, mesh_building};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let bay = Part::builder("example:bay")
        .element(
            Element::new(
                "shell",
                Geometry::cuboid([4.0, 3.0, 0.3]).subtract(
                    Geometry::cuboid([1.2, 2.2, 0.5]).placed(Pose::at([1.4, 0.0, -0.1])),
                ),
                "wall",
            )
            .collision(Collision::Bounds),
        )
        .build()?;

    let building = Building::builder("example:wall")
        .part(bay)
        .material("wall", "library:brick")
        .instance(Instance::new("first", "example:bay"))
        .instance(Instance::new("second", "example:bay").placed(Pose::at([4.0, 0.0, 0.0])))
        .build()?;

    let meshed = mesh_building(&building, &ManifoldMesher::default())?;
    let triangles: usize = meshed.pieces().map(|piece| piece.mesh.triangle_count()).sum();
    println!("{triangles} triangles, {} collision proxies", meshed.colliders().len());
    Ok(())
}

Notice how little of this is geometry. The bay is one element: a box with a smaller box subtracted from it, and the cutter runs past both faces of the wall, so it makes an opening rather than a recess. The element names a slot, wall, and the building binds that slot to a material key. The part never learns which material it wears, which is what lets one part dress as brick in one building and concrete in the next.

The instances place the part twice. A cuboid's origin is its minimum corner, so the second bay starts where the first one ends. build() validates the whole recipe and, when something is wrong, returns an error with a path such as instances[second].part: unknown part "example:bya", which is the first thing to read when a building will not build.

Giving it a material

library:brick is one of the forty surfaces in ashlar_material::stdlib. stdlib::materials() answers a MaterialLibrary with a definition for each, and stdlib::graphs() answers the graphs they are baked from. A definition names its graph through Surface::Graph, which a tool bakes into images when it registers the material.

A game never bakes. The content step writes each graph's KTX2 maps to disk once, and the library the game loads names those files through Surface::Files instead. Materials is the guide to writing graphs of your own, and Shipping to a game is the guide to that content step.

Seeing it

There are two ways to put our wall on screen.

The quick one is the preview: write the recipe as RON and open it. BuildingRecipe is plain serde data, so ron::to_string(building.recipe()) is all it takes, and just preview --recipe wall.ron draws it. Without a material library it draws every slot in its own diagnostic colour, which is exactly what we want while the shape is still moving; --materials names a library when it is time to dress it.

The real one is the game path. examples/integration-content and examples/integration-game in this repository are a content crate and a game crate, the split a game copies:

cargo run --release -p integration-content   # bake the village's buildings and materials to files
cargo run --release -p integration-game      # load them with AshlarPlugin and look

The content crate is where our wall would go. The game crate is one short main.rs around AshlarPlugin and an AshlarBuilding, and it compiles without the kernel, the graph engine or a C++ toolchain.

Where next

From here the two authoring guides take over. Building recipes covers parts, sockets, collision, interiors, merged buildings and terrain fitting, and Materials covers graphs, the default library, weathering and strands. The showcase's kits, walked through in A tour of the kits, are the largest worked examples of both.

Building recipes

ashlar is the authoring layer for script-defined architecture. It supplies regular Rust types and consuming builders, with no macro language, Bevy, IO or mesh generation dependency. Recipes generate meshes in a separate adapter and can be inspected in the standalone preview or fitted to terrain by the game that embeds them. This is the building authoring workflow.

Author a part, then assemble it

#![allow(unused)]
fn main() {
use ashlar::{Building, Element, Geometry, Instance, Part, Pose};

let panel = Part::builder("example:panel")
    .element(Element::new(
        "body",
        Geometry::cuboid([4.0, 3.0, 0.25]),
        "surface",
    ))
    .build()?;

let building = Building::builder("example:screen")
    .part(panel)
    .material("surface", "example:painted_metal")
    .instance(Instance::new("left", "example:panel"))
    .instance(
        Instance::new("right", "example:panel")
            .placed(Pose::at([4.0, 0.0, 0.0]))
            .material("surface", "example:timber"),
    )
    .build()?;
}

Run the complete example, which includes an opening, sockets, repetition and an arbitrary rotation, to print a RON recipe:

cargo run -p ashlar --example recipe
cargo test -p ashlar

Ordinary Rust functions parameterize parts; loops repeat instances. A part is registered once and referenced by ID. There is no enum of architectural styles, mandatory storey height, facade pattern, default material, or required grid.

Data and validation boundary

  • Geometry holds a Shape and Pose. Shapes include plain/chamfered cuboids, cylinders, polygon extrusions, and subtraction, union, array and mirror expressions. Separate elements remain separate solids, so composition does not imply a boolean union.
  • Element gives geometry a local identity, a semantic material slot, a UV mode and an optional collision proxy.
  • Part contains named elements and optional named attachment frames (Socket). PartBuilder::build checks local validity. Parts remain editable data.
  • Instance references a part and supplies a placement or an attachment, plus optional material overrides. Recipe bindings provide defaults. Material keys are opaque strings; material definitions and texture loading are resolved separately by the render adapter. An override is a Binding: a key, and optionally values of its own for the parameters of the graph behind that key — see one material key, a value per instance.
  • BuildingRecipe carries a version. A document written by a newer schema is refused rather than reinterpreted; one written before the field existed reads as version 1, which is what it is.
  • BuildingBuilder::build and BuildingRecipe::build validate the whole assembly and return an immutable Building. They recheck edited parts, duplicate names, part references, transforms and every used material slot. Errors identify the offending path. Instance overrides must refer to slots actually used by that instance's part; recipe palettes may contain additional unused slots.

BuildingRecipe supports Serde for editable interchange. A Building serializes as that recipe but cannot be directly deserialized. Load a BuildingRecipe, then call build(). building.recipe() provides read-only access; building.into_recipe() permits edits followed by revalidation. The RON schema is experimental; runtime recipe loading and a persistent format migration contract are not implemented yet.

Coordinates and geometry semantics

All coordinates are building-local f64 metres, right-handed and Y-up. World placement remains the embedding game's concern. Pose is translation plus a unit quaternion, applied as translation + rotation * point; there is no scale. Dimension changes happen in the geometry recipe. placed and rotated set absolute local values, not incremental transforms.

Cuboids run from [0, 0, 0] to their positive X/Y/Z dimensions. Cylinders run from Y=0 to Y=height around the Y axis and carry an explicit subdivision count. Extrusions use an unclosed simple polygon in X/Z, extruded from Y=0 to Y=depth. Either winding and concave profiles are accepted. Holes, duplicate consecutive vertices, collinear neighbours and self-intersections are rejected. Polygon checks use floating-point predicates, not a CAD tolerance or repair kernel; a future mesher must handle its own numerical tolerances and topology checks.

Subtraction operands have their own poses in their parent's frame. The result's pose is applied after the operation. Cut faces inherit the containing element's material slot unless Element::cut_material binds a separate reveal slot.

Three more nodes compose solids rather than cut them. union and union_all fuse solids in one operation. arrayed(count, step) repeats a solid, copy n carrying step applied n times, so a translation gives a row and a rotation gives a ring around the local origin; MAX_ARRAY_COUNT bounds it. mirrored(MirrorPlane::new(axis, offset)) reflects a solid across an axis-aligned plane and gives back the reflection alone, so a symmetric part is a union of a solid and its mirror. Cut provenance follows the operands: a union creates no cut faces of its own, every copy of an array carries the provenance of its source, and a reflection keeps the provenance of the faces it mirrors. Validation limits expression depth to 64, but does not evaluate booleans, prove overlap, determine manifoldness or promise tessellation success. A future backend must report unsupported operations explicitly.

An optional grid is an editor hint only. It neither snaps positions nor restricts rotations.

Sockets that attach

Instance::attach(own_socket, target_instance, target_socket) derives a placement instead of stating one. Sockets face outward along +Z, so attaching turns the attached part half a turn about the socket's Y axis and the two outward directions oppose, the way two modules meet at a shared edge. attach_aligned lays the frames on top of each other instead, for a socket that marks a direction to continue in.

A bay with a left socket at its origin facing -X and a right socket at its far edge facing +X, attached left to right, lands the next bay exactly one bay along with the same rotation. That is what walls in the study kit does; only the corner bay of each side is placed by hand. build() resolves attachments in dependency order, so a target may appear later in the file, and reports unknown sockets, unknown targets, self-attachment and cycles by path. The derived pose is written into the instance. placed clears an attachment, because two sources for one pose is an authoring mistake, not a merge.

An attachment names an instance by ID. A building assembled out of another building's instances, as corporate::block is, has to rewrite attachment targets along with the IDs it renames.

Collision proxies

Element::collision declares what an element contributes to static collision: None by default, Bounds for the axis-aligned box of the evaluated solid in part space, or Hull for its convex hull. The mesher derives the proxy from the same surface it meshed, once per part definition, and MeshedBuilding::colliders() places one per instance. Trim, slats, lights and signage stay None and cost nothing.

A proxy is convex and conservative: an opening subtracted inside an element is not subtracted from its proxy. An opening that has to be walked through is the gap between elements, not a hole inside one. ashlar itself derives nothing; it owns the declaration and the ConvexSolid the backend hands back.

Generate and preview

just preview
# Export the built-in Rust example for editing:
just preview --write-example /tmp/building.ron
just preview --recipe /tmp/building.ron --materials /tmp/city.ron --graphs /tmp/city.graphs.ron
# Bounded visual verification:
just preview --screenshot /tmp/building.png
just preview --wireframe --screenshot /tmp/building-wire.png

Drag left to orbit, drag right to pan, scroll to zoom. F toggles wireframe, P toggles textured materials and neutral clay, M opens the material parameter panel, S saves the graph library it is editing, Home resets the initial framing, Escape exits. --clay, --yaw, --pitch and --zoom make comparisons reproducible. Captures wait for texture dependencies to load before counting warmup frames; texture failures return an error. Screenshot mode ignores live view, palette and panel input. Restart after editing a recipe or a material library. A graph library read from a file with --graphs is watched and reloaded while the preview runs; the showcase's own scenes hand their graphs over as Rust values, so there is nothing to watch until --graphs-out saves what the panel tuned. --bake makes a library that names exported files render the graphs they were baked from instead.

The default --scene outpost and the larger --scene office share the same facade and entrance definitions in examples/showcase/src/lib.rs. --scene facade and --scene entrance isolate the finished parts. The earlier primitive fixture remains available as --scene basic in examples/showcase/src/example.rs. All architectural dimensions, layouts and style choices belong to this example content, and the tour of the kits describes them.

For custom --recipe files, pass --materials to load a material library, and --graphs for the graphs its surfaces name. Without them, the preview uses stable diagnostic colors. The built-in scenes bring the showcase's library with them, which is Rust in examples/showcase/src/library.rs over the default library in ashlar_material::stdlib. Texture keys are relative to --asset-root, which defaults to the repository's assets folder. An explicitly selected library must resolve all used keys, including cut slots; missing or corrupt PNGs fail before the window opens.

Previewing your own content

ashlar-preview is a library with a thin binary over it. The scene list is a Catalog the binary supplies, not a fixed enum, so a game gets the same viewer and the same gallery over its own buildings in a few dozen lines and without depending on ashlar-showcase:

use ashlar_preview::{Catalog, Options, Scene, run};

fn main() -> anyhow::Result<()> {
    let catalog = Catalog::new()
        .scene(
            Scene::new("tower", || my_game::tower())
                .materials(my_game::materials)
                .graphs(my_game::graphs),
        )
        .scene(Scene::new("plaza", || my_game::plaza()).material_library("buildings/plaza.ron"))
        .scene(Scene::new("depot", || my_game::depot()).display_name("Supply depot"))
        .default_scene("tower")
        .asset_root(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets"));
    run(&catalog, Options::parse_for(&catalog))
}

Scene::new takes a closure, so nothing is meshed until it is asked for. --scene and --reference-scenes accept the registered names and are validated against the catalog, so --help lists exactly what was registered and an unknown name reports the alternatives. A scene takes its libraries as Rust values with materials and graphs, or as RON files with material_library and graph_library, whose keys are relative to --asset-root. That root is this workspace's assets folder unless the catalog names its own with asset_root. Either way the library is preflighted before the window opens. The preview writes no files a game ships; that is the content step's job, ashlar-content.

The mesh backend

The GeometryMesher port, the TriangleMesh result and the MeshedBuilding that holds a whole assembly's worth of them live in ashlar: none of that vocabulary names a kernel type, so a consumer reading meshed results never compiles one. The ashlar-manifold adapter implements the port using manifold-csg, safe Rust bindings around the native Manifold solid kernel. Plain/chamfered cuboids, Y-up cylinders, concave extrusions in either winding and nested, arbitrarily rotated subtraction are supported. Full subtraction returns an empty mesh; disjoint cutters leave the solid intact. Kernel failures include the part/element/expression path.

#![allow(unused)]
fn main() {
use ashlar_manifold::{mesh_building, ManifoldMesher};
let output = mesh_building(&building, &ManifoldMesher::default())?;
}

Each used part is evaluated once. MeshedBuilding preserves instance poses, material slots, overrides and socket metadata alongside shared part meshes.

MeshedBuilding::pieces() is the join between the two halves, and what a renderer draws from: one Piece per instance, per element and per resolved slot, carrying the instance's pose, the part's triangles and the Binding that instance's palette resolves for that slot. A cutter's own slot comes first, then the element's cut slot, then its main slot, and the instance's own override where it has one. A merged building yields its group batches first; see "Merged buildings, interiors and damage" below. Key uploaded geometry by Piece::mesh_key(), and key what is derived from the geometry and the material by Piece::shared_key(), which is that key and the binding: two placements of one part share an uploaded mesh and a scattered strand layer, and two values of one graph parameter are two texture sets and must not. ashlar_bevy::dressed_pieces walks the same pieces with each definition resolved on top.

Meshes use f64 positions and normals with outward winding, indexed: vertices are shared where position, normal and UV agree within WELD_TOLERANCE, so creases and UV seams still split and flat continuous surfaces do not. On the corporate scene that is 5071 vertices rather than 7056 for the same 2352 triangles. Welding is an optimization, not a repair: two vertices either side of a quantization bucket edge stay separate, which costs a vertex and no geometry. unweld gives every triangle its own corners back, which is what a per-face projection needs. The preview uploads each element/surface batch once and narrows to f32 at its renderer boundary. Default normals crease at 45 degrees and smooth below it, and an edge at exactly 45 degrees is a crease, so a 45 degree chamfer keeps its faces flat instead of being decided by rounding. Cylinder sides are smooth and their caps are creased without any authoring. The explicit chamfered_cuboid(size, bevel) primitive adds real edge/corner geometry while retaining the requested bounds. It uses planar bevels, and is not a general modifier for arbitrary boolean edges. Bevels must be positive and smaller than half every box dimension.

UVs are metres in part space, in one of three modes per element. Planar, the default, uses an orthonormal face frame: the dominant axis chooses its orientation, angled faces are not foreshortened, and every face starts its repeat at its own origin. Box reads the world axes with one origin per axis, so coplanar faces of separate elements share a grid and their repeats line up across a wall of modules; a face at an angle to its dominant axis is foreshortened, as in any triplanar mapping. Cylindrical { axis } puts arc length along U and height along V, so a cylinder tiles at metre scale with a single seam, and faces perpendicular to the axis fall back to the plane mapping. The mode is applied after evaluation. Foundation and ramp meshes use the planar projection. Physical texture scale comes from the material's tile_metres, so repeated and rotated modules preserve density without scaling geometry. These coordinates have seams; they are not an atlas, lightmap unwrap or seamless cylindrical mapping. Bevy receives generated tangents for normal maps. Levels of detail are the recipe simplified and baked into a .ashlar file (ADR 0006); GLB export and runtime streaming remain future work.

The default adapter rejects cylinders above 4096 segments and results above one million triangles after each solid operation. These checks are output budgets, not hard memory/time limits on native boolean evaluation. Recipe validation limits expression nesting to 64. Evaluation is synchronous and runs before the preview window opens; an in-game consumer must schedule it off the main thread. The preview caps its Bevy worker pool at four and uses vertical sync. It rejects empty assemblies and coordinates outside +/- one million local metres.

Whole repeats across a stacked or arrayed module

A part is meshed once, in its own frame, and every instance of it starts its UV repeat at zero — the mesh is shared and cannot know where it will stand. So when several instances of one part stack into a storey or array into a bay run to read as one continuous surface, that surface only stays continuous if the module is a whole number of the material's tile_metres: a storey of two repeats reads as one wall, and a storey of 1.6 repeats shows a cut at every floor line, however good the material. UvMode::Box does not reach this — it lines up coplanar faces within one part around one shared origin, not across separate instances, each of which carries its own copy of the mesh and starts its own origin at zero. Until a structural answer lands, size the module to the material instead: pick tile_metres so a whole number of repeats divides the module (the storey height, most often), rather than the graph's own authored repeat. Read alone, that disagreement is what ashlar_bevy::tiling cannot tell apart from a tile_metres typo, so declare it: tile_scale is the factor by which the definition scales the graph's own repeat on purpose. The corporate kit's stone wall tiles library:stone-cladding at 1.9 m — two whole repeats over its 3.8 m storey — against the 2 m the graph was authored for, and writes tile_scale: Some(1.9 / 2.0) beside tile_metres to say so; see corporate_materials in examples/showcase/src/library.rs. The structural fix — a building-space or merged mapping that does not care where an instance's local UV started — is ADR 0005, described under "Merged buildings, interiors and damage" below.

Native build prerequisite

The recipe domain remains Rust-only. The mesher requires Git, CMake and a C++ compiler, except when building documentation: manifold-csg-sys returns from its build script under DOCS_RS, and rustdoc links nothing, so docs.rs needs neither. The pinned adapter version currently builds Manifold v3.5.3 and fetches its source and Clipper2 on the first build; cargo fetch alone does not cache these native sources. Subsequent builds reuse the target directory. See the upstream MANIFOLD_CSG_LIB_DIR override for a prebuilt/offline installation. The adapter's parallel feature is disabled. For constrained machines, limit Cargo jobs and CPU affinity for a clean native build; upstream CMake invokes --parallel without an explicit job count.

One toolchain is known to build a kernel that crashes. An activated Anaconda environment exports CC and CXX, its GCC emits the kernel's static initialisers into a legacy .ctors section, and rust-lld never runs them, so Manifold::sphere, refine and the smoothing calls die on SIGFPE. Nothing ashlar evaluates reaches those calls, which is why a blast solid is a hull of our own points. Build the kernel with the system compiler in that environment: export CC=/usr/bin/gcc CXX=/usr/bin/g++ on Linux, using the appropriate paths on other platforms. The workspace leaves compiler selection to the caller, including on Windows, NixOS and when cross-compiling. Alternatively, link with -C link-arg=-fuse-ld=bfd. crates/ashlar-manifold/tests/kernel_init.rs is the canary: it calls Manifold::sphere, and on a bad build the test process dies rather than failing. To confirm it on a binary that crashes, readelf -p .comment names the C++ compiler that built the kernel, and the kernel's four _GLOBAL__sub_I_* initialisers sit in a .ctors section while .init_array holds only Rust's; GNU ld folds .ctors in and rust-lld does not. CMake caches its compiler, so rebuilding with another needs cargo clean -p manifold-csg-sys. Inside this workspace RUSTFLAGS replaces the target features .cargo/config.toml sets, so there the bfd workaround is RUSTFLAGS="-C link-arg=-fuse-ld=bfd -C target-feature=+sse4.1,+fma".

Merged buildings, interiors and damage

By default a building is drawn as the parts it was authored from. Every element is meshed alone, a part's meshes are shared between its instances, and nothing is unioned. That is cheap and it instances well. It is also why a wall of bays has internal faces nobody sees, coincident faces that fight for depth, normals that break at every joint, and a texture that starts again at every storey.

ADR 0005 adds the other way to draw one, and everything in this section follows from a single fact it made the mesher keep: every face knows which solid it came from.

Every face names its source

TriangleMesh::sources holds one FaceSource per triangle, an operand and a FaceOrigin. The origin is Body, Cutter(k) or Damage(j), and TriangleMesh::is_cut(face) is the old cut flag asked as a question.

The operand is an index, not an instance name, and this may look like the wrong way round. The reason is that a mesher is handed geometry and nothing else, so it cannot know what an instance is. The caller holds the table from operand to (instance, element), and for a merged group that table is MergedGroup::operands.

Cutters are numbered depth first, and Geometry::cutters() is that numbering. A backend handing out Cutter(k) and a resolver looking cutter k up read the same function, so they cannot disagree.

A cutter may wear its own reveal

Two openings in one wall used to share one cut slot. A cutter can now name its own with Geometry::cut_material:

#![allow(unused)]
fn main() {
let wall = Geometry::cuboid([6.0, 3.0, 0.3])
    .subtract(door)                                 // falls back to the element's cut slot
    .subtract(window.cut_material("sill"));         // wears its own
}

Element::slot_for(origin) resolves a face: the cutter's slot, then the element's cut_material_slot, then its material_slot. A cutter slot needs a binding like any other slot, and build() refuses a recipe that leaves one out. mesh_building yields one ElementMesh per resolved slot, so an element may now come back as several cut batches. That is why uploaded geometry is keyed by Piece::mesh_key() and no longer by a cut flag.

Merge groups

#![allow(unused)]
fn main() {
let tower = Building::builder("metro:tower")
    .merged()
    .group(MergeGroup::new("storey-1").storey(1))
    .instance(Instance::new("bay-a", "metro:bay").group("storey-1"))
    // ...
    .build()?;
}

A merged building unions each group into one solid and meshes it once, in building space. An instance that names no group is in the default group, which is the building itself. Element::standalone() keeps an element its own mesh whatever group its instance is in; use it for glass, lights, doors and anything else that moves or is swapped. Element::far() is the level-of-detail twin of that: a coarser level drops what is thinner than its feature size, and a far element stays anyway, which is what a neon line or a crown of lights wants.

What comes back is MeshedBuilding::groups: a MergedGroup per group, split into a GroupBatch per binding and side. The material follows the face and not the mesh it happens to be in. Each face resolves its binding through its own instance's palette, exactly as Building::binding resolves it per element, so an instance override survives the union.

Parts stacked exactly face to face union with no internal wall and no seam triangles. A gap of a nanometre is still a valid result, but it is two components, so author joints to touch.

Same-facing coplanar faces are the other case: when two elements of one part put faces on one plane in two materials, drawn as parts they fight for depth, and merged the kernel gives each coplanar triangle to one operand, so slivers of one material show in the other. We offset the trim by a centimetre, or give both the same slot. Faces on one plane facing opposite ways are two solids touching, and those are fine.

MeshedBuilding::coplanar_overlaps finds the defect in a whole building, after every instance pose, so two parts placed into each other are caught as surely as two elements of one part. It reports each pair of elements that puts same-facing faces on one plane with area in common, largest first, with the element paths, the bindings, the area and a point to look at. same_binding() marks an overlap in one material, which fights only where UVs or normals differ, and covered marks one pressed against a face turned the other way, such as the underside of a plinth on a pavement, which nobody sees. It needs no kernel, so a content step can run it over every level of a BakedBuilding.

UVs are projected in the building frame. Planar and Box elements are box-mapped there, which is continuous across every former joint and does not move when a later cut re-triangulates a wall. A Cylindrical element is still wrapped around its own axis, in its own part frame, so a column stays a column.

Normals are computed by ashlar::shade_normals and not by the kernel. The kernel's own pass was nearly the whole cost of extracting a mesh, and ours is the same crease rule. An edge at the sharp angle is a crease, so a 45 degree chamfer keeps its faces flat however the union cut them.

Merging gives up instancing, and that is a real cost. A merged group is a unique mesh, and its triangle count goes up by a few per cent because the union re-triangulates. Measured in a release build, the corporate tower merges by storey in about 140 ms where the unmerged path takes 3. So merged is off unless a recipe says otherwise, a repeated kit may well stay unmerged, and a far level of detail can be the old shared parts.

The group is also the unit of damage. A hit re-meshes the groups it touches and no others, so group size is the cost of a hit. A storey or a bay is the natural size, and a group boundary is a seam again, so put it where a seam is honest: at a floor line, not across a wall. corporate::merged_by_storey in the showcase is the worked example, and just preview --scene corporate-merged shows it.

A GeometryMesher that cannot union keeps the default mesh_group, which answers an error by path. It still meshes every unmerged building.

Pieces

MeshedBuilding::pieces() yields group batches first and element pieces after them. A Piece carries a PieceOrigin, which is Element { instance, part, element, slot, is_cut } or Group { group, batch }, beside its storey, side, pose, mesh and binding. A group piece's pose is the identity, because a group is already in building space.

Piece::mesh_key() says what uploaded geometry may be shared by. Element pieces over one part, element and slot share their triangles, and a group batch is shared with nothing. Piece::label() is a readable name for an entity or a diagnostic. ashlar_bevy::drawables handles both kinds, so a game that spawns through it needs no change to draw a merged building.

Interiors

Walls and floors stay elements, and only openings are carved. Carving a room out of a mass would be shorter to write, and it would make the mass's convex proxy fill the room. The colliders of the previous section stay correct only while solids are added.

The library does not cull. It classifies, and the policy is the game's:

  • Element::interior() declares a side, and a group is split by binding and Side. A liner solid over the inner face of a wall unions in with no seam, and its faces are their own Interior batch even when the wall and the liner wear the same plaster.
  • A group's storey reaches every Piece, group and standalone alike.
  • Geometry::portal(id) on a cutter publishes the opening. MeshedBuilding::portals() answers one Portal per placement, in building space: a rectangle at the middle of the wall's thickness, clipped to the wall, with its normal and its storey.
  • Room is a declared box with a pose, a group and the portal ids that open onto it. Building::room_at(point) and Room::contains need no kernel, so a server can ask which room a player is in with ashlar alone.

Side is declared and not detected, and this is deliberate. A detected answer would change when a blast opens a room to the sky; a declared one does not. The price is that it is per element, so a floor slab declared exterior is exterior on its top face as well. Give it a thin liner over the face that is inside, which is what the walls already do.

A portal is a rectangle in this version, so an arched opening publishes its bounding rectangle. It is measured against the core where the cutter removes material, not against the bounds of the whole solid. A doorway through one leg of an L-shaped element is therefore a portal, at the middle of that leg. A portal cutter cannot sit under an Array or a Mirror, because its frame would not be one rigid pose.

just preview --scene interior is a small house built this way. PageUp and PageDown move the highest storey shown and I hides the exterior, which is a top-down game's whole culling policy in a dozen lines.

Damage is data

#![allow(unused)]
fn main() {
let mut log = DamageLog::default();
log.push(Damage::blast([6.0, 1.5, 0.15], 1.2, "rubble"));
let meshed = mesh_building_with_damage(&building, &log, &ManifoldMesher::default())?;
}

A Damage is a solid, a pose and the slot its exposed faces wear. A DamageLog is an ordered list of them, and it is both the save format and the wire format: a server sends "this solid, here", and every client derives the same hole. The slot is resolved against the building's own palette, because a hole belongs to no instance.

Damage::blast builds its solid with Geometry::ball, which is a convex hull of points we generate ourselves. This may seem like a roundabout way to make a sphere. The reason is that the point set is then identical on every machine, and a hull needs none of the kernel entry points that crash on some toolchains (see the native build note above).

mesh_building_with_damage is what a load, a late-joining client and a replaying server call, and it is GroupSolids::build. GroupSolids::apply keeps one kernel solid per group, so a hit subtracts from what is already unioned, and it is the path for play. The two are one code path, because build replays each record through apply, so they cannot drift. They live in ashlar-manifold because keeping a solid means naming a kernel type.

A replay and a played hit agree on volume, area, bounds and the area each source contributes. They do not promise the same triangulation, so nothing may key on triangle order or count.

A hit on the tower's largest storey group is about 9 ms in a release build, debris included. That belongs on a task and not in a frame, and GroupSolids is Send so that it can be. Apply hits one at a time and in order; the log is the order things happened in.

GroupSolids::apply answers a GroupHit per group the solid actually changed:

  • group is the re-meshed MergedGroup. Feed it to MeshedBuilding::replace_group, and record the damage once.
  • debris is one Debris per connected piece of what was removed, with its volume and centre. Its faces kept their sources, so the outside of a chunk of brick wall is still brick and the fracture wears the damage slot. Breaking it up further is out of scope.
  • Exposed faces are classed Interior, because what a hole exposes is the inside of something.

A hit may also be collapsing: Damage::collapsing makes it drop what it cut free. After the solid is subtracted, a connected piece is supported when it reaches the ground or touches something supported, of its own group or another, and what is not supported falls. Support crosses groups, so cutting the storey under another drops that one too. A piece that was already loose in the undamaged building is left alone unless a hit changed it, though a rail or a sign stood off its wall may well be one a game wants to fall with its storey. A single column still holds a storey up, so this is geometry and not structural analysis. The flag is on the record rather than in the mesher, and a replay and a played hit are one code path, because the log is the wire format and every client has to derive the same building.

Collision follows. A convex proxy cannot have a hole in it, so a group that has been hit gets MergedGroup::collider, a TriangleCollider built from the same solid. MeshedBuilding::colliders() then leaves that group's proxies out, and mesh_colliders() yields the replacements.

Damage cuts merged groups only. MeshedBuilding::standalone_touched names the standalone pieces inside a hit, and whether glass shatters or a door falls off is the game's decision. MeshedBuilding::groups_touched is a bounds test with no kernel in it, so a server that trusts its clients can still say which chunks a hit invalidates.

Who computes the shape is a deployment choice. A server that must know it links ashlar-manifold and pays for a C++ toolchain; cargo run -p ashlar-manifold --example damage_server is that server, and it links no Bevy. ashlar-bevy never links the kernel: batch_drawables draws the batches a hit produced, wherever it was computed. In the preview, shift-click on a merged scene blasts it, and --blast x,y,z does the same for a capture.

PBR materials and exposed cuts

MaterialLibrary is a serializable map from opaque material keys to MaterialDefinition. It is separate from the recipe and contains no building style enum. Every definition carries the constants:

  • sRGB base color multiplier.
  • Roughness and metallic multipliers in 0..=1.
  • Linear HDR emissive color; emission is visible but does not illuminate neighbours.
  • Physical texture repeat size in metres and UV offset in repeats.
  • An optional tile_scale, saying that the repeat above is deliberately a multiple of the graph's own authored size rather than a tile_metres typo — see "Whole repeats across a stacked or arrayed module" below.
  • For a surface with holes in it, such as a leaf card: alpha: Mask(cutoff: 0.5) cuts it out where the base colour's alpha is under the cutoff, double_sided: true draws and lights both faces, and diffuse_transmission in 0..=1 lets light through from behind. All three default to a solid, one-sided wall and are left out of the file when they do; the material guide has the details under "Cut-outs".

Where the varying detail comes from is one surface field, a Surface enum:

"game:signal": (base_color: (0.55, 0.24, 0.045), roughness: 0.55, surface: Plain),
"library:formed-concrete": (
    tile_metres: (2.0, 2.0),
    surface: Graph((graph: "library:formed-concrete", params: {"seams": Float(1.0)}, resolution: 512)),
),
"game:concrete-files": (
    tile_metres: (2.0, 2.0),
    surface: Files(
        base_color: Some("materials/library/formed-concrete/base.ktx2"),
        normal: Some("materials/library/formed-concrete/normal.ktx2"),
        orm: Some("materials/library/formed-concrete/orm.ktx2"),
        height: Some("materials/library/formed-concrete/height.ktx2"),
        baked_from: Some((graph: "library:formed-concrete", params: {}, resolution: 512)),
    ),
),
  • Plain is constants only: glass, lights and trim.
  • Files names files on disk. base_color is sRGB and multiplies the base_color constant; normal is a linear tangent-space map, read in the bake's convention when baked_from is present and as OpenGL +Y when it is not, so a painted map leaves baked_from out and an exported one keeps it; orm is linear packed R=occlusion, G=roughness, B=metallic; height is a linear relief field; and emissive is linear HDR that multiplies the emissive constant, which defaults to zero, so a material naming an emissive map raises that constant too or it glows not at all. Any of the five may be omitted. baked_from records the bake a content step ran to write them, so the step can be re-run. A map is a PNG or a KTX2, and a baked one should be a KTX2: the container carries the whole mip chain, where a PNG carries level 0 and Bevy builds no chain for an image it loaded, so a PNG set is one the hardware point samples as soon as the wall is far away. KTX2 is also the only one of the two that can hold the sixteen-bit height plane at all. The export writes zstd-supercompressed KTX2, and ashlar-bevy enables Bevy's pure-Rust decoder for it, so a game needs no C library to read one.
  • Graph((graph: "library:formed-concrete", params: {...}, resolution: 512)) names a material graph to bake into images when the material is registered. It is what every definition of the default library is, and it is for authoring: the preview and the tool features bake it, and a game with ashlar-bevy's default features refuses it. A content step exports these definitions as Files files; see the integration guide.
  • Shader(graph: "showcase:concrete-wet", params: {...}) names a graph to compile, for parameters that move per frame and for the things no image can hold at all — the clock, the fragment's world position, its normal. The showcase's two worked examples are showcase:light, over a graph whose emissive is a sine of Time, and showcase:concrete-wet, which instances the graph the walls are baked from and gates a live wetness by the fragment's own world normal and height, so the same material is wet on a coping and dry on the wall under it. A bake refuses a world-space input rather than folding it to zero, which is why that is a second graph over the first rather than one more parameter on it.

A surface refuses a field it does not know, so a misspelled map key is a parse error naming the field rather than a surface that silently loses every map it meant to name. Graph doubles its parentheses because it wraps the same Bake a Files surface records under baked_from.

The last two are the procedural material path. ashlar_bevy::read_library_with_graphs takes a MaterialGraphLibrary beside the material library — ashlar_bevy::read_graphs reads one from RON — and check_library_with_graphs does the same for a library already in memory, such as ashlar_material::stdlib::materials() merged with a game's own. Both preflight every Graph and Shader surface by validating and lowering its graph, so an unknown graph key, a parameter the graph does not declare, a cycle or a node the backend cannot lower is a startup error naming the material and the path inside the graph; a Shader surface is partitioned and printed as WGSL on top of that, so a live set the backend cannot honour or a graph that binds more than eight textures is refused there too. A Shader surface is then compiled by ashlar_bevy::shader::create_shader_material into a ProceduralMaterial, which is a StandardMaterial with a generated fragment over it; add ashlar_bevy::shader::ProceduralMaterialPlugin to an app that may draw one. A Graph one is baked into images when the material is created, by ashlar_bevy::runtime_bake::create_graph_material, which takes the graph library, a BakeCache and Assets<Image> beside what create_material takes; the images are the bytes, formats and mip chain the KTX2 of that same Bake would carry, and two definitions naming one Bake share one texture set, so a per-building seed costs a set in memory and no file on disk. Plain create_material has nothing to bake with and hands a graph surface back as its constants. A bake resolution must be a power of two in 256..=4096, and parameter values must be finite; both are rejected by path. baked_from on a Files surface is provenance rather than a dependency, so it is not lowered: the files it names are already on disk and are what load.

The renderer applies repeat sampling and validates color-space use. A single image key cannot be reused as both sRGB color and linear data, and a height map is preflighted but not uploaded — a runtime bake keeps its height plane on the cache entry and binds it to no slot either, for the same reason. Bevy 0.19 does have a relief slot, StandardMaterial::depth_map, but it reads black as the top surface where these height fields read white as the top, and filling it also means choosing a parallax depth in the mesh's own units that a 0..=1 field does not carry; both wait for the bake.

Texture generation is not part of the core, and nothing baked is committed. The source of every material is its Rust graph: the default library in crates/ashlar-material/src/stdlib/, and the showcase's four compiled surfaces in examples/showcase/src/materials.rs. A game that wants files rather than a bake at registration runs the content step, ashlar_material::export, over its definitions; it answers the texture sets, the strand sets and a library whose surfaces name those files with baked_from beside them. The showcase's writer is examples/showcase/src/export.rs:

just materials                  # every material, into the ignored assets/materials/
just materials 2048 brick png   # one, at 2048, with level-0 PNGs to look at

Glass is opaque reflective glazing; transparency, transmission and decal blending are not implemented in this first contract.

#![allow(unused)]
fn main() {
let panel = Element::new(
    "body",
    Geometry::chamfered_cuboid([4.0, 4.8, 0.8], 0.03)
        .subtract(Geometry::cuboid([2.0, 2.0, 1.0])
            .placed(Pose::at([1.0, 1.5, -0.1]))),
    "painted",
).cut_material("exposed");
}

Bind both slots through recipe defaults or instance overrides. Manifold surface provenance identifies faces introduced by cutters, including nested expressions; this is not a position-based guess. The output retains one cut flag per triangle. mesh_building yields one batch per resolved slot, keyed by MeshKey::Element { part, element, slot }, as "A cutter may wear its own reveal" above describes. Without a cut slot, all faces share the original element slot.

A second slot is not the only way. A material graph can read the flag itself through its CutFlag node — ashlar-bevy uploads it as a per-vertex attribute, and TriangleMesh::weld splits the vertices either side of a cut so the value a fragment reads is exactly one or exactly zero — and darken or roughen the faces a cutter made without a second element, a second mesh or a second key. That wants a Surface::Shader, since a bake is one texture set for both kinds of face; showcase:concrete-cut-aware in the showcase is the worked example, and the study kit keeps cut_material on its entrance bay because a reveal that really is a different material still wants one. Changing a library or instance material override does not change its mesh.

One material key, a value per instance

A slot binds a material key, and an instance may override that binding. An override may also carry parameter values of its own, for the graph the material's surface names:

instances: [
    (id: "tower/bay-0", part: "metro:bay", pose: (...), materials: {}),
    (id: "annex/bay-0", part: "metro:bay", pose: (...), materials: {
        "stone": (material: "metro:stone", params: {"variation": Float(0.37)}),
    }),
],

A binding is therefore one of two forms, and both are the same field: the bare key "metro:stone" where nothing is overridden, which is what every recipe written before this existed holds and what a binding with no overrides still writes itself back as, and (material: "metro:stone", params: {...}) where something is. In Rust it is ashlar::Binding:

#![allow(unused)]
fn main() {
instance.binding(
    "stone",
    Binding::new("metro:stone").param("variation", ParamValue::Float(0.37)),
)
}

What it is for is variety without a library entry per building. The corporate block is the worked example: three buildings over one showcase:corporate-stone, which is library:stone-cladding at a variation that moves the whole surface to another piece of the same endless wall, and each building names its own value on its own instances. One definition in examples/showcase/src/library.rs is bound by every bay of all three.

The rules, and where each is enforced:

  • Only a surface that binds graph parameters takes them, which means Graph and Shader. A Plain or Files surface has nothing to override — a Files surface's baked_from is provenance rather than a request, and the files it names are what load — and MaterialLibrary::check_for refuses one by the path of the binding, instances[<id>].materials[<slot>].
  • The names must be parameters the graph declares, at the type it declares them. Only a crate holding the graph library can know that, so ashlar_bevy::read_library checks it, by the same path, by lowering the overridden surface exactly as it lowers a definition's own.
  • An override replaces values and nothing else. The graph, the bake resolution and the definition's own constants — base colour, roughness, tile_metres — stay the library's. A per-instance tint is therefore not this: a tint multiplier lives on the definition, so two tints are still two keys.
  • An instance that overrides a slot replaces the palette's binding for it, rather than adding to it, exactly as an override of the key alone does.

What it costs is what the value reaches. ashlar_bevy::definition(&library, binding) answers the definition with the overrides written into it, and everything below that deals in definitions:

  • A Graph override is a different Bake, so it is a different BakeKey and its own texture set in memory — and every instance that asked for the same one shares it, which is what makes a hundred bays over three seeds three sets.
  • A Shader override of a live parameter is the same pipeline, the same shader and the same bound textures with a different uniform block, which is the cheapest thing in this system: nothing is rasterised and nothing is rebuilt, because none of them ever saw the value.
  • A Shader override of a folded parameter is a second compiled graph, because folding a parameter puts it into the bound textures and into the generated text. Allowed, counted, and the reason the cost report exists.

Fit buildings to terrain

GroundingSpec declares a support footprint, floor, slab underside and entrance Socket (+Z outward). GroundingPolicy supplies limits. These plain types and fit_ground live in ashlar; they contain no architectural palette, world source, physics engine or renderer. Content-specific dimensions, limits and static collision proxies live with the content, as they do in ashlar-showcase.

The caller gathers a TerrainPatch of world-aligned triangles, typically a one-metre grid matching its own LOD0 collision, and hands it to the pure solver. TerrainPatch::with_diagonal says which corner pair each cell splits along; a patch read along the other diagonal is a different surface from the one the game's own players walk on. new keeps the anti-diagonal. That solver clips the triangles to the yawed footprint, every support segment and both approach sections, so heights between corners participate in extrema and ramp clearance. It raises the floor, embeds segmented perimeter supports, and searches for a level landing plus a straight grade-limited ramp. Invalid inputs, missing patch coverage, excessive support depth and unreachable approaches return typed errors. Larger footprints need a larger caller-supplied patch.

Grounding supplies a world pose, convex solids, conservative vegetation bounds and diagnostics. ashlar_bevy::ground_mesh draws those exact solid corners, and a physics adapter converts them to convex static collision. The shared render adapter also owns metre UV upload, tangents, material preflight and PBR texture loading. The native Manifold kernel stays in authoring, so a headless runtime that consumes fitted results sees only pure data and collision.

Levels of detail are baked ahead of time (ADR 0006), and there is no live site refitting or building streaming yet. Moving doors and terrain modification are separate future work.

The showcase kits

The outpost, office, facade and entrance scenes are one kit. The corporate, corporate-annex and corporate-block scenes are a second kit and palette in ashlar_showcase::corporate. Thirteen part definitions produce different heights and rotated courtyard wings. The shared geometry builders, the mesher and the render adapter have no corporate-specific behaviour.

just preview --serial-pipelines --scene corporate-block

The tour of the kits shows each of them.

The sheet-<name> scenes are not buildings. There is one for every baked material of the default library, sheet-brick to sheet-wood-floor, and each is that material on a ball and a cube at three texel densities, for looking at a surface rather than at a shape. Every column is a pair — the graph baked at 2048 when it is registered, and the same graph compiled — so that "a graph with no live parameter is the same picture either way" is something somebody can check by looking. detail-<name> is the same material on one two-metre patch, close enough to judge a joint or a blade.

just references renders every scene at three fixed angles, headlessly in one process. Open target/references/index.html. Use --reference-scenes corporate for a subset. The gallery is for visual inspection: there is no pixel baseline comparison, because coincident-edge draw ordering and driver revisions move pixels for reasons that have nothing to do with the geometry. The studio is fixed: 800×600 at MSAA 4×, front, rear and elevated views framed at 1.4 times the model's bounds diagonal, twelve warm-up frames after textures load and after each camera move, serial shader compilation and direct drawing, and a 120-second timeout that fails the run. Direct drawing is there because indirect draws were seen to reorder coincident edges between runs.

Stability

Shape, GroundError, UvMode, Collision, Facing and Diagonal are #[non_exhaustive]: a consumer matches on them, and a new node, failure mode or projection must not break that match. A backend rejects by path what it does not implement. The crate re-exports the glam it was built against as ashlar::glam; a consumer that resolves a second glam gets a DVec3 that is a different type from the one in a recipe, and converts at every boundary.

Next boundary

General edge bevels, authored UV atlases, layered wear/decal materials, convex decomposition and GLB export can build on this evaluator. A later macro can construct the same types without becoming their only authoring interface.

Material graphs in depth

This is the long guide to ashlar-material: tiling, the lowering, the buffered filters, shader compilation, what a bake writes and costs, and the standard library. The crate's README is the short version; start there.

Procedural material graphs as ordinary Rust data. Wire noises, patterns, transforms and blends into a PBR output; validate it; bake it into a tileable texture set, or compile it into a shader. No Bevy, no IO, no node editor.

The vocabulary follows Material Maker where a Material Maker node exists, so porting a graph by hand is mechanical.

Layering

ashlar                 ashlar-material   <- you are here. glam, serde, thiserror.
  <- ashlar-manifold                        No Bevy, no IO, no kernel.
                                            One feature, `zstd`, off by default:
                                            the KTX2 writer's supercompression.
                       ashlar-strands    <- the half of a strand layer after
                                            the scatter: the set, its file,
                                            place, mesh and cards. This crate
                                            depends on it and re-exports it.
  <- ashlar-bevy       <-                bakes graphs to Image assets and
                                          compiles them to Bevy materials.

ashlar and this crate sit side by side and know nothing of each other: a graph knows nothing about buildings, and a building names a graph by key only. Under both is ashlar-surface, the vocabulary a material definition is written in, which this crate re-exports; it is why a ParamValue here is the one a recipe binds. ashlar-bevy is the first crate that depends on both. An arrow never points the other way.

Tiling is checked, not hoped for

Every signal carries an integer Period: Tiled { u, v } means the field is laid on a lattice of that many cells across UV [0, 1) and meets itself at the seam. Generators take an integer period and wrap their lattice, pointwise nodes take the least common multiple of their inputs, and transforms may only change a period in ways that keep it an integer. A period is what the field actually does rather than what its counts suggest: brickwork in a running bond repeats every second row, and a bond that does not divide the rows is rejected at offset, because the wall would not meet itself across the v seam. Every bound output must tile, and so must the multiple across them, since that multiple is the repeat a bake writes; a graph that cannot tile is rejected by node path before anything is baked.

What a period does not say is that a coordinate meets itself: Uv tiles once because the lattice it addresses does, while u runs from 0 to 1 and jumps back. Every generator wraps the coordinate itself, so this only shows where a coordinate is used as a value — a Warp offset built from a bare Uv displaces differently on each side of the seam — and the node's own documentation says what to build such an offset from instead.

One lowering, two backends

Every node lowers into a small typed intermediate representation of pure per-texel expressions: an SSA list of three dozen primitive operations over f32, vec2 and vec3. The CPU interpreter runs that list, and the shader backend prints it; a node is written once, so a baked material and its live twin cannot drift apart, and only the primitives are implemented twice. Lowering folds constants — which is how a parameter exposed for baking disappears — gives one id to identical sub-expressions, and drops everything no output reads. The result hashes to a stable 64-bit cache key.

Every node the vocabulary declares lowers, and so does every output of every one of them: the coordinate; value and Perlin noise with their octaves; cellular noise with three metrics and its distance, cell, edge and border outputs; brickwork and a grid, hexagonal or herringbone tiling, each with a mask, a bevel, a per-tile id and a fill; the regular patterns, the signed-distance shapes — a disc, a box, a polygon, a star, a capsule and a gear, each of them roundable and hollow and read either as a mask or as the signed distance itself — a scatter of scratches and a woven cloth, plain, twill or satin, read as a mask, as relief, as one set of threads against the other or as a hash per thread; the resamplers — transform, warp, directional warp, gradient and intensity warps, mirror, kaleidoscope, a circle map that reads a source round a disc inside the repeat, a circle splatter that scatters one round a ring inside it, and a tile whose instances a mask and their own hash decide; levels, a monotone curve, a gradient, brightness and hue, the scalar operators, invert, mix, clamp, switch, decompose, combine and its two-lane form, a direction field, all eleven blend modes, a height-to-mask band and a normal from height; the buffered filters below; the four runtime inputs — time, world position, world normal and the mesh's cut flag — and the triplanar and world mask built out of the two world-space ones; and a subgraph, which is inlined into the graph that instanced it, together with the typed inputs that graph declares.

There is no GradientMap, which a Material Maker author will look for. What that name usually means — a float through colour stops — is Colorize, whose gradient takes any number of stops; the other thing it means, a gradient applied to an imported image, has nothing to apply to, because a graph has no image input.

A graph takes signals as well as parameters, and the difference between the two is when they are resolved. A parameter bound at a subgraph is a number, folded into constants when the instance is lowered, and never live: a live parameter of an instanced graph would want a uniform the outer graph never declared. An input is a whole field wired into a GraphInput node of the instanced graph, so it carries a type, a period and a lattice inward — which means the instance is inferred again under what was wired in, because an input of period 8 read through an inner transform of scale 2 lays 16 cells and nothing short of inferring it again says so. Instances are therefore cached per binding signature rather than per key: two nodes that bind the same fields share one instance and one inlined copy, and two that bind different ones pay for both. With nothing bound, a GraphInput is its default literal at unit period, which is what lets a compound still build, bake and be looked at on its own. Beside the six PBR channels a graph may export named float masks; a bake writes no map for one and a shader binds nothing for one, and they exist so that a compound which decided something — where the wear took, where the water sat — can hand that decision back rather than make its caller build it again and get an answer a texel off.

The runtime inputs are the one place where which backend is asking changes the answer. Time and the cut flag are zero in a bake, because the instant an app started and an uncut face are both pictures; BakeReport::cut_flag says when a bake answered that way, since a graph authored to darken its own reveals bakes to a surface with no reveals in it. In a compiled material the cut flag comes off the mesh: ashlar-bevy uploads one flag per vertex and splits the vertices either side of a cut, so a fragment reads exactly one or exactly zero. A world position and a world normal are refused by one, at the node that asked, with a message naming the shader surface that does answer: a world position of zero is every fragment of a wall at one point, which for a triplanar is one sample of its source smeared across a whole map, and a world normal of zero is not a direction at all, so a mask of the faces rain lands on would answer the same number everywhere and a bake would ship it as a surface. The op is unchanged either way — what is refused is a material somebody asked to bake, not an expression somebody asked to evaluate.

Two settings are still refused, and a refusal names the node that asked rather than quietly answering something else. One is a backend waiting its turn — a rounded or cut brick corner. The other is not waiting for anything: a simplex noise has no integer period at all, because the skew that turns the square lattice into the triangular one is (sqrt(3) - 1) / 2 and no whole number of repeats brings an irrational skew back to itself. It validates, it infers a period, and asking a backend to run one says why and points at Perlin, which has the same character and a lattice that wraps.

A resampler is the one thing in the lowering that costs rather than folds. The IR is one expression per texel and Op::Uv is that texel's coordinate, so a transform, a warp or a scatter cannot move a value that has already been computed: it emits its source again with another coordinate where the texel's own stood. Only the instructions that actually depend on the coordinate are copied, identical copies still collapse, and a sample of a plane is re-read at the new coordinate rather than becoming a second plane. A graph that reads one source through four frames pays for four, which is what it asked for.

Randomness is integer hashing and nothing else: a wrapping multiply-xorshift over lattice coordinates reduced modulo the period, which is bit-identical between Rust and WGSL and is why a noise wraps at its own period rather than approximately. A Perlin gradient is a pair of those hashes normalised rather than an angle through a sine and a cosine, so no noise here calls libm and no two backends can disagree about a transcendental function's last bit.

"Cannot drift apart" is a claim, and one test in the workspace makes it: the #[ignore]d tests/conformance.rs in ashlar-bevy, which just conformance runs on a machine with a GPU. It dispatches the emitted compute kernel and compares it with this crate's own interpreter over the same partition, texel for texel — one partition per primitive op, one probe per node whose arithmetic no shipped graph reaches yet, every shipped graph with every parameter live, the whole GPU bake of every shipped graph against the whole CPU bake of it as encoded bytes, and the lattice hash, which agrees bit for bit. On the reference machine nothing differs by more than eight parts in a million, where a colour has a budget of one part in 255. Everything else in this crate checks one backend; that one checks that they are the same function.

The filters that need a neighbourhood

A blur, an occlusion, a distance, an erosion, a dilation, a curvature or an edge detect cannot be a texel's own expression: reading a neighbourhood per texel would be the whole expression again once per tap. So the lowering cuts the graph there. Everything upstream is rasterised once into a plane, the filter runs over that plane as ordinary Rust, and everything downstream reads the result bilinearly with wrap. Buffer is the same cut with no filter, for pinning a plane's resolution or paying for a sub-expression once.

Wrapped is the word that carries the weight: every filter reads its neighbours across the seam, so a blurred noise still meets itself and the period a port carries still means what it says. A filter that clamped at the edge would leave a period that was right and a picture that was wrong, which is why each of them is tested against a field whose answer is different if it had.

Every radius is a reach in UV rather than a count of texels, so a graph describes the same surface at 512 and at 4096: the blur is a Gaussian truncated at its radius, with its standard deviation a third of it, and the same kernel laid along one angle is what makes a brushed metal out of a grain; the occlusion marches eight directions out to its radius and keeps the steepest rise in each; the distance is a jump flood over the torus, in UV units; the erosion and dilation take the square neighbourhood of the radius, which is separable and gives an axis-aligned mask back unchanged where a disc would round its corners; and the curvature and the edge detect answer differences over the radius rather than derivatives, so the edge of a mask is one whatever the radius and the crest of a relief answers the height it stands proud by.

One filter reads two planes rather than one. A slope blur walks each texel down the gradient of a height and averages the source at every place it stopped, which is what drags rust out of a chip and dust out of a joint — wear that follows the relief instead of ignoring it. So its filter names the plane it walks beside the plane it was given; the guide is rasterised first, costs no binding in a shader because nothing samples it, and is followed by everything that walks the plan list.

One more node is buffered, for a different reason. StrandRelief splats a graph's own strand layer — the same roots, lengths and colours the geometry is scattered from — back into a plane seen from directly above, so the relief the camera sees once the blades have faded out is the blades. A strand crosses many lattice cells, so splatting each footprint once into a plane is the bounded way to answer it. ADR 0004 is where it comes from.

Blur::slope(source, guide).slope_mode(SlopeMode::Min) takes the minimum along the walk instead: use it for chipped stone silhouettes and edge erosion. SlopeMode::Max builds deposits; Average remains the backward-compatible default. Min and max include the starting sample and operate per channel. All modes wrap across the tile and use the shared buffered pipeline for CPU bakes and shader delivery. The mode participates in the plane cache key.

A plane is the expensive thing in a bake — a megatexel of f32 per lane, rasterised and filtered once — so two nodes that ask for the same filter over the same expression share one, a buffered node no output reads leaves none, and a BakeCache handed to rasterise_with keeps the planes whose expression, filter and resolution did not change. That is what a preview re-baking on every turn of a slider wants.

What decides all of that is planes::PlaneKey, and it is public because the layer above keeps the encoded planes and has to ask the same question. It is the canonical encoding of the plane's whole closure after folding: the plane, the planes it samples or is guided by and theirs in turn, each written once and each sub-expression renumbered from its own root, with an Op::Sample standing in as a position in that list, and with every plan's filter chain, resolution and value type. An exact key rather than a digest of one: two keys are equal exactly when the planes are, and the comparison is a byte comparison, because a collision here would put one wall's texels on another. That is not free — a key is the sum of its closure, so a few kilobytes over a shallow graph and about a hundred over the deepest in the retired study library — and the type's own documentation has the measured table. planes::plane_keys answers them without rasterising anything, so a caller can find out what it already has before paying for a texel, and planes::rasterise_wanted then runs only the planes it names and the planes those read — planes::plane_closure is that dependency sweep.

Compiled to a shader instead

The same lowering is a fragment shader. partition colours every value in it: runtime where it depends on a parameter the author exposed as live, or on time, world position, world normal or the mesh's cut flag — the four things a texture cannot hold — and static everywhere else. Then it cuts at the frontier. Every maximal static sub-expression a runtime op reads becomes a bound texture, baked by the same plane pipeline the buffered filters use; what is left is an expression over the uniforms, the runtime inputs and samples of those textures. An output that reaches nothing runtime is a texture in its own right, sampled straight into its PBR slot, and the material's normal — which the bake derives from height outside the graph — comes back in as a root of its own: a bound normal map where the height is static, and four offset taps of it where it is not.

The point of the pass is the number it prints. A live parameter at the final blend costs one blend per fragment; one that warps the bound outputs, which is what a per-building variation is, costs every instruction that depends on the coordinate — the retired study brick was 1189 instructions baked and 2683 per fragment with its variation live, and the report says so before the shader ships. A graph with no live inputs reports zero runtime ops and the verdict no live inputs: bake this.

A world-space graph is the other end of that scale, and the showcase ships one. showcase:concrete-wet instances library:formed-concrete, the baked wall, and multiplies its live wetness by two WorldMasks — the faces that point up, and the faces under six metres — so one material is wet on a coping and dry on the wall beneath it, which was a slot somebody bound by hand until a fragment could ask. It costs a few dozen instructions over bound textures, and the two masks are most of them.

A bound texture goes into the format the bake writes that map in where it is one of the five maps, and into half floats where it is an interior cut, whose range the graph never promised. A graph binding more than the eight images a bind group has room for packs its scalars four to an image first — and a format belongs to an image rather than to a plane, so everything that packs goes into half floats whatever port it serves. Packing therefore trades the bake's own quantisation for fitting, which is the kind of thing a cost report exists to say.

Two things the cut cannot do, and says rather than fudging. A blur, an occlusion, a distance or a morphology reads an unbounded neighbourhood: it is a plane or it is nothing, so a parameter that reaches one is frozen — folded at its value the way a bake would fold it, and named in the report, because a slider that silently does nothing is worse than one that says why. And a runtime input above such a filter has no value to fold, so it is refused by node path.

Printed as WGSL

wgsl::emit turns a partition into shader text: one let per instruction, in the order the interpreter runs them, inside a function that takes the coordinate and the four runtime inputs and answers the ports the graph bound. Every op is written as the arithmetic the interpreter does rather than as the WGSL built-in that is nearly it — mix is a + (b - a) * t, because WGSL's mix rounds the other way; clamp is min(max(x, low), high), because WGSL leaves its clamp indeterminate when the bounds cross and a graph is allowed to cross them; and division, smoothstep and normalize go through small helpers that keep the guarded case each of them has. The lattice hash is the same integer multiply-xorshift over the same modulo-period reduction, in u32, whose arithmetic WGSL defines as wrapping. One op is narrower on this side and is written down rather than hidden: pow says nothing about a negative base, and f32::powf answers for one. One more difference is against the bake rather than the interpreter, and cannot be closed: a height that evaluates to an infinity bakes to a flat normal, because the plane pipeline guards a non-finite length, and compiles to a NaN one, because normalize of an infinite vector is NaN — and WGSL lets an implementation assume infinities never happen, so a comparison guarding it would be a promise this side cannot keep.

Around that function: the AshlarParams uniform block at binding 100, laid out the way WGSL lays one out — a scalar aligned to four, a colour to sixteen, consecutive floats packed four to a sixteen-byte row, in the order the graph declares its parameters, with the block rounded up to sixteen — and a ParamLayout per parameter so the Bevy side fills bytes without re-deriving any of it; one texture_2d<f32> and sampler pair per bound image from binding 101; a fragment over pbr_input_from_standard_material that multiplies into the PBR slots the way the same material's baked maps would, and writes its tangent-space normal through calculate_tbn_mikktspace where the mesh carries tangents and leaves the geometric normal alone where it does not; and a prepass fragment writing the same normal, so screen-space occlusion and the deferred path see the procedural relief and not the flat mesh. wgsl::emit_compute prints the same function as a @workgroup_size(8, 8) kernel over the texel centres of a square, which is what the conformance test dispatches and what the GPU bake in ashlar-bevy runs. wgsl::emit_compute_ports is that kernel for a subset of the ports, declaring only the images those ports sample: a GPU bake dispatches one plane at a time over partition::over_planes, which is this partition turned inside out — nothing cut, the whole expression on the runtime side, and every plane's root named as a port of its own so a kernel can be written for it.

Two texts come out of one emitter, and only one of them is a module naga parses. The Bevy-flavoured one opens with #import bevy_pbr::… and writes its bind group as #{MATERIAL_BIND_GROUP}; both are naga_oil preprocessor directives. The core — the block, the bindings, the helpers and the function — is the same generated code with a literal group and no imports, and it is what the crate's tests parse and validate at the naga version Bevy pins: every node of the vocabulary in isolation with a live parameter forcing it into the fragment, every shipped graph with every parameter live and with none, and the compute kernel. The wrapper around it is checked by the Bevy step compiling one, and compiling one is what found the last thing in it: globals is binding 11 of mesh_view_bindings in the main pass and binding 1 of the prepass's own smaller view layout, where nothing declares it because no Bevy prepass shader reads it, so a prepass that reads the clock declares ashlar_globals there itself rather than importing the main pass's name.

bake::encode_bound is the other half of the compiled path, and it lives with the bake rather than with the emitter because it is the bake's own policy: a bound texture is quantised with the same sRGB transfer, the same sixteen-bit height codes and the same half floats the five PBR maps are, and mipped with the same wrapped box filter down to 1x1. A live material and its baked twin have to be the same picture down to the eighth bit, and this is where that is decided — so the two places that policy is not "round every channel the same way" are followed here exactly:

  • Only the colour is dithered. The bake puts a 4x4 ordered Bayer cell in front of the quantiser for its sRGB base colour, because banding in a gradient that crosses less than one code per texel is what that exists to hide, and rounds its normal, its ORM and its height plainly, because dither in a direction field or a roughness is noise in data nobody looks at directly. encode_bound keys on the sRGB format, which is exactly the map the bake dithers.
  • A normal chain is filtered un-normalised. The mean of four unit normals is short where they disagree, and that shortness is the variance the next level needs; renormalising is therefore what a level ships, not what the next one is built from.

Both were found by comparing the two halves of a material sheet's pair as bytes rather than as pixels, and before them a bound normal map differed from the normal.ktx2 of the same graph in one code on an eighth of its texels.

What a bake writes

bake takes a graph, a resolution and the parameter values to fold in, and answers a TextureSet: sRGB base colour, a tangent-space normal, occlusion, roughness and metallic packed into one linear map, and the height and emissive maps a graph binds or does not, with the graph's opacity, where it binds one, in the base colour's alpha. The normal is derived from the height plane by a wrapped central difference, so a parallax shader reads the same field the relief was lit from. The one way the two can part company is a height that leaves 0..=1: the plane and the normal keep the value, the 16-bit map saturates, and the report says what the range was so that the graph can be fixed with a Levels rather than the map shipped flat. The f32 planes are exposed before encoding, because that is what the mip chain filters and what a test reads without arguing about rounding.

A bake is refused when the graph lays a lattice finer than the texels asked for — a noise of 512 cells baked at 256, octaves included. Below that the answer is not a coarser version of the surface but an arbitrary sample of it, different at every resolution, and the fix is more texels or a coarser graph. The count is carried through the graph rather than read off each node alone, because a resampler changes how fine a lattice lands: a transform that scales a noise by four lays four times the cells, a scatter of sixteen instances lays sixteen times what its source laid, and a graph counts what it instances, because lowering inlines it.

Asking for mips writes the whole chain: every level down to one texel, each the 2x2 box filter of the one above it, taken over the f32 planes and not over the encoded bytes. At an even size the groups partition the plane, so there is no wrap to run and no edge case to get wrong, and the mip of a repeat is still a repeat. Two planes are not a plain average, and they are the two that would otherwise make a wall shimmer: the chain carries the un-normalised mean of the level-0 normals, ships its direction, and widens the roughness by how much shorter that mean got — Toksvig's r' = sqrt(r^2 + (1 - |n_avg|) * k), over a constant the crate documents and defends. Renormalise without that and a bumpy surface turns to glass at distance. Height stays 16-bit and emissive stays half-float at every level.

Cut-outs: opacity, masks and leaves

A leaf card, a chain-link fence or a torn awning is a surface with holes in it, and ADR 0007 is how a material says so. The graph and the definition each carry half.

The graph binds PbrOutput::opacity, a float in 0..=1 held to the same tiling rule as every other output. A bake writes it into the base colour map's alpha, rounded rather than dithered, because the one thing that alpha is read for is which side of a line it falls. A graph that binds no opacity writes an alpha of 255 everywhere, exactly as before. A Subgraph reads another graph's opacity with SurfaceOutput::Opacity.

#![allow(unused)]
fn main() {
MaterialGraph::builder("game:leaf")
    .node("blade", Shape::new(ShapeKind::Capsule).size(0.12).length(0.5).edge(0.01))
    .node("veins", Noise::value().period(16))
    .output(PbrOutput::new().base_color("veins").opacity("blade"))
}

The mip chain is where a cut-out usually goes wrong. A box filter halves the alpha of every edge texel, so a thin leaf drops under the cutoff a level or two down and the card thins away at exactly the distance it was drawn for. So a graph with opacity gets a different chain for its base colour: each level carries the plain box-filtered opacity as its state and ships it scaled by the one factor that makes the share of texels at or over mips::COVERAGE_CUTOFF (one half) match level 0's, and the colour is averaged weighted by opacity so a transparent texel's colour does not fringe the leaf. A level can only cover whole texels, and a shape square to the grid ties whole rows of them, so where the nearest count would leave a level empty the tie that overshoots is taken instead. mips::coverage is the share, for a test or a report. The reference line is a half rather than the definition's own cutoff because a bake is a graph and a resolution and never sees a definition; a cutoff near a half keeps nearly exactly the coverage the chain preserves.

The definition says how the result is drawn:

"game:leaf": (
    tile_metres: (0.3, 0.3),
    alpha: Mask(cutoff: 0.5),
    double_sided: true,
    diffuse_transmission: 0.3,
    surface: Graph((graph: "game:leaf", params: {}, resolution: 512)),
),

alpha is Opaque or Mask(cutoff: ...) with the cutoff strictly inside 0..1; double_sided draws and lights both faces with culling off; and diffuse_transmission is the share of diffuse light that passes through, in 0..=1, which is what makes a leaf glow with the sun behind it. ashlar-bevy maps them to AlphaMode::Mask, double_sided with cull_mode: None, and StandardMaterial::diffuse_transmission. All three are skipped when default, so a library that sets none of them is the file it was.

Two things are refused rather than approximated. Blending is not an alpha mode at all: sorted transparency is a renderer's problem, and a mask keeps depth writes, shadows and the prepass. And a Surface::Shader over a graph with opacity is refused at output.opacity, because a compiled cut-out has to discard in the prepass and the shadow pass as well as the main one and the emitter does not write those yet; a leaf is baked, into a Graph surface or into files.

Anti-tiling: a repeat that does not show

A baked set repeats every tile_metres, and a wall sixty metres long wears thirty copies of a two-metre repeat side by side. Every mark the graph drew is thirty marks in a row, and from a little way off the row is the thing the eye finds. A definition that sets anti_tiling asks the renderer to read its maps so the repeat does not show:

"game:render": (
    tile_metres: (2.0, 2.0),
    anti_tiling: Some((
        stochastic: true,
        rotate: false,
        cells_per_repeat: 2.0,
        macro_metres: 24.0,
        macro_strength: 0.12,
    )),
    surface: Files(...),
),

Every field has the default shown and may be left out, and a definition without anti_tiling is the file it was. ashlar-bevy draws such a definition as an AntiTilingMaterial: Bevy's StandardMaterial with the base colour, normal and ORM maps read through a random triangular lattice, cells_per_repeat cells along a repeat, each lattice vertex reading the maps at an offset of its own (and, with rotate, a random quarter turn), blended across each triangle and renormalised about the map's mean colour so the blend keeps its contrast. Over that, two octaves of world-space value noise at macro_metres and a third of it move the albedo by up to macro_strength and the roughness the other way by half as much, so a whole facade is not one flat tone.

Rubble over sixty metres: the plain repeat below, the same maps anti-tiled above

The lattice suits a surface with no structure at the scale of its repeat: plaster, render, concrete without form lines, stone, earth, rubble. A pattern that has to line up across the repeat, such as brick courses, tiles, planks, panel seams or a formwork grid, is cut wherever two cells meet, and wants AntiTiling::macro_only(), which keeps the macro variation and reads the maps plainly. rotate is off by default because weathering runs down a wall and not across it, and a renderer ignores it where the repeat is not square.

What it costs is nine texture reads a fragment where a standard material makes three, plus one of the base colour's last mip; the prepass reads the same, so a mask cuts out the same texels in the depth, normal and shadow passes. The material is always forward. cargo run --release -p ashlar-bevy --example anti_tiling draws the comparison above, and -- --screenshot <path> renders it offscreen.

What a bake ships as

A texture set goes to disk as KTX2, one file per map, with every level inside it. PNG cannot carry a chain, and Bevy does not build one for an image it loaded, so a PNG set throws away the renormalised normals and the widened roughness the chain exists for and is point sampled the moment a wall is far away. It cannot carry the sixteen-bit height plane at all.

ktx2::write is the whole container: the eighty-byte header, the level index, a data format descriptor and a KTXwriter key. No dependency, because the parts of KTX2 a bake uses are a layout and four VkFormat numbers — R8G8B8A8_SRGB, R8G8B8A8_UNORM, R16_UNORM and R16G16B16A16_SFLOAT, each of which Bevy's own loader takes without transcoding. The pieces that are easy to get wrong are the ones the tests read back with somebody else's parser: the levels run largest first in the index and smallest first in the file, each aligned as the specification asks, and the descriptor says which transfer function the bytes are in, which is where a loader learns that the base colour is sRGB and the normal map is not.

ktx2::write_with is the same file with its levels supercompressed, one zstd frame per level under scheme 2, the uncompressed length recorded beside the stored one and the alignment dropped to a byte as the specification says it is for a stored level that is not texels. That is what export writes: the old study maps went from 94 MB of levels to 24 MB of frames, about a second a map to write and nothing measurable to read. The 8-bit planes come back at a tenth to a third of their bytes and the 16-bit height barely moves, its low byte being noise. It is the one thing in this crate behind a feature — zstd, off by default — because the encoder is a C library and only a step that writes files needs one; a game reads them with whatever decoder its engine already has, which for ashlar-bevy is the pure-Rust ruzstd Bevy builds in.

ktx2::inspect reads a file's header and level index back and checks them against the file's own length, whichever scheme they are under — for a compressed level it is the recorded uncompressed length that has to come to the level's own size, which is the one thing a reader with no decoder can check. That is what a startup preflight wants — is this a KTX2 file, is it the shape it claims — without decoding a texel, and it is what ashlar-bevy validates a .ktx2 texture key with.

The content step

A Surface::Graph definition bakes when the material is registered, and that is the default for the whole library: nothing is written to disk, and the source of every material is its Rust graph. A game that would rather load files than pay for a bake at startup runs the same definitions through export::export first. It bakes every Graph surface, scatters every strand layer that names no set, and answers three things: the texture sets, the strand-set bytes, and a MaterialLibrary whose surfaces now name KTX2 files under a directory, each recording the bake it came from in baked_from.

#![allow(unused)]
fn main() {
use ashlar_material::{bake::bake_with_report, export, ktx2::Supercompression, stdlib};

let graphs = stdlib::graphs();
let exported = export::export(&export::ExportRequest {
    graphs: &graphs,
    definitions: &stdlib::materials(),
    directory: "materials",
    resolution: None,
    threads: std::num::NonZeroUsize::new(8),
    backend: &bake_with_report,
})?;
for set in &exported.sets {
    for (path, bytes) in set.files(Supercompression::Zstd)? {
        // `materials/library/brick/base.ktx2` and the rest: yours to write.
    }
}
}

This may seem like a strange place to stop, one step short of a file. It is the same rule as the rest of the crate: no IO. ExportedSet::files is the whole of what a writer needs, so the step that owns the disk owns the only line that touches it. Two definitions that bake the same graph at the same parameters share one set of files, which is how a scene-specific definition that only changes the repeat costs nothing to export.

Writing is not free either. ktx2::ZSTD_LEVEL is 19, and ExportedSet::files compresses every level of every map on the calling thread, one map after another: measured 2026-09-20 on the SOI cobblestone at 2048, its four files took 21.5 s against a 13 s bake. The maps are independent, so a writer that wants the time back can call ktx2::write_with over export::maps(&set.maps) on a thread per map; the bytes do not depend on the order.

In this repository the showcase's writer is examples/showcase/src/export.rs, and just materials runs it into assets/materials/, which git ignores.

What a bake costs

Baking is CPU work per material, and the cost is per texel, not per frame. A 1024-squared bake of a graph with roughly two hundred operations per output is a few seconds single-threaded and under a second across eight cores; rows are rasterised in parallel through std::thread::scope on native targets, with no extra dependency, and inline on one thread where there is only one span to run, which is what wasm32 gets. That is generous for a content step that writes files, and it is why a material every level shares should be baked to files once rather than baked at startup. bake_with_report says what one actually cost: wall clock, the size of the whole expression, how much of it each output reaches, and how many planes its buffered filters needed.

What a node costs is its instruction count, and the second set spread that out: a grid of tiles is twenty-two instructions and a pattern the same, a shape from fourteen for a disc to thirty-three for a star or a gear, a herringbone weave forty-six, a Perlin octave sixty, a cellular noise about a hundred and fifty — nine cells searched — and a scatter of scratches about three hundred and seventy, because a texel tests the nine cells a segment could reach it from. A Tile costs its source again per instance it might draw: one while overlap is zero, nine past it. A Shape's own fields are priced the same way, measured on a probe whose wiring is four of every count here: a box is seventeen, a capsule twenty-one and a polygon thirty-two; asking for the signed distance rather than the mask saves three, because the inward ramp is what the mask adds to it; a shell costs four whatever it hollows; and a corner radius is two over a polygon or a star and eight over a box, which is the exact distance to a square standing in for the Chebyshev one, that being flat across a whole quadrant and so having no corner to round. A CircleMap is twenty-four instructions over the source it re-emits once — measured on a probe whose source is a Uv axis, which is six of the thirty — and twenty when its twist, its fill and its second turn fold away, because a polar frame is an atan2, a length and a handful of multiplies whichever way it is set. A CircleSplatter re-emits its source three times per ring, the instance nearest the texel in angle and the one either side of it, and that is the whole shape of its bill: over the same Uv axis a plain single ring is eighty instructions, of which six are the source; with all four variations, a face-centre turn and a mask that is a field rather than a literal it is a hundred and ninety-two; and a second ring of that takes it to three hundred and forty-three. Rings and variations are therefore what to reach for last. The two signed-distance nodes are the cheap end of the same ledger, measured on a probe whose two decomposed UV axes are seven of the count: a hard SdfCombine is one instruction, a min or a max, and two for a subtraction, which negates first; filleted it is eleven, twelve or thirteen, the polynomial being a clamped ramp, a mix and a quadratic. An SdfMask is three — a smoothstep and the complement of it — which is exactly what a Shape's mask costs over its own distance, eleven against fourteen, because it is the same three instructions. A Weave is priced the same way and by its output rather than by its cloth: twenty instructions as a mask, thirty as one set of threads against the other, thirty-five as a hash per thread and about fifty as relief, which is where both cross-sections, the crossing and the two crests the maximum chooses between are all paid for. Its pattern costs almost nothing — a plain weave and a twill are the same instructions at different constants, and a satin adds three for the move and the offset that scatter its binding points. Measured at 1024 squared across this machine's cores, that is a hundredth of a second for a grid, six hundredths for a Perlin, eight for a Voronoi and eighteen for either of the two that search a neighbourhood.

A plane is the other half of that bill, and it is paid once rather than per texel: at 1024 squared, across eight cores, rasterising a three-octave noise into one is about two tenths of a second, and the filter over it is another tenth for an erosion, a third for a blur of a hundredth of the repeat, and about half for a horizon occlusion or a jump-flood distance. A graph with four of them costs about two seconds, which is the same order as the rest of the bake and is why the plane cache exists.

The mip chain is a third again of level 0 in bytes and in texels, and costs about that: measured at 1024 squared on a graph binding all six outputs, level 0 takes a tenth of a second and the ten levels under it, filtered and encoded, add a quarter of that again. It is single-threaded on purpose — averaging four numbers is memory-bound, and the pass that made level 0 has already spent the machine on it.

Resolution costs more than its texel count. A filter's radius is in UV, so its reach in texels grows with the side, and a separable blur is n² × 2rn taps: cubic in the side. Measured 2026-09-20 on eight threads, 1024 to 2048 cost 4.8 times on the SOI cobblestone and the brick rather than four, so a 4096 bake extrapolates to five or six times a 2048 one. The grass moves less (2.4 times) because its scatter runs at strands::FIELD_RESOLUTION whatever the bake asks for. There is a floor, too: nothing bakes below its finest lattice, and the reference graphs lay 512 cells, so 256 is a BakeError::Lattice. The same run found the bake 82 per cent efficient at eight threads, with no serial section worth attacking.

Every speed-up the bake has had — the live walk, per-stage memoisation, strips, hoisted samplers, between 2026-09-18 and 2026-09-20 — left its bytes unchanged, and that is the rule for the next one: the same f32 operations in the same order. No mul_add where there was a multiply and an add, no reassociated sums, the same tap order in every filter, the same std transcendental per lane. A value stored and read back is the value recomputed, which is why memoisation is allowed and reassociation is not. The planes' byte-for-byte filter tests, the strips' bit-for-bit tests against the per-texel walk and just conformance hold it. A hardware FMA is allowed only because it is the correctly rounded fmaf it replaces, which is .cargo/config.toml's argument.

The default is one thread per core, bounded so that no span is shorter than a few dozen rows: several bakes at once — a test binary, or a task pool building a street of houses — would otherwise each ask for the whole machine, and the smallest bake has nothing to gain from more threads than it has rows worth giving them. BakeRequest::threads names a count where one is wanted, and is honoured as named. The answer is the same bytes whatever the number.

Same bytes is a promise about one machine. Across platforms the promise is the same picture: the sRGB encoding and the Pow, Exp2, Log2, Sin, Cos and Atan2 operators go through Rust's f32 maths functions, whose last bit is the platform's, so a value that lands on the edge of a code can round the other way on another operating system or architecture. The noises and the lattice hash use none of them, which is why most of a surface does not move. A content pipeline that caches or compares bakes by their bytes should key on the platform too.

Baking at runtime is for variety — a per-building seed, a tint the game picked — and its costs are real: every distinct parameter set is its own texture set in memory, and the bake belongs off the main thread. A material whose only change is a value that moves per frame should be a shader instead, where nothing is baked at all.

A standard library

ashlar_material::stdlib::graphs() is the one set of graphs this crate ships rather than reads, and it is a whole default material library. The finished surfaces are keyed library:<name>: concrete, paving, stone cladding, ashlar, plaster, the reference brick and cobblestone and their moss, four steels, timber, roof and wall tile, asphalt, rubble, grass, the city's facades, road and night surfaces, and a small utility set, forty in all. The ground surfaces — asphalt, road and paving-slabs — take a wet parameter, rain through weathering:moisture, whose default of zero leaves the dry bake exactly as it was; stained-concrete is a storey of dark cast concrete streaked from its slab edge, and office-window and shopfront are lit interiors behind glass, their colour and brightness the definition's emissive. They are built bottom-up rather than written one by one. A layouts:<name> graph draws unit identity and joints and no surface, a substances:<name> graph is a surface with no layout, and a finished material instances both through Subgraph. Under all of them are the weathering compounds and four pattern recipes, keyed weathering:<name> and patterns:<name>.

stdlib::materials() hands out the other half: a MaterialDefinition for every finished surface, carrying its physical tile_metres, its default parameters and a Surface::Graph at 512. A game merges the graphs into its own library with MaterialGraphLibrary::extend, which refuses a key collision rather than replacing either side, merges the definitions into its material library, and binds library:formed-concrete like any key of its own. Its graphs can then name any of these keys from Subgraph nodes. They live in the crate rather than in an example because the crate's own tests are what hold them to the tiling, lattice, lowering and WGSL rules that make a graph safe to instance at all, and because a game that wants dirt in its hollows should get it by adding a dependency rather than by copying six hundred lines of builder.

A compound takes the substrate's channels it intends to change, as GraphInputs whose defaults make it bake to a plausible picture with nothing wired in; it exposes the author's decisions as parameters, which fold when the instance is lowered; it writes back the channels it names and no others; and it exports the mask it decided with as an extra, so a caller can put its own dirt in the same hollows rather than build a second mask that agrees with the first to within a texel. Height and emissive are optional on PbrOutput, so reading one a compound does not write is a refusal by path; base colour, roughness, metalness and occlusion always have a value, so reading one a compound never touched answers PbrOutput's default — a metalness of zero, say — with no message. Read the channels the table lists and no others. No compound reads a runtime input — a bake refuses WorldMask by path, so a compound that read one could never be baked or tested on its own. Where the caller wants the weather gated by the world, the compound takes a plain float and the caller wires WorldMask::up(), a live parameter or a constant into it.

KeyInputsChannels it writesExtraOps aloneOps live
weathering:edge_wearheight, base_color, roughness, metallic, substrate_color, substrate_roughness, substrate_metallic, biasbase colour, roughness, metalness, heightmask18274
weathering:dirt_dustheight, base_color, roughness, biasbase colour, roughnesscavity_mask24918
weathering:rustheight, base_color, roughness, metallic, seed_maskbase colour, roughness, metalness, heightrust_mask398123
weathering:peeling_paintheight, paint_color, paint_roughness, substrate_color, substrate_roughness, peel_maskbase colour, roughness, occlusion, heightbare2866
weathering:moistureheight, base_color, roughness, metallic, wetbase colour, roughness, metalness, heightwet_mask644
weathering:streaksbase_color, roughness, source, biasbase colour, roughnessmask40825
patterns:radial_gradient—base colour, roughness, heightgradient3147
patterns:angular_gradient—base colour, roughnessgradient3310
patterns:wood—base colour, roughness, heightrings286150
patterns:cracksmaskbase colour, roughness, heightcrack_mask35468

The two counts are the two ways a compound is paid for, and they are not the same number at all. Ops alone is Ir::len of the graph lowered for a bake with nothing wired in: every input is its default constant and every parameter has folded, so this is what the compound's own arithmetic comes to once the optimiser has had the easy half of it. It is the number to read as the cost of baking the compound into somebody's texture set, and it is small for moisture — six — for a reason worth knowing: with a flat height at 0.5 and the waterline at 0.35, the whole submergence is a comparison between two constants and folds away. Bind a real height and the arithmetic comes back. Ops live is the fragment cost with every parameter kept as a uniform, measured as the instructions a partition at 512 texels leaves reachable from the outputs; that is the cost of compiling the compound into a shader with all of its decisions still turnable. Two of the ten cost more live than they do baked — patterns:radial_gradient at 31 against 47 and weathering:peeling_paint at 28 against 66 — and both for the same reason: baked with nothing wired in, each is arithmetic over constants that folds nearly to nothing, while live it keeps every parameter and, where it binds a height, the four taps the derived normal costs. The graphs that lay a real lattice go the other way, patterns:wood from 286 to 150 and patterns:cracks from 354 to 68, because what the partition leaves in the fragment is only what a live parameter reaches.

Two more compounds ship under weathering: and were added after the table was measured. weathering:moss grows moss over whatever it is wired onto: it takes base_color, roughness, metallic, height and bias like the others, plus shelter, the habitat moss prefers — the mortar, the hollows — and coverage, a field over its amount that is a good place for a live control. It writes base colour, roughness, metalness and height and exports its mask; growth only ever raises the height, and with amount or bias at zero every channel comes back exactly as it went in. It leaves occlusion alone, so the caller derives that again from the new height. weathering:paint_film is a dielectric coat over the channels it is given, plus a paint_color and a loss_mask: loss is shared by colour, roughness, metalness and thickness, so full loss is the substrate again. It exports remaining, and library:painted-metal is built on it.

A parameter only reaches an input port, so a filter radius, a noise seed, an octave count and a generator's period — all of them node fields — are documented constants in these builders rather than parameters. Exposing one would validate, bind and silently do nothing. The amount of a compound is instead a contour on whatever field it decided with, clamp((field - (1 - amount)) / softness, 0, 1), which is the same meaning in every graph here; and a compound that reads a neighbourhood of the height takes a relief parameter, because a curvature and an occlusion read the height as a length in the units of UV while a height input is a 0..=1 field. It is the same number the caller passes to normal_strength, which is what makes a compound instanced onto a shallow surface wear less than the same compound on a deep one.

MaterialGraphBuilder::layer is the sugar for reading several of one instance's outputs. A compound answers a surface rather than a field, and each output is a Subgraph node of its own whose bindings must agree exactly — an instance is inferred per binding signature, so a fifth input on one of four near-identical nodes would quietly make it a second instance. layer writes them from one node, adding <id>.<port> per listed output:

#![allow(unused)]
fn main() {
let material = MaterialGraph::builder("game:brick-weathered")
    // Live in the compiled twin: the rain the game moves.
    .param(Param::float("rain", 0.0).range(0.0, 1.0).live())
    // The wall itself: three channels off one instance of the library brick.
    .layer(
        "brick",
        Subgraph::new("library:brick"),
        &[SurfaceOutput::BaseColor, SurfaceOutput::Roughness, SurfaceOutput::Height],
    )
    // Dirt where the world says the rain cannot wash it off. The compound
    // takes a plain float; the caller decides what it means.
    .node("upward", WorldMask::up().softness(0.35))
    .layer(
        "dirt",
        Subgraph::new("weathering:dirt_dust")
            .input("height", "brick.height")
            .input("base_color", "brick.base_color")
            .input("roughness", "brick.roughness")
            .input("bias", "upward")
            // The brick's own relief, so the cavity filter reads a length.
            .param("relief", ParamValue::Float(0.02)),
        &[SurfaceOutput::BaseColor, SurfaceOutput::Roughness],
    )
    .layer(
        "wet",
        Subgraph::new("weathering:moisture")
            .input("height", "brick.height")
            .input("base_color", "dirt.base_color")
            .input("roughness", "dirt.roughness")
            .input("wet", Input::param("rain")),
        &[SurfaceOutput::BaseColor, SurfaceOutput::Roughness, SurfaceOutput::Height],
    )
    .output(
        PbrOutput::new()
            .base_color("wet.base_color")
            .roughness("wet.roughness")
            .height("wet.height")
            .normal_strength(0.02),
    )
    .build_in(&library)?;
}

That wall reads a world normal, so it is a Shader rather than a bake, and the partition is the point: one uniform per fragment over bound textures, with nothing frozen. The brick and the dirt are both dry arithmetic over the coordinate and both went into the textures; only moisture, which is downstream of the live rain, is left in the fragment, and it contains no buffered filter for the freeze rule to catch.

Example

#![allow(unused)]
fn main() {
use ashlar_material::{
    BlendMode, Channel, GraphError, Input, Material, MaterialGraph, MathOp, Param,
    PbrOutput,
    nodes::{Blend, Colorize, Decompose, Invert, Levels, Math, Noise, Uv},
};

fn concrete() -> Result<Material, GraphError> {
    MaterialGraph::builder("game:concrete")
        .param(Param::color("tint", [0.66, 0.65, 0.64]))
        .node("coarse", Noise::value().period(4))
        .node("grain", Noise::value().period(128))
        .node("pores", Levels::new("grain").in_low(0.87))
        // A cast panel's joints run across it and not down it, so they are a
        // band in v: eight rows, and the height drops where a row is within
        // 0.007 of its edge. `Bricks` would lay a wall, vertical joints and
        // all, which is a different surface.
        .node("rows", Math::new(MathOp::Mul, Decompose::new(Uv::new(), Channel::G), 8.0))
        .node("into_row", Math::new(MathOp::Sub, Math::unary(MathOp::Fract, "rows"), 0.5))
        .node("seams", Math::new(MathOp::Step, Math::unary(MathOp::Abs, "into_row"), 0.493))
        // `height` is a `0..=1` field, and taking a joint of 0.16 out of a
        // noise that starts at zero would run it negative: the map would floor
        // where the normal derived from the plane still had slope. Bedding the
        // grain at 0.16 first keeps the two describing one surface.
        .node("bedded", Levels::new("grain").out_range(0.16, 1.0))
        .node("height", Blend::new(BlendMode::Subtract, "bedded", "seams").opacity(0.16))
        .node("albedo", Colorize::new("coarse").gradient([
            (0.0, [0.60, 0.59, 0.58]),
            (1.0, [0.74, 0.73, 0.71]),
        ]))
        .node("tinted", Blend::new(BlendMode::Multiply, "albedo", Input::param("tint")))
        // An inline node needs no id of its own; this one becomes `dirty.b`.
        .node("dirty", Blend::new(BlendMode::Multiply, "tinted", Invert::new("pores")))
        .output(
            PbrOutput::new()
                .base_color("dirty")
                .roughness(Levels::new("coarse").out_range(0.85, 0.97))
                .height("height")
                .normal_strength(0.015),
        )
        // How much wall one repeat is. Advisory — see below.
        .tile_metres([2.4, 2.4])
        .build()
}
}

MaterialGraph is editable interchange data that Serde reads and writes; Material is the validated snapshot a backend consumes, carrying the inferred type and period of every port, and the only way to one is build. Errors carry a path: nodes[height].inputs[b], the way an ashlar recipe's do.

tile_metres, the one number both halves carry

A graph is authored per repeat, and some of what it says is in metres all the same: normal_strength is metres of relief per unit height per repeat, and a strand layer's lengths and radii are absolute metres. Both are only true at one repeat size, and that size is set on the other side of the seam, by the tile_metres of the ashlar::MaterialDefinition that lays the graph on a wall.

MaterialGraph::tile_metres: Option<[f32; 2]> is where a graph says which size that was. It is advice and nothing else: no bake, lowering, shader key or texture cache reads it, so declaring it changes no byte any graph ever baked, and a graph that declares nothing serialises exactly as it always did. What it buys is that a reader of a shipped graph can see the scale its relief was drawn at instead of inferring it, and that ashlar-bevy, the one crate holding both libraries, can compare it with what a definition tiles at: ashlar_bevy::tiling is that comparison, and the graph preflight — read_library_with_graphs and check_library_with_graphs — logs it. A definition that lays the graph at a different size warns unless its own tile_scale names that factor, which is how a specimen sheet says it shrinks a material on purpose to fake distance and how a typo of 2.4 for 1.72 still gets caught. One that lays it at a different shape, or scales a graph it grows strands from, warns whatever tile_scale says, because relief authored in metres of a repeat cannot follow a stretch and a blade's length is absolute metres.

Documentation

The founding decision is ADR 0003. The Bevy side — the bake cache, the compiled ProceduralMaterial, the GPU bake and the conformance suite between the two backends — is the adapter in depth. The node vocabulary is the rustdoc of ashlar_material::nodes, one module per family.

Licence

MIT or Apache-2.0, at your option.

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.

Props

A game draws more than buildings. Trees, rocks, crates and debris are not recipes of solids, and we would not want them to be: a tree is grown by code that knows about branches, not about booleans. So ashlar does not generate props. Your generator makes the meshes, and ashlar gives them the pipeline a building already has: a baked file with levels of detail and colliders, a loader, and a spawner with distance bands. ADR 0007 has the reasoning; this page is how you use it.

A prop, in code

A Prop lives in ashlar::prop, in the plain ashlar crate. It has an id, its levels of detail, its colliders and a bounding box, all in its own frame: metres, Y up.

#![allow(unused)]
fn main() {
use ashlar::prop::{Prop, PropCollider, PropLevel, PropPiece};
use ashlar::{FaceSource, TriangleMesh, VertexData, glam::DVec3};

let mut trunk = TriangleMesh::default();
// ... push_triangle for every face your generator makes ...
trunk.attributes.insert("pivot".into(), VertexData::F32x3(pivots));

let tree = Prop::new(
    "forest:oak",
    vec![
        PropLevel {
            until: Some(40.0),
            pieces: vec![
                PropPiece { mesh: trunk, material: "forest:bark".into() },
                PropPiece { mesh: crown, material: "forest:leaves".into() },
            ],
            ..PropLevel::default()
        },
        PropLevel {
            until: Some(150.0),
            cast_shadows: false,
            pieces: vec![PropPiece { mesh: coarse, material: "forest:oak-far".into() }],
            impostor: None,
        },
    ],
    vec![PropCollider::Capsule { a: DVec3::ZERO, b: DVec3::Y * 4.0, radius: 0.4 }],
);
}

A PropLevel is what a BakedLevel is for a building: an until distance, then the next level. It also says whether it casts shadows, because a far level's shadow is smaller than a shadow-map texel and still costs a pass. A PropPiece names its material by key in a material library, the same library your buildings wear. It is a key and not a slot, since a prop has no instances whose palettes could rebind it.

The last level may carry an ImpostorRef instead of pieces: a baked atlas of views of the prop, drawn as one quad. It names two KTX2 files, albedo and normal_depth, and carries the ImpostorLayout they were baked with. The next section's content step bakes one from the prop itself, and Impostors says what the pictures are and how they are drawn.

Colliders are a Capsule, an upright Cylinder or a ConvexHull. That may look like a short list, but they are the shapes every physics engine has natively, and a trunk, a post and a rock are made of them. Anything else is a hull.

Prop::new fits bounds around every level and every collider. Prop::check says what is wrong by path, levels[1].pieces[0].mesh.colors for example, and both write and read run it.

Vertex data

A tree's wind needs a pivot per vertex, and a stylised shader wants ambient occlusion or a height gradient. So a TriangleMesh carries colors, linear RGBA per vertex, and attributes, named streams of VertexData: F32, F32x2, F32x3, F32x4 or U32, one value per position.

Every mesh operation keeps them with their vertex. weld compares them exactly, so two corners with different pivots stay two vertices however close they stand. unweld and project_uvs copy them to every corner, and append joins two meshes whose layouts agree. push_triangle does not know about them, so a generator that pushes triangles extends its streams itself; is_consistent and check catch it when it did not.

Buildings never have vertex data. The kernel makes new vertices at every boolean, and there is no honest rule for what those would carry. A building file refuses a mesh with colours or attributes, which also keeps every building file readable by an ashlar from before vertex data existed.

In Bevy, ashlar_bevy::mesh uploads colours as Mesh::ATTRIBUTE_COLOR, which the standard material multiplies into its base colour. A named stream becomes ashlar_bevy::attribute(name, format), whose id is a hash of the name. Your shader specialisation calls the same function with the same word, so neither side keeps a table of attribute numbers.

The file

The content step writes a prop the way it writes a building:

#![allow(unused)]
fn main() {
let written = content.write_prop("props/oak.ashprop", &tree)?;
println!("{:?} triangles by level", written.triangles);
}

A .ashprop is the building file's container with its own twelve-byte identifier: a version, an indented RON manifest with the levels, colliders and bounds, and the binary mesh table, with positions narrowed to f32 as a building's are. A mesh two levels share is stored once. Prop::read refuses what it cannot account for by kind, as BakedBuilding::read does.

A server reads colliders from the same file with nothing but ashlar: no Bevy and no kernel.

The nearest level can be baked into the impostor the last level draws:

#![allow(unused)]
fn main() {
use ashlar_content::ImpostorSettings;

let written = content.write_impostor(
    "props/oak",               // albedo.ktx2 and normal-depth.ktx2 go here
    &tree,
    &graphs,
    &definitions,              // the library as authored, graphs and all
    ImpostorSettings::default(), // 12 x 12 frames of 128 texels, upper hemisphere
)?;
tree.levels.last_mut().expect("a level").until = Some(150.0);
tree.levels.push(PropLevel {
    cast_shadows: false,
    impostor: Some(written.impostor),
    ..PropLevel::default()
});
content.write_prop("props/oak.ashprop", &tree)?;
}

Each piece's material is looked up in definitions. A Surface::Graph is baked in memory and its maps are what the rasteriser samples; a Surface::Plain is its colour. The colour multiplier, repeat, offset, cut-out and sides come from the definition, so the pictures are of what the game draws. Hand it the library as authored, not the one write_materials returned: a Surface::Files definition is refused, because the step writes those files and does not read them back, and a Surface::Shader is refused because a live shader has no one picture to take. The impostor is cached like a building, by the nearest level, the definitions and graphs it wears and the grid.

Props themselves are not cached by Content::cache. That may seem like an oversight, but there is nothing to cache: writing a prop is serialising it, and the cache key would be those same bytes. write_file already leaves an unchanged file alone.

In the game

AshlarPlugin loads .ashprop files as PropAssets. The loader uploads every piece's mesh once, as a labelled sub-asset (props/oak.ashprop#level0/piece1), so every copy of a prop draws the same meshes and batches.

For the handful of props a scene places by hand, spawn an AshlarProp:

#![allow(unused)]
fn main() {
commands.spawn((
    AshlarProp {
        prop: server.load("props/oak.ashprop"),
        materials: server.load("materials/library.materials.ron"),
    },
    Transform::from_xyz(12.0, 0.0, -3.0),
));
}

Once both files have loaded, the plugin spawns one child per piece per level, with an AshlarPropPiece, its level's VisibilityRange band, and NotShadowCaster on a level that casts none. The bands and the crossfade are exactly a building's, including Bands::Auto on WebGL2. A piece a coarser level did not change is one entity whose band runs on through that level. The root gets AshlarSpawned and an AshlarPropSpawned message, or AshlarFailed with the reason, and a changed file respawns it.

An impostor level is one more child: impostor_quad wearing an ImpostorMaterial over the level's two atlas files, marked AshlarPropImpostor, in the level's band and with NotShadowCaster. The quad and the material are made once per prop file and shared by every copy. AshlarPlugin adds the ImpostorPlugin that draws them; adding it yourself as well is harmless.

A forest is a different matter. Tens of thousands of entities with a component each is the wrong shape, so a game drawing that many reads PropAsset::meshes and the materials itself and manages its own instances. prop_level_range(&prop, level, crossfade) is the band AshlarProp would have given each level, so a forest's own entities switch at the same distances. AshlarProp is for the rest.

cargo run --example prop -p ashlar-bevy builds a small prop in code, writes it and loads it back.

Tables

A prop can carry named tables of numbers in Prop::tables, for data a game's shader reads once per prop rather than once per vertex: a tree's wind pivots, say, indexed by a small per-vertex attribute. ashlar checks that the values are finite and stores them in the file's manifest; what they mean is the game's.

Impostors

A forest's far level cannot be geometry. An impostor is a prop drawn as one camera-facing quad wearing pictures of itself: ashlar-impostor takes the pictures in the content step, and ashlar-bevy's impostor module draws them in the game. The game half is featureless and links no baker, the same split as buildings and materials (see Using ashlar in a game). The decision is ADR 0007.

A procedural tree on the left and its impostor on the right, from the same camera

What is baked

An atlas is a square grid of frames, frames across and down, each frame_px texels square. Every frame is an orthographic picture of the prop's bounding sphere from one direction, and the directions are laid out hemi-octahedrally: the middle of the grid looks straight down, the border looks along the horizon, and the frames crowd towards the border, where a walking camera sees a tree from. A prop seen from below, a floating rock say, asks for the full octahedron instead.

Two maps come out, both RGBA8:

  • albedo, sRGB with straight alpha. Alpha is coverage, from sixteen samples a texel, so edges are anti-aliased and a leaf card's cut-out is the leaf's own.
  • normal and depth, linear. RGB is the shading normal in the prop's own frame with any normal map applied, and A is where the surface is along the frame's direction: 0 at the sphere's near side, 0.5 at its centre, 1 at its far side.

Both come with a mip chain that holds each frame's alpha coverage at the 0.5 cutoff, so a cut-out tree keeps its density with distance rather than thinning away.

The content step

A prop's impostor is one call, Content::write_impostor, which bakes the prop's nearest level in the materials it wears and writes both maps; Props shows it, and AshlarProp then draws the impostor as the prop's last level. The rest of this section is the baker underneath, for a step that pictures something that is not a Prop.

#![allow(unused)]
fn main() {
use ashlar::TriangleMesh;
use ashlar_impostor::{BackNormal, ImpostorPiece, ImpostorRequest, PieceMaterial, bake};
use ashlar_material::ktx2::Supercompression;

fn write_tree(bark: &TriangleMesh, leaves: &TriangleMesh, bark_set: &PieceMaterial,
              leaf_set: &PieceMaterial) -> anyhow::Result<()> {
    let atlas = bake(&ImpostorRequest {
        pieces: &[
            ImpostorPiece { mesh: bark, material: bark_set },
            ImpostorPiece { mesh: leaves, material: leaf_set },
        ],
        frames: 12,
        frame_px: 128,
        hemispherical: true,
        threads: None,
    })?;
    let files = atlas.ktx2(Supercompression::Zstd)?;
    std::fs::write("assets/props/oak/albedo.ktx2", files.albedo)?;
    std::fs::write("assets/props/oak/normal-depth.ktx2", files.normal_depth)?;
    std::fs::write("assets/props/oak/impostor.ron", ron::to_string(&atlas.layout)?)?;
    Ok(())
}
}

A piece is a TriangleMesh, in the prop's own frame with UVs in metres, and a PieceMaterial: a base colour map, an optional normal map, the metres one repeat covers, a colour multiplier, an alpha cutoff and whether the piece has two sides. PieceMaterial::from_texture_set takes level 0 of a texture set ashlar-material baked. Give the pieces what the real prop's materials show, because the picture is taken of what the rasteriser samples.

Two-sided pieces need one more decision, because Bevy itself makes it two ways. A double-sided StandardMaterial turns a back face's normal round when the mesh has a normal map or has no tangents, and keeps it when the mesh has tangents and no normal map, which is how ashlar_bevy::mesh uploads a leaf card. BackNormal::Flip, the default, is the first; BackNormal::Keep is the second, and it is what foliage with normals pointing out of the crown wants.

zstd supercompression needs ashlar-impostor's zstd feature. ImpostorLayout is plain serde data in ashlar::impostor, so the game reads it without the baker.

Choosing the grid

The atlas is frames * frame_px texels across, and the two maps with their chains are about eleven bytes a texel.

GridAtlasBoth maps, uncompressed
8 x 8 of 128102411 MB
12 x 12 of 128153625 MB
12 x 12 of 2563072100 MB

Twelve across puts neighbouring frames about eight degrees apart at the horizon, which is where blending between them stops showing. A frame need be no larger than the prop ever is on screen at the distance the impostor takes over: a tree two hundred pixels tall at forty metres wants 256, and the same tree from eighty metres wants 128. Keep the atlas at 4096 or under for WebGL2.

A 15,000-triangle tree at 12 x 12 frames of 256 texels bakes in about a second on 32 threads. The bytes are the same whatever the thread count.

The game

A prop whose last level is an impostor needs nothing here: AshlarProp spawns the quad. What follows is the same by hand, for an impostor that is not a prop's level, or a forest whose instances the game manages itself.

#![allow(unused)]
fn main() {
use ashlar_bevy::prelude::*;
use ashlar_bevy::ashlar::impostor::ImpostorLayout;
use bevy::prelude::*;

fn spawn_oak(
    mut commands: Commands,
    server: Res<AssetServer>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ImpostorMaterial>>,
    layout: Res<OakLayout>, // the RON the content step wrote, read at startup
) {
    let (albedo, normal_depth) = load_impostor_images(
        &server,
        "props/oak/albedo.ktx2",
        "props/oak/normal-depth.ktx2",
    );
    let material = materials.add(impostor_material(&layout.0, albedo, normal_depth));
    let quad = meshes.add(impostor_quad(&layout.0));
    for position in [Vec3::new(40.0, 0.0, 12.0), Vec3::new(55.0, 0.0, -30.0)] {
        commands.spawn((
            Mesh3d(quad.clone()),
            MeshMaterial3d(material.clone()),
            Transform::from_translation(position),
        ));
    }
}

#[derive(Resource)]
struct OakLayout(ImpostorLayout);
}

Add ImpostorPlugin beside DefaultPlugins, or AshlarPlugin, which adds it. One material and one quad serve every instance of a prop, and each instance's Transform places, turns and scales it: the frames are read in the instance's own frame, so a tree turned a quarter round shows the side of it that now faces the camera.

impostor_quad's positions span the bounding sphere's box and are never drawn, because the vertex stage builds the billboard from the corners' UVs. They are there so Bevy's frustum culling and VisibilityRange see the prop's real extent. A VisibilityRange on the real prop that ends where the impostor's begins, with the same margins, is the level-of-detail switch, and Bevy dithers between the two; prop_level_range answers those bands for a Prop's levels.

The base StandardMaterial inside ImpostorMaterial is the lighting: its colour tints the atlas, and its roughness, reflectance, diffuse transmission and fog apply as they would to the prop. impostor_material sets it rough, dielectric, cut out at 0.5, unculled and forward.

How it draws

The vertex stage turns the quad to face the camera around the instance's bounding-sphere centre, 2 * radius * scale across, a little wider close up where perspective shows more of a sphere than its radius.

The fragment finds the direction from the centre to the camera in the instance's frame, maps it onto the grid, and takes the three frames of the grid triangle it lands in, weighted by their barycentric coordinates. That choice is per instance, so every fragment of one impostor reads the same three frames. Each frame is read where the fragment's own ray meets it: the ray is intersected with the frame's plane, and the frame's depth moves it to where the surface was, twice. The three reads are blended, colour weighted by their alpha, and the result is cut out at 0.5 and handed, with the blended normal rotated into the world, to Bevy's own lighting through the base material. So an impostor is lit by the same sun and shadowed by the same shadow maps as the geometry around it.

The depth, normal and motion-vector prepasses draw the same billboard with the same cut-out, so the material works under MSAA and temporal anti-aliasing, and on a camera that also runs a deferred prepass.

What it does not do

  • Cast shadows. A billboard turned to the sun is a different picture from the one the camera sees, and its shadow swims as the camera moves. The material has no shadow pass. Past the shadow cascades that is usually invisible; nearer, give the prop a cheap proxy that casts.
  • Shadow itself. The pictures are taken unlit, so a crown does not darken its own trunk the way the real tree's shadow maps do. Seen side by side with --no-shadows, the example's tree and impostor light alike.
  • Write per-texel depth. The quad's depth is the plane through the prop's centre, so an impostor meets the ground and its neighbours as a flat card through its middle.
  • Draw deferred. The material is always forward.

Seeing it

cargo run --release --example impostor -p ashlar-bevy grows a tree in code, bakes it, and orbits the tree and its impostor side by side, with a second pair behind them turned a quarter round and scaled down. --show mesh and --show impostor stand one alone so two screenshots from one camera compare exactly; --yaw, --pitch, --distance, --no-shadows, --no-parallax, --taa, --prepass, --atlas <dir> and --screenshot <path> are the rest. just draw runs the impostor on five cameras against a real GPU.

The preview

ashlar-preview is the authoring tool. It draws any showcase scene, a material sheet or a recipe of your own, bakes materials from their graphs as it goes, and lets you move a graph's parameters and watch the surface change. It is a tool and links everything: the kernel, the graph engine and Bevy with every feature. A game never needs it.

just preview --scene corporate-block

just preview builds it and passes the arguments through. The first build compiles the Manifold kernel, so it takes a while; after that it starts in seconds. Run it with --help for the full list; this chapter covers what you reach for.

Moving around

Drag with the left button to orbit, with the right to pan, and scroll to zoom. Home frames the model again, and Escape quits.

A few keys change what is drawn. F toggles the wireframe and P toggles clay, which drops every material for a neutral grey, so silhouettes and gaps read before surfaces distract. PageDown and PageUp move a storey cut, hiding every storey above it, and I hides the exterior, which is how we look into a building's rooms.

The camera orbits the middle of the model by default. That may seem a strange default for a city, where the middle is half a tower up, but it frames anything without configuration. --focus X,Y,Z orbits a point in building space instead, and with it --zoom can go right down to street level:

just preview --scene metropolis --night --focus 30,2,10 --zoom 0.03 --pitch 0.1 --yaw 0.15

Lighting

The default rig is daylight: a key at fourteen thousand lux and enough fill to read inside reveals. --night swaps it for a dim, cold moon, a blue-black sky and bloom, and stands a small light at every strongly emitting piece, so street lamps pool on the road and signs wash their wall. --key and --ambient set the two numbers by hand.

By day the preview turns emission down to a quarter, so a lit window reads as a lit room rather than as neon in full sun. That is a choice of the preview's rig, not of the materials; a game lights its own scene.

Materials

Press M for the material panel, then click a surface to select the graph behind it. Every parameter of that graph gets a slider. A parameter a compiled Surface::Shader reads live moves under the pointer, and anything else re-bakes when you let go.

S writes the graph library back to RON, over the file it came from or to --graphs-out. The preview also watches that file, so editing the RON in an editor reloads it within half a second. Keep in mind that the Rust builder is the source: copy the numbers you settle on back into it, because the next content step bakes from Rust, not from the RON.

sheet-<name> and detail-<name> scenes exist for every surface of the default library. A sheet is twelve specimens, two shapes at three distances, baked and live side by side; a detail is a two-metre patch, close enough to see a joint or a blade of grass.

Your own recipes

--recipe wall.ron draws a recipe of your own instead of a catalog scene, and --write-example out.ron writes the current scene as editable RON to start from. Without --materials a recipe draws every slot in its own diagnostic colour, which is what we want while the shape is still moving.

Damage

In a merged scene, such as corporate-merged, shift-clicking a surface blasts a hole in it. [ and ] change the radius, and holding control reaches three times as far. Whatever the blast leaves hanging falls, unless --no-collapse says otherwise. ADR 0005 is the design behind it.

Screenshots and galleries

--screenshot out.png renders one frame and exits, with --yaw, --pitch and --zoom for a reproducible view. It is how every capture in this repository's history was made.

just references renders a windowless gallery of every scene, three views each, into target/references. --reference-scenes narrows it, and --reference-distance and --reference-view set up close captures of a surface.

Screenshots and galleries work headless on a software renderer such as llvmpipe, just slowly. A driver that crashes compiling shaders can be worked around with --serial-pipelines.

A tour of the kits

The showcase in examples/showcase is a set of kits built with nothing but the public API. They are there to be read and copied, not to be a style you inherit. A game brings its own look; what it takes from here is how a kit is put together.

Every scene below opens in the preview with just preview --scene <name>, and most of them are in the live demo as well.

The study pieces

facade, entrance, outpost and office are where the recipe API was first worked out. A facade bay is one chamfered panel with its openings subtracted and trim added as further elements, and the outpost and the office are those bays chained by sockets into a one- and a three-storey building. They are small enough to read in one sitting, and references/walkthrough.md in the geometry skill takes the facade apart element by element.

basic is the primitive fixture: one of every shape, drawn in diagnostic colours. It exists for tests and for checking a mesher change at a glance.

The corporate kit

The corporate kit's courtyard block

corporate, corporate-annex and corporate-block are the kit in corporate.rs: a double-height public podium, a lobby, and curtain bays on a four-metre grid, one ring per storey, with a dark service riser up one side and plant and rails on the roof. Glazing channels, pressure caps and transoms frame the panes; the projecting sills and cap courses have drip edges. The lobby canopy has fascia, soffit ribs and supports, and the roof plant has fan cowls, grilles and a coupled duct. Every bay is one part placed many times, so a forty-storey tower is a few dozen unique meshes.

corporate-merged and corporate-block-merged are the same buildings merged by storey. A merged building unions each storey into one solid, which removes the seams between parts and makes the storey the unit a hit re-meshes; shift-click one in the preview to blast a hole in it.

The house

The house with its roof and upper storey cut away

interior is a two-storey house built the way ADR 0005 says to build an interior. Walls, floors, liners and partitions are added elements, and only the openings are subtracted. This may look like the long way round, when carving rooms out of a solid mass would be shorter to write, but a collision proxy is convex, so the proxy of a carved mass would fill the room it was carved from. Window surrounds, projecting sills, door casings and dark metal roof edging finish the exterior. Inside, skirting stops at the doorways and the partition has a casing on each side. Open it with --hide-exterior --max-storey 1 to walk its rooms from above.

The sci-fi kit

The frontier colony

scifi-kit, scifi-outpost and scifi-colony are a frontier kit in the idiom of Anarchy Online and Star Wars Galaxies, in scifi.rs and scifi/. Adobe domes, capsule habitats on stilts, a flared hab tower, moisture vaporators, tube walkways and prefab modules, most of them lathe-turned with Geometry::revolve. The enterable ones carry furnished interiors with rooms and portals, and a building varies a piece by overriding a slot's graph parameters on an instance, a faction's hull colour or a weathered hut, never by copying geometry. Dark metal and stained concrete, corporate blade signs, cyan readouts and amber warning lights connect the colony to the city. Braced supports, docking collars, framed openings and service covers belong to the shared parts; consoles, bunks, lockers and galleys carry their own fittings. scifi::outpost_seeded and scifi::colony_seeded select from a finite palette and shared utility packages. Fine collar sectors disappear at distance while the luminous accents remain.

The dark city

The metropolis by night

city-kit, city-block, city-alley, city-tower, city-landmark and metropolis are the kit in city/, and the largest example in the repository. It takes the corporate kit's facade language tall and dark: a deep wall of stained concrete, recessed windows in steel frames, a strip light in each window's head and a lit line down each chamfered corner.

A tower is dressed in one of three styles, each with its own plans for its three tiers. Frame towers have punched windows between tapered fins, band towers are brutalist ribbons between precast spandrels, and cage towers are floor-to-ceiling glass behind a deep concrete frame. The styles differ in massing too, so they read apart at a kilometre and not only up close: a frame tower keeps a heavy lower tier, a band tower is a long slab on a short podium, and a cage steps in early to a slim shaft. The generator picks a style per tower from its seed, and avoids matching neighbours across an alley.

Every setback is a built terrace, with a cornice, a coped and lit parapet and planters, and every tower ends in a designed crown its topper stands on: a louvred penthouse, lit steps, a tapered lantern, or an open ring of blades. At street level a tower stands on a podium of piered shopfronts under a lit fascia, with glazed sliding doors in a deep portal under a canopy.

The landmark in the middle of a city is the one building every skyline shot is composed around, so it is designed as one. It stands on a double-height colonnade over a composed forecourt, glazed sky floors mark both of its setbacks, and it ends in a tall lit lantern the spire grows out of.

Every storey is still one part per footprint, and every one is an office a player can walk into: a lift lobby at the core, a glazed meeting room, desk benches with chairs, a server room, and ceiling panels that light it. A tower's ground floor is a lobby, with reception, seating and security gates on the way to the lifts. All of it is interior, so it drops at the first coarser level of detail.

Between a lot's four towers run back alleys: gutters, service shutters and loading bays, fire escapes with open grated landings, air-conditioners, pipes, caged lamps, neon blade signs and lanterns strung overhead. The sidewalks have lowered crossings, benches, tram shelters, news terminals and cycle stands, and some lots are night markets of stalls and noodle counters. Which pieces dress which alley and block varies by seed, and none of it is per-instance geometry.

What varies from storey to storey is material, not geometry. Which offices are lit, which storeys are dark, and which neon a building wears on its crowns and corners, are all instance overrides of a handful of definitions, so a city of thousands of storeys costs a few dozen meshes and a few dozen materials. That may sound too good to be true for a city this size, and it does have a price: the 4 x 4 metropolis draws about 4.3 million triangles at full detail, which is why that level only ever draws within sixty metres of the camera, and why a test in examples/showcase/tests/city.rs holds the city to its budget at every level.

A wet street and a back alley by night

Look at it by night:

just preview --scene metropolis --night
just preview --scene city-alley --night --zoom 0.35 --pitch 0.12

metropolis is city::metropolis(seed, blocks), and the same seed lays the same city. It is also the scene the content step bakes to measure levels of detail: ADR 0006 has the numbers.

The materials

Every surface in the showcase comes from ashlar-material's default library of forty surfaces, bound through examples/showcase/src/library.rs, which adds the kits' own definitions on top: the corporate stone, the dark city's windows, neon and wet roads. A definition there is almost always a library graph with different parameters, not a new graph. sheet-<name> shows any library surface on its own.

Architecture

ashlar is eight crates, and the first thing to know about them is which half each belongs to. The content half runs when content changes and writes files. The game half loads those files and draws them, and never links the content half.

The crates

CrateWhat it isHalf
ashlarThe domain: parts, elements, instances, sockets, slots, rooms, terrain fitting, levels of detail, and the .ashlar baked building fileboth
ashlar-surfaceThe material vocabulary both halves share: MaterialDefinition, Surface, Bindingboth
ashlar-strandsStrand sets: placing, meshing and reading baked grass, fur and mossgame
ashlar-bevyThe Bevy adapter: AshlarPlugin for the game, tool features for authoringgame
ashlar-manifoldThe mesher, over the native Manifold kernelcontent
ashlar-materialMaterial graphs, the default library, the bake and the KTX2 exportcontent
ashlar-contentThe content step as a library: bake a game's buildings and materials to filescontent
ashlar-impostorBakes a prop into a hemi-octahedral impostor atlas for drawing it far awaycontent

Each crate depends only on the ones listed before it here:

ashlar-surface    nothing of ours
ashlar-strands    nothing of ours
ashlar            ashlar-surface
ashlar-manifold   ashlar
ashlar-material   ashlar-surface, ashlar-strands
ashlar-content    ashlar, ashlar-manifold, ashlar-material
ashlar-impostor   ashlar, ashlar-material
ashlar-bevy       ashlar; ashlar-strands under `strands`; ashlar-material only under the tool features

ashlar knows nothing about Bevy, files or kernels, so a dedicated server can depend on it alone. ashlar-bevy with default features depends on neither ashlar-manifold nor ashlar-material, so a game has no C++ toolchain and no graph engine in its build. A test in ashlar-bevy holds that, because a feature added in the wrong place would quietly undo it.

How content flows

A building starts as a Building, built in Rust and validated by build(). ashlar_manifold::bake meshes it at every level of a LodPolicy ladder, simplifying the recipe for each coarser level rather than decimating triangles, joins a level into a few batches per cell where its policy asks (a concatenated level), and writes a BakedBuilding into an .ashlar file.

A material starts as a MaterialGraph, a graph of tiled fields with a PBR output. A MaterialDefinition names it through Surface::Graph, and the content step bakes it to KTX2 maps with their mip chains and rewrites the definition to name those files through Surface::Files.

ashlar_content::Content::ship does both in one call, and resolves per-instance parameter overrides into definitions of their own first, because a file cannot take a parameter at runtime. In the game, AshlarPlugin loads the .ashlar file and the library.materials.ron as assets and spawns every level with its distance band, the collision proxies and the rooms.

The tool path is the same pipeline without the files. ashlar-bevy's runtime-bake feature bakes a Surface::Graph into images when the material is registered, and shader compiles a graph into a fragment shader for parameters that change per frame. That is what the preview and the web demo use, and what a game should not.

Where things live

crates/ holds the eight crates, each with its own README and its own examples. examples/showcase is the kits and their scenes, examples/integration-content and examples/integration-game are the split a game copies, and examples/web is the browser demo. tools/ashlar-preview is the authoring tool.

docs/guide is this manual's chapters, docs/adr the decision records, and docs/material-references the reference material the benchmark reproductions were matched against. .agents/skills holds the two skills an AI agent working in this repository loads, one for geometry and one for materials, which are also a compact reference for a person.

The justfile is the front door for commands: just --list shows them, and just ci is what continuous integration runs.

Words we use

Part, element, instance. A part is a reusable solid made of named elements, each a Geometry expression with a material slot, a UV mode and a collision declaration. A building places parts as instances, by pose or by socket.

Slot, binding, key. An element names a slot, such as wall. A building binds each slot to a material key, such as library:brick, and an instance may override a binding for itself, parameters included. The part never learns what it wears.

Graph, definition, library. A graph is a procedural material. A definition says how a surface is drawn: its constants and its Surface. A library is a map of keys to definitions, and a graph library a map of keys to graphs.

Baked. Baked always means the content step's output, a file a game ships: a baked texture set is a material's KTX2 maps, a baked building an .ashlar file, and a baked strand set a set.strands file.

Merged, standalone, far. A merged building unions its elements by merge group, usually a storey, into one solid. A standalone element stays its own mesh anyway: glass, lights, doors. A far element stays at every level of detail, however thin: a neon line, a crown of lights.

Room, portal. A room is a declared box volume with a storey, and a portal an opening a door or window cutter publishes. Neither is meshed; they are what AI navigation, audio and "which room is the player in" read.

The Bevy adapter, in depth

The long form of crates/ashlar-bevy/README.md: how the adapter joins meshes and materials without the plugin, what its preflight checks, how the tool features bake and compile material graphs, and how the two bake backends are held to one answer. The README is the page a game reads; this is the page a contributor reads.

Some timings below were measured on the retired study kit, whose graphs have since become the default library's. They are kept because they are what the decisions were made on.

Joining meshes and materials

drawables is the join done once. It walks dressed_pieces, uploads each distinct mesh with mesh and creates each distinct material with create_material, and the two are deduplicated by two different keys on purpose: geometry by Piece::mesh_key() (part, element and resolved slot), because a part is evaluated once however many times it is placed, and materials by the whole Binding rather than by the material key, because an instance may write its own parameter values over the definition a key names and two values of one parameter are two materials. Keying materials by the key is the bug this function exists to stop a caller writing. A batch with no triangles is dropped from the list, and its binding is still resolved, so an emptied element that names a missing definition is still an error.

A drawable's material is a MaterialHandle: a StandardMaterial, or, for a definition that sets anti_tiling, an AntiTilingMaterial from the anti_tiling module, which reads the same maps through a random lattice so their repeat does not show. add_material is the one place that decides, and MaterialHandle::insert puts the matching MeshMaterial3d on an entity. The anti-tiled store is UploadContext::anti_tiled; a caller that passes None draws those definitions as plain StandardMaterials, and one that passes a store adds AntiTilingPlugin to its app (AshlarPlugin does both itself).

read_library is the startup check: it parses a material library, validates it against the building that uses it, refuses a texture key that leaves the asset root or that is used as both sRGB colour and linear data, and opens every map it names. A missing or miscoloured map is then a startup error naming the key rather than a black surface. It says nothing about material graphs — a Files surface carries its baked_from as provenance and loads the files beside it, and a featureless build has nothing to lower a graph with. read_library_with_graphs, under runtime-bake, is read_library plus the graph half: every Graph surface lowered, every Shader one partitioned, every strand layer checked against the graph that declares it and every repeat compared.

A library does not have to come from a file. The default library is Rust, ashlar_material::stdlib::materials(), and a game that merges its own definitions into it holds the result as a value. check_library and check_library_with_graphs are read_library and read_library_with_graphs for exactly that: the same preflight over a MaterialLibrary already in memory, with an origin string standing in for the path in errors.

A map is a PNG or a KTX2. A PNG is decoded through the image crate, as before; a KTX2 is checked rather than decoded, and how deeply depends on the build. Under runtime-bake it goes through ashlar-material's own reader, which walks the header and the level index and says whether the file's claims fit inside the file — that is the crate that writes the container, so the check costs no second parser. With default features there is no KTX2 reader in the tree, and pulling one in for a startup check would undo the point of the split, so the header alone is read by hand: the identifier, which tells a KTX2 from a PNG somebody renamed, and the supercompression scheme. What a game gives up is exactly truncated after the header, which becomes an error from Bevy's loader instead of one from preflight. KTX2 is what a baked material should ship as, because it carries the whole mip chain and a PNG carries level 0 alone — Bevy builds no chain for an image it loaded, so a set of PNGs is a set the hardware point samples as soon as the wall is far away. Bevy is built here with its ktx2 feature and with zstd_rust, which is ruzstd: pure Rust, decode only, and what reads the zstd-supercompressed levels ashlar-material's writer produces — the retired study's maps were 24 MB that way rather than 94. The encoder is the other side of the file and is not linked here. A file under any other supercompression scheme is refused by number at preflight rather than failing at load.

A definition's surface says where its varying detail comes from: Plain for constants, Files for files on disk, and Graph or Shader for a material graph. read_library_with_graphs takes a MaterialGraphLibrary beside the material library — read_graphs reads one from RON and validates it — and preflights both graph surfaces, so a broken graph is a startup error the way a missing file already is. A Graph one is validated and lowered; a Shader one is validated, lowered, partitioned and printed as WGSL for both stages, under the shader feature that holds the partitioner. What is checked always follows what is compiled, and in the safe direction: a build that cannot check a surface cannot create it either. The one thing preflight cannot do is validate that WGSL: naga is a dev-dependency of ashlar-material on purpose, so even a tool linking this crate does not link a shader front end it will never call, and the generated text is validated in that crate's tests and compiled for real by the first frame that draws the material. The graph side of the path is the material guide.

read_library_with_graphs also advises on the one number both halves carry. A graph may declare the repeat it was authored against — MaterialGraph::tile_metres, which no bake or key reads — and a definition's own tile_metres is the repeat it lays that graph over. tiling compares them, is pure and is available in every build, and the read logs the answer per definition: nothing when the graph declares none, when the two agree, or when the whole material is scaled by exactly the factor the definition's tile_scale declares (a specimen sheet does that deliberately to fake distance); a warning for any other uniform scale, naming the tile_scale that would silence it; and a warning whatever tile_scale says when the repeat is a different shape, or is scaled under a definition that grows strands — a strand's length is absolute metres and does not scale with the repeat. It is never an error: a definition is allowed to lay a graph at whatever size a wall needs.

A Graph surface is baked into Image assets when the material is created. That is runtime_bake::create_graph_material, which takes the graph library, a runtime_bake::BakeCache and Assets<Image> beside what create_material takes and handles every variant, so a library that can hold a graph surface never matches on the variant itself; create_material stays the one-borrow, cannot-fail call a game shipping texture files makes, and hands a graph surface back as its constants. The images carry the same bytes, formats and mip chain the KTX2 of the same Bake would have carried — the same four formats, untranscoded, with repeat addressing and trilinear filtering — and live in the render world only. The cache is keyed by the whole bake, so two definitions naming one Bake share one texture set and a per-building seed is a distinct set with no distinct file.

No material this crate makes reads a height map. The bake writes one, preflight opens a Files one and a runtime bake keeps one on GraphImages, but it reaches no StandardMaterial slot, and a compiled surface's height port reaches none either. Bevy's relief slot, depth_map, wants black-is-top depth and a parallax scale in the mesh's own units, and a 0..=1 height field carries neither, so the inversion and the scale are a game's to decide. What the height buys here is the normal, which the bake derives from it.

An instance may put its own values on the graph parameters of the surface a slot binds — that is ashlar::Binding, which is a material key in RON where it overrides nothing and (material: "...", params: {...}) where it does — and ashlar_bevy::definition is where the two meet: it answers the definition a binding names with those values written into it, so nothing below it learns that an override happened. read_library_with_graphs preflights every binding as well as every definition, by lowering the overridden surface, so a parameter the graph does not declare is a startup error naming the instance, the slot and the parameter. What it costs is what the value reaches: an override of a Graph surface is a distinct bake and so a distinct texture set, shared between every instance that asked for the same one; an override of a live parameter of a Shader surface is the same pipeline and the same bound textures with a different uniform block, and of a folded one is a second compiled graph. An override reaches the graph's parameters and never the definition's own constants, which stay the library's.

A Shader surface is compiled instead of baked, by shader::create_shader_material, and what comes back is not a StandardMaterial at all: it is a shader::ProceduralMaterial, which is ExtendedMaterial<StandardMaterial, GraphExtension> — the same constants, the same uv_transform, the same lighting, with a generated fragment over them. The graph is partitioned: everything that does not move is baked into the same BakeCache and everything that does is emitted as WGSL and evaluated per fragment.

The static half is cached by the plane behind each bound image rather than by the graph that asked for it. A ShaderKey — the graph, the resolution, which parameters stayed live and the value of every parameter that did not — still names one compiled graph's list of images, but the images themselves are keyed by runtime_bake::PlaneImageKey: the format, and the identity of the plane in each of its lanes, which is ashlar_material::planes::PlaneKey — the canonical encoding of that plane's whole closure after folding, each plan written once with its filter chain, its resolution and its value type. So two compiled graphs that instance one wall through Subgraph share its planes instead of each rasterising them: showcase:concrete-wet and showcase:concrete-cut-aware are two shaders and two fragment halves over one library:formed-concrete. Measured on the study concrete they instanced before it, the second of them cost about 0.18 seconds where it used to cost 1.3. A graph whose every bound image is already there rasterises nothing at all, and one that shares some of them rasterises only its own — planes::rasterise_wanted and GpuBaker::planes_wanted take the planes a caller is missing and the planes those read, and skip the rest on either backend. The key is exact rather than a digest, for the reason BakeKey gives: a collision would be one building wearing another's texels. It is the only key in that cache whose size is worth thinking about — a plane key is the sum of its closure's sub-expressions, five kilobytes for one of the retired study concrete's planes and a hundred for one of its painted metal's, and PlaneKey's own documentation has the measured table.

MaterialExtension::fragment_shader is a static function and cannot vary per graph, so GraphExtension does not use it. It carries a GraphKey as its bind_group_data, which Bevy makes part of the pipeline cache key and hands to specialize, and specialize swaps the descriptor's fragment shader — the main pass one or the prepass one, told apart by the PREPASS_PIPELINE shader-def — for a weak Handle<Shader> whose UUID is derived from that key. create_shader_material is what put a shader under that id. Two definitions over one graph with one live set and one set of folded values therefore share a pipeline and differ only in a uniform; two that fold a parameter differently do not, because folding one changes the generated text. shader::ProceduralMaterialPlugin registers the material, its pipeline and its prepass; without it a compiled surface has no asset type to be.

The bind group is fixed by the type and not by the graph: a 256-byte uniform block at binding 100 and eight texture-and-sampler pairs from 101, of which a graph fills the front. Eight is the number the partition refuses above. Each image is encoded the way the bake encodes the map it is — the same dither, the same transfer, the same chain — except where several planes were packed four to one image, which have to share one format and so share half floats.

A live parameter is that uniform block and nothing else, which is the whole point of having partitioned. shader::block fills one from a name-to-value map against the layout the emitter answered, and writing it over a material's old one is the entire update: no plane is re-rasterised, no WGSL re-emitted, no pipeline rebuilt, because none of them ever saw the value. That is what lets ashlar-preview's parameter panel move a compiled wall on every step of a drag while a baked one waits for the pointer to let go. A parameter the partition folded is the other case and costs the lot — it went into the bound textures and sometimes into the generated text — which is why GraphKey covers the folded values as well as the live set.

The bake is CPU work and it blocks: about 0.16 seconds and 19 MiB of images for the retired study concrete at 1024 across this machine's cores, chain included. Calling create_graph_material on the main thread is right for a preview, a content step and a test, and wrong for a game at play; runtime_bake::bake_images is the same work as a pure function of a bake and a graph library, so it runs in an AsyncComputeTaskPool task and only BakeCache::insert — moving the finished images into Assets<Image> — has to happen on the main thread. Its documentation carries that pattern in full.

Or it does not have to be CPU work. gpu::GpuBaker bakes the same graph through the same emitter on the render device — a compute dispatch per plane, one for the outputs, and the normal, the chain and the encoding still on the CPU over the read-back f32 planes — and Baker::{Cpu, Gpu} on BakeContext and ShaderContext is how a caller says which. gpu::GpuBakePlugin puts a baker in the world wherever the app has a device, and every caller asks with an Option, because a headless test and a build server have none and the CPU bake is the reference anyway. A graph the device cannot split goes to the CPU as well: a plane pass binds the planes it reads as textures, at most eight of them, and the grass's canopy reads eleven. gpu::dispatchable is that question, asked before the dispatch, because it is a limit of the device and not a fault to report. What it is worth: at 1024 in a debug build, the retired study's painted metal goes from 3.1 s to 1.1 s and its plaster from 2.3 s to 1.0 s, while its cast concrete does not move and its plain metal gets slower — a dispatch costs a round trip whatever it evaluates, and a small graph's expression was never the expensive part. The bytes agree with the CPU bake to within one eight-bit code, which just conformance checks map by map and level by level.

A plane goes to the next dispatch as sixteen bytes a texel, and it is packed and written once per bake: the dispatch for each plane and the one for the material's outputs share a single cache of uploaded textures, so the outputs never re-write what the planes just put on the device. That is not tidiness but the larger half of the cost — an upload is about 37 ms at 1024 squared in a debug build against about 60 ms for the dispatch reading it, which was 110 ms of the retired study painted metal's 1.08 s. GpuBaker::uploads counts what reached the device, and just conformance holds every shipped graph to one upload per bound image. It stops at the bake: keeping the textures alive across bakes would hold 16 MB of device memory a plane on a resource with nothing to evict with, and would still pay for the dispatch that recomputed the plane, because nothing on this side caches planes.

mesh uploads one attribute the geometry layer does not name: a surface with any cut face in it carries ATTRIBUTE_ASHLAR_CUT, one flag per vertex, so a compiled material can read CutFlag and treat the faces a cutter made differently from the ones it did not. That attribute is Bevy's second UV set, and deliberately: a MaterialExtension draws through Bevy's own vertex stage into Bevy's own VertexOutput, which has no free location, and the alternative to repurposing a slot is emitting a vertex shader that reimplements mesh.wgsl and prepass.wgsl to carry one float. Vertex colour was the other free slot and is worse, because pbr_fragment assigns it over the material's base colour rather than multiplying. A mesh with no cut face carries no attribute and costs no vertex bytes; the generated fragment reads zero where VERTEX_UVS_B is undefined, which is the same answer. The per-triangle flag becomes one unambiguous per-vertex value because TriangleMesh::weld treats the edge of a cut as a seam.

ground_mesh draws the exact convex corners fit_ground produced, so what is rendered and what is collided with are one set of vertices.

This crate carries no solid kernel and no C++ toolchain: it consumes meshes, whoever evaluated them.

Testing and conformance

Everything in tests/ runs headless on MinimalPlugins and needs no adapter, with one deliberate exception.

tests/conformance.rs is the contract between the two backends, and it needs a GPU. Every test in it that dispatches one is #[ignore]d, and just conformance is what runs them:

just conformance

It opens a headless wgpu device through Bevy's own render::renderer::initialize_renderer — no window, no App, no render world, and no new dependency, because this crate already links the wgpu Bevy pins — then, for every case, dispatches the compute kernel ashlar_material::wgsl emits from a partition and compares the result with ashlar_material::interp over that same partition, texel for texel at 256 squared:

  • one partition per primitive IR op, each a static field cut into a bound texture with a live parameter over it, which is as close to a one-op partition as a graph can be written. A coverage test — the one test in the file that is not #[ignore]d, because it only partitions — asserts the cases between them reach every arm of Op the node vocabulary can produce, so a new op with no case fails in just ci, on the branch that added the op and on a machine with no GPU in it.
  • every graph of the default library with every parameter live, at 512, which is the same claim over thousands of instructions and up to eight bound textures rather than over three. The grass is the one it skips: all live, its eleven strand reliefs do not fit, and it ships baked.
  • the lattice hash, against the emitted helper itself. This one is not a tolerance: hash2 and hash3 must agree bit for bit, because every noise and every scatter in the crate rests on them and a single differing bit is a different wall.
  • the whole GPU bake against the whole CPU bake, at 512 squared, for every graph of the default library. The other three cases compare numbers; this one compares the encoded bytes of every map at every mip level, so the four-tap normal, the chain, the Toksvig widening and the quantisation are under the contract beside the expression. Worst observed: one eight-bit code, on a texel a hair either side of a rounding boundary. It prints what each bake cost, which is the other reason it exists — a runtime bake is paid for in front of a frame.

The bound textures of the first three cases are uploaded as Rgba32Float holding the CPU planes' own numbers rather than as the eight-bit images a shipped material binds, so what is under test is the runtime half alone; the encoding is tests/shaders.rs's, ashlar-material's, and the bake case above's to check.

The budgets, and what the two backends actually come to on the reference machine — an RTX 4090 on Vulkan, driver 595.84:

whatallowedworst observed
a colour channel (base_color, emissive)1/2556.1e-7
a normal lane2/2551.2e-6
a raw scalar plane (roughness, metallic, occlusion, height)1e-43.8e-6
the lattice hashzerozero

Three orders of magnitude of headroom, and all of what is left comes from the two places the backends are allowed to differ: sin and cos, whose accuracy WGSL states in ULP and each vendor meets differently, and the hardware's bilinear weights, which the specification only requires to carry eight fractional bits of subtexel precision where a CPU plane carries all of them. A run that lands near a budget has found something; the fix belongs in the emitter, not in the budget. Each case prints its own worst difference, which is the number to read rather than the pass.

Decisions

These are the architecture decision records: one page per decision that shaped ashlar, with the problem it answered, what we chose and what it costs. They are written when the decision is made and amended, with a date, when it moves, so a record says what was true then and what changed since.

If you are reading the manual front to back, you do not need these to use the crates. They are what to read when something looks surprising and you want to know why.

RecordWhat it decides
0001A building is plain Rust data, and the kernel that meshes it is a separate crate
0002Parts attach by sockets, and collision is derived from the same geometry
0003A material is a graph that bakes to textures or compiles to a shader
0004Grass, fur and moss are strands scattered from the material's own fields
0005Merged buildings, interiors, face provenance, and damage as data
0006Buildings bake to files with levels of detail, like materials do
0007Props, cut-out and translucent materials, vertex data and impostors

ADR 0001: Rust building descriptions separate from geometry backends

Status: accepted, 2026-09-13.

Context

Games should be able to describe buildings as reusable Rust recipes without baking one architectural style into the engine. A macro language is premature before its underlying types are useful. An earlier external-authoring prototype was retired.

Decision

ashlar is a Bevy-free domain crate. It depends only on shared math, Serde and typed errors. It describes local solids, reusable parts, instances, material slots and attachment frames. Rust functions and builders author the same editable data that Serde loads.

Finalization validates a recipe and returns an immutable description. Backends consume that description; the domain has no IO or renderer dependencies. All dimensions and placements are local f64 metres in right-handed Y-up space. Style choices live in recipe geometry and supplied material bindings, with instance overrides. There is no mandatory grid or fixed rotation increment.

Consequences

The domain can be tested without a GPU. A future macro is a thin frontend over ordinary types. Mesh generation, physics export and visual preview remain separate implementation steps. The serialized recipe is experimental, not a runtime asset format.

Mesh adapter

GeometryMesher, TriangleMesh and MeshError define the renderer-independent output boundary in the domain. ashlar-manifold evaluates expressions via Manifold's Rust bindings, preserving shared part meshes and instance metadata. The native C++ dependency lives entirely in that adapter, outside the domain and outside any headless runtime graph that consumes fitted results. The kernel can be replaced by another implementation of the port. No architectural style, palette, grid policy or storey dimension is introduced into the backend.

Manifold was chosen to evaluate nested arbitrary solid subtraction without introducing a bespoke boolean kernel. Its native toolchain and first-build source download are explicit tradeoffs; see the recipe workflow for prerequisites.

Expression vocabulary

Shape grew union, array and mirror nodes beside the primitives and subtraction. They express repetition and symmetry as geometry rather than as a loop that produces one element per copy, so the kernel fuses the copies and the cut slots keep working across them: a union creates no cut faces of its own, every copy of an array carries the provenance of its source, and a reflection keeps the provenance of the faces it mirrors. Shape is #[non_exhaustive], and a backend rejects by path what it does not implement, so the vocabulary can grow without breaking one.

Material and physical detail

The domain also describes opaque PBR material libraries independently of building recipes. Texture keys remain opaque; the render adapter handles PNG loading, color spaces, repeat sampling and tangent generation. Per-material metre scale and pattern offsets preserve shared geometry across finish variants. Optional element cut slots use native surface provenance to split outer and cut batches; no architectural role or material type is baked into that mechanism.

Texture projection is per element: planar by default, box mapping for repeats that line up across coplanar elements of separate modules, and cylindrical for a metre-true wrap with one seam. The mode is applied after evaluation, so the mesher port is unchanged by it. Meshes are indexed, with vertices shared where position, normal and UV agree, which keeps creases and UV seams split and nothing else.

Physical chamfers are an explicit box primitive, not an implicit global smoothing policy. General edge modifiers can be added independently. The example content lives in ashlar-showcase together with the generator for its study textures and material RON, and demonstrates shared modules in two structures.

Sockets and collision

See ADR 0002: sockets derive an instance's pose, and elements declare a conservative convex collision proxy that the mesher derives from the evaluated solid.

Terrain fitting

Ground fitting is a pure calculation over a supplied triangulated height patch. The domain owns footprint, entrance, policy and fitted convex-solid data. It does not sample a world, select a style or create renderer/physics handles. Triangle clipping checks the full support and approach surface, including extrema between corners. The caller says which way its own cells split, because a patch read along the other diagonal is a different surface from the one its players walk on. World sampling and convex collision conversion belong to the embedding game.

ashlar-showcase is renderer-free content. ashlar-bevy shares mesh and PBR upload without a native kernel dependency, so a consumer can draw fitted results without compiling C++. This is not yet a building streaming system.

ADR 0002: Sockets attach, and collision is derived from geometry

Status: accepted, 2026-09-13.

Context

Two things in the first contract were metadata only. A Socket recorded a frame and nothing consumed it, so a wall of modules was placed by multiplying a bay index by a bay width and rotating the result, once per kit, in the content. Collision was worse: each showcase kit carried a hand-typed table of boxes beside its geometry, matched to it by eye, and every dimension that moved had to be retyped in two places or the proxies quietly drifted off the building.

Both are the same problem. The recipe already knows where the parts are and what shape they have, and the content was restating it.

Decision

Attachment

An Instance may name its own socket, another instance, and that instance's socket. build() derives the pose so the two socket frames meet, in dependency order, and reports unknown sockets, unknown targets, self-attachment and cycles with the path of the instance that asked. The derived pose is written into the instance, so nothing downstream has to know an attachment happened.

Sockets face outward along +Z. Attaching therefore turns the attached part half a turn about the socket's Y axis, so the two outward directions oppose: the convention of a plug and a socket, not of two frames laid on top of each other. Facing::Aligned asks for the other one, for a socket that marks a direction to continue in rather than a face to meet.

The convention pays for itself where it is used. A bay with a left socket at its origin facing -X and a right socket at its far edge facing +X, attached left to right, lands the next bay exactly one bay along with the same rotation, which is what the study kit's walls used to compute by hand.

Derived collision

An Element declares Collision::Bounds or Collision::Hull, and the mesher derives the proxy from the same evaluated solid it meshed, once per part definition and a transform per placement. The default is None, so trim, slats, lights and signage cost nothing and say so.

A proxy is convex and conservative. An opening subtracted inside an element is not subtracted from its proxy. An opening that has to be walked through is therefore the gap between elements, not a hole inside one. That is a real constraint on how a passable kit is authored, and it is the price of deriving collision from a boolean result without a decomposition step.

Consequences

Content states dimensions once. A collider set cannot drift from the geometry, because there is nothing left to drift from. The showcase's old tables survive only as the fixture of the test that shows the derived set occupies what they occupied.

The proxies are conservative, which suits static architecture and does not suit anything that has to be walked into and out of through its own openings. Convex decomposition, per-element materials for collision, and a proxy that follows a cut are all still open. An attachment names an instance by ID, so composing a building out of another building's instances, as the corporate block does, has to rewrite attachment targets along with the IDs it renames.

ADR 0003: Procedural materials are a graph that bakes or compiles

Status: accepted, 2026-09-15; proposed 2026-09-13; amended 2026-09-20 and 2026-09-24. The long guide to what was built is docs/guide/materials.md.

Context

The material contract from ADR 0001 is a library of MaterialDefinitions naming PNG keys. The only textures in the workspace are generated by examples/showcase/src/textures.rs, a hand-written pixel loop with a value noise, a seam mask and a finite-difference normal. Every new surface means a new loop, the loop is not data, and it cannot be varied per instance without regenerating files.

Game-ready procedural materials, in the sense of Material Maker or Substance, are a different shape of thing: a graph of tileable 2D fields (noises, patterns, shapes, transforms, blends, filters) with a PBR output node, exposed parameters, and an offline bake to a texture set with correct colour spaces and mips. The same graph is also, for most of its nodes, a fragment shader. A material that stays a shader can take runtime parameters, world-space inputs and time, which a baked texture cannot.

Decision

A new Bevy-free crate, ashlar-material, owns the material graph. Its position in the layering is beside ashlar, not below or above it: a graph knows nothing about buildings, and a building names a graph by key only.

ashlar            ashlar-material        no Bevy, no IO. glam, serde, thiserror.
  <- ashlar-manifold
  <- ashlar-bevy  <-                     bakes to Image assets; compiles to a Material.

One lowering, two backends. Every node lowers to a small typed intermediate representation of pure per-texel expressions. The CPU bake interprets that IR; the shader backend prints it as WGSL. Nodes are written once; only the IR's primitive operations exist twice, and those are tested against each other. This is what keeps a baked material and its live shader the same material.

Tiling is checked, not hoped for. Every signal carries an integer period through validation. Noise and pattern nodes take an integer period and wrap their lattice; transforms are only allowed to change a period in ways that keep it an integer; the output must have one. A graph that cannot tile is rejected by node path before anything is baked.

Non-local filters are buffers, and buffers are the bake boundary. Blur, distance fields and occlusion from height need a rasterised neighbourhood. In the bake they are intermediate planes. In a shader they cannot be evaluated per fragment, so the compiler splits the graph: everything that a buffer node or a runtime-only input does not depend on is baked once into bound textures, and the remainder is emitted as WGSL over those textures and a uniform block of the live parameters. The author decides which parameters are live; the compiler decides what that costs.

Two kinds of material, three sources. A MaterialDefinition's surface is either textured or a shader. A textured surface names files on disk, or names a graph with parameter values and a resolution and is baked into images when the material is registered. A shader surface names a graph and is compiled when the material is registered. The same bake code produces both the shipped files and the runtime images, so a game chooses per material: files for the surfaces every level uses, a runtime bake where each building wants its own seed or tint, a shader where a value moves per frame. The domain crate needs no new dependency for this, because every variant is data. The old three optional texture keys are replaced, not kept beside the new form.

Colours inside a graph are linear. sRGB exists only where a base colour texture is encoded, in the same place the adapter already decides colour spaces. Normals are derived from height, never authored directly, so the OpenGL-convention normal map and the height for parallax always agree.

Consequences

The showcase textures become graphs, and the first proof of the crate is that they reproduce the study surfaces. A material can be edited as RON and re-baked without a Rust build; the preview re-bakes on change. Baked files carry their own mip chain, which PNG cannot, so the bake writes KTX2 and PNG is a debugging export.

A material graph is a second data model beside the recipe, with the same rules: builders and Serde produce the same data, validation returns paths, and the node enum is #[non_exhaustive] so a backend rejects what it does not implement rather than the vocabulary freezing. There is no node editor. As with ADR 0001, a GUI is a frontend over these types and comes after the types have earned it.

A shader per graph is a pipeline per graph. Instances that differ only in parameter values share a pipeline, because parameters are uniforms. Content with hundreds of distinct live graphs should bake most of them; the design makes the switch a one-field change in the material library.

The CPU bake is the reference and runs anywhere, including tests without a GPU. It is not required to match the GPU bit for bit. It is required to match within a tolerance the conformance test states, on the primitives, and that test is what stands between the two backends drifting apart.

Out of scope for the first version, and not blocked by it: a node editor, importing Material Maker or Substance files, transparency and transmission, decal layering over a finished building, and an atlas. The node vocabulary is deliberately close to Material Maker's so that porting a graph by hand is mechanical.

Amended 2026-09-20: the vocabulary is a crate below both

One thing above is withdrawn, and it is the layering: ashlar-material beside ashlar, with nothing under the pair.

The domain crate needs no new dependency for this, because every variant is data.

That was true and it had a price that took a while to show. A definition writes parameter values and a graph declares them, so there were two ParamValue enums with the same four variants and the same RON, and three hand-written conversions between them in ashlar-bevy. A test existed only to stop the two drifting. And because a MaterialDefinition lived in ashlar, the crate that owns the graphs could not name one, so it could not ship a finished material: a graph without its repeat size, its delivery and its default parameters is half of one. The default material library (lineup) is what made that matter.

ashlar-surface now sits under both:

ashlar-surface                           serde, thiserror. The vocabulary.
  <- ashlar           <- ashlar-material
       <- ashlar-manifold
       <- ashlar-bevy <-

It owns ParamValue, Binding, Bake, Surface, StrandSettings, MaterialDefinition, MaterialLibrary and ValidationError. Both crates re-export every name, so ashlar::MaterialLibrary and ashlar_material::ParamValue are the paths they always were. What a parameter value means inside a graph stays with the graph, as the GraphParam trait.

What the original decision was protecting still holds. A graph knows nothing about buildings and a building knows nothing about graphs: the new crate holds neither, and ashlar and ashlar-material still do not depend on one another. A library is checked against a building through BoundSlots, a trait the building implements, so the vocabulary never learns what a building is. A dedicated server still links ashlar with no graph engine. No RON changed.

Amended 2026-09-24: one shipping path, and nothing baked in git

The decision above offers three deliveries of one graph, and it never said which of them this repository ships. By this date it shipped all of them at once. The reference materials were 550 MB of committed 2048² KTX2 under a benchmark: key shim, the study kit was 44 MB of maps from nine graphs no building still bound, and every material library was a RON file written from Rust and checked in beside the Rust it was written from. Three writers produced those files, and a test existed to notice when any of them went stale.

The user's call was short: no baked output in git, the source graphs are what matter, and there is one consistent way to define and ship a material.

So the graph is the material. The default library is Rust in ashlar_material::stdlib, and stdlib::materials() gives every finished surface a definition whose surface is Surface::Graph. A game that registers those definitions bakes them when the material is created and ships no files. That may seem backwards for a game, which is exactly the build that should not bake at startup, but the files are one step away rather than gone. ashlar_material::export takes the same definitions and answers the texture sets, the strand-set bytes and a library whose surfaces name those files, with baked_from recording the bake each came from. It does no IO, like the rest of the crate; the step that owns the disk writes the bytes. In this repository that step is just materials, and it writes into assets/materials/, which git ignores.

Three things follow from it.

  • A library does not have to be a file. The preview's scenes take their libraries as Rust values, and ashlar_bevy::check_library and check_library_with_graphs preflight an in-memory library the way read and read_library_with_graphs preflight a RON one. Files still work everywhere they did.
  • Tests that read shipped bytes are gone, and so is the class of bug they caught. A committed map could drift from its graph; a bake at registration cannot. What is left to pin is the graphs themselves, which their own tests do, and the draw tests that need real files export them at the start of the run.
  • The GPU bake can refuse a graph and the CPU takes it. The grass's canopy reads eleven planes in one pass and a pass binds eight, so ashlar_bevy::gpu::dispatchable sends it to the CPU bake, which is the reference anyway. That limit was always there; nothing exercised it until the reference grass became a library material that scenes bake at runtime.

What we give up is a checked-in picture of every material. Renders and flat comparison sheets are regenerated when a fidelity pass wants them, and the commands are in docs/material-references.

Amended 2026-09-26: cut-outs and transmission are in, blending is still out

The list of what the first version leaves out named "transparency and transmission". ADR 0007 narrows it, because a leaf card is a material a game cannot do without and every part of it fits the pipeline above.

  • MaterialDefinition gains alpha, double_sided and diffuse_transmission. alpha is Opaque or Mask { cutoff }; the other two are a switch and a share in 0..=1. All three default to what a definition meant before them and are skipped when default, so every library written earlier reads and writes byte for byte as it did.
  • PbrOutput gains an optional opacity, a float field held to the tiling rule like every other output. A bake writes it into the base colour map's alpha, and the mip chain keeps its coverage: each level's box-filtered opacity is scaled so the share of texels at or over one half matches level 0's, and its colour is averaged weighted by opacity. The line is a half rather than the definition's own cutoff because a bake never sees a definition.
  • A compiled shader refuses opacity by path. A cut-out has to discard in the prepass and the shadow pass as well as the main pass, which the emitter does not write yet; a leaf is baked.

Sorted alpha blending stays out, for the reason ADR 0007 gives: it is a renderer's problem, and a cut-out keeps depth writes, shadows and the prepass.

ADR 0004: Strand layers are geometry scattered from graph fields

Status: accepted, 2026-09-19.

Context

ADR 0003 made a material one height field over a continuous surface. The grass pass showed what that costs. benchmark:grass spent six Tile scatters of capsules to suggest blades and was still a picture of grass: no silhouette at the edge of the sphere, no blade standing over its neighbour, no overhang, and nothing that changed with the view. Fur, moss, carpet, thatch and bristles all have that shape, so the gap is a kind of surface rather than one material.

The reading we did first says the same thing from the other side. Substance 3D Designer has no strands at all: its grass materials are Tile Sampler and Shape Splatter laying shapes into a height map, and everything the material knows about a blade is what survived being flattened. Material Maker and Blender's hair are the two directions out of that — a richer splatter, or real curves — and the game accounts of dense grass, the Ghost of Tsushima talk in particular, are all on the curve side with a clump structure over it.

So the decision is not "how do we draw grass". It is where strands live relative to the graph that already describes the surface, given that the two have to agree.

Sources: Substance 3D Designer's Tile Sampler, Shape Splatter and Splatter Circular; Material Maker's Splatter; Blender's hair nodes. The Ghost of Tsushima talk (GDC 2021) was not reached; its clump model is taken from two agreeing secondary accounts, a written reconstruction and a caption summary.

Decision

A material graph gains a second kind of output beside PbrOutput: a named strand layer. MaterialGraph::strands is a BTreeMap<String, StrandLayer> with #[serde(default)], so every RON file written before this reads unchanged.

A strand layer reads ordinary graph nodes, but it reads them once per strand at the root rather than once per texel. What comes out is a StrandSet: a deterministic list of strands over one repeat of the material, sorted by a per-strand rank. ashlar-material::strands owns the scatter, the placement and the mesh, and it needs only glam, so nothing below the renderer learns what a building is.

One scatter, three consumers. The mesh builder grows ribbons or tubes from the set, and a new buffered node, StrandRelief, splats the same set from directly above back into planes the PBR half reads. That second consumer looks like a detour, since the request was about geometry. It is what makes the level of detail honest: when the blades fade out, what the camera sees is the same blades seen from above, so the colour and the clump pattern do not change at the switch. This is ADR 0003's "one lowering, two backends" applied one level up.

Strand fields are held to the tiling rule. They join period inference exactly as PbrOutput::extra does, and the scatter's own count joins the least common multiple. A set that does not meet itself at the seam is as wrong as a colour that does not.

Density is a threshold, never a fade. A strand's own hash is compared against the density field and the strand is there or it is not. Strands that thin out read as a sparse patch; strands that shrink towards nothing read as a mistake. Tile::mask already argued this and this follows it.

A level of detail is a cut on the rank. Every strand carries one more hash in 0..1. A level that keeps a quarter keeps the strands below 0.25 and widens the survivors by 1/sqrt(keep), so the coverage is the same and the survivors are a subset — nothing reshuffles at a switch. Levels are Bevy VisibilityRange entities per UV repeat, with dithered crossfade: it costs draw calls and it costs us no render-graph code.

Past the last cut a tuft is a picture of itself. A rank prefix stops paying when the strands it keeps are under a pixel wide, so the level after the thinnest strands is cards: two crossed quads per clump, wearing the same set drawn side on. They are alpha mask and never alpha blend — ADR 0003 puts transparency out of scope, and a cutout keeps depth writes, shadows and the prepass working — and their mip chain is rescaled to hold the coverage level zero had, because a box filter halves the alpha of every edge texel and a field of cards would otherwise evaporate at exactly the distance it exists for. Past the card distance there is still nothing but the relief.

Strand fields are static, and wind is the renderer's. A field that reaches Time, WorldPos, WorldNormal, CutFlag, Triplanar or WorldMask is refused at the node that asked. A Live parameter is not refused; it is folded at the value the request bound. Movement is a vertex stage over the finished geometry, which is ashlar-bevy::wind.

What we decided differently once we were writing it

Eleven of these, and each is a place the plan said one thing and the code says another.

  • VoronoiOutput::Offset answers UV, not cells. Voronoi's distances are in cell units, so the offset should have been too. But nothing in the vocabulary scales a Vec2 — Math takes a float or a colour — so an offset in cells could only be brought back to a displacement by taking it apart and putting it together again. Every port that accepts a displacement measures one in UV.
  • Angles are turns. Direction::from_angle takes turns, as MathOp::Sin and Atan2 already do. A material graph has no pi, and an author writing 0.25 for a quarter turn is writing what they meant.
  • The runtime refusal is a graph walk, not a lowering target. A bake refuses WorldPos inside the lowering, because a bake is a Target. A scatter cannot: it lowers for the shader target so that a graph delivered as Surface::Shader may still carry world-space inputs in its PBR half, which is a delivery the design keeps in scope. So strands::refuse_dynamic walks the graph from the layer's own ports and names the same paths validation names.
  • The field resolution is a constant of the crate. FIELD_RESOLUTION is 512 and is not a parameter of the splat. A buffered filter upstream of a strand field has to be rasterised at some size, and two scatters at two sizes are two scatters: a relief inside a 2048 bake would read a 2048 slope where the mesh builder read a 512 one, and the blades in the texture would lean differently from the blades standing on them. Four millimetres a texel over a two-metre repeat is about one blade wide, which is as fine a question as a strand layer can ask.
  • Edge ownership is local. A root on an edge shared by two triangles is planted once, and which triangle owns it is decided from the edge's two endpoints and the opposite corner alone — the edge oriented from its lexicographically smaller endpoint, the owner the triangle whose corner lies to the left. The plan said "the triangle with the lower index", which needs the mesh's own ordering; this needs nothing but the three points, so the answer does not change when a mesh is rewelded.
  • A clump owns the blade. The plan had clump as a UV offset that gathers roots. That port still exists and still works, and it is not enough: the Tsushima accounts are explicit that a blade takes its height, direction, colour and bend from the clump point it belongs to. So a clump is an object with an id, a distance and a tip pull, and clump_share of every per-strand variation is taken from the clump's hash instead of the strand's. A lawn is a mat of little bunches that agree with themselves; without this it is a comb.
  • The relief gained Mass and Occlusion. Coverage is a union and stops at one, which is the right answer to "is there a blade here" and the wrong one to "how much grass is here": two blades crossing read the same as one. Mass is every contribution summed. Occlusion is the same sum weighted by the height each contribution stood at. Without them the splat composited over nothing, and the graph had to build its pile out of a blur of the coverage, which spreads mass sideways instead of summing it.
  • The dark goes under the strands, not on them. This one was a bug for two passes. root_occlusion darkens a blade towards its root, and a graph that starts from a lit bed and darkens wherever a strand stands puts the shadow on the one thing that is in the light. A lawn is a canopy: the ground under it is the darkest thing in frame and the crown is the brightest. benchmark:grass now mixes up from a dark bed towards the strand colours by coverage and then shades by how high the canopy stands over the texel.
  • A card is keyed on the clump's hash and then split by where it is. The plan said a card is a clump, and a clump is a hash: every member of one carries it. Two things are wrong with the hash alone. Four thousand hashes over a repeat collide often enough that some card would span two tufts at opposite ends of the lawn, and a set is a list over one repeat that a surface may reach several times — both faces of a slab and all six of a box are the ordinary case — so one clump of one repeat is genuinely several tufts a hand's breadth apart. A card at the mean of those is a quad in the air facing nothing. So the members of a hash are split again: a strand joins the first group standing on the same side of the surface and within one clump cell's diagonal of it. Keying on the root's cell instead was tried and is wrong, because the clump port moves a root after its clump was chosen and a tuft therefore straddles two or three cells.
  • Cards are a plain StandardMaterial, so they do not sway. A card binds a normal map, a normal map needs a tangent frame, and the wind extension's two generated vertex stages declare position, normal, UV, colour and the wind attribute — no tangent. Carrying one through both stages is a change to the one shader in this crate that a shadow pass depends on, for a tip travel of twelve millimetres at ten metres and beyond, which is a fraction of a pixel. So a card is drawn by Bevy's own vertex stage and stands still.
  • Every card of a layer is turned by its own hash. Two crossed quads on a frame the surface handed them puts every card of a lawn in the same two planes, and a lawn seen from anywhere near along one of them shows its cards as a regular grid of slivers on the clump lattice. A turn per tuft leaves the same number of quads facing the same way on average and no lattice in them.

What did not ship

A continuous last band. LAST_BAND_LENGTH still shortens the last level's strands in one step at the bake rather than shrinking them across the band with distance. A continuous shrink has to scale a vertex towards its own root, and a vertex carries how far along the strand it is and not where the strand began; carrying the root is a second three-float attribute on every strand vertex, about 17 MB over one full-detail repeat of the benchmark lawn.

Consequences

A lawn costs what a lawn costs. benchmark:grass is about 620 000 triangles over one full-detail two-metre repeat across its two layers, and the rank cut is the only thing that makes that affordable at distance. StrandSettings carries a density so a scene that cannot afford the authored lawn says so, and it keeps a prefix rather than thinning at random.

The card level is about 16 000 triangles over that same repeat — one clump in 4096 per layer, two quads each — which is under three per cent of the geometry it stands in for, plus one shared 256² atlas pair per scatter. What it buys is measurable rather than argued: at nine metres the card level changes the mean colour of the lawn by less than one code in 255 and adds about half a per cent to the pixels it covers, all of it on the silhouette, where a slab of grass stops ending in a straight line.

Strands need the graph at runtime. Surface::Graph and Surface::Shader qualify, and Surface::Files qualifies only through its baked_from and a loaded graph library. Generating a set is milliseconds, so there is no strand file format and no serialized StrandSet.

A partition cannot see a strand layer, because it lowers the PBR half alone. A Live parameter that a strand field reads is therefore reported in Partition::frozen as well as in Partition::live, and that overlap is the honest report of a material whose two halves answer differently.

Strand geometry draws through an ExtendedMaterial, which is a third material asset type beside StandardMaterial and the compiled ProceduralMaterial. A scene decides once which of them its strands use. At zero wind the extension's vertex stage displaces by zero before it computes anything, and the frame is byte-identical to the one a plain StandardMaterial drew. A scene that grows cards spawns both types for one layer, because the card level is the plain one; a caller that spawns strand chunks by hand therefore has two material handles to carry rather than one.

Out of scope and not blocked: collision and trampling, hair simulation, order-independent transparency for fine fur, GPU-side scattering, and the instanced flat-ground fast path.

Amended 2026-09-20: a strand set has a baked form

Nothing above is withdrawn except one sentence, and the sentence is this, from Consequences:

Generating a set is milliseconds, so there is no strand file format and no serialized StrandSet.

Both halves of that turned out to be wrong, and they were wrong in different ways.

The milliseconds were a measurement of the wrong lawn. Scattering benchmark:grass is 528 ms on eight cores in a release build — 112 ms for blades, 370 ms for fibres, 46 ms for stragglers — and the moss carpet is 204 ms. It is milliseconds for the thirty-two-cell lawn in a doctest. The figure in the original text was never measured against the set this workspace actually ships. (It also settles a profiling question left open that morning: the grass bake at 512 spends about 1.1 s in "scatter and splat", and the split is roughly half each.)

The premise underneath it no longer holds. The paragraph above that sentence says strands need the graph at runtime, and that was fine while a strand layer was something only a tool grew. The owner's decision of 2026-09-20 is that games consume baked files and must not link the graph engine, and standing grass is wanted in games now. Half a second was never the binding cost; the dependency was.

So there is a strand file format, and there is a serialized StrandSet.

  • ashlar-strands is a new crate holding everything after the scatter: Strand, StrandSet, place, mesh, the rank-cut levels and the card atlas, on glam, serde and thiserror. ashlar-material depends on it, keeps scatter and the StrandRelief splat, and re-exports every name from ashlar_material::strands, so nothing written against this ADR has to move. The split is clean because the ADR made it clean: "ashlar-material::strands owns the scatter, the placement and the mesh, and it needs only glam" was already true of three quarters of the module.
  • ashlar_strands::file is the format. One file per material definition holding every layer it grows, little-endian, versioned, with a directory entry per layer and a field-major payload. Lossless, so a baked lawn and a scattered one mesh to identical triangles and a test says so. Stored rather than compressed: zstd takes 15 % off the grass and 18 % off the moss, and that does not pay for a decoder in every game that draws a lawn. The header records a scheme all the same, so the measurement can be revisited without a second version.
  • StrandSettings::baked_set names the file, the way Surface::Files names its maps. MaterialDefinition::check no longer insists that a definition growing strands name a graph: a baked_set is the other answer, and a game's lawn may sit on a Plain surface.
  • ashlar-bevy's strands is a game feature now, and strand-scatter is the tool feature beside it. cargo tree -e normal --features strands has no ashlar-material in it.

Two things the amendment does not change.

The card atlas needs no file. cards is a pure function of a StrandSet, so a game that holds the set draws its own — 0.5 ms for the lawn's 256² pair. The Consequences section's "one shared 256² atlas pair per scatter" is answered by the set alone, and there is no second artifact to ship or to keep in step.

A lawn costs what a lawn costs, on disk too. The grass set is 24.09 MiB and the moss carpet 10.98 MiB, because a strand is twenty-three f32 and a repeat is a quarter of a million of them. Both are committed, beside the 87 MiB of KTX2 the same preset already ships. The exits, in the order they should be taken: a definition's own density, which keeps a rank prefix and so writes a shorter file of the same lawn; and quantisation, which would halve the file and end the bit-exactness the parity test rests on. Neither is needed yet and both are cheaper than a third one.

ADR 0005: Merged geometry, face provenance, and damage as data

Status: accepted, 2026-09-20, and implemented as decided. The timings this decision rests on were measured first, and are at the end; what building it changed is marked where it changed.

Context

A building is drawn as the parts it was authored from. mesh_building meshes every element on its own, shares a part's meshes between its instances, and never unions anything, so a tower is a pile of closed solids that touch. That is cheap and it instances well. It is also why a wall of bays has internal faces nobody sees, coincident faces that fight for depth, normals that break at every joint, and a texture that starts again at every storey: a part is meshed once in its own frame and cannot know where it will stand.

Three things are wanted that the pile cannot give. A building that is one continuous surface. An interior, with its own finishes and a way to draw or hide it. And damage: a hole anywhere, made during play, that a server may have to know the shape of.

They look like three features. They are one missing fact. The kernel knows which input solid every output triangle came from, and the mesher throws that away for everything except the cut flag.

Decision

Every face knows where it came from

TriangleMesh carries a source per face instead of a bool per face. A source names the element, the instance that placed it, and whether the face came from the element's own surface or from a named cutter. cut_faces becomes the question "is this face's source a cutter", so nothing that reads it changes meaning.

The material follows from the source, not from the mesh the triangle happens to be in. The element's slot, the cut slot, and the instance's own binding are resolved per face exactly as Building::binding resolves them per element today. MeshedBuilding::pieces() keeps its shape; a piece is now a batch of faces that share a binding, not a whole element.

This may look like bookkeeping. It is the whole design: once a face knows its source, merging cannot lose a material, a cutter can carry its own finish, and debris knows what it is made of.

A merge group is unioned, and it is also the chunk

A building declares merge groups: sets of placed elements that are unioned into one solid and meshed once, in building space. The default group is the building. An element opts out by declaration, because glass, lights, doors and anything that moves or is swapped must stay its own mesh.

The port grows one method. GeometryMesher::mesh takes one geometry; mesh_group takes placed geometries and answers one TriangleMesh with sources. A backend that cannot union answers an error, and the unmerged path remains for it.

The group is also the unit of damage. A blast re-meshes the groups it touches and no others, so the grouping an author chooses for seams is the grouping that bounds the cost of a hit. A storey or a bay is the natural size. A group boundary is a seam again, so it belongs where a seam is honest: at a floor line, not across a wall.

Merging gives up instancing, and that is a real cost. A merged group is a unique mesh. A street of identical towers merged per building is a street of unique meshes. So merging is a per-building choice, a repeated kit may stay unmerged, and a far level of detail may well be the old shared parts.

UVs are in building space

A merged mesh knows where every face stands, so its UVs are projected in the building frame. UvMode::Box there is continuous across every former joint, and it does not move when a cut re-triangulates a wall, which per-face planar mapping would. Unmerged parts keep today's modes.

Interiors are authored additively, and classified

Walls and floors stay elements. Carving a room out of a mass would be shorter to write, and it would make the mass's convex proxy fill the room; ADR 0002's colliders stay correct only while solids are added. Openings are carved, as they are now.

A liner solid over the inner face of a wall unions in, and its faces wear its slot, which is inside-and-outside finishes with no seam. A cutter may name a slot of its own, so two openings in one wall need not share a reveal.

The library does not cull. It classifies: a batch says whether it is exterior or interior and which storey it belongs to, an opening's cutter is published as a portal polygon, and a declared room volume is published as data. A top-down game hides the storeys above the player, a first-person game walks the portals, and a third-person game ignores all of it. That policy is the game's.

Damage is data

A damage record is a solid, a pose and a slot for the faces it exposes. A building's damage is an ordered list of them. Applying one subtracts it from the groups it touches and re-meshes those. The list is the save format and the wire format: a server sends "this solid, here", and every client derives the same hole.

The volume removed is the intersection, and its faces carry their sources, so debris is cut from it with the right materials on it.

Collision for a damaged group is its triangle mesh. A convex proxy cannot have a hole in it. Undamaged elements keep their proxies; a group that has been hit answers with a mesh collider built from the same evaluated solid.

Who computes that is a deployment choice, and the ADR keeps it one. A server that must know the shape links ashlar-manifold and pays for a C++ toolchain. A server that trusts its clients, or has no damage, still depends on ashlar alone. A kernel-free answer, a distance-field evaluation of Geometry in ashlar that a hole is one max in, stays open and is not decided here.

What was measured

A spike on the corporate kit, 2026-09-20. The kernel's C++ is always an optimised build, so dev and release agree to within noise for everything below.

A storey-sized group merges in tens of milliseconds and takes a hit in under twenty. The slowest group of the tower, 156 solids and 9,900 triangles, unions and extracts in 68 ms. Subtracting a blast from one group and re-meshing it is 17 to 19 ms, of which the subtraction is 4 to 5 and the rest is the kernel computing normals. The debris intersection is 2 to 4 ms. The whole block as one group is half a second per hit, which is the argument for groups in one number. So arbitrary holes hold, at the granularity this ADR chose, and a hit belongs on a task rather than in a frame.

Parts stacked exactly face to face union cleanly. No seam triangles, exact volume and area, and a third of the surface area gone as internal faces. The triangle count goes up by about a third, because the union re-triangulates; merging buys a continuous surface, not a cheaper one.

Provenance needed one decision, and building it made the decision easier than the spike did. Copies of a shared part carry one original ID, so without help between 0 and 8 per cent of merged triangles map back to a placement. The spike fixed that with as_original on each placed copy, which erases the cutter IDs that mark cut faces, and left how the cut flag survives open between three alternatives. Two of them are wrong before they are slow. Matching a merged triangle back by source and plane cannot say which cutter, because two windows in one wall share a sill plane, and a cutter's own slot needs exactly that. Telling copies apart by a run's transform is ambiguous whenever an array step inside a part equals the spacing between its instances. The third needs no as_original at all: every element is evaluated once per placement, every evaluation constructs fresh primitives, fresh primitives get fresh original IDs, and the evaluator already records which IDs sit under which cutter. So a run ID names one operand and one origin by construction. Its price is inside the numbers below: the tower merged by storey is 141 ms in a release build against the spike's 160, and the block 291 against 321 to 335.

A property channel was the fourth way, measured and not taken. set_properties survives a boolean exactly, and a cutter's faces carry the cutter's tag, so one tag per placed copy gives identity and the cut mark together. It cost extraction 2.2 times on the tower and 7.2 times on the block in a dev build (2.4 in release), because the kernel re-runs a Rust closure per vertex, through an FFI trampoline, during extraction.

Normals left the kernel. calculate_normals was nearly all of the extraction cost and grows faster than the triangle count. ashlar::shade_normals derives them on our side from the same sharp-angle rule, angle weighted so the answer does not depend on how a flat face was split. With it, a hit on the tower's largest storey group is about 9 ms in a release build, debris, batching and collider included. A dev build is slower than the spike's, because that pass is now unoptimised Rust where the kernel's was always optimised C++.

One blocker, and it is not in the design. Manifold::sphere, refine and the smoothing calls crash with SIGFPE on this machine. The kernel's C++ is compiled by an Anaconda GCC that emits legacy .ctors, rust-lld never runs them, and four static initialisers stay unrun. Linking with bfd fixes it, and so would building the kernel with the system compiler. Every shape the recipes use today avoids those calls, which is why nothing had hit it. A game that spawns a kernel sphere as a blast solid would. It was settled twice. This workspace builds the kernel with the system compiler, and crates/ashlar-manifold/tests/kernel_init.rs is the canary. And the library never reaches those calls at all: a blast solid is Geometry::ball, a convex hull of points we generate, which is also what makes it the same solid on every client.

Out of scope

Convex decomposition. Structural collapse, though the attachment graph of ADR 0002 is what it would query. Fracture patterns for debris. A web build, where the native kernel is an open question. A second mesher backend.

Two kernel levers were never measured: Manifold::with_context, which makes an evaluation cancellable and is the obvious way to budget a hit on a task, and the adapter's parallel feature, which pulls TBB and would thread the union (350 ms for the whole block in the spike, single-threaded).

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 than MIN_SEGMENTS (eight) and never more than were authored.
  • drop_interior: every Side::Interior element goes.
  • until (metres): where the level stops drawing; None on 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):

SceneLevel 0Level 1Level 2BakeFile
metropolis (4 x 4 blocks)3,813,0341,351,694914,0740.25 s9.1 MB
city block306,442108,06671,5080.10 s4.8 MB
sci-fi colony119,18253,71825,0880.26 s6.7 MB
corporate block41,13241,00040,2080.01 s0.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-surface and the featureless ashlar-bevy: no Manifold, no graph engine, no C++ toolchain in its build.
  • A content step runs ashlar-manifold and ashlar-material once, and writes .ashlar buildings 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:

SceneLevel 0Level 1Level 2
metropolis (4 x 4, the dark city)3,928,1561,161,406760,902
city block (the dark city)402,248119,42480,654
sci-fi colony119,18248,13019,292
corporate block41,13233,69614,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) and floor(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::operand is renumbered into the new group's operands, which name the instance and element it came from, and its origin is carried, so a cut face is still cut. ashlar-bevy marks cut faces per face from sources for element pieces and group batches alike (the per-piece is_cut is 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} or cell: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::pieces yields them.
  • Reuse survives. bake keeps 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 not is_identity() (its level is drawn differently), but it may still not simplifies(), and level zero takes the authored meshes whenever it does not.
  • A bad cell is refused. LodPolicy::check, which bake runs over the ladder before meshing anything, refuses a side that is zero, negative, NaN or infinite, at levels[n].cell. The whole building has one spelling.
  • Bands measure to the cell. ashlar-bevy now 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-bevy pushes 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 in ashlar-bevy sweeps 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.

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. PbrOutput has no opacity, MaterialDefinition no alpha mode, and ADR 0003 put transparency out of scope.
  • Meshes carry no data of their own. TriangleMesh has 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, Opaque or Mask { 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-bevy gains a module and a material; the featureless game path stays free of the graph engine and the kernel. ashlar-impostor joins ashlar-manifold and ashlar-material as a tool-only crate.
  • ADR 0003's exclusion of transparency is narrowed: cut-out alpha and diffuse transmission are in, blending is still out.

Changelog

Notable changes to the ashlar workspace. Every crate shares one version.

The format follows Keep a Changelog, and the project uses semantic versioning.

[Unreleased]

First extraction of the building system out of the game it grew in. Nothing has been released yet, so this section is the whole story so far.

Added

  • The cooked building file, version 2 (2026-10-21). BakedBuilding::write_cooked writes what a game draws and nothing a kernel needs: the structure of every level (instances with their pose and storey, a table of the palettes their slots wear, merge groups with their bounds and operands), the rooms, the portals placed, the collision hulls in part space, and a table of meshes that are quantised (positions and UVs as u16 across the mesh's own bounds, normal and tangent as octahedral i16 pairs, the cut flag as a bit), carry tangents baked in the content step, are reordered for the vertex cache and fetch, and are coded with meshopt 0.6.2 (u16 indices under 65 536 vertices). There is no recipe and no face provenance. The tangent generator is the caller's (TangentFn, passed to write_cooked), because mikktspace's Rust implementation is a crate named for Bevy and ashlar links none; ashlar_content::mikktspace_tangents is the one Bevy's Mesh::generate_tangents uses, and a test holds the two equal. CookedBuilding::read decodes it with no kernel; it walks pieces(level) exactly as MeshedBuilding::pieces does and answers BoundSlots, so MaterialLibrary::check_for takes either. Version 1 is unchanged and still reads (BakedBuilding::read), Content::cooked_buildings chooses which the content step writes (the cache keeps version 1 either way), and ashlar-bevy's loader branches on the version: a cooked file's meshes become labelled sub-assets (file.ashlar#mesh3) made on the IO pool with RenderAssetUsages::RENDER_WORLD, and each spawned piece carries the Aabb of its mesh because no CPU copy is left to measure. BakedBuildingAsset is one type for both (full, cooked, mesh_handles).

  • A prop's face sources are stored only when something reads them. A table record whose faces are all plain body faces sets a new flag bit and omits the eight bytes a triangle; it reads back identically. Files without it read as before, and building tables never set it.

  • Concatenated levels of detail (2026-09-28). LodPolicy::cell: Option<LodCell> (#[serde(default)], so every older ladder reads and draws placed pieces as before) bakes a level concatenated: every placed piece of it, standalone elements and merge-group batches alike, copied into building space and joined per square cell of the building's XZ plane and per binding and side. LodCell::Side(metres) is a grid from the building's origin; LodCell::Whole is one cell for the whole building, a skyline. MeshedBuilding::concatenated(cell) does the joining with no kernel and answers one MergedGroup per occupied cell (id cell:{side}m:{i},{j} or cell:whole, lengthened past any declared group id or the building's own; no storey, no collider; bounds of its vertices; operands naming every placed element that went in, and every face's FaceSource::operand renumbered into them with its origin kept, so cut faces stay cut). A piece is never split: it goes to the cell its placed bounds' centre is in. Output is ordered by cell, then binding and side, then walk order, so a bake is the same bytes every run. ashlar_manifold::bake stores such a level with no parts and the cells as its groups, meshing it exactly as before and joining once every level is meshed, so the part reuse between levels still works after a concatenated one. The file format and its version are unchanged, so an ashlar-bevy from before this change loads such a file without an error but draws every cell about the building's origin and bands it by the distance to that origin; ship the two together. The file still carries level zero's collision proxies and portals, and ashlar-bevy spawns a collider entity per proxy per instance, so a skyline clears part_colliders and part_portals before it is written. LodCell::check and LodPolicy::check refuse a side that is zero, negative, NaN or infinite; bake runs the check over the whole ladder before meshing, at levels[n].cell. LodPolicy::simplifies asks whether a policy changes the recipe, whatever it does with the level. The cost is instancing: in a concatenated level every placed copy is its own triangles, so unique triangles equal drawn triangles there. LodPolicy::ladder() sets no cell. See ADR 0006, amendment of 2026-09-28.

  • Anti-tiling (2026-09-27). MaterialDefinition::anti_tiling: Option<AntiTiling> (stochastic, rotate, cells_per_repeat, macro_metres, macro_strength, each defaulted, checked by path, and the field skipped when absent so an older library is byte for byte the file it was; AntiTiling::macro_only() for patterned surfaces). ashlar-bevy draws such a definition as anti_tiling::AntiTilingMaterial, an ExtendedMaterial<StandardMaterial, AntiTilingExtension> whose fragment reads the base colour, normal and ORM maps through a random triangular lattice with variance-preserving blending, and multiplies the albedo (and, the other way, the roughness) by a two-octave world-space value noise; its prepass entry reads the same, so masks, normal prepasses and shadows agree, and the material is always forward. anti_tile converts any StandardMaterial; AntiTilingPlugin registers it and AshlarPlugin adds it. A piece's material is now a MaterialHandle (Standard or AntiTiled, with insert, id, standard and anti_tiled): Drawable::material and BatchDrawable::material changed type, batch_drawables caches MaterialHandles, UploadContext gains anti_tiled (None draws anti-tiled definitions plain), and add_material is the routing. The anti_tiling example compares the two.

  • Coplanar-face lint (2026-09-27). MeshedBuilding::coplanar_overlaps finds where a building will z-fight: faces of two different elements, after every instance pose, that lie on one plane (normals within half a degree, planes within a millimetre at the shared area), face the same way and share area. Each CoplanarOverlap names the two elements by path (instances[a].elements[panel], or groups[g] for a face a hit exposed), the binding each wears there, the area, and a point and normal to look at, largest first; same_binding() marks the one-material case and covered one pressed against a face turned the other way. Faces touching back to back, as a kit stacks, are never reported. Bucketed by facing and position, so a million triangles take about two seconds in a release build; coplanar_overlaps_within takes a CoplanarTolerance. Kernel-free: a baked level answers through BakedBuilding::level.

  • Prop tables (2026-09-26). Prop::tables holds named Vec<f32> tables a game's shaders read beside the meshes, such as a tree's wind pivots, once per prop instead of on every vertex. Validated finite, carried in the manifest, and left out of it when empty, so existing files are unchanged.

  • Props and vertex data (2026-09-26, ADR 0007). TriangleMesh carries optional colors and named attributes (VertexData: F32 to F32x4 and U32, one value per position), kept with their vertex by weld, unweld, project_uvs and the new append, and checked by path with TriangleMesh::check. ashlar::prop holds a Prop: levels of detail of meshes wearing material keys, each with an until and whether it casts shadows, an optional ImpostorRef on the last (the albedo and normal-depth atlas keys and the ImpostorLayout they were baked with), Capsule, Cylinder and ConvexHull colliders, and bounds. Prop::write and Prop::read are the .ashprop file, the building file's container with its own identifier, readable by plain ashlar; Content::write_prop writes one. ashlar-bevy uploads colours as the vertex colour and each attribute as ashlar_bevy::attribute(name, format), loads .ashprop files as a PropAsset whose meshes are labelled sub-assets, and spawns an AshlarProp with each level in its band and NotShadowCaster where a level casts none, and its impostor level as an ImpostorMaterial quad marked AshlarPropImpostor, which casts no shadow. prop_level_range(&prop, level, crossfade) is that band rule for a game's own entities, and Content::write_impostor bakes a prop's nearest level, in the graph and plain materials its library gives it, into the two atlas files and answers the ImpostorRef (ImpostorSettings for the grid; cached like a building). AshlarPlugin adds ImpostorPlugin, which may now be added twice. A building file is byte for byte what it was, and refuses vertex data. docs/guide/props.md walks it.

  • Cut-out, two-sided and translucent materials (2026-09-26, ADR 0007). MaterialDefinition gains alpha (Alpha::Opaque or Alpha::Mask { cutoff }), double_sided and diffuse_transmission, each validated by path and skipped when default, so an older library reads and writes byte for byte as it did. PbrOutput::opacity bakes into the base colour map's alpha, on the CPU and the GPU bake alike, with a mip chain that keeps the share of texels over one half (mips::COVERAGE_CUTOFF, mips::coverage) and averages the colour weighted by opacity; SurfaceOutput::Opacity reads it from a subgraph. ashlar-bevy maps the three fields to AlphaMode::Mask, double_sided with cull_mode: None and diffuse_transmission, from files and runtime bakes alike. A Surface::Shader over a graph with opacity is refused at output.opacity until the emitter writes a discarding prepass. bake::Planes gains an opacity field, so code that builds one by hand names it.

  • Impostors (2026-09-26, ADR 0007). ashlar-impostor, a new content-step crate, bakes a prop's triangles and maps into a hemi-octahedral atlas on the CPU: frames by frames orthographic pictures of the bounding sphere, an sRGB albedo with coverage alpha from sixteen samples a texel and a linear map of prop-frame normals and depth, colour padded past every silhouette, a mip chain that holds each frame's cut-out coverage, and both maps as KTX2 (zstd feature for supercompression). The same bytes on any thread count. ashlar::impostor holds the ImpostorLayout both halves read and the hemi-octahedral and octahedral mappings. ashlar-bevy's new featureless impostor module draws an atlas: ImpostorMaterial, a StandardMaterial extension that turns one quad per instance to the camera, blends the three nearest frames with a depth parallax step, cuts out at 0.5 and lights the blended normal with Bevy's own lighting; impostor_quad, impostor_images, load_impostor_images and ImpostorPlugin, the last four in the prelude. It works behind depth, normal and motion-vector prepasses and beside a deferred one, casts no shadow, and just draw holds it on five cameras. The impostor example grows a tree in code and shows it beside its impostor, and docs/guide/impostors.md is the chapter.

  • A content cache (2026-09-26). Content::cache keeps every bake and meshing under a directory, keyed by a BLAKE3 hash of what decides it: a material set by its definitions, the graph library, the resolution and the encoding; a building by its recipe, its ladder and the mesher. A re-run replays unchanged work and redoes only what changed, with the same bytes an uncached run writes. MaterialsWritten::cached and BuildingWritten::cached say what came from it. Independently, Content::write_file now leaves a file alone when its bytes are already there, so a warm run rewrites nothing and a watching game reloads nothing.

  • Libraries in named asset sources (2026-09-26). A .materials.ron loaded from a named source such as generated:// resolves its maps and strand sets in that source, and the MaterialLibraryAsset it becomes holds its keys qualified with it, so a game can mount its content step's output as a source of its own. A library in the default source is unchanged.

  • A manual and a site (2026-09-26). The docs folder is an mdBook: an introduction, getting started, the preview, a tour of the kits, the architecture and a glossary beside the existing guides and the decisions. just site assembles it with the API docs and a browser demo into target/site, and .github/workflows/pages.yml deploys that to GitHub Pages. The README is an entry page with screenshots; first_building is the getting-started code as a runnable example.

  • A browser demo (examples/web): the showcase's scenes baked and loaded the way a game loads them, by day and by night, with orbit and walk cameras, a storey cut, the interior and a level-of-detail view; and a material stage whose graph parameters are sliders that re-bake in the browser, on one thread. It runs natively too, for development. It streams pieces by distance, so a browser's frame over the metropolis takes half the time it did; it draws without shadows by night, and on a phone it draws less and opens with its panel folded.

  • Level bands that work on WebGL2: AshlarPlugin::bands, Bands::Auto by default, cuts each level at the middle of its crossfade margin with an abrupt VisibilityRange on a render device that cannot crossfade one: Bevy 0.19.1's WebGL2 fallback declares the range uniform as 64 vec4s in the shader and 16 bytes in the bind group layout. Native devices crossfade as before.

  • Content::review_pngs: the content step can write each map's full level as a PNG beside its KTX2, for looking at. just materials is that review export.

  • The production path: a game ships baked files and links no kernel and no graph engine. docs/guide/integration.md walks it end to end.

  • ashlar::BakedBuilding and the .ashlar file (ADR 0006): a meshed building and its coarser levels of detail, with level zero's collision proxies, portals and rooms, as a RON manifest plus a binary mesh table. write and read; a read refuses what it cannot account for, by kind, and never panics.

  • Levels of detail from the recipe: LodPolicy (until, min_feature, segment_scale, drop_interior), Geometry, Part and Building::simplified, and LodPolicy::ladder(), three levels to 60 m, 250 m and beyond. ashlar_manifold::bake meshes a building at every level, re-meshing only what a level changed; a building nothing of which survives a level bakes that level empty. The 4 x 4 metropolis draws 3.8 M, 1.35 M and 0.91 M triangles at its three levels.

  • ashlar_bevy::baked::AshlarPlugin, with default features: loads .ashlar buildings and .materials.ron libraries as assets and spawns an AshlarBuilding as AshlarPiece children, one VisibilityRange band per level with a crossfade. Colliders arrive as AshlarCollider, rooms and portals as AshlarSpaces, and AshlarBuildingSpawned or AshlarFailed says how it went. Meshes and materials are shared across instances and across levels that left a piece unchanged, through AshlarCache; a merged group switches level by its own distance; a rewritten file respawns its buildings, a file that arrives after a failure clears it, and an asset nothing holds leaves the cache.

  • ashlar-content: the content step as a library. Content::ship bakes a material library to KTX2 and strand sets and each building to .ashlar, under one asset root, writing through a temporary file and a rename. flatten_overrides turns each distinct per-instance graph override into a definition of its own, keyed <material>--<hash>, so it bakes once however many buildings use it. just content runs it over the showcase.

  • The integration template: examples/integration-content bakes a small village of the game's own, and examples/integration-game draws it with one plugin and one component. The split a game copies.

  • The showcase's geometry, taken to production quality (2026-09-26), in five reviewed passes. The dark city's towers come in three styles with their own massing, designed crowns, built terraces and podiums with glazed doors; the landmark gets a colonnade, sky floors and a lantern; every storey is an enterable office and every ground floor a lobby. The street level gets lowered crossings, built props and seeded alleys and markets; the sci-fi kit is built out and brought into the city's palette as its corporate colony; the corporate kit, the house and the rooftop pieces get their joints, frames and supports. The 4 x 4 metropolis draws 4.28 M / 1.20 M / 0.78 M triangles by level, held there by a test.

  • Levels of detail judge thin, not small (2026-09-25, ADR 0006 amendment): an element or union member is dropped by the middle of its extents rather than its largest, so sills, mullions, rails and strips go at a distance however long they are, and Element::far() keeps a glowing line at every level. The 4 x 4 metropolis's far level draws 0.76 M triangles rather than 1.59 M.

  • A dark city (2026-09-25): the city kit is rebuilt in one look, the corporate kit's language in dark stained concrete and lit for the night. Towers step in twice as they rise to ninety storeys, landmarks pass a hundred, and every storey is still an enterable office with a room. Six- and ten-metre back alleys run between a lot's towers, dressed with gutters, dumpsters, pipes, air-conditioners, fire escapes, caged sodium lamps, neon blade signs and cables strung overhead; tenements with fire escapes and night markets fill other lots. Which offices are lit is a per-storey material override, so a thousand storeys of one part cost a handful of materials. Scenes city-alley, city-tower, city-landmark and city-tenement are new. Its materials are new stdlib graphs: library:stained-concrete (streaked from each storey's slab edge, grime at its foot, over a new weathering:streaks compound), library:office-window and library:shopfront (lit interiors in the emissive map), and a wet parameter on library:asphalt, library:road and library:paving-slabs (plus tint on paving) whose default leaves the dry bake byte for byte. The preview gains --night (a moon key, bloom, and a shadowless light in every small strongly emitting piece, so lamps pool and signs wash their wall), --key, --ambient and --focus X,Y,Z for a street-level orbit; the baked example gains --night. The glass, stepped, round and slab families, city::Style, metropolis_styled and metropolis-corporate are gone.

  • Packaging: licence files in every publishable crate, docs.rs metadata, and just configs, which CI runs, checking and documenting the game and tool configurations one at a time so a feature-gated link cannot hide.

  • Geometry::revolve_arc(profile, segments, sweep): part of a turn, so a round wall is cut into runs that each carry a tight convex proxy.

  • Walkable interiors for the sci-fi kit (domes, dome house, hab pod and tube chain, module, tower) built additively with rooms, portals and furniture; scifi::Habitat::place adds one to a building at a pose with a look.

  • A city kit and city::metropolis(seed, blocks): enterable storeys, a street kit, and a deterministic street grid of lots. Scenes city-kit, city-block, metropolis, metropolis-large, city-<piece>. Its first tower families gave way to the dark city above.

  • City materials: library:curtain-wall, library:window-band, library:road, library:interior-panelling, library:holo-sign.

  • A frontier sci-fi kit in examples/showcase/src/scifi.rs, in the idiom of Anarchy Online and Star Wars Galaxies: adobe dome huts (two sizes) and a two-tier dome house, a capsule hab pod on stilts, walkway tubes that chain pod to pod by socket, a flared hab tower, a prefab module, a quonset hangar, a landing pad, perimeter walls, posts, a blast gate and a force fence, a moisture vaporator, a generator, a storage tank, a vendor kiosk, conduit runs, dish antennas, lamps, crates and barrels. Scenes scifi-kit, scifi-outpost, scifi-colony, and scifi-<piece> for each piece alone.

  • Four library materials for it: library:hull-plating (panelled composite with fasteners, grilles and hatches; color, wear), library:tread-plate (rust), library:adobe and library:desert-sand (color each).

  • Geometry::revolve(profile, segments) and Shape::Revolve: a [radius, height] profile turned a full circle about Y, evaluated by the kernel's own revolve at an authored segment count. Domes, capsules, tanks and flared towers, which no extrusion reaches.

  • ashlar: the Bevy-free recipe domain. Geometry, Shape and Pose; Element, Part, Socket, Instance, BuildingRecipe and the validated Building; MaterialLibrary and MaterialDefinition; the GeometryMesher port with TriangleMesh and planar_uvs; and fit_ground with GroundingSpec, GroundingPolicy, TerrainPatch and Grounding.

  • ashlar-manifold: ManifoldMesher and mesh_building, evaluating recipes through the native Manifold kernel into MeshedBuilding.

  • ashlar-bevy: Definitions, read, create, mesh and ground_mesh, turning meshes and material libraries into Bevy assets with generated tangents and preflighted textures.

  • ashlar-showcase (unpublished): the outpost, office and corporate kits, their grounding contract, and the generator for their tileable PBR maps.

  • ashlar-preview (unpublished): the orbit viewer and the headless reference gallery, as a library over a pluggable Catalog of scenes with a thin binary that supplies the showcase.

Added since the extraction

  • Expression nodes: Shape::Union, Shape::Array and Shape::Mirror, with Geometry::union, union_all, arrayed and mirrored, evaluated through the kernel and documented for cut-face provenance.

  • UV modes: UvMode per element, with Planar (the default and the old behaviour), Box and Cylindrical, plus box_uvs, cylindrical_uvs and project_uvs beside planar_uvs.

  • Indexed meshes: TriangleMesh carries an index buffer, with weld, unweld, triangles, corners and is_consistent. The Bevy adapter uploads Indices::U32.

  • Derived collision: Collision on an element and ConvexSolid in the domain; MeshedBuilding::part_colliders and colliders() in the mesher. See ADR 0002.

  • Catalog::asset_root: a downstream preview binary points --asset-root at its own game's assets by default, instead of at this workspace's.

  • Grounding to collision: ConvexSolid::from(&GroundSolid), so a game builds one compound collider out of a fitted site and the proxies its elements declare.

  • Socket attachment: Instance::attach and attach_aligned, with Attachment and Facing, resolved into poses by build().

  • Terrain triangulation: Diagonal and TerrainPatch::with_diagonal.

  • API hardening: #[non_exhaustive] on the enums a consumer matches on, a version field on BuildingRecipe checked at build time, a glam re-export, and an index inside Building so material, the new part and the new instance are map lookups.

  • ashlar-manifold documents on docs.rs without a C++ toolchain or a vendored kernel, and records why in its manifest.

  • Tests for ashlar-bevy, which had none: mesh upload, welding, ground meshes, the UV transform, texture colour spaces and sampling, and every rejection read makes. All of it runs without a GPU, on MinimalPlugins plus AssetPlugin and Bevy's own image loader registered by hand.

  • Property tests over the pure geometry, on proptest: profile validation answers rather than panicking and accepts every strictly convex ring, planar_uvs preserves edge length, weld then unweld is the identity on the triangles, and Pose::transform_point is an isometry.

  • Gallery tests in ashlar-preview for everything below the screenshot: the scene queue, the unknown-scene error, the index.html and manifest.tsv writers, and the camera framing.

  • Crate documentation: each of ashlar, ashlar-manifold and ashlar-bevy opens with what it is, where it sits in the layering and a worked example that is a real doctest, and the builder entry points carry short examples of their own. Sixteen doctests where there was one.

  • Per-crate README.md for every publishable crate, with readme and documentation in their manifests, so a crates.io listing and a docs.rs sidebar say what the crate is, what it costs to build and which Bevy the adapter pairs with.

  • ashlar-material: procedural material graphs as plain data, beside ashlar rather than under it. MaterialGraph, Param, Input, PbrOutput and a vocabulary of generator, transform, filter, buffered and structure Nodes, with build() returning a validated Material the way BuildingRecipe::build returns a Building: node paths of the form nodes[id].inputs[name], type inference with the float/colour conversions, cycles named by the ring they close, and a MaterialGraphLibrary that resolves subgraphs and refuses recursion. Every port carries an integer Period, so a graph whose output does not tile is rejected by node path before anything is baked. A built material lowers once into the typed SSA expression in ir, which folds constants — this is how a parameter exposed for baking stops being an instruction — gives one id to identical sub-expressions, drops everything no output reads, and hashes to a stable 64-bit cache key; interp is the reference backend that runs that expression over f32, and its only randomness is the lattice hash the showcase's pixel loops already used, bit for bit, so the study's textures survive the move to graphs. The first set of nodes lowers with it — the coordinate, value noise and its octaves, brickwork's mask, levels, the arithmetic blend modes, a gradient, the scalar operators, invert, mix, decompose and combine — each pinned by a hand-computed value and by evaluating the field at both seams and a repeat along, over random periods and parameters; a node or a setting no backend has reached yet is a refusal naming the node that asked rather than a quietly different picture. A brick bond is held to the whole wall rather than to one row, so an offset that only looks like a fraction — and would walk visibly out of bond across a tall wall — is refused at offset instead of claiming a period in v it does not have. bake turns a graph and a set of parameter values into a tileable texture set: the expression run once per texel over one repeat, rows rasterised across std::thread::scope threads with no extra dependency, and the same bytes however the rows were divided, since a texel reads nothing but its own coordinate. The normal is derived from the height plane rather than computed beside it, so relief and parallax describe one surface; the f32 planes are exposed before anything is quantised, and bake_with_report says what the bake cost and what range the height actually reached, which is the one way a correct bake can still ship a flat map. bake::preflight stops after the lowering, for a caller that wants to know whether a graph would bake rather than what it bakes to. See ADR 0003.

  • Buffered filters in ashlar-material, and the plane pipeline under them. Blur, OcclusionFromHeight, Distance, Erode, Dilate and Buffer lower to a plan and an Op::Sample: everything upstream is rasterised once into a Plane, the filter runs over it as ordinary Rust, and everything downstream reads the result bilinearly and with wrap. Every filter reads its neighbours across the seam, so a blurred noise still meets itself and the period a port carries still means what it says — a filter that clamped at the edge would leave a period that was right and a picture that was wrong, so each is tested against a field whose answer is different if it had. Every radius is a reach in UV rather than a count of texels: the blur is a Gaussian truncated at its radius with sigma a third of it, the occlusion marches eight directions and keeps the steepest rise in each, the distance is a jump flood over the torus in UV units, and the morphologies take the square neighbourhood of the radius, which is separable and gives an axis-aligned mask back rather than rounding its corners. A plane is the expensive thing in a bake, so two nodes that ask for the same filter over the same expression share one, a buffered node no output reads leaves none, and bake::rasterise_with takes a BakeCache that keeps planes across bakes for a preview that re-bakes on every slider. BakeError is #[non_exhaustive] and gained Lattice: a bake whose graph lays a lattice finer than the texels asked for is refused, because below that the answer is not a coarser surface but an arbitrary sample of one.

  • Mip chains in ashlar-material. BakeRequest::mips fills every map with every level down to 1x1, filtered from the f32 planes rather than from the encoded bytes and rather than by re-evaluating the graph at a lower resolution, which would make each level a different picture of the surface instead of a blurrier one. Each level is the 2x2 box filter of the one above it, which at an even size needs no wrap and no edge case at all: the groups partition the plane, so the mip of a repeat is still a repeat. Two planes are not a plain average. The chain carries the un-normalised mean of the level-0 normals, so a level ships that mean's direction and its length is what says how much slope the level lost; the roughness is widened for it by the design's Toksvig form r' = sqrt(r^2 + (1 - |n_avg|) * k) over a documented mips::TOKSVIG_K, clamped to one, and widened from the box-filtered level-0 roughness so the same lost variance is counted once rather than compounded down the chain. Without that a bumpy wall turns to glass at distance, which is the whole reason the pass exists. Everything else — base colour, metallic, occlusion, height, emissive — is box filtered plainly and encoded in its own format at every level, so height stays 16-bit and emissive stays half-float all the way down. Planes::mips is the chain as numbers and Planes::encode_mips is the chain as bytes; BakeReport::levels says how many there are, and BakeError::Mips is gone.

  • A KTX2 writer in ashlar-material, and the study ships KTX2. ktx2::write turns one Encoded map into a file with every level inside it: the 80-byte header, the level index, the mandatory data format descriptor, a KTXwriter key and the levels, which are stored smallest first — the reverse of the index's order — each aligned as the specification asks. No dependency and no supercompression. The four formats a bake produces are the four Bevy's loader takes without transcoding, and the descriptor is what tells it that the base colour is sRGB and the normal map is not; the round trip is tested by parsing the bytes back with the ktx2 crate Bevy itself pins, including comparing the descriptor against the one that crate generates for the same format. ktx2::inspect reads a file's header and level index back and checks them against its own length, which is what a startup preflight can afford: ashlar-bevy validates every .ktx2 key with it, builds Bevy with the ktx2 feature and no supercompression decoder, and refuses a supercompressed file by name. The showcase's asset writer now bakes with mips and writes base.ktx2, normal.ktx2, orm.ktx2 and — new, because no PNG here could hold it — the sixteen-bit height.ktx2; the study's PNGs are deleted and --write-png beside --write-study-assets exports level 0 of each map for looking at. Level 0 is unchanged, so every golden hash is the number it was.

  • Runtime bake in ashlar-bevy: a Surface::Graph definition becomes Image assets when the material is created, in the new runtime_bake module. Each plane is one image at its own format — Rgba8UnormSrgb, Rgba8Unorm, R16Unorm, Rgba16Float, untranscoded — with every mip level concatenated largest-first, repeat addressing, trilinear filtering and RenderAssetUsages::RENDER_WORLD, because these texels exist to be uploaded and a texture set is nineteen megabytes at 1024. The bake always asks for mips: a runtime-baked wall is looked at from the distances a file-baked one is, and Bevy builds no chain for an image it was handed. The bytes are the bytes the KTX2 of the same Bake carries, which is what makes moving a surface between files and a registration-time bake a change of variant and nothing in the picture — the study's facade rendered from a library flipped to Graph is pixel for pixel the facade rendered from its files. BakeCache is the resource that keeps them, keyed by the whole bake rather than a digest of it, so two definitions naming one Bake share one texture set and a hash collision cannot hand a building somebody else's textures; a per-building seed is then a distinct set in memory with no distinct file. create is unchanged and still needs nothing but an AssetServer; runtime_bake::create_graph_material is the superset that takes the graph library, the cache and Assets<Image> and handles every variant, so a library that can hold a graph surface never matches on the variant itself. A bake is CPU work that blocks — 0.16 seconds for the study concrete at 1024 across this machine's cores — so runtime_bake::bake_images is that work as a pure function of a bake and a graph library, callable from an AsyncComputeTaskPool task with only BakeCache::insert left for the main thread; the preview calls it synchronously, which is what a preview should do.

  • The second node set in ashlar-material: every node the vocabulary declares now lowers, and so does every output of every one of them. Perlin noise on a lattice that wraps, with the same octaves value noise has and a gradient taken from a pair of hashes rather than from a sine and a cosine, so no noise in the crate calls libm; Voronoi with three metrics, a jitter and its distance, cell, edge and border outputs; Tiles over a grid, a hexagonal lattice and a herringbone weave, with the same mask, bevel, id and fill outputs brickwork has and with Bricks gaining the three it was missing; Pattern, Shape and Scratches; the resamplers Transform, Warp, Mirror, Kaleidoscope and Tile; Curve as a monotone cubic, Adjust, Clamp, Switch and NormalFromHeight; the seven blend modes past the arithmetic four; and Subgraph, inlined into the arena that instanced it so that it costs nothing at runtime and folds and shares as if it had been written out by hand. A resampler is Lowering::substitute, which emits the sub-expression again with another coordinate where Op::Uv stood: the honest cost of reading a source through a frame is that source again, and only the instructions that actually depend on the coordinate are copied. The periodicity property runs over the whole vocabulary at random integer periods and parameters, and each node also carries a hand-computed value or a stated invariant — Perlin is a half at every lattice corner, a Voronoi distance is zero at a feature point, a subgraph is the graph it instances inlined by hand. Three things are refused rather than lowered, each by path: a rounded or cut brick corner, the directional and slope blurs, and a simplex noise, whose skew is (sqrt(3) - 1) / 2 and therefore has no integer period at all — the refusal says so rather than promising one later, and points at Perlin.

  • The finest lattice is carried through the graph, rather than read off each node alone. A Transform that scales a noise by four lays four times the cells, and a Tile that scatters a shape sixteen ways lays sixteen times what the shape laid; reading the nodes one at a time answered the noise's own count and let a bake resolve a quarter of what it wrote. Material::finest_lattice now propagates — multiplied by a transform's scale with the axes exchanged on a quarter turn, by a tile's instance count and by a quadrant kaleidoscope's fold — and counts the graphs a Subgraph instances, since lowering inlines them. This is the concern phase two's first step left written down in period.rs, and it is what BakeError::Lattice now compares against.

  • An authoring loop in ashlar-preview: the graph library is watched, and a parameter panel edits it in place. A change to the file re-reads and re-validates it and creates every material again from it, writing through the handles the scene already holds, so a save in an editor and the wall changing are one motion — measured at 279 ms for the study's five materials at 1024. The watch is a poll of the file's stamp twice a second rather than notify: that crate is not in this workspace's dependency graph, and Bevy's file_watcher feature, which is notify, was measured to bring a second windows-sys family past cargo deny's duplicate rule. The stamp carries a digest of the bytes beside the timestamp, because two writes inside one kernel tick share an st_mtime_ns — 195 times out of 200 on this filesystem — and a missed edit is the one thing a watcher may not do. M opens the panel: every Param of one graph as a slider, a colour as three and a switch as a checkbox, editing the library as the pointer moves and baking when it is let go, since a 1024 bake is a fifth of a second and that is a fine pause after a drag and an impossible one during it. S or the Save button writes the library back to RON, over the file it was read from or wherever --graphs-out says. Clicking a wall points the panel at the graph behind it, through the baked_from a textured surface records; the arrows walk the library for the graphs nothing on screen uses. The widgets are Bevy's own: bevy_ui_widgets ships headless Slider and Checkbox in 0.19, so only their appearance is written here, and the alternative was measured first — bevy_egui 0.42, the release built against Bevy 0.19, brings a second itertools and a second guillotiere, neither under an existing skip-tree entry. The step adds no dependency at all.

  • --bake on the preview turns every surface that records a graph into a Surface::Graph, so the preview renders the graphs rather than the files a content step wrote from them. That is what makes an edit visible: the study ships files, and should, and until now a graph library in a preview was a thing that could only be validated. The picture is the same either way — the facade rendered with --bake is pixel for pixel the facade rendered from its KTX2 files, checked.

  • Three authored materials, and a sheet to read them on. study:brick, study:plaster and study:painted-metal in examples/showcase/src/materials.rs are the first surfaces in this workspace that are not ports of a pixel loop, and they are what the phase-two node set, the buffered filters and the mip chain were built for. The brick takes its tint per brick from BrickOutput::Id through a gradient of clay colours, its joint from a recess the horizon march darkens, and its grime from a Distance measured out of the mortar, because what dirties a brick wall is the joint it sits next to; the plaster crazes along a warped Voronoi border whose cracks are the falloff around a one-texel line rather than the line, which is the only way to get a crack a fixed width in UV instead of in texels; the painted metal chips to bare steel where Scratches and a knock field say it has been hit, rusts in the rim a second Dilate leaves around each chip, and is the one graph in the workspace whose metallic is a field rather than a constant, because paint is a dielectric and the steel under a chip is not. Each takes a variation parameter, which is a domain shift rather than a seed: a node's seed is a field of the node and not an input, so what a parameter can reach is a Warp over the bound outputs, and a constant displacement of the sampling frame is another piece of the same endless wall. The writer bakes all five graphs to KTX2, the corporate block bakes three of them at registration — the tower, the annex and the wing are one plaster graph at three values of one parameter, with the BakeCache handing out three texture sets over a hundred and more instances and no file for any of them — and the outpost's repaired bay is clad in the brick. New material sheet scenes, one per graph in the library, put each material on a ball and a cube at three texel densities in just references: the gallery frames a scene to its own bounds, so a row laid out in depth can only span two to one in distance, and one, a third and a ninth of the tiling is the same number as one, three and nine times the distance with the specimen still a size somebody can look at. What the sheet says, and what still looks wrong, is in docs/research/material-sheet (removed; in git history).

  • The study's concrete gained a horizon and lost its clipping. Its occlusion is OcclusionFromHeight over the panel's own relief now, not the 1 - pores * 0.3 the pixel loop wrote — the one number in the phase-one port that was a stand-in rather than a translation, and the reason a joint half a centimetre deep cast no shadow into its own corner. The filter reads height as a length in UV, so what it is handed is the height scaled by the same relief the normal is derived with. And the height is bedded onto the whole of 0..=1 by a Levels with the same factor divided back out of the normal strength, because its relief ran from -0.216 to 0.370 and the sixteen-bit map was clamping the panel joint flat while the f32 normal was not. A new test reads BakeReport::height_range for every shipped graph and fails on a relief that leaves the interval, and another holds every graph's lattice inside the 512 the corporate kit bakes at. The concrete's occlusion, normal and height maps moved and its golden hashes moved with them; its base colour did not.

  • The study's surfaces are graphs. examples/showcase/src/materials.rs carries the concrete and the metal as ashlar-material graphs — the port of the pixel loops that were in textures.rs, node for node and number for number — and the asset writer bakes them at 1024 instead of looping over texels. The graph library is written beside the material library as assets/buildings/recipe-study/materials.graphs.ron, and every textured surface records the graph, parameters and resolution it came from under baked_from, so a map can be traced to what wrote it and the step re-run. Two things moved in the port and are documented where they moved: the pixel loop's normal strength was per two texels at 1024 and a graph's is per UV unit, and the loop's colour constants were display-referred where a graph's base_color is linear, so the study's tone is computed as the loop computed it and then decoded through the sRGB transfer the bake's encoder puts back. The maps are within an eight-bit code of the ones the loop wrote, the remaining difference being that a bake samples texel centres where the loop sampled corners. The golden hash of every plane is recorded in examples/showcase/tests/materials.rs so the interpreter cannot drift silently, and the numeric seam check that lived in textures.rs is now in ashlar-material's own tests/periods.rs, at the study's own periods. The facade and the entrance rendered from the graph-baked maps, the two graphs as RON and what a bake costs are in docs/research/material-graph (removed; in git history).

  • The shader partition in ashlar-material: partition::partition takes a built material, a Target::Shader and the resolution its textures would be baked at, and answers what a fragment has to compute and what a texture can hold. Every value in the lowered expression is coloured runtime where it depends on a live parameter or on one of the four inputs a texture cannot hold, and static otherwise; the cut is at the frontier, and every maximal static sub-expression a runtime op reads becomes a bound texture rooted in the partition's own IR, so planes::rasterise_buffers bakes the static half with nothing new written and the WGSL emitter prints the runtime half over the same op set the interpreter runs. An output that reaches nothing runtime is a texture in its own right, sampled straight into its PBR slot, and the material's normal — which the bake derives from height outside the graph — comes back as a root of its own: a bound normal map over a static height, four offset taps at the bake's own span over a live one, and nothing at all where normal_strength is zero. A value built only of constants and the coordinate is written into the shader rather than bound, which is also what stops the cut from binding a texture for Op::Uv and sampling it at itself. CostReport is the number an author acts on — runtime ops per output, texture reads, bound textures, planes, live uniforms, the neighbourhood taps and their ceiling — and it says no live inputs: bake this when the fragment computes nothing, which is what all five study graphs say with their parameters baked. With variation live instead, study:brick is 2683 ops per fragment against 1189 baked, because a Warp over the bound outputs re-emits every instruction that depends on the coordinate and a live height is differenced four times over: the design's "a parameter that feeds everything costs everything", as a number, before it ships. A parameter that reaches a blur, an occlusion, a distance or a morphology cannot move per frame — those are a plane or they are nothing — so it is frozen, folded at its value the way a bake folds it and named in the report, rather than the graph being refused or the slider silently doing nothing; study:plaster's cracking and study:painted-metal's chipping are both of that kind. A runtime input above such a filter has nothing to fold to and is refused by node path. A material may bind eight textures, with scalars packed four to an RGBA image above that and a refusal by path above that again; a packed image is half floats whatever ports its lanes serve, because a format belongs to an image rather than to a plane and a height asking for sixteen-bit codes cannot share one with a roughness asking for eight unorm bits. A plane that only feeds another plane is rasterised and dropped, and costs no binding. Checked by rasterising the partition's own planes and running its IR through the interpreter: a bound texture answers at a texel centre exactly what the sub-expression it replaced answers in a bake, and the four taps answer what the baked normal plane does.

  • Four runtime input nodes in ashlar-material: Time, WorldPos, WorldNormal and CutFlag, which lower to the Ops the IR has carried since phase one and which the node table did not expose. All four are zero in a bake, because a bake has no frame and no mesh, and all four are period-neutral rather than free: a period says how many times a field comes back to itself across UV, and these are not fields over UV at all — at one instant, at one fragment, each is the same number across the whole repeat, exactly as a parameter is. Calling them free would refuse every graph that read one, on the strength of a question they are not answers to.

  • The WGSL emitter in ashlar-material: wgsl::emit prints a runtime partition as one let per instruction inside a function that takes the coordinate and the four runtime inputs and answers the PBR ports the graph bound, and wgsl::emit_compute prints the same function as a @workgroup_size(8, 8) kernel over the texel centres of a square, which is what the conformance test dispatches and what a GPU bake would. Around it: the AshlarParams uniform block at binding 100, laid out the way WGSL lays one out — a scalar aligned to four, a colour to sixteen, consecutive floats packed four to a row, the order the graph declares its parameters in, the block rounded up to sixteen, and a ParamLayout per parameter so the Bevy step fills bytes without re-deriving any of it; one texture_2d<f32> and sampler pair per bound image from binding 101; a MaterialExtension fragment over pbr_input_from_standard_material that multiplies into the PBR slots the way the same material's baked maps would and writes its tangent-space normal through calculate_tbn_mikktspace, under the VERTEX_TANGENTS def that declares the tangent and leaving the geometric normal alone without it; and a prepass fragment writing the same normal so screen-space occlusion and the deferred path see the relief. Every op is the arithmetic the interpreter does rather than the WGSL built-in that is nearly it — mix is a + (b - a) * t, clamp is min(max(x, low), high), and division, smoothstep and normalize go through helpers that keep the guarded case each has — and the lattice hash is the same integer multiply-xorshift over the same modulo-period reduction, in u32, whose arithmetic WGSL defines as wrapping. Validated with naga at the version Bevy 0.19.1 pins, as a dev-dependency with only wgsl-in: every node of the vocabulary in isolation with a live parameter forcing it into the fragment, every study graph with every parameter live and with none, and the compute kernel. What is validated is the generated core — the block, the bindings, the helpers and the SSA function, with the bind group written as a literal and a stub entry point appended; the Bevy-flavoured wrapper around it carries #import lines and #{MATERIAL_BIND_GROUP}, which are naga_oil directives that plain naga does not parse, and is checked by the Bevy step compiling one. Compiling one found the one thing that had to change afterwards: the clock lives in two different bind groups, binding 11 of mesh_view_bindings in the main pass and binding 1 of the prepass's own smaller view layout, so an emitted prepass that reads Time declares ashlar_globals there itself rather than importing the main pass's name.

  • ProceduralMaterial in ashlar-bevy: a ashlar::Surface::Shader definition now compiles rather than being refused. shader::create_shader_material partitions the graph, bakes the static half into Image assets through the phase-two runtime_bake::BakeCache under an exact ShaderKey — graph, resolution, live names, and the value of every parameter that was folded — emits both stages as WGSL, registers them as Shader assets under ids derived from the graph, and answers ExtendedMaterial<StandardMaterial, GraphExtension>. tile_metres and uv_offset go through uv_transform exactly as create and create_graph_material put them there, so a wall does not change how it tiles when its surface moves between files, a bake and a shader. MaterialExtension::fragment_shader cannot vary per graph, so GraphExtension carries a GraphKey as its bind_group_data — Bevy makes that part of the pipeline cache key and hands it to specialize, which swaps the descriptor's fragment shader for the weak handle whose UUID the key derives, choosing the prepass text when the PREPASS_PIPELINE shader-def is present and doing nothing where there is no fragment stage at all. GraphKey is a sixty-four-bit digest because Bevy's bind group data has to be Copy; GraphShaders keeps the exact ShaderKey beside every entry, so a collision is a startup error naming both graphs rather than one wall wearing another's shader. The bind group is fixed by the type: a 256-byte uniform block at binding 100 and eight texture-and-sampler pairs from 101, which is the number the partition refuses above. shader::ProceduralMaterialPlugin registers the material, its pipeline and its prepass. read preflights a Shader surface by lowering, partitioning and printing it, so everything but the WGSL itself is a startup error; validating the WGSL needs naga, which is a dev-dependency of ashlar-material on purpose.

  • bake::encode_bound in ashlar-material: one arbitrary plane quantised into an image with its whole mip chain, using the bake's own ordered dither, sRGB transfer, sixteen-bit height codes, half floats and wrapped box filter, with a normal written half and half about zero and renormalised at every level. This is what makes a compiled material's bound textures the same bytes the same graph's baked maps are.

  • MaterialGraph::with_params in ashlar-material: the graph with a caller's parameter values in place of its declared defaults, refusing an unknown name or a wrong type at params[name]. The bake already did this privately; the shader partition needs the same step, so it is public and written once.

  • The conformance test in ashlar-bevy: tests/conformance.rs, run by just conformance, is the contract between the two backends and the reason the IR exists. Every test in it that dispatches one is #[ignore]d because it needs a GPU: it opens a headless device through Bevy's own initialize_renderer — no window, no App, and no new dependency, since this crate already links the wgpu Bevy pins — dispatches the compute kernel wgsl::emit_compute writes, and compares it with interp over the same partition, texel for texel at 256 squared. One partition per primitive Op, each a static field cut into a bound texture with a live parameter over it, with a coverage test — the one that needs no adapter and so runs in just ci — that fails when an op has no case; a row for the one layout a table of ops cannot reach, ten scalar cuts packed four to an image, where every sample has to read the lane it landed in; every study graph with every parameter live; and the lattice hash against the emitted helper itself, which has to agree bit for bit rather than within a tolerance. The bound textures are uploaded as Rgba32Float holding the CPU planes' own numbers, so the runtime half is what is under test and not the bake's quantisation. Budgets: one eight-bit step for a colour, two for a normal, one part in ten thousand for a raw scalar plane. Worst observed on an RTX 4090 under Vulkan: four parts in a million, and the hash exactly zero.

  • Two live materials in the showcase, which is what the whole of phase 3 was for. study:strip is a light strip whose emissive is diffuser * (0.75 + 0.25 * sin(time * rate)) — the first graph here that a texture cannot hold, since an image has no axis to store time along — and it is bound to the study's light key as a Surface::Shader. Its rate is a Bake parameter on purpose: how fast a light beats is a decision about what the light is, and the phase is the clock rather than a parameter. It compiles to fourteen instructions over three bound textures; the diffuser's noises are baked. study:concrete gained a live wetness that collects water in its own height field's low places, darkens the albedo towards a third of itself and drops the roughness to near a varnish; the study library ships it twice, as the Files surface every wall uses and as study:concrete-wet, a Surface::Shader over the same graph with wetness bound, which the entrance's coping and threshold carry. Eleven instructions and one uniform. At wetness zero the graph is the wall it always was, bit for bit — Op::Mix is a + (b - a) * t — so the shipped KTX2 files and the golden hashes are unchanged by the parameter existing. See docs/research/shader-materials (removed; in git history).

  • The material sheet is baked beside live: every column of a sheet is now a pair of specimens, the maps a bake wrote and the graph they were written from compiled, so "a graph with no live parameter is the same picture either way" is a claim somebody can check by looking. sheet::DELIVERY is the second axis and sheet::material_key takes it. tools/ashlar-preview/tests/sheet.rs checks it as bytes rather than as pixels: a compiled material's bound base_color and normal images are byte for byte the maps the bake wrote, whole mip chain included.

  • The preview drives live parameters without a rebake. reload::redress writes a new uniform block over every compiled material's, and the panel asks for it on every step of a slider drag rather than on release, because a live parameter rasterises no texel, emits no WGSL and rebuilds no pipeline. A definition that pinned a live parameter keeps the value it pinned: the panel edits graphs, and a definition that named a value has chosen one. panel::graph_of answers for a Shader surface now, and the cost report the preview prints when one loads carries the wall-clock time of the compile beside it.

  • A GPU bake, through the same emitter. ashlar-bevy::gpu bakes a graph on the render device: partition::over_planes is the shader partition turned inside out — nothing cut, the whole expression on the runtime side, and every plane's root named as a port of its own through Ir::rooted_at — and wgsl::emit_compute_ports writes one kernel per port over only the images that port samples. A bake is then a dispatch per plane with planes::filter run on the CPU between them, a dispatch for the outputs, and the normal, the mip chain, the Toksvig widening and the encoding still on the CPU over the read-back f32 planes, which is what keeps a GPU bake's normal identical to a CPU bake's rather than merely close. Baker::{Cpu, Gpu} on BakeContext and ShaderContext says which backend runs; gpu::GpuBakePlugin puts a GpuBaker in the world wherever the app has a device, and the preview's runtime bakes and its panel's re-bake take it. The CPU bake stays the reference: it writes the shipped bytes and needs no adapter, and --write-study-assets only leaves it for --gpu. Measured on an RTX 4090 in a debug build: just references 65.1 s to 19.6 s, the study's painted metal at 1024 from 3.11 s to 1.08 s and its plaster from 2.29 s to 1.03 s, with a small graph slightly slower because a dispatch costs a shader compile whatever it evaluates. just conformance grew the_gpu_bake_is_the_cpu_bake_within_the_conformance_budget, which compares the two bakes of every study graph at 512 as encoded bytes, map by map and level by level: worst difference one eight-bit code.

  • World-space inputs, and a material that decides for itself where it is wet. WorldPos and WorldNormal were emitted and conformance-tested with nothing bound to them; ashlar-material now gives them something to do and gives a bake an answer about them. WorldMask { field, axis, threshold, softness } takes one axis of the world normal or the world position through a soft threshold — WorldMask::up() is the faces rain lands on, and WorldMask::below(6.0) the faces under six metres — and Triplanar { source, tile_metres, sharpness } reads a tiled source three times by world position, once on each axis plane, blended by the normal, for a surface that must ignore UV seams; it is a resampler, so its source is emitted three times and the cost report counts all three, and its source must tile, checked by path, because the field it lays repeats every tile_metres along each axis. All of them are period-neutral: they are not fields over UV, so the question a period answers does not apply to them. A bake now refuses a world-space input by the path of the node that asked, with a message naming the Shader surface that does answer — zero seconds is a picture and a world position of zero is a whole wall at one point, so Time and CutFlag still bake at zero and these two do not bake at all. wgsl::emit_compute_world emits a kernel that reads a world position and a world normal per texel from two storage buffers, because a triplanar at one fixed position is one sample of its source repeated across the square and two backends that disagreed everywhere would compare equal; just conformance uses it for four new cases and for the study's own world-space graph. The study ships that graph: study:concrete-wet instances study:concrete and multiplies its live wetness by an up-facing mask and a near-ground one, so the coping of every storey of the office stops being a wet band across the facade while a doorway threshold stays exactly as wet as it was — eleven runtime instructions become twenty-five, with the bound textures unmoved. The preview also learned where the ground is: it recentred a model on its own bounds, which put a four-metre bay's floor two metres underground, and it now puts the model's feet at zero and lets the camera make up the difference, so world height is height above the ground in every scene.

  • The cut flag as a vertex attribute, and a material that dresses its own reveals. ashlar::TriangleMesh has carried one flag per triangle since the kernel went in, and the only thing a building could do with it was Element::cut_material, a second mesh and a second material key. ashlar-bevy::mesh now uploads it as ATTRIBUTE_ASHLAR_CUT, one flag per vertex, on any surface that has a cut face — and that attribute is Bevy's second UV set, because a MaterialExtension draws through Bevy's own vertex stage into a VertexOutput with no free location, and the alternative to repurposing a slot is emitting a vertex shader that reimplements mesh.wgsl and prepass.wgsl to carry one float. Vertex colour was the other free slot and is worse: pbr_fragment assigns it over the material's base colour rather than multiplying. A mesh with no cut face carries no attribute and the generated fragment reads zero under an #ifdef, which is the same answer. Because the flag is per triangle and an attribute is per vertex, TriangleMesh::weld now treats the edge of a cut as a seam, as it already treats a crease and a UV seam: splitting rather than taking a maximum, because an interpolated maximum is a ramp across the uncut face instead of a boundary. A bake still answers zero for the flag — one texture set serves both kinds of face — and BakeReport::cut_flag says when it did. wgsl::WorldInputs is renamed MeshInputs and emit_compute_world to emit_compute_mesh, because Planes now lays a cut flag over the square beside the world position and normal; just conformance gains a row that reads it out of that plane in blocks, so both arms of a mix and every boundary between them are compared. The study ships study:concrete-cut-aware: four Subgraph nodes on study:concrete, a CutFlag and two Mixes, nine runtime instructions and no uniform at all. The facade bay wears it and drops its cut slot; the entrance bay keeps the slot, because a reveal that really is a different material still wants one. The two pictures differ by at most seven codes of 255, over exactly the pixels the flag reached, and just references costs 4 s more over 39 shots — all of it the static half of a second compiled material being baked once per scene, because BakeCache keys a bound image by the graph that asked for it rather than by the plane behind it.

  • Per-instance parameter overrides, and one material key where there were three. A slot has always bound a material key, and an instance has always been able to override which key; what a graph surface adds is a reason to want nearly the same one, since three buildings of a block are one render at three values of one parameter. So a binding is now ashlar::Binding — a key, and optionally values of its own for the graph parameters of the surface behind it — and Instance::materials and a recipe's palette hold those. Its RON is two forms in one field: the bare "metro:stone" where nothing is overridden, which is what every recipe written before this holds and what such a binding still writes itself back as, and (material: "metro:stone", params: {"variation": Float(0.37)}) where something is. Validation is in the two places that can see the answer: MaterialLibrary::check_for refuses an override on a Plain or Files surface, which binds no parameters at all, and ashlar_bevy::read_library refuses a name the graph does not declare or declares at another type, both at instances[<id>].materials[<slot>]. ashlar_bevy::definition writes a binding's values into the definition it names, so nothing below it learns that an override happened and the two caches key what they always keyed: a Graph override is its own BakeKey and its own texture set, shared by every instance that asked for the same one; a Shader override of a live parameter is the same pipeline and the same bound images with a different uniform block; of a folded one it is a second compiled graph. What an override cannot reach is the definition's own constants, so a per-instance tint is still a second key. The corporate block is the worked example: metro:stone-annex and metro:stone-wing are gone from assets/buildings/corporate/materials.ron, all three buildings bind metro:stone, and each puts its own variation on its own bays — three texture sets over a hundred and more instances, asserted as such, and the block renders within five codes of 255 of what it did, all of it the two base-colour multipliers those two keys also carried.

  • The third node set, and the three materials re-authored over it. ashlar-material gains the nodes a worn surface is actually made of: Blur's directional kind (the same Gaussian along one angle, which is what makes a brushed metal out of a grain) and its slope kind (each texel walked steps times down the gradient of a height input and the source averaged at every stop, so rust creeps out of a chip and dust out of a joint); DirectionalWarp, which carries a field along an angle field by a fixed distance rather than by a vector, so a crack bends instead of pinching; Curvature from a height, as the signed field or as the Peaks and Cavity masks of one plane, by four taps or by the difference of the height and its own blur; EdgeDetect, a difference over a radius so a mask's edge is one whatever the radius; HeightToMask { low, high, softness }; and a per-instance mask on Tile, read once at each instance's cell centre and resolved against that instance's own hash, so a masked scatter thins out rather than dissolving. There is deliberately no GradientMap: Colorize is that node and its gradient already takes any number of stops, and the other reading of the name wants an image input this vocabulary does not have. A slope blur is also the first filter to read two planes, so a Filter may now name a guide BufferId — followed by the dead-plane sweep, the renumbering, the plane cache key and the partition alike — and planes::filter takes the planes before the one it is filtering. The study's brick, plaster and painted metal are re-authored over all of it: brickwork whose arrises are chipped where EdgeDetect found them and whose mortar dust is smeared up the course above by a slope blur, with grime and bloom read off the relief by Curvature; a render whose crazing runs under a DirectionalWarp and whose spalled patches carry the chalky lip their own edge draws; and paint that fails over the rims of the panel's dents because that is where Curvature says the film was already lifting. Their KTX2 sets, their golden hashes and the materials.graphs.ron the study ships are regenerated.

  • Zstd supercompression in the KTX2 writer, and assets/ down from 94 MB to 24 MB. ktx2::write_with takes a Supercompression beside the map and the resolution, and Supercompression::Zstd compresses each level into a frame of its own under scheme 2, recording the uncompressed length beside the stored one and packing the levels with the one-byte alignment the specification gives a stored level that is not texels. Nothing else about the file moves: the format, the dimensions, the data format descriptor and the KTXwriter key are written exactly as before, and ktx2::write is write_with with no scheme. The shipped study maps are rewritten under it — every one of the twenty decodes to the bytes it decoded to before, checked level for level — and the numbers are per map rather than uniform: the 8-bit planes come back at 2% to 26% of their bytes and the 16-bit heights at 81% to 95%, because the low byte of a quantised noise field is very nearly random. The encoder is the C library and is behind ashlar-material's new zstd feature, off by default, which the showcase's asset writer turns on and nothing that reads a map does; the decoder is ruzstd, pure Rust and decode-only, which ashlar-bevy gets by building Bevy with zstd_rust. ktx2::inspect reads a supercompressed file's claims — checking the uncompressed length against the size each level's own dimensions imply, which is what a reader with no decoder can check — and ashlar-bevy's preflight now refuses every scheme except zstd by number rather than refusing all of them. just references renders the gallery from the compressed files unchanged, pixel for pixel.

  • Phase 4's capture, in docs/research/materials-depth (removed; in git history): the six entries above with their measurements, and the three items the phase left undone with the reason for each. The bake timed both ways at 1024; the three re-authored maps beside the maps they replaced, and the material sheet that barely shows the difference between them; the wet concrete and the cut-aware concrete, whose own pictures stay in shader materials; the corporate block's three walls out of one key; and the twenty files at a quarter of their bytes. The plan's own phase 4 list is marked to match.

  • Subgraphs that take signals, and graphs that export masks. A library graph in ashlar-material now declares what it takes: GraphInput { name, value_type, default } is a node like any other, so validation, the topological order, period inference and lowering all reach an input without being taught about a second kind of table, and a compound with nothing wired into it still builds, checks, bakes and previews — it is its defaults, which is the picture its author chose them for. Subgraph gained inputs beside params, with a .input(name, value) builder that takes anything a port takes, a node written inline included. The two bind different things at different times: a parameter is a number, folded when the instance is lowered and never live, while an input is a whole field that carries a type, a period and a lattice across the boundary. Carrying them is why the instanced graph is now inferred again under what was wired in — an input of period 8 read through an inner Transform of scale 2 lays 16 cells, and an instance built once per key could only ever claim 8 — so an instance is built and cached per binding signature, keyed by the graph key plus the type, period and lattice of every bound field and written key@<hash> in a path when something is bound. Two nodes that bind the same fields share one instance and one inlined copy; two that bind different fields are two graphs and cost two. Recursion is still refused by key alone, whatever is bound, because a graph that includes itself is a cycle however it is specialised. PbrOutput::extra is the other half: named float masks beside the six PBR channels, read by SurfaceOutput::Extra(name), held to the same tiling rule and counted into the same repeat, because a mask that does not meet itself at the seam is as wrong as a colour that does not. A bake writes no map for one and a shader binds nothing for one — they are roots only inside an inlined instance, so the texture set and the BakeReport of a graph with an extra are the set and the report of the same graph without it. They exist so that a compound which decided something, where the wear took or where the water sat, can hand that decision back instead of making its caller build it a second time and get an answer a texel off. Two smaller things moved to make room: a port name is a Cow<'static, str> rather than a &'static str, which is what lets a node declare its ports at runtime, and SurfaceOutput lost its Copy for the string an extra's name is. Everything an author can get wrong is refused by path, with what the graph does declare: an unknown input name, a type the declaration does not admit under the two conversions, two inputs sharing a name, an extra named after a PBR channel, an Extra the graph does not export, and a field that does not tile bound into a compound whose output reaches it — that last one at the outer node, with the inner path in the message, because the node that bound it is the only place its author can fix it.

  • A conformance probe per new node. ashlar-bevy/tests/conformance.rs carried one partition per primitive Op and every study graph; between them those reach every arm the two backends implement, but they say nothing about a node that composes those arms — and a lowering can lean on arithmetic that only parts once the numbers are real, which is what a division whose divisor reaches a seam, a mix at a weight of exactly one and a pow of a base that goes negative do. So node_cases() joins op_cases(): a probe per node and per new output this phase added — a twisted CircleMap over a hole, a masked CircleSplatter with every variation, a hollow Gear as a distance, a rounded Box beside a hollow Capsule, an SdfCombine hard and filleted, an SdfMask, and a plain, a twill and a satin Weave — each the tests/wgsl.rs graph for that node, a warp over a live parameter, which makes the whole probe runtime. every_new_node_agrees_with_the_interpreter dispatches them under just conformance; a second test that needs no adapter, and so runs in just ci, asserts the partition cut none of them into a bound texture, since a probe that quietly became a texture read would pass saying nothing. The worst any of them differs by on the reference machine is eight parts in a million, in the splatter, whose per-instance turn is a sine and a cosine — which is one of the two places the specifications let the backends differ at all. The observed maximum in the README and the design doc moved from four parts in a million to that.

  • Weave, the one genuinely new generator of this phase: woven cloth, with x warp threads running along v and y weft threads along u, a width saying how much of its own pitch a thread fills, and a Plain, Twill or Satin crossing. The three patterns are one rule at different numbers — the warp is on top where the residue of warp - move * weft - offset modulo the repeat falls under the float — so a twill is a float of half its repeat marching a thread a weft and a satin a float of all but one thread whose single binding point moves by a step coprime with the repeat. That move is chosen from the repeat rather than authored, because a move that shares a factor with it binds some threads twice and others never; a repeat that admits no move at all is four threads and six, and both are refused rather than laid as the twill they would come out as. The period is the thread counts divided by the pattern's repeat, and the counts must therefore divide it: a plain weave of seven threads meets its own opposite crossing at the seam, which is a fault running the length of the cloth, so it is refused at x the way a brick bond that does not divide its rows is refused at offset. Height is one relief model rather than a field per output: each thread is a half-cosine ridge across its own width, lifted where it passes over another and pushed down where it passes under, in proportion to the crossing thread's own cross-section. That proportion is the whole of why the relief is continuous where the pattern changes — the pattern changes at the middle of the gap between two threads, where the cross-section carrying the lift has already fallen to zero, so both sides read the thread at its own plain crest and agree. Beside it Mask is the cloth against its holes, Warp and Weft are the topmost thread and are exactly each other's complement over the cloth, which makes them a pair of tint masks, and Id is a hash per thread rather than per crossing, so a tint runs the length of a thread.

  • SdfCombine and SdfMask, the boolean and the ramp that go with a shape's new distance output. A combine puts two signed distance fields through a union, an intersection or a subtraction — min, max and max(a, -b), because a field is negative inside its shape — and answers a distance again, so booleans chain and a mask over the last of them is what turns the result into a picture. smooth is a fillet width in UV: at zero the node emits the hard boolean and nothing else, bit for bit, because the polynomial smooth minimum divides by that width and the crate's guarded division would answer the average of the two fields rather than an infinity; anything more rounds the meeting of the two over a band that wide, which is a weld rather than a joint and a boss growing out of a plate rather than sitting on it. The fillet is written as one expression with two signs rather than as a smooth minimum and a smooth maximum, so a union and an intersection cannot round by different amounts. SdfMask is the inward ramp a Shape has always put its own distance through, factored into a node so the two cannot drift: a shape read as a mask and the same shape read as a distance and masked afterwards are one instruction list, and a test pins the hash and the texels. That identity is the whole point — it is what makes a hole cut in a plate readable as a shape rather than as a second thing that looks like one.

  • Shape grows: two kinds, two shaping fields and a second output, with every configuration an author could already write lowering to the same instruction list it always did — the mask is unchanged bit for bit, and a test pins the hash of the lowered expression rather than a picture of it. The new kinds are a capsule, the segment of length lying along u with a cap of radius size at each end, and a gear, a disc with sides teeth cut depth deep into it. A capsule is the one kind whose reach is not size, so the rule that keeps a shape off the seam is read against half its length too and refused at .length; a gear's teeth are cut inward from size, so the radius it promised still bounds it, and what it answers is a radial distance rather than a Euclidean one, which the node says plainly because it widens the edge ramp on a tooth's flank. round is the usual corner radius — the shape drawn smaller and the distance moved back out — which rounds a box, a polygon and a star, folds away on the three kinds with no corner, and makes a rounded box take the exact distance to a square instead of the Chebyshev one, that being flat across a whole quadrant outside a corner and so having nothing to round. hollow is the onion: the band from the boundary to that far inside it, an annulus of a circle and a frame of a box, taken inward so a hollow shape reaches exactly as far as the solid one the check already bounded. output adds the signed distance beside the mask, unclamped and untouched by edge, which is the field a boolean between two shapes will combine and the reason the mask is a ramp over a distance rather than a shape drawn twice.

  • CircleSplatter, the ring scatter: a source laid round one or more concentric rings inside the repeat, which is what draws a bolt circle, a ring of rivets, the ticks of a dial or the holes of a drain. It is a Tile on a circle rather than on a grid, and deliberately the same tool otherwise — an instance is the source's own unit drawn scale across, varied by hashes of its own place in the ring, windowed to that unit and combined with its neighbours by taking the larger, and the mask is read once per instance at that instance's own centre against its own hash, so a half mask keeps about half the bolts whole rather than dimming all of them. The rings stand at the outer edge of the bands they cut inner to radius into, so the outermost is at radius whatever rings is, and one check — radius plus what an instance reaches is at most a half — keeps every one of them off the seam, which is what lets the node tile once whatever it scattered. What an instance reaches is half its width while it stands square to the repeat and its half-diagonal once face_centre or a rotation variation turns it, because what is drawn is the source's unit square and a turned square presents a corner; a ring that fits square is therefore not always a ring that fits facing. Every variation of an instance's size or place takes away rather than adds, for the reason a scatter's does: the check is made once against the fields as written, and a variation that could grow an instance past it would make it a bound on nothing. A rotation is the one that takes nothing away, and so the one the check itself reads. The cost is stated rather than hidden: a texel draws the instance nearest it in angle and the one either side of it, so the source is evaluated three times per ring.

  • CircleMap, the polar resampler: a source read round a disc centred in the repeat, its u the angle and its v the normalised radius, with turns and rings copies across the two and a twist that carries the source round by so many turns per unit of radius. It is windowed rather than a plain polar frame, and that is the correction rather than a limitation: a polar frame over the whole repeat answers a different angle at u = 0 than at u = 1, so it never meets itself at the seam and cannot tile at all. A disc no wider than the repeat with a fill outside it does, which is the rule Shape has always lived under, so the radius stops at a half, the coordinate is wrapped before the disc is measured, and everything past either radius answers outside. The source must itself tile, the way a Triplanar's must, because the angular axis wraps into the source's u. The centre pinches — every angle meets there — so a source whose v = 0 row is not constant shows a knot at the middle, and the node's own documentation says to read a gradient there and put the noise further out.

  • A standard library, ashlar_material::stdlib: the first graphs this crate ships rather than reads. Five weathering compounds — weathering:edge_wear, dirt_dust, rust, peeling_paint and moisture — and four pattern recipes — patterns:radial_gradient, angular_gradient, wood and cracks — answered together by stdlib::graphs() and merged into a game's own library by the new MaterialGraphLibrary::extend, which is two passes so that a refused merge leaves the library untouched and a collision errors at graphs[<key>]. None of them is a node, and that is the point: a wall rained on for thirty years is a dozen primitives wired the way the study's brick wires its own, so what is worth shipping is the wiring, and shipping it as a graph means the crate's own tests hold it to the same tiling, lattice, lowering and WGSL rules as anything an author writes. A compound takes the substrate channels it intends to change as GraphInputs whose defaults bake a plausible picture with nothing wired in, exposes the author's decisions as parameters that fold, writes back the channels it names and no others — dirt_dust binds no height, because a film of dust is microns on a wall whose relief is centimetres, and reading one off it is refused rather than silently passed through — and exports the mask it decided with as an extra, so a caller putting its own dirt in the same hollows layers on that decision instead of building a second one that agrees to within a texel. Three of them take a place rather than an amount, which is the honest shape of what they model: rust is measured out of a seed_mask by a Distance plane, a peel's lip and undercut are measured off the edge of a peel_mask, and a waterline is a contour on the caller's own height. Their defaults are the terminal state — a plate rusted through rather than a wall with a patch on it — because a constant input cannot draw a boundary, and the boundary is exactly what the caller wires. No compound reads a runtime input: a bake refuses WorldMask by path, so world-space bias is a plain float input the caller drives from WorldMask::up(), a live parameter or a constant, and moisture is built for the live case — pointwise throughout, no buffered filter, so a live wet freezes nothing and costs forty-four fragment instructions. Two conventions run through all nine: amount is always the same contour, clamp((field - (1 - amount)) / softness, 0, 1), over whatever field that compound decided with; and every compound that reads a neighbourhood of the height takes a relief parameter, because a curvature and an occlusion read their input as a length in the units of UV while a height input is a 0..=1 field, which is also what makes a compound wear a shallow surface less than a deep one. What could not be exposed was not exposed: a filter radius, a noise seed, an octave count, a generator's period and a Distance's range are node fields, and a parameter reaches only a port, so those are documented constants and a boundary that had to move is read as a contour off a plane rasterised once out to a constant reach — which makes that constant the parameter's ceiling, since past it the plane saturates. MaterialGraphBuilder::layer(id, subgraph, outputs) is the sugar that goes with them: a compound answers a surface rather than a field, so reading it costs one Subgraph node per output, and those nodes' bindings have to agree exactly — an instance is inferred per binding signature, so one stray binding on one of four near-identical nodes makes a second instance and a second inlined copy of its arithmetic. layer writes them from one node, under <id>.<port>. The whole library joins the two shipped-graph loops in tests/wgsl.rs and the every-parameter-live case in ashlar-bevy/tests/conformance.rs, both of which now run over the study and the standard library; tests/stdlib.rs runs its generic claims over every key stdlib::graphs() answers rather than over a list, so a compound added later is held to the same bar without anybody remembering to add it.

  • Five study surfaces assembled out of the standard library, which is the claim the whole expansion was built to test: a graph in a library stops being a thing you copy and becomes a thing you wire into. study:rusted-steel is the study's own metal instanced through one layer, its height bedded out of the plate's 0.14 of relief into a band in the middle of the unit, and then three compounds in the order the damage happened — weathering:edge_wear over the rolled crests, weathering:rust measured out of a Perlin cut saying where the water stood, weathering:dirt_dust in whatever hollow was left. It contains one noise anybody wrote for it and every other decision in it is made by a compound the crate ships. study:weathered-paint is the other half of the argument: the painted metal's panel through weathering:peeling_paint, and then the same rust compound seeded by that peel's own bare extra, which is the composition point the extras exist for — the film lifts, the steel under it is bare, and the rust starts exactly at the edge the peel drew rather than at a second mask that agrees to within a texel. Its metalness is lifted to one by the caller across the bare patches and taken back down by the rust's own mask, because neither compound can see the other. The two drawn surfaces have no noise deciding anything at all: study:drain-cover is a cast iron cover in its frame, drawn from signed distances — a Gear boss of twelve teeth whose tips stand exactly at the inner ring's inner edge so the smooth union fillets twelve webs, hollow rings and a rounded frame through SdfCombine, thirty-two slots laid by a CircleMap of sixteen turns and two rings, six hexagonal bolt heads by a CircleSplatter, and grit in the corners by dirt_dust — and study:canvas is thirty-two threads each way through a plain Weave, tinted per thread off its Id hash, with one anisotropic Perlin read twice, the second read turned a quarter turn, which is what makes one noise serve both the warp fibre and the weft. Counts rather than densities, in both. study:brick-weathered is the fifth and the only compiled one: the study brick through dirt_dust biased by WorldMask::up() and moisture with a live wet, which is the world-bias idiom the standard library is written around — a compound may never read a runtime input, so it takes a plain float and the caller decides what that float means. A bake refuses it by the path of the node that asked for a world normal; compiled at 1024 it is fifty-one fragment instructions and ten texture reads over five bound images out of nineteen planes, with nothing frozen, because everything above the waterline is arithmetic over the coordinate and went into the textures. It binds the brick's height rather than the flooded one the moisture offers, and that is the one decision in the five that cost measuring: taking the flooded height costs four finite-difference taps, eight bound images instead of five, and widens: false — level-zero roughness at every distance, on a wall whose wet joints sit at 0.02 — to flatten a film in an eleven-millimetre bed joint that is not a puddle.

  • Twenty more maps, and the five that already shipped unchanged byte for byte. The four surfaces that bake are written by the showcase's asset writer like the rest, and assets/ goes from 24 605 508 bytes in 30 files to 44 465 773 in 50, with GOLDEN_256 and GOLDEN_1024 re-recorded at thirty-six entries each. That the old five did not move was the test this step existed to run: phase 2 re-factored Shape so that its mask is an SdfMask over its distance, and twenty KTX2 files coming back container and compressed level identical is what says the re-factor kept the bit-for-bit promise it made. What did move is the checked-in graph library, because a Subgraph resolves by key at load and the writer therefore writes every compound a shipped surface reaches beside it: assets/buildings/recipe-study/materials.graphs.ron went from eight graphs and 68 320 bytes to twenty-two and 151 000. Two tests that read that file and then merged stdlib::graphs() into it had to stop merging, and ashlar-material/tests/wgsl.rs now makes the stronger claim instead — every compound on disk must be the graph ashlar_material::stdlib builds, node for node — which fails loudly on a compound edited in Rust and never written out, where the merge only ever failed on a collision. The same file's compute-stage count went from twenty-five stages to seventy-two: twenty graphs that bake with one outputs stage each, and fifty-two buffered filters between them, of which twenty-one are in the two layered surfaces alone. One compression number is worth keeping: drain-cover/height stores at 8.1 % of its uncompressed chain where the three noise-derived heights store at 90 % to 93 %. The rule was never about bit depth, it was about entropy, and a height that takes five values with soft edges between them is the one thing in this repository zstd can eat.

  • The expansion's capture, in docs/research/material-synthesis (removed; in git history) — the instruction count of every new node and every compound, the four bakes with their periods, lattices and height ranges, the compiled cost report verbatim beside the flooded-height comparison, the asset growth per map, and the gallery at 51 shots in 36.43 s against 39 in 19.5 s. One surface did not read on a lit wall, and the review sent it back rather than letting the capture record it: study:rusted-steel was rusty everywhere except in its colour — 72 % of the plate between 0.25 and 0.75 metalness and an albedo that never passed 0.29 saturation, because its seed band was a soft ramp near a half rather than a set of patches, and forty per cent of a dark oxide mixed uniformly into a light steel grey is a mid grey-brown. Four numbers in the showcase fixed it and none of them is in weathering:rust: the Levels band on the seed Perlin became a cut, the spread narrowed from 0.05 to 0.03 so the halo is narrower than the gaps between seeds, the seed lattice turned from four by sixteen to eight by four so a patch runs down the plate, and the plate hands the compound its own two oxide colours, because that mask peaks near two thirds and a third of a light steel left in every rusted texel is a grey. 50 % of the tile is now at metalness exactly one — the plate study:metal bakes, bit for bit — against 8.5 % before, and the saturation ninetieth percentile goes from 0.16 to 0.26; four golden hashes at each resolution and four KTX2 files were re-recorded with it. Two things that did not read were left as follow-ups with their numbers. The material sheet is a weaker instrument for these four than for the three it was built for, and now for a reason it can be held to: a canvas thread is four tenths of a pixel in the largest column the sheet ever draws, and a drain cover is one object rather than a field, so 2.5 of them fit the near specimen. And the weathered brick at rest is the plain brick, by construction — its wet defaults to zero and its dirt is gated on a world normal that is approximately zero on a vertical wall — so the shot had to be made twice, and moving that one slider moves 4.02 % of the frame at a mean of 12.8 codes, almost all of it on the joint lines the eye was already reading. The plan's own follow-up list carries those, one the review found — a compound that binds no metalness or no occlusion is read silently, because only height and emissive are optional on a PbrOutput and the other four always answer their defaults — and one the retune ran into: weathering:rust's mask cannot reach one, since the Perlin that decides how thick the scale is doubles as the one that decides whether there is any.

  • Strand layers: geometry scattered from the same graph fields the surface is made of. MaterialGraph::strands holds named StrandLayers beside the PbrOutput; ashlar_material::strands scatters one into a deterministic StrandSet over a repeat, plants it on plain triangles and builds ribbons or three-sided tubes; the buffered StrandRelief node splats the same set from directly above into planes the PBR half reads, so the texture the camera sees once the geometry has faded is the same strands. Direction, Combine2 and VoronoiOutput::Offset are the three nodes a strand field needed and a texel did not. ashlar::StrandSettings on a MaterialDefinition asks for layers, level-of-detail distances, a density cut and shadow casting, and ashlar_bevy::strands answers one mesh per UV repeat per level, thinned by a rank prefix and widened by 1/sqrt(keep) under a VisibilityRange. See ADR 0004.

  • Cards: the level past the last band of real strands, in ashlar_bevy::cards. Where a definition names a StrandSettings::card_metres, every clump of a layer becomes two crossed quads at the mean of its roots, sized from its own strands and oriented on the surface frame, wearing a four-variant atlas of the same set drawn side on by ashlar_material::strands::cards. The atlas is cached in BakeCache under the scatter's key plus the numbers it was drawn at, its mip chain is rescaled level by level to keep the alpha coverage level zero had — a box filter alone thins a cutout until a field of cards evaporates — and it is bound as AlphaMode::Mask, never blended, so depth, shadows and the prepass still work. The band starts on the margin the last level of detail ends on, so the dither crossfade is continuous, and past card_metres there is still nothing but the relief. Cards do not sway: they bind a normal map, the wind extension's two vertex stages declare no tangent to read one in, and at ten metres a strand's tip travel is a fraction of a pixel. benchmark:grass draws them from 7 to 14 m.

  • Wind: ashlar_bevy::wind, a MaterialExtension over StandardMaterial whose vertex stage bends a strand along a StrandWind resource by strength * v² * stiffness * gust, reading the [phase, v, stiffness] attribute every strand mesh already carried. The prepass and the shadow pass get the same displacement from the same function, or a blade's shadow stays where the blade was. StrandWind::still() is zero strength and renders byte-identically to a plain StandardMaterial, which is what the reference gallery sets.

  • benchmark:moss-carpet: a second strand material, to show the vocabulary is not grass-shaped. One StrandProfile::Fibre layer of upright shoots grown off weathering:moss's own coverage mask, with no flow field under it.

  • --reference-key on the preview gallery, beside --reference-ambient: a building is lit to read across a facade and a material at arm's length is not, and under a key meant for a facade an 18 mm pile is a pale floor with the shadows blown off it. The material-scale captures of the grass are taken at the swatch studio's own 6500 lux.

  • Anisotropic filtering on every surface's maps, at eight taps (ashlar_bevy::ANISOTROPY), for both a Files surface and a runtime bake. A trilinear sampler picks its mip off the longer axis of the footprint, so a wall seen at an angle loses its detail in the view that shows most of it.

  • The join between a meshed building and its materials, in the library rather than in every renderer. MeshedBuilding::pieces() walks instances and their elements and yields each drawable Piece with the Binding in force on it — the cut slot for a cut batch, the instance's override where it has one — and ashlar_bevy::dressed_pieces() adds the definition that binding names. Piece::shared_key() names what pieces share the work derived from geometry and material together, which is what a strand layer is grown once per. The preview's three hand-written loops are now that walk.

  • MaterialGraph::tile_metres, the repeat a graph was drawn for. Relief is metres per repeat and a strand's length is absolute metres, so a graph was always authored against a tiling and had nowhere to say so. Advisory, skipped when unset, and part of no bake or shader key. ashlar_bevy::tiling compares it with the definition's at read: a repeat that changed shape warns, a scaled one under a strand layer warns, and a uniform scale is a debug line, because a specimen sheet divides the tiling on purpose.

  • MaterialDefinition::tile_scale, the opt-out that closes the gap the entry above left open: a uniform scale used to be a debug line whatever caused it, so an honest tile_metres typo (2.4 for 1.72) read exactly like the sheet's own deliberate ninth. Now a definition declares the factor it scales its graph's repeat by on purpose, ashlar_bevy::scale_verdict reads it beside the scale tiling found, and only a declared, matching factor stays quiet; a uniform scale with no declaration, or one that disagrees, is a warn! naming the fix. Advisory like tile_metres itself and skipped when unset. The material sheet, the benchmark and the corporate kit's whole-repeats-per-storey walls (docs/guide/recipes.md, "Whole repeats across a stacked or arrayed module") all declare it now, and no shipped library warns.

  • ashlar-strands, and with it a lawn a game can ship. ADR 0004 said "generating a set is milliseconds, so there is no strand file format and no serialized StrandSet"; the milliseconds were a measurement of a doctest lawn — the shipped one is 528 ms on eight cores — and the premise under them, that a game holds the graph, is the one the 2026-09-20 decision reverses. The new crate holds everything after the scatter: Strand, StrandSet, place, mesh, the rank-cut levels and the card atlas, on glam, serde and thiserror. ashlar-material keeps scatter and the StrandRelief splat and re-exports every name from ashlar_material::strands, so no path a caller wrote has moved.

  • ashlar_strands::file, the baked strand set. One file per material definition holding every layer it grows, little-endian, versioned, with a directory entry per layer and a field-major payload — one field is one run of bytes, and the values in it are alike. Lossless: read(write(set)) is set, which is what lets a test assert that a baked lawn and a scattered one mesh to identical triangles. Stored rather than compressed, because zstd takes 15 % off the grass and 18 % off the moss and that does not pay for a decoder in every game that draws a lawn; the header records a scheme anyway. The reader validates every offset, length, name and count against the file, and every strand for a non-finite number, a rank outside 0..=1 and a negative size — the three whose failure is not a bad blade but a broken chunk.

  • StrandSettings::baked_set, the content key that names it, the way Surface::Files names its maps. Serde-defaulted and skipped when absent, so every shipped library is byte for byte the library it was, validated in check, and preflighted by ashlar_bevy::read_library before a window exists — the identifier and version by hand in a featureless build, and the directory and the definition's own layer names under strands, which is the stale case. MaterialDefinition::check no longer insists a definition growing strands name a graph: a baked_set is the other answer, and a game's lawn may sit on a Plain surface.

  • ashlar-bevy's strands is a game feature, and strand-scatter is the tool feature beside it. cargo tree -e normal --features strands has no ashlar-material in it. StrandSets is the value the two halves meet on: read builds one from a file and scatter_sets from a graph, and create_strands takes one and cannot tell which. Card atlases move out of BakeCache into a CardCache of their own, keyed by where the set came from rather than by a graph. examples/lawn.rs is thirty lines between a file and a lawn, and just draw grows one on a GPU and looks at the pixels.

  • The benchmark writes its lawns. grass and moss-carpet ship set.strands beside their four KTX2 maps — 24.09 MiB over three layers and 10.98 MiB over one — and a golden beside committed_preview_maps_match_fresh_bakes holds them against a fresh scatter. No baked texel moved.

  • Merged geometry, interiors and damage (ADR 0005). Every face of a TriangleMesh names its source: sources: Vec<FaceSource> replaces cut_faces: Vec<bool>, with FaceOrigin::{Body, Cutter, Damage}, and is_cut(face) and cut_faces() ask the old question. A cutter may name its own slot with Geometry::cut_material, Geometry::cutters() defines the numbering, and Element::slot_for resolves a face. A recipe may be merged: MergeGroup, Instance::group, Element::standalone, Building::merge_groups, and GeometryMesher::mesh_group with PlacedGeometry and GroupMesh, whose default refuses. MeshedBuilding::groups holds a MergedGroup of GroupBatches per group, in building space, with box UVs in the building frame and normals from the new kernel-free ashlar::shade_normals. Interiors are classified and never culled: Side and Element::interior, a storey on every Piece, Geometry::portal with GeometryMesher::portals, PortalShape, Portal and MeshedBuilding::portals, where a portal is measured against the core its cutter removes, and Room with Building::room_at. Damage is data: Damage, DamageLog, Damage::blast, Damage::collapsing, Shape::Hull, Geometry::hull, Geometry::ball and the kernel-free Geometry::bounds; mesh_building_with_damage, which is GroupSolids::build replaying each record through the same apply play uses, and GroupSolids, GroupHit, Debris and DebrisKind, held to the same volume, area and sources by a test. A collapsing hit drops a piece that reaches neither the ground nor anything supported, across every group, so a piece held up by another group falls when that group goes; a piece already loose in the undamaged building is left alone unless a hit changed it, and Debris carries the piece's bounds for a renderer to decide what falls with it. GeometryMesher::mesh_group takes PlacedCut, so a cut says whether what it leaves unattached falls; TriangleCollider, MeshedBuilding::mesh_colliders, groups_touched, standalone_touched, replace_group and record. ashlar_bevy::batch_drawables draws what a hit produced, and the crate still links no kernel. The showcase gains corporate::merged_by_storey and the interior house, and the preview gains the corporate-merged, corporate-block-merged and interior scenes, storey and side culling on PageUp, PageDown and I, and shift-click or --blast x,y,z to blast. cargo run -p ashlar-manifold --example damage_server is a server that links no Bevy.

  • A default material library, and one way to ship it. ashlar-surface sits under ashlar and ashlar-material and owns the definition vocabulary, so ashlar_material::stdlib ships finished materials and not only graphs: twenty-eight library:* surfaces built from layouts:*, substances:* and the weathering compounds, with a definition for each from stdlib::materials() baked at 512 when it is registered. The brick, cobblestone, grass and moss carpet moved in as library:*, and every other textured surface was reviewed against an ambientCG scan to their standard. ashlar_material::export is the content step for a build that wants files: it answers the texture sets, the strand-set bytes and a library whose surfaces name them, and does no IO. ashlar_bevy::check_library and check_library_with_graphs preflight an in-memory library the way read and read_library_with_graphs preflight a file, and ashlar_bevy::gpu::dispatchable sends a graph the device cannot split to the CPU bake. The preview takes a scene's libraries as Rust values through Scene::materials and Scene::graphs, and gains a sheet-<name> and a detail-<name> scene for every baked library material. just materials exports the showcase's library into the ignored assets/materials/. See the 2026-09-24 amendment to ADR 0003.

Changed since the extraction

  • Groups are drawn about the bounds of their first level (2026-09-28). ashlar-bevy drew every group about the centre of its level-zero bounds, so a group that first appeared at a later level, which a concatenated level's cells always do, was drawn about the building's origin and switched level by its distance to that origin. A group is now drawn about the centre of its bounds at the first level it appears in with any, and every later level that names it shares that pivot. A piece whose band ends where the next level draws the same elements from another origin (placed pieces handing over to a concatenated level's cells, cells of one size to another) has its band's end pushed out by the distance to the farthest such origin, so no piece is left undrawn across the handoff; the pieces nearest the camera are drawn twice for that stretch instead. Between placed levels and merge groups the origins coincide and nothing moves. LodPolicy has a new public field, so a struct literal that names every field without .. needs cell: None, and LodPolicy::is_identity is false for a policy with a cell, because its level is drawn differently even when the recipe is untouched.

  • A reload takes disabled pieces with it (2026-09-29). ashlar-bevy despawned a changed building's pieces through a query that did not see entities marked Disabled, so a game that takes pieces out of play that way (a city past its draw distance) kept the old pieces under the root beside the fresh set, and drew the building twice when it brought them back. The query allows disabled entities now.

  • Names a consumer reads without a glossary (2026-09-25). "Baked" now only ever means the content step's files, and the rest was renamed to match:

    • Surface::Baked is Surface::Graph and Surface::Textures is Surface::Files, in Rust and in RON (Graph((..)), Files(..));
    • ashlar_bevy::procedural is runtime_bake, after its feature; create_baked is create_graph_material, shader::create_shader is create_shader_material, and BakedImages/BakedTextures/baked_images are GraphImages/GraphTextures/graph_images;
    • ashlar_bevy::read, check and create are read_library, check_library and create_material, and the *_with_graphs pair follows; surfaces is dressed_pieces (SurfacePiece is DressedPiece), MeshedBuilding::surfaces is pieces, and SpawnContext is UploadContext;
    • ashlar_bevy::prelude holds everything the game path names; BuildingSpawned is AshlarBuildingSpawned and AshlarLod is AshlarCrossfade;
    • stdlib::stdlib() is stdlib::graphs();
    • Content::materials, materials_on, building and write are write_materials, write_materials_on, write_building and write_file, and flatten_bindings is flatten_overrides.
  • Piece says what it is a piece of. instance, part, element and is_cut moved into PieceOrigin::Element, beside PieceOrigin::Group, and a piece gained storey and side. A merged batch has no single instance to put in those fields. Piece::mesh_key() is what uploaded geometry is shared by, shared_key() is that key and the binding, and label() names an entity. ashlar_bevy::drawables keys by mesh_key, which also stops two cut batches of one element colliding under the old (part, element, is_cut) key.

  • The kernel is built with the system compiler in this workspace. .cargo/config.toml forces CC and CXX, because an activated Anaconda environment exports its own and that GCC builds a kernel whose static initialisers never run under rust-lld: Manifold::sphere and refine died on SIGFPE. crates/ashlar-manifold/tests/kernel_init.rs is the canary.

  • This workspace builds x86-64 with -C target-feature=+sse4.1,+fma, from a new .cargo/config.toml. A bake is about 21% faster on the SOI cobblestone and 16% on the brick, and no baked byte moves: a hardware FMA is the number fmaf returns and roundss is the floor floorf returns, so the goldens, the shipped-resolution cases, just conformance and just draw all pass unaltered. It raises the CPU this workspace's binaries need to roughly Haswell (2013) or Excavator (2015) — older hardware gets SIGILL, not a slow run. The flags are scoped to cfg(target_arch = "x86_64"), so an ARM build is unaffected, and cargo reads them only for builds in this workspace: a game depending on ashlar-material compiles it at its own baseline and bakes the same bytes more slowly. RUSTFLAGS in the environment replaces them rather than adding to them, which is why the CI workflow now repeats them beside its -D warnings.

  • interp::floor_cell uses the hardware floor again, reverting half of 18c88b5: under +sse4.1 the bit-twiddled integer floor is slower than the roundss it was written to avoid, by 3.6% of the SOI bake and 8.0% of the brick. interp::cell keeps its integer reduction, because no target feature turns rem_euclid into an instruction. interp::floor is gone with the branch it wrapped, and so is the test that pinned the two floors against each other, which had become x.floor() == x.floor().

  • MaterialDefinition::surface replaces the three optional texture keys with a Surface enum: Plain, Files (base colour, normal, ORM, height and emissive, with the Bake a content step recorded), Graph(Bake) and Shader, over the new plain-data ParamValue. The enum denies unknown fields, so an unknown field inside a surface is a parse error naming it and a map may be omitted rather than written None. This is a breaking change to the material RON form as much as to the Rust type: a library written against the old base_color_texture, normal_texture and orm_texture keys no longer parses, and there is no compatibility form. The study's materials.ron is regenerated by the same writer that writes its textures, and the corporate library was converted by hand. See ADR 0003.

  • ashlar_bevy::read_library takes a graph library beside the material library, as the design asks, and read_graphs reads and validates one from RON. A Surface::Graph is preflighted by validating and lowering its graph, so an unknown graph key, a parameter the graph does not declare or a node the backend cannot lower is a startup error naming the material — the way a missing PNG already was. A Surface::Shader is preflighted the same way, by lowering it, partitioning it and emitting both stages, so everything a compiled material can be refused for except the WGSL itself is refused before the window opens. graph_params converts the parameter values a Bake carries into the graph crate's, which is the one place the two ParamValue enums meet. The preview passes a scene's graph library through Scene::graph_library or --graphs.

  • reload::rebake re-makes compiled surfaces too, with the shader registry cleared beside the bake cache and for the same reason: a ShaderKey names a graph and says nothing about what its nodes do, so after an edit every entry would answer the shader it had before. An edit to a compiled graph used to cost a restart.

  • bake::encode_bound quantises the way the five named maps do. It dithered every format and renormalised a normal chain before filtering it; the bake dithers only the sRGB colour — dither in a direction field or a roughness is noise in data nobody looks at directly — and filters the un-normalised normal chain, renormalising what each level ships. Both are now the same on the two paths, which is what makes a bound texture and the map it replaces byte for byte identical. Before: one code on an eighth of a normal map's texels.

  • The preview's camera framing is one framing function and two named standoff constants instead of the same expression written out three times with two different factors.

  • The showcase kits declare collision on their elements instead of carrying hand-typed collider tables, and the study kit's walls attach bay to bay instead of multiplying a bay index. ashlar_showcase::outpost_colliders and ashlar_showcase::corporate::colliders are gone.

  • A compiled graph's bound textures are cached by the plane behind each image rather than by the ShaderKey of the graph that asked for it, so two compiled graphs over one wall bake that wall once. planes::PlaneKey is the new identity — the canonical encoding of a plane's whole closure after folding, each plan written once with its filter chain, its resolution and its value type, which is what keeps the key the sum of the graph above the filter rather than something that doubles with every level of sharing — and runtime_bake::PlaneImageKey is an image's: that key per lane, and the format. Both are exact keys rather than digests, for the reason BakeKey already gives, and planes::plane_keys answers them without rasterising anything. Where a graph shares only some of its images, planes::rasterise_wanted, planes::plane_closure and gpu::GpuBaker::planes_wanted rasterise the planes it is missing and the planes those read, and skip the rest on either backend. study:concrete-wet after study:concrete-cut-aware costs 194 ms where it cost 1391, just references renders the gallery in 19.5 s instead of 23.0 s, and not a byte of any shipped map moves. BakeCache::insert_static now takes handles rather than images, since a compiled graph may bind nothing new, and BakeCache::rasterised and bound_images are what a caller counts the saving with.

  • A GPU bake uploads each bound image once, rather than once per pass. gpu::GpuBaker::rasterise kept one cache of uploaded textures for its plane dispatches and a second for its outputs dispatch, so the outputs pass packed and wrote every plane the plane dispatches had just put on the device a second time; there is now one cache for the whole bake. Packing a megatexel plane into Rgba32Float bytes and writing it is about 37 ms at 1024 squared in a debug build, against about 60 ms for the dispatch that reads it, so at 1024 study:painted-metal goes from nine uploads and 1.19 s to five and 1.08 s, study:plaster from six and 1.08 s to four and 1.01 s, and study:brick from six and 1.35 s to five and 1.30 s. The bytes do not move: the same GPU writer answers the same twenty files. gpu::GpuBaker::uploads is a counter of images written to the device, and the_gpu_bake_is_the_cpu_bake_within_the_conformance_budget holds every study graph to the uploads a cache that shares every finished image makes.

  • A GPU bake of a graph whose planes are packed four to an image answers the right texels. gpu::GpuBaker cached an uploaded texture as soon as the dispatch binding it had every lane that dispatch reads, which for a packed image is not every lane the image holds: the kernel for an early plane samples one lane of four, and the half-written texture it uploaded was then handed, from the cache, to every later dispatch and to the outputs pass that reads all four. Completeness and the upload are now over the image's own lanes, out of the partition. Nothing shipped was wrong — the study's maps are baked on the CPU — but a game running a runtime GPU bake of a graph past MAX_BOUND_TEXTURES bindings got three zeroed channels: at 512 texels study:rusted-steel's roughness was 0.61 out over 98 % of the tile and study:weathered-paint's height over all of it, against a worst difference of 1.2e-5 now. just conformance is the only thing that says so, and it did not say it before phase 4 because no study graph packed.

  • A compiled material's roughness widens with distance, as a baked one's always has. mips::encode_mips derives the Toksvig term from the normal plane's own coherence and can only do it because a bake holds both planes at once; a partition binds separate images, so a compiled wall kept its close-up roughness at every distance and went glassy where its baked twin stayed matt. The coherence now rides in the alpha channel of the bound normal map's chain — bake::bound_mips writes |n_avg| there, one per texel per level, and level 0 is one — and the emitted fragment applies the bake's own r' = sqrt(r^2 + (1 - a) * k) over mips::TOKSVIG_K after the runtime roughness expression. Six instructions and no extra fetch: the alpha comes off a read the shader already makes, at the same coordinate and so at the same level the hardware chose. It holds whether the roughness is a texture read, a literal or eleven instructions over a live uniform, which is why it is emitted rather than baked into the bound roughness map's own chain — the study's wet concrete has no roughness map to have widened. A material whose normal is itself computed per fragment has no |n_avg| to read and keeps its level-0 roughness; CostReport::widens is false there and the report says so in words. A graph with normal_strength at zero is not that case — a flat normal plane loses no coherence, so the bake's own term is zero at every level and the two agree by widening nothing — and the report stays quiet about it. Measured: the compiled study:concrete at mip 3 of a 1024 bake is the bake's own ORM roughness to within half an eight-bit code, where without the term it is four codes glossier. On the material sheet the compiled specimens of sheet-brick move on 30 445 pixels and by up to 11 codes, almost all of it darker; sheet-concrete's move on 855 and by one, because that wall's relief is shallow enough that its widening is four tenths of a code of roughness at any level against brick's twenty-three. All three sheet captures in docs/research/shader-materials/ are re-shot, the third because a set of three captured on two different days compares nothing. Partition::widening, wgsl::emit_compute_mesh's new level argument and ComputeShader::level are what a harness reads a chain with — a fragment picks its level from derivatives a dispatch does not have, and at level 0 the term is exactly nothing.

  • just conformance runs on one test thread. Every case in that file opens its own adapter through initialize_renderer, so the default thread count starts one renderer per ignored test at once, and about one run in four the NVIDIA driver takes that badly enough to kill the process on a signal before the first case reports. It predates the case added above — it reproduces with that one skipped — but a fifth concurrent adapter is not the direction to push it in. Serially the suite is 25 seconds instead of 16 and has not failed.

  • MeshedBuilding, ElementMesh, ElementCollider and InstanceCollider live in ashlar, beside the GeometryMesher port they are the output of. They named no kernel type, and ashlar-bevy could not see them where they were. ashlar-manifold re-exports them, so no import changes.

  • A compiled graph leaves the deferred prepass alone. Its generated prepass writes no G-buffer, so on a camera with a DeferredPrepass the surface vanished; it now falls back to its plain StandardMaterial there and says so. GpuBakePlugin no longer offers a baker on a device without compute, and warns when it was added before the render plugin rather than silently meaning CPU bakes. GraphShaders::clear takes the Assets<Shader> and frees the shaders nothing else could.

  • A bake is faster again, to the same bytes. MEMO_BUDGET is one gibibyte, because the graphs outgrew 256 MiB and a third of the interpreter's work had become recomputation; interp::cell is integer arithmetic; and the benchmark writes its four KTX2 files on a thread each, which was most of the wait. The benchmark regeneration goes from 4m31 to 2m33.

  • The preview's --write-study-assets is --write-assets, and runs the catalog's content step, which for the showcase is the library export. --write-png and --gpu keep their meaning beside it.

Changed from the original in-game version

  • The three crates were renamed: scrolls-buildings to ashlar, scrolls-building-mesh to ashlar-manifold, scrolls-building-render to ashlar-bevy, and veyra-buildings to ashlar-showcase.
  • The preview is a library plus a binary. Scenes come from a Catalog a downstream game builds over its own content, and --scene and --reference-scenes validate against that catalog rather than a fixed enum.
  • The study texture generator moved from the preview tool into the showcase crate, where the keys and the weathering it encodes actually belong.

Removed

  • The finished plans, progress logs and research notes under docs/design and docs/research (2026-09-26); what was still true moved into the guides, the decisions and the skills, and git keeps the rest. Dead code went with them: the showcase's unused grounding specs, pack-reference, the showcase's own material exporter (the content step is the one way to write materials), the preview's --write-assets, --write-png and --gpu, unused dev-dependencies, and ashlar_bevy::{Definitions, Graphs}, which moved into the preview.

  • The reference gallery's pixel baseline comparison, with its --reference-baseline flag. Coincident-edge draw ordering and driver revisions moved pixels for reasons unrelated to the geometry. The gallery is for visual inspection.

  • The game-only site-fitting example and its dependencies on the game's worldgen, registry and core crates.

  • Every baked file in the repository. The 550 MB of benchmark maps, renders and strand sets under assets/materials/benchmark, the study's maps under assets/buildings/recipe-study, and the material and graph libraries written as RON beside them, including the hand-edited corporate palette. The Rust graphs are the source and a bake happens when it is needed.

  • The nine study:* graphs, which the library replaced, the benchmark: key shim with its module and example, the study's asset writer, and the material-library and sync-materials examples, which export-materials replaces. The showcase's four compiled surfaces are showcase:* now.

  • What nothing used any more (2026-09-25): ashlar_bevy::Definitions and Graphs, which only the preview read and which now live there; ashlar-manifold's re-export of ashlar's meshed-building types, which are imported from ashlar; the two stdlib::*_HEIGHT_SCALE_METRES aliases; the showcase's grounding specs, which no scene fitted to terrain, and its pack-reference example, whose KTX2 output nothing read; and ashlar_preview::Source with the two catalog getters that returned it.

  • The finished plans, progress logs, review notes and research captures under docs/design and docs/research. What stayed true is in the guides, the ADRs, the skills and the rustdoc; the rest is in git history.