feat(render): bind materials per mesh batch
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m7s
Test / Test (push) Skipped
Test / Render parity (push) Skipped

This commit is contained in:
2026-07-18 08:07:45 +04:00
parent b915554f3c
commit efaba9001f
8 changed files with 398 additions and 116 deletions
@@ -68,6 +68,7 @@ pub fn project_msh_to_static_mesh(
draw_ranges.push(VulkanStaticDrawRange { draw_ranges.push(VulkanStaticDrawRange {
first_index, first_index,
index_count: u32::from(batch.index_count), index_count: u32::from(batch.index_count),
material_index: batch.material_index,
}); });
} }
if indices.is_empty() { if indices.is_empty() {
@@ -181,6 +182,7 @@ mod tests {
vec![VulkanStaticDrawRange { vec![VulkanStaticDrawRange {
first_index: 0, first_index: 0,
index_count: 3, index_count: 3,
material_index: 0,
}] }]
); );
assert_eq!(mesh.vertices[1].position, [-0.8, -0.8]); 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[1].uv, [0.0, 2.0]);
assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); 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,
},
]
);
}
} }
+3 -2
View File
@@ -65,8 +65,9 @@ pub use self::runtime::{
pub use self::smoke_types::{ pub use self::smoke_types::{
VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome,
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError,
VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticDrawRange, VulkanStaticMesh, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticDrawRange,
VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticVertex,
VulkanValidationReport,
}; };
#[cfg(test)] #[cfg(test)]
use self::surface::extension_name; use self::surface::extension_name;
+46 -26
View File
@@ -2,6 +2,7 @@
use ash::vk; use ash::vk;
use super::smoke_types::resolve_draw_texture_indices;
use super::{ use super::{
create_command_pool, create_frame_sync, create_static_mesh_index_buffer, create_command_pool, create_frame_sync, create_static_mesh_index_buffer,
create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources, create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources,
@@ -109,11 +110,9 @@ impl VulkanSmokeRenderer {
.mesh .mesh
.validate() .validate()
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?; .map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
if let Some(texture) = &create_info.texture { let draw_texture_indices =
texture resolve_draw_texture_indices(&create_info.mesh.draw_ranges, &create_info.materials)
.validate() .map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
.map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?;
}
let bootstrap_progress = create_info.bootstrap_progress.as_ref(); let bootstrap_progress = create_info.bootstrap_progress.as_ref();
let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
.map_err(VulkanSmokeRendererError::ShaderManifest)?; .map_err(VulkanSmokeRendererError::ShaderManifest)?;
@@ -188,18 +187,31 @@ impl VulkanSmokeRenderer {
height: 1, height: 1,
rgba8: vec![255, 255, 255, 255], rgba8: vec![255, 255, 255, 255],
}; };
let texture_source = create_info.texture.as_ref().unwrap_or(&fallback_texture); let texture_sources = if create_info.materials.is_empty() {
let texture = 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) { match create_static_texture_image(&instance, &device, command_pool, texture_source) {
Ok(image) => Some(image), Ok(image) => textures.push(image),
Err(error) => { 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. // SAFETY: These resources belong to this live device and are rolled back before it drops.
unsafe { device.device().destroy_command_pool(command_pool, None) }; unsafe { device.device().destroy_command_pool(command_pool, None) };
destroy_allocated_buffer(&device, &index_buffer); destroy_allocated_buffer(&device, &index_buffer);
destroy_allocated_buffer(&device, &vertex_buffer); destroy_allocated_buffer(&device, &vertex_buffer);
return Err(error); return Err(error);
} }
}; }
}
let mut renderer = Self { let mut renderer = Self {
instance: Some(instance), instance: Some(instance),
validation, validation,
@@ -210,8 +222,9 @@ impl VulkanSmokeRenderer {
swapchain_resources: None, swapchain_resources: None,
vertex_buffer: Some(vertex_buffer), vertex_buffer: Some(vertex_buffer),
index_buffer: Some(index_buffer), index_buffer: Some(index_buffer),
texture, textures,
draw_ranges: create_info.mesh.draw_ranges.clone(), draw_ranges: create_info.mesh.draw_ranges.clone(),
draw_texture_indices,
frame_sync: Vec::new(), frame_sync: Vec::new(),
images_in_flight: Vec::new(), images_in_flight: Vec::new(),
current_frame: 0, current_frame: 0,
@@ -322,12 +335,14 @@ impl VulkanSmokeRenderer {
}) })
} }
fn texture_ref(&self) -> Result<&super::VulkanAllocatedImage, VulkanSmokeRendererError> { fn textures_ref(&self) -> Result<&[super::VulkanAllocatedImage], VulkanSmokeRendererError> {
self.texture if self.textures.is_empty() {
.as_ref() Err(VulkanSmokeRendererError::InvariantViolation {
.ok_or(VulkanSmokeRendererError::InvariantViolation { context: "static material textures",
context: "static material texture",
}) })
} else {
Ok(&self.textures)
}
} }
fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> { fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> {
@@ -544,7 +559,7 @@ impl VulkanSmokeRenderer {
self.command_pool, self.command_pool,
self.vertex_buffer_ref()?, self.vertex_buffer_ref()?,
self.index_buffer_ref()?, self.index_buffer_ref()?,
self.texture_ref()?, self.textures_ref()?,
reuse_command_pool, reuse_command_pool,
)?, )?,
|resources| destroy_swapchain_resources(device, self.command_pool, resources), |resources| destroy_swapchain_resources(device, self.command_pool, resources),
@@ -621,14 +636,6 @@ impl VulkanSmokeRenderer {
vk::PipelineBindPoint::GRAPHICS, vk::PipelineBindPoint::GRAPHICS,
resources.pipeline, 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 vertex_buffers = [self.vertex_buffer_ref()?.buffer];
let offsets = [0_u64]; let offsets = [0_u64];
device device
@@ -640,7 +647,20 @@ impl VulkanSmokeRenderer {
0, 0,
vk::IndexType::UINT16, 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( device.device().cmd_draw_indexed(
command_buffer, command_buffer,
range.index_count, range.index_count,
@@ -689,7 +709,7 @@ impl VulkanSmokeRenderer {
fn destroy_device_owned_resources(&mut self) { fn destroy_device_owned_resources(&mut self) {
self.destroy_swapchain_resources(); self.destroy_swapchain_resources();
if let Some(device) = self.device.as_ref() { 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); destroy_allocated_image(device, &texture);
} }
if let Some(buffer) = self.index_buffer.take() { if let Some(buffer) = self.index_buffer.take() {
@@ -29,8 +29,12 @@ pub struct VulkanSmokeRendererCreateInfo {
/// This initial bridge keeps positions in clip-space. MSH transforms, /// This initial bridge keeps positions in clip-space. MSH transforms,
/// materials, and textures are deliberately higher-level Stage 3 work. /// materials, and textures are deliberately higher-level Stage 3 work.
pub mesh: VulkanStaticMesh, pub mesh: VulkanStaticMesh,
/// Optional RGBA8 texture uploaded before the first live frame. /// Material textures uploaded before the first live frame.
pub texture: Option<VulkanStaticTexture>, ///
/// 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<VulkanStaticMaterial>,
/// Optional shared bootstrap progress tracker for failure evidence. /// Optional shared bootstrap progress tracker for failure evidence.
pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>, pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>,
} }
@@ -64,6 +68,17 @@ pub struct VulkanStaticDrawRange {
pub first_index: u32, pub first_index: u32,
/// Number of indices in this draw. /// Number of indices in this draw.
pub index_count: u32, 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. /// 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<Vec<usize>, &'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 { impl VulkanStaticMesh {
/// Returns the compatibility triangle used by the native Stage 0 smoke app. /// Returns the compatibility triangle used by the native Stage 0 smoke app.
#[must_use] #[must_use]
@@ -124,6 +172,7 @@ impl VulkanStaticMesh {
draw_ranges: vec![VulkanStaticDrawRange { draw_ranges: vec![VulkanStaticDrawRange {
first_index: 0, first_index: 0,
index_count: 3, index_count: 3,
material_index: 0,
}], }],
} }
} }
@@ -189,6 +238,7 @@ mod static_mesh_tests {
draw_ranges: vec![VulkanStaticDrawRange { draw_ranges: vec![VulkanStaticDrawRange {
first_index: 0, first_index: 0,
index_count: 2, index_count: 2,
material_index: 0,
}], }],
}; };
let out_of_range_index = VulkanStaticMesh { let out_of_range_index = VulkanStaticMesh {
@@ -207,6 +257,42 @@ mod static_mesh_tests {
Err("static mesh index exceeds vertex count") 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. /// Shared bootstrap progress used to report partial renderer startup evidence.
@@ -421,8 +507,9 @@ pub struct VulkanSmokeRenderer {
pub(super) swapchain_resources: Option<VulkanSwapchainResources>, pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>, pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_buffer: Option<VulkanAllocatedBuffer>, pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
pub(super) texture: Option<VulkanAllocatedImage>, pub(super) textures: Vec<VulkanAllocatedImage>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>, pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
pub(super) draw_texture_indices: Vec<usize>,
pub(super) frame_sync: Vec<VulkanFrameSync>, pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>, pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize, pub(super) current_frame: usize,
@@ -14,7 +14,7 @@ pub(super) struct VulkanSwapchainResources {
pub(super) pipeline_layout: vk::PipelineLayout, pub(super) pipeline_layout: vk::PipelineLayout,
pub(super) descriptor_set_layout: vk::DescriptorSetLayout, pub(super) descriptor_set_layout: vk::DescriptorSetLayout,
pub(super) descriptor_pool: vk::DescriptorPool, pub(super) descriptor_pool: vk::DescriptorPool,
pub(super) descriptor_set: vk::DescriptorSet, pub(super) descriptor_sets: Vec<vk::DescriptorSet>,
pub(super) sampler: vk::Sampler, pub(super) sampler: vk::Sampler,
pub(super) pipeline: vk::Pipeline, pub(super) pipeline: vk::Pipeline,
pub(super) framebuffers: Vec<vk::Framebuffer>, pub(super) framebuffers: Vec<vk::Framebuffer>,
@@ -39,7 +39,7 @@ pub(super) fn create_swapchain_resources(
command_pool: vk::CommandPool, command_pool: vk::CommandPool,
vertex_buffer: &VulkanAllocatedBuffer, vertex_buffer: &VulkanAllocatedBuffer,
index_buffer: &VulkanAllocatedBuffer, index_buffer: &VulkanAllocatedBuffer,
texture: &VulkanAllocatedImage, textures: &[VulkanAllocatedImage],
reuse_command_pool: bool, reuse_command_pool: bool,
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> { ) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
// SAFETY: The swapchain is live and owned by this renderer for the duration of the query. // 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, pipeline,
descriptor_set_layout, descriptor_set_layout,
descriptor_pool, descriptor_pool,
descriptor_set, descriptor_sets,
sampler, sampler,
) = match create_swapchain_pipeline_bundle( ) = match create_swapchain_pipeline_bundle(
device, device,
swapchain.report.plan.format.format, swapchain.report.plan.format.format,
swapchain.report.plan.extent, swapchain.report.plan.extent,
texture, textures,
) { ) {
Ok(bundle) => bundle, Ok(bundle) => bundle,
Err(error) => { Err(error) => {
@@ -126,7 +126,7 @@ pub(super) fn create_swapchain_resources(
pipeline_layout, pipeline_layout,
descriptor_set_layout, descriptor_set_layout,
descriptor_pool, descriptor_pool,
descriptor_set, descriptor_sets,
sampler, sampler,
pipeline, pipeline,
framebuffers: partial.framebuffers, framebuffers: partial.framebuffers,
@@ -151,7 +151,7 @@ fn create_swapchain_pipeline_bundle(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
format: i32, format: i32,
extent: (u32, u32), extent: (u32, u32),
texture: &VulkanAllocatedImage, textures: &[VulkanAllocatedImage],
) -> Result< ) -> Result<
( (
vk::RenderPass, vk::RenderPass,
@@ -159,14 +159,14 @@ fn create_swapchain_pipeline_bundle(
vk::Pipeline, vk::Pipeline,
vk::DescriptorSetLayout, vk::DescriptorSetLayout,
vk::DescriptorPool, vk::DescriptorPool,
vk::DescriptorSet, Vec<vk::DescriptorSet>,
vk::Sampler, vk::Sampler,
), ),
VulkanSmokeRendererError, VulkanSmokeRendererError,
> { > {
let render_pass = create_render_pass(device, format)?; let render_pass = create_render_pass(device, format)?;
let (descriptor_set_layout, descriptor_pool, descriptor_set, sampler) = let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) =
create_texture_descriptor_bundle(device, texture).inspect_err(|_| { 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. // 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) }; unsafe { device.device().destroy_render_pass(render_pass, None) };
})?; })?;
@@ -207,7 +207,7 @@ fn create_swapchain_pipeline_bundle(
pipeline, pipeline,
descriptor_set_layout, descriptor_set_layout,
descriptor_pool, descriptor_pool,
descriptor_set, descriptor_sets,
sampler, sampler,
)) ))
} }
@@ -334,12 +334,12 @@ fn create_pipeline_layout(
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
fn create_texture_descriptor_bundle( fn create_texture_descriptor_bundle(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
texture: &VulkanAllocatedImage, textures: &[VulkanAllocatedImage],
) -> Result< ) -> Result<
( (
vk::DescriptorSetLayout, vk::DescriptorSetLayout,
vk::DescriptorPool, vk::DescriptorPool,
vk::DescriptorSet, Vec<vk::DescriptorSet>,
vk::Sampler, vk::Sampler,
), ),
VulkanSmokeRendererError, VulkanSmokeRendererError,
@@ -361,13 +361,23 @@ fn create_texture_descriptor_bundle(
context: "vkCreateDescriptorSetLayout", context: "vkCreateDescriptorSetLayout",
result, 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() let pool_size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(1); .descriptor_count(texture_count);
let pool_sizes = [pool_size]; let pool_sizes = [pool_size];
let pool_info = vk::DescriptorPoolCreateInfo::default() let pool_info = vk::DescriptorPoolCreateInfo::default()
.pool_sizes(&pool_sizes) .pool_sizes(&pool_sizes)
.max_sets(1); .max_sets(texture_count);
let pool = let pool =
// SAFETY: The pool description is stack-owned and reserves exactly one descriptor. // SAFETY: The pool description is stack-owned and reserves exactly one descriptor.
unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| { unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| {
@@ -378,12 +388,12 @@ fn create_texture_descriptor_bundle(
result, result,
} }
})?; })?;
let layouts = [layout]; let layouts = vec![layout; textures.len()];
let allocate_info = vk::DescriptorSetAllocateInfo::default() let allocate_info = vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(pool) .descriptor_pool(pool)
.set_layouts(&layouts); .set_layouts(&layouts);
// SAFETY: The pool and layout are live on this logical device. // 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| { .map_err(|result| {
// SAFETY: Resources were created above on this device and are rolled back once. // SAFETY: Resources were created above on this device and are rolled back once.
unsafe { unsafe {
@@ -394,7 +404,7 @@ fn create_texture_descriptor_bundle(
context: "vkAllocateDescriptorSets", context: "vkAllocateDescriptorSets",
result, result,
} }
})?[0]; })?;
let sampler_info = vk::SamplerCreateInfo::default() let sampler_info = vk::SamplerCreateInfo::default()
.mag_filter(vk::Filter::LINEAR) .mag_filter(vk::Filter::LINEAR)
.min_filter(vk::Filter::LINEAR) .min_filter(vk::Filter::LINEAR)
@@ -416,19 +426,30 @@ fn create_texture_descriptor_bundle(
result, result,
} }
})?; })?;
let image_info = vk::DescriptorImageInfo::default() let image_infos = textures
.sampler(sampler) .iter()
.image_view(texture.view) .map(|texture| {
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL); vk::DescriptorImageInfo::default()
let image_infos = [image_info]; .sampler(sampler)
let write = vk::WriteDescriptorSet::default() .image_view(texture.view)
.dst_set(descriptor_set) .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
.dst_binding(0) })
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .collect::<Vec<_>>();
.image_info(&image_infos); let writes = descriptor_sets
// SAFETY: The descriptor set, sampler and image view are live and the texture upload completed its shader-read transition. .iter()
unsafe { device.device().update_descriptor_sets(&[write], &[]) }; .copied()
Ok((layout, pool, descriptor_set, sampler)) .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::<Vec<_>>();
// 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)] #[allow(clippy::cast_precision_loss)]
+171 -53
View File
@@ -16,7 +16,8 @@ use fparkan_platform_winit::{window_native_handles, WinitWindowPlan};
use fparkan_render_vulkan::{ use fparkan_render_vulkan::{
project_msh_to_static_mesh, VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, project_msh_to_static_mesh, VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome,
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport,
VulkanSmokeShutdownReport, VulkanStaticMesh, VulkanStaticTexture, VulkanValidationReport, VulkanSmokeShutdownReport, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanValidationReport,
}; };
use serde::Serialize; use serde::Serialize;
use std::path::PathBuf; use std::path::PathBuf;
@@ -56,7 +57,7 @@ fn main() {
fn run(args: &[String]) -> Result<String, String> { fn run(args: &[String]) -> Result<String, String> {
let options = SmokeOptions::parse(args)?; let options = SmokeOptions::parse(args)?;
let mesh = options.load_mesh()?; let mesh = options.load_mesh()?;
let texture = options.load_texture()?; let materials = options.load_materials(&mesh)?;
remove_stale_output(&options)?; remove_stale_output(&options)?;
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);
@@ -67,7 +68,7 @@ fn run(args: &[String]) -> Result<String, String> {
Arc::clone(&completed), Arc::clone(&completed),
Arc::clone(&progress), 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) { 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}"));
} }
@@ -131,10 +132,18 @@ struct WearMaterialInput {
material_index: u16, material_index: u16,
} }
#[derive(Clone, Debug, Eq, PartialEq)]
struct WearInput {
root: PathBuf,
archive: String,
name: String,
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
enum TextureInput { enum TextureInput {
Direct(ResourceInput), Direct(ResourceInput),
WearMaterial(WearMaterialInput), WearMaterial(WearMaterialInput),
WearBatchMaterials(WearInput),
} }
impl TextureInput { impl TextureInput {
@@ -142,13 +151,14 @@ impl TextureInput {
match self { match self {
Self::Direct(_) => "original-texm", Self::Direct(_) => "original-texm",
Self::WearMaterial(_) => "original-wear-mat0-texm", Self::WearMaterial(_) => "original-wear-mat0-texm",
Self::WearBatchMaterials(_) => "original-wear-mat0-texm-batch",
} }
} }
fn archive(&self) -> &str { fn archive(&self) -> &str {
match self { match self {
Self::Direct(input) => &input.archive, Self::Direct(input) => &input.archive,
Self::WearMaterial(_) => "Textures.lib", Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "Textures.lib",
} }
} }
@@ -156,7 +166,7 @@ impl TextureInput {
match self { match self {
Self::Direct(input) => &input.name, Self::Direct(input) => &input.name,
// The selected name comes from MAT0 at load time and is not a CLI request. // 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, material_index,
})) }))
} }
(Some(root), Some(archive), Some(name), None) => {
Some(TextureInput::WearBatchMaterials(WearInput {
root,
archive,
name,
}))
}
_ => { _ => {
return Err( 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(), .to_string(),
); );
} }
@@ -400,42 +417,93 @@ impl SmokeOptions {
} }
} }
fn load_texture(&self) -> Result<Option<LoadedTexture>, String> { fn load_materials(&self, mesh: &VulkanStaticMesh) -> Result<Vec<LoadedTexture>, String> {
self.texture_input.as_ref().map_or(Ok(None), |input| { let Some(input) = self.texture_input.as_ref() else {
let (image, selected_name) = match input { return Ok(Vec::new());
TextureInput::Direct(input) => ( };
fparkan_inspection::load_texture_mip0_rgba8_from_root( match input {
TextureInput::Direct(input) => Ok(vec![Self::load_material(
0,
{
let image = fparkan_inspection::load_texture_mip0_rgba8_from_root(
&input.root, &input.root,
&input.archive, &input.archive,
&input.name, &input.name,
)?, )?;
input.name.clone(), (image.width, image.height, image.rgba8)
),
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,
}, },
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::<Vec<_>>();
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<u8>),
selected_name: String,
) -> LoadedTexture {
LoadedTexture {
material_index,
texture: VulkanStaticTexture {
width: image.0,
height: image.1,
rgba8: image.2,
},
selected_name,
}
} }
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct LoadedTexture { struct LoadedTexture {
material_index: u16,
texture: VulkanStaticTexture, texture: VulkanStaticTexture,
selected_name: String, selected_name: String,
} }
@@ -443,7 +511,7 @@ struct LoadedTexture {
struct SmokeApp { struct SmokeApp {
options: SmokeOptions, options: SmokeOptions,
mesh: VulkanStaticMesh, mesh: VulkanStaticMesh,
texture: Option<LoadedTexture>, materials: Vec<LoadedTexture>,
completed: Arc<AtomicBool>, completed: Arc<AtomicBool>,
progress: Arc<SharedSmokeProgress>, progress: Arc<SharedSmokeProgress>,
window_id: Option<WindowId>, window_id: Option<WindowId>,
@@ -488,14 +556,14 @@ impl SmokeApp {
fn new( fn new(
options: SmokeOptions, options: SmokeOptions,
mesh: VulkanStaticMesh, mesh: VulkanStaticMesh,
texture: Option<LoadedTexture>, materials: Vec<LoadedTexture>,
completed: Arc<AtomicBool>, completed: Arc<AtomicBool>,
progress: Arc<SharedSmokeProgress>, progress: Arc<SharedSmokeProgress>,
) -> Self { ) -> Self {
Self { Self {
options, options,
mesh, mesh,
texture, materials,
completed, completed,
progress, progress,
window_id: None, window_id: None,
@@ -603,23 +671,35 @@ impl SmokeApp {
.texture_input .texture_input
.as_ref() .as_ref()
.map_or("", TextureInput::archive), .map_or("", TextureInput::archive),
texture_name: self.texture.as_ref().map_or_else( texture_name: match self.materials.as_slice() {
|| { [] => self
self.options .options
.texture_input .texture_input
.as_ref() .as_ref()
.map_or("", TextureInput::requested_name) .map_or("", TextureInput::requested_name),
}, [material] => material.selected_name.as_str(),
|texture| texture.selected_name.as_str(), _ => "multiple",
), },
texture_width: self texture_width: self
.texture .materials
.as_ref() .first()
.map_or(0, |texture| texture.texture.width), .map_or(0, |material| material.texture.width),
texture_height: self texture_height: self
.texture .materials
.as_ref() .first()
.map_or(0, |texture| texture.texture.height), .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 shader_manifest_hash: renderer
.as_ref() .as_ref()
.map_or("", |snapshot| snapshot.report.shader_manifest_hash.as_str()), .map_or("", |snapshot| snapshot.report.shader_manifest_hash.as_str()),
@@ -839,6 +919,9 @@ fn render_timeout_failure_report(
.map_or("", TextureInput::requested_name), .map_or("", TextureInput::requested_name),
texture_width: 0, texture_width: 0,
texture_height: 0, texture_height: 0,
material_binding_count: 0,
mesh_material_indices: Vec::new(),
material_texture_names: Vec::new(),
shader_manifest_hash: "", shader_manifest_hash: "",
vulkan_loader_status: if bootstrap.loader_available { vulkan_loader_status: if bootstrap.loader_available {
"available" "available"
@@ -934,7 +1017,14 @@ impl ApplicationHandler for SmokeApp {
render_request: RenderRequest::conservative(), render_request: RenderRequest::conservative(),
enable_validation: true, enable_validation: true,
mesh: self.mesh.clone(), 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)), bootstrap_progress: Some(Arc::clone(&self.progress.bootstrap)),
}) { }) {
Ok(renderer) => renderer, Ok(renderer) => renderer,
@@ -1073,6 +1163,9 @@ struct SmokeReport<'a> {
texture_name: &'a str, texture_name: &'a str,
texture_width: u32, texture_width: u32,
texture_height: u32, texture_height: u32,
material_binding_count: usize,
mesh_material_indices: Vec<u16>,
material_texture_names: Vec<String>,
shader_manifest_hash: &'a str, shader_manifest_hash: &'a str,
vulkan_loader_status: &'a str, vulkan_loader_status: &'a str,
vulkan_instance_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] #[test]
fn rejects_deprecated_self_assertion_flags() { fn rejects_deprecated_self_assertion_flags() {
for flag in [ for flag in [
@@ -1533,6 +1648,9 @@ mod tests {
texture_name: "DEFAULT.0", texture_name: "DEFAULT.0",
texture_width: 16, texture_width: 16,
texture_height: 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", shader_manifest_hash: "deadbeef",
vulkan_loader_status: "available", vulkan_loader_status: "available",
vulkan_instance_status: "created", vulkan_instance_status: "created",
@@ -1585,7 +1703,7 @@ mod tests {
texture_input: None, texture_input: None,
}, },
mesh: VulkanStaticMesh::smoke_triangle(), mesh: VulkanStaticMesh::smoke_triangle(),
texture: None, materials: Vec::new(),
completed: Arc::new(AtomicBool::new(false)), completed: Arc::new(AtomicBool::new(false)),
progress: Arc::new(SharedSmokeProgress::default()), progress: Arc::new(SharedSmokeProgress::default()),
window_id: None, window_id: None,
+1 -1
View File
@@ -29,7 +29,7 @@
## Current repository status ## Current repository status
- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static asset bridge. Помимо direct TEXM input, `fparkan-vulkan-smoke --model-root <GOG_ROOT> --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root <GOG_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 поверх - `apps/fparkan-game` сейчас выдает `render-planning` JSON report поверх
synthetic window descriptor и `VulkanPlanningBackend`. synthetic window descriptor и `VulkanPlanningBackend`.
- `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan - `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan
+1 -1
View File
@@ -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 <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_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-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 <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_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 <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_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-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 <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_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-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 <GOG_ROOT> --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root <GOG_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 <GOG_ROOT> --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root <GOG_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-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-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 <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH; validation_warning_count=0 and validation_error_count=0 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 <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH; validation_warning_count=0 and validation_error_count=0
1 # Acceptance coverage manifest.
298 S3-VK-TEXM-UPLOAD-001
299 S3-VK-DESCRIPTOR-001
300 S3-VK-PIPELINE-001
301 S3-VK-DRAW-MODEL-001
302 S3-VK-DRAW-TERRAIN-001
303 S3-VK-PIXEL-CAPTURE-001
304 S3-VK-VALIDATION-001