Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

A procedural tree on the left and its impostor on the right, from the same camera

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. RGB is the shading normal in the prop's own frame with any normal map applied, and A is 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.

GridAtlasBoth maps, uncompressed
8 x 8 of 128102411 MB
12 x 12 of 128153625 MB
12 x 12 of 2563072100 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.