aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-fx/src/shadow.rs
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-11 12:12:50 +0300
committerValentin Popov <valentin@popov.link>2026-10-11 12:12:50 +0300
commita8faba8aad441e87f8a8b94c148c01f9c075d621 (patch)
tree97aa4972990edba9991ff2e125edac3a7883f50f /crates/fparkan-fx/src/shadow.rs
parentaa51f3574d3d4d4e0fa59653eedd718fceb5d1bd (diff)
downloadfparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.tar.xz
fparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.zip
feat: complete map viewer scene and static CTL pose preview
Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
Diffstat (limited to 'crates/fparkan-fx/src/shadow.rs')
-rw-r--r--crates/fparkan-fx/src/shadow.rs1785
1 files changed, 1785 insertions, 0 deletions
diff --git a/crates/fparkan-fx/src/shadow.rs b/crates/fparkan-fx/src/shadow.rs
new file mode 100644
index 0000000..64e2b8e
--- /dev/null
+++ b/crates/fparkan-fx/src/shadow.rs
@@ -0,0 +1,1785 @@
+//! CPU reference implementation of the native projected shadow path.
+//!
+//! The original renderer keeps shadow pages in one 256 by 256 ARGB4444
+//! texture. This module deliberately keeps that work independent from a
+//! graphics backend: callers provide world-space caster and receiver
+//! triangles, and receive an RGBA8 atlas plus ordinary projected geometry.
+
+use std::error::Error;
+use std::fmt;
+
+/// Native shadow atlas width in texels.
+pub const SHADOW_ATLAS_WIDTH: usize = 256;
+/// Native shadow atlas height in texels.
+pub const SHADOW_ATLAS_HEIGHT: usize = 256;
+/// Number of native page slots.
+pub const SHADOW_PAGE_COUNT: usize = 28;
+/// Maximum number of shadow casters considered by the native frame builder.
+///
+/// The atlas has 28 physical slots, but the native `CShade` object allocates
+/// only 20 live shadow records per frame.
+pub const SHADOW_MAX_ACTIVE_CASTERS: usize = 20;
+/// Native primary directional-light flag.
+pub const SHADOW_PRIMARY_DIRECTIONAL_FLAG: u32 = 0x0800_0000;
+/// Full receiver flag used by the ordinary shadow query.
+///
+/// `ShadowTriangle::mask` contains the full decoded face flags. The native
+/// general query excludes a triangle when this bit is present.
+pub const SHADOW_RECEIVER_MASK: u32 = 0x2000;
+/// Compact query mask passed to the native four-ray world call.
+pub const SHADOW_FOUR_RAY_QUERY_MASK_COMPACT: u32 = 0x8008;
+/// Full receiver flags produced by expanding [`SHADOW_FOUR_RAY_QUERY_MASK_COMPACT`].
+///
+/// Terrain expands the compact descriptor before testing a receiver triangle,
+/// so this is the mask that belongs in `ShadowTriangle::mask` comparisons.
+pub const SHADOW_FOUR_RAY_MASK: u32 = 0x0020_0020;
+const SHADOW_PROJECTION_EPSILON: f32 = -1.175_494_4e-37;
+const SHADOW_CLIP_EPSILON: f32 = f32::MIN_POSITIVE;
+const SHADOW_QUAD_UV_MAX: f32 = 0.99;
+
+/// Bounding sphere used by native light selection and page projection.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowSphere {
+ /// World-space center.
+ pub center: [f32; 3],
+ /// Radius in world units.
+ pub radius: f32,
+}
+
+/// World-space triangle used by a caster or receiver.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowTriangle {
+ /// Triangle positions in world space.
+ pub positions: [[f32; 3]; 3],
+ /// World-space normal. `new` derives this value when the caller does not
+ /// already have the native normal.
+ pub normal: [f32; 3],
+ /// Full decoded native face flags used as receiver exclusion bits.
+ pub mask: u32,
+}
+
+/// A world-space shadow caster.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShadowCaster {
+ /// Stable caster id from the caller's object table.
+ pub id: u32,
+ /// Native object axes. Each entry is one world-space axis.
+ pub world_axes: [[f32; 3]; 3],
+ /// Native bounding sphere.
+ pub sphere: ShadowSphere,
+ /// Native object kind (`3` is the building path and `4` is the four-ray
+ /// actor path).
+ pub native_kind: u32,
+ /// Entire visible UNIT triangle set in world space.
+ pub triangles: Vec<ShadowTriangle>,
+}
+
+/// Camera values consumed by the native projected-size shadow LOD test.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowCamera {
+ /// World-space camera position.
+ pub position: [f32; 3],
+ /// Viewport width in pixels.
+ pub viewport_width: f32,
+ /// Horizontal field of view in radians.
+ pub horizontal_fov: f32,
+}
+
+/// Native shadow quality controls used by the page allocator and LOD test.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowLodSettings {
+ /// Minimum building LOD (`kind == 3`).
+ pub building_min_lod: u8,
+ /// Building screen-size multiplier.
+ pub building_detail: f32,
+ /// Minimum BTLU/robot LOD (`kind == 4`).
+ pub robot_min_lod: u8,
+ /// Robot screen-size multiplier.
+ pub robot_detail: f32,
+}
+
+impl Default for ShadowLodSettings {
+ fn default() -> Self {
+ Self {
+ building_min_lod: 1,
+ building_detail: 1.0,
+ robot_min_lod: 1,
+ robot_detail: 1.0,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowCacheSlot {
+ caster: u32,
+ age: u32,
+}
+
+/// Reusable native page ownership state.
+///
+/// The original allocator keeps page ownership and an age counter between
+/// frames. Keeping that small piece of state separate from the CPU raster
+/// output lets callers rebuild the atlas without changing a caster's UV page
+/// whenever the camera moves.
+#[derive(Clone, Debug)]
+pub struct ShadowPageCache {
+ slots: [Option<ShadowCacheSlot>; SHADOW_PAGE_COUNT],
+ /// Slots claimed while building the current frame. A slot already used
+ /// by an earlier candidate must not be evicted again by a later one.
+ frame_used: [bool; SHADOW_PAGE_COUNT],
+}
+
+impl Default for ShadowPageCache {
+ fn default() -> Self {
+ Self {
+ slots: [None; SHADOW_PAGE_COUNT],
+ frame_used: [false; SHADOW_PAGE_COUNT],
+ }
+ }
+}
+
+impl ShadowPageCache {
+ /// Builds a frame while preserving page ownership across calls.
+ pub fn build(&mut self, scene: ShadowScene<'_>) -> Result<ShadowFrame, ShadowError> {
+ self.begin_frame();
+ build_shadow_frame_inner(scene, Some(self))
+ }
+
+ /// Clears all page ownership. The next build will allocate from the
+ /// native preferred page groups again.
+ pub fn clear(&mut self) {
+ self.slots = [None; SHADOW_PAGE_COUNT];
+ self.frame_used = [false; SHADOW_PAGE_COUNT];
+ }
+
+ /// Returns the cached page slot for a caster, if it currently owns one.
+ #[must_use]
+ pub fn page_slot(&self, caster: u32) -> Option<usize> {
+ self.slots
+ .iter()
+ .enumerate()
+ .filter_map(|(index, slot)| {
+ slot.filter(|slot| slot.caster == caster)
+ .map(|slot| (index, slot.age))
+ })
+ .min_by_key(|&(_, age)| age)
+ .map(|(index, _)| index)
+ }
+
+ fn begin_frame(&mut self) {
+ self.frame_used = [false; SHADOW_PAGE_COUNT];
+ for slot in &mut self.slots {
+ if let Some(slot) = slot {
+ slot.age = slot.age.saturating_add(1);
+ }
+ }
+ }
+
+ fn assign(&mut self, caster: u32, lod: u8) -> Option<usize> {
+ // A native candidate is submitted at most once per frame. Preserve
+ // that invariant even when a caller repeats an id: falling through
+ // from the preferred group to the fallback group must not allocate a
+ // second page for the same caster.
+ if self.slots.iter().enumerate().any(|(index, slot)| {
+ self.frame_used[index] && slot.is_some_and(|slot| slot.caster == caster)
+ }) {
+ return None;
+ }
+ let large = lod <= 1;
+ let preferred = if large { 0..12 } else { 12..28 };
+ let fallback = if large { 12..28 } else { 0..28 };
+ let threshold = if large { 1 } else { 6 };
+ self.assign_range(caster, preferred, 6)
+ .or_else(|| self.assign_range(caster, fallback, threshold))
+ }
+
+ /// Applies the native allocator's inclusive-range search:
+ /// existing owner, first free slot, then the oldest eligible slot. A
+ /// current-frame slot is never eligible for reuse.
+ fn assign_range(
+ &mut self,
+ caster: u32,
+ range: std::ops::Range<usize>,
+ threshold: u32,
+ ) -> Option<usize> {
+ let mut oldest = None;
+ // 30BE0 scans the inclusive page range once. An existing owner or
+ // the first free page wins immediately; only an occupied page past
+ // the age threshold is deferred until the complete scan.
+ for index in range {
+ if self.frame_used[index] {
+ continue;
+ }
+ match self.slots[index] {
+ Some(slot) if slot.caster == caster => return self.claim(index, caster),
+ None => return self.claim(index, caster),
+ Some(slot) if slot.age >= threshold => {
+ // Native max-age selection keeps the later physical page
+ // on ties.
+ if oldest.is_none_or(|(age, old_index)| {
+ slot.age > age || (slot.age == age && index > old_index)
+ }) {
+ oldest = Some((slot.age, index));
+ }
+ }
+ Some(_) => {}
+ }
+ }
+ oldest.and_then(|(_, index)| self.claim(index, caster))
+ }
+
+ fn claim(&mut self, index: usize, caster: u32) -> Option<usize> {
+ if self.frame_used.get(index).copied().unwrap_or(true) {
+ return None;
+ }
+ self.frame_used[index] = true;
+ self.slots[index] = Some(ShadowCacheSlot { caster, age: 0 });
+ Some(index)
+ }
+}
+
+/// Native light records that can contribute to projected shadows.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum ShadowLight {
+ /// A directional sun or moon light.
+ Directional {
+ /// Native world-space direction.
+ direction: [f32; 3],
+ /// RGB intensity.
+ rgb: [f32; 3],
+ /// Whether the native slot is live.
+ active: bool,
+ /// Native light flags.
+ flags: u32,
+ },
+ /// A native point light.
+ Point {
+ /// World-space point-light position.
+ position: [f32; 3],
+ /// RGB intensity.
+ rgb: [f32; 3],
+ /// Light range.
+ range: f32,
+ /// Native attenuation coefficients.
+ coefficients: [f32; 3],
+ /// Whether the native slot is live.
+ active: bool,
+ /// Native light flags.
+ flags: u32,
+ },
+}
+
+/// The projection transform and opacity used by one atlas page.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowProjection {
+ /// Row-major matrix from shadow-local coordinates to world space.
+ pub world_from_shadow: [f32; 16],
+ /// Row-major matrix from world space to shadow-local coordinates.
+ pub shadow_from_world: [f32; 16],
+ /// Shadow axis (`M.Z`) used by the native receiver test.
+ pub shadow_axis: [f32; 3],
+ /// Shadow opacity before the receiver normal term.
+ pub opacity: f32,
+ /// Upper and lower native extrusion distances along `shadow_axis`.
+ pub upper_distance: f32,
+ pub lower_distance: f32,
+ /// Maximum light strength used by receiver opacity.
+ light_strength: f32,
+}
+
+/// One occupied native atlas page.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowPage {
+ /// Top-left atlas origin.
+ pub origin: [u32; 2],
+ /// Page side in texels, either 64 or 32.
+ pub size: u32,
+ /// Caster assigned to this page.
+ caster: u32,
+ /// Native projection transform for this page.
+ pub projection: ShadowProjection,
+}
+
+/// One vertex of a projected receiver shadow.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowVertex {
+ /// World-space receiver position.
+ pub position: [f32; 3],
+ /// Normalized UV into the 256 by 256 atlas.
+ pub uv: [f32; 2],
+ /// Receiver alpha after native light and normal terms.
+ pub alpha: f32,
+ /// Receiver normal passed to the backend.
+ pub normal: [f32; 3],
+}
+
+/// CPU-produced shadow frame.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShadowFrame {
+ /// RGBA8 atlas. Native `F000` texels become `[0, 0, 0, 255]`.
+ pub atlas_rgba8: Vec<u8>,
+ /// Projected receiver vertices.
+ pub vertices: Vec<ShadowVertex>,
+ /// Triangle-list indices into [`ShadowFrame::vertices`].
+ pub indices: Vec<u32>,
+}
+
+/// Inputs for one shadow build.
+pub struct ShadowScene<'a> {
+ /// Active light records.
+ pub lights: &'a [ShadowLight],
+ /// Visible world-space casters.
+ pub casters: &'a [ShadowCaster],
+ /// World-space receiver triangles.
+ pub receivers: &'a [ShadowTriangle],
+ /// Native fade multiplier before the `0.2` opacity scale.
+ pub fade: f32,
+ /// Native blur passes: zero, forward once, or forward then reverse.
+ pub smooth_passes: u32,
+ /// Camera used for native projected-size LOD selection. `None` keeps the
+ /// CPU reference useful for callers that already performed visibility and
+ /// LOD selection upstream.
+ pub camera: Option<ShadowCamera>,
+ /// Native shadow quality controls.
+ pub lod: ShadowLodSettings,
+}
+
+/// Shadow build failures at the renderer trust boundary.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ShadowError {
+ /// A caller supplied a non-finite or otherwise unusable value.
+ InvalidInput(&'static str),
+}
+
+impl fmt::Display for ShadowError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidInput(field) => write!(f, "invalid shadow input: {field}"),
+ }
+ }
+}
+
+impl Error for ShadowError {}
+
+/// Builds one native-style projected shadow frame.
+pub fn build_shadow_frame(scene: ShadowScene<'_>) -> Result<ShadowFrame, ShadowError> {
+ build_shadow_frame_inner(scene, None)
+}
+
+fn build_shadow_frame_inner(
+ scene: ShadowScene<'_>,
+ mut cache: Option<&mut ShadowPageCache>,
+) -> Result<ShadowFrame, ShadowError> {
+ if !scene.fade.is_finite() || scene.fade < 0.0 {
+ return Err(ShadowError::InvalidInput("fade"));
+ }
+ if let Some(camera) = scene.camera {
+ if !camera.position.iter().all(|value| value.is_finite())
+ || !camera.viewport_width.is_finite()
+ || camera.viewport_width <= 0.0
+ || !camera.horizontal_fov.is_finite()
+ || camera.horizontal_fov <= 0.0
+ {
+ return Err(ShadowError::InvalidInput("shadow camera"));
+ }
+ }
+ if !scene.lod.building_detail.is_finite()
+ || scene.lod.building_detail <= 0.0
+ || !scene.lod.robot_detail.is_finite()
+ || scene.lod.robot_detail <= 0.0
+ {
+ return Err(ShadowError::InvalidInput("shadow LOD settings"));
+ }
+ for light in scene.lights {
+ validate_light(light)?;
+ }
+ for receiver in scene.receivers {
+ validate_triangle(receiver, "receiver")?;
+ }
+
+ let mut frame = ShadowFrame {
+ atlas_rgba8: vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4],
+ vertices: Vec::new(),
+ indices: Vec::new(),
+ };
+ let mut pages = Vec::new();
+
+ let mut candidates = Vec::with_capacity(scene.casters.len().min(SHADOW_MAX_ACTIVE_CASTERS));
+ for caster in scene.casters {
+ validate_caster(caster)?;
+ let lod = scene
+ .camera
+ .map(|camera| projected_shadow_lod(caster, camera, scene.lod))
+ .unwrap_or(0);
+ if lod >= 8 {
+ continue;
+ }
+ candidates.push((caster, lod));
+ if candidates.len() == SHADOW_MAX_ACTIVE_CASTERS {
+ break;
+ }
+ }
+
+ let mut assignments = Vec::with_capacity(candidates.len());
+ for (caster, lod) in candidates {
+ let slot = match cache.as_deref_mut() {
+ Some(cache) => {
+ let Some(slot) = cache.assign(caster.id, lod) else {
+ // A page that is still too young, or a duplicate caster
+ // in this frame, is omitted by the native allocator. Do
+ // not invent a stateless slot: it could overwrite a page
+ // already assigned to an earlier candidate.
+ continue;
+ };
+ slot
+ }
+ None => assignments.len(),
+ };
+ assignments.push((slot, caster, lod));
+ }
+
+ // Terrain owns the candidate list and performs native radius/distance
+ // selection. The CPU contract therefore consumes the visible list in
+ // caller order while preserving the native 20-record frame budget. A
+ // cache supplies stable physical page slots; the stateless wrapper above
+ // retains the original deterministic slot order for simple callers.
+ for (slot, caster, _lod) in assignments {
+ let Some(contribution) = select_light(caster, scene.lights) else {
+ continue;
+ };
+ let (origin, size) = native_page_layout(slot);
+ let projection = make_projection(caster, contribution, scene.fade)?;
+ rasterize_page(
+ &mut frame.atlas_rgba8,
+ origin,
+ size,
+ caster,
+ projection,
+ scene.smooth_passes,
+ );
+ pages.push(ShadowPage {
+ origin: [origin[0] as u32, origin[1] as u32],
+ size: size as u32,
+ caster: caster.id,
+ projection,
+ });
+ }
+
+ // Page blur happens after all page rasterization and before receiver UVs
+ // are emitted, matching the native texture upload order.
+ for page in &pages {
+ project_receivers(&mut frame, page, scene.receivers, scene.casters);
+ }
+ Ok(frame)
+}
+
+/// Returns the fixed native page layout, including unused slots.
+#[cfg(test)]
+fn native_shadow_pages() -> Vec<([u32; 2], u32)> {
+ (0..SHADOW_PAGE_COUNT)
+ .map(native_page_layout)
+ .map(|(origin, size)| ([origin[0] as u32, origin[1] as u32], size as u32))
+ .collect()
+}
+
+#[derive(Clone, Copy, Debug)]
+struct LightContribution {
+ direction: [f32; 3],
+ strength: f32,
+}
+
+fn select_light(caster: &ShadowCaster, lights: &[ShadowLight]) -> Option<LightContribution> {
+ let center = caster.sphere.center;
+ let radius = caster.sphere.radius;
+ if caster.native_kind == 3 {
+ for light in lights {
+ if let ShadowLight::Directional {
+ direction,
+ rgb,
+ active: true,
+ flags,
+ } = *light
+ {
+ if flags & SHADOW_PRIMARY_DIRECTIONAL_FLAG == 0 {
+ continue;
+ }
+ let direction = normalize([direction[0], direction[1], direction[2] - 0.3])?;
+ let strength = length(rgb);
+ if strength > 0.0 && strength.is_finite() {
+ return Some(LightContribution {
+ direction,
+ strength,
+ });
+ }
+ }
+ }
+ return None;
+ }
+
+ let mut direction_sum = [0.0; 3];
+ let mut max_strength: f32 = 0.0;
+ for light in lights {
+ match *light {
+ ShadowLight::Directional {
+ direction,
+ rgb,
+ active: true,
+ flags,
+ } if flags & SHADOW_PRIMARY_DIRECTIONAL_FLAG != 0 => {
+ let direction = [direction[0], direction[1], direction[2] - 0.3];
+ let strength = length(rgb);
+ if strength > 0.0 && strength.is_finite() {
+ direction_sum = add(direction_sum, direction);
+ max_strength = max_strength.max(strength);
+ }
+ }
+ ShadowLight::Point {
+ position,
+ rgb,
+ range,
+ coefficients,
+ active: true,
+ flags,
+ } if flags & 0x8000_0000 == 0 && range >= 10.0 => {
+ let to_center = sub(center, position);
+ let distance = length(to_center);
+ if !(distance < range) || !distance.is_finite() {
+ continue;
+ }
+ let t = (range - distance) / range;
+ let strength = length(rgb)
+ * (coefficients[0] + coefficients[1] * t + coefficients[2] * t * t)
+ * 1.5;
+ if !(strength > 0.0) || !strength.is_finite() {
+ continue;
+ }
+ let Some(direction) = normalize(to_center) else {
+ continue;
+ };
+ let Some(direction) = normalize([direction[0], direction[1], -0.3]) else {
+ continue;
+ };
+ let contribution = (strength / 15.0 * range / (2.0 * radius)).min(2.0);
+ direction_sum = add(direction_sum, scale(direction, contribution));
+ max_strength = max_strength.max(strength);
+ }
+ _ => {}
+ }
+ }
+ // The ordinary-object path retains the magnitude of the accumulated
+ // vector; native uses it again in the receiver normal term.
+ if length(direction_sum) <= 1.0e-6 || !length(direction_sum).is_finite() {
+ return None;
+ }
+ Some(LightContribution {
+ direction: direction_sum,
+ strength: max_strength,
+ })
+}
+
+fn make_projection(
+ caster: &ShadowCaster,
+ light: LightContribution,
+ fade: f32,
+) -> Result<ShadowProjection, ShadowError> {
+ let center = caster.sphere.center;
+ let radius = caster.sphere.radius;
+ let light_direction =
+ normalize(light.direction).ok_or(ShadowError::InvalidInput("light direction"))?;
+ // Kind 3 arrives unit length. Other kinds intentionally keep the
+ // accumulated native vector magnitude in M.Z.
+ let shadow_axis = scale(light.direction, -1.0);
+ let mut axis = caster.world_axes[0];
+ let mut best = f32::INFINITY;
+ for candidate in caster.world_axes {
+ let Some(candidate) = normalize(candidate) else {
+ continue;
+ };
+ let score = dot(candidate, light_direction).abs();
+ if score < best {
+ best = score;
+ axis = candidate;
+ }
+ }
+ let mut u = normalize(cross(axis, shadow_axis));
+ if u.is_none() {
+ for candidate in identity_axes() {
+ u = normalize(cross(candidate, shadow_axis));
+ if u.is_some() {
+ break;
+ }
+ }
+ }
+ let u = u.ok_or(ShadowError::InvalidInput("world axes"))?;
+ let v = normalize(cross(shadow_axis, u)).ok_or(ShadowError::InvalidInput("shadow basis"))?;
+ let x_column = scale(v, 2.0 * radius);
+ let y_column = scale(u, -2.0 * radius);
+ let z_column = shadow_axis;
+ let world_from_shadow = matrix_from_columns(x_column, y_column, z_column, center);
+ let shadow_from_world = inverse_shadow_matrix(v, u, shadow_axis, center, radius);
+ Ok(ShadowProjection {
+ world_from_shadow,
+ shadow_from_world,
+ shadow_axis,
+ opacity: (fade * light.strength * 0.2).min(1.0),
+ upper_distance: 4.0 * radius,
+ lower_distance: -100.0,
+ light_strength: light.strength,
+ })
+}
+
+fn projected_shadow_lod(
+ caster: &ShadowCaster,
+ camera: ShadowCamera,
+ settings: ShadowLodSettings,
+) -> u8 {
+ let offset = sub(camera.position, caster.sphere.center);
+ let distance_squared = dot(offset, offset);
+ let radius_squared = caster.sphere.radius * caster.sphere.radius;
+ let measure = if distance_squared < radius_squared {
+ camera.viewport_width
+ } else {
+ let tangent_distance = (distance_squared - radius_squared).max(0.0).sqrt();
+ caster.sphere.radius.atan2(tangent_distance) * camera.viewport_width / camera.horizontal_fov
+ };
+ if !measure.is_finite() {
+ return 8;
+ }
+ match caster.native_kind {
+ 4 => {
+ let computed = if measure < 4.0 {
+ 8
+ } else if measure >= 70.0 {
+ 0
+ } else {
+ let scaled = measure * settings.robot_detail;
+ if scaled < 8.0 {
+ 3
+ } else if scaled < 30.0 {
+ 2
+ } else {
+ 1
+ }
+ };
+ computed.max(settings.robot_min_lod)
+ }
+ 3 => {
+ let computed = if measure < 10.0 {
+ 8
+ } else if measure >= 120.0 {
+ 1
+ } else {
+ let scaled = measure * settings.building_detail;
+ if scaled < 40.0 {
+ 3
+ } else if scaled < 80.0 {
+ 2
+ } else {
+ 1
+ }
+ };
+ computed.max(settings.building_min_lod)
+ }
+ _ => 8,
+ }
+}
+
+fn native_page_layout(index: usize) -> ([usize; 2], usize) {
+ // This is the physical order emitted by Terrain's page allocator, not a
+ // row-major packing approximation. The non-monotonic order is observable
+ // in the native page table and is part of the texture contract.
+ const LARGE: [[usize; 2]; 12] = [
+ [0, 0],
+ [0, 64],
+ [64, 64],
+ [64, 0],
+ [0, 128],
+ [0, 192],
+ [64, 192],
+ [64, 128],
+ [128, 0],
+ [128, 64],
+ [192, 64],
+ [192, 0],
+ ];
+ const SMALL: [[usize; 2]; 16] = [
+ [128, 128],
+ [128, 160],
+ [160, 160],
+ [160, 128],
+ [128, 192],
+ [128, 224],
+ [160, 224],
+ [160, 192],
+ [192, 192],
+ [192, 224],
+ [224, 224],
+ [224, 192],
+ [192, 128],
+ [192, 160],
+ [224, 160],
+ [224, 128],
+ ];
+ if index < LARGE.len() {
+ (LARGE[index], 64)
+ } else {
+ (SMALL[index - LARGE.len()], 32)
+ }
+}
+
+/// Matches the x86 `cvtt` helper used by Ngi103F0 with the renderer's
+/// default nearest-even rounding mode. `f32::round` is ties-away-from-zero,
+/// so it cannot be used for the native half-pixel boundaries.
+fn native_round(value: f32) -> isize {
+ debug_assert!(value.is_finite());
+ let lower = value.floor();
+ let fraction = value - lower;
+ let rounded = if fraction < 0.5 {
+ lower
+ } else if fraction > 0.5 {
+ lower + 1.0
+ } else if (lower as i64) & 1 == 0 {
+ lower
+ } else {
+ lower + 1.0
+ };
+ rounded as isize
+}
+
+fn rasterize_page(
+ atlas: &mut [u8],
+ origin: [usize; 2],
+ size: usize,
+ caster: &ShadowCaster,
+ projection: ShadowProjection,
+ smooth_passes: u32,
+) {
+ for triangle in &caster.triangles {
+ let points = triangle
+ .positions
+ .map(|position| transform_point(projection.shadow_from_world, position));
+ let area = cross2(sub2(points[1], points[0]), sub2(points[2], points[0]));
+ if !area.is_finite() || area < 0.0 {
+ continue;
+ }
+
+ // Ngi103F0 converts the projected endpoints with the active nearest,
+ // ties-to-even rounding mode. The raster loop visits rows below the
+ // rounded upper endpoint; evaluating the edge slopes at `y + 0.5`
+ // reproduces the native pixel-center scanline.
+ let min_y = points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::INFINITY, f32::min);
+ let max_y = points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::NEG_INFINITY, f32::max);
+ let first_y = native_round((min_y + 0.5) * size as f32);
+ let last_y = native_round((max_y + 0.5) * size as f32);
+ let first_y = first_y.max(0);
+ let last_y = last_y.min(size as isize);
+ if first_y >= last_y {
+ continue;
+ }
+ for y in first_y as usize..last_y as usize {
+ let local_y = (y as f32 + 0.5) / size as f32 - 0.5;
+ let mut intersections = [0.0_f32; 3];
+ let mut count = 0;
+ for edge in [(0usize, 1usize), (1, 2), (2, 0)] {
+ let a = points[edge.0];
+ let b = points[edge.1];
+ if ((a[1] <= local_y && b[1] > local_y) || (b[1] <= local_y && a[1] > local_y))
+ && count < intersections.len()
+ {
+ let t = (local_y - a[1]) / (b[1] - a[1]);
+ intersections[count] = a[0] + (b[0] - a[0]) * t;
+ count += 1;
+ }
+ }
+ if count < 2 {
+ continue;
+ }
+ let min_x = intersections[..count]
+ .iter()
+ .copied()
+ .fold(f32::INFINITY, f32::min);
+ let max_x = intersections[..count]
+ .iter()
+ .copied()
+ .fold(f32::NEG_INFINITY, f32::max);
+
+ // Ngi103F0 is also used for both X intersections. The native
+ // fill includes the rounded right endpoint, which matters for
+ // half-pixel and zero-width scanline cases.
+ let first = native_round((min_x + 0.5) * size as f32);
+ let last = native_round((max_x + 0.5) * size as f32);
+ let first = first.max(0);
+ let last = last.min(size as isize - 1);
+ if first > last {
+ continue;
+ }
+ for x in first as usize..=last as usize {
+ write_alpha_nibble(atlas, origin, size, x, y, 15);
+ }
+ }
+ }
+ if smooth_passes > 0 {
+ blur_page(atlas, origin, size, false);
+ }
+ if smooth_passes > 1 {
+ blur_page(atlas, origin, size, true);
+ }
+}
+
+fn blur_page(atlas: &mut [u8], origin: [usize; 2], size: usize, reverse: bool) {
+ if size < 2 {
+ return;
+ }
+
+ // FastProc3A368/24510 operates in place. The forward pass writes the
+ // top-left texel of each 2x2 window in row-major order; the last row and
+ // column are never visited. The optional reverse pass mirrors that
+ // traversal and writes the bottom-right texel, leaving the first row and
+ // column untouched. Native integer division always uses four samples,
+ // including at the page boundary.
+ if reverse {
+ for y in (1..size).rev() {
+ for x in (1..size).rev() {
+ let total = u32::from(read_alpha_nibble(atlas, origin, size, x - 1, y - 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y - 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x - 1, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y));
+ write_alpha_nibble(atlas, origin, size, x, y, (total / 4) as u8);
+ }
+ }
+ } else {
+ for y in 0..size - 1 {
+ for x in 0..size - 1 {
+ let total = u32::from(read_alpha_nibble(atlas, origin, size, x, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x + 1, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y + 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x + 1, y + 1));
+ write_alpha_nibble(atlas, origin, size, x, y, (total / 4) as u8);
+ }
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+struct ClipVertex {
+ position: [f32; 3],
+ uv: [f32; 2],
+}
+
+fn project_receivers(
+ frame: &mut ShadowFrame,
+ page: &ShadowPage,
+ receivers: &[ShadowTriangle],
+ casters: &[ShadowCaster],
+) {
+ let Some(caster) = casters.iter().find(|caster| caster.id == page.caster) else {
+ return;
+ };
+ // Kind-4 small actors select the four-ray receiver path once for the
+ // caster. The native query descriptor is compact 0x8008, but Landscape
+ // expands it to full face flags 0x00200020 before filtering triangles;
+ // all other casters use the ordinary full 0x2000 exclusion bit.
+ let ray_path = caster.native_kind == 4 && caster.sphere.radius < 10.0;
+ let ray_fade = ray_path.then(|| four_ray_fade(page, receivers));
+ for receiver in receivers {
+ let use_rays = ray_path && receiver.mask & SHADOW_FOUR_RAY_MASK == 0;
+ let use_general = !ray_path && receiver.mask & SHADOW_RECEIVER_MASK == 0;
+ 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])
+ });
+ let normal_term = dot(page.projection.shadow_axis, normal);
+ if normal_term <= 0.0 || !normal_term.is_finite() {
+ continue;
+ }
+
+ let plane_distance = dot(normal, receiver.positions[0]);
+ let corners = shadow_quad(&page.projection);
+ let mut projected = Vec::with_capacity(4);
+ for (upper, lower, uv) in corners.iter() {
+ let delta = sub(*lower, *upper);
+ if dot(normal, delta) >= SHADOW_PROJECTION_EPSILON {
+ projected.clear();
+ break;
+ }
+ let upper_distance = dot(normal, *upper) - plane_distance;
+ let lower_distance = dot(normal, *lower) - plane_distance;
+ if upper_distance < 0.0 || lower_distance >= 0.0 {
+ projected.clear();
+ break;
+ }
+ let denominator = upper_distance - lower_distance;
+ if denominator <= SHADOW_CLIP_EPSILON {
+ projected.clear();
+ break;
+ }
+ let t = (upper_distance / denominator).clamp(0.0, 1.0);
+ projected.push(ClipVertex {
+ position: lerp(*upper, *lower, t),
+ uv: *uv,
+ });
+ }
+ if projected.len() != 4 {
+ continue;
+ }
+
+ // Terrain's receiver routine clips the projected four-corner quad
+ // against the three receiver edge planes. Keeping UV with the point
+ // makes the later fan exactly the native interpolated quad.
+ for edge in 0..3 {
+ let a = receiver.positions[edge];
+ let b = receiver.positions[(edge + 1) % 3];
+ projected = clip_polygon(projected, |vertex| {
+ dot(cross(sub(b, a), sub(vertex.position, a)), normal)
+ });
+ }
+ projected.truncate(9);
+ if projected.len() < 3 {
+ continue;
+ }
+ let opacity = if use_rays {
+ (ray_fade.unwrap_or(0.0) * page.projection.light_strength * 0.2).min(1.0)
+ } else {
+ page.projection.opacity
+ };
+ let alpha = opacity * normal_term.sqrt();
+ if alpha <= 0.0 || !alpha.is_finite() {
+ continue;
+ }
+ let start = frame.vertices.len() as u32;
+ for vertex in &projected {
+ frame.vertices.push(ShadowVertex {
+ position: vertex.position,
+ uv: [
+ (page.origin[0] as f32 + vertex.uv[0] * page.size as f32)
+ / SHADOW_ATLAS_WIDTH as f32,
+ (page.origin[1] as f32 + vertex.uv[1] * page.size as f32)
+ / SHADOW_ATLAS_HEIGHT as f32,
+ ],
+ alpha,
+ normal,
+ });
+ }
+ for index in 1..projected.len() - 1 {
+ frame.indices.extend_from_slice(&[
+ start,
+ start + index as u32,
+ start + index as u32 + 1,
+ ]);
+ }
+ }
+}
+
+fn shadow_quad(projection: &ShadowProjection) -> [([f32; 3], [f32; 3], [f32; 2]); 4] {
+ let uv_max = SHADOW_QUAD_UV_MAX;
+ let corners = [[0.0, 0.0], [uv_max, 0.0], [uv_max, uv_max], [0.0, uv_max]];
+ corners.map(|corner| {
+ let local = [corner[0] - 0.5, corner[1] - 0.5];
+ let uv = corner;
+ (
+ transform_point(
+ projection.world_from_shadow,
+ [local[0], local[1], projection.upper_distance],
+ ),
+ transform_point(
+ projection.world_from_shadow,
+ [local[0], local[1], projection.lower_distance],
+ ),
+ uv,
+ )
+ })
+}
+
+/// Casts the four native shadow-quad segments against receiver query
+/// triangles. A miss contributes nothing; the native average divides by the
+/// number of successful ray hits rather than by four.
+fn four_ray_fade(page: &ShadowPage, receivers: &[ShadowTriangle]) -> f32 {
+ let corners = shadow_quad(&page.projection);
+ let mut sum = 0.0;
+ let mut hits = 0_u32;
+ for (upper, lower, _) in corners {
+ let delta = sub(lower, upper);
+ let mut nearest = f32::INFINITY;
+ for receiver in receivers {
+ if receiver.mask & SHADOW_FOUR_RAY_MASK != 0 {
+ continue;
+ }
+ if let Some(parameter) = segment_triangle_parameter(upper, delta, receiver) {
+ let hit = add(upper, scale(delta, parameter));
+ let offset = sub(hit, upper);
+ let distance_squared = dot(offset, offset);
+ nearest = nearest.min(distance_squared);
+ }
+ }
+ if nearest.is_finite() {
+ sum += (1.0 - nearest * 0.0001).clamp(0.0, 1.0);
+ hits += 1;
+ }
+ }
+ if hits == 0 {
+ 0.0
+ } else {
+ sum / hits as f32
+ }
+}
+
+fn segment_triangle_parameter(
+ origin: [f32; 3],
+ delta: [f32; 3],
+ triangle: &ShadowTriangle,
+) -> Option<f32> {
+ let edge1 = sub(triangle.positions[1], triangle.positions[0]);
+ let edge2 = sub(triangle.positions[2], triangle.positions[0]);
+ let p = cross(delta, edge2);
+ let determinant = dot(edge1, p);
+ if determinant.abs() <= SHADOW_CLIP_EPSILON {
+ return None;
+ }
+ let inverse = 1.0 / determinant;
+ let tvec = sub(origin, triangle.positions[0]);
+ let u = dot(tvec, p) * inverse;
+ if !(0.0..=1.0).contains(&u) {
+ return None;
+ }
+ let q = cross(tvec, edge1);
+ let v = dot(delta, q) * inverse;
+ if v < 0.0 || u + v > 1.0 {
+ return None;
+ }
+ let parameter = dot(edge2, q) * inverse;
+ (parameter >= 0.0 && parameter <= 1.0).then_some(parameter)
+}
+
+fn clip_polygon<F>(polygon: Vec<ClipVertex>, signed_distance: F) -> Vec<ClipVertex>
+where
+ F: Fn(ClipVertex) -> f32,
+{
+ if polygon.is_empty() {
+ return polygon;
+ }
+ let mut output = Vec::with_capacity(polygon.len() + 1);
+ let mut previous = *polygon.last().expect("non-empty polygon");
+ let mut previous_distance = signed_distance(previous);
+ for &current in &polygon {
+ let current_distance = signed_distance(current);
+ let previous_inside = previous_distance >= 0.0;
+ let current_inside = current_distance >= 0.0;
+ if previous_inside != current_inside {
+ let denominator = previous_distance - current_distance;
+ let t = if denominator.abs() > SHADOW_CLIP_EPSILON {
+ previous_distance / denominator
+ } else {
+ 0.0
+ };
+ output.push(interpolate_clip(previous, current, t));
+ }
+ if current_inside {
+ output.push(current);
+ }
+ previous = current;
+ previous_distance = current_distance;
+ }
+ output
+}
+
+fn interpolate_clip(a: ClipVertex, b: ClipVertex, t: f32) -> ClipVertex {
+ ClipVertex {
+ position: lerp(a.position, b.position, t),
+ uv: [
+ a.uv[0] + (b.uv[0] - a.uv[0]) * t,
+ a.uv[1] + (b.uv[1] - a.uv[1]) * t,
+ ],
+ }
+}
+
+fn validate_caster(caster: &ShadowCaster) -> Result<(), ShadowError> {
+ if !caster.sphere.radius.is_finite() || caster.sphere.radius <= 0.0 {
+ return Err(ShadowError::InvalidInput("caster radius"));
+ }
+ if !caster.sphere.center.iter().all(|value| value.is_finite())
+ || !caster
+ .world_axes
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ {
+ return Err(ShadowError::InvalidInput("caster transform"));
+ }
+ for triangle in &caster.triangles {
+ validate_triangle(triangle, "caster")?;
+ }
+ Ok(())
+}
+
+fn validate_triangle(triangle: &ShadowTriangle, field: &'static str) -> Result<(), ShadowError> {
+ if !triangle
+ .positions
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !triangle.normal.iter().all(|value| value.is_finite())
+ {
+ return Err(ShadowError::InvalidInput(field));
+ }
+ Ok(())
+}
+
+fn validate_light(light: &ShadowLight) -> Result<(), ShadowError> {
+ match *light {
+ ShadowLight::Directional { direction, rgb, .. }
+ if direction.iter().all(|value| value.is_finite())
+ && rgb.iter().all(|value| value.is_finite()) =>
+ {
+ Ok(())
+ }
+ ShadowLight::Point {
+ position,
+ rgb,
+ range,
+ coefficients,
+ ..
+ } if position.iter().all(|value| value.is_finite())
+ && rgb.iter().all(|value| value.is_finite())
+ && range.is_finite()
+ && coefficients.iter().all(|value| value.is_finite()) =>
+ {
+ Ok(())
+ }
+ _ => Err(ShadowError::InvalidInput("light")),
+ }
+}
+
+fn write_alpha_nibble(
+ atlas: &mut [u8],
+ origin: [usize; 2],
+ size: usize,
+ x: usize,
+ y: usize,
+ nibble: u8,
+) {
+ let index = ((origin[1] + y) * SHADOW_ATLAS_WIDTH + origin[0] + x) * 4;
+ if index + 3 < atlas.len() {
+ atlas[index] = 0;
+ atlas[index + 1] = 0;
+ atlas[index + 2] = 0;
+ atlas[index + 3] = nibble.saturating_mul(17);
+ }
+ let _ = size;
+}
+
+fn read_alpha_nibble(atlas: &[u8], origin: [usize; 2], _size: usize, x: usize, y: usize) -> u8 {
+ let index = ((origin[1] + y) * SHADOW_ATLAS_WIDTH + origin[0] + x) * 4;
+ atlas.get(index + 3).copied().unwrap_or(0) / 17
+}
+
+fn matrix_from_columns(x: [f32; 3], y: [f32; 3], z: [f32; 3], translation: [f32; 3]) -> [f32; 16] {
+ [
+ x[0],
+ y[0],
+ z[0],
+ translation[0],
+ x[1],
+ y[1],
+ z[1],
+ translation[1],
+ x[2],
+ y[2],
+ z[2],
+ translation[2],
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+}
+
+fn inverse_shadow_matrix(
+ v: [f32; 3],
+ u: [f32; 3],
+ shadow_axis: [f32; 3],
+ center: [f32; 3],
+ radius: f32,
+) -> [f32; 16] {
+ let x_scale = scale(v, 1.0 / (2.0 * radius));
+ let y_scale = scale(u, -1.0 / (2.0 * radius));
+ let z_scale = scale(shadow_axis, 1.0 / dot(shadow_axis, shadow_axis));
+ [
+ x_scale[0],
+ x_scale[1],
+ x_scale[2],
+ -dot(x_scale, center),
+ y_scale[0],
+ y_scale[1],
+ y_scale[2],
+ -dot(y_scale, center),
+ z_scale[0],
+ z_scale[1],
+ z_scale[2],
+ -dot(z_scale, center),
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+}
+
+fn transform_point(matrix: [f32; 16], point: [f32; 3]) -> [f32; 3] {
+ [
+ matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3],
+ matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7],
+ matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11],
+ ]
+}
+
+fn identity_axes() -> [[f32; 3]; 3] {
+ [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
+}
+
+fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
+}
+
+fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
+}
+
+fn scale(a: [f32; 3], scalar: f32) -> [f32; 3] {
+ [a[0] * scalar, a[1] * scalar, a[2] * scalar]
+}
+
+fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
+ add(a, scale(sub(b, a), t))
+}
+
+fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+}
+
+fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ]
+}
+
+fn cross2(a: [f32; 2], b: [f32; 2]) -> f32 {
+ a[0] * b[1] - a[1] * b[0]
+}
+
+fn sub2(a: [f32; 3], b: [f32; 3]) -> [f32; 2] {
+ [a[0] - b[0], a[1] - b[1]]
+}
+
+fn length(a: [f32; 3]) -> f32 {
+ dot(a, a).sqrt()
+}
+
+fn normalize(a: [f32; 3]) -> Option<[f32; 3]> {
+ let length = length(a);
+ (length > 1.0e-6 && length.is_finite()).then(|| scale(a, 1.0 / length))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn native_layout_has_twelve_large_and_sixteen_small_pages() {
+ let pages = native_shadow_pages();
+ assert_eq!(pages.len(), 28);
+ assert_eq!(
+ pages[..12].iter().map(|(_, size)| *size).sum::<u32>(),
+ 12 * 64
+ );
+ assert!(pages[..12].iter().all(|(_, size)| *size == 64));
+ assert!(pages[12..].iter().all(|(_, size)| *size == 32));
+ assert_eq!(
+ pages.iter().map(|(origin, _)| *origin).collect::<Vec<_>>(),
+ vec![
+ [0, 0],
+ [0, 64],
+ [64, 64],
+ [64, 0],
+ [0, 128],
+ [0, 192],
+ [64, 192],
+ [64, 128],
+ [128, 0],
+ [128, 64],
+ [192, 64],
+ [192, 0],
+ [128, 128],
+ [128, 160],
+ [160, 160],
+ [160, 128],
+ [128, 192],
+ [128, 224],
+ [160, 224],
+ [160, 192],
+ [192, 192],
+ [192, 224],
+ [224, 224],
+ [224, 192],
+ [192, 128],
+ [192, 160],
+ [224, 160],
+ [224, 128],
+ ]
+ );
+ }
+
+ #[test]
+ fn directional_shadow_raster_and_receiver_projection_are_concrete() {
+ let caster_triangle = triangle([[-1.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, -1.0, 0.0]], 0);
+ let caster = caster(7, 2.0, 1, vec![caster_triangle]);
+ let receiver = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0, 1.0, 1.0],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[receiver],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("shadow frame");
+ assert_eq!(frame.atlas_rgba8.len(), 256 * 256 * 4);
+ assert!(frame.atlas_rgba8.chunks_exact(4).any(|rgba| rgba[3] == 255));
+ assert!(!frame.vertices.is_empty());
+ assert_eq!(frame.indices.len() % 3, 0);
+ assert!(frame.vertices.iter().all(|vertex| vertex.alpha > 0.0));
+ }
+
+ #[test]
+ fn point_light_range_and_native_flags_gate_pages() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let inactive = ShadowLight::Point {
+ position: [0.0, 0.0, -1.0],
+ rgb: [1.0; 3],
+ range: 20.0,
+ coefficients: [1.0, 0.0, 0.0],
+ active: true,
+ flags: 0x8000_0000,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[inactive],
+ casters: &[caster],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("valid empty frame");
+ assert!(frame.atlas_rgba8.iter().all(|byte| *byte == 0));
+ }
+
+ #[test]
+ fn ordinary_directional_uses_primary_raw_direction_and_max_strength_only() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let lights = [
+ ShadowLight::Directional {
+ direction: [2.0, 0.0, 4.0],
+ rgb: [0.0, 0.0, 3.0],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ },
+ ShadowLight::Directional {
+ direction: [100.0, 0.0, 100.0],
+ rgb: [100.0; 3],
+ active: true,
+ flags: 0x1000_0000,
+ },
+ ];
+ let contribution = select_light(&caster, &lights).expect("primary directional");
+ assert_eq!(contribution.direction, [2.0, 0.0, 3.7]);
+ assert!((contribution.strength - 3.0).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn point_direction_overwrites_normalized_z_before_second_normalize() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let light = ShadowLight::Point {
+ position: [0.0, 0.0, -5.0],
+ rgb: [1.0; 3],
+ range: 20.0,
+ coefficients: [1.0, 0.0, 0.0],
+ active: true,
+ flags: 0,
+ };
+ let contribution = select_light(&caster, &[light]).expect("point light");
+ let expected_strength = 3.0_f32.sqrt() * 1.5;
+ let expected_contribution = expected_strength / 15.0 * 20.0 / 2.0;
+ assert!((contribution.direction[2] + expected_contribution).abs() < 1.0e-6);
+ assert!(contribution.direction[0].abs() < 1.0e-6);
+ assert!((contribution.strength - expected_strength).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn four_ray_fade_averages_successes_and_zeroes_all_misses() {
+ let caster = caster(1, 1.0, 4, 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 projection = make_projection(
+ &caster,
+ select_light(&caster, &[light]).expect("light"),
+ 1.0,
+ )
+ .expect("projection");
+ let page = ShadowPage {
+ origin: [0, 0],
+ size: 64,
+ caster: 1,
+ projection,
+ };
+ let partial = triangle([[-1.0, 1.0, 1.0], [0.98, 1.0, 1.0], [-1.0, -0.98, 1.0]], 0);
+ let fade = four_ray_fade(&page, &[partial]);
+ assert!(
+ fade > 0.9,
+ "three successful rays must not be divided by four"
+ );
+ let fifty_world_units = triangle(
+ [
+ [-100.0, -100.0, 47.2],
+ [100.0, -100.0, 47.2],
+ [0.0, 100.0, 47.2],
+ ],
+ 0,
+ );
+ let fade_at_fifty = four_ray_fade(&page, &[fifty_world_units]);
+ assert!(
+ (fade_at_fifty - 0.75).abs() < 1.0e-6,
+ "fade at 50 world units: {fade_at_fifty}"
+ );
+ let miss = triangle([[10.0, 10.0, 1.0], [11.0, 10.0, 1.0], [10.0, 11.0, 1.0]], 0);
+ assert_eq!(four_ray_fade(&page, &[miss]), 0.0);
+ let excluded = triangle(
+ [[-1.0, 1.0, 1.0], [0.98, 1.0, 1.0], [-1.0, -0.98, 1.0]],
+ SHADOW_FOUR_RAY_MASK,
+ );
+ assert_eq!(four_ray_fade(&page, &[excluded]), 0.0);
+ }
+
+ #[test]
+ fn ordinary_alpha_keeps_native_hdr_normal_term_after_opacity_clamp() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let receiver = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 10.0],
+ rgb: [100.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[receiver],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("HDR shadow frame");
+ assert!(frame.vertices.iter().any(|vertex| vertex.alpha > 1.0));
+ }
+
+ #[test]
+ fn smoothing_uses_nibble_quantization() {
+ let caster_triangle = triangle([[-1.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, -1.0, 0.0]], 0);
+ let caster = caster(1, 2.0, 1, vec![caster_triangle]);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 2,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("smooth frame");
+ assert!(frame
+ .atlas_rgba8
+ .chunks_exact(4)
+ .any(|rgba| rgba[3] > 0 && rgba[3] < 255 && rgba[3] % 17 == 0));
+ }
+
+ #[test]
+ fn native_rounding_and_blur_preserve_asymmetric_page_edges() {
+ assert_eq!(native_round(1.5), 2);
+ assert_eq!(native_round(2.5), 2);
+ assert_eq!(native_round(-0.5), 0);
+ assert_eq!(native_round(-1.5), -2);
+
+ let size = 4;
+ let origin = [0, 0];
+ let mut atlas = vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4];
+ for y in 0..size {
+ for x in 0..size {
+ write_alpha_nibble(&mut atlas, origin, size, x, y, (y * size + x) as u8);
+ }
+ }
+ blur_page(&mut atlas, origin, size, false);
+ let expected_forward = [
+ [2, 3, 4, 3],
+ [6, 7, 8, 7],
+ [10, 11, 12, 11],
+ [12, 13, 14, 15],
+ ];
+ for (y, row) in expected_forward.iter().enumerate() {
+ for (x, expected) in row.iter().enumerate() {
+ assert_eq!(
+ read_alpha_nibble(&atlas, origin, size, x, y),
+ *expected,
+ "forward blur at ({x},{y})"
+ );
+ }
+ }
+
+ let mut reverse_atlas = vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4];
+ for y in 0..size {
+ for x in 0..size {
+ write_alpha_nibble(&mut reverse_atlas, origin, size, x, y, (y * size + x) as u8);
+ }
+ }
+ blur_page(&mut reverse_atlas, origin, size, true);
+ let expected_reverse = [[0, 1, 2, 3], [4, 2, 3, 4], [8, 6, 7, 8], [12, 10, 11, 12]];
+ for (y, row) in expected_reverse.iter().enumerate() {
+ for (x, expected) in row.iter().enumerate() {
+ assert_eq!(
+ read_alpha_nibble(&reverse_atlas, origin, size, x, y),
+ *expected,
+ "reverse blur at ({x},{y})"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn native_lod_uses_projected_measure_and_reuses_page_slots() {
+ let mut building = caster(41, 1.0, 3, Vec::new());
+ building.sphere.center = [0.0, 0.0, 1.05];
+ let camera = ShadowCamera {
+ position: [0.0, 0.0, 0.0],
+ viewport_width: 100.0,
+ horizontal_fov: std::f32::consts::FRAC_PI_2,
+ };
+ let close_lod = projected_shadow_lod(&building, camera, ShadowLodSettings::default());
+ assert_eq!(close_lod, 1);
+
+ let mut cache = ShadowPageCache::default();
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let first = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &[building.clone()],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: Some(camera),
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("first cached frame");
+ assert_eq!(
+ first.atlas_rgba8.len(),
+ SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4
+ );
+ let slot = cache.page_slot(building.id).expect("cached page");
+ let second = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &[building],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: Some(camera),
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("reused cached frame");
+ assert_eq!(cache.page_slot(41), Some(slot));
+ assert_eq!(second.atlas_rgba8.len(), first.atlas_rgba8.len());
+ }
+
+ #[test]
+ fn native_lod_rejects_small_and_unknown_shadow_objects() {
+ let camera = ShadowCamera {
+ position: [0.0, 0.0, 0.0],
+ viewport_width: 100.0,
+ horizontal_fov: std::f32::consts::FRAC_PI_2,
+ };
+ let mut robot = caster(1, 1.0, 4, Vec::new());
+ robot.sphere.center = [0.0, 0.0, 100.0];
+ assert_eq!(
+ projected_shadow_lod(&robot, camera, ShadowLodSettings::default()),
+ 8
+ );
+ let unknown = caster(2, 100.0, 10, Vec::new());
+ assert_eq!(
+ projected_shadow_lod(&unknown, camera, ShadowLodSettings::default()),
+ 8
+ );
+ }
+
+ #[test]
+ fn page_cache_keeps_current_frame_slots_unique_and_rejects_stale_overflow() {
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let casters = (0..40)
+ .map(|id| caster(id, 1.0, 3, Vec::new()))
+ .collect::<Vec<_>>();
+ let mut cache = ShadowPageCache::default();
+
+ for (start, expected_used) in [(0, 20), (20, 16), (20, 16)] {
+ let selected = casters[start..start + 20].to_vec();
+ let _frame = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &selected,
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("cached multi-frame shadow build");
+
+ let used = cache
+ .frame_used
+ .iter()
+ .enumerate()
+ .filter_map(|(index, used)| used.then_some(index))
+ .collect::<Vec<_>>();
+ assert_eq!(used.len(), expected_used);
+ let mut owners = used
+ .iter()
+ .map(|&index| cache.slots[index].expect("used slot owner").caster)
+ .collect::<Vec<_>>();
+ owners.sort_unstable();
+ owners.dedup();
+ assert_eq!(owners.len(), used.len(), "two current casters share a page");
+
+ for id in 0..40 {
+ let owner_count = cache
+ .slots
+ .iter()
+ .flatten()
+ .filter(|slot| slot.caster == id)
+ .count();
+ assert!(owner_count <= 1, "caster {id} owns duplicate page slots");
+ }
+ }
+
+ // The second frame has more new casters than the aged fallback range
+ // can accept. Rejected records must remain unowned rather than being
+ // assigned an occupied stateless index.
+ assert!(cache.page_slot(36).is_none());
+ assert!(cache.page_slot(37).is_none());
+ assert!(cache.page_slot(38).is_none());
+ assert!(cache.page_slot(39).is_none());
+ }
+
+ #[test]
+ fn receiver_masks_are_full_exclusions() {
+ let caster = caster(1, 1.0, 4, 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 ground = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let excluded = triangle(
+ [[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]],
+ SHADOW_FOUR_RAY_MASK,
+ );
+ let accepted = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster.clone()],
+ receivers: &[ground],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("unmasked ground receiver");
+ let filtered = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[ground, excluded],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("masked receiver");
+ assert_eq!(filtered.vertices.len(), accepted.vertices.len());
+ assert_eq!(filtered.indices.len(), accepted.indices.len());
+ }
+
+ fn triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
+ ShadowTriangle {
+ positions,
+ normal: normalize(cross(
+ sub(positions[1], positions[0]),
+ sub(positions[2], positions[0]),
+ ))
+ .unwrap_or([0.0; 3]),
+ mask,
+ }
+ }
+
+ fn caster(
+ id: u32,
+ radius: f32,
+ native_kind: u32,
+ triangles: Vec<ShadowTriangle>,
+ ) -> ShadowCaster {
+ ShadowCaster {
+ id,
+ world_axes: identity_axes(),
+ sphere: ShadowSphere {
+ center: [0.0; 3],
+ radius,
+ },
+ native_kind,
+ triangles,
+ }
+ }
+}