@@ -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