Skip to main content

arcane_core/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        Self::new_internal(&gpu.device, gpu.config.format)
201    }
202
203    /// Create for headless testing (no window/surface required).
204    pub fn new_headless(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
205        Self::new_internal(device, format)
206    }
207
208    fn new_internal(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
209        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
210            label: Some("radiance_compute_shader"),
211            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/radiance.wgsl").into()),
212        });
213
214        // Bind group layout for compute passes
215        let compute_bind_group_layout =
216            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
217                label: Some("radiance_compute_bind_group_layout"),
218                entries: &[
219                    // binding 0: uniform params
220                    wgpu::BindGroupLayoutEntry {
221                        binding: 0,
222                        visibility: wgpu::ShaderStages::COMPUTE,
223                        ty: wgpu::BindingType::Buffer {
224                            ty: wgpu::BufferBindingType::Uniform,
225                            has_dynamic_offset: false,
226                            min_binding_size: None,
227                        },
228                        count: None,
229                    },
230                    // binding 1: scene texture (read)
231                    wgpu::BindGroupLayoutEntry {
232                        binding: 1,
233                        visibility: wgpu::ShaderStages::COMPUTE,
234                        ty: wgpu::BindingType::Texture {
235                            multisampled: false,
236                            view_dimension: wgpu::TextureViewDimension::D2,
237                            sample_type: wgpu::TextureSampleType::Float { filterable: false },
238                        },
239                        count: None,
240                    },
241                    // binding 2: cascade input texture (read)
242                    wgpu::BindGroupLayoutEntry {
243                        binding: 2,
244                        visibility: wgpu::ShaderStages::COMPUTE,
245                        ty: wgpu::BindingType::Texture {
246                            multisampled: false,
247                            view_dimension: wgpu::TextureViewDimension::D2,
248                            sample_type: wgpu::TextureSampleType::Float { filterable: false },
249                        },
250                        count: None,
251                    },
252                    // binding 3: cascade output (storage write)
253                    wgpu::BindGroupLayoutEntry {
254                        binding: 3,
255                        visibility: wgpu::ShaderStages::COMPUTE,
256                        ty: wgpu::BindingType::StorageTexture {
257                            access: wgpu::StorageTextureAccess::WriteOnly,
258                            format: wgpu::TextureFormat::Rgba16Float,
259                            view_dimension: wgpu::TextureViewDimension::D2,
260                        },
261                        count: None,
262                    },
263                ],
264            });
265
266        let compute_layout =
267            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
268                label: Some("radiance_compute_layout"),
269                bind_group_layouts: &[&compute_bind_group_layout],
270                push_constant_ranges: &[],
271            });
272
273        let ray_march_pipeline =
274            device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
275                label: Some("radiance_ray_march"),
276                layout: Some(&compute_layout),
277                module: &shader,
278                entry_point: Some("ray_march"),
279                compilation_options: Default::default(),
280                cache: None,
281            });
282
283        let merge_pipeline =
284            device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
285                label: Some("radiance_merge"),
286                layout: Some(&compute_layout),
287                module: &shader,
288                entry_point: Some("merge_cascades"),
289                compilation_options: Default::default(),
290                cache: None,
291            });
292
293        let finalize_pipeline =
294            device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
295                label: Some("radiance_finalize"),
296                layout: Some(&compute_layout),
297                module: &shader,
298                entry_point: Some("finalize"),
299                compilation_options: Default::default(),
300                cache: None,
301            });
302
303        // Params uniform buffer
304        let params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
305            label: Some("radiance_params_buffer"),
306            contents: bytemuck::cast_slice(&[RadianceParams {
307                scene_dims: [0.0; 4],
308                cascade_params: [0.0; 4],
309                camera: [0.0; 4],
310                ambient: [1.0, 1.0, 1.0, 0.0],
311            }]),
312            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
313        });
314
315        // Composition pass: renders light texture over the sprite output
316        let compose_bind_group_layout =
317            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
318                label: Some("radiance_compose_bind_group_layout"),
319                entries: &[
320                    wgpu::BindGroupLayoutEntry {
321                        binding: 0,
322                        visibility: wgpu::ShaderStages::FRAGMENT,
323                        ty: wgpu::BindingType::Texture {
324                            multisampled: false,
325                            view_dimension: wgpu::TextureViewDimension::D2,
326                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
327                        },
328                        count: None,
329                    },
330                    wgpu::BindGroupLayoutEntry {
331                        binding: 1,
332                        visibility: wgpu::ShaderStages::FRAGMENT,
333                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
334                        count: None,
335                    },
336                ],
337            });
338
339        let compose_layout =
340            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
341                label: Some("radiance_compose_layout"),
342                bind_group_layouts: &[&compose_bind_group_layout],
343                push_constant_ranges: &[],
344            });
345
346        let compose_shader =
347            device.create_shader_module(wgpu::ShaderModuleDescriptor {
348                label: Some("radiance_compose_shader"),
349                source: wgpu::ShaderSource::Wgsl(COMPOSE_WGSL.into()),
350            });
351
352        let compose_pipeline =
353            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
354                label: Some("radiance_compose_pipeline"),
355                layout: Some(&compose_layout),
356                vertex: wgpu::VertexState {
357                    module: &compose_shader,
358                    entry_point: Some("vs_main"),
359                    buffers: &[],
360                    compilation_options: Default::default(),
361                },
362                fragment: Some(wgpu::FragmentState {
363                    module: &compose_shader,
364                    entry_point: Some("fs_main"),
365                    targets: &[Some(wgpu::ColorTargetState {
366                        format: surface_format,
367                        blend: Some(wgpu::BlendState {
368                            // Additive: result = src * dst + dst * 1
369                            // = dst * (1 + src) — GI adds light without darkening
370                            color: wgpu::BlendComponent {
371                                src_factor: wgpu::BlendFactor::Dst,
372                                dst_factor: wgpu::BlendFactor::One,
373                                operation: wgpu::BlendOperation::Add,
374                            },
375                            alpha: wgpu::BlendComponent::OVER,
376                        }),
377                        write_mask: wgpu::ColorWrites::ALL,
378                    })],
379                    compilation_options: Default::default(),
380                }),
381                primitive: wgpu::PrimitiveState {
382                    topology: wgpu::PrimitiveTopology::TriangleList,
383                    ..Default::default()
384                },
385                depth_stencil: None,
386                multisample: wgpu::MultisampleState::default(),
387                multiview: None,
388                cache: None,
389            });
390
391        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
392            label: Some("radiance_sampler"),
393            address_mode_u: wgpu::AddressMode::ClampToEdge,
394            address_mode_v: wgpu::AddressMode::ClampToEdge,
395            mag_filter: wgpu::FilterMode::Linear,
396            min_filter: wgpu::FilterMode::Linear,
397            ..Default::default()
398        });
399
400        Self {
401            ray_march_pipeline,
402            merge_pipeline,
403            finalize_pipeline,
404            compose_pipeline,
405            compose_bind_group_layout,
406            compute_bind_group_layout,
407            params_buffer,
408            scene_texture: None,
409            cascade_textures: None,
410            light_texture: None,
411            base_rays: DEFAULT_BASE_RAYS,
412            probe_spacing: DEFAULT_PROBE_SPACING,
413            interval: DEFAULT_INTERVAL,
414            cascade_count: 4,
415            sampler,
416            surface_format,
417        }
418    }
419
420    /// Ensure textures exist and match the given scene dimensions.
421    fn ensure_textures(&mut self, gpu: &GpuContext, scene_w: u32, scene_h: u32) {
422        let needs_recreate = self
423            .scene_texture
424            .as_ref()
425            .map(|t| t.width != scene_w || t.height != scene_h)
426            .unwrap_or(true);
427
428        if !needs_recreate {
429            return;
430        }
431
432        // Scene texture
433        let scene_tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
434            label: Some("radiance_scene_texture"),
435            size: wgpu::Extent3d {
436                width: scene_w,
437                height: scene_h,
438                depth_or_array_layers: 1,
439            },
440            mip_level_count: 1,
441            sample_count: 1,
442            dimension: wgpu::TextureDimension::D2,
443            format: wgpu::TextureFormat::Rgba32Float,
444            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
445            view_formats: &[],
446        });
447
448        let scene_view = scene_tex.create_view(&wgpu::TextureViewDescriptor::default());
449        self.scene_texture = Some(SceneTexture {
450            texture: scene_tex,
451            view: scene_view,
452            width: scene_w,
453            height: scene_h,
454        });
455
456        // Cascade textures: size determined by probe grid and ray block layout
457        // For cascade 0: probes_x * rays_per_side x probes_y * rays_per_side
458        // All cascades use the same total memory, but the largest cascade texture size
459        // is determined by cascade 0 (most probes, fewest rays).
460        let probes_x = (scene_w as f32 / self.probe_spacing).ceil() as u32;
461        let probes_y = (scene_h as f32 / self.probe_spacing).ceil() as u32;
462        let rays_per_side = (self.base_rays as f32).sqrt().ceil() as u32;
463        let cascade_w = probes_x * rays_per_side;
464        let cascade_h = probes_y * rays_per_side;
465
466        // Ensure minimum size
467        let cascade_w = cascade_w.max(1);
468        let cascade_h = cascade_h.max(1);
469
470        let create_cascade_tex = |label: &str| -> (wgpu::Texture, wgpu::TextureView) {
471            let tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
472                label: Some(label),
473                size: wgpu::Extent3d {
474                    width: cascade_w,
475                    height: cascade_h,
476                    depth_or_array_layers: 1,
477                },
478                mip_level_count: 1,
479                sample_count: 1,
480                dimension: wgpu::TextureDimension::D2,
481                format: wgpu::TextureFormat::Rgba16Float,
482                usage: wgpu::TextureUsages::TEXTURE_BINDING
483                    | wgpu::TextureUsages::STORAGE_BINDING,
484                view_formats: &[],
485            });
486            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
487            (tex, view)
488        };
489
490        let (tex_a, view_a) = create_cascade_tex("radiance_cascade_a");
491        let (tex_b, view_b) = create_cascade_tex("radiance_cascade_b");
492
493        self.cascade_textures = Some(CascadeTextures {
494            tex_a,
495            view_a,
496            tex_b,
497            view_b,
498            width: cascade_w,
499            height: cascade_h,
500        });
501
502        // Light texture: scene resolution
503        let light_tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
504            label: Some("radiance_light_texture"),
505            size: wgpu::Extent3d {
506                width: scene_w,
507                height: scene_h,
508                depth_or_array_layers: 1,
509            },
510            mip_level_count: 1,
511            sample_count: 1,
512            dimension: wgpu::TextureDimension::D2,
513            format: wgpu::TextureFormat::Rgba16Float,
514            usage: wgpu::TextureUsages::TEXTURE_BINDING
515                | wgpu::TextureUsages::STORAGE_BINDING,
516            view_formats: &[],
517        });
518
519        let light_view = light_tex.create_view(&wgpu::TextureViewDescriptor::default());
520
521        let light_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
522            label: Some("radiance_light_bind_group"),
523            layout: &self.compose_bind_group_layout,
524            entries: &[
525                wgpu::BindGroupEntry {
526                    binding: 0,
527                    resource: wgpu::BindingResource::TextureView(&light_view),
528                },
529                wgpu::BindGroupEntry {
530                    binding: 1,
531                    resource: wgpu::BindingResource::Sampler(&self.sampler),
532                },
533            ],
534        });
535
536        self.light_texture = Some(LightTexture {
537            texture: light_tex,
538            view: light_view,
539            bind_group: light_bind_group,
540            width: scene_w,
541            height: scene_h,
542        });
543    }
544
545    fn build_scene_data(
546        &self,
547        scene_w: u32,
548        scene_h: u32,
549        radiance: &RadianceState,
550        lighting: &LightingState,
551        camera_x: f32,
552        camera_y: f32,
553        viewport_w: f32,
554        viewport_h: f32,
555    ) -> Vec<u8> {
556        build_scene_data(scene_w, scene_h, radiance, lighting, camera_x, camera_y, viewport_w, viewport_h)
557    }
558}
559
560/// Build the scene texture data from emissives, occluders, point lights,
561/// directional lights, and spot lights.
562fn build_scene_data(
563    scene_w: u32,
564    scene_h: u32,
565    radiance: &RadianceState,
566    lighting: &LightingState,
567    camera_x: f32,
568    camera_y: f32,
569    viewport_w: f32,
570    viewport_h: f32,
571) -> Vec<u8> {
572        let w = scene_w as usize;
573        let h = scene_h as usize;
574        // Rgba32Float: 4 channels × 4 bytes = 16 bytes per pixel
575        let mut pixels = vec![0.0f32; w * h * 4];
576
577        // World-space origin for the scene texture (camera-centered)
578        let world_left = camera_x - viewport_w / 2.0;
579        let world_top = camera_y - viewport_h / 2.0;
580
581        // Rasterize emissive surfaces (HDR — intensity is preserved)
582        for em in &radiance.emissives {
583            let px0 = ((em.x - world_left) as i32).max(0) as usize;
584            let py0 = ((em.y - world_top) as i32).max(0) as usize;
585            let px1 = ((em.x + em.width - world_left) as i32).max(0).min(w as i32) as usize;
586            let py1 = ((em.y + em.height - world_top) as i32).max(0).min(h as i32) as usize;
587
588            let er = em.r * em.intensity;
589            let eg = em.g * em.intensity;
590            let eb = em.b * em.intensity;
591
592            for py in py0..py1 {
593                for px in px0..px1 {
594                    let idx = (py * w + px) * 4;
595                    pixels[idx] += er;
596                    pixels[idx + 1] += eg;
597                    pixels[idx + 2] += eb;
598                }
599            }
600        }
601
602        // Rasterize point lights as emissive circles
603        for light in &lighting.lights {
604            let cx = (light.x - world_left) as i32;
605            let cy = (light.y - world_top) as i32;
606            let r_px = (light.radius * 0.1).max(2.0) as i32;
607
608            let er = light.r * light.intensity;
609            let eg = light.g * light.intensity;
610            let eb = light.b * light.intensity;
611
612            for dy in -r_px..=r_px {
613                for dx in -r_px..=r_px {
614                    if dx * dx + dy * dy <= r_px * r_px {
615                        let px = (cx + dx) as usize;
616                        let py = (cy + dy) as usize;
617                        if px < w && py < h {
618                            let idx = (py * w + px) * 4;
619                            pixels[idx] += er;
620                            pixels[idx + 1] += eg;
621                            pixels[idx + 2] += eb;
622                        }
623                    }
624                }
625            }
626        }
627
628        // Rasterize spot lights as emissive spots
629        for spot in &radiance.spot_lights {
630            let cx = (spot.x - world_left) as i32;
631            let cy = (spot.y - world_top) as i32;
632            let r_px = 3i32;
633
634            let er = spot.r * spot.intensity;
635            let eg = spot.g * spot.intensity;
636            let eb = spot.b * spot.intensity;
637
638            for dy in -r_px..=r_px {
639                for dx in -r_px..=r_px {
640                    if dx * dx + dy * dy <= r_px * r_px {
641                        let px = (cx + dx) as usize;
642                        let py = (cy + dy) as usize;
643                        if px < w && py < h {
644                            let idx = (py * w + px) * 4;
645                            pixels[idx] += er;
646                            pixels[idx + 1] += eg;
647                            pixels[idx + 2] += eb;
648                        }
649                    }
650                }
651            }
652        }
653
654        // Rasterize occluders (alpha = 1.0)
655        for occ in &radiance.occluders {
656            let px0 = ((occ.x - world_left) as i32).max(0) as usize;
657            let py0 = ((occ.y - world_top) as i32).max(0) as usize;
658            let px1 = ((occ.x + occ.width - world_left) as i32).max(0).min(w as i32) as usize;
659            let py1 = ((occ.y + occ.height - world_top) as i32).max(0).min(h as i32) as usize;
660
661            for py in py0..py1 {
662                for px in px0..px1 {
663                    let idx = (py * w + px) * 4;
664                    pixels[idx + 3] = 1.0; // occluder flag
665                }
666            }
667        }
668
669        // Return as raw bytes (f32 → bytemuck cast)
670        bytemuck::cast_slice(&pixels).to_vec()
671    }
672
673impl RadiancePipeline {
674    /// Execute the full radiance cascade pipeline for one frame.
675    /// Returns true if the light texture was computed and the compose pass should run.
676    pub fn compute(
677        &mut self,
678        gpu: &GpuContext,
679        encoder: &mut wgpu::CommandEncoder,
680        radiance: &RadianceState,
681        lighting: &LightingState,
682        camera_x: f32,
683        camera_y: f32,
684        viewport_w: f32,
685        viewport_h: f32,
686    ) -> bool {
687        if !radiance.enabled {
688            return false;
689        }
690
691        // Apply quality overrides from game code
692        if let Some(ps) = radiance.probe_spacing {
693            self.probe_spacing = ps;
694        }
695        if let Some(iv) = radiance.interval {
696            self.interval = iv;
697        }
698        if let Some(cc) = radiance.cascade_count {
699            self.cascade_count = cc;
700        }
701
702        // Scene resolution is the viewport size (in logical pixels)
703        let scene_w = viewport_w.ceil() as u32;
704        let scene_h = viewport_h.ceil() as u32;
705        if scene_w == 0 || scene_h == 0 {
706            return false;
707        }
708
709        self.ensure_textures(gpu, scene_w, scene_h);
710
711        let scene_tex = self.scene_texture.as_ref().unwrap();
712        let cascades = self.cascade_textures.as_ref().unwrap();
713        let light_tex = self.light_texture.as_ref().unwrap();
714
715        // Upload scene data to GPU
716        let scene_data = self.build_scene_data(
717            scene_w, scene_h, radiance, lighting, camera_x, camera_y, viewport_w, viewport_h,
718        );
719
720        gpu.queue.write_texture(
721            wgpu::TexelCopyTextureInfo {
722                texture: &scene_tex.texture,
723                mip_level: 0,
724                origin: wgpu::Origin3d::ZERO,
725                aspect: wgpu::TextureAspect::All,
726            },
727            &scene_data,
728            wgpu::TexelCopyBufferLayout {
729                offset: 0,
730                bytes_per_row: Some(scene_w * 16), // Rgba32Float: 16 bytes per pixel
731                rows_per_image: Some(scene_h),
732            },
733            wgpu::Extent3d {
734                width: scene_w,
735                height: scene_h,
736                depth_or_array_layers: 1,
737            },
738        );
739
740        let cascade_count = self.cascade_count.min(MAX_CASCADES as u32);
741
742        // === Pass 1: Ray-march each cascade (highest first) ===
743        // We write each cascade to tex_a using a dedicated bind group,
744        // then in merge pass we read from tex_a and write to tex_b.
745        for c in (0..cascade_count).rev() {
746            let params = RadianceParams {
747                scene_dims: [scene_w as f32, scene_h as f32, c as f32, cascade_count as f32],
748                cascade_params: [
749                    self.probe_spacing,
750                    self.base_rays as f32,
751                    self.interval,
752                    radiance.gi_intensity,
753                ],
754                camera: [camera_x, camera_y, viewport_w, viewport_h],
755                ambient: [
756                    lighting.ambient[0],
757                    lighting.ambient[1],
758                    lighting.ambient[2],
759                    0.0,
760                ],
761            };
762
763            gpu.queue.write_buffer(
764                &self.params_buffer,
765                0,
766                bytemuck::cast_slice(&[params]),
767            );
768
769            // For ray-march: write to tex_a (scene + empty cascade_in + tex_a as output)
770            let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
771                label: Some(&format!("radiance_ray_march_bg_{c}")),
772                layout: &self.compute_bind_group_layout,
773                entries: &[
774                    wgpu::BindGroupEntry {
775                        binding: 0,
776                        resource: self.params_buffer.as_entire_binding(),
777                    },
778                    wgpu::BindGroupEntry {
779                        binding: 1,
780                        resource: wgpu::BindingResource::TextureView(&scene_tex.view),
781                    },
782                    wgpu::BindGroupEntry {
783                        binding: 2,
784                        resource: wgpu::BindingResource::TextureView(&cascades.view_b),
785                    },
786                    wgpu::BindGroupEntry {
787                        binding: 3,
788                        resource: wgpu::BindingResource::TextureView(&cascades.view_a),
789                    },
790                ],
791            });
792
793            {
794                let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
795                    label: Some(&format!("radiance_ray_march_{c}")),
796                    timestamp_writes: None,
797                });
798                pass.set_pipeline(&self.ray_march_pipeline);
799                pass.set_bind_group(0, &bind_group, &[]);
800                pass.dispatch_workgroups(
801                    (cascades.width + 7) / 8,
802                    (cascades.height + 7) / 8,
803                    1,
804                );
805            }
806
807            // === Pass 2: Merge (if not the highest cascade) ===
808            if c < cascade_count - 1 {
809                // Read from tex_a (ray-marched), write to tex_b
810                let merge_bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
811                    label: Some(&format!("radiance_merge_bg_{c}")),
812                    layout: &self.compute_bind_group_layout,
813                    entries: &[
814                        wgpu::BindGroupEntry {
815                            binding: 0,
816                            resource: self.params_buffer.as_entire_binding(),
817                        },
818                        wgpu::BindGroupEntry {
819                            binding: 1,
820                            resource: wgpu::BindingResource::TextureView(&scene_tex.view),
821                        },
822                        wgpu::BindGroupEntry {
823                            binding: 2,
824                            resource: wgpu::BindingResource::TextureView(&cascades.view_a),
825                        },
826                        wgpu::BindGroupEntry {
827                            binding: 3,
828                            resource: wgpu::BindingResource::TextureView(&cascades.view_b),
829                        },
830                    ],
831                });
832
833                {
834                    let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
835                        label: Some(&format!("radiance_merge_{c}")),
836                        timestamp_writes: None,
837                    });
838                    pass.set_pipeline(&self.merge_pipeline);
839                    pass.set_bind_group(0, &merge_bg, &[]);
840                    pass.dispatch_workgroups(
841                        (cascades.width + 7) / 8,
842                        (cascades.height + 7) / 8,
843                        1,
844                    );
845                }
846
847                // Copy tex_b back to tex_a for the next level's merge
848                // (the finalize pass reads from cascade_in which is tex_b after the last merge)
849            }
850        }
851
852        // === Pass 3: Finalize — cascade 0 -> light texture ===
853        {
854            let params = RadianceParams {
855                scene_dims: [scene_w as f32, scene_h as f32, 0.0, cascade_count as f32],
856                cascade_params: [
857                    self.probe_spacing,
858                    self.base_rays as f32,
859                    self.interval,
860                    radiance.gi_intensity,
861                ],
862                camera: [camera_x, camera_y, viewport_w, viewport_h],
863                ambient: [
864                    lighting.ambient[0],
865                    lighting.ambient[1],
866                    lighting.ambient[2],
867                    0.0,
868                ],
869            };
870
871            gpu.queue.write_buffer(
872                &self.params_buffer,
873                0,
874                bytemuck::cast_slice(&[params]),
875            );
876
877            // Read from the last written cascade (tex_b if merged, tex_a if only ray-marched)
878            let final_cascade_view = if cascade_count > 1 {
879                &cascades.view_b
880            } else {
881                &cascades.view_a
882            };
883
884            let finalize_bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
885                label: Some("radiance_finalize_bg"),
886                layout: &self.compute_bind_group_layout,
887                entries: &[
888                    wgpu::BindGroupEntry {
889                        binding: 0,
890                        resource: self.params_buffer.as_entire_binding(),
891                    },
892                    wgpu::BindGroupEntry {
893                        binding: 1,
894                        resource: wgpu::BindingResource::TextureView(&scene_tex.view),
895                    },
896                    wgpu::BindGroupEntry {
897                        binding: 2,
898                        resource: wgpu::BindingResource::TextureView(final_cascade_view),
899                    },
900                    wgpu::BindGroupEntry {
901                        binding: 3,
902                        resource: wgpu::BindingResource::TextureView(&light_tex.view),
903                    },
904                ],
905            });
906
907            {
908                let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
909                    label: Some("radiance_finalize"),
910                    timestamp_writes: None,
911                });
912                pass.set_pipeline(&self.finalize_pipeline);
913                pass.set_bind_group(0, &finalize_bg, &[]);
914                pass.dispatch_workgroups((scene_w + 7) / 8, (scene_h + 7) / 8, 1);
915            }
916        }
917
918        true
919    }
920
921    /// Compose the light texture onto the sprite output.
922    /// Call this after sprites have been rendered to the target view.
923    /// This applies additive blending: sprite_color + light_contribution.
924    /// The sprite shader already handles ambient + point lights via multiplication.
925    /// GI adds indirect illumination on top.
926    pub fn compose(
927        &self,
928        encoder: &mut wgpu::CommandEncoder,
929        target: &wgpu::TextureView,
930    ) {
931        let Some(ref light_tex) = self.light_texture else {
932            return;
933        };
934
935        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
936            label: Some("radiance_compose_pass"),
937            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
938                view: target,
939                resolve_target: None,
940                ops: wgpu::Operations {
941                    load: wgpu::LoadOp::Load, // keep existing sprite output
942                    store: wgpu::StoreOp::Store,
943                },
944            })],
945            depth_stencil_attachment: None,
946            timestamp_writes: None,
947            occlusion_query_set: None,
948        });
949
950        pass.set_pipeline(&self.compose_pipeline);
951        pass.set_bind_group(0, &light_tex.bind_group, &[]);
952        pass.draw(0..3, 0..1); // fullscreen triangle
953    }
954}
955
956/// Composition shader: fullscreen pass that samples the light texture
957/// and multiplies it with existing pixel values.
958const COMPOSE_WGSL: &str = r#"
959@group(0) @binding(0)
960var t_light: texture_2d<f32>;
961
962@group(0) @binding(1)
963var s_light: sampler;
964
965struct VertexOutput {
966    @builtin(position) position: vec4<f32>,
967    @location(0) uv: vec2<f32>,
968};
969
970@vertex
971fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput {
972    var out: VertexOutput;
973    let uv = vec2<f32>(f32((idx << 1u) & 2u), f32(idx & 2u));
974    out.position = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
975    out.uv = vec2<f32>(uv.x, 1.0 - uv.y);
976    return out;
977}
978
979@fragment
980fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
981    let light = textureSample(t_light, s_light, in.uv);
982    // Output the light color — blend state does the multiplication with dst
983    return light;
984}
985"#;
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990
991    #[test]
992    fn test_radiance_params_size() {
993        assert_eq!(std::mem::size_of::<RadianceParams>(), 64);
994    }
995
996    #[test]
997    fn test_emissive_surface_clone() {
998        let em = EmissiveSurface {
999            x: 10.0,
1000            y: 20.0,
1001            width: 32.0,
1002            height: 32.0,
1003            r: 1.0,
1004            g: 0.5,
1005            b: 0.0,
1006            intensity: 2.0,
1007        };
1008        let em2 = em.clone();
1009        assert_eq!(em2.x, 10.0);
1010        assert_eq!(em2.intensity, 2.0);
1011    }
1012
1013    #[test]
1014    fn test_occluder_clone() {
1015        let occ = Occluder {
1016            x: 50.0,
1017            y: 60.0,
1018            width: 100.0,
1019            height: 20.0,
1020        };
1021        let occ2 = occ.clone();
1022        assert_eq!(occ2.width, 100.0);
1023    }
1024
1025    #[test]
1026    fn test_radiance_state_default() {
1027        let state = RadianceState::default();
1028        assert!(!state.enabled);
1029        assert!(state.emissives.is_empty());
1030        assert!(state.occluders.is_empty());
1031        assert!(state.directional_lights.is_empty());
1032        assert!(state.spot_lights.is_empty());
1033        assert_eq!(state.gi_intensity, 1.0);
1034    }
1035
1036    #[test]
1037    fn test_directional_light() {
1038        let dl = DirectionalLight {
1039            angle: 1.5,
1040            r: 1.0,
1041            g: 0.9,
1042            b: 0.7,
1043            intensity: 0.8,
1044        };
1045        assert_eq!(dl.angle, 1.5);
1046    }
1047
1048    #[test]
1049    fn test_spot_light() {
1050        let sl = SpotLight {
1051            x: 100.0,
1052            y: 200.0,
1053            angle: 0.0,
1054            spread: 0.5,
1055            range: 300.0,
1056            r: 1.0,
1057            g: 1.0,
1058            b: 0.8,
1059            intensity: 1.5,
1060        };
1061        assert_eq!(sl.range, 300.0);
1062    }
1063
1064    // Regression: off-screen occluders/emissives caused index-out-of-bounds panic
1065    // because negative pixel coordinates wrapped to huge usize values.
1066
1067    fn empty_lighting() -> LightingState {
1068        LightingState::default()
1069    }
1070
1071    #[test]
1072    fn test_build_scene_data_occluder_offscreen_left() {
1073        let mut radiance = RadianceState::default();
1074        // Occluder entirely to the left of the viewport
1075        radiance.occluders.push(Occluder {
1076            x: -200.0,
1077            y: 100.0,
1078            width: 50.0,
1079            height: 50.0,
1080        });
1081        // Camera at (400,300) with 800x600 viewport → world_left=0, world_top=0
1082        // Occluder right edge at -150, which is left of viewport
1083        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1084        assert_eq!(data.len(), 800 * 600 * 4 * 4); // w*h*4 channels * 4 bytes per f32
1085    }
1086
1087    #[test]
1088    fn test_build_scene_data_occluder_offscreen_above() {
1089        let mut radiance = RadianceState::default();
1090        // Occluder entirely above the viewport
1091        radiance.occluders.push(Occluder {
1092            x: 100.0,
1093            y: -300.0,
1094            width: 50.0,
1095            height: 50.0,
1096        });
1097        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1098        assert_eq!(data.len(), 800 * 600 * 4 * 4);
1099    }
1100
1101    #[test]
1102    fn test_build_scene_data_emissive_offscreen_left() {
1103        let mut radiance = RadianceState::default();
1104        // Emissive entirely to the left of the viewport
1105        radiance.emissives.push(EmissiveSurface {
1106            x: -500.0,
1107            y: 100.0,
1108            width: 100.0,
1109            height: 100.0,
1110            r: 1.0, g: 1.0, b: 1.0,
1111            intensity: 1.0,
1112        });
1113        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1114        assert_eq!(data.len(), 800 * 600 * 4 * 4);
1115    }
1116
1117    #[test]
1118    fn test_build_scene_data_occluder_partially_onscreen() {
1119        let mut radiance = RadianceState::default();
1120        // Occluder that straddles the bottom-right edge of the viewport
1121        radiance.occluders.push(Occluder {
1122            x: 750.0,
1123            y: 550.0,
1124            width: 200.0,
1125            height: 200.0,
1126        });
1127        // Camera at (400,300) → viewport covers (0,0)-(800,600)
1128        // Occluder covers (750,550)-(950,750), clipped to (750,550)-(800,600)
1129        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1130        let pixels: &[f32] = bytemuck::cast_slice(&data);
1131        // Check that an occluder pixel inside the viewport is set
1132        let idx = (560 * 800 + 760) * 4; // py=560, px=760 — inside the clipped region
1133        assert_eq!(pixels[idx + 3], 1.0);
1134    }
1135
1136    #[test]
1137    fn test_build_scene_data_occluder_far_offscreen() {
1138        let mut radiance = RadianceState::default();
1139        // Occluder very far off-screen in all negative directions
1140        radiance.occluders.push(Occluder {
1141            x: -10000.0,
1142            y: -10000.0,
1143            width: 50.0,
1144            height: 50.0,
1145        });
1146        radiance.emissives.push(EmissiveSurface {
1147            x: -10000.0,
1148            y: -10000.0,
1149            width: 50.0,
1150            height: 50.0,
1151            r: 1.0, g: 1.0, b: 1.0,
1152            intensity: 5.0,
1153        });
1154        // Should not panic
1155        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1156        assert_eq!(data.len(), 800 * 600 * 4 * 4);
1157    }
1158}