Use LOD0 for terrain queries and rendering
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m39s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m39s
This commit is contained in:
5 files changed
+649
-91
No files matched your search
@@ -7,7 +7,7 @@ use fparkan_msh::{
|
|||||||
ModelAsset, NodeId,
|
ModelAsset, NodeId,
|
||||||
};
|
};
|
||||||
use fparkan_render::{LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState};
|
use fparkan_render::{LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState};
|
||||||
use fparkan_terrain_format::LandMeshDocument;
|
use fparkan_terrain_format::{LandMeshDocument, TerrainFaceLod, TerrainLod, TerrainLodError};
|
||||||
|
|
||||||
fn world_pipeline_state() -> LegacyPipelineState {
|
fn world_pipeline_state() -> LegacyPipelineState {
|
||||||
LegacyPipelineState {
|
LegacyPipelineState {
|
||||||
@@ -34,6 +34,8 @@ pub enum VulkanAssetMeshError {
|
|||||||
/// Number of supplied node poses.
|
/// Number of supplied node poses.
|
||||||
actual: usize,
|
actual: usize,
|
||||||
},
|
},
|
||||||
|
/// Terrain Node38/Slot68 records do not assign every face to one LOD.
|
||||||
|
InvalidTerrainLodMapping(TerrainLodError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluated world pose of one standard Node38 node in model-local space.
|
/// Evaluated world pose of one standard Node38 node in model-local space.
|
||||||
@@ -108,6 +110,9 @@ impl std::fmt::Display for VulkanAssetMeshError {
|
|||||||
f,
|
f,
|
||||||
"node pose buffer has {actual} poses, but the MSH has {expected} nodes"
|
"node pose buffer has {actual} poses, but the MSH has {expected} nodes"
|
||||||
),
|
),
|
||||||
|
Self::InvalidTerrainLodMapping(error) => {
|
||||||
|
write!(f, "invalid terrain LOD mapping: {error}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1016,23 +1021,15 @@ fn transform_normal(
|
|||||||
pub fn project_land_msh_to_static_mesh(
|
pub fn project_land_msh_to_static_mesh(
|
||||||
terrain: &LandMeshDocument,
|
terrain: &LandMeshDocument,
|
||||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||||
let mut indices = Vec::with_capacity(
|
let lod0 = static_terrain_lod0_assignments(terrain)?;
|
||||||
terrain
|
let indices = static_terrain_indices(terrain, &lod0)?;
|
||||||
.faces
|
|
||||||
.len()
|
|
||||||
.checked_mul(3)
|
|
||||||
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?,
|
|
||||||
);
|
|
||||||
for face in &terrain.faces {
|
|
||||||
indices.extend(face.vertices.map(u32::from));
|
|
||||||
}
|
|
||||||
if indices.is_empty() {
|
if indices.is_empty() {
|
||||||
return Err(VulkanAssetMeshError::EmptyGeometry);
|
return Err(VulkanAssetMeshError::EmptyGeometry);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (min_x, max_x, min_y, max_y) = static_mesh_xy_bounds(&terrain.positions, &indices)?;
|
let (min_x, max_x, min_y, max_y) = static_mesh_xy_bounds(&terrain.positions, &indices)?;
|
||||||
let frame = VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)?;
|
let frame = VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)?;
|
||||||
project_land_msh_to_static_mesh_in_xy_frame(terrain, frame)
|
project_land_msh_to_static_mesh_with_lod0(terrain, frame, &lod0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Projects `Land.msh` terrain geometry into a shared diagnostic XY frame.
|
/// Projects `Land.msh` terrain geometry into a shared diagnostic XY frame.
|
||||||
@@ -1045,16 +1042,16 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame(
|
|||||||
terrain: &LandMeshDocument,
|
terrain: &LandMeshDocument,
|
||||||
frame: VulkanStaticXyFrame,
|
frame: VulkanStaticXyFrame,
|
||||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||||
let mut indices = Vec::with_capacity(
|
let lod0 = static_terrain_lod0_assignments(terrain)?;
|
||||||
terrain
|
project_land_msh_to_static_mesh_with_lod0(terrain, frame, &lod0)
|
||||||
.faces
|
}
|
||||||
.len()
|
|
||||||
.checked_mul(3)
|
fn project_land_msh_to_static_mesh_with_lod0(
|
||||||
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?,
|
terrain: &LandMeshDocument,
|
||||||
);
|
frame: VulkanStaticXyFrame,
|
||||||
for face in &terrain.faces {
|
lod0: &[TerrainFaceLod],
|
||||||
indices.extend(face.vertices.map(u32::from));
|
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||||
}
|
let indices = static_terrain_indices(terrain, lod0)?;
|
||||||
if indices.is_empty() {
|
if indices.is_empty() {
|
||||||
return Err(VulkanAssetMeshError::EmptyGeometry);
|
return Err(VulkanAssetMeshError::EmptyGeometry);
|
||||||
}
|
}
|
||||||
@@ -1089,7 +1086,7 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame(
|
|||||||
Ok(VulkanStaticMesh {
|
Ok(VulkanStaticMesh {
|
||||||
vertices,
|
vertices,
|
||||||
indices,
|
indices,
|
||||||
draw_ranges: static_terrain_draw_ranges(terrain)?,
|
draw_ranges: static_terrain_draw_ranges(terrain, lod0)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,7 +1104,8 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame(
|
|||||||
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 indices = static_terrain_indices(terrain)?;
|
let lod0 = static_terrain_lod0_assignments(terrain)?;
|
||||||
|
let indices = static_terrain_indices(terrain, &lod0)?;
|
||||||
let vertices = terrain
|
let vertices = terrain
|
||||||
.positions
|
.positions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1132,19 +1130,34 @@ pub fn project_land_msh_to_static_mesh_in_world_space(
|
|||||||
Ok(VulkanStaticMesh {
|
Ok(VulkanStaticMesh {
|
||||||
vertices,
|
vertices,
|
||||||
indices,
|
indices,
|
||||||
draw_ranges: static_terrain_draw_ranges(terrain)?,
|
draw_ranges: static_terrain_draw_ranges(terrain, &lod0)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn static_terrain_indices(terrain: &LandMeshDocument) -> Result<Vec<u32>, VulkanAssetMeshError> {
|
fn static_terrain_lod0_assignments(
|
||||||
|
terrain: &LandMeshDocument,
|
||||||
|
) -> Result<Vec<TerrainFaceLod>, VulkanAssetMeshError> {
|
||||||
|
let mut assignments = terrain
|
||||||
|
.face_lod_assignments()
|
||||||
|
.map_err(VulkanAssetMeshError::InvalidTerrainLodMapping)?;
|
||||||
|
assignments.retain(|assignment| assignment.lod == TerrainLod::Lod0);
|
||||||
|
Ok(assignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_terrain_indices(
|
||||||
|
terrain: &LandMeshDocument,
|
||||||
|
lod0: &[TerrainFaceLod],
|
||||||
|
) -> Result<Vec<u32>, VulkanAssetMeshError> {
|
||||||
let mut indices = Vec::with_capacity(
|
let mut indices = Vec::with_capacity(
|
||||||
terrain
|
lod0.len()
|
||||||
.faces
|
|
||||||
.len()
|
|
||||||
.checked_mul(3)
|
.checked_mul(3)
|
||||||
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?,
|
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?,
|
||||||
);
|
);
|
||||||
for face in &terrain.faces {
|
for assignment in lod0 {
|
||||||
|
let face = terrain
|
||||||
|
.faces
|
||||||
|
.get(assignment.face_index)
|
||||||
|
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?;
|
||||||
indices.extend(face.vertices.map(u32::from));
|
indices.extend(face.vertices.map(u32::from));
|
||||||
}
|
}
|
||||||
if indices.is_empty() {
|
if indices.is_empty() {
|
||||||
@@ -1187,14 +1200,24 @@ fn terrain_overlay_alpha(terrain: &LandMeshDocument, index: usize) -> f32 {
|
|||||||
|
|
||||||
fn static_terrain_draw_ranges(
|
fn static_terrain_draw_ranges(
|
||||||
terrain: &LandMeshDocument,
|
terrain: &LandMeshDocument,
|
||||||
|
lod0: &[TerrainFaceLod],
|
||||||
) -> Result<Vec<VulkanStaticDrawRange>, VulkanAssetMeshError> {
|
) -> Result<Vec<VulkanStaticDrawRange>, VulkanAssetMeshError> {
|
||||||
let mut ranges = Vec::new();
|
let mut ranges = Vec::new();
|
||||||
let mut start_face = 0usize;
|
let mut start_face = 0usize;
|
||||||
while start_face < terrain.faces.len() {
|
while start_face < lod0.len() {
|
||||||
let material_index = terrain.faces[start_face].material_tag;
|
let material_index = terrain
|
||||||
|
.faces
|
||||||
|
.get(lod0[start_face].face_index)
|
||||||
|
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?
|
||||||
|
.material_tag;
|
||||||
let mut end_face = start_face + 1;
|
let mut end_face = start_face + 1;
|
||||||
while end_face < terrain.faces.len()
|
while end_face < lod0.len()
|
||||||
&& terrain.faces[end_face].material_tag == material_index
|
&& terrain
|
||||||
|
.faces
|
||||||
|
.get(lod0[end_face].face_index)
|
||||||
|
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?
|
||||||
|
.material_tag
|
||||||
|
== material_index
|
||||||
{
|
{
|
||||||
end_face += 1;
|
end_face += 1;
|
||||||
}
|
}
|
||||||
@@ -1998,7 +2021,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projects_land_faces_in_source_order_with_packed_uv0() {
|
fn projects_land_faces_in_source_order_with_packed_uv0() {
|
||||||
let terrain = LandMeshDocument {
|
let mut terrain = LandMeshDocument {
|
||||||
streams: Vec::new(),
|
streams: Vec::new(),
|
||||||
nodes_raw: Vec::new(),
|
nodes_raw: Vec::new(),
|
||||||
slots: TerrainSlotTable {
|
slots: TerrainSlotTable {
|
||||||
@@ -2018,6 +2041,7 @@ mod tests {
|
|||||||
aux18: Vec::new(),
|
aux18: Vec::new(),
|
||||||
faces: vec![terrain_face([0, 1, 2]), terrain_face([1, 3, 2])],
|
faces: vec![terrain_face([0, 1, 2]), terrain_face([1, 3, 2])],
|
||||||
};
|
};
|
||||||
|
set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 2]]);
|
||||||
|
|
||||||
let mesh = project_land_msh_to_static_mesh(&terrain).expect("representable terrain");
|
let mesh = project_land_msh_to_static_mesh(&terrain).expect("representable terrain");
|
||||||
|
|
||||||
@@ -2030,9 +2054,61 @@ mod tests {
|
|||||||
assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]);
|
assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terrain_projection_filters_interleaved_lods_in_source_face_order() {
|
||||||
|
let mut terrain = LandMeshDocument {
|
||||||
|
streams: Vec::new(),
|
||||||
|
nodes_raw: Vec::new(),
|
||||||
|
slots: TerrainSlotTable {
|
||||||
|
header_raw: Vec::new(),
|
||||||
|
slots_raw: Vec::new(),
|
||||||
|
},
|
||||||
|
positions: vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[10.0, 10.0, 10.0],
|
||||||
|
[11.0, 10.0, 10.0],
|
||||||
|
[10.0, 11.0, 10.0],
|
||||||
|
],
|
||||||
|
normals: Vec::new(),
|
||||||
|
uv0: Vec::new(),
|
||||||
|
accelerator: Vec::new(),
|
||||||
|
aux14: Vec::new(),
|
||||||
|
aux18: Vec::new(),
|
||||||
|
faces: vec![
|
||||||
|
terrain_face_with_tag(0x0101, [0, 1, 2]),
|
||||||
|
terrain_face_with_tag(0x0202, [3, 4, 5]),
|
||||||
|
terrain_face_with_tag(0x0101, [0, 1, 2]),
|
||||||
|
terrain_face_with_tag(0x0202, [3, 4, 5]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
set_terrain_lod_slots(
|
||||||
|
&mut terrain,
|
||||||
|
&[[0, 1], [2, 3]],
|
||||||
|
&[[0, 1], [1, 1], [2, 1], [3, 1]],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain)
|
||||||
|
.expect("LOD0 terrain projection");
|
||||||
|
assert_eq!(mesh.indices, [0, 1, 2, 0, 1, 2]);
|
||||||
|
assert_eq!(mesh.draw_ranges.len(), 1);
|
||||||
|
assert_eq!(mesh.draw_ranges[0].first_index, 0);
|
||||||
|
assert_eq!(mesh.draw_ranges[0].index_count, 6);
|
||||||
|
assert_eq!(mesh.draw_ranges[0].material_index, 0x0101);
|
||||||
|
|
||||||
|
terrain.nodes_raw.clear();
|
||||||
|
assert!(matches!(
|
||||||
|
project_land_msh_to_static_mesh_in_world_space(&terrain),
|
||||||
|
Err(VulkanAssetMeshError::InvalidTerrainLodMapping(
|
||||||
|
TerrainLodError::MissingNodes { face_count: 4 }
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terrain_normals_keep_native_one_over_128_scale() {
|
fn terrain_normals_keep_native_one_over_128_scale() {
|
||||||
let terrain = LandMeshDocument {
|
let mut terrain = LandMeshDocument {
|
||||||
streams: Vec::new(),
|
streams: Vec::new(),
|
||||||
nodes_raw: Vec::new(),
|
nodes_raw: Vec::new(),
|
||||||
slots: TerrainSlotTable {
|
slots: TerrainSlotTable {
|
||||||
@@ -2047,6 +2123,7 @@ mod tests {
|
|||||||
aux18: Vec::new(),
|
aux18: Vec::new(),
|
||||||
faces: vec![terrain_face([0, 1, 2])],
|
faces: vec![terrain_face([0, 1, 2])],
|
||||||
};
|
};
|
||||||
|
set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 1]]);
|
||||||
|
|
||||||
let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain)
|
let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain)
|
||||||
.expect("representable terrain normals");
|
.expect("representable terrain normals");
|
||||||
@@ -2055,7 +2132,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn world_space_terrain_preserves_nonzero_source_heights_and_faces() {
|
fn world_space_terrain_preserves_nonzero_source_heights_and_faces() {
|
||||||
let terrain = LandMeshDocument {
|
let mut terrain = LandMeshDocument {
|
||||||
streams: Vec::new(),
|
streams: Vec::new(),
|
||||||
nodes_raw: Vec::new(),
|
nodes_raw: Vec::new(),
|
||||||
slots: TerrainSlotTable {
|
slots: TerrainSlotTable {
|
||||||
@@ -2074,6 +2151,7 @@ mod tests {
|
|||||||
aux18: Vec::new(),
|
aux18: Vec::new(),
|
||||||
faces: vec![terrain_face([2, 0, 1])],
|
faces: vec![terrain_face([2, 0, 1])],
|
||||||
};
|
};
|
||||||
|
set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 1]]);
|
||||||
|
|
||||||
let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain)
|
let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain)
|
||||||
.expect("representable source-space terrain");
|
.expect("representable source-space terrain");
|
||||||
@@ -2101,9 +2179,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_terrain_lod_slots(
|
||||||
|
terrain: &mut LandMeshDocument,
|
||||||
|
node_slots: &[[u16; 2]],
|
||||||
|
face_ranges: &[[u16; 2]],
|
||||||
|
) {
|
||||||
|
const NODE_STRIDE: usize = 38;
|
||||||
|
let mut nodes = vec![u8::MAX; node_slots.len() * NODE_STRIDE];
|
||||||
|
for (node_index, [lod0, lod1]) in node_slots.iter().copied().enumerate() {
|
||||||
|
let offset = node_index * NODE_STRIDE + 8;
|
||||||
|
nodes[offset..offset + 2].copy_from_slice(&lod0.to_le_bytes());
|
||||||
|
nodes[offset + 2..offset + 4].copy_from_slice(&lod1.to_le_bytes());
|
||||||
|
}
|
||||||
|
let mut slots = vec![[0_u8; 68]; face_ranges.len()];
|
||||||
|
for (slot, [start, count]) in slots.iter_mut().zip(face_ranges.iter().copied()) {
|
||||||
|
slot[..2].copy_from_slice(&start.to_le_bytes());
|
||||||
|
slot[2..4].copy_from_slice(&count.to_le_bytes());
|
||||||
|
}
|
||||||
|
terrain.nodes_raw = nodes;
|
||||||
|
terrain.slots.slots_raw = slots;
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terrain_ranges_preserve_face_order_and_packed_material_tags() {
|
fn terrain_ranges_preserve_face_order_and_packed_material_tags() {
|
||||||
let terrain = LandMeshDocument {
|
let mut terrain = LandMeshDocument {
|
||||||
streams: Vec::new(),
|
streams: Vec::new(),
|
||||||
nodes_raw: Vec::new(),
|
nodes_raw: Vec::new(),
|
||||||
slots: TerrainSlotTable {
|
slots: TerrainSlotTable {
|
||||||
@@ -2123,8 +2222,10 @@ mod tests {
|
|||||||
terrain_face_with_tag(0xff01, [0, 1, 2]),
|
terrain_face_with_tag(0xff01, [0, 1, 2]),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
set_terrain_lod_slots(&mut terrain, &[[0, u16::MAX]], &[[0, 4]]);
|
||||||
|
|
||||||
let ranges = static_terrain_draw_ranges(&terrain).expect("representable terrain");
|
let lod0 = static_terrain_lod0_assignments(&terrain).expect("terrain LOD0");
|
||||||
|
let ranges = static_terrain_draw_ranges(&terrain, &lod0).expect("representable terrain");
|
||||||
|
|
||||||
assert_eq!(ranges.len(), 3);
|
assert_eq!(ranges.len(), 3);
|
||||||
assert_eq!(ranges[0].first_index, 0);
|
assert_eq!(ranges[0].first_index, 0);
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ use fparkan_runtime::{
|
|||||||
loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets,
|
loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets,
|
||||||
MissionObjectDraft, MissionRequest,
|
MissionObjectDraft, MissionRequest,
|
||||||
};
|
};
|
||||||
use fparkan_terrain::{FullSurfaceMask, SurfaceQuery, TerrainMaterialLayers, TerrainWorld};
|
use fparkan_terrain::{
|
||||||
|
FullSurfaceMask, SurfaceQuery, TerrainLod, TerrainMaterialLayers, TerrainWorld,
|
||||||
|
};
|
||||||
use fparkan_vfs::DirectoryVfs;
|
use fparkan_vfs::DirectoryVfs;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -1390,24 +1392,55 @@ fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
|
|||||||
fn terrain_shadow_receivers(
|
fn terrain_shadow_receivers(
|
||||||
terrain: &fparkan_terrain_format::LandMeshDocument,
|
terrain: &fparkan_terrain_format::LandMeshDocument,
|
||||||
) -> Result<Vec<ShadowTriangle>, String> {
|
) -> Result<Vec<ShadowTriangle>, String> {
|
||||||
terrain
|
let assignments = terrain
|
||||||
.faces
|
.face_lod_assignments()
|
||||||
.iter()
|
.map_err(|error| error.to_string())?;
|
||||||
.map(|face| {
|
let mut receivers = Vec::with_capacity(
|
||||||
let positions = face.vertices.map(|index| {
|
assignments
|
||||||
let source = terrain
|
.iter()
|
||||||
.positions
|
.filter(|assignment| assignment.lod == TerrainLod::Lod0)
|
||||||
.get(usize::from(index))
|
.count(),
|
||||||
.copied()
|
);
|
||||||
.unwrap_or([0.0; 3]);
|
for assignment in assignments {
|
||||||
source
|
if assignment.lod != TerrainLod::Lod0 {
|
||||||
});
|
continue;
|
||||||
if !positions.iter().flatten().all(|value| value.is_finite()) {
|
}
|
||||||
return Err("terrain shadow receiver contains a non-finite position".to_string());
|
let face = terrain.faces.get(assignment.face_index).ok_or_else(|| {
|
||||||
}
|
format!(
|
||||||
Ok(shadow_triangle(positions, face.flags.0))
|
"terrain LOD0 references unknown face {}",
|
||||||
})
|
assignment.face_index
|
||||||
.collect()
|
)
|
||||||
|
})?;
|
||||||
|
let positions = [
|
||||||
|
terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[0])?,
|
||||||
|
terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[1])?,
|
||||||
|
terrain_shadow_vertex_position(terrain, assignment.face_index, face.vertices[2])?,
|
||||||
|
];
|
||||||
|
receivers.push(shadow_triangle(positions, face.flags.0));
|
||||||
|
}
|
||||||
|
Ok(receivers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terrain_shadow_vertex_position(
|
||||||
|
terrain: &fparkan_terrain_format::LandMeshDocument,
|
||||||
|
face_index: usize,
|
||||||
|
vertex_index: u16,
|
||||||
|
) -> Result<[f32; 3], String> {
|
||||||
|
let position = terrain
|
||||||
|
.positions
|
||||||
|
.get(usize::from(vertex_index))
|
||||||
|
.copied()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"terrain LOD0 shadow face {face_index} references unknown vertex {vertex_index}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !position.iter().all(|value| value.is_finite()) {
|
||||||
|
return Err(format!(
|
||||||
|
"terrain LOD0 shadow face {face_index} has a non-finite vertex {vertex_index}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> {
|
fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> {
|
||||||
@@ -5938,6 +5971,20 @@ fn usage() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn set_test_terrain_lod0_range(terrain: &mut fparkan_terrain_format::LandMeshDocument) {
|
||||||
|
const NODE_STRIDE: usize = 38;
|
||||||
|
const SLOT_STRIDE: usize = 68;
|
||||||
|
let mut node = vec![u8::MAX; NODE_STRIDE];
|
||||||
|
node[8..10].copy_from_slice(&0_u16.to_le_bytes());
|
||||||
|
node[10..12].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||||
|
let mut slot = [0_u8; SLOT_STRIDE];
|
||||||
|
slot[..2].copy_from_slice(&0_u16.to_le_bytes());
|
||||||
|
let face_count = u16::try_from(terrain.faces.len()).expect("test face count fits");
|
||||||
|
slot[2..4].copy_from_slice(&face_count.to_le_bytes());
|
||||||
|
terrain.nodes_raw = node;
|
||||||
|
terrain.slots.slots_raw = vec![slot];
|
||||||
|
}
|
||||||
|
|
||||||
fn strings(values: &[&str]) -> Vec<String> {
|
fn strings(values: &[&str]) -> Vec<String> {
|
||||||
values.iter().map(|value| (*value).to_string()).collect()
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
}
|
}
|
||||||
@@ -6507,7 +6554,7 @@ mod tests {
|
|||||||
assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0]));
|
assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0]));
|
||||||
assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0]));
|
assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0]));
|
||||||
|
|
||||||
let land = fparkan_terrain_format::LandMeshDocument {
|
let mut land = fparkan_terrain_format::LandMeshDocument {
|
||||||
streams: Vec::new(),
|
streams: Vec::new(),
|
||||||
nodes_raw: Vec::new(),
|
nodes_raw: Vec::new(),
|
||||||
slots: fparkan_terrain_format::TerrainSlotTable {
|
slots: fparkan_terrain_format::TerrainSlotTable {
|
||||||
@@ -6531,8 +6578,14 @@ mod tests {
|
|||||||
raw: [0; 28],
|
raw: [0; 28],
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
set_test_terrain_lod0_range(&mut land);
|
||||||
let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers");
|
let shadow_receivers = terrain_shadow_receivers(&land).expect("raw-height receivers");
|
||||||
assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]);
|
assert_eq!(shadow_receivers[0].positions[0], [0.0, 0.0, 32.0]);
|
||||||
|
let mut invalid_shadow_face = land.clone();
|
||||||
|
invalid_shadow_face.faces[0].vertices[2] = u16::MAX;
|
||||||
|
assert!(terrain_shadow_receivers(&invalid_shadow_face)
|
||||||
|
.expect_err("bad terrain indices must fail instead of inventing vertices")
|
||||||
|
.contains("unknown vertex 65535"));
|
||||||
let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain");
|
let terrain = TerrainWorld::from_land_msh(&land).expect("synthetic terrain");
|
||||||
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0));
|
assert_eq!(terrain.height_at([0.25, 0.25]).unwrap(), Some(32.0));
|
||||||
let floor_camera =
|
let floor_camera =
|
||||||
@@ -6583,31 +6636,32 @@ mod tests {
|
|||||||
[0.0, 0.0, -1.0]
|
[0.0, 0.0, -1.0]
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let mut excluded_land = fparkan_terrain_format::LandMeshDocument {
|
||||||
|
streams: Vec::new(),
|
||||||
|
nodes_raw: Vec::new(),
|
||||||
|
slots: fparkan_terrain_format::TerrainSlotTable {
|
||||||
|
header_raw: Vec::new(),
|
||||||
|
slots_raw: Vec::new(),
|
||||||
|
},
|
||||||
|
positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
|
||||||
|
normals: Vec::new(),
|
||||||
|
uv0: Vec::new(),
|
||||||
|
accelerator: Vec::new(),
|
||||||
|
aux14: Vec::new(),
|
||||||
|
aux18: Vec::new(),
|
||||||
|
faces: vec![fparkan_terrain_format::TerrainFace28 {
|
||||||
|
flags: FullSurfaceMask(0x20),
|
||||||
|
material_tag: 0,
|
||||||
|
aux_tag: 0,
|
||||||
|
vertices: [0, 1, 2],
|
||||||
|
neighbors: [None, None, None],
|
||||||
|
tail_raw: [0; 8],
|
||||||
|
raw: [0; 28],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
set_test_terrain_lod0_range(&mut excluded_land);
|
||||||
let excluded_terrain =
|
let excluded_terrain =
|
||||||
TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument {
|
TerrainWorld::from_land_msh(&excluded_land).expect("excluded terrain");
|
||||||
streams: Vec::new(),
|
|
||||||
nodes_raw: Vec::new(),
|
|
||||||
slots: fparkan_terrain_format::TerrainSlotTable {
|
|
||||||
header_raw: Vec::new(),
|
|
||||||
slots_raw: Vec::new(),
|
|
||||||
},
|
|
||||||
positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
|
|
||||||
normals: Vec::new(),
|
|
||||||
uv0: Vec::new(),
|
|
||||||
accelerator: Vec::new(),
|
|
||||||
aux14: Vec::new(),
|
|
||||||
aux18: Vec::new(),
|
|
||||||
faces: vec![fparkan_terrain_format::TerrainFace28 {
|
|
||||||
flags: FullSurfaceMask(0x20),
|
|
||||||
material_tag: 0,
|
|
||||||
aux_tag: 0,
|
|
||||||
vertices: [0, 1, 2],
|
|
||||||
neighbors: [None, None, None],
|
|
||||||
tail_raw: [0; 8],
|
|
||||||
raw: [0; 28],
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
.expect("excluded terrain");
|
|
||||||
assert!(native_sun_unoccluded_with_world(
|
assert!(native_sun_unoccluded_with_world(
|
||||||
&excluded_terrain,
|
&excluded_terrain,
|
||||||
&world_mesh,
|
&world_mesh,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const REQUIRED_TYPES: [u32; 9] = [
|
|||||||
const AREAL_PREFIX_SIZE: usize = 56;
|
const AREAL_PREFIX_SIZE: usize = 56;
|
||||||
const SLOT_HEADER_SIZE: usize = 0x8c;
|
const SLOT_HEADER_SIZE: usize = 0x8c;
|
||||||
const SLOT_STRIDE: usize = 68;
|
const SLOT_STRIDE: usize = 68;
|
||||||
|
const NODE38_STRIDE: usize = 38;
|
||||||
const GRID_HIT_COUNT_BITS: u32 = 10;
|
const GRID_HIT_COUNT_BITS: u32 = 10;
|
||||||
const GRID_POOL_OFFSET_MASK: u32 = (1 << 22) - 1;
|
const GRID_POOL_OFFSET_MASK: u32 = (1 << 22) - 1;
|
||||||
|
|
||||||
@@ -152,6 +153,129 @@ pub struct TerrainFace28 {
|
|||||||
pub raw: [u8; 28],
|
pub raw: [u8; 28],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Terrain detail level selected by one Node38 slot reference.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TerrainLod {
|
||||||
|
/// Near terrain geometry.
|
||||||
|
Lod0,
|
||||||
|
/// Far terrain geometry.
|
||||||
|
Lod1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Node and detail level that own one source face.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct TerrainFaceLod {
|
||||||
|
/// Index into [`LandMeshDocument::faces`].
|
||||||
|
pub face_index: usize,
|
||||||
|
/// Index into the Node38 stream.
|
||||||
|
pub node_index: usize,
|
||||||
|
/// Selected terrain detail level.
|
||||||
|
pub lod: TerrainLod,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error in the terrain Node38-to-face slot mapping.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TerrainLodError {
|
||||||
|
/// Nonempty terrain has no Node38 records to select its slots.
|
||||||
|
MissingNodes { face_count: usize },
|
||||||
|
/// Node stream size is not a whole number of 38-byte records.
|
||||||
|
InvalidNodeStreamSize { size: usize },
|
||||||
|
/// Terrain uses a slot group beyond its two supported detail levels.
|
||||||
|
UnsupportedNodeSlot {
|
||||||
|
/// Node containing the unsupported slot.
|
||||||
|
node: usize,
|
||||||
|
/// Index in the 15-entry Node38 slot map.
|
||||||
|
slot_index: usize,
|
||||||
|
/// Raw terrain slot id.
|
||||||
|
slot: u16,
|
||||||
|
},
|
||||||
|
/// A Node38 entry references a missing Slot68 record.
|
||||||
|
SlotOutOfRange {
|
||||||
|
/// Node containing the reference.
|
||||||
|
node: usize,
|
||||||
|
/// Selected detail level.
|
||||||
|
lod: TerrainLod,
|
||||||
|
/// Raw terrain slot id.
|
||||||
|
slot: u16,
|
||||||
|
/// Number of decoded Slot68 records.
|
||||||
|
slot_count: usize,
|
||||||
|
},
|
||||||
|
/// A Slot68 triangle range exceeds the source face array.
|
||||||
|
FaceRangeOutOfBounds {
|
||||||
|
/// Node containing the slot.
|
||||||
|
node: usize,
|
||||||
|
/// Selected detail level.
|
||||||
|
lod: TerrainLod,
|
||||||
|
/// Raw terrain slot id.
|
||||||
|
slot: u16,
|
||||||
|
/// First source face.
|
||||||
|
start: usize,
|
||||||
|
/// Number of faces.
|
||||||
|
count: usize,
|
||||||
|
/// Number of source faces.
|
||||||
|
face_count: usize,
|
||||||
|
},
|
||||||
|
/// A source face belongs to more than one terrain slot.
|
||||||
|
DuplicateFaceAssignment { face: usize },
|
||||||
|
/// A source face is absent from every supported terrain slot.
|
||||||
|
MissingFaceAssignment { face: usize },
|
||||||
|
/// A checked index calculation overflowed.
|
||||||
|
IntegerOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TerrainLodError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingNodes { face_count } => write!(
|
||||||
|
f,
|
||||||
|
"terrain has {face_count} faces but no Node38 records"
|
||||||
|
),
|
||||||
|
Self::InvalidNodeStreamSize { size } => write!(
|
||||||
|
f,
|
||||||
|
"terrain Node38 stream has {size} bytes, not a multiple of {NODE38_STRIDE}"
|
||||||
|
),
|
||||||
|
Self::UnsupportedNodeSlot {
|
||||||
|
node,
|
||||||
|
slot_index,
|
||||||
|
slot,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"terrain Node38 {node} uses unsupported slot group {slot_index} ({slot})"
|
||||||
|
),
|
||||||
|
Self::SlotOutOfRange {
|
||||||
|
node,
|
||||||
|
lod,
|
||||||
|
slot,
|
||||||
|
slot_count,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"terrain Node38 {node} LOD {lod:?} references slot {slot} outside {slot_count} slots"
|
||||||
|
),
|
||||||
|
Self::FaceRangeOutOfBounds {
|
||||||
|
node,
|
||||||
|
lod,
|
||||||
|
slot,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
face_count,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"terrain Node38 {node} LOD {lod:?} slot {slot} range {start}..{} exceeds {face_count} faces",
|
||||||
|
start.saturating_add(*count)
|
||||||
|
),
|
||||||
|
Self::DuplicateFaceAssignment { face } => {
|
||||||
|
write!(f, "terrain face {face} belongs to more than one slot")
|
||||||
|
}
|
||||||
|
Self::MissingFaceAssignment { face } => {
|
||||||
|
write!(f, "terrain face {face} is not assigned to a supported slot")
|
||||||
|
}
|
||||||
|
Self::IntegerOverflow => write!(f, "terrain slot index overflow"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TerrainLodError {}
|
||||||
|
|
||||||
impl TerrainFace28 {
|
impl TerrainFace28 {
|
||||||
/// Decodes the proven two-table selector layout of [`Self::material_tag`].
|
/// Decodes the proven two-table selector layout of [`Self::material_tag`].
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -273,6 +397,106 @@ pub struct LandMeshDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LandMeshDocument {
|
impl LandMeshDocument {
|
||||||
|
/// Resolves each source face to the Node38 and terrain LOD that own it.
|
||||||
|
///
|
||||||
|
/// The supported terrain layout uses slot-map entries 0 and 1 for LOD0
|
||||||
|
/// and LOD1. The other 13 entries must be empty. Every source face must be
|
||||||
|
/// covered exactly once; empty terrain may omit Node38 records.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`TerrainLodError`] for missing or unsupported slots, invalid
|
||||||
|
/// ranges, overlaps, or uncovered faces.
|
||||||
|
pub fn face_lod_assignments(&self) -> Result<Vec<TerrainFaceLod>, TerrainLodError> {
|
||||||
|
if self.faces.is_empty() && self.nodes_raw.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if !self.nodes_raw.len().is_multiple_of(NODE38_STRIDE) {
|
||||||
|
return Err(TerrainLodError::InvalidNodeStreamSize {
|
||||||
|
size: self.nodes_raw.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.nodes_raw.is_empty() {
|
||||||
|
return Err(TerrainLodError::MissingNodes {
|
||||||
|
face_count: self.faces.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut assignments = vec![None; self.faces.len()];
|
||||||
|
for (node_index, node) in self.nodes_raw.chunks_exact(NODE38_STRIDE).enumerate() {
|
||||||
|
for slot_index in 2..15 {
|
||||||
|
let offset = 8 + slot_index * 2;
|
||||||
|
let slot = u16::from_le_bytes([node[offset], node[offset + 1]]);
|
||||||
|
if slot != u16::MAX {
|
||||||
|
return Err(TerrainLodError::UnsupportedNodeSlot {
|
||||||
|
node: node_index,
|
||||||
|
slot_index,
|
||||||
|
slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (lod_index, lod) in [TerrainLod::Lod0, TerrainLod::Lod1].into_iter().enumerate() {
|
||||||
|
let offset = 8 + lod_index * 2;
|
||||||
|
let slot = u16::from_le_bytes([node[offset], node[offset + 1]]);
|
||||||
|
if slot == u16::MAX {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let slot_index = usize::from(slot);
|
||||||
|
let raw = self.slots.slots_raw.get(slot_index).ok_or(
|
||||||
|
TerrainLodError::SlotOutOfRange {
|
||||||
|
node: node_index,
|
||||||
|
lod,
|
||||||
|
slot,
|
||||||
|
slot_count: self.slots.slots_raw.len(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let start = usize::from(u16::from_le_bytes([raw[0], raw[1]]));
|
||||||
|
let count = usize::from(u16::from_le_bytes([raw[2], raw[3]]));
|
||||||
|
let end = start
|
||||||
|
.checked_add(count)
|
||||||
|
.ok_or(TerrainLodError::IntegerOverflow)?;
|
||||||
|
if end > self.faces.len() {
|
||||||
|
return Err(TerrainLodError::FaceRangeOutOfBounds {
|
||||||
|
node: node_index,
|
||||||
|
lod,
|
||||||
|
slot,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
face_count: self.faces.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for face_index in start..end {
|
||||||
|
let assignment = assignments.get_mut(face_index).ok_or(
|
||||||
|
TerrainLodError::FaceRangeOutOfBounds {
|
||||||
|
node: node_index,
|
||||||
|
lod,
|
||||||
|
slot,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
face_count: self.faces.len(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if assignment.is_some() {
|
||||||
|
return Err(TerrainLodError::DuplicateFaceAssignment { face: face_index });
|
||||||
|
}
|
||||||
|
*assignment = Some(TerrainFaceLod {
|
||||||
|
face_index,
|
||||||
|
node_index,
|
||||||
|
lod,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignments
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(face_index, assignment)| {
|
||||||
|
assignment.ok_or(TerrainLodError::MissingFaceAssignment { face: face_index })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves the type-11 material pairs addressed by one 68-byte slot.
|
/// Resolves the type-11 material pairs addressed by one 68-byte slot.
|
||||||
///
|
///
|
||||||
/// Returns `None` when the slot is absent or its pair range does not fit
|
/// Returns `None` when the slot is absent or its pair range does not fit
|
||||||
@@ -1563,6 +1787,80 @@ mod tests {
|
|||||||
assert_eq!(slots.render_dispatch(1), None);
|
assert_eq!(slots.render_dispatch(1), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terrain_face_lod_assignments_keep_interleaved_source_order_and_reject_missing_nodes() {
|
||||||
|
let mut nodes_raw = vec![u8::MAX; 2 * NODE38_STRIDE];
|
||||||
|
for (node_index, [lod0_slot, lod1_slot]) in [[0_u16, 1_u16], [2, 3]].into_iter().enumerate()
|
||||||
|
{
|
||||||
|
let offset = node_index * NODE38_STRIDE + 8;
|
||||||
|
nodes_raw[offset..offset + 2].copy_from_slice(&lod0_slot.to_le_bytes());
|
||||||
|
nodes_raw[offset + 2..offset + 4].copy_from_slice(&lod1_slot.to_le_bytes());
|
||||||
|
}
|
||||||
|
let mut slots_raw = vec![[0_u8; SLOT_STRIDE]; 4];
|
||||||
|
for (raw, start) in slots_raw.iter_mut().zip([0_u16, 1, 2, 3]) {
|
||||||
|
raw[..2].copy_from_slice(&start.to_le_bytes());
|
||||||
|
raw[2..4].copy_from_slice(&1_u16.to_le_bytes());
|
||||||
|
}
|
||||||
|
let mesh = LandMeshDocument {
|
||||||
|
streams: Vec::new(),
|
||||||
|
nodes_raw,
|
||||||
|
slots: TerrainSlotTable {
|
||||||
|
header_raw: Vec::new(),
|
||||||
|
slots_raw,
|
||||||
|
},
|
||||||
|
positions: Vec::new(),
|
||||||
|
normals: Vec::new(),
|
||||||
|
uv0: Vec::new(),
|
||||||
|
accelerator: Vec::new(),
|
||||||
|
aux14: Vec::new(),
|
||||||
|
aux18: Vec::new(),
|
||||||
|
faces: (0..4).map(|_| terrain_face([0, 1, 2])).collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let assignments = mesh.face_lod_assignments().expect("complete slot mapping");
|
||||||
|
assert_eq!(
|
||||||
|
assignments,
|
||||||
|
vec![
|
||||||
|
TerrainFaceLod {
|
||||||
|
face_index: 0,
|
||||||
|
node_index: 0,
|
||||||
|
lod: TerrainLod::Lod0,
|
||||||
|
},
|
||||||
|
TerrainFaceLod {
|
||||||
|
face_index: 1,
|
||||||
|
node_index: 0,
|
||||||
|
lod: TerrainLod::Lod1,
|
||||||
|
},
|
||||||
|
TerrainFaceLod {
|
||||||
|
face_index: 2,
|
||||||
|
node_index: 1,
|
||||||
|
lod: TerrainLod::Lod0,
|
||||||
|
},
|
||||||
|
TerrainFaceLod {
|
||||||
|
face_index: 3,
|
||||||
|
node_index: 1,
|
||||||
|
lod: TerrainLod::Lod1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut missing_nodes = mesh.clone();
|
||||||
|
missing_nodes.nodes_raw.clear();
|
||||||
|
assert_eq!(
|
||||||
|
missing_nodes.face_lod_assignments(),
|
||||||
|
Err(TerrainLodError::MissingNodes { face_count: 4 })
|
||||||
|
);
|
||||||
|
missing_nodes.faces.clear();
|
||||||
|
assert_eq!(missing_nodes.face_lod_assignments(), Ok(Vec::new()));
|
||||||
|
|
||||||
|
let mut overlapping = mesh;
|
||||||
|
overlapping.slots.slots_raw[3][..2].copy_from_slice(&2_u16.to_le_bytes());
|
||||||
|
assert_eq!(
|
||||||
|
overlapping.face_lod_assignments(),
|
||||||
|
Err(TerrainLodError::DuplicateFaceAssignment { face: 2 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slot_material_pairs_use_type11_pair_table_and_preserve_flags() {
|
fn slot_material_pairs_use_type11_pair_table_and_preserve_flags() {
|
||||||
let nres =
|
let nres =
|
||||||
@@ -1836,6 +2134,42 @@ Generator 1
|
|||||||
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
||||||
let document =
|
let document =
|
||||||
decode_land_msh(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
decode_land_msh(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
||||||
|
let assignments = document
|
||||||
|
.face_lod_assignments()
|
||||||
|
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
||||||
|
assert_eq!(assignments.len(), document.faces.len(), "{corpus} {path:?}");
|
||||||
|
for (face_index, assignment) in assignments.iter().enumerate() {
|
||||||
|
assert_eq!(assignment.face_index, face_index, "{corpus} {path:?}");
|
||||||
|
}
|
||||||
|
let lod0_faces = assignments
|
||||||
|
.iter()
|
||||||
|
.filter(|assignment| assignment.lod == TerrainLod::Lod0)
|
||||||
|
.count();
|
||||||
|
let lod1_faces = assignments
|
||||||
|
.iter()
|
||||||
|
.filter(|assignment| assignment.lod == TerrainLod::Lod1)
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
lod0_faces + lod1_faces,
|
||||||
|
document.faces.len(),
|
||||||
|
"{corpus} {path:?}"
|
||||||
|
);
|
||||||
|
if corpus == "IS" {
|
||||||
|
let map_name = path
|
||||||
|
.parent()
|
||||||
|
.and_then(Path::file_name)
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(str::to_ascii_lowercase);
|
||||||
|
let expected_lod0 = match map_name.as_deref() {
|
||||||
|
Some("automap") => Some(1_912),
|
||||||
|
Some("prol") => Some(5_868),
|
||||||
|
Some("tut_1") => Some(5_046),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(expected) = expected_lod0 {
|
||||||
|
assert_eq!(lod0_faces, expected, "{corpus} {path:?} LOD0 face count");
|
||||||
|
}
|
||||||
|
}
|
||||||
files += 1;
|
files += 1;
|
||||||
vertices += document.positions.len();
|
vertices += document.positions.len();
|
||||||
faces += document.faces.len();
|
faces += document.faces.len();
|
||||||
@@ -2127,6 +2461,18 @@ Generator 1
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 {
|
||||||
|
TerrainFace28 {
|
||||||
|
flags: FullSurfaceMask(0),
|
||||||
|
material_tag: 0,
|
||||||
|
aux_tag: 0,
|
||||||
|
vertices,
|
||||||
|
neighbors: [None; 3],
|
||||||
|
tail_raw: *b"TAILFACE",
|
||||||
|
raw: face(vertices, [None; 3]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn entry(type_id: u32, attr1: u32, attr3: u32, payload: &[u8]) -> TestEntry<'_> {
|
fn entry(type_id: u32, attr1: u32, attr3: u32, payload: &[u8]) -> TestEntry<'_> {
|
||||||
TestEntry {
|
TestEntry {
|
||||||
type_id,
|
type_id,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::collections::VecDeque;
|
|||||||
/// Applications access this semantic terrain API through this terrain crate
|
/// Applications access this semantic terrain API through this terrain crate
|
||||||
/// rather than taking a direct dependency on the binary-format parser.
|
/// rather than taking a direct dependency on the binary-format parser.
|
||||||
pub use fparkan_terrain_format::{
|
pub use fparkan_terrain_format::{
|
||||||
FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection,
|
FullSurfaceMask, TerrainLod, TerrainLodError, TerrainMaterialLayers, TerrainMaterialSelection,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Terrain world.
|
/// Terrain world.
|
||||||
@@ -91,6 +91,8 @@ pub enum TerrainError {
|
|||||||
/// Position count.
|
/// Position count.
|
||||||
position_count: usize,
|
position_count: usize,
|
||||||
},
|
},
|
||||||
|
/// Terrain Node38/Slot68 records do not assign every face to one LOD.
|
||||||
|
InvalidLodMapping(TerrainLodError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for TerrainError {
|
impl std::fmt::Display for TerrainError {
|
||||||
@@ -117,11 +119,23 @@ impl std::fmt::Display for TerrainError {
|
|||||||
f,
|
f,
|
||||||
"terrain face {face} references vertex {vertex} outside {position_count} positions"
|
"terrain face {face} references vertex {vertex} outside {position_count} positions"
|
||||||
),
|
),
|
||||||
|
Self::InvalidLodMapping(error) => write!(f, "invalid terrain LOD mapping: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for TerrainError {}
|
impl std::error::Error for TerrainError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::InvalidLodMapping(error) => Some(error),
|
||||||
|
Self::Unsupported
|
||||||
|
| Self::TooManyAreals { .. }
|
||||||
|
| Self::InvalidGridReference { .. }
|
||||||
|
| Self::InvalidArealReference { .. }
|
||||||
|
| Self::InvalidSurfaceVertex { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Surface query.
|
/// Surface query.
|
||||||
pub trait SurfaceQuery {
|
pub trait SurfaceQuery {
|
||||||
@@ -247,8 +261,8 @@ impl TerrainWorld {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`TerrainError`] if a face cannot be represented by runtime
|
/// Returns [`TerrainError`] if terrain LOD metadata or a face cannot be
|
||||||
/// indexes.
|
/// represented by runtime indexes.
|
||||||
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
|
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
surfaces: build_surfaces(mesh)?,
|
surfaces: build_surfaces(mesh)?,
|
||||||
@@ -878,8 +892,19 @@ impl RuntimeGrid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, TerrainError> {
|
fn build_surfaces(mesh: &LandMeshDocument) -> Result<Vec<RuntimeTriangle>, TerrainError> {
|
||||||
let mut triangles = Vec::with_capacity(mesh.faces.len());
|
let assignments = mesh
|
||||||
for (face_index, face) in mesh.faces.iter().enumerate() {
|
.face_lod_assignments()
|
||||||
|
.map_err(TerrainError::InvalidLodMapping)?;
|
||||||
|
let lod0_count = assignments
|
||||||
|
.iter()
|
||||||
|
.filter(|assignment| assignment.lod == TerrainLod::Lod0)
|
||||||
|
.count();
|
||||||
|
let mut triangles = Vec::with_capacity(lod0_count);
|
||||||
|
for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) {
|
||||||
|
if assignment.lod != TerrainLod::Lod0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let face_index = assignment.face_index;
|
||||||
let vertices = [
|
let vertices = [
|
||||||
surface_vertex(mesh, face_index, face.vertices[0])?,
|
surface_vertex(mesh, face_index, face.vertices[0])?,
|
||||||
surface_vertex(mesh, face_index, face.vertices[1])?,
|
surface_vertex(mesh, face_index, face.vertices[1])?,
|
||||||
@@ -1243,7 +1268,7 @@ mod tests {
|
|||||||
|
|
||||||
let face0 = terrain_face(FullSurfaceMask(0x0000_0001), [0, 1, 2]);
|
let face0 = terrain_face(FullSurfaceMask(0x0000_0001), [0, 1, 2]);
|
||||||
let face1 = terrain_face(FullSurfaceMask(0x0000_0002), [1, 3, 2]);
|
let face1 = terrain_face(FullSurfaceMask(0x0000_0002), [1, 3, 2]);
|
||||||
LandMeshDocument {
|
let mut mesh = LandMeshDocument {
|
||||||
streams: Vec::<TerrainStream>::new(),
|
streams: Vec::<TerrainStream>::new(),
|
||||||
nodes_raw: Vec::new(),
|
nodes_raw: Vec::new(),
|
||||||
slots: TerrainSlotTable {
|
slots: TerrainSlotTable {
|
||||||
@@ -1262,7 +1287,14 @@ mod tests {
|
|||||||
aux14: Vec::new(),
|
aux14: Vec::new(),
|
||||||
aux18: Vec::new(),
|
aux18: Vec::new(),
|
||||||
faces: vec![face0, face1],
|
faces: vec![face0, face1],
|
||||||
}
|
};
|
||||||
|
let mut node = vec![u8::MAX; 38];
|
||||||
|
node[8..10].copy_from_slice(&0_u16.to_le_bytes());
|
||||||
|
let mut slot = [0_u8; 68];
|
||||||
|
slot[2..4].copy_from_slice(&(mesh.faces.len() as u16).to_le_bytes());
|
||||||
|
mesh.nodes_raw = node;
|
||||||
|
mesh.slots.slots_raw.push(slot);
|
||||||
|
mesh
|
||||||
}
|
}
|
||||||
|
|
||||||
fn terrain_face(
|
fn terrain_face(
|
||||||
|
|||||||
@@ -930,6 +930,31 @@ sun-ray queries поэтому используют исходные коорд
|
|||||||
Эти два указателя нельзя подменять друг другом. Формулы камеры и размещения
|
Эти два указателя нельзя подменять друг другом. Формулы камеры и размещения
|
||||||
реализованы и проверяются в `fparkan-render`.
|
реализованы и проверяются в `fparkan-render`.
|
||||||
|
|
||||||
|
## LOD ландшафта
|
||||||
|
|
||||||
|
У `Land.msh` список slot-ссылок terrain хранится в 38-байтовых записях
|
||||||
|
Node38, а диапазоны граней — в 68-байтовых Slot68. В текущем runtime первые
|
||||||
|
две ссылки Node38 выбирают соответственно LOD0 и LOD1; каждая запись слота
|
||||||
|
задаёт начало и число граней в исходном массиве `TerrainFace28`. Все грани
|
||||||
|
проверяются на единственное назначение, а выходящий за массив, повторный или
|
||||||
|
неподдерживаемый диапазон считается ошибкой загрузки.
|
||||||
|
|
||||||
|
Первый рендер карты, terrain-запросы высоты и пересечения, receiver-ы теней и
|
||||||
|
обрезка контуром зданий используют только LOD0. Отбор сохраняет порядок
|
||||||
|
исходных граней, а контурный проход отдельно переводит индекс спроецированной
|
||||||
|
грани обратно в исходный. LOD1 пока не подставляется по расстоянию: такой
|
||||||
|
переход не реализован.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let assignments = mesh.face_lod_assignments()?;
|
||||||
|
for (face, assignment) in mesh.faces.iter().zip(assignments.iter()) {
|
||||||
|
if assignment.lod != TerrainLod::Lod0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Build terrain query geometry from this source face.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Материалы ландшафта
|
## Материалы ландшафта
|
||||||
|
|
||||||
Рядом с `Land.msh` лежат `Land1.wea` и `Land2.wea`. Старший байт
|
Рядом с `Land.msh` лежат `Land1.wea` и `Land2.wea`. Старший байт
|
||||||
|
|||||||
Reference in new issue
Block a user