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()
let image_infos = textures
.iter()
.map(|texture| {
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()
.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(&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))
.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)]
+158 -40
View File
@@ -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<String, String> {
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<String, String> {
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<Option<LoadedTexture>, 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<Vec<LoadedTexture>, 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,
)?,
)?;
(image.width, image.height, image.rgba8)
},
input.name.clone(),
),
)]),
TextureInput::WearMaterial(input) => {
let selected =
fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root(
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(vec![Self::load_material(
input.material_index,
(
selected.image.width,
selected.image.height,
selected.image.rgba8,
),
selected.texture_name,
)])
}
};
Ok(Some(LoadedTexture {
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.width,
height: image.height,
rgba8: image.rgba8,
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<LoadedTexture>,
materials: Vec<LoadedTexture>,
completed: Arc<AtomicBool>,
progress: Arc<SharedSmokeProgress>,
window_id: Option<WindowId>,
@@ -488,14 +556,14 @@ impl SmokeApp {
fn new(
options: SmokeOptions,
mesh: VulkanStaticMesh,
texture: Option<LoadedTexture>,
materials: Vec<LoadedTexture>,
completed: Arc<AtomicBool>,
progress: Arc<SharedSmokeProgress>,
) -> 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_name: match self.materials.as_slice() {
[] => self
.options
.texture_input
.as_ref()
.map_or("", TextureInput::requested_name)
.map_or("", TextureInput::requested_name),
[material] => material.selected_name.as_str(),
_ => "multiple",
},
|texture| texture.selected_name.as_str(),
),
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<u16>,
material_texture_names: Vec<String>,
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,
+1 -1
View File
@@ -29,7 +29,7 @@
## 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 поверх
synthetic window descriptor и `VulkanPlanningBackend`.
- `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-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-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-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
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