Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

Context

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

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

Decision

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

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

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

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

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

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

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

Consequences

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

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

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

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

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

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

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

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

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

ashlar-surface now sits under both:

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

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

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

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

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

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

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

Three things follow from it.

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

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

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

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

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

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