1use 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
23henad_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
34pub fn words_per_row(width: u32) -> usize {
36 (width as usize).div_ceil(32)
37}
38
39pub 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#[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 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 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 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 #[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 #[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 #[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 #[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 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 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 #[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, ¶ms(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 #[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 #[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, ¶ms(width, height, 0.3));
411 assert_eq!(state.population(), u64::from(width) * u64::from(height));
412
413 let cpu = GridModelState::<GameOfLifeModel>::from_params(¶ms(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#[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 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 #[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, ¶ms(width, height, 0.3));
474 let mut thread = GpuSimThread::new(ctx, Box::new(state), GpuBatchSettings::default(), None);
475
476 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 #[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, ¶ms(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 drop(thread);
528 }
529 }
530
531 #[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(¶ms(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}