Skip to main content

arcane_engine/renderer/
radiance.rs

1//! Radiance Cascades 2D Global Illumination
2//!
3//! Implements Alexander Sannikov's Radiance Cascades algorithm using wgpu compute shaders.
4//! Provides real-time 2D GI with emissive surfaces, occluders, and light propagation.
5//!
6//! Architecture:
7//! 1. Scene pass: CPU writes emissive/occluder data to a scene texture
8//! 2. Ray-march pass (per cascade): probes cast rays through the scene
9//! 3. Merge passes: upper cascades merge into lower (propagates far-field radiance)
10//! 4. Finalize pass: cascade 0 probes sum rays, produces light texture
11//! 5. Composition: sprite shader reads light texture to modulate output
12
13use bytemuck::{Pod, Zeroable};
14use wgpu::util::DeviceExt;
15
16use super::gpu::GpuContext;
17use super::lighting::LightingState;
18
19/// Default base ray count for cascade 0 (4 rays = 2x2 block per probe).
20const DEFAULT_BASE_RAYS: u32 = 4;
21
22/// Default probe spacing in pixels for cascade 0.
23const DEFAULT_PROBE_SPACING: f32 = 8.0;
24
25/// Default ray march interval length in pixels.
26const DEFAULT_INTERVAL: f32 = 4.0;
27
28/// Maximum cascade levels.
29const MAX_CASCADES: usize = 5;
30
31/// GPU uniform data for radiance cascade compute passes.
32#[repr(C)]
33#[derive(Copy, Clone, Pod, Zeroable)]
34struct RadianceParams {
35    /// [scene_width, scene_height, cascade_index, cascade_count]
36    scene_dims: [f32; 4],
37    /// [probe_spacing, ray_count, interval_length, gi_intensity]
38    cascade_params: [f32; 4],
39    /// [camera_x, camera_y, viewport_w, viewport_h]
40    camera: [f32; 4],
41    /// [ambient_r, ambient_g, ambient_b, _pad]
42    ambient: [f32; 4],
43}
44
45/// An emissive surface that radiates light.
46#[derive(Clone, Debug)]
47pub struct EmissiveSurface {
48    pub x: f32,
49    pub y: f32,
50    pub width: f32,
51    pub height: f32,
52    pub r: f32,
53    pub g: f32,
54    pub b: f32,
55    pub intensity: f32,
56}
57
58/// A rectangular occluder that blocks light.
59#[derive(Clone, Debug)]
60pub struct Occluder {
61    pub x: f32,
62    pub y: f32,
63    pub width: f32,
64    pub height: f32,
65}
66
67/// A directional light (infinite distance, parallel rays).
68#[derive(Clone, Debug)]
69pub struct DirectionalLight {
70    pub angle: f32,
71    pub r: f32,
72    pub g: f32,
73    pub b: f32,
74    pub intensity: f32,
75}
76
77/// A spot light with position, direction, and spread.
78#[derive(Clone, Debug)]
79pub struct SpotLight {
80    pub x: f32,
81    pub y: f32,
82    pub angle: f32,
83    pub spread: f32,
84    pub range: f32,
85    pub r: f32,
86    pub g: f32,
87    pub b: f32,
88    pub intensity: f32,
89}
90
91/// Radiance state gathered from TypeScript each frame.
92#[derive(Clone, Debug)]
93pub struct RadianceState {
94    pub enabled: bool,
95    pub emissives: Vec<EmissiveSurface>,
96    pub occluders: Vec<Occluder>,
97    pub directional_lights: Vec<DirectionalLight>,
98    pub spot_lights: Vec<SpotLight>,
99    pub gi_intensity: f32,
100    /// Probe spacing in pixels (smaller = smoother but slower). Default: 8.
101    pub probe_spacing: Option<f32>,
102    /// Ray march interval length in pixels. Default: 4.
103    pub interval: Option<f32>,
104    /// Number of cascade levels (more = longer light reach). Default: 4.
105    pub cascade_count: Option<u32>,
106}
107
108impl Default for RadianceState {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl RadianceState {
115    pub fn new() -> Self {
116        Self {
117            enabled: false,
118            emissives: Vec::new(),
119            occluders: Vec::new(),
120            directional_lights: Vec::new(),
121            spot_lights: Vec::new(),
122            gi_intensity: 1.0,
123            probe_spacing: None,
124            interval: None,
125            cascade_count: None,
126        }
127    }
128}
129
130/// The radiance cascade compute pipeline.
131pub struct RadiancePipeline {
132    // Compute pipelines
133    ray_march_pipeline: wgpu::ComputePipeline,
134    merge_pipeline: wgpu::ComputePipeline,
135    finalize_pipeline: wgpu::ComputePipeline,
136
137    // Composition render pipeline (fullscreen quad that multiplies light texture onto sprites)
138    compose_pipeline: wgpu::RenderPipeline,
139    compose_bind_group_layout: wgpu::BindGroupLayout,
140
141    // Bind group layout (shared across passes)
142    compute_bind_group_layout: wgpu::BindGroupLayout,
143
144    // Uniform buffer
145    params_buffer: wgpu::Buffer,
146
147    // Scene texture: emissive (RGB) + occluder (A)
148    scene_texture: Option<SceneTexture>,
149
150    // Cascade textures (ping-pong pair for merge)
151    cascade_textures: Option<CascadeTextures>,
152
153    // Light output texture (scene resolution)
154    light_texture: Option<LightTexture>,
155
156    // Configuration
157    pub base_rays: u32,
158    pub probe_spacing: f32,
159    pub interval: f32,
160    pub cascade_count: u32,
161
162    // Sampler for the composition pass
163    sampler: wgpu::Sampler,
164    #[allow(dead_code)]
165    surface_format: wgpu::TextureFormat,
166}
167
168struct SceneTexture {
169    texture: wgpu::Texture,
170    view: wgpu::TextureView,
171    width: u32,
172    height: u32,
173}
174
175struct CascadeTextures {
176    // Two textures for ping-pong during merge (kept alive for GPU references)
177    #[allow(dead_code)]
178    tex_a: wgpu::Texture,
179    view_a: wgpu::TextureView,
180    #[allow(dead_code)]
181    tex_b: wgpu::Texture,
182    view_b: wgpu::TextureView,
183    width: u32,
184    height: u32,
185}
186
187struct LightTexture {
188    #[allow(dead_code)]
189    texture: wgpu::Texture,
190    view: wgpu::TextureView,
191    bind_group: wgpu::BindGroup,
192    #[allow(dead_code)]
193    width: u32,
194    #[allow(dead_code)]
195    height: u32,
196}
197
198impl RadiancePipeline {
199    pub fn new(gpu: &GpuContext) -> Self {
200        let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
201            label: Some("radiance_compute_shader"),
202            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/radiance.wgsl").into()),
203        });
204
205        // Bind group layout for compute passes
206        let compute_bind_group_layout =
207            gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
208                label: Some("radiance_compute_bind_group_layout"),
209                entries: &[
210                    // binding 0: uniform params
211                    wgpu::BindGroupLayoutEntry {
212                        binding: 0,
213                        visibility: wgpu::ShaderStages::COMPUTE,
214                        ty: wgpu::BindingType::Buffer {
215                            ty: wgpu::BufferBindingType::Uniform,
216                            has_dynamic_offset: false,
217                            min_binding_size: None,
218                        },
219                        count: None,
220                    },
221                    // binding 1: scene texture (read)
222                    wgpu::BindGroupLayoutEntry {
223                        binding: 1,
224                        visibility: wgpu::ShaderStages::COMPUTE,
225                        ty: wgpu::BindingType::Texture {
226                            multisampled: false,
227                            view_dimension: wgpu::TextureViewDimension::D2,
228                            sample_type: wgpu::TextureSampleType::Float { filterable: false },
229                        },
230                        count: None,
231                    },
232                    // binding 2: cascade input texture (read)
233                    wgpu::BindGroupLayoutEntry {
234                        binding: 2,
235                        visibility: wgpu::ShaderStages::COMPUTE,
236                        ty: wgpu::BindingType::Texture {
237                            multisampled: false,
238                            view_dimension: wgpu::TextureViewDimension::D2,
239                            sample_type: wgpu::TextureSampleType::Float { filterable: false },
240                        },
241                        count: None,
242                    },
243                    // binding 3: cascade output (storage write)
244                    wgpu::BindGroupLayoutEntry {
245                        binding: 3,
246                        visibility: wgpu::ShaderStages::COMPUTE,
247                        ty: wgpu::BindingType::StorageTexture {
248                            access: wgpu::StorageTextureAccess::WriteOnly,
249                            format: wgpu::TextureFormat::Rgba16Float,
250                            view_dimension: wgpu::TextureViewDimension::D2,
251                        },
252                        count: None,
253                    },
254                ],
255            });
256
257        let compute_layout =
258            gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
259                label: Some("radiance_compute_layout"),
260                bind_group_layouts: &[&compute_bind_group_layout],
261                push_constant_ranges: &[],
262            });
263
264        let ray_march_pipeline =
265            gpu.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
266                label: Some("radiance_ray_march"),
267                layout: Some(&compute_layout),
268                module: &shader,
269                entry_point: Some("ray_march"),
270                compilation_options: Default::default(),
271                cache: None,
272            });
273
274        let merge_pipeline =
275            gpu.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
276                label: Some("radiance_merge"),
277                layout: Some(&compute_layout),
278                module: &shader,
279                entry_point: Some("merge_cascades"),
280                compilation_options: Default::default(),
281                cache: None,
282            });
283
284        let finalize_pipeline =
285            gpu.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
286                label: Some("radiance_finalize"),
287                layout: Some(&compute_layout),
288                module: &shader,
289                entry_point: Some("finalize"),
290                compilation_options: Default::default(),
291                cache: None,
292            });
293
294        // Params uniform buffer
295        let params_buffer = gpu.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
296            label: Some("radiance_params_buffer"),
297            contents: bytemuck::cast_slice(&[RadianceParams {
298                scene_dims: [0.0; 4],
299                cascade_params: [0.0; 4],
300                camera: [0.0; 4],
301                ambient: [1.0, 1.0, 1.0, 0.0],
302            }]),
303            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
304        });
305
306        // Composition pass: renders light texture over the sprite output
307        let compose_bind_group_layout =
308            gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
309                label: Some("radiance_compose_bind_group_layout"),
310                entries: &[
311                    wgpu::BindGroupLayoutEntry {
312                        binding: 0,
313                        visibility: wgpu::ShaderStages::FRAGMENT,
314                        ty: wgpu::BindingType::Texture {
315                            multisampled: false,
316                            view_dimension: wgpu::TextureViewDimension::D2,
317                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
318                        },
319                        count: None,
320                    },
321                    wgpu::BindGroupLayoutEntry {
322                        binding: 1,
323                        visibility: wgpu::ShaderStages::FRAGMENT,
324                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
325                        count: None,
326                    },
327                ],
328            });
329
330        let compose_layout =
331            gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
332                label: Some("radiance_compose_layout"),
333                bind_group_layouts: &[&compose_bind_group_layout],
334                push_constant_ranges: &[],
335            });
336
337        let compose_shader =
338            gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor {
339                label: Some("radiance_compose_shader"),
340                source: wgpu::ShaderSource::Wgsl(COMPOSE_WGSL.into()),
341            });
342
343        let compose_pipeline =
344            gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
345                label: Some("radiance_compose_pipeline"),
346                layout: Some(&compose_layout),
347                vertex: wgpu::VertexState {
348                    module: &compose_shader,
349                    entry_point: Some("vs_main"),
350                    buffers: &[],
351                    compilation_options: Default::default(),
352                },
353                fragment: Some(wgpu::FragmentState {
354                    module: &compose_shader,
355                    entry_point: Some("fs_main"),
356                    targets: &[Some(wgpu::ColorTargetState {
357                        format: gpu.config.format,
358                        blend: Some(wgpu::BlendState {
359                            // Additive: result = src * dst + dst * 1
360                            // = dst * (1 + src) — GI adds light without darkening
361                            color: wgpu::BlendComponent {
362                                src_factor: wgpu::BlendFactor::Dst,
363                                dst_factor: wgpu::BlendFactor::One,
364                                operation: wgpu::BlendOperation::Add,
365                            },
366                            alpha: wgpu::BlendComponent::OVER,
367                        }),
368                        write_mask: wgpu::ColorWrites::ALL,
369                    })],
370                    compilation_options: Default::default(),
371                }),
372                primitive: wgpu::PrimitiveState {
373                    topology: wgpu::PrimitiveTopology::TriangleList,
374                    ..Default::default()
375                },
376                depth_stencil: None,
377                multisample: wgpu::MultisampleState::default(),
378                multiview: None,
379                cache: None,
380            });
381
382        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
383            label: Some("radiance_sampler"),
384            address_mode_u: wgpu::AddressMode::ClampToEdge,
385            address_mode_v: wgpu::AddressMode::ClampToEdge,
386            mag_filter: wgpu::FilterMode::Linear,
387            min_filter: wgpu::FilterMode::Linear,
388            ..Default::default()
389        });
390
391        Self {
392            ray_march_pipeline,
393            merge_pipeline,
394            finalize_pipeline,
395            compose_pipeline,
396            compose_bind_group_layout,
397            compute_bind_group_layout,
398            params_buffer,
399            scene_texture: None,
400            cascade_textures: None,
401            light_texture: None,
402            base_rays: DEFAULT_BASE_RAYS,
403            probe_spacing: DEFAULT_PROBE_SPACING,
404            interval: DEFAULT_INTERVAL,
405            cascade_count: 4,
406            sampler,
407            surface_format: gpu.config.format,
408        }
409    }
410
411    /// Ensure textures exist and match the given scene dimensions.
412    fn ensure_textures(&mut self, gpu: &GpuContext, scene_w: u32, scene_h: u32) {
413        let needs_recreate = self
414            .scene_texture
415            .as_ref()
416            .map(|t| t.width != scene_w || t.height != scene_h)
417            .unwrap_or(true);
418
419        if !needs_recreate {
420            return;
421        }
422
423        // Scene texture
424        let scene_tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
425            label: Some("radiance_scene_texture"),
426            size: wgpu::Extent3d {
427                width: scene_w,
428                height: scene_h,
429                depth_or_array_layers: 1,
430            },
431            mip_level_count: 1,
432            sample_count: 1,
433            dimension: wgpu::TextureDimension::D2,
434            format: wgpu::TextureFormat::Rgba32Float,
435            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
436            view_formats: &[],
437        });
438
439        let scene_view = scene_tex.create_view(&wgpu::TextureViewDescriptor::default());
440        self.scene_texture = Some(SceneTexture {
441            texture: scene_tex,
442            view: scene_view,
443            width: scene_w,
444            height: scene_h,
445        });
446
447        // Cascade textures: size determined by probe grid and ray block layout
448        // For cascade 0: probes_x * rays_per_side x probes_y * rays_per_side
449        // All cascades use the same total memory, but the largest cascade texture size
450        // is determined by cascade 0 (most probes, fewest rays).
451        let probes_x = (scene_w as f32 / self.probe_spacing).ceil() as u32;
452        let probes_y = (scene_h as f32 / self.probe_spacing).ceil() as u32;
453        let rays_per_side = (self.base_rays as f32).sqrt().ceil() as u32;
454        let cascade_w = probes_x * rays_per_side;
455        let cascade_h = probes_y * rays_per_side;
456
457        // Ensure minimum size
458        let cascade_w = cascade_w.max(1);
459        let cascade_h = cascade_h.max(1);
460
461        let create_cascade_tex = |label: &str| -> (wgpu::Texture, wgpu::TextureView) {
462            let tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
463                label: Some(label),
464                size: wgpu::Extent3d {
465                    width: cascade_w,
466                    height: cascade_h,
467                    depth_or_array_layers: 1,
468                },
469                mip_level_count: 1,
470                sample_count: 1,
471                dimension: wgpu::TextureDimension::D2,
472                format: wgpu::TextureFormat::Rgba16Float,
473                usage: wgpu::TextureUsages::TEXTURE_BINDING
474                    | wgpu::TextureUsages::STORAGE_BINDING,
475                view_formats: &[],
476            });
477            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
478            (tex, view)
479        };
480
481        let (tex_a, view_a) = create_cascade_tex("radiance_cascade_a");
482        let (tex_b, view_b) = create_cascade_tex("radiance_cascade_b");
483
484        self.cascade_textures = Some(CascadeTextures {
485            tex_a,
486            view_a,
487            tex_b,
488            view_b,
489            width: cascade_w,
490            height: cascade_h,
491        });
492
493        // Light texture: scene resolution
494        let light_tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
495            label: Some("radiance_light_texture"),
496            size: wgpu::Extent3d {
497                width: scene_w,
498                height: scene_h,
499                depth_or_array_layers: 1,
500            },
501            mip_level_count: 1,
502            sample_count: 1,
503            dimension: wgpu::TextureDimension::D2,
504            format: wgpu::TextureFormat::Rgba16Float,
505            usage: wgpu::TextureUsages::TEXTURE_BINDING
506                | wgpu::TextureUsages::STORAGE_BINDING,
507            view_formats: &[],
508        });
509
510        let light_view = light_tex.create_view(&wgpu::TextureViewDescriptor::default());
511
512        let light_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
513            label: Some("radiance_light_bind_group"),
514            layout: &self.compose_bind_group_layout,
515            entries: &[
516                wgpu::BindGroupEntry {
517                    binding: 0,
518                    resource: wgpu::BindingResource::TextureView(&light_view),
519                },
520                wgpu::BindGroupEntry {
521                    binding: 1,
522                    resource: wgpu::BindingResource::Sampler(&self.sampler),
523                },
524            ],
525        });
526
527        self.light_texture = Some(LightTexture {
528            texture: light_tex,
529            view: light_view,
530            bind_group: light_bind_group,
531            width: scene_w,
532            height: scene_h,
533        });
534    }
535
536    /// Build the scene texture data from emissives, occluders, point lights,
537    /// directional lights, and spot lights.
538    fn build_scene_data(
539        &self,
540        scene_w: u32,
541        scene_h: u32,
542        radiance: &RadianceState,
543        lighting: &LightingState,
544        camera_x: f32,
545        camera_y: f32,
546        viewport_w: f32,
547        viewport_h: f32,
548    ) -> Vec<u8> {
549        let w = scene_w as usize;
550        let h = scene_h as usize;
551        // Rgba32Float: 4 channels × 4 bytes = 16 bytes per pixel
552        let mut pixels = vec![0.0f32; w * h * 4];
553
554        // World-space origin for the scene texture (camera-centered)
555        let world_left = camera_x - viewport_w / 2.0;
556        let world_top = camera_y - viewport_h / 2.0;
557
558        // Rasterize emissive surfaces (HDR — intensity is preserved)
559        for em in &radiance.emissives {
560            let px0 = ((em.x - world_left) as i32).max(0) as usize;
561            let py0 = ((em.y - world_top) as i32).max(0) as usize;
562            let px1 = ((em.x + em.width - world_left) as i32).min(w as i32) as usize;
563            let py1 = ((em.y + em.height - world_top) as i32).min(h as i32) as usize;
564
565            let er = em.r * em.intensity;
566            let eg = em.g * em.intensity;
567            let eb = em.b * em.intensity;
568
569            for py in py0..py1 {
570                for px in px0..px1 {
571                    let idx = (py * w + px) * 4;
572                    pixels[idx] += er;
573                    pixels[idx + 1] += eg;
574                    pixels[idx + 2] += eb;
575                }
576            }
577        }
578
579        // Rasterize point lights as emissive circles
580        for light in &lighting.lights {
581            let cx = (light.x - world_left) as i32;
582            let cy = (light.y - world_top) as i32;
583            let r_px = (light.radius * 0.1).max(2.0) as i32;
584
585            let er = light.r * light.intensity;
586            let eg = light.g * light.intensity;
587            let eb = light.b * light.intensity;
588
589            for dy in -r_px..=r_px {
590                for dx in -r_px..=r_px {
591                    if dx * dx + dy * dy <= r_px * r_px {
592                        let px = (cx + dx) as usize;
593                        let py = (cy + dy) as usize;
594                        if px < w && py < h {
595                            let idx = (py * w + px) * 4;
596                            pixels[idx] += er;
597                            pixels[idx + 1] += eg;
598                            pixels[idx + 2] += eb;
599                        }
600                    }
601                }
602            }
603        }
604
605        // Rasterize spot lights as emissive spots
606        for spot in &radiance.spot_lights {
607            let cx = (spot.x - world_left) as i32;
608            let cy = (spot.y - world_top) as i32;
609            let r_px = 3i32;
610
611            let er = spot.r * spot.intensity;
612            let eg = spot.g * spot.intensity;
613            let eb = spot.b * spot.intensity;
614
615            for dy in -r_px..=r_px {
616                for dx in -r_px..=r_px {
617                    if dx * dx + dy * dy <= r_px * r_px {
618                        let px = (cx + dx) as usize;
619                        let py = (cy + dy) as usize;
620                        if px < w && py < h {
621                            let idx = (py * w + px) * 4;
622                            pixels[idx] += er;
623                            pixels[idx + 1] += eg;
624                            pixels[idx + 2] += eb;
625                        }
626                    }
627                }
628            }
629        }
630
631        // Rasterize occluders (alpha = 1.0)
632        for occ in &radiance.occluders {
633            let px0 = ((occ.x - world_left) as i32).max(0) as usize;
634            let py0 = ((occ.y - world_top) as i32).max(0) as usize;
635            let px1 = ((occ.x + occ.width - world_left) as i32).min(w as i32) as usize;
636            let py1 = ((occ.y + occ.height - world_top) as i32).min(h as i32) as usize;
637
638            for py in py0..py1 {
639                for px in px0..px1 {
640                    let idx = (py * w + px) * 4;
641                    pixels[idx + 3] = 1.0; // occluder flag
642                }
643            }
644        }
645
646        // Return as raw bytes (f32 → bytemuck cast)
647        bytemuck::cast_slice(&pixels).to_vec()
648    }
649
650    /// Execute the full radiance cascade pipeline for one frame.
651    /// Returns true if the light texture was computed and the compose pass should run.
652    pub fn compute(
653        &mut self,
654        gpu: &GpuContext,
655        encoder: &mut wgpu::CommandEncoder,
656        radiance: &RadianceState,
657        lighting: &LightingState,
658        camera_x: f32,
659        camera_y: f32,
660        viewport_w: f32,
661        viewport_h: f32,
662    ) -> bool {
663        if !radiance.enabled {
664            return false;
665        }
666
667        // Apply quality overrides from game code
668        if let Some(ps) = radiance.probe_spacing {
669            self.probe_spacing = ps;
670        }
671        if let Some(iv) = radiance.interval {
672            self.interval = iv;
673        }
674        if let Some(cc) = radiance.cascade_count {
675            self.cascade_count = cc;
676        }
677
678        // Scene resolution is the viewport size (in logical pixels)
679        let scene_w = viewport_w.ceil() as u32;
680        let scene_h = viewport_h.ceil() as u32;
681        if scene_w == 0 || scene_h == 0 {
682            return false;
683        }
684
685        self.ensure_textures(gpu, scene_w, scene_h);
686
687        let scene_tex = self.scene_texture.as_ref().unwrap();
688        let cascades = self.cascade_textures.as_ref().unwrap();
689        let light_tex = self.light_texture.as_ref().unwrap();
690
691        // Upload scene data to GPU
692        let scene_data = self.build_scene_data(
693            scene_w, scene_h, radiance, lighting, camera_x, camera_y, viewport_w, viewport_h,
694        );
695
696        gpu.queue.write_texture(
697            wgpu::TexelCopyTextureInfo {
698                texture: &scene_tex.texture,
699                mip_level: 0,
700                origin: wgpu::Origin3d::ZERO,
701                aspect: wgpu::TextureAspect::All,
702            },
703            &scene_data,
704            wgpu::TexelCopyBufferLayout {
705                offset: 0,
706                bytes_per_row: Some(scene_w * 16), // Rgba32Float: 16 bytes per pixel
707                rows_per_image: Some(scene_h),
708            },
709            wgpu::Extent3d {
710                width: scene_w,
711                height: scene_h,
712                depth_or_array_layers: 1,
713            },
714        );
715
716        let cascade_count = self.cascade_count.min(MAX_CASCADES as u32);
717
718        // === Pass 1: Ray-march each cascade (highest first) ===
719        // We write each cascade to tex_a using a dedicated bind group,
720        // then in merge pass we read from tex_a and write to tex_b.
721        for c in (0..cascade_count).rev() {
722            let params = RadianceParams {
723                scene_dims: [scene_w as f32, scene_h as f32, c as f32, cascade_count as f32],
724                cascade_params: [
725                    self.probe_spacing,
726                    self.base_rays as f32,
727                    self.interval,
728                    radiance.gi_intensity,
729                ],
730                camera: [camera_x, camera_y, viewport_w, viewport_h],
731                ambient: [
732                    lighting.ambient[0],
733                    lighting.ambient[1],
734                    lighting.ambient[2],
735                    0.0,
736                ],
737            };
738
739            gpu.queue.write_buffer(
740                &self.params_buffer,
741                0,
742                bytemuck::cast_slice(&[params]),
743            );
744
745            // For ray-march: write to tex_a (scene + empty cascade_in + tex_a as output)
746            let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
747                label: Some(&format!("radiance_ray_march_bg_{c}")),
748                layout: &self.compute_bind_group_layout,
749                entries: &[
750                    wgpu::BindGroupEntry {
751                        binding: 0,
752                        resource: self.params_buffer.as_entire_binding(),
753                    },
754                    wgpu::BindGroupEntry {
755                        binding: 1,
756                        resource: wgpu::BindingResource::TextureView(&scene_tex.view),
757                    },
758                    wgpu::BindGroupEntry {
759                        binding: 2,
760                        resource: wgpu::BindingResource::TextureView(&cascades.view_b),
761                    },
762                    wgpu::BindGroupEntry {
763                        binding: 3,
764                        resource: wgpu::BindingResource::TextureView(&cascades.view_a),
765                    },
766                ],
767            });
768
769            {
770                let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
771                    label: Some(&format!("radiance_ray_march_{c}")),
772                    timestamp_writes: None,
773                });
774                pass.set_pipeline(&self.ray_march_pipeline);
775                pass.set_bind_group(0, &bind_group, &[]);
776                pass.dispatch_workgroups(
777                    (cascades.width + 7) / 8,
778                    (cascades.height + 7) / 8,
779                    1,
780                );
781            }
782
783            // === Pass 2: Merge (if not the highest cascade) ===
784            if c < cascade_count - 1 {
785                // Read from tex_a (ray-marched), write to tex_b
786                let merge_bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
787                    label: Some(&format!("radiance_merge_bg_{c}")),
788                    layout: &self.compute_bind_group_layout,
789                    entries: &[
790                        wgpu::BindGroupEntry {
791                            binding: 0,
792                            resource: self.params_buffer.as_entire_binding(),
793                        },
794                        wgpu::BindGroupEntry {
795                            binding: 1,
796                            resource: wgpu::BindingResource::TextureView(&scene_tex.view),
797                        },
798                        wgpu::BindGroupEntry {
799                            binding: 2,
800                            resource: wgpu::BindingResource::TextureView(&cascades.view_a),
801                        },
802                        wgpu::BindGroupEntry {
803                            binding: 3,
804                            resource: wgpu::BindingResource::TextureView(&cascades.view_b),
805                        },
806                    ],
807                });
808
809                {
810                    let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
811                        label: Some(&format!("radiance_merge_{c}")),
812                        timestamp_writes: None,
813                    });
814                    pass.set_pipeline(&self.merge_pipeline);
815                    pass.set_bind_group(0, &merge_bg, &[]);
816                    pass.dispatch_workgroups(
817                        (cascades.width + 7) / 8,
818                        (cascades.height + 7) / 8,
819                        1,
820                    );
821                }
822
823                // Copy tex_b back to tex_a for the next level's merge
824                // (the finalize pass reads from cascade_in which is tex_b after the last merge)
825            }
826        }
827
828        // === Pass 3: Finalize — cascade 0 -> light texture ===
829        {
830            let params = RadianceParams {
831                scene_dims: [scene_w as f32, scene_h as f32, 0.0, cascade_count as f32],
832                cascade_params: [
833                    self.probe_spacing,
834                    self.base_rays as f32,
835                    self.interval,
836                    radiance.gi_intensity,
837                ],
838                camera: [camera_x, camera_y, viewport_w, viewport_h],
839                ambient: [
840                    lighting.ambient[0],
841                    lighting.ambient[1],
842                    lighting.ambient[2],
843                    0.0,
844                ],
845            };
846
847            gpu.queue.write_buffer(
848                &self.params_buffer,
849                0,
850                bytemuck::cast_slice(&[params]),
851            );
852
853            // Read from the last written cascade (tex_b if merged, tex_a if only ray-marched)
854            let final_cascade_view = if cascade_count > 1 {
855                &cascades.view_b
856            } else {
857                &cascades.view_a
858            };
859
860            let finalize_bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
861                label: Some("radiance_finalize_bg"),
862                layout: &self.compute_bind_group_layout,
863                entries: &[
864                    wgpu::BindGroupEntry {
865                        binding: 0,
866                        resource: self.params_buffer.as_entire_binding(),
867                    },
868                    wgpu::BindGroupEntry {
869                        binding: 1,
870                        resource: wgpu::BindingResource::TextureView(&scene_tex.view),
871                    },
872                    wgpu::BindGroupEntry {
873                        binding: 2,
874                        resource: wgpu::BindingResource::TextureView(final_cascade_view),
875                    },
876                    wgpu::BindGroupEntry {
877                        binding: 3,
878                        resource: wgpu::BindingResource::TextureView(&light_tex.view),
879                    },
880                ],
881            });
882
883            {
884                let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
885                    label: Some("radiance_finalize"),
886                    timestamp_writes: None,
887                });
888                pass.set_pipeline(&self.finalize_pipeline);
889                pass.set_bind_group(0, &finalize_bg, &[]);
890                pass.dispatch_workgroups((scene_w + 7) / 8, (scene_h + 7) / 8, 1);
891            }
892        }
893
894        true
895    }
896
897    /// Compose the light texture onto the sprite output.
898    /// Call this after sprites have been rendered to the target view.
899    /// This applies additive blending: sprite_color + light_contribution.
900    /// The sprite shader already handles ambient + point lights via multiplication.
901    /// GI adds indirect illumination on top.
902    pub fn compose(
903        &self,
904        encoder: &mut wgpu::CommandEncoder,
905        target: &wgpu::TextureView,
906    ) {
907        let Some(ref light_tex) = self.light_texture else {
908            return;
909        };
910
911        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
912            label: Some("radiance_compose_pass"),
913            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
914                view: target,
915                resolve_target: None,
916                ops: wgpu::Operations {
917                    load: wgpu::LoadOp::Load, // keep existing sprite output
918                    store: wgpu::StoreOp::Store,
919                },
920            })],
921            depth_stencil_attachment: None,
922            timestamp_writes: None,
923            occlusion_query_set: None,
924        });
925
926        pass.set_pipeline(&self.compose_pipeline);
927        pass.set_bind_group(0, &light_tex.bind_group, &[]);
928        pass.draw(0..3, 0..1); // fullscreen triangle
929    }
930}
931
932/// Composition shader: fullscreen pass that samples the light texture
933/// and multiplies it with existing pixel values.
934const COMPOSE_WGSL: &str = r#"
935@group(0) @binding(0)
936var t_light: texture_2d<f32>;
937
938@group(0) @binding(1)
939var s_light: sampler;
940
941struct VertexOutput {
942    @builtin(position) position: vec4<f32>,
943    @location(0) uv: vec2<f32>,
944};
945
946@vertex
947fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput {
948    var out: VertexOutput;
949    let uv = vec2<f32>(f32((idx << 1u) & 2u), f32(idx & 2u));
950    out.position = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
951    out.uv = vec2<f32>(uv.x, 1.0 - uv.y);
952    return out;
953}
954
955@fragment
956fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
957    let light = textureSample(t_light, s_light, in.uv);
958    // Output the light color — blend state does the multiplication with dst
959    return light;
960}
961"#;
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn test_radiance_params_size() {
969        assert_eq!(std::mem::size_of::<RadianceParams>(), 64);
970    }
971
972    #[test]
973    fn test_emissive_surface_clone() {
974        let em = EmissiveSurface {
975            x: 10.0,
976            y: 20.0,
977            width: 32.0,
978            height: 32.0,
979            r: 1.0,
980            g: 0.5,
981            b: 0.0,
982            intensity: 2.0,
983        };
984        let em2 = em.clone();
985        assert_eq!(em2.x, 10.0);
986        assert_eq!(em2.intensity, 2.0);
987    }
988
989    #[test]
990    fn test_occluder_clone() {
991        let occ = Occluder {
992            x: 50.0,
993            y: 60.0,
994            width: 100.0,
995            height: 20.0,
996        };
997        let occ2 = occ.clone();
998        assert_eq!(occ2.width, 100.0);
999    }
1000
1001    #[test]
1002    fn test_radiance_state_default() {
1003        let state = RadianceState::default();
1004        assert!(!state.enabled);
1005        assert!(state.emissives.is_empty());
1006        assert!(state.occluders.is_empty());
1007        assert!(state.directional_lights.is_empty());
1008        assert!(state.spot_lights.is_empty());
1009        assert_eq!(state.gi_intensity, 1.0);
1010    }
1011
1012    #[test]
1013    fn test_directional_light() {
1014        let dl = DirectionalLight {
1015            angle: 1.5,
1016            r: 1.0,
1017            g: 0.9,
1018            b: 0.7,
1019            intensity: 0.8,
1020        };
1021        assert_eq!(dl.angle, 1.5);
1022    }
1023
1024    #[test]
1025    fn test_spot_light() {
1026        let sl = SpotLight {
1027            x: 100.0,
1028            y: 200.0,
1029            angle: 0.0,
1030            spread: 0.5,
1031            range: 300.0,
1032            r: 1.0,
1033            g: 1.0,
1034            b: 0.8,
1035            intensity: 1.5,
1036        };
1037        assert_eq!(sl.range, 300.0);
1038    }
1039}