From 1afe1698db5e9b3378f5087890c8177201a84789 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 08:18:39 +0400 Subject: feat(render): add deterministic pipeline state keys --- crates/fparkan-render/src/lib.rs | 137 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 133 insertions(+), 4 deletions(-) (limited to 'crates/fparkan-render') 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, -- cgit v1.2.3