codecraft 0.1.1

A minimalist 3D game engine built on parts of Bevy (ECS, color) with wgpu and winit: OpenPBR materials, clustered lighting, an immediate-mode UI, audio and gamepad haptics
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
//! Things drawn to help you see where you are, rather than to be looked at.
//!
//! [`Grid`] is the ground plane a modelling program puts under a scene, one
//! line to the unit, so a shape being flown around has something to be flown
//! around *over*. [`light`] shows where a light is standing and which way it
//! shines, which is otherwise only visible in what it does to everything
//! else.
//!
//! A gizmo is a scene entity — `app.spawn(gizmos::grid())` — so it goes away
//! with the scene that asked for it and nothing has to remember to turn it
//! off. [`collect_grid_system`] copies whichever one is live into
//! [`ActiveGrid`], which is what the frame is actually drawn from.
use bytemuck::{Pod, Zeroable};

use glam::Vec3;

use crate::ecs::{Application, Component, Entity, Plugin, Query, ResMut, Resource, World};
use crate::render3d::DEPTH_FORMAT;
use crate::scene::SceneEntity;
use crate::sceneobjects::lights::{Light, LightShape, LocalLight, Suns};
use crate::ui::{Color, Widget, linear_rgba};

/// The ground grid, which lives with the other scene objects now --
/// see [`crate::helpers::grid`]. Re-exported here because a grid is
/// still a gizmo to everything that puts one up, and `gizmos::grid()`
/// is how they all ask for it.
pub use crate::helpers::grid::{Grid, grid};


/// A drawing in lines, in world space.
///
/// Gizmos are wires rather than shapes on purpose: a marker made of solid
/// geometry is one more thing in the scene to be lit, to be hidden behind,
/// and to be mistaken for something that belongs there.
#[derive(Component, Clone, Debug)]
pub struct Wires {
    pub segments: Vec<[Vec3; 2]>,
    pub color: Color,
}

impl Wires {
    pub fn new(color: Color) -> Self {
        Self {
            segments: Vec::new(),
            color,
        }
    }

    /// Adds one line.
    pub fn line(&mut self, from: Vec3, to: Vec3) -> &mut Self {
        self.segments.push([from, to]);
        self
    }

    /// Adds a closed ring of `sides` lines around `middle`, spanned by two
    /// vectors that give it its radius and its plane.
    pub fn ring(&mut self, middle: Vec3, across: Vec3, down: Vec3, sides: usize) -> &mut Self {
        let point = |i: usize| {
            let angle = i as f32 / sides as f32 * std::f32::consts::TAU;
            middle + across * angle.cos() + down * angle.sin()
        };
        for i in 0..sides {
            self.line(point(i), point((i + 1) % sides));
        }
        self
    }

    /// Adds a broken line from `from` to `to`, in `dashes` pieces.
    pub fn dashed(&mut self, from: Vec3, to: Vec3, dashes: usize) -> &mut Self {
        for i in 0..dashes {
            let start = i as f32 / dashes as f32;
            let end = start + 0.55 / dashes as f32;
            self.line(from.lerp(to, start), from.lerp(to, end));
        }
        self
    }
}

impl Widget for Wires {
    type Output = Entity;

    fn spawn(self, world: &mut World, _screen_width: f32, _screen_height: f32) -> Entity {
        world.spawn((self, SceneEntity)).id()
    }
}

/// How many sides a drawn ring has: enough to read as round, few enough that
/// nobody has to pay for it.
const RING_SIDES: usize = 24;

/// Rays off the sun, and how far out they reach as a multiple of its radius.
const RAYS: usize = 8;
const RAY_INNER: f32 = 1.35;
const RAY_OUTER: f32 = 2.1;

/// Marks where a light is and which way it shines.
///
/// A directional light has no position, so the drawing goes a chosen distance
/// out along the direction it comes from: a ring with rays off it, in the
/// light's own colour, and a broken line pointing back at what it lights.
/// The colour is the light's own, so a warm key and a cool rim are told apart
/// by looking at them.
///
/// ```no_run
/// # use codecraft::{AppState, Light, gizmos, glam::Vec3};
/// # fn demo(app: &mut AppState) {
/// app.spawn(gizmos::light(&Light::default(), Vec3::ZERO, 3.0));
/// # }
/// ```
pub fn light(light: &Light, focus: Vec3, distance: f32) -> Wires {
    let towards = light.direction.normalize_or(Vec3::Y);
    let tint = light.color();
    let mut wires = Wires::new(Color::linear_rgba(tint.x, tint.y, tint.z, 1.0));

    // A plane across the direction, to draw the ring and the rays in.
    let any = match towards.y.abs() > 0.99 {
        true => Vec3::Z,
        false => Vec3::Y,
    };
    let across = towards.cross(any).normalize_or(Vec3::X);
    let down = towards.cross(across).normalize_or(Vec3::Z);

    let at = focus + towards * distance;
    // Big enough to read as a sun at the range the marker stands at, rather
    // than as a dot on the end of a line.
    let radius = distance * 0.16;
    // One ring, in the plane the light aims through. Three rings about three
    // axes would read from any angle, but that is how a point light is drawn
    // — a light with nowhere to face. This one faces somewhere, and the ring
    // going edge-on as the camera comes round into its plane is the drawing
    // saying so.
    wires.ring(at, across * radius, down * radius, RING_SIDES);

    for i in 0..RAYS {
        let angle = i as f32 / RAYS as f32 * std::f32::consts::TAU;
        let out = across * angle.cos() + down * angle.sin();
        wires.line(at + out * radius * RAY_INNER, at + out * radius * RAY_OUTER);
    }

    // Which way it shines, and how far away it is standing.
    wires.dashed(at - towards * radius * RAY_INNER, focus, 6);
    wires
}

/// The same for every directional light the scene is shaded by. However
/// many that is: a scene with nothing casting draws one marker, and one with
/// no lights at all draws none.
pub fn lights(suns: &Suns, focus: Vec3, distance: f32) -> Vec<Wires> {
    [suns.shadowed, suns.unshadowed]
        .into_iter()
        .flatten()
        .map(|sun| light(&sun, focus, distance))
        .collect()
}

/// A local light, drawn where it actually is.
///
/// The key and the rim are directions, so [`light`] draws them as a ring out
/// at a distance with a dashed line back to what they are lighting. A lamp
/// has a *place*, so this draws the same ring-and-rays at that place -- it
/// should read as a light at a glance, and the marker that already reads as
/// one is the one to reuse.
///
/// A spot adds a short line the way it points, and no more. Drawing its
/// actual cone was the obvious thing and the wrong one: a sixty-degree cone
/// over a reach of thirteen is a shape wider than the board it lights, so
/// four lamps filled the frame with a fan of lines and read as anything but
/// lights. A gizmo says where a thing is and which way it faces; how far it
/// reaches is a number, and the picture of it is the lit board itself.
pub fn local_light(light: &LocalLight) -> Wires {
    // The colour with its brightness divided out: a lamp at intensity 18 is
    // not eighteen times whiter than one at 1, it is the same colour.
    let tint = light.color();
    let tint = tint / tint.max_element().max(1.0e-4);
    let mut wires = Wires::new(Color::linear_rgba(tint.x, tint.y, tint.z, 1.0));
    let at = light.position;

    // Big enough to pick out over a board, small enough not to be the thing
    // you look at.
    const RADIUS: f32 = 0.22;

    let towards = match light.shape {
        LightShape::Point => Vec3::NEG_Y,
        LightShape::Spot { direction, .. } => direction.normalize_or(Vec3::NEG_Y),
    };
    let any = match towards.y.abs() > 0.99 {
        true => Vec3::Z,
        false => Vec3::Y,
    };
    let across = towards.cross(any).normalize_or(Vec3::X);
    let down = towards.cross(across).normalize_or(Vec3::Z);

    match light.shape {
        // Nowhere to face, so three rings: the marker reads from any angle,
        // which is exactly what a light with no direction should look like.
        LightShape::Point => {
            wires.ring(at, across * RADIUS, down * RADIUS, RING_SIDES);
            wires.ring(at, across * RADIUS, towards * RADIUS, RING_SIDES);
            wires.ring(at, down * RADIUS, towards * RADIUS, RING_SIDES);
        }
        // One ring, in the plane it aims through, as [`light`] draws it -- the
        // ring going edge-on as the camera comes round is the drawing saying
        // the light faces somewhere -- and a stub pointing that way.
        LightShape::Spot { .. } => {
            wires.ring(at, across * RADIUS, down * RADIUS, RING_SIDES);
            wires.dashed(at + towards * RADIUS, at + towards * RADIUS * 5.0, 4);
        }
    }

    for i in 0..RAYS {
        let angle = i as f32 / RAYS as f32 * std::f32::consts::TAU;
        let out = across * angle.cos() + down * angle.sin();
        wires.line(at + out * RADIUS * RAY_INNER, at + out * RADIUS * RAY_OUTER);
    }
    wires
}

/// The lines to draw this frame, gathered from every [`Wires`] in the world.
#[derive(Resource, Default)]
pub struct WireDrawList(pub Vec<WireVertex>);

/// One end of one line, as the GPU takes it.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct WireVertex {
    position: [f32; 3],
    color: [f32; 4],
}

pub fn collect_wires_system(mut list: ResMut<WireDrawList>, wires: Query<&Wires>) {
    list.0.clear();
    for drawing in &wires {
        let color = linear_rgba(drawing.color);
        for [from, to] in &drawing.segments {
            list.0.push(WireVertex {
                position: from.to_array(),
                color,
            });
            list.0.push(WireVertex {
                position: to.to_array(),
                color,
            });
        }
    }
}

/// The grid to draw this frame, or `None` when the scene has not asked for
/// one. Gathered by [`collect_grid_system`], read by [`crate::AppState`].
#[derive(Resource, Clone, Copy, Default, Debug)]
pub struct ActiveGrid(pub Option<Grid>);

pub fn collect_grid_system(mut active: ResMut<ActiveGrid>, grids: Query<&Grid>) {
    // One ground plane per frame: a second would only ever fight the first
    // for the same pixels, so the first one found wins.
    active.0 = grids.iter().next().copied();
}

/// Draws [`WireDrawList`] as lines, into the pass the models left.
///
/// One buffer, rewritten every frame: a gizmo moves whenever what it marks
/// moves, and there are never enough lines for that to be worth being clever
/// about.
pub struct WireRenderer {
    pipeline: wgpu::RenderPipeline,
    uniform_buffer: wgpu::Buffer,
    bind_group: wgpu::BindGroup,
    vertices: wgpu::Buffer,
    capacity: usize,
}

/// How many line ends the buffer starts out able to hold.
const WIRE_CAPACITY: usize = 1024;

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct WireUniform {
    view_proj: [f32; 16],
}

impl WireRenderer {
    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("wire shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("wire.wgsl").into()),
        });

        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("wire uniform"),
            size: std::mem::size_of::<WireUniform>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("wire bind group layout"),
            entries: &[wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::VERTEX,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            }],
        });

        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("wire bind group"),
            layout: &bind_group_layout,
            entries: &[wgpu::BindGroupEntry {
                binding: 0,
                resource: uniform_buffer.as_entire_binding(),
            }],
        });

        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("wire pipeline layout"),
            bind_group_layouts: &[Some(&bind_group_layout)],
            immediate_size: 0,
        });

        const ATTRS: [wgpu::VertexAttribute; 2] =
            wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x4];

        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("wire pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                buffers: &[Some(wgpu::VertexBufferLayout {
                    array_stride: std::mem::size_of::<WireVertex>() as u64,
                    step_mode: wgpu::VertexStepMode::Vertex,
                    attributes: &ATTRS,
                })],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                targets: &[Some(wgpu::ColorTargetState {
                    format,
                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
                    write_mask: wgpu::ColorWrites::ALL,
                })],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::LineList,
                strip_index_format: None,
                front_face: wgpu::FrontFace::Ccw,
                cull_mode: None,
                polygon_mode: wgpu::PolygonMode::Fill,
                unclipped_depth: false,
                conservative: false,
            },
            depth_stencil: Some(wgpu::DepthStencilState {
                format: DEPTH_FORMAT,
                // Tested against the world so a gizmo behind something is
                // behind it, but writing nothing: a line is a hairline, and
                // what is drawn after it should not be cut by it.
                depth_write_enabled: Some(false),
                depth_compare: Some(wgpu::CompareFunction::Less),
                stencil: wgpu::StencilState::default(),
                bias: wgpu::DepthBiasState::default(),
            }),
            multisample: wgpu::MultisampleState {
                count: crate::render3d::SAMPLES,
                ..wgpu::MultisampleState::default()
            },
            multiview_mask: None,
            cache: None,
        });

        let vertices = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("wire vertices"),
            size: (WIRE_CAPACITY * std::mem::size_of::<WireVertex>()) as u64,
            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        Self {
            pipeline,
            uniform_buffer,
            bind_group,
            vertices,
            capacity: WIRE_CAPACITY,
        }
    }

    #[allow(clippy::too_many_arguments)]
    pub fn render(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        view: &wgpu::TextureView,
        resolve: &wgpu::TextureView,
        depth: &wgpu::TextureView,
        width: u32,
        height: u32,
        scene: &crate::views::View,
        lines: &[WireVertex],
    ) {
        if lines.is_empty() {
            return;
        }
        if lines.len() > self.capacity {
            self.capacity = lines.len().next_power_of_two();
            self.vertices = device.create_buffer(&wgpu::BufferDescriptor {
                label: Some("wire vertices"),
                size: (self.capacity * std::mem::size_of::<WireVertex>()) as u64,
                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
                mapped_at_creation: false,
            });
        }

        let uniform = WireUniform {
            view_proj: scene
                .camera
                .view_proj(scene.aspect())
                .to_cols_array(),
        };
        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform));
        queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(lines));

        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: Some("wire render pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view,
                resolve_target: Some(resolve),
                depth_slice: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Load,
                    store: wgpu::StoreOp::Store,
                },
            })],
            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
                view: depth,
                depth_ops: Some(wgpu::Operations {
                    load: wgpu::LoadOp::Load,
                    store: wgpu::StoreOp::Store,
                }),
                stencil_ops: None,
            }),
            timestamp_writes: None,
            occlusion_query_set: None,
            multiview_mask: None,
        });

        crate::render3d::set_view(&mut pass, scene, (width, height));
        pass.set_pipeline(&self.pipeline);
        pass.set_bind_group(0, &self.bind_group, &[]);
        pass.set_vertex_buffer(0, self.vertices.slice(..));
        pass.draw(0..lines.len() as u32, 0..1);
    }
}

/// Registers the gizmo resources and their per-frame gathering.
pub struct GizmosPlugin;

impl Plugin for GizmosPlugin {
    fn build(&self, app: &mut Application) {
        app.insert_resource(ActiveGrid::default());
        app.insert_resource(WireDrawList::default());
        app.add_update_systems((collect_grid_system, collect_wires_system));
    }
}

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct GridUniform {
    view_proj: [f32; 16],
    inv_view_proj: [f32; 16],
    /// xyz: the eye. w: the height of the plane.
    eye: [f32; 4],
    /// spacing, cells per heavy line, fade start, fade end.
    params: [f32; 4],
    line: [f32; 4],
    major: [f32; 4],
    x_axis: [f32; 4],
    z_axis: [f32; 4],
}

/// Draws [`Grid`] as a single triangle over the frame.
///
/// It runs after the models and shares their depth buffer, testing against it
/// without writing: a model in front of the plane hides it, and the plane
/// never hides anything drawn later.
pub struct GridRenderer {
    pipeline: wgpu::RenderPipeline,
    uniform_buffer: wgpu::Buffer,
    bind_group: wgpu::BindGroup,
}

impl GridRenderer {
    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("grid shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("grid.wgsl").into()),
        });

        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("grid uniform"),
            size: std::mem::size_of::<GridUniform>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });

        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
            label: Some("grid bind group layout"),
            entries: &[wgpu::BindGroupLayoutEntry {
                binding: 0,
                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                ty: wgpu::BindingType::Buffer {
                    ty: wgpu::BufferBindingType::Uniform,
                    has_dynamic_offset: false,
                    min_binding_size: None,
                },
                count: None,
            }],
        });

        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("grid bind group"),
            layout: &bind_group_layout,
            entries: &[wgpu::BindGroupEntry {
                binding: 0,
                resource: uniform_buffer.as_entire_binding(),
            }],
        });

        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("grid pipeline layout"),
            bind_group_layouts: &[Some(&bind_group_layout)],
            immediate_size: 0,
        });

        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("grid pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &shader,
                entry_point: Some("vs_main"),
                // The triangle comes from the vertex index; there is nothing
                // to feed it.
                buffers: &[],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &shader,
                entry_point: Some("fs_main"),
                targets: &[Some(wgpu::ColorTargetState {
                    format,
                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
                    write_mask: wgpu::ColorWrites::ALL,
                })],
                compilation_options: wgpu::PipelineCompilationOptions::default(),
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                strip_index_format: None,
                front_face: wgpu::FrontFace::Ccw,
                // The covering triangle is not a solid, so which way it faces
                // means nothing.
                cull_mode: None,
                polygon_mode: wgpu::PolygonMode::Fill,
                unclipped_depth: false,
                conservative: false,
            },
            depth_stencil: Some(wgpu::DepthStencilState {
                format: DEPTH_FORMAT,
                // Tested against, never written: the grid is a backdrop, and
                // writing would let it hide the UI's world-space neighbours.
                depth_write_enabled: Some(false),
                depth_compare: Some(wgpu::CompareFunction::Less),
                stencil: wgpu::StencilState::default(),
                bias: wgpu::DepthBiasState::default(),
            }),
            // As many samples as the pass it joins: the grid draws into the
            // image the models left, and tests against their depth.
            multisample: wgpu::MultisampleState {
                count: crate::render3d::SAMPLES,
                ..wgpu::MultisampleState::default()
            },
            multiview_mask: None,
            cache: None,
        });

        Self {
            pipeline,
            uniform_buffer,
            bind_group,
        }
    }

    #[allow(clippy::too_many_arguments)]
    pub fn render(
        &mut self,
        queue: &wgpu::Queue,
        encoder: &mut wgpu::CommandEncoder,
        view: &wgpu::TextureView,
        // Where the multisampled image is resolved down to, which is what is
        // actually shown.
        resolve: &wgpu::TextureView,
        depth: &wgpu::TextureView,
        width: u32,
        height: u32,
        scene: &crate::views::View,
        grid: &Grid,
    ) {
        let view_proj = scene.camera.view_proj(scene.aspect());
        let uniform = GridUniform {
            view_proj: view_proj.to_cols_array(),
            inv_view_proj: view_proj.inverse().to_cols_array(),
            eye: [
                scene.camera.eye.x,
                scene.camera.eye.y,
                scene.camera.eye.z,
                grid.height,
            ],
            params: [
                grid.spacing.max(1e-4),
                grid.major_every.max(1.0),
                grid.fade_from,
                grid.fade_to.max(grid.fade_from + 1e-3),
            ],
            line: linear_rgba(grid.line),
            major: linear_rgba(grid.major_line),
            x_axis: linear_rgba(grid.x_axis),
            z_axis: linear_rgba(grid.z_axis),
        };
        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform));

        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: Some("grid render pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view,
                resolve_target: Some(resolve),
                depth_slice: None,
                ops: wgpu::Operations {
                    // The model pass has already cleared and drawn the world.
                    load: wgpu::LoadOp::Load,
                    store: wgpu::StoreOp::Store,
                },
            })],
            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
                view: depth,
                depth_ops: Some(wgpu::Operations {
                    load: wgpu::LoadOp::Load,
                    store: wgpu::StoreOp::Store,
                }),
                stencil_ops: None,
            }),
            timestamp_writes: None,
            occlusion_query_set: None,
            multiview_mask: None,
        });

        crate::render3d::set_view(&mut pass, scene, (width, height));
        pass.set_pipeline(&self.pipeline);
        pass.set_bind_group(0, &self.bind_group, &[]);
        pass.draw(0..3, 0..1);
    }
}

// The uniform is bound as a whole, so its size has to be a multiple of the
// 16-byte alignment WGSL gives a struct of vec4s and matrices.
const _: () = assert!(std::mem::size_of::<GridUniform>() % 16 == 0);