Skip to content

Commit a8c6e9e

Browse files
jsvdclaude
andcommitted
Fix radiance GI crash when occluders/emissives are off-screen
Negative pixel coordinates from off-screen objects were cast from i32 to usize without a lower-bound clamp, wrapping to ~2^64 and causing an index-out-of-bounds panic in build_scene_data. Added .max(0) before .min(w/h) on px1/py1 for both emissive and occluder rasterization. Extracted build_scene_data as a free function for testability and added 5 regression tests covering off-screen left/above, partially on-screen, and far off-screen cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1ca11c3 commit a8c6e9e

1 file changed

Lines changed: 117 additions & 6 deletions

File tree

core/src/renderer/radiance.rs

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -533,8 +533,6 @@ impl RadiancePipeline {
533533
});
534534
}
535535

536-
/// Build the scene texture data from emissives, occluders, point lights,
537-
/// directional lights, and spot lights.
538536
fn build_scene_data(
539537
&self,
540538
scene_w: u32,
@@ -546,6 +544,22 @@ impl RadiancePipeline {
546544
viewport_w: f32,
547545
viewport_h: f32,
548546
) -> 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.
553+
fn 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> {
549563
let w = scene_w as usize;
550564
let h = scene_h as usize;
551565
// Rgba32Float: 4 channels × 4 bytes = 16 bytes per pixel
@@ -559,8 +573,8 @@ impl RadiancePipeline {
559573
for em in &radiance.emissives {
560574
let px0 = ((em.x - world_left) as i32).max(0) as usize;
561575
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;
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;
564578

565579
let er = em.r * em.intensity;
566580
let eg = em.g * em.intensity;
@@ -632,8 +646,8 @@ impl RadiancePipeline {
632646
for occ in &radiance.occluders {
633647
let px0 = ((occ.x - world_left) as i32).max(0) as usize;
634648
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;
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;
637651

638652
for py in py0..py1 {
639653
for px in px0..px1 {
@@ -647,6 +661,7 @@ impl RadiancePipeline {
647661
bytemuck::cast_slice(&pixels).to_vec()
648662
}
649663

664+
impl RadiancePipeline {
650665
/// Execute the full radiance cascade pipeline for one frame.
651666
/// Returns true if the light texture was computed and the compose pass should run.
652667
pub fn compute(
@@ -1036,4 +1051,100 @@ mod tests {
10361051
};
10371052
assert_eq!(sl.range, 300.0);
10381053
}
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+
}
10391150
}

0 commit comments

Comments
 (0)