From 1afe1698db5e9b3378f5087890c8177201a84789 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 08:18:39 +0400 Subject: [PATCH] feat(render): add deterministic pipeline state keys --- .../fparkan-render-vulkan/src/ffi/tests.rs | 5 +- apps/fparkan-game/src/main.rs | 1 + apps/fparkan-viewer/src/main.rs | 1 + crates/fparkan-render/src/lib.rs | 137 +++++++++++++++++- docs/tomes/05-render.md | 18 +++ 5 files changed, 157 insertions(+), 5 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs index 731fb91..6cf070e 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs @@ -10,7 +10,8 @@ use crate::shader_manifest::{ use crate::*; use fparkan_platform::{DepthStencilSupport, RenderRequest}; use fparkan_render::{ - DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase, + DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, LegacyPipelineState, RenderCommand, + RenderPhase, }; use fparkan_render::{RenderBackend, RenderError}; @@ -35,6 +36,7 @@ fn planning_backend_tracks_render_request_and_simulated_present() -> Result<(), object_id: None, mesh: GpuMeshId(1), material: GpuMaterialId(2), + pipeline_key: LegacyPipelineState::default().into(), transform: [1.0; 16], range: IndexRange { start: 0, count: 3 }, stable_order: 7, @@ -75,6 +77,7 @@ fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> { object_id: None, mesh: GpuMeshId(1), material: GpuMaterialId(2), + pipeline_key: LegacyPipelineState::default().into(), transform: [1.0; 16], range: IndexRange { start: 0, count: 3 }, stable_order: 7, diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index d7d1336..ca245c1 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -162,6 +162,7 @@ fn render_snapshot_with_assets( mesh, material_slots: vec![material], material_index: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), transform: identity_transform(index_to_f32(index)), range: IndexRange { start: 0, count: 3 }, stable_order, diff --git a/apps/fparkan-viewer/src/main.rs b/apps/fparkan-viewer/src/main.rs index 6befe2a..5b4e26e 100644 --- a/apps/fparkan-viewer/src/main.rs +++ b/apps/fparkan-viewer/src/main.rs @@ -122,6 +122,7 @@ impl ViewerModelService { mesh: GpuMeshId(1), material_slots: vec![GpuMaterialId(7)], material_index: 0, + pipeline_state: fparkan_render::LegacyPipelineState::default(), transform: identity_transform(), range: IndexRange { start: 0, count: 3 }, stable_order: 0, diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index 79e5ae5..4f2192d 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -71,6 +71,93 @@ pub enum RenderPhase { Ui, } +/// Fixed-function blend behaviour represented without a graphics API type. +/// +/// This is a compatibility contract, not yet a decoded MAT0 mapping. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LegacyBlendMode { + /// Do not blend the fragment with the existing colour. + #[default] + Opaque, + /// Blend using source alpha. + SourceAlpha, +} + +/// Depth-buffer behaviour represented without a graphics API type. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LegacyDepthMode { + /// No depth attachment is used. + #[default] + Disabled, + /// Test depth and write passing fragments. + TestWrite, + /// Test depth without modifying it. + TestReadOnly, +} + +/// Triangle culling behaviour represented without a graphics API type. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LegacyCullMode { + /// Keep both front- and back-facing triangles. + #[default] + Disabled, + /// Cull back-facing triangles. + BackFace, + /// Cull front-facing triangles. + FrontFace, +} + +/// Legacy fixed-function state that changes graphics-pipeline structure. +/// +/// Alpha reference is deliberately not present: it is dynamic material data, +/// whereas this state records only whether an alpha-test shader variant is used. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct LegacyPipelineState { + /// Colour blend mode. + pub blend: LegacyBlendMode, + /// Depth test/write mode. + pub depth: LegacyDepthMode, + /// Face culling mode. + pub cull: LegacyCullMode, + /// Whether alpha-test shader logic is enabled. + pub alpha_test: bool, +} + +/// Canonical, backend-neutral key for a graphics-pipeline variant. +/// +/// The value is explicitly packed rather than hashed, so captures and caches +/// remain stable across processes and Rust toolchain updates. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PipelineKey(u8); + +impl PipelineKey { + /// Returns the canonical packed representation. + #[must_use] + pub const fn packed(self) -> u8 { + self.0 + } +} + +impl From for PipelineKey { + fn from(state: LegacyPipelineState) -> Self { + let blend = match state.blend { + LegacyBlendMode::Opaque => 0, + LegacyBlendMode::SourceAlpha => 1, + }; + let depth = match state.depth { + LegacyDepthMode::Disabled => 0, + LegacyDepthMode::TestWrite => 1, + LegacyDepthMode::TestReadOnly => 2, + }; + let cull = match state.cull { + LegacyCullMode::Disabled => 0, + LegacyCullMode::BackFace => 1, + LegacyCullMode::FrontFace => 2, + }; + Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5)) + } +} + /// Index range. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct IndexRange { @@ -95,6 +182,8 @@ pub struct RenderSnapshotDraw { pub material_slots: Vec, /// Batch material index into [`Self::material_slots`]. pub material_index: u16, + /// Fixed-function state resolved for this draw. + pub pipeline_state: LegacyPipelineState, /// Node transform matrix, row-major. pub transform: [f32; 16], /// Index range. @@ -132,6 +221,8 @@ pub struct DrawCommand { pub mesh: GpuMeshId, /// Material. pub material: GpuMaterialId, + /// Canonical graphics-pipeline variant. + pub pipeline_key: PipelineKey, /// Transform matrix, row-major. pub transform: [f32; 16], /// Index range. @@ -369,6 +460,7 @@ pub fn build_commands( object_id: draw.object_id, mesh: draw.mesh, material, + pipeline_key: draw.pipeline_state.into(), transform: draw.transform, range: draw.range, stable_order: draw.stable_order, @@ -453,8 +545,13 @@ pub fn canonical_capture(commands: &RenderCommandList) -> Result, Render RenderCommand::Draw(draw) => { out.extend_from_slice( format!( - "D,{:?},{},{},{},{}\n", - draw.phase, draw.id.0, draw.mesh.0, draw.material.0, draw.stable_order + "D,{:?},{},{},{},{},{}\n", + draw.phase, + draw.id.0, + draw.mesh.0, + draw.material.0, + draw.pipeline_key.packed(), + draw.stable_order ) .as_bytes(), ); @@ -618,6 +715,7 @@ mod tests { mesh: GpuMeshId(10 + id), material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)], material_index, + pipeline_state: LegacyPipelineState::default(), transform: identity_transform(), range: IndexRange { start: 0, count: 3 }, stable_order, @@ -635,6 +733,7 @@ mod tests { object_id: None, mesh: GpuMeshId(2), material: GpuMaterialId(3), + pipeline_key: LegacyPipelineState::default().into(), transform: [0.0; 16], range: IndexRange { start: 0, count: 3 }, stable_order: 4, @@ -644,10 +743,35 @@ mod tests { }; assert_eq!( canonical_capture(&list).expect("capture"), - b"B\nD,Opaque,1,2,3,4\nE\n" + b"B\nD,Opaque,1,2,3,0,4\nE\n" ); } + #[test] + fn pipeline_key_is_explicit_stable_and_sensitive_to_pipeline_structure() { + let base = LegacyPipelineState::default(); + assert_eq!(PipelineKey::from(base).packed(), 0); + + let variant = LegacyPipelineState { + blend: LegacyBlendMode::SourceAlpha, + depth: LegacyDepthMode::TestReadOnly, + cull: LegacyCullMode::BackFace, + alpha_test: true, + }; + assert_eq!(PipelineKey::from(variant).packed(), 0b00_101_101); + assert_ne!(PipelineKey::from(base), PipelineKey::from(variant)); + } + + #[test] + fn alpha_test_flag_changes_key_without_encoding_material_threshold() { + let opaque = LegacyPipelineState::default(); + let alpha_test = LegacyPipelineState { + alpha_test: true, + ..opaque + }; + assert_eq!(PipelineKey::from(alpha_test).packed(), 0b10_0000); + } + #[test] fn null_backend_validates_without_capture() { let mut backend = NullBackend; @@ -660,6 +784,7 @@ mod tests { object_id: None, mesh: GpuMeshId(2), material: GpuMaterialId(3), + pipeline_key: LegacyPipelineState::default().into(), transform: [0.0; 16], range: IndexRange { start: 0, count: 0 }, stable_order: 4, @@ -762,7 +887,7 @@ mod tests { assert_eq!( capture, - b"B\nD,Opaque,1,11,31,10\nD,Opaque,2,12,31,20\nD,Transparent,3,13,31,0\nE\n" + b"B\nD,Opaque,1,11,31,0,10\nD,Opaque,2,12,31,0,20\nD,Transparent,3,13,31,0,0\nE\n" ); Ok(()) } @@ -868,6 +993,7 @@ mod tests { object_id: None, mesh: GpuMeshId(2), material: GpuMaterialId(3), + pipeline_key: LegacyPipelineState::default().into(), transform: identity_transform(), range: IndexRange { start: u32::MAX, @@ -922,6 +1048,7 @@ mod tests { object_id: None, mesh: GpuMeshId(2), material: GpuMaterialId(3), + pipeline_key: LegacyPipelineState::default().into(), transform: identity_transform(), range: IndexRange { start: 14, @@ -956,6 +1083,7 @@ mod tests { object_id: None, mesh: GpuMeshId(1), material: GpuMaterialId(1), + pipeline_key: LegacyPipelineState::default().into(), transform: identity_transform(), range: IndexRange { start: 0, count: 3 }, stable_order: 0, @@ -966,6 +1094,7 @@ mod tests { object_id: None, mesh: GpuMeshId(1), material: GpuMaterialId(1), + pipeline_key: LegacyPipelineState::default().into(), transform: identity_transform(), range: IndexRange { start: 0, count: 3 }, stable_order: 0, diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 6804fa6..a508334 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -829,6 +829,24 @@ draw_indexed(item.batch.base_vertex, item.batch.index_count); ``` +## Текущий контракт pipeline key + +`fparkan-render` теперь переносит вместе с каждым backend-neutral draw +`LegacyPipelineState` и детерминированный `PipelineKey`. Ключ не хешируется: +он явно упаковывает blend mode, depth mode, cull mode и флаг alpha-test, поэтому +одинаковые snapshots дают одинаковый capture и будущий Vulkan cache не зависит +от версии 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. + После последнего world pass renderer закрывает сцену и выводит back buffer. World3D снимает `in_render`, восстанавливает временный viewport state и вызывает `on_end_render` у active objects. Только после этого допустимо освобождать