Skip to main content

ringkernel_wavesim3d/visualization/
volume.rs

1//! Volumetric rendering for 3D wave simulation.
2//!
3//! Uses ray marching to render the pressure field as a translucent volume.
4
5use crate::simulation::SimulationGrid3D;
6use half::f16;
7use wgpu::util::DeviceExt;
8
9/// Ray marching shader for volume rendering
10/// Uses a cube proxy geometry to constrain rendering to the volume bounds
11const VOLUME_SHADER: &str = r#"
12struct CameraUniform {
13    view_proj: mat4x4<f32>,
14    view: mat4x4<f32>,
15    camera_pos: vec4<f32>,
16    grid_size: vec4<f32>,
17};
18
19struct VolumeParams {
20    grid_dims: vec4<u32>,      // x, y, z dimensions, padding
21    density_scale: f32,
22    step_size: f32,
23    max_steps: u32,
24    threshold: f32,
25};
26
27// Group 0: Camera bind group (shared with other render passes)
28@group(0) @binding(0) var<uniform> camera: CameraUniform;
29// Group 1: Volume-specific bind group
30@group(1) @binding(0) var<uniform> params: VolumeParams;
31@group(1) @binding(1) var volume_texture: texture_3d<f32>;
32@group(1) @binding(2) var volume_sampler: sampler;
33
34struct VertexOutput {
35    @builtin(position) clip_position: vec4<f32>,
36    @location(0) world_pos: vec3<f32>,
37};
38
39// Cube vertices for proxy geometry (36 vertices for 12 triangles)
40@vertex
41fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
42    // Unit cube vertices (will be scaled by grid_size)
43    var cube_vertices = array<vec3<f32>, 36>(
44        // Front face
45        vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(1.0, 0.0, 1.0), vec3<f32>(1.0, 1.0, 1.0),
46        vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(1.0, 1.0, 1.0), vec3<f32>(0.0, 1.0, 1.0),
47        // Back face
48        vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 0.0, 0.0), vec3<f32>(0.0, 1.0, 0.0),
49        vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(1.0, 1.0, 0.0),
50        // Top face
51        vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(0.0, 1.0, 1.0), vec3<f32>(1.0, 1.0, 1.0),
52        vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(1.0, 1.0, 1.0), vec3<f32>(1.0, 1.0, 0.0),
53        // Bottom face
54        vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(0.0, 0.0, 0.0), vec3<f32>(1.0, 0.0, 0.0),
55        vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(1.0, 0.0, 1.0),
56        // Right face
57        vec3<f32>(1.0, 0.0, 1.0), vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(1.0, 1.0, 0.0),
58        vec3<f32>(1.0, 0.0, 1.0), vec3<f32>(1.0, 1.0, 0.0), vec3<f32>(1.0, 1.0, 1.0),
59        // Left face
60        vec3<f32>(0.0, 0.0, 0.0), vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(0.0, 1.0, 1.0),
61        vec3<f32>(0.0, 0.0, 0.0), vec3<f32>(0.0, 1.0, 1.0), vec3<f32>(0.0, 1.0, 0.0),
62    );
63
64    // Scale cube to grid size
65    let local_pos = cube_vertices[vertex_index];
66    let world_pos = local_pos * camera.grid_size.xyz;
67
68    var output: VertexOutput;
69    output.clip_position = camera.view_proj * vec4<f32>(world_pos, 1.0);
70    output.world_pos = world_pos;
71
72    return output;
73}
74
75// Ray-box intersection
76fn intersect_box(ray_origin: vec3<f32>, ray_dir: vec3<f32>, box_min: vec3<f32>, box_max: vec3<f32>) -> vec2<f32> {
77    let inv_dir = 1.0 / ray_dir;
78    let t1 = (box_min - ray_origin) * inv_dir;
79    let t2 = (box_max - ray_origin) * inv_dir;
80
81    let tmin = min(t1, t2);
82    let tmax = max(t1, t2);
83
84    let t_near = max(max(tmin.x, tmin.y), tmin.z);
85    let t_far = min(min(tmax.x, tmax.y), tmax.z);
86
87    return vec2<f32>(t_near, t_far);
88}
89
90// Color mapping: blue (negative) - white (zero) - red (positive)
91fn pressure_to_color(pressure: f32) -> vec4<f32> {
92    let p = clamp(pressure, -1.0, 1.0);
93
94    if (p >= 0.0) {
95        // White to Red
96        return vec4<f32>(1.0, 1.0 - p, 1.0 - p, abs(p) * params.density_scale);
97    } else {
98        // Blue to White
99        return vec4<f32>(1.0 + p, 1.0 + p, 1.0, abs(p) * params.density_scale);
100    }
101}
102
103@fragment
104fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
105    let ray_origin = camera.camera_pos.xyz;
106    // Ray direction from camera through the cube surface point
107    let ray_dir = normalize(in.world_pos - ray_origin);
108
109    // Box bounds (0 to grid_size)
110    let box_min = vec3<f32>(0.0, 0.0, 0.0);
111    let box_max = camera.grid_size.xyz;
112
113    // Find intersection with volume box
114    let t = intersect_box(ray_origin, ray_dir, box_min, box_max);
115
116    if (t.x > t.y || t.y < 0.0) {
117        // No intersection - transparent
118        discard;
119    }
120
121    // Clamp to positive t values (start from where ray enters the volume)
122    let t_start = max(t.x, 0.0);
123    let t_end = t.y;
124
125    // Ray marching through the volume
126    var accumulated_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
127    var current_t = t_start;
128    let step = params.step_size;
129    var steps: u32 = 0u;
130
131    loop {
132        if (current_t >= t_end || steps >= params.max_steps || accumulated_color.a >= 0.95) {
133            break;
134        }
135
136        let pos = ray_origin + ray_dir * current_t;
137
138        // Convert to normalized texture coordinates [0, 1]
139        let uvw = pos / box_max;
140
141        // Sample pressure from 3D texture
142        let pressure = textureSample(volume_texture, volume_sampler, uvw).r;
143
144        // Only render if above threshold
145        if (abs(pressure) > params.threshold) {
146            let sample_color = pressure_to_color(pressure);
147
148            // Front-to-back compositing
149            let alpha = sample_color.a * (1.0 - accumulated_color.a);
150            accumulated_color = vec4<f32>(
151                accumulated_color.rgb + sample_color.rgb * alpha,
152                accumulated_color.a + alpha
153            );
154        }
155
156        current_t = current_t + step;
157        steps = steps + 1u;
158    }
159
160    // Blend with background
161    if (accumulated_color.a < 0.01) {
162        discard;
163    }
164
165    return accumulated_color;
166}
167"#;
168
169/// Parameters for volume rendering
170#[repr(C)]
171#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
172pub struct VolumeParams {
173    /// Grid dimensions (x, y, z, padding)
174    pub grid_dims: [u32; 4],
175    /// Density scale for alpha
176    pub density_scale: f32,
177    /// Step size for ray marching
178    pub step_size: f32,
179    /// Maximum ray marching steps
180    pub max_steps: u32,
181    /// Minimum pressure threshold
182    pub threshold: f32,
183}
184
185impl Default for VolumeParams {
186    fn default() -> Self {
187        Self {
188            grid_dims: [64, 32, 64, 0],
189            density_scale: 2.0,
190            step_size: 0.05,
191            max_steps: 256,
192            threshold: 0.01,
193        }
194    }
195}
196
197/// Volume renderer using ray marching
198pub struct VolumeRenderer {
199    /// Render pipeline
200    pipeline: wgpu::RenderPipeline,
201    /// Volume texture
202    volume_texture: wgpu::Texture,
203    /// Volume texture view
204    #[allow(dead_code)]
205    volume_view: wgpu::TextureView,
206    /// Texture sampler
207    #[allow(dead_code)]
208    sampler: wgpu::Sampler,
209    /// Parameters uniform buffer
210    params_buffer: wgpu::Buffer,
211    /// Bind group
212    bind_group: wgpu::BindGroup,
213    /// Bind group layout
214    #[allow(dead_code)]
215    bind_group_layout: wgpu::BindGroupLayout,
216    /// Volume parameters
217    pub params: VolumeParams,
218    /// Grid dimensions
219    dimensions: (usize, usize, usize),
220    /// Maximum pressure for normalization
221    max_pressure: f32,
222}
223
224impl VolumeRenderer {
225    /// Create a new volume renderer
226    pub fn new(
227        device: &wgpu::Device,
228        surface_format: wgpu::TextureFormat,
229        camera_bind_group_layout: &wgpu::BindGroupLayout,
230        dimensions: (usize, usize, usize),
231    ) -> Self {
232        let (width, height, depth) = dimensions;
233
234        // Create 3D texture for volume data
235        // Using R16Float which supports filtering (linear sampling)
236        let volume_texture = device.create_texture(&wgpu::TextureDescriptor {
237            label: Some("volume_texture"),
238            size: wgpu::Extent3d {
239                width: width as u32,
240                height: height as u32,
241                depth_or_array_layers: depth as u32,
242            },
243            mip_level_count: 1,
244            sample_count: 1,
245            dimension: wgpu::TextureDimension::D3,
246            format: wgpu::TextureFormat::R16Float,
247            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
248            view_formats: &[],
249        });
250
251        let volume_view = volume_texture.create_view(&wgpu::TextureViewDescriptor::default());
252
253        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
254            label: Some("volume_sampler"),
255            address_mode_u: wgpu::AddressMode::ClampToEdge,
256            address_mode_v: wgpu::AddressMode::ClampToEdge,
257            address_mode_w: wgpu::AddressMode::ClampToEdge,
258            mag_filter: wgpu::FilterMode::Linear,
259            min_filter: wgpu::FilterMode::Linear,
260            mipmap_filter: wgpu::FilterMode::Nearest,
261            ..Default::default()
262        });
263
264        let params = VolumeParams {
265            grid_dims: [width as u32, height as u32, depth as u32, 0],
266            ..Default::default()
267        };
268
269        let params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
270            label: Some("volume_params_buffer"),
271            contents: bytemuck::cast_slice(&[params]),
272            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
273        });
274
275        // Create bind group layout for volume-specific bindings
276        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
277            label: Some("volume_bind_group_layout"),
278            entries: &[
279                // VolumeParams uniform
280                wgpu::BindGroupLayoutEntry {
281                    binding: 0,
282                    visibility: wgpu::ShaderStages::FRAGMENT,
283                    ty: wgpu::BindingType::Buffer {
284                        ty: wgpu::BufferBindingType::Uniform,
285                        has_dynamic_offset: false,
286                        min_binding_size: None,
287                    },
288                    count: None,
289                },
290                // 3D texture
291                wgpu::BindGroupLayoutEntry {
292                    binding: 1,
293                    visibility: wgpu::ShaderStages::FRAGMENT,
294                    ty: wgpu::BindingType::Texture {
295                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
296                        view_dimension: wgpu::TextureViewDimension::D3,
297                        multisampled: false,
298                    },
299                    count: None,
300                },
301                // Sampler
302                wgpu::BindGroupLayoutEntry {
303                    binding: 2,
304                    visibility: wgpu::ShaderStages::FRAGMENT,
305                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
306                    count: None,
307                },
308            ],
309        });
310
311        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
312            label: Some("volume_bind_group"),
313            layout: &bind_group_layout,
314            entries: &[
315                wgpu::BindGroupEntry {
316                    binding: 0,
317                    resource: params_buffer.as_entire_binding(),
318                },
319                wgpu::BindGroupEntry {
320                    binding: 1,
321                    resource: wgpu::BindingResource::TextureView(&volume_view),
322                },
323                wgpu::BindGroupEntry {
324                    binding: 2,
325                    resource: wgpu::BindingResource::Sampler(&sampler),
326                },
327            ],
328        });
329
330        // Create pipeline layout
331        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
332            label: Some("volume_pipeline_layout"),
333            bind_group_layouts: &[camera_bind_group_layout, &bind_group_layout],
334            push_constant_ranges: &[],
335        });
336
337        // Create shader module
338        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
339            label: Some("volume_shader"),
340            source: wgpu::ShaderSource::Wgsl(VOLUME_SHADER.into()),
341        });
342
343        // Create pipeline
344        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
345            label: Some("volume_pipeline"),
346            layout: Some(&pipeline_layout),
347            vertex: wgpu::VertexState {
348                module: &shader,
349                entry_point: Some("vs_main"),
350                compilation_options: Default::default(),
351                buffers: &[],
352            },
353            fragment: Some(wgpu::FragmentState {
354                module: &shader,
355                entry_point: Some("fs_main"),
356                compilation_options: Default::default(),
357                targets: &[Some(wgpu::ColorTargetState {
358                    format: surface_format,
359                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
360                    write_mask: wgpu::ColorWrites::ALL,
361                })],
362            }),
363            primitive: wgpu::PrimitiveState {
364                topology: wgpu::PrimitiveTopology::TriangleList,
365                strip_index_format: None,
366                front_face: wgpu::FrontFace::Ccw,
367                cull_mode: None,
368                polygon_mode: wgpu::PolygonMode::Fill,
369                unclipped_depth: false,
370                conservative: false,
371            },
372            depth_stencil: Some(wgpu::DepthStencilState {
373                format: wgpu::TextureFormat::Depth32Float,
374                depth_write_enabled: false,
375                depth_compare: wgpu::CompareFunction::Always,
376                stencil: wgpu::StencilState::default(),
377                bias: wgpu::DepthBiasState::default(),
378            }),
379            multisample: wgpu::MultisampleState::default(),
380            multiview: None,
381            cache: None,
382        });
383
384        Self {
385            pipeline,
386            volume_texture,
387            volume_view,
388            sampler,
389            params_buffer,
390            bind_group,
391            bind_group_layout,
392            params,
393            dimensions,
394            max_pressure: 1.0,
395        }
396    }
397
398    /// Update the volume texture from simulation grid
399    pub fn update_volume(&mut self, queue: &wgpu::Queue, grid: &SimulationGrid3D) {
400        let (width, height, depth) = self.dimensions;
401
402        // Update max pressure for normalization
403        self.max_pressure = grid.max_pressure().max(0.001);
404
405        // Copy and normalize pressure data to f16 format (as raw bytes)
406        let mut data: Vec<u8> = Vec::with_capacity(width * height * depth * 2);
407        for z in 0..depth {
408            for y in 0..height {
409                for x in 0..width {
410                    let idx = z * (width * height) + y * width + x;
411                    let pressure = grid.pressure[idx] / self.max_pressure;
412                    let f16_val = f16::from_f32(pressure);
413                    data.extend_from_slice(&f16_val.to_le_bytes());
414                }
415            }
416        }
417
418        // Upload to texture (f16 = 2 bytes per pixel)
419        queue.write_texture(
420            wgpu::TexelCopyTextureInfo {
421                texture: &self.volume_texture,
422                mip_level: 0,
423                origin: wgpu::Origin3d::ZERO,
424                aspect: wgpu::TextureAspect::All,
425            },
426            &data,
427            wgpu::TexelCopyBufferLayout {
428                offset: 0,
429                bytes_per_row: Some((width * 2) as u32), // 2 bytes per f16 pixel
430                rows_per_image: Some(height as u32),
431            },
432            wgpu::Extent3d {
433                width: width as u32,
434                height: height as u32,
435                depth_or_array_layers: depth as u32,
436            },
437        );
438    }
439
440    /// Update volume parameters
441    pub fn update_params(&self, queue: &wgpu::Queue) {
442        queue.write_buffer(&self.params_buffer, 0, bytemuck::cast_slice(&[self.params]));
443    }
444
445    /// Render the volume
446    pub fn render<'a>(
447        &'a self,
448        render_pass: &mut wgpu::RenderPass<'a>,
449        camera_bind_group: &'a wgpu::BindGroup,
450    ) {
451        render_pass.set_pipeline(&self.pipeline);
452        render_pass.set_bind_group(0, camera_bind_group, &[]);
453        render_pass.set_bind_group(1, &self.bind_group, &[]);
454        render_pass.draw(0..36, 0..1); // 36 vertices for cube (6 faces * 2 triangles * 3 vertices)
455    }
456
457    /// Set density scale
458    pub fn set_density_scale(&mut self, scale: f32) {
459        self.params.density_scale = scale;
460    }
461
462    /// Set step size
463    pub fn set_step_size(&mut self, step: f32) {
464        self.params.step_size = step;
465    }
466
467    /// Set threshold
468    pub fn set_threshold(&mut self, threshold: f32) {
469        self.params.threshold = threshold;
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_volume_params_default() {
479        let params = VolumeParams::default();
480        assert_eq!(params.max_steps, 256);
481        assert!(params.density_scale > 0.0);
482    }
483}