feat(render): add deterministic pipeline state keys
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m4s
Test / Test (push) Skipped
Test / Render parity (push) Skipped

This commit is contained in:
2026-07-18 08:18:39 +04:00
parent 2745b938d8
commit 1afe1698db
5 changed files with 157 additions and 5 deletions
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+133 -4
View File
@@ -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<LegacyPipelineState> 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<GpuMaterialId>,
/// 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<Vec<u8>, 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,
+18
View File
@@ -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. Только после этого допустимо освобождать