1843 lines
62 KiB
Rust
1843 lines
62 KiB
Rust
//! 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;
|
|
}
|
|
// Reject collapsed geometry even when the caller supplied a valid fallback normal.
|
|
let Some(geometric_normal) = geometric_triangle_normal(receiver.positions) else {
|
|
continue;
|
|
};
|
|
let normal = normalize(receiver.normal).unwrap_or(geometric_normal);
|
|
let normal_term = dot(page.projection.shadow_axis, normal);
|
|
if normal_term <= 0.0 || !normal_term.is_finite() {
|
|
continue;
|
|
}
|
|
|
|
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 ¤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 geometric_triangle_normal(positions: [[f32; 3]; 3]) -> Option<[f32; 3]> {
|
|
let a = positions[0].map(f64::from);
|
|
let b = positions[1].map(f64::from);
|
|
let c = positions[2].map(f64::from);
|
|
let ab = std::array::from_fn::<_, 3, _>(|axis| b[axis] - a[axis]);
|
|
let ac = std::array::from_fn::<_, 3, _>(|axis| c[axis] - a[axis]);
|
|
let cross = [
|
|
ab[1] * ac[2] - ab[2] * ac[1],
|
|
ab[2] * ac[0] - ab[0] * ac[2],
|
|
ab[0] * ac[1] - ab[1] * ac[0],
|
|
];
|
|
let length_squared = cross.iter().map(|value| value * value).sum::<f64>();
|
|
if !length_squared.is_finite() || length_squared == 0.0 {
|
|
return None;
|
|
}
|
|
let length = length_squared.sqrt();
|
|
Some(cross.map(|value| (value / length) as f32))
|
|
}
|
|
|
|
fn validate_light(light: &ShadowLight) -> Result<(), ShadowError> {
|
|
match *light {
|
|
ShadowLight::Directional { direction, rgb, .. }
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn degenerate_receivers_with_supplied_normal_are_skipped_without_losing_neighbors() {
|
|
let caster = caster(1, 1.0, 3, Vec::new());
|
|
let light = ShadowLight::Directional {
|
|
direction: [0.0, 0.0, 1.0],
|
|
rgb: [1.0; 3],
|
|
active: true,
|
|
flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
|
|
};
|
|
let valid = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
|
|
let point = ShadowTriangle {
|
|
positions: [[0.0, 0.0, 1.0]; 3],
|
|
normal: [0.0, 0.0, 1.0],
|
|
mask: 0,
|
|
};
|
|
let collinear = ShadowTriangle {
|
|
positions: [[-1.0, 0.0, 1.0], [0.0, 0.0, 1.0], [1.0, 0.0, 1.0]],
|
|
normal: [0.0, 0.0, 1.0],
|
|
mask: 0,
|
|
};
|
|
let build = |receivers: &[ShadowTriangle]| {
|
|
build_shadow_frame(ShadowScene {
|
|
lights: &[light],
|
|
casters: &[caster.clone()],
|
|
receivers,
|
|
fade: 1.0,
|
|
smooth_passes: 0,
|
|
camera: None,
|
|
lod: ShadowLodSettings::default(),
|
|
})
|
|
.expect("shadow frame")
|
|
};
|
|
|
|
let expected = build(&[valid]);
|
|
let actual = build(&[point, collinear, valid]);
|
|
assert!(!expected.indices.is_empty(), "valid neighbor must render");
|
|
assert_eq!(actual.vertices, expected.vertices);
|
|
assert_eq!(actual.indices, expected.indices);
|
|
}
|
|
|
|
fn triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
|
|
ShadowTriangle {
|
|
positions,
|
|
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,
|
|
}
|
|
}
|
|
}
|