fix(render): select terrain LOD from native camera cells
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s

This commit is contained in:
Valentin Popov committed 2026-11-01 00:09:07 +04:00
1 parent 853d9b115a
commit d7c18d4c1a
9 files changed
+776 -21

No files matched your search

+43 -11
View File
@@ -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()
+286
View File
@@ -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());
}
}