2 Commits
Author SHA1 Message Date
Valentin Popov 3633c91a9b docs(render): record part preview probes
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m6s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:59:27 +04:00
Valentin Popov c8dd5f989f feat(game): bind preview diffuse materials 2026-07-18 09:52:20 +04:00
5 changed files with 138 additions and 25 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ Open source проект с реализацией компонентов игр
## Текущий статус рендера ## Текущий статус рендера
- `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry. - `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry.
- `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. Новый `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge без подтверждённого полного corpus run, текстур, transforms или pixel parity. - `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge: для первого root он загружает первую MAT0 diffuse texture на каждый используемый MSH selector; полный corpus, material phases, 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 ещё не закрыты. - `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). - Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence).
+98 -12
View File
@@ -21,7 +21,7 @@
#![allow(clippy::print_stderr, clippy::print_stdout)] #![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` render-planning composition root. //! `FParkan` render-planning composition root.
use fparkan_assets::PreparedVisual; use fparkan_assets::{PreparedTextureUsage, PreparedVisual};
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::{
@@ -31,7 +31,8 @@ use fparkan_render::{
}; };
use fparkan_render_vulkan::{ use fparkan_render_vulkan::{
project_msh_to_static_mesh, VulkanPlanningBackend, VulkanSmokeFrameOutcome, project_msh_to_static_mesh, VulkanPlanningBackend, VulkanSmokeFrameOutcome,
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticMesh, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticMaterial, VulkanStaticMesh,
VulkanStaticTexture,
}; };
use fparkan_runtime::{ use fparkan_runtime::{
create, frame, load_mission, load_mission_static_preview, create, frame, load_mission, load_mission_static_preview,
@@ -81,12 +82,14 @@ fn run(args: &[String]) -> Result<String, String> {
if args.backend == RenderBackendMode::StaticVulkan { if args.backend == RenderBackendMode::StaticVulkan {
let mission_assets = loaded_mission_assets(&engine) let mission_assets = loaded_mission_assets(&engine)
.ok_or_else(|| "mission assets are unavailable after loading".to_string())?; .ok_or_else(|| "mission assets are unavailable after loading".to_string())?;
let model = mission_assets.models.first().ok_or_else(|| { let (mesh, materials) = static_preview_mesh_and_materials(mission_assets)?;
"mission has no mesh-backed model for the static Vulkan bridge".to_string() return run_static_vulkan_mode(
})?; mesh,
let mesh = project_msh_to_static_mesh(&model.validated) materials,
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; args.frames,
return run_static_vulkan_mode(mesh, args.frames, &args.mission, loaded.object_count); &args.mission,
loaded.object_count,
);
} }
let mut backend = VulkanPlanningBackend::new(); let mut backend = VulkanPlanningBackend::new();
@@ -174,6 +177,79 @@ fn load_requested_mission(
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())
} }
/// Projects the explicitly bounded static-preview root into geometry and its
/// selector-keyed diffuse material descriptors.
///
/// This intentionally takes only the first MAT0 texture request for each MSH
/// batch selector. Later material phases, animation, lightmaps, transforms,
/// and gameplay visibility remain outside this preview bridge.
fn static_preview_mesh_and_materials(
assets: &MissionAssets,
) -> Result<(VulkanStaticMesh, Vec<VulkanStaticMaterial>), String> {
let model = 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}"))?;
let visual = assets
.visuals
.iter()
.find(|visual| visual.model_id == Some(model.id))
.ok_or_else(|| "static preview model has no prepared visual".to_string())?;
let mut selectors = mesh
.draw_ranges
.iter()
.map(|range| range.material_index)
.collect::<Vec<_>>();
selectors.sort_unstable();
selectors.dedup();
let materials = selectors
.into_iter()
.map(|material_index| {
let material_id = visual.material_ids.get(usize::from(material_index)).ok_or_else(|| {
format!(
"static preview MSH batch selector {material_index} has no prepared WEAR material"
)
})?;
let material = assets.material_by_id(*material_id).ok_or_else(|| {
format!(
"static preview prepared material {material_index} is unavailable"
)
})?;
let texture_name = material.texture_requests.first().ok_or_else(|| {
format!("static preview material {material_index} has no MAT0 diffuse texture")
})?;
let texture = assets
.textures
.iter()
.find(|texture| {
texture.usage == PreparedTextureUsage::Diffuse
&& texture.source.name == *texture_name
})
.ok_or_else(|| {
format!(
"static preview diffuse texture {texture_name:?} for material {material_index} is unavailable"
)
})?;
let image = texture.decode_mip_rgba8(0).map_err(|err| {
format!(
"decode static preview diffuse texture {texture_name:?} for material {material_index}: {err}"
)
})?;
Ok(VulkanStaticMaterial {
material_index,
texture: VulkanStaticTexture {
width: image.width,
height: image.height,
rgba8: image.rgba8,
},
})
})
.collect::<Result<Vec<_>, String>>()?;
Ok((mesh, materials))
}
fn prepare_load_progress_path(path: &std::path::Path) -> Result<(), String> { fn prepare_load_progress_path(path: &std::path::Path) -> Result<(), String> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
@@ -187,13 +263,14 @@ fn write_load_progress(path: &std::path::Path, phase: MissionLoadPhase) -> Resul
fn run_static_vulkan_mode( fn run_static_vulkan_mode(
mesh: VulkanStaticMesh, mesh: VulkanStaticMesh,
materials: Vec<VulkanStaticMaterial>,
target_frames: u64, target_frames: u64,
mission: &str, mission: &str,
object_count: usize, object_count: usize,
) -> Result<String, String> { ) -> Result<String, String> {
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
event_loop.set_control_flow(ControlFlow::Poll); event_loop.set_control_flow(ControlFlow::Poll);
let mut app = StaticVulkanApp::new(mesh, target_frames, mission, object_count); let mut app = StaticVulkanApp::new(mesh, materials, target_frames, mission, object_count);
if let Err(err) = event_loop.run_app(&mut app) { if let Err(err) = event_loop.run_app(&mut app) {
app.error = Some(format!("winit event loop: {err}")); app.error = Some(format!("winit event loop: {err}"));
} }
@@ -202,6 +279,7 @@ fn run_static_vulkan_mode(
struct StaticVulkanApp { struct StaticVulkanApp {
mesh: VulkanStaticMesh, mesh: VulkanStaticMesh,
materials: Vec<VulkanStaticMaterial>,
target_frames: u64, target_frames: u64,
mission: String, mission: String,
object_count: usize, object_count: usize,
@@ -214,9 +292,16 @@ struct StaticVulkanApp {
} }
impl StaticVulkanApp { impl StaticVulkanApp {
fn new(mesh: VulkanStaticMesh, target_frames: u64, mission: &str, object_count: usize) -> Self { fn new(
mesh: VulkanStaticMesh,
materials: Vec<VulkanStaticMaterial>,
target_frames: u64,
mission: &str,
object_count: usize,
) -> Self {
Self { Self {
mesh, mesh,
materials,
target_frames, target_frames,
mission: mission.to_string(), mission: mission.to_string(),
object_count, object_count,
@@ -259,10 +344,11 @@ impl StaticVulkanApp {
return; return;
} }
self.output = Some(format!( 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\":{}}}", "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}",
json_string(&self.mission), json_string(&self.mission),
self.object_count, self.object_count,
self.frames_presented, self.frames_presented,
self.materials.len(),
report.renderer_report.swapchain_extent.0, report.renderer_report.swapchain_extent.0,
report.renderer_report.swapchain_extent.1, report.renderer_report.swapchain_extent.1,
report.renderer_report.swapchain_image_count, report.renderer_report.swapchain_image_count,
@@ -320,7 +406,7 @@ impl ApplicationHandler for StaticVulkanApp {
render_request: WinitWindow::default_render_request(), render_request: WinitWindow::default_render_request(),
enable_validation: true, enable_validation: true,
mesh: self.mesh.clone(), mesh: self.mesh.clone(),
materials: Vec::new(), materials: self.materials.clone(),
bootstrap_progress: None, bootstrap_progress: None,
}) { }) {
Ok(renderer) => renderer, Ok(renderer) => renderer,
+13 -1
View File
@@ -39,7 +39,7 @@ use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
pub use fparkan_terrain::{TerrainError, TerrainWorld}; pub use fparkan_terrain::{TerrainError, TerrainWorld};
use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh}; use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError}; pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
use fparkan_texm::{decode_texm, TexmDocument, TexmError}; use fparkan_texm::{decode_mip_rgba8, decode_texm, RgbaImage, TexmDocument, TexmError};
use std::collections::{hash_map::Entry, HashMap, HashSet}; use std::collections::{hash_map::Entry, HashMap, HashSet};
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
@@ -307,6 +307,18 @@ pub struct PreparedTexture {
pub usage: PreparedTextureUsage, pub usage: PreparedTextureUsage,
} }
impl PreparedTexture {
/// Decodes one prepared texture mip into the renderer upload representation.
///
/// # Errors
///
/// Returns the original TEXM decode error when the requested mip is invalid
/// or malformed.
pub fn decode_mip_rgba8(&self, level: u32) -> Result<RgbaImage, TexmError> {
decode_mip_rgba8(&self.texm, level)
}
}
impl PreparedVisual { impl PreparedVisual {
/// Returns the primary material id, if any. /// Returns the primary material id, if any.
#[must_use] #[must_use]
+7 -5
View File
@@ -13,7 +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 | | `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 | | `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 | | `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` | Yes, GOG `Autodemo.00` | Yes, static MSH draw | First MSH from first mission root | `covered-gpu` only for the narrow static-preview bridge | 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 | | `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, static MSH draw | First MSH from first mission root; first MAT0 diffuse TEXM per used selector | `covered-gpu` only for the narrow static-preview bridge | Opt-in mission-to-native-window bootstrap, static MSH projection, selector-keyed diffuse descriptor upload and synchronized teardown telemetry | Full mission scene, later MAT0 phases/animation, lightmaps, 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 | | 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 ## Rules
@@ -35,14 +35,16 @@
- `apps/fparkan-game` по умолчанию выдает `render-planning` JSON report поверх - `apps/fparkan-game` по умолчанию выдает `render-planning` JSON report поверх
synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan` synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan`
уже создаёт native `winit` window и передаёт первую подготовленную MSH модели миссии в уже создаёт native `winit` window и передаёт первую подготовленную MSH модели миссии в
`VulkanSmokeRenderer` с пустым списком materials (явный white-fallback). Режим использует `VulkanSmokeRenderer` вместе с первым diffuse TEXM из MAT0 для каждого реально используемого
`Batch20.material_index`. Режим использует
отдельный first-root preview loader: normal `load_mission` по-прежнему готовит все reachable отдельный first-root preview loader: normal `load_mission` по-прежнему готовит все reachable
assets и весь graph, тогда как preview строит graph и готовит assets только для первого assets и весь graph, тогда как preview строит graph и готовит assets только для первого
mission root. `--load-progress <file>` writes the last entered loader phase synchronously for mission root. `--load-progress <file>` writes the last entered loader phase synchronously for
timeout diagnosis. Fresh GOG `MISSIONS/Autodemo.00/data.tma` run passed in 38.7 seconds with timeout diagnosis. Fresh GOG `MISSIONS/Autodemo.00/data.tma` run passed in 38.7 seconds with
one presented frame, native 1280×720 swapchain (2 images), 7,372,800-byte readback hash one presented frame, native 1280×720 swapchain (2 images), one original diffuse material
`10681850560830502773`, and validation warnings/errors `0/0`. This is `covered-gpu` evidence descriptor, 7,372,800-byte readback hash `12332214764918703197`, and validation
for that narrow static-preview bridge only, not full-scene or original-renderer parity. warnings/errors `0/0`. This is `covered-gpu` evidence for that narrow static-preview bridge
only, not full-scene or original-renderer parity.
- `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan - `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan
asset viewer. asset viewer.
- Следующий реальный milestone для rendered acceptance: `VulkanAssetRenderer` - Следующий реальный milestone для rendered acceptance: `VulkanAssetRenderer`
+19 -6
View File
@@ -941,18 +941,31 @@ not the default planning path. It visits only the first mission root, selects
its prepared MSH model, sends it through the existing static XZ clip-space its prepared MSH model, sends it through the existing static XZ clip-space
projection and renders a requested number of frames through projection and renders a requested number of frames through
`VulkanSmokeRenderer`; teardown rejects validation warnings/errors and reports `VulkanSmokeRenderer`; teardown rejects validation warnings/errors and reports
swapchain/readback telemetry. It deliberately supplies no material textures, swapchain/readback telemetry. For each used `Batch20.material_index`, it resolves
so the renderer's documented white fallback is used. the positional prepared WEAR material and uploads mip 0 of that material's first
MAT0 diffuse texture request as a selector-keyed descriptor.
This is a narrow bootstrap from mission assets to a live Vulkan renderer. It 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 does not render every placed object, apply TMA transforms or orientation, select
WEAR/MAT0/TEXM material data, or establish a game camera. Fresh GOG later MAT0 phases or animation, bind lightmaps, or establish a game camera. Fresh GOG
`MISSIONS/Autodemo.00/data.tma` evidence now proves the narrow GPU bridge: one `MISSIONS/Autodemo.00/data.tma` evidence now proves the narrow GPU bridge: one
presented frame completed in 38.7 seconds with a native 1280×720 two-image presented frame completed in 38.7 seconds with a native 1280×720 two-image
swapchain, 7,372,800-byte synchronized readback (FNV-1a swapchain, one selector-keyed original diffuse descriptor, 7,372,800-byte
`10681850560830502773`) and validation warnings/errors `0/0`. This is not a synchronized readback (FNV-1a `12332214764918703197`) and validation
warnings/errors `0/0`. This is not a
full-scene or original-renderer pixel-parity claim. full-scene or original-renderer pixel-parity claim.
The same bounded command was then run against the licensed installed Part 1
(`C:\\Program Files (x86)\\Nikita\\IS`) and Part 2
(`C:\\Program Files (x86)\\Nikita\\IS2`) corpora supplied for testing. Both
`MISSIONS/Autodemo.00/data.tma` runs completed with the same one-material,
1280×720 two-image report, the same `12332214764918703197` readback hash and
validation warnings/errors `0/0`; Part 1 took 28.5 seconds and Part 2 took
94.5 seconds. Part 2's synchronous checkpoint was `Graph` while it was still
loading, so the longer 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. The preview now asks `load_mission_static_preview` for both graph and assets.
Normal mission loading remains full and transactional; preview graph traversal Normal mission loading remains full and transactional; preview graph traversal
and asset preparation are restricted to the first TMA root. It is therefore not and asset preparation are restricted to the first TMA root. It is therefore not