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 {
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,
},
]
);
}
}
+3 -2
View File
@@ -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;
+46 -26
View File
@@ -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() {
@@ -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<VulkanStaticTexture>,
/// 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<VulkanStaticMaterial>,
/// Optional shared bootstrap progress tracker for failure evidence.
pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>,
}
@@ -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<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 {
/// 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<VulkanSwapchainResources>,
pub(super) vertex_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_texture_indices: Vec<usize>,
pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize,
@@ -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<vk::DescriptorSet>,
pub(super) sampler: vk::Sampler,
pub(super) pipeline: vk::Pipeline,
pub(super) framebuffers: Vec<vk::Framebuffer>,
@@ -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<VulkanSwapchainResources, VulkanSmokeRendererError> {
// 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::DescriptorSet>,
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::DescriptorSet>,
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::<Vec<_>>();
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::<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)]