From a8faba8aad441e87f8a8b94c148c01f9c075d621 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 11 Oct 2026 13:12:50 +0400 Subject: 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. --- crates/fparkan-fx/src/shadow.rs | 1785 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1785 insertions(+) create mode 100644 crates/fparkan-fx/src/shadow.rs (limited to 'crates/fparkan-fx/src/shadow.rs') 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, +} + +/// 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; 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 { + 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 { + 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 { + // 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, + threshold: u32, + ) -> Option { + 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 { + 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, + /// Projected receiver vertices. + pub vertices: Vec, + /// Triangle-list indices into [`ShadowFrame::vertices`]. + pub indices: Vec, +} + +/// 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, + /// 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 { + build_shadow_frame_inner(scene, None) +} + +fn build_shadow_frame_inner( + scene: ShadowScene<'_>, + mut cache: Option<&mut ShadowPageCache>, +) -> Result { + 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 { + 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 { + 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 { + 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(polygon: Vec, signed_distance: F) -> Vec +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 ¤t 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::(), + 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![ + [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::>(); + 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::>(); + assert_eq!(used.len(), expected_used); + let mut owners = used + .iter() + .map(|&index| cache.slots[index].expect("used slot owner").caster) + .collect::>(); + 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, + ) -> ShadowCaster { + ShadowCaster { + id, + world_axes: identity_axes(), + sphere: ShadowSphere { + center: [0.0; 3], + radius, + }, + native_kind, + triangles, + } + } +} -- cgit v1.2.3