From efaba9001fb64eca14db3668c1c5295f078691b5 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 08:07:45 +0400 Subject: [PATCH] feat(render): bind materials per mesh batch --- .../fparkan-render-vulkan/src/asset_mesh.rs | 35 +++ adapters/fparkan-render-vulkan/src/ffi.rs | 5 +- .../fparkan-render-vulkan/src/ffi/smoke.rs | 72 ++++-- .../src/ffi/smoke_types.rs | 93 +++++++- .../src/ffi/swapchain_resources.rs | 81 ++++--- apps/fparkan-vulkan-smoke/src/main.rs | 224 +++++++++++++----- docs/rendering/renderer_truth_table.md | 2 +- fixtures/acceptance/coverage.tsv | 2 +- 8 files changed, 398 insertions(+), 116 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index b90499a..fdc378f 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -68,6 +68,7 @@ pub fn project_msh_to_static_mesh( draw_ranges.push(VulkanStaticDrawRange { first_index, index_count: u32::from(batch.index_count), + material_index: batch.material_index, }); } if indices.is_empty() { @@ -181,6 +182,7 @@ mod tests { vec![VulkanStaticDrawRange { first_index: 0, index_count: 3, + material_index: 0, }] ); assert_eq!(mesh.vertices[1].position, [-0.8, -0.8]); @@ -203,4 +205,37 @@ mod tests { assert_eq!(mesh.vertices[1].uv, [0.0, 2.0]); assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); } + + #[test] + fn retains_each_source_batch_material_selector() { + let mut second = batch(3, 3, 0); + second.material_index = 7; + let mesh = project_msh_to_static_mesh(&model( + vec![ + [-2.0, 0.0, -1.0], + [2.0, 0.0, -1.0], + [-2.0, 0.0, 3.0], + [2.0, 0.0, 3.0], + ], + vec![0, 1, 2, 1, 3, 2], + vec![batch(0, 3, 0), second], + )) + .expect("representable MSH"); + + assert_eq!( + mesh.draw_ranges, + vec![ + VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + }, + VulkanStaticDrawRange { + first_index: 3, + index_count: 3, + material_index: 7, + }, + ] + ); + } } diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 3beedbd..91c8254 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -65,8 +65,9 @@ pub use self::runtime::{ pub use self::smoke_types::{ VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, - VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticDrawRange, VulkanStaticMesh, - VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport, + VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticDrawRange, + VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticVertex, + VulkanValidationReport, }; #[cfg(test)] use self::surface::extension_name; diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index a071e74..533ab1c 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -2,6 +2,7 @@ use ash::vk; +use super::smoke_types::resolve_draw_texture_indices; use super::{ create_command_pool, create_frame_sync, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources, @@ -109,11 +110,9 @@ impl VulkanSmokeRenderer { .mesh .validate() .map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?; - if let Some(texture) = &create_info.texture { - texture - .validate() - .map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?; - } + let draw_texture_indices = + resolve_draw_texture_indices(&create_info.mesh.draw_ranges, &create_info.materials) + .map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?; let bootstrap_progress = create_info.bootstrap_progress.as_ref(); let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) .map_err(VulkanSmokeRendererError::ShaderManifest)?; @@ -188,18 +187,31 @@ impl VulkanSmokeRenderer { height: 1, rgba8: vec![255, 255, 255, 255], }; - let texture_source = create_info.texture.as_ref().unwrap_or(&fallback_texture); - let texture = + let texture_sources = if create_info.materials.is_empty() { + vec![&fallback_texture] + } else { + create_info + .materials + .iter() + .map(|material| &material.texture) + .collect() + }; + let mut textures = Vec::with_capacity(texture_sources.len()); + for texture_source in texture_sources { match create_static_texture_image(&instance, &device, command_pool, texture_source) { - Ok(image) => Some(image), + Ok(image) => textures.push(image), Err(error) => { + for texture in &textures { + destroy_allocated_image(&device, texture); + } // SAFETY: These resources belong to this live device and are rolled back before it drops. unsafe { device.device().destroy_command_pool(command_pool, None) }; destroy_allocated_buffer(&device, &index_buffer); destroy_allocated_buffer(&device, &vertex_buffer); return Err(error); } - }; + } + } let mut renderer = Self { instance: Some(instance), validation, @@ -210,8 +222,9 @@ impl VulkanSmokeRenderer { swapchain_resources: None, vertex_buffer: Some(vertex_buffer), index_buffer: Some(index_buffer), - texture, + textures, draw_ranges: create_info.mesh.draw_ranges.clone(), + draw_texture_indices, frame_sync: Vec::new(), images_in_flight: Vec::new(), current_frame: 0, @@ -322,12 +335,14 @@ impl VulkanSmokeRenderer { }) } - fn texture_ref(&self) -> Result<&super::VulkanAllocatedImage, VulkanSmokeRendererError> { - self.texture - .as_ref() - .ok_or(VulkanSmokeRendererError::InvariantViolation { - context: "static material texture", + fn textures_ref(&self) -> Result<&[super::VulkanAllocatedImage], VulkanSmokeRendererError> { + if self.textures.is_empty() { + Err(VulkanSmokeRendererError::InvariantViolation { + context: "static material textures", }) + } else { + Ok(&self.textures) + } } fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> { @@ -544,7 +559,7 @@ impl VulkanSmokeRenderer { self.command_pool, self.vertex_buffer_ref()?, self.index_buffer_ref()?, - self.texture_ref()?, + self.textures_ref()?, reuse_command_pool, )?, |resources| destroy_swapchain_resources(device, self.command_pool, resources), @@ -621,14 +636,6 @@ impl VulkanSmokeRenderer { vk::PipelineBindPoint::GRAPHICS, resources.pipeline, ); - device.device().cmd_bind_descriptor_sets( - command_buffer, - vk::PipelineBindPoint::GRAPHICS, - resources.pipeline_layout, - 0, - &[resources.descriptor_set], - &[], - ); let vertex_buffers = [self.vertex_buffer_ref()?.buffer]; let offsets = [0_u64]; device @@ -640,7 +647,20 @@ impl VulkanSmokeRenderer { 0, vk::IndexType::UINT16, ); - for range in &self.draw_ranges { + for (range, &texture_index) in self.draw_ranges.iter().zip(&self.draw_texture_indices) { + let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static material descriptor set", + }, + )?; + device.device().cmd_bind_descriptor_sets( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + resources.pipeline_layout, + 0, + std::slice::from_ref(descriptor_set), + &[], + ); device.device().cmd_draw_indexed( command_buffer, range.index_count, @@ -689,7 +709,7 @@ impl VulkanSmokeRenderer { fn destroy_device_owned_resources(&mut self) { self.destroy_swapchain_resources(); if let Some(device) = self.device.as_ref() { - if let Some(texture) = self.texture.take() { + for texture in self.textures.drain(..) { destroy_allocated_image(device, &texture); } if let Some(buffer) = self.index_buffer.take() { diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 0821aba..da2694e 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -29,8 +29,12 @@ pub struct VulkanSmokeRendererCreateInfo { /// This initial bridge keeps positions in clip-space. MSH transforms, /// materials, and textures are deliberately higher-level Stage 3 work. pub mesh: VulkanStaticMesh, - /// Optional RGBA8 texture uploaded before the first live frame. - pub texture: Option, + /// Material textures uploaded before the first live frame. + /// + /// An empty list retains the compatibility white fallback. A singleton list + /// is a deliberately explicit one-material compatibility mode; otherwise + /// every source batch selector must resolve to one entry in this list. + pub materials: Vec, /// Optional shared bootstrap progress tracker for failure evidence. pub bootstrap_progress: Option>, } @@ -64,6 +68,17 @@ pub struct VulkanStaticDrawRange { pub first_index: u32, /// Number of indices in this draw. pub index_count: u32, + /// Original positional `Batch20.material_index` selector. + pub material_index: u16, +} + +/// One diffuse material texture keyed by an original MSH batch selector. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanStaticMaterial { + /// Positional material selector used by one or more source batches. + pub material_index: u16, + /// Decoded RGBA8 diffuse texture for this selector. + pub texture: VulkanStaticTexture, } /// Decoded RGBA8 image accepted by the initial Vulkan texture upload path. @@ -98,6 +113,39 @@ impl VulkanStaticTexture { } } +/// Resolves each source range to its descriptor-set material index. +/// +/// Empty material input selects the renderer's white fallback. A singleton is +/// the explicit direct-TEXM compatibility mode. Multiple materials must map +/// each `Batch20.material_index` exactly and never duplicate a selector. +pub(super) fn resolve_draw_texture_indices( + draw_ranges: &[VulkanStaticDrawRange], + materials: &[VulkanStaticMaterial], +) -> Result, &'static str> { + for (index, material) in materials.iter().enumerate() { + material.texture.validate()?; + if materials[..index] + .iter() + .any(|previous| previous.material_index == material.material_index) + { + return Err("static material selectors must be unique"); + } + } + draw_ranges + .iter() + .map(|range| { + if materials.len() <= 1 { + Ok(0) + } else { + materials + .iter() + .position(|material| material.material_index == range.material_index) + .ok_or("static mesh draw range has no material texture") + } + }) + .collect() +} + impl VulkanStaticMesh { /// Returns the compatibility triangle used by the native Stage 0 smoke app. #[must_use] @@ -124,6 +172,7 @@ impl VulkanStaticMesh { draw_ranges: vec![VulkanStaticDrawRange { first_index: 0, index_count: 3, + material_index: 0, }], } } @@ -189,6 +238,7 @@ mod static_mesh_tests { draw_ranges: vec![VulkanStaticDrawRange { first_index: 0, index_count: 2, + material_index: 0, }], }; let out_of_range_index = VulkanStaticMesh { @@ -207,6 +257,42 @@ mod static_mesh_tests { Err("static mesh index exceeds vertex count") ); } + + #[test] + fn material_descriptors_follow_source_batch_selectors() { + let ranges = [ + VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 7, + }, + VulkanStaticDrawRange { + first_index: 3, + index_count: 3, + material_index: 2, + }, + ]; + let texture = || VulkanStaticTexture { + width: 1, + height: 1, + rgba8: vec![255; 4], + }; + let materials = [ + VulkanStaticMaterial { + material_index: 2, + texture: texture(), + }, + VulkanStaticMaterial { + material_index: 7, + texture: texture(), + }, + ]; + + assert_eq!( + resolve_draw_texture_indices(&ranges, &materials), + Ok(vec![1, 0]) + ); + } } /// Shared bootstrap progress used to report partial renderer startup evidence. @@ -421,8 +507,9 @@ pub struct VulkanSmokeRenderer { pub(super) swapchain_resources: Option, pub(super) vertex_buffer: Option, pub(super) index_buffer: Option, - pub(super) texture: Option, + pub(super) textures: Vec, pub(super) draw_ranges: Vec, + pub(super) draw_texture_indices: Vec, pub(super) frame_sync: Vec, pub(super) images_in_flight: Vec, pub(super) current_frame: usize, diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index 50bf0a1..8dbbc27 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -14,7 +14,7 @@ pub(super) struct VulkanSwapchainResources { pub(super) pipeline_layout: vk::PipelineLayout, pub(super) descriptor_set_layout: vk::DescriptorSetLayout, pub(super) descriptor_pool: vk::DescriptorPool, - pub(super) descriptor_set: vk::DescriptorSet, + pub(super) descriptor_sets: Vec, pub(super) sampler: vk::Sampler, pub(super) pipeline: vk::Pipeline, pub(super) framebuffers: Vec, @@ -39,7 +39,7 @@ pub(super) fn create_swapchain_resources( command_pool: vk::CommandPool, vertex_buffer: &VulkanAllocatedBuffer, index_buffer: &VulkanAllocatedBuffer, - texture: &VulkanAllocatedImage, + textures: &[VulkanAllocatedImage], reuse_command_pool: bool, ) -> Result { // SAFETY: The swapchain is live and owned by this renderer for the duration of the query. @@ -73,13 +73,13 @@ pub(super) fn create_swapchain_resources( pipeline, descriptor_set_layout, descriptor_pool, - descriptor_set, + descriptor_sets, sampler, ) = match create_swapchain_pipeline_bundle( device, swapchain.report.plan.format.format, swapchain.report.plan.extent, - texture, + textures, ) { Ok(bundle) => bundle, Err(error) => { @@ -126,7 +126,7 @@ pub(super) fn create_swapchain_resources( pipeline_layout, descriptor_set_layout, descriptor_pool, - descriptor_set, + descriptor_sets, sampler, pipeline, framebuffers: partial.framebuffers, @@ -151,7 +151,7 @@ fn create_swapchain_pipeline_bundle( device: &VulkanLogicalDeviceProbe, format: i32, extent: (u32, u32), - texture: &VulkanAllocatedImage, + textures: &[VulkanAllocatedImage], ) -> Result< ( vk::RenderPass, @@ -159,14 +159,14 @@ fn create_swapchain_pipeline_bundle( vk::Pipeline, vk::DescriptorSetLayout, vk::DescriptorPool, - vk::DescriptorSet, + Vec, vk::Sampler, ), VulkanSmokeRendererError, > { let render_pass = create_render_pass(device, format)?; - let (descriptor_set_layout, descriptor_pool, descriptor_set, sampler) = - create_texture_descriptor_bundle(device, texture).inspect_err(|_| { + let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) = + create_texture_descriptor_bundle(device, textures).inspect_err(|_| { // SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure. unsafe { device.device().destroy_render_pass(render_pass, None) }; })?; @@ -207,7 +207,7 @@ fn create_swapchain_pipeline_bundle( pipeline, descriptor_set_layout, descriptor_pool, - descriptor_set, + descriptor_sets, sampler, )) } @@ -334,12 +334,12 @@ fn create_pipeline_layout( #[allow(clippy::too_many_lines)] fn create_texture_descriptor_bundle( device: &VulkanLogicalDeviceProbe, - texture: &VulkanAllocatedImage, + textures: &[VulkanAllocatedImage], ) -> Result< ( vk::DescriptorSetLayout, vk::DescriptorPool, - vk::DescriptorSet, + Vec, vk::Sampler, ), VulkanSmokeRendererError, @@ -361,13 +361,23 @@ fn create_texture_descriptor_bundle( context: "vkCreateDescriptorSetLayout", result, })?; + let texture_count = u32::try_from(textures.len()).map_err(|_| { + VulkanSmokeRendererError::InvariantViolation { + context: "static material texture count exceeds Vulkan descriptor limit", + } + })?; + if texture_count == 0 { + return Err(VulkanSmokeRendererError::InvariantViolation { + context: "static material texture list is empty", + }); + } let pool_size = vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .descriptor_count(1); + .descriptor_count(texture_count); let pool_sizes = [pool_size]; let pool_info = vk::DescriptorPoolCreateInfo::default() .pool_sizes(&pool_sizes) - .max_sets(1); + .max_sets(texture_count); let pool = // SAFETY: The pool description is stack-owned and reserves exactly one descriptor. unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| { @@ -378,12 +388,12 @@ fn create_texture_descriptor_bundle( result, } })?; - let layouts = [layout]; + let layouts = vec![layout; textures.len()]; let allocate_info = vk::DescriptorSetAllocateInfo::default() .descriptor_pool(pool) .set_layouts(&layouts); // SAFETY: The pool and layout are live on this logical device. - let descriptor_set = unsafe { device.device().allocate_descriptor_sets(&allocate_info) } + let descriptor_sets = unsafe { device.device().allocate_descriptor_sets(&allocate_info) } .map_err(|result| { // SAFETY: Resources were created above on this device and are rolled back once. unsafe { @@ -394,7 +404,7 @@ fn create_texture_descriptor_bundle( context: "vkAllocateDescriptorSets", result, } - })?[0]; + })?; let sampler_info = vk::SamplerCreateInfo::default() .mag_filter(vk::Filter::LINEAR) .min_filter(vk::Filter::LINEAR) @@ -416,19 +426,30 @@ fn create_texture_descriptor_bundle( result, } })?; - let image_info = vk::DescriptorImageInfo::default() - .sampler(sampler) - .image_view(texture.view) - .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL); - let image_infos = [image_info]; - let write = vk::WriteDescriptorSet::default() - .dst_set(descriptor_set) - .dst_binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .image_info(&image_infos); - // SAFETY: The descriptor set, sampler and image view are live and the texture upload completed its shader-read transition. - unsafe { device.device().update_descriptor_sets(&[write], &[]) }; - Ok((layout, pool, descriptor_set, sampler)) + let image_infos = textures + .iter() + .map(|texture| { + vk::DescriptorImageInfo::default() + .sampler(sampler) + .image_view(texture.view) + .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) + }) + .collect::>(); + let writes = descriptor_sets + .iter() + .copied() + .zip(image_infos.iter()) + .map(|(descriptor_set, image_info)| { + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(0) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(std::slice::from_ref(image_info)) + }) + .collect::>(); + // SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition. + unsafe { device.device().update_descriptor_sets(&writes, &[]) }; + Ok((layout, pool, descriptor_sets, sampler)) } #[allow(clippy::cast_precision_loss)] diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index fc725d8..48bd766 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -16,7 +16,8 @@ use fparkan_platform_winit::{window_native_handles, WinitWindowPlan}; use fparkan_render_vulkan::{ project_msh_to_static_mesh, VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, - VulkanSmokeShutdownReport, VulkanStaticMesh, VulkanStaticTexture, VulkanValidationReport, + VulkanSmokeShutdownReport, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, + VulkanValidationReport, }; use serde::Serialize; use std::path::PathBuf; @@ -56,7 +57,7 @@ fn main() { fn run(args: &[String]) -> Result { let options = SmokeOptions::parse(args)?; let mesh = options.load_mesh()?; - let texture = options.load_texture()?; + let materials = options.load_materials(&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); @@ -67,7 +68,7 @@ fn run(args: &[String]) -> Result { Arc::clone(&completed), Arc::clone(&progress), ); - let mut app = SmokeApp::new(options, mesh, texture, completed, progress); + let mut app = SmokeApp::new(options, mesh, materials, completed, progress); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); } @@ -131,10 +132,18 @@ struct WearMaterialInput { material_index: u16, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct WearInput { + root: PathBuf, + archive: String, + name: String, +} + #[derive(Clone, Debug, Eq, PartialEq)] enum TextureInput { Direct(ResourceInput), WearMaterial(WearMaterialInput), + WearBatchMaterials(WearInput), } impl TextureInput { @@ -142,13 +151,14 @@ impl TextureInput { match self { Self::Direct(_) => "original-texm", Self::WearMaterial(_) => "original-wear-mat0-texm", + Self::WearBatchMaterials(_) => "original-wear-mat0-texm-batch", } } fn archive(&self) -> &str { match self { Self::Direct(input) => &input.archive, - Self::WearMaterial(_) => "Textures.lib", + Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "Textures.lib", } } @@ -156,7 +166,7 @@ impl TextureInput { match self { Self::Direct(input) => &input.name, // The selected name comes from MAT0 at load time and is not a CLI request. - Self::WearMaterial(_) => "", + Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "", } } } @@ -363,9 +373,16 @@ impl SmokeOptions { material_index, })) } + (Some(root), Some(archive), Some(name), None) => { + Some(TextureInput::WearBatchMaterials(WearInput { + root, + archive, + name, + })) + } _ => { return Err( - "--wear-root, --wear-archive, --wear-name and --material-index must be supplied together" + "--wear-root, --wear-archive and --wear-name must be supplied together; --material-index is optional" .to_string(), ); } @@ -400,42 +417,93 @@ impl SmokeOptions { } } - fn load_texture(&self) -> Result, String> { - self.texture_input.as_ref().map_or(Ok(None), |input| { - let (image, selected_name) = match input { - TextureInput::Direct(input) => ( - fparkan_inspection::load_texture_mip0_rgba8_from_root( + fn load_materials(&self, mesh: &VulkanStaticMesh) -> Result, String> { + let Some(input) = self.texture_input.as_ref() else { + return Ok(Vec::new()); + }; + match input { + TextureInput::Direct(input) => Ok(vec![Self::load_material( + 0, + { + let image = fparkan_inspection::load_texture_mip0_rgba8_from_root( &input.root, &input.archive, &input.name, - )?, - input.name.clone(), - ), - TextureInput::WearMaterial(input) => { - let selected = - fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root( - &input.root, - &input.archive, - &input.name, - input.material_index, - )?; - (selected.image, selected.texture_name) - } - }; - Ok(Some(LoadedTexture { - texture: VulkanStaticTexture { - width: image.width, - height: image.height, - rgba8: image.rgba8, + )?; + (image.width, image.height, image.rgba8) }, - selected_name, - })) - }) + input.name.clone(), + )]), + TextureInput::WearMaterial(input) => { + let selected = fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root( + &input.root, + &input.archive, + &input.name, + input.material_index, + )?; + Ok(vec![Self::load_material( + input.material_index, + ( + selected.image.width, + selected.image.height, + selected.image.rgba8, + ), + selected.texture_name, + )]) + } + TextureInput::WearBatchMaterials(input) => { + let mut selectors = mesh + .draw_ranges + .iter() + .map(|range| range.material_index) + .collect::>(); + selectors.sort_unstable(); + selectors.dedup(); + selectors + .into_iter() + .map(|material_index| { + let selected = + fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root( + &input.root, + &input.archive, + &input.name, + material_index, + )?; + Ok(Self::load_material( + material_index, + ( + selected.image.width, + selected.image.height, + selected.image.rgba8, + ), + selected.texture_name, + )) + }) + .collect() + } + } + } + + fn load_material( + material_index: u16, + image: (u32, u32, Vec), + selected_name: String, + ) -> LoadedTexture { + LoadedTexture { + material_index, + texture: VulkanStaticTexture { + width: image.0, + height: image.1, + rgba8: image.2, + }, + selected_name, + } } } #[derive(Clone, Debug)] struct LoadedTexture { + material_index: u16, texture: VulkanStaticTexture, selected_name: String, } @@ -443,7 +511,7 @@ struct LoadedTexture { struct SmokeApp { options: SmokeOptions, mesh: VulkanStaticMesh, - texture: Option, + materials: Vec, completed: Arc, progress: Arc, window_id: Option, @@ -488,14 +556,14 @@ impl SmokeApp { fn new( options: SmokeOptions, mesh: VulkanStaticMesh, - texture: Option, + materials: Vec, completed: Arc, progress: Arc, ) -> Self { Self { options, mesh, - texture, + materials, completed, progress, window_id: None, @@ -603,23 +671,35 @@ impl SmokeApp { .texture_input .as_ref() .map_or("", TextureInput::archive), - texture_name: self.texture.as_ref().map_or_else( - || { - self.options - .texture_input - .as_ref() - .map_or("", TextureInput::requested_name) - }, - |texture| texture.selected_name.as_str(), - ), + texture_name: match self.materials.as_slice() { + [] => self + .options + .texture_input + .as_ref() + .map_or("", TextureInput::requested_name), + [material] => material.selected_name.as_str(), + _ => "multiple", + }, texture_width: self - .texture - .as_ref() - .map_or(0, |texture| texture.texture.width), + .materials + .first() + .map_or(0, |material| material.texture.width), texture_height: self - .texture - .as_ref() - .map_or(0, |texture| texture.texture.height), + .materials + .first() + .map_or(0, |material| material.texture.height), + material_binding_count: self.materials.len(), + mesh_material_indices: self + .mesh + .draw_ranges + .iter() + .map(|range| range.material_index) + .collect(), + material_texture_names: self + .materials + .iter() + .map(|material| material.selected_name.clone()) + .collect(), shader_manifest_hash: renderer .as_ref() .map_or("", |snapshot| snapshot.report.shader_manifest_hash.as_str()), @@ -839,6 +919,9 @@ fn render_timeout_failure_report( .map_or("", TextureInput::requested_name), texture_width: 0, texture_height: 0, + material_binding_count: 0, + mesh_material_indices: Vec::new(), + material_texture_names: Vec::new(), shader_manifest_hash: "", vulkan_loader_status: if bootstrap.loader_available { "available" @@ -934,7 +1017,14 @@ impl ApplicationHandler for SmokeApp { render_request: RenderRequest::conservative(), enable_validation: true, mesh: self.mesh.clone(), - texture: self.texture.as_ref().map(|texture| texture.texture.clone()), + materials: self + .materials + .iter() + .map(|material| VulkanStaticMaterial { + material_index: material.material_index, + texture: material.texture.clone(), + }) + .collect(), bootstrap_progress: Some(Arc::clone(&self.progress.bootstrap)), }) { Ok(renderer) => renderer, @@ -1073,6 +1163,9 @@ struct SmokeReport<'a> { texture_name: &'a str, texture_width: u32, texture_height: u32, + material_binding_count: usize, + mesh_material_indices: Vec, + material_texture_names: Vec, shader_manifest_hash: &'a str, vulkan_loader_status: &'a str, vulkan_instance_status: &'a str, @@ -1463,6 +1556,28 @@ mod tests { )); } + #[test] + fn parses_original_wear_batch_material_input_without_override() { + let parsed = SmokeOptions::parse(&[ + "--out".to_string(), + "target/report.json".to_string(), + "--wear-root".to_string(), + "C:/GOG".to_string(), + "--wear-archive".to_string(), + "system.rlb".to_string(), + "--wear-name".to_string(), + "MODEL.WEA".to_string(), + ]); + + assert!(matches!( + parsed, + Ok(SmokeOptions { + texture_input: Some(TextureInput::WearBatchMaterials(WearInput { archive, name, .. })), + .. + }) if archive == "system.rlb" && name == "MODEL.WEA" + )); + } + #[test] fn rejects_deprecated_self_assertion_flags() { for flag in [ @@ -1533,6 +1648,9 @@ mod tests { texture_name: "DEFAULT.0", texture_width: 16, texture_height: 16, + material_binding_count: 1, + mesh_material_indices: vec![0], + material_texture_names: vec!["DEFAULT.0".to_string()], shader_manifest_hash: "deadbeef", vulkan_loader_status: "available", vulkan_instance_status: "created", @@ -1585,7 +1703,7 @@ mod tests { texture_input: None, }, mesh: VulkanStaticMesh::smoke_triangle(), - texture: None, + materials: Vec::new(), 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 1b19547..a35dec8 100644 --- a/docs/rendering/renderer_truth_table.md +++ b/docs/rendering/renderer_truth_table.md @@ -29,7 +29,7 @@ ## Current repository status -- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static asset bridge. Помимо direct TEXM input, `fparkan-vulkan-smoke --model-root --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root --wear-archive fortif.rlb --wear-name FR_L_MTP.WEA --material-index 0` проходит `WEAR → MAT0 → Textures.lib`, выбирает `MTP_01.0`, сохраняет 237 исходных MSH batch ranges и выпускает 237 indexed Vulkan draws, затем загружает selected TEXM mip-0 в device-local image. Image переходит в sampling layout, записывается в `set=0,binding=0` combined image sampler и sampled fragment shader-ом. `Res5` UV0 декодируется как signed fixed point `int16 / 1024.0`; XZ-planar UV остаётся только fallback для модели без optional stream. Все 237 draws пока используют один explicitly selected material, а не полный per-batch/phase/lightmap/terrain или gameplay renderer. +- Реальный 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. - `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 66fa7ac..e9e3bb9 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -298,7 +298,7 @@ S3-VK-MESH-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release -- S3-VK-TEXM-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-texm-upload.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH --texture-root --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM 16x16 decoded RGBA8, staged into device-local image and transitioned to SHADER_READ_ONLY_OPTIMAL S3-VK-DESCRIPTOR-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-texm-sampled.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH --texture-root --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM is bound as set=0,binding=0 combined image sampler and sampled in fragment shader; validation_warning_count=0 and validation_error_count=0 S3-VK-PIPELINE-001 blocked awaits Stage 3 graphics pipeline key and cache implementation -S3-VK-DRAW-MODEL-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-fr-l-mtp-batches.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root --wear-archive fortif.rlb --wear-name FR_L_MTP.WEA --material-index 0; live Win32 Vulkan issues 237 source-preserving indexed batch draws for original MSH with WEAR→MAT0-selected MTP_01.0 TEXM +S3-VK-DRAW-MODEL-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-fr-l-mtp-per-batch-materials.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root --wear-archive fortif.rlb --wear-name FR_L_MTP.WEA; live Win32 Vulkan issues 237 source-preserving indexed batch draws and binds the deduplicated WEAR→MAT0 material descriptor selected by each Batch20.material_index (this corpus model has selector 0 only) 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 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