Props
A game draws more than buildings. Trees, rocks, crates and debris are not recipes of solids, and we would not want them to be: a tree is grown by code that knows about branches, not about booleans. So ashlar does not generate props. Your generator makes the meshes, and ashlar gives them the pipeline a building already has: a baked file with levels of detail and colliders, a loader, and a spawner with distance bands. ADR 0007 has the reasoning; this page is how you use it.
A prop, in code
A Prop lives in ashlar::prop, in the plain ashlar crate.
It has an id, its levels of detail, its colliders and a bounding box, all in its own frame: metres, Y up.
#![allow(unused)] fn main() { use ashlar::prop::{Prop, PropCollider, PropLevel, PropPiece}; use ashlar::{FaceSource, TriangleMesh, VertexData, glam::DVec3}; let mut trunk = TriangleMesh::default(); // ... push_triangle for every face your generator makes ... trunk.attributes.insert("pivot".into(), VertexData::F32x3(pivots)); let tree = Prop::new( "forest:oak", vec![ PropLevel { until: Some(40.0), pieces: vec![ PropPiece { mesh: trunk, material: "forest:bark".into() }, PropPiece { mesh: crown, material: "forest:leaves".into() }, ], ..PropLevel::default() }, PropLevel { until: Some(150.0), cast_shadows: false, pieces: vec![PropPiece { mesh: coarse, material: "forest:oak-far".into() }], impostor: None, }, ], vec![PropCollider::Capsule { a: DVec3::ZERO, b: DVec3::Y * 4.0, radius: 0.4 }], ); }
A PropLevel is what a BakedLevel is for a building: an until distance, then the next level.
It also says whether it casts shadows, because a far level's shadow is smaller than a shadow-map texel and still costs a pass.
A PropPiece names its material by key in a material library, the same library your buildings wear.
It is a key and not a slot, since a prop has no instances whose palettes could rebind it.
The last level may carry an ImpostorRef instead of pieces: a baked atlas of views of the prop, drawn as one quad.
It names two KTX2 files, albedo and normal_depth, and carries the ImpostorLayout they were baked with.
The next section's content step bakes one from the prop itself,
and Impostors says what the pictures are and how they are drawn.
Colliders are a Capsule, an upright Cylinder or a ConvexHull.
That may look like a short list, but they are the shapes every physics engine has natively,
and a trunk, a post and a rock are made of them.
Anything else is a hull.
Prop::new fits bounds around every level and every collider.
Prop::check says what is wrong by path, levels[1].pieces[0].mesh.colors for example,
and both write and read run it.
Vertex data
A tree's wind needs a pivot per vertex, and a stylised shader wants ambient occlusion or a height gradient.
So a TriangleMesh carries colors, linear RGBA per vertex, and attributes,
named streams of VertexData: F32, F32x2, F32x3, F32x4 or U32, one value per position.
Every mesh operation keeps them with their vertex.
weld compares them exactly, so two corners with different pivots stay two vertices however close they stand.
unweld and project_uvs copy them to every corner, and append joins two meshes whose layouts agree.
push_triangle does not know about them, so a generator that pushes triangles extends its streams itself;
is_consistent and check catch it when it did not.
Buildings never have vertex data.
The kernel makes new vertices at every boolean, and there is no honest rule for what those would carry.
A building file refuses a mesh with colours or attributes,
which also keeps every building file readable by an ashlar from before vertex data existed.
In Bevy, ashlar_bevy::mesh uploads colours as Mesh::ATTRIBUTE_COLOR,
which the standard material multiplies into its base colour.
A named stream becomes ashlar_bevy::attribute(name, format), whose id is a hash of the name.
Your shader specialisation calls the same function with the same word,
so neither side keeps a table of attribute numbers.
The file
The content step writes a prop the way it writes a building:
#![allow(unused)] fn main() { let written = content.write_prop("props/oak.ashprop", &tree)?; println!("{:?} triangles by level", written.triangles); }
A .ashprop is the building file's container with its own twelve-byte identifier:
a version, an indented RON manifest with the levels, colliders and bounds,
and the binary mesh table, with positions narrowed to f32 as a building's are.
A mesh two levels share is stored once.
Prop::read refuses what it cannot account for by kind, as BakedBuilding::read does.
A server reads colliders from the same file with nothing but ashlar: no Bevy and no kernel.
The nearest level can be baked into the impostor the last level draws:
#![allow(unused)] fn main() { use ashlar_content::ImpostorSettings; let written = content.write_impostor( "props/oak", // albedo.ktx2 and normal-depth.ktx2 go here &tree, &graphs, &definitions, // the library as authored, graphs and all ImpostorSettings::default(), // 12 x 12 frames of 128 texels, upper hemisphere )?; tree.levels.last_mut().expect("a level").until = Some(150.0); tree.levels.push(PropLevel { cast_shadows: false, impostor: Some(written.impostor), ..PropLevel::default() }); content.write_prop("props/oak.ashprop", &tree)?; }
Each piece's material is looked up in definitions.
A Surface::Graph is baked in memory and its maps are what the rasteriser samples;
a Surface::Plain is its colour.
The colour multiplier, repeat, offset, cut-out and sides come from the definition,
so the pictures are of what the game draws.
Hand it the library as authored, not the one write_materials returned:
a Surface::Files definition is refused, because the step writes those files and does not read them back,
and a Surface::Shader is refused because a live shader has no one picture to take.
The impostor is cached like a building, by the nearest level, the definitions and graphs it wears and the grid.
Props themselves are not cached by Content::cache.
That may seem like an oversight, but there is nothing to cache:
writing a prop is serialising it, and the cache key would be those same bytes.
write_file already leaves an unchanged file alone.
In the game
AshlarPlugin loads .ashprop files as PropAssets.
The loader uploads every piece's mesh once, as a labelled sub-asset (props/oak.ashprop#level0/piece1),
so every copy of a prop draws the same meshes and batches.
For the handful of props a scene places by hand, spawn an AshlarProp:
#![allow(unused)] fn main() { commands.spawn(( AshlarProp { prop: server.load("props/oak.ashprop"), materials: server.load("materials/library.materials.ron"), }, Transform::from_xyz(12.0, 0.0, -3.0), )); }
Once both files have loaded, the plugin spawns one child per piece per level,
with an AshlarPropPiece, its level's VisibilityRange band, and NotShadowCaster on a level that casts none.
The bands and the crossfade are exactly a building's, including Bands::Auto on WebGL2.
A piece a coarser level did not change is one entity whose band runs on through that level.
The root gets AshlarSpawned and an AshlarPropSpawned message, or AshlarFailed with the reason,
and a changed file respawns it.
An impostor level is one more child: impostor_quad wearing an ImpostorMaterial over the level's two atlas files,
marked AshlarPropImpostor, in the level's band and with NotShadowCaster.
The quad and the material are made once per prop file and shared by every copy.
AshlarPlugin adds the ImpostorPlugin that draws them; adding it yourself as well is harmless.
A forest is a different matter.
Tens of thousands of entities with a component each is the wrong shape,
so a game drawing that many reads PropAsset::meshes and the materials itself and manages its own instances.
prop_level_range(&prop, level, crossfade) is the band AshlarProp would have given each level,
so a forest's own entities switch at the same distances.
AshlarProp is for the rest.
cargo run --example prop -p ashlar-bevy builds a small prop in code, writes it and loads it back.
Tables
A prop can carry named tables of numbers in Prop::tables, for data a game's
shader reads once per prop rather than once per vertex: a tree's wind pivots,
say, indexed by a small per-vertex attribute. ashlar checks that the values are
finite and stores them in the file's manifest; what they mean is the game's.