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 /apps/fparkan-game/src | |
| parent | 853d9b115aa3a7e6790c79c17418e0669d322023 (diff) | |
| download | fparkan-d7c18d4c1ab576e4329a85b4729403fef3c09183.tar.xz fparkan-d7c18d4c1ab576e4329a85b4729403fef3c09183.zip | |
fix(render): select terrain LOD from native camera cells
Diffstat (limited to 'apps/fparkan-game/src')
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 54 | ||||
| -rw-r--r-- | apps/fparkan-game/src/terrain_lod.rs | 286 |
2 files changed, 329 insertions, 11 deletions
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()); + } +} |
