From 283b48e8c512dd251d4617cc988985e3be2a0c5e Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 07:21:29 +0400 Subject: [PATCH] feat(render): draw original MSH through Vulkan --- Cargo.lock | 2 + adapters/fparkan-render-vulkan/Cargo.toml | 1 + .../fparkan-render-vulkan/src/asset_mesh.rs | 161 +++++++++++++++++ adapters/fparkan-render-vulkan/src/ffi.rs | 3 + .../src/ffi/swapchain_resources.rs | 4 +- apps/fparkan-vulkan-smoke/Cargo.toml | 1 + apps/fparkan-vulkan-smoke/src/main.rs | 167 +++++++++++++++++- docs/rendering/renderer_truth_table.md | 4 +- fixtures/acceptance/coverage.tsv | 6 +- 9 files changed, 338 insertions(+), 11 deletions(-) create mode 100644 adapters/fparkan-render-vulkan/src/asset_mesh.rs diff --git a/Cargo.lock b/Cargo.lock index 0d7afee..a8b06f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,6 +561,7 @@ dependencies = [ "ash", "ash-window", "fparkan-binary", + "fparkan-msh", "fparkan-platform", "fparkan-render", "serde", @@ -650,6 +651,7 @@ dependencies = [ name = "fparkan-vulkan-smoke" version = "0.1.0" dependencies = [ + "fparkan-inspection", "fparkan-platform", "fparkan-platform-winit", "fparkan-render-vulkan", diff --git a/adapters/fparkan-render-vulkan/Cargo.toml b/adapters/fparkan-render-vulkan/Cargo.toml index bb7dbd0..909ac1f 100644 --- a/adapters/fparkan-render-vulkan/Cargo.toml +++ b/adapters/fparkan-render-vulkan/Cargo.toml @@ -10,6 +10,7 @@ build = "build.rs" ash = "0.38" ash-window = "0.13" fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" } +fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" } fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" } fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" } serde = { version = "1.0", features = ["derive"] } diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs new file mode 100644 index 0000000..8e45f08 --- /dev/null +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -0,0 +1,161 @@ +//! Format-to-GPU geometry bridge for the initial static asset renderer. + +use crate::{VulkanStaticMesh, VulkanStaticVertex}; +use fparkan_msh::ModelAsset; + +/// Error returned when a validated MSH cannot enter the current static GPU path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VulkanAssetMeshError { + /// The model has no triangle batches. + EmptyGeometry, + /// A position used by a batch is non-finite. + NonFinitePosition, + /// The view-plane extent is zero or otherwise not usable for normalization. + DegenerateViewExtent, + /// The indexed model exceeds the current 16-bit Vulkan input contract. + IndexOutOfRange, +} + +impl std::fmt::Display for VulkanAssetMeshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyGeometry => write!(f, "MSH contains no triangle geometry"), + Self::NonFinitePosition => write!(f, "MSH contains a non-finite position"), + Self::DegenerateViewExtent => write!(f, "MSH has a degenerate XZ view extent"), + Self::IndexOutOfRange => write!(f, "MSH index exceeds the static Vulkan u16 contract"), + } + } +} + +impl std::error::Error for VulkanAssetMeshError {} + +/// Projects validated MSH geometry into the current static Vulkan clip-space path. +/// +/// The original engine's node transforms, camera and material pipeline are not +/// substituted here. This bridge is intentionally a static asset-viewer step: +/// it preserves every batch's `index_start`, `index_count` and `base_vertex`, +/// projects the conventional `Iron3D` XZ ground plane into the current XY shader +/// input, and scales the used bounds uniformly into the visible clip rectangle. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] if the source cannot be represented by the +/// current 16-bit, triangle-list viewer input. +pub fn project_msh_to_static_mesh( + model: &ModelAsset, +) -> Result { + let mut indices = Vec::new(); + for batch in &model.batches { + let start = usize::try_from(batch.index_start) + .map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?; + let end = start + .checked_add(usize::from(batch.index_count)) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?; + for &raw_index in model + .indices + .get(start..end) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)? + { + let index = batch + .base_vertex + .checked_add(u32::from(raw_index)) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?; + indices.push(u16::try_from(index).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?); + } + } + if indices.is_empty() { + return Err(VulkanAssetMeshError::EmptyGeometry); + } + + let mut min_x = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut min_z = f32::INFINITY; + let mut max_z = f32::NEG_INFINITY; + for &index in &indices { + let position = model + .positions + .get(usize::from(index)) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?; + if !position.iter().all(|value| value.is_finite()) { + return Err(VulkanAssetMeshError::NonFinitePosition); + } + min_x = min_x.min(position[0]); + max_x = max_x.max(position[0]); + min_z = min_z.min(position[2]); + max_z = max_z.max(position[2]); + } + let extent = (max_x - min_x).max(max_z - min_z); + if !extent.is_finite() || extent <= f32::EPSILON { + return Err(VulkanAssetMeshError::DegenerateViewExtent); + } + let center_x = (min_x + max_x) * 0.5; + let center_z = (min_z + max_z) * 0.5; + let scale = 1.6 / extent; + let vertices = model + .positions + .iter() + .map(|position| VulkanStaticVertex { + position: [ + (position[0] - center_x) * scale, + (position[2] - center_z) * scale, + ], + color: [0.82, 0.72, 0.31], + }) + .collect(); + + Ok(VulkanStaticMesh { vertices, indices }) +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_msh::{Batch, ModelAsset}; + + fn model(positions: Vec<[f32; 3]>, indices: Vec, batches: Vec) -> ModelAsset { + ModelAsset { + node_stride: 0, + node_count: 0, + nodes_raw: Vec::new(), + slots: Vec::new(), + positions, + normals: None, + uv0: None, + indices, + batches, + node_names: None, + } + } + + fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> Batch { + Batch { + batch_flags: 0, + material_index: 0, + opaque4: 0, + opaque6: 0, + index_count, + index_start, + opaque14: 0, + base_vertex, + } + } + + #[test] + fn projects_xz_geometry_and_applies_base_vertex() { + let mesh = project_msh_to_static_mesh(&model( + vec![ + [99.0, 0.0, 99.0], + [-2.0, 4.0, -1.0], + [2.0, 8.0, -1.0], + [-2.0, 1.0, 3.0], + ], + vec![0, 1, 2], + vec![batch(0, 3, 1)], + )) + .expect("representable MSH"); + + assert_eq!(mesh.indices, vec![1, 2, 3]); + assert_eq!(mesh.vertices[1].position, [-0.8, -0.8]); + assert_eq!(mesh.vertices[2].position, [0.8, -0.8]); + assert_eq!(mesh.vertices[3].position, [-0.8, 0.8]); + } +} diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 0b6f1f2..9433759 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -26,6 +26,8 @@ //! //! This crate is the declared low-level Vulkan boundary. +#[path = "asset_mesh.rs"] +mod asset_mesh; mod capabilities; mod instance; mod resources; @@ -37,6 +39,7 @@ mod swapchain; mod swapchain_resources; mod validation; +pub use self::asset_mesh::{project_msh_to_static_mesh, VulkanAssetMeshError}; pub use self::capabilities::{ probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe, diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index 41dfc0f..dd68ef4 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -337,7 +337,9 @@ fn create_graphics_pipeline( .rasterizer_discard_enable(false) .polygon_mode(vk::PolygonMode::FILL) .line_width(1.0) - .cull_mode(vk::CullModeFlags::BACK) + // Static MSH viewer mode has no original material/cull state yet; draw + // both windings so every source batch is visible during the bridge. + .cull_mode(vk::CullModeFlags::NONE) .front_face(vk::FrontFace::CLOCKWISE) .depth_bias_enable(false); let multisample_state = vk::PipelineMultisampleStateCreateInfo::default() diff --git a/apps/fparkan-vulkan-smoke/Cargo.toml b/apps/fparkan-vulkan-smoke/Cargo.toml index ca70d33..4492c66 100644 --- a/apps/fparkan-vulkan-smoke/Cargo.toml +++ b/apps/fparkan-vulkan-smoke/Cargo.toml @@ -10,6 +10,7 @@ build = "build.rs" fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" } fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" } +fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" winit = { version = "0.30", default-features = false, features = ["rwh_06"] } diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index 88d7944..90c59c9 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -14,9 +14,9 @@ use fparkan_platform::RenderRequest; use fparkan_platform_winit::{window_native_handles, WinitWindowPlan}; use fparkan_render_vulkan::{ - VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, - VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, - VulkanStaticMesh, VulkanValidationReport, + project_msh_to_static_mesh, VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, + VulkanSmokeShutdownReport, VulkanStaticMesh, VulkanValidationReport, }; use serde::Serialize; use std::path::PathBuf; @@ -55,6 +55,7 @@ fn main() { fn run(args: &[String]) -> Result { let options = SmokeOptions::parse(args)?; + let mesh = options.load_mesh()?; remove_stale_output(&options)?; let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); @@ -65,7 +66,7 @@ fn run(args: &[String]) -> Result { Arc::clone(&completed), Arc::clone(&progress), ); - let mut app = SmokeApp::new(options, completed, progress); + let mut app = SmokeApp::new(options, mesh, completed, progress); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); } @@ -110,6 +111,40 @@ struct SmokeOptions { frames: u32, resize_frame: u32, timeout_seconds: u64, + mesh_input: MeshInput, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum MeshInput { + SmokeTriangle, + Msh { + root: PathBuf, + archive: String, + name: String, + }, +} + +impl MeshInput { + fn kind(&self) -> &'static str { + match self { + Self::SmokeTriangle => "synthetic-smoke-triangle", + Self::Msh { .. } => "original-msh", + } + } + + fn archive(&self) -> &str { + match self { + Self::SmokeTriangle => "", + Self::Msh { archive, .. } => archive, + } + } + + fn name(&self) -> &str { + match self { + Self::SmokeTriangle => "", + Self::Msh { name, .. } => name, + } + } } impl SmokeOptions { @@ -118,6 +153,9 @@ impl SmokeOptions { let mut frames = DEFAULT_TARGET_FRAMES; let mut resize_frame = DEFAULT_RESIZE_FRAME; let mut timeout_seconds = DEFAULT_TIMEOUT_SECONDS; + let mut model_root = None; + let mut model_archive = None; + let mut model_name = None; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { @@ -149,6 +187,27 @@ impl SmokeOptions { .parse() .map_err(|_| "--timeout-seconds must be an integer".to_string())?; } + "--model-root" => { + model_root = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--model-root requires a path".to_string())?, + ); + } + "--model-archive" => { + model_archive = Some( + iter.next() + .cloned() + .ok_or_else(|| "--model-archive requires a value".to_string())?, + ); + } + "--model-name" => { + model_name = Some( + iter.next() + .cloned() + .ok_or_else(|| "--model-name requires a value".to_string())?, + ); + } _ => return Err(format!("unknown native smoke option: {arg}")), } } @@ -161,17 +220,47 @@ impl SmokeOptions { if timeout_seconds == 0 { return Err("native smoke requires --timeout-seconds >= 1".to_string()); } + let mesh_input = match (model_root, model_archive, model_name) { + (None, None, None) => MeshInput::SmokeTriangle, + (Some(root), Some(archive), Some(name)) => MeshInput::Msh { + root, + archive, + name, + }, + _ => { + return Err( + "--model-root, --model-archive and --model-name must be supplied together" + .to_string(), + ); + } + }; Ok(Self { out, frames, resize_frame, timeout_seconds, + mesh_input, }) } + + fn load_mesh(&self) -> Result { + match &self.mesh_input { + MeshInput::SmokeTriangle => Ok(VulkanStaticMesh::smoke_triangle()), + MeshInput::Msh { + root, + archive, + name, + } => { + let model = fparkan_inspection::load_model_from_root(root, archive, name)?; + project_msh_to_static_mesh(&model).map_err(|err| err.to_string()) + } + } + } } struct SmokeApp { options: SmokeOptions, + mesh: VulkanStaticMesh, completed: Arc, progress: Arc, window_id: Option, @@ -215,11 +304,13 @@ impl From for RendererSnapshot { impl SmokeApp { fn new( options: SmokeOptions, + mesh: VulkanStaticMesh, completed: Arc, progress: Arc, ) -> Self { Self { options, + mesh, completed, progress, window_id: None, @@ -278,6 +369,7 @@ impl SmokeApp { .or_else(|| self.live_renderer_snapshot()) } + #[allow(clippy::too_many_lines)] fn render_report( &self, status: &'static str, @@ -310,6 +402,11 @@ impl SmokeApp { validation_vuids: &validation.vuids, requested_frames: self.options.frames, timeout_seconds: self.options.timeout_seconds, + mesh_source: self.options.mesh_input.kind(), + mesh_archive: self.options.mesh_input.archive(), + mesh_name: self.options.mesh_input.name(), + mesh_vertex_count: self.mesh.vertices.len(), + mesh_index_count: self.mesh.indices.len(), shader_manifest_hash: renderer .as_ref() .map_or("", |snapshot| snapshot.report.shader_manifest_hash.as_str()), @@ -509,6 +606,11 @@ fn render_timeout_failure_report( validation_vuids: &[], requested_frames: options.frames, timeout_seconds: options.timeout_seconds, + mesh_source: options.mesh_input.kind(), + mesh_archive: options.mesh_input.archive(), + mesh_name: options.mesh_input.name(), + mesh_vertex_count: 0, + mesh_index_count: 0, shader_manifest_hash: "", vulkan_loader_status: if bootstrap.loader_available { "available" @@ -603,7 +705,7 @@ impl ApplicationHandler for SmokeApp { drawable_extent: (size.width.max(1), size.height.max(1)), render_request: RenderRequest::conservative(), enable_validation: true, - mesh: VulkanStaticMesh::smoke_triangle(), + mesh: self.mesh.clone(), bootstrap_progress: Some(Arc::clone(&self.progress.bootstrap)), }) { Ok(renderer) => renderer, @@ -731,6 +833,11 @@ struct SmokeReport<'a> { validation_vuids: &'a [String], requested_frames: u32, timeout_seconds: u64, + mesh_source: &'a str, + mesh_archive: &'a str, + mesh_name: &'a str, + mesh_vertex_count: usize, + mesh_index_count: usize, shader_manifest_hash: &'a str, vulkan_loader_status: &'a str, vulkan_instance_status: &'a str, @@ -972,6 +1079,7 @@ mod tests { frames: DEFAULT_TARGET_FRAMES, resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, + mesh_input: MeshInput::SmokeTriangle, }) ); } @@ -1007,6 +1115,7 @@ mod tests { frames: DEFAULT_TARGET_FRAMES, resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: 45, + mesh_input: MeshInput::SmokeTriangle, }) ); } @@ -1026,6 +1135,46 @@ mod tests { ); } + #[test] + fn parses_original_msh_input_as_one_atomic_request() { + let parsed = SmokeOptions::parse(&[ + "--out".to_string(), + "target/report.json".to_string(), + "--model-root".to_string(), + "C:/GOG".to_string(), + "--model-archive".to_string(), + "system.rlb".to_string(), + "--model-name".to_string(), + "MTCHECK.MSH".to_string(), + ]); + + assert!(matches!( + parsed, + Ok(SmokeOptions { + mesh_input: MeshInput::Msh { archive, name, .. }, + .. + }) if archive == "system.rlb" && name == "MTCHECK.MSH" + )); + } + + #[test] + fn rejects_partial_original_msh_input() { + let parsed = SmokeOptions::parse(&[ + "--out".to_string(), + "target/report.json".to_string(), + "--model-root".to_string(), + "C:/GOG".to_string(), + ]); + + assert_eq!( + parsed, + Err( + "--model-root, --model-archive and --model-name must be supplied together" + .to_string() + ) + ); + } + #[test] fn rejects_deprecated_self_assertion_flags() { for flag in [ @@ -1085,6 +1234,11 @@ mod tests { validation_vuids: &["VUID-A".to_string(), "VUID-B".to_string()], requested_frames: 300, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, + mesh_source: "original-msh", + mesh_archive: "system.rlb", + mesh_name: "MTCHECK.MSH", + mesh_vertex_count: 128, + mesh_index_count: 252, shader_manifest_hash: "deadbeef", vulkan_loader_status: "available", vulkan_instance_status: "created", @@ -1109,6 +1263,7 @@ mod tests { assert!(json.contains("\"validation_vuids\": [")); assert!(json.contains("\"vulkan_device_name\": \"Windows test GPU\"")); assert!(json.contains("\"runner_architecture\": \"x86_64\"")); + assert!(json.contains("\"mesh_source\": \"original-msh\"")); } #[test] @@ -1130,7 +1285,9 @@ mod tests { frames: DEFAULT_TARGET_FRAMES, resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: 7, + mesh_input: MeshInput::SmokeTriangle, }, + mesh: VulkanStaticMesh::smoke_triangle(), completed: Arc::new(AtomicBool::new(false)), progress: Arc::new(SharedSmokeProgress::default()), window_id: None, diff --git a/docs/rendering/renderer_truth_table.md b/docs/rendering/renderer_truth_table.md index 6969a81..9de5f71 100644 --- a/docs/rendering/renderer_truth_table.md +++ b/docs/rendering/renderer_truth_table.md @@ -8,7 +8,7 @@ | Path | Native window / swapchain | Draws pixels | Uses original assets | Acceptance class | Что доказывает | Чего не доказывает | | --- | --- | --- | --- | --- | --- | --- | -| `fparkan-vulkan-smoke` / `VulkanSmokeRenderer` | Yes | Yes | No | `covered-gpu` for Stage 0 smoke-only IDs | Loader, instance, surface, swapchain, submit/present, validation-clean triangle path | Model upload, texture sampling, descriptors, terrain, gameplay rendering | +| `fparkan-vulkan-smoke` / `VulkanSmokeRenderer` | Yes | Yes | Static MSH only | `covered-gpu` for Stage 0 smoke and explicit MSH bridge IDs | Loader, instance, surface, swapchain, submit/present, validation-clean triangle path; original MSH vertex/index upload and indexed draw | Texture sampling, descriptors, terrain, gameplay rendering | | `VulkanPlanningBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Deterministic command validation, canonical capture, frame submission planning | Любой live GPU draw, pixel parity, validation-clean asset frame | | `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 | @@ -29,7 +29,7 @@ ## Current repository status -- Реальный Vulkan в репозитории есть, но только как smoke triangle path. +- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static-MSH bridge: `fparkan-vulkan-smoke --model-root --model-archive system.rlb --model-name MTCHECK.MSH` загружает validated original MSH, разворачивает batch `base_vertex`, нормализует XZ viewer plane и выполняет real indexed draw. Это не является texture/material/terrain renderer. - `apps/fparkan-game` сейчас выдает `render-planning` JSON report поверх synthetic window descriptor и `VulkanPlanningBackend`. - `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 7c80e1b..ba080ed 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -294,14 +294,14 @@ S2-PLANNING-RENDER-006 covered cargo test -p fparkan-render --offline invalid_ra S2-PLANNING-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable S2-PLANNING-RENDER-008 covered-planning cargo test -p fparkan-render --offline recording_backend_stores_captures S2-PLANNING-RENDER-009 covered cargo xtask policy -S3-VK-MESH-UPLOAD-001 blocked awaits Stage 3 Vulkan asset renderer and GPU upload path +S3-VK-MESH-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH; original MSH reports 128 vertices and 252 indices S3-VK-TEXM-UPLOAD-001 blocked awaits Stage 3 Vulkan texture upload path S3-VK-DESCRIPTOR-001 blocked awaits Stage 3 descriptor set contract implementation S3-VK-PIPELINE-001 blocked awaits Stage 3 graphics pipeline key and cache implementation -S3-VK-DRAW-MODEL-001 blocked awaits Stage 3 real Vulkan indexed model draw path +S3-VK-DRAW-MODEL-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH; live Win32 Vulkan indexed MSH draw/present S3-VK-DRAW-TERRAIN-001 blocked awaits Stage 3 terrain Vulkan draw path S3-VK-PIXEL-CAPTURE-001 blocked awaits Stage 3 fixed-camera pixel capture approval flow -S3-VK-VALIDATION-001 blocked awaits Stage 3 validation-clean GPU frame execution +S3-VK-VALIDATION-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH; validation_warning_count=0 and validation_error_count=0 S3-GL-001 omitted legacy OpenGL adapters are outside the Windows-only Vulkan renderer scope S3-GL-002 omitted GLES2 and portable-device targets are outside the Windows-only Vulkan renderer scope S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend