Skip to main content

physics3d/
physics3d.rs

1//! 3D physics: walk around hilly terrain in first person, climb steps,
2//! push crates, and knock them over by clicking. The terrain is drawn
3//! from the same heights its heightfield collider is made from.
4//!
5//!   `cargo run --release --example physics3d --features physics3d`
6//!
7//! Click to lock the mouse (Escape lets it go, Escape again quits). The
8//! mouse looks, WASD walks, Shift runs, Space jumps; left click shoves the
9//! crate you're looking at.
10
11use ponko::*;
12
13const N: usize = 65;
14const SIZE: f32 = 128.0;
15
16fn height(x: f32, z: f32) -> f32 {
17    (x * 0.05).sin() * 3.0 + (z * 0.07).cos() * 2.0 + (x * 0.013 + z * 0.021).sin() * 6.0
18}
19
20/// The walker and where they look.
21struct Player {
22    id: Entity,
23    yaw: f32,
24    pitch: f32,
25}
26
27// ANCHOR: world
28fn build_world(ctx: &mut Ctx) {
29    // Ground: a terrain tile and a heightfield from the same heights,
30    // both at the tile's corner.
31    let corner = vec3(-SIZE / 2.0, 0.0, -SIZE / 2.0);
32    let step = SIZE / (N - 1) as f32;
33    let heights: Vec<f32> = (0..N * N)
34        .map(|i| height(corner.x + (i % N) as f32 * step, corner.z + (i / N) as f32 * step))
35        .collect();
36    let ground = ctx.physics3d.heightfield(&heights, SIZE);
37    let look = grass(ctx);
38    let tile = ctx.create_terrain_tile(&heights, SIZE, look);
39    ctx.world.spawn((
40        Transform3D::at(corner),
41        tile,
42        RigidBody3D::fixed(),
43        Collider3D::mesh(ground),
44    ));
45
46    // Stairs: fixed boxes 0.25 m higher each.
47    let stone = ctx.create_material(MaterialDesc::color(Color::hex(0x9A948A)));
48    let block = ctx.create_mesh(&MeshData::cube(1.0), stone);
49    for i in 0..8 {
50        let size = vec3(2.0, 0.25 * (i + 1) as f32, 0.6);
51        let at = vec3(6.0, height(6.0, 3.0 + i as f32 * 0.6), 3.0 + i as f32 * 0.6);
52        ctx.world.spawn((
53            Transform3D {
54                scale: size,
55                ..Transform3D::at(at + Vec3::Y * size.y / 2.0)
56            },
57            Model::new(block),
58            RigidBody3D::fixed(),
59            Collider3D::cuboid(size),
60        ));
61    }
62
63    // Crates: dynamic, light enough to push.
64    let wood = ctx.create_material(MaterialDesc::color(Color::hex(0xB07A43)));
65    let crate_mesh = ctx.create_mesh(&MeshData::cube(1.0), wood);
66    for i in 0..6 {
67        let (x, z) = (-4.0 + i as f32 * 1.5, -6.0);
68        ctx.world.spawn((
69            Transform3D::at(vec3(x, height(x, z) + 2.0 + i as f32, z)),
70            Model::new(crate_mesh),
71            RigidBody3D::dynamic(),
72            Collider3D::cuboid(Vec3::ONE).density(150.0).friction(0.7),
73            Velocity3D::default(),
74        ));
75    }
76
77    // The walker: a capsule 1.8 m tall, its transform at its feet.
78    let mut walker = Character3D::new(1.8, 0.3);
79    walker.step_height = 0.3;
80    let id = ctx
81        .world
82        .spawn((Transform3D::at(vec3(0.0, height(0.0, 0.0) + 1.0, 0.0)), walker));
83    ctx.world.insert_resource(Player {
84        id,
85        yaw: 0.0,
86        pitch: 0.0,
87    });
88}
89// ANCHOR_END: world
90
91/// A one-layer terrain material: grass everywhere.
92fn grass(ctx: &mut Ctx) -> Material {
93    let green: Vec<u8> = (0..16 * 16)
94        .flat_map(|i| {
95            let shade = 200 + (i * 37 % 56) as u8;
96            [
97                (60 * shade as u32 / 255) as u8,
98                (110 * shade as u32 / 255) as u8,
99                40,
100                255,
101            ]
102        })
103        .collect();
104    let layers = ctx.create_texture_array(16, 16, &[&green], TextureKind::Color);
105    let weights = ctx.create_texture_array(1, 1, &[&[255, 0, 0, 0]], TextureKind::Data);
106    let corner = vec2(-SIZE / 2.0, -SIZE / 2.0);
107    ctx.create_terrain_material(&TerrainMaterialDesc::new(layers, weights, corner, vec2(SIZE, SIZE)))
108}
109
110// ANCHOR: walk
111/// WASD walks the way the walker faces, Shift runs, Space jumps (only
112/// from the ground). A simulation system: it runs before each step.
113fn walk(ctx: &mut Ctx) {
114    let keys = [Key::W, Key::A, Key::S, Key::D, Key::LeftShift].map(|k| ctx.key_down(k));
115    let jump = ctx.key_down(Key::Space);
116    let player = ctx.world.resource::<Player>();
117    let (id, yaw) = (player.id, player.yaw);
118    let Some(c) = ctx.world.get_mut::<Character3D>(id) else {
119        return;
120    };
121    let forward = vec3(-yaw.sin(), 0.0, -yaw.cos());
122    let right = vec3(yaw.cos(), 0.0, -yaw.sin());
123    let axis = |plus: bool, minus: bool| plus as i32 as f32 - minus as i32 as f32;
124    let wish = (forward * axis(keys[0], keys[2]) + right * axis(keys[3], keys[1])).normalize_or_zero();
125    let speed = if keys[4] { 8.0 } else { 4.0 };
126    c.velocity.x = wish.x * speed;
127    c.velocity.z = wish.z * speed;
128    if c.grounded && jump {
129        c.velocity.y = 5.0;
130    }
131}
132// ANCHOR_END: walk
133
134// ANCHOR: queries
135/// Left click shoves whatever is in front of the eye, up to 30 m away.
136fn shove(ctx: &mut Ctx) {
137    let me = ctx.world.resource::<Player>().id;
138    let (eye, forward) = (ctx.camera3d.position, ctx.camera3d.rotation * Vec3::NEG_Z);
139    let filter = QueryFilter3D::default().exclude(me);
140    if let Some(hit) = ctx.physics3d.cast_ray(eye, forward, 30.0, filter)
141        && let Some(mass) = ctx.physics3d.mass(hit.entity)
142    {
143        ctx.physics3d
144            .apply_impulse_at(hit.entity, forward * mass * 6.0, hit.point);
145    }
146}
147// ANCHOR_END: queries
148
149// ANCHOR: look
150/// Every frame: the mouse turns the head (raw motion, so it works while the
151/// cursor is locked) and the camera sits at the walker's eyes. A click
152/// locks the mouse, Escape frees it.
153fn eyes(ctx: &mut Ctx) {
154    if ctx.mouse_pressed(MouseButton::Left) {
155        if ctx.cursor_locked() {
156            shove(ctx);
157        } else {
158            ctx.set_cursor_locked(true);
159        }
160    } else if ctx.key_pressed(Key::Escape) {
161        if ctx.cursor_locked() {
162            ctx.set_cursor_locked(false);
163        } else {
164            ctx.quit();
165        }
166    }
167    let turn = if ctx.cursor_locked() {
168        ctx.mouse_motion() * 0.003
169    } else {
170        Vec2::ZERO
171    };
172    let p = ctx.world.resource_mut::<Player>();
173    p.yaw -= turn.x;
174    p.pitch = (p.pitch - turn.y).clamp(-1.5, 1.5);
175    let (id, yaw, pitch) = (p.id, p.yaw, p.pitch);
176    if let Some(t) = ctx.world.get::<Transform3D>(id) {
177        ctx.camera3d.position = t.position + Vec3::Y * 1.65;
178        ctx.camera3d.rotation = Quat::from_rotation_y(yaw) * Quat::from_rotation_x(pitch);
179    }
180}
181// ANCHOR_END: look
182
183fn main() {
184    // ANCHOR: setup
185    Game::new("Crabby Engine: 3D physics", 1280, 720)
186        .physics3d(vec3(0.0, -9.81, 0.0)) // m/s², +Y up; 50 steps a second
187        .sim_system(walk)
188        .system(eyes)
189        // ANCHOR_END: setup
190        .run(|ctx| {
191            ctx.clear(Color::hex(0x9CC4E4));
192            build_world(ctx);
193        });
194}