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.