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.