Skip to main content

eulumdat_rt/
camera.rs

1//! Camera ray tracing — renders images from the same physics engine.
2
3use crate::pipeline::{GpuMaterial, GpuPrimitive};
4use bytemuck::{Pod, Zeroable};
5use std::borrow::Cow;
6use wgpu::util::DeviceExt;
7
8/// Camera configuration — matches CameraConfig in camera.wgsl.
9#[repr(C)]
10#[derive(Clone, Copy, Debug, Pod, Zeroable)]
11pub struct CameraConfig {
12    pub width: u32,
13    pub height: u32,
14    pub samples_per_pixel: u32,
15    pub max_bounces: u32,
16    pub cam_pos: [f32; 3],
17    pub _pad0: f32,
18    pub cam_forward: [f32; 3],
19    pub _pad1: f32,
20    pub cam_right: [f32; 3],
21    pub _pad2: f32,
22    pub cam_up: [f32; 3],
23    pub fov_tan: f32,
24    pub num_primitives: u32,
25    pub seed_offset: u32,
26    pub source_intensity: f32,
27    pub source_radius: f32,
28    pub source_pos: [f32; 3],
29    pub _pad3: f32,
30    pub lvk_c_steps: u32,
31    pub lvk_g_steps: u32,
32    pub lvk_g_max: f32,
33    pub lvk_max_intensity: f32,
34}
35
36/// Result from a camera render — RGB pixels.
37pub struct CameraImage {
38    pub width: u32,
39    pub height: u32,
40    pub pixels: Vec<[f32; 3]>, // linear RGB, HDR
41}
42
43impl CameraImage {
44    /// Apply edge-preserving denoise (bilateral filter).
45    /// `strength` controls the spatial radius (3-7 recommended).
46    pub fn denoise(&mut self, strength: u32) {
47        let radius = strength.min(10) as i32;
48        let sigma_space = radius as f32 * 0.5;
49        let sigma_color = 0.15f32; // how different colors can be before edge is detected
50        let src = self.pixels.clone();
51        let w = self.width as i32;
52        let h = self.height as i32;
53
54        for y in 0..h {
55            for x in 0..w {
56                let idx = (y * w + x) as usize;
57                let center = src[idx];
58                let mut sum = [0.0f32; 3];
59                let mut weight_sum = 0.0f32;
60
61                for dy in -radius..=radius {
62                    for dx in -radius..=radius {
63                        let nx = x + dx;
64                        let ny = y + dy;
65                        if nx < 0 || nx >= w || ny < 0 || ny >= h {
66                            continue;
67                        }
68
69                        let ni = (ny * w + nx) as usize;
70                        let neighbor = src[ni];
71
72                        // Spatial weight (Gaussian)
73                        let dist2 = (dx * dx + dy * dy) as f32;
74                        let w_space = (-dist2 / (2.0 * sigma_space * sigma_space)).exp();
75
76                        // Color weight (edge-preserving)
77                        let cdiff = (center[0] - neighbor[0]).powi(2)
78                            + (center[1] - neighbor[1]).powi(2)
79                            + (center[2] - neighbor[2]).powi(2);
80                        let w_color = (-cdiff / (2.0 * sigma_color * sigma_color)).exp();
81
82                        let w = w_space * w_color;
83                        sum[0] += neighbor[0] * w;
84                        sum[1] += neighbor[1] * w;
85                        sum[2] += neighbor[2] * w;
86                        weight_sum += w;
87                    }
88                }
89
90                if weight_sum > 0.0 {
91                    self.pixels[idx] = [
92                        sum[0] / weight_sum,
93                        sum[1] / weight_sum,
94                        sum[2] / weight_sum,
95                    ];
96                }
97            }
98        }
99    }
100
101    /// Convert to 8-bit sRGB bytes (for saving as PNG/BMP).
102    pub fn to_srgb_bytes(&self) -> Vec<u8> {
103        self.to_srgb_bytes_with_exposure(1.0)
104    }
105
106    /// Convert with exposure adjustment (1.0 = default, 2.0 = brighter).
107    pub fn to_srgb_bytes_with_exposure(&self, exposure: f32) -> Vec<u8> {
108        let mut bytes = Vec::with_capacity(self.width as usize * self.height as usize * 4);
109        for pixel in &self.pixels {
110            let exposed = [
111                pixel[0] * exposure,
112                pixel[1] * exposure,
113                pixel[2] * exposure,
114            ];
115            // ACES filmic tone mapping (more natural than Reinhard)
116            let mapped = [
117                aces_tonemap(exposed[0]),
118                aces_tonemap(exposed[1]),
119                aces_tonemap(exposed[2]),
120            ];
121            // Linear to sRGB gamma
122            for c in &mapped {
123                let srgb = if *c <= 0.0031308 {
124                    c * 12.92
125                } else {
126                    1.055 * c.powf(1.0 / 2.4) - 0.055
127                };
128                bytes.push((srgb.clamp(0.0, 1.0) * 255.0) as u8);
129            }
130            bytes.push(255);
131        }
132        bytes
133    }
134}
135
136/// ACES filmic tone mapping curve.
137fn aces_tonemap(x: f32) -> f32 {
138    let a = 2.51;
139    let b = 0.03;
140    let c = 2.43;
141    let d = 0.59;
142    let e = 0.14;
143    ((x * (a * x + b)) / (x * (c * x + d) + e)).clamp(0.0, 1.0)
144}
145
146/// GPU camera renderer — uses the same device as GpuTracer.
147pub struct GpuCamera {
148    device: wgpu::Device,
149    queue: wgpu::Queue,
150    pipeline: wgpu::ComputePipeline,
151    bind_group_layout: wgpu::BindGroupLayout,
152}
153
154impl GpuCamera {
155    /// Create a new GPU camera renderer.
156    pub async fn new() -> Result<Self, String> {
157        let instance = wgpu::Instance::default();
158        let adapter = instance
159            .request_adapter(&wgpu::RequestAdapterOptions {
160                power_preference: wgpu::PowerPreference::HighPerformance,
161                ..default()
162            })
163            .await
164            .map_err(|e| format!("No GPU: {e}"))?;
165
166        let (device, queue) = adapter
167            .request_device(&wgpu::DeviceDescriptor {
168                label: Some("eulumdat-rt-camera"),
169                required_features: wgpu::Features::empty(),
170                required_limits: wgpu::Limits::default(),
171                ..Default::default()
172            })
173            .await
174            .map_err(|e| format!("Device: {e}"))?;
175
176        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
177            label: Some("camera.wgsl"),
178            source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("shaders/camera.wgsl"))),
179        });
180
181        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
182            label: Some("camera_bgl"),
183            entries: &[
184                // pixels (read_write storage)
185                wgpu::BindGroupLayoutEntry {
186                    binding: 0,
187                    visibility: wgpu::ShaderStages::COMPUTE,
188                    ty: wgpu::BindingType::Buffer {
189                        ty: wgpu::BufferBindingType::Storage { read_only: false },
190                        has_dynamic_offset: false,
191                        min_binding_size: None,
192                    },
193                    count: None,
194                },
195                // config (uniform)
196                wgpu::BindGroupLayoutEntry {
197                    binding: 1,
198                    visibility: wgpu::ShaderStages::COMPUTE,
199                    ty: wgpu::BindingType::Buffer {
200                        ty: wgpu::BufferBindingType::Uniform,
201                        has_dynamic_offset: false,
202                        min_binding_size: None,
203                    },
204                    count: None,
205                },
206                // primitives (read storage)
207                wgpu::BindGroupLayoutEntry {
208                    binding: 2,
209                    visibility: wgpu::ShaderStages::COMPUTE,
210                    ty: wgpu::BindingType::Buffer {
211                        ty: wgpu::BufferBindingType::Storage { read_only: true },
212                        has_dynamic_offset: false,
213                        min_binding_size: None,
214                    },
215                    count: None,
216                },
217                // materials (read storage)
218                wgpu::BindGroupLayoutEntry {
219                    binding: 3,
220                    visibility: wgpu::ShaderStages::COMPUTE,
221                    ty: wgpu::BindingType::Buffer {
222                        ty: wgpu::BufferBindingType::Storage { read_only: true },
223                        has_dynamic_offset: false,
224                        min_binding_size: None,
225                    },
226                    count: None,
227                },
228                // lvk_data (read storage — light emission pattern)
229                wgpu::BindGroupLayoutEntry {
230                    binding: 4,
231                    visibility: wgpu::ShaderStages::COMPUTE,
232                    ty: wgpu::BindingType::Buffer {
233                        ty: wgpu::BufferBindingType::Storage { read_only: true },
234                        has_dynamic_offset: false,
235                        min_binding_size: None,
236                    },
237                    count: None,
238                },
239            ],
240        });
241
242        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
243            label: Some("camera_pl"),
244            bind_group_layouts: &[Some(&bind_group_layout)],
245            immediate_size: 0,
246        });
247
248        let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
249            label: Some("camera_pipeline"),
250            layout: Some(&pipeline_layout),
251            module: &shader,
252            entry_point: Some("trace_camera"),
253            compilation_options: Default::default(),
254            cache: None,
255        });
256
257        Ok(Self {
258            device,
259            queue,
260            pipeline,
261            bind_group_layout,
262        })
263    }
264
265    /// Render an image of the scene.
266    ///
267    /// `lvk_data`: optional flat array of intensity values [c0g0, c0g1, ..., c1g0, ...] for
268    /// LDT-based light emission. If empty, uses uniform emission.
269    #[allow(clippy::too_many_arguments)]
270    pub async fn render(
271        &self,
272        width: u32,
273        height: u32,
274        samples_per_pixel: u32,
275        camera_pos: [f32; 3],
276        look_at: [f32; 3],
277        fov_degrees: f32,
278        primitives: &[GpuPrimitive],
279        materials: &[GpuMaterial],
280        source_intensity: f32,
281        source_pos: [f32; 3],
282    ) -> CameraImage {
283        self.render_with_lvk(
284            width,
285            height,
286            samples_per_pixel,
287            camera_pos,
288            look_at,
289            fov_degrees,
290            primitives,
291            materials,
292            source_intensity,
293            source_pos,
294            &[],
295            0,
296            0,
297            0.0,
298            0.0,
299        )
300        .await
301    }
302
303    /// Render with LDT-based light emission pattern.
304    #[allow(clippy::too_many_arguments)]
305    pub async fn render_with_lvk(
306        &self,
307        width: u32,
308        height: u32,
309        samples_per_pixel: u32,
310        camera_pos: [f32; 3],
311        look_at: [f32; 3],
312        fov_degrees: f32,
313        primitives: &[GpuPrimitive],
314        materials: &[GpuMaterial],
315        source_intensity: f32,
316        source_pos: [f32; 3],
317        lvk_data: &[f32],
318        lvk_c_steps: u32,
319        lvk_g_steps: u32,
320        lvk_g_max: f32,
321        lvk_max_intensity: f32,
322    ) -> CameraImage {
323        // Compute camera basis vectors
324        let pos = glam::Vec3::from(camera_pos);
325        let target = glam::Vec3::from(look_at);
326        let forward = (target - pos).normalize();
327        let world_up = glam::Vec3::Y;
328        let right = forward.cross(world_up).normalize();
329        let up = right.cross(forward).normalize();
330
331        let config = CameraConfig {
332            width,
333            height,
334            samples_per_pixel,
335            max_bounces: 8,
336            cam_pos: camera_pos,
337            _pad0: 0.0,
338            cam_forward: forward.to_array(),
339            _pad1: 0.0,
340            cam_right: right.to_array(),
341            _pad2: 0.0,
342            cam_up: up.to_array(),
343            fov_tan: (fov_degrees.to_radians() / 2.0).tan(),
344            num_primitives: primitives.len() as u32,
345            seed_offset: 42,
346            source_intensity,
347            source_radius: 0.02,
348            source_pos,
349            _pad3: 0.0,
350            lvk_c_steps,
351            lvk_g_steps,
352            lvk_g_max,
353            lvk_max_intensity,
354        };
355
356        let total_pixels = width * height;
357        // 4 u32 per pixel: R, G, B, sample_count
358        let pixel_buffer_size = (total_pixels * 4) as u64 * 4;
359
360        let config_buf = self
361            .device
362            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
363                label: Some("cam_config"),
364                contents: bytemuck::bytes_of(&config),
365                usage: wgpu::BufferUsages::UNIFORM,
366            });
367
368        let pixel_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
369            label: Some("pixel_buf"),
370            size: pixel_buffer_size,
371            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
372            mapped_at_creation: false,
373        });
374
375        let readback_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
376            label: Some("readback_buf"),
377            size: pixel_buffer_size,
378            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
379            mapped_at_creation: false,
380        });
381
382        // Dummy buffers if no geometry
383        let dummy_prim = GpuPrimitive {
384            ptype: 0,
385            material_id: 0,
386            _pad0: 0,
387            _pad1: 0,
388            params: [0.0; 12],
389        };
390        let dummy_mat = GpuMaterial {
391            mtype: 0,
392            _pad0: 0,
393            _pad1: 0,
394            _pad2: 0,
395            reflectance: 0.0,
396            ior: 1.0,
397            transmittance: 0.0,
398            min_reflectance: 0.0,
399            absorption_coeff: 0.0,
400            scattering_coeff: 0.0,
401            asymmetry: 0.0,
402            thickness: 0.0,
403        };
404
405        let prim_data: Vec<GpuPrimitive> = if primitives.is_empty() {
406            vec![dummy_prim]
407        } else {
408            primitives.to_vec()
409        };
410        let mat_data: Vec<GpuMaterial> = if materials.is_empty() {
411            vec![dummy_mat]
412        } else {
413            materials.to_vec()
414        };
415
416        let prim_buf = self
417            .device
418            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
419                label: Some("cam_prims"),
420                contents: bytemuck::cast_slice(&prim_data),
421                usage: wgpu::BufferUsages::STORAGE,
422            });
423        let mat_buf = self
424            .device
425            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
426                label: Some("cam_mats"),
427                contents: bytemuck::cast_slice(&mat_data),
428                usage: wgpu::BufferUsages::STORAGE,
429            });
430
431        // LVK buffer
432        let lvk_buf_data: Vec<f32> = if lvk_data.is_empty() {
433            vec![1.0]
434        } else {
435            lvk_data.to_vec()
436        };
437        let lvk_buf = self
438            .device
439            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
440                label: Some("cam_lvk"),
441                contents: bytemuck::cast_slice(&lvk_buf_data),
442                usage: wgpu::BufferUsages::STORAGE,
443            });
444
445        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
446            label: Some("cam_bg"),
447            layout: &self.bind_group_layout,
448            entries: &[
449                wgpu::BindGroupEntry {
450                    binding: 0,
451                    resource: pixel_buf.as_entire_binding(),
452                },
453                wgpu::BindGroupEntry {
454                    binding: 1,
455                    resource: config_buf.as_entire_binding(),
456                },
457                wgpu::BindGroupEntry {
458                    binding: 2,
459                    resource: prim_buf.as_entire_binding(),
460                },
461                wgpu::BindGroupEntry {
462                    binding: 3,
463                    resource: mat_buf.as_entire_binding(),
464                },
465                wgpu::BindGroupEntry {
466                    binding: 4,
467                    resource: lvk_buf.as_entire_binding(),
468                },
469            ],
470        });
471
472        // Dispatch: 16x16 workgroups covering the image
473        let wg_x = width.div_ceil(16);
474        let wg_y = height.div_ceil(16);
475
476        let mut encoder = self
477            .device
478            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
479                label: Some("cam_encoder"),
480            });
481
482        {
483            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
484                label: Some("cam_pass"),
485                timestamp_writes: None,
486            });
487            pass.set_pipeline(&self.pipeline);
488            pass.set_bind_group(0, &bind_group, &[]);
489            pass.dispatch_workgroups(wg_x, wg_y, 1);
490        }
491
492        encoder.copy_buffer_to_buffer(&pixel_buf, 0, &readback_buf, 0, pixel_buffer_size);
493        self.queue.submit(Some(encoder.finish()));
494
495        // Readback
496        let slice = readback_buf.slice(..);
497        let (tx, rx) = flume::bounded(1);
498        slice.map_async(wgpu::MapMode::Read, move |r| {
499            tx.send(r).unwrap();
500        });
501        self.device.poll(wgpu::PollType::wait_indefinitely()).ok();
502        rx.recv_async().await.unwrap().unwrap();
503
504        let data = slice.get_mapped_range();
505        let raw: &[u32] = bytemuck::cast_slice(&data);
506
507        let mut pixels = vec![[0.0f32; 3]; total_pixels as usize];
508        for i in 0..total_pixels as usize {
509            let r = raw[i * 4] as f32 / 1000.0;
510            let g = raw[i * 4 + 1] as f32 / 1000.0;
511            let b = raw[i * 4 + 2] as f32 / 1000.0;
512            let count = raw[i * 4 + 3].max(1) as f32;
513            pixels[i] = [r / count, g / count, b / count];
514        }
515
516        drop(data);
517        readback_buf.unmap();
518
519        CameraImage {
520            width,
521            height,
522            pixels,
523        }
524    }
525}
526
527fn default() -> wgpu::RequestAdapterOptions<'static, 'static> {
528    wgpu::RequestAdapterOptions::default()
529}