From e5d273158cd27545479276120dba8c02913f7930 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 18 Oct 2026 05:59:15 +0400 Subject: Use LOD0 for terrain queries and rendering --- adapters/fparkan-render-vulkan/src/asset_mesh.rs | 179 +++++++++--- apps/fparkan-game/src/main.rs | 142 +++++++--- crates/fparkan-terrain-format/src/lib.rs | 346 +++++++++++++++++++++++ crates/fparkan-terrain/src/lib.rs | 48 +++- docs/tomes/05-render.md | 25 ++ 5 files changed, 649 insertions(+), 91 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 9d084ae..0de1381 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -7,7 +7,7 @@ use fparkan_msh::{ ModelAsset, NodeId, }; use fparkan_render::{LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState}; -use fparkan_terrain_format::LandMeshDocument; +use fparkan_terrain_format::{LandMeshDocument, TerrainFaceLod, TerrainLod, TerrainLodError}; fn world_pipeline_state() -> LegacyPipelineState { LegacyPipelineState { @@ -34,6 +34,8 @@ pub enum VulkanAssetMeshError { /// Number of supplied node poses. actual: usize, }, + /// Terrain Node38/Slot68 records do not assign every face to one LOD. + InvalidTerrainLodMapping(TerrainLodError), } /// Evaluated world pose of one standard Node38 node in model-local space. @@ -108,6 +110,9 @@ impl std::fmt::Display for VulkanAssetMeshError { f, "node pose buffer has {actual} poses, but the MSH has {expected} nodes" ), + Self::InvalidTerrainLodMapping(error) => { + write!(f, "invalid terrain LOD mapping: {error}") + } } } } @@ -1016,23 +1021,15 @@ fn transform_normal( pub fn project_land_msh_to_static_mesh( terrain: &LandMeshDocument, ) -> Result { - let mut indices = Vec::with_capacity( - terrain - .faces - .len() - .checked_mul(3) - .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, - ); - for face in &terrain.faces { - indices.extend(face.vertices.map(u32::from)); - } + let lod0 = static_terrain_lod0_assignments(terrain)?; + let indices = static_terrain_indices(terrain, &lod0)?; if indices.is_empty() { return Err(VulkanAssetMeshError::EmptyGeometry); } let (min_x, max_x, min_y, max_y) = static_mesh_xy_bounds(&terrain.positions, &indices)?; let frame = VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)?; - project_land_msh_to_static_mesh_in_xy_frame(terrain, frame) + project_land_msh_to_static_mesh_with_lod0(terrain, frame, &lod0) } /// Projects `Land.msh` terrain geometry into a shared diagnostic XY frame. @@ -1045,16 +1042,16 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame( terrain: &LandMeshDocument, frame: VulkanStaticXyFrame, ) -> Result { - let mut indices = Vec::with_capacity( - terrain - .faces - .len() - .checked_mul(3) - .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, - ); - for face in &terrain.faces { - indices.extend(face.vertices.map(u32::from)); - } + let lod0 = static_terrain_lod0_assignments(terrain)?; + project_land_msh_to_static_mesh_with_lod0(terrain, frame, &lod0) +} + +fn project_land_msh_to_static_mesh_with_lod0( + terrain: &LandMeshDocument, + frame: VulkanStaticXyFrame, + lod0: &[TerrainFaceLod], +) -> Result { + let indices = static_terrain_indices(terrain, lod0)?; if indices.is_empty() { return Err(VulkanAssetMeshError::EmptyGeometry); } @@ -1089,7 +1086,7 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame( Ok(VulkanStaticMesh { vertices, indices, - draw_ranges: static_terrain_draw_ranges(terrain)?, + draw_ranges: static_terrain_draw_ranges(terrain, lod0)?, }) } @@ -1107,7 +1104,8 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame( pub fn project_land_msh_to_static_mesh_in_world_space( terrain: &LandMeshDocument, ) -> Result { - let indices = static_terrain_indices(terrain)?; + let lod0 = static_terrain_lod0_assignments(terrain)?; + let indices = static_terrain_indices(terrain, &lod0)?; let vertices = terrain .positions .iter() @@ -1132,19 +1130,34 @@ pub fn project_land_msh_to_static_mesh_in_world_space( Ok(VulkanStaticMesh { vertices, indices, - draw_ranges: static_terrain_draw_ranges(terrain)?, + draw_ranges: static_terrain_draw_ranges(terrain, &lod0)?, }) } -fn static_terrain_indices(terrain: &LandMeshDocument) -> Result, VulkanAssetMeshError> { +fn static_terrain_lod0_assignments( + terrain: &LandMeshDocument, +) -> Result, VulkanAssetMeshError> { + let mut assignments = terrain + .face_lod_assignments() + .map_err(VulkanAssetMeshError::InvalidTerrainLodMapping)?; + assignments.retain(|assignment| assignment.lod == TerrainLod::Lod0); + Ok(assignments) +} + +fn static_terrain_indices( + terrain: &LandMeshDocument, + lod0: &[TerrainFaceLod], +) -> Result, VulkanAssetMeshError> { let mut indices = Vec::with_capacity( - terrain - .faces - .len() + lod0.len() .checked_mul(3) .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, ); - for face in &terrain.faces { + for assignment in lod0 { + let face = terrain + .faces + .get(assignment.face_index) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?; indices.extend(face.vertices.map(u32::from)); } if indices.is_empty() { @@ -1187,14 +1200,24 @@ fn terrain_overlay_alpha(terrain: &LandMeshDocument, index: usize) -> f32 { fn static_terrain_draw_ranges( terrain: &LandMeshDocument, + lod0: &[TerrainFaceLod], ) -> Result, VulkanAssetMeshError> { let mut ranges = Vec::new(); let mut start_face = 0usize; - while start_face < terrain.faces.len() { - let material_index = terrain.faces[start_face].material_tag; + while start_face < lod0.len() { + let material_index = terrain + .faces + .get(lod0[start_face].face_index) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)? + .material_tag; let mut end_face = start_face + 1; - while end_face < terrain.faces.len() - && terrain.faces[end_face].material_tag == material_index + while end_face < lod0.len() + && terrain + .faces + .get(lod0[end_face].face_index) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)? + .material_tag + == material_index { end_face += 1; } @@ -1998,7 +2021,7 @@ mod tests { #[test] fn projects_land_faces_in_source_order_with_packed_uv0() { - let terrain = LandMeshDocument { + let mut terrain = LandMeshDocument { streams: Vec::new(), nodes_raw: Vec::new(), slots: TerrainSlotTable { @@ -2018,6 +2041,7 @@ mod tests { aux18: Vec::new(), faces: vec![terrain_face([0, 1, 2]), terrain_face([1, 3, 2])], }; + set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 2]]); let mesh = project_land_msh_to_static_mesh(&terrain).expect("representable terrain"); @@ -2030,9 +2054,61 @@ mod tests { assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]); } + #[test] + fn terrain_projection_filters_interleaved_lods_in_source_face_order() { + let mut terrain = LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [10.0, 10.0, 10.0], + [11.0, 10.0, 10.0], + [10.0, 11.0, 10.0], + ], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![ + terrain_face_with_tag(0x0101, [0, 1, 2]), + terrain_face_with_tag(0x0202, [3, 4, 5]), + terrain_face_with_tag(0x0101, [0, 1, 2]), + terrain_face_with_tag(0x0202, [3, 4, 5]), + ], + }; + set_terrain_lod_slots( + &mut terrain, + &[[0, 1], [2, 3]], + &[[0, 1], [1, 1], [2, 1], [3, 1]], + ); + + let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain) + .expect("LOD0 terrain projection"); + assert_eq!(mesh.indices, [0, 1, 2, 0, 1, 2]); + assert_eq!(mesh.draw_ranges.len(), 1); + assert_eq!(mesh.draw_ranges[0].first_index, 0); + assert_eq!(mesh.draw_ranges[0].index_count, 6); + assert_eq!(mesh.draw_ranges[0].material_index, 0x0101); + + terrain.nodes_raw.clear(); + assert!(matches!( + project_land_msh_to_static_mesh_in_world_space(&terrain), + Err(VulkanAssetMeshError::InvalidTerrainLodMapping( + TerrainLodError::MissingNodes { face_count: 4 } + )) + )); + } + #[test] fn terrain_normals_keep_native_one_over_128_scale() { - let terrain = LandMeshDocument { + let mut terrain = LandMeshDocument { streams: Vec::new(), nodes_raw: Vec::new(), slots: TerrainSlotTable { @@ -2047,6 +2123,7 @@ mod tests { aux18: Vec::new(), faces: vec![terrain_face([0, 1, 2])], }; + set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 1]]); let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain) .expect("representable terrain normals"); @@ -2055,7 +2132,7 @@ mod tests { #[test] fn world_space_terrain_preserves_nonzero_source_heights_and_faces() { - let terrain = LandMeshDocument { + let mut terrain = LandMeshDocument { streams: Vec::new(), nodes_raw: Vec::new(), slots: TerrainSlotTable { @@ -2074,6 +2151,7 @@ mod tests { aux18: Vec::new(), faces: vec![terrain_face([2, 0, 1])], }; + set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 1]]); let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain) .expect("representable source-space terrain"); @@ -2101,9 +2179,30 @@ mod tests { } } + fn set_terrain_lod_slots( + terrain: &mut LandMeshDocument, + node_slots: &[[u16; 2]], + face_ranges: &[[u16; 2]], + ) { + const NODE_STRIDE: usize = 38; + let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE]; + for (node_index, [lod0, lod1]) in node_slots.iter().copied().enumerate() { + let offset = node_index * NODE_STRIDE + 8; + nodes[offset..offset + 2].copy_from_slice(&lod0.to_le_bytes()); + nodes[offset + 2..offset + 4].copy_from_slice(&lod1.to_le_bytes()); + } + let mut slots = vec![[0_u8; 68]; face_ranges.len()]; + for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) { + slot[..2].copy_from_slice(&start.to_le_bytes()); + slot[2..4].copy_from_slice(&count.to_le_bytes()); + } + terrain.nodes_raw = nodes; + terrain.slots.slots_raw = slots; + } + #[test] fn terrain_ranges_preserve_face_order_and_packed_material_tags() { - let terrain = LandMeshDocument { + let mut terrain = LandMeshDocument { streams: Vec::new(), nodes_raw: Vec::new(), slots: TerrainSlotTable { @@ -2123,8 +2222,10 @@ mod tests { terrain_face_with_tag(0xff01, [0, 1, 2]), ], }; + set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 4]]); - let ranges = static_terrain_draw_ranges(&terrain).expect("representable terrain"); + let lod0 = static_terrain_lod0_assignments(&terrain).expect("terrain LOD0"); + let ranges = static_terrain_draw_ranges(&terrain, &lod0).expect("representable terrain"); assert_eq!(ranges.len(), 3); assert_eq!(ranges[0].first_index, 0); diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 0b1319d..1260af9 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -54,7 +54,9 @@ use fparkan_runtime::{ loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionObjectDraft, MissionRequest, }; -use fparkan_terrain::{FullSurfaceMask, SurfaceQuery, TerrainMaterialLayers, TerrainWorld}; +use fparkan_terrain::{ + FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld, +}; use fparkan_vfs::DirectoryVfs; use serde::Deserialize; use std::collections::{HashMap, HashSet}; @@ -1390,24 +1392,55 @@ fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle { fn terrain_shadow_receivers( terrain: &fparkan_terrain_format::LandMeshDocument, ) -> Result, String> { - terrain - .faces - .iter() - .map(|face| { - let positions = face.vertices.map(|index| { - let source = terrain - .positions - .get(usize::from(index)) - .copied() - .unwrap_or([0.0; 3]); - source - }); - if !positions.iter().flatten().all(|value| value.is_finite()) { - return Err("terrain shadow receiver contains a non-finite position".to_string()); - } - Ok(shadow_triangle(positions, face.flags.0)) - }) - .collect() + let assignments = terrain + .face_lod_assignments() + .map_err(|error| error.to_string())?; + let mut receivers = Vec::with_capacity( + assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .count(), + ); + for assignment in assignments { + if assignment.lod != TerrainLod::Lod0 { + continue; + } + let face = terrain.faces.get(assignment.face_index).ok_or_else(|| { + format!( + "terrain LOD0 references unknown face {}", + assignment.face_index + ) + })?; + let positions = [ + terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[0])?, + terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[1])?, + terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[2])?, + ]; + receivers.push(shadow_triangle(positions, face.flags.0)); + } + Ok(receivers) +} + +fn terrain_shadow_vertex_position( + terrain: &fparkan_terrain_format::LandMeshDocument, + face_index: usize, + vertex_index: u16, +) -> Result<[f32; 3], String> { + let position = terrain + .positions + .get(usize::from(vertex_index)) + .copied() + .ok_or_else(|| { + format!( + "terrain LOD0 shadow face {face_index} references unknown vertex {vertex_index}" + ) + })?; + if !position.iter().all(|value| value.is_finite()) { + return Err(format!( + "terrain LOD0 shadow face {face_index} has a non-finite vertex {vertex_index}" + )); + } + Ok(position) } fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec { @@ -5938,6 +5971,20 @@ fn usage() -> String { mod tests { use super::*; + fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) { + const NODE_STRIDE: usize = 38; + const SLOT_STRIDE: usize = 68; + let mut node = vec![u8::MAX; NODE_STRIDE]; + node[8..10].copy_from_slice(&0_u16.to_le_bytes()); + node[10..12].copy_from_slice(&u16::MAX.to_le_bytes()); + let mut slot = [0_u8; SLOT_STRIDE]; + slot[..2].copy_from_slice(&0_u16.to_le_bytes()); + let face_count = u16::try_from(terrain.faces.len()).expect("test face count fits"); + slot[2..4].copy_from_slice(&face_count.to_le_bytes()); + terrain.nodes_raw = node; + terrain.slots.slots_raw = vec![slot]; + } + fn strings(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_string()).collect() } @@ -6507,7 +6554,7 @@ mod tests { assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0])); assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0])); - let land = fparkan_terrain_format::LandMeshDocument { + let mut land = fparkan_terrain_format::LandMeshDocument { streams: Vec::new(), nodes_raw: Vec::new(), slots: fparkan_terrain_format::TerrainSlotTable { @@ -6531,8 +6578,14 @@ mod tests { raw: [0; 28], }], }; + set_test_terrain_lod0_range(&mut land); let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers"); assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]); + let mut invalid_shadow_face = land.clone(); + invalid_shadow_face.faces[0].vertices[2] = u16::MAX; + assert!(terrain_shadow_receivers(&invalid_shadow_face) + .expect_err("bad terrain indices must fail instead of inventing vertices") + .contains("unknown vertex 65535")); let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain"); assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0)); let floor_camera = @@ -6583,31 +6636,32 @@ mod tests { [0.0, 0.0, -1.0] )); + let mut excluded_land = fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(0x20), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }; + set_test_terrain_lod0_range(&mut excluded_land); let excluded_terrain = - TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument { - streams: Vec::new(), - nodes_raw: Vec::new(), - slots: fparkan_terrain_format::TerrainSlotTable { - header_raw: Vec::new(), - slots_raw: Vec::new(), - }, - positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]], - normals: Vec::new(), - uv0: Vec::new(), - accelerator: Vec::new(), - aux14: Vec::new(), - aux18: Vec::new(), - faces: vec![fparkan_terrain_format::TerrainFace28 { - flags: FullSurfaceMask(0x20), - material_tag: 0, - aux_tag: 0, - vertices: [0, 1, 2], - neighbors: [None, None, None], - tail_raw: [0; 8], - raw: [0; 28], - }], - }) - .expect("excluded terrain"); + TerrainWorld::from_land_msh(&excluded_land).expect("excluded terrain"); assert!(native_sun_unoccluded_with_world( &excluded_terrain, &world_mesh, diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index 639fc57..88bd325 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -28,6 +28,7 @@ const REQUIRED_TYPES: [u32; 9] = [ const AREAL_PREFIX_SIZE: usize = 56; const SLOT_HEADER_SIZE: usize = 0x8c; const SLOT_STRIDE: usize = 68; +const NODE38_STRIDE: usize = 38; const GRID_HIT_COUNT_BITS: u32 = 10; const GRID_POOL_OFFSET_MASK: u32 = (1 << 22) - 1; @@ -152,6 +153,129 @@ pub struct TerrainFace28 { pub raw: [u8; 28], } +/// Terrain detail level selected by one Node38 slot reference. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerrainLod { + /// Near terrain geometry. + Lod0, + /// Far terrain geometry. + Lod1, +} + +/// Node and detail level that own one source face. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerrainFaceLod { + /// Index into [`LandMeshDocument::faces`]. + pub face_index: usize, + /// Index into the Node38 stream. + pub node_index: usize, + /// Selected terrain detail level. + pub lod: TerrainLod, +} + +/// Error in the terrain Node38-to-face slot mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TerrainLodError { + /// Nonempty terrain has no Node38 records to select its slots. + MissingNodes { face_count: usize }, + /// Node stream size is not a whole number of 38-byte records. + InvalidNodeStreamSize { size: usize }, + /// Terrain uses a slot group beyond its two supported detail levels. + UnsupportedNodeSlot { + /// Node containing the unsupported slot. + node: usize, + /// Index in the 15-entry Node38 slot map. + slot_index: usize, + /// Raw terrain slot id. + slot: u16, + }, + /// A Node38 entry references a missing Slot68 record. + SlotOutOfRange { + /// Node containing the reference. + node: usize, + /// Selected detail level. + lod: TerrainLod, + /// Raw terrain slot id. + slot: u16, + /// Number of decoded Slot68 records. + slot_count: usize, + }, + /// A Slot68 triangle range exceeds the source face array. + FaceRangeOutOfBounds { + /// Node containing the slot. + node: usize, + /// Selected detail level. + lod: TerrainLod, + /// Raw terrain slot id. + slot: u16, + /// First source face. + start: usize, + /// Number of faces. + count: usize, + /// Number of source faces. + face_count: usize, + }, + /// A source face belongs to more than one terrain slot. + DuplicateFaceAssignment { face: usize }, + /// A source face is absent from every supported terrain slot. + MissingFaceAssignment { face: usize }, + /// A checked index calculation overflowed. + IntegerOverflow, +} + +impl std::fmt::Display for TerrainLodError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingNodes { face_count } => write!( + f, + "terrain has {face_count} faces but no Node38 records" + ), + Self::InvalidNodeStreamSize { size } => write!( + f, + "terrain Node38 stream has {size} bytes, not a multiple of {NODE38_STRIDE}" + ), + Self::UnsupportedNodeSlot { + node, + slot_index, + slot, + } => write!( + f, + "terrain Node38 {node} uses unsupported slot group {slot_index} ({slot})" + ), + Self::SlotOutOfRange { + node, + lod, + slot, + slot_count, + } => write!( + f, + "terrain Node38 {node} LOD {lod:?} references slot {slot} outside {slot_count} slots" + ), + Self::FaceRangeOutOfBounds { + node, + lod, + slot, + start, + count, + face_count, + } => write!( + f, + "terrain Node38 {node} LOD {lod:?} slot {slot} range {start}..{} exceeds {face_count} faces", + start.saturating_add(*count) + ), + Self::DuplicateFaceAssignment { face } => { + write!(f, "terrain face {face} belongs to more than one slot") + } + Self::MissingFaceAssignment { face } => { + write!(f, "terrain face {face} is not assigned to a supported slot") + } + Self::IntegerOverflow => write!(f, "terrain slot index overflow"), + } + } +} + +impl std::error::Error for TerrainLodError {} + impl TerrainFace28 { /// Decodes the proven two-table selector layout of [`Self::material_tag`]. #[must_use] @@ -273,6 +397,106 @@ pub struct LandMeshDocument { } impl LandMeshDocument { + /// Resolves each source face to the Node38 and terrain LOD that own it. + /// + /// The supported terrain layout uses slot-map entries 0 and 1 for LOD0 + /// and LOD1. The other 13 entries must be empty. Every source face must be + /// covered exactly once; empty terrain may omit Node38 records. + /// + /// # Errors + /// + /// Returns [`TerrainLodError`] for missing or unsupported slots, invalid + /// ranges, overlaps, or uncovered faces. + pub fn face_lod_assignments(&self) -> Result, TerrainLodError> { + if self.faces.is_empty() && self.nodes_raw.is_empty() { + return Ok(Vec::new()); + } + if !self.nodes_raw.len().is_multiple_of(NODE38_STRIDE) { + return Err(TerrainLodError::InvalidNodeStreamSize { + size: self.nodes_raw.len(), + }); + } + if self.nodes_raw.is_empty() { + return Err(TerrainLodError::MissingNodes { + face_count: self.faces.len(), + }); + } + + let mut assignments = vec![None; self.faces.len()]; + for (node_index, node) in self.nodes_raw.chunks_exact(NODE38_STRIDE).enumerate() { + for slot_index in 2..15 { + let offset = 8 + slot_index * 2; + let slot = u16::from_le_bytes([node[offset], node[offset + 1]]); + if slot != u16::MAX { + return Err(TerrainLodError::UnsupportedNodeSlot { + node: node_index, + slot_index, + slot, + }); + } + } + + for (lod_index, lod) in [TerrainLod::Lod0, TerrainLod::Lod1].into_iter().enumerate() { + let offset = 8 + lod_index * 2; + let slot = u16::from_le_bytes([node[offset], node[offset + 1]]); + if slot == u16::MAX { + continue; + } + let slot_index = usize::from(slot); + let raw = self.slots.slots_raw.get(slot_index).ok_or( + TerrainLodError::SlotOutOfRange { + node: node_index, + lod, + slot, + slot_count: self.slots.slots_raw.len(), + }, + )?; + let start = usize::from(u16::from_le_bytes([raw[0], raw[1]])); + let count = usize::from(u16::from_le_bytes([raw[2], raw[3]])); + let end = start + .checked_add(count) + .ok_or(TerrainLodError::IntegerOverflow)?; + if end > self.faces.len() { + return Err(TerrainLodError::FaceRangeOutOfBounds { + node: node_index, + lod, + slot, + start, + count, + face_count: self.faces.len(), + }); + } + for face_index in start..end { + let assignment = assignments.get_mut(face_index).ok_or( + TerrainLodError::FaceRangeOutOfBounds { + node: node_index, + lod, + slot, + start, + count, + face_count: self.faces.len(), + }, + )?; + if assignment.is_some() { + return Err(TerrainLodError::DuplicateFaceAssignment { face: face_index }); + } + *assignment = Some(TerrainFaceLod { + face_index, + node_index, + lod, + }); + } + } + } + assignments + .into_iter() + .enumerate() + .map(|(face_index, assignment)| { + assignment.ok_or(TerrainLodError::MissingFaceAssignment { face: face_index }) + }) + .collect() + } + /// Resolves the type-11 material pairs addressed by one 68-byte slot. /// /// Returns `None` when the slot is absent or its pair range does not fit @@ -1563,6 +1787,80 @@ mod tests { assert_eq!(slots.render_dispatch(1), None); } + #[test] + fn terrain_face_lod_assignments_keep_interleaved_source_order_and_reject_missing_nodes() { + let mut nodes_raw = vec![u8::MAX; 2 * NODE38_STRIDE]; + for (node_index, [lod0_slot, lod1_slot]) in [[0_u16, 1_u16], [2, 3]].into_iter().enumerate() + { + let offset = node_index * NODE38_STRIDE + 8; + nodes_raw[offset..offset + 2].copy_from_slice(&lod0_slot.to_le_bytes()); + nodes_raw[offset + 2..offset + 4].copy_from_slice(&lod1_slot.to_le_bytes()); + } + let mut slots_raw = vec![[0_u8; SLOT_STRIDE]; 4]; + for (raw, start) in slots_raw.iter_mut().zip([0_u16, 1, 2, 3]) { + raw[..2].copy_from_slice(&start.to_le_bytes()); + raw[2..4].copy_from_slice(&1_u16.to_le_bytes()); + } + let mesh = LandMeshDocument { + streams: Vec::new(), + nodes_raw, + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw, + }, + positions: Vec::new(), + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: (0..4).map(|_| terrain_face([0, 1, 2])).collect(), + }; + + let assignments = mesh.face_lod_assignments().expect("complete slot mapping"); + assert_eq!( + assignments, + vec![ + TerrainFaceLod { + face_index: 0, + node_index: 0, + lod: TerrainLod::Lod0, + }, + TerrainFaceLod { + face_index: 1, + node_index: 0, + lod: TerrainLod::Lod1, + }, + TerrainFaceLod { + face_index: 2, + node_index: 1, + lod: TerrainLod::Lod0, + }, + TerrainFaceLod { + face_index: 3, + node_index: 1, + lod: TerrainLod::Lod1, + }, + ] + ); + + let mut missing_nodes = mesh.clone(); + missing_nodes.nodes_raw.clear(); + assert_eq!( + missing_nodes.face_lod_assignments(), + Err(TerrainLodError::MissingNodes { face_count: 4 }) + ); + missing_nodes.faces.clear(); + assert_eq!(missing_nodes.face_lod_assignments(), Ok(Vec::new())); + + let mut overlapping = mesh; + overlapping.slots.slots_raw[3][..2].copy_from_slice(&2_u16.to_le_bytes()); + assert_eq!( + overlapping.face_lod_assignments(), + Err(TerrainLodError::DuplicateFaceAssignment { face: 2 }) + ); + } + #[test] fn slot_material_pairs_use_type11_pair_table_and_preserve_flags() { let nres = @@ -1836,6 +2134,42 @@ Generator 1 .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); let document = decode_land_msh(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + let assignments = document + .face_lod_assignments() + .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); + assert_eq!(assignments.len(), document.faces.len(), "{corpus} {path:?}"); + for (face_index, assignment) in assignments.iter().enumerate() { + assert_eq!(assignment.face_index, face_index, "{corpus} {path:?}"); + } + let lod0_faces = assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .count(); + let lod1_faces = assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod1) + .count(); + assert_eq!( + lod0_faces + lod1_faces, + document.faces.len(), + "{corpus} {path:?}" + ); + if corpus == "IS" { + let map_name = path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase); + let expected_lod0 = match map_name.as_deref() { + Some("automap") => Some(1_912), + Some("prol") => Some(5_868), + Some("tut_1") => Some(5_046), + _ => None, + }; + if let Some(expected) = expected_lod0 { + assert_eq!(lod0_faces, expected, "{corpus} {path:?} LOD0 face count"); + } + } files += 1; vertices += document.positions.len(); faces += document.faces.len(); @@ -2127,6 +2461,18 @@ Generator 1 out } + fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 { + TerrainFace28 { + flags: FullSurfaceMask(0), + material_tag: 0, + aux_tag: 0, + vertices, + neighbors: [None; 3], + tail_raw: *b"TAILFACE", + raw: face(vertices, [None; 3]), + } + } + fn entry(type_id: u32, attr1: u32, attr3: u32, payload: &[u8]) -> TestEntry<'_> { TestEntry { type_id, diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index cd2a874..a014b61 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -9,7 +9,7 @@ use std::collections::VecDeque; /// Applications access this semantic terrain API through this terrain crate /// rather than taking a direct dependency on the binary-format parser. pub use fparkan_terrain_format::{ - FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection, + FullSurfaceMask, TerrainLod, TerrainLodError, TerrainMaterialLayers, TerrainMaterialSelection, }; /// Terrain world. @@ -91,6 +91,8 @@ pub enum TerrainError { /// Position count. position_count: usize, }, + /// Terrain Node38/Slot68 records do not assign every face to one LOD. + InvalidLodMapping(TerrainLodError), } impl std::fmt::Display for TerrainError { @@ -117,11 +119,23 @@ impl std::fmt::Display for TerrainError { f, "terrain face {face} references vertex {vertex} outside {position_count} positions" ), + Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"), } } } -impl std::error::Error for TerrainError {} +impl std::error::Error for TerrainError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidLodMapping(error) => Some(error), + Self::Unsupported + | Self::TooManyAreals { .. } + | Self::InvalidGridReference { .. } + | Self::InvalidArealReference { .. } + | Self::InvalidSurfaceVertex { .. } => None, + } + } +} /// Surface query. pub trait SurfaceQuery { @@ -247,8 +261,8 @@ impl TerrainWorld { /// /// # Errors /// - /// Returns [`TerrainError`] if a face cannot be represented by runtime - /// indexes. + /// Returns [`TerrainError`] if terrain LOD metadata or a face cannot be + /// represented by runtime indexes. pub fn from_land_msh(mesh: &LandMeshDocument) -> Result { Ok(Self { surfaces: build_surfaces(mesh)?, @@ -878,8 +892,19 @@ impl RuntimeGrid { } fn build_surfaces(mesh: &LandMeshDocument) -> Result, TerrainError> { - let mut triangles = Vec::with_capacity(mesh.faces.len()); - for (face_index, face) in mesh.faces.iter().enumerate() { + let assignments = mesh + .face_lod_assignments() + .map_err(TerrainError::InvalidLodMapping)?; + let lod0_count = assignments + .iter() + .filter(|assignment| assignment.lod == TerrainLod::Lod0) + .count(); + let mut triangles = Vec::with_capacity(lod0_count); + for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) { + if assignment.lod != TerrainLod::Lod0 { + continue; + } + let face_index = assignment.face_index; let vertices = [ surface_vertex(mesh, face_index, face.vertices[0])?, surface_vertex(mesh, face_index, face.vertices[1])?, @@ -1243,7 +1268,7 @@ mod tests { let face0 = terrain_face(FullSurfaceMask(0x0000_0001), [0, 1, 2]); let face1 = terrain_face(FullSurfaceMask(0x0000_0002), [1, 3, 2]); - LandMeshDocument { + let mut mesh = LandMeshDocument { streams: Vec::::new(), nodes_raw: Vec::new(), slots: TerrainSlotTable { @@ -1262,7 +1287,14 @@ mod tests { aux14: Vec::new(), aux18: Vec::new(), faces: vec![face0, face1], - } + }; + let mut node = vec![u8::MAX; 38]; + node[8..10].copy_from_slice(&0_u16.to_le_bytes()); + let mut slot = [0_u8; 68]; + slot[2..4].copy_from_slice(&(mesh.faces.len() as u16).to_le_bytes()); + mesh.nodes_raw = node; + mesh.slots.slots_raw.push(slot); + mesh } fn terrain_face( diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index dce7724..b04af31 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -930,6 +930,31 @@ sun-ray queries поэтому используют исходные коорд Эти два указателя нельзя подменять друг другом. Формулы камеры и размещения реализованы и проверяются в `fparkan-render`. +## LOD ландшафта + +У `Land.msh` список slot-ссылок terrain хранится в 38-байтовых записях +Node38, а диапазоны граней — в 68-байтовых Slot68. В текущем runtime первые +две ссылки Node38 выбирают соответственно LOD0 и LOD1; каждая запись слота +задаёт начало и число граней в исходном массиве `TerrainFace28`. Все грани +проверяются на единственное назначение, а выходящий за массив, повторный или +неподдерживаемый диапазон считается ошибкой загрузки. + +Первый рендер карты, terrain-запросы высоты и пересечения, receiver-ы теней и +обрезка контуром зданий используют только LOD0. Отбор сохраняет порядок +исходных граней, а контурный проход отдельно переводит индекс спроецированной +грани обратно в исходный. LOD1 пока не подставляется по расстоянию: такой +переход не реализован. + +```rust +let assignments = mesh.face_lod_assignments()?; +for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) { + if assignment.lod != TerrainLod::Lod0 { + continue; + } + // Build terrain query geometry from this source face. +} +``` + ## Материалы ландшафта Рядом с `Land.msh` лежат `Land1.wea` и `Land2.wea`. Старший байт -- cgit v1.2.3