Skip to main content

stress_preview/
stress-preview.rs

1//! Many rock draws, values of one mesh type keyed by a seed and batched by
2//! it, scattered from the field's center out to its own edge. A panel
3//! raises or lowers the load: how many instances the field draws, how many
4//! distinct seed values among them, the sun's own shadow, and whether a
5//! control turns a fraction of the field each frame, and reports what a
6//! frame costs alongside how many of its instances lie in the camera's
7//! view.
8//!
9//! `cargo run --example stress-preview`. The camera starts outside the
10//! field's edge looking across it toward the horizon, and orbits the
11//! field's center on its own until `WASD`, the arrows, the wheel or a drag
12//! first move it, at which point the orbit stops for good: `WASD` or the
13//! arrows pan across the field, the wheel raises or lowers the camera, and
14//! holding the left mouse button and dragging turns it, so turning away
15//! from the field faces open ground and turning back faces it again.
16
17use core::time::Duration;
18
19use mirage_engine::prelude::*;
20
21/// Instance count the field starts at.
22const DEFAULT_INSTANCE_COUNT: u32 = 5_000;
23/// Least and most instances the slider allows.
24const MIN_INSTANCE_COUNT: u32 = 500;
25const MAX_INSTANCE_COUNT: u32 = 500_000;
26
27/// Distinct seed values the field starts drawing.
28const DEFAULT_SEED_COUNT: u32 = 8;
29/// Least and most seed values the slider allows, which sets how many
30/// batches the field draws in.
31const MIN_SEED_COUNT: u32 = 1;
32const MAX_SEED_COUNT: u32 = 64;
33
34/// Meters from the center the field scatters over, out to the horizon
35/// under the camera's orbit.
36const FIELD_RADIUS: f32 = 400.0;
37/// Nearest a rock is placed to the center, so the middle stays clear.
38const FIELD_INNER_RADIUS: f32 = 4.0;
39
40/// One rock's side count.
41const ROCK_SIDES: u32 = 7;
42/// Its base radius and height, in meters, before either is displaced.
43const ROCK_BASE_RADIUS: f32 = 0.5;
44const ROCK_HEIGHT: f32 = 1.4;
45/// How far each base corner, and the whole rock's height, are displaced by
46/// an integer-hash of its seed, as a fraction of the base value.
47const ROCK_RADIAL_DISPLACEMENT: f32 = 0.3;
48const ROCK_HEIGHT_DISPLACEMENT: f32 = 0.3;
49
50const ROCK_COLOR: Color = Color::rgb(0.42, 0.4, 0.38);
51const GROUND_COLOR: Color = Color::rgb(0.16, 0.17, 0.14);
52
53const SUN_DIRECTION: Vec3 = Vec3::new(-0.35, -1.0, -0.5);
54const SUN_COLOR: Color = Color::rgb(0.95, 0.92, 0.85);
55
56/// The sky's own zenith, horizon and nadir colors, and the fraction of its
57/// own light it lands on top of the sun's, held low so the sun's shadows
58/// still read.
59const SKY_ZENITH: Color = Color::rgb(0.25, 0.4, 0.65);
60const SKY_HORIZON: Color = Color::rgb(0.75, 0.72, 0.62);
61const SKY_NADIR: Color = Color::rgb(0.12, 0.12, 0.1);
62const SKY_LIGHT: f32 = 0.15;
63
64/// One instance in every `MOVING_STRIDE` is the fraction a control turns
65/// each frame.
66const MOVING_STRIDE: u32 = 5;
67/// Radians a moving rock turns per second.
68const MOVING_SPEED: f32 = 0.6;
69
70/// The camera's height above the ground and its distance from the center,
71/// placed outside the field's own edge so its shallow angle down to the
72/// center keeps the horizon in frame; its vertical field of view in
73/// degrees, and the radians it turns per second.
74const CAMERA_HEIGHT: f32 = 40.0;
75const CAMERA_ORBIT_RADIUS: f32 = 440.0;
76const CAMERA_FOV: f32 = 55.0;
77const CAMERA_ANGULAR_SPEED: f32 = 0.05;
78
79/// Items the camera's view test samples at most, stepping over the field
80/// instead of a test per item where it holds more.
81const MAX_IN_VIEW_SAMPLES: u32 = 50_000;
82
83/// Meters a player's pan covers per second.
84const PAN_SPEED: f32 = 30.0;
85/// Meters one wheel step raises or lowers the camera.
86const WHEEL_STEP: f32 = 3.0;
87/// The camera's least and most height above the ground once the player
88/// holds it.
89const MIN_CAMERA_HEIGHT: f32 = 2.0;
90const MAX_CAMERA_HEIGHT: f32 = 250.0;
91/// Radians a drag turns the view by, per physical pixel it crosses.
92const LOOK_SENSITIVITY: f32 = core::f32::consts::FRAC_PI_2 / 1280.0;
93/// How far short of straight up or down the pitch may turn, in radians.
94const PITCH_LIMIT: f32 = 1.5;
95
96/// Frames the average frame time is taken over.
97const FRAME_TIME_SAMPLES: usize = 60;
98
99/// The control panel's padding, in points.
100const PANEL_PADDING: i8 = 8;
101
102fn main() {
103    run(
104        Config::new("Mirage: stress preview").with_size(1280, 720),
105        StressPreview::init,
106    );
107}
108
109/// One generated rock: a cone of [`ROCK_SIDES`] sides, whose base corners
110/// and height are each displaced by an integer-hash of `seed`.
111#[derive(Catalog, Clone, PartialEq, Eq, Hash)]
112#[catalog(Self { seed: 0 }, Self { seed: 1 }, Self { seed: 2 })]
113struct Rock {
114    seed: u32,
115}
116
117impl Mesh for Rock {
118    fn build(&self, _: &Assets) -> MeshData {
119        build_rock(self.seed)
120    }
121}
122
123// Everything this game draws: the generated rock field and the ground
124// plane under it.
125meshes! { enum Shape { Rock, Plane } }
126
127/// The one sky this game draws, a gradient set each frame.
128#[derive(Catalog, Clone, Copy, Debug, Eq, Hash, PartialEq)]
129enum Sky {
130    Day,
131}
132
133impl Skyboxes for Sky {
134    fn build(&self, _assets: &Assets) -> SkyboxData {
135        match self {
136            Self::Day => SkyboxData::gradient(SKY_ZENITH, SKY_HORIZON, SKY_NADIR).lit_by(SKY_LIGHT),
137        }
138    }
139}
140
141/// `WASD`/arrows pan the camera across the field; the pointer's own motion
142/// turns it, read only while [`Drag::Turn`] is held.
143#[derive(InputAxis2Action, Clone, Copy, PartialEq)]
144enum Motion {
145    Pan,
146    Look,
147}
148
149impl InputAxis2Action for Motion {
150    fn bindings(&self) -> Vec<Axis2Binding> {
151        match self {
152            Self::Pan => vec![
153                Axis2Binding::from(ButtonAxis2 {
154                    left: Key::A,
155                    right: Key::D,
156                    down: Key::S,
157                    up: Key::W,
158                }),
159                Axis2Binding::from(ButtonAxis2 {
160                    left: Key::Left,
161                    right: Key::Right,
162                    down: Key::Down,
163                    up: Key::Up,
164                }),
165            ],
166            Self::Look => vec![Axis2Binding::pointer().scale(LOOK_SENSITIVITY)],
167        }
168    }
169}
170
171/// Held, turns the camera by the pointer's own motion while dragging.
172#[derive(InputButtonAction, Clone, Copy, PartialEq)]
173enum Drag {
174    Turn,
175}
176
177impl InputButtonAction for Drag {
178    fn bindings(&self) -> Vec<ButtonBinding> {
179        match self {
180            Self::Turn => vec![MouseButton::Left.into()],
181        }
182    }
183}
184
185/// How far the wheel moved this frame, which raises or lowers the camera.
186#[derive(InputAxisAction, Clone, Copy, PartialEq)]
187enum Height {
188    Wheel,
189}
190
191impl InputAxisAction for Height {
192    fn bindings(&self) -> Vec<AxisBinding> {
193        match self {
194            Self::Wheel => vec![AxisBinding::from(WheelDelta::Up)],
195        }
196    }
197}
198
199struct Controls;
200
201impl InputActions for Controls {
202    type Button = Drag;
203    type Axis = Height;
204    type Axis2 = Motion;
205}
206
207/// One entry of the field: which rock it draws, where it stands, the
208/// turn it starts at, and whether it is one the moving control turns
209/// further each frame.
210struct FieldEntry {
211    seed: u32,
212    position: Vec3,
213    phase: f32,
214    moving: bool,
215}
216
217/// The load a player sets: how many instances the field draws, how many
218/// distinct seed values among them, and the two controls beside them.
219struct Settings {
220    instance_count: u32,
221    seed_count: u32,
222    sun_shadow: bool,
223    moving: bool,
224}
225
226impl Default for Settings {
227    fn default() -> Self {
228        Self {
229            instance_count: DEFAULT_INSTANCE_COUNT,
230            seed_count: DEFAULT_SEED_COUNT,
231            sun_shadow: true,
232            moving: true,
233        }
234    }
235}
236
237/// Frame time averaged over the last [`FRAME_TIME_SAMPLES`] frames, in
238/// milliseconds.
239struct FrameTimer {
240    samples: [f32; FRAME_TIME_SAMPLES],
241    filled: usize,
242    next: usize,
243}
244
245impl FrameTimer {
246    fn new() -> Self {
247        Self {
248            samples: [0.0; FRAME_TIME_SAMPLES],
249            filled: 0,
250            next: 0,
251        }
252    }
253
254    fn record(&mut self, dt: Duration) {
255        self.samples[self.next] = dt.as_secs_f32() * 1000.0;
256        self.next = (self.next + 1) % self.samples.len();
257        self.filled = (self.filled + 1).min(self.samples.len());
258    }
259
260    /// The average over every sample kept so far, `0.0` before the first.
261    fn average_ms(&self) -> f32 {
262        if self.filled == 0 {
263            return 0.0;
264        }
265        self.samples[..self.filled].iter().sum::<f32>() / self.filled as f32
266    }
267}
268
269/// The camera once the player has taken it over: an `eye` turned by `yaw`
270/// (around the world's own up) and `pitch` (up or down).
271struct Player {
272    eye: Vec3,
273    yaw: f32,
274    pitch: f32,
275}
276
277impl Player {
278    /// This camera's forward direction, from `yaw` and `pitch`.
279    fn forward(&self) -> Vec3 {
280        Vec3::new(
281            -self.pitch.cos() * self.yaw.sin(),
282            self.pitch.sin(),
283            -self.pitch.cos() * self.yaw.cos(),
284        )
285    }
286
287    fn camera(&self) -> Camera {
288        Camera::new(
289            View::look_at(self.eye, self.eye + self.forward()),
290            Projection::perspective(CAMERA_FOV),
291        )
292    }
293}
294
295struct StressPreview {
296    settings: Settings,
297    applied_instance_count: u32,
298    applied_seed_count: u32,
299    field: Vec<FieldEntry>,
300    frame_times: FrameTimer,
301    /// Set at the instant a pan, a wheel step or a drag first moves the
302    /// camera; from then on the orbit never runs again.
303    player: Option<Player>,
304}
305
306impl StressPreview {
307    fn init(_ctx: &mut InitContext<'_, Self>) -> Result<Self, Error> {
308        let settings = Settings::default();
309        let field = build_field(settings.instance_count, settings.seed_count);
310        Ok(Self {
311            applied_instance_count: settings.instance_count,
312            applied_seed_count: settings.seed_count,
313            settings,
314            field,
315            frame_times: FrameTimer::new(),
316            player: None,
317        })
318    }
319
320    /// Rebuilds the field where the instance count or the seed count
321    /// changed since the last frame.
322    fn apply_settings(&mut self) {
323        if self.settings.instance_count == self.applied_instance_count
324            && self.settings.seed_count == self.applied_seed_count
325        {
326            return;
327        }
328        self.field = build_field(self.settings.instance_count, self.settings.seed_count);
329        self.applied_instance_count = self.settings.instance_count;
330        self.applied_seed_count = self.settings.seed_count;
331    }
332
333    /// The camera's place along the orbit at `elapsed`, before the player
334    /// takes it over.
335    fn orbit_eye(elapsed: f32) -> Vec3 {
336        let angle = elapsed * CAMERA_ANGULAR_SPEED;
337        Vec3::new(
338            angle.cos() * CAMERA_ORBIT_RADIUS,
339            CAMERA_HEIGHT,
340            angle.sin() * CAMERA_ORBIT_RADIUS,
341        )
342    }
343
344    /// The frame's camera: the orbit at `elapsed`, or the player's own
345    /// place once they have taken over.
346    fn camera(&self, elapsed: f32) -> Camera {
347        match &self.player {
348            Some(player) => player.camera(),
349            None => Camera::new(
350                View::look_at(Self::orbit_eye(elapsed), Vec3::ZERO),
351                Projection::perspective(CAMERA_FOV),
352            ),
353        }
354    }
355
356    /// Reads the pan, wheel and drag controls, taking the camera over from
357    /// the orbit at the first frame any of them moves it.
358    fn handle_camera(&mut self, ctx: &mut FrameContext<'_, Self>, elapsed: f32) {
359        if ctx.ui_wants_pointer() || ctx.ui_wants_keyboard() {
360            return;
361        }
362        let pan = ctx.axis2(Motion::Pan);
363        let wheel = ctx.axis(Height::Wheel);
364        let look = if ctx.down(Drag::Turn) {
365            ctx.axis2(Motion::Look)
366        } else {
367            Vec2::ZERO
368        };
369        if pan == Vec2::ZERO && wheel == 0.0 && look == Vec2::ZERO {
370            return;
371        }
372
373        let player = self.player.get_or_insert_with(|| {
374            let eye = Self::orbit_eye(elapsed);
375            let forward = (Vec3::ZERO - eye).normalize();
376            Player {
377                eye,
378                yaw: (-forward.x).atan2(-forward.z),
379                pitch: forward.y.asin(),
380            }
381        });
382
383        player.yaw -= look.x;
384        player.pitch = (player.pitch + look.y).clamp(-PITCH_LIMIT, PITCH_LIMIT);
385
386        let forward = Vec3::new(-player.yaw.sin(), 0.0, -player.yaw.cos());
387        let right = Vec3::new(player.yaw.cos(), 0.0, -player.yaw.sin());
388        player.eye += (forward * pan.y + right * pan.x) * PAN_SPEED * ctx.dt().as_secs_f32();
389        player.eye.y =
390            (player.eye.y + wheel * WHEEL_STEP).clamp(MIN_CAMERA_HEIGHT, MAX_CAMERA_HEIGHT);
391    }
392
393    fn draw_ground(ctx: &mut FrameContext<'_, Self>) {
394        let side = (FIELD_RADIUS + FIELD_INNER_RADIUS) * 2.2;
395        ctx.draw(
396            Plane
397                .at(Transform::from_scale(Vec3::new(side, 1.0, side)))
398                .material(Material::lit(GROUND_COLOR).roughness(0.9)),
399        );
400    }
401
402    fn draw_field(&self, ctx: &mut FrameContext<'_, Self>, elapsed: f32) {
403        for entry in &self.field {
404            let yaw = if self.settings.moving && entry.moving {
405                entry.phase + elapsed * MOVING_SPEED
406            } else {
407                entry.phase
408            };
409            ctx.draw(
410                Rock { seed: entry.seed }.at(Transform::from_scale_rotation_translation(
411                    Vec3::ONE,
412                    Quat::from_rotation_y(yaw),
413                    entry.position,
414                )),
415            );
416        }
417    }
418
419    /// How many field items lie in the camera's view at `window_size`:
420    /// every item where the field holds at most [`MAX_IN_VIEW_SAMPLES`],
421    /// otherwise one item stepped at a time and the count scaled back up
422    /// to the whole field; `true` in the second place where the count
423    /// came from such a step.
424    fn count_in_view(&self, camera: &Camera, window_size: UVec2) -> (usize, bool) {
425        let stride = (self.field.len() as u32 / MAX_IN_VIEW_SAMPLES).max(1) as usize;
426        let tested = self.field.iter().step_by(stride);
427        let tested_count = tested.clone().count();
428        let in_view = tested
429            .filter(|entry| Self::in_view(camera, entry.position, window_size))
430            .count();
431        let estimate = in_view
432            .checked_mul(self.field.len())
433            .and_then(|scaled| scaled.checked_div(tested_count))
434            .unwrap_or(in_view);
435        (estimate, stride > 1)
436    }
437
438    /// Whether `position` draws inside `window_size`, the frame's own
439    /// bound of what the camera's view holds.
440    fn in_view(camera: &Camera, position: Vec3, window_size: UVec2) -> bool {
441        camera.pixel_of(position, window_size).is_some_and(|pixel| {
442            pixel.x >= 0.0
443                && pixel.y >= 0.0
444                && pixel.x < window_size.x as f32
445                && pixel.y < window_size.y as f32
446        })
447    }
448
449    /// The load controls, and this frame's own cost, reported below them.
450    fn controls(&mut self, ctx: &mut FrameContext<'_, Self>, camera: &Camera) {
451        let submitted = self.field.len();
452        let seeds = self.applied_seed_count;
453        let average_ms = self.frame_times.average_ms();
454        let fps = if average_ms > 0.0 {
455            1000.0 / average_ms
456        } else {
457            0.0
458        };
459        let elapsed = ctx.elapsed().as_secs_f32();
460        let (in_view, sampled) = self.count_in_view(camera, ctx.window_size());
461
462        ctx.ui(|ui| {
463            egui::Frame::new()
464                .fill(egui::Color32::from_gray(24))
465                .inner_margin(PANEL_PADDING)
466                .corner_radius(f32::from(PANEL_PADDING))
467                .show(ui, |ui| {
468                    ui.add(
469                        egui::Slider::new(
470                            &mut self.settings.instance_count,
471                            MIN_INSTANCE_COUNT..=MAX_INSTANCE_COUNT,
472                        )
473                        .text("instance count"),
474                    );
475                    ui.add(
476                        egui::Slider::new(
477                            &mut self.settings.seed_count,
478                            MIN_SEED_COUNT..=MAX_SEED_COUNT,
479                        )
480                        .text("distinct seeds"),
481                    );
482                    ui.checkbox(&mut self.settings.sun_shadow, "sun shadow");
483                    ui.checkbox(&mut self.settings.moving, "moving fraction");
484                    ui.separator();
485                    ui.label(format!("instances submitted {submitted}"));
486                    if sampled {
487                        ui.label(format!("in view, sampled {in_view}"));
488                    } else {
489                        ui.label(format!("instances in view {in_view}"));
490                    }
491                    ui.label(format!("distinct seeds {seeds}"));
492                    ui.label(format!("frame time {average_ms:.2}ms, {fps:.0} fps"));
493                    ui.label(format!("elapsed {elapsed:.1}s"));
494                });
495        });
496    }
497}
498
499/// `instance_count` field values, each drawing one of `seed_count`
500/// distinct seed values in a cycle, and scattered from
501/// [`FIELD_INNER_RADIUS`] out to [`FIELD_RADIUS`]; each built from an
502/// integer-hash of its own index.
503fn build_field(instance_count: u32, seed_count: u32) -> Vec<FieldEntry> {
504    (0..instance_count)
505        .map(|index| {
506            let angle = hash_unit(index, 0) * core::f32::consts::TAU;
507            let spread = hash_unit(index, 1).sqrt();
508            let distance = FIELD_INNER_RADIUS + spread * (FIELD_RADIUS - FIELD_INNER_RADIUS);
509            FieldEntry {
510                seed: index % seed_count,
511                position: Vec3::new(angle.cos() * distance, 0.0, angle.sin() * distance),
512                phase: hash_unit(index, 2) * core::f32::consts::TAU,
513                moving: index % MOVING_STRIDE == 0,
514            }
515        })
516        .collect()
517}
518
519/// A rock built from `seed`: a cone of [`ROCK_SIDES`] sides, each base
520/// corner and the apex height displaced by an integer-hash of `seed`.
521fn build_rock(seed: u32) -> MeshData {
522    let height = ROCK_HEIGHT * (1.0 + hash_signed(seed, ROCK_SIDES) * ROCK_HEIGHT_DISPLACEMENT);
523    let apex = Vec3::Y * height;
524    let base: Vec<Vec3> = (0..ROCK_SIDES)
525        .map(|corner| {
526            let angle = core::f32::consts::TAU * corner as f32 / ROCK_SIDES as f32;
527            let radius =
528                ROCK_BASE_RADIUS * (1.0 + hash_signed(seed, corner) * ROCK_RADIAL_DISPLACEMENT);
529            Vec3::new(angle.cos() * radius, 0.0, angle.sin() * radius)
530        })
531        .collect();
532
533    let mut vertices = Vec::with_capacity(base.len() * 6);
534    let mut indices = Vec::with_capacity(base.len() * 6);
535    for corner in 0..base.len() {
536        let next = (corner + 1) % base.len();
537        push_face(&mut vertices, &mut indices, base[corner], apex, base[next]);
538        push_face(
539            &mut vertices,
540            &mut indices,
541            base[corner],
542            base[next],
543            Vec3::ZERO,
544        );
545    }
546
547    MeshData::new(vertices, indices).with_material(Material::lit(ROCK_COLOR))
548}
549
550/// One triangle, shaded flat, over `a`, `b`, `c`, in the order that faces
551/// outward: counter-clockwise as seen from the side its own normal points
552/// to.
553fn push_face(vertices: &mut Vec<Vertex>, indices: &mut Vec<u32>, a: Vec3, b: Vec3, c: Vec3) {
554    let normal = (b - a).cross(c - a).normalize();
555    let uvs = [
556        Vec2::new(0.0, 1.0),
557        Vec2::new(0.5, 0.0),
558        Vec2::new(1.0, 1.0),
559    ];
560    let base = vertices.len() as u32;
561    for (point, uv) in [a, b, c].into_iter().zip(uvs) {
562        vertices.push(Vertex::new(point, normal, uv));
563    }
564    indices.extend([base, base + 1, base + 2]);
565}
566
567/// An integer-hash of `seed` and `salt`.
568fn hash(seed: u32, salt: u32) -> u32 {
569    let mut x = seed ^ salt.wrapping_mul(0x9E37_79B9);
570    x ^= x >> 16;
571    x = x.wrapping_mul(0x7FEB_352D);
572    x ^= x >> 15;
573    x = x.wrapping_mul(0x846C_A68B);
574    x ^= x >> 16;
575    x
576}
577
578/// `hash`, scaled to `0.0..1.0`.
579fn hash_unit(seed: u32, salt: u32) -> f32 {
580    hash(seed, salt) as f32 / u32::MAX as f32
581}
582
583/// `hash`, scaled to `-1.0..1.0`.
584fn hash_signed(seed: u32, salt: u32) -> f32 {
585    hash_unit(seed, salt) * 2.0 - 1.0
586}
587
588impl Game for StressPreview {
589    type Meshes = Shape;
590    type Sounds = NoSounds;
591    type InputActions = Controls;
592    type Skyboxes = Sky;
593    type SurfaceStyles = ();
594    type PostEffects = ();
595
596    fn tick(&mut self, _ctx: &mut TickContext<'_, Self>) {}
597
598    fn frame(&mut self, ctx: &mut FrameContext<'_, Self>) {
599        self.apply_settings();
600        self.frame_times.record(ctx.dt());
601
602        let elapsed = ctx.elapsed().as_secs_f32();
603        self.handle_camera(ctx, elapsed);
604        let camera = self.camera(elapsed);
605        ctx.set_camera(camera);
606        ctx.set_skybox(Sky::Day);
607
608        let sun = Light::directional(SUN_DIRECTION, SUN_COLOR);
609        ctx.light(if self.settings.sun_shadow {
610            sun.shadow()
611        } else {
612            sun
613        });
614
615        Self::draw_ground(ctx);
616        self.draw_field(ctx, elapsed);
617        self.controls(ctx, &camera);
618    }
619}