diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-fx/src/ambient_sound.rs | 288 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/sky.rs | 67 | ||||
| -rw-r--r-- | crates/fparkan-render/src/lib.rs | 42 | ||||
| -rw-r--r-- | crates/fparkan-render/src/water.rs | 108 | ||||
| -rw-r--r-- | crates/fparkan-terrain-format/src/lib.rs | 37 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 80 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lod.rs | 301 |
8 files changed, 911 insertions, 13 deletions
diff --git a/crates/fparkan-fx/src/ambient_sound.rs b/crates/fparkan-fx/src/ambient_sound.rs new file mode 100644 index 0000000..bcc1814 --- /dev/null +++ b/crates/fparkan-fx/src/ambient_sound.rs @@ -0,0 +1,288 @@ +//! Opcode-2 sound commands, independent of an audio device. + +use crate::environment::{SoundAction, SoundEvent}; +use crate::{FxCommand, FxOpcode}; + +/// A validated ordinary Op2 command. Modes 1/3 request an unverified backend +/// flag and are left to the caller's unsupported-command report. +#[derive(Clone, Debug)] +pub struct Op2SoundCommand { + looping: bool, + start: f32, + end: f32, + position: [f32; 3], + position_delta: [f32; 3], + min_distance: f32, + max_distance: f32, + frequency: f32, + frequency_delta: f32, + archive: String, + name: String, +} + +/// Playback crossing state belongs to a command instance, even when several +/// instances share the same sample. +#[derive(Clone, Debug, Default)] +pub struct Op2SoundState { + previous_phase: f32, +} + +impl Op2SoundCommand { + /// Decodes the supported native modes 0 (once) and 2 (loop). + #[must_use] + pub fn from_command(command: &FxCommand) -> Option<Self> { + let body = command.raw_body.as_ref(); + if command.opcode != FxOpcode::Op2 || body.len() != 144 { + return None; + } + let mode = u32::from_le_bytes(body[..4].try_into().ok()?); + // shortcut: backend flag 0x200 and body+76 need a native playback + // comparison before modes 1/3 or nonzero backend parameters are enabled. + if !matches!(mode, 0 | 2) || body[76..80] != [0; 4] { + return None; + } + let float = |offset| { + let value = f32::from_le_bytes(body[offset..offset + 4].try_into().ok()?); + value.is_finite().then_some(value) + }; + let vector = |offset| Some([float(offset)?, float(offset + 4)?, float(offset + 8)?]); + let start = float(4)?; + let end = float(8)?; + let position = vector(12)?; + let position_end = vector(24)?; + let min_distance = float(60)?; + let max_distance = float(64)?; + let frequency = float(68)?; + let frequency_end = float(72)?; + let position_delta = std::array::from_fn(|axis| position_end[axis] - position[axis]); + let frequency_delta = frequency_end - frequency; + if end <= start + || !(end - start).is_finite() + || !position_delta.iter().all(|value: &f32| value.is_finite()) + || !frequency_delta.is_finite() + || min_distance < 0.0 + || max_distance < min_distance + || frequency <= 0.0 + || frequency_end <= 0.0 + { + return None; + } + let text = |bytes: &[u8]| { + let end = bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(bytes.len()); + (!bytes[..end].is_empty()).then(|| String::from_utf8_lossy(&bytes[..end]).into_owned()) + }; + Some(Self { + looping: mode == 2, + start, + end, + position, + position_delta, + min_distance, + max_distance, + frequency, + frequency_delta, + archive: text(&body[80..112])?, + name: text(&body[112..144])?, + }) + } + + /// Evaluates Effect.dll's phase crossings and current source parameters. + /// `playing` comes from the audio owner, since a one-shot can finish between + /// updates. The matrix maps local command coordinates into world space. + #[must_use] + pub fn update( + &self, + state: &mut Op2SoundState, + phase: f32, + world_from_effect: [[f32; 4]; 4], + playing: bool, + ) -> Option<SoundEvent> { + if !phase.is_finite() || !world_from_effect.iter().flatten().all(|v| v.is_finite()) { + return None; + } + let action = if self.looping { + if phase >= self.start && phase <= self.end { + if playing { + SoundAction::SetLoopVolume + } else { + SoundAction::StartLoop + } + } else if playing { + SoundAction::StopLoop + } else { + return None; + } + } else { + let crossing_phase = if phase == 1.0 { 0.0 } else { phase }; + let crossed = state.previous_phase <= crossing_phase + && state.previous_phase <= self.start + && self.start < crossing_phase; + state.previous_phase = crossing_phase; + if crossed { + SoundAction::OneShot + } else if playing { + SoundAction::SetLoopVolume + } else { + return None; + } + }; + let t = (phase - self.start) / (self.end - self.start); + let local: [f32; 3] = + std::array::from_fn(|axis| self.position[axis] + t * self.position_delta[axis]); + let position = std::array::from_fn(|axis| { + let row = world_from_effect[axis]; + row[0] * local[0] + row[1] * local[1] + row[2] * local[2] + row[3] + }); + let frequency_ratio = self.frequency + t * self.frequency_delta; + if !position.iter().all(|value| value.is_finite()) || !frequency_ratio.is_finite() { + return None; + } + Some(SoundEvent { + archive: self.archive.clone(), + name: self.name.clone(), + position, + volume: 1.0, + min_distance: self.min_distance, + max_distance: self.max_distance, + frequency_ratio, + action, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + const IDENTITY: [[f32; 4]; 4] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + + fn command(mode: u32) -> FxCommand { + let mut body = vec![0; 144]; + body[..4].copy_from_slice(&mode.to_le_bytes()); + for (offset, value) in [ + (4, 0.25_f32), + (8, 0.75), + (60, 12.0), + (64, 80.0), + (68, 1.0), + (72, 2.0), + ] { + body[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + body[80..90].copy_from_slice(b"sounds.lib"); + body[112..122].copy_from_slice(b"f_defl.wav"); + FxCommand { + word: 2, + opcode: FxOpcode::Op2, + native_flag: false, + raw_body: Arc::from(body), + resource_refs: vec![], + } + } + + #[test] + fn loop_has_inclusive_boundaries_and_updates_without_restarting() { + let command = Op2SoundCommand::from_command(&command(2)).unwrap(); + let mut state = Op2SoundState::default(); + assert!(command.update(&mut state, 0.0, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.25, IDENTITY, false) + .unwrap() + .action, + SoundAction::StartLoop + ); + assert_eq!( + command + .update(&mut state, 0.75, IDENTITY, true) + .unwrap() + .action, + SoundAction::SetLoopVolume + ); + assert_eq!( + command + .update(&mut state, 0.8, IDENTITY, true) + .unwrap() + .action, + SoundAction::StopLoop + ); + assert!(command.update(&mut state, 0.8, IDENTITY, false).is_none()); + } + + #[test] + fn one_shot_crossing_is_strict_and_rearms_after_phase_one() { + let command = Op2SoundCommand::from_command(&command(0)).unwrap(); + let mut state = Op2SoundState::default(); + assert!(command.update(&mut state, 0.25, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.3, IDENTITY, false) + .unwrap() + .action, + SoundAction::OneShot + ); + assert_eq!( + command + .update(&mut state, 0.5, IDENTITY, true) + .unwrap() + .action, + SoundAction::SetLoopVolume + ); + assert!(command.update(&mut state, 0.6, IDENTITY, false).is_none()); + assert!(command.update(&mut state, 1.0, IDENTITY, false).is_none()); + assert_eq!( + command + .update(&mut state, 0.3, IDENTITY, false) + .unwrap() + .action, + SoundAction::OneShot + ); + } + + #[test] + fn parameters_keep_native_constant_ranges_and_transform_the_position() { + let mut source = command(2); + let mut body = source.raw_body.to_vec(); + body[24..28].copy_from_slice(&4.0_f32.to_le_bytes()); + source.raw_body = Arc::from(body); + let command = Op2SoundCommand::from_command(&source).unwrap(); + let mut matrix = IDENTITY; + matrix[0] = [0.0, -1.0, 0.0, 10.0]; + matrix[1] = [2.0, 0.0, 0.0, 20.0]; + let event = command + .update(&mut Op2SoundState::default(), 0.5, matrix, false) + .unwrap(); + assert_eq!(event.position, [10.0, 24.0, 0.0]); + assert_eq!( + ( + event.min_distance, + event.max_distance, + event.frequency_ratio + ), + (12.0, 80.0, 1.5) + ); + assert_eq!( + (event.archive.as_str(), event.name.as_str()), + ("sounds.lib", "f_defl.wav") + ); + } + + #[test] + fn backend_modes_and_malformed_commands_are_explicitly_unsupported() { + assert!(Op2SoundCommand::from_command(&command(1)).is_none()); + let mut source = command(2); + let mut body = source.raw_body.to_vec(); + body[8..12].copy_from_slice(&f32::NAN.to_le_bytes()); + source.raw_body = Arc::from(body); + assert!(Op2SoundCommand::from_command(&source).is_none()); + } +} diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 4125a68..af7408a 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -7,6 +7,7 @@ //! are backed by runtime-captured evidence. pub mod ambient_op3; +pub mod ambient_sound; pub mod atmosphere; pub mod environment; pub mod fx_visual; diff --git a/crates/fparkan-fx/src/sky.rs b/crates/fparkan-fx/src/sky.rs index 26e68ab..1b793f0 100644 --- a/crates/fparkan-fx/src/sky.rs +++ b/crates/fparkan-fx/src/sky.rs @@ -328,6 +328,24 @@ pub struct SkyMesh { } impl SkyMesh { + /// Updates native nebula, star and cloud scroll from the sky object's elapsed milliseconds. + pub fn update_uvs(&mut self, elapsed_ms: u32) { + let offsets = [ + [(elapsed_ms % 300_000) as f32 * 3.333_333_3e-6; 2], + [(elapsed_ms % 100_000) as f32 * 1.0e-5; 2], + [(elapsed_ms % 50_000) as f32 * 2.0e-5, 0.0], + ]; + let inverse_radius = self.radius.recip(); + for vertex in &mut self.vertices { + for stage in 0..3 { + let scale = inverse_radius * SKY_UV_SCALES[stage]; + vertex.uv[stage] = std::array::from_fn(|axis| { + scale * vertex.position[axis] + offsets[stage][axis] + }); + } + } + } + /// Number of triangles in the mesh. #[must_use] pub fn triangle_count(&self) -> usize { @@ -1143,12 +1161,11 @@ impl SunFrame { /// Converts an interpolated atmosphere sun sample to a renderer-facing frame. /// /// The original update computes `orbit_direction = [cos(theta), 0, -/// -sin(theta)]`, then applies the row-vector product of the two birth-angle +/// -sin(theta)]`, then applies the column-vector product of the two birth-angle /// matrices. With `A` and `B` in radians, the resulting direction is: /// /// ```text -/// [x*cos(A), -x*sin(A)*cos(B) + z*sin(B), -/// x*sin(A)*sin(B) + z*cos(B)] +/// [x*cos(A) + z*sin(A)*sin(B), x*sin(A) - z*cos(A)*sin(B), z*cos(B)] /// ``` /// /// where `x` and `z` are the two non-zero orbit components. @@ -1178,9 +1195,9 @@ pub fn sun_frame(sample: &SunSample, now_seconds: f32, config: SunConfig) -> Sun let x = orbit_direction[0]; let z = orbit_direction[2]; let direction = normalize3([ - x * cos_a, - -x * sin_a * cos_b + z * sin_b, - x * sin_a * sin_b + z * cos_b, + x * cos_a + z * sin_a * sin_b, + x * sin_a - z * cos_a * sin_b, + z * cos_b, ]); SunFrame { active: true, @@ -1788,6 +1805,26 @@ mod tests { } #[test] + fn scrolling_uvs_match_native_periods_and_wrapped_elapsed_clock() { + let mut mesh = SkyGeometryConfig::default().try_mesh(&sample()).unwrap(); + let topology = mesh.indices.clone(); + for elapsed in [0, 25_000, 49_999, 50_000, 100_000, 300_000, 4_051_271_258] { + mesh.update_uvs(elapsed); + let zenith = mesh.vertices[0].uv; + assert_eq!(zenith[0], [(elapsed % 300_000) as f32 * 3.333_333_3e-6; 2]); + assert_eq!(zenith[1], [(elapsed % 100_000) as f32 * 1e-5; 2]); + assert_eq!(zenith[2], [(elapsed % 50_000) as f32 * 2e-5, 0.0]); + } + // Live GOG sky render: now 265584022, object origin 509280060. + mesh.update_uvs(265_584_022_u32.wrapping_sub(509_280_060)); + assert!((mesh.vertices[0].uv[2][0] - 0.42516).abs() < 1e-7); + let once = mesh.vertices.clone(); + mesh.update_uvs(4_051_271_258); + assert_eq!(mesh.vertices, once, "updates must not accumulate drift"); + assert_eq!(mesh.indices, topology); + } + + #[test] fn geometry_matches_original_counts_positions_uv_and_winding() { let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3); let mesh = config.try_mesh(&sample()).expect("valid mesh"); @@ -1892,16 +1929,30 @@ mod tests { let sample = sun(true); let at_start = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [90.0, 0.0])); assert!((at_start.direction[0]).abs() < 1.0e-5); - assert!((at_start.direction[1] + at_start.orbit_direction[0]).abs() < 1.0e-5); + assert!((at_start.direction[1] - at_start.orbit_direction[0]).abs() < 1.0e-5); assert!((at_start.direction[2] - at_start.orbit_direction[2]).abs() < 1.0e-5); let side = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [0.0, 90.0])); assert!((side.direction[0] - side.orbit_direction[0]).abs() < 1.0e-5); - assert!((side.direction[1] - side.orbit_direction[2]).abs() < 1.0e-5); + assert!((side.direction[1] + side.orbit_direction[2]).abs() < 1.0e-5); assert!(side.direction[2].abs() < 1.0e-5); } #[test] + fn sun_direction_matches_live_native_birth_matrix() { + // Terrain CSun::Update, elapsed 282184 ms, lifetime 525000 ms. + let frame = sun_frame( + &sun(true), + 282.184, + SunConfig::new(0.0, 525.0, [90.0, 30.0]), + ); + let expected = [0xbefd_7269, 0xbe10_4168, 0xbf5b_7dda].map(f32::from_bits); + for (actual, expected) in frame.direction.into_iter().zip(expected) { + assert!((actual - expected).abs() < 3.0e-6, "{actual} != {expected}"); + } + } + + #[test] fn native_birth_keeps_the_real_start_time() { assert!(!celestial_frame(&sun(false), 100.0).active); let birth = SunBirth { diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index ad96204..db0171f 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -1,6 +1,8 @@ #![forbid(unsafe_code)] //! Camera mathematics and graphics pipeline state. +pub mod water; + /// A 64-byte transform block returned by the original Terrain camera ABI. /// /// The original uses two selector-dependent transform pointers. Their exact @@ -349,6 +351,23 @@ pub enum LegacyDepthMode { TestReadOnly, } +/// Reflects world Z before a row-vector clip transform, preserving points on the plane. +#[must_use] +pub fn reflect_clip_from_world_about_z(matrix: [f32; 16], height: f32) -> Option<[f32; 16]> { + if !height.is_finite() || !matrix.iter().all(|value| value.is_finite()) { + return None; + } + let mut reflected = matrix; + for column in 0..4 { + reflected[8 + column] = -matrix[8 + column]; + reflected[12 + column] = matrix[12 + column] + 2.0 * height * matrix[8 + column]; + } + reflected + .iter() + .all(|value| value.is_finite()) + .then_some(reflected) +} + /// Triangle culling behaviour represented without a graphics API type. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum LegacyCullMode { @@ -556,6 +575,29 @@ mod tests { result } + #[test] + fn water_reflection_preserves_the_plane_and_reverses_height_before_projection() { + let matrix = [ + 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, + ]; + let reflected = reflect_clip_from_world_about_z(matrix, 150.0).unwrap(); + let project = |m: [f32; 16], p: [f32; 4]| -> [f32; 4] { + std::array::from_fn(|column| (0..4).map(|row| p[row] * m[row * 4 + column]).sum()) + }; + for z in [125.0, 150.0, 185.0] { + assert_eq!( + project(reflected, [7.0, 11.0, z, 1.0]), + project(matrix, [7.0, 11.0, 300.0 - z, 1.0]) + ); + } + assert_eq!( + reflect_clip_from_world_about_z(reflected, 150.0), + Some(matrix) + ); + assert!(reflect_clip_from_world_about_z(matrix, f32::NAN).is_none()); + assert!(reflect_clip_from_world_about_z([f32::MAX; 16], f32::MAX).is_none()); + } + fn assert_matrix_approximately_identity(matrix: [f32; 16]) { for (index, value) in matrix.into_iter().enumerate() { let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 }; diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs new file mode 100644 index 0000000..0c7ad2a --- /dev/null +++ b/crates/fparkan-render/src/water.rs @@ -0,0 +1,108 @@ +//! The GOG Terrain EMBM wave map and its world-space animation clock. + +/// Side length of the native signed Du/Dv texture. +pub const EMBM_BUMP_SIZE: u32 = 128; +/// Native default wave tiling across the bounding rectangle of reflective faces. +pub const EMBM_BUMP_TILE: f32 = 100.0; +/// Native diagonal bump matrix, without luminance modulation. +pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01]; + +/// Generates the signed bytes written by Terrain.dll RVA 0x2BF50 with CW 0x007F. +#[must_use] +pub fn legacy_embm_bump_map() -> Vec<[i8; 2]> { + let mut texels = Vec::with_capacity((EMBM_BUMP_SIZE * EMBM_BUMP_SIZE) as usize); + for y in 0..EMBM_BUMP_SIZE { + for x in 0..EMBM_BUMP_SIZE { + // The native constant is slightly smaller than mathematical 4*pi. + let angle = ((y as f32 / EMBM_BUMP_SIZE as f32 - 0.5) + + (x as f32 / EMBM_BUMP_SIZE as f32 - 0.5)) + * f32::from_bits(0x4149_0fd0); + texels.push([ + (angle.cos() * 64.0).round_ties_even() as i8, + (angle.sin() * 64.0).round_ties_even() as i8, + ]); + } + } + texels +} + +/// Queue clock modulo the native ten-second wave scroll period. +#[must_use] +pub fn legacy_embm_phase(clock_ms: u32) -> f32 { + (clock_ms % 10_000) as f32 / 10_000.0 +} + +/// Native reflection coordinates: U reverses world Y and V reverses world X. +#[must_use] +pub fn legacy_embm_uv(world_xy: [f32; 2], bounds: [f32; 4]) -> Option<[f32; 2]> { + let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]]; + if !world_xy + .into_iter() + .chain(bounds) + .chain(span) + .all(f32::is_finite) + || span.iter().any(|&value| value <= 0.0) + { + return None; + } + Some([ + 1.0 - (world_xy[1] - bounds[1]) / span[1], + 1.0 - (world_xy[0] - bounds[0]) / span[0], + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wave_bytes_match_native_generation_and_clock_wraps_without_float_time_loss() { + let texels = legacy_embm_bump_map(); + assert_eq!(texels.len(), 128 * 128); + assert_eq!( + &texels[..16], + &[ + [64, 0], + [64, 6], + [63, 12], + [61, 19], + [59, 24], + [56, 30], + [53, 36], + [49, 41], + [45, 45], + [41, 49], + [36, 53], + [30, 56], + [24, 59], + [19, 61], + [12, 63], + [6, 64], + ] + ); + assert_eq!(texels[16], [0, 64]); + assert_eq!(texels[32], [-64, 0]); + assert_eq!(texels[48], [0, -64]); + let native_hash = texels + .iter() + .flatten() + .fold(0xcbf29ce484222325u64, |hash, &byte| { + (hash ^ u64::from(byte as u8)).wrapping_mul(0x100000001b3) + }); + assert_eq!(native_hash, 0x0b92dc6a9e482625); + assert_eq!(legacy_embm_phase(0), 0.0); + assert_eq!(legacy_embm_phase(5_000), 0.5); + assert_eq!(legacy_embm_phase(10_000), 0.0); + assert_eq!(legacy_embm_phase(u32::MAX), 0.7295); + } + + #[test] + fn mapping_uses_the_reflective_rectangle_and_keeps_axis_order() { + let bounds = [100.0, 200.0, 300.0, 600.0]; + assert_eq!(legacy_embm_uv([100.0, 200.0], bounds), Some([1.0, 1.0])); + assert_eq!(legacy_embm_uv([300.0, 600.0], bounds), Some([0.0, 0.0])); + assert_eq!(legacy_embm_uv([150.0, 500.0], bounds), Some([0.25, 0.75])); + assert_eq!(legacy_embm_uv([0.0; 2], [1.0; 4]), None); + assert_eq!(legacy_embm_uv([f32::NAN, 0.0], bounds), None); + } +} diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index e4028f9..63d21a1 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -58,7 +58,7 @@ pub struct TerrainMaterialLayers { pub overlay_selector: Option<u8>, } -/// One `World3D` material-manager lookup key. +/// One `World3D` material-manager phase query. /// /// The original manager selects a WEAR table in the upper 16 bits and its /// positional material row in the lower 16 bits. @@ -68,6 +68,9 @@ pub struct TerrainMaterialSelection { pub table_index: u16, /// Positional material row in that WEAR table. pub material_index: u16, + /// Animation block passed separately to GetMaterialPhase. The multitexture + /// path uses block 1 for Land2, with the material sampler's block-0 fallback. + pub animation_block_index: usize, } impl TerrainMaterialSelection { @@ -99,6 +102,7 @@ impl TerrainMaterialLayers { TerrainMaterialSelection { table_index: 0, material_index: u16::from(self.base_selector), + animation_block_index: 0, } } @@ -109,27 +113,30 @@ impl TerrainMaterialLayers { .map(|material_index| TerrainMaterialSelection { table_index: 0, material_index: u16::from(material_index), + animation_block_index: 0, }) } - /// Returns the detail selection corresponding to the base row in - /// `Land2.wea`. + /// Returns the multitexture detail selection corresponding to the base + /// row in `Land2.wea`. #[must_use] pub fn detail_base_selection(self) -> TerrainMaterialSelection { TerrainMaterialSelection { table_index: 1, material_index: u16::from(self.base_selector), + animation_block_index: 1, } } - /// Returns the optional detail selection corresponding to the overlay row - /// in `Land2.wea`. + /// Returns the optional multitexture detail selection corresponding to + /// the overlay row in `Land2.wea`. #[must_use] pub fn detail_overlay_selection(self) -> Option<TerrainMaterialSelection> { self.overlay_selector .map(|material_index| TerrainMaterialSelection { table_index: 1, material_index: u16::from(material_index), + animation_block_index: 1, }) } } @@ -219,6 +226,8 @@ pub enum TerrainLodError { DuplicateFaceAssignment { face: usize }, /// A source face is absent from every supported terrain slot. MissingFaceAssignment { face: usize }, + /// Slot bounds do not form the regular XY cell grid used by terrain rendering. + InvalidCellGrid, /// A checked index calculation overflowed. IntegerOverflow, } @@ -269,6 +278,7 @@ impl std::fmt::Display for TerrainLodError { Self::MissingFaceAssignment { face } => { write!(f, "terrain face {face} is not assigned to a supported slot") } + Self::InvalidCellGrid => write!(f, "terrain slots do not form a regular XY cell grid"), Self::IntegerOverflow => write!(f, "terrain slot index overflow"), } } @@ -1948,6 +1958,7 @@ mod tests { TerrainMaterialSelection { table_index: 0, material_index: 2, + animation_block_index: 0, } ); assert_eq!( @@ -1968,11 +1979,26 @@ mod tests { assert_eq!( document.faces[0] .material_layers() + .detail_base_selection() + .animation_block_index, + 1 + ); + assert_eq!( + document.faces[0] + .material_layers() .detail_overlay_selection() .expect("detail overlay") .phase_key(), 0x0001_0001 ); + assert_eq!( + document.faces[0] + .material_layers() + .detail_overlay_selection() + .unwrap() + .animation_block_index, + 1 + ); raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes()); let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres"); @@ -1989,6 +2015,7 @@ mod tests { TerrainMaterialSelection { table_index: 0, material_index: 3, + animation_block_index: 0, } ); assert_eq!( diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index 5e17a23..64e6814 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -6,6 +6,9 @@ use std::collections::VecDeque; mod contour; mod foundation; +mod lod; + +pub use lod::TerrainLodPlan; use foundation::basement_surface_mask; pub use foundation::{ @@ -1633,6 +1636,83 @@ mod tests { } #[test] + fn lod_grid_uses_authored_face_ownership_and_pins_changed_foundation_cells() { + use fparkan_terrain_format::{TerrainStream, TerrainStreamAttributes}; + let mut mesh = synthetic_land_mesh(); + mesh.faces.extend(mesh.faces.clone()); + assign_terrain_lod_slots(&mut mesh, &[[0, 1]], &[[0, 2], [2, 2]]); + mesh.slots.header_raw = vec![0; 0x8c]; + mesh.streams.push(TerrainStream { + type_id: 1, + attributes: TerrainStreamAttributes { + attr1: 1, + attr2: 1, + attr3: 0, + }, + size: 38, + }); + for slot in &mut mesh.slots.slots_raw { + slot[20..24].copy_from_slice(&1.0_f32.to_le_bytes()); + slot[24..28].copy_from_slice(&1.0_f32.to_le_bytes()); + } + let mut foundation = BasementFoundation { + placements: Vec::new(), + fragments: (0..2) + .map(|face| BasementFoundationFragment { + source_face: Some(face), + surface_mask: mesh.faces[face].flags, + positions: mesh.faces[face] + .vertices + .map(|v| mesh.positions[usize::from(v)]), + }) + .collect(), + }; + foundation.fragments[0].positions.rotate_left(1); + let plan = TerrainLodPlan::new(&mesh, Some(&foundation)).unwrap(); + assert_eq!(plan.source_face_node(3), Some(0)); + assert_eq!(plan.source_face_node(4), None); + assert_eq!( + plan.selected_lod(0, [4.0, 4.0], 1.3), + Some(TerrainLod::Lod1) + ); + foundation.fragments.pop(); + let patched = TerrainLodPlan::new(&mesh, Some(&foundation)).unwrap(); + assert_eq!( + patched.selected_lod(0, [4.0, 4.0], 1.3), + Some(TerrainLod::Lod0) + ); + mesh.slots.slots_raw[0][24..28].copy_from_slice(&f32::NAN.to_le_bytes()); + assert!(matches!( + TerrainLodPlan::new(&mesh, None), + Err(TerrainLodError::InvalidCellGrid) + )); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_land_meshes_build_native_lod_grids() { + let mut files = 0; + for path in files_under(&corpus_root("IS")) { + if !path + .file_name() + .and_then(|s| s.to_str()) + .is_some_and(|s| s.eq_ignore_ascii_case("Land.msh")) + { + continue; + } + let bytes = std::fs::read(&path).unwrap(); + let nres = + fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) + .unwrap(); + let mesh = fparkan_terrain_format::decode_land_msh(&nres).unwrap(); + let plan = TerrainLodPlan::new(&mesh, None).unwrap_or_else(|e| panic!("{path:?}: {e}")); + assert!(plan.has_far_geometry(), "{path:?}: no far slots"); + files += 1; + } + assert_eq!(files, 33, "original game LOD grid count"); + } + + #[test] #[ignore = "requires licensed corpus"] fn licensed_corpus_land_meshes_build_surface_worlds() { for (corpus, expected_files, expected_faces) in [ diff --git a/crates/fparkan-terrain/src/lod.rs b/crates/fparkan-terrain/src/lod.rs new file mode 100644 index 0000000..cf5991f --- /dev/null +++ b/crates/fparkan-terrain/src/lod.rs @@ -0,0 +1,301 @@ +use crate::{BasementFoundation, TerrainLod, TerrainLodError}; +use fparkan_terrain_format::LandMeshDocument; + +/// Camera-dependent terrain detail selection, retaining LOD0 in basement cells. +#[derive(Clone, Debug)] +pub struct TerrainLodPlan { + origin: [f32; 2], + inverse_cell_size: [f32; 2], + counts: [usize; 2], + forced_lod0: Vec<bool>, + has_lod1: Vec<bool>, + face_nodes: Vec<usize>, +} + +impl TerrainLodPlan { + /// Validates the authored grid and registers foundation patches in its cells. + /// + /// # Errors + /// Returns an error for an invalid slot mapping, grid or foundation source face. + pub fn new( + mesh: &LandMeshDocument, + foundation: Option<&BasementFoundation>, + ) -> Result<Self, TerrainLodError> { + let assignments = mesh.face_lod_assignments()?; + let bounds = mesh + .nodes_raw + .chunks_exact(38) + .map(|node| { + let slot = u16::from_le_bytes([node[8], node[9]]); + let raw = mesh.slots.slots_raw.get(usize::from(slot))?; + let coordinate = + |offset: usize| f32::from_le_bytes(raw[offset..offset + 4].try_into().unwrap()); + Some(( + [coordinate(8), coordinate(12)], + [coordinate(20), coordinate(24)], + )) + }) + .collect::<Option<Vec<_>>>() + .ok_or(TerrainLodError::InvalidCellGrid)?; + let &(first_min, first_max) = bounds.first().ok_or(TerrainLodError::InvalidCellGrid)?; + let header = &mesh.slots.header_raw; + let coordinate = |offset: usize| { + header + .get(offset..offset + 4) + .map(|bytes| f32::from_le_bytes(bytes.try_into().unwrap())) + }; + let origin = [ + coordinate(0).ok_or(TerrainLodError::InvalidCellGrid)?, + coordinate(4).ok_or(TerrainLodError::InvalidCellGrid)?, + ]; + let size = [first_max[0] - first_min[0], first_max[1] - first_min[1]]; + if !origin.iter().all(|value| value.is_finite()) + || !size.iter().all(|value| value.is_finite() && *value > 0.0) + { + return Err(TerrainLodError::InvalidCellGrid); + } + let count_x = mesh + .streams + .iter() + .find(|s| s.type_id == 1) + .map(|s| s.attributes.attr2 as usize) + .ok_or(TerrainLodError::InvalidCellGrid)?; + if count_x == 0 || !bounds.len().is_multiple_of(count_x) { + return Err(TerrainLodError::InvalidCellGrid); + } + let count_y = bounds.len() / count_x; + let counts = [count_x, count_y]; + for (node, &(min, max)) in bounds.iter().enumerate() { + let cell = [node / count_y, node % count_y]; + for axis in 0..2 { + let expected = origin[axis] + cell[axis] as f32 * size[axis]; + let tolerance = size[axis] * 1.0e-5; + if !min[axis].is_finite() + || !max[axis].is_finite() + || (min[axis] - expected).abs() > tolerance + || (max[axis] - (expected + size[axis])).abs() > tolerance + { + return Err(TerrainLodError::InvalidCellGrid); + } + } + } + let has_lod1 = mesh + .nodes_raw + .chunks_exact(38) + .map(|node| { + let slot = u16::from_le_bytes([node[10], node[11]]); + slot != 0xff + && slot != u16::MAX + && mesh + .slots + .slots_raw + .get(usize::from(slot)) + .is_some_and(|raw| u16::from_le_bytes([raw[2], raw[3]]) != 0) + }) + .collect(); + let mut result = Self { + origin, + inverse_cell_size: size.map(f32::recip), + counts, + forced_lod0: vec![false; bounds.len()], + has_lod1, + face_nodes: assignments.iter().map(|a| a.node_index).collect(), + }; + if let Some(foundation) = foundation { + let mut fragments = vec![0usize; mesh.faces.len()]; + let mut unchanged = vec![false; mesh.faces.len()]; + for fragment in &foundation.fragments { + if let Some(face_index) = fragment.source_face { + let face = mesh + .faces + .get(face_index) + .ok_or(TerrainLodError::MissingFaceAssignment { face: face_index })?; + fragments[face_index] += 1; + let original = face.vertices.map(|v| mesh.positions[usize::from(v)]); + unchanged[face_index] = fragment.positions.iter().all(|p| original.contains(p)); + } + } + for assignment in assignments.iter().filter(|a| a.lod == TerrainLod::Lod0) { + if fragments[assignment.face_index] != 1 || !unchanged[assignment.face_index] { + result.forced_lod0[assignment.node_index] = true; + } + } + // Native registers patch triangles against every cell, including shared edges. + for fragment in foundation.fragments.iter().filter(|fragment| { + fragment + .source_face + .is_none_or(|face| fragments[face] != 1 || !unchanged[face]) + }) { + for node in 0..bounds.len() { + let cell = [node / count_y, node % count_y]; + let center = std::array::from_fn(|axis| { + (cell[axis] as f32 + 0.5) * size[axis] + origin[axis] + }); + if triangle_intersects_cell( + fragment.positions, + center, + result.inverse_cell_size, + ) { + result.forced_lod0[node] = true; + } + } + } + } + Ok(result) + } + + /// Returns the cell named by the authored slot that owns a source face. + #[must_use] + pub fn source_face_node(&self, face: usize) -> Option<usize> { + self.face_nodes.get(face).copied() + } + + fn camera_cell(&self, point: [f32; 2]) -> Option<[i32; 2]> { + let cell: [f32; 2] = std::array::from_fn(|axis| { + ((point[axis] - self.origin[axis]) * self.inverse_cell_size[axis]).floor() + }); + cell.iter() + .all(|v| v.is_finite() && *v >= i32::MIN as f32 && *v < i32::MAX as f32) + .then(|| cell.map(|v| v as i32)) + } + + /// Selects LOD1 at a squared cell distance of at least nine, unless forced near. + /// Returns `None` for an invalid node, camera position or FOV. + #[must_use] + pub fn selected_lod( + &self, + node: usize, + eye: [f32; 2], + horizontal_fov: f32, + ) -> Option<TerrainLod> { + let forced = *self.forced_lod0.get(node)?; + let has_far = *self.has_lod1.get(node)?; + let camera = self.camera_cell(eye)?; + if !horizontal_fov.is_finite() || horizontal_fov <= 0.0 { + return None; + } + let dx = (node / self.counts[1]) as f64 - f64::from(camera[0]); + let dy = (node % self.counts[1]) as f64 - f64::from(camera[1]); + Some( + if !forced && has_far && horizontal_fov >= 0.5 && dx * dx + dy * dy >= 9.0 { + TerrainLod::Lod1 + } else { + TerrainLod::Lod0 + }, + ) + } + + /// Whether the map contains any usable far slots. + #[must_use] + pub fn has_far_geometry(&self) -> bool { + self.has_lod1.iter().any(|&value| value) + } +} + +fn triangle_intersects_cell( + triangle: [[f32; 3]; 3], + center: [f32; 2], + inverse_size: [f32; 2], +) -> bool { + // Normalize in f32 before testing the native [-0.5, 0.5] box. + let points = triangle.map(|p| { + std::array::from_fn::<_, 2, _>(|axis| { + f64::from((p[axis] - center[axis]) * inverse_size[axis]) + }) + }); + let edges = std::array::from_fn::<_, 3, _>(|i| { + let next = points[(i + 1) % 3]; + [next[1] - points[i][1], points[i][0] - next[0]] + }); + [[1.0, 0.0], [0.0, 1.0]] + .into_iter() + .chain(edges) + .all(|axis| { + let projections = points.map(|p| p[0] * axis[0] + p[1] * axis[1]); + let radius = 0.5 * (axis[0].abs() + axis[1].abs()); + projections.iter().copied().fold(f64::INFINITY, f64::min) <= radius + && projections + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max) + >= -radius + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn patch_registration_keeps_native_normalization_and_triangle_edges() { + let size = f32::from_bits(0x42d4_15f5); + let center = [2.5 * size, 6.5 * size]; + let edge = [ + [3.0 * size, 6.0 * size, 0.0], + [3.0 * size, 7.0 * size, 0.0], + [4.0 * size, 6.0 * size, 0.0], + ]; + // The authored boundary rounds outside this cell after native normalization. + assert!(!triangle_intersects_cell(edge, center, [size.recip(); 2])); + assert!(triangle_intersects_cell( + edge, + [3.5 * size, 6.5 * size], + [size.recip(); 2] + )); + assert!(!triangle_intersects_cell( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]], + [1.75, 1.75], + [2.0; 2] + )); + assert!(triangle_intersects_cell( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]], + [1.5, 1.0], + [2.0; 2] + )); + } + + #[test] + fn native_cell_distance_boundary_zoom_and_basement_override() { + let mut plan = TerrainLodPlan { + origin: [0.0; 2], + inverse_cell_size: [1.0; 2], + counts: [8, 8], + forced_lod0: vec![false; 64], + has_lod1: vec![true; 64], + face_nodes: Vec::new(), + }; + assert_eq!( + plan.selected_lod(2 * 8 + 2, [0.9, 0.9], 1.3), + Some(TerrainLod::Lod0) + ); + assert_eq!( + plan.selected_lod(3 * 8, [0.9, 0.9], 1.3), + Some(TerrainLod::Lod1) + ); + assert_eq!( + plan.selected_lod(3 * 8, [1.0, 0.9], 1.3), + Some(TerrainLod::Lod0) + ); + assert_eq!( + plan.selected_lod(3 * 8, [0.9, 0.9], 0.49), + Some(TerrainLod::Lod0) + ); + assert_eq!( + plan.selected_lod(3 * 8, [0.9, 0.9], 0.5), + Some(TerrainLod::Lod1) + ); + plan.forced_lod0[3 * 8] = true; + assert_eq!( + plan.selected_lod(3 * 8, [0.9, 0.9], 1.3), + Some(TerrainLod::Lod0) + ); + plan.has_lod1[7 * 8] = false; + assert_eq!( + plan.selected_lod(7 * 8, [0.9, 0.9], 1.3), + Some(TerrainLod::Lod0) + ); + assert_eq!(plan.selected_lod(64, [0.0; 2], 1.3), None); + assert_eq!(plan.selected_lod(0, [f32::NAN, 0.0], 1.3), None); + assert_eq!(plan.selected_lod(0, [0.0; 2], f32::INFINITY), None); + } +} |
