Skip to main content

henad_models/gpu_game_of_life/
mod.rs

1//! Game of Life as a [`GpuGridModel`], with the rules of [`crate::game_of_life`] and the grid in a storage buffer.
2//!
3//! Cells are bit-packed, 32 to a `u32`. Cell `x` of row `y` is bit `x % 32` of word `y * words_per_row + x / 32`, and
4//! each row is padded with zero bits to [`words_per_row`] whole words.
5//! The step dispatches one invocation per word. Per cell, 32 invocations would write the same word and race.
6//! Display dispatches one invocation per texel and reduce one per cell. Each invocation extracts the bit of its cell.
7//!
8//! [`seed_random`] repeats the draws of the CPU model's `init`. For the same seed both backends start from the same
9//! grid, and the CPU model is an exact oracle on every tick after.
10
11use henad_compute::cpu::grid_engine::grid_init_rng;
12use henad_core::action::ActionDescriptor;
13use henad_core::authoring::model::binding::BindingDecl;
14use henad_core::authoring::model::gpu_grid_model::{GpuGridAction, GpuGridModel};
15use henad_core::authoring::primitives::rng::xorshift64;
16use henad_core::helpers::{extract_f32, extract_u32, f32_param, u32_param};
17use henad_core::params::{ParamDescriptor, ParamValue};
18use henad_core::view::{StatDescriptor, StatValue};
19
20use crate::game_of_life::PALETTE;
21use crate::shader_bindings::gpu_game_of_life::randomise::Params as ActionParams;
22
23// The list repeats the ids and order the CPU engine composes for `GameOfLifeModel`, so one parameter vector drives
24// either backend.
25henad_core::params! {
26    const PARAM_WIDTH = u32_param("grid_width", "Grid Width", DEFAULT_DIM, 1, 16_384);
27    const PARAM_HEIGHT = u32_param("grid_height", "Grid Height", DEFAULT_DIM, 1, 16_384);
28    const PARAM_DENSITY = f32_param("density", "Initial Density", DEFAULT_DENSITY, 0.0, 1.0, Some(0.01));
29}
30
31const DEFAULT_DIM: u32 = 1024;
32const DEFAULT_DENSITY: f32 = 0.3;
33
34/// Returns the number of words in a padded row of `width` cells, 32 cells to a word.
35pub fn words_per_row(width: u32) -> usize {
36    (width as usize).div_ceil(32)
37}
38
39/// Returns a bit-packed grid of `width` by `height` cells, each alive with probability `density`.
40///
41/// The cells are drawn from `rng` in row-major order, with the draws and the threshold of `GameOfLifeModel::init`.
42/// With `rng` from `grid_init_rng`, the grid matches the CPU model's bit for bit.
43///
44/// Padding bits, present when `width % 32 != 0`, start at zero, and the step keeps them at zero.
45pub fn seed_random(width: u32, height: u32, density: f32, mut rng: u64) -> Vec<u32> {
46    let threshold = (density * u32::MAX as f32) as u32;
47    let stride = words_per_row(width);
48    let mut words = vec![0u32; stride * (height as usize)];
49    for y in 0..height as usize {
50        for x in 0..width as usize {
51            rng = xorshift64(rng);
52            if ((rng >> 32) as u32) < threshold {
53                words[y * stride + (x / 32)] |= 1u32 << (x % 32);
54            }
55        }
56    }
57    words
58}
59
60/// Conway's Game of Life as a [`GpuGridModel`], over a bit-packed grid.
61#[derive(Debug)]
62pub struct GpuGameOfLife;
63
64impl GpuGridModel for GpuGameOfLife {
65    const NAME: &'static str = "Game of Life (GPU)";
66    const ID: &'static str = "gpu_game_of_life";
67    const DESCRIPTION: &'static str = "Conway's Game of Life on a toroidal grid, stepped entirely on the GPU";
68    const PALETTE: &'static [[u8; 4]] = &PALETTE;
69    const STATS: &'static [StatDescriptor] = &[StatDescriptor::new("Alive", PALETTE[1])];
70
71    const BUFFERS: &'static [&'static str] = &["state"];
72
73    const STEP_BINDINGS: &'static [BindingDecl] = crate::binding_decls::bindings::GPU_GAME_OF_LIFE_STEP;
74    const DISPLAY_BINDINGS: &'static [BindingDecl] = crate::binding_decls::bindings::GPU_GAME_OF_LIFE_DISPLAY;
75    const REDUCE_BINDINGS: &'static [BindingDecl] = crate::binding_decls::bindings::GPU_GAME_OF_LIFE_REDUCE;
76
77    const STEP_SHADER: &'static str = crate::shader_bindings::gpu_game_of_life::step::SHADER_STRING;
78    const DISPLAY_SHADER: &'static str = crate::shader_bindings::gpu_game_of_life::display::SHADER_STRING;
79    const REDUCE_SHADER: &'static str = crate::shader_bindings::gpu_game_of_life::reduce::SHADER_STRING;
80
81    const ACTIONS: &'static [GpuGridAction] = &[
82        GpuGridAction {
83            desc: ActionDescriptor::new("randomise", "Randomise"),
84            shader: crate::shader_bindings::gpu_game_of_life::randomise::SHADER_STRING,
85            bindings: crate::binding_decls::bindings::GPU_GAME_OF_LIFE_RANDOMISE,
86        },
87        GpuGridAction {
88            desc: ActionDescriptor::new("clear", "Clear"),
89            shader: crate::shader_bindings::gpu_game_of_life::clear::SHADER_STRING,
90            bindings: crate::binding_decls::bindings::GPU_GAME_OF_LIFE_CLEAR,
91        },
92    ];
93
94    fn param_descriptors() -> Vec<ParamDescriptor> {
95        descriptors()
96    }
97
98    fn dims(params: &[ParamValue]) -> (u32, u32) {
99        (
100            extract_u32(params, PARAM_WIDTH, DEFAULT_DIM),
101            extract_u32(params, PARAM_HEIGHT, DEFAULT_DIM),
102        )
103    }
104
105    fn buffer_lens(width: u32, height: u32) -> Vec<usize> {
106        vec![words_per_row(width) * (height as usize)]
107    }
108
109    /// Returns one invocation per word of each row.
110    fn step_dims(width: u32, height: u32) -> (u32, u32) {
111        (words_per_row(width) as u32, height)
112    }
113
114    fn seed_buffers(width: u32, height: u32, params: &[ParamValue], seed: Option<u64>) -> Vec<Vec<u32>> {
115        let density = extract_f32(params, PARAM_DENSITY, DEFAULT_DENSITY);
116        vec![seed_random(width, height, density, grid_init_rng(seed))]
117    }
118
119    /// `step.wgsl` reads nothing but `dims: vec2<u32>`.
120    fn step_params_bytes(width: u32, height: u32, _params: &[ParamValue]) -> Vec<u8> {
121        bytemuck::cast_slice(&[width, height]).to_vec()
122    }
123
124    fn action_params_bytes(_action: usize, width: u32, height: u32, params: &[ParamValue], seed: u32) -> Vec<u8> {
125        let density = extract_f32(params, PARAM_DENSITY, DEFAULT_DENSITY);
126        bytemuck::bytes_of(&ActionParams {
127            width,
128            height,
129            threshold: (density * u32::MAX as f32) as u32,
130            seed,
131        })
132        .to_vec()
133    }
134
135    fn stats(counts: &[u32]) -> Vec<StatValue> {
136        vec![StatValue::Scalar(f64::from(counts[0]))]
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use henad_compute::cpu::grid_engine::{GRID_PARAM_BASE, GridModelState};
144    use henad_compute::gpu::GpuContext;
145    use henad_compute::gpu::grid_engine::GpuGridState;
146    use henad_compute::gpu::sim_thread::GpuSimState as _;
147    use henad_compute::gpu::timing::TimestampQuery;
148    use henad_core::authoring::model::grid_model::GridModel as _;
149    use henad_core::grid::Grid2D;
150    use henad_core::model::SimState as _;
151    use henad_explore::testing::{TestDeviceRequest, headless_test_device};
152
153    use crate::game_of_life::GameOfLifeModel;
154
155    type State = GpuGridState<GpuGameOfLife>;
156
157    pub(super) fn headless_context() -> Option<GpuContext> {
158        headless_test_device(&TestDeviceRequest::baseline())
159    }
160
161    pub(super) fn params(width: u32, height: u32, density: f32) -> Vec<ParamValue> {
162        vec![
163            ParamValue::U32(width),
164            ParamValue::U32(height),
165            ParamValue::F32(density),
166        ]
167    }
168
169    fn reported_alive(state: &State) -> u64 {
170        match state.stats().first().map(|s| s.value.clone()) {
171            Some(StatValue::Scalar(v)) => v as u64,
172            other => panic!("expected a scalar Alive stat, got {other:?}"),
173        }
174    }
175
176    /// Runs the display, reduce and readback passes as the sim thread's one-shot snapshot does.
177    fn refresh_stats(ctx: &GpuContext, state: &mut State) {
178        let mut encoder = ctx
179            .device
180            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
181        state.encode_snapshot_passes(&mut encoder);
182        ctx.queue.submit(Some(encoder.finish()));
183        state.begin_stats_readback();
184        state.poll_stats_readback(&ctx.device, true);
185    }
186
187    /// The port repeats `GameOfLifeModel::init` rather than calling it, so every cell is compared.
188    /// The CPU grid is packed into the shaders' layout. A width of 37 leaves padding bits, and
189    /// the seed leaves them zero.
190    #[test]
191    fn the_seeded_grid_matches_the_cpu_init() {
192        let (width, height) = (37u32, 23u32);
193        let p = params(width, height, 0.3);
194        let stride = words_per_row(width);
195        for seed in [None, Some(7)] {
196            let mut grid = Grid2D::new(width, height);
197            GameOfLifeModel::init(&mut grid, &p[GRID_PARAM_BASE..], &mut grid_init_rng(seed));
198            let mut cpu = vec![0u32; stride * height as usize];
199            for (index, &cell) in grid.current().iter().enumerate() {
200                let (x, y) = (index % width as usize, index / width as usize);
201                cpu[y * stride + x / 32] |= u32::from(cell) << (x % 32);
202            }
203            assert_eq!(
204                GpuGameOfLife::seed_buffers(width, height, &p, seed)[0],
205                cpu,
206                "the seeded words differ from the CPU grid for seed {seed:?}"
207            );
208        }
209    }
210
211    /// Checks the alive count that the GPU reduces against the CPU model's count, tick for tick.
212    ///
213    /// Equal params seed the same grid on both backends, and the CPU model is the oracle.
214    #[test]
215    fn gpu_alive_count_matches_cpu_model() {
216        let Some(ctx) = headless_context() else {
217            log::warn!("skipping gpu_alive_count_matches_cpu_model: no wgpu adapter available");
218            return;
219        };
220        check_agreement_over_ticks(&ctx, 64, 64);
221    }
222
223    /// Checks the same oracle at a width that is neither a multiple of 32 nor a power of two.
224    ///
225    /// The width exercises both x-wrap boundaries of the packing.
226    ///
227    /// - The last word is ragged (50 % 32 = 18). It holds cells 32 to 49 in bits 0 to 17, and cell 49's right neighbour
228    ///   wraps to cell 0 from a bit other than 31.
229    /// - The width is not a power of two. The left neighbour of column 0 comes from a `% width`, which gives the right
230    ///   answer for any width dividing 2^32 even when the arithmetic feeding it is wrong.
231    #[test]
232    fn gpu_alive_count_matches_cpu_model_at_ragged_width() {
233        let Some(ctx) = headless_context() else {
234            log::warn!("skipping gpu_alive_count_matches_cpu_model_at_ragged_width: no adapter");
235            return;
236        };
237        check_agreement_over_ticks(&ctx, 50, 30);
238    }
239
240    /// Checks that a grid wider than `max_texture_dimension_2d` builds and steps.
241    ///
242    /// Only the width is over the limit, and this also pins the per-axis capping. Display runs at 4096 by 64, and step
243    /// and reduce cover all 8200 columns.
244    #[test]
245    fn a_grid_past_the_texture_limit_still_builds_and_steps() {
246        let Some(ctx) = headless_context() else {
247            log::warn!("skipping a_grid_past_the_texture_limit_still_builds_and_steps: no adapter");
248            return;
249        };
250        assert!(
251            ctx.device.limits().max_texture_dimension_2d < 8200,
252            "the test device is not a baseline one, so this proves nothing"
253        );
254        check_agreement_over_ticks(&ctx, 8200, 64);
255    }
256
257    fn check_agreement_over_ticks(ctx: &GpuContext, width: u32, height: u32) {
258        let ctx = ctx.clone();
259        let p = params(width, height, 0.3);
260
261        let mut gpu = State::new(&ctx, &p);
262        let mut cpu = GridModelState::<GameOfLifeModel>::from_params(&p);
263
264        let cpu_alive = |cpu: &GridModelState<GameOfLifeModel>| -> u64 {
265            match cpu.stats().first().map(|s| s.value.clone()) {
266                Some(StatValue::Scalar(v)) => v as u64,
267                other => panic!("expected a scalar Alive stat, got {other:?}"),
268            }
269        };
270
271        for tick in 0..10 {
272            refresh_stats(&ctx, &mut gpu);
273            assert_eq!(
274                reported_alive(&gpu),
275                cpu_alive(&cpu),
276                "GPU-reduced alive count must match the CPU model's at tick {tick} ({width}x{height})"
277            );
278            assert_eq!(gpu.tick(), cpu.tick(), "tick counters must stay in step");
279
280            let mut encoder = ctx
281                .device
282                .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
283            gpu.encode_steps(&mut encoder, 1, None);
284            ctx.queue.submit(Some(encoder.finish()));
285            cpu.step();
286        }
287
288        // The assertions above would also pass with both counts stuck at zero.
289        refresh_stats(&ctx, &mut gpu);
290        assert!(
291            reported_alive(&gpu) > 0,
292            "a {width}x{height} grid seeded at density 0.3 must have live cells after 10 ticks"
293        );
294    }
295
296    /// Returns a baseline test device with `TIMESTAMP_QUERY`, which the app requests where the adapter supports it.
297    ///
298    /// The default test device requests no features.
299    fn headless_timing_context() -> Option<GpuContext> {
300        headless_test_device(
301            &TestDeviceRequest::baseline().features(henad_compute::gpu::wgpu::Features::TIMESTAMP_QUERY),
302        )
303    }
304
305    /// Checks that the GPU time per step is never zero and its readback never fails over many batches run back to back.
306    ///
307    /// Each batch is recorded, resolved and read as `GpuSimLoop::step_batch` does it. The test takes a reading after
308    /// every batch, where the runner takes a reading once a second.
309    #[test]
310    fn gpu_timing_readback_is_stable_over_many_batches() {
311        let Some(ctx) = headless_timing_context() else {
312            log::warn!(
313                "skipping gpu_timing_readback_is_stable_over_many_batches: \
314                 no adapter with TIMESTAMP_QUERY available"
315            );
316            return;
317        };
318
319        let (width, height) = (256u32, 256u32);
320        let mut state = State::new(&ctx, &params(width, height, 0.3));
321
322        let tq = TimestampQuery::new(&ctx.device, &ctx.queue).expect("device has TIMESTAMP_QUERY");
323        let batch_size = 64;
324        let iterations = 200;
325        let mut zero_count = 0usize;
326        let mut none_count = 0usize;
327
328        for _ in 0..iterations {
329            let mut encoder = ctx
330                .device
331                .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
332            state.encode_steps(&mut encoder, batch_size, Some(tq.query_set()));
333            let write_submission = ctx.queue.submit(Some(encoder.finish()));
334
335            tq.resolve_after(&ctx.device, &ctx.queue, write_submission);
336
337            match tq.read_gpu_us_per_step(&ctx.device, batch_size) {
338                Some(us) if us <= 0.0 => zero_count += 1,
339                Some(_) => {}
340                None => none_count += 1,
341            }
342        }
343
344        assert_eq!(
345            none_count, 0,
346            "readback failed (returned None) on {none_count}/{iterations} back-to-back batches"
347        );
348        assert_eq!(
349            zero_count, 0,
350            "readback returned 0 (end timestamp <= start timestamp) on \
351             {zero_count}/{iterations} back-to-back batches"
352        );
353    }
354
355    /// Checks the bit-sliced rule of `step.wgsl` against Conway's rule for all 256 neighbourhoods, n == 8 included.
356    ///
357    /// The shader counts neighbours with a carry-save adder, bit-sliced across sb0, sb1 and sb2, and the rule
358    /// collapses to `alive = ~sb2 & sb1 & (sb0 | cells)`. The test pins two steps derived by hand: that identity
359    /// for the 2-survives, 3-births rule, and the dropped weight-8 carry, which is sound only because n == 8 has
360    /// sb1 == 0. `gpu_alive_count_matches_cpu_model` compares alive counts, and a rule error that kept the
361    /// population would pass it.
362    ///
363    /// The test mirrors the shader's operations instead of invoking them. Every operation is bitwise and the 32 lanes
364    /// are independent, so lane 0 suffices.
365    #[test]
366    fn swar_rule_matches_conway_for_every_neighbourhood() {
367        fn full_add(a: u32, b: u32, c: u32) -> (u32, u32) {
368            let t = a ^ b;
369            (t ^ c, (a & b) | (c & t))
370        }
371        fn half_add(a: u32, b: u32) -> (u32, u32) {
372            (a ^ b, a & b)
373        }
374
375        for mask in 0u32..256 {
376            let nb: [u32; 8] = std::array::from_fn(|i| (mask >> i) & 1);
377            for cell in 0u32..2 {
378                let (a_sum, a_carry) = full_add(nb[0], nb[1], nb[2]);
379                let (b_sum, b_carry) = full_add(nb[3], nb[4], nb[5]);
380                let (c_sum, c_carry) = half_add(nb[6], nb[7]);
381
382                let (sb0, d_carry) = full_add(a_sum, b_sum, c_sum);
383                let (e_sum, e_carry) = full_add(a_carry, b_carry, c_carry);
384                let (sb1, f_carry) = half_add(e_sum, d_carry);
385                let sb2 = e_carry ^ f_carry;
386
387                let alive = !sb2 & sb1 & (sb0 | cell) & 1;
388
389                let n = mask.count_ones();
390                let expected = u32::from(n == 3 || (n == 2 && cell == 1));
391                assert_eq!(
392                    alive, expected,
393                    "SWAR rule disagrees with Conway at neighbours={n} (mask {mask:#010b}), cell={cell}"
394                );
395            }
396        }
397    }
398
399    /// Checks that `population()` reports the total cell count, as `GridModelState` does.
400    ///
401    /// The alive count is a stat, and the two counts are easy to conflate.
402    #[test]
403    fn population_is_total_cells_not_alive_count() {
404        let Some(ctx) = headless_context() else {
405            log::warn!("skipping population_is_total_cells_not_alive_count: no adapter");
406            return;
407        };
408
409        let (width, height) = (32u32, 16u32);
410        let state = State::new(&ctx, &params(width, height, 0.3));
411        assert_eq!(state.population(), u64::from(width) * u64::from(height));
412
413        let cpu = GridModelState::<GameOfLifeModel>::from_params(&params(width, height, 0.3));
414        assert_eq!(
415            state.population(),
416            cpu.population(),
417            "GPU and CPU Game of Life must agree on what 'population' means"
418        );
419    }
420}
421
422/// Tests that drive a [`henad_compute::gpu::GpuSimThread`] or a registry entry as the app does.
423///
424/// The texture sampling in egui's paint callback needs a surface, and no test here covers it.
425#[cfg(test)]
426#[cfg(not(target_arch = "wasm32"))]
427mod runner_tests {
428    use std::time::{Duration, Instant};
429
430    use henad_compute::gpu::grid_engine::GpuGridState;
431    use henad_compute::gpu::sim_thread::{GpuBatchSettings, GpuSimThread};
432    use henad_compute::snapshot::{Snapshot, SnapshotView};
433
434    use super::GpuGameOfLife;
435    use super::tests::{headless_context, params};
436    use henad_compute::entry::ModelState;
437    use henad_core::view::StatValue;
438
439    /// Polls until the thread publishes a snapshot satisfying `pred`, or the deadline passes.
440    ///
441    /// The GPU thread publishes on a wall-clock cadence, and a fixed sleep would be flakier.
442    fn wait_for(thread: &mut GpuSimThread, timeout: Duration, pred: impl Fn(&Snapshot) -> bool) -> Option<Snapshot> {
443        let deadline = Instant::now() + timeout;
444        while Instant::now() < deadline {
445            if let Some(snap) = thread.take_snapshot()
446                && pred(&snap)
447            {
448                return Some(snap);
449            }
450            std::thread::sleep(Duration::from_millis(2));
451        }
452        None
453    }
454
455    fn alive(snap: &Snapshot) -> u64 {
456        match snap.stats.first().map(|s| s.value.clone()) {
457            Some(StatValue::Scalar(v)) => v as u64,
458            other => panic!("expected a scalar Alive stat, got {other:?}"),
459        }
460    }
461
462    /// Checks that a GPU model's thread publishes snapshots carrying `SnapshotView::Gpu`, and steps when played.
463    ///
464    /// The viewport uses that variant to choose between uploading a `ColorImage` and issuing the paint callback.
465    #[test]
466    fn gpu_thread_publishes_gpu_snapshots_and_runs() {
467        let Some(ctx) = headless_context() else {
468            log::warn!("skipping gpu_thread_publishes_gpu_snapshots_and_runs: no adapter");
469            return;
470        };
471
472        let (width, height) = (128u32, 128u32);
473        let state = GpuGridState::<GpuGameOfLife>::new(&ctx, &params(width, height, 0.3));
474        let mut thread = GpuSimThread::new(ctx, Box::new(state), GpuBatchSettings::default(), None);
475
476        // The thread publishes an initial snapshot before anything runs, and the viewport shows the
477        // seeded grid before Play.
478        let initial = wait_for(&mut thread, Duration::from_secs(5), |_| true)
479            .expect("the GPU thread must publish an initial snapshot before Play");
480
481        assert!(
482            matches!(initial.view, SnapshotView::Gpu(_)),
483            "a GPU model must publish SnapshotView::Gpu — the viewport branches on this variant"
484        );
485        assert_eq!(initial.tick, 0, "the initial snapshot is pre-step");
486        assert_eq!(
487            initial.population,
488            u64::from(width) * u64::from(height),
489            "population must report total cells"
490        );
491        let initial_alive = alive(&initial);
492        assert!(
493            initial_alive > 0 && initial_alive < initial.population,
494            "a density-0.3 seed must be neither empty nor full, got {initial_alive} alive"
495        );
496
497        thread.play();
498        let running = wait_for(&mut thread, Duration::from_secs(5), |s| s.tick > 0)
499            .expect("playing must advance the tick counter and publish fresh snapshots");
500        assert!(matches!(running.view, SnapshotView::Gpu(_)));
501        assert!(alive(&running) > 0, "the sim must not have died out");
502
503        thread.pause();
504        drop(thread);
505    }
506
507    /// Checks that a `GpuSimThread` can be dropped mid-run and built again on the same context, three times.
508    ///
509    /// A model switch away and back does the same. Each drop shuts down and joins the OS thread and releases its
510    /// buffers and pipelines. A thread that fails to join, or GPU state left dangling in the shared context, shows up
511    /// here as a hang or a panic.
512    #[test]
513    fn gpu_thread_teardown_and_respawn_is_clean() {
514        let Some(ctx) = headless_context() else {
515            log::warn!("skipping gpu_thread_teardown_and_respawn_is_clean: no adapter");
516            return;
517        };
518
519        for round in 0..3 {
520            let state = GpuGridState::<GpuGameOfLife>::new(&ctx, &params(64, 64, 0.3));
521            let mut thread = GpuSimThread::new(ctx.clone(), Box::new(state), GpuBatchSettings::default(), None);
522            thread.play();
523            let snap = wait_for(&mut thread, Duration::from_secs(5), |s| s.tick > 0)
524                .unwrap_or_else(|| panic!("round {round}: a respawned GPU thread must step"));
525            assert!(matches!(snap.view, SnapshotView::Gpu(_)));
526            // The thread is dropped mid-run, as a model switch drops it.
527            drop(thread);
528        }
529    }
530
531    /// Checks that a lookup in the example set with a context returns the GPU entry, and that its factory builds a
532    /// `ModelState::Gpu` that steps. `HenadApp` routes that variant to the GPU thread.
533    #[test]
534    fn registry_with_gpu_context_offers_a_drivable_gpu_model() {
535        let Some(ctx) = headless_context() else {
536            log::warn!("skipping registry_with_gpu_context_offers_a_drivable_gpu_model: no adapter");
537            return;
538        };
539
540        let models = crate::example_models();
541        let entry = models
542            .lookup("gpu_game_of_life", Some(&ctx))
543            .expect("a GPU context must make the GPU model selectable");
544
545        let built = entry
546            .build(&params(32, 32, 0.3), None, Some(&ctx))
547            .unwrap_or_else(|fault| panic!("the GPU entry's factory failed to build: {fault}"));
548        let ModelState::Gpu(mut state) = built else {
549            panic!("the GPU entry's factory must yield ModelState::Gpu, not ModelState::Cpu");
550        };
551
552        let mut encoder = ctx
553            .device
554            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
555        state.encode_steps(&mut encoder, 4, None);
556        ctx.queue.submit(Some(encoder.finish()));
557        assert_eq!(state.tick(), 4, "the registry-built state must be steppable");
558    }
559}