Skip to main content

loam_render/raymarch/
hyperslice4d.rs

1use anyhow::{Context, Result};
2use bytemuck::{Pod, Zeroable};
3use loam_math::Rotor4;
4use wgpu::*;
5
6use crate::pass::ColorLoad;
7
8const INITIAL_BODY_CAPACITY: usize = 32;
9
10/// Mirrored as `SHAPE_*` in [`HYPERSLICE_KERNEL_WGSL`]; keep in sync.
11pub const SHAPE_PENTATOPE: u32 = 0;
12pub const SHAPE_TESSERACT: u32 = 1;
13pub const SHAPE_16CELL: u32 = 2;
14pub const SHAPE_24CELL: u32 = 3;
15pub const SHAPE_120CELL: u32 = 4;
16pub const SHAPE_600CELL: u32 = 5;
17pub const SHAPE_3SPHERE: u32 = 6;
18pub const SHAPE_DUOCYLINDER: u32 = 7;
19pub const SHAPE_CLIFFORD_TORUS: u32 = 8;
20pub const SHAPE_SPHERINDER: u32 = 9;
21
22/// 80 bytes, matching the kernel's `BodyUniform`; the rotor packs as two `vec4`.
23#[repr(C)]
24#[derive(Copy, Clone, Debug, Pod, Zeroable)]
25pub struct BodyUniform {
26    pub position: [f32; 4],
27    pub kind: f32,
28    pub radius_or_shape: f32,
29    pub polytope_size: f32,
30    pub _pad0: f32,
31    pub color: [f32; 3],
32    pub _pad1: f32,
33    pub rotor: [f32; 8],
34}
35
36impl Default for BodyUniform {
37    fn default() -> Self {
38        Self {
39            position: [0.0; 4],
40            kind: BodyKind::Invalid as i32 as f32,
41            radius_or_shape: 0.0,
42            polytope_size: 0.0,
43            _pad0: 0.0,
44            color: [0.7, 0.7, 0.7],
45            _pad1: 0.0,
46            rotor: [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
47        }
48    }
49}
50
51/// Written as `f32`; the shader reads it back as `u32`.
52#[derive(Copy, Clone, Debug, PartialEq, Eq)]
53pub enum BodyKind {
54    Sphere = 0,
55    Polytope = 1,
56    Invalid = 255,
57}
58
59impl BodyUniform {
60    pub fn sphere(position: [f32; 4], radius: f32, color: [f32; 3]) -> Self {
61        Self {
62            position,
63            kind: BodyKind::Sphere as i32 as f32,
64            radius_or_shape: radius,
65            color,
66            ..Self::default()
67        }
68    }
69
70    pub fn polytope(
71        position: [f32; 4],
72        shape_index: u32,
73        size: f32,
74        rotor: [f32; 8],
75        color: [f32; 3],
76    ) -> Self {
77        Self {
78            position,
79            kind: BodyKind::Polytope as i32 as f32,
80            radius_or_shape: shape_index as f32,
81            polytope_size: size,
82            color,
83            rotor,
84            ..Self::default()
85        }
86    }
87
88    pub fn polytope_with_rotor(
89        position: [f32; 4],
90        shape_index: u32,
91        size: f32,
92        rotor: Rotor4,
93        color: [f32; 3],
94    ) -> Self {
95        Self::polytope(position, shape_index, size, rotor.into(), color)
96    }
97}
98
99/// Bind group 0, binding 0; matches the kernel's `Uniforms`.
100#[repr(C)]
101#[derive(Copy, Clone, Debug, Pod, Zeroable)]
102pub struct Hyperslice4DUniforms {
103    pub camera_pos: [f32; 3],
104    pub _pad0: f32,
105    pub camera_forward: [f32; 3],
106    pub _pad1: f32,
107    pub camera_right: [f32; 3],
108    pub _pad2: f32,
109    pub camera_up: [f32; 3],
110    pub fov_y_tan: f32,
111    pub resolution: [f32; 2],
112    pub time: f32,
113    pub tick: f32,
114    pub w_slice: f32,
115    pub body_count: f32,
116    /// Framebuffer pixel of the viewport's top-left.
117    pub viewport_origin: [f32; 2],
118    pub params: [f32; 4],
119    pub near: f32,
120    pub body_offset: f32,
121    pub _pad3: [f32; 2],
122    pub sky_below: [f32; 3],
123    pub _pad4: f32,
124    pub sky_above: [f32; 3],
125    pub _pad5: f32,
126}
127
128impl Default for Hyperslice4DUniforms {
129    fn default() -> Self {
130        Self {
131            camera_pos: [0.0, 0.0, 5.0],
132            _pad0: 0.0,
133            camera_forward: [0.0, 0.0, -1.0],
134            _pad1: 0.0,
135            camera_right: [1.0, 0.0, 0.0],
136            _pad2: 0.0,
137            camera_up: [0.0, 1.0, 0.0],
138            fov_y_tan: (60.0_f32.to_radians() * 0.5).tan(),
139            resolution: [1.0, 1.0],
140            time: 0.0,
141            tick: 0.0,
142            w_slice: 0.0,
143            body_count: 0.0,
144            viewport_origin: [0.0, 0.0],
145            params: [0.0; 4],
146            near: 0.05,
147            body_offset: 0.0,
148            _pad3: [0.0; 2],
149            sky_below: crate::sky_ground::DEFAULT_SKY.below,
150            _pad4: 0.0,
151            sky_above: crate::sky_ground::DEFAULT_SKY.above,
152            _pad5: 0.0,
153        }
154    }
155}
156
157/// Prefixed with [`crate::sky_ground::SKY_GROUND_WGSL`] and the shared `loam_projective_depth`; the user's `Scene4` emit supplies `loam_scene_sdf`.
158pub const HYPERSLICE_KERNEL_WGSL: &str = concat!(
159    include_str!("../sky_ground.wgsl"),
160    include_str!("../shader/projective_depth.wgsl"),
161    r#"
162const NO_BODY: u32 = 0xffffffffu;
163
164const BODY_KIND_SPHERE: u32 = 0u;
165const BODY_KIND_POLYTOPE: u32 = 1u;
166
167const SHAPE_PENTATOPE: u32 = 0u;
168const SHAPE_TESSERACT: u32 = 1u;
169const SHAPE_16CELL: u32 = 2u;
170const SHAPE_24CELL: u32 = 3u;
171const SHAPE_120CELL: u32 = 4u;
172const SHAPE_600CELL: u32 = 5u;
173const SHAPE_3SPHERE: u32 = 6u;
174const SHAPE_DUOCYLINDER: u32 = 7u;
175const SHAPE_CLIFFORD_TORUS: u32 = 8u;
176const SHAPE_SPHERINDER: u32 = 9u;
177const BODY_KIND_INVALID: u32 = 255u;
178
179struct BodyUniform {
180    position: vec4<f32>,
181    kind: f32,
182    radius_or_shape: f32,
183    polytope_size: f32,
184    _pad0: f32,
185    color: vec3<f32>,
186    _pad1: f32,
187    // Two vec4 so the std140 stride matches Rust's `[f32; 8]`; order [s, xy, xz, xw, yz, yw, zw, xyzw].
188    rotor_lo: vec4<f32>,
189    rotor_hi: vec4<f32>,
190};
191
192struct Uniforms {
193    camera_pos: vec3<f32>,
194    camera_forward: vec3<f32>,
195    camera_right: vec3<f32>,
196    camera_up: vec3<f32>,
197    fov_y_tan: f32,
198    resolution: vec2<f32>,
199    time: f32,
200    tick: f32,
201    w_slice: f32,
202    body_count: f32,
203    viewport_origin: vec2<f32>,
204    params: vec4<f32>,
205    near: f32,
206    body_offset: f32,
207    sky_below: vec3<f32>,
208    sky_above: vec3<f32>,
209};
210
211@group(0) @binding(0) var<uniform> u: Uniforms;
212@group(0) @binding(1) var<storage, read> bodies: array<BodyUniform>;
213
214fn body_at(index: u32) -> BodyUniform {
215    return bodies[u32(u.body_offset + 0.5) + index];
216}
217
218fn body_sphere_sdf_4d(p4: vec4<f32>, b: BodyUniform) -> f32 {
219    return length(p4 - b.position) - b.radius_or_shape;
220}
221
222fn rotor4_inverse_apply(rotor_lo: vec4<f32>, rotor_hi: vec4<f32>, v: vec4<f32>) -> vec4<f32> {
223    let rs  = rotor_lo.x;
224    let rxy = -rotor_lo.y;
225    let rxz = -rotor_lo.z;
226    let rxw = -rotor_lo.w;
227    let ryz = -rotor_hi.x;
228    let ryw = -rotor_hi.y;
229    let rzw = -rotor_hi.z;
230    let r_i = rotor_hi.w;
231
232    let vx = v.x; let vy = v.y; let vz = v.z; let vw = v.w;
233
234    let p1 = rs * vx - rxy * vy - rxz * vz - rxw * vw;
235    let p2 = rs * vy + rxy * vx - ryz * vz - ryw * vw;
236    let p3 = rs * vz + rxz * vx + ryz * vy - rzw * vw;
237    let p4 = rs * vw + rxw * vx + ryw * vy + rzw * vz;
238
239    let t123 = -rxy * vz + rxz * vy - ryz * vx + r_i * vw;
240    let t124 = -rxy * vw + rxw * vy - ryw * vx - r_i * vz;
241    let t134 = -rxz * vw + rxw * vz - rzw * vx + r_i * vy;
242    let t234 = -ryz * vw + ryw * vz - rzw * vy - r_i * vx;
243
244    let q1 = rs * p1 - rxy * p2 - rxz * p3 - rxw * p4
245           - ryz * t123 - ryw * t124 - rzw * t134 + r_i * t234;
246    let q2 = rs * p2 + rxy * p1 - ryz * p3 - ryw * p4
247           + rxz * t123 + rxw * t124 - rzw * t234 - r_i * t134;
248    let q3 = rs * p3 + rxz * p1 + ryz * p2 - rzw * p4
249           - rxy * t123 + rxw * t134 + ryw * t234 + r_i * t124;
250    let q4 = rs * p4 + rxw * p1 + ryw * p2 + rzw * p3
251           - rxy * t124 - rxz * t134 - ryz * t234 - r_i * t123;
252
253    return vec4<f32>(q1, q2, q3, q4);
254}
255
256fn pentatope_sdf_local(p: vec4<f32>) -> f32 {
257    let t  = 0.55901699437;  // sqrt(5) / 4
258    let n0 = vec4<f32>(0.0, 0.0, 0.0, -1.0);
259    let n1 = vec4<f32>(-t, -t, -t, 0.25);
260    let n2 = vec4<f32>(-t,  t,  t, 0.25);
261    let n3 = vec4<f32>( t, -t,  t, 0.25);
262    let n4 = vec4<f32>( t,  t, -t, 0.25);
263    let r = 0.25;
264    var d: f32 = dot(n0, p) - r;
265    d = max(d, dot(n1, p) - r);
266    d = max(d, dot(n2, p) - r);
267    d = max(d, dot(n3, p) - r);
268    d = max(d, dot(n4, p) - r);
269    return d;
270}
271
272fn tesseract_sdf_local(p: vec4<f32>) -> f32 {
273    let q = abs(p) - vec4<f32>(0.5, 0.5, 0.5, 0.5);
274    let outside = length(max(q, vec4<f32>(0.0, 0.0, 0.0, 0.0)));
275    let inside = min(max(max(q.x, q.y), max(q.z, q.w)), 0.0);
276    return outside + inside;
277}
278
279fn cell16_sdf_local(p: vec4<f32>) -> f32 {
280    let q = abs(p);
281    return (q.x + q.y + q.z + q.w - 1.0) * 0.5;
282}
283
284fn cell24_sdf_local(p: vec4<f32>) -> f32 {
285    let inv_sqrt2: f32 = 0.70710678;
286    let sqrt2:     f32 = 1.41421356;
287    let q = abs(p);
288    let tess  = max(max(q.x, q.y), max(q.z, q.w)) - inv_sqrt2;
289    let cross = (q.x + q.y + q.z + q.w - sqrt2) * 0.5;
290    return max(tess, cross);
291}
292
293fn sphere3_sdf_local(p: vec4<f32>) -> f32 {
294    return length(p) - 1.0;
295}
296
297fn duocylinder_sdf_local(p: vec4<f32>) -> f32 {
298    let r = 0.7071068;
299    let dxy = length(p.xy) - r;
300    let dzw = length(p.zw) - r;
301    let outside = length(vec2<f32>(max(dxy, 0.0), max(dzw, 0.0)));
302    let inside = min(max(dxy, dzw), 0.0);
303    return outside + inside;
304}
305
306fn clifford_torus_sdf_local(p: vec4<f32>) -> f32 {
307    let r1 = 0.5;
308    let r2 = 0.5;
309    let tube = 0.2;
310    let q1 = length(p.xy) - r1;
311    let q2 = length(p.zw) - r2;
312    return length(vec2<f32>(q1, q2)) - tube;
313}
314
315fn spherinder_sdf_local(p: vec4<f32>) -> f32 {
316    let r = 0.7071068;
317    let h = 0.7071068;
318    let dxyz = length(p.xyz) - r;
319    let dw = abs(p.w) - h;
320    let outside = length(vec2<f32>(max(dxyz, 0.0), max(dw, 0.0)));
321    let inside = min(max(dxyz, dw), 0.0);
322    return outside + inside;
323}
324
325// Outside 1.5× the circumradius the ball SDF is a Lipschitz-1 lower bound; skips the rotor inverse.
326fn body_polytope_sdf_4d(p4: vec4<f32>, b: BodyUniform) -> f32 {
327    let size = max(b.polytope_size, 1.0e-6);
328    let world_v = p4 - b.position;
329    let world_dist2 = dot(world_v, world_v);
330    let bound = size * 1.5;
331    if (world_dist2 > bound * bound) {
332        return sqrt(world_dist2) - size;
333    }
334    let local_v = rotor4_inverse_apply(b.rotor_lo, b.rotor_hi, world_v);
335    let unit_p = local_v / size;
336    let shape = u32(b.radius_or_shape + 0.5);
337    var d: f32 = 1.0e9;
338    if (shape == SHAPE_PENTATOPE) {
339        d = pentatope_sdf_local(unit_p);
340    } else if (shape == SHAPE_TESSERACT) {
341        d = tesseract_sdf_local(unit_p);
342    } else if (shape == SHAPE_16CELL) {
343        d = cell16_sdf_local(unit_p);
344    } else if (shape == SHAPE_24CELL) {
345        d = cell24_sdf_local(unit_p);
346    } else if (shape == SHAPE_120CELL) {
347        d = cell120_sdf_local(unit_p);
348    } else if (shape == SHAPE_600CELL) {
349        d = cell600_sdf_local(unit_p);
350    } else if (shape == SHAPE_3SPHERE) {
351        d = sphere3_sdf_local(unit_p);
352    } else if (shape == SHAPE_DUOCYLINDER) {
353        d = duocylinder_sdf_local(unit_p);
354    } else if (shape == SHAPE_CLIFFORD_TORUS) {
355        d = clifford_torus_sdf_local(unit_p);
356    } else if (shape == SHAPE_SPHERINDER) {
357        d = spherinder_sdf_local(unit_p);
358    }
359    return d * size;
360}
361
362fn loam_dynamic_bodies_sdf(p3: vec3<f32>) -> f32 {
363    let p4 = vec4<f32>(p3, u.w_slice);
364    let body_count = u32(u.body_count + 0.5);
365    var sdf: f32 = 1.0e9;
366    for (var i: u32 = 0u; i < body_count; i = i + 1u) {
367        let b = body_at(i);
368        let kind = u32(b.kind + 0.5);
369        if (kind == BODY_KIND_SPHERE) {
370            sdf = min(sdf, body_sphere_sdf_4d(p4, b));
371        } else if (kind == BODY_KIND_POLYTOPE) {
372            sdf = min(sdf, body_polytope_sdf_4d(p4, b));
373        }
374    }
375    return sdf;
376}
377
378fn loam_body_sdf_at(p3: vec3<f32>, body_idx: u32) -> f32 {
379    if (body_idx >= u32(u.body_count + 0.5)) { return 1.0e9; }
380    let p4 = vec4<f32>(p3, u.w_slice);
381    let b = body_at(body_idx);
382    let kind = u32(b.kind + 0.5);
383    if (kind == BODY_KIND_SPHERE) {
384        return body_sphere_sdf_4d(p4, b);
385    } else if (kind == BODY_KIND_POLYTOPE) {
386        return body_polytope_sdf_4d(p4, b);
387    }
388    return 1.0e9;
389}
390
391struct HitInfo {
392    dist: f32,
393    body_idx: u32,
394};
395
396fn loam_total_sdf(p3: vec3<f32>) -> HitInfo {
397    let scene_d = loam_scene_sdf(p3);
398
399    let p4 = vec4<f32>(p3, u.w_slice);
400    let body_count = u32(u.body_count + 0.5);
401    var dyn_d: f32 = 1.0e9;
402    var dyn_idx: u32 = NO_BODY;
403    for (var i: u32 = 0u; i < body_count; i = i + 1u) {
404        let b = body_at(i);
405        let kind = u32(b.kind + 0.5);
406        var d: f32 = 1.0e9;
407        if (kind == BODY_KIND_SPHERE) {
408            d = body_sphere_sdf_4d(p4, b);
409        } else if (kind == BODY_KIND_POLYTOPE) {
410            d = body_polytope_sdf_4d(p4, b);
411        }
412        if (d < dyn_d) {
413            dyn_d = d;
414            dyn_idx = i;
415        }
416    }
417
418    if (scene_d <= dyn_d) {
419        return HitInfo(scene_d, NO_BODY);
420    }
421    return HitInfo(dyn_d, dyn_idx);
422}
423
424@vertex
425fn vs_fullscreen(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
426    let uv = vec2<f32>(f32((vid << 1u) & 2u), f32(vid & 2u));
427    return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
428}
429
430fn estimate_normal(p: vec3<f32>, body_idx: u32) -> vec3<f32> {
431    let h = 0.001;
432    if (body_idx == NO_BODY) {
433        let dx = loam_scene_sdf(p + vec3<f32>(h, 0.0, 0.0))
434               - loam_scene_sdf(p - vec3<f32>(h, 0.0, 0.0));
435        let dy = loam_scene_sdf(p + vec3<f32>(0.0, h, 0.0))
436               - loam_scene_sdf(p - vec3<f32>(0.0, h, 0.0));
437        let dz = loam_scene_sdf(p + vec3<f32>(0.0, 0.0, h))
438               - loam_scene_sdf(p - vec3<f32>(0.0, 0.0, h));
439        return normalize(vec3<f32>(dx, dy, dz));
440    }
441    let dx = loam_body_sdf_at(p + vec3<f32>(h, 0.0, 0.0), body_idx)
442           - loam_body_sdf_at(p - vec3<f32>(h, 0.0, 0.0), body_idx);
443    let dy = loam_body_sdf_at(p + vec3<f32>(0.0, h, 0.0), body_idx)
444           - loam_body_sdf_at(p - vec3<f32>(0.0, h, 0.0), body_idx);
445    let dz = loam_body_sdf_at(p + vec3<f32>(0.0, 0.0, h), body_idx)
446           - loam_body_sdf_at(p - vec3<f32>(0.0, 0.0, h), body_idx);
447    return normalize(vec3<f32>(dx, dy, dz));
448}
449
450struct Shaded {
451    color: vec4<f32>,
452    depth: f32,
453};
454
455struct Fragment {
456    @location(0) color: vec4<f32>,
457    @builtin(frag_depth) depth: f32,
458};
459
460fn shade(frag_pos: vec4<f32>) -> Shaded {
461    let uv = ((frag_pos.xy - u.viewport_origin) / u.resolution) * 2.0 - vec2<f32>(1.0, 1.0);
462    let aspect = u.resolution.x / u.resolution.y;
463    let ndc = vec2<f32>(uv.x * aspect, -uv.y);
464    let rd = normalize(
465        u.camera_forward
466        + u.camera_right * (ndc.x * u.fov_y_tan)
467        + u.camera_up    * (ndc.y * u.fov_y_tan)
468    );
469    let ro = u.camera_pos;
470
471    var t: f32 = 0.0;
472    let scene_max_t = loam_scene_max_t(ro, rd);
473    let max_t = min(60.0, scene_max_t + 1.0);
474    var hit = false;
475    var hit_idx: u32 = NO_BODY;
476    // Every composed SDF is a 1-Lipschitz lower bound, so the full step cannot tunnel.
477    let hit_eps = 0.001;
478    let min_step = 0.0001;
479    for (var i: i32 = 0; i < 384; i = i + 1) {
480        let p = ro + rd * t;
481        let h = loam_total_sdf(p);
482        if (h.dist < hit_eps) {
483            hit = true;
484            hit_idx = h.body_idx;
485            break;
486        }
487        t = t + max(h.dist, min_step);
488        if (t > max_t) { break; }
489    }
490
491    if (!hit) {
492        discard;
493        return Shaded(vec4<f32>(0.0, 0.0, 0.0, 0.0), 0.0);
494    }
495
496    let p_hit = ro + rd * t;
497    if (hit_idx == NO_BODY) {
498        if (loam_scene_at(p_hit).kind == LOAM_PRIM_HALFSPACE4D) {
499            discard;
500            return Shaded(vec4<f32>(0.0, 0.0, 0.0, 0.0), 0.0);
501        }
502    }
503    let n = estimate_normal(p_hit, hit_idx);
504    let light_dir = normalize(vec3<f32>(0.5, 0.85, 0.3));
505    let lambert = max(dot(n, light_dir), 0.0);
506    let ambient = 0.20;
507    var base = vec3<f32>(0.65, 0.65, 0.72);
508    if (hit_idx != NO_BODY) {
509        base = body_at(hit_idx).color;
510    }
511    let lit = base * (ambient + lambert * 0.85);
512    let fog = 1.0 - exp(-t * 0.05);
513    let final_color = mix(lit, sky(rd, u.sky_below, u.sky_above), fog * 0.5);
514    let image = vec3<f32>(0.0, 0.0, -t * dot(rd, u.camera_forward));
515    return Shaded(vec4<f32>(final_color, 1.0), loam_projective_depth(image, u.near));
516}
517
518@fragment
519fn fs_main(@builtin(position) frag_pos: vec4<f32>) -> @location(0) vec4<f32> {
520    return shade(frag_pos).color;
521}
522
523@fragment
524fn fs_depth(@builtin(position) frag_pos: vec4<f32>) -> Fragment {
525    let shaded = shade(frag_pos);
526    return Fragment(shaded.color, shaded.depth);
527}
528"#
529);
530
531const HYPERSLICE_UNIFORMS_SIZE: u64 = std::mem::size_of::<Hyperslice4DUniforms>() as u64;
532
533fn strip_cell_stride(min_uniform_buffer_offset_alignment: u64) -> u64 {
534    HYPERSLICE_UNIFORMS_SIZE.div_ceil(min_uniform_buffer_offset_alignment)
535        * min_uniform_buffer_offset_alignment
536}
537
538fn strip_cell_uniforms(
539    base: &Hyperslice4DUniforms,
540    viewport: crate::Viewport,
541    w_slice: f32,
542    slot: usize,
543) -> Hyperslice4DUniforms {
544    let mut cell = *base;
545    cell.body_count = 1.0;
546    cell.body_offset = slot as f32;
547    cell.w_slice = w_slice;
548    cell.resolution = viewport.resolution_f32();
549    cell.viewport_origin = [viewport.x as f32, viewport.y as f32];
550    cell
551}
552
553const BODY_SIZE: u64 = std::mem::size_of::<BodyUniform>() as u64;
554
555fn body_buffer(device: &Device, label: &'static str, capacity: usize) -> Buffer {
556    device.create_buffer(&BufferDescriptor {
557        label: Some(label),
558        size: BODY_SIZE * capacity.max(1) as u64,
559        usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
560        mapped_at_creation: false,
561    })
562}
563
564fn body_bind_group(
565    device: &Device,
566    layout: &BindGroupLayout,
567    uniforms: BindingResource<'_>,
568    bodies: &Buffer,
569) -> BindGroup {
570    device.create_bind_group(&BindGroupDescriptor {
571        label: Some("hyperslice4d bg"),
572        layout,
573        entries: &[
574            BindGroupEntry {
575                binding: 0,
576                resource: uniforms,
577            },
578            BindGroupEntry {
579                binding: 1,
580                resource: bodies.as_entire_binding(),
581            },
582        ],
583    })
584}
585
586struct StripCellUniforms {
587    buffer: Buffer,
588    bodies: Buffer,
589    bind_groups: Vec<BindGroup>,
590    stride: u64,
591}
592
593impl StripCellUniforms {
594    fn new(device: &Device, layout: &BindGroupLayout, cell_count: usize) -> Self {
595        let stride = strip_cell_stride(device.limits().min_uniform_buffer_offset_alignment as u64);
596        let buffer = device.create_buffer(&BufferDescriptor {
597            label: Some("hyperslice4d strip cell uniforms"),
598            size: stride * cell_count as u64,
599            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
600            mapped_at_creation: false,
601        });
602        let bodies = body_buffer(device, "hyperslice4d strip cell bodies", cell_count);
603        let bind_groups = (0..cell_count)
604            .map(|i| {
605                body_bind_group(
606                    device,
607                    layout,
608                    BindingResource::Buffer(BufferBinding {
609                        buffer: &buffer,
610                        offset: i as u64 * stride,
611                        size: BufferSize::new(HYPERSLICE_UNIFORMS_SIZE),
612                    }),
613                    &bodies,
614                )
615            })
616            .collect();
617        Self {
618            buffer,
619            bodies,
620            bind_groups,
621            stride,
622        }
623    }
624}
625
626pub struct Hyperslice4DNode {
627    device: Device,
628    pipeline: RenderPipeline,
629    strip_pipeline: Option<RenderPipeline>,
630    uniforms: Hyperslice4DUniforms,
631    uniform_buf: Buffer,
632    bodies: Vec<BodyUniform>,
633    body_buf: Buffer,
634    body_capacity: usize,
635    bind_group: BindGroup,
636    bind_group_layout: BindGroupLayout,
637    clear_color: Color,
638    strip_cells: Option<StripCellUniforms>,
639    has_depth: bool,
640}
641
642impl Hyperslice4DNode {
643    pub fn new(device: &Device, surface_format: TextureFormat, module: &ShaderModule) -> Self {
644        Self::with_depth(device, surface_format, module, crate::DepthMode::Off)
645    }
646
647    pub fn with_depth(
648        device: &Device,
649        surface_format: TextureFormat,
650        module: &ShaderModule,
651        depth: crate::DepthMode,
652    ) -> Self {
653        let uniform_buf = device.create_buffer(&BufferDescriptor {
654            label: Some("hyperslice4d uniforms"),
655            size: std::mem::size_of::<Hyperslice4DUniforms>() as u64,
656            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
657            mapped_at_creation: false,
658        });
659
660        let bgl = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
661            label: Some("hyperslice4d bgl"),
662            entries: &[
663                BindGroupLayoutEntry {
664                    binding: 0,
665                    visibility: ShaderStages::FRAGMENT,
666                    ty: BindingType::Buffer {
667                        ty: BufferBindingType::Uniform,
668                        has_dynamic_offset: false,
669                        min_binding_size: None,
670                    },
671                    count: None,
672                },
673                BindGroupLayoutEntry {
674                    binding: 1,
675                    visibility: ShaderStages::FRAGMENT,
676                    ty: BindingType::Buffer {
677                        ty: BufferBindingType::Storage { read_only: true },
678                        has_dynamic_offset: false,
679                        min_binding_size: None,
680                    },
681                    count: None,
682                },
683            ],
684        });
685
686        let body_buf = body_buffer(device, "hyperslice4d bodies", INITIAL_BODY_CAPACITY);
687        let bind_group = body_bind_group(device, &bgl, uniform_buf.as_entire_binding(), &body_buf);
688
689        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
690            label: Some("hyperslice4d pipeline layout"),
691            bind_group_layouts: &[&bgl],
692            push_constant_ranges: &[],
693        });
694
695        let pipeline = hyperslice_pipeline(device, &pipeline_layout, module, surface_format, depth);
696        let strip_pipeline = depth.is_active().then(|| {
697            hyperslice_pipeline(
698                device,
699                &pipeline_layout,
700                module,
701                surface_format,
702                crate::DepthMode::Off,
703            )
704        });
705
706        Self {
707            device: device.clone(),
708            pipeline,
709            strip_pipeline,
710            uniforms: Hyperslice4DUniforms::default(),
711            uniform_buf,
712            bodies: Vec::with_capacity(INITIAL_BODY_CAPACITY),
713            body_buf,
714            body_capacity: INITIAL_BODY_CAPACITY,
715            bind_group,
716            bind_group_layout: bgl,
717            clear_color: crate::sky_ground::SKY_HORIZON,
718            strip_cells: None,
719            has_depth: depth.is_active(),
720        }
721    }
722
723    pub fn uniforms(&self) -> &Hyperslice4DUniforms {
724        &self.uniforms
725    }
726    pub fn uniforms_mut(&mut self) -> &mut Hyperslice4DUniforms {
727        &mut self.uniforms
728    }
729
730    pub fn set_uniforms(&mut self, queue: &Queue, uniforms: Hyperslice4DUniforms) {
731        self.uniforms = uniforms;
732        queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&self.uniforms));
733    }
734
735    pub fn flush_uniforms(&self, queue: &Queue) {
736        queue.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&self.uniforms));
737        if !self.bodies.is_empty() {
738            queue.write_buffer(&self.body_buf, 0, bytemuck::cast_slice(&self.bodies));
739        }
740    }
741
742    pub fn set_clear_color(&mut self, color: Color) {
743        self.clear_color = color;
744    }
745
746    fn color_load_op(&self, load: ColorLoad) -> LoadOp<Color> {
747        match load {
748            ColorLoad::Clear => LoadOp::Clear(self.clear_color),
749            ColorLoad::Load => LoadOp::Load,
750        }
751    }
752
753    pub fn body_capacity(&self) -> usize {
754        self.body_capacity
755    }
756
757    /// Does not auto-flush; pair with [`Self::flush_uniforms`].
758    pub fn set_bodies(&mut self, bodies: &[BodyUniform]) {
759        if bodies.len() > self.body_capacity {
760            let mut capacity = self.body_capacity.max(1);
761            while capacity < bodies.len() {
762                capacity *= 2;
763            }
764            self.body_buf = body_buffer(&self.device, "hyperslice4d bodies", capacity);
765            self.body_capacity = capacity;
766            self.bind_group = body_bind_group(
767                &self.device,
768                &self.bind_group_layout,
769                self.uniform_buf.as_entire_binding(),
770                &self.body_buf,
771            );
772        }
773        self.bodies.clear();
774        self.bodies.extend_from_slice(bodies);
775        self.uniforms.body_count = bodies.len() as f32;
776        self.uniforms.body_offset = 0.0;
777    }
778}
779
780impl Hyperslice4DNode {
781    /// `LoadOp::Load`: the kernel discards unshaded pixels, so a background records ahead of it within the same stage.
782    pub fn record_in_viewport(
783        &mut self,
784        encoder: &mut wgpu::CommandEncoder,
785        view: &wgpu::TextureView,
786        viewport: crate::Viewport,
787    ) {
788        self.record(encoder, view, None, viewport, ColorLoad::Load);
789    }
790
791    pub fn record(
792        &self,
793        encoder: &mut wgpu::CommandEncoder,
794        view: &wgpu::TextureView,
795        depth_view: Option<&wgpu::TextureView>,
796        viewport: crate::Viewport,
797        load: ColorLoad,
798    ) {
799        match (self.has_depth, depth_view.is_some()) {
800            (true, false) => panic!(
801                "Hyperslice4DNode::record: the pipeline has a depth format but no depth view \
802                 was given"
803            ),
804            (false, true) => panic!(
805                "Hyperslice4DNode::record: the pipeline has no depth format but a depth view \
806                 was given"
807            ),
808            _ => {}
809        }
810        let depth_stencil_attachment = depth_view.map(|dv| RenderPassDepthStencilAttachment {
811            view: dv,
812            depth_ops: Some(Operations {
813                load: LoadOp::Load,
814                store: StoreOp::Store,
815            }),
816            stencil_ops: None,
817        });
818        let mut rp = encoder.begin_render_pass(&RenderPassDescriptor {
819            label: Some("hyperslice4d pass"),
820            color_attachments: &[Some(RenderPassColorAttachment {
821                view,
822                depth_slice: None,
823                resolve_target: None,
824                ops: Operations {
825                    load: self.color_load_op(load),
826                    store: StoreOp::Store,
827                },
828            })],
829            depth_stencil_attachment,
830            timestamp_writes: None,
831            occlusion_query_set: None,
832        });
833        viewport.apply(&mut rp);
834        rp.set_pipeline(&self.pipeline);
835        rp.set_bind_group(0, &self.bind_group, &[]);
836        rp.draw(0..3, 0..1);
837    }
838
839    /// Each cell gets its own uniform image and bind group: `write_buffer` lands ahead of the whole command buffer, so a shared image would give every cell the last write.
840    pub fn record_strip(
841        &mut self,
842        device: &Device,
843        queue: &Queue,
844        encoder: &mut CommandEncoder,
845        view: &wgpu::TextureView,
846        cells: &[(crate::Viewport, f32, BodyUniform)],
847        load: ColorLoad,
848    ) -> Result<()> {
849        let drawn = cells
850            .iter()
851            .filter(|(viewport, _, _)| viewport.width != 0 && viewport.height != 0)
852            .count();
853        if drawn == 0 {
854            return Ok(());
855        }
856        if self
857            .strip_cells
858            .as_ref()
859            .is_none_or(|strip| strip.bind_groups.len() < drawn)
860        {
861            self.strip_cells = Some(StripCellUniforms::new(
862                device,
863                &self.bind_group_layout,
864                drawn,
865            ));
866        }
867        let strip = self
868            .strip_cells
869            .as_ref()
870            .context("missing strip uniform storage")?;
871
872        {
873            let upload_size = BufferSize::new(drawn as u64 * strip.stride)
874                .context("empty strip uniform storage")?;
875            let mut staging = queue
876                .write_buffer_with(&strip.buffer, 0, upload_size)
877                .context("mapping the filmstrip's per-cell uniform staging buffer")?;
878            let mut slot = 0usize;
879            for (viewport, w_slice, _) in cells {
880                if viewport.width == 0 || viewport.height == 0 {
881                    continue;
882                }
883                let cell = strip_cell_uniforms(&self.uniforms, *viewport, *w_slice, slot);
884                let start = slot * strip.stride as usize;
885                staging[start..start + HYPERSLICE_UNIFORMS_SIZE as usize]
886                    .copy_from_slice(bytemuck::bytes_of(&cell));
887                slot += 1;
888            }
889        }
890
891        {
892            let upload_size =
893                BufferSize::new(drawn as u64 * BODY_SIZE).context("empty strip body storage")?;
894            let mut staging = queue
895                .write_buffer_with(&strip.bodies, 0, upload_size)
896                .context("mapping the filmstrip's per-cell body staging buffer")?;
897            let mut slot = 0usize;
898            for (viewport, _, body) in cells {
899                if viewport.width == 0 || viewport.height == 0 {
900                    continue;
901                }
902                let start = slot * BODY_SIZE as usize;
903                staging[start..start + BODY_SIZE as usize]
904                    .copy_from_slice(bytemuck::bytes_of(body));
905                slot += 1;
906            }
907        }
908
909        {
910            let mut rp = encoder.begin_render_pass(&RenderPassDescriptor {
911                label: Some("hyperslice4d strip pass"),
912                color_attachments: &[Some(RenderPassColorAttachment {
913                    view,
914                    depth_slice: None,
915                    resolve_target: None,
916                    ops: Operations {
917                        load: self.color_load_op(load),
918                        store: StoreOp::Store,
919                    },
920                })],
921                depth_stencil_attachment: None,
922                timestamp_writes: None,
923                occlusion_query_set: None,
924            });
925            rp.set_pipeline(self.strip_pipeline.as_ref().unwrap_or(&self.pipeline));
926            let mut slot = 0usize;
927            for (viewport, _, _) in cells {
928                if viewport.width == 0 || viewport.height == 0 {
929                    continue;
930                }
931                viewport.apply(&mut rp);
932                rp.set_bind_group(0, &strip.bind_groups[slot], &[]);
933                rp.draw(0..3, 0..1);
934                slot += 1;
935            }
936        }
937        Ok(())
938    }
939}
940
941fn hyperslice_pipeline(
942    device: &Device,
943    layout: &PipelineLayout,
944    module: &ShaderModule,
945    surface_format: TextureFormat,
946    depth: crate::DepthMode,
947) -> RenderPipeline {
948    device.create_render_pipeline(&RenderPipelineDescriptor {
949        label: Some("hyperslice4d pipeline"),
950        layout: Some(layout),
951        vertex: VertexState {
952            module,
953            entry_point: Some("vs_fullscreen"),
954            buffers: &[],
955            compilation_options: Default::default(),
956        },
957        fragment: Some(FragmentState {
958            module,
959            entry_point: Some(if depth.is_active() {
960                "fs_depth"
961            } else {
962                "fs_main"
963            }),
964            targets: &[Some(ColorTargetState {
965                format: surface_format,
966                blend: None,
967                write_mask: ColorWrites::ALL,
968            })],
969            compilation_options: Default::default(),
970        }),
971        primitive: PrimitiveState {
972            topology: PrimitiveTopology::TriangleList,
973            ..Default::default()
974        },
975        depth_stencil: depth.format().map(|format| DepthStencilState {
976            format,
977            depth_write_enabled: depth.writes(),
978            depth_compare: crate::view::DEPTH_COMPARE,
979            stencil: StencilState::default(),
980            bias: DepthBiasState::default(),
981        }),
982        multisample: MultisampleState {
983            count: 1,
984            ..Default::default()
985        },
986        multiview: None,
987        cache: None,
988    })
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    #[test]
996    fn kernel_validates_with_real_scene_union() {
997        use crate::raymarch::scene::{Scene4, SceneNode4};
998        use glam::Vec4;
999
1000        let scene = Scene4::new(
1001            SceneNode4::hypersphere(Vec4::new(-0.6, 0.7, -1.5, 0.0), 0.7)
1002                .union(SceneNode4::hypersphere(Vec4::new(0.6, 0.7, -1.5, 0.0), 0.7))
1003                .union(SceneNode4::hypersphere(Vec4::new(0.0, 1.0, 1.5, 0.0), 1.0))
1004                .union(SceneNode4::halfspace(Vec4::Y, 0.0)),
1005        );
1006        let scene_wgsl = scene.to_hyperslice_wgsl("u.w_slice");
1007        let polytope = super::super::polytope_data::polytope_extended_sdfs_wgsl();
1008        let source = format!("{HYPERSLICE_KERNEL_WGSL}\n{polytope}\n{scene_wgsl}");
1009        let module = naga::front::wgsl::parse_str(&source)
1010            .expect("hyperslice4d kernel + Scene4 emit should parse as WGSL");
1011        let flags = naga::valid::ValidationFlags::all();
1012        let caps = naga::valid::Capabilities::empty();
1013        naga::valid::Validator::new(flags, caps)
1014            .validate(&module)
1015            .expect("hyperslice4d kernel + Scene4 emit should validate");
1016    }
1017
1018    #[test]
1019    fn kernel_validates_with_gated_scene() {
1020        use crate::raymarch::scene::{Scene4, SceneNode4};
1021        use glam::Vec4;
1022
1023        let scene = Scene4::new(
1024            SceneNode4::hypersphere(Vec4::new(0.0, 1.0, 0.0, 0.0), 0.5)
1025                .union(SceneNode4::halfspace(Vec4::Y, 0.0)),
1026        );
1027        let scene_wgsl = scene.to_hyperslice_wgsl_gated("u.w_slice", "u.params.x");
1028        let polytope = super::super::polytope_data::polytope_extended_sdfs_wgsl();
1029        let source = format!("{HYPERSLICE_KERNEL_WGSL}\n{polytope}\n{scene_wgsl}");
1030        let module = naga::front::wgsl::parse_str(&source)
1031            .expect("gated Scene4 emit should parse against the kernel");
1032        let flags = naga::valid::ValidationFlags::all();
1033        let caps = naga::valid::Capabilities::empty();
1034        naga::valid::Validator::new(flags, caps)
1035            .validate(&module)
1036            .expect("gated Scene4 emit should validate against the kernel");
1037    }
1038
1039    fn strip_probe_cells() -> Vec<(crate::Viewport, f32, BodyUniform)> {
1040        crate::Viewport::full([192, 64])
1041            .split_horizontal(3)
1042            .zip([0.0_f32, 0.6, 0.9])
1043            .map(|(viewport, w_slice)| {
1044                (
1045                    viewport,
1046                    w_slice,
1047                    BodyUniform::sphere([0.0; 4], 1.0, [1.0, 0.0, 0.0]),
1048                )
1049            })
1050            .collect()
1051    }
1052
1053    #[test]
1054    fn strip_cell_stride_is_offset_legal_and_covers_one_image() {
1055        for alignment in [32_u64, 64, 128, 256] {
1056            let stride = strip_cell_stride(alignment);
1057            assert_eq!(stride % alignment, 0, "stride must be an aligned offset");
1058            assert!(
1059                stride >= HYPERSLICE_UNIFORMS_SIZE,
1060                "stride {stride} would overlap the next cell's image",
1061            );
1062            assert!(
1063                stride - HYPERSLICE_UNIFORMS_SIZE < alignment,
1064                "stride {stride} wastes a whole alignment unit",
1065            );
1066        }
1067    }
1068
1069    #[test]
1070    fn kernel_uniforms_match_the_host_layout() {
1071        let source = format!(
1072            "{HYPERSLICE_KERNEL_WGSL}\n{}\n{EMPTY_SCENE_STUB}",
1073            super::super::polytope_data::polytope_stub_sdfs_wgsl()
1074        );
1075        let module = naga::front::wgsl::parse_str(&source).expect("parse");
1076        let ty = module
1077            .types
1078            .iter()
1079            .map(|(_, t)| t)
1080            .find(|t| t.name.as_deref() == Some("Uniforms"))
1081            .expect("the kernel declares `Uniforms`");
1082        let naga::TypeInner::Struct { members, span } = &ty.inner else {
1083            panic!("`Uniforms` is not a struct");
1084        };
1085        assert_eq!(*span as usize, std::mem::size_of::<Hyperslice4DUniforms>());
1086        let rust_offsets = [
1087            (
1088                "camera_pos",
1089                std::mem::offset_of!(Hyperslice4DUniforms, camera_pos),
1090            ),
1091            (
1092                "camera_forward",
1093                std::mem::offset_of!(Hyperslice4DUniforms, camera_forward),
1094            ),
1095            (
1096                "camera_right",
1097                std::mem::offset_of!(Hyperslice4DUniforms, camera_right),
1098            ),
1099            (
1100                "camera_up",
1101                std::mem::offset_of!(Hyperslice4DUniforms, camera_up),
1102            ),
1103            (
1104                "fov_y_tan",
1105                std::mem::offset_of!(Hyperslice4DUniforms, fov_y_tan),
1106            ),
1107            (
1108                "resolution",
1109                std::mem::offset_of!(Hyperslice4DUniforms, resolution),
1110            ),
1111            ("time", std::mem::offset_of!(Hyperslice4DUniforms, time)),
1112            ("tick", std::mem::offset_of!(Hyperslice4DUniforms, tick)),
1113            (
1114                "w_slice",
1115                std::mem::offset_of!(Hyperslice4DUniforms, w_slice),
1116            ),
1117            (
1118                "body_count",
1119                std::mem::offset_of!(Hyperslice4DUniforms, body_count),
1120            ),
1121            (
1122                "viewport_origin",
1123                std::mem::offset_of!(Hyperslice4DUniforms, viewport_origin),
1124            ),
1125            ("params", std::mem::offset_of!(Hyperslice4DUniforms, params)),
1126            ("near", std::mem::offset_of!(Hyperslice4DUniforms, near)),
1127            (
1128                "body_offset",
1129                std::mem::offset_of!(Hyperslice4DUniforms, body_offset),
1130            ),
1131            (
1132                "sky_below",
1133                std::mem::offset_of!(Hyperslice4DUniforms, sky_below),
1134            ),
1135            (
1136                "sky_above",
1137                std::mem::offset_of!(Hyperslice4DUniforms, sky_above),
1138            ),
1139        ];
1140        assert_eq!(members.len(), rust_offsets.len());
1141        for (member, (name, offset)) in members.iter().zip(rust_offsets) {
1142            assert_eq!(member.name.as_deref(), Some(name));
1143            assert_eq!(member.offset as usize, offset, "offset of {name}");
1144        }
1145    }
1146
1147    const EMPTY_SCENE_STUB: &str = r#"
1148const LOAM_PRIM_HYPERSPHERE4D: u32 = 0u;
1149const LOAM_PRIM_HALFSPACE4D: u32 = 1u;
1150const LOAM_PRIM_OTHER: u32 = 255u;
1151struct LoamSceneHit { dist: f32, kind: u32 }
1152fn loam_scene_at(p: vec3<f32>) -> LoamSceneHit {
1153    return LoamSceneHit(1.0e9, LOAM_PRIM_OTHER);
1154}
1155fn loam_scene_sdf(p: vec3<f32>) -> f32 {
1156    return loam_scene_at(p).dist;
1157}
1158fn loam_scene_max_t(ro: vec3<f32>, rd: vec3<f32>) -> f32 {
1159    return 1.0e9;
1160}
1161"#;
1162
1163    async fn request_device() -> Result<(Device, Queue), String> {
1164        let instance = wgpu::Instance::default();
1165        let adapter = instance
1166            .request_adapter(&wgpu::RequestAdapterOptions {
1167                power_preference: wgpu::PowerPreference::LowPower,
1168                compatible_surface: None,
1169                force_fallback_adapter: false,
1170            })
1171            .await
1172            .map_err(|e| format!("request_adapter failed: {e}"))?;
1173        adapter
1174            .request_device(&wgpu::DeviceDescriptor {
1175                label: Some("hyperslice4d-strip"),
1176                required_features: wgpu::Features::empty(),
1177                required_limits: wgpu::Limits::default(),
1178                memory_hints: wgpu::MemoryHints::default(),
1179                trace: wgpu::Trace::Off,
1180                experimental_features: Default::default(),
1181            })
1182            .await
1183            .map_err(|e| format!("request_device failed: {e}"))
1184    }
1185
1186    fn read_back_rgba(
1187        device: &Device,
1188        queue: &Queue,
1189        texture: &Texture,
1190        size: [u32; 2],
1191    ) -> Vec<u8> {
1192        let bytes_per_row = size[0] * 4;
1193        assert_eq!(
1194            bytes_per_row % COPY_BYTES_PER_ROW_ALIGNMENT,
1195            0,
1196            "probe width chosen so no row padding is needed"
1197        );
1198        let readback = device.create_buffer(&BufferDescriptor {
1199            label: Some("hyperslice4d strip readback"),
1200            size: (bytes_per_row * size[1]) as u64,
1201            usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ,
1202            mapped_at_creation: false,
1203        });
1204        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
1205            label: Some("hyperslice4d strip readback encoder"),
1206        });
1207        encoder.copy_texture_to_buffer(
1208            TexelCopyTextureInfo {
1209                texture,
1210                mip_level: 0,
1211                origin: Origin3d::ZERO,
1212                aspect: TextureAspect::All,
1213            },
1214            TexelCopyBufferInfo {
1215                buffer: &readback,
1216                layout: TexelCopyBufferLayout {
1217                    offset: 0,
1218                    bytes_per_row: Some(bytes_per_row),
1219                    rows_per_image: None,
1220                },
1221            },
1222            Extent3d {
1223                width: size[0],
1224                height: size[1],
1225                depth_or_array_layers: 1,
1226            },
1227        );
1228        queue.submit(Some(encoder.finish()));
1229        let slice = readback.slice(..);
1230        slice.map_async(MapMode::Read, |_| {});
1231        device
1232            .poll(PollType::Wait {
1233                submission_index: None,
1234                timeout: None,
1235            })
1236            .expect("readback poll");
1237        let data = slice.get_mapped_range().to_vec();
1238        readback.unmap();
1239        data
1240    }
1241
1242    fn strip_probe_module(device: &Device) -> ShaderModule {
1243        let polytope = super::super::polytope_data::polytope_stub_sdfs_wgsl();
1244        device.create_shader_module(ShaderModuleDescriptor {
1245            label: Some("hyperslice4d strip probe"),
1246            source: ShaderSource::Wgsl(
1247                format!("{HYPERSLICE_KERNEL_WGSL}\n{polytope}\n{EMPTY_SCENE_STUB}").into(),
1248            ),
1249        })
1250    }
1251
1252    #[test]
1253    #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1254    fn each_strip_cell_renders_its_own_w_slice_gpu_probe() {
1255        const SIZE: [u32; 2] = [192, 64];
1256        let (device, queue) = pollster::block_on(request_device()).expect("wgpu device");
1257
1258        let module = strip_probe_module(&device);
1259        let target = device.create_texture(&TextureDescriptor {
1260            label: Some("hyperslice4d strip probe target"),
1261            size: Extent3d {
1262                width: SIZE[0],
1263                height: SIZE[1],
1264                depth_or_array_layers: 1,
1265            },
1266            mip_level_count: 1,
1267            sample_count: 1,
1268            dimension: TextureDimension::D2,
1269            format: TextureFormat::Rgba8Unorm,
1270            usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC,
1271            view_formats: &[],
1272        });
1273        let view = target.create_view(&TextureViewDescriptor::default());
1274
1275        let cells = strip_probe_cells();
1276        let mut node = Hyperslice4DNode::new(&device, TextureFormat::Rgba8Unorm, &module);
1277        let mut encoder = device.create_command_encoder(&Default::default());
1278        node.record_strip(
1279            &device,
1280            &queue,
1281            &mut encoder,
1282            &view,
1283            &cells,
1284            ColorLoad::Clear,
1285        )
1286        .expect("filmstrip should render");
1287        queue.submit(Some(encoder.finish()));
1288
1289        let pixels = read_back_rgba(&device, &queue, &target, SIZE);
1290        let footprints: Vec<usize> = cells
1291            .iter()
1292            .map(|(viewport, _, _)| {
1293                let mut covered = 0;
1294                for y in viewport.y..viewport.y + viewport.height {
1295                    for x in viewport.x..viewport.x + viewport.width {
1296                        let px = ((y * SIZE[0] + x) * 4) as usize;
1297                        if pixels[px] > pixels[px + 2] {
1298                            covered += 1;
1299                        }
1300                    }
1301                }
1302                covered
1303            })
1304            .collect();
1305
1306        assert!(
1307            footprints[2] > 0,
1308            "the w = 0.9 slice should still show a body: {footprints:?}"
1309        );
1310        assert!(
1311            footprints[0] > footprints[1] && footprints[1] > footprints[2],
1312            "footprints should shrink with |w|; equal cells mean one uniform image \
1313             fed every cell: {footprints:?}"
1314        );
1315    }
1316
1317    #[test]
1318    fn set_bodies_grows_past_the_old_thirty_two_body_ceiling() {
1319        let gpu = crate::device::noop_context();
1320        let module = strip_probe_module(&gpu.device);
1321        let mut node = Hyperslice4DNode::new(&gpu.device, TextureFormat::Rgba8Unorm, &module);
1322        let bodies = vec![BodyUniform::sphere([0.0; 4], 1.0, [1.0; 3]); 33];
1323        node.set_bodies(&bodies);
1324        assert_eq!(node.uniforms().body_count, 33.0);
1325        assert!(node.body_capacity() >= 33);
1326    }
1327
1328    const DEPTH_PROBE_SIZE: u32 = 64;
1329    const DEPTH_PROBE_NEAR: f32 = 0.05;
1330    const DEPTH_PROBE_CENTER: [f32; 4] = [0.0, 0.0, -3.0, 0.0];
1331    const DEPTH_PROBE_RADIUS: f32 = 0.5;
1332
1333    fn depth_probe_ray() -> glam::Vec3 {
1334        let pixel = DEPTH_PROBE_SIZE / 2;
1335        let uv = |v: u32| ((v as f32 + 0.5) / DEPTH_PROBE_SIZE as f32) * 2.0 - 1.0;
1336        let tan = Hyperslice4DUniforms::default().fov_y_tan;
1337        glam::Vec3::new(uv(pixel) * tan, -uv(pixel) * tan, -1.0).normalize()
1338    }
1339
1340    fn depth_probe_expected() -> f32 {
1341        let center = glam::Vec3::new(
1342            DEPTH_PROBE_CENTER[0],
1343            DEPTH_PROBE_CENTER[1],
1344            DEPTH_PROBE_CENTER[2],
1345        );
1346        let rd = depth_probe_ray();
1347        let along = rd.dot(center);
1348        let gap = along * along - center.length_squared() + DEPTH_PROBE_RADIUS * DEPTH_PROBE_RADIUS;
1349        assert!(gap > 0.0, "the probe ray misses the body");
1350        let t = along - gap.sqrt();
1351        crate::view::projective_depth(rd * t, DEPTH_PROBE_NEAR)
1352    }
1353
1354    #[test]
1355    #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1356    fn the_thirty_third_body_still_shades_a_pixel_gpu_probe() {
1357        const SIZE: u32 = 64;
1358        let (device, queue) = pollster::block_on(request_device()).expect("wgpu device");
1359        let module = strip_probe_module(&device);
1360        let target = device.create_texture(&TextureDescriptor {
1361            label: Some("hyperslice4d 33-body probe"),
1362            size: Extent3d {
1363                width: SIZE,
1364                height: SIZE,
1365                depth_or_array_layers: 1,
1366            },
1367            mip_level_count: 1,
1368            sample_count: 1,
1369            dimension: TextureDimension::D2,
1370            format: TextureFormat::Rgba8Unorm,
1371            usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC,
1372            view_formats: &[],
1373        });
1374        let view = target.create_view(&TextureViewDescriptor::default());
1375
1376        let mut bodies = vec![BodyUniform::default(); 32];
1377        bodies.push(BodyUniform::sphere(
1378            [0.0, 0.0, -3.0, 0.0],
1379            0.9,
1380            [1.0, 0.0, 0.0],
1381        ));
1382        let mut node = Hyperslice4DNode::new(&device, TextureFormat::Rgba8Unorm, &module);
1383        node.set_uniforms(
1384            &queue,
1385            Hyperslice4DUniforms {
1386                camera_pos: [0.0; 3],
1387                resolution: [SIZE as f32; 2],
1388                ..Hyperslice4DUniforms::default()
1389            },
1390        );
1391        node.set_bodies(&bodies);
1392        node.flush_uniforms(&queue);
1393
1394        let mut encoder = device.create_command_encoder(&Default::default());
1395        {
1396            encoder.begin_render_pass(&RenderPassDescriptor {
1397                label: Some("hyperslice4d 33-body clear"),
1398                color_attachments: &[Some(RenderPassColorAttachment {
1399                    view: &view,
1400                    depth_slice: None,
1401                    resolve_target: None,
1402                    ops: Operations {
1403                        load: LoadOp::Clear(Color::BLACK),
1404                        store: StoreOp::Store,
1405                    },
1406                })],
1407                depth_stencil_attachment: None,
1408                timestamp_writes: None,
1409                occlusion_query_set: None,
1410            });
1411        }
1412        node.record(
1413            &mut encoder,
1414            &view,
1415            None,
1416            crate::Viewport::full([SIZE, SIZE]),
1417            ColorLoad::Load,
1418        );
1419        queue.submit(Some(encoder.finish()));
1420
1421        let pixels = read_back_rgba(&device, &queue, &target, [SIZE, SIZE]);
1422        let center = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize;
1423        assert!(
1424            pixels[center] > pixels[center + 2],
1425            "the 33rd body must shade the centre pixel, not the background: {:?}",
1426            &pixels[center..center + 4]
1427        );
1428    }
1429
1430    #[test]
1431    #[ignore = "requires a working wgpu adapter; run with --include-ignored"]
1432    fn a_hyperslice_hit_writes_the_root_eyes_projective_depth_gpu_probe() {
1433        let size = Extent3d {
1434            width: DEPTH_PROBE_SIZE,
1435            height: DEPTH_PROBE_SIZE,
1436            depth_or_array_layers: 1,
1437        };
1438        let (device, queue) = pollster::block_on(request_device()).expect("wgpu device");
1439        let module = strip_probe_module(&device);
1440        let attachment = |format: TextureFormat, label: &str| {
1441            device.create_texture(&TextureDescriptor {
1442                label: Some(label),
1443                size,
1444                mip_level_count: 1,
1445                sample_count: 1,
1446                dimension: TextureDimension::D2,
1447                format,
1448                usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC,
1449                view_formats: &[],
1450            })
1451        };
1452        let color = attachment(TextureFormat::Rgba8Unorm, "hyperslice4d depth probe color");
1453        let depth = attachment(crate::view::DEPTH_FORMAT, "hyperslice4d depth probe depth");
1454        let color_view = color.create_view(&TextureViewDescriptor::default());
1455        let depth_view = depth.create_view(&TextureViewDescriptor::default());
1456
1457        let mut node = Hyperslice4DNode::with_depth(
1458            &device,
1459            TextureFormat::Rgba8Unorm,
1460            &module,
1461            crate::DepthMode::ReadWrite {
1462                format: crate::view::DEPTH_FORMAT,
1463            },
1464        );
1465        node.set_uniforms(
1466            &queue,
1467            Hyperslice4DUniforms {
1468                camera_pos: [0.0; 3],
1469                resolution: [DEPTH_PROBE_SIZE as f32; 2],
1470                near: DEPTH_PROBE_NEAR,
1471                ..Hyperslice4DUniforms::default()
1472            },
1473        );
1474        node.set_bodies(&[BodyUniform::sphere(
1475            DEPTH_PROBE_CENTER,
1476            DEPTH_PROBE_RADIUS,
1477            [1.0, 1.0, 1.0],
1478        )]);
1479        node.flush_uniforms(&queue);
1480
1481        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
1482            label: Some("hyperslice4d depth probe"),
1483        });
1484        {
1485            encoder.begin_render_pass(&RenderPassDescriptor {
1486                label: Some("hyperslice4d depth probe clear"),
1487                color_attachments: &[Some(RenderPassColorAttachment {
1488                    view: &color_view,
1489                    depth_slice: None,
1490                    resolve_target: None,
1491                    ops: Operations {
1492                        load: LoadOp::Clear(Color::BLACK),
1493                        store: StoreOp::Store,
1494                    },
1495                })],
1496                depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
1497                    view: &depth_view,
1498                    depth_ops: Some(Operations {
1499                        load: LoadOp::Clear(crate::view::DEPTH_CLEAR),
1500                        store: StoreOp::Store,
1501                    }),
1502                    stencil_ops: None,
1503                }),
1504                timestamp_writes: None,
1505                occlusion_query_set: None,
1506            });
1507        }
1508        node.record(
1509            &mut encoder,
1510            &color_view,
1511            Some(&depth_view),
1512            crate::Viewport::full([DEPTH_PROBE_SIZE, DEPTH_PROBE_SIZE]),
1513            ColorLoad::Load,
1514        );
1515        let readback = device.create_buffer(&BufferDescriptor {
1516            label: Some("hyperslice4d depth probe readback"),
1517            size: (DEPTH_PROBE_SIZE * DEPTH_PROBE_SIZE * 4) as u64,
1518            usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ,
1519            mapped_at_creation: false,
1520        });
1521        encoder.copy_texture_to_buffer(
1522            TexelCopyTextureInfo {
1523                texture: &depth,
1524                mip_level: 0,
1525                origin: Origin3d::ZERO,
1526                aspect: TextureAspect::DepthOnly,
1527            },
1528            TexelCopyBufferInfo {
1529                buffer: &readback,
1530                layout: TexelCopyBufferLayout {
1531                    offset: 0,
1532                    bytes_per_row: Some(DEPTH_PROBE_SIZE * 4),
1533                    rows_per_image: None,
1534                },
1535            },
1536            size,
1537        );
1538        queue.submit(Some(encoder.finish()));
1539        readback.slice(..).map_async(MapMode::Read, |_| {});
1540        device
1541            .poll(PollType::Wait {
1542                submission_index: None,
1543                timeout: None,
1544            })
1545            .expect("readback poll");
1546        let depths =
1547            bytemuck::cast_slice::<u8, f32>(&readback.slice(..).get_mapped_range()).to_vec();
1548
1549        let center = DEPTH_PROBE_SIZE / 2;
1550        let written = depths[(center * DEPTH_PROBE_SIZE + center) as usize];
1551        let expected = depth_probe_expected();
1552        assert!(
1553            (written - expected).abs() <= 5.0e-5,
1554            "the hit wrote depth {written}, not the projective depth {expected}"
1555        );
1556    }
1557}