From c92beb1b389fb1b35fd8eac871fb5372c5093b88 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Fri, 16 Oct 2026 01:07:56 +0400 Subject: Reject degenerate shadow receivers --- crates/fparkan-fx/src/shadow.rs | 71 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 7 deletions(-) (limited to 'crates/fparkan-fx/src') diff --git a/crates/fparkan-fx/src/shadow.rs b/crates/fparkan-fx/src/shadow.rs index 64e2b8e..19473ca 100644 --- a/crates/fparkan-fx/src/shadow.rs +++ b/crates/fparkan-fx/src/shadow.rs @@ -893,13 +893,11 @@ fn project_receivers( if !use_rays && !use_general { continue; } - let normal = normalize(receiver.normal).unwrap_or_else(|| { - normalize(cross( - sub(receiver.positions[1], receiver.positions[0]), - sub(receiver.positions[2], receiver.positions[0]), - )) - .unwrap_or([0.0, 0.0, 0.0]) - }); + // Reject collapsed geometry even when the caller supplied a valid fallback normal. + let Some(geometric_normal) = geometric_triangle_normal(receiver.positions) else { + continue; + }; + let normal = normalize(receiver.normal).unwrap_or(geometric_normal); let normal_term = dot(page.projection.shadow_axis, normal); if normal_term <= 0.0 || !normal_term.is_finite() { continue; @@ -1136,6 +1134,25 @@ fn validate_triangle(triangle: &ShadowTriangle, field: &'static str) -> Result<( Ok(()) } +fn geometric_triangle_normal(positions: [[f32; 3]; 3]) -> Option<[f32; 3]> { + let a = positions[0].map(f64::from); + let b = positions[1].map(f64::from); + let c = positions[2].map(f64::from); + let ab = std::array::from_fn::<_, 3, _>(|axis| b[axis] - a[axis]); + let ac = std::array::from_fn::<_, 3, _>(|axis| c[axis] - a[axis]); + let cross = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + let length_squared = cross.iter().map(|value| value * value).sum::(); + if !length_squared.is_finite() || length_squared == 0.0 { + return None; + } + let length = length_squared.sqrt(); + Some(cross.map(|value| (value / length) as f32)) +} + fn validate_light(light: &ShadowLight) -> Result<(), ShadowError> { match *light { ShadowLight::Directional { direction, rgb, .. } @@ -1753,6 +1770,46 @@ mod tests { assert_eq!(filtered.indices.len(), accepted.indices.len()); } + #[test] + fn degenerate_receivers_with_supplied_normal_are_skipped_without_losing_neighbors() { + let caster = caster(1, 1.0, 3, Vec::new()); + let light = ShadowLight::Directional { + direction: [0.0, 0.0, 1.0], + rgb: [1.0; 3], + active: true, + flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG, + }; + let valid = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0); + let point = ShadowTriangle { + positions: [[0.0, 0.0, 1.0]; 3], + normal: [0.0, 0.0, 1.0], + mask: 0, + }; + let collinear = ShadowTriangle { + positions: [[-1.0, 0.0, 1.0], [0.0, 0.0, 1.0], [1.0, 0.0, 1.0]], + normal: [0.0, 0.0, 1.0], + mask: 0, + }; + let build = |receivers: &[ShadowTriangle]| { + build_shadow_frame(ShadowScene { + lights: &[light], + casters: &[caster.clone()], + receivers, + fade: 1.0, + smooth_passes: 0, + camera: None, + lod: ShadowLodSettings::default(), + }) + .expect("shadow frame") + }; + + let expected = build(&[valid]); + let actual = build(&[point, collinear, valid]); + assert!(!expected.indices.is_empty(), "valid neighbor must render"); + assert_eq!(actual.vertices, expected.vertices); + assert_eq!(actual.indices, expected.indices); + } + fn triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle { ShadowTriangle { positions, -- cgit v1.2.3