diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-31 23:09:07 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-31 23:09:07 +0300 |
| commit | d7c18d4c1ab576e4329a85b4729403fef3c09183 (patch) | |
| tree | 2d79173006e680136275bf1a7004c610362d91b4 /crates/fparkan-terrain | |
| parent | 853d9b115aa3a7e6790c79c17418e0669d322023 (diff) | |
| download | fparkan-d7c18d4c1ab576e4329a85b4729403fef3c09183.tar.xz fparkan-d7c18d4c1ab576e4329a85b4729403fef3c09183.zip | |
fix(render): select terrain LOD from native camera cells
Diffstat (limited to 'crates/fparkan-terrain')
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 80 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lod.rs | 301 |
2 files changed, 381 insertions, 0 deletions
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); + } +} |
