fix(render): select terrain LOD from native camera cells
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
This commit is contained in:
9 files changed
+776
-21
No files matched your search
@@ -9,6 +9,8 @@
|
|||||||
прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим
|
прототипа, а поддерживаемые Control-анимации и эффекты обновляются по общим
|
||||||
часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной
|
часам сцены. Неподдерживаемые Control-треки остаются на сохранённой начальной
|
||||||
позе.
|
позе.
|
||||||
|
Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры;
|
||||||
|
под основаниями строений сохраняется подробная сетка.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
|
|||||||
@@ -1105,7 +1105,21 @@ fn project_land_msh_to_static_mesh_with_lod0(
|
|||||||
pub fn project_land_msh_to_static_mesh_in_world_space(
|
pub fn project_land_msh_to_static_mesh_in_world_space(
|
||||||
terrain: &LandMeshDocument,
|
terrain: &LandMeshDocument,
|
||||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
) -> 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 indices = static_terrain_indices(terrain, &lod0)?;
|
||||||
let vertices = terrain
|
let vertices = terrain
|
||||||
.positions
|
.positions
|
||||||
@@ -2118,6 +2132,13 @@ mod tests {
|
|||||||
assert_eq!(mesh.draw_ranges[0].index_count, 6);
|
assert_eq!(mesh.draw_ranges[0].index_count, 6);
|
||||||
assert_eq!(mesh.draw_ranges[0].material_index, 0x0101);
|
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();
|
terrain.nodes_raw.clear();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
project_land_msh_to_static_mesh_in_world_space(&terrain),
|
project_land_msh_to_static_mesh_in_world_space(&terrain),
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ mod validation;
|
|||||||
|
|
||||||
pub use self::asset_mesh::{
|
pub use self::asset_mesh::{
|
||||||
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
|
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_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, project_msh_to_static_mesh_in_world_space,
|
||||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! `FParkan` render-planning composition root.
|
//! `FParkan` render-planning composition root.
|
||||||
|
|
||||||
mod camera_capture;
|
mod camera_capture;
|
||||||
|
mod terrain_lod;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use camera_capture::parse_legacy_camera_capture;
|
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 {
|
struct StaticPreviewScene {
|
||||||
mesh: VulkanStaticMesh,
|
mesh: VulkanStaticMesh,
|
||||||
|
terrain_lod: Option<terrain_lod::TerrainLodDraws>,
|
||||||
native_handedness_by_range: HashMap<usize, i8>,
|
native_handedness_by_range: HashMap<usize, i8>,
|
||||||
type16_present_ranges: HashSet<usize>,
|
type16_present_ranges: HashSet<usize>,
|
||||||
materials: Vec<VulkanStaticMaterial>,
|
materials: Vec<VulkanStaticMaterial>,
|
||||||
@@ -8019,6 +8021,11 @@ fn static_preview_mesh_and_materials(
|
|||||||
terrain,
|
terrain,
|
||||||
foundation,
|
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(
|
let terrain_materials = static_preview_terrain_base_materials(
|
||||||
&mut terrain_material_loader,
|
&mut terrain_material_loader,
|
||||||
root,
|
root,
|
||||||
@@ -8521,6 +8528,7 @@ fn static_preview_mesh_and_materials(
|
|||||||
let shadow_material_index = append_shadow_material(&mut materials)?;
|
let shadow_material_index = append_shadow_material(&mut materials)?;
|
||||||
Ok(StaticPreviewScene {
|
Ok(StaticPreviewScene {
|
||||||
mesh,
|
mesh,
|
||||||
|
terrain_lod,
|
||||||
native_handedness_by_range,
|
native_handedness_by_range,
|
||||||
type16_present_ranges,
|
type16_present_ranges,
|
||||||
materials,
|
materials,
|
||||||
@@ -9333,6 +9341,7 @@ fn run_static_vulkan_mode(
|
|||||||
|
|
||||||
struct StaticVulkanApp {
|
struct StaticVulkanApp {
|
||||||
mesh: VulkanStaticMesh,
|
mesh: VulkanStaticMesh,
|
||||||
|
terrain_lod: Option<terrain_lod::TerrainLodDraws>,
|
||||||
native_handedness_by_range: HashMap<usize, i8>,
|
native_handedness_by_range: HashMap<usize, i8>,
|
||||||
type16_present_ranges: HashSet<usize>,
|
type16_present_ranges: HashSet<usize>,
|
||||||
materials: Vec<VulkanStaticMaterial>,
|
materials: Vec<VulkanStaticMaterial>,
|
||||||
@@ -9439,6 +9448,7 @@ impl StaticVulkanApp {
|
|||||||
.collect();
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
mesh: preview.mesh,
|
mesh: preview.mesh,
|
||||||
|
terrain_lod: preview.terrain_lod,
|
||||||
native_handedness_by_range: preview.native_handedness_by_range,
|
native_handedness_by_range: preview.native_handedness_by_range,
|
||||||
type16_present_ranges: preview.type16_present_ranges,
|
type16_present_ranges: preview.type16_present_ranges,
|
||||||
materials: preview.materials,
|
materials: preview.materials,
|
||||||
@@ -9509,6 +9519,12 @@ impl StaticVulkanApp {
|
|||||||
renderer: &mut VulkanSmokeRenderer,
|
renderer: &mut VulkanSmokeRenderer,
|
||||||
aspect: f32,
|
aspect: f32,
|
||||||
) -> Result<(), String> {
|
) -> 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
|
let approximate_portals = self
|
||||||
.world_range_indices
|
.world_range_indices
|
||||||
.iter()
|
.iter()
|
||||||
@@ -9573,18 +9589,26 @@ impl StaticVulkanApp {
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
format!("configure world material category {range_index}: {error}")
|
format!("configure world material category {range_index}: {error}")
|
||||||
})?;
|
})?;
|
||||||
renderer
|
let count = world_draw_range_index_count(
|
||||||
.set_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,
|
range_index,
|
||||||
world_draw_range_index_count(
|
count,
|
||||||
&self.mesh,
|
self.view_camera.position,
|
||||||
range,
|
horizontal_fov(&self.view_camera, aspect),
|
||||||
range.index_count,
|
)?
|
||||||
effective_alpha,
|
} else {
|
||||||
self.view_camera.position,
|
count
|
||||||
self.native_handedness_by_range.get(&range_index).copied(),
|
};
|
||||||
),
|
renderer
|
||||||
)
|
.set_draw_range_index_count(range_index, count)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
format!("configure world draw range {range_index} count: {error}")
|
format!("configure world draw range {range_index} count: {error}")
|
||||||
})?;
|
})?;
|
||||||
@@ -9642,6 +9666,7 @@ impl StaticVulkanApp {
|
|||||||
type16_present_ranges: &HashSet<usize>,
|
type16_present_ranges: &HashSet<usize>,
|
||||||
world_range_index_counts: &HashMap<usize, u32>,
|
world_range_index_counts: &HashMap<usize, u32>,
|
||||||
native_handedness_by_range: &HashMap<usize, i8>,
|
native_handedness_by_range: &HashMap<usize, i8>,
|
||||||
|
terrain_lod: Option<&terrain_lod::TerrainLodDraws>,
|
||||||
eye_world: [f32; 3],
|
eye_world: [f32; 3],
|
||||||
horizontal_fov: f32,
|
horizontal_fov: f32,
|
||||||
active_materials: &HashMap<usize, ActiveMaterialState>,
|
active_materials: &HashMap<usize, ActiveMaterialState>,
|
||||||
@@ -9686,6 +9711,11 @@ impl StaticVulkanApp {
|
|||||||
eye_world,
|
eye_world,
|
||||||
native_handedness_by_range.get(&range_index).copied(),
|
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
|
renderer
|
||||||
.set_draw_range_index_count(range_index, count)
|
.set_draw_range_index_count(range_index, count)
|
||||||
.map_err(|error| format!("update world draw range {range_index} count: {error}"))?;
|
.map_err(|error| format!("update world draw range {range_index} count: {error}"))?;
|
||||||
@@ -10295,6 +10325,7 @@ impl StaticVulkanApp {
|
|||||||
&self.type16_present_ranges,
|
&self.type16_present_ranges,
|
||||||
&self.world_range_index_counts,
|
&self.world_range_index_counts,
|
||||||
&self.native_handedness_by_range,
|
&self.native_handedness_by_range,
|
||||||
|
self.terrain_lod.as_ref(),
|
||||||
frame.camera.position,
|
frame.camera.position,
|
||||||
horizontal_fov(&frame.camera, frame.aspect),
|
horizontal_fov(&frame.camera, frame.aspect),
|
||||||
&frame.material_update.active_materials,
|
&frame.material_update.active_materials,
|
||||||
@@ -13571,6 +13602,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let view_camera = free_camera.preview_frame(16.0 / 9.0);
|
let view_camera = free_camera.preview_frame(16.0 / 9.0);
|
||||||
let preview = StaticPreviewScene {
|
let preview = StaticPreviewScene {
|
||||||
|
terrain_lod: None,
|
||||||
mesh: VulkanStaticMesh {
|
mesh: VulkanStaticMesh {
|
||||||
vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
|
vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,8 @@ pub enum TerrainLodError {
|
|||||||
DuplicateFaceAssignment { face: usize },
|
DuplicateFaceAssignment { face: usize },
|
||||||
/// A source face is absent from every supported terrain slot.
|
/// A source face is absent from every supported terrain slot.
|
||||||
MissingFaceAssignment { face: usize },
|
MissingFaceAssignment { face: usize },
|
||||||
|
/// Slot bounds do not form the regular XY cell grid used by terrain rendering.
|
||||||
|
InvalidCellGrid,
|
||||||
/// A checked index calculation overflowed.
|
/// A checked index calculation overflowed.
|
||||||
IntegerOverflow,
|
IntegerOverflow,
|
||||||
}
|
}
|
||||||
@@ -276,6 +278,7 @@ impl std::fmt::Display for TerrainLodError {
|
|||||||
Self::MissingFaceAssignment { face } => {
|
Self::MissingFaceAssignment { face } => {
|
||||||
write!(f, "terrain face {face} is not assigned to a supported slot")
|
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"),
|
Self::IntegerOverflow => write!(f, "terrain slot index overflow"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ use std::collections::VecDeque;
|
|||||||
|
|
||||||
mod contour;
|
mod contour;
|
||||||
mod foundation;
|
mod foundation;
|
||||||
|
mod lod;
|
||||||
|
|
||||||
|
pub use lod::TerrainLodPlan;
|
||||||
|
|
||||||
use foundation::basement_surface_mask;
|
use foundation::basement_surface_mask;
|
||||||
pub use foundation::{
|
pub use foundation::{
|
||||||
@@ -1632,6 +1635,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]
|
#[test]
|
||||||
#[ignore = "requires licensed corpus"]
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_land_meshes_build_surface_worlds() {
|
fn licensed_corpus_land_meshes_build_surface_worlds() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-8
@@ -1030,11 +1030,39 @@ Node38, а диапазоны граней — в 68-байтовых Slot68. В
|
|||||||
проверяются на единственное назначение, а выходящий за массив, повторный или
|
проверяются на единственное назначение, а выходящий за массив, повторный или
|
||||||
неподдерживаемый диапазон считается ошибкой загрузки.
|
неподдерживаемый диапазон считается ошибкой загрузки.
|
||||||
|
|
||||||
Первый рендер карты, terrain-запросы высоты и пересечения, receiver-ы теней и
|
Рисуемый уровень выбирается по ячейкам, а не по расстоянию до отдельного
|
||||||
обрезка контуром зданий используют только LOD0. Отбор сохраняет порядок
|
треугольника. Начало сетки находится в первых двух float заголовка Slot68,
|
||||||
исходных граней, а контурный проход отдельно переводит индекс спроецированной
|
размер ячейки — в границах первого слота LOD0. Атрибут 2 потока Node38 задаёт
|
||||||
грани обратно в исходный. LOD1 пока не подставляется по расстоянию: такой
|
число ячеек по 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
|
```rust
|
||||||
let assignments = mesh.face_lod_assignments()?;
|
let assignments = mesh.face_lod_assignments()?;
|
||||||
@@ -1052,9 +1080,10 @@ for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) {
|
|||||||
исходные фрагменты рельефа вместе с треугольниками отмостки в одном наборе.
|
исходные фрагменты рельефа вместе с треугольниками отмостки в одном наборе.
|
||||||
`TerrainWorld::with_basement_foundation` принимает этот набор во владение и
|
`TerrainWorld::with_basement_foundation` принимает этот набор во владение и
|
||||||
устанавливает те же координаты и маски в BVH высотных запросов и пересечений.
|
устанавливает те же координаты и маски в BVH высотных запросов и пересечений.
|
||||||
Предпросмотр миссии на Vulkan пока строит только LOD0: связывание BAS-записей
|
Предпросмотр миссии связывает BAS-записи с этой подготовкой до загрузки
|
||||||
с этой подготовкой ещё не подключено, поэтому наличие API не означает, что
|
материалов. Полученная поверхность общая для изображения, теней и запросов;
|
||||||
геометрия миссии уже обрезана.
|
поправка размещения основания один раз применяется к корню строения и его
|
||||||
|
присоединённым компонентам.
|
||||||
|
|
||||||
Внешнее кольцо должно лежать на области LOD0, а каждый его сегмент — оставаться
|
Внешнее кольцо должно лежать на области LOD0, а каждый его сегмент — оставаться
|
||||||
покрытым текущей поверхностью. Непокрытый сегмент или пересекающиеся контуры
|
покрытым текущей поверхностью. Непокрытый сегмент или пересекающиеся контуры
|
||||||
|
|||||||
Reference in new issue
Block a user