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

3D: meshes, materials and the sun

The same engine draws 3D: the same ECS, systems, clock and tools, with lit meshes under the 2D layer. Turn it on in Cargo.toml:

[dependencies]
ponko = { version = "0.0.1", features = ["3d"] }

Games that leave it off pay nothing for it. The C library (ponko_ffi) always has it.

A 3D entity has a Transform3D (where it is, which way it faces, its size) and a Model (which mesh to draw, and a tint). Here’s a village of houses that turn slowly:

#![allow(unused)]
fn main() {
/// Turns around its own Y axis, this many radians a second.
struct Spin(f32);

fn village(ctx: &mut Ctx) {
    let grass = ctx.create_material(MaterialDesc {
        roughness: 0.9,
        ..MaterialDesc::color(Color::hex(0x5E8C4A))
    });
    let ground = ctx.create_mesh(&MeshData::plane(100.0), grass);
    ctx.world.spawn((Transform3D::default(), Model::new(ground)));

    let house = ctx.load_mesh("models/house.glb"); // its walls and roof keep their materials
    for i in 0..5 {
        let at = vec3(i as f32 * 4.0 - 8.0, 0.0, -6.0);
        ctx.world.spawn((Transform3D::at(at), Model::new(house), Spin(0.5)));
    }

    ctx.camera3d.position = vec3(0.0, 6.0, 12.0);
    ctx.camera3d.look_at(vec3(0.0, 0.0, -4.0));
    ctx.lighting.sun_direction = vec3(-0.8, -0.6, -0.4); // late afternoon: long shadows
}

fn spin(ctx: &mut Ctx) {
    let dt = ctx.dt();
    ctx.world.query::<(&mut Transform3D, &Spin)>().for_each(|(t, s)| {
        t.rotation *= Quat::from_rotation_y(s.0 * dt);
    });
}
}
/// Turns around its own Y axis, this many radians a second.
struct Spin { float speed; };

static void village(crabby::ctx& ctx) {
    auto grass_look = crabby::material_of(crabby::rgb(.37f, .55f, .29f));
    grass_look.roughness = 0.9f;
    auto grass = ctx.create_material(grass_look);
    auto ground = ctx.create_mesh(crabby::mesh_data::plane(100), grass);
    ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(ground));

    auto house = ctx.load_mesh("models/house.glb");   // its walls and roof keep their materials
    // The house's walls, drawn with a shader of our own: keep what they
    // were made from (textures, colors) and change the shader.
    auto weathered = ctx.create_shader("weathered.wgsl",
        "fn surface(s: Surface) -> Surface { var o = s; o.albedo *= 0.8; o.roughness = 0.95; return o; }");
    for (auto [walls, desc] : ctx.loaded_materials("walls")) {
        desc.has_shader = true;
        desc.shader = weathered;
        ctx.update_material(walls, desc);
    }
    for (int i = 0; i < 5; ++i) {
        crabby::vec3 at{float(i) * 4 - 8, 0, -6};
        ctx.world().spawn(crabby::transform3d_at(at), crabby::model_of(house), Spin{0.5f});
    }

    auto camera = ctx.camera3d();
    camera.position = {0, 6, 12};
    ctx.set_camera3d(camera);
    ctx.camera3d_look_at({0, 0, -4});
    auto light = ctx.lighting();
    light.sun_direction = {-0.8f, -0.6f, -0.4f};   // late afternoon: long shadows
    ctx.set_lighting(light);
}

static void spin(crabby::ctx& ctx) {
    const float dt = ctx.dt();
    ctx.world().each([&](crabby::transform3d& t, const Spin& s) {
        t.rotation *= crabby::quat_from_rotation_y(s.speed * dt);
    });
}

Try the demo: cargo run --release --example 3d --features 3d. Drag with the left mouse button to orbit and use the wheel to zoom. CUBES=100000 fills the field with that many spinning cubes.

Space

3D space is right-handed with +Y up. The default camera looks along -Z. Units are yours to choose; meters work well. A Transform3D has a position, a rotation (a quaternion, Quat) and a scale, and is built the same way as a 2D one:

#![allow(unused)]
fn main() {
Transform3D::at(vec3(0.0, 1.0, -5.0))
    .rotated(Quat::from_rotation_y(0.5))
    .scaled(2.0)
}

t.looking_at(target) turns it to face a point, and t.forward() is the way it faces (its -Z axis). C++ has crabby::transform3d_at(position, rotation, scale), crabby::looking_at, crabby::forward, and * on quaternions and vectors.

Meshes and materials

A material says how a surface looks: its color (and an optional texture), how rough it is (0 is a mirror, 1 is chalk), how metallic (0 for most things, 1 for bare metal) and the light it gives off (emissive). Lighting is physically based, as in glTF, so models made in Blender look the same here.

A mesh is triangles with materials. There are three ways to get one:

  • Shapes: MeshData::cube(size), plane(size) and sphere(radius, segments), then ctx.create_mesh(&data, material).
  • Your own triangles: fill a MeshData with positions, indices (three per triangle, counter-clockwise from the front) and optionally normals, texture coordinates and colors (one per corner, multiplying the material’s color). Normals you leave out are computed. A second set of texture coordinates, uvs1 (glTF’s TEXCOORD_1), is there for your own shaders to read as v.uv1 and s.uv1: texture layers, masks, lightmaps. create_mesh checks that the data fits together.
  • glTF files: ctx.load_mesh("models/house.glb") loads a .glb (or a .gltf with its files) from assets/, with every mesh in its scene, its materials with all their maps (base color, normal, occlusion, roughness and metalness, emissive), vertex colors, transparency and double-sidedness. Loading the same file again returns the same mesh.

Meshes and materials are cheap handles: make each one once, at startup, and spawn as many models of it as you like. A Model tint (Model::new(mesh).with_color(c)) multiplies the mesh’s colors, so a thousand cubes can come in a thousand colors from one mesh.

Skinned meshes

A skinned mesh bends with a Skeleton: each corner follows up to four joints with weights that add up to one. Load the mesh and skeleton from the same glTF, then edit pose to move joints relative to their parents. find looks up a joint by name. joint_matrix gives its current matrix in the entity’s space; multiply by the entity transform for world space.

#![allow(unused)]
fn main() {
    let mesh = ctx.load_mesh("models/character.glb");
    let skeleton = ctx.load_skeleton("models/character.glb");
    let hand = skeleton.find("hand_r").expect("model needs hand_r");
    let character = ctx.world.spawn((Transform3D::default(), Model::new(mesh), skeleton));
    let skeleton = ctx.world.get_mut::<Skeleton>(character).unwrap();
    skeleton.pose[hand].rotation = Quat::from_rotation_y(0.3);
    let local_hand = skeleton.joint_matrix(hand);
    let transform = ctx.world.get::<Transform3D>(character).unwrap();
    let world_hand = (transform.matrix() * local_hand).transform_point3(Vec3::ZERO);
    println!("Hand at {world_hand}");
}
    auto mesh = ctx.load_mesh("models/character.glb");
    auto character = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(mesh));
    ctx.load_skeleton(character, "models/character.glb");
    auto hand = ctx.find_joint(character, "hand_r").value();
    auto pose = ctx.joint_pose(character, hand);
    pose.rotation = crabby::quat_from_rotation_y(0.3f);
    ctx.set_joint_pose(character, hand, pose);
    auto m = ctx.joint_matrix(character, hand); // model space, column-major
    std::printf("Hand at %g %g %g (model space)\n", m[12], m[13], m[14]);

Each pose starts at rest. reset_pose() restores it; joints() exposes the joint names, parents and rest transforms. A system can pose the skeleton directly, or sample the animation clips described below:

#![allow(unused)]
fn main() {
fn sway(ctx: &mut Ctx) {
    let time = ctx.time() as f32;
    ctx.world.query::<(&mut Skeleton, &Sway)>().for_each(|(s, phase)| {
        for (i, pose) in s.pose.iter_mut().enumerate() {
            pose.rotation = Quat::from_rotation_z((time * 1.5 + phase.0 - i as f32 * 0.45).sin() * 0.16);
        }
    });
    ctx.world.query::<(&mut Skeleton, &Bend)>().for_each(|(s, bend)| {
        bend.0.sample_blended(time.rem_euclid(bend.0.duration()), 0.5, s);
    });
    ctx.world.query::<&mut Animator>().for_each(|a| {
        a.set_bool(0, ((time / 3.0) as u32).is_multiple_of(2));
    });
    if ctx.key_pressed(Key::Escape) {
        ctx.quit();
    }
}
}
    float time = float(ctx.time());
    for (std::size_t i = 0; i < ctx.joint_count(tentacle); i++) {
        auto pose = ctx.joint_pose(tentacle, i);
        pose.rotation = crabby::quat_from_rotation_z(std::sin(time * 1.5f - i * 0.45f) * 0.16f);
        ctx.set_joint_pose(tentacle, i, pose);
    }

AttachToJoint keeps a rigid object on a joint, with an offset in that joint’s space. It updates the object’s Transform3D just before drawing, so systems see the previous frame’s attachment transform; use joint_matrix when you need the current pose during a system. SkinOf instead bends another mesh with the owner’s skeleton and draws it at the owner’s transform. Use it for clothes and interchangeable parts: joints bind by name, even when the part was made with a separate skeleton. The part needs no transform of its own.

#![allow(unused)]
fn main() {
                ctx.world.spawn((Model::new(band), SkinOf(tentacle)));
                let tip = skeleton.find("joint_7").unwrap();
                ctx.world.spawn((
                    Transform3D::default(),
                    Model::new(ball),
                    AttachToJoint {
                        skeleton: tentacle,
                        joint: tip,
                        offset: Transform3D::at(vec3(0.0, 0.12, 0.0)),
                    },
                ));
}
    // Clothes: a mesh bound to the same joint names, posed by the tentacle.
    auto band = ctx.create_skinned_mesh(tube(2.2f, 2.5f, 0.25f), gold, tentacle);
    ctx.set_skin_of(ctx.world().spawn(crabby::model_of(band)), tentacle);
    // A ball riding the tip, a little above it.
    auto tip = *ctx.find_joint(tentacle, "joint_7");
    auto rider = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(ball));
    ctx.attach_to_joint(rider, tentacle, tip, crabby::transform3d_at({0, 0.12f, 0}));

You can also make everything in code. Put parents before their children:

#![allow(unused)]
fn main() {
fn chain() -> Skeleton {
    Skeleton::new(
        (0..JOINTS)
            .map(|i| Joint {
                name: format!("joint_{i}"),
                parent: i.checked_sub(1),
                rest: Transform3D::at(Vec3::Y * if i == 0 { 0.0 } else { STEP }),
            })
            .collect(),
    )
}
}
constexpr int chain_joints = 8;
constexpr float chain_step = 0.5f;
static std::vector<crabby::joint> chain() {
    std::vector<crabby::joint> joints;
    for (int i = 0; i < chain_joints; i++)
        joints.push_back({"joint_" + std::to_string(i), i - 1, crabby::transform3d_at({0, i == 0 ? 0 : chain_step, 0})});
    return joints;
}

Fill MeshData::joints and weights with one four-element array per corner. Here a subdivided cylinder blends between its two nearest joints:

#![allow(unused)]
fn main() {
fn tube(bottom: f32, top: f32, radius: f32) -> MeshData {
    const SLICES: usize = 12;
    const ROWS: usize = 28;
    let mut data = MeshData::default();
    for row in 0..=ROWS {
        let y = bottom + (top - bottom) * row as f32 / ROWS as f32;
        let along = (y / STEP).clamp(0.0, (JOINTS - 1) as f32);
        let lower = (along.floor() as usize).min(JOINTS - 2);
        let blend = along - lower as f32;
        for slice in 0..SLICES {
            let angle = std::f32::consts::TAU * slice as f32 / SLICES as f32;
            data.positions.push(vec3(radius * angle.cos(), y, radius * angle.sin()));
            data.joints.push([lower as u16, (lower + 1) as u16, 0, 0]);
            data.weights.push([1.0 - blend, blend, 0.0, 0.0]);
        }
    }
    for row in 0..ROWS {
        for slice in 0..SLICES {
            let a = (row * SLICES + slice) as u32;
            let b = (row * SLICES + (slice + 1) % SLICES) as u32;
            let c = a + SLICES as u32;
            let d = b + SLICES as u32;
            data.indices.extend([a, c, b, b, c, d]);
        }
    }
    for slice in 1..SLICES - 1 {
        let last = (ROWS * SLICES) as u32;
        data.indices.extend([0, slice as u32, (slice + 1) as u32]);
        data.indices
            .extend([last, last + (slice + 1) as u32, last + slice as u32]);
    }
    data
}
}
static crabby::mesh_data tube(float bottom, float top, float radius) {
    const int slices = 12, rows = 28;
    std::vector<crabby::vec3> positions;
    std::vector<std::uint32_t> indices;
    std::vector<std::array<std::uint16_t, 4>> joints;
    std::vector<std::array<float, 4>> weights;
    for (int row = 0; row <= rows; row++) {
        float y = bottom + (top - bottom) * row / rows;
        float along = std::clamp(y / chain_step, 0.0f, float(chain_joints - 1));
        int lower = std::min(int(along), chain_joints - 2);
        float blend = along - lower;
        for (int slice = 0; slice < slices; slice++) {
            float angle = 6.2831853f * slice / slices;
            positions.push_back({radius * std::cos(angle), y, radius * std::sin(angle)});
            joints.push_back({std::uint16_t(lower), std::uint16_t(lower + 1), 0, 0});
            weights.push_back({1 - blend, blend, 0, 0});
        }
    }
    for (std::uint32_t row = 0; row < rows; row++)
        for (std::uint32_t slice = 0; slice < slices; slice++) {
            std::uint32_t a = row * slices + slice, b = row * slices + (slice + 1) % slices;
            indices.insert(indices.end(), {a, a + slices, b, b, a + slices, b + slices});
        }
    crabby::mesh_data data(positions, indices);
    data.set_skin(joints, weights);
    return data;
}

Pass the rest skeleton to create_skinned_mesh; it records the bind pose:

#![allow(unused)]
fn main() {
        let skeleton = chain();
        let mesh = ctx.create_skinned_mesh(&tube(0.0, 3.5, 0.22), green, &skeleton);
        // A separate skeleton with matching names demonstrates name binding.
        let band = ctx.create_skinned_mesh(&tube(2.2, 2.5, 0.25), gold, &chain());
}
    auto tentacle = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}));
    ctx.set_skeleton(tentacle, chain());
    auto mesh = ctx.create_skinned_mesh(tube(0, 3.5f, 0.22f), green, tentacle);
    ctx.world().set(tentacle, crabby::model_of(mesh));

Try cargo run --example skinning --features 3d for three swaying tentacles, a band sharing one skeleton and a sphere riding its tip. Skinning runs on the GPU in a compute pass before any drawing pass uses the mesh, including shadows and other cameras. WebGL2 uses a CPU fallback. Custom material shaders need nothing special: they receive the deformed geometry.

Animation clips

ctx.load_clips(path) loads every animation in a skinned glTF file. ctx.load_clip(path, name) selects one, and lists available names if missing. The try_load_clips and try_load_clip variants return Result instead. Clips share their data when cloned and are cached with the file. Morph-weight channels are currently ignored; translation, rotation and scale support Step, Linear and glTF CubicSpline interpolation.

Sample clips into a skeleton each frame. Time clamps to the clip’s endpoints; use the remainder for looping, guarding zero-duration clips. For a crossfade, sample the first clip and blend toward the second with a weight from 0 to 1:

#![allow(unused)]
fn main() {
    let mut skeleton = ctx.load_skeleton("models/character.glb");
    let walk = ctx.load_clip("models/character.glb", "walk");
    let run = ctx.load_clip("models/character.glb", "run");
    let time = ctx.time() as f32;
    let loop_time = |clip: &AnimationClip| {
        if clip.duration() > 0.0 {
            time.rem_euclid(clip.duration())
        } else {
            0.0
        }
    };
    skeleton.reset_pose();
    walk.sample(loop_time(&walk), &mut skeleton);
    run.sample_blended(loop_time(&run), 0.5, &mut skeleton);
    // Keep clips and the skeleton as components/resources for subsequent frames.
}
    auto walk = ctx.load_clip("models/character.glb", "walk");
    auto run = ctx.load_clip("models/character.glb", "run");
    float t = float(ctx.time());
    ctx.reset_pose(character);
    ctx.sample_clip(walk, character, std::fmod(t, walk.duration()));
    ctx.sample_clip(run, character, std::fmod(t, run.duration()), 0.5f);

Only animated properties change; reset the pose first when each frame should start at rest. Blending uses vector lerp and shortest-path quaternion slerp. Clips bind directly to their source skeleton’s joint indices. On another skeleton, they match names (the first match wins), caching that mapping on the first sample; later samples and skeleton clones allocate nothing. Missing joints are skipped. Sample once during setup to prepare a different skeleton’s binding before gameplay. Each clip retains these mappings and their shared joint lists until the clip is dropped.

Clips can also be built in code; this short bend is applied over the procedural sway in the skinning example:

#![allow(unused)]
fn main() {
fn bend_clip(skeleton: &Skeleton) -> AnimationClip {
    let rotation = AnimationTrack::new(
        vec![0.0, 1.0, 2.0],
        [0.0, 0.3, 0.0]
            .map(|angle| Vec4::from_array(Quat::from_rotation_z(angle).to_array()))
            .to_vec(),
        Interpolation::Linear,
    );
    AnimationClip::new(
        "bend",
        skeleton,
        vec![JointAnimation {
            joint: 1,
            name: "joint_1".into(),
            translation: None,
            rotation: Some(rotation),
            scale: None,
        }],
    )
}
}

C++ can’t build clips in code yet: load them from glTF files with ctx.load_clip.

AnimationTrack stores vector values as xyz with w ignored and quaternion values as xyzw. Cubic tracks have three values per key: incoming tangent, value, outgoing tangent. Tangents are derivatives per second; cubic rotation results are normalized. You can keep time and fade weights in your own components, or use an animator.

Animation state machines

AnimationGraph shares an immutable definition through Arc; each Animator keeps its own parameters, state and clocks. State and parameter indices follow vector order. This two-state graph switches between code-made clips:

#![allow(unused)]
fn main() {
fn bend_animator(skeleton: &Skeleton) -> Animator {
    let idle = AnimationClip::new("idle", skeleton, vec![]);
    let bend = bend_clip(skeleton);
    let states = vec![
        AnimationState::new(Motion::Clip(idle)),
        AnimationState::new(Motion::Clip(bend)),
    ];
    let transition = |from, to, active| AnimationTransition {
        from: Some(from),
        to,
        conditions: vec![AnimationCondition::Bool(0, active)],
        exit_time: None,
        duration: 0.12,
        offset: 0.0,
    };
    let graph = AnimationGraph::new(
        vec![AnimationParameter::Bool(false)],
        states,
        vec![transition(0, 1, true), transition(1, 0, false)],
        0,
    );
    Animator::new(graph)
}
}
    crabby::animation_graph_builder b;
    auto moving = b.add_bool();
    auto idle = b.add_state(crabby::motion::of(ctx.load_clip("models/character.glb", "idle")));
    auto walking = b.add_state(crabby::motion::of(walk));
    b.add_condition(b.add_transition(idle, walking, 0.12f), CRABBY_CONDITION_BOOL, moving, 1);
    b.add_condition(b.add_transition(walking, idle, 0.12f), CRABBY_CONDITION_BOOL, moving, 0);
    auto graph = b.build(idle); // shared: give it to every character
    ctx.set_animator(character, graph);

Spawn an Animator and Skeleton on the same entity for automatic playback. The engine advances every animator each frame after your frame systems and before your draw systems. So frame systems see last frame’s pose, and draw systems this frame’s. run_headless has no frames; there, or to pose one at another point, set manual = true and call update yourself:

#![allow(unused)]
fn main() {
    let mut skeleton = chain();
    let mut animator = bend_animator(&skeleton);
    animator.manual = true;
    animator.set_bool(0, true);
    animator.update(0.016, &mut skeleton);
    // Or spawn (Animator, Skeleton) together for automatic updates.
    animator.crossfade(0, 0.12, 0.0);
    animator.update(0.016, &mut skeleton);
    for name in animator.drain_events() {
        println!("Animation event: {name}");
    }
}
    ctx.animator_set_bool(character, moving, true);
    // Or pose it yourself instead of every frame:
    ctx.animator_set_manual(character, true);
    ctx.animator_update(character, 0.016f);
    ctx.animator_crossfade(character, idle, 0.12f);
    while (auto name = ctx.animator_next_event(character)) std::printf("Animation event: %s\n", name->c_str());

Motion::Blend1D linearly blends neighbouring sorted thresholds on a float parameter, clamping beyond the ends. Motion::Blend2D uses normalized inverse squared distance on two floats; an exact child point selects exactly that child (first wins for coincident points). Trees can contain clips or nested trees through MotionChild, whose positive time_scale multiplies seconds. A tree’s duration is the longest child duration divided by its time scale. Each leaf loops at its own duration for looping states; clamped states hold leaves at their ends. State speed can also be multiplied by a float parameter; negative parameter values pause playback. Missing tracks use the rest pose. Rotations use incremental shortest-path slerp in child order.

Transitions are tested in vector order, first match wins, at most once per update. All conditions must pass: float greater/less, bool equality, or a set trigger. Only a selected transition consumes its triggers. An exit time is a minimum on the source’s unwrapped normalized clock, checked against the end of the update; an any-state transition ignores it and skips its current target. The target starts at offset and advances for the update’s full dt. play and crossfade queue commands for the next update (last command wins), which override graph transitions for that update.

Fades last a fixed number of seconds, independent of playback speed. Both states advance during an ordinary fade. An interruption starts from the last displayed mixed pose and freezes that pose as its source, preserving continuity. Only the destination state tests transitions and emits events during a fade. Events use normalized times in [0, 1); zero fires on a loop wrap, not initial entry. Crossed events, including multiple loop wraps, arrive in time order via drain_events. The reusable buffer retains at most 64 events between drains; excess events are dropped. Sampling buffers allocate on first update or when the skeleton layout changes; warm up clips on the target layout before gameplay.

Layers and root motion

A layer plays its own graph over the base animation, with its own parameters, clock and events: a wave over a walk, or an additive lean. Its weight fades it in and out, and a JointMask limits it to part of the skeleton (JointMask::descendants picks a joint and everything under it).

#![allow(unused)]
fn main() {
    let mut animator = bend_animator(skeleton);
    let waving = AnimationGraph::new(vec![], vec![AnimationState::new(Motion::Clip(wave))], vec![], 0);
    let mut layer = AnimationLayer::new(waving);
    layer.mask = JointMask::descendants(skeleton, "joint_3"); // that joint and its children
    layer.weight = 0.8;
    let wave = animator.add_layer(layer);
    animator.layer_mut(wave).weight = 0.5; // fade it any time; its graph has its own parameters
    // The clip's own steps move the entity, instead of sliding in place.
    animator.root_joint = 0;
    animator.apply_root_motion = true;
}
    crabby::animation_graph_builder upper;
    upper.add_state(crabby::motion::of(ctx.load_clip("models/character.glb", "wave")));
    auto waving = upper.build(0);
    // The wave plays on the spine and everything under it, over the walk.
    auto wave = ctx.animator_add_layer(character, waving, crabby::layer_blend::override_pose, "spine");
    ctx.animator_set_layer_weight(character, wave, 0.8f);
    // The walk's own steps move the character, instead of sliding in place.
    ctx.animator_set_root_motion(character, crabby::root_motion::apply, *ctx.find_joint(character, "hips"));

Layers apply in the order they were added, each on the pose below it. LayerBlend::Override blends toward the layer’s pose by its weight. LayerBlend::Additive adds the layer’s difference from a reference pose: reference_pose if you set one, otherwise the layer’s current motion at time zero. Layers update even at weight zero, so they stay in time.

With root motion, the root joint’s horizontal movement and turning (yaw) are taken out of the pose; height, pitch and roll stay in it. root_motion() returns what the latest update took out, in the entity’s own axes. Whole loops count, faster states move further, and crossfades blend the two states’ movement. apply_root_motion also moves the entity’s Transform3D by it during the engine’s automatic updates; extract_root_motion alone leaves moving the entity to you, for example to drive a physics character instead. Pick a root joint at the top of the hierarchy (often the hips): its axes are the ones extraction uses.

IK and look-at

Register procedural corrections with Game::late_system: it runs once per frame after automatic animators and before drawing, in registration order, with frame dt and system timings. It does not run in headless simulations. two_bone_ik rotates a direct three-joint chain toward a world target and uses a world-space pole direction to choose its bend. Unreachable targets extend to full reach; weight zero preserves the pose. look_at_joint aims a joint-local axis, with a correction angle limit in radians and a blend weight. Apply it parent first with separate weights to spread aim over a chain.

Entity non-uniform scale is removed before solving. Exact two-bone reach requires uniform positive joint scales; non-uniform joint scale and shear are approximate. Zero-length bones and singular transforms are ignored. ctx.draw_skeleton uses projected 2D lines over the scene: bones are shown through surfaces, without depth testing.

#![allow(unused)]
fn main() {
// Register this with Game::new(...).late_system(procedural_pose).
#[allow(dead_code)]
fn procedural_pose(ctx: &mut Ctx) {
    let target = vec3(0.7, 0.6, 0.2);
    ctx.world
        .query::<(&Transform3D, &mut Skeleton)>()
        .for_each(|(world, skeleton)| {
            two_bone_ik(skeleton, world, [0, 1, 2], target, Vec3::Z, 1.0);
            // Apply parent first when distributing aim across spine/neck/head.
            look_at_joint(skeleton, world, 5, Vec3::Y, target, 0.3, 0.2);
            look_at_joint(skeleton, world, 6, Vec3::Y, target, 0.5, 0.5);
            look_at_joint(skeleton, world, 7, Vec3::Y, target, 0.8, 1.0);
        });
}

#[allow(dead_code)]
fn debug_bones(ctx: &mut Ctx, character: Entity) {
    ctx.draw_skeleton(character, Color::RED);
}
}
// Register this with crabby::game(...).late_system(procedural_pose).
[[maybe_unused]] static void procedural_pose(crabby::ctx& ctx, crabby::entity character) {
    crabby::vec3 target{0.7f, 0.6f, 0.2f};
    ctx.two_bone_ik(character, 0, 1, 2, target, {0, 0, 1});
    // Apply parent first when distributing aim across spine/neck/head.
    ctx.look_at_joint(character, 5, {0, 1, 0}, target, 0.3f, 0.2f);
    ctx.look_at_joint(character, 6, {0, 1, 0}, target, 0.5f, 0.5f);
    ctx.look_at_joint(character, 7, {0, 1, 0}, target, 0.8f, 1.0f);
}

[[maybe_unused]] static void debug_bones(crabby::ctx& ctx, crabby::entity character) {
    ctx.draw_skeleton(character, crabby::colors::red);
}

Morph targets

A morph target (blend shape) is a named shape a mesh can blend toward: an offset for each corner, scaled by a weight. Blend a few to make poses that a model can slide between: a face between a smile and a blink, sliders for nose length and jaw width, a dent where something hit.

Each entry of MeshData::morph_targets has a name, one position offset per corner and, optionally, one normal offset per corner so the lighting follows the shape. ctx.morph_target(mesh, name) finds a target’s index. A MorphWeights component on a model says how much of each target to show: 0 for none, 1 for all of it (values outside that range exaggerate or invert the shape). A model without one shows its mesh’s own weights, which a glTF file can set per mesh. glTF targets are named from the file’s targetNames extras, and meshes of one file that use the same name share one target.

#![allow(unused)]
fn main() {
// System or setup: everything here runs headless too.
fn morph(ctx: &mut Ctx) {
    // One target, named to find it again: at full weight its third corner
    // sinks 0.4 into the sheet. Normals are left out, so the shading keeps
    // its smooth look; give one offset per corner to bend the light along.
    let mut sheet = MeshData::plane(1.0);
    let mut dent = vec![Vec3::ZERO; sheet.positions.len()];
    dent[2] = vec3(0.0, 0.0, -0.4);
    sheet.morph_targets.push(MorphTarget {
        name: "dent".into(),
        positions: dent,
        normals: Vec::new(),
    });

    let mut halfway = sheet.morph_targets[0].clone();
    halfway.name = "dent_half".into();
    halfway.positions[2] = vec3(0.1, 0.0, -0.15);
    sheet.morph_targets.push(halfway);
    sheet.morph_frames = vec![
        MorphFrame {
            channel: 0,
            target: 1,
            weight: 0.5,
        },
        MorphFrame {
            channel: 0,
            target: 0,
            weight: 1.0,
        },
    ];
    let material = ctx.default_material();
    let mesh = ctx.create_mesh(&sheet, material);
    let dent = ctx.morph_target(mesh, "dent").unwrap();
    let mut weights = MorphWeights::default();
    weights.set(dent, 0.5); // halfway dented
    let mut skeleton = Skeleton::new(vec![]);
    let expression = AnimationClip::new("expression", &skeleton, vec![]).with_morph_tracks(vec![MorphAnimation {
        name: "dent".into(),
        track: AnimationTrack::new(vec![0.0, 1.0], vec![Vec4::ZERO, Vec4::X], Interpolation::Linear),
    }]);
    expression.sample(0.5, &mut skeleton);
    ctx.world
        .spawn((Transform3D::default(), Model::new(mesh), weights, skeleton));
    ctx.write_animated_morphs();
}
}
// System or setup: everything here runs headless too.
static void morphed_sheet(crabby::ctx& ctx) {
    // One target, named to find it again: at full weight its third corner
    // sinks 0.4 into the sheet. Leave the normals empty to keep the
    // shading, or give one offset per corner to bend the light along.
    crabby::morph_target dent{"dent", std::vector<crabby::vec3>(4), {}};
    dent.positions[2] = {0, 0, -0.4f};
    auto sheet = crabby::mesh_data::plane(1.0);
    sheet.add_morph_target(dent);
    auto halfway = dent;
    halfway.name = "dent_half";
    halfway.positions[2] = {0.1f, 0, -0.15f};
    sheet.add_morph_target(halfway);
    sheet.add_morph_frame(0, 1, 0.5f);
    sheet.add_morph_frame(0, 0, 1.0f);

    auto mesh = ctx.create_mesh(sheet, ctx.default_material());
    auto at = ctx.morph_target(mesh, "dent").value();
    auto panel = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(mesh));
    ctx.set_morph_weight(panel, at, 0.5f); // authored halfway frame
    ctx.set_skeleton(panel, {}); // morph-only clips need no joints
    crabby::clip expression("expression");
    expression.set_morph_track("dent", {0, 1}, {0, 1});
    ctx.sample_clip(expression, panel, 0.5f);
}

Morph targets combine with skinning: the shape is added first, then the joints bend the result. A mesh with morph targets is shaped on the GPU in the same compute pass as skinning, and WebGL2 uses the same CPU fallback. glTF weights channels animate named targets with STEP, LINEAR or CUBICSPLINE interpolation, including morph-only files without joints. AnimationClip::with_morph_tracks adds scalar tracks in the x component; C++ uses clip::set_morph_track. One means full weight. The animator blends these tracks through blend trees, crossfades and override layers. Additive layers add their weighted morph values (zero reference), independently of the layer’s joint reference pose. Joint masks affect joints only: morph tracks on a masked layer still run. Clips leave unanimated weights untouched. Automatic animators write MorphWeights on their model and SkinOf attachments. After manual Rust sampling, call ctx.write_animated_morphs(); C++ sample_clip and animator_update write the component immediately. Skeleton::write_morph_weights can instead write to a caller-owned weight list. Partial overrides use the model’s initial weights as their baseline; explicit changes to its weight component establish a new baseline.

Unity/FBX channels can have several authored frames. Importing preserves every frame, including position and normal deltas. MeshData::morph_frames (C++ mesh_data::add_morph_frame) links existing targets to a controlling channel index and a positive frame weight. Set only the channel’s weight: halfway may be its own authored shape, rather than half the final deltas. Evaluation is piecewise linear, interpolating the first frame from zero and extrapolating outside the authored range using the end segments. The CPU expands each channel into at most two active target weights for the existing GPU/CPU skinning paths. Ordinary glTF targets need no frame metadata.

The Unity importer carries mappings in mesh extras crabbyMorphFrames (channel, target, weight indices/weight), alongside standard glTF targets. Plain YAML blendShape.<name> curves on SkinnedMeshRenderer use 0–100 units and export sampled weight channels, just like imported transform curves; Hermite curves are sampled at the clip rate plus source key times. All-step curves retain STEP interpolation; mixed interpolation is approximated by linear samples. Animation extras crabbyAnimatedTargets identify the sparse Unity channels so unrelated target weights remain untouched. Compressed or binary Unity clips remain unsupported.

Camera

ctx.camera3d is the 3D camera. It has a position and rotation, and look_at(target) turns it to face a point. Its fov_y is the vertical field of view in radians (60° by default), and near and far bound what’s drawn. ctx.camera3d.screen_ray(mouse) gives the ray under a window point, for picking things with the mouse.

C++: ctx.camera3d() and ctx.set_camera3d(c) read and write it, ctx.camera3d_look_at(target) turns it, and ctx.screen_ray(point) returns the ray.

Light and shadows

ctx.lighting holds the sun and the sky:

  • sun_direction: which way sunlight travels. Straight down is noon; a low angle gives long shadows.
  • sun_color and sun_intensity: the sun’s color and brightness.
  • sky_color and ground_color: soft light from above and below, scaled by ambient_intensity. They (or the atmosphere) are also what sky IBL lights surfaces with.
  • equator_color (None by default): light from the horizon. With it the ambient goes from the ground color below, through this on the horizon, to the sky color above, so walls facing sideways get a color of their own (a three-color gradient, as other engines’ “trilight” ambient). Like the other two, it’s only used without an atmosphere.
#![allow(unused)]
fn main() {
        // Without an atmosphere: a blue sky, a green-grey horizon and brown ground.
        ctx.sky.atmosphere = false;
        ctx.lighting.sky_color = Color::hex(0x8FB3D9);
        ctx.lighting.equator_color = Some(Color::hex(0x8C9A86));
        ctx.lighting.ground_color = Color::hex(0x4D4135);
}
/// Without an atmosphere: a blue sky, a green-grey horizon and brown ground.
static void trilight(crabby::ctx& ctx) {
    auto light = ctx.lighting();
    light.sky_color = crabby::rgb(0.56f, 0.70f, 0.85f);
    light.has_equator = true;
    light.equator_color = crabby::rgb(0.55f, 0.60f, 0.53f);
    light.ground_color = crabby::rgb(0.30f, 0.25f, 0.21f);
    ctx.set_lighting(light);
}
  • shadows and shadow_distance: the sun casts soft shadows up to that far from the camera (60 units by default), fading out at the edge.
  • shadow_cascades (1 to 4, default 4): how many shadow maps share that distance. Each covers a stretch further out than the one before, so shadows stay sharp near the camera even when they reach hundreds of units; neighbouring cascades blend where they meet. Fewer are cheaper: each one draws its shadow casters again.
  • shadow_resolution (256 to 4096, default 2048): the width of each cascade’s shadow map. The maps take 4 × resolution² × cascades bytes of GPU memory (64 MB by default). Change it while the game runs and the maps are made again at the new size.
  • shadow_refresh (1 to 8, default 2): how often, in frames, the far half of the cascades is drawn again. The far cascades take turns, so at 2 each one is drawn every other frame and the far shadows cost half as much. Each is drawn into a box a little bigger than it needs (texels about 20% coarser), so the camera can move a while before it’s drawn again early; so is a cascade whose box the camera leaves, and every cascade when the sun moves. What moves out there casts a shadow that lags by up to that many frames. 1 draws every cascade every frame, in a tight box.
  • shadow_filter (1 to 3, default 3): how soft the sun’s shadow edges are. Each lit pixel looks the shadow up once (1: a smooth 2×2 texel edge), four times (2: 3×3) or nine times (3: 4×4). On a large, high-resolution screen the lookups are a good part of drawing every lit pixel, so 1 or 2 is a cheap win on weaker GPUs.

The shadow maps follow the camera and move in whole texels, so shadow edges stay still while the camera moves.

Only casters whose shadow can land on something the camera sees (out to shadow_distance) go into the cascades: a tree behind the camera with the sun in front of it is skipped. Casters smaller than one of a cascade’s texels are left out of it too, so pebbles cast in the near cascades and not in the far ones, where their shadow wouldn’t show.

Every model casts a shadow, and each one costs as much in the shadow maps as on screen. For small things that crowd the ground, such as grass and pebbles, turn it off per mesh with ctx.set_mesh_shadows(mesh, false):

#![allow(unused)]
fn main() {
/// A meadow: thousands of grass tufts, too small for their shadows to show.
fn plant_meadow(ctx: &mut Ctx, tuft: Mesh) {
    ctx.set_mesh_shadows(tuft, false); // no shadow map work for grass
    for i in 0..1000 {
        let at = vec3((i % 40) as f32 * 0.5, 0.0, (i / 40) as f32 * 0.5);
        ctx.world.spawn((Transform3D::at(at), Model::new(tuft)));
    }
}
}
/// A meadow: thousands of grass tufts, too small for their shadows to show.
static void plant_meadow(crabby::ctx& ctx, crabby::mesh tuft) {
    ctx.set_mesh_shadows(tuft, false); // no shadow map work for grass
    for (int i = 0; i < 1000; ++i) {
        crabby::vec3 at{(i % 40) * 0.5f, 0, (i / 40) * 0.5f};
        ctx.world().spawn(crabby::transform3d_at(at), crabby::model_of(tuft));
    }
}

Per entity, a ShadowMesh picks what goes into the shadow maps instead of the model: a simplified copy or a billboard for a detailed model far away (a shadow there covers a few texels, so a tenth of the triangles casts the same one), or ShadowMesh(None) for no shadow at all. An entity with a ShadowMesh and no Model casts only a shadow: one simple stand-in for the shadows of many small pieces, such as a fence, that cast none themselves. On a TerrainTile, ShadowMesh(None) turns the ground’s own shadow off. Scene files write it as "ShadowMesh": { "mesh": "file.glb" } or { "mesh": null }.

#![allow(unused)]
fn main() {
/// A detailed tree with a cheap shadow, a flat sign with none, and a stand-in
/// shadow for a row of fence pieces that cast none themselves.
fn yard(ctx: &mut Ctx, tree: Mesh, blob: Mesh, sign: Mesh, fence: Mesh, fence_shadow: Mesh) {
    ctx.world
        .spawn((Transform3D::default(), Model::new(tree), ShadowMesh(Some(blob))));
    ctx.world
        .spawn((Transform3D::at(vec3(4.0, 0.0, 0.0)), Model::new(sign), ShadowMesh(None)));
    for i in 0..10 {
        let at = vec3(i as f32, 0.0, 6.0);
        ctx.world
            .spawn((Transform3D::at(at), Model::new(fence), ShadowMesh(None)));
    }
    // Only a shadow: no Model.
    ctx.world
        .spawn((Transform3D::at(vec3(0.0, 0.0, 6.0)), ShadowMesh(Some(fence_shadow))));
}
}
/// A detailed tree with a cheap shadow, a flat sign with none, and a stand-in
/// shadow for a row of fence pieces that cast none themselves.
static void yard(crabby::ctx& ctx, crabby::mesh tree, crabby::mesh blob, crabby::mesh sign,
                 crabby::mesh fence, crabby::mesh fence_shadow) {
    auto t = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(tree));
    ctx.set_shadow_mesh(t, blob);
    auto s = ctx.world().spawn(crabby::transform3d_at({4, 0, 0}), crabby::model_of(sign));
    ctx.set_no_shadow(s);
    for (int i = 0; i < 10; ++i) {
        auto f = ctx.world().spawn(crabby::transform3d_at({float(i), 0, 6}), crabby::model_of(fence));
        ctx.set_no_shadow(f);
    }
    // Only a shadow: no model.
    auto only = ctx.world().spawn(crabby::transform3d_at({0, 0, 6}));
    ctx.set_shadow_mesh(only, fence_shadow);
}

Shadows are often where a big 3D scene’s GPU time goes: every caster is drawn once per shadow cascade it falls in, also when it’s behind the camera. The profiling tools count the triangles drawn into the shadow maps separately from the camera’s.

Normal maps, ORM maps and glow

Besides texture, a MaterialDesc takes the maps that glTF models come with:

  • normal_map: surface detail (bumps, grooves, bricks) as tangent-space normals, OpenGL style. normal_strength scales it.
  • orm_map: ambient occlusion in red, roughness in green and metallic in blue, multiplying roughness and metallic.
  • emissive_map: multiplies emissive.

A texture gets mipmaps (smaller copies of itself) the first time a material uses it, so it stays smooth far away and at a slant instead of shimmering. Sprites drawing the same texture still use it at full size.

Materials are solid by default (Blend::Opaque), whatever their alpha. blend: Blend::Cutout cuts out the pixels whose alpha is below 0.5 (leaves, fences, grass cards), in their shadows too. The edge of the cut is smoothed with the multisampling (alpha to coverage): the alpha is sharpened to a ramp about a pixel wide around 0.5, and each pixel keeps as many of its samples as that says, so leaves and wire fences don’t stair-step or shimmer. Shadows keep the hard cut. It costs more, as the material’s shader then also runs for hidden pixels and shadows, so keep it for materials with holes. blend: Blend::Transparent makes a material see-through by its alpha (glass, water). Transparent models are drawn after everything solid and cast no shadows; they aren’t sorted, so overlapping ones can show in the wrong order. double_sided: true draws the back of each triangle too (leaves, cloth). ctx.update_material(material, desc) changes a material while the game runs.

Point and spot lights

Attach a PointLight3D or SpotLight3D to an entity with a Transform3D. Point lights shine in every direction; spots aim along the transform’s local −Z (forward()). Transform scale never changes their range. Color is sRGB (alpha ignored); intensity is linear radiant intensity. Cone angles are half angles in radians, with 0 <= inner_angle <= outer_angle < pi/2.

#![allow(unused)]
fn main() {
use ponko::*;
fn lamps(ctx: &mut Ctx) {
ctx.world.spawn((Transform3D::at(vec3(0.0, 3.0, 0.0)),
    PointLight3D { color: Color::rgb(1.0, 0.3, 0.1), intensity: 30.0, range: 12.0,
                   ..Default::default() }));
let aim = Transform3D::at(vec3(4.0, 3.0, 0.0)).looking_at(Vec3::ZERO);
ctx.world.spawn((aim,
    SpotLight3D { intensity: 60.0, range: 20.0, inner_angle: 0.2,
                  outer_angle: 0.5, shadows: true, ..Default::default() }));
}
}
static void punctual_lights(crabby::ctx& ctx) {
    ctx.world().spawn(crabby::transform3d_at({0, 3, 0}),
        crabby::point_light3d{crabby::rgb(1, .3f, .1f), 30, 12, false});
    auto aim = crabby::looking_at(crabby::transform3d_at({4, 3, 0}), {0, 0, 0});
    ctx.world().spawn(aim,
        crabby::spot_light3d{crabby::colors::white, 60, 20, .2f, .5f, true}); // casts shadows
}

Both types use the sun’s Cook–Torrance GGX diffuse/specular model. Distance attenuation is max(1 - (distance/range)^4, 0)^2 / max(distance^2, 1): finite near the source, smoothly zero at the range. Spots also smoothstep from the inner cone to the outer cone; equal angles give a hard edge. Nonfinite or nonpositive intensity/range and invalid cone angles disable a light.

The CPU culls lights conservatively and bins them into 16×9 screen tiles with 24 logarithmic depth slices, independently for each camera (including target and reflection cameras). Pixels loop over their cluster’s list, with no fixed per-cluster cap or silent overflow. CPU lists and GPU textures retain capacity and grow only at new high-water marks. Allocation-free steady state refers to the engine’s light collection and packing; the GPU driver’s upload machinery is outside that guarantee. Packed textures also work on WebGL2. Adapter texture limits bound the maximum number of visible lights and cluster references; exceeding them reports an explicit capacity error.

Shadows from point and spot lights

Set shadows: true on a light (the spot light above has it) to have models block it. A spot light takes one shadow view, looking down its cone, and a point light six, one per cube face. Shadowed lights nearest the camera get theirs first, up to light_shadow_views views a frame (12 by default, at most MAX_LIGHT_SHADOW_VIEWS, 24); any left over shine unshadowed. So keep shadows for the lights that need them: a desk lamp, headlights, a torch, a muzzle flash.

Every view is a square tile of one depth atlas, packed again each frame, so many small far lights fit beside a few large near ones. A light’s tiles are as wide as it looks big: one whose reach fills the view (or that the camera stands in) gets light_shadow_resolution texels (default LIGHT_SHADOW_SIZE, 1024), one twice as far away half that, down to 64. Set a light’s shadow_resolution to pick its size yourself (64 to 2048). Sizes are powers of two; when the views don’t all fit the atlas, the largest shrink first, and only if even the smallest don’t fit are the farthest lights left unshadowed.

#![allow(unused)]
fn main() {
/// A street at night: a sharp shadow for the lamp beside the player,
/// rough ones for the street lights down the road.
fn street_lights(ctx: &mut Ctx) {
    ctx.lighting.light_shadow_atlas = 8192; // room for more shadow views
    ctx.lighting.light_shadow_views = 24; // as many as there can be
    let lamp = PointLight3D {
        color: Color::rgb(1.0, 0.8, 0.6),
        intensity: 40.0,
        range: 12.0,
        shadows: true,
        shadow_resolution: 2048, // always sharp
    };
    ctx.world.spawn((Transform3D::at(vec3(0.0, 3.0, 0.0)), lamp));
    for i in 0..18 {
        let x = -20.0 + 2.5 * i as f32;
        let at = Transform3D::at(vec3(x, 5.0, -30.0)).looking_at(vec3(x, 0.0, -29.9));
        // Sized by how big they look: smaller the farther they are.
        let street_light = SpotLight3D {
            intensity: 60.0,
            range: 8.0,
            shadows: true,
            ..Default::default()
        };
        ctx.world.spawn((at, street_light));
    }
}
}
/// A street at night: a sharp shadow for the lamp beside the player,
/// rough ones for the street lights down the road.
static void street_lights(crabby::ctx& ctx) {
    auto light = ctx.lighting();
    light.light_shadow_atlas = 8192; // room for more shadow views
    light.light_shadow_views = 24;   // as many as there can be
    ctx.set_lighting(light);
    crabby::point_light3d lamp{crabby::rgb(1, .8f, .6f), 40, 12, true};
    lamp.shadow_resolution = 2048; // always sharp
    ctx.world().spawn(crabby::transform3d_at({0, 3, 0}), lamp);
    for (int i = 0; i < 18; i++) {
        float x = -20 + 2.5f * i;
        auto at = crabby::looking_at(crabby::transform3d_at({x, 5, -30}), {x, 0, -29.9f});
        // Sized by how big they look: smaller the farther they are.
        ctx.world().spawn(at, crabby::spot_light3d{crabby::colors::white, 60, 8, .3f, .5f, true});
    }
}

Light shadows use the same caster meshes as the sun (ShadowMesh, mesh shadows off), are filtered over 3×3 texels (kept inside each light’s tile, so they never pick up a neighbor’s), and are pushed a little off the surface against shadow acne, like the sun’s. Lighting::shadows off turns them off too. Only the main camera sees them: target and reflection cameras get the light unshadowed. The atlas is made the first time a light casts shadows.

light_shadow_atlas (1024 to 8192, a power of two, default LIGHT_SHADOW_ATLAS_SIZE, 4096) is the atlas’s width and height: it takes 4 × size² bytes (64 MB by default) and holds sixteen 1024² views, or 256 of 256². A budget of 0 views turns point and spot light shadows off and frees it; a new atlas size makes it again.

Try cargo run --release --features 3d --example lights (LIGHTS=1000). The scene has colored points and spots, with shadows and bloom disabled. A windowless release benchmark of 0, 100 and 1,000 lights is available with cargo test --release --features 3d --lib offscreen_light_benchmark -- --ignored --nocapture. It reports ctx.render_stats() CPU and GPU milliseconds after warmup at 1280×720.

Custom shaders

A material can have its own shader, in WGSL. It doesn’t have to do everything: it defines one or both of two functions, and the engine does the rest (lighting, shadows, the depth prepass, and transparency):

  • fn vertex(v: Vertex) -> Vertex moves vertices, in world space: wind in grass, waves on water. v.origin is where the model is, so each model can sway or vary on its own.
  • fn surface(s: Surface) -> Surface changes each pixel: its albedo, alpha, normal, roughness, metallic, occlusion and emissive, filled in from the material first. It can read s.world, s.uv, s.uv1 (the mesh’s second texture coordinates), s.color (the model’s tint times the vertex color), s.view (towards the camera) and s.origin (where the model is: a height above its base, a color per model).
#![allow(unused)]
fn main() {
/// Grass that sways in the wind: the higher a vertex, the more it moves.
const WIND: &str = "
fn vertex(v: Vertex) -> Vertex {
    var out = v;
    let sway = sin(frame.time.x * 2.0 + v.position.x) * material.custom[0].x;
    out.position.x += sway * v.uv.y * v.uv.y;
    return out;
}";

fn meadow(ctx: &mut Ctx) {
    let wind = ctx.create_shader("wind", WIND);
    let grass = ctx.create_material(MaterialDesc {
        shader: Some(wind),
        double_sided: true,
        custom: [vec4(0.2, 0.0, 0.0, 0.0), Vec4::ZERO, Vec4::ZERO, Vec4::ZERO],
        ..MaterialDesc::color(Color::hex(0x7FA83C))
    });
    let blade = ctx.create_mesh(&MeshData::plane(0.5), grass);
    ctx.world.spawn((Transform3D::default(), Model::new(blade)));
}
}
/// Grass that sways in the wind: the higher a vertex, the more it moves.
static const char* wind_shader = R"(
fn vertex(v: Vertex) -> Vertex {
    var out = v;
    let sway = sin(frame.time.x * 2.0 + v.position.x) * material.custom[0].x;
    out.position.x += sway * v.uv.y * v.uv.y;
    return out;
})";

static void meadow(crabby::ctx& ctx) {
    auto look = crabby::material_of(crabby::rgb(.5f, .66f, .24f));
    look.has_shader = true;
    look.shader = ctx.create_shader("wind", wind_shader);
    look.double_sided = true;
    look.custom[0] = 0.2f;
    auto grass = ctx.create_material(look);
    auto blade = ctx.create_mesh(crabby::mesh_data::plane(0.5f), grass);
    ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(blade));
}

A shader can read:

  • frame.time.x: seconds since the game started (frame.time.y: since the last frame), frame.camera.xyz, frame.sun_dir.xyz, frame.sun_color.rgb, frame.sky.rgb and frame.ground.rgb;
  • material.custom[0] to [3]: four vec4s from MaterialDesc::custom;
  • custom_texture0 and custom_texture1 (from MaterialDesc::custom_textures), and the material’s own textures, with material_sampler;
  • custom_array0 to custom_array2 (texture_2d_array<f32>, from MaterialDesc::custom_arrays): texture arrays, many same-size images in one, picked by layer: textureSample(custom_array0, material_sampler, s.uv, 2). Make one with ctx.create_texture_array or ctx.load_texture_array, as TextureKind::Color (painted colors) or TextureKind::Data (normal maps, masks and weights, read as stored);
  • helpers: hash2, noise2 (smooth value noise) and fbm2 (five octaves of it).

Transparent materials can also see what is behind them, for water, glass and heat haze. With s.screen (the pixel’s position on screen, in pixels):

  • scene_color(screen) is the lit color of the opaque scene there;
  • scene_position(screen) is the world position of the opaque surface there, and scene_distance(screen) its distance from the camera.

Offsetting screen bends what shows through (refraction), and comparing scene_distance(s.screen) with the surface’s own distance gives how deep the water is. A frame with such a shader copies the scene once before its transparent models are drawn; frames without one don’t pay for it.

ctx.load_shader("shaders/water.wgsl") loads one from assets/; with hot reload on, saving the file changes the running game. A shader that doesn’t compile stops the game at load with the compiler’s message, pointing at the line; on reload, the message is printed and the old shader stays. cargo run --release --example shaders --features 3d shows a lake, grass in the wind and a pattern made in a shader.

Custom material hooks can call punctual_light(s: Surface) -> vec3<f32>. It returns linear HDR diffuse plus specular using the surface’s world position, normal, view direction, albedo, metallic and roughness. The built-in shade already adds it; adding it to emissive adds the light a second time. For stylized uses, copy the surface, change its BRDF fields, and evaluate that copy. The lower-level direct_light(s, direction_to_light, radiance) evaluates the same BRDF for one light. sunlight(world, normal) returns sun shadow visibility (screen effects and compute passes can call it as well). It includes light shadows for lights that cast them.

Sky and fog

ctx.sky draws a physical sky behind the 3D world, lit by the sun in ctx.lighting: blue by day, orange and red when the sun is low, dark blue with stars at night. The same air lights the scene: the sun turns orange and dims as it sets, and the sky’s light (bluish in the shade) replaces Lighting::sky_color and ground_color. One world unit counts as a meter.

  • atmosphere: on by default. Off, the background is ctx.clear’s color and the light comes from Lighting’s colors.
  • haze: dust and water in the air. More gives a paler sky, a bigger glow around the sun and redder sunsets.
  • sun_size (degrees across), stars and night_light (moonlight and starlight, when the sun is down).
  • fog_density, fog_height and fog_falloff: height fog, thickest at fog_height and thinning out above it. It takes the sky’s color behind it (or fog_color without an atmosphere) and covers the sky near the horizon too.
  • fog_start and fog_end: distance fog, none closer than fog_start and all fog past fog_end, whatever the height. Off while fog_end isn’t past fog_start (the default). It takes the same color as the height fog, and leaves the sky alone.
  • fog_sun_glow and fog_sun_focus: sunlight the fog scatters toward the camera, so both kinds glow when looking toward the sun; the focus (0 to under 1) draws the glow in close around it.

Sky::sun_direction_at(hour) gives the sun’s direction for a time of day, for a day and night cycle: it rises in the east (+X) at 6, is highest at noon and sets in the west at 18.

#![allow(unused)]
fn main() {
/// A misty morning: the sun low in the east, fog lying in the valleys.
fn misty_morning(ctx: &mut Ctx) {
    ctx.lighting.sun_direction = Sky::sun_direction_at(7.5);
    ctx.sky.haze = 2.0; // a paler sky, a bigger glow around the sun
    ctx.sky.fog_density = 0.03; // thick near the ground
    ctx.sky.fog_height = 0.0;
    ctx.sky.fog_falloff = 10.0; // and thin ten meters up
}
}
/// A misty morning: the sun low in the east, fog lying in the valleys.
static void misty_morning(crabby::ctx& ctx) {
    auto light = ctx.lighting();
    light.sun_direction = crabby::sun_direction_at(7.5f);
    ctx.set_lighting(light);
    auto sky = ctx.sky();
    sky.haze = 2;              // a paler sky, a bigger glow around the sun
    sky.fog_density = 0.03f;   // thick near the ground
    sky.fog_height = 0;
    sky.fog_falloff = 10;      // and thin ten meters up
    ctx.set_sky(sky);
}

For a big open world, distance fog hides where the streamed world ends:

#![allow(unused)]
fn main() {
/// A big open world fading out far away, glowing toward the sun.
fn far_haze(ctx: &mut Ctx) {
    ctx.sky.fog_start = 500.0; // clear up to half a kilometre
    ctx.sky.fog_end = 1200.0; // and gone in the haze past 1.2 km
    ctx.sky.fog_sun_glow = 0.06; // the haze glows toward the sun
    ctx.sky.fog_sun_focus = 0.7; // most of all close around it
}
}
/// A big open world fading out far away, glowing toward the sun.
static void far_haze(crabby::ctx& ctx) {
    auto sky = ctx.sky();
    sky.fog_start = 500;        // clear up to half a kilometre
    sky.fog_end = 1200;         // and gone in the haze past 1.2 km
    sky.fog_sun_glow = 0.06f;   // the haze glows toward the sun
    sky.fog_sun_focus = 0.7f;   // most of all close around it
    ctx.set_sky(sky);
}

Light shafts let the sun’s shadows reach into the fog: where the mist is in a tree’s shade it stops glowing, so beams of sunlight show between the trunks. They need fog with a sun glow and shadows, and reach as far as the shadows do. Each pixel samples the shadow map 16 times along its view ray, with a different offset every frame; temporal antialiasing smooths that grain away.

#![allow(unused)]
fn main() {
/// Beams of morning sun through the trees.
fn sunbeams(ctx: &mut Ctx) {
    ctx.sky.fog_density = 0.03; // low mist to light up
    ctx.sky.fog_sun_glow = 0.1; // glowing in the sun
    ctx.sky.light_shafts = 1.0; // but not in the shade
    ctx.lighting.shadows = true; // shafts follow the sun's shadows
    ctx.post.anti_aliasing = AntiAliasing::Taa; // and smooth out their grain
}
}
/// Beams of morning sun through the trees.
static void sunbeams(crabby::ctx& ctx) {
    auto sky = ctx.sky();
    sky.fog_density = 0.03f;    // low mist to light up
    sky.fog_sun_glow = 0.1f;    // glowing in the sun
    sky.light_shafts = 1;       // but not in the shade
    ctx.set_sky(sky);
    auto light = ctx.lighting();
    light.shadows = true;       // shafts follow the sun's shadows
    ctx.set_lighting(light);
    auto post = ctx.post();
    post.anti_aliasing = uint32_t(crabby::anti_aliasing::taa); // smooth their grain
    ctx.set_post(post);
}

The sky costs two small passes a frame: one works it out for every direction into a 256x128 texture, one draws it where nothing else was. In the shaders example, the left and right arrows turn the time of day and F turns fog on and off.

Screen effects

A screen effect is a shader run over the whole lit 3D frame, for looks that belong to the picture rather than to one surface: under water, heat haze, a dream blur, sun shafts, outlines. It defines one function:

  • fn effect(p: Pixel) -> vec3<f32> gives the new light at each pixel, from p.color (the light there so far, brighter than white where the scene is bright), p.world and p.distance (the nearest solid surface and how far it is from the camera), p.sky (nothing solid there), p.screen (in pixels) and p.uv (0 to 1 across the screen).

It can read other pixels with scene_color(screen) and scene_position(screen), and everything a material shader reads: frame, the noise helpers, and material.custom and the textures of the material it’s given. That includes the sun’s shadow maps: sunlight(world, normal) says how much sun reaches a point (0 in shadow, 1 lit), so an effect can march along the view ray and add light only where the sun gets through, for sun shafts in fog or dust. Compute passes can call it too. Make one with ctx.create_effect or ctx.load_effect (hot reloaded, like material shaders), and run it by adding a ScreenEffect to ctx.effects. Effects run in that order, each on what the one before left, after the transparent models and before bloom and the post-processing below. p.world is the nearest solid surface: transparent models don’t count.

#![allow(unused)]
fn main() {
/// Under water, light fades to deep blue with distance (and the sky above
/// is gone). `material.custom[0].x` is the water's height.
const UNDERWATER: &str = "
fn effect(p: Pixel) -> vec3<f32> {
    if frame.camera.y > material.custom[0].x {
        return p.color; // above the water: nothing changes
    }
    let deep = vec3<f32>(0.02, 0.12, 0.18);
    let fade = 1.0 - exp(-p.distance * 0.08);
    return mix(p.color * vec3<f32>(0.6, 0.9, 1.0), deep, fade);
}";

fn dive(ctx: &mut Ctx) {
    let underwater = ctx.create_effect("underwater.wgsl", UNDERWATER);
    let lake = ctx.create_material(MaterialDesc {
        custom: [vec4(2.0, 0.0, 0.0, 0.0), Vec4::ZERO, Vec4::ZERO, Vec4::ZERO], // water at y = 2
        ..Default::default()
    });
    ctx.effects.push(ScreenEffect::new(underwater).with_material(lake));
}
}
/// Under water, light fades to deep blue with distance (and the sky above
/// is gone). `material.custom[0].x` is the water's height.
static const char* underwater_effect = R"(
fn effect(p: Pixel) -> vec3<f32> {
    if frame.camera.y > material.custom[0].x {
        return p.color; // above the water: nothing changes
    }
    let deep = vec3<f32>(0.02, 0.12, 0.18);
    let fade = 1.0 - exp(-p.distance * 0.08);
    return mix(p.color * vec3<f32>(0.6, 0.9, 1.0), deep, fade);
})";

static void dive(crabby::ctx& ctx) {
    auto underwater = ctx.create_effect("underwater.wgsl", underwater_effect);
    auto desc = crabby::material_of(crabby::colors::white);
    desc.custom[0] = 2; // water at y = 2
    auto lake = ctx.create_material(desc);
    auto list = ctx.effects();
    list.push_back(crabby::screen_effect_of(underwater, lake));
    ctx.set_effects(list);
}

Each effect costs one full-screen draw: effects take turns between two frame buffers, each reading one and writing the other, so the frame is never copied. A game with no effects doesn’t pay for them.

Render targets

A render target is a texture a second camera draws the world into, each frame, before the main view: for mirrors, water that reflects the hills, a security camera’s monitor, a portal. ctx.create_render_target(w, h) makes one, and a TargetCamera in ctx.target_cameras draws into it. Materials then use it like any other texture. It holds the lit scene with its sky and fog, in the same HDR light as the frame before tone mapping (so put it in emissive, not albedo, to show it as it is). Target cameras use the main view’s shadows and don’t draw transparent models.

TargetCamera::reflection(camera, target, level) is the camera for a level mirror at height level: camera mirrored below it, drawing only what’s above it (its clip_plane, which tilts the camera’s near plane onto the mirror, so it costs nothing). Read it at the screen position flipped upside down, and update it every frame as the camera moves:

#![allow(unused)]
fn main() {
/// A still pond: its color is the world mirrored in it, which a target
/// camera below the surface draws into `custom_texture0`, upside down.
const MIRROR: &str = "
fn surface(s: Surface) -> Surface {
    var out = s;
    let uv = s.screen * frame.screen.zw;
    out.emissive = textureSample(custom_texture0, material_sampler, vec2<f32>(uv.x, 1.0 - uv.y)).rgb * 0.9;
    out.albedo = vec3<f32>(0.0);
    return out;
}";

fn pond(ctx: &mut Ctx) {
    let size = ctx.window_size();
    let reflection = ctx.create_render_target(size.x as u32 / 2, size.y as u32 / 2);
    let mirror = ctx.create_shader("mirror", MIRROR);
    let water = ctx.create_material(MaterialDesc {
        shader: Some(mirror),
        custom_textures: [Some(reflection), None],
        ..Default::default()
    });
    let surface = ctx.create_mesh(&MeshData::plane(20.0), water);
    ctx.world.spawn((Transform3D::default(), Model::new(surface)));
    ctx.target_cameras
        .push(TargetCamera::reflection(ctx.camera3d, reflection, 0.0));
}

/// Every frame: the mirrored camera follows the real one.
fn follow_reflections(ctx: &mut Ctx) {
    for t in &mut ctx.target_cameras {
        *t = TargetCamera::reflection(ctx.camera3d, t.target, 0.0);
    }
}
}
/// A still pond: its color is the world mirrored in it, which a target
/// camera below the surface draws into `custom_texture0`, upside down.
static const char* mirror_shader = R"(
fn surface(s: Surface) -> Surface {
    var out = s;
    let uv = s.screen * frame.screen.zw;
    out.emissive = textureSample(custom_texture0, material_sampler, vec2<f32>(uv.x, 1.0 - uv.y)).rgb * 0.9;
    out.albedo = vec3<f32>(0.0);
    return out;
})";

static void pond(crabby::ctx& ctx) {
    auto size = ctx.window_size();
    auto reflection = ctx.create_render_target(std::uint32_t(size.x / 2), std::uint32_t(size.y / 2));
    auto look = crabby::material_of(crabby::colors::white);
    look.has_shader = true;
    look.shader = ctx.create_shader("mirror", mirror_shader);
    look.has_custom_textures[0] = true;
    look.custom_textures[0] = reflection;
    auto surface = ctx.create_mesh(crabby::mesh_data::plane(20), ctx.create_material(look));
    ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(surface));
    ctx.set_target_cameras({ctx.reflection_camera(ctx.camera3d(), reflection, 0)});
}

/// Every frame: the mirrored camera follows the real one.
static void follow_reflections(crabby::ctx& ctx) {
    auto list = ctx.target_cameras();
    for (auto& t : list) t = ctx.reflection_camera(ctx.camera3d(), t.target, 0);
    ctx.set_target_cameras(list);
}

Each target camera draws its view again (a depth prepass and the lit pass), so keep targets small: half the window is plenty for a rippled lake. examples/terrain.rs has a lake with waves that reflects the hills.

Compute passes

A compute pass runs a shader on the GPU once per texel of a render target, every frame, before anything is drawn: for things that live in a texture and change over time, such as ripples, footprints and tyre tracks in snow or mud, wet ground drying, spreading fire, or fog of war. It defines one function:

  • fn compute(c: Cell) -> vec4<f32> gives the texel’s new value, from what it holds now (c.value, all 0 at first), where it is (c.pixel, c.size, and c.uv from 0 to 1) and its neighbours: cell_at(pixel) reads any texel as it was before this pass.

It also reads frame (frame.time.y is the frame’s seconds) and the values and textures of the material it’s given, as screen effects do, so the game steers it with ctx.update_material. Make one with ctx.create_compute or ctx.load_compute (hot reloaded), and run it by adding a ComputePass to ctx.compute_passes. Passes run in that order; the same pass twice steps the simulation twice a frame. Materials read the target as a texture, in the same frame:

#![allow(unused)]
fn main() {
/// Ripples: each texel moves toward the average of its neighbours,
/// overshooting like water. Red is the height now, green the height a
/// frame ago; `material.custom[0]` drops a stone (uv x, uv y, strength).
const RIPPLES: &str = "
fn compute(c: Cell) -> vec4<f32> {
    let around = cell_at(c.pixel + vec2<i32>(1, 0)).x + cell_at(c.pixel - vec2<i32>(1, 0)).x
        + cell_at(c.pixel + vec2<i32>(0, 1)).x + cell_at(c.pixel - vec2<i32>(0, 1)).x;
    var height = (around * 0.5 - c.value.y) * 0.985;
    let drop = material.custom[0];
    if drop.z > 0.0 && distance(c.uv, drop.xy) * f32(c.size.x) < 3.0 {
        height -= drop.z;
    }
    return vec4<f32>(height, c.value.x, 0.0, 0.0);
}";

/// The simulated heights, for a water material to read, and the material
/// that drops stones into them.
fn ripples(ctx: &mut Ctx) -> (Texture, Material) {
    let heights = ctx.create_render_target(256, 256);
    let ripples = ctx.create_compute("ripples", RIPPLES);
    let stones = ctx.create_material(MaterialDesc::default());
    ctx.compute_passes
        .push(ComputePass::new(ripples, heights).with_material(stones));
    (heights, stones)
}

/// Drops a stone at `uv` (0 to 1 across the water) this frame; `strength`
/// 0 drops none.
fn drop_stone(ctx: &mut Ctx, stones: Material, uv: Vec2, strength: f32) {
    let stone = uv.extend(strength).extend(0.0);
    ctx.update_material(
        stones,
        MaterialDesc {
            custom: [stone, Vec4::ZERO, Vec4::ZERO, Vec4::ZERO],
            ..Default::default()
        },
    );
}
}
/// Ripples: each texel moves toward the average of its neighbours,
/// overshooting like water. Red is the height now, green the height a
/// frame ago; `material.custom[0]` drops a stone (uv x, uv y, strength).
static const char* ripples_compute = R"(
fn compute(c: Cell) -> vec4<f32> {
    let around = cell_at(c.pixel + vec2<i32>(1, 0)).x + cell_at(c.pixel - vec2<i32>(1, 0)).x
        + cell_at(c.pixel + vec2<i32>(0, 1)).x + cell_at(c.pixel - vec2<i32>(0, 1)).x;
    var height = (around * 0.5 - c.value.y) * 0.985;
    let drop = material.custom[0];
    if drop.z > 0.0 && distance(c.uv, drop.xy) * f32(c.size.x) < 3.0 {
        height -= drop.z;
    }
    return vec4<f32>(height, c.value.x, 0.0, 0.0);
})";

struct ripples_state { crabby::texture heights; crabby::material stones; };

/// The simulated heights, for a water material to read, and the material
/// that drops stones into them.
static ripples_state ripples(crabby::ctx& ctx) {
    auto heights = ctx.create_render_target(256, 256);
    auto ripples = ctx.create_compute("ripples", ripples_compute);
    auto stones = ctx.create_material(crabby::material_of(crabby::colors::white));
    auto list = ctx.compute_passes();
    list.push_back(crabby::compute_pass_of(ripples, heights, stones));
    ctx.set_compute_passes(list);
    return {heights, stones};
}

/// Drops a stone at `uv` (0 to 1 across the water) this frame; `strength`
/// 0 drops none.
static void drop_stone(crabby::ctx& ctx, crabby::material stones, crabby::vec2 uv, float strength) {
    auto desc = crabby::material_of(crabby::colors::white);
    desc.custom[0] = uv.x;
    desc.custom[1] = uv.y;
    desc.custom[2] = strength;
    ctx.update_material(stones, desc);
}

examples/compute.rs rains on a pond with this, and bends the water’s normals by the heights. Values are 16-bit floats, four per texel. A pass costs one GPU dispatch and one copy of its target: about 0.04 ms of GPU time for 512 x 512 on an M4 Max. Browsers without WebGPU (WebGL2) can’t run compute passes: making one there returns an error.

Post-processing

The 3D frame is lit in HDR: light can be brighter than white. On its way to the screen, ctx.post decides how it looks:

  • exposure: brightness in stops (+1 doubles it).
  • bloom and bloom_threshold: light brighter than the threshold glows into its surroundings (0 turns bloom off).
  • temperature and tint: white balance, cooler or warmer, greener or pinker.
  • contrast and saturation: 1 keeps them as they are.
  • tonemap: how bright light rolls off to white. Tonemap::Aces (the default) is film-like, AgX keeps the hue of bright colors, Neutral stays closest to the materials’ own colors.
  • lut: a color lookup table for grading in an image editor. Start from Post::neutral_lut(32), save it as a PNG, paint over it, and load it with ctx.load_texture.
  • vignette: darker corners.
#![allow(unused)]
fn main() {
/// A warm, glowing evening look.
fn golden_hour(ctx: &mut Ctx) {
    ctx.post.exposure = 0.3; // a third of a stop brighter
    ctx.post.bloom = 0.2; // highlights glow
    ctx.post.temperature = 0.4; // warmer
    ctx.post.saturation = 1.1;
    ctx.post.vignette = 0.3; // darker corners
    ctx.post.tonemap = Tonemap::AgX;
}
}
/// A warm, glowing evening look.
static void golden_hour(crabby::ctx& ctx) {
    auto post = ctx.post();
    post.ao_enabled = true;
    post.ao_half_resolution = true;
    post.ao_temporal = true;
    post.ao_radius = 1.5f; // world units
    post.ao_intensity = 1.0f;
    post.ao_slices = 3;
    post.ao_steps = 6;
    post.contact_shadows = 0.3f; // small, sharp sun shadows under feet and props
    post.anti_aliasing = uint32_t(crabby::anti_aliasing::smaa);
    post.exposure = 0.3f;      // a third of a stop brighter
    post.bloom = 0.2f;         // highlights glow
    post.temperature = 0.4f;   // warmer
    post.saturation = 1.1f;
    post.vignette = 0.3f;      // darker corners
    post.tonemap = uint32_t(crabby::tonemap::agx);
    ctx.set_post(post);
}

Lens effects sell speed or a camera look:

  • chromatic_aberration: red, green and blue drift apart toward the edges, 0 to 1.
  • lens_distortion: bends the picture, -1 to 1. Above 0 bulges the middle out (barrel), below 0 pinches it in (pincushion).
  • lens_scale: zooms in after the bend (above 1) to hide the edges it pulls in.
#![allow(unused)]
fn main() {
/// The rush of speed: colors split at the edges, the middle pinches in.
fn speed_lens(ctx: &mut Ctx, speed: f32) {
    let rush = (speed / 40.0).clamp(0.0, 1.0);
    ctx.post.chromatic_aberration = 0.8 * rush;
    ctx.post.lens_distortion = -0.26 * rush;
    ctx.post.lens_scale = 1.0 + 0.04 * rush; // hide the pulled-in edges
}
}
/// The rush of speed: colors split at the edges, the middle pinches in.
static void speed_lens(crabby::ctx& ctx, float speed) {
    float rush = std::clamp(speed / 40.0f, 0.0f, 1.0f);
    auto post = ctx.post();
    post.chromatic_aberration = 0.8f * rush;
    post.lens_distortion = -0.26f * rush;
    post.lens_scale = 1.0f + 0.04f * rush; // hide the pulled-in edges
    ctx.set_post(post);
}

Sprites, text and UI drawn on top are not changed. Nor is the background color from ctx.clear.

Ambient occlusion and post antialiasing

ctx.post.ambient_occlusion enables GTAO: screen-space horizon integration with normals reconstructed from depth, followed by a depth-aware bilateral filter. Only ambient/indirect light is occluded; direct sun, punctual lights and emissive surfaces retain their light. Radius is in world units. Start with radius 1, intensity 1, 3 slices and 6 steps per direction. Rust clamps slices to 1–8 and steps to 1–16; the C API rejects values outside these ranges. AO defaults off to preserve existing scene output and render tests.

ctx.post.contact_shadows adds contact shadows in the same pass: each pixel steps toward the sun across the depth buffer, up to that many world units, and loses the sun if something on screen sits in front of a step. They catch the small, sharp shadows the shadow map is too coarse for: under feet, pebbles and cups, and in creases. 0.3 suits people and props; 0 (the default) is off. Like AO they only see what is on screen.

ctx.post.anti_aliasing chooses AntiAliasing::None (default), Fxaa, Smaa (SMAA 1x) or Taa (temporal, below). FXAA and SMAA run on tone-mapped LDR before sprites and UI, in addition to the configured geometry MSAA. FXAA is cheaper; SMAA searches edges and uses the reference area/search lookup textures (MIT licensed, notices in src/render/smaa/).

#![allow(unused)]
fn main() {
        ctx.post.ao_half_resolution = true;
        ctx.post.ao_temporal = true;
        ctx.post.ambient_occlusion = AmbientOcclusion {
            enabled: true,
            radius: 1.5,
            intensity: 1.0,
            slices: 3,
            steps: 6,
        };
        ctx.post.contact_shadows = 0.3; // small, sharp sun shadows under feet and props
        ctx.post.anti_aliasing = AntiAliasing::Smaa;
}
    post.ao_enabled = true;
    post.ao_half_resolution = true;
    post.ao_temporal = true;
    post.ao_radius = 1.5f; // world units
    post.ao_intensity = 1.0f;
    post.ao_slices = 3;
    post.ao_steps = 6;
    post.contact_shadows = 0.3f; // small, sharp sun shadows under feet and props
    post.anti_aliasing = uint32_t(crabby::anti_aliasing::smaa);
ctx.set_post(post);

Post::ao_half_resolution traces at half width and height and resolves to full resolution with depth-aware weights. Contact shadows remain sharp: their march runs at full resolution during the resolve in this mode. Post::ao_temporal rotates slice directions over eight frames and reprojects history using the previous camera matrix, independently of TAA. Depth mismatch, sky and offscreen reprojections reject history; a local clamp limits trails. It uses the same object motion vectors as TAA (below), so moving models reproject with their own motion. Both switches default off. High uses half-resolution temporal AO; Ultra does too with more samples. Lower presets leave AO off.

GTAO uses one RG8 trace target (full or half resolution), one full-resolution output and two fragment passes. Temporal mode adds two full-resolution RGBA16F history targets; spatial mode allocates no history. With SSR the full-resolution output is the first mip of the shared image; its reflection mip is untouched. Enabling it also enables the solid depth prepass on GPUs that normally skip that prepass. It is skipped on WebGL2/downlevel devices without compute capabilities, matching the engine’s optional advanced-effects fallback. FXAA and SMAA use fragment passes, including on WebGL2: FXAA adds one LDR intermediate and one pass, SMAA adds three passes and edge/weight targets. Targets are reused until resize. No timing claim is made without a measured scene. Screen-space AO cannot see offscreen or hidden occluders and can halo at silhouettes. Target cameras and transparent surfaces do not receive AO. History resets on resize, mode changes and AO sample/radius/intensity changes. Use fewer slices/steps with temporal mode to offset reprojection and bandwidth costs.

Offscreen release measurement on Apple M4 Max (Metal), 1920×1080, a floor and 2,501 boxes, no contact shadows or TAA: full-resolution 4 slices × 8 steps 2.35 ms, half-resolution 4 × 8 0.76 ms, half-resolution temporal 2 × 4 0.36 ms for the two AO passes combined. These are means of the final 20 of 80 frames per mode from the engine GPU timestamp profiler, excluding scene rendering. They measure cost, not equal image quality; results depend on the scene and GPU. Reproduce with cargo test --release --features 3d --lib ao_gpu_timings_1080p -- --ignored --nocapture.

Temporal antialiasing

AntiAliasing::Taa (anti_aliasing::taa in C++) spreads the work of antialiasing over frames. Each frame the camera is nudged by a sub-pixel amount (a 16 position Halton sequence), so every frame samples the picture at slightly different spots; the lit HDR frame is then blended with the last result, moved along each pixel’s motion. It runs on the HDR frame before bloom, screen effects and tone mapping, so bright edges do not flicker and bloom stays steady, and it also smooths shading and thin details that FXAA and SMAA cannot see. The geometry MSAA stays on under it.

Motion comes from two places. Anything that did not move relative to the world is found from the depth buffer and the last frame’s camera. Models whose transform changed since the last frame (tracked per entity, while TAA or temporal AO is on) are drawn again, right after the depth prepass, into a small two-channel motion image: how much further the model moved on screen than the camera alone explains, worked out from the model matrix it had the frame before. Only models that moved are drawn there, so a still scene costs nothing extra. Both TAA and temporal AO (ctx.post.ao_temporal) add it to the camera’s motion, taking it from the nearest surface in each 3x3 block of pixels so the edges of moving models follow them. Where the history disagrees with the colors around a pixel (something moved into view, a light changed), it is clipped to them, so there are no trails behind moving models. The result is softened a little, so ctx.post.taa_sharpness (default 0.25, 0 to 1) sharpens it back.

The history starts over by itself on window resizes, when TAA is turned on, and when the camera jumps far or turns sharply in one frame. After a camera cut the engine cannot see (a teleport that keeps the angle, a switch to a new scene), or after teleporting an object across the screen, call ctx.reset_temporal_history(). It resets temporal AO too.

What the motion image does not see: skinned and morphing meshes move as a whole with their entity (the last frame’s pose is not kept), a material’s vertex function is evaluated at this frame’s time for both frames, and meshes drawn from a GPU buffer (set_mesh_gpu_buffer) move only with the camera. These rely on the color clipping above.

#![allow(unused)]
fn main() {
        ctx.post.anti_aliasing = AntiAliasing::Taa;
        ctx.post.taa_sharpness = 0.3; // it softens the image a little
        // After teleporting the camera (or an object), forget the old view:
        ctx.camera3d.position = vec3(400.0, 3.0, 0.0);
        ctx.reset_temporal_history();
}
/// Temporal antialiasing, and a camera cut.
static void temporal_antialiasing(crabby::ctx& ctx) {
    auto post = ctx.post();
    post.anti_aliasing = uint32_t(crabby::anti_aliasing::taa);
    post.taa_sharpness = 0.3f; // it softens the image a little
    ctx.set_post(post);
    // After teleporting the camera (or an object), forget the old view:
    ctx.reset_temporal_history();
}

What to expect:

  • Cost. Two passes: one draws the motion of the models that moved (depth-tested against the opaque pass, so it costs their vertices and nothing else; with nothing moved it only clears), one resolves the frame. Memory: three full-size HDR images (the lit frame and two histories, 8 bytes a pixel each), a motion image (8 bytes a pixel) and its multisampled partner (8 bytes a sample, discarded after use on tiled GPUs); the opaque pass also keeps its depth for the motion pass. With TAA off none of this is allocated or drawn.
  • Skinned models move with their model matrix only: a limb that bends between frames is not followed, and may smear slightly at its edge.
  • Particles, trails and transparent surfaces have no motion of their own. They reproject with the opaque surface behind them, so a still one is clean and a fast one can ghost a little before the clipping catches it. Custom vertex functions that move vertices (wind, waves) are likewise not part of the motion.
  • Meshes drawn from a GPU buffer (see “GPU buffers”) move rigidly with their entity: the model matrix of the last frame and of this one are applied to the same buffer elements. What the buffer does to each element between frames (a particle’s own motion) is not tracked, so a fast particle can ghost a little like the particles above.
  • LOD groups have no motion of their own either: they suit scenery that stays put. A moving object is best drawn as a Model.
  • Jitter moves only the 3D camera. Shadow maps, sprites and UI, picking rays and anything you read back on the CPU use the unjittered camera; GTAO and screen effects see the same jittered depth as the frame, so they agree with it.
  • Resolution scale. The resolve takes its input and output sizes separately, so a lower-resolution render can later be resolved up to the window’s size; today both are the window’s size.
  • WebGL2 cannot run it (it needs the compute-class devices that ambient occlusion needs, and reads the multisampled depth), so there Taa draws with FXAA instead, without any error.

Screen-space reflections

ctx.post.ssr (0 to 1; 0, the default, is off) makes glossy surfaces reflect what is on screen: wet streets, polished floors, still water. Each pixel follows its reflected view ray across the depth buffer, in 32 jittered steps that start short, then halves the step that went behind something six times to find the hit. The ray reaches up to ctx.post.ssr_max_distance world units (default 30). The opaque pass uses what it hit in place of the sky or reflection probe the surface would reflect otherwise, with the surface’s own Fresnel, so a dielectric floor reflects mostly at grazing angles and a metal one everywhere. Reflections stay at full strength up to roughness 0.4, then fade out by 0.7. Mirrors stay sharp; glossy floors use progressively coarser, depth-aware reflection mips, with interpolation between levels.

Where the ray finds nothing (it leaves the screen, passes behind something, or runs out of length) the environment’s reflection stays. The reflection also fades out near the screen’s edges and over the last 40% of the ray, so it never cuts off hard.

#![allow(unused)]
fn main() {
        // Wet streets and polished floors reflect what's on screen.
        ctx.post.ssr = 1.0;
        ctx.post.ssr_max_distance = 40.0; // world units along the reflected ray
        ctx.post.anti_aliasing = AntiAliasing::Taa; // smooths their grain
        // Glossy surfaces blur it: full strength up to roughness 0.4, gone by 0.7.
        let wet = MaterialDesc {
            roughness: 0.1,
            ..MaterialDesc::color(Color::rgb(0.2, 0.2, 0.22))
        };
}
/// Wet streets and polished floors reflect what's on screen.
static void screen_reflections(crabby::ctx& ctx) {
    auto post = ctx.post();
    post.ssr = 1.0f;
    post.ssr_max_distance = 40.0f; // world units along the reflected ray
    post.anti_aliasing = uint32_t(crabby::anti_aliasing::taa); // smooths their grain
    ctx.set_post(post);
    // Glossy surfaces blur it: full strength up to roughness 0.4, gone by 0.7.
    auto wet = crabby::material_of(crabby::rgb(.2f, .2f, .22f));
    wet.roughness = 0.1f;
    (void)wet;
}

What to expect:

  • Cost. A half-resolution full-screen pass after the depth prepass (each of its pixels stands for a 2x2 block), and a copy of the opaque image after the opaque pass. One compute dispatch builds up to four smaller, depth-aware reflection levels from the trace, starting at quarter resolution; it reads the depth only where there is something to filter. The GPU profiler shows them as screen reflections (the trace) and reflection blur (on tile-based GPUs the latter overlaps the start of the opaque pass, which then carries most of its time). A release offscreen test (reflection_filter_gpu_cost, ignored by default), at 1920x1080 on an Apple M4 Max with a glossy floor and an emissive box, measured about 0.28 ms for the trace and 0.05 ms of frame time for the levels (frames with and without them, alternated); a scene measurement, not a bound. The High and Ultra presets turn SSR on. Memory: the copy (8 bytes a pixel) and one image the opaque pass reads both the reflections and GTAO from (8 bytes a pixel, plus a quarter of that for the half-size reflections, plus under 1.4 bytes per full-size pixel for the filter levels and the image they are made in). Turning them on also turns on the solid depth prepass on GPUs that skip it. With ssr at 0 none of this is allocated or drawn.
  • Blur limits. The footprint grows with roughness, up to about 32 full-size pixels; it does not track ray-hit distance or sample a GGX cone. Depth-plane rejection reduces bleeding across receiver edges, but there is no normal buffer and coarse mips can still lose thin details. Confidence-premultiplied filtering fades misses toward the environment.
  • Last frame’s colors. The reflections are mixed in while the frame is lit, so the colors come from the last frame’s image, at the point the hit was then. The first frame after they are turned on, after a resize or after a camera cut reflects only the environment, and a moving object’s reflection trails it by a frame.
  • Only what is on screen is reflected: not what is behind the camera, off to the side or hidden behind something else. A reflection fades out as what it shows leaves the screen.
  • Normals come from the depth buffer, since the engine keeps no normal image: the reflection follows a surface’s shape, but not its normal map.
  • Grain. The steps are jittered per pixel and per frame. TAA averages that away; without it, reflections show some noise along their edges, and the half resolution makes those edges a little blocky.
  • Transparent surfaces and target cameras don’t get them; nor does WebGL2 (like GTAO, they need a compute-class device).

Graphics quality

Games let players trade looks for speed: Low, Medium, High, Ultra and Custom (some setting changed by hand). GraphicsQuality gathers the settings that decide how heavy the 3D picture is, and ctx.set_graphics_quality switches them while the game runs, with no restart. A new game starts with the engine’s defaults, which are none of the presets.

#![allow(unused)]
fn main() {
        // An options menu: pick a preset ...
        ctx.set_graphics_quality(GraphicsQuality::medium());
        // ... then change single settings on top of it.
        let mut quality = ctx.graphics_quality();
        quality.render_scale = 0.8; // draw the 3D scene at 80% and scale it up
        quality.sharpen = 0.3;
        quality.shadow_resolution = 4096;
        ctx.set_graphics_quality(quality);
        // The settings are the ordinary ones in ctx.post and ctx.lighting.
        ctx.post.ambient_occlusion.radius = 2.0;
        let level = ctx.graphics_quality().level(); // QualityLevel::Custom
}
/// An options menu: pick a preset, then change single settings on top of it.
static void options_menu(crabby::ctx& ctx) {
    ctx.set_graphics_quality(crabby::graphics_quality_preset(crabby::quality_level::medium));
    auto quality = ctx.graphics_quality();
    quality.render_scale = 0.8f; // draw the 3D scene at 80% and scale it up
    quality.sharpen = 0.3f;
    quality.shadow_resolution = 4096;
    ctx.set_graphics_quality(quality);
    // The settings are the ordinary ones in ctx.post() and ctx.lighting().
    auto level = crabby::graphics_quality_level(ctx.graphics_quality()); // quality_level::custom
    (void)level;
}
lowmediumhighultra
render_scale0.67111
sharpen0.5000
shadow_distance305080120
shadow_cascades2344
shadow_resolution1024204820484096
shadow_refresh4221
shadow_filter1233
light_shadow_views04824
light_shadow_resolution51251210242048
light_shadow_atlas1024204840968192
ambient occlusion (slices, steps)offoff2, 44, 8
AO half resolution / temporaloff / offoff / offon / onon / on
ssr (screen-space reflections)0011
gi (global illumination)offoffoffon
anti_aliasingnoneFXAASMAASMAA

The struct holds nothing of its own. Applying it writes the matching fields of ctx.post and ctx.lighting (so everything else there, such as the AO radius, the tone map or the sun, stays as the game set it), and ctx.graphics_quality() reads them back. level() says which preset the settings are, or Custom. Rust clamps values to their ranges; the C API refuses them.

Render scale. ctx.post.render_scale (0.25 to 2) draws the 3D scene at that fraction of the window’s size in each direction, then scales it to the window, under the sprites, text and UI, which stay at the window’s own resolution. Everything in the 3D frame follows the scaled size: the multisampled color and depth, bloom, ambient occlusion, antialiasing and screen effects, so the cost of a pixel-bound scene drops about with the square of the scale. The scaling pass is bilinear when the scene is smaller than the window; above 1 (supersampling) it averages four taps. sharpen (0 to 1) adds an adaptive contrast filter after the scaling, which brings back some of the edge contrast a low scale loses (the idea of AMD’s CAS, in the same pass). At a scale of exactly 1 there is no extra target and no extra pass.

What switching costs. Nothing is drawn from stale targets: the next frame makes what it needs first.

  • A new render_scale, or turning AO or FXAA on or off, makes the frame’s targets again (one frame of allocation, no more).
  • A new shadow_resolution or shadow_cascades makes the sun’s shadow map again; light_shadow_atlas the lights’ atlas. Turning every shadow off frees both.
  • Turning SMAA off frees its targets.
  • Turning global illumination on makes its probes (they start dark and fill in over the next frames); turning it off frees them.
  • Changing the AO’s radius, intensity, slices or steps, the sharpen amount or the shadow distance allocates nothing.

The old GPU memory is freed once the commands that used it have run. MSAA (Game::msaa) is chosen when the game starts and is not part of the presets.

Terrain

Ground that goes on for kilometers is made of tiles: squares of heightfield terrain, each from a grid of heights. ctx.create_terrain_tile(&heights, size, material) makes one from n × n heights, row after row along +Z, and you spawn it with a Transform3D at its corner instead of a Model. Neighbouring tiles share their edge heights, so a tile of 65 × 65 heights 2 m apart is 128 m wide and the next one starts 128 m further on. A NaN height cuts a hole, for a cave mouth or a tunnel.

Each tile is drawn more coarsely as the camera moves away: every height up to lod_distance (by default the tile’s size), then every 2nd height to twice that, every 4th to four times that, and every 8th beyond. The lighting doesn’t jump between levels, because the normals always come from every height. A short skirt hangs down around each tile’s edge, so there are no cracks where tiles of different detail meet.

#![allow(unused)]
fn main() {
/// Rolling hills: 3 x 3 tiles of 128 m, each from 65 x 65 heights 2 m
/// apart, sharing their edges, with a pond-sized hole in the middle tile.
/// Grass in the valleys, dirt higher up and rock on the tops.
fn hills(ctx: &mut Ctx) {
    let height = |x: f32, z: f32| (x * 0.02).sin() * 6.0 + (z * 0.03).cos() * 4.0;
    let ground = hill_ground(ctx, height);
    let (n, size) = (65, 128.0);
    for tz in -1..=1 {
        for tx in -1..=1 {
            let corner = vec3(tx as f32 * size, 0.0, tz as f32 * size);
            let heights: Vec<f32> = (0..n * n)
                .map(|i| {
                    let (x, z) = ((i % n) as f32 * 2.0 + corner.x, (i / n) as f32 * 2.0 + corner.z);
                    let hole = tx == 0 && tz == 0 && (vec2(x, z) - vec2(64.0, 64.0)).length() < 6.0;
                    if hole { f32::NAN } else { height(x, z) }
                })
                .collect();
            let tile = ctx.create_terrain_tile(&heights, size, ground);
            ctx.world.spawn((Transform3D::at(corner), tile));
        }
    }
    ctx.camera3d.position = vec3(64.0, 30.0, 220.0);
    ctx.camera3d.look_at(vec3(64.0, 0.0, 64.0));
}
}
/// Rolling hills: 3 x 3 tiles of 128 m, each from 65 x 65 heights 2 m
/// apart, sharing their edges, with a pond-sized hole in the middle tile.
/// Grass in the valleys, dirt higher up and rock on the tops.
static void hills(crabby::ctx& ctx) {
    auto ground = hill_ground(ctx);
    const int n = 65;
    const float size = 128;
    for (int tz = -1; tz <= 1; ++tz)
        for (int tx = -1; tx <= 1; ++tx) {
            crabby::vec3 corner{float(tx) * size, 0, float(tz) * size};
            std::vector<float> heights(n * n);
            for (int i = 0; i < n * n; ++i) {
                float x = float(i % n) * 2 + corner.x, z = float(i / n) * 2 + corner.z;
                bool hole = tx == 0 && tz == 0 && std::hypot(x - 64, z - 64) < 6;
                heights[i] = hole ? NAN : hill_height(x, z);
            }
            auto tile = ctx.create_terrain_tile(heights, size, ground);
            ctx.world().spawn(crabby::transform3d_at(corner), tile);
        }
    auto cam = ctx.camera3d();
    cam.position = {64, 30, 220};
    ctx.set_camera3d(cam);
    ctx.camera3d_look_at({64, 0, 64});
}

The ground’s look comes from ctx.create_terrain_material: up to 8 layers (grass, dirt, rock, …) as one texture array, and a weight map saying how much of each shows where. The weight map is a texture array of TextureKind::Data too, laid over the ground from weights_corner across weights_size world units: its first image’s red, green, blue and alpha are layers 0 to 3, and a second image holds layers 4 to 7. One weight map can cover many tiles. The layers’ textures are lined up with the world, not with each tile, so they run on across tile edges, each repeating every layer_size units. normals adds a normal map for each layer.

#![allow(unused)]
fn main() {
/// Three ground layers, speckled so they don't look flat (in a game,
/// `ctx.load_texture_array` from image files), and one weight map over
/// all nine tiles from the heights: red grass, green dirt, blue rock.
fn hill_ground(ctx: &mut Ctx, height: impl Fn(f32, f32) -> f32) -> Material {
    let speckled = |base: [u8; 3]| -> Vec<u8> {
        (0..64 * 64u32)
            .flat_map(|i| {
                let shade = (i.wrapping_mul(2_654_435_761) >> 27) as u8; // 0..31
                [
                    base[0].saturating_add(shade),
                    base[1].saturating_add(shade),
                    base[2].saturating_add(shade),
                    255,
                ]
            })
            .collect()
    };
    let (grass, dirt, rock) = (
        speckled([70, 120, 50]),
        speckled([110, 85, 60]),
        speckled([120, 120, 115]),
    );
    let layers = ctx.create_texture_array(64, 64, &[&grass, &dirt, &rock], TextureKind::Color);
    let (cells, area) = (96, 384.0); // 4 m a pixel, from (-128, -128)
    let weights: Vec<u8> = (0..cells * cells)
        .flat_map(|i| {
            let x = (i % cells) as f32 * area / cells as f32 - 128.0;
            let z = (i / cells) as f32 * area / cells as f32 - 128.0;
            let up = ((height(x, z) + 10.0) / 20.0).clamp(0.0, 1.0); // 0 low, 1 high
            let rock = ((up - 0.7) * 4.0).clamp(0.0, 1.0);
            let dirt = (1.0 - (up - 0.5).abs() * 4.0).clamp(0.0, 1.0);
            let grass = 1.0 - rock.max(dirt);
            [grass, dirt, rock, 0.0].map(|w| (w * 255.0) as u8)
        })
        .collect();
    let weights = ctx.create_texture_array(cells, cells, &[&weights], TextureKind::Data);
    let desc = TerrainMaterialDesc {
        layer_size: [6.0, 4.0, 8.0, 4.0, 4.0, 4.0, 4.0, 4.0],
        ..TerrainMaterialDesc::new(layers, weights, vec2(-128.0, -128.0), vec2(area, area))
    };
    ctx.create_terrain_material(&desc)
}
}
static float hill_height(float x, float z) { return std::sin(x * 0.02f) * 6 + std::cos(z * 0.03f) * 4; }

/// Three ground layers, speckled so they don't look flat (in a game,
/// ctx.load_texture_array from image files), and one weight map over
/// all nine tiles from the heights: red grass, green dirt, blue rock.
static crabby::material hill_ground(crabby::ctx& ctx) {
    std::vector<std::uint8_t> pixels;
    for (auto base : {std::array<int, 3>{70, 120, 50}, {110, 85, 60}, {120, 120, 115}})
        for (std::uint32_t i = 0; i < 64 * 64; ++i) {
            int shade = int((i * 2654435761u) >> 27); // 0..31
            for (int c : base) pixels.push_back(std::uint8_t(std::min(c + shade, 255)));
            pixels.push_back(255);
        }
    auto layers = ctx.create_texture_array(64, 64, pixels, crabby::texture_kind::color);
    const std::uint32_t cells = 96;
    const float area = 384; // 4 m a pixel, from (-128, -128)
    std::vector<std::uint8_t> weights;
    for (std::uint32_t i = 0; i < cells * cells; ++i) {
        float x = float(i % cells) * area / float(cells) - 128, z = float(i / cells) * area / float(cells) - 128;
        float up = std::clamp((hill_height(x, z) + 10) / 20, 0.0f, 1.0f); // 0 low, 1 high
        float rock = std::clamp((up - 0.7f) * 4, 0.0f, 1.0f);
        float dirt = std::clamp(1 - std::abs(up - 0.5f) * 4, 0.0f, 1.0f);
        float grass = 1 - std::max(rock, dirt);
        for (float w : {grass, dirt, rock, 0.0f}) weights.push_back(std::uint8_t(w * 255));
    }
    auto weight_map = ctx.create_texture_array(cells, cells, weights, crabby::texture_kind::data);
    auto desc = crabby::terrain_material_of(layers, weight_map, {-128, -128}, {area, area});
    const float sizes[] = {6, 4, 8};
    std::copy(std::begin(sizes), std::end(sizes), desc.layer_size);
    return ctx.create_terrain_material(desc);
}

On steep ground the layers are also laid on from the sides (triplanar mapping), so cliffs don’t stretch them. Flat ground skips the side lookups, so the terrain costs no more than before.

MeshData::heightfield(&heights, size, step) gives the same triangles as plain mesh data, for one detail level of your own.

To find the ground under a point on the CPU, to stand a tree, a character or a camera on it, make a Heightfield from the same heights. height_at(x, z), counted from the tile’s corner, follows the triangles the tile is drawn with, so what it finds is exactly what you see. It gives nothing off the tile or over a hole. normal_at gives the way the ground faces there. TerrainTileData::heightfield() keeps one for a streamed tile.

#![allow(unused)]
fn main() {
/// Trees standing on a 128 m tile: each at the ground's height under it,
/// none over a hole.
fn plant_trees(ctx: &mut Ctx, heights: &[f32], corner: Vec3, tree: Mesh) {
    let ground = Heightfield::new(heights, 128.0);
    for i in 0..20 {
        let (x, z) = ((i * 37 % 128) as f32, (i * 71 % 128) as f32);
        if let Some(y) = ground.height_at(x, z) {
            ctx.world
                .spawn((Transform3D::at(corner + vec3(x, y, z)), Model::new(tree)));
        }
    }
}
}
/// Trees standing on a 128 m tile: each at the ground's height under it,
/// none over a hole.
static void plant_trees(crabby::ctx& ctx, const std::vector<float>& heights, crabby::vec3 corner, crabby::mesh tree) {
    crabby::heightfield ground(heights, 128);
    for (int i = 0; i < 20; ++i) {
        float x = float(i * 37 % 128), z = float(i * 71 % 128);
        if (auto y = ground.height_at(x, z))
            ctx.world().spawn(crabby::transform3d_at({corner.x + x, corner.y + *y, corner.z + z}), crabby::model_of(tree));
    }
}

To walk and drive on the tiles, give each tile’s entity a TerrainCollider from the same heights: it matches the drawn ground, turned and scaled with the tile, holes and all (see 3D physics).

Levels of detail

A LodGroup draws cheaper meshes as an object shrinks on screen, and none once it’s small enough not to matter. It goes on an entity with a Transform3D in place of a Model. Its levels go from the most detailed down, each with the fraction of the screen’s height the object must cover for it to show (as in Unity, so the choice doesn’t change with the window size), and the object’s size, the height that counts. LodLevel::at_distance and LodGroup::distance_at convert between distances and those fractions for games that think in metres.

#![allow(unused)]
fn main() {
        // A 2 m object: the sphere until it's 25% of the screen's height,
        // the cube down to 3%, then nothing. The sphere crossfades into the
        // cube over the top quarter of its range.
        let group = LodGroup::new(
            2.0,
            vec![
                LodLevel::new(0.25, vec![LodRenderer::new(high)]).with_fade(0.25),
                LodLevel::new(0.03, vec![LodRenderer::new(low)]),
            ],
        );
        let e = ctx.world.spawn((Transform3D::default(), group));
        // Lower quality: drop detail at 70% of the distance.
        ctx.lod.bias = 0.7;
}
/// A 2 m object: the sphere until it's 25% of the screen's height, the cube
/// down to 3%, then nothing. The sphere crossfades into the cube over the top
/// quarter of its range.
static crabby::entity lod_object(crabby::ctx& ctx, crabby::mesh high, crabby::mesh low) {
    auto e = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}));
    crabby::lod_level near_level{0.25f, 0.25f, {crabby::lod_renderer_of(high)}};
    crabby::lod_level far_level{0.03f, 0, {crabby::lod_renderer_of(low)}};
    ctx.set_lod_group(e, 2, {near_level, far_level});
    // Lower quality: drop detail at 70% of the distance.
    ctx.set_lod({0.7f, true});
    return e;
}
  • Crossfades. A level with a fade_width hands over to the next one across the top of its range instead of all at once. For that stretch both levels draw through matching dither patterns, so every pixel shows one or the other and no hole opens between them. ctx.lod.crossfade = false turns every fade into a plain switch, which costs less.
  • Quality. ctx.lod.bias scales every group’s screen height: 2 keeps detail twice as far away, 0.5 drops it at half the distance. It is the setting a graphics quality menu changes.
  • Shadows. The shadow maps use the level the main camera picked, crossfade included. with_shadow(LodShadow::Casters(...)) gives a level other meshes to cast with (a simple stand-in, or the detailed meshes while a billboard shows); an empty list casts no shadow.
  • Other cameras. Cameras drawing into render targets pick their own levels.
  • Decals. A ReceivesDecals on the group’s entity applies to every level, as it does to a Model.
  • Scene files. A group is saved as "LodGroup": { "size": 8, "levels": [{ "screen_height": 0.3, "renderers": [{ "mesh": "tree.glb" }] }] }. Its meshes are loaded with the scene, and a group with a mesh made in code isn’t saved.

Picking runs in parallel across all groups each frame, and the meshes it picks are drawn instanced with everything else, so thousands of groups cost about what their visible meshes do.

Occlusion culling

In a town or a forest, most of what’s in view is behind something: a house, a hill, a trunk. With occlusion culling on, those models aren’t drawn.

#![allow(unused)]
fn main() {
        // A town or a forest: skip what houses, hills and trunks hide.
        ctx.camera3d.occlusion_culling = true;
}
/// A town or a forest: skip what houses, hills and trunks hide.
static void cull_hidden(crabby::ctx& ctx) {
    ctx.set_occlusion_culling(true);
}
  • How. After the depth pass, a compute pass shrinks the depth to a grid of tiles (about 160 across) holding each tile’s farthest depth. The CPU reads it back without waiting for the GPU, so the grid it culls with is one to three frames old. A model is skipped when its bounding box, seen from where the camera was then, lies behind that depth everywhere it covers. The test is a handful of lookups per model, done on all cores with the rest of the culling.
  • Late by a frame. Something that comes out from behind a wall can show up a frame or two late. Turning the camera doesn’t cause this (the grid is tested from the view it was drawn from). Moving it can, so the box is grown by how far the camera moved since that frame. A cut to a new place is safe: nothing far from the old view is culled.
  • Shadows. Hidden models still cast shadows into the view.
  • What counts as an occluder. Everything solid that the camera drew, including cut-out leaves (only their opaque texels). Transparent surfaces and particles hide nothing.
  • Cost and limits. Making the grid reads each depth sample once, and testing a model costs far less than drawing it. ctx.render_stats().occluded counts the models skipped last frame. Needs compute shaders: on WebGL2 nothing is culled. Cameras that draw into render targets don’t cull.

Streaming big worlds

A world bigger than memory loads around the player and frees what’s far behind. The engine gives three pieces for this, and the game decides what a “cell” holds: terrain, trees, houses, or all of them.

  • CellStreamer::new(cell_size, load_radius, keep_radius) keeps a grid of square cells on the ground. Each frame, cells.update(position, max_new) works out which cells came within load_radius (at most max_new of them, nearest first, so the work spreads over frames) and which went beyond keep_radius. Read them with to_load() and to_drop(). The gap between the two radii stops a cell at the edge from loading and dropping over and over as the camera moves back and forth.
  • Job::spawn(|| ...) runs work on a worker thread, and job.take() hands its result over once it’s done, without waiting. Build the data there (heights, MeshData, decoded files) and keep GPU work on the game’s thread. TerrainTileData::new(&heights, size) builds every detail level of a tile, and ctx.upload_terrain_tile(&data, material) is then the quick part. In C++, use std::async for the work.
  • ctx.free_mesh(mesh) and ctx.free_terrain_tile(tile) give the GPU memory back. Despawn the entity that showed it too. The next mesh made takes the freed id, so streaming in and out forever doesn’t grow the engine’s tables; drop the old handle when you free it, since it would then name the new mesh. To hide a model for a while (a plant beyond its draw distance), show Mesh::NONE (C++ crabby::mesh_none) instead: it draws nothing and is never another mesh, while a freed handle is. Reloading an evicted glTF keeps its existing materials and textures, including custom shader edits, so repeated travel does not allocate a fresh material set every time geometry comes back.
  • A FarTerrain draws the rest of the world beyond the loaded tiles, from a coarse grid of heights you give it (the world’s heights every 32 m, say): a few large meshes with the terrain material. When a tile is shown, far.cut(cell) cuts the far ground away under it, and far.uncut(cell) fills it in again when the tile goes; far.update(ctx) rebuilds only the meshes around a changed cell. The far ground never overlaps a tile, so nothing fights over depth, and a skirt hangs down from every cut edge, beside the tile’s own skirt, so there’s no crack where coarse and fine ground meet. Its grid must line up with the cells: the cell size a whole number of its spacing, its corner on its grid.
#![allow(unused)]
fn main() {
/// Ground that loads around the camera: tiles 128 m wide within 600 m,
/// built on worker threads, freed past 800 m, and coarse far ground
/// beyond them.
struct Ground {
    cells: CellStreamer,
    tiles: std::collections::HashMap<(i32, i32), Tile>,
    material: Material,
    far: FarTerrain,
}

enum Tile {
    Building(Job<TerrainTileData>),
    Live(Entity, TerrainTile),
}

fn hill(x: f32, z: f32) -> f32 {
    (x * 0.01).sin() * (z * 0.013).cos() * 20.0
}

impl Ground {
    fn new(material: Material) -> Self {
        // The far ground: 8 km around the start, a height every 32 m.
        let n = 257;
        let heights: Vec<f32> = (0..n * n)
            .map(|i| hill(-4096.0 + (i % n) as f32 * 32.0, -4096.0 + (i / n) as f32 * 32.0))
            .collect();
        Self {
            cells: CellStreamer::new(128.0, 600.0, 800.0),
            tiles: Default::default(),
            material,
            far: FarTerrain::new(&heights, n, vec2(-4096.0, -4096.0), 32.0, 128.0, material),
        }
    }

    /// Every frame: start up to 4 tiles, drop the far ones, and put up to
    /// 2 finished ones in the world.
    fn update(&mut self, ctx: &mut Ctx) {
        self.cells.update(ctx.camera3d.position, 4);
        for &cell in self.cells.to_load() {
            let corner = self.cells.corner(cell);
            let job = Job::spawn(move || {
                // 65 x 65 heights, 2 m apart.
                let heights: Vec<f32> = (0..65 * 65)
                    .map(|i| hill(corner.x + (i % 65) as f32 * 2.0, corner.z + (i / 65) as f32 * 2.0))
                    .collect();
                TerrainTileData::new(&heights, 128.0)
            });
            self.tiles.insert(cell, Tile::Building(job));
        }
        for cell in self.cells.to_drop() {
            if let Some(Tile::Live(entity, tile)) = self.tiles.remove(cell) {
                ctx.world.despawn(entity);
                ctx.free_terrain_tile(tile);
                self.far.uncut(*cell);
            }
        }
        let mut uploads = 0;
        for (&cell, tile) in &mut self.tiles {
            if let Tile::Building(job) = tile
                && uploads < 2
                && let Some(data) = job.take()
            {
                let made = ctx.upload_terrain_tile(&data, self.material);
                let entity = ctx.world.spawn((Transform3D::at(self.cells.corner(cell)), made));
                *tile = Tile::Live(entity, made);
                // Shown now: cut the far ground away under it.
                self.far.cut(cell);
                uploads += 1;
            }
        }
        self.far.update(ctx);
    }
}
}
/// Ground that loads around the camera: tiles 128 m wide within 600 m,
/// built on worker threads, freed past 800 m, and coarse far ground
/// beyond them.
static float hill(float x, float z) { return std::sin(x * 0.01f) * std::cos(z * 0.013f) * 20.0f; }

/// The far ground: 8 km around the start, a height every 32 m.
static std::vector<float> far_heights() {
    std::vector<float> heights(257 * 257);
    for (std::size_t i = 0; i < heights.size(); ++i)
        heights[i] = hill(-4096 + float(i % 257) * 32, -4096 + float(i / 257) * 32);
    return heights;
}

struct Ground {
    struct Tile {
        std::future<crabby::terrain_tile_data> building;
        crabby::entity entity = 0;   // once built (`building` is then spent)
        crabby::terrain_tile tile{};
    };
    crabby::cell_streamer cells{128, 600, 800};
    std::map<std::pair<int, int>, Tile> tiles;
    crabby::material material;
    crabby::far_terrain far;

    explicit Ground(crabby::material m) : material(m), far(far_heights(), 257, {-4096, -4096}, 32, 128, m) {}

    /// Every frame: start up to 4 tiles, drop the far ones, and put up to
    /// 2 finished ones in the world.
    void update(crabby::ctx& ctx) {
        cells.update(ctx.camera3d().position, 4);
        for (auto cell : cells.to_load()) {
            auto corner = cells.corner(cell);
            tiles[{cell.x, cell.z}].building = std::async(std::launch::async, [corner] {
                // 65 x 65 heights, 2 m apart.
                std::vector<float> heights(65 * 65);
                for (std::size_t i = 0; i < heights.size(); ++i)
                    heights[i] = hill(corner.x + float(i % 65) * 2, corner.z + float(i / 65) * 2);
                return crabby::terrain_tile_data(heights, 128);
            });
        }
        for (auto cell : cells.to_drop()) {
            auto found = tiles.find({cell.x, cell.z});
            if (found == tiles.end()) continue;
            if (!found->second.building.valid()) {
                ctx.world().despawn(found->second.entity);
                ctx.free_terrain_tile(found->second.tile);
                far.uncut(cell);
            }
            tiles.erase(found);   // a tile still building is waited for here
        }
        int uploads = 0;
        for (auto& [cell, t] : tiles) {
            if (uploads == 2) break;
            if (!t.building.valid() || t.building.wait_for(std::chrono::seconds(0)) != std::future_status::ready) continue;
            t.tile = ctx.upload_terrain_tile(t.building.get(), material);
            t.entity = ctx.world().spawn(crabby::transform3d_at(cells.corner({cell.first, cell.second})), t.tile);
            far.cut({cell.first, cell.second});   // shown now: cut the far ground away under it
            ++uploads;
        }
        far.update(ctx);
    }
};

Cut a cell only once its tile is in the world, not when it starts loading, so there’s never a gap. Without far terrain, hide where the world ends with fog (ctx.sky.fog_density) a little inside the load radius, so new tiles appear behind it, not at an edge. The streaming example flies over endless hills this way. Physics needn’t reach as far as the drawing: collider streaming keeps the ground solid only near the player. On an M4 Max, uploading a 256 m tile of 129 × 129 heights takes 0.5 ms of the game’s frame (0.75 ms at worst). Building it on a worker takes longer, but it doesn’t hold up the frame. Far terrain for 16 km sampled every 32 m (64 meshes) builds in 4 ms the first time; cutting or filling a cell after that rebuilds a mesh or a few, 0.13 ms on average and 0.25 ms at worst, before the GPU upload.

Mixing with 2D

3D draws first and everything 2D draws on top: sprites, shapes, text, UI and the inspector. A 3D game can have a 2D HUD with no extra work. ctx.clear(color) sets the background behind both.

Cost

Models with the same mesh are drawn together in one instanced draw call. A depth prepass finds the nearest surface of every pixel first, so each pixel is lit only once, however many models overlap it. Models outside the camera’s view are skipped, except when they cast a shadow into it. Edges are smoothed with 4x multisampling; Game::msaa(2) takes 2 samples instead, about a third less GPU time for the 3D passes on a large screen.

On GPUs that draw in tiles (Apple’s), solid surfaces skip the prepass: those GPUs already drop hidden solid fragments before lighting them, and only cut-out ones (leaves, grass) gain from being laid down first.

On an M4 Max MacBook Pro, 100,000 spinning cubes with shadows run at 402 FPS, fullscreen with vsync off: 2.5 ms of CPU a frame, in 28 draw calls. The same scene in Unity 6, made of GameObjects, runs at 54 FPS (the comparison). ctx.models_drawn() counts the models drawn last frame, and ctx.render_stats() gives the frame’s draw calls and CPU time.

Not yet

The parts that are deliberately still ahead:

  • tried and tested 3D in the browser (it builds for the web, but no one has run it there yet).

Particles

Add a ParticleEmitter3D and Transform3D to an entity. Each emitter owns a preallocated CPU pool; frame systems run before particles advance. Rate emission and queued bursts share the hard max_particles cap. Excess emission is discarded. The same seed and frame steps reproduce an emitter’s simulation independently of other emitters. This is a visual frame simulation, not a fixed-tick lockstep effect.

#![allow(unused)]
fn main() {
fn setup(ctx: &mut Ctx) {
    ctx.camera3d.position = vec3(0.0, 2.0, 7.0);
    ctx.camera3d.look_at(vec3(0.0, 1.0, 0.0));
    ctx.sky.atmosphere = false;
    let emitter = ParticleEmitter3D::new(ParticleSettings3D {
        rate: 120.0,
        burst: 40,
        lifetime: (0.7, 1.4),
        speed: (2.0, 4.0),
        shape: ParticleShape3D::Cone { angle: 0.45 },
        gravity: vec3(0.0, -3.0, 0.0),
        drag: 0.2,
        size: (0.05, 0.13),
        rotation: (0.0, std::f32::consts::TAU),
        size_over_life: ParticleCurve::new(vec![(0.0, 0.5), (0.2, 1.0), (1.0, 0.0)]),
        color_over_life: ParticleGradient::new(vec![
            (0.0, Color::rgba(1.0, 0.7, 0.2, 1.0)),
            (1.0, Color::rgba(1.0, 0.1, 0.0, 0.0)),
        ]),
        blend: ParticleBlend3D::Additive,
        max_particles: 512,
        seed: 42,
        ..Default::default()
    });
    ctx.world.spawn((Transform3D::default(), emitter));
}
}
    auto fire = crabby::particle_settings()
                    .rate(120)
                    .burst(40)
                    .lifetime(0.7f, 1.4f)
                    .speed(2, 4)
                    .cone(0.45f)
                    .gravity({0, -3, 0})
                    .drag(0.2f)
                    .size(0.05f, 0.13f)
                    .rotation(0, 6.2831853f)
                    .size_over_life({{0, 0.5f}, {0.2f, 1}, {1, 0}})
                    .color_over_life({{0, {1, 0.7f, 0.2f, 1}}, {1, {1, 0.1f, 0, 0}}})
                    .blend(crabby::particle_blend::additive)
                    .max_particles(512)
                    .seed(42);
    auto e = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}));
    ctx.set_particle_emitter3d(e, fire);
    ctx.particle_burst(e, 20); // more at any time

Run the example with cargo run --features 3d --example particles. ParticleShape3D supports point, sphere, cone (along local +Y), and box emission. ParticleCurve and ParticleGradient use ordered keys at normalized ages 0–1, linearly interpolate, and clamp at their endpoints. Settings are immutable after creation; replace the component to change them or reset its seed.

Set looping: false and duration to stop continuous emission after that many seconds. burst is an initial, non-repeating burst; set rate: 0.0 for a pure one-shot. Queue another burst at any time with ctx.world.get_mut::<ParticleEmitter3D>(entity).unwrap().burst(20) (C++: ctx.particle_burst(entity, 20)). Expired particles release pool slots without reallocating. Bursts and rate particles spawn at the end of the current frame; existing particles advance first.

World-space particles keep their spawn transform. With ParticleSpace3D::Local, existing particles follow the entity, gravity uses its coordinates, and the largest absolute scale component scales their square billboards. World-space speed uses rotation without scale. Supply texture: Some(texture) for sprites; sheet: (columns, rows) animates a row-major sprite sheet over each particle’s lifetime. Without a texture, particles are white quads tinted by their gradient.

Particles draw after opaque surfaces, depth tested without depth writes, one instanced draw per live emitter. Emitter centers are sorted by camera-forward depth back to front, and alpha particles are also sorted within each emitter. Overlapping emitters cannot interleave their particles perfectly. Additive particles skip internal sorting. These unlit billboards pass through HDR post processing. This first implementation renders particles in the main camera only. Soft intersections are omitted: the combined opaque/transparent pass does not expose depth for simultaneous sampling. run_headless skips the visual frame phase, so emitters remain idle without GPU work.

Ribbons and lines

Two more components draw camera-facing strips in the same pass as particles, with the same blending (alpha or additive) and textures:

  • Trail3D leaves a ribbon behind a moving entity: tracers, sword swings, streaks behind cars or projectiles. Give an entity a Transform3D and a trail; the engine records where it went and draws the ribbon through those points, so the ribbon stays where the entity was.
  • Line3D draws a line through points: beams, cables, ropes, paths.
#![allow(unused)]
fn main() {
    // A glowing tracer behind a flying ball: a quarter second long, thin at
    // the end and fading out.
    let mut tracer = Trail3D::new(0.25, 0.12);
    tracer.color = ParticleGradient::new(vec![
        (0.0, Color::rgb(1.0, 0.8, 0.3)),
        (1.0, Color::rgba(1.0, 0.3, 0.0, 0.0)),
    ]);
    tracer.blend = ParticleBlend3D::Additive;
    let white = ctx.default_material();
    let ball = ctx.create_mesh(&MeshData::sphere(0.15, 16), white);
    ctx.world
        .spawn((Transform3D::default(), Model::new(ball), tracer, Orbiting));
    // After a teleport: ctx.world.get_mut::<Trail3D>(entity).unwrap().clear();
}
    // A glowing tracer behind a flying ball: a quarter second long, thin at
    // the end and fading out.
    auto tracer = crabby::trail3d(0.25f, 0.12f)
                      .color_over_age({{0, {1, 0.8f, 0.3f, 1}}, {1, {1, 0.3f, 0, 0}}})
                      .blend(crabby::particle_blend::additive);
    auto ball = ctx.create_mesh(crabby::mesh_data::sphere(0.15f, 16), white);
    auto flyer = ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(ball));
    ctx.set_trail3d(flyer, tracer);
    // After a teleport: ctx.clear_trail3d(flyer);

A trail’s lifetime is how long each point lasts. width is a ParticleCurve and color a ParticleGradient, both over the ribbon’s age: 0 at the newest end (the entity), 1 at the oldest. Trail3D::new(lifetime, width) narrows from width to 0 and fades to transparent. A new point is kept only after the entity moved min_distance meters, up to max_points (the oldest go first), though the ribbon always reaches the entity. Set emitting to false and the ribbon fades out; clear() forgets every point (C++: ctx.clear_trail3d(entity)), which you want after a teleport so no ribbon spans the jump. With a texture, u runs along the ribbon (a point’s age over lifetime) and v across. point_count() says how many points it keeps (C++: ctx.trail3d_point_count(entity)).

#![allow(unused)]
fn main() {
    // A laser beam 5 m long, from the entity forward.
    let beam = Line3D {
        blend: ParticleBlend3D::Additive,
        ..Line3D::new(vec![Vec3::ZERO, vec3(0.0, 0.0, -5.0)], 0.03, Color::RED)
    };
    ctx.world.spawn((Transform3D::at(vec3(-4.0, 1.5, 3.0)), beam));
}
    // A laser beam 5 m long, from the entity forward.
    auto beam = crabby::line3d({{0, 0, 0}, {0, 0, -5}}, 0.03f, crabby::colors::red)
                    .blend(crabby::particle_blend::additive);
    auto emitter = ctx.world().spawn(crabby::transform3d_at({-4, 1.5f, 3}));
    ctx.set_line3d(emitter, beam);

A line’s points are in its entity’s space, so the entity moves, turns and scales it (width is multiplied by the largest scale component). Change the points any time: the strip is rebuilt every frame. texture_length is the meters of line per repeat of the texture; 0 stretches it once over the whole line. In C++ call ctx.set_line3d(entity, line) again to change a line.

Like particles, trails and lines are only for show: run_headless skips the visual frame phase, so trails record nothing there.

Decals

There are two kinds. Mesh decals are built once on the CPU and drawn as a mesh; projected decals are boxes the GPU paints onto whatever is inside them each frame. The next section helps choose.

Mesh decals

MeshData::decal(projector, lift) cuts the part of a mesh inside a box out as a mesh of its own, with an image projected straight onto it: bullet holes, scorch marks, splats, signs and tyre marks. projector places the box in the mesh’s space; it maps the unit cube (-0.5 to 0.5 on each axis) onto the box, so its scale is the decal’s size and depth. The image is projected along the box’s -Z (looking from +Z) with its top along +Y, and only faces turned toward +Z get it. Every corner is lifted lift meters along its normal so the decal sits on the surface without flickering; a few millimeters is typical. The result is empty when the box misses the mesh.

#![allow(unused)]
fn main() {
    // A 1 m scorch mark on the floor at (1, 0, -1): the box is 1 x 1 m wide
    // and 0.4 m deep, its +Z turned up, so the image is projected down.
    let up = Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2);
    let projector = Mat4::from_scale_rotation_translation(vec3(1.0, 1.0, 0.4), up, vec3(1.0, 0.0, -1.0));
    let mark = floor_data.decal(projector, 0.003);
    if !mark.indices.is_empty() {
        let scorch = ctx.create_material(MaterialDesc {
            blend: Blend::Transparent,
            ..MaterialDesc::color(Color::rgba(0.05, 0.04, 0.03, 0.8))
        });
        let mark = ctx.create_mesh(&mark, scorch);
        // Drawn where the floor is.
        ctx.world.spawn((Transform3D::default(), Model::new(mark)));
    }
    // A mesh from a glTF file works the same: ctx.load_mesh_data("models/wall.glb")?.decal(..)
}
    // A 1 m scorch mark on the floor at (1, 0, -1): the box is 1 x 1 m wide
    // and 0.4 m deep, its +Z turned up, so the image is projected down.
    // The projector's 16 floats go column after column.
    std::array<float, 16> projector = {1, 0, 0, 0,   0, 0, -1, 0,   0, 0.4f, 0, 0,   1, 0, -1, 1};
    auto mark = floor_data.decal(projector, 0.003f);
    if (mark.view().index_count > 0) {
        auto look = crabby::material_of({0.05f, 0.04f, 0.03f, 0.8f});
        look.blend = 1; // transparent
        auto scorch = ctx.create_mesh(mark, ctx.create_material(look));
        // Drawn where the floor is.
        ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(scorch));
    }
    // A mesh from a glTF file works the same: ctx.load_mesh_data("models/wall.glb").decal(..)

Draw the decal with its own material (usually transparent or cut out) and the same transform as the mesh it was cut from. To cut decals from a model, read its triangles with ctx.load_mesh_data(path), which loads a glTF file’s mesh (all its parts in one, placed as in the file) onto the CPU without making anything on the GPU. It loads the file on each call, so keep the result. In C++ the projector is std::array<float, 16> (column after column).

Projected decals

A Decal3D is a box in the world whose image the renderer paints onto the opaque surfaces inside it, every frame, with no mesh built. Footprints, blood, tyre marks, bullet holes and dirt can number in the hundreds or thousands, and they follow whatever is under them: a decal stays put while a model, a skinned character or a door moves through its box, and a decal parented to a moving model (see attachments) carries its mark along.

The entity’s Transform3D places the box (its scale multiplies size). As with mesh decals the image is projected along the box’s -Z (looking from +Z), its top along +Y. In the example the box’s +Z is turned to point up, so the image lands on the ground below.

#![allow(unused)]
fn main() {
use ponko::*;
fn tyre_marks(ctx: &mut Ctx) {
// One texture array holds every decal image and normal map. Make it with
// TextureKind::Data: decals decode the color layers from sRGB themselves.
let images = ctx.load_texture_array(&["tyre.png", "tyre_n.png"], TextureKind::Data);
ctx.set_decal_textures(Some(images));

let mark = Decal3D {
    size: vec3(1.5, 3.0, 0.5), // width, length, depth
    image: 0,
    normal_image: 1,
    roughness: 0.9,
    max_distance: 40.0,
    ..Default::default()
};
// Looking down: the box's +Z turned to point up.
let down = Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2);
ctx.world.spawn((Transform3D::at(vec3(4.0, 0.1, 2.0)).rotated(down), mark));
// A character that no decal lands on:
ctx.world.spawn((Transform3D::default(), ReceivesDecals::NONE));
}
}
static void projected_decals(crabby::ctx& ctx) {
    // Images and normal maps share one texture array: layer 0 is a tyre mark,
    // layer 1 its normal map. Make it with texture_kind::data.
    std::vector<std::uint8_t> pixels(2 * 2 * 4 * 2, 255);
    ctx.set_decal_textures(ctx.create_texture_array(2, 2, pixels, crabby::texture_kind::data));
    auto mark = crabby::decal3d_default();
    mark.size = {1.5f, 3.0f, 0.5f}; // width, length, depth
    mark.image = 0;
    mark.normal_image = 1;
    mark.roughness = 0.9f;
    mark.max_distance = 40;
    // Looking down: the box's +Z turned to point up.
    auto at = crabby::transform3d_at({4, 0.1f, 2});
    at.rotation = crabby::quat_from_rotation_x(-1.5707964f);
    auto tyre = ctx.world().spawn(at, mark);
    (void)tyre;
    // A character that no decal lands on:
    ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::receives_decals{0});
}

What a Decal3D sets:

  • image, normal_image, uv_min, uv_max: the layer of the decal texture array with the color image and the one with a tangent-space normal map (Decal3D::NO_IMAGE for none; a decal without an image is a flat color rectangle), and the part of the layer to use, so a layer can be an atlas. Leave a few pixels of padding between atlas entries: mipmaps blend them.
  • color, opacity: a tint on the image, and how much of it shows.
  • roughness, metallic: the surface’s values under the decal; negative leaves them. Wet blood is smooth, dust rough, dirt on metal not metallic.
  • angle_fade_start, angle_fade_end: the decal fades out on surfaces turned away from its projection, so it doesn’t stretch over a wall it grazes. Fully visible up to about 57°, gone at about 83° by default.
  • depth_fade: how much of the box’s depth the decal fades over at each end, so it doesn’t end in a hard slice where the ground rises or falls.
  • max_distance: past it the decal isn’t drawn (it fades over the last quarter): the cheapest way to keep a level full of marks light.
  • order: decals with a higher order draw over lower ones.
  • layers and ReceivesDecals: a decal shows on a model when its layers and the model’s ReceivesDecals share a bit (16 layers). Models without the component take every decal; ReceivesDecals::NONE opts a character or a first-person weapon out.

Projected decals are blended into the surface before lighting. They are lit like the surface they land on, so they darken in shadow, take the sun, point lights and reflections, and a normal map or lowered roughness catches the light. Transparent materials, particles and the sky don’t get decals.

How it works and what it costs. The renderer already sorts point and spot lights into a grid of 16 x 9 screen cells with 24 slices of depth. Decals share that grid: each frame the decals the camera sees (at most MAX_DECALS, 4096; beyond that the nearest are kept) are listed per cell, and a pixel blends the decals in its cell. A cell holds at most MAX_DECALS_PER_CLUSTER (64), keeping the highest orders. Pixels outside every decal pay one cell lookup. Collecting 4096 decals took 0.33 ms of CPU in a release build, and an offscreen 1280 x 720 frame with 1000 to 4000 mostly separate decals added roughly 1 to 3 ms (readback included) on a Metal GPU, so cost grows with how many decals overlap each pixel, not with the geometry under them. Each decal uses 128 bytes of GPU memory; the images are the texture array you set.

WebGL2. The mesh shader needs one more sampled texture for the decal images than WebGL2 (and GPUs left at the default limit) allow. Native builds ask the adapter for more; where that fails, ctx.supports_decals() is false and decals are skipped cleanly: nothing else changes and no shader errors.

Mesh decals or projected decals? Use a projected decal for the many small marks that appear and disappear while the game runs (footprints, blood, bullet holes), for anything on moving or skinned models, on terrain, or on geometry you can’t cut (any model in the scene): nothing is built, so spawning one is just spawning an entity. Use a mesh decal when the mark is permanent and the GPU has no room for projected ones (WebGL2), when the decal needs a material of its own (a glowing sign, an animated shader, glass), when it must be transparent or sorted like a transparent surface, or when it should follow one mesh exactly (a sign painted on a wall) and cost nothing per pixel.

Text in the world (SDF)

Text3D draws signs, labels and name tags on an entity’s Transform3D. Its glyph atlas uses signed distance fields (SDF), so one set of glyphs serves different sizes and distances, with outlines and soft edges. Screen-space text stays bitmap for pixel-exact drawing; SDF is for text in the world.

#![allow(unused)]
fn main() {
use ponko::*;
Game::new("World text", 1, 1).run_headless(0, |ctx| {
    let label = Text3D {
        size: 0.3,
        outline: Color::BLACK,
        outline_width: 0.06,
        billboard: true,
        ..Text3D::new("OPEN")
    };
    ctx.world.spawn((Transform3D::at(vec3(0.0, 2.5, 0.0)), label));
});
}
static void world_text_snippet() {
    crabby::game("World text", 1, 1).run_headless(0, [](crabby::ctx& ctx) {
        auto e = ctx.world().spawn(crabby::transform3d_at({0, 2.5f, 0}));
        crabby::text3d label("OPEN");
        label.size = .3f;
        label.outline = {0, 0, 0, 1};
        label.outline_width = .06f;
        label.billboard = true;
        ctx.set_text3d(e, label);
    });
}

Line height (size) is in meters, multiplied by the transform’s scale. The block is centered on the entity in its XY plane, read from +Z; billboard turns it toward the camera. Newlines start lines, aligned left, center (default), or right inside the block. font uses any loaded font, with the built-in font as default. outline_width and softness are fractions of a line, clamped to about 0.12. World text is transparent, depth-tested against solid models, and casts no shadows.

In C++, read an optional owned copy with ctx.text3d(e), replace it with ctx.set_text3d(e, label), or remove it with ctx.remove_text3d(e). The C equivalents use CrabbyText3D settings initialized by crabby_text3d_default(), a copied UTF-8 C string, and crabby_ctx_text3d_text for a caller-owned output buffer. Query its byte length with capacity zero, then allocate length plus one for the NUL. Unknown fonts and alignment values are rejected before changing text.

Sky IBL and local reflections

Ambient light uses diffuse irradiance and a GGX-prefiltered environment with roughness levels and a split-sum BRDF lookup. The diffuse part comes from order-2 spherical harmonics of the environment (nine coefficients, projected on the GPU from every texel of the cube), so it is smooth, with no sampling noise, and looked up from a small cube like before. The procedural sky is baked when its lighting or camera altitude changes. Without an atmosphere, the existing sky and ground colors supply the environment. ambient_intensity scales the sky environment; direct sun lighting is unchanged. Smooth and metallic surfaces now show the sky in their reflections instead of a flat tint, and rough ones are lit by the sky’s direction and color, not just up and down.

A reflection probe captures six 128² linear HDR views only when requested. Place its entity inside a room, then request a bake after creating the room. The capture includes opaque geometry, emissive materials, sky and existing lights; transparent objects are omitted, and where nothing is drawn the sky shows. Captures use sky IBL to avoid recursive local reflections. Moving a probe or changing the scene requires another bake. A bake asked for while the window is hidden happens on the next frame that is drawn.

#![allow(unused)]
fn main() {
use ponko::*;
fn setup(ctx: &mut Ctx) -> Result<(), String> {
let volume = ReflectionProbe3D {
    size: vec3(12.0, 4.0, 8.0),
    ..Default::default()
};
let probe = ctx.world.spawn((Transform3D::at(vec3(0.0, 2.0, 0.0)), volume));
ctx.bake_reflection_probe(probe)?;
// To use a file instead: ctx.load_reflection_probe(probe, "room.exr")?;
Ok(()) }
}
static void reflection_probes(crabby::ctx& ctx) {
    auto volume = crabby::reflection_probe3d_default();
    volume.size = {12, 4, 8};
    auto probe = ctx.world().spawn(crabby::transform3d_at({0, 2, 0}), volume);
    ctx.bake_reflection_probe(probe);
    // To use a file instead: ctx.load_reflection_probe(probe, "room.exr");
}

ReflectionProbe3D defaults to a 10-unit axis-aligned box, a 1-unit fade inside its boundary, intensity 1 and box projection enabled. Transform scale and rotation do not change the box. Box projection intersects the reflection ray with its walls before sampling; disable it for distant environments. Where volumes overlap, the two smallest that reach a pixel are used: a room inside a bigger one wins in its interior, and fades into the bigger one (or the sky) across its blend_distance. Sky lighting fills any remaining weight; outside all volumes, only the sky contributes. Unbaked probes have no influence. content is context-owned: leave it zero until baking or loading assigns it.

A context supports four resident probe environments. Repeated bakes reuse the same slot; attempting to allocate a fifth returns an error. A probe can instead load a linear HDR or EXR 2:1 equirectangular image (+Y at the top, +X at the center). Loading and runtime capture use the same prefilter. Images must have finite radiance; negative values clamp to zero and values above 65504 clamp to the half-float range.

The renderer stores cube faces and roughness levels in a 2D RGBA16F atlas, using the existing sky binding. No extra sampled-texture binding, compute pass or cube array is required: the same render-pass path supports WebGL2. The atlas uses 12 MiB, the scratch cube 0.75 MiB, and each allocated capture’s six targets 0.75 MiB (plus retained multisample/depth target-camera resources). The BRDF lookup is generated once per renderer. Baking costs six scene views plus 64-sample diffuse/specular filtering; it is synchronous GPU work on the next rendered frame, so request bakes during loading or at infrequent scene changes.

GPU buffers and particles

A GpuBuffer holds a fixed-length array of vec4<f32> elements on the GPU. Create it with a nonzero length and either no initial data (zeroed) or one value per element. write_gpu_buffer replaces a checked range before the next frame; it is useful for setup and occasional input, without per-particle CPU work.

A buffer compute shader defines fn update(i: u32), reading and writing items[i]. The engine dispatches one invocation per element in workgroups of 64, checking the last group’s bounds. frame and material have the same uniforms as texture compute, including frame.time.y (delta seconds). ComputePass::for_buffer and texture passes share one ordered list; each finishes before the next begins, before drawing. Invocations within a pass have no ordering: avoid reading values other invocations write.

#![allow(unused)]
fn main() {
fn gpu_particles(ctx: &mut Ctx) -> GpuBuffer {
    let items = ctx.create_gpu_buffer(128, &[]);
    let update = ctx.create_compute("particles", "fn update(i: u32) { items[i].y += frame.time.y; }");
    ctx.compute_passes.push(ComputePass::for_buffer(update, items));
    let shader = ctx.create_shader(
        "particles",
        "fn vertex(v: Vertex) -> Vertex { var o = v; o.position += items[v.instance_index].xyz; return o; }",
    );
    let material = ctx.create_material(MaterialDesc {
        shader: Some(shader),
        ..Default::default()
    });
    let mesh = ctx.create_mesh(&MeshData::cube(0.05), material);
    ctx.set_mesh_gpu_buffer(mesh, items, 128);
    // The emitter's extent around its entity, for culling.
    ctx.set_mesh_gpu_bounds(mesh, Vec3::ZERO, 20.0);
    ctx.world.spawn((Transform3D::default(), Model::new(mesh)));
    items
}
}
static void gpu_particles(crabby::ctx& ctx) {
    auto items = ctx.create_gpu_buffer(128);
    auto update = ctx.create_compute("particles", "fn update(i: u32) { items[i].y += frame.time.y; }");
    ctx.set_compute_passes({crabby::buffer_compute_pass(update, items)});
    auto shader = ctx.create_shader("particles", "fn vertex(v: Vertex) -> Vertex { var o = v; o.position += items[v.instance_index].xyz; return o; }");
    auto desc = crabby::material_of(crabby::colors::white);
    desc.has_shader = true;
    desc.shader = shader;
    auto material = ctx.create_material(desc);
    auto mesh = ctx.create_mesh(crabby::mesh_data::cube(0.05f), material);
    ctx.set_mesh_gpu_buffer(mesh, items, 128);
    // The emitter's extent around its entity, for culling.
    ctx.set_mesh_gpu_bounds(mesh, {0, 0, 0}, 20);
    ctx.world().spawn(crabby::transform3d_at({0, 0, 0}), crabby::model_of(mesh));
}

set_mesh_gpu_buffer binds the same storage as read-only items in the mesh’s material shader and draws count instances. Read items[v.instance_index] in vertex. Every instance is drawn at its entity’s transform and tint, like a normal model: v.position arrives already placed there (v.origin is the entity’s position), and the shader moves it by what it reads from items, in world axes. A particle emitter therefore follows its entity. The engine uploads one instance record per entity, shared by all count instances, and draws once per entity, so use one entity per emitter.

The entity is culled by the mesh’s bounds, which don’t know where the shader puts the instances. set_mesh_gpu_bounds(mesh, center, radius) grows them to also hold a sphere around the entity (in the entity’s space) that covers the whole simulation; call it again to replace the sphere. Disable occlusion culling for shader-displaced geometry. Depth and shadow passes use the same vertex shader. With temporal antialiasing or temporal AO, a moving entity also writes its motion vector, so the whole effect reprojects with it instead of trailing; per-element motion inside the buffer is not tracked (see the motion limits under temporal antialiasing). Buffers cost no CPU work per frame.

free_gpu_buffer releases a buffer’s GPU memory, for effects that come and go. Its compute passes are removed from the context’s list and meshes drawing from it draw nothing until bound to another buffer. The next buffer made reuses the freed id, like freed meshes do, so drop the handle after freeing it.

On devices without storage compute (including WebGL2), buffer updates and buffer-driven draws are skipped without a panic. Headless games retain handles and validate writes, so setup code can be tested without a GPU.

GPU-driven instances

For large static populations, create a persistent instance batch instead of one entity per object. The mesh supplies its materials. Transforms upload when the batch is created or updated; steady frames upload only view and occlusion data. World-space cells reject groups before individual GPU sphere tests. Visible indices drive the normal material, depth and shadow passes through indirect indexed draws. Static instances use camera motion for TAA; they do not need an object motion pass.

#![allow(unused)]
fn main() {
use ponko::*;
fn persistent_instances(ctx: &mut Ctx, mesh: Mesh) {
    let transforms = (0..100_000)
        .map(|i| Mat4::from_translation(vec3((i % 1000) as f32, 0.0, (i / 1000) as f32)))
        .collect::<Vec<_>>();
    let batch = ctx.create_instance_batch(
        mesh,
        &transforms,
        InstanceBatchOptions {
            max_distance: 500.0,
            ..Default::default()
        },
    );
    assert_eq!(ctx.instance_batch_len(batch), Some(100_000));
    assert!(ctx.update_instance_batch(batch, &[Mat4::IDENTITY]));
    ctx.free_instance_batch(batch);
    assert_eq!(ctx.instance_batch_len(batch), None);
    assert!(!ctx.update_instance_batch(batch, &[]));
    ctx.free_instance_batch(batch);
}
}
void persistent_instances(crabby::ctx& ctx, crabby::mesh mesh) {
    std::vector<std::array<float,16>> transforms;
    for (unsigned i=0; i<100000; ++i) {
        transforms.push_back({1,0,0,0, 0,1,0,0, 0,0,1,0,
            float(i%1000),0,float(i/1000),1});
    }
    auto batch = ctx.create_instance_batch(mesh, transforms, {32.0f,500.0f,true,crabby::colors::white});
    ctx.update_instance_batch(batch, transforms);
    ctx.free_instance_batch(batch);
}

Use one batch per streamed region, and free_instance_batch when unloading it. update_instance_batch replaces all transforms and rebuilds cells. Handles are not recycled. Options set cell width, maximum draw distance, tint and shadow casting. Matrices must be finite and affine. Empty batches are valid. A batch holds at most 16,777,216 instances and must fit the device storage-buffer limit. Instance ordering within a batch is unspecified. Materials should be opaque or cutout; transparent instances are not depth sorted. Meshes must outlive their batches.

Devices without compute use persistent vertex buffers and CPU cell culling; they conservatively draw all instances in visible cells. This fallback avoids per-frame transform uploads too. The GPU path keeps independent visibility and indirect-argument buffers for the main camera, sun cascades, target cameras (including oblique reflection views), and packed point/spot shadow views. cast_shadows controls both sun and local shadows. Distance culling uses the target camera or light position; sun cascades use the main camera. Only the main view uses occlusion culling.

View slots grow to the peak concurrent view count and are reused across frames; steady frames allocate no batch buffers or CPU visibility lists. Each GPU slot costs 112 * instance_count + 16 + 20 * mesh_part_count + 128 bytes, plus bind groups. Four sun slots are reserved; a point light adds six slots, a spot light one, and each target camera one. CPU fallback slots reserve one range per cell. Transform/cell data is retained until a batch is updated or freed, so budget batch sizes against the number of simultaneous views. Updates are explicit; animated, skinned and moving objects should continue to use models.

Global illumination

ctx.lighting.gi turns on bounced light: sunlight, sky light and lamp light that hits a surface and lights others from there. A red wall in the sun tints the floor beside it red, a room lit through a window is lit all over, and a closed room goes dark instead of taking the sky’s ambient light, all following the sun through a day and night. It is off by default.

#![allow(unused)]
fn main() {
        // Sunlight bounces off walls and floors onto other surfaces.
        ctx.lighting.gi = GlobalIllumination {
            enabled: true,
            extent: vec3(48.0, 16.0, 48.0), // the probe grid around the camera
            spacing: 2.0,                   // a probe every 2 world units
            probes_per_frame: 4,            // how many look around each frame
            intensity: 1.0,
        };
}
/// Sunlight bounces off walls and floors onto other surfaces.
static void bounced_light(crabby::ctx& ctx) {
    auto light = ctx.lighting();
    light.gi_enabled = true;
    light.gi_extent = {48, 16, 48};  // the probe grid around the camera
    light.gi_spacing = 2.0f;         // a probe every 2 world units
    light.gi_probes_per_frame = 4;   // how many look around each frame
    light.gi_intensity = 1.0f;
    ctx.set_lighting(light);
}

How it works: a grid of light probes moves with the camera, extent across, one every spacing world units (the default 32 x 16 x 32 at 2 units is 17 x 9 x 17 probes). Each frame probes_per_frame of them look around: each draws six small (16x16) views of what is near it, lit as usual (the sun with its shadows, point and spot lights, the sky) and with the probes’ own light as the ambient light, so light bounces once more each time a probe looks again. Each probe keeps how much light reaches it from every direction, and how far away the surfaces around it are. A surface then takes its ambient light from the eight probes around it, each counted only as far as it can see the surface (from those distances), which keeps light from leaking through walls. The probes that look first are the new ones, nearest first (the grid moves a whole spacing at a time, and keeps the probes that are still inside it), then the ones that looked longest ago, nearer ones sooner.

Inside the grid the bounced light replaces the sky and ground ambient light (and reflection probes’ diffuse light); past its edges it fades back to that over one spacing. Where it is darker than the sky’s light would be (indoors), reflections of the sky darken with it.

What to expect:

  • Cost. Per frame: one more pass over the world to find what is near the grid, a 1024² shadow map of that, probes_per_frame times six small views, a compute pass, and eight probe lookups in every lit pixel. In a test scene of 900 buildings at 1280x720 on an Apple M4 Max, the defaults added about 0.5 ms of GPU time and 0.1 ms of CPU time a frame; 16 probes a frame added about 1 to 1.5 ms of GPU time. The Ultra preset turns it on. Memory: about 2.5 kB per probe (6.5 MB for the default grid) and 2 MB for the views and their shadow map.
  • Lag. Probes take turns, so bounced light catches up with a change (a door opening, a lamp turned on, the camera arriving somewhere new) over a few seconds at the defaults: 17 x 9 x 17 probes at 4 a frame is a full round in about 10 seconds at 60 frames a second, nearest probes first. Slow changes like the sun’s are followed smoothly. More probes a frame catch up faster.
  • Coarse light. It is diffuse light at the probes’ spacing: it can’t tell apart spaces much smaller than that, and walls thinner than about a tenth of the spacing may leak. Small things within a cell don’t get their own bounced light, which is what ambient occlusion is for.
  • What the probes see. Their views draw opaque surfaces near the grid (up to four spacings past it), not transparent ones or particles, with the coarser levels of detail, and not instance batches. Surfaces further away are sky to them. Point and spot lights light the views but cast no shadows in them.
  • Where it runs. It needs compute shaders and an 18th texture slot in material shaders, which every desktop and laptop GPU has (Vulkan, Metal, DirectX 12). Elsewhere, as on WebGL2, it is skipped and the ambient light is as without it.