diff --git a/Cargo.lock b/Cargo.lock index d433c53..4ec6bca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,6 +453,7 @@ dependencies = [ "fparkan-runtime", "fparkan-vfs", "fparkan-world", + "winit", ] [[package]] diff --git a/README.md b/README.md index 029bec5..011eb88 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,12 @@ Open source проект с реализацией компонентов игр - [apps/fparkan-cli](apps/fparkan-cli) — CLI для архивов, графов и acceptance-отчетов. - [apps/fparkan-viewer](apps/fparkan-viewer) — inspection-only CLI для archive/model/texture/map без live Vulkan draw path. - [apps/fparkan-headless](apps/fparkan-headless) — headless runtime composition root. -- [apps/fparkan-game](apps/fparkan-game) — render-planning composition root; сейчас выдает planning report, а не живой отрисованный кадр. +- [apps/fparkan-game](apps/fparkan-game) — mission composition root: по умолчанию выдаёт planning report; opt-in `--backend static-vulkan` открывает native Vulkan окно для первой подготовленной MSH-модели миссии. ## Текущий статус рендера - `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry. -- `VulkanPlanningBackend` и `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. +- `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. Новый `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge без подтверждённого полного corpus run, текстур, transforms или pixel parity. - `fparkan-viewer` пока является инспектором ассетов. `fparkan-vulkan-smoke` имеет live Stage 3 bridge для original `MSH`/`Texm`/`WEAR`/`MAT0` и geometry-only `Land.msh`; полноценный viewer, исходные terrain-material states, camera и pixel parity ещё не закрыты. - Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence). diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml index b21f2ce..4d01c94 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -14,6 +14,7 @@ fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" } fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" } fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" } +winit = { version = "0.30", default-features = false, features = ["rwh_06"] } [lints] workspace = true diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index b199805..d8958f2 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -23,13 +23,16 @@ use fparkan_assets::PreparedVisual; use fparkan_platform::WindowPort; -use fparkan_platform_winit::WinitWindow; +use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; use fparkan_render::{ build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand, RenderCommandList, RenderPhase, RenderProfile, RenderSnapshot, RenderSnapshotDraw, }; -use fparkan_render_vulkan::VulkanPlanningBackend; +use fparkan_render_vulkan::{ + project_msh_to_static_mesh, VulkanPlanningBackend, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticMesh, +}; use fparkan_runtime::{ create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices, MissionAssets, MissionObjectDraft, MissionRequest, @@ -40,6 +43,11 @@ use fparkan_world::OriginalObjectId; use fparkan_world::WorldSnapshot; use std::path::PathBuf; use std::sync::Arc; +use winit::application::ApplicationHandler; +use winit::dpi::PhysicalSize as WinitPhysicalSize; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::window::{Window, WindowId}; fn main() { let raw_args = std::env::args().skip(1).collect::>(); @@ -74,6 +82,17 @@ fn run(args: &[String]) -> Result { ) .map_err(|err| err.to_string())?; + if args.backend == RenderBackendMode::StaticVulkan { + let mission_assets = loaded_mission_assets(&engine) + .ok_or_else(|| "mission assets are unavailable after loading".to_string())?; + let model = mission_assets.models.first().ok_or_else(|| { + "mission has no mesh-backed model for the static Vulkan bridge".to_string() + })?; + let mesh = project_msh_to_static_mesh(&model.validated) + .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; + return run_static_vulkan_mode(mesh, args.frames, &args.mission, loaded.object_count); + } + let mut backend = VulkanPlanningBackend::new(); let _request = WinitWindow::default_render_request(); let window = WinitWindow::synthetic(1280, 720); @@ -116,6 +135,212 @@ fn run(args: &[String]) -> Result { )) } +fn run_static_vulkan_mode( + mesh: VulkanStaticMesh, + 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, target_frames, mission, object_count); + if let Err(err) = event_loop.run_app(&mut app) { + app.error = Some(format!("winit event loop: {err}")); + } + app.finish() +} + +struct StaticVulkanApp { + mesh: VulkanStaticMesh, + target_frames: u64, + mission: String, + object_count: usize, + window_id: Option, + window: Option, + renderer: Option, + frames_presented: u64, + output: Option, + error: Option, +} + +impl StaticVulkanApp { + fn new(mesh: VulkanStaticMesh, target_frames: u64, mission: &str, object_count: usize) -> Self { + Self { + mesh, + target_frames, + mission: mission.to_string(), + object_count, + window_id: None, + window: None, + renderer: None, + frames_presented: 0, + output: None, + error: None, + } + } + + fn schedule_next_redraw(&self) { + if let Some(window) = self.window.as_ref() { + window.request_redraw(); + } + } + + fn complete(&mut self, event_loop: &ActiveEventLoop) { + let Some(renderer) = self.renderer.take() else { + self.error = Some("native Vulkan renderer was not initialized".to_string()); + event_loop.exit(); + return; + }; + let report = match renderer.shutdown() { + Ok(report) => report, + Err(err) => { + self.error = Some(err.to_string()); + event_loop.exit(); + return; + } + }; + self.window.take(); + if report.validation.warning_count != 0 || report.validation.error_count != 0 { + self.error = Some(format!( + "native Vulkan validation must stay clean (warnings={}, errors={})", + report.validation.warning_count, report.validation.error_count + )); + event_loop.exit(); + return; + } + self.output = Some(format!( + "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}", + json_string(&self.mission), + self.object_count, + self.frames_presented, + report.renderer_report.swapchain_extent.0, + report.renderer_report.swapchain_extent.1, + report.renderer_report.swapchain_image_count, + report.validation.warning_count, + report.validation.error_count, + report.renderer_report.readback_byte_count, + report.renderer_report.readback_fnv1a64, + )); + event_loop.exit(); + } + + fn finish(self) -> Result { + self.output.ok_or_else(|| { + self.error.unwrap_or_else(|| { + "native Vulkan mode exited before producing a report".to_string() + }) + }) + } +} + +impl ApplicationHandler for StaticVulkanApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let plan = match WinitWindowPlan::smoke().validate() { + Ok(plan) => plan, + Err(err) => { + self.error = Some(err.to_string()); + event_loop.exit(); + return; + } + }; + let attributes = Window::default_attributes() + .with_title("FParkan static mission Vulkan") + .with_inner_size(WinitPhysicalSize::new(plan.width, plan.height)); + let window = match event_loop.create_window(attributes) { + Ok(window) => window, + Err(err) => { + self.error = Some(format!("winit window: {err}")); + event_loop.exit(); + return; + } + }; + let Some(native_handles) = window_native_handles(&window) else { + self.error = Some("winit window does not expose native handles".to_string()); + event_loop.exit(); + return; + }; + let size = window.inner_size(); + let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { + application_name: "fparkan-game".to_string(), + native_handles, + drawable_extent: (size.width.max(1), size.height.max(1)), + render_request: WinitWindow::default_render_request(), + enable_validation: true, + mesh: self.mesh.clone(), + materials: Vec::new(), + bootstrap_progress: None, + }) { + Ok(renderer) => renderer, + Err(err) => { + self.error = Some(err.to_string()); + event_loop.exit(); + return; + } + }; + self.window_id = Some(window.id()); + self.renderer = Some(renderer); + self.window = Some(window); + self.schedule_next_redraw(); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + if Some(window_id) != self.window_id { + return; + } + match event { + WindowEvent::CloseRequested => { + self.error = Some("native Vulkan window closed before completion".to_string()); + event_loop.exit(); + } + WindowEvent::Resized(size) => { + if let Some(renderer) = self.renderer.as_mut() { + renderer.request_resize((size.width, size.height)); + } + } + WindowEvent::RedrawRequested => { + let Some(renderer) = self.renderer.as_mut() else { + self.error = Some("native Vulkan renderer was not initialized".to_string()); + event_loop.exit(); + return; + }; + match renderer.draw_frame() { + Ok(VulkanSmokeFrameOutcome::Presented) => { + self.frames_presented = self.frames_presented.saturating_add(1); + } + Ok( + VulkanSmokeFrameOutcome::Recreated | VulkanSmokeFrameOutcome::ZeroExtent, + ) => {} + Err(err) => { + self.error = Some(err.to_string()); + event_loop.exit(); + return; + } + } + if self.frames_presented >= self.target_frames { + self.complete(event_loop); + } else { + self.schedule_next_redraw(); + } + } + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if self.output.is_none() && self.error.is_none() { + self.schedule_next_redraw(); + } + } +} + #[cfg(test)] fn render_snapshot_commands(snapshot: &WorldSnapshot) -> Result { render_snapshot_commands_with_assets(snapshot, None, None) @@ -229,6 +454,13 @@ struct Args { root: PathBuf, mission: String, frames: u64, + backend: RenderBackendMode, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RenderBackendMode { + Planning, + StaticVulkan, } impl Args { @@ -236,6 +468,7 @@ impl Args { let mut root = None; let mut mission = None; let mut frames = 1; + let mut backend = RenderBackendMode::Planning; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { @@ -260,6 +493,17 @@ impl Args { .parse() .map_err(|_| "--frames must be an integer".to_string())?; } + "--backend" => { + backend = match iter + .next() + .ok_or_else(|| "--backend requires a value".to_string())? + .as_str() + { + "planning" => RenderBackendMode::Planning, + "static-vulkan" => RenderBackendMode::StaticVulkan, + _ => return Err("--backend must be planning or static-vulkan".to_string()), + }; + } _ => return Err(usage()), } } @@ -272,6 +516,7 @@ impl Args { root, mission, frames, + backend, }) } } @@ -308,7 +553,7 @@ fn json_hash(hash: &[u8; 32]) -> String { } fn usage() -> String { - "usage: fparkan-game --root --mission [--frames ]".to_string() + "usage: fparkan-game --root --mission [--frames ] [--backend ]".to_string() } #[cfg(test)] @@ -337,6 +582,27 @@ mod tests { root: PathBuf::from("testdata/IS"), mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 3, + backend: RenderBackendMode::Planning, + }) + ); + } + + #[test] + fn parses_static_vulkan_backend() { + assert_eq!( + Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--backend", + "static-vulkan", + ])), + Ok(Args { + root: PathBuf::from("testdata/IS"), + mission: "MISSIONS/Autodemo.00/data.tma".to_string(), + frames: 1, + backend: RenderBackendMode::StaticVulkan, }) ); } diff --git a/docs/rendering/renderer_truth_table.md b/docs/rendering/renderer_truth_table.md index f2674b2..26b113a 100644 --- a/docs/rendering/renderer_truth_table.md +++ b/docs/rendering/renderer_truth_table.md @@ -13,6 +13,7 @@ | `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels | | `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels | | `VulkanAssetRenderer` | Yes | Yes | Yes | `covered-gpu` | Static original asset rendering: MSH/Texm/WEAR/MAT0/terrain through Vulkan | Animation/FX parity unless explicitly wired | +| `fparkan-game --backend static-vulkan` | Implemented; native run not yet accepted on a full corpus mission | Implemented static MSH draw | First prepared MSH from loaded mission assets | Not yet `covered-gpu` | Opt-in mission-to-native-window bootstrap, static MSH projection and synchronized teardown telemetry | Full mission scene, texture/material binding, placed transforms/orientation, camera, gameplay, original-runtime parity | | Future rendered `fparkan-game` mode | Yes | Yes | Yes | `covered-gpu` plus original-evidence IDs | Mission-driven render snapshot execution and pixel capture | Original-runtime parity for animation/FX/x87 without dedicated captures | ## Rules @@ -31,8 +32,13 @@ - Реальный Vulkan в репозитории имеет smoke triangle path и узкий static asset bridge. `VulkanStaticDrawRange` сохраняет исходный `Batch20.material_index`; когда smoke запускается с `--wear-root`, `--wear-archive`, `--wear-name` без override, он дедуплицирует selectors, проходит каждый через `WEAR → MAT0 → Textures.lib`, создаёт по одному image/descriptor set и бинит set непосредственно перед соответствующим indexed draw. Direct TEXM и `--material-index` — намеренно однотекстурные compatibility modes. Fresh GOG `fortif.rlb::FR_L_MTP.msh` подтверждает 237 batch draws, но его selectors все `0`, поэтому live report содержит один binding `MTP_01.0`; unit contract подтверждает точное сопоставление двух разных selectors с разными descriptor sets. Это не доказывает material phase animation, lightmaps, alpha/depth/cull state, terrain, camera/node transforms или pixel approval. - Lightmap остаётся отдельным, не реализованным contract: оригинальный `World3D.dll` экспортирует самостоятельный `SetLightMapLib` наряду с `SetTexturesLib` и `SetMaterialLib`; WEAR содержит независимый блок `LIGHTMAPS`. Текущая документация не подтверждает связь этих slots с `Batch20.material_index` или их UV/channel semantics, поэтому viewer не подменяет lightmap diffuse texture и не добавляет недоказанное binding. -- `apps/fparkan-game` сейчас выдает `render-planning` JSON report поверх - synthetic window descriptor и `VulkanPlanningBackend`. +- `apps/fparkan-game` по умолчанию выдает `render-planning` JSON report поверх + synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan` + уже создаёт native `winit` window и передаёт первую подготовленную MSH модели миссии в + `VulkanSmokeRenderer` с пустым списком materials (явный white-fallback). Это не является + rendered acceptance: fresh GOG `MISSIONS/Autodemo.00/data.tma` test не дошёл до окна за + локальный 120-second runner limit, поэтому нет live report, pixel artifact или validation + evidence именно для game path. - `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan asset viewer. - Следующий реальный milestone для rendered acceptance: `VulkanAssetRenderer` diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 44ffac7..be60d30 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -934,6 +934,23 @@ visual component emitted for that object. The bridge deliberately leaves the value absent when no mission draft exists; it does not infer an original ID from a runtime slot or draw order. +### Opt-in mission static-Vulkan bridge + +`fparkan-game --backend static-vulkan` is an explicit native-window experiment, +not the default planning path. After mission loading it selects the first +prepared MSH model, sends it through the existing static XZ clip-space +projection and renders a requested number of frames through +`VulkanSmokeRenderer`; teardown rejects validation warnings/errors and reports +swapchain/readback telemetry. It deliberately supplies no material textures, +so the renderer's documented white fallback is used. + +This is a narrow bootstrap from mission assets to a live Vulkan renderer. It +does not render every placed object, apply TMA transforms or orientation, bind +WEAR/MAT0/TEXM material data, or establish a game camera. A GOG +`MISSIONS/Autodemo.00/data.tma` attempt exceeded the local 120-second runner +limit before the window/report, so this mode has implementation and unit/lint +evidence only, not a corpus GPU acceptance claim. + `Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28` сохраняет source triangle order, а его positions и packed UV0 попадают в тот же static vertex/index upload path. Для текущего диагностического viewer XZ bounds