diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/asset_mesh.rs | 23 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/ffi.rs | 3 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 54 | ||||
| -rw-r--r-- | apps/fparkan-game/src/terrain_lod.rs | 286 | ||||
| -rw-r--r-- | crates/fparkan-terrain-format/src/lib.rs | 3 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 80 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lod.rs | 301 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 45 |
9 files changed, 776 insertions, 21 deletions
@@ -9,6 +9,8 @@ прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной позе. +Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры; +под основаниями строений сохраняется подробная сетка. ## Запуск diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index f691b34..826e89a 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -1105,7 +1105,21 @@ fn project_land_msh_to_static_mesh_with_lod0( pub fn project_land_msh_to_static_mesh_in_world_space( terrain: &LandMeshDocument, ) -> Result<VulkanStaticMesh, VulkanAssetMeshError> { - let lod0 = static_terrain_lod0_assignments(terrain)?; + project_land_msh_lod_to_static_mesh_in_world_space(terrain, TerrainLod::Lod0) +} + +/// Projects one authored terrain LOD without changing source positions or attributes. +/// +/// # Errors +/// Returns an error for invalid slot assignments or an empty selected LOD. +pub fn project_land_msh_lod_to_static_mesh_in_world_space( + terrain: &LandMeshDocument, + lod: TerrainLod, +) -> Result<VulkanStaticMesh, VulkanAssetMeshError> { + let mut lod0 = terrain + .face_lod_assignments() + .map_err(VulkanAssetMeshError::InvalidTerrainLodMapping)?; + lod0.retain(|assignment| assignment.lod == lod); let indices = static_terrain_indices(terrain, &lod0)?; let vertices = terrain .positions @@ -2118,6 +2132,13 @@ mod tests { assert_eq!(mesh.draw_ranges[0].index_count, 6); assert_eq!(mesh.draw_ranges[0].material_index, 0x0101); + let far = project_land_msh_lod_to_static_mesh_in_world_space(&terrain, TerrainLod::Lod1) + .expect("LOD1 terrain projection"); + assert_eq!(far.indices, [3, 4, 5, 3, 4, 5]); + assert_eq!(far.draw_ranges.len(), 1); + assert_eq!(far.draw_ranges[0].material_index, 0x0202); + assert_eq!(far.vertices[3].position, [10.0, 10.0, 10.0]); + terrain.nodes_raw.clear(); assert!(matches!( project_land_msh_to_static_mesh_in_world_space(&terrain), diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 9584405..b98fa0d 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -22,7 +22,8 @@ mod validation; pub use self::asset_mesh::{ node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, - node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh, + node38_pose_relative_to_root_from_hierarchy, + project_land_msh_lod_to_static_mesh_in_world_space, project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses, diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index f3211af..f987bed 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -3,6 +3,7 @@ //! `FParkan` render-planning composition root. mod camera_capture; +mod terrain_lod; #[cfg(test)] use camera_capture::parse_legacy_camera_capture; @@ -4626,6 +4627,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { struct StaticPreviewScene { mesh: VulkanStaticMesh, + terrain_lod: Option<terrain_lod::TerrainLodDraws>, native_handedness_by_range: HashMap<usize, i8>, type16_present_ranges: HashSet<usize>, materials: Vec<VulkanStaticMaterial>, @@ -8019,6 +8021,11 @@ fn static_preview_mesh_and_materials( terrain, foundation, )?; + let (terrain_component, terrain_lod) = terrain_lod::prepare( + terrain_mesh, + terrain_component, + terrain_query.basement_foundation(), + )?; let terrain_materials = static_preview_terrain_base_materials( &mut terrain_material_loader, root, @@ -8521,6 +8528,7 @@ fn static_preview_mesh_and_materials( let shadow_material_index = append_shadow_material(&mut materials)?; Ok(StaticPreviewScene { mesh, + terrain_lod, native_handedness_by_range, type16_present_ranges, materials, @@ -9333,6 +9341,7 @@ fn run_static_vulkan_mode( struct StaticVulkanApp { mesh: VulkanStaticMesh, + terrain_lod: Option<terrain_lod::TerrainLodDraws>, native_handedness_by_range: HashMap<usize, i8>, type16_present_ranges: HashSet<usize>, materials: Vec<VulkanStaticMaterial>, @@ -9439,6 +9448,7 @@ impl StaticVulkanApp { .collect(); Self { mesh: preview.mesh, + terrain_lod: preview.terrain_lod, native_handedness_by_range: preview.native_handedness_by_range, type16_present_ranges: preview.type16_present_ranges, materials: preview.materials, @@ -9509,6 +9519,12 @@ impl StaticVulkanApp { renderer: &mut VulkanSmokeRenderer, aspect: f32, ) -> Result<(), String> { + if let Some(lod) = &self.terrain_lod { + lod.report( + self.view_camera.position, + horizontal_fov(&self.view_camera, aspect), + )?; + } let approximate_portals = self .world_range_indices .iter() @@ -9573,18 +9589,26 @@ impl StaticVulkanApp { .map_err(|error| { format!("configure world material category {range_index}: {error}") })?; - renderer - .set_draw_range_index_count( + let count = world_draw_range_index_count( + &self.mesh, + range, + range.index_count, + effective_alpha, + self.view_camera.position, + self.native_handedness_by_range.get(&range_index).copied(), + ); + let count = if let Some(lod) = &self.terrain_lod { + lod.index_count( range_index, - world_draw_range_index_count( - &self.mesh, - range, - range.index_count, - effective_alpha, - self.view_camera.position, - self.native_handedness_by_range.get(&range_index).copied(), - ), - ) + count, + self.view_camera.position, + horizontal_fov(&self.view_camera, aspect), + )? + } else { + count + }; + renderer + .set_draw_range_index_count(range_index, count) .map_err(|error| { format!("configure world draw range {range_index} count: {error}") })?; @@ -9642,6 +9666,7 @@ impl StaticVulkanApp { type16_present_ranges: &HashSet<usize>, world_range_index_counts: &HashMap<usize, u32>, native_handedness_by_range: &HashMap<usize, i8>, + terrain_lod: Option<&terrain_lod::TerrainLodDraws>, eye_world: [f32; 3], horizontal_fov: f32, active_materials: &HashMap<usize, ActiveMaterialState>, @@ -9686,6 +9711,11 @@ impl StaticVulkanApp { eye_world, native_handedness_by_range.get(&range_index).copied(), ); + let count = if let Some(lod) = terrain_lod { + lod.index_count(range_index, count, eye_world, horizontal_fov)? + } else { + count + }; renderer .set_draw_range_index_count(range_index, count) .map_err(|error| format!("update world draw range {range_index} count: {error}"))?; @@ -10295,6 +10325,7 @@ impl StaticVulkanApp { &self.type16_present_ranges, &self.world_range_index_counts, &self.native_handedness_by_range, + self.terrain_lod.as_ref(), frame.camera.position, horizontal_fov(&frame.camera, frame.aspect), &frame.material_update.active_materials, @@ -13571,6 +13602,7 @@ mod tests { }; let view_camera = free_camera.preview_frame(16.0 / 9.0); let preview = StaticPreviewScene { + terrain_lod: None, mesh: VulkanStaticMesh { vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] .into_iter() diff --git a/apps/fparkan-game/src/terrain_lod.rs b/apps/fparkan-game/src/terrain_lod.rs new file mode 100644 index 0000000..599178c --- /dev/null +++ b/apps/fparkan-game/src/terrain_lod.rs @@ -0,0 +1,286 @@ +use fparkan_render_vulkan::{ + project_land_msh_lod_to_static_mesh_in_world_space, VulkanStaticDrawRange, VulkanStaticMesh, +}; +use fparkan_terrain::{BasementFoundation, TerrainLod, TerrainLodPlan}; +use fparkan_terrain_format::LandMeshDocument; + +pub(super) struct TerrainLodDraws { + plan: TerrainLodPlan, + ranges: Vec<(Option<usize>, TerrainLod)>, +} + +impl TerrainLodDraws { + pub(super) fn index_count( + &self, + range: usize, + count: u32, + eye: [f32; 3], + fov: f32, + ) -> Result<u32, String> { + let Some(&(Some(node), lod)) = self.ranges.get(range) else { + return Ok(count); + }; + let selected = self + .plan + .selected_lod(node, [eye[0], eye[1]], fov) + .ok_or_else(|| "invalid terrain LOD camera or cell".to_string())?; + Ok(if selected == lod { count } else { 0 }) + } + + pub(super) fn report(&self, eye: [f32; 3], fov: f32) -> Result<(), String> { + let mut counts = [0; 2]; + for (range, &(_, lod)) in self.ranges.iter().enumerate() { + if self.index_count(range, 1, eye, fov)? != 0 { + counts[usize::from(lod == TerrainLod::Lod1)] += 1; + } + } + eprintln!( + "render_coverage terrain_lod_selected_ranges lod0={} lod1={}", + counts[0], counts[1] + ); + Ok(()) + } +} + +pub(super) fn prepare( + terrain: &LandMeshDocument, + mut near_mesh: VulkanStaticMesh, + foundation: Option<&BasementFoundation>, +) -> Result<(VulkanStaticMesh, Option<TerrainLodDraws>), String> { + let assignments = terrain.face_lod_assignments().map_err(|e| e.to_string())?; + if !assignments.iter().any(|a| a.lod == TerrainLod::Lod1) { + return Ok((near_mesh, None)); + } + let plan = TerrainLodPlan::new(terrain, foundation) + .map_err(|e| format!("prepare terrain LOD: {e}"))?; + let near_nodes = if let Some(foundation) = foundation { + foundation + .fragments + .iter() + .map(|fragment| { + fragment + .source_face + .map(|face| { + plan.source_face_node(face) + .ok_or_else(|| "foundation LOD source face is missing".to_string()) + }) + .transpose() + }) + .collect::<Result<Vec<_>, _>>()? + } else { + assignments + .iter() + .filter(|a| a.lod == TerrainLod::Lod0) + .map(|a| Some(a.node_index)) + .collect() + }; + let mut ranges = split_ranges(&mut near_mesh, &near_nodes, TerrainLod::Lod0)?; + if plan.has_far_geometry() { + let mut far_mesh = + project_land_msh_lod_to_static_mesh_in_world_space(terrain, TerrainLod::Lod1) + .map_err(|e| format!("project far terrain: {e}"))?; + let far_nodes = assignments + .iter() + .filter(|a| a.lod == TerrainLod::Lod1) + .map(|a| Some(a.node_index)) + .collect::<Vec<_>>(); + ranges.extend(split_ranges(&mut far_mesh, &far_nodes, TerrainLod::Lod1)?); + let vertex_offset = u32::try_from(near_mesh.vertices.len()) + .map_err(|_| "terrain LOD vertex offset exceeds u32".to_string())?; + let index_offset = u32::try_from(near_mesh.indices.len()) + .map_err(|_| "terrain LOD index offset exceeds u32".to_string())?; + for index in &mut far_mesh.indices { + *index = index + .checked_add(vertex_offset) + .ok_or_else(|| "far terrain vertex index overflows".to_string())?; + } + for range in &mut far_mesh.draw_ranges { + range.first_index = range + .first_index + .checked_add(index_offset) + .ok_or_else(|| "far terrain draw range overflows".to_string())?; + } + near_mesh.vertices.extend(far_mesh.vertices); + near_mesh.indices.extend(far_mesh.indices); + near_mesh.draw_ranges.extend(far_mesh.draw_ranges); + } + Ok((near_mesh, Some(TerrainLodDraws { plan, ranges }))) +} + +fn split_ranges( + mesh: &mut VulkanStaticMesh, + nodes: &[Option<usize>], + lod: TerrainLod, +) -> Result<Vec<(Option<usize>, TerrainLod)>, String> { + if mesh.indices.len() / 3 != nodes.len() || !mesh.indices.len().is_multiple_of(3) { + return Err("terrain LOD triangle metadata does not match the index buffer".to_string()); + } + let mut ranges = Vec::<VulkanStaticDrawRange>::new(); + let mut bindings = Vec::new(); + for &range in &mesh.draw_ranges { + if !range.first_index.is_multiple_of(3) || !range.index_count.is_multiple_of(3) { + return Err("terrain LOD range is not triangle aligned".to_string()); + } + let first = range.first_index as usize / 3; + let end = first + .checked_add(range.index_count as usize / 3) + .filter(|&end| end <= nodes.len()) + .ok_or_else(|| "terrain LOD range is outside triangle metadata".to_string())?; + let mut start = first; + while start < end { + let node = nodes[start]; + let mut next = start + 1; + while next < end && nodes[next] == node { + next += 1; + } + let mut split = range; + split.first_index = + u32::try_from(start * 3).map_err(|_| "terrain LOD index overflows".to_string())?; + split.index_count = u32::try_from((next - start) * 3) + .map_err(|_| "terrain LOD count overflows".to_string())?; + ranges.push(split); + bindings.push((node, lod)); + start = next; + } + } + mesh.draw_ranges = ranges; + Ok(bindings) +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_render_vulkan::VulkanStaticVertex; + + #[test] + #[ignore = "requires licensed GOG Constructor resources"] + fn gog_constructor_lod0_cells_match_native_basement_mask() { + use fparkan_runtime::*; + use std::{num::NonZeroUsize, sync::Arc}; + let root = std::env::var_os("FPARKAN_GAME_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + let mut engine = create(EngineServices::new(Arc::new( + fparkan_vfs::DirectoryVfs::new(root), + ))) + .unwrap(); + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: r"MISSIONS\CAMPAIGN\CAMPAIGN.00\Mission.02\data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(usize::MAX).unwrap(), + ) + .unwrap(); + let assets = loaded_mission_assets(&engine).unwrap(); + let roots = loaded_mission_object_drafts(&engine).unwrap(); + let terrain = loaded_terrain(&engine).unwrap().source_mesh().unwrap(); + let footprints = super::super::mission_basement_footprints(assets, roots).unwrap(); + let borrowed = footprints + .iter() + .map(|f| fparkan_terrain::BasementFootprint { + outer: &f.outer, + inner: &f.inner, + flatten_inner_to_mean_outer_z: f.flatten_inner_to_mean_outer_z, + }) + .collect::<Vec<_>>(); + let mut foundation = + fparkan_terrain::build_basement_foundations(terrain, &borrowed).unwrap(); + let source = + fparkan_render_vulkan::project_land_msh_to_static_mesh_in_world_space(terrain).unwrap(); + let (near, _) = + super::super::project_basement_foundation(terrain, &source, &mut foundation).unwrap(); + let plan = TerrainLodPlan::new(terrain, Some(&foundation)).unwrap(); + let forced = (0..terrain.nodes_raw.len() / 38) + .filter(|&node| plan.selected_lod(node, [-10_000.0; 2], 1.3) == Some(TerrainLod::Lod0)) + .collect::<Vec<_>>(); + // GOG Terrain.dll (hash in original-binaries.md), live Render +0x11A19: + // cells whose patch list at Render-this +0x7A30 has a nonzero count. + assert_eq!( + forced, + [ + 54, 55, 56, 57, 70, 71, 72, 73, 86, 87, 113, 114, 115, 129, 130, 131, 146, 147, + 183, 184, 199, 200 + ] + ); + let (mesh, draws) = prepare(terrain, near, Some(&foundation)).unwrap(); + let draws = draws.unwrap(); + let mut checked = [false; 2]; + for (range, &(node, lod)) in draws.ranges.iter().enumerate() { + if node != Some(40) { + continue; + } + let count = mesh.draw_ranges[range].index_count; + // Native cell 40 selects far at camera cell [2, 5], near at [2, 7]. + assert_eq!( + draws + .index_count(range, count, [300.42047, 556.282, 155.91914], 1.3) + .unwrap(), + if lod == TerrainLod::Lod1 { count } else { 0 } + ); + assert_eq!( + draws + .index_count(range, count, [265.0, 795.0, 155.91914], 1.3) + .unwrap(), + if lod == TerrainLod::Lod0 { count } else { 0 } + ); + checked[usize::from(lod == TerrainLod::Lod1)] = true; + } + assert_eq!(checked, [true, true]); + } + + #[test] + fn cell_ranges_keep_material_state_and_separate_generated_bands() { + let range = VulkanStaticDrawRange { + first_index: 0, + index_count: 12, + material_index: 7, + lightmap_index: u8::MAX, + batch_flags: 0, + terrain_base_only: true, + pipeline_state: super::super::world_pipeline_state( + fparkan_render::LegacyBlendMode::Opaque, + fparkan_render::LegacyDepthMode::TestWrite, + ), + alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE, + }; + let mut mesh = VulkanStaticMesh { + vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + .map(|position| VulkanStaticVertex { + position, + color: [1.0; 3], + normal: [0.0, 0.0, 1.0], + uv: [0.0; 2], + detail_uv: [0.0; 2], + overlay_alpha: 0.0, + }) + .to_vec(), + indices: vec![0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2], + draw_ranges: vec![range], + }; + let nodes = [Some(4), Some(4), None, Some(5)]; + let bindings = split_ranges(&mut mesh, &nodes, TerrainLod::Lod0).unwrap(); + assert_eq!( + bindings, + vec![ + (Some(4), TerrainLod::Lod0), + (None, TerrainLod::Lod0), + (Some(5), TerrainLod::Lod0) + ] + ); + assert_eq!( + mesh.draw_ranges + .iter() + .map(|r| (r.first_index, r.index_count)) + .collect::<Vec<_>>(), + vec![(0, 6), (6, 3), (9, 3)] + ); + for split in &mesh.draw_ranges { + assert_eq!(split.material_index, 7); + assert_eq!(split.pipeline_state, range.pipeline_state); + assert!(split.terrain_base_only); + } + assert!(split_ranges(&mut mesh, &nodes[..3], TerrainLod::Lod0).is_err()); + } +} diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index 595f4d8..63d21a1 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -226,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, } @@ -276,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"), } } 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); + } +} diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 610491b..034e255 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1030,11 +1030,39 @@ Node38, а диапазоны граней — в 68-байтовых Slot68. В проверяются на единственное назначение, а выходящий за массив, повторный или неподдерживаемый диапазон считается ошибкой загрузки. -Первый рендер карты, terrain-запросы высоты и пересечения, receiver-ы теней и -обрезка контуром зданий используют только LOD0. Отбор сохраняет порядок -исходных граней, а контурный проход отдельно переводит индекс спроецированной -грани обратно в исходный. LOD1 пока не подставляется по расстоянию: такой -переход не реализован. +Рисуемый уровень выбирается по ячейкам, а не по расстоянию до отдельного +треугольника. Начало сетки находится в первых двух float заголовка Slot68, +размер ячейки — в границах первого слота LOD0. Атрибут 2 потока Node38 задаёт +число ячеек по X; индекс ячейки равен `x * count_y + y`. Приложение проверяет, +что границы всех слотов образуют эту регулярную сетку. Все 33 карты GOG +проходят такую проверку, включая вырожденные грани на общей границе ячеек: +принадлежность грани берётся из слота, а не угадывается по её центру. + +Координаты камеры переводятся в целые координаты сетки округлением вниз. +Если `dx * dx + dy * dy >= 9` и горизонтальный FOV не меньше 0,5 радиана, +выбирается LOD1. При увеличении масштаба, отсутствии дальнего слота или +наличии геометрического patch остаётся LOD0. Камера за границей карты не +прижимается к крайней ячейке для этого расчёта. Правило восстановлено по +GOG `Terrain.dll` ([сборка и SHA-256](../reference/original-binaries.md)): +`CLandscape::Render`, RVA `0x11340`, выбор уровня `0x11878–0x11A03`. +Исполнение оригинала подтверждает выбор дальнего слота, а getter камеры +`Ngi32.dll` RVA `0x4D960` возвращает именно FOV. + +Основания строений сохраняют подробную геометрию. Нативная регистрация +patch (`Terrain.dll` RVA `0x12E20`) проверяет его треугольники против каждой +ячейки через `mrnParaIntersectsPoly`. Поэтому учитываются и соседние ячейки, +которых касается край треугольника. Перед проверкой координаты нормализуются +в `f32` относительно центра ячейки; замена этой операции сравнением мировых +границ меняет результат на общих рёбрах. На «Конструкторе» игнорируемый +ресурсный тест сверяет все 22 ячейки с принудительным LOD0 с набором, +полученным в живом оригинале. + +Vulkan получает оба уровня один раз. Диапазоны индексов разделены по +материалу и исходной ячейке; на каждом кадре включается только выбранный +уровень. Сгенерированные отмостки остаются включены. Terrain-запросы высоты +и пересечения, receiver-ы теней и обрезка контуром зданий используют +канонический LOD0. Отбор сохраняет порядок исходных граней, а контурный проход +отдельно переводит индекс спроецированной грани обратно в исходный. ```rust let assignments = mesh.face_lod_assignments()?; @@ -1052,9 +1080,10 @@ for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) { исходные фрагменты рельефа вместе с треугольниками отмостки в одном наборе. `TerrainWorld::with_basement_foundation` принимает этот набор во владение и устанавливает те же координаты и маски в BVH высотных запросов и пересечений. -Предпросмотр миссии на Vulkan пока строит только LOD0: связывание BAS-записей -с этой подготовкой ещё не подключено, поэтому наличие API не означает, что -геометрия миссии уже обрезана. +Предпросмотр миссии связывает BAS-записи с этой подготовкой до загрузки +материалов. Полученная поверхность общая для изображения, теней и запросов; +поправка размещения основания один раз применяется к корню строения и его +присоединённым компонентам. Внешнее кольцо должно лежать на области LOD0, а каждый его сегмент — оставаться покрытым текущей поверхностью. Непокрытый сегмент или пересекающиеся контуры |
