Skip to main content

alice_physics/
gpu_sdf.rs

1//! GPU-Accelerated SDF Evaluation (Compute Shader Interface)
2//!
3//! Provides data structures and dispatch logic for evaluating
4//! SDF collision queries on the GPU. Pairs with ALICE-SDF's `gpu` feature.
5//!
6//! # Design
7//!
8//! CPU side prepares query buffers (positions, radii) and dispatches
9//! to GPU compute shader. GPU evaluates all SDF queries in parallel
10//! and writes back (distance, normal) results.
11//!
12//! This module defines the buffer layouts and dispatch interface.
13//! Actual GPU execution requires ALICE-SDF's wgpu/vulkan backend.
14//!
15//! Author: Moroya Sakamoto
16
17use crate::math::{Fix128, Vec3Fix};
18
19#[cfg(not(feature = "std"))]
20use alloc::vec::Vec;
21
22// ============================================================================
23// GPU Buffer Layouts
24// ============================================================================
25
26/// Per-query input data (CPU → GPU)
27///
28/// Packed for GPU transfer: 16 bytes per query
29#[derive(Clone, Copy, Debug, PartialEq)]
30#[repr(C, align(16))]
31pub struct GpuSdfQuery {
32    /// Query position (x, y, z)
33    pub x: f32,
34    /// Y position
35    pub y: f32,
36    /// Z position
37    pub z: f32,
38    /// Query radius (for sphere-SDF test, 0 for point query)
39    pub radius: f32,
40}
41
42/// Per-query output data (GPU → CPU)
43///
44/// Packed for GPU transfer: 16 bytes per result
45#[derive(Clone, Copy, Debug, Default)]
46#[repr(C, align(16))]
47pub struct GpuSdfResult {
48    /// Signed distance to SDF surface
49    pub distance: f32,
50    /// Surface normal X
51    pub normal_x: f32,
52    /// Surface normal Y
53    pub normal_y: f32,
54    /// Surface normal Z
55    pub normal_z: f32,
56}
57
58const _: () = {
59    assert!(core::mem::size_of::<GpuSdfQuery>() == 16);
60    assert!(core::mem::size_of::<GpuSdfResult>() == 16);
61};
62
63/// GPU dispatch configuration
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub struct GpuDispatchConfig {
66    /// Workgroup size (typically 64 or 256)
67    pub workgroup_size: u32,
68    /// Maximum queries per dispatch
69    pub max_queries: u32,
70    /// Whether to compute normals (more expensive)
71    pub compute_normals: bool,
72}
73
74impl Default for GpuDispatchConfig {
75    fn default() -> Self {
76        Self {
77            workgroup_size: 64,
78            max_queries: 65536,
79            compute_normals: true,
80        }
81    }
82}
83
84// ============================================================================
85// GPU SDF Batch
86// ============================================================================
87
88/// Batch of SDF queries prepared for GPU dispatch
89pub struct GpuSdfBatch {
90    /// Input query buffer
91    pub queries: Vec<GpuSdfQuery>,
92    /// Output result buffer
93    pub results: Vec<GpuSdfResult>,
94    /// Body index for each query (to map results back)
95    pub body_indices: Vec<usize>,
96    /// Dispatch config
97    pub config: GpuDispatchConfig,
98}
99
100impl GpuSdfBatch {
101    /// Create a new empty batch
102    #[must_use]
103    pub const fn new(config: GpuDispatchConfig) -> Self {
104        Self {
105            queries: Vec::new(),
106            results: Vec::new(),
107            body_indices: Vec::new(),
108            config,
109        }
110    }
111
112    /// Add a query to the batch
113    pub fn add_query(&mut self, body_idx: usize, position: Vec3Fix, radius: Fix128) {
114        let (x, y, z) = position.to_f32();
115        self.queries.push(GpuSdfQuery {
116            x,
117            y,
118            z,
119            radius: radius.to_f32(),
120        });
121        self.body_indices.push(body_idx);
122    }
123
124    /// Number of queries in the batch
125    #[inline]
126    #[must_use]
127    pub fn query_count(&self) -> usize {
128        self.queries.len()
129    }
130
131    /// Calculate number of workgroups needed
132    #[must_use]
133    pub fn num_workgroups(&self) -> u32 {
134        let n = self.queries.len() as u32;
135        n.div_ceil(self.config.workgroup_size)
136    }
137
138    /// Prepare output buffer (allocate space for results)
139    pub fn prepare_output(&mut self) {
140        self.results
141            .resize(self.queries.len(), GpuSdfResult::default());
142    }
143
144    /// Clear batch for reuse
145    pub fn clear(&mut self) {
146        self.queries.clear();
147        self.results.clear();
148        self.body_indices.clear();
149    }
150
151    /// Get raw query buffer as bytes (for GPU upload)
152    #[must_use]
153    pub fn query_bytes(&self) -> &[u8] {
154        // SAFETY: GpuSdfQuery is repr(C, align(16)) with no padding or uninitialized bytes.
155        // The pointer is derived from a valid Vec<GpuSdfQuery>, and the byte length equals
156        // queries.len() * size_of::<GpuSdfQuery>(). The returned slice lifetime is tied to &self.
157        unsafe {
158            core::slice::from_raw_parts(
159                self.queries.as_ptr().cast::<u8>(),
160                self.queries.len() * core::mem::size_of::<GpuSdfQuery>(),
161            )
162        }
163    }
164
165    /// Get mutable raw result buffer as bytes (for GPU readback)
166    pub fn result_bytes_mut(&mut self) -> &mut [u8] {
167        // SAFETY: GpuSdfResult is repr(C, align(16)) with no padding or uninitialized bytes
168        // (all fields are f32, default-initialized). The pointer is derived from a valid
169        // Vec<GpuSdfResult>, and the byte length equals results.len() * size_of::<GpuSdfResult>().
170        // The returned slice lifetime is tied to &mut self, preventing aliased access.
171        unsafe {
172            core::slice::from_raw_parts_mut(
173                self.results.as_mut_ptr().cast::<u8>(),
174                self.results.len() * core::mem::size_of::<GpuSdfResult>(),
175            )
176        }
177    }
178
179    /// Check results for collisions and return contacts.
180    ///
181    /// A query is a sphere of its own `radius` (0 for a point) grown by the
182    /// common `collision_radius`: it penetrates by
183    /// `collision_radius + radius − distance` when that is positive.
184    #[must_use]
185    pub fn extract_contacts(&self, collision_radius: f32) -> Vec<GpuSdfContact> {
186        let mut contacts = Vec::new();
187
188        for (i, result) in self.results.iter().enumerate() {
189            let radius = self.queries.get(i).map_or(0.0, |q| q.radius);
190            let penetration = collision_radius + radius - result.distance;
191            if penetration > 0.0 {
192                contacts.push(GpuSdfContact {
193                    body_index: self.body_indices[i],
194                    penetration,
195                    normal: (result.normal_x, result.normal_y, result.normal_z),
196                    distance: result.distance,
197                });
198            }
199        }
200
201        contacts
202    }
203}
204
205// ============================================================================
206// GPU Instancing — Instanced SDF Batch & Multi-Dispatch
207// ============================================================================
208
209/// Instanced SDF query batch — evaluates many queries against one SDF in a
210/// single GPU kernel dispatch.
211///
212/// Grouping queries by `sdf_id` minimises the number of kernel launches and
213/// avoids redundant SDF binding changes on the GPU. Pass a collection of these
214/// to [`GpuSdfMultiDispatch`] to assemble a full frame's worth of queries.
215#[repr(C, align(16))]
216pub struct GpuSdfInstancedBatch {
217    /// Identifier of the SDF to evaluate (maps to a GPU buffer / binding slot).
218    pub sdf_id: u32,
219    /// Number of queries in `queries` (mirrors `queries.len()` for C FFI).
220    pub query_count: u32,
221    /// Query inputs for this SDF instance.
222    pub queries: Vec<GpuSdfQuery>,
223}
224
225/// Multi-SDF dispatch — groups queries by SDF for the minimum number of GPU
226/// kernel calls per frame (one dispatch per unique `sdf_id`).
227///
228/// # Usage
229///
230/// ```
231/// use alice_physics::gpu_sdf::{GpuSdfMultiDispatch, GpuSdfQuery};
232///
233/// let mut dispatch = GpuSdfMultiDispatch::new();
234/// let sphere_queries = vec![GpuSdfQuery { x: 0.0, y: 0.0, z: 0.0, radius: 0.5 }];
235/// let box_queries = vec![GpuSdfQuery { x: 1.0, y: 1.0, z: 1.0, radius: 0.0 }];
236/// dispatch.add_batch(0, sphere_queries);
237/// dispatch.add_batch(1, box_queries);
238///
239/// assert_eq!(dispatch.total_queries(), 2);
240/// assert_eq!(dispatch.total_dispatches(), 2);
241/// ```
242pub struct GpuSdfMultiDispatch {
243    /// One entry per unique SDF — ordered by insertion.
244    pub batches: Vec<GpuSdfInstancedBatch>,
245}
246
247impl GpuSdfMultiDispatch {
248    /// Create an empty multi-dispatch container.
249    #[must_use]
250    pub const fn new() -> Self {
251        Self {
252            batches: Vec::new(),
253        }
254    }
255
256    /// Append a batch of queries for a single SDF.
257    ///
258    /// `sdf_id` identifies which GPU-side SDF buffer to bind during this
259    /// dispatch. Queries for an `sdf_id` that already has a batch are appended
260    /// to it (in call order), so there is one batch, and one dispatch, per
261    /// unique `sdf_id`; batches keep the order of their first `add_batch`.
262    pub fn add_batch(&mut self, sdf_id: u32, mut queries: Vec<GpuSdfQuery>) {
263        if let Some(b) = self.batches.iter_mut().find(|b| b.sdf_id == sdf_id) {
264            b.queries.append(&mut queries);
265            b.query_count = b.queries.len() as u32;
266            return;
267        }
268        self.batches.push(GpuSdfInstancedBatch {
269            sdf_id,
270            query_count: queries.len() as u32,
271            queries,
272        });
273    }
274
275    /// Total number of individual SDF point queries across all batches.
276    #[must_use]
277    pub fn total_queries(&self) -> usize {
278        self.batches.iter().map(|b| b.queries.len()).sum()
279    }
280
281    /// Number of GPU kernel dispatches that will be issued (one per batch, so
282    /// one per unique `sdf_id`).
283    #[must_use]
284    pub fn total_dispatches(&self) -> usize {
285        self.batches.len()
286    }
287}
288
289impl Default for GpuSdfMultiDispatch {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295// ============================================================================
296// Kernel Auto-Dispatch — SIMD-width aligned batch size
297// ============================================================================
298
299/// Returns the optimal CPU-side pre-processing batch size.
300///
301/// Equals [`crate::math::SIMD_WIDTH`] so inner loops that feed data to SIMD
302/// registers process exactly one register's worth of items per iteration —
303/// no padding, no partial loads.
304///
305/// Use this when chunking queries before uploading to the GPU:
306///
307/// ```
308/// use alice_physics::gpu_sdf::batch_size;
309///
310/// let queries = vec![1.0f32; 64];
311/// for chunk in queries.chunks(batch_size()) {
312///     assert!(chunk.len() <= batch_size());
313/// }
314/// ```
315#[inline(always)]
316#[must_use]
317pub const fn batch_size() -> usize {
318    crate::math::SIMD_WIDTH
319}
320
321/// Contact from GPU SDF evaluation
322#[derive(Clone, Copy, Debug, PartialEq)]
323pub struct GpuSdfContact {
324    /// Body index
325    pub body_index: usize,
326    /// Penetration depth
327    pub penetration: f32,
328    /// Surface normal (pointing outward)
329    pub normal: (f32, f32, f32),
330    /// Signed distance
331    pub distance: f32,
332}
333
334// ============================================================================
335// CPU Fallback (for testing without GPU)
336// ============================================================================
337
338/// Execute batch on CPU (fallback when GPU is unavailable)
339#[cfg(feature = "std")]
340pub fn execute_batch_cpu(batch: &mut GpuSdfBatch, sdf: &dyn crate::sdf_collider::SdfField) {
341    batch.prepare_output();
342
343    for (i, query) in batch.queries.iter().enumerate() {
344        let dist = sdf.distance(query.x, query.y, query.z);
345
346        let (nx, ny, nz) = if batch.config.compute_normals {
347            sdf.normal(query.x, query.y, query.z)
348        } else {
349            (0.0, 0.0, 0.0)
350        };
351
352        batch.results[i] = GpuSdfResult {
353            distance: dist,
354            normal_x: nx,
355            normal_y: ny,
356            normal_z: nz,
357        };
358    }
359}
360
361// ============================================================================
362// WGSL Shader Source (for reference / code generation)
363// ============================================================================
364
365/// WGSL compute shader source for SDF evaluation.
366///
367/// This is provided as a reference; actual GPU dispatch uses ALICE-SDF's
368/// GPU backend which compiles SDF nodes to WGSL/SPIR-V.
369pub const SDF_EVAL_WGSL: &str = r"
370struct Query {
371    x: f32,
372    y: f32,
373    z: f32,
374    radius: f32,
375};
376
377struct Result {
378    distance: f32,
379    normal_x: f32,
380    normal_y: f32,
381    normal_z: f32,
382};
383
384@group(0) @binding(0) var<storage, read> queries: array<Query>;
385@group(0) @binding(1) var<storage, read_write> results: array<Result>;
386
387// SDF evaluation function (generated per-SDF)
388fn sdf_distance(x: f32, y: f32, z: f32) -> f32 {
389    // Placeholder - replaced by ALICE-SDF code generation
390    return length(vec3(x, y, z)) - 1.0;
391}
392
393fn sdf_normal(x: f32, y: f32, z: f32) -> vec3<f32> {
394    let eps = 0.001;
395    let dx = sdf_distance(x + eps, y, z) - sdf_distance(x - eps, y, z);
396    let dy = sdf_distance(x, y + eps, z) - sdf_distance(x, y - eps, z);
397    let dz = sdf_distance(x, y, z + eps) - sdf_distance(x, y, z - eps);
398    return normalize(vec3(dx, dy, dz));
399}
400
401@compute @workgroup_size(64)
402fn main(@builtin(global_invocation_id) id: vec3<u32>) {
403    let idx = id.x;
404    if idx >= arrayLength(&queries) {
405        return;
406    }
407    let q = queries[idx];
408    let d = sdf_distance(q.x, q.y, q.z);
409    let n = sdf_normal(q.x, q.y, q.z);
410    results[idx] = Result(d, n.x, n.y, n.z);
411}
412";
413
414// ============================================================================
415// Tests
416// ============================================================================
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::sdf_collider::ClosureSdf;
422
423    #[test]
424    fn test_gpu_batch_creation() {
425        let mut batch = GpuSdfBatch::new(GpuDispatchConfig::default());
426
427        batch.add_query(0, Vec3Fix::from_f32(1.0, 0.0, 0.0), Fix128::from_f32(0.5));
428        batch.add_query(1, Vec3Fix::from_f32(0.0, 2.0, 0.0), Fix128::from_f32(0.5));
429
430        assert_eq!(batch.query_count(), 2);
431        assert_eq!(batch.num_workgroups(), 1);
432    }
433
434    #[test]
435    fn test_cpu_fallback() {
436        let sphere = ClosureSdf::new(
437            |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
438            |x, y, z| {
439                let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
440                if len < 1e-10 {
441                    (0.0, 1.0, 0.0)
442                } else {
443                    (x / len, y / len, z / len)
444                }
445            },
446        );
447
448        let mut batch = GpuSdfBatch::new(GpuDispatchConfig::default());
449        batch.add_query(0, Vec3Fix::from_f32(2.0, 0.0, 0.0), Fix128::from_f32(0.5));
450        batch.add_query(1, Vec3Fix::from_f32(0.5, 0.0, 0.0), Fix128::from_f32(0.5));
451
452        execute_batch_cpu(&mut batch, &sphere);
453
454        // Point at (2,0,0): distance = 1.0 (outside)
455        assert!(batch.results[0].distance > 0.0, "Should be outside sphere");
456        // Point at (0.5,0,0): distance = -0.5 (inside)
457        assert!(batch.results[1].distance < 0.0, "Should be inside sphere");
458    }
459
460    #[test]
461    fn test_extract_contacts() {
462        let sphere = ClosureSdf::new(
463            |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
464            |x, y, z| {
465                let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
466                if len < 1e-10 {
467                    (0.0, 1.0, 0.0)
468                } else {
469                    (x / len, y / len, z / len)
470                }
471            },
472        );
473
474        let mut batch = GpuSdfBatch::new(GpuDispatchConfig::default());
475        batch.add_query(0, Vec3Fix::from_f32(0.5, 0.0, 0.0), Fix128::from_f32(0.5));
476
477        execute_batch_cpu(&mut batch, &sphere);
478
479        let contacts = batch.extract_contacts(0.5);
480        assert!(
481            !contacts.is_empty(),
482            "Should detect contact for body inside sphere"
483        );
484    }
485
486    #[test]
487    fn test_buffer_layout() {
488        assert_eq!(core::mem::size_of::<GpuSdfQuery>(), 16);
489        assert_eq!(core::mem::size_of::<GpuSdfResult>(), 16);
490    }
491
492    fn q(x: f32, radius: f32) -> GpuSdfQuery {
493        GpuSdfQuery {
494            x,
495            y: 0.0,
496            z: 0.0,
497            radius,
498        }
499    }
500
501    #[test]
502    fn multi_dispatch_merges_batches_by_sdf_id_in_call_order() {
503        let mut d = GpuSdfMultiDispatch::default();
504        assert_eq!(d.total_queries(), 0);
505        assert_eq!(d.total_dispatches(), 0);
506        d.add_batch(7, vec![q(1.0, 0.0)]);
507        d.add_batch(3, vec![q(2.0, 0.0), q(3.0, 0.0)]);
508        d.add_batch(7, vec![q(4.0, 0.5), q(5.0, 0.0)]);
509        // one dispatch per unique sdf_id, in order of first appearance
510        assert_eq!(d.total_dispatches(), 2);
511        assert_eq!(d.total_queries(), 5);
512        assert_eq!(d.batches[0].sdf_id, 7);
513        assert_eq!(d.batches[1].sdf_id, 3);
514        // the repeated id appended to its batch and query_count tracks len
515        assert_eq!(d.batches[0].query_count, 3);
516        assert_eq!(
517            d.batches[0].queries,
518            vec![q(1.0, 0.0), q(4.0, 0.5), q(5.0, 0.0)]
519        );
520        assert_eq!(d.batches[1].query_count, 2);
521    }
522
523    #[test]
524    fn byte_views_cover_every_query_and_result() {
525        let mut batch = GpuSdfBatch::new(GpuDispatchConfig::default());
526        batch.add_query(4, Vec3Fix::from_f32(1.5, -2.0, 0.25), Fix128::from_f32(0.5));
527        batch.add_query(9, Vec3Fix::from_f32(0.0, 3.0, 0.0), Fix128::ZERO);
528        let bytes = batch.query_bytes();
529        assert_eq!(bytes.len(), 2 * 16);
530        let f = |k: usize| f32::from_ne_bytes([bytes[k], bytes[k + 1], bytes[k + 2], bytes[k + 3]]);
531        assert_eq!([f(0), f(4), f(8), f(12)], [1.5, -2.0, 0.25, 0.5]);
532        assert_eq!([f(16), f(20), f(24), f(28)], [0.0, 3.0, 0.0, 0.0]);
533
534        // results are empty until prepared; a GPU readback writes through the view
535        assert!(batch.result_bytes_mut().is_empty());
536        batch.prepare_output();
537        let out = batch.result_bytes_mut();
538        assert_eq!(out.len(), 2 * 16);
539        out[16..20].copy_from_slice(&(-0.75f32).to_ne_bytes());
540        out[24..28].copy_from_slice(&1.0f32.to_ne_bytes());
541        assert_eq!(batch.results[1].distance, -0.75);
542        assert_eq!(batch.results[1].normal_y, 1.0);
543        assert_eq!(batch.results[0].distance, 0.0);
544
545        batch.clear();
546        assert_eq!(batch.query_count(), 0);
547        assert!(batch.results.is_empty());
548        assert!(batch.body_indices.is_empty());
549        assert!(batch.query_bytes().is_empty());
550        assert_eq!(batch.num_workgroups(), 0);
551    }
552
553    #[test]
554    fn cpu_fallback_without_normals_and_contact_depth() {
555        // plane y = 0, normals disabled: distance only, normal left at zero
556        let plane = ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0));
557        let mut batch = GpuSdfBatch::new(GpuDispatchConfig {
558            compute_normals: false,
559            ..GpuDispatchConfig::default()
560        });
561        batch.add_query(2, Vec3Fix::from_f32(0.0, 0.25, 0.0), Fix128::from_f32(0.5));
562        batch.add_query(5, Vec3Fix::from_f32(0.0, 2.0, 0.0), Fix128::ZERO);
563        execute_batch_cpu(&mut batch, &plane);
564        assert_eq!(batch.results[0].distance, 0.25);
565        assert_eq!(
566            (
567                batch.results[0].normal_x,
568                batch.results[0].normal_y,
569                batch.results[0].normal_z
570            ),
571            (0.0, 0.0, 0.0)
572        );
573        // penetration = collision_radius + radius - distance = 0.25 + 0.5 - 0.25
574        let contacts = batch.extract_contacts(0.25);
575        assert_eq!(contacts.len(), 1);
576        assert_eq!(contacts[0].body_index, 2);
577        assert_eq!(contacts[0].penetration, 0.5);
578        assert_eq!(contacts[0].distance, 0.25);
579    }
580
581    #[test]
582    fn workgroup_count_rounds_up() {
583        let mut batch = GpuSdfBatch::new(GpuDispatchConfig {
584            workgroup_size: 4,
585            ..GpuDispatchConfig::default()
586        });
587        for i in 0..9 {
588            batch.add_query(i, Vec3Fix::ZERO, Fix128::ZERO);
589        }
590        assert_eq!(batch.num_workgroups(), 3);
591        assert_eq!(batch_size(), crate::math::SIMD_WIDTH);
592    }
593}