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)andsphere(radius, segments), thenctx.create_mesh(&data, material). - Your own triangles: fill a
MeshDatawith positions, indices (three per triangle, counter-clockwise from the front) and optionally normals, texture coordinates andcolors(one per corner, multiplying the material’s color). Normals you leave out are computed. A second set of texture coordinates,uvs1(glTF’sTEXCOORD_1), is there for your own shaders to read asv.uv1ands.uv1: texture layers, masks, lightmaps.create_meshchecks that the data fits together. - glTF files:
ctx.load_mesh("models/house.glb")loads a.glb(or a.gltfwith its files) fromassets/, 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_colorandsun_intensity: the sun’s color and brightness.sky_colorandground_color: soft light from above and below, scaled byambient_intensity. They (or the atmosphere) are also what sky IBL lights surfaces with.equator_color(Noneby 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);
}
shadowsandshadow_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 take4 × resolution² × cascadesbytes 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_strengthscales it.orm_map: ambient occlusion in red, roughness in green and metallic in blue, multiplyingroughnessandmetallic.emissive_map: multipliesemissive.
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) -> Vertexmoves vertices, in world space: wind in grass, waves on water.v.originis where the model is, so each model can sway or vary on its own.fn surface(s: Surface) -> Surfacechanges each pixel: itsalbedo,alpha,normal,roughness,metallic,occlusionandemissive, filled in from the material first. It can reads.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) ands.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.rgbandframe.ground.rgb;material.custom[0]to[3]: fourvec4s fromMaterialDesc::custom;custom_texture0andcustom_texture1(fromMaterialDesc::custom_textures), and the material’s own textures, withmaterial_sampler;custom_array0tocustom_array2(texture_2d_array<f32>, fromMaterialDesc::custom_arrays): texture arrays, many same-size images in one, picked by layer:textureSample(custom_array0, material_sampler, s.uv, 2). Make one withctx.create_texture_arrayorctx.load_texture_array, asTextureKind::Color(painted colors) orTextureKind::Data(normal maps, masks and weights, read as stored);- helpers:
hash2,noise2(smooth value noise) andfbm2(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, andscene_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 isctx.clear’s color and the light comes fromLighting’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),starsandnight_light(moonlight and starlight, when the sun is down).fog_density,fog_heightandfog_falloff: height fog, thickest atfog_heightand thinning out above it. It takes the sky’s color behind it (orfog_colorwithout an atmosphere) and covers the sky near the horizon too.fog_startandfog_end: distance fog, none closer thanfog_startand all fog pastfog_end, whatever the height. Off whilefog_endisn’t pastfog_start(the default). It takes the same color as the height fog, and leaves the sky alone.fog_sun_glowandfog_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, fromp.color(the light there so far, brighter than white where the scene is bright),p.worldandp.distance(the nearest solid surface and how far it is from the camera),p.sky(nothing solid there),p.screen(in pixels) andp.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, andc.uvfrom 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).bloomandbloom_threshold: light brighter than the threshold glows into its surroundings (0 turns bloom off).temperatureandtint: white balance, cooler or warmer, greener or pinker.contrastandsaturation: 1 keeps them as they are.tonemap: how bright light rolls off to white.Tonemap::Aces(the default) is film-like,AgXkeeps the hue of bright colors,Neutralstays closest to the materials’ own colors.lut: a color lookup table for grading in an image editor. Start fromPost::neutral_lut(32), save it as a PNG, paint over it, and load it withctx.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
vertexfunctions 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
Taadraws 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) andreflection 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. Withssrat 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;
}
| low | medium | high | ultra | |
|---|---|---|---|---|
render_scale | 0.67 | 1 | 1 | 1 |
sharpen | 0.5 | 0 | 0 | 0 |
shadow_distance | 30 | 50 | 80 | 120 |
shadow_cascades | 2 | 3 | 4 | 4 |
shadow_resolution | 1024 | 2048 | 2048 | 4096 |
shadow_refresh | 4 | 2 | 2 | 1 |
shadow_filter | 1 | 2 | 3 | 3 |
light_shadow_views | 0 | 4 | 8 | 24 |
light_shadow_resolution | 512 | 512 | 1024 | 2048 |
light_shadow_atlas | 1024 | 2048 | 4096 | 8192 |
| ambient occlusion (slices, steps) | off | off | 2, 4 | 4, 8 |
| AO half resolution / temporal | off / off | off / off | on / on | on / on |
ssr (screen-space reflections) | 0 | 0 | 1 | 1 |
gi (global illumination) | off | off | off | on |
anti_aliasing | none | FXAA | SMAA | SMAA |
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_resolutionorshadow_cascadesmakes the sun’s shadow map again;light_shadow_atlasthe 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,slicesorsteps, thesharpenamount 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_widthhands 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 = falseturns every fade into a plain switch, which costs less. - Quality.
ctx.lod.biasscales 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
ReceivesDecalson the group’s entity applies to every level, as it does to aModel. - 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().occludedcounts 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 withinload_radius(at mostmax_newof them, nearest first, so the work spreads over frames) and which went beyondkeep_radius. Read them withto_load()andto_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, andjob.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, andctx.upload_terrain_tile(&data, material)is then the quick part. In C++, usestd::asyncfor the work.ctx.free_mesh(mesh)andctx.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), showMesh::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
FarTerraindraws 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, andfar.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:
Trail3Dleaves a ribbon behind a moving entity: tracers, sword swings, streaks behind cars or projectiles. Give an entity aTransform3Dand a trail; the engine records where it went and draws the ribbon through those points, so the ribbon stays where the entity was.Line3Ddraws 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_IMAGEfor none; a decal without an image is a flatcolorrectangle), 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.layersandReceivesDecals: a decal shows on a model when itslayersand the model’sReceivesDecalsshare a bit (16 layers). Models without the component take every decal;ReceivesDecals::NONEopts 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_frametimes 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.