feat(game): bind preview diffuse materials

This commit is contained in:
2026-07-18 09:52:20 +04:00
parent 2772c39e41
commit c8dd5f989f
5 changed files with 127 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`
+8 -6
View File
@@ -941,16 +941,18 @@ 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 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.