feat(terrain): bind map base textures
This commit is contained in:
Generated
+1
@@ -447,6 +447,7 @@ name = "fparkan-game"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fparkan-assets",
|
"fparkan-assets",
|
||||||
|
"fparkan-inspection",
|
||||||
"fparkan-platform",
|
"fparkan-platform",
|
||||||
"fparkan-platform-winit",
|
"fparkan-platform-winit",
|
||||||
"fparkan-render",
|
"fparkan-render",
|
||||||
|
|||||||
@@ -420,8 +420,10 @@ fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
|
|||||||
/// Projects validated `Land.msh` terrain geometry into the static Vulkan path.
|
/// Projects validated `Land.msh` terrain geometry into the static Vulkan path.
|
||||||
///
|
///
|
||||||
/// This bridge preserves the source triangle order from `TerrainFace28` and
|
/// This bridge preserves the source triangle order from `TerrainFace28` and
|
||||||
/// consumes its validated position and UV0 streams. It is deliberately a
|
/// consumes its validated position and UV0 streams. Contiguous source faces are
|
||||||
/// geometry-only terrain slice: source slot/material selection, camera
|
/// retained as separate draw ranges keyed by their exact packed
|
||||||
|
/// `TerrainFace28.material_tag`, so a caller can resolve the map-local
|
||||||
|
/// material contract without changing topology. Slot selection, camera
|
||||||
/// transforms, fog and surface-mask shading need their own evidence before
|
/// transforms, fog and surface-mask shading need their own evidence before
|
||||||
/// they can be modeled as renderer state.
|
/// they can be modeled as renderer state.
|
||||||
///
|
///
|
||||||
@@ -592,16 +594,32 @@ fn static_terrain_indices(terrain: &LandMeshDocument) -> Result<Vec<u32>, Vulkan
|
|||||||
fn static_terrain_draw_ranges(
|
fn static_terrain_draw_ranges(
|
||||||
terrain: &LandMeshDocument,
|
terrain: &LandMeshDocument,
|
||||||
) -> Result<Vec<VulkanStaticDrawRange>, VulkanAssetMeshError> {
|
) -> Result<Vec<VulkanStaticDrawRange>, VulkanAssetMeshError> {
|
||||||
Ok(vec![VulkanStaticDrawRange {
|
let mut ranges = Vec::new();
|
||||||
first_index: 0,
|
let mut start_face = 0usize;
|
||||||
index_count: u32::try_from(terrain.faces.len())
|
while start_face < terrain.faces.len() {
|
||||||
.map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?
|
let material_index = terrain.faces[start_face].material_tag;
|
||||||
.checked_mul(3)
|
let mut end_face = start_face + 1;
|
||||||
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?,
|
while end_face < terrain.faces.len()
|
||||||
material_index: 0,
|
&& terrain.faces[end_face].material_tag == material_index
|
||||||
pipeline_state: LegacyPipelineState::default(),
|
{
|
||||||
alpha_test_reference: 0,
|
end_face += 1;
|
||||||
}])
|
}
|
||||||
|
ranges.push(VulkanStaticDrawRange {
|
||||||
|
first_index: u32::try_from(start_face)
|
||||||
|
.map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?
|
||||||
|
.checked_mul(3)
|
||||||
|
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?,
|
||||||
|
index_count: u32::try_from(end_face - start_face)
|
||||||
|
.map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?
|
||||||
|
.checked_mul(3)
|
||||||
|
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?,
|
||||||
|
material_index,
|
||||||
|
pipeline_state: LegacyPipelineState::default(),
|
||||||
|
alpha_test_reference: 0,
|
||||||
|
});
|
||||||
|
start_face = end_face;
|
||||||
|
}
|
||||||
|
Ok(ranges)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn static_model_indices_and_ranges(
|
fn static_model_indices_and_ranges(
|
||||||
@@ -1109,9 +1127,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 {
|
fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 {
|
||||||
|
terrain_face_with_tag(0, vertices)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terrain_face_with_tag(material_tag: u16, vertices: [u16; 3]) -> TerrainFace28 {
|
||||||
TerrainFace28 {
|
TerrainFace28 {
|
||||||
flags: FullSurfaceMask(0),
|
flags: FullSurfaceMask(0),
|
||||||
material_tag: 0,
|
material_tag,
|
||||||
aux_tag: 0,
|
aux_tag: 0,
|
||||||
vertices,
|
vertices,
|
||||||
neighbors: [None; 3],
|
neighbors: [None; 3],
|
||||||
@@ -1119,4 +1141,41 @@ mod tests {
|
|||||||
raw: [0; 28],
|
raw: [0; 28],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terrain_ranges_preserve_face_order_and_packed_material_tags() {
|
||||||
|
let 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]; 3],
|
||||||
|
normals: Vec::new(),
|
||||||
|
uv0: vec![[0, 0]; 3],
|
||||||
|
accelerator: Vec::new(),
|
||||||
|
aux14: Vec::new(),
|
||||||
|
aux18: Vec::new(),
|
||||||
|
faces: vec![
|
||||||
|
terrain_face_with_tag(0xff01, [0, 1, 2]),
|
||||||
|
terrain_face_with_tag(0xff01, [0, 1, 2]),
|
||||||
|
terrain_face_with_tag(0x0102, [0, 1, 2]),
|
||||||
|
terrain_face_with_tag(0xff01, [0, 1, 2]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let ranges = static_terrain_draw_ranges(&terrain).expect("representable terrain");
|
||||||
|
|
||||||
|
assert_eq!(ranges.len(), 3);
|
||||||
|
assert_eq!(ranges[0].first_index, 0);
|
||||||
|
assert_eq!(ranges[0].index_count, 6);
|
||||||
|
assert_eq!(ranges[0].material_index, 0xff01);
|
||||||
|
assert_eq!(ranges[1].first_index, 6);
|
||||||
|
assert_eq!(ranges[1].index_count, 3);
|
||||||
|
assert_eq!(ranges[1].material_index, 0x0102);
|
||||||
|
assert_eq!(ranges[2].first_index, 9);
|
||||||
|
assert_eq!(ranges[2].index_count, 3);
|
||||||
|
assert_eq!(ranges[2].material_index, 0xff01);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
|
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
|
||||||
|
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
||||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
||||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
||||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
|
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
//! `FParkan` render-planning composition root.
|
//! `FParkan` render-planning composition root.
|
||||||
|
|
||||||
use fparkan_assets::{PreparedTextureUsage, PreparedVisual};
|
use fparkan_assets::{PreparedTextureUsage, PreparedVisual};
|
||||||
|
use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root;
|
||||||
use fparkan_platform::WindowPort;
|
use fparkan_platform::WindowPort;
|
||||||
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
|
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
|
||||||
use fparkan_render::{
|
use fparkan_render::{
|
||||||
@@ -102,7 +103,14 @@ fn run(args: &[String]) -> Result<String, String> {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(load_legacy_camera_capture)
|
.map(load_legacy_camera_capture)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let preview = static_preview_mesh_and_materials(mission_assets, terrain, roots, camera)?;
|
let preview = static_preview_mesh_and_materials(
|
||||||
|
mission_assets,
|
||||||
|
terrain,
|
||||||
|
roots,
|
||||||
|
camera,
|
||||||
|
&args.root,
|
||||||
|
&loaded.land_msh_path,
|
||||||
|
)?;
|
||||||
return run_static_vulkan_mode(
|
return run_static_vulkan_mode(
|
||||||
preview,
|
preview,
|
||||||
roots.len(),
|
roots.len(),
|
||||||
@@ -236,14 +244,18 @@ struct StaticPreviewScene {
|
|||||||
/// bounded static-preview root into one diagnostic XY frame.
|
/// bounded static-preview root into one diagnostic XY frame.
|
||||||
///
|
///
|
||||||
/// This intentionally takes only the first MAT0 texture request for each MSH
|
/// This intentionally takes only the first MAT0 texture request for each MSH
|
||||||
/// batch selector. The terrain uses an explicit white diagnostic texture;
|
/// batch selector. Terrain maps its proven low-byte packed tag channel through
|
||||||
/// terrain slot/material selection, orientation, camera, later material phases,
|
/// the map-local `Land2.wea` table. The high-byte `Land1.wea` channel is
|
||||||
/// animation, lightmaps and gameplay visibility remain outside this bridge.
|
/// retained on draw ranges but awaits a recovered blend equation; orientation,
|
||||||
|
/// later material phases, animation, lightmaps and gameplay visibility remain
|
||||||
|
/// outside this bridge.
|
||||||
fn static_preview_mesh_and_materials(
|
fn static_preview_mesh_and_materials(
|
||||||
assets: &MissionAssets,
|
assets: &MissionAssets,
|
||||||
terrain: &TerrainWorld,
|
terrain: &TerrainWorld,
|
||||||
roots: &[MissionObjectDraft],
|
roots: &[MissionObjectDraft],
|
||||||
legacy_camera: Option<VulkanStaticCamera>,
|
legacy_camera: Option<VulkanStaticCamera>,
|
||||||
|
root: &std::path::Path,
|
||||||
|
land_msh_path: &str,
|
||||||
) -> Result<StaticPreviewScene, String> {
|
) -> Result<StaticPreviewScene, String> {
|
||||||
let terrain_mesh = terrain
|
let terrain_mesh = terrain
|
||||||
.source_mesh()
|
.source_mesh()
|
||||||
@@ -257,14 +269,7 @@ fn static_preview_mesh_and_materials(
|
|||||||
indices: Vec::new(),
|
indices: Vec::new(),
|
||||||
draw_ranges: Vec::new(),
|
draw_ranges: Vec::new(),
|
||||||
};
|
};
|
||||||
let mut materials = vec![VulkanStaticMaterial {
|
let mut materials = Vec::new();
|
||||||
material_index: 0,
|
|
||||||
texture: VulkanStaticTexture {
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
rgba8: vec![255, 255, 255, 255],
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
let terrain_component = if legacy_camera.is_some() {
|
let terrain_component = if legacy_camera.is_some() {
|
||||||
project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh)
|
project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh)
|
||||||
} else {
|
} else {
|
||||||
@@ -272,7 +277,13 @@ fn static_preview_mesh_and_materials(
|
|||||||
project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame)
|
project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame)
|
||||||
}
|
}
|
||||||
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
|
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
|
||||||
append_static_preview_component(&mut mesh, terrain_component, &[(0, 0)])?;
|
let terrain_materials = static_preview_terrain_base_materials(
|
||||||
|
root,
|
||||||
|
land_msh_path,
|
||||||
|
&terrain_component,
|
||||||
|
&mut materials,
|
||||||
|
)?;
|
||||||
|
append_static_preview_component(&mut mesh, terrain_component, &terrain_materials)?;
|
||||||
let mut mesh_components = 0;
|
let mut mesh_components = 0;
|
||||||
for (object_index, root) in roots.iter().enumerate() {
|
for (object_index, root) in roots.iter().enumerate() {
|
||||||
for visual_id in assets.visuals_for_object(object_index) {
|
for visual_id in assets.visuals_for_object(object_index) {
|
||||||
@@ -445,6 +456,53 @@ fn extend_static_preview_xy_bounds(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn static_preview_terrain_base_materials(
|
||||||
|
root: &std::path::Path,
|
||||||
|
land_msh_path: &str,
|
||||||
|
mesh: &VulkanStaticMesh,
|
||||||
|
materials: &mut Vec<VulkanStaticMaterial>,
|
||||||
|
) -> Result<Vec<(u16, u16)>, String> {
|
||||||
|
let land_path = root.join(land_msh_path);
|
||||||
|
let wear_path = land_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))?
|
||||||
|
.join("Land2.wea");
|
||||||
|
let mut packed_tags = mesh
|
||||||
|
.draw_ranges
|
||||||
|
.iter()
|
||||||
|
.map(|range| range.material_index)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
packed_tags.sort_unstable();
|
||||||
|
packed_tags.dedup();
|
||||||
|
packed_tags
|
||||||
|
.into_iter()
|
||||||
|
.map(|packed_tag| {
|
||||||
|
let selector = packed_tag & 0x00ff;
|
||||||
|
let selected = load_standalone_wear_material_texture_mip0_rgba8_from_root(
|
||||||
|
root, &wear_path, selector,
|
||||||
|
)
|
||||||
|
.map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"resolve terrain base material tag 0x{packed_tag:04x} through {}: {err}",
|
||||||
|
wear_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let preview_selector = u16::try_from(materials.len()).map_err(|_| {
|
||||||
|
"static preview exceeds the available 16-bit material selector space".to_string()
|
||||||
|
})?;
|
||||||
|
materials.push(VulkanStaticMaterial {
|
||||||
|
material_index: preview_selector,
|
||||||
|
texture: VulkanStaticTexture {
|
||||||
|
width: selected.image.width,
|
||||||
|
height: selected.image.height,
|
||||||
|
rgba8: selected.image.rgba8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Ok((packed_tag, preview_selector))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn static_preview_component_materials(
|
fn static_preview_component_materials(
|
||||||
assets: &MissionAssets,
|
assets: &MissionAssets,
|
||||||
visual: &PreparedVisual,
|
visual: &PreparedVisual,
|
||||||
|
|||||||
@@ -484,6 +484,43 @@ pub fn load_wear_material_texture_mip0_rgba8_from_root(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves phase-zero diffuse TEXM through a standalone map-local WEAR file.
|
||||||
|
///
|
||||||
|
/// Map terrain keeps its two WEAR tables adjacent to `Land.msh`, while their
|
||||||
|
/// MAT0 and TEXM resources remain in the game root's normal archives. This
|
||||||
|
/// preserves that split instead of treating the sidecar as an archive entry.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error if the standalone WEAR file, global material
|
||||||
|
/// resources, or selected diffuse TEXM cannot be decoded.
|
||||||
|
pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root(
|
||||||
|
root: &Path,
|
||||||
|
wear_path: &Path,
|
||||||
|
material_index: u16,
|
||||||
|
) -> Result<WearMaterialTexture, String> {
|
||||||
|
let wear_bytes =
|
||||||
|
fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?;
|
||||||
|
let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
|
||||||
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
||||||
|
let material =
|
||||||
|
resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
|
||||||
|
let texture = material.document.primary_texture().ok_or_else(|| {
|
||||||
|
"MAT0 phase zero declares an intentionally untextured material".to_string()
|
||||||
|
})?;
|
||||||
|
let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
|
||||||
|
let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
|
||||||
|
Ok(WearMaterialTexture {
|
||||||
|
wear_archive: "<standalone>".to_string(),
|
||||||
|
wear_resource: wear_path.display().to_string(),
|
||||||
|
material_index,
|
||||||
|
material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
|
||||||
|
material_fallback: material.fallback,
|
||||||
|
texture_name,
|
||||||
|
image,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Inspects a terrain land file by path.
|
/// Inspects a terrain land file by path.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -813,6 +850,35 @@ mod tests {
|
|||||||
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standalone_wear_material_texture_loader_keeps_map_sidecar_separate() {
|
||||||
|
let dir = temp_dir("standalone-wear-material-texture");
|
||||||
|
let wear_path = dir.join("Land2.wea");
|
||||||
|
fs::write(&wear_path, b"1\n0 MAT\n").expect("standalone wear");
|
||||||
|
let mut mat0 = vec![0; 4 + 34];
|
||||||
|
mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
|
||||||
|
mat0[22..25].copy_from_slice(b"TEX");
|
||||||
|
fs::write(
|
||||||
|
dir.join("material.lib"),
|
||||||
|
build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
|
||||||
|
)
|
||||||
|
.expect("material archive");
|
||||||
|
fs::write(
|
||||||
|
dir.join("Textures.lib"),
|
||||||
|
build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
|
||||||
|
)
|
||||||
|
.expect("texture archive");
|
||||||
|
|
||||||
|
let selected =
|
||||||
|
load_standalone_wear_material_texture_mip0_rgba8_from_root(&dir, &wear_path, 0)
|
||||||
|
.expect("resolved material texture");
|
||||||
|
|
||||||
|
assert_eq!(selected.wear_archive, "<standalone>");
|
||||||
|
assert_eq!(selected.wear_resource, wear_path.display().to_string());
|
||||||
|
assert_eq!(selected.texture_name, "TEX");
|
||||||
|
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn land_mesh_bounds_preserve_each_source_axis() {
|
fn land_mesh_bounds_preserve_each_source_axis() {
|
||||||
let mesh = LandMeshDocument {
|
let mesh = LandMeshDocument {
|
||||||
|
|||||||
@@ -1550,6 +1550,18 @@ This is evidence for a two-layer terrain-material contract, but not yet for
|
|||||||
its blend equation, so the Vulkan bridge intentionally remains texture-agnostic
|
its blend equation, so the Vulkan bridge intentionally remains texture-agnostic
|
||||||
until the base/overlay composition is recovered.
|
until the base/overlay composition is recovered.
|
||||||
|
|
||||||
|
The static Vulkan bridge now carries each contiguous face run as a separate
|
||||||
|
draw range keyed by the original packed `material_tag`; it neither reorders
|
||||||
|
triangles nor collapses the high byte. For the first usable visual layer, the
|
||||||
|
low byte resolves positionally through the adjacent standalone `Land2.wea`,
|
||||||
|
then the existing `MAT0 -> Textures.lib -> TEXM` chain supplies the RGBA8
|
||||||
|
descriptor. `Land1.wea` remains preserved as the high byte/overlay channel and
|
||||||
|
is deliberately not drawn until its blend equation is recovered. A canonical
|
||||||
|
three-frame AutoDemo run now uses 71 descriptors (66 MSH plus five terrain
|
||||||
|
tags), produces readback hash `17034026600547661445`, and stays at zero Vulkan
|
||||||
|
validation warnings/errors. This is original terrain texture upload, not
|
||||||
|
terrain visual parity.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cargo run -q -p fparkan-cli -- terrain inspect `
|
cargo run -q -p fparkan-cli -- terrain inspect `
|
||||||
'C:\GOG Games\Parkan - Iron Strategy\DATA\MAPS\AutoMAP\Land.msh' --format json
|
'C:\GOG Games\Parkan - Iron Strategy\DATA\MAPS\AutoMAP\Land.msh' --format json
|
||||||
|
|||||||
Reference in New Issue
Block a user