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
Geometryholds aShapeandPose. 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.Elementgives geometry a local identity, a semantic material slot, a UV mode and an optional collision proxy.Partcontains named elements and optional named attachment frames (Socket).PartBuilder::buildchecks local validity. Parts remain editable data.Instancereferences 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 aBinding: 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.BuildingRecipecarries aversion. 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::buildandBuildingRecipe::buildvalidate the whole assembly and return an immutableBuilding. 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 andSide. A liner solid over the inner face of a wall unions in with no seam, and its faces are their ownInteriorbatch even when the wall and the liner wear the same plaster.- A group's
storeyreaches everyPiece, group and standalone alike. Geometry::portal(id)on a cutter publishes the opening.MeshedBuilding::portals()answers onePortalper placement, in building space: a rectangle at the middle of the wall's thickness, clipped to the wall, with its normal and its storey.Roomis a declared box with a pose, a group and the portal ids that open onto it.Building::room_at(point)andRoom::containsneed no kernel, so a server can ask which room a player is in withashlaralone.
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:
groupis the re-meshedMergedGroup. Feed it toMeshedBuilding::replace_group, andrecordthe damage once.debrisis oneDebrisper 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 atile_metrestypo — 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: truedraws and lights both faces, anddiffuse_transmissionin0..=1lets 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)),
),
),
Plainis constants only: glass, lights and trim.Filesnames files on disk.base_coloris sRGB and multiplies thebase_colorconstant;normalis a linear tangent-space map, read in the bake's convention whenbaked_fromis present and as OpenGL +Y when it is not, so a painted map leavesbaked_fromout and an exported one keeps it;ormis linear packed R=occlusion, G=roughness, B=metallic;heightis a linear relief field; andemissiveis linear HDR that multiplies theemissiveconstant, 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_fromrecords 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, andashlar-bevyenables 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 withashlar-bevy's default features refuses it. A content step exports these definitions asFilesfiles; 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 areshowcase:light, over a graph whose emissive is a sine ofTime, andshowcase:concrete-wet, which instances the graph the walls are baked from and gates a livewetnessby 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
GraphandShader. APlainorFilessurface has nothing to override — aFilessurface'sbaked_fromis provenance rather than a request, and the files it names are what load — andMaterialLibrary::check_forrefuses 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_librarychecks 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
Graphoverride is a differentBake, so it is a differentBakeKeyand 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
Shaderoverride 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
Shaderoverride 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.
Reference gallery
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_boundkeys 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.

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.
| Key | Inputs | Channels it writes | Extra | Ops alone | Ops live |
|---|---|---|---|---|---|
weathering:edge_wear | height, base_color, roughness, metallic, substrate_color, substrate_roughness, substrate_metallic, bias | base colour, roughness, metalness, height | mask | 182 | 74 |
weathering:dirt_dust | height, base_color, roughness, bias | base colour, roughness | cavity_mask | 249 | 18 |
weathering:rust | height, base_color, roughness, metallic, seed_mask | base colour, roughness, metalness, height | rust_mask | 398 | 123 |
weathering:peeling_paint | height, paint_color, paint_roughness, substrate_color, substrate_roughness, peel_mask | base colour, roughness, occlusion, height | bare | 28 | 66 |
weathering:moisture | height, base_color, roughness, metallic, wet | base colour, roughness, metalness, height | wet_mask | 6 | 44 |
weathering:streaks | base_color, roughness, source, bias | base colour, roughness | mask | 408 | 25 |
patterns:radial_gradient | — | base colour, roughness, height | gradient | 31 | 47 |
patterns:angular_gradient | — | base colour, roughness | gradient | 33 | 10 |
patterns:wood | — | base colour, roughness, height | rings | 286 | 150 |
patterns:cracks | mask | base colour, roughness, height | crack_mask | 354 | 68 |
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-bevywith its default features, which are none, and pulls neitherashlar-manifoldnorashlar-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:
| Path | What it is |
|---|---|
materials/<namespace>/<name>/base.ktx2, normal.ktx2, orm.ktx2, height.ktx2 | One material's maps, each with its full mip chain, zstd-supercompressed |
materials/<namespace>/<name>/set.strands | The strand set of a material that grows grass or moss |
materials/library.materials.ron | The material library naming those files, one definition per key |
materials/graphs.ron | The graphs they were baked from, for tools; the game never reads it |
buildings/<name>.ashlar | A 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:
| Field | What it does |
|---|---|
until | Metres from the camera where the level stops drawing; None on the last level |
min_feature | Elements, union members and openings thinner than this are left out |
segment_scale | Cylinders and revolves keep this share of their segments, never fewer than eight |
drop_interior | Interior elements go: liners, floors seen from inside, furniture |
cell | None 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):
| Scene | Level 0 | Level 1 | Level 2 |
|---|---|---|---|
| Metropolis, 4 x 4 blocks | 3,928,156 | 1,161,406 | 760,902 |
| City block | 402,248 | 119,424 | 80,654 |
| Sci-fi colony | 119,182 | 48,130 | 19,292 |
| Corporate block | 41,132 | 33,696 | 14,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
shipdoes. - "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::GraphorSurface::Shaderdefinitions 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.

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.
RGBis the shading normal in the prop's own frame with any normal map applied, andAis 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.
| Grid | Atlas | Both maps, uncompressed |
|---|---|---|
| 8 x 8 of 128 | 1024 | 11 MB |
| 12 x 12 of 128 | 1536 | 25 MB |
| 12 x 12 of 256 | 3072 | 100 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

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

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

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

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.

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
| Crate | What it is | Half |
|---|---|---|
ashlar | The domain: parts, elements, instances, sockets, slots, rooms, terrain fitting, levels of detail, and the .ashlar baked building file | both |
ashlar-surface | The material vocabulary both halves share: MaterialDefinition, Surface, Binding | both |
ashlar-strands | Strand sets: placing, meshing and reading baked grass, fur and moss | game |
ashlar-bevy | The Bevy adapter: AshlarPlugin for the game, tool features for authoring | game |
ashlar-manifold | The mesher, over the native Manifold kernel | content |
ashlar-material | Material graphs, the default library, the bake and the KTX2 export | content |
ashlar-content | The content step as a library: bake a game's buildings and materials to files | content |
ashlar-impostor | Bakes a prop into a hemi-octahedral impostor atlas for drawing it far away | content |
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 ofOpthe node vocabulary can produce, so a new op with no case fails injust 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:
hash2andhash3must 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:
| what | allowed | worst observed |
|---|---|---|
a colour channel (base_color, emissive) | 1/255 | 6.1e-7 |
| a normal lane | 2/255 | 1.2e-6 |
a raw scalar plane (roughness, metallic, occlusion, height) | 1e-4 | 3.8e-6 |
| the lattice hash | zero | zero |
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.
| Record | What it decides |
|---|---|
| 0001 | A building is plain Rust data, and the kernel that meshes it is a separate crate |
| 0002 | Parts attach by sockets, and collision is derived from the same geometry |
| 0003 | A material is a graph that bakes to textures or compiles to a shader |
| 0004 | Grass, fur and moss are strands scattered from the material's own fields |
| 0005 | Merged buildings, interiors, face provenance, and damage as data |
| 0006 | Buildings bake to files with levels of detail, like materials do |
| 0007 | Props, 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_libraryandcheck_library_with_graphspreflight an in-memory library the wayreadandread_library_with_graphspreflight 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::dispatchablesends 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.
MaterialDefinitiongainsalpha,double_sidedanddiffuse_transmission.alphaisOpaqueorMask { cutoff }; the other two are a switch and a share in0..=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.PbrOutputgains an optionalopacity, 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
opacityby 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::Offsetanswers UV, not cells.Voronoi's distances are in cell units, so the offset should have been too. But nothing in the vocabulary scales aVec2—Mathtakes 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_angletakes turns, asMathOp::SinandAtan2already do. A material graph has nopi, and an author writing0.25for a quarter turn is writing what they meant. - The runtime refusal is a graph walk, not a lowering target. A bake
refuses
WorldPosinside the lowering, because a bake is aTarget. A scatter cannot: it lowers for the shader target so that a graph delivered asSurface::Shadermay still carry world-space inputs in its PBR half, which is a delivery the design keeps in scope. Sostrands::refuse_dynamicwalks 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_RESOLUTIONis 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
clumpas 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, andclump_shareof 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
MassandOcclusion.Coverageis 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.Massis every contribution summed.Occlusionis 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_occlusiondarkens 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:grassnow 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
clumpport 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-strandsis a new crate holding everything after the scatter:Strand,StrandSet,place,mesh, the rank-cut levels and the card atlas, onglam,serdeandthiserror.ashlar-materialdepends on it, keepsscatterand theStrandReliefsplat, and re-exports every name fromashlar_material::strands, so nothing written against this ADR has to move. The split is clean because the ADR made it clean: "ashlar-material::strandsowns the scatter, the placement and the mesh, and it needs onlyglam" was already true of three quarters of the module.ashlar_strands::fileis 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_setnames the file, the waySurface::Filesnames its maps.MaterialDefinition::checkno longer insists that a definition growing strands name a graph: abaked_setis the other answer, and a game's lawn may sit on aPlainsurface.ashlar-bevy'sstrandsis a game feature now, andstrand-scatteris the tool feature beside it.cargo tree -e normal --features strandshas noashlar-materialin 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 thanMIN_SEGMENTS(eight) and never more than were authored.drop_interior: everySide::Interiorelement goes.until(metres): where the level stops drawing;Noneon the last level.
Geometry::simplified, Part::simplified and Building::simplified apply a
policy. A part nothing of which survives is removed, and so are the instances
that placed it; surviving instances keep their resolved poses with their
attachments cleared, and only the slot overrides their parts still use. A
baked level simply has no entry for a vanished part, and
MeshedBuilding::pieces already skips a part it has no meshes for.
ashlar_manifold::bake(building, ladder, mesher) meshes level zero exactly as
mesh_building does, colliders and portals included, and each later level by
meshing the simplified parts. A part whose simplification equals the previous
level's is not meshed again, and a merge group is re-meshed only when one of
its members changed. The mesher evaluates simplified parts exactly as it
evaluates authored ones, so every level is a closed, correctly shaded and
correctly mapped solid, and a level's texture lines up with the next one's.
Collision proxies and portals are level zero's at every level: physics and visibility run at every distance.
LodPolicy::ladder() is a default of three levels: as authored to 60 m; no
interior, nothing under 35 cm and half the segments to 250 m; nothing under
1.5 m and a quarter of the segments beyond. Measured on the showcase
(triangles drawn, every instance counted, per level):
| Scene | Level 0 | Level 1 | Level 2 | Bake | File |
|---|---|---|---|---|---|
| metropolis (4 x 4 blocks) | 3,813,034 | 1,351,694 | 914,074 | 0.25 s | 9.1 MB |
| city block | 306,442 | 108,066 | 71,508 | 0.10 s | 4.8 MB |
| sci-fi colony | 119,182 | 53,718 | 25,088 | 0.26 s | 6.7 MB |
| corporate block | 41,132 | 41,000 | 40,208 | 0.01 s | 0.3 MB |
The corporate kit barely coarsens: it is chamfered boxes with storey-sized openings, and nothing in it is small. The 8 x 8 metropolis bakes in 0.3 s to 12.4 MB. Much of a file is its RON manifest, since the recipe is in it whole. File sizes are as of the 2026-09-25 review fixes, which store a mesh that two levels share once and indent the manifest.
A game draws levels with VisibilityRange
Bevy 0.19's VisibilityRange gives each level a distance band with a
crossfade margin, per entity, so instances of one part at one level still batch.
The featureless ashlar-bevy loads a baked building as an asset and spawns
each level's pieces with their band.
Two details keep the bands honest. The distance is Bevy's, camera to the
piece's origin, and a merged group's mesh is in building space, so it is drawn
about the centre of its bounds at the first level it appears in (level zero
for a declared group; see the amendment of 2026-09-28 for the cells of a
concatenated level); otherwise a group a
block long would switch level by the camera's distance to the building origin.
We did not use VisibilityRange::use_aabb for this, because Bevy documents it
as breaking the crossfade unless every level has the same bounds. And a piece
a coarser level did not change is one entity whose band spans both levels,
not two identical entities crossfading into one another.
The file stores such a piece once as well: the mesh table writes identical records once, and every level that draws them names the same index.
Consequences
- A game links
ashlar,ashlar-surfaceand the featurelessashlar-bevy: no Manifold, no graph engine, no C++ toolchain in its build. - A content step runs
ashlar-manifoldandashlar-materialonce, and writes.ashlarbuildings beside the KTX2 materials. - Damage stays a runtime-kernel feature (ADR 0005): a game that cuts holes in play links the mesher, and bakes its undamaged state all the same.
Amendment, 2026-09-25: thin, not small, and Element::far
The first rule judged an element by its largest side, and the far levels hardly coarsened: a sill three metres long and twenty centimetres deep, a mullion, a strip light, a rail, all survived to any distance, well under a pixel wide. The 4 x 4 metropolis of the dark city drew 1.59 M triangles at its far level against 3.93 M at level zero.
An element or union member is now judged by the middle of its three extents, the narrower side of its broadest face: how wide it looks from where it looks widest. That is the rule cutters already had. A plate a finger thick but four metres square stays; a rail as long stays only as long as it is thick enough.
What glows is the exception, because a lit line reads from afar at a fraction
of a pixel. Element::far keeps an element and every member of it at every
level; only its segments coarsen. The dark city marks its crowns, corner
lines, neon signs, lanterns and lamps far.
Measured the same way as the table above:
| Scene | Level 0 | Level 1 | Level 2 |
|---|---|---|---|
| metropolis (4 x 4, the dark city) | 3,928,156 | 1,161,406 | 760,902 |
| city block (the dark city) | 402,248 | 119,424 | 80,654 |
| sci-fi colony | 119,182 | 48,130 | 19,292 |
| corporate block | 41,132 | 33,696 | 14,116 |
The corporate kit now coarsens too: its fins, mullions, sills and rails go.
Amendment, 2026-09-28: concatenated levels
A level simplifies triangles and leaves the number of pieces almost alone. The
game this library grew in draws a city of 32 baked buildings, and their
coarsest levels, from 250 m to any distance, are still 7,272 placed pieces
between them (the game's own count over its baked files, 2026-09-28, against
15,072 at level zero): the kits' walls, slabs and frames survive
min_feature by design, and each is an entity. The game measured a frame's
CPU cost there scaling with the entities in band, about half a millisecond
per thousand on the render thread. The far level was cheap in triangles and
expensive in everything else.
Decision
LodPolicy gains cell: Option<LodCell>, defaulted to None, so every
ladder written before it reads unchanged and draws placed pieces as it did. A
level whose policy has a cell is baked concatenated: bake meshes it
exactly as before, then MeshedBuilding::concatenated copies every piece of
the level (standalone elements and merge-group batches alike) into building
space by its pose and joins the triangles per square cell of the building's
XZ plane and per binding and side. The level is stored with no parts and one
MergedGroup per occupied cell. LodCell::Side(metres) is a grid from the
building's origin; LodCell::Whole is one cell for the building, which makes
a whole city a few tens of entities: a skyline file.
It is not a merge group (ADR 0005). A merge group is a boolean union through
the kernel, declared in the recipe, and exists so damage has a unit to
re-mesh. Concatenation copies vertices, needs no kernel, and belongs to a level
of detail. It reuses the merge group's output types because they already say
what a concatenated batch is: building-space triangles sharing a binding and a
side, drawn about the centre of their bounds. So the file format and its
version are unchanged, and a reader from before this amendment reads a
concatenated level as a level of groups without an error. It does not draw it
well: an ashlar-bevy from before this amendment took group pivots from level
zero alone, where no cell appears, so it draws every cell about the building's
origin and switches it by the camera's distance to that origin, and it has
none of the handoff below. A game that bakes a concatenated level ships the
ashlar-bevy that came with it.
The details that make it safe to use:
- A piece is never split. It belongs to the cell its placed bounds'
centre falls in,
floor(x / side)andfloor(z / side), so a wall across a cell line stays whole in one cell. A cell's bounds may therefore overhang its square. - Nothing is lost. Positions are placed, normals turned, UVs and winding
kept, and no weld runs, so every output triangle is one input triangle. Each
face's
FaceSource::operandis renumbered into the new group'soperands, which name the instance and element it came from, and its origin is carried, so a cut face is still cut.ashlar-bevymarks cut faces per face fromsourcesfor element pieces and group batches alike (the per-pieceis_cutis identity, not shading), so a concatenated level shades its cut faces as the placed pieces did. - Stable ids. A cell's id is
cell:{side}m:{i},{j}orcell:whole, lengthened if it would equal a declared merge group's id or the building's. Two levels concatenated at one side length share ids; different side lengths do not. - Deterministic bytes. Groups are ordered by cell, batches by binding and
side, and pieces appended in the order
MeshedBuilding::piecesyields them. - Reuse survives.
bakekeeps every level as placed pieces while it meshes, and joins at the end, so a level after a concatenated one still reuses the parts it did not change. A policy with a cell is notis_identity()(its level is drawn differently), but it may still notsimplifies(), and level zero takes the authored meshes whenever it does not. - A bad cell is refused.
LodPolicy::check, whichbakeruns over the ladder before meshing anything, refuses a side that is zero, negative, NaN or infinite, atlevels[n].cell. The whole building has one spelling. - Bands measure to the cell.
ashlar-bevynow draws a group about the centre of its bounds at the first level it appears in, not only level zero. Before, a group that first appeared at a later level was drawn about the building's origin, and its band was measured to that origin. Two levels joined at one cell size share each cell's pivot, so their crossfade is between two views of one thing in one place, and a batch the coarser level left unchanged is one entity whose band runs on. - No hole at a handoff. Bevy measures each band to its own entity's
origin: a placed piece's to its instance, a cell's to its centre. Handing
one to the other at 250 m with nothing more, a piece on the far side of its
cell leaves its band before the cell enters its own, and for those metres
nothing draws it; one on the near side is drawn by both. So
ashlar-bevypushes the end of every band that hands over at a boundary out by the distance from that piece's origin to the farthest origin of the pieces that draw the same placed elements at the next level (found through the cells' and groups'operands). By the triangle inequality the camera is then never past the one band and short of the other, and across a crossfade the next level's dither is always at least as far in as this one's is out. The same rule covers a cell handing over to cells of another size, a piece whose simplified centre crosses a cell line, and a concatenated level followed by a placed one. Where the two origins are one point, as an instance or a merge group's pivot is at every level, the push is zero and nothing changed. A test inashlar-bevysweeps cameras past a row of walls under four ladders and asserts, with Bevy's own cull and dither rule, that every wall is wholly drawn from everywhere.
A cell's group has no storey, because a cell spans storeys: a game that hides
storeys by AshlarPiece::storey cannot do so in a concatenated level. It has
no collider, because collision is level zero's at every level. That does not
make collision free: bake carries level zero's collision proxies and portals
into every file, a skyline too, and ashlar-bevy spawns one AshlarCollider
entity per proxy per instance and puts every room and portal in
AshlarSpaces, whatever the levels are. A skyline meant to be a few tens of
entities is baked from a recipe without them, or has
BakedBuilding::part_colliders and part_portals cleared before it is
written; the rooms are the recipe's and cost no entities.
What it costs
Instancing, in that level. A placed piece shares its part's mesh with
every other placement of the part; a concatenated batch is unique geometry.
So in a concatenated level unique triangles equal drawn triangles: the level
stores, uploads and keeps in GPU memory one copy of every triangle it draws.
A building that places one window a thousand times stores the window once in
a placed level and a thousand times in a concatenated one.
BakedBuilding::triangles(), which counts unique triangles, counts every
placed copy in such a level.
Put in bytes: ashlar-bevy uploads a position, a normal, a UV and a tangent
per vertex, 48 bytes, plus 4 per index (and 8 more per vertex for a mesh with
cut faces); the file stores 32 bytes per vertex and 20 per triangle,
indices and face source. A far
level of a few hundred thousand triangles is tens of megabytes of vertex
memory for a city, where the placed level shared most of it.
The handoff is by cell, and it overlaps. A cell switches level as a whole, at its own centre's distance, and the handoff above keeps a placed piece drawn until its cell is sure to be: its band runs on by its distance to the cell's centre. Across that extra band both are drawn, the same surface twice under the same material, which costs draws and shows nothing where the level left the piece unchanged, and shows the finer piece where it did not. A piece at the corner of a 160 m cell stands over a hundred metres from its cell's centre, so it is drawn twice for over a hundred metres of approach. Pick a side that is small against the distance the level starts at: with the default crossfade of a tenth, a cell whose half-diagonal is under a tenth of that distance (about 35 m of side at 250 m) overlaps by no more than the crossfade margin already does.
The choice is per level. It is the right trade for a far level, where the
triangles are few and the pieces are what costs, and the wrong one for a near
level of a repeated kit, where instancing is what makes the triangles
affordable. The game's census of that city's far level (2026-09-28, its own
tool over its baked files, not measured here) found 45 distinct bindings, and
851 batches at 80 m cells or 350 at 160 m, against 7,272 placed pieces. It
counted cells in the settlement's frame, shared between buildings; bake
concatenates each building in its own frame from its own origin, so a city
baked building by building has at least that many.
LodPolicy::ladder() sets no cell; a game chooses where it wants one.
The cooked file (version 2)
The full file is what a content step caches and a tool inspects: the recipe,
every triangle's provenance, meshes in full precision. A game that draws a
building pays for all of it, and pays on the thread that spawns it, so a
second file is cut from the first at bake time. It holds the structure the
renderer and the runtime ask about (placements, the palettes their slots wear,
levels, groups with bounds and operands, rooms, portals, collision hulls) and
a table of meshes made ready for a GPU: quantised against their own bounds,
tangents generated once (by a generator the caller passes: mikktspace's Rust
crate is named for Bevy and ashlar links none), reordered for the vertex
cache and entropy-coded.
There is no recipe in it, so a game that reads it links no kernel and cannot
edit a building back; the content cache keeps version 1 for that.
Three choices are worth saying why. UVs are quantised with the positions and
not stored as half floats, because they are metres and run to hundreds on a
wall, where a half float's step is centimetres and a texture would swim.
Normal and tangent are octahedral i16, which holds the axes exactly, so a
flat face is one repeated value and the coder takes it for almost nothing.
And a mesh no coarser level changed is one record at every level, which the
full file already did and the cooked one keeps.
Both versions read. The loader chooses by the version word, a cooked file's meshes are made in the loader (an asset loader runs on the IO pool) and live in the render world alone, and since a mesh with no CPU copy cannot be measured the spawned piece carries the box the file recorded.
ADR 0007: Props, cut-out materials and impostors
Status: accepted, 2026-09-26.
Context
ashlar bakes buildings: recipes of parts meshed by a kernel into levels of detail, dressed in opaque tiling materials. A game also draws things that are not buildings and are not made from a recipe of solids: trees, bushes, rocks, cliffs, debris. A game generates those with its own code, and what it needs from ashlar is the rest of the pipeline it already has for buildings: a baked file with levels and colliders, materials baked from graphs, a loader, a way to draw thousands of them, and something cheap to draw far away.
Four things are missing:
- Materials are opaque. A leaf card needs its alpha cut out, light through
it from behind, and both sides drawn.
PbrOutputhas no opacity,MaterialDefinitionno alpha mode, and ADR 0003 put transparency out of scope. - Meshes carry no data of their own.
TriangleMeshhas positions, normals and metre UVs. A tree's wind needs a pivot per vertex and a stylised shader wants ambient occlusion and a height gradient; there is nowhere to put either. - The only baked asset is a building. A tree dressed as a building would need a recipe of solids it does not have, and would carry rooms and portals it never uses.
- Nothing is cheap far away. A building's far level is still its geometry. A forest's far level has to be a picture.
Decision
Cut-out, two-sided and translucent materials
MaterialDefinition gains three fields, all defaulting to today's behaviour
and skipped when default so every library written before them reads and
writes byte for byte as it did:
alpha: Alpha,OpaqueorMask { cutoff }. Blending is out of scope: sorted transparency is a renderer problem, and every foliage path worth shipping cuts out.double_sided: bool, which also turns off back-face culling.diffuse_transmission: f32,0..=1, the share of diffuse light that passes through, which is how a leaf lit from behind glows.
PbrOutput gains an optional opacity input, baked into the base colour
map's alpha, with a coverage-preserving mip chain (each level's alpha is
rescaled so the fraction of texels over the cutoff matches level zero), so a
card does not thin away with distance. create_material maps all three to
StandardMaterial.
Meshes carry named vertex data
TriangleMesh gains colors: Option<Vec<[f32; 4]>> and
attributes: BTreeMap<String, VertexData>, where VertexData is one of
F32, F32x2, F32x3, F32x4 or U32, one value per position. Both
default empty, and a building never sets them. ashlar-bevy uploads colours as
Mesh::ATTRIBUTE_COLOR and each named attribute as a MeshVertexAttribute
whose id is a stable hash of its name, exposed as
ashlar_bevy::attribute(name, format), so a game's shader specialisation
names the same attribute without either side agreeing on a number.
A prop is a baked asset beside a building
ashlar::prop holds a Prop: an id, levels of detail, colliders and a
bounding box, and no recipe. A PropLevel has an until distance, whether
it casts shadows, and its PropPieces: a TriangleMesh and a material key.
Its last level may instead be an impostor (below): an ImpostorRef, the
keys of the two atlas files and the ImpostorLayout they were baked with.
A PropCollider is a
capsule, a cylinder or a convex hull, in the prop's own frame. The file is the
building file's container (identifier, version, a RON manifest, a binary mesh
table) with its own identifier, written by ashlar-content's
Content::write_prop and read by plain ashlar, so a server reads a prop's
colliders with no Bevy and no kernel.
ashlar-bevy loads .ashprop files into a PropAsset whose meshes are
labelled sub-assets, created once however many copies are drawn, and spawns
one with an AshlarProp { prop, materials } component the way it spawns a
building: one child per piece per level, each with its VisibilityRange.
A game drawing tens of thousands, such as a forest, takes the asset's mesh
and material handles and manages its own entities; the component is for the
handful of props a scene places by hand.
Impostors are baked pictures of a prop
ashlar-impostor bakes a prop's nearest level into a hemi-octahedral
impostor: a square grid of views from the upper hemisphere, more of them near
the horizon where a camera usually is, each view an albedo with alpha and a
normal with depth. It rasterises on the CPU with a depth buffer, sampling each
piece's baked maps for colour and cut-out, so CI gets the same bytes on every
machine and no GPU is needed. It is a content-step crate, like the material
graph engine.
ashlar-bevy draws an impostor as one camera-facing quad per instance with an
ImpostorMaterial that finds the three frames nearest the view direction,
blends them by their weights with a depth parallax offset, cuts out by alpha
and hands the normal to Bevy's standard lighting, so an impostor is lit by the
same sun as its prop.
The two meet in the prop file. ashlar-content's Content::write_impostor
bakes a prop's nearest level in the materials its library gives it and
answers the ImpostorRef its last level carries, and AshlarProp spawns that
level as one more child: the quad, in the level's band, casting no shadow.
ImpostorRef holds the layout whole rather than a copy of its fields, so the
file and the renderer read one type.
What this does not do
- Sorted alpha blending, and order-independent transparency.
- Non-square bakes and a general atlas packer: impostor frames and leaf sheets are square grids, which need neither.
- Skinned or vertex-animated props: wind is the game's shader, reading the vertex data above.
- Generators. ashlar does not grow trees or shape rocks; a game does, and hands ashlar the meshes.
Consequences
- A library, a building and a building file written before this ADR read and write unchanged. A prop file is a new kind with its own identifier.
ashlar-bevygains a module and a material; the featureless game path stays free of the graph engine and the kernel.ashlar-impostorjoinsashlar-manifoldandashlar-materialas a tool-only crate.- ADR 0003's exclusion of transparency is narrowed: cut-out alpha and diffuse transmission are in, blending is still out.
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_cookedwrites 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 asu16across the mesh's own bounds, normal and tangent as octahedrali16pairs, the cut flag as a bit), carry tangents baked in the content step, are reordered for the vertex cache and fetch, and are coded withmeshopt0.6.2 (u16indices under 65 536 vertices). There is no recipe and no face provenance. The tangent generator is the caller's (TangentFn, passed towrite_cooked), because mikktspace's Rust implementation is a crate named for Bevy andashlarlinks none;ashlar_content::mikktspace_tangentsis the one Bevy'sMesh::generate_tangentsuses, and a test holds the two equal.CookedBuilding::readdecodes it with no kernel; it walkspieces(level)exactly asMeshedBuilding::piecesdoes and answersBoundSlots, soMaterialLibrary::check_fortakes either. Version 1 is unchanged and still reads (BakedBuilding::read),Content::cooked_buildingschooses which the content step writes (the cache keeps version 1 either way), andashlar-bevy's loader branches on the version: a cooked file's meshes become labelled sub-assets (file.ashlar#mesh3) made on the IO pool withRenderAssetUsages::RENDER_WORLD, and each spawned piece carries theAabbof its mesh because no CPU copy is left to measure.BakedBuildingAssetis 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::Wholeis one cell for the whole building, a skyline.MeshedBuilding::concatenated(cell)does the joining with no kernel and answers oneMergedGroupper occupied cell (idcell:{side}m:{i},{j}orcell:whole, lengthened past any declared group id or the building's own; no storey, no collider; bounds of its vertices;operandsnaming every placed element that went in, and every face'sFaceSource::operandrenumbered 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::bakestores 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 anashlar-bevyfrom 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, andashlar-bevyspawns a collider entity per proxy per instance, so a skyline clearspart_collidersandpart_portalsbefore it is written.LodCell::checkandLodPolicy::checkrefuse a side that is zero, negative, NaN or infinite;bakeruns the check over the whole ladder before meshing, atlevels[n].cell.LodPolicy::simplifiesasks 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-bevydraws such a definition asanti_tiling::AntiTilingMaterial, anExtendedMaterial<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_tileconverts anyStandardMaterial;AntiTilingPluginregisters it andAshlarPluginadds it. A piece's material is now aMaterialHandle(StandardorAntiTiled, withinsert,id,standardandanti_tiled):Drawable::materialandBatchDrawable::materialchanged type,batch_drawablescachesMaterialHandles,UploadContextgainsanti_tiled(Nonedraws anti-tiled definitions plain), andadd_materialis the routing. Theanti_tilingexample compares the two. -
Coplanar-face lint (2026-09-27).
MeshedBuilding::coplanar_overlapsfinds 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. EachCoplanarOverlapnames the two elements by path (instances[a].elements[panel], orgroups[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 andcoveredone 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_withintakes aCoplanarTolerance. Kernel-free: a baked level answers throughBakedBuilding::level. -
Prop tables (2026-09-26).
Prop::tablesholds namedVec<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).
TriangleMeshcarries optionalcolorsand namedattributes(VertexData:F32toF32x4andU32, one value per position), kept with their vertex byweld,unweld,project_uvsand the newappend, and checked by path withTriangleMesh::check.ashlar::propholds aProp: levels of detail of meshes wearing material keys, each with anuntiland whether it casts shadows, an optionalImpostorRefon the last (the albedo and normal-depth atlas keys and theImpostorLayoutthey were baked with),Capsule,CylinderandConvexHullcolliders, and bounds.Prop::writeandProp::readare the.ashpropfile, the building file's container with its own identifier, readable by plainashlar;Content::write_propwrites one.ashlar-bevyuploads colours as the vertex colour and each attribute asashlar_bevy::attribute(name, format), loads.ashpropfiles as aPropAssetwhose meshes are labelled sub-assets, and spawns anAshlarPropwith each level in its band andNotShadowCasterwhere a level casts none, and its impostor level as anImpostorMaterialquad markedAshlarPropImpostor, which casts no shadow.prop_level_range(&prop, level, crossfade)is that band rule for a game's own entities, andContent::write_impostorbakes a prop's nearest level, in the graph and plain materials its library gives it, into the two atlas files and answers theImpostorRef(ImpostorSettingsfor the grid; cached like a building).AshlarPluginaddsImpostorPlugin, 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).
MaterialDefinitiongainsalpha(Alpha::OpaqueorAlpha::Mask { cutoff }),double_sidedanddiffuse_transmission, each validated by path and skipped when default, so an older library reads and writes byte for byte as it did.PbrOutput::opacitybakes 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::Opacityreads it from a subgraph.ashlar-bevymaps the three fields toAlphaMode::Mask,double_sidedwithcull_mode: Noneanddiffuse_transmission, from files and runtime bakes alike. ASurface::Shaderover a graph with opacity is refused atoutput.opacityuntil the emitter writes a discarding prepass.bake::Planesgains anopacityfield, 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:framesbyframesorthographic 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 (zstdfeature for supercompression). The same bytes on any thread count.ashlar::impostorholds theImpostorLayoutboth halves read and the hemi-octahedral and octahedral mappings.ashlar-bevy's new featurelessimpostormodule draws an atlas:ImpostorMaterial, aStandardMaterialextension 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_imagesandImpostorPlugin, the last four in the prelude. It works behind depth, normal and motion-vector prepasses and beside a deferred one, casts no shadow, andjust drawholds it on five cameras. Theimpostorexample grows a tree in code and shows it beside its impostor, anddocs/guide/impostors.mdis the chapter. -
A content cache (2026-09-26).
Content::cachekeeps 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::cachedandBuildingWritten::cachedsay what came from it. Independently,Content::write_filenow 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.ronloaded from a named source such asgenerated://resolves its maps and strand sets in that source, and theMaterialLibraryAssetit 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 siteassembles it with the API docs and a browser demo intotarget/site, and.github/workflows/pages.ymldeploys that to GitHub Pages. The README is an entry page with screenshots;first_buildingis 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::Autoby default, cuts each level at the middle of its crossfade margin with an abruptVisibilityRangeon a render device that cannot crossfade one: Bevy 0.19.1's WebGL2 fallback declares the range uniform as 64vec4s 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 materialsis 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::BakedBuildingand the.ashlarfile (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.writeandread; 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,PartandBuilding::simplified, andLodPolicy::ladder(), three levels to 60 m, 250 m and beyond.ashlar_manifold::bakemeshes 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.ashlarbuildings and.materials.ronlibraries as assets and spawns anAshlarBuildingasAshlarPiecechildren, oneVisibilityRangeband per level with a crossfade. Colliders arrive asAshlarCollider, rooms and portals asAshlarSpaces, andAshlarBuildingSpawnedorAshlarFailedsays how it went. Meshes and materials are shared across instances and across levels that left a piece unchanged, throughAshlarCache; 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::shipbakes 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_overridesturns 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 contentruns it over the showcase. -
The integration template:
examples/integration-contentbakes a small village of the game's own, andexamples/integration-gamedraws 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-landmarkandcity-tenementare new. Its materials are new stdlib graphs:library:stained-concrete(streaked from each storey's slab edge, grime at its foot, over a newweathering:streakscompound),library:office-windowandlibrary:shopfront(lit interiors in the emissive map), and awetparameter onlibrary:asphalt,library:roadandlibrary:paving-slabs(plustinton 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,--ambientand--focus X,Y,Zfor a street-level orbit; thebakedexample gains--night. The glass, stepped, round and slab families,city::Style,metropolis_styledandmetropolis-corporateare 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::placeadds 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. Scenescity-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. Scenesscifi-kit,scifi-outpost,scifi-colony, andscifi-<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:adobeandlibrary:desert-sand(coloreach). -
Geometry::revolve(profile, segments)andShape::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,ShapeandPose;Element,Part,Socket,Instance,BuildingRecipeand the validatedBuilding;MaterialLibraryandMaterialDefinition; theGeometryMesherport withTriangleMeshandplanar_uvs; andfit_groundwithGroundingSpec,GroundingPolicy,TerrainPatchandGrounding. -
ashlar-manifold:ManifoldMesherandmesh_building, evaluating recipes through the native Manifold kernel intoMeshedBuilding. -
ashlar-bevy:Definitions,read,create,meshandground_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 pluggableCatalogof scenes with a thin binary that supplies the showcase.
Added since the extraction
-
Expression nodes:
Shape::Union,Shape::ArrayandShape::Mirror, withGeometry::union,union_all,arrayedandmirrored, evaluated through the kernel and documented for cut-face provenance. -
UV modes:
UvModeper element, withPlanar(the default and the old behaviour),BoxandCylindrical, plusbox_uvs,cylindrical_uvsandproject_uvsbesideplanar_uvs. -
Indexed meshes:
TriangleMeshcarries an index buffer, withweld,unweld,triangles,cornersandis_consistent. The Bevy adapter uploadsIndices::U32. -
Derived collision:
Collisionon an element andConvexSolidin the domain;MeshedBuilding::part_collidersandcolliders()in the mesher. See ADR 0002. -
Catalog::asset_root: a downstream preview binary points--asset-rootat 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::attachandattach_aligned, withAttachmentandFacing, resolved into poses bybuild(). -
Terrain triangulation:
DiagonalandTerrainPatch::with_diagonal. -
API hardening:
#[non_exhaustive]on the enums a consumer matches on, aversionfield onBuildingRecipechecked at build time, aglamre-export, and an index insideBuildingsomaterial, the newpartand the newinstanceare map lookups. -
ashlar-manifolddocuments 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 rejectionreadmakes. All of it runs without a GPU, onMinimalPluginsplusAssetPluginand 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_uvspreserves edge length,weldthenunweldis the identity on the triangles, andPose::transform_pointis an isometry. -
Gallery tests in
ashlar-previewfor everything below the screenshot: the scene queue, the unknown-scene error, theindex.htmlandmanifest.tsvwriters, and the camera framing. -
Crate documentation: each of
ashlar,ashlar-manifoldandashlar-bevyopens 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.mdfor every publishable crate, withreadmeanddocumentationin 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, besideashlarrather than under it.MaterialGraph,Param,Input,PbrOutputand a vocabulary of generator, transform, filter, buffered and structureNodes, withbuild()returning a validatedMaterialthe wayBuildingRecipe::buildreturns aBuilding: node paths of the formnodes[id].inputs[name], type inference with the float/colour conversions, cycles named by the ring they close, and aMaterialGraphLibrarythat resolves subgraphs and refuses recursion. Every port carries an integerPeriod, 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 inir, 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;interpis the reference backend that runs that expression overf32, 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 atoffsetinstead of claiming a period in v it does not have.baketurns a graph and a set of parameter values into a tileable texture set: the expression run once per texel over one repeat, rows rasterised acrossstd::thread::scopethreads 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; thef32planes are exposed before anything is quantised, andbake_with_reportsays 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::preflightstops 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,DilateandBufferlower to a plan and anOp::Sample: everything upstream is rasterised once into aPlane, 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, andbake::rasterise_withtakes aBakeCachethat keeps planes across bakes for a preview that re-bakes on every slider.BakeErroris#[non_exhaustive]and gainedLattice: 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::mipsfills every map with every level down to 1x1, filtered from thef32planes 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 formr' = sqrt(r^2 + (1 - |n_avg|) * k)over a documentedmips::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::mipsis the chain as numbers andPlanes::encode_mipsis the chain as bytes;BakeReport::levelssays how many there are, andBakeError::Mipsis gone. -
A KTX2 writer in
ashlar-material, and the study ships KTX2.ktx2::writeturns oneEncodedmap into a file with every level inside it: the 80-byte header, the level index, the mandatory data format descriptor, aKTXwriterkey 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 thektx2crate Bevy itself pins, including comparing the descriptor against the one that crate generates for the same format.ktx2::inspectreads a file's header and level index back and checks them against its own length, which is what a startup preflight can afford:ashlar-bevyvalidates every.ktx2key with it, builds Bevy with thektx2feature and no supercompression decoder, and refuses a supercompressed file by name. The showcase's asset writer now bakes with mips and writesbase.ktx2,normal.ktx2,orm.ktx2and — new, because no PNG here could hold it — the sixteen-bitheight.ktx2; the study's PNGs are deleted and--write-pngbeside--write-study-assetsexports 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: aSurface::Graphdefinition becomesImageassets when the material is created, in the newruntime_bakemodule. 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 andRenderAssetUsages::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 sameBakecarries, 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 toGraphis pixel for pixel the facade rendered from its files.BakeCacheis the resource that keeps them, keyed by the whole bake rather than a digest of it, so two definitions naming oneBakeshare 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.createis unchanged and still needs nothing but anAssetServer;runtime_bake::create_graph_materialis the superset that takes the graph library, the cache andAssets<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 — soruntime_bake::bake_imagesis that work as a pure function of a bake and a graph library, callable from anAsyncComputeTaskPooltask with onlyBakeCache::insertleft 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 callslibm;Voronoiwith three metrics, a jitter and its distance, cell, edge and border outputs;Tilesover a grid, a hexagonal lattice and a herringbone weave, with the same mask, bevel, id and fill outputs brickwork has and withBricksgaining the three it was missing;Pattern,ShapeandScratches; the resamplersTransform,Warp,Mirror,KaleidoscopeandTile;Curveas a monotone cubic,Adjust,Clamp,SwitchandNormalFromHeight; the seven blend modes past the arithmetic four; andSubgraph, 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 isLowering::substitute, which emits the sub-expression again with another coordinate whereOp::Uvstood: 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) / 2and 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
Transformthat scales a noise by four lays four times the cells, and aTilethat 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_latticenow 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 aSubgraphinstances, since lowering inlines them. This is the concern phase two's first step left written down inperiod.rs, and it is whatBakeError::Latticenow 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 thannotify: that crate is not in this workspace's dependency graph, and Bevy'sfile_watcherfeature, which isnotify, was measured to bring a secondwindows-sysfamily pastcargo deny's duplicate rule. The stamp carries a digest of the bytes beside the timestamp, because two writes inside one kernel tick share anst_mtime_ns— 195 times out of 200 on this filesystem — and a missed edit is the one thing a watcher may not do.Mopens the panel: everyParamof 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.Sor the Save button writes the library back to RON, over the file it was read from or wherever--graphs-outsays. Clicking a wall points the panel at the graph behind it, through thebaked_froma textured surface records; the arrows walk the library for the graphs nothing on screen uses. The widgets are Bevy's own:bevy_ui_widgetsships headlessSliderandCheckboxin 0.19, so only their appearance is written here, and the alternative was measured first —bevy_egui0.42, the release built against Bevy 0.19, brings a seconditertoolsand a secondguillotiere, neither under an existingskip-treeentry. The step adds no dependency at all. -
--bakeon the preview turns every surface that records a graph into aSurface::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--bakeis pixel for pixel the facade rendered from its KTX2 files, checked. -
Three authored materials, and a sheet to read them on.
study:brick,study:plasterandstudy:painted-metalinexamples/showcase/src/materials.rsare 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 fromBrickOutput::Idthrough a gradient of clay colours, its joint from a recess the horizon march darkens, and its grime from aDistancemeasured out of the mortar, because what dirties a brick wall is the joint it sits next to; the plaster crazes along a warpedVoronoiborder 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 whereScratchesand a knock field say it has been hit, rusts in the rim a secondDilateleaves around each chip, and is the one graph in the workspace whosemetallicis a field rather than a constant, because paint is a dielectric and the steel under a chip is not. Each takes avariationparameter, which is a domain shift rather than a seed: a node'sseedis a field of the node and not an input, so what a parameter can reach is aWarpover 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 theBakeCachehanding 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 injust 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 indocs/research/material-sheet(removed; in git history). -
The study's concrete gained a horizon and lost its clipping. Its occlusion is
OcclusionFromHeightover the panel's own relief now, not the1 - pores * 0.3the 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 of0..=1by aLevelswith 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 thef32normal was not. A new test readsBakeReport::height_rangefor 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.rscarries the concrete and the metal asashlar-materialgraphs — the port of the pixel loops that were intextures.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 asassets/buildings/recipe-study/materials.graphs.ron, and every textured surface records the graph, parameters and resolution it came from underbaked_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'sbase_coloris 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 inexamples/showcase/tests/materials.rsso the interpreter cannot drift silently, and the numeric seam check that lived intextures.rsis now inashlar-material's owntests/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 indocs/research/material-graph(removed; in git history). -
The shader partition in
ashlar-material:partition::partitiontakes a built material, aTarget::Shaderand 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, soplanes::rasterise_buffersbakes 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 wherenormal_strengthis 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 forOp::Uvand sampling it at itself.CostReportis 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 saysno live inputs: bake thiswhen the fragment computes nothing, which is what all five study graphs say with their parameters baked. Withvariationlive instead,study:brickis 2683 ops per fragment against 1189 baked, because aWarpover 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'scrackingandstudy:painted-metal'schippingare 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 aheightasking for sixteen-bit codes cannot share one with aroughnessasking 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,WorldNormalandCutFlag, which lower to theOps 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::emitprints a runtime partition as oneletper instruction inside a function that takes the coordinate and the four runtime inputs and answers the PBR ports the graph bound, andwgsl::emit_computeprints 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: theAshlarParamsuniform 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 aParamLayoutper parameter so the Bevy step fills bytes without re-deriving any of it; onetexture_2d<f32>andsamplerpair per bound image from binding 101; aMaterialExtensionfragment overpbr_input_from_standard_materialthat multiplies into the PBR slots the way the same material's baked maps would and writes its tangent-space normal throughcalculate_tbn_mikktspace, under theVERTEX_TANGENTSdef 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 —mixisa + (b - a) * t,clampismin(max(x, low), high), and division,smoothstepandnormalizego 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, inu32, whose arithmetic WGSL defines as wrapping. Validated withnagaat the version Bevy 0.19.1 pins, as a dev-dependency with onlywgsl-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#importlines and#{MATERIAL_BIND_GROUP}, which arenaga_oildirectives that plainnagadoes 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 ofmesh_view_bindingsin the main pass and binding 1 of the prepass's own smaller view layout, so an emitted prepass that readsTimedeclaresashlar_globalsthere itself rather than importing the main pass's name. -
ProceduralMaterialinashlar-bevy: aashlar::Surface::Shaderdefinition now compiles rather than being refused.shader::create_shader_materialpartitions the graph, bakes the static half intoImageassets through the phase-tworuntime_bake::BakeCacheunder an exactShaderKey— graph, resolution, live names, and the value of every parameter that was folded — emits both stages as WGSL, registers them asShaderassets under ids derived from the graph, and answersExtendedMaterial<StandardMaterial, GraphExtension>.tile_metresanduv_offsetgo throughuv_transformexactly ascreateandcreate_graph_materialput them there, so a wall does not change how it tiles when its surface moves between files, a bake and a shader.MaterialExtension::fragment_shadercannot vary per graph, soGraphExtensioncarries aGraphKeyas itsbind_group_data— Bevy makes that part of the pipeline cache key and hands it tospecialize, which swaps the descriptor's fragment shader for the weak handle whose UUID the key derives, choosing the prepass text when thePREPASS_PIPELINEshader-def is present and doing nothing where there is no fragment stage at all.GraphKeyis a sixty-four-bit digest because Bevy's bind group data has to beCopy;GraphShaderskeeps the exactShaderKeybeside 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::ProceduralMaterialPluginregisters the material, its pipeline and its prepass.readpreflights aShadersurface by lowering, partitioning and printing it, so everything but the WGSL itself is a startup error; validating the WGSL needsnaga, which is a dev-dependency ofashlar-materialon purpose. -
bake::encode_boundinashlar-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_paramsinashlar-material: the graph with a caller's parameter values in place of its declared defaults, refusing an unknown name or a wrong type atparams[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 byjust 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 owninitialize_renderer— no window, noApp, and no new dependency, since this crate already links thewgpuBevy pins — dispatches the compute kernelwgsl::emit_computewrites, and compares it withinterpover the same partition, texel for texel at 256 squared. One partition per primitiveOp, 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 injust 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 asRgba32Floatholding 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:stripis a light strip whose emissive isdiffuser * (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'slightkey as aSurface::Shader. Itsrateis aBakeparameter 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:concretegained a livewetnessthat 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 theFilessurface every wall uses and asstudy:concrete-wet, aSurface::Shaderover the same graph withwetnessbound, which the entrance's coping and threshold carry. Eleven instructions and one uniform. Atwetnesszero the graph is the wall it always was, bit for bit —Op::Mixisa + (b - a) * t— so the shipped KTX2 files and the golden hashes are unchanged by the parameter existing. Seedocs/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::DELIVERYis the second axis andsheet::material_keytakes it.tools/ashlar-preview/tests/sheet.rschecks it as bytes rather than as pixels: a compiled material's boundbase_colorandnormalimages are byte for byte the maps the bake wrote, whole mip chain included. -
The preview drives live parameters without a rebake.
reload::redresswrites 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_ofanswers for aShadersurface 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::gpubakes a graph on the render device:partition::over_planesis 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 throughIr::rooted_at— andwgsl::emit_compute_portswrites one kernel per port over only the images that port samples. A bake is then a dispatch per plane withplanes::filterrun 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-backf32planes, which is what keeps a GPU bake's normal identical to a CPU bake's rather than merely close.Baker::{Cpu, Gpu}onBakeContextandShaderContextsays which backend runs;gpu::GpuBakePluginputs aGpuBakerin 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-assetsonly leaves it for--gpu. Measured on an RTX 4090 in a debug build:just references65.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 conformancegrewthe_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.
WorldPosandWorldNormalwere emitted and conformance-tested with nothing bound to them;ashlar-materialnow 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, andWorldMask::below(6.0)the faces under six metres — andTriplanar { 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 everytile_metresalong 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 theShadersurface that does answer — zero seconds is a picture and a world position of zero is a whole wall at one point, soTimeandCutFlagstill bake at zero and these two do not bake at all.wgsl::emit_compute_worldemits 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 conformanceuses it for four new cases and for the study's own world-space graph. The study ships that graph:study:concrete-wetinstancesstudy:concreteand multiplies its livewetnessby 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::TriangleMeshhas carried one flag per triangle since the kernel went in, and the only thing a building could do with it wasElement::cut_material, a second mesh and a second material key.ashlar-bevy::meshnow uploads it asATTRIBUTE_ASHLAR_CUT, one flag per vertex, on any surface that has a cut face — and that attribute is Bevy's second UV set, because aMaterialExtensiondraws through Bevy's own vertex stage into aVertexOutputwith no free location, and the alternative to repurposing a slot is emitting a vertex shader that reimplementsmesh.wgslandprepass.wgslto carry one float. Vertex colour was the other free slot and is worse:pbr_fragmentassigns 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::weldnow 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 — andBakeReport::cut_flagsays when it did.wgsl::WorldInputsis renamedMeshInputsandemit_compute_worldtoemit_compute_mesh, becausePlanesnow lays a cut flag over the square beside the world position and normal;just conformancegains 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 shipsstudy:concrete-cut-aware: fourSubgraphnodes onstudy:concrete, aCutFlagand twoMixes, 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, andjust referencescosts 4 s more over 39 shots — all of it the static half of a second compiled material being baked once per scene, becauseBakeCachekeys 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 — andInstance::materialsand 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_forrefuses an override on aPlainorFilessurface, which binds no parameters at all, andashlar_bevy::read_libraryrefuses a name the graph does not declare or declares at another type, both atinstances[<id>].materials[<slot>].ashlar_bevy::definitionwrites 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: aGraphoverride is its ownBakeKeyand its own texture set, shared by every instance that asked for the same one; aShaderoverride 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-annexandmetro:stone-wingare gone fromassets/buildings/corporate/materials.ron, all three buildings bindmetro:stone, and each puts its ownvariationon 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-materialgains 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 walkedstepstimes 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;Curvaturefrom a height, as the signed field or as thePeaksandCavitymasks 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-instancemaskonTile, 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 noGradientMap:Colorizeis 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 aFiltermay now name a guideBufferId— followed by the dead-plane sweep, the renumbering, the plane cache key and the partition alike — andplanes::filtertakes 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 whereEdgeDetectfound them and whose mortar dust is smeared up the course above by a slope blur, with grime and bloom read off the relief byCurvature; a render whose crazing runs under aDirectionalWarpand 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 whereCurvaturesays the film was already lifting. Their KTX2 sets, their golden hashes and thematerials.graphs.ronthe study ships are regenerated. -
Zstd supercompression in the KTX2 writer, and
assets/down from 94 MB to 24 MB.ktx2::write_withtakes aSupercompressionbeside the map and the resolution, andSupercompression::Zstdcompresses 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 theKTXwriterkey are written exactly as before, andktx2::writeiswrite_withwith 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 behindashlar-material's newzstdfeature, off by default, which the showcase's asset writer turns on and nothing that reads a map does; the decoder isruzstd, pure Rust and decode-only, whichashlar-bevygets by building Bevy withzstd_rust.ktx2::inspectreads 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 — andashlar-bevy's preflight now refuses every scheme except zstd by number rather than refusing all of them.just referencesrenders 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-materialnow 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.Subgraphgainedinputsbesideparams, 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 innerTransformof 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 writtenkey@<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::extrais the other half: named float masks beside the six PBR channels, read bySurfaceOutput::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 theBakeReportof 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 aCow<'static, str>rather than a&'static str, which is what lets a node declare its ports at runtime, andSurfaceOutputlost itsCopyfor 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, anExtrathe 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.rscarried one partition per primitiveOpand 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, amixat a weight of exactly one and apowof a base that goes negative do. Sonode_cases()joinsop_cases(): a probe per node and per new output this phase added — a twistedCircleMapover a hole, a maskedCircleSplatterwith every variation, a hollowGearas a distance, a roundedBoxbeside a hollowCapsule, anSdfCombinehard and filleted, anSdfMask, and a plain, a twill and a satinWeave— each thetests/wgsl.rsgraph for that node, a warp over a live parameter, which makes the whole probe runtime.every_new_node_agrees_with_the_interpreterdispatches them underjust conformance; a second test that needs no adapter, and so runs injust 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, withxwarp threads running along v andyweft threads along u, awidthsaying how much of its own pitch a thread fills, and aPlain,TwillorSatincrossing. The three patterns are one rule at different numbers — the warp is on top where the residue ofwarp - move * weft - offsetmodulo 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 atxthe way a brick bond that does not divide its rows is refused atoffset.Heightis 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 itMaskis the cloth against its holes,WarpandWeftare the topmost thread and are exactly each other's complement over the cloth, which makes them a pair of tint masks, andIdis a hash per thread rather than per crossing, so a tint runs the length of a thread. -
SdfCombineandSdfMask, 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,maxandmax(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.smoothis 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.SdfMaskis the inward ramp aShapehas 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. -
Shapegrows: 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 oflengthlying along u with a cap of radiussizeat each end, and a gear, a disc withsidesteeth cutdepthdeep into it. A capsule is the one kind whose reach is notsize, 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 fromsize, 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.roundis 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.hollowis 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.outputadds the signed distance beside the mask, unclamped and untouched byedge, 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 aTileon a circle rather than on a grid, and deliberately the same tool otherwise — an instance is the source's own unit drawnscaleacross, varied by hashes of its own place in the ring, windowed to that unit and combined with its neighbours by taking the larger, and themaskis 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 cutinnertoradiusinto, so the outermost is atradiuswhateverringsis, and one check —radiusplus 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 onceface_centreor 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, withturnsandringscopies across the two and atwistthat 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 atu = 0than atu = 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 ruleShapehas always lived under, so the radius stops at a half, the coordinate is wrapped before the disc is measured, and everything past either radius answersoutside. The source must itself tile, the way aTriplanar's must, because the angular axis wraps into the source's u. The centre pinches — every angle meets there — so a source whosev = 0row 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_paintandmoisture— and four pattern recipes —patterns:radial_gradient,angular_gradient,woodandcracks— answered together bystdlib::graphs()and merged into a game's own library by the newMaterialGraphLibrary::extend, which is two passes so that a refused merge leaves the library untouched and a collision errors atgraphs[<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 asGraphInputs 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_dustbinds 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 aseed_maskby aDistanceplane, a peel's lip and undercut are measured off the edge of apeel_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 refusesWorldMaskby path, so world-space bias is a plain float input the caller drives fromWorldMask::up(), a live parameter or a constant, andmoistureis built for the live case — pointwise throughout, no buffered filter, so a livewetfreezes nothing and costs forty-four fragment instructions. Two conventions run through all nine:amountis 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 areliefparameter, because a curvature and an occlusion read their input as a length in the units of UV while a height input is a0..=1field, 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 aDistance'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 oneSubgraphnode 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.layerwrites them from one node, under<id>.<port>. The whole library joins the two shipped-graph loops intests/wgsl.rsand the every-parameter-live case inashlar-bevy/tests/conformance.rs, both of which now run over the study and the standard library;tests/stdlib.rsruns its generic claims over every keystdlib::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-steelis the study's own metal instanced through onelayer, 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_wearover the rolled crests,weathering:rustmeasured out of a Perlin cut saying where the water stood,weathering:dirt_dustin 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-paintis the other half of the argument: the painted metal's panel throughweathering:peeling_paint, and then the same rust compound seeded by that peel's ownbareextra, 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-coveris a cast iron cover in its frame, drawn from signed distances — aGearboss 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 throughSdfCombine, thirty-two slots laid by aCircleMapof sixteen turns and two rings, six hexagonal bolt heads by aCircleSplatter, and grit in the corners bydirt_dust— andstudy:canvasis thirty-two threads each way through a plainWeave, tinted per thread off itsIdhash, 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-weatheredis the fifth and the only compiled one: the study brick throughdirt_dustbiased byWorldMask::up()andmoisturewith a livewet, 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, andwidens: 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, withGOLDEN_256andGOLDEN_1024re-recorded at thirty-six entries each. That the old five did not move was the test this step existed to run: phase 2 re-factoredShapeso that its mask is anSdfMaskover 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 aSubgraphresolves by key at load and the writer therefore writes every compound a shipped surface reaches beside it:assets/buildings/recipe-study/materials.graphs.ronwent from eight graphs and 68 320 bytes to twenty-two and 151 000. Two tests that read that file and then mergedstdlib::graphs()into it had to stop merging, andashlar-material/tests/wgsl.rsnow makes the stronger claim instead — every compound on disk must be the graphashlar_material::stdlibbuilds, 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/heightstores 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-steelwas 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 inweathering:rust: theLevelsband 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 platestudy:metalbakes, 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 — itswetdefaults 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 onlyheightandemissiveare optional on aPbrOutputand 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::strandsholds namedStrandLayers beside thePbrOutput;ashlar_material::strandsscatters one into a deterministicStrandSetover a repeat, plants it on plain triangles and builds ribbons or three-sided tubes; the bufferedStrandReliefnode 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,Combine2andVoronoiOutput::Offsetare the three nodes a strand field needed and a texel did not.ashlar::StrandSettingson aMaterialDefinitionasks for layers, level-of-detail distances, a density cut and shadow casting, andashlar_bevy::strandsanswers one mesh per UV repeat per level, thinned by a rank prefix and widened by1/sqrt(keep)under aVisibilityRange. See ADR 0004. -
Cards: the level past the last band of real strands, in
ashlar_bevy::cards. Where a definition names aStrandSettings::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 byashlar_material::strands::cards. The atlas is cached inBakeCacheunder 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 asAlphaMode::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 pastcard_metresthere 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:grassdraws them from 7 to 14 m. -
Wind:
ashlar_bevy::wind, aMaterialExtensionoverStandardMaterialwhose vertex stage bends a strand along aStrandWindresource bystrength * 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 plainStandardMaterial, which is what the reference gallery sets. -
benchmark:moss-carpet: a second strand material, to show the vocabulary is not grass-shaped. OneStrandProfile::Fibrelayer of upright shoots grown offweathering:moss's own coverage mask, with no flow field under it. -
--reference-keyon 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 aFilessurface 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 drawablePiecewith theBindingin force on it — the cut slot for a cut batch, the instance's override where it has one — andashlar_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::tilingcompares it with the definition's atread: 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 honesttile_metrestypo (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_verdictreads it beside the scaletilingfound, and only a declared, matching factor stays quiet; a uniform scale with no declaration, or one that disagrees, is awarn!naming the fix. Advisory liketile_metresitself 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 serializedStrandSet"; 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, onglam,serdeandthiserror.ashlar-materialkeepsscatterand theStrandReliefsplat and re-exports every name fromashlar_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))isset, 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 outside0..=1and 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 waySurface::Filesnames its maps. Serde-defaulted and skipped when absent, so every shipped library is byte for byte the library it was, validated incheck, and preflighted byashlar_bevy::read_librarybefore a window exists — the identifier and version by hand in a featureless build, and the directory and the definition's own layer names understrands, which is the stale case.MaterialDefinition::checkno longer insists a definition growing strands name a graph: abaked_setis the other answer, and a game's lawn may sit on aPlainsurface. -
ashlar-bevy'sstrandsis a game feature, andstrand-scatteris the tool feature beside it.cargo tree -e normal --features strandshas noashlar-materialin it.StrandSetsis the value the two halves meet on:readbuilds one from a file andscatter_setsfrom a graph, andcreate_strandstakes one and cannot tell which. Card atlases move out ofBakeCacheinto aCardCacheof their own, keyed by where the set came from rather than by a graph.examples/lawn.rsis thirty lines between a file and a lawn, andjust drawgrows one on a GPU and looks at the pixels. -
The benchmark writes its lawns.
grassandmoss-carpetshipset.strandsbeside their four KTX2 maps — 24.09 MiB over three layers and 10.98 MiB over one — and a golden besidecommitted_preview_maps_match_fresh_bakesholds them against a fresh scatter. No baked texel moved. -
Merged geometry, interiors and damage (ADR 0005). Every face of a
TriangleMeshnames its source:sources: Vec<FaceSource>replacescut_faces: Vec<bool>, withFaceOrigin::{Body, Cutter, Damage}, andis_cut(face)andcut_faces()ask the old question. A cutter may name its own slot withGeometry::cut_material,Geometry::cutters()defines the numbering, andElement::slot_forresolves a face. A recipe may bemerged:MergeGroup,Instance::group,Element::standalone,Building::merge_groups, andGeometryMesher::mesh_groupwithPlacedGeometryandGroupMesh, whose default refuses.MeshedBuilding::groupsholds aMergedGroupofGroupBatches per group, in building space, with box UVs in the building frame and normals from the new kernel-freeashlar::shade_normals. Interiors are classified and never culled:SideandElement::interior, a storey on everyPiece,Geometry::portalwithGeometryMesher::portals,PortalShape,PortalandMeshedBuilding::portals, where a portal is measured against the core its cutter removes, andRoomwithBuilding::room_at. Damage is data:Damage,DamageLog,Damage::blast,Damage::collapsing,Shape::Hull,Geometry::hull,Geometry::balland the kernel-freeGeometry::bounds;mesh_building_with_damage, which isGroupSolids::buildreplaying each record through the sameapplyplay uses, andGroupSolids,GroupHit,DebrisandDebrisKind, 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, andDebriscarries the piece'sboundsfor a renderer to decide what falls with it.GeometryMesher::mesh_grouptakesPlacedCut, so a cut says whether what it leaves unattached falls;TriangleCollider,MeshedBuilding::mesh_colliders,groups_touched,standalone_touched,replace_groupandrecord.ashlar_bevy::batch_drawablesdraws what a hit produced, and the crate still links no kernel. The showcase gainscorporate::merged_by_storeyand theinteriorhouse, and the preview gains thecorporate-merged,corporate-block-mergedandinteriorscenes, storey and side culling onPageUp,PageDownandI, and shift-click or--blast x,y,zto blast.cargo run -p ashlar-manifold --example damage_serveris a server that links no Bevy. -
A default material library, and one way to ship it.
ashlar-surfacesits underashlarandashlar-materialand owns the definition vocabulary, soashlar_material::stdlibships finished materials and not only graphs: twenty-eightlibrary:*surfaces built fromlayouts:*,substances:*and the weathering compounds, with a definition for each fromstdlib::materials()baked at 512 when it is registered. The brick, cobblestone, grass and moss carpet moved in aslibrary:*, and every other textured surface was reviewed against an ambientCG scan to their standard.ashlar_material::exportis 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_libraryandcheck_library_with_graphspreflight an in-memory library the wayreadandread_library_with_graphspreflight a file, andashlar_bevy::gpu::dispatchablesends a graph the device cannot split to the CPU bake. The preview takes a scene's libraries as Rust values throughScene::materialsandScene::graphs, and gains asheet-<name>and adetail-<name>scene for every baked library material.just materialsexports the showcase's library into the ignoredassets/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-bevydrew 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.LodPolicyhas a new public field, so a struct literal that names every field without..needscell: None, andLodPolicy::is_identityis 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-bevydespawned a changed building's pieces through a query that did not see entities markedDisabled, 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::BakedisSurface::GraphandSurface::TexturesisSurface::Files, in Rust and in RON (Graph((..)),Files(..));ashlar_bevy::proceduralisruntime_bake, after its feature;create_bakediscreate_graph_material,shader::create_shaderiscreate_shader_material, andBakedImages/BakedTextures/baked_imagesareGraphImages/GraphTextures/graph_images;ashlar_bevy::read,checkandcreateareread_library,check_libraryandcreate_material, and the*_with_graphspair follows;surfacesisdressed_pieces(SurfacePieceisDressedPiece),MeshedBuilding::surfacesispieces, andSpawnContextisUploadContext;ashlar_bevy::preludeholds everything the game path names;BuildingSpawnedisAshlarBuildingSpawnedandAshlarLodisAshlarCrossfade;stdlib::stdlib()isstdlib::graphs();Content::materials,materials_on,buildingandwritearewrite_materials,write_materials_on,write_buildingandwrite_file, andflatten_bindingsisflatten_overrides.
-
Piecesays what it is a piece of.instance,part,elementandis_cutmoved intoPieceOrigin::Element, besidePieceOrigin::Group, and a piece gainedstoreyandside. 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, andlabel()names an entity.ashlar_bevy::drawableskeys bymesh_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.tomlforcesCCandCXX, because an activated Anaconda environment exports its own and that GCC builds a kernel whose static initialisers never run underrust-lld:Manifold::sphereandrefinedied on SIGFPE.crates/ashlar-manifold/tests/kernel_init.rsis 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 numberfmafreturns androundssis the floorfloorfreturns, so the goldens, the shipped-resolution cases,just conformanceandjust drawall 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 tocfg(target_arch = "x86_64"), so an ARM build is unaffected, and cargo reads them only for builds in this workspace: a game depending onashlar-materialcompiles it at its own baseline and bakes the same bytes more slowly.RUSTFLAGSin the environment replaces them rather than adding to them, which is why the CI workflow now repeats them beside its-D warnings. -
interp::floor_celluses the hardware floor again, reverting half of 18c88b5: under+sse4.1the bit-twiddled integer floor is slower than theroundssit was written to avoid, by 3.6% of the SOI bake and 8.0% of the brick.interp::cellkeeps its integer reduction, because no target feature turnsrem_euclidinto an instruction.interp::flooris gone with the branch it wrapped, and so is the test that pinned the two floors against each other, which had becomex.floor() == x.floor(). -
MaterialDefinition::surfacereplaces the three optional texture keys with aSurfaceenum:Plain,Files(base colour, normal, ORM, height and emissive, with theBakea content step recorded),Graph(Bake)andShader, over the new plain-dataParamValue. 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 writtenNone. This is a breaking change to the material RON form as much as to the Rust type: a library written against the oldbase_color_texture,normal_textureandorm_texturekeys no longer parses, and there is no compatibility form. The study'smaterials.ronis regenerated by the same writer that writes its textures, and the corporate library was converted by hand. See ADR 0003. -
ashlar_bevy::read_librarytakes a graph library beside the material library, as the design asks, andread_graphsreads and validates one from RON. ASurface::Graphis 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. ASurface::Shaderis 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_paramsconverts the parameter values aBakecarries into the graph crate's, which is the one place the twoParamValueenums meet. The preview passes a scene's graph library throughScene::graph_libraryor--graphs. -
reload::rebakere-makes compiled surfaces too, with the shader registry cleared beside the bake cache and for the same reason: aShaderKeynames 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_boundquantises 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
framingfunction 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_collidersandashlar_showcase::corporate::collidersare gone. -
A compiled graph's bound textures are cached by the plane behind each image rather than by the
ShaderKeyof the graph that asked for it, so two compiled graphs over one wall bake that wall once.planes::PlaneKeyis 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 — andruntime_bake::PlaneImageKeyis an image's: that key per lane, and the format. Both are exact keys rather than digests, for the reasonBakeKeyalready gives, andplanes::plane_keysanswers them without rasterising anything. Where a graph shares only some of its images,planes::rasterise_wanted,planes::plane_closureandgpu::GpuBaker::planes_wantedrasterise the planes it is missing and the planes those read, and skip the rest on either backend.study:concrete-wetafterstudy:concrete-cut-awarecosts 194 ms where it cost 1391,just referencesrenders the gallery in 19.5 s instead of 23.0 s, and not a byte of any shipped map moves.BakeCache::insert_staticnow takes handles rather than images, since a compiled graph may bind nothing new, andBakeCache::rasterisedandbound_imagesare what a caller counts the saving with. -
A GPU bake uploads each bound image once, rather than once per pass.
gpu::GpuBaker::rasterisekept 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 intoRgba32Floatbytes 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 1024study:painted-metalgoes from nine uploads and 1.19 s to five and 1.08 s,study:plasterfrom six and 1.08 s to four and 1.01 s, andstudy:brickfrom 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::uploadsis a counter of images written to the device, andthe_gpu_bake_is_the_cpu_bake_within_the_conformance_budgetholds 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::GpuBakercached 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 pastMAX_BOUND_TEXTURESbindings got three zeroed channels: at 512 texelsstudy:rusted-steel's roughness was 0.61 out over 98 % of the tile andstudy:weathered-paint's height over all of it, against a worst difference of 1.2e-5 now.just conformanceis 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_mipsderives 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_mipswrites|n_avg|there, one per texel per level, and level 0 is one — and the emitted fragment applies the bake's ownr' = sqrt(r^2 + (1 - a) * k)overmips::TOKSVIG_Kafter 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::widensis false there and the report says so in words. A graph withnormal_strengthat 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 compiledstudy:concreteat 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 ofsheet-brickmove 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 indocs/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 newlevelargument andComputeShader::levelare 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 conformanceruns on one test thread. Every case in that file opens its own adapter throughinitialize_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,ElementColliderandInstanceColliderlive inashlar, beside theGeometryMesherport they are the output of. They named no kernel type, andashlar-bevycould not see them where they were.ashlar-manifoldre-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
DeferredPrepassthe surface vanished; it now falls back to its plainStandardMaterialthere and says so.GpuBakePluginno 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::cleartakes theAssets<Shader>and frees the shaders nothing else could. -
A bake is faster again, to the same bytes.
MEMO_BUDGETis one gibibyte, because the graphs outgrew 256 MiB and a third of the interpreter's work had become recomputation;interp::cellis 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-assetsis--write-assets, and runs the catalog's content step, which for the showcase is the library export.--write-pngand--gpukeep their meaning beside it.
Changed from the original in-game version
- The three crates were renamed:
scrolls-buildingstoashlar,scrolls-building-meshtoashlar-manifold,scrolls-building-rendertoashlar-bevy, andveyra-buildingstoashlar-showcase. - The preview is a library plus a binary. Scenes come from a
Cataloga downstream game builds over its own content, and--sceneand--reference-scenesvalidate 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/designanddocs/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-pngand--gpu, unused dev-dependencies, andashlar_bevy::{Definitions, Graphs}, which moved into the preview. -
The reference gallery's pixel baseline comparison, with its
--reference-baselineflag. 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 underassets/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, thebenchmark:key shim with its module and example, the study's asset writer, and thematerial-libraryandsync-materialsexamples, whichexport-materialsreplaces. The showcase's four compiled surfaces areshowcase:*now. -
What nothing used any more (2026-09-25):
ashlar_bevy::DefinitionsandGraphs, which only the preview read and which now live there;ashlar-manifold's re-export ofashlar's meshed-building types, which are imported fromashlar; the twostdlib::*_HEIGHT_SCALE_METRESaliases; the showcase's grounding specs, which no scene fitted to terrain, and itspack-referenceexample, whose KTX2 output nothing read; andashlar_preview::Sourcewith the two catalog getters that returned it. -
The finished plans, progress logs, review notes and research captures under
docs/designanddocs/research. What stayed true is in the guides, the ADRs, the skills and the rustdoc; the rest is in git history.