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    fn build_scene_data(
537        &self,
538        scene_w: u32,
539        scene_h: u32,
540        radiance: &RadianceState,
541        lighting: &LightingState,
542        camera_x: f32,
543        camera_y: f32,
544        viewport_w: f32,
545        viewport_h: f32,
546    ) -> Vec<u8> {
547        build_scene_data(scene_w, scene_h, radiance, lighting, camera_x, camera_y, viewport_w, viewport_h)
548    }
549}
550
551/// Build the scene texture data from emissives, occluders, point lights,
552/// directional lights, and spot lights.
553fn build_scene_data(
554    scene_w: u32,
555    scene_h: u32,
556    radiance: &RadianceState,
557    lighting: &LightingState,
558    camera_x: f32,
559    camera_y: f32,
560    viewport_w: f32,
561    viewport_h: f32,
562) -> Vec<u8> {
563        let w = scene_w as usize;
564        let h = scene_h as usize;
565        // Rgba32Float: 4 channels × 4 bytes = 16 bytes per pixel
566        let mut pixels = vec![0.0f32; w * h * 4];
567
568        // World-space origin for the scene texture (camera-centered)
569        let world_left = camera_x - viewport_w / 2.0;
570        let world_top = camera_y - viewport_h / 2.0;
571
572        // Rasterize emissive surfaces (HDR — intensity is preserved)
573        for em in &radiance.emissives {
574            let px0 = ((em.x - world_left) as i32).max(0) as usize;
575            let py0 = ((em.y - world_top) as i32).max(0) as usize;
576            let px1 = ((em.x + em.width - world_left) as i32).max(0).min(w as i32) as usize;
577            let py1 = ((em.y + em.height - world_top) as i32).max(0).min(h as i32) as usize;
578
579            let er = em.r * em.intensity;
580            let eg = em.g * em.intensity;
581            let eb = em.b * em.intensity;
582
583            for py in py0..py1 {
584                for px in px0..px1 {
585                    let idx = (py * w + px) * 4;
586                    pixels[idx] += er;
587                    pixels[idx + 1] += eg;
588                    pixels[idx + 2] += eb;
589                }
590            }
591        }
592
593        // Rasterize point lights as emissive circles
594        for light in &lighting.lights {
595            let cx = (light.x - world_left) as i32;
596            let cy = (light.y - world_top) as i32;
597            let r_px = (light.radius * 0.1).max(2.0) as i32;
598
599            let er = light.r * light.intensity;
600            let eg = light.g * light.intensity;
601            let eb = light.b * light.intensity;
602
603            for dy in -r_px..=r_px {
604                for dx in -r_px..=r_px {
605                    if dx * dx + dy * dy <= r_px * r_px {
606                        let px = (cx + dx) as usize;
607                        let py = (cy + dy) as usize;
608                        if px < w && py < h {
609                            let idx = (py * w + px) * 4;
610                            pixels[idx] += er;
611                            pixels[idx + 1] += eg;
612                            pixels[idx + 2] += eb;
613                        }
614                    }
615                }
616            }
617        }
618
619        // Rasterize spot lights as emissive spots
620        for spot in &radiance.spot_lights {
621            let cx = (spot.x - world_left) as i32;
622            let cy = (spot.y - world_top) as i32;
623            let r_px = 3i32;
624
625            let er = spot.r * spot.intensity;
626            let eg = spot.g * spot.intensity;
627            let eb = spot.b * spot.intensity;
628
629            for dy in -r_px..=r_px {
630                for dx in -r_px..=r_px {
631                    if dx * dx + dy * dy <= r_px * r_px {
632                        let px = (cx + dx) as usize;
633                        let py = (cy + dy) as usize;
634                        if px < w && py < h {
635                            let idx = (py * w + px) * 4;
636                            pixels[idx] += er;
637                            pixels[idx + 1] += eg;
638                            pixels[idx + 2] += eb;
639                        }
640                    }
641                }
642            }
643        }
644
645        // Rasterize occluders (alpha = 1.0)
646        for occ in &radiance.occluders {
647            let px0 = ((occ.x - world_left) as i32).max(0) as usize;
648            let py0 = ((occ.y - world_top) as i32).max(0) as usize;
649            let px1 = ((occ.x + occ.width - world_left) as i32).max(0).min(w as i32) as usize;
650            let py1 = ((occ.y + occ.height - world_top) as i32).max(0).min(h as i32) as usize;
651
652            for py in py0..py1 {
653                for px in px0..px1 {
654                    let idx = (py * w + px) * 4;
655                    pixels[idx + 3] = 1.0; // occluder flag
656                }
657            }
658        }
659
660        // Return as raw bytes (f32 → bytemuck cast)
661        bytemuck::cast_slice(&pixels).to_vec()
662    }
663
664impl RadiancePipeline {
665    /// Execute the full radiance cascade pipeline for one frame.
666    /// Returns true if the light texture was computed and the compose pass should run.
667    pub fn compute(
668        &mut self,
669        gpu: &GpuContext,
670        encoder: &mut wgpu::CommandEncoder,
671        radiance: &RadianceState,
672        lighting: &LightingState,
673        camera_x: f32,
674        camera_y: f32,
675        viewport_w: f32,
676        viewport_h: f32,
677    ) -> bool {
678        if !radiance.enabled {
679            return false;
680        }
681
682        // Apply quality overrides from game code
683        if let Some(ps) = radiance.probe_spacing {
684            self.probe_spacing = ps;
685        }
686        if let Some(iv) = radiance.interval {
687            self.interval = iv;
688        }
689        if let Some(cc) = radiance.cascade_count {
690            self.cascade_count = cc;
691        }
692
693        // Scene resolution is the viewport size (in logical pixels)
694        let scene_w = viewport_w.ceil() as u32;
695        let scene_h = viewport_h.ceil() as u32;
696        if scene_w == 0 || scene_h == 0 {
697            return false;
698        }
699
700        self.ensure_textures(gpu, scene_w, scene_h);
701
702        let scene_tex = self.scene_texture.as_ref().unwrap();
703        let cascades = self.cascade_textures.as_ref().unwrap();
704        let light_tex = self.light_texture.as_ref().unwrap();
705
706        // Upload scene data to GPU
707        let scene_data = self.build_scene_data(
708            scene_w, scene_h, radiance, lighting, camera_x, camera_y, viewport_w, viewport_h,
709        );
710
711        gpu.queue.write_texture(
712            wgpu::TexelCopyTextureInfo {
713                texture: &scene_tex.texture,
714                mip_level: 0,
715                origin: wgpu::Origin3d::ZERO,
716                aspect: wgpu::TextureAspect::All,
717            },
718            &scene_data,
719            wgpu::TexelCopyBufferLayout {
720                offset: 0,
721                bytes_per_row: Some(scene_w * 16), // Rgba32Float: 16 bytes per pixel
722                rows_per_image: Some(scene_h),
723            },
724            wgpu::Extent3d {
725                width: scene_w,
726                height: scene_h,
727                depth_or_array_layers: 1,
728            },
729        );
730
731        let cascade_count = self.cascade_count.min(MAX_CASCADES as u32);
732
733        // === Pass 1: Ray-march each cascade (highest first) ===
734        // We write each cascade to tex_a using a dedicated bind group,
735        // then in merge pass we read from tex_a and write to tex_b.
736        for c in (0..cascade_count).rev() {
737            let params = RadianceParams {
738                scene_dims: [scene_w as f32, scene_h as f32, c as f32, cascade_count as f32],
739                cascade_params: [
740                    self.probe_spacing,
741                    self.base_rays as f32,
742                    self.interval,
743                    radiance.gi_intensity,
744                ],
745                camera: [camera_x, camera_y, viewport_w, viewport_h],
746                ambient: [
747                    lighting.ambient[0],
748                    lighting.ambient[1],
749                    lighting.ambient[2],
750                    0.0,
751                ],
752            };
753
754            gpu.queue.write_buffer(
755                &self.params_buffer,
756                0,
757                bytemuck::cast_slice(&[params]),
758            );
759
760            // For ray-march: write to tex_a (scene + empty cascade_in + tex_a as output)
761            let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
762                label: Some(&format!("radiance_ray_march_bg_{c}")),
763                layout: &self.compute_bind_group_layout,
764                entries: &[
765                    wgpu::BindGroupEntry {
766                        binding: 0,
767                        resource: self.params_buffer.as_entire_binding(),
768                    },
769                    wgpu::BindGroupEntry {
770                        binding: 1,
771                        resource: wgpu::BindingResource::TextureView(&scene_tex.view),
772                    },
773                    wgpu::BindGroupEntry {
774                        binding: 2,
775                        resource: wgpu::BindingResource::TextureView(&cascades.view_b),
776                    },
777                    wgpu::BindGroupEntry {
778                        binding: 3,
779                        resource: wgpu::BindingResource::TextureView(&cascades.view_a),
780                    },
781                ],
782            });
783
784            {
785                let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
786                    label: Some(&format!("radiance_ray_march_{c}")),
787                    timestamp_writes: None,
788                });
789                pass.set_pipeline(&self.ray_march_pipeline);
790                pass.set_bind_group(0, &bind_group, &[]);
791                pass.dispatch_workgroups(
792                    (cascades.width + 7) / 8,
793                    (cascades.height + 7) / 8,
794                    1,
795                );
796            }
797
798            // === Pass 2: Merge (if not the highest cascade) ===
799            if c < cascade_count - 1 {
800                // Read from tex_a (ray-marched), write to tex_b
801                let merge_bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
802                    label: Some(&format!("radiance_merge_bg_{c}")),
803                    layout: &self.compute_bind_group_layout,
804                    entries: &[
805                        wgpu::BindGroupEntry {
806                            binding: 0,
807                            resource: self.params_buffer.as_entire_binding(),
808                        },
809                        wgpu::BindGroupEntry {
810                            binding: 1,
811                            resource: wgpu::BindingResource::TextureView(&scene_tex.view),
812                        },
813                        wgpu::BindGroupEntry {
814                            binding: 2,
815                            resource: wgpu::BindingResource::TextureView(&cascades.view_a),
816                        },
817                        wgpu::BindGroupEntry {
818                            binding: 3,
819                            resource: wgpu::BindingResource::TextureView(&cascades.view_b),
820                        },
821                    ],
822                });
823
824                {
825                    let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
826                        label: Some(&format!("radiance_merge_{c}")),
827                        timestamp_writes: None,
828                    });
829                    pass.set_pipeline(&self.merge_pipeline);
830                    pass.set_bind_group(0, &merge_bg, &[]);
831                    pass.dispatch_workgroups(
832                        (cascades.width + 7) / 8,
833                        (cascades.height + 7) / 8,
834                        1,
835                    );
836                }
837
838                // Copy tex_b back to tex_a for the next level's merge
839                // (the finalize pass reads from cascade_in which is tex_b after the last merge)
840            }
841        }
842
843        // === Pass 3: Finalize — cascade 0 -> light texture ===
844        {
845            let params = RadianceParams {
846                scene_dims: [scene_w as f32, scene_h as f32, 0.0, cascade_count as f32],
847                cascade_params: [
848                    self.probe_spacing,
849                    self.base_rays as f32,
850                    self.interval,
851                    radiance.gi_intensity,
852                ],
853                camera: [camera_x, camera_y, viewport_w, viewport_h],
854                ambient: [
855                    lighting.ambient[0],
856                    lighting.ambient[1],
857                    lighting.ambient[2],
858                    0.0,
859                ],
860            };
861
862            gpu.queue.write_buffer(
863                &self.params_buffer,
864                0,
865                bytemuck::cast_slice(&[params]),
866            );
867
868            // Read from the last written cascade (tex_b if merged, tex_a if only ray-marched)
869            let final_cascade_view = if cascade_count > 1 {
870                &cascades.view_b
871            } else {
872                &cascades.view_a
873            };
874
875            let finalize_bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
876                label: Some("radiance_finalize_bg"),
877                layout: &self.compute_bind_group_layout,
878                entries: &[
879                    wgpu::BindGroupEntry {
880                        binding: 0,
881                        resource: self.params_buffer.as_entire_binding(),
882                    },
883                    wgpu::BindGroupEntry {
884                        binding: 1,
885                        resource: wgpu::BindingResource::TextureView(&scene_tex.view),
886                    },
887                    wgpu::BindGroupEntry {
888                        binding: 2,
889                        resource: wgpu::BindingResource::TextureView(final_cascade_view),
890                    },
891                    wgpu::BindGroupEntry {
892                        binding: 3,
893                        resource: wgpu::BindingResource::TextureView(&light_tex.view),
894                    },
895                ],
896            });
897
898            {
899                let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
900                    label: Some("radiance_finalize"),
901                    timestamp_writes: None,
902                });
903                pass.set_pipeline(&self.finalize_pipeline);
904                pass.set_bind_group(0, &finalize_bg, &[]);
905                pass.dispatch_workgroups((scene_w + 7) / 8, (scene_h + 7) / 8, 1);
906            }
907        }
908
909        true
910    }
911
912    /// Compose the light texture onto the sprite output.
913    /// Call this after sprites have been rendered to the target view.
914    /// This applies additive blending: sprite_color + light_contribution.
915    /// The sprite shader already handles ambient + point lights via multiplication.
916    /// GI adds indirect illumination on top.
917    pub fn compose(
918        &self,
919        encoder: &mut wgpu::CommandEncoder,
920        target: &wgpu::TextureView,
921    ) {
922        let Some(ref light_tex) = self.light_texture else {
923            return;
924        };
925
926        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
927            label: Some("radiance_compose_pass"),
928            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
929                view: target,
930                resolve_target: None,
931                ops: wgpu::Operations {
932                    load: wgpu::LoadOp::Load, // keep existing sprite output
933                    store: wgpu::StoreOp::Store,
934                },
935            })],
936            depth_stencil_attachment: None,
937            timestamp_writes: None,
938            occlusion_query_set: None,
939        });
940
941        pass.set_pipeline(&self.compose_pipeline);
942        pass.set_bind_group(0, &light_tex.bind_group, &[]);
943        pass.draw(0..3, 0..1); // fullscreen triangle
944    }
945}
946
947/// Composition shader: fullscreen pass that samples the light texture
948/// and multiplies it with existing pixel values.
949const COMPOSE_WGSL: &str = r#"
950@group(0) @binding(0)
951var t_light: texture_2d<f32>;
952
953@group(0) @binding(1)
954var s_light: sampler;
955
956struct VertexOutput {
957    @builtin(position) position: vec4<f32>,
958    @location(0) uv: vec2<f32>,
959};
960
961@vertex
962fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput {
963    var out: VertexOutput;
964    let uv = vec2<f32>(f32((idx << 1u) & 2u), f32(idx & 2u));
965    out.position = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
966    out.uv = vec2<f32>(uv.x, 1.0 - uv.y);
967    return out;
968}
969
970@fragment
971fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
972    let light = textureSample(t_light, s_light, in.uv);
973    // Output the light color — blend state does the multiplication with dst
974    return light;
975}
976"#;
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981
982    #[test]
983    fn test_radiance_params_size() {
984        assert_eq!(std::mem::size_of::<RadianceParams>(), 64);
985    }
986
987    #[test]
988    fn test_emissive_surface_clone() {
989        let em = EmissiveSurface {
990            x: 10.0,
991            y: 20.0,
992            width: 32.0,
993            height: 32.0,
994            r: 1.0,
995            g: 0.5,
996            b: 0.0,
997            intensity: 2.0,
998        };
999        let em2 = em.clone();
1000        assert_eq!(em2.x, 10.0);
1001        assert_eq!(em2.intensity, 2.0);
1002    }
1003
1004    #[test]
1005    fn test_occluder_clone() {
1006        let occ = Occluder {
1007            x: 50.0,
1008            y: 60.0,
1009            width: 100.0,
1010            height: 20.0,
1011        };
1012        let occ2 = occ.clone();
1013        assert_eq!(occ2.width, 100.0);
1014    }
1015
1016    #[test]
1017    fn test_radiance_state_default() {
1018        let state = RadianceState::default();
1019        assert!(!state.enabled);
1020        assert!(state.emissives.is_empty());
1021        assert!(state.occluders.is_empty());
1022        assert!(state.directional_lights.is_empty());
1023        assert!(state.spot_lights.is_empty());
1024        assert_eq!(state.gi_intensity, 1.0);
1025    }
1026
1027    #[test]
1028    fn test_directional_light() {
1029        let dl = DirectionalLight {
1030            angle: 1.5,
1031            r: 1.0,
1032            g: 0.9,
1033            b: 0.7,
1034            intensity: 0.8,
1035        };
1036        assert_eq!(dl.angle, 1.5);
1037    }
1038
1039    #[test]
1040    fn test_spot_light() {
1041        let sl = SpotLight {
1042            x: 100.0,
1043            y: 200.0,
1044            angle: 0.0,
1045            spread: 0.5,
1046            range: 300.0,
1047            r: 1.0,
1048            g: 1.0,
1049            b: 0.8,
1050            intensity: 1.5,
1051        };
1052        assert_eq!(sl.range, 300.0);
1053    }
1054
1055    // Regression: off-screen occluders/emissives caused index-out-of-bounds panic
1056    // because negative pixel coordinates wrapped to huge usize values.
1057    // https://github.com/anthropics/arcane/issues/XXX
1058
1059    fn empty_lighting() -> LightingState {
1060        LightingState::default()
1061    }
1062
1063    #[test]
1064    fn test_build_scene_data_occluder_offscreen_left() {
1065        let mut radiance = RadianceState::default();
1066        // Occluder entirely to the left of the viewport
1067        radiance.occluders.push(Occluder {
1068            x: -200.0,
1069            y: 100.0,
1070            width: 50.0,
1071            height: 50.0,
1072        });
1073        // Camera at (400,300) with 800x600 viewport → world_left=0, world_top=0
1074        // Occluder right edge at -150, which is left of viewport
1075        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1076        assert_eq!(data.len(), 800 * 600 * 4 * 4); // w*h*4 channels * 4 bytes per f32
1077    }
1078
1079    #[test]
1080    fn test_build_scene_data_occluder_offscreen_above() {
1081        let mut radiance = RadianceState::default();
1082        // Occluder entirely above the viewport
1083        radiance.occluders.push(Occluder {
1084            x: 100.0,
1085            y: -300.0,
1086            width: 50.0,
1087            height: 50.0,
1088        });
1089        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1090        assert_eq!(data.len(), 800 * 600 * 4 * 4);
1091    }
1092
1093    #[test]
1094    fn test_build_scene_data_emissive_offscreen_left() {
1095        let mut radiance = RadianceState::default();
1096        // Emissive entirely to the left of the viewport
1097        radiance.emissives.push(EmissiveSurface {
1098            x: -500.0,
1099            y: 100.0,
1100            width: 100.0,
1101            height: 100.0,
1102            r: 1.0, g: 1.0, b: 1.0,
1103            intensity: 1.0,
1104        });
1105        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1106        assert_eq!(data.len(), 800 * 600 * 4 * 4);
1107    }
1108
1109    #[test]
1110    fn test_build_scene_data_occluder_partially_onscreen() {
1111        let mut radiance = RadianceState::default();
1112        // Occluder that straddles the bottom-right edge of the viewport
1113        radiance.occluders.push(Occluder {
1114            x: 750.0,
1115            y: 550.0,
1116            width: 200.0,
1117            height: 200.0,
1118        });
1119        // Camera at (400,300) → viewport covers (0,0)-(800,600)
1120        // Occluder covers (750,550)-(950,750), clipped to (750,550)-(800,600)
1121        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1122        let pixels: &[f32] = bytemuck::cast_slice(&data);
1123        // Check that an occluder pixel inside the viewport is set
1124        let idx = (560 * 800 + 760) * 4; // py=560, px=760 — inside the clipped region
1125        assert_eq!(pixels[idx + 3], 1.0);
1126    }
1127
1128    #[test]
1129    fn test_build_scene_data_occluder_far_offscreen() {
1130        let mut radiance = RadianceState::default();
1131        // Occluder very far off-screen in all negative directions
1132        radiance.occluders.push(Occluder {
1133            x: -10000.0,
1134            y: -10000.0,
1135            width: 50.0,
1136            height: 50.0,
1137        });
1138        radiance.emissives.push(EmissiveSurface {
1139            x: -10000.0,
1140            y: -10000.0,
1141            width: 50.0,
1142            height: 50.0,
1143            r: 1.0, g: 1.0, b: 1.0,
1144            intensity: 5.0,
1145        });
1146        // Should not panic
1147        let data = build_scene_data(800, 600, &radiance, &empty_lighting(), 400.0, 300.0, 800.0, 600.0);
1148        assert_eq!(data.len(), 800 * 600 * 4 * 4);
1149    }
1150}