From 5df58144881a90bc4546c21b9ec1a688dba2e535 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 10:36:29 +0400 Subject: [PATCH] feat(game): preview multiple mission roots --- apps/fparkan-game/src/main.rs | 260 ++++++++++++++++++------------ crates/fparkan-runtime/src/lib.rs | 52 ++++++ docs/tomes/05-render.md | 29 +++- 3 files changed, 232 insertions(+), 109 deletions(-) diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 2bc7386..b5106ea 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -36,16 +36,17 @@ use fparkan_render_vulkan::{ VulkanStaticXzFrame, }; use fparkan_runtime::{ - create, frame, load_mission, load_mission_static_preview, - load_mission_static_preview_with_progress, load_mission_with_progress, loaded_mission_assets, - loaded_mission_object_drafts, loaded_terrain, EngineConfig, EngineMode, EngineServices, - MissionAssets, MissionLoadPhase, MissionObjectDraft, MissionRequest, + create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots, + load_mission_static_preview_roots_with_progress, load_mission_with_progress, + loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineConfig, EngineMode, + EngineServices, MissionAssets, MissionLoadPhase, MissionObjectDraft, MissionRequest, }; use fparkan_terrain::TerrainWorld; use fparkan_vfs::DirectoryVfs; #[cfg(test)] use fparkan_world::OriginalObjectId; use fparkan_world::WorldSnapshot; +use std::num::NonZeroUsize; use std::path::PathBuf; use std::sync::Arc; use winit::application::ApplicationHandler; @@ -86,15 +87,16 @@ fn run(args: &[String]) -> Result { .ok_or_else(|| "mission assets are unavailable after loading".to_string())?; let terrain = loaded_terrain(&engine) .ok_or_else(|| "mission terrain is unavailable after loading".to_string())?; - let root = loaded_mission_object_drafts(&engine) - .and_then(|drafts| drafts.first()) - .ok_or_else(|| "first mission object draft is unavailable after loading".to_string())?; - let preview = static_preview_mesh_and_materials(mission_assets, terrain, root)?; + let roots = loaded_mission_object_drafts(&engine) + .map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())]) + .filter(|roots| !roots.is_empty()) + .ok_or_else(|| { + "selected mission object drafts are unavailable after loading".to_string() + })?; + let preview = static_preview_mesh_and_materials(mission_assets, terrain, roots)?; return run_static_vulkan_mode( - preview.mesh, - preview.materials, - preview.mesh_components, - preview.terrain_components, + preview, + roots.len(), args.frames, &args.mission, loaded.object_count, @@ -154,13 +156,18 @@ fn load_requested_mission( prepare_load_progress_path(progress_path)?; let mut write_error = None; let loaded = if args.backend == RenderBackendMode::StaticVulkan { - load_mission_static_preview_with_progress(engine, request, |phase| { - if write_error.is_none() { - if let Err(err) = write_load_progress(progress_path, phase) { - write_error = Some(err); + load_mission_static_preview_roots_with_progress( + engine, + request, + args.preview_roots, + |phase| { + if write_error.is_none() { + if let Err(err) = write_load_progress(progress_path, phase) { + write_error = Some(err); + } } - } - }) + }, + ) } else { load_mission_with_progress(engine, request, |phase| { if write_error.is_none() { @@ -179,7 +186,11 @@ fn load_requested_mission( return Ok(loaded); } if args.backend == RenderBackendMode::StaticVulkan { - load_mission_static_preview(engine, request) + if args.preview_roots.get() == 1 { + load_mission_static_preview(engine, request) + } else { + load_mission_static_preview_roots(engine, request, args.preview_roots) + } } else { load_mission(engine, request) } @@ -204,16 +215,12 @@ struct StaticPreviewScene { fn static_preview_mesh_and_materials( assets: &MissionAssets, terrain: &TerrainWorld, - root: &MissionObjectDraft, + roots: &[MissionObjectDraft], ) -> Result { - let visual_ids = assets.visuals_for_object(0); - if visual_ids.is_empty() { - return Err("first static preview root has no prepared visuals".to_string()); - } let terrain_mesh = terrain .source_mesh() .ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?; - let frame = static_preview_xz_frame(assets, visual_ids, terrain, root)?; + let frame = static_preview_xz_frame(assets, terrain, roots)?; let mut mesh = VulkanStaticMesh { vertices: Vec::new(), indices: Vec::new(), @@ -231,30 +238,34 @@ fn static_preview_mesh_and_materials( .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; append_static_preview_component(&mut mesh, terrain_component, &[(0, 0)])?; let mut mesh_components = 0; - for visual_id in visual_ids { - let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { - format!("first static preview root references unknown visual {visual_id:?}") - })?; - let Some(model_id) = visual.model_id else { - continue; - }; - let model = assets.model_by_id(model_id).ok_or_else(|| { - format!("static preview visual {visual_id:?} references unknown model {model_id:?}") - })?; - let component = project_msh_to_static_mesh_in_xz_frame( - &model.validated, - frame, - root.position, - root.scale, - ) - .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; - let selector_remap = - static_preview_component_materials(assets, visual, &component, &mut materials)?; - append_static_preview_component(&mut mesh, component, &selector_remap)?; - mesh_components += 1; + for (object_index, root) in roots.iter().enumerate() { + for visual_id in assets.visuals_for_object(object_index) { + let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { + format!( + "static preview root {object_index} references unknown visual {visual_id:?}" + ) + })?; + let Some(model_id) = visual.model_id else { + continue; + }; + let model = assets.model_by_id(model_id).ok_or_else(|| { + format!("static preview visual {visual_id:?} references unknown model {model_id:?}") + })?; + let component = project_msh_to_static_mesh_in_xz_frame( + &model.validated, + frame, + root.position, + root.scale, + ) + .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; + let selector_remap = + static_preview_component_materials(assets, visual, &component, &mut materials)?; + append_static_preview_component(&mut mesh, component, &selector_remap)?; + mesh_components += 1; + } } if mesh_components == 0 { - return Err("first static preview root has no mesh-backed visual".to_string()); + return Err("selected static preview roots have no mesh-backed visual".to_string()); } Ok(StaticPreviewScene { mesh, @@ -266,18 +277,9 @@ fn static_preview_mesh_and_materials( fn static_preview_xz_frame( assets: &MissionAssets, - visual_ids: &[fparkan_assets::AssetId], terrain: &TerrainWorld, - root: &MissionObjectDraft, + roots: &[MissionObjectDraft], ) -> Result { - if !root - .position - .iter() - .chain(root.scale.iter()) - .all(|value| value.is_finite()) - { - return Err("first static preview root has a non-finite position or scale".to_string()); - } let mut min_x = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; let mut min_z = f32::INFINITY; @@ -288,28 +290,42 @@ fn static_preview_xz_frame( for position in terrain_positions { extend_static_preview_xz_bounds(*position, &mut min_x, &mut max_x, &mut min_z, &mut max_z)?; } - for visual_id in visual_ids { - let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { - format!("first static preview root references unknown visual {visual_id:?}") - })?; - let Some(model_id) = visual.model_id else { - continue; - }; - let model = assets.model_by_id(model_id).ok_or_else(|| { - format!("static preview visual {visual_id:?} references unknown model {model_id:?}") - })?; - for position in &model.validated.positions { - extend_static_preview_xz_bounds( - [ - position[0] * root.scale[0] + root.position[0], - position[1] * root.scale[1] + root.position[1], - position[2] * root.scale[2] + root.position[2], - ], - &mut min_x, - &mut max_x, - &mut min_z, - &mut max_z, - )?; + for (object_index, root) in roots.iter().enumerate() { + if !root + .position + .iter() + .chain(root.scale.iter()) + .all(|value| value.is_finite()) + { + return Err(format!( + "static preview root {object_index} has a non-finite position or scale" + )); + } + for visual_id in assets.visuals_for_object(object_index) { + let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { + format!( + "static preview root {object_index} references unknown visual {visual_id:?}" + ) + })?; + let Some(model_id) = visual.model_id else { + continue; + }; + let model = assets.model_by_id(model_id).ok_or_else(|| { + format!("static preview visual {visual_id:?} references unknown model {model_id:?}") + })?; + for position in &model.validated.positions { + extend_static_preview_xz_bounds( + [ + position[0] * root.scale[0] + root.position[0], + position[1] * root.scale[1] + root.position[1], + position[2] * root.scale[2] + root.position[2], + ], + &mut min_x, + &mut max_x, + &mut min_z, + &mut max_z, + )?; + } } } VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z) @@ -461,25 +477,16 @@ fn write_load_progress(path: &std::path::Path, phase: MissionLoadPhase) -> Resul } fn run_static_vulkan_mode( - mesh: VulkanStaticMesh, - materials: Vec, - mesh_components: usize, - terrain_components: usize, + preview: StaticPreviewScene, + preview_roots: usize, target_frames: u64, mission: &str, object_count: usize, ) -> Result { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); - let mut app = StaticVulkanApp::new( - mesh, - materials, - mesh_components, - terrain_components, - target_frames, - mission, - object_count, - ); + let mut app = + StaticVulkanApp::new(preview, preview_roots, target_frames, mission, object_count); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); } @@ -491,6 +498,7 @@ struct StaticVulkanApp { materials: Vec, mesh_components: usize, terrain_components: usize, + preview_roots: usize, target_frames: u64, mission: String, object_count: usize, @@ -504,19 +512,18 @@ struct StaticVulkanApp { impl StaticVulkanApp { fn new( - mesh: VulkanStaticMesh, - materials: Vec, - mesh_components: usize, - terrain_components: usize, + preview: StaticPreviewScene, + preview_roots: usize, target_frames: u64, mission: &str, object_count: usize, ) -> Self { Self { - mesh, - materials, - mesh_components, - terrain_components, + mesh: preview.mesh, + materials: preview.materials, + mesh_components: preview.mesh_components, + terrain_components: preview.terrain_components, + preview_roots, target_frames, mission: mission.to_string(), object_count, @@ -559,10 +566,11 @@ impl StaticVulkanApp { return; } self.output = Some(format!( - "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"mesh_components\":{},\"terrain_components\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}", + "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}", json_string(&self.mission), self.object_count, self.frames_presented, + self.preview_roots, self.mesh_components, self.terrain_components, self.materials.len(), @@ -809,6 +817,7 @@ struct Args { frames: u64, backend: RenderBackendMode, load_progress: Option, + preview_roots: NonZeroUsize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -824,6 +833,7 @@ impl Args { let mut frames = 1; let mut backend = RenderBackendMode::Planning; let mut load_progress = None; + let mut preview_roots = NonZeroUsize::MIN; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { @@ -866,6 +876,13 @@ impl Args { .ok_or_else(|| "--load-progress requires a path".to_string())?, ); } + "--preview-roots" => { + preview_roots = iter + .next() + .ok_or_else(|| "--preview-roots requires a value".to_string())? + .parse() + .map_err(|_| "--preview-roots must be a non-zero integer".to_string())?; + } _ => return Err(usage()), } } @@ -880,6 +897,7 @@ impl Args { frames, backend, load_progress, + preview_roots, }) } } @@ -916,7 +934,7 @@ fn json_hash(hash: &[u8; 32]) -> String { } fn usage() -> String { - "usage: fparkan-game --root --mission [--frames ] [--backend ] [--load-progress ]".to_string() + "usage: fparkan-game --root --mission [--frames ] [--backend ] [--preview-roots ] [--load-progress ]".to_string() } #[cfg(test)] @@ -947,6 +965,7 @@ mod tests { frames: 3, backend: RenderBackendMode::Planning, load_progress: None, + preview_roots: NonZeroUsize::MIN, }) ); } @@ -968,10 +987,46 @@ mod tests { frames: 1, backend: RenderBackendMode::StaticVulkan, load_progress: None, + preview_roots: NonZeroUsize::MIN, }) ); } + #[test] + fn parses_nonzero_static_preview_root_count() { + let parsed = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--backend", + "static-vulkan", + "--preview-roots", + "2", + ])) + .expect("valid static preview arguments"); + assert_eq!( + parsed.preview_roots, + NonZeroUsize::new(2).expect("non-zero literal") + ); + } + + #[test] + fn rejects_zero_static_preview_root_count() { + let error = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--preview-roots", + "0", + ])); + assert_eq!( + error, + Err("--preview-roots must be a non-zero integer".to_string()) + ); + } + #[test] fn parses_load_progress_path() { assert_eq!( @@ -989,6 +1044,7 @@ mod tests { frames: 1, backend: RenderBackendMode::Planning, load_progress: Some(PathBuf::from("target/probe.txt")), + preview_roots: NonZeroUsize::MIN, }) ); } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 512ba9b..79a81c7 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -37,6 +37,7 @@ use fparkan_world::{ construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft, OriginalObjectId, World, WorldConfig, WorldSnapshot, }; +use std::num::NonZeroUsize; use std::sync::Arc; pub use fparkan_assets::MissionAssets; @@ -193,6 +194,8 @@ enum MissionAssetScope { Full, /// Prepare only the first mission root for a static preview. FirstMeshPreview, + /// Prepare a non-zero prefix of mission roots for a static preview. + PreviewRoots(NonZeroUsize), } /// Loaded mission. @@ -492,6 +495,46 @@ pub fn load_mission_static_preview( load_mission_static_preview_with_progress(engine, request, |_| {}) } +/// Loads a static preview for the first requested non-zero count of mission roots. +/// +/// Normal gameplay still uses [`load_mission`] and always resolves every root. +/// +/// # Errors +/// +/// Returns [`EngineError`] under the same conditions as +/// [`load_mission_static_preview`]. +pub fn load_mission_static_preview_roots( + engine: &mut Engine, + request: MissionRequest, + root_count: NonZeroUsize, +) -> Result { + load_mission_static_preview_roots_with_progress(engine, request, root_count, |_| {}) +} + +/// Loads selected static-preview roots while synchronously reporting phases. +/// +/// # Errors +/// +/// Returns [`EngineError`] under the same conditions as +/// [`load_mission_static_preview_roots`]. +pub fn load_mission_static_preview_roots_with_progress( + engine: &mut Engine, + request: MissionRequest, + root_count: NonZeroUsize, + mut on_phase: impl FnMut(MissionLoadPhase), +) -> Result { + load_mission_with_options_and_progress( + engine, + request, + MissionLoadOptions { + asset_scope: MissionAssetScope::PreviewRoots(root_count), + ..MissionLoadOptions::default() + }, + Some(&mut on_phase), + ) + .map(|(loaded, _trace)| loaded) +} + /// Loads a static preview while synchronously reporting entered loading phases. /// /// This has the same bounded asset scope as [`load_mission_static_preview`]. @@ -633,6 +676,9 @@ fn load_mission_with_options_and_progress( let scoped_graph_roots = match options.asset_scope { MissionAssetScope::Full => graph_roots.as_slice(), MissionAssetScope::FirstMeshPreview => graph_roots.get(..1).unwrap_or_default(), + MissionAssetScope::PreviewRoots(root_count) => graph_roots + .get(..root_count.get().min(graph_roots.len())) + .unwrap_or_default(), }; let (mut prototype_graph, resolved_prototypes, mut prototype_report) = build_prototype_graph_report(&repository, vfs.as_ref(), scoped_graph_roots); @@ -658,6 +704,12 @@ fn load_mission_with_options_and_progress( &prototype_graph.root_prototype_request_spans, &resolved_prototypes, ), + MissionAssetScope::PreviewRoots(root_count) => asset_manager.prepare_mission_assets( + &prototype_graph.root_prototype_request_spans[..root_count + .get() + .min(prototype_graph.root_prototype_request_spans.len())], + &resolved_prototypes, + ), } .map_err(|source| EngineError::AssetPreparation { mission: request.key.clone(), diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 96b7e52..7ce73a6 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -971,13 +971,12 @@ startup is not attributed to Vulkan. This is only a cross-corpus confirmation of the first-root static-preview bridge, not a claim that the two games' full mission renderers are compatible. -The preview now asks `load_mission_static_preview` for both graph and assets. -Normal mission loading remains full and transactional; preview graph traversal -and asset preparation are restricted to the first TMA root. It is therefore not -a hidden relaxation of gameplay validation. The preceding all-root preview -probe timed out at `Graph`; this reduction is what allowed the successful GOG -native run. If that first root has no usable MSH, static preview fails explicitly -rather than expanding later roots or pretending to render the entire mission. +The preview now has an explicit root-prefix contract. `--preview-roots N` +prepares the first non-zero `N` TMA roots for the opt-in static Vulkan path; +the default remains one root. Normal mission loading remains full and +transactional, so this is not a hidden relaxation of gameplay validation. The +previous all-root probe timed out at `Graph`; the prefix makes broader scene +work measurable without pretending that a bounded preview is the full game. ### Shared terrain/root diagnostic frame @@ -1002,6 +1001,22 @@ one terrain component, 15 descriptors, a 7,372,800-byte readback (FNV-1a from the earlier first-root-only preview is expected because the submitted geometry and descriptor set changed; it is not an original-frame comparison. +### Multiple static-preview roots + +The native bridge now combines every mesh-backed visual of each selected root, +using that root's preserved TMA `position` and `scale` in the shared diagnostic +XZ frame. It reports the actual selected root count as `preview_roots`, rather +than implying that all mission objects are rendered. Raw orientation, original +camera/frustum and visibility remain unresolved. + +Fresh canonical GOG `Autodemo.00` evidence with `--preview-roots 2` completed +in 59.7 seconds: 25 submitted MSH components, one terrain component and 26 +descriptors on the native 1280x720 two-image swapchain, with validation +warnings/errors `0/0`. The readback FNV-1a remained +`10739087367165646439`, equal to the one-root terrain/root run. That equality +does **not** prove the second root is visible or equivalent: it is recorded as +a camera/frustum/overlap investigation target, not hidden as a parity result. + ### Camera ownership boundary from the GOG renderer The GOG `World3D.dll` export `LoadCamera` at RVA `0x1FB06` is only an import