From e7c16641716b41b8682b05bb96f74f25707d9d93 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 08:22:29 +0400 Subject: [PATCH] feat(vulkan): cache pipeline variants by state key --- .../fparkan-render-vulkan/src/asset_mesh.rs | 5 + .../fparkan-render-vulkan/src/ffi/smoke.rs | 16 ++- .../src/ffi/smoke_types.rs | 30 ++++++ .../src/ffi/swapchain_resources.rs | 101 +++++++++++++++--- docs/tomes/05-render.md | 22 ++-- fixtures/acceptance/coverage.tsv | 2 +- 6 files changed, 146 insertions(+), 30 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index fdc378f..a449f6b 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -2,6 +2,7 @@ use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; use fparkan_msh::ModelAsset; +use fparkan_render::LegacyPipelineState; /// Error returned when a validated MSH cannot enter the current static GPU path. #[derive(Clone, Debug, Eq, PartialEq)] @@ -69,6 +70,7 @@ pub fn project_msh_to_static_mesh( first_index, index_count: u32::from(batch.index_count), material_index: batch.material_index, + pipeline_state: LegacyPipelineState::default(), }); } if indices.is_empty() { @@ -183,6 +185,7 @@ mod tests { first_index: 0, index_count: 3, material_index: 0, + pipeline_state: LegacyPipelineState::default(), }] ); assert_eq!(mesh.vertices[1].position, [-0.8, -0.8]); @@ -229,11 +232,13 @@ mod tests { first_index: 0, index_count: 3, material_index: 0, + pipeline_state: LegacyPipelineState::default(), }, VulkanStaticDrawRange { first_index: 3, index_count: 3, material_index: 7, + pipeline_state: LegacyPipelineState::default(), }, ] ); diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index 533ab1c..71af2a8 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -560,6 +560,7 @@ impl VulkanSmokeRenderer { self.vertex_buffer_ref()?, self.index_buffer_ref()?, self.textures_ref()?, + &self.draw_ranges, reuse_command_pool, )?, |resources| destroy_swapchain_resources(device, self.command_pool, resources), @@ -631,11 +632,6 @@ impl VulkanSmokeRenderer { &render_pass_info, vk::SubpassContents::INLINE, ); - device.device().cmd_bind_pipeline( - command_buffer, - vk::PipelineBindPoint::GRAPHICS, - resources.pipeline, - ); let vertex_buffers = [self.vertex_buffer_ref()?.buffer]; let offsets = [0_u64]; device @@ -648,6 +644,16 @@ impl VulkanSmokeRenderer { vk::IndexType::UINT16, ); for (range, &texture_index) in self.draw_ranges.iter().zip(&self.draw_texture_indices) { + let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or( + VulkanSmokeRendererError::InvariantViolation { + context: "static draw pipeline variant", + }, + )?; + device.device().cmd_bind_pipeline( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + *pipeline, + ); let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or( VulkanSmokeRendererError::InvariantViolation { context: "static material descriptor set", diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index da2694e..97ee54a 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs @@ -1,5 +1,6 @@ use ash::vk; use fparkan_platform::{NativeWindowHandles, RenderRequest}; +use fparkan_render::{LegacyPipelineState, PipelineKey}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; @@ -70,6 +71,16 @@ pub struct VulkanStaticDrawRange { pub index_count: u32, /// Original positional `Batch20.material_index` selector. pub material_index: u16, + /// Backend-neutral fixed-function state for this source range. + pub pipeline_state: LegacyPipelineState, +} + +impl VulkanStaticDrawRange { + /// Returns the canonical key used for Vulkan pipeline selection. + #[must_use] + pub fn pipeline_key(self) -> PipelineKey { + self.pipeline_state.into() + } } /// One diffuse material texture keyed by an original MSH batch selector. @@ -173,6 +184,7 @@ impl VulkanStaticMesh { first_index: 0, index_count: 3, material_index: 0, + pipeline_state: LegacyPipelineState::default(), }], } } @@ -239,6 +251,7 @@ mod static_mesh_tests { first_index: 0, index_count: 2, material_index: 0, + pipeline_state: LegacyPipelineState::default(), }], }; let out_of_range_index = VulkanStaticMesh { @@ -265,11 +278,13 @@ mod static_mesh_tests { first_index: 0, index_count: 3, material_index: 7, + pipeline_state: LegacyPipelineState::default(), }, VulkanStaticDrawRange { first_index: 3, index_count: 3, material_index: 2, + pipeline_state: LegacyPipelineState::default(), }, ]; let texture = || VulkanStaticTexture { @@ -293,6 +308,21 @@ mod static_mesh_tests { Ok(vec![1, 0]) ); } + + #[test] + fn draw_range_pipeline_key_follows_backend_neutral_state() { + let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0]; + let blended = VulkanStaticDrawRange { + pipeline_state: LegacyPipelineState { + blend: fparkan_render::LegacyBlendMode::SourceAlpha, + ..LegacyPipelineState::default() + }, + ..base + }; + + assert_eq!(base.pipeline_key().packed(), 0); + assert_ne!(base.pipeline_key(), blended.pipeline_key()); + } } /// Shared bootstrap progress used to report partial renderer startup evidence. diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs index 8dbbc27..07c2869 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -1,6 +1,10 @@ #![allow(unsafe_code)] use ash::vk; +use fparkan_render::{ + LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState, PipelineKey, +}; +use std::collections::BTreeMap; use super::{ color_subresource_range, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanLogicalDeviceProbe, @@ -16,7 +20,8 @@ pub(super) struct VulkanSwapchainResources { pub(super) descriptor_pool: vk::DescriptorPool, pub(super) descriptor_sets: Vec, pub(super) sampler: vk::Sampler, - pub(super) pipeline: vk::Pipeline, + /// Live graphics pipelines indexed by canonical backend-neutral state. + pub(super) pipelines: BTreeMap, pub(super) framebuffers: Vec, pub(super) command_buffers: Vec, } @@ -28,11 +33,12 @@ struct PartialSwapchainResources { descriptor_set_layout: Option, descriptor_pool: Option, sampler: Option, - pipeline: Option, + pipelines: BTreeMap, framebuffers: Vec, command_buffers: Vec, } +#[allow(clippy::too_many_arguments)] pub(super) fn create_swapchain_resources( device: &VulkanLogicalDeviceProbe, swapchain: &VulkanSwapchainProbe, @@ -40,6 +46,7 @@ pub(super) fn create_swapchain_resources( vertex_buffer: &VulkanAllocatedBuffer, index_buffer: &VulkanAllocatedBuffer, textures: &[VulkanAllocatedImage], + draw_ranges: &[super::VulkanStaticDrawRange], reuse_command_pool: bool, ) -> Result { // SAFETY: The swapchain is live and owned by this renderer for the duration of the query. @@ -63,14 +70,14 @@ pub(super) fn create_swapchain_resources( descriptor_set_layout: None, descriptor_pool: None, sampler: None, - pipeline: None, + pipelines: BTreeMap::new(), framebuffers: Vec::new(), command_buffers: Vec::new(), }; let ( render_pass, pipeline_layout, - pipeline, + pipelines, descriptor_set_layout, descriptor_pool, descriptor_sets, @@ -80,6 +87,7 @@ pub(super) fn create_swapchain_resources( swapchain.report.plan.format.format, swapchain.report.plan.extent, textures, + draw_ranges, ) { Ok(bundle) => bundle, Err(error) => { @@ -92,7 +100,7 @@ pub(super) fn create_swapchain_resources( partial.descriptor_set_layout = Some(descriptor_set_layout); partial.descriptor_pool = Some(descriptor_pool); partial.sampler = Some(sampler); - partial.pipeline = Some(pipeline); + partial.pipelines = pipelines; let framebuffers = match create_swapchain_framebuffers( device, render_pass, @@ -128,7 +136,7 @@ pub(super) fn create_swapchain_resources( descriptor_pool, descriptor_sets, sampler, - pipeline, + pipelines: partial.pipelines, framebuffers: partial.framebuffers, command_buffers: partial.command_buffers, }) @@ -152,11 +160,12 @@ fn create_swapchain_pipeline_bundle( format: i32, extent: (u32, u32), textures: &[VulkanAllocatedImage], + draw_ranges: &[super::VulkanStaticDrawRange], ) -> Result< ( vk::RenderPass, vk::PipelineLayout, - vk::Pipeline, + BTreeMap, vk::DescriptorSetLayout, vk::DescriptorPool, Vec, @@ -184,8 +193,9 @@ fn create_swapchain_pipeline_bundle( device.device().destroy_render_pass(render_pass, None); } })?; - let pipeline = create_graphics_pipeline(device, render_pass, pipeline_layout, extent) - .inspect_err(|_| { + let pipelines = + create_graphics_pipeline_cache(device, render_pass, pipeline_layout, extent, draw_ranges) + .inspect_err(|_| { // SAFETY: These objects were created above on this live logical device and are destroyed on setup failure. unsafe { device @@ -204,7 +214,7 @@ fn create_swapchain_pipeline_bundle( Ok(( render_pass, pipeline_layout, - pipeline, + pipelines, descriptor_set_layout, descriptor_pool, descriptor_sets, @@ -457,13 +467,62 @@ fn extent_component_to_f32(value: u32) -> f32 { value as f32 } +#[allow(clippy::too_many_lines)] +fn create_graphics_pipeline_cache( + device: &VulkanLogicalDeviceProbe, + render_pass: vk::RenderPass, + pipeline_layout: vk::PipelineLayout, + extent: (u32, u32), + draw_ranges: &[super::VulkanStaticDrawRange], +) -> Result, VulkanSmokeRendererError> { + let mut pipelines = BTreeMap::new(); + for range in draw_ranges { + let key = range.pipeline_key(); + if pipelines.contains_key(&key) { + continue; + } + let pipeline = match create_graphics_pipeline( + device, + render_pass, + pipeline_layout, + extent, + range.pipeline_state, + ) { + Ok(pipeline) => pipeline, + Err(error) => { + // SAFETY: All previously created pipelines belong to this live device and are rolled back with their bundle. + unsafe { + for pipeline in pipelines.into_values() { + device.device().destroy_pipeline(pipeline, None); + } + } + return Err(error); + } + }; + pipelines.insert(key, pipeline); + } + Ok(pipelines) +} + #[allow(clippy::too_many_lines)] fn create_graphics_pipeline( device: &VulkanLogicalDeviceProbe, render_pass: vk::RenderPass, pipeline_layout: vk::PipelineLayout, extent: (u32, u32), + state: LegacyPipelineState, ) -> Result { + if state.depth != LegacyDepthMode::Disabled { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: "static renderer has no depth attachment for requested pipeline state", + }); + } + if state.alpha_test { + return Err(VulkanSmokeRendererError::InvalidStaticMesh { + context: + "static renderer has no alpha-test shader variant for requested pipeline state", + }); + } let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?; let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) { Ok(module) => module, @@ -535,9 +594,11 @@ fn create_graphics_pipeline( .rasterizer_discard_enable(false) .polygon_mode(vk::PolygonMode::FILL) .line_width(1.0) - // Static MSH viewer mode has no original material/cull state yet; draw - // both windings so every source batch is visible during the bridge. - .cull_mode(vk::CullModeFlags::NONE) + .cull_mode(match state.cull { + LegacyCullMode::Disabled => vk::CullModeFlags::NONE, + LegacyCullMode::BackFace => vk::CullModeFlags::BACK, + LegacyCullMode::FrontFace => vk::CullModeFlags::FRONT, + }) .front_face(vk::FrontFace::CLOCKWISE) .depth_bias_enable(false); let multisample_state = vk::PipelineMultisampleStateCreateInfo::default() @@ -550,7 +611,13 @@ fn create_graphics_pipeline( | vk::ColorComponentFlags::B | vk::ColorComponentFlags::A, ) - .blend_enable(false); + .blend_enable(state.blend == LegacyBlendMode::SourceAlpha) + .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA) + .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) + .color_blend_op(vk::BlendOp::ADD) + .src_alpha_blend_factor(vk::BlendFactor::ONE) + .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) + .alpha_blend_op(vk::BlendOp::ADD); let color_blend_attachments = [color_blend_attachment]; let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default() .logic_op_enable(false) @@ -655,7 +722,9 @@ pub(super) fn destroy_swapchain_resources( for framebuffer in resources.framebuffers { device.device().destroy_framebuffer(framebuffer, None); } - device.device().destroy_pipeline(resources.pipeline, None); + for pipeline in resources.pipelines.into_values() { + device.device().destroy_pipeline(pipeline, None); + } device .device() .destroy_pipeline_layout(resources.pipeline_layout, None); @@ -690,7 +759,7 @@ fn destroy_partial_swapchain_resources( for framebuffer in partial.framebuffers { device.device().destroy_framebuffer(framebuffer, None); } - if let Some(pipeline) = partial.pipeline { + for pipeline in partial.pipelines.into_values() { device.device().destroy_pipeline(pipeline, None); } if let Some(pipeline_layout) = partial.pipeline_layout { diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index a508334..ccbc578 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -838,14 +838,20 @@ draw_indexed(item.batch.base_vertex, от версии Rust или процесса. Alpha reference намеренно не входит в key: это dynamic material constant, а не структурный вариант graphics pipeline. -Пока текущий статический Vulkan viewer создаёт только базовое состояние -`opaque + depth disabled + cull disabled + alpha-test disabled`; ключ уже -передаётся через render command и canonical capture, но Vulkan pipeline cache -ещё не выбирает variants по нему. Также не установлен источник значений для -Batch20/MAT0: их поля нельзя объявлять blend/depth/cull mapping без dynamic -capture или дополнительного дизассемблирования. Поэтому этот контракт — -подготовленная граница совместимости, а не заявление о готовой parity -fixed-function state. +Текущий static Vulkan viewer дедуплицирует состояния ranges при каждой сборке +swapchain resources, создаёт один `vk::Pipeline` на уникальный `PipelineKey` и +выбирает его непосредственно перед соответствующим `vkCmdDrawIndexed`. +Кэш корректно уничтожается при recreate/teardown; setup failure откатывает уже +созданные variants. Baseline оригинального MSH подтверждён с состоянием +`opaque + depth disabled + cull disabled + alpha-test disabled`. + +`SourceAlpha` и front/back culling имеют Vulkan mapping в этом кэше. Запрос +depth или alpha-test сейчас завершается явной ошибкой: renderer ещё не имеет +depth attachment либо alpha-test shader variant, поэтому он не должен молча +подменять state. Также не установлен источник значений для Batch20/MAT0: их +поля нельзя объявлять blend/depth/cull mapping без dynamic capture или +дополнительного дизассемблирования. Это частично реализованная compatibility +boundary, а не заявление о готовой parity fixed-function state. После последнего world pass renderer закрывает сцену и выводит back buffer. World3D снимает `in_render`, восстанавливает временный viewport state и вызывает diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index e9e3bb9..6177c75 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -297,7 +297,7 @@ S2-PLANNING-RENDER-009 covered cargo xtask policy S3-VK-MESH-UPLOAD-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; original MSH reports 128 vertices and 252 indices 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-PIPELINE-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-pipeline-key-cache-smoke.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; PipelineKey selects a deduplicated live Vulkan pipeline per static draw range (baseline key only); unproven Batch20/MAT0 state mapping and unsupported depth/alpha-test variants remain explicit gaps 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