Impostors
A forest's far level cannot be geometry.
An impostor is a prop drawn as one camera-facing quad wearing pictures of itself:
ashlar-impostor takes the pictures in the content step,
and ashlar-bevy's impostor module draws them in the game.
The game half is featureless and links no baker,
the same split as buildings and materials (see Using ashlar in a game).
The decision is ADR 0007.

What is baked
An atlas is a square grid of frames, frames across and down,
each frame_px texels square.
Every frame is an orthographic picture of the prop's bounding sphere
from one direction, and the directions are laid out hemi-octahedrally:
the middle of the grid looks straight down, the border looks along the horizon,
and the frames crowd towards the border, where a walking camera sees a tree from.
A prop seen from below, a floating rock say, asks for the full octahedron instead.
Two maps come out, both RGBA8:
- albedo, sRGB with straight alpha. Alpha is coverage, from sixteen samples a texel, so edges are anti-aliased and a leaf card's cut-out is the leaf's own.
- normal and depth, linear.
RGBis the shading normal in the prop's own frame with any normal map applied, andAis where the surface is along the frame's direction: 0 at the sphere's near side, 0.5 at its centre, 1 at its far side.
Both come with a mip chain that holds each frame's alpha coverage at the 0.5 cutoff, so a cut-out tree keeps its density with distance rather than thinning away.
The content step
A prop's impostor is one call, Content::write_impostor,
which bakes the prop's nearest level in the materials it wears and writes both maps;
Props shows it, and AshlarProp then draws the impostor as the prop's last level.
The rest of this section is the baker underneath, for a step that pictures something that is not a Prop.
#![allow(unused)] fn main() { use ashlar::TriangleMesh; use ashlar_impostor::{BackNormal, ImpostorPiece, ImpostorRequest, PieceMaterial, bake}; use ashlar_material::ktx2::Supercompression; fn write_tree(bark: &TriangleMesh, leaves: &TriangleMesh, bark_set: &PieceMaterial, leaf_set: &PieceMaterial) -> anyhow::Result<()> { let atlas = bake(&ImpostorRequest { pieces: &[ ImpostorPiece { mesh: bark, material: bark_set }, ImpostorPiece { mesh: leaves, material: leaf_set }, ], frames: 12, frame_px: 128, hemispherical: true, threads: None, })?; let files = atlas.ktx2(Supercompression::Zstd)?; std::fs::write("assets/props/oak/albedo.ktx2", files.albedo)?; std::fs::write("assets/props/oak/normal-depth.ktx2", files.normal_depth)?; std::fs::write("assets/props/oak/impostor.ron", ron::to_string(&atlas.layout)?)?; Ok(()) } }
A piece is a TriangleMesh, in the prop's own frame with UVs in metres,
and a PieceMaterial: a base colour map, an optional normal map,
the metres one repeat covers, a colour multiplier, an alpha cutoff and whether the piece has two sides.
PieceMaterial::from_texture_set takes level 0 of a texture set ashlar-material baked.
Give the pieces what the real prop's materials show,
because the picture is taken of what the rasteriser samples.
Two-sided pieces need one more decision, because Bevy itself makes it two ways.
A double-sided StandardMaterial turns a back face's normal round
when the mesh has a normal map or has no tangents,
and keeps it when the mesh has tangents and no normal map,
which is how ashlar_bevy::mesh uploads a leaf card.
BackNormal::Flip, the default, is the first; BackNormal::Keep is the second,
and it is what foliage with normals pointing out of the crown wants.
zstd supercompression needs ashlar-impostor's zstd feature.
ImpostorLayout is plain serde data in ashlar::impostor, so the game reads it without the baker.
Choosing the grid
The atlas is frames * frame_px texels across, and the two maps with their chains
are about eleven bytes a texel.
| Grid | Atlas | Both maps, uncompressed |
|---|---|---|
| 8 x 8 of 128 | 1024 | 11 MB |
| 12 x 12 of 128 | 1536 | 25 MB |
| 12 x 12 of 256 | 3072 | 100 MB |
Twelve across puts neighbouring frames about eight degrees apart at the horizon, which is where blending between them stops showing. A frame need be no larger than the prop ever is on screen at the distance the impostor takes over: a tree two hundred pixels tall at forty metres wants 256, and the same tree from eighty metres wants 128. Keep the atlas at 4096 or under for WebGL2.
A 15,000-triangle tree at 12 x 12 frames of 256 texels bakes in about a second on 32 threads. The bytes are the same whatever the thread count.
The game
A prop whose last level is an impostor needs nothing here: AshlarProp spawns the quad.
What follows is the same by hand, for an impostor that is not a prop's level,
or a forest whose instances the game manages itself.
#![allow(unused)] fn main() { use ashlar_bevy::prelude::*; use ashlar_bevy::ashlar::impostor::ImpostorLayout; use bevy::prelude::*; fn spawn_oak( mut commands: Commands, server: Res<AssetServer>, mut meshes: ResMut<Assets<Mesh>>, mut materials: ResMut<Assets<ImpostorMaterial>>, layout: Res<OakLayout>, // the RON the content step wrote, read at startup ) { let (albedo, normal_depth) = load_impostor_images( &server, "props/oak/albedo.ktx2", "props/oak/normal-depth.ktx2", ); let material = materials.add(impostor_material(&layout.0, albedo, normal_depth)); let quad = meshes.add(impostor_quad(&layout.0)); for position in [Vec3::new(40.0, 0.0, 12.0), Vec3::new(55.0, 0.0, -30.0)] { commands.spawn(( Mesh3d(quad.clone()), MeshMaterial3d(material.clone()), Transform::from_translation(position), )); } } #[derive(Resource)] struct OakLayout(ImpostorLayout); }
Add ImpostorPlugin beside DefaultPlugins, or AshlarPlugin, which adds it.
One material and one quad serve every instance of a prop,
and each instance's Transform places, turns and scales it:
the frames are read in the instance's own frame, so a tree turned a quarter round
shows the side of it that now faces the camera.
impostor_quad's positions span the bounding sphere's box and are never drawn,
because the vertex stage builds the billboard from the corners' UVs.
They are there so Bevy's frustum culling and VisibilityRange see the prop's real extent.
A VisibilityRange on the real prop that ends where the impostor's begins,
with the same margins, is the level-of-detail switch, and Bevy dithers between the two;
prop_level_range answers those bands for a Prop's levels.
The base StandardMaterial inside ImpostorMaterial is the lighting:
its colour tints the atlas, and its roughness, reflectance, diffuse transmission
and fog apply as they would to the prop.
impostor_material sets it rough, dielectric, cut out at 0.5, unculled and forward.
How it draws
The vertex stage turns the quad to face the camera around the instance's
bounding-sphere centre, 2 * radius * scale across,
a little wider close up where perspective shows more of a sphere than its radius.
The fragment finds the direction from the centre to the camera in the instance's frame, maps it onto the grid, and takes the three frames of the grid triangle it lands in, weighted by their barycentric coordinates. That choice is per instance, so every fragment of one impostor reads the same three frames. Each frame is read where the fragment's own ray meets it: the ray is intersected with the frame's plane, and the frame's depth moves it to where the surface was, twice. The three reads are blended, colour weighted by their alpha, and the result is cut out at 0.5 and handed, with the blended normal rotated into the world, to Bevy's own lighting through the base material. So an impostor is lit by the same sun and shadowed by the same shadow maps as the geometry around it.
The depth, normal and motion-vector prepasses draw the same billboard with the same cut-out, so the material works under MSAA and temporal anti-aliasing, and on a camera that also runs a deferred prepass.
What it does not do
- Cast shadows. A billboard turned to the sun is a different picture from the one the camera sees, and its shadow swims as the camera moves. The material has no shadow pass. Past the shadow cascades that is usually invisible; nearer, give the prop a cheap proxy that casts.
- Shadow itself. The pictures are taken unlit, so a crown does not darken its own trunk
the way the real tree's shadow maps do. Seen side by side with
--no-shadows, the example's tree and impostor light alike. - Write per-texel depth. The quad's depth is the plane through the prop's centre, so an impostor meets the ground and its neighbours as a flat card through its middle.
- Draw deferred. The material is always forward.
Seeing it
cargo run --release --example impostor -p ashlar-bevy grows a tree in code,
bakes it, and orbits the tree and its impostor side by side,
with a second pair behind them turned a quarter round and scaled down.
--show mesh and --show impostor stand one alone so two screenshots from one camera compare exactly;
--yaw, --pitch, --distance, --no-shadows, --no-parallax, --taa, --prepass,
--atlas <dir> and --screenshot <path> are the rest.
just draw runs the impostor on five cameras against a real GPU.