diff options
Diffstat (limited to 'apps/fparkan-game/src/terrain_lod.rs')
| -rw-r--r-- | apps/fparkan-game/src/terrain_lod.rs | 288 |
1 files changed, 288 insertions, 0 deletions
diff --git a/apps/fparkan-game/src/terrain_lod.rs b/apps/fparkan-game/src/terrain_lod.rs new file mode 100644 index 0000000..1dd707d --- /dev/null +++ b/apps/fparkan-game/src/terrain_lod.rs @@ -0,0 +1,288 @@ +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 { + owner_scope: 0, + first_index: 0, + index_count: 12, + material_index: 7, + lightmap_index: u8::MAX, + batch_flags: 0, + terrain_base_only: true, + terrain_reflection: false, + 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()); + } +} |
