mirage-engine 0.1.1

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
use core::marker::PhantomData;
use core::time::Duration;

use crate::animation::{AnimationStates, Animator, Running};
use crate::math::{Mat3, Mat4, Vec3, Vec4};
use crate::mesh::{Animation, Clip, Frame, Mesh, Part, Posing};
use crate::surface_style::{Styled, SurfaceStyle, SurfaceStyleId, SurfaceStyles};
use crate::{Holds, Material, Transform, View};

/// The fade of a draw until it sets one: its slots keep the alpha they
/// resolved to.
const OPAQUE: f32 = 1.0;

/// A draw as the engine records it: a mesh, its position, its turn, the
/// part of its textures it samples, the seat of the style it is drawn
/// with, the fade over its slots, the pose it is drawn in, and the
/// materials that override its slot defaults.
#[derive(Clone, Debug)]
pub(crate) struct Draw<M> {
    mesh: M,
    transform: Transform,
    facing: Facing,
    roll: f32,
    frame: Frame,
    style: Option<Styled>,
    fade: f32,
    posed: Option<Running>,
    paints: Paints,
}

impl<M> Draw<M> {
    /// The same draw with its mesh turned into the set `T` that holds it.
    pub(crate) fn into_set<T: From<M>>(self) -> Draw<T> {
        let Self {
            mesh,
            transform,
            facing,
            roll,
            frame,
            style,
            fade,
            posed,
            paints,
        } = self;
        Draw {
            mesh: mesh.into(),
            transform,
            facing,
            roll,
            frame,
            style,
            fade,
            posed,
            paints,
        }
    }

    pub(crate) fn mesh(&self) -> &M {
        &self.mesh
    }

    /// The draw's position and turn, seen from `view`.
    pub(crate) fn placement(&self, view: View) -> Placement {
        Placement {
            transform: self.facing.applied(self.transform, view, self.roll),
            faced: self.faced(),
        }
    }

    /// Where the draw is positioned, whatever turn a view applies to it.
    pub(crate) fn anchor(&self) -> Vec3 {
        self.transform.matrix().w_axis.truncate()
    }

    /// The part of its textures the draw samples.
    pub(crate) fn window(&self) -> Frame {
        self.frame
    }

    /// Whether the draw is turned by the camera rather than by its own
    /// transform.
    pub(crate) fn faced(&self) -> bool {
        self.facing != Facing::AsPlaced
    }

    /// The style the draw is drawn with, or nothing where none was set.
    pub(crate) fn styled(&self) -> Option<Styled> {
        self.style
    }

    /// The pose the draw holds at `now`, read over the clips of the mesh
    /// drawn; nothing where it is drawn at the rest of every joint.
    pub(crate) fn posing(&self, now: Duration, clips: &[Animation]) -> Option<Posing> {
        Some(self.posed?.posing(now, clips))
    }

    /// The material a slot the draw covers resolves to: the draw's write
    /// to the part at `part`, else its write to every slot, else the
    /// slot's own `default`, faded by the draw's own alpha.
    pub(crate) fn resolved(&self, part: Option<u32>, default: Material) -> Material {
        part.and_then(|part| self.paints.of(part))
            .or(self.paints.every)
            .unwrap_or(default)
            .faded(self.fade)
    }
}

/// A draw a game builds and submits: a mesh's position, its turn, the
/// part of its textures it samples, the style it is drawn with, the fade
/// over its slots, and any materials that override its slot defaults.
///
/// `S` is the game's style set, which [`surface_style`](Instance::surface_style) proves a
/// style against. It is `()` by default, the style set of a game with no
/// style of its own; a game with styles of its own writes
/// `Instance<M, Looks>` wherever it names the type — `Looks` is the set
/// `examples/sprite-adventure.rs` declares.
#[must_use = "an instance is only drawn once FrameContext::draw takes it"]
#[derive(Debug)]
pub struct Instance<M, S: SurfaceStyles = ()> {
    draw: Draw<M>,
    styles: PhantomData<S>,
}

impl<M, S: SurfaceStyles> Instance<M, S> {
    /// A draw of `mesh` placed by `transform`, with nothing else set.
    pub(crate) fn new(mesh: M, transform: Transform) -> Self {
        Self {
            draw: Draw {
                mesh,
                transform,
                facing: Facing::AsPlaced,
                roll: 0.0,
                frame: Frame::default(),
                style: None,
                fade: OPAQUE,
                posed: None,
                paints: Paints::default(),
            },
            styles: PhantomData,
        }
    }

    /// Moves the instance to `transform`, in place of the one it has.
    pub fn at(mut self, transform: impl Into<Transform>) -> Self {
        self.draw.transform = transform.into();
        self
    }

    /// Turns the draw to face the frame's camera, in place of the turn its
    /// transform holds.
    ///
    /// The transform's sizes are still the draw's size, and its position
    /// still places it. The last of the two facing calls is the one used.
    pub fn billboard(mut self) -> Self {
        self.draw.facing = Facing::Billboard;
        self
    }

    /// Turns the draw about `+Y` alone to face the frame's camera — a
    /// sprite upright on the ground, however far the camera looks down at
    /// it.
    ///
    /// Keeps the transform's sizes and position, like
    /// [`billboard`](Instance::billboard).
    pub fn upright(mut self) -> Self {
        self.draw.facing = Facing::Upright;
        self
    }

    /// Turns the draw `radians` within the view plane, counter-clockwise
    /// from the camera's viewpoint.
    ///
    /// Required if you want a billboarded draw turned around: a draw turned
    /// by [`upright`](Instance::upright) or by its own transform ignores it,
    /// with no such turn left free.
    pub fn roll(mut self, radians: f32) -> Self {
        self.draw.roll = radians;
        self
    }

    /// Draws with the WGSL of `T`, in the pass that style declares,
    /// instead of with the built-in look.
    ///
    /// Takes a style of [`Game::SurfaceStyles`](crate::Game::SurfaceStyles)
    /// and no other. The last call is the one used. To read the seat of
    /// `T` the call turns `T::default()` into the set and drops that
    /// value's fields; the values the WGSL reads come from
    /// [`set_surface_style`](crate::FrameContext::set_surface_style).
    pub fn surface_style<T: SurfaceStyle>(mut self) -> Self
    where
        S: Holds<T> + From<T>,
    {
        let seat = SurfaceStyleId(S::from(T::default()).seat());
        self.draw.style = Some(Styled::at::<T>(seat));
        self
    }

    /// Draws the mesh in the pose `animator` holds, in place of the rest of
    /// every joint.
    ///
    /// Takes a machine typed by this mesh and no other, and reads it at the
    /// instant the frame draws. A mesh with no joints is drawn as it is,
    /// and the last call is the one used.
    pub fn posed<P: Part, A: AnimationStates>(mut self, animator: &Animator<M, A>) -> Self
    where
        M: Mesh<P, A::Clip>,
    {
        self.draw.posed = Some(animator.running());
        self
    }

    /// Draws the mesh in the pose `posing` holds, at the fixed times it
    /// states.
    ///
    /// The engine's own tests of what a pose draws are the only caller: a
    /// game poses a draw through [`posed`](Self::posed), which takes no
    /// time of its own.
    #[cfg(all(test, feature = "offscreen"))]
    pub(crate) fn posed_by(mut self, posing: Posing) -> Self {
        self.draw.posed = Some(Running::stopped(posing));
        self
    }

    /// Samples `frame` of every texture the mesh draws with; the whole of
    /// each by default.
    pub fn frame(mut self, frame: Frame) -> Self {
        self.draw.frame = frame;
        self
    }

    /// Draws every slot of the mesh with `material` instead of its default,
    /// the ones no part names too.
    ///
    /// The last write to a slot is the one used, so a
    /// [`material_of`](Instance::material_of) after this call writes one
    /// part again, and one before it is replaced. A material holds no maps;
    /// the maps beside a slot's color are the mesh's own, since each binds GPU
    /// state a draw does not change (see [`Slot`](crate::mesh::Slot)).
    pub fn material(mut self, material: Material) -> Self {
        self.draw.paints.every(material);
        self
    }

    /// Draws the slot `part` selects with `material` instead of the mesh's
    /// default for it.
    ///
    /// Takes a part of the mesh's own vocabulary and no other. The last
    /// write to a slot is the one used.
    pub fn material_of<P: Part, C: Clip>(mut self, part: P, material: Material) -> Self
    where
        M: Mesh<P, C>,
    {
        self.draw.paints.one(part.index(), material);
        self
    }

    /// Scales the tint alpha of every slot the draw covers by `alpha`,
    /// clamped to `0.0..=1.0`, leaving the rest of each material as it is.
    ///
    /// Required if you want to fade a mesh and repaint none of it: the fade
    /// applies once [`material`](Instance::material) overrides have resolved,
    /// so every slot fades the same. Under `1.0` the draw blends in the
    /// transparent pass and blocks that same fraction of every light it is
    /// within, as a tint alpha under `1.0` does on its own: a fading draw's
    /// shadow fades with the draw instead of dropping away, and one faded to
    /// `0.0` casts nothing. A styled draw keeps its own pass and casts as that
    /// pass does. The fade scales what an additive draw adds, its
    /// [`emissive`](Material::emissive) light too, and the alpha a cutout draw
    /// drops texels from, so one faded past `0.5` keeps none of them. The last
    /// call is the one used.
    pub fn faded(mut self, alpha: f32) -> Self {
        self.draw.fade = alpha.clamp(0.0, OPAQUE);
        self
    }

    /// The same draw as a draw of the game's set `T`, which
    /// [`FrameContext::draw`](crate::FrameContext::draw) takes as it takes any
    /// mesh the set holds.
    ///
    /// Required if you want one variable to hold a draw of either of two
    /// mesh types.
    pub fn into_set<T: From<M>>(self) -> Instance<T, S> {
        Instance {
            draw: self.draw.into_set(),
            styles: PhantomData,
        }
    }

    /// The draw as the engine records it: the style set proved the style
    /// at [`surface_style`](Instance::surface_style), so the seat it took is all the engine
    /// reads past this call.
    pub(crate) fn record(self) -> Draw<M> {
        self.draw
    }
}

impl<M: Clone, S: SurfaceStyles> Clone for Instance<M, S> {
    fn clone(&self) -> Self {
        Self {
            draw: self.draw.clone(),
            styles: PhantomData,
        }
    }
}

/// A draw's turn: as its transform sets, or towards the frame's camera.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Facing {
    AsPlaced,
    Billboard,
    Upright,
}

impl Facing {
    /// The turn `transform` takes for a frame viewed from `view`: whatever
    /// turn this facing chooses, `roll` within it where the facing leaves
    /// that free, over the sizes and position the transform holds.
    fn applied(self, transform: Transform, view: View, roll: f32) -> Transform {
        let Some(turn) = self.turn(view, roll) else {
            return transform;
        };

        let model = transform.matrix();
        let sized = |axis: Vec3, column: Vec4| (axis * column.truncate().length()).extend(0.0);
        Transform::from(Mat4::from_cols(
            sized(turn.x_axis, model.x_axis),
            sized(turn.y_axis, model.y_axis),
            sized(turn.z_axis, model.z_axis),
            model.w_axis,
        ))
    }

    /// The turn this facing applies to a draw, or nothing where the draw
    /// keeps its own.
    fn turn(self, view: View, roll: f32) -> Option<Mat3> {
        match self {
            Self::AsPlaced => None,
            // The view plane's own `+Z` faces the camera, so a turn about it
            // is counter-clockwise from the camera's viewpoint.
            Self::Billboard => {
                Some(view_plane(looking(view)?, view.up()) * Mat3::from_rotation_z(roll))
            }
            Self::Upright => Some(standing(looking(view)?)),
        }
    }
}

/// A draw as one view places it: the turn that view applied to it, and
/// whether a facing rather than the draw's own transform chose it.
///
/// [`Draw::placement`] is the only call that returns one, so a draw is
/// recorded against a view and never against a bare transform.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Placement {
    transform: Transform,
    faced: bool,
}

impl Placement {
    /// The matrix the draw is placed by.
    pub(crate) fn transform(self) -> Transform {
        self.transform
    }

    /// Whether the view turned the draw rather than its own transform.
    pub(crate) fn faced(self) -> bool {
        self.faced
    }
}

/// The direction the camera looks, or nothing where it looks at where it
/// already is.
fn looking(view: View) -> Option<Vec3> {
    (view.target() - view.eye()).try_normalize()
}

/// A turn whose `+Z` faces the camera and whose `+Y` is the camera's own up.
fn view_plane(looking: Vec3, up: Vec3) -> Mat3 {
    let across = looking
        .cross(up)
        .try_normalize()
        .unwrap_or_else(|| looking.cross(aside(looking)).normalize());

    Mat3::from_cols(across, across.cross(looking), -looking)
}

/// A turn about `+Y` alone, as far towards the camera as that leaves it.
fn standing(looking: Vec3) -> Mat3 {
    let back = Vec3::new(-looking.x, 0.0, -looking.z)
        .try_normalize()
        .unwrap_or(Vec3::Z);

    Mat3::from_cols(Vec3::Y.cross(back), Vec3::Y, back)
}

/// An up `looking` is not parallel to, so that a view plane is total.
fn aside(looking: Vec3) -> Vec3 {
    if looking.y.abs() > 0.99 {
        Vec3::Z
    } else {
        Vec3::Y
    }
}

/// A draw's material overrides: one for every slot, and one per part at
/// the part's index, the later write to a slot winning.
#[derive(Clone, Debug, Default)]
struct Paints {
    every: Option<Material>,
    parts: Vec<Option<Material>>,
}

impl Paints {
    /// Writes every slot, which replaces every write before this one.
    fn every(&mut self, material: Material) {
        self.every = Some(material);
        self.parts.clear();
    }

    fn one(&mut self, part: u32, material: Material) {
        let at = part as usize;
        if at >= self.parts.len() {
            self.parts.resize(at + 1, None);
        }
        self.parts[at] = Some(material);
    }

    /// The material written to the part at `part` alone, absent where none
    /// was.
    fn of(&self, part: u32) -> Option<Material> {
        self.parts.get(part as usize).copied().flatten()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::math::Quat;
    use crate::mesh::{Cube, MeshData, Slot};
    use crate::{Assets, Catalog, Color};

    /// A camera up and back from the origin, looking at it, so that a
    /// billboarded draw and an upright one differ.
    const DIVING: View = View::look_at(Vec3::new(0.0, 5.0, 5.0), Vec3::ZERO);

    /// The roll a draw is turned by until it sets one.
    const STILL: f32 = 0.0;

    /// A quarter of a turn of it, which takes one axis of a billboarded
    /// draw onto the next.
    const QUARTER: f32 = core::f32::consts::FRAC_PI_2;

    const GOLD: Material = Material::lit(Color::rgb(1.0, 0.8, 0.2));
    const RED: Material = Material::lit(Color::rgb(1.0, 0.0, 0.0));

    /// A mesh of two parts, named by hand.
    #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
    struct Lantern;

    impl Catalog for Lantern {
        fn catalog() -> Vec<Self> {
            vec![Self]
        }
    }

    impl Mesh<LanternPart> for Lantern {
        fn build(&self, assets: &Assets) -> MeshData<LanternPart> {
            let cube = Cube.build(assets);
            let half = cube.indices().len() as u32 / 2;
            MeshData::in_parts(cube.vertices().to_vec(), cube.indices().to_vec(), |_| {
                Slot::new(half, Material::default())
            })
        }
    }

    #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
    enum LanternPart {
        Frame,
        Glass,
    }

    impl Part for LanternPart {
        fn from_name(_name: &str) -> Option<Self> {
            None
        }

        fn all() -> Vec<Self> {
            vec![Self::Frame, Self::Glass]
        }

        fn index(&self) -> u32 {
            *self as u32
        }
    }

    #[test]
    fn the_last_write_to_a_part_is_the_one_a_slot_resolves_to() {
        let refined = Lantern
            .at::<()>(Vec3::ZERO)
            .material(GOLD)
            .material_of(LanternPart::Glass, RED)
            .record();
        let replaced = Lantern
            .at::<()>(Vec3::ZERO)
            .material_of(LanternPart::Glass, RED)
            .material(GOLD)
            .record();
        let glass = Some(LanternPart::Glass.index());
        let frame = Some(LanternPart::Frame.index());

        assert_eq!(refined.resolved(glass, Material::default()), RED);
        assert_eq!(refined.resolved(frame, Material::default()), GOLD);
        assert_eq!(replaced.resolved(glass, Material::default()), GOLD);
        assert_eq!(replaced.resolved(frame, Material::default()), GOLD);
    }

    #[test]
    fn an_anonymous_slot_takes_the_write_to_every_slot_and_no_write_to_a_part() {
        let draw = Lantern
            .at::<()>(Vec3::ZERO)
            .material(GOLD)
            .material_of(LanternPart::Glass, RED)
            .record();

        assert_eq!(draw.resolved(None, Material::default()), GOLD);
        assert_eq!(
            Cube.at::<()>(Vec3::ZERO).record().resolved(None, RED),
            RED,
            "and a slot no draw wrote to keeps its default"
        );
    }

    /// A draw turned every which way and a different size along each axis,
    /// so facing has a turn of its own to drop and sizes to keep.
    fn turned() -> Transform {
        Transform::from_scale_rotation_translation(
            Vec3::new(1.0, 2.0, 3.0),
            Quat::from_rotation_x(0.7) * Quat::from_rotation_y(1.1),
            Vec3::new(4.0, 5.0, 6.0),
        )
    }

    /// The turned, scaled axes of a transform.
    fn columns(transform: Transform) -> [Vec3; 3] {
        let model = transform.matrix();
        [model.x_axis, model.y_axis, model.z_axis].map(Vec4::truncate)
    }

    #[test]
    fn a_billboarded_draw_stands_across_the_direction_the_camera_looks() {
        for eye in [Vec3::new(0.0, 0.0, 3.0), Vec3::new(3.0, 4.0, -5.0)] {
            let view = View::look_at(eye, Vec3::ZERO);
            let ahead = (view.target() - view.eye()).normalize();
            let [across, up, out] = columns(Facing::Billboard.applied(turned(), view, STILL));

            assert!(across.dot(ahead).abs() < 1e-5, "{across} leans out of view");
            assert!(up.dot(ahead).abs() < 1e-5, "{up} leans out of view");
            assert!(
                out.normalize().abs_diff_eq(-ahead, 1e-5),
                "{out} faces away"
            );
        }
    }

    #[test]
    fn an_upright_draw_keeps_the_way_up_and_turns_about_it_alone() {
        let view = View::look_at(Vec3::new(3.0, 9.0, 3.0), Vec3::ZERO);
        let [across, up, out] = columns(Facing::Upright.applied(turned(), view, STILL));

        assert!(up.abs_diff_eq(Vec3::Y * 2.0, 1e-5), "{up} left the way up");
        assert!(
            across.y.abs() < 1e-5 && out.y.abs() < 1e-5,
            "and stood level"
        );
        assert!(
            out.normalize()
                .abs_diff_eq(Vec3::new(3.0, 0.0, 3.0).normalize(), 1e-5),
            "{out} does not face the camera"
        );
    }

    #[test]
    fn facing_keeps_the_sizes_and_the_position_the_transform_gave_a_draw() {
        for (facing, roll) in [
            (Facing::Billboard, STILL),
            (Facing::Billboard, QUARTER),
            (Facing::Upright, STILL),
        ] {
            let faced = facing.applied(turned(), DIVING, roll);
            let sizes = columns(faced).map(|column| column.length());

            assert!(
                sizes
                    .iter()
                    .zip(columns(turned()))
                    .all(|(kept, column)| (kept - column.length()).abs() < 1e-5),
                "{sizes:?} are not the sizes the transform carried"
            );
            assert_eq!(faced.matrix().w_axis, turned().matrix().w_axis);
            assert_ne!(columns(faced), columns(turned()), "and the turn is gone");
        }
    }

    #[test]
    fn a_camera_straight_overhead_leaves_an_upright_draw_standing() {
        let view = View::look_at(Vec3::Y * 5.0, Vec3::ZERO).with_up(Vec3::NEG_Z);
        let [across, up, out] = columns(Facing::Upright.applied(Transform::IDENTITY, view, STILL));

        assert_eq!(up, Vec3::Y);
        assert!(across.is_finite() && out.is_finite(), "{across} {out}");
        assert!(out.y.abs() < 1e-5, "so it is seen edge-on from up there");
    }

    #[test]
    fn a_billboard_stands_even_where_the_camera_looks_along_its_own_way_up() {
        let view = View::look_at(Vec3::Y * 5.0, Vec3::ZERO);
        let [across, up, out] =
            columns(Facing::Billboard.applied(Transform::IDENTITY, view, STILL));

        assert!(across.is_finite() && up.is_finite(), "{across} {up}");
        assert!(out.abs_diff_eq(Vec3::Y, 1e-5), "{out} does not face back");
    }

    #[test]
    fn a_quarter_of_a_roll_takes_a_billboards_across_onto_the_way_up() {
        let view = View::look_at(Vec3::Z * 4.0, Vec3::ZERO);
        let [across, up, out] =
            columns(Facing::Billboard.applied(Transform::IDENTITY, view, QUARTER));

        assert!(
            across.abs_diff_eq(Vec3::Y, 1e-5),
            "{across} is not the way the camera is up"
        );
        assert!(up.abs_diff_eq(Vec3::NEG_X, 1e-5), "{up} followed it around");
        assert!(out.abs_diff_eq(Vec3::Z, 1e-5), "{out} left the view plane");
    }

    #[test]
    fn a_rolled_billboard_stands_in_the_view_plane_however_far_it_is_turned() {
        let view = View::look_at(Vec3::new(3.0, 4.0, -5.0), Vec3::ZERO);
        let ahead = (view.target() - view.eye()).normalize();

        for roll in [0.3, 2.0, -1.7, 100.0] {
            let [across, up, out] =
                columns(Facing::Billboard.applied(turned(), view, roll)).map(Vec3::normalize);

            assert!(across.dot(up).abs() < 1e-5, "{across} leans onto {up}");
            assert!(
                across.dot(ahead).abs() < 1e-5 && up.dot(ahead).abs() < 1e-5,
                "{across} or {up} leans out of view"
            );
            assert!(out.abs_diff_eq(-ahead, 1e-5), "{out} faces away");
        }
    }

    #[test]
    fn only_a_billboarded_draw_is_turned_by_the_roll_it_asks_for() {
        for facing in [Facing::AsPlaced, Facing::Upright] {
            assert_eq!(
                facing.applied(turned(), DIVING, QUARTER),
                facing.applied(turned(), DIVING, STILL),
                "a turn of its own is a turn roll has no say in"
            );
        }
        assert_ne!(
            Facing::Billboard.applied(turned(), DIVING, QUARTER),
            Facing::Billboard.applied(turned(), DIVING, STILL),
            "where a billboarded draw leaves it free"
        );
    }

    /// The placement a draw takes as `DIVING` places it.
    fn placed(instance: Instance<Cube>) -> Placement {
        instance.record().placement(DIVING)
    }

    #[test]
    fn a_draw_is_rolled_whichever_way_round_it_asked_to_be_billboarded() {
        let cube = Cube.at::<()>(turned());

        assert_eq!(
            placed(cube.clone().roll(QUARTER).billboard()),
            placed(cube.clone().billboard().roll(QUARTER))
        );
        assert_eq!(
            placed(cube.clone()),
            placed(cube.roll(QUARTER)),
            "and a draw the camera never turned is left where it was"
        );
    }

    #[test]
    fn a_camera_that_looks_nowhere_leaves_a_faced_draw_where_it_was() {
        let view = View::look_at(Vec3::Y, Vec3::Y);

        for facing in [Facing::Billboard, Facing::Upright] {
            assert_eq!(facing.applied(turned(), view, STILL), turned());
        }
    }

    #[test]
    fn the_last_facing_a_draw_asks_for_is_the_one_it_is_turned_by() {
        let cube = Cube.at::<()>(turned());

        assert_eq!(
            placed(cube.clone().billboard().upright()),
            placed(cube.clone().upright())
        );
        assert_eq!(
            placed(cube.clone().upright().billboard()),
            placed(cube.clone().billboard())
        );
        assert_ne!(
            placed(cube.clone().upright()),
            placed(cube.clone().billboard())
        );
        assert!(
            !cube.record().faced(),
            "and a draw asks for neither by default"
        );
    }
}