From 218cb9420a01ff2b65f3019c5e0c51a4edbe6fc0 Mon Sep 17 00:00:00 2001 From: Jonathan Fontanez Date: Mon, 17 Aug 2026 15:41:19 -0400 Subject: [PATCH 1/3] feat(engine): graphics and ray-tracing bindings carry the shader's own names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflection already hands `DescriptorInfo.name` to both multi-stage paths and both discarded it. Compute stopped discarding it in #1882; this brings the other two pipeline kinds to the same spelling, which is what a by-name draw or trace resolves against. `GraphicsBindingSpec` and `RayTracingBindingSpec` gain the `Option>` name compute's carries, their construction adopts the reflected name the way `VulkanComputeKernel` does, and each kind gains the declaration type and reflection-derive its escalate handler will build from. The reconciliation is shared rather than written twice: graphics and ray tracing differ only in which two newtypes they name, so both map into one view and one check. Compute keeps its own — it has no stage axis, which is the whole complication. Stage masks gain the from-bits constructors a wire-supplied u32 needs, refusing an unknown bit rather than masking it off. The test-fixture shaders keep their binding names too. The engine's own build.rs already applies `-g` uniformly for exactly this reason; a fixture compiled without it would be the one blob in the tree whose bindings cannot be bound. Refs #1777 Co-Authored-By: Claude Opus 5 (1M context) --- packages/test-fixtures/build.rs | 10 + .../src/core/rhi/compute_kernel.rs | 14 +- .../src/core/rhi/graphics_kernel.rs | 179 +++++++- .../src/core/rhi/kernel_binding_names.rs | 395 ++++++++++++++++++ runtime/streamlib-engine/src/core/rhi/mod.rs | 28 +- .../src/core/rhi/ray_tracing_kernel.rs | 291 ++++++++++++- .../src/vulkan/rhi/vulkan_graphics_kernel.rs | 55 ++- .../vulkan/rhi/vulkan_ray_tracing_kernel.rs | 87 ++-- 8 files changed, 982 insertions(+), 77 deletions(-) create mode 100644 runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs diff --git a/packages/test-fixtures/build.rs b/packages/test-fixtures/build.rs index c6c109b57..d421f62cb 100644 --- a/packages/test-fixtures/build.rs +++ b/packages/test-fixtures/build.rs @@ -12,6 +12,13 @@ fn main() { } } +/// `glslc -O` strips every `OpName`, and a binding with no reflected name +/// cannot be dispatched against by name. The engine's own `build.rs` applies +/// this uniformly for that reason; a fixture compiled without it would be the +/// one blob in the tree whose bindings cannot be bound. +#[cfg(target_os = "linux")] +const KEEP_BINDING_NAMES: &str = "-g"; + #[cfg(target_os = "linux")] fn compile_cpu_ref_doubler() { use std::path::{Path, PathBuf}; @@ -23,6 +30,7 @@ fn compile_cpu_ref_doubler() { let dst: PathBuf = Path::new(&out_dir).join("cpu_ref_doubler.spv"); let status = Command::new("glslc") .arg("-fshader-stage=compute") + .arg(KEEP_BINDING_NAMES) .arg("-O") .arg(Path::new(src)) .arg("-o") @@ -57,6 +65,7 @@ fn compile_graphics_kernel_smoke() { let dst: PathBuf = Path::new(&out_dir).join(dst_name); let status = Command::new("glslc") .arg(stage_arg) + .arg(KEEP_BINDING_NAMES) .arg("-O") .arg(Path::new(src)) .arg("-o") @@ -104,6 +113,7 @@ fn compile_ray_tracing_kernel_smoke() { .arg(stage_arg) .arg("--target-env=vulkan1.2") .arg("--target-spv=spv1.4") + .arg(KEEP_BINDING_NAMES) .arg("-O") .arg(Path::new(src)) .arg("-o") diff --git a/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs b/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs index 79f944792..206283f69 100644 --- a/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs @@ -14,6 +14,8 @@ use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; use crate::core::{Error, Result}; +use super::kernel_binding_names::quote_declared_shader_binding_names; + /// Kind of resource bound at a particular slot in a compute kernel's /// descriptor set. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -88,18 +90,6 @@ pub struct ComputeBindingDeclaration { pub kind: ComputeBindingKind, } -/// Render a shader's declared binding names for an error message. -pub(crate) fn quote_declared_shader_binding_names(names: &[&str]) -> String { - if names.is_empty() { - return "no named bindings".to_string(); - } - names - .iter() - .map(|name| format!("`{name}`")) - .collect::>() - .join(", ") -} - /// Check a caller's binding declarations against what the shader's reflection /// actually found, by name. /// diff --git a/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs b/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs index 97fc17512..a26f5d93d 100644 --- a/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs @@ -12,11 +12,17 @@ //! creation, validates the declaration matches, and from that point on the //! caller binds resources by slot via simple typed setters. +use std::borrow::Cow; + use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; use crate::core::{Error, Result}; use super::TextureFormat; +use super::kernel_binding_names::{ + KernelBindingUnderReconciliation, KernelShaderStageMask, + reconcile_staged_kernel_binding_declarations, +}; /// Shader stages that contribute to a graphics pipeline. /// @@ -52,6 +58,20 @@ impl GraphicsShaderStageFlags { pub const fn intersects(self, other: Self) -> bool { (self.0 & other.0) != 0 } + + /// The mask a raw bitmask names, or `None` if it names a bit no graphics + /// stage owns. + /// + /// An unknown bit is refused rather than masked off: a caller that set it + /// meant a stage, and dropping it silently would make the declaration + /// assert less than it said. + pub const fn from_bits(bits: u32) -> Option { + if bits & !Self::VERTEX_FRAGMENT.0 == 0 { + Some(Self(bits)) + } else { + None + } + } } impl std::ops::BitOr for GraphicsShaderStageFlags { @@ -104,12 +124,95 @@ pub enum GraphicsBindingKind { StorageImage, } -/// One binding declaration: (binding index, resource kind, visible stages). -#[derive(Debug, Clone, Copy)] +/// One binding declaration: (binding index, resource kind, visible stages, the +/// shader's own name for the binding). +/// +/// `name` is `None` on a declaration that asserts only slot, kind and stages, +/// and `Some` on every spec the RHI has reconciled against the shader: the +/// numeric binding is what the descriptor set is built from, the name is what a +/// by-name draw resolves against. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GraphicsBindingSpec { pub binding: u32, pub kind: GraphicsBindingKind, pub stages: GraphicsShaderStageFlags, + pub name: Option>, +} + +/// What a caller asserts about one graphics binding before reflection has +/// assigned it a slot: the shader's name for it, the kind expected there, and +/// the stages the caller believes read it. +/// +/// Deliberately not a [`GraphicsBindingSpec`]: a declaration has no slot, and +/// carrying a placeholder slot in a spec would put a meaningless number in a +/// field every resolved path treats as load-bearing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphicsBindingDeclaration { + pub name: String, + pub kind: GraphicsBindingKind, + pub stages: GraphicsShaderStageFlags, +} + +impl KernelShaderStageMask for GraphicsShaderStageFlags { + fn named_stages(self) -> Vec<&'static str> { + let mut names = Vec::new(); + if self.contains(Self::VERTEX) { + names.push("vertex"); + } + if self.contains(Self::FRAGMENT) { + names.push("fragment"); + } + names + } + + fn names_no_stage(self) -> bool { + self == Self::NONE + } + + fn stages_missing_from(self, available: Self) -> Vec<&'static str> { + Self(self.0 & !available.0).named_stages() + } + + fn contains_every_stage_in(self, other: Self) -> bool { + self.contains(other) + } +} + +/// Check a caller's graphics binding declarations against what the shaders' +/// reflection actually found, by name. +/// +/// Runs at kernel construction, where the multi-stage declaration is built — +/// which is the only place a stage mismatch can be caught, because a draw +/// never revisits which stage reads what. +pub(crate) fn reconcile_graphics_binding_declarations( + declared: &[GraphicsBindingDeclaration], + reflected: &[GraphicsBindingSpec], + stages_the_kernel_was_built_from: GraphicsShaderStageFlags, +) -> Result<()> { + let declared_view: Vec<_> = declared + .iter() + .map(|declaration| KernelBindingUnderReconciliation { + name: declaration.name.as_str(), + kind: declaration.kind, + stages: declaration.stages, + }) + .collect(); + let reflected_view: Vec<_> = reflected + .iter() + .filter_map(|spec| { + Some(KernelBindingUnderReconciliation { + name: spec.name.as_deref()?, + kind: spec.kind, + stages: spec.stages, + }) + }) + .collect(); + reconcile_staged_kernel_binding_declarations( + "graphics", + &declared_view, + &reflected_view, + stages_the_kernel_was_built_from, + ) } impl GraphicsBindingSpec { @@ -118,6 +221,7 @@ impl GraphicsBindingSpec { binding, kind: GraphicsBindingKind::SampledTexture, stages, + name: None, } } @@ -126,6 +230,7 @@ impl GraphicsBindingSpec { binding, kind: GraphicsBindingKind::StorageBuffer, stages, + name: None, } } @@ -134,6 +239,7 @@ impl GraphicsBindingSpec { binding, kind: GraphicsBindingKind::UniformBuffer, stages, + name: None, } } @@ -142,8 +248,16 @@ impl GraphicsBindingSpec { binding, kind: GraphicsBindingKind::StorageImage, stages, + name: None, } } + + /// Assert the shader's spelling of this binding as well as its slot. + #[must_use] + pub fn with_name(mut self, name: impl Into>) -> Self { + self.name = Some(name.into()); + self + } } /// Push-constant range declaration. Set `size = 0` to opt out. @@ -344,6 +458,16 @@ impl ColorWriteMask { pub const fn bits(self) -> u32 { self.0 } + + /// The mask a raw bitmask names, or `None` if it names a bit no color + /// channel owns. + pub const fn from_bits(bits: u32) -> Option { + if bits & !Self::RGBA.0 == 0 { + Some(Self(bits)) + } else { + None + } + } } impl std::ops::BitOr for ColorWriteMask { @@ -562,12 +686,19 @@ pub struct DrawIndexedCall { /// Rejects descriptor-type conflicts (same binding declared as /// StorageBuffer in vertex and UniformBuffer in fragment) and multi-set /// kernels (only descriptor set 0 supported). +/// +/// Every derived spec carries the shader's own name for its binding, and the +/// two ways a name can fail to identify one binding are rejected here rather +/// than at draw time: a slot the two stages spell differently, and one name on +/// two slots. A blob whose `OpName` decorations were stripped is rejected for +/// the same reason — bindings are resolved by name in one spelling for both +/// languages, so an unnamed binding cannot be bound at all. pub fn derive_bindings_from_spirv_multistage( stages: &[GraphicsStage<'_>], ) -> Result<(Vec, GraphicsPushConstants)> { let mut merged: std::collections::BTreeMap< u32, - (GraphicsBindingKind, GraphicsShaderStageFlags), + (GraphicsBindingKind, GraphicsShaderStageFlags, String), > = std::collections::BTreeMap::new(); let mut push_size: u32 = 0; let mut push_stages = GraphicsShaderStageFlags::NONE; @@ -601,15 +732,34 @@ pub fn derive_bindings_from_spirv_multistage( stage.stage, info.ty )) })?; - let entry = merged - .entry(binding) - .or_insert((kind, GraphicsShaderStageFlags::NONE)); + if info.name.is_empty() { + return Err(Error::GpuError(format!( + "Graphics kernel: SPIR-V {:?} stage binding {binding} ({kind:?}) carries \ + no name — its OpName decorations were stripped, and bindings are \ + resolved by name. Compile with debug info retained (`glslc -g`) so the \ + shader's own binding names survive optimization", + stage.stage + ))); + } + let entry = merged.entry(binding).or_insert(( + kind, + GraphicsShaderStageFlags::NONE, + info.name.clone(), + )); if entry.0 != kind { return Err(Error::GpuError(format!( "Graphics kernel: binding {binding} kind conflict — {:?} vs {:?} (introduced by {:?})", entry.0, kind, stage.stage ))); } + if entry.2 != info.name { + return Err(Error::GpuError(format!( + "Graphics kernel: binding {binding} is named `{}` by one stage and `{}` \ + by the {:?} stage; bindings are resolved by name, so one slot spelled \ + two ways cannot be bound", + entry.2, info.name, stage.stage + ))); + } entry.1 |= stage_flag; } } @@ -629,12 +779,27 @@ pub fn derive_bindings_from_spirv_multistage( let bindings: Vec = merged .into_iter() - .map(|(binding, (kind, stages))| GraphicsBindingSpec { + .map(|(binding, (kind, stages, name))| GraphicsBindingSpec { binding, kind, stages, + name: Some(Cow::Owned(name)), }) .collect(); + for (index, spec) in bindings.iter().enumerate() { + if let Some(earlier) = bindings[..index] + .iter() + .find(|earlier| earlier.name == spec.name) + { + return Err(Error::GpuError(format!( + "Graphics kernel: bindings {} and {} are both named `{}`; bindings are resolved \ + by name, so one name on two slots cannot be bound", + earlier.binding, + spec.binding, + spec.name.as_deref().unwrap_or_default() + ))); + } + } Ok(( bindings, GraphicsPushConstants { diff --git a/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs b/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs new file mode 100644 index 000000000..c72bef86d --- /dev/null +++ b/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs @@ -0,0 +1,395 @@ +// Copyright (c) 2025 Jonathan Fontanez +// SPDX-License-Identifier: BUSL-1.1 + +//! Resolving a kernel's descriptor bindings by the shader's own names. +//! +//! Every pipeline kind reads its binding names out of SPIR-V reflection and +//! checks a caller's declaration against them rather than letting the +//! declaration replace them. Compute has no stage axis and reconciles in +//! [`super::compute_kernel`]; graphics and ray tracing scope each binding to +//! the stages that read it, and share the reconciliation here because the only +//! thing that differs between them is which two newtypes they name. + +use crate::core::{Error, Result}; + +/// Render a shader's declared binding names for an error message. +pub fn quote_declared_shader_binding_names(names: &[&str]) -> String { + if names.is_empty() { + return "no named bindings".to_string(); + } + names + .iter() + .map(|name| format!("`{name}`")) + .collect::>() + .join(", ") +} + +/// The stage mask a graphics or ray-tracing binding carries. +/// +/// The two flag types are unrelated newtypes over `u32`, so the reconciliation +/// they share is generic over this rather than over either of them. +pub trait KernelShaderStageMask: Copy + PartialEq { + /// Every stage this mask names, spelled as the wire and the shader spell + /// them. + fn named_stages(self) -> Vec<&'static str>; + + /// Whether this mask names no stage at all. + fn names_no_stage(self) -> bool; + + /// The stages this mask names that `available` does not. + fn stages_missing_from(self, available: Self) -> Vec<&'static str>; + + /// Whether every stage `other` names is also named here. + fn contains_every_stage_in(self, other: Self) -> bool; +} + +/// Render a stage mask for an error message. +fn quote_stage_mask(stages: Stages) -> String { + quote_names(&stages.named_stages(), "no stage") +} + +fn quote_names(names: &[&str], when_empty: &str) -> String { + if names.is_empty() { + return when_empty.to_string(); + } + names + .iter() + .map(|name| format!("`{name}`")) + .collect::>() + .join(", ") +} + +/// One binding reduced to the three things reconciliation compares. +/// +/// Graphics and ray tracing each map their own spec and declaration types into +/// this view, which is why neither has to give up its own explicit types to +/// share the check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KernelBindingUnderReconciliation<'a, Kind, Stages> { + pub name: &'a str, + pub kind: Kind, + pub stages: Stages, +} + +/// Check a caller's binding declarations against what reflection actually +/// found, by name, for a kernel kind whose bindings carry stage masks. +/// +/// An empty declaration asserts nothing and the reflected shape stands alone. +/// Otherwise every declared name must exist in the shader with the kind and +/// the stage visibility the caller claimed, and every reflected name must be +/// accounted for — leaving a shader binding unmentioned is how a dispatch +/// silently binds nothing. +/// +/// A declaration whose stage mask is empty asserts nothing about stages: the +/// descriptor set layout is built from reflection, so the mask is an assertion +/// rather than an input, and asserting no stage at all is not a claim. +/// +/// `stages_the_kernel_was_built_from` is the union of the stages this kernel +/// actually supplied a shader module for. Naming a stage outside it is the +/// stage-mismatch case the plan puts at construction: the kernel has no module +/// that could ever read that binding, so no dispatch could make it true. +pub fn reconcile_staged_kernel_binding_declarations( + kernel_kind_label: &str, + declared: &[KernelBindingUnderReconciliation<'_, Kind, Stages>], + reflected: &[KernelBindingUnderReconciliation<'_, Kind, Stages>], + stages_the_kernel_was_built_from: Stages, +) -> Result<()> +where + Kind: Copy + PartialEq + std::fmt::Debug, + Stages: KernelShaderStageMask, +{ + if declared.is_empty() { + return Ok(()); + } + let shader_names: Vec<&str> = reflected.iter().map(|binding| binding.name).collect(); + let shader_declares = || quote_declared_shader_binding_names(&shader_names); + + for declaration in declared { + let absent_stages = declaration + .stages + .stages_missing_from(stages_the_kernel_was_built_from); + if !absent_stages.is_empty() { + return Err(Error::GpuError(format!( + "{kernel_kind_label} binding `{}` is declared for {}, which this kernel has no \ + shader module for; it was built from {}", + declaration.name, + quote_names(&absent_stages, "no stage"), + quote_stage_mask(stages_the_kernel_was_built_from) + ))); + } + + let found = reflected + .iter() + .find(|binding| binding.name == declaration.name) + .ok_or_else(|| { + Error::GpuError(format!( + "{kernel_kind_label} kernel declares a binding named `{}`, which this shader \ + does not declare; the shader declares {}", + declaration.name, + shader_declares() + )) + })?; + + if found.kind != declaration.kind { + return Err(Error::GpuError(format!( + "{kernel_kind_label} binding `{}` was declared {:?} but this shader declares it \ + {:?}", + declaration.name, declaration.kind, found.kind + ))); + } + + if !declaration.stages.names_no_stage() + && !declaration.stages.contains_every_stage_in(found.stages) + { + return Err(Error::GpuError(format!( + "{kernel_kind_label} binding `{}` was declared for {} but this shader also reads \ + it from {}; a declaration may widen a binding's visibility, never narrow it \ + below what the shaders actually read", + declaration.name, + quote_stage_mask(declaration.stages), + quote_names( + &found.stages.stages_missing_from(declaration.stages), + "no stage" + ) + ))); + } + } + + for binding in reflected { + if !declared + .iter() + .any(|declaration| declaration.name == binding.name) + { + return Err(Error::GpuError(format!( + "{kernel_kind_label} kernel leaves the shader's binding `{}` undeclared; every \ + binding the shader declares must be accounted for", + binding.name + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct TestStageMask(u32); + + impl TestStageMask { + const NONE: Self = Self(0); + const VERTEX: Self = Self(0b01); + const FRAGMENT: Self = Self(0b10); + const VERTEX_FRAGMENT: Self = Self(0b11); + } + + impl KernelShaderStageMask for TestStageMask { + fn named_stages(self) -> Vec<&'static str> { + let mut names = Vec::new(); + if self.0 & 0b01 != 0 { + names.push("vertex"); + } + if self.0 & 0b10 != 0 { + names.push("fragment"); + } + names + } + + fn names_no_stage(self) -> bool { + self.0 == 0 + } + + fn stages_missing_from(self, available: Self) -> Vec<&'static str> { + Self(self.0 & !available.0).named_stages() + } + + fn contains_every_stage_in(self, other: Self) -> bool { + (self.0 & other.0) == other.0 + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum TestKind { + SampledTexture, + StorageImage, + } + + fn binding( + name: &str, + kind: TestKind, + stages: TestStageMask, + ) -> KernelBindingUnderReconciliation<'_, TestKind, TestStageMask> { + KernelBindingUnderReconciliation { name, kind, stages } + } + + fn reconcile( + declared: &[KernelBindingUnderReconciliation<'_, TestKind, TestStageMask>], + reflected: &[KernelBindingUnderReconciliation<'_, TestKind, TestStageMask>], + built_from: TestStageMask, + ) -> Result<()> { + reconcile_staged_kernel_binding_declarations("test", declared, reflected, built_from) + } + + #[test] + fn an_empty_declaration_lets_the_reflected_shape_stand_alone() { + let reflected = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + )]; + reconcile(&[], &reflected, TestStageMask::VERTEX_FRAGMENT).expect("nothing asserted"); + } + + #[test] + fn a_matching_declaration_is_accepted() { + let bindings = [ + binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + ), + binding( + "output_image", + TestKind::StorageImage, + TestStageMask::VERTEX, + ), + ]; + reconcile(&bindings, &bindings, TestStageMask::VERTEX_FRAGMENT).expect("agreed"); + } + + #[test] + fn a_name_the_shader_does_not_declare_is_refused_naming_what_it_does() { + let declared = [binding( + "sharpen_amount", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + )]; + let reflected = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + )]; + let refusal = reconcile(&declared, &reflected, TestStageMask::VERTEX_FRAGMENT) + .expect_err("unknown name"); + let message = refusal.to_string(); + assert!(message.contains("sharpen_amount"), "{message}"); + assert!(message.contains("`source_image`"), "{message}"); + } + + #[test] + fn a_kind_the_shader_disagrees_with_is_refused() { + let declared = [binding( + "source_image", + TestKind::StorageImage, + TestStageMask::FRAGMENT, + )]; + let reflected = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + )]; + let refusal = + reconcile(&declared, &reflected, TestStageMask::VERTEX_FRAGMENT).expect_err("kind"); + let message = refusal.to_string(); + assert!(message.contains("StorageImage"), "{message}"); + assert!(message.contains("SampledTexture"), "{message}"); + } + + #[test] + fn a_stage_the_kernel_has_no_module_for_is_refused_at_reconciliation() { + let declared = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::VERTEX_FRAGMENT, + )]; + let reflected = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + )]; + let refusal = + reconcile(&declared, &reflected, TestStageMask::FRAGMENT).expect_err("no such stage"); + let message = refusal.to_string(); + assert!(message.contains("no shader module for"), "{message}"); + assert!(message.contains("`vertex`"), "{message}"); + } + + #[test] + fn a_declaration_narrower_than_what_the_shaders_read_is_refused() { + let declared = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::VERTEX, + )]; + let reflected = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::VERTEX_FRAGMENT, + )]; + let refusal = reconcile(&declared, &reflected, TestStageMask::VERTEX_FRAGMENT) + .expect_err("stage mismatch"); + let message = refusal.to_string(); + assert!(message.contains("declared for `vertex`"), "{message}"); + assert!( + message.contains("also reads it from `fragment`"), + "{message}" + ); + } + + #[test] + fn a_declaration_wider_than_what_the_shaders_read_is_accepted() { + let declared = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::VERTEX_FRAGMENT, + )]; + let reflected = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + )]; + reconcile(&declared, &reflected, TestStageMask::VERTEX_FRAGMENT) + .expect("widening visibility is the caller's to do"); + } + + #[test] + fn an_empty_stage_mask_asserts_nothing_about_stages() { + let declared = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::NONE, + )]; + let reflected = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + )]; + reconcile(&declared, &reflected, TestStageMask::VERTEX_FRAGMENT) + .expect("no stage claim to disagree with"); + } + + #[test] + fn leaving_a_shader_binding_undeclared_is_refused() { + let declared = [binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + )]; + let reflected = [ + binding( + "source_image", + TestKind::SampledTexture, + TestStageMask::FRAGMENT, + ), + binding( + "output_image", + TestKind::StorageImage, + TestStageMask::FRAGMENT, + ), + ]; + let refusal = + reconcile(&declared, &reflected, TestStageMask::VERTEX_FRAGMENT).expect_err("omitted"); + assert!(refusal.to_string().contains("output_image"), "{refusal}"); + } +} diff --git a/runtime/streamlib-engine/src/core/rhi/mod.rs b/runtime/streamlib-engine/src/core/rhi/mod.rs index f7b5d19d1..fbd801dfe 100644 --- a/runtime/streamlib-engine/src/core/rhi/mod.rs +++ b/runtime/streamlib-engine/src/core/rhi/mod.rs @@ -17,6 +17,7 @@ mod glsl_shader_source_compiler; mod graphics_kernel; mod host_timeline_semaphore; mod index_buffer; +mod kernel_binding_names; mod pixel_buffer; mod pixel_buffer_pool; mod pixel_buffer_ref; @@ -37,13 +38,11 @@ pub use color_converter::{ }; pub use command_buffer::CommandBuffer; pub use command_queue::RhiCommandQueue; +pub(crate) use compute_kernel::reconcile_compute_binding_declarations; pub use compute_kernel::{ ComputeBindingDeclaration, ComputeBindingKind, ComputeBindingSpec, ComputeKernelDescriptor, SurfaceBoundComputeBindingKind, derive_bindings_from_spirv, }; -pub(crate) use compute_kernel::{ - quote_declared_shader_binding_names, reconcile_compute_binding_declarations, -}; pub use device::GpuDevice; pub use external_handle::{RhiExternalHandle, RhiPixelBufferExport, RhiPixelBufferImport}; pub use gl_interop::{GlContext, GlTextureBinding, gl_constants}; @@ -54,25 +53,32 @@ pub use glsl_shader_source_compiler::{ pub use graphics_kernel::{ AttachmentFormats, BlendFactor, BlendOp, ColorBlendAttachment, ColorBlendState, ColorWriteMask, CullMode, DepthCompareOp, DepthFormat, DepthStencilState, DrawCall, DrawIndexedCall, FrontFace, - GraphicsBindingKind, GraphicsBindingSpec, GraphicsDynamicState, GraphicsKernelDescriptor, - GraphicsPipelineState, GraphicsPushConstants, GraphicsShaderStage, GraphicsShaderStageFlags, - GraphicsStage, IndexType, MultisampleState, PolygonMode, PrimitiveTopology, RasterizationState, - ScissorRect, VertexAttributeFormat, VertexInputAttribute, VertexInputBinding, VertexInputRate, - VertexInputState, Viewport, derive_bindings_from_spirv_multistage, + GraphicsBindingDeclaration, GraphicsBindingKind, GraphicsBindingSpec, GraphicsDynamicState, + GraphicsKernelDescriptor, GraphicsPipelineState, GraphicsPushConstants, GraphicsShaderStage, + GraphicsShaderStageFlags, GraphicsStage, IndexType, MultisampleState, PolygonMode, + PrimitiveTopology, RasterizationState, ScissorRect, VertexAttributeFormat, + VertexInputAttribute, VertexInputBinding, VertexInputRate, VertexInputState, Viewport, + derive_bindings_from_spirv_multistage, }; #[cfg(target_os = "linux")] pub use host_timeline_semaphore::HostTimelineSemaphore; #[cfg(target_os = "linux")] pub use index_buffer::IndexBuffer; +pub(crate) use kernel_binding_names::quote_declared_shader_binding_names; pub use pixel_buffer::PixelBuffer; pub use pixel_buffer_pool::{ PixelBufferDescriptor, PixelBufferPoolSlotId, PublishedPixelBufferFrameId, pool_slot_key_of_surface_id, split_pool_slot_and_frame_generation, }; +pub(crate) use ray_tracing_kernel::{ + ray_tracing_spirv_type_to_kind, reconcile_ray_tracing_binding_declarations, +}; pub use ray_tracing_kernel::{ - RayTracingBindingKind, RayTracingBindingSpec, RayTracingKernelDescriptor, - RayTracingPushConstants, RayTracingShaderGroup, RayTracingShaderStage, - RayTracingShaderStageFlags, RayTracingStage, validate_shader_groups, + RayTracingBindingDeclaration, RayTracingBindingKind, RayTracingBindingSpec, + RayTracingKernelDescriptor, RayTracingPushConstants, RayTracingShaderGroup, + RayTracingShaderStage, RayTracingShaderStageFlags, RayTracingStage, + derive_ray_tracing_bindings_from_spirv_multistage, ray_tracing_stages_covered_by, + validate_shader_groups, }; #[cfg(target_os = "linux")] pub use storage_buffer::StorageBuffer; diff --git a/runtime/streamlib-engine/src/core/rhi/ray_tracing_kernel.rs b/runtime/streamlib-engine/src/core/rhi/ray_tracing_kernel.rs index efe37ecb9..8c951c18c 100644 --- a/runtime/streamlib-engine/src/core/rhi/ray_tracing_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/ray_tracing_kernel.rs @@ -13,8 +13,17 @@ //! declarations, and from that point on the caller binds resources by //! slot via simple typed setters. +use std::borrow::Cow; + +use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; + use crate::core::{Error, Result}; +use super::kernel_binding_names::{ + KernelBindingUnderReconciliation, KernelShaderStageMask, + reconcile_staged_kernel_binding_declarations, +}; + /// Shader stages that contribute to a ray-tracing pipeline. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RayTracingShaderStage { @@ -53,6 +62,20 @@ impl RayTracingShaderStageFlags { pub const fn bits(self) -> u32 { self.0 } + + /// The mask a raw bitmask names, or `None` if it names a bit no + /// ray-tracing stage owns. + /// + /// An unknown bit is refused rather than masked off: a caller that set it + /// meant a stage, and dropping it silently would make the declaration + /// assert less than it said. + pub const fn from_bits(bits: u32) -> Option { + if bits & !Self::ALL.0 == 0 { + Some(Self(bits)) + } else { + None + } + } } impl std::ops::BitOr for RayTracingShaderStageFlags { @@ -143,12 +166,264 @@ pub enum RayTracingBindingKind { AccelerationStructure, } -/// One binding declaration: (binding index, resource kind, visible stages). -#[derive(Debug, Clone, Copy)] +/// One binding declaration: (binding index, resource kind, visible stages, the +/// shader's own name for the binding). +/// +/// `name` is `None` on a declaration that asserts only slot, kind and stages, +/// and `Some` on every spec the RHI derived from reflection: the numeric +/// binding is what the descriptor set is built from, the name is what a +/// by-name dispatch resolves against. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RayTracingBindingSpec { pub binding: u32, pub kind: RayTracingBindingKind, pub stages: RayTracingShaderStageFlags, + pub name: Option>, +} + +/// What a caller asserts about one ray-tracing binding before reflection has +/// assigned it a slot: the shader's name for it, the kind expected there, and +/// the stages the caller believes read it. +/// +/// Deliberately not a [`RayTracingBindingSpec`]: a declaration has no slot, and +/// carrying a placeholder slot in a spec would put a meaningless number in a +/// field every resolved path treats as load-bearing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RayTracingBindingDeclaration { + pub name: String, + pub kind: RayTracingBindingKind, + pub stages: RayTracingShaderStageFlags, +} + +impl KernelShaderStageMask for RayTracingShaderStageFlags { + fn named_stages(self) -> Vec<&'static str> { + [ + (Self::RAYGEN, "ray_gen"), + (Self::MISS, "miss"), + (Self::CLOSEST_HIT, "closest_hit"), + (Self::ANY_HIT, "any_hit"), + (Self::INTERSECTION, "intersection"), + (Self::CALLABLE, "callable"), + ] + .into_iter() + .filter(|(flag, _)| self.contains(*flag)) + .map(|(_, name)| name) + .collect() + } + + fn names_no_stage(self) -> bool { + self == Self::NONE + } + + fn stages_missing_from(self, available: Self) -> Vec<&'static str> { + Self(self.0 & !available.0).named_stages() + } + + fn contains_every_stage_in(self, other: Self) -> bool { + self.contains(other) + } +} + +/// Check a caller's ray-tracing binding declarations against what the stages' +/// reflection actually found, by name. +/// +/// Runs at kernel construction, where the multi-stage declaration is built — +/// which is the only place a stage mismatch can be caught, because a dispatch +/// never revisits which stage reads what. A ray-tracing kernel's stage set +/// varies per kernel, so naming `any_hit` on a kernel built without an any-hit +/// module is a mistake no dispatch could ever make true. +pub(crate) fn reconcile_ray_tracing_binding_declarations( + declared: &[RayTracingBindingDeclaration], + reflected: &[RayTracingBindingSpec], + stages_the_kernel_was_built_from: RayTracingShaderStageFlags, +) -> Result<()> { + let declared_view: Vec<_> = declared + .iter() + .map(|declaration| KernelBindingUnderReconciliation { + name: declaration.name.as_str(), + kind: declaration.kind, + stages: declaration.stages, + }) + .collect(); + let reflected_view: Vec<_> = reflected + .iter() + .filter_map(|spec| { + Some(KernelBindingUnderReconciliation { + name: spec.name.as_deref()?, + kind: spec.kind, + stages: spec.stages, + }) + }) + .collect(); + reconcile_staged_kernel_binding_declarations( + "ray-tracing", + &declared_view, + &reflected_view, + stages_the_kernel_was_built_from, + ) +} + +/// Reflect every stage of a ray-tracing pipeline and return the merged binding +/// shape + total push-constant size + visible stages per binding. +/// +/// The ray-tracing twin of +/// [`super::graphics_kernel::derive_bindings_from_spirv_multistage`], and the +/// same contract: each stage's reflection is unioned, every derived spec +/// carries the shader's own name, and the two ways a name can fail to identify +/// one binding — a slot two stages spell differently, and one name on two +/// slots — are rejected here rather than at dispatch. A blob whose `OpName` +/// decorations were stripped is rejected for the same reason. +pub fn derive_ray_tracing_bindings_from_spirv_multistage( + stages: &[RayTracingStage<'_>], +) -> Result<(Vec, RayTracingPushConstants)> { + let mut merged: std::collections::BTreeMap< + u32, + (RayTracingBindingKind, RayTracingShaderStageFlags, String), + > = std::collections::BTreeMap::new(); + let mut push_size: u32 = 0; + let mut push_stages = RayTracingShaderStageFlags::NONE; + + for stage in stages { + let stage_flag = ray_tracing_stage_to_flag(stage.stage); + let reflection = Reflection::new_from_spirv(stage.spv).map_err(|e| { + Error::GpuError(format!( + "Ray-tracing kernel: failed to reflect SPIR-V for {:?} stage: {e:?}", + stage.stage + )) + })?; + let sets = reflection.get_descriptor_sets().map_err(|e| { + Error::GpuError(format!( + "Ray-tracing kernel: failed to extract descriptor sets for {:?} stage: {e:?}", + stage.stage + )) + })?; + if sets.len() > 1 { + return Err(Error::GpuError(format!( + "Ray-tracing kernel: only descriptor set 0 is supported; SPIR-V {:?} stage uses \ + sets {:?}", + stage.stage, + sets.keys().collect::>() + ))); + } + if let Some(set0) = sets.get(&0) { + for (&binding, info) in set0 { + let kind = ray_tracing_spirv_type_to_kind(info.ty).ok_or_else(|| { + Error::GpuError(format!( + "Ray-tracing kernel: SPIR-V {:?} stage binding {binding} has unsupported \ + descriptor type {:?}", + stage.stage, info.ty + )) + })?; + if info.name.is_empty() { + return Err(Error::GpuError(format!( + "Ray-tracing kernel: SPIR-V {:?} stage binding {binding} ({kind:?}) \ + carries no name — its OpName decorations were stripped, and bindings are \ + resolved by name. Compile with debug info retained (`glslc -g`) so the \ + shader's own binding names survive optimization", + stage.stage + ))); + } + let entry = merged.entry(binding).or_insert(( + kind, + RayTracingShaderStageFlags::NONE, + info.name.clone(), + )); + if entry.0 != kind { + return Err(Error::GpuError(format!( + "Ray-tracing kernel: binding {binding} kind conflict — {:?} vs {:?} \ + (introduced by {:?})", + entry.0, kind, stage.stage + ))); + } + if entry.2 != info.name { + return Err(Error::GpuError(format!( + "Ray-tracing kernel: binding {binding} is named `{}` by one stage and \ + `{}` by the {:?} stage; bindings are resolved by name, so one slot \ + spelled two ways cannot be bound", + entry.2, info.name, stage.stage + ))); + } + entry.1 |= stage_flag; + } + } + if let Some(info) = reflection.get_push_constant_range().map_err(|e| { + Error::GpuError(format!( + "Ray-tracing kernel: failed to read push-constant range for {:?} stage: {e:?}", + stage.stage + )) + })? { + push_size = push_size.max(info.size); + push_stages |= stage_flag; + } + } + + let bindings: Vec = merged + .into_iter() + .map(|(binding, (kind, stages, name))| RayTracingBindingSpec { + binding, + kind, + stages, + name: Some(Cow::Owned(name)), + }) + .collect(); + for (index, spec) in bindings.iter().enumerate() { + if let Some(earlier) = bindings[..index] + .iter() + .find(|earlier| earlier.name == spec.name) + { + return Err(Error::GpuError(format!( + "Ray-tracing kernel: bindings {} and {} are both named `{}`; bindings are \ + resolved by name, so one name on two slots cannot be bound", + earlier.binding, + spec.binding, + spec.name.as_deref().unwrap_or_default() + ))); + } + } + Ok(( + bindings, + RayTracingPushConstants { + size: push_size, + stages: push_stages, + }, + )) +} + +/// The union of the stages a set of shader modules covers. +pub fn ray_tracing_stages_covered_by(stages: &[RayTracingStage<'_>]) -> RayTracingShaderStageFlags { + stages + .iter() + .fold(RayTracingShaderStageFlags::NONE, |covered, stage| { + covered | ray_tracing_stage_to_flag(stage.stage) + }) +} + +const fn ray_tracing_stage_to_flag(stage: RayTracingShaderStage) -> RayTracingShaderStageFlags { + match stage { + RayTracingShaderStage::RayGen => RayTracingShaderStageFlags::RAYGEN, + RayTracingShaderStage::Miss => RayTracingShaderStageFlags::MISS, + RayTracingShaderStage::ClosestHit => RayTracingShaderStageFlags::CLOSEST_HIT, + RayTracingShaderStage::AnyHit => RayTracingShaderStageFlags::ANY_HIT, + RayTracingShaderStage::Intersection => RayTracingShaderStageFlags::INTERSECTION, + RayTracingShaderStage::Callable => RayTracingShaderStageFlags::CALLABLE, + } +} + +/// The binding kind a reflected descriptor type names, or `None` for a type no +/// ray-tracing binding kind covers. +pub(crate) fn ray_tracing_spirv_type_to_kind( + ty: RDescriptorType, +) -> Option { + match ty { + RDescriptorType::STORAGE_BUFFER => Some(RayTracingBindingKind::StorageBuffer), + RDescriptorType::UNIFORM_BUFFER => Some(RayTracingBindingKind::UniformBuffer), + RDescriptorType::COMBINED_IMAGE_SAMPLER => Some(RayTracingBindingKind::SampledTexture), + RDescriptorType::STORAGE_IMAGE => Some(RayTracingBindingKind::StorageImage), + RDescriptorType::ACCELERATION_STRUCTURE_KHR => { + Some(RayTracingBindingKind::AccelerationStructure) + } + _ => None, + } } impl RayTracingBindingSpec { @@ -157,6 +432,7 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::StorageBuffer, stages, + name: None, } } @@ -165,6 +441,7 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::UniformBuffer, stages, + name: None, } } @@ -173,6 +450,7 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::SampledTexture, stages, + name: None, } } @@ -181,6 +459,7 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::StorageImage, stages, + name: None, } } @@ -189,8 +468,16 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::AccelerationStructure, stages, + name: None, } } + + /// Assert the shader's spelling of this binding as well as its slot. + #[must_use] + pub fn with_name(mut self, name: impl Into>) -> Self { + self.name = Some(name.into()); + self + } } /// Push-constant range declaration. Set `size = 0` to opt out. diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs index 7780baa2f..189bbb880 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs @@ -213,7 +213,7 @@ impl VulkanGraphicsKernelInner { ))); } - validate_against_spirv(descriptor)?; + let reconciled_bindings = validate_against_spirv(descriptor)?; let device = vulkan_device.device(); let queue = vulkan_device.queue(); @@ -312,7 +312,7 @@ impl VulkanGraphicsKernelInner { device: device.clone(), queue, queue_family_index, - bindings: descriptor.bindings.to_vec(), + bindings: reconciled_bindings, push_constant_size: descriptor.push_constants.size, push_constant_stages: shader_stage_flags_to_vk(descriptor.push_constants.stages), pipeline_state: descriptor.pipeline_state.clone(), @@ -1452,10 +1452,15 @@ impl std::fmt::Debug for VulkanGraphicsKernel { // ---- Validation + creation helpers -------------------------------------------- -fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<()> { +/// Validate the declaration against the shaders and return the specs with the +/// shader's own binding names adopted onto them. +fn validate_against_spirv( + descriptor: &GraphicsKernelDescriptor<'_>, +) -> Result> { use std::collections::BTreeMap; - let mut merged: BTreeMap = BTreeMap::new(); + let mut merged: BTreeMap = + BTreeMap::new(); let mut spirv_push_size: u32 = 0; let mut spirv_push_stages = GraphicsShaderStageFlags::NONE; @@ -1483,15 +1488,25 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( } if let Some(set0) = sets.get(&0) { for (&binding, info) in set0 { - let entry = merged - .entry(binding) - .or_insert((info.ty, GraphicsShaderStageFlags::NONE)); + let entry = merged.entry(binding).or_insert(( + info.ty, + GraphicsShaderStageFlags::NONE, + info.name.clone(), + )); if entry.0 != info.ty { return Err(Error::GpuError(format!( "Graphics kernel '{}': binding {binding} type conflict — {:?} vs {:?} (in {:?})", descriptor.label, entry.0, info.ty, stage.stage ))); } + if entry.2 != info.name { + return Err(Error::GpuError(format!( + "Graphics kernel '{}': binding {binding} is named `{}` by one stage and \ + `{}` by the {:?} stage; bindings are resolved by name, so one slot \ + spelled two ways cannot be bound", + descriptor.label, entry.2, info.name, stage.stage + ))); + } entry.1 |= stage_flag; } } @@ -1506,7 +1521,11 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( } } - // Each declared binding must agree with merged shader declaration. + // Each declared binding must agree with merged shader declaration. The + // shader's own name for the binding is adopted onto the returned spec — + // that is what a by-name draw resolves against, and the shader is its + // source of truth. + let mut reconciled: Vec = Vec::with_capacity(descriptor.bindings.len()); for spec in descriptor.bindings { let merged_entry = merged.get(&spec.binding).ok_or_else(|| { Error::GpuError(format!( @@ -1521,6 +1540,14 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( descriptor.label, spec.binding, spec.kind, expected, merged_entry.0 ))); } + if let Some(declared_name) = spec.name.as_deref() + && declared_name != merged_entry.2 + { + return Err(Error::GpuError(format!( + "Graphics kernel '{}': binding {} declared name `{}`, but SPIR-V names it `{}`", + descriptor.label, spec.binding, declared_name, merged_entry.2 + ))); + } // Declared visibility must cover at least the SPIR-V's stages — // i.e., declared stages ⊇ shader stages. A binding consumed in // fragment but declared visible only to vertex would be a Vulkan @@ -1534,16 +1561,22 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( merged_entry.1.bits() ))); } + let mut adopted = spec.clone(); + if !merged_entry.2.is_empty() { + adopted.name = Some(std::borrow::Cow::Owned(merged_entry.2.clone())); + } + reconciled.push(adopted); } // Conversely, every SPIR-V binding must be declared. - for (&binding, (ty, stages)) in &merged { + for (&binding, (ty, stages, name)) in &merged { if !descriptor.bindings.iter().any(|s| s.binding == binding) { return Err(Error::GpuError(format!( - "Graphics kernel '{}': SPIR-V declares binding {} ({:?}, stages {:#b}) but it is missing from the descriptor", + "Graphics kernel '{}': SPIR-V declares binding {} ({:?}, name `{}`, stages {:#b}) but it is missing from the descriptor", descriptor.label, binding, ty, + name, stages.bits() ))); } @@ -1570,7 +1603,7 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( ))); } - Ok(()) + Ok(reconciled) } fn stage_to_flag(stage: GraphicsShaderStage) -> GraphicsShaderStageFlags { diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs index 463ac0ecb..9a999d16a 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs @@ -22,7 +22,7 @@ use std::collections::HashMap; use std::sync::Arc; use parking_lot::Mutex; -use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; +use rspirv_reflect::Reflection; use vma::Alloc as _; use vulkanalia::prelude::v1_4::*; use vulkanalia::vk; @@ -34,7 +34,7 @@ use std::ffi::c_void; use crate::core::rhi::{ RayTracingBindingKind, RayTracingBindingSpec, RayTracingKernelDescriptor, RayTracingShaderGroup, RayTracingShaderStage, RayTracingShaderStageFlags, RayTracingStage, - Texture, validate_shader_groups, + Texture, ray_tracing_spirv_type_to_kind, validate_shader_groups, }; use crate::core::{Error, Result}; @@ -130,7 +130,7 @@ impl VulkanRayTracingKernelInner { } validate_shader_groups(descriptor.label, descriptor.stages, descriptor.groups)?; - validate_bindings_against_spirv(descriptor)?; + let reconciled_bindings = validate_bindings_against_spirv(descriptor)?; validate_push_constants_against_spirv(descriptor)?; let device = vulkan_device.device(); @@ -361,7 +361,7 @@ impl VulkanRayTracingKernelInner { vulkan_device: Arc::clone(vulkan_device), device: device.clone(), queue, - bindings: descriptor.bindings.to_vec(), + bindings: reconciled_bindings, push_constant_size: descriptor.push_constants.size, push_constant_stages: stage_flags_to_vk(descriptor.push_constants.stages), pipeline, @@ -1076,11 +1076,15 @@ mod plugin_abi_object_layout_tests { // ---- Validation + creation helpers -------------------------------------------- -fn validate_bindings_against_spirv(descriptor: &RayTracingKernelDescriptor<'_>) -> Result<()> { +/// Validate the declaration against every stage and return the specs with the +/// shader's own binding names adopted onto them. +fn validate_bindings_against_spirv( + descriptor: &RayTracingKernelDescriptor<'_>, +) -> Result> { use std::collections::BTreeMap; // Merge per-stage SPIR-V reflection into a single map. - let mut merged: BTreeMap = + let mut merged: BTreeMap = BTreeMap::new(); for stage in descriptor.stages { @@ -1107,34 +1111,49 @@ fn validate_bindings_against_spirv(descriptor: &RayTracingKernelDescriptor<'_>) let stage_flag = stage_to_stage_flag(stage.stage); if let Some(set0) = sets.get(&0) { for (&binding, info) in set0 { - let kind = spirv_type_to_kind(info.ty).ok_or_else(|| { + let kind = ray_tracing_spirv_type_to_kind(info.ty).ok_or_else(|| { Error::GpuError(format!( "Ray-tracing kernel '{}': SPIR-V binding {} in stage {:?} has unsupported descriptor type {:?}", descriptor.label, binding, stage.stage, info.ty )) })?; - let entry = merged - .entry(binding) - .or_insert((kind, RayTracingShaderStageFlags::NONE)); + let entry = merged.entry(binding).or_insert(( + kind, + RayTracingShaderStageFlags::NONE, + info.name.clone(), + )); if entry.0 != kind { return Err(Error::GpuError(format!( "Ray-tracing kernel '{}': SPIR-V binding {} declared as {:?} in one stage and {:?} in another", descriptor.label, binding, entry.0, kind ))); } + if entry.2 != info.name { + return Err(Error::GpuError(format!( + "Ray-tracing kernel '{}': binding {} is named `{}` by one stage and `{}` \ + by the {:?} stage; bindings are resolved by name, so one slot spelled \ + two ways cannot be bound", + descriptor.label, binding, entry.2, info.name, stage.stage + ))); + } entry.1 |= stage_flag; } } } - // Every declared binding must exist in the merged SPIR-V map. + // Every declared binding must exist in the merged SPIR-V map. The shader's + // own name for the binding is adopted onto the returned spec — that is what + // a by-name dispatch resolves against, and the shader is its source of + // truth. + let mut reconciled: Vec = Vec::with_capacity(descriptor.bindings.len()); for spec in descriptor.bindings { - let (spirv_kind, spirv_stages) = merged.get(&spec.binding).ok_or_else(|| { - Error::GpuError(format!( - "Ray-tracing kernel '{}': binding {} declared but missing in SPIR-V", - descriptor.label, spec.binding - )) - })?; + let (spirv_kind, spirv_stages, spirv_name) = + merged.get(&spec.binding).ok_or_else(|| { + Error::GpuError(format!( + "Ray-tracing kernel '{}': binding {} declared but missing in SPIR-V", + descriptor.label, spec.binding + )) + })?; if *spirv_kind != spec.kind { return Err(Error::GpuError(format!( "Ray-tracing kernel '{}': binding {} declared {:?}, but SPIR-V has {:?}", @@ -1147,19 +1166,32 @@ fn validate_bindings_against_spirv(descriptor: &RayTracingKernelDescriptor<'_>) descriptor.label, spec.binding, spec.stages, spirv_stages ))); } + if let Some(declared_name) = spec.name.as_deref() + && declared_name != spirv_name + { + return Err(Error::GpuError(format!( + "Ray-tracing kernel '{}': binding {} declared name `{}`, but SPIR-V names it `{}`", + descriptor.label, spec.binding, declared_name, spirv_name + ))); + } + let mut adopted = spec.clone(); + if !spirv_name.is_empty() { + adopted.name = Some(std::borrow::Cow::Owned(spirv_name.clone())); + } + reconciled.push(adopted); } // Conversely, every SPIR-V binding must be declared. - for (&binding, &(kind, _)) in &merged { + for (&binding, (kind, _, name)) in &merged { if !descriptor.bindings.iter().any(|s| s.binding == binding) { return Err(Error::GpuError(format!( - "Ray-tracing kernel '{}': SPIR-V declares binding {} ({:?}) but it is missing from the descriptor", - descriptor.label, binding, kind + "Ray-tracing kernel '{}': SPIR-V declares binding {} ({:?}, name `{}`) but it is missing from the descriptor", + descriptor.label, binding, kind, name ))); } } - Ok(()) + Ok(reconciled) } fn validate_push_constants_against_spirv( @@ -1191,19 +1223,6 @@ fn validate_push_constants_against_spirv( Ok(()) } -fn spirv_type_to_kind(ty: RDescriptorType) -> Option { - match ty { - RDescriptorType::STORAGE_BUFFER => Some(RayTracingBindingKind::StorageBuffer), - RDescriptorType::UNIFORM_BUFFER => Some(RayTracingBindingKind::UniformBuffer), - RDescriptorType::COMBINED_IMAGE_SAMPLER => Some(RayTracingBindingKind::SampledTexture), - RDescriptorType::STORAGE_IMAGE => Some(RayTracingBindingKind::StorageImage), - RDescriptorType::ACCELERATION_STRUCTURE_KHR => { - Some(RayTracingBindingKind::AccelerationStructure) - } - _ => None, - } -} - fn create_descriptor_set_layout( device: &vulkanalia::Device, bindings: &[RayTracingBindingSpec], From 648478f7c5f92c7cf5b9adeae80b96851a064b42 Mon Sep 17 00:00:00 2001 From: Jonathan Fontanez Date: Mon, 17 Aug 2026 21:30:06 -0400 Subject: [PATCH 2/3] feat(engine)!: graphics and ray-tracing kernels at Python parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute reached named bindings and lost its bridge in #1882. Graphics and ray tracing were left one change behind, and both their escalate paths were dead: the only implementations of either bridge trait ever written lived in the polyglot example hosts #1715 deleted, so every caller got an "unsupported" refusal. This brings both kinds to compute's spelling and puts them in Python's hands. Bindings resolve by the shader's own name, on the wire and at the RHI. The four binding structs swap their slot number for it, the duplicate register/run kind enums collapse to one per pipeline kind, and all three register responses share one binding type — same bytes, one shape. Reflection stops discarding `DescriptorInfo.name` for the staged kinds, and both kernels adopt it at construction the way `VulkanComputeKernel` does. Both bridge traits are deleted whole, with their fields, installers, getters and `GpuContextFullAccess` mirrors. `GpuContext` grows the kernel caches compute already had plus an acceleration-structure registry, and the handlers convert wire types to RHI arguments directly — the second half of that conversion never existed in the engine, because it was the absent bridge's. Python gains `GraphicsKernel`, `RayTracingKernel` and the acceleration-structure builders. No slot number reaches it: dispatch is synchronous, so the kernels register one descriptor set and draw at index 0, and the descriptor ring stays where it belongs. Four graphics capabilities Rust keeps are refused by name rather than dropped silently — vertex and index buffers, depth attachments, buffer-kind bindings, and MSAA. Each traces to a primitive the engine does not have: no escalate op mints a vertex buffer, `offscreen_render` attaches no depth, and only texture-shaped surface resolution exists. Three defects the Python path is the first caller to reach are fixed at the engine layer rather than worked around: a draw now barriers its sampled inputs into the layout their descriptors require, publishes the layout it leaves each colour target in, and clears rather than loading pixels `offscreen_render` has just discarded. Refs #1777 Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/adapter-authoring.md | 6 +- .../adapter-runtime-integration.md | 24 +- docs/architecture/graphics-kernel.md | 32 +- docs/architecture/ray-tracing-kernel.md | 37 +- docs/plan/changes/python-kernel-surface.md | 2 +- .../compiler_ops/subprocess_escalate.rs | 6504 ++++++++++------- .../escalate_request.rs | 291 +- .../escalate_response.rs | 19 +- .../escalate_wire_encoding_tests.rs | 105 +- .../src/core/context/gpu_context.rs | 591 +- .../core/context/graphics_kernel_bridge.rs | 358 - .../streamlib-engine/src/core/context/mod.rs | 21 - .../core/context/ray_tracing_kernel_bridge.rs | 235 - .../src/core/rhi/compute_kernel.rs | 35 +- .../src/core/rhi/graphics_kernel.rs | 233 +- .../src/core/rhi/kernel_binding_names.rs | 266 +- runtime/streamlib-engine/src/core/rhi/mod.rs | 21 +- .../src/core/rhi/ray_tracing_kernel.rs | 228 +- .../rhi/spirv_module_rewriting_for_tests.rs | 142 + .../streamlib-engine/src/vulkan/rhi/mod.rs | 1 + .../rhi/vulkan_acceleration_structure.rs | 62 + .../src/vulkan/rhi/vulkan_graphics_kernel.rs | 140 +- .../vulkan/rhi/vulkan_ray_tracing_kernel.rs | 154 +- .../python/streamlib/_engine.pyi | 191 +- sdk/streamlib-python-wheel/src/lib.rs | 3 + .../python_helper_process_pixel_exchange.rs | 283 +- .../src/python_processor_context.rs | 1734 ++++- .../tests/graphics_kernel_app.py | 28 + .../tests/graphics_kernel_probes.py | 433 ++ .../tests/ray_tracing_kernel_app.py | 28 + .../tests/ray_tracing_kernel_probes.py | 473 ++ .../tests/test_graphics_kernel.py | 294 + .../tests/test_ray_tracing_kernel.py | 273 + 33 files changed, 9547 insertions(+), 3700 deletions(-) delete mode 100644 runtime/streamlib-engine/src/core/context/graphics_kernel_bridge.rs delete mode 100644 runtime/streamlib-engine/src/core/context/ray_tracing_kernel_bridge.rs create mode 100644 runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs create mode 100644 sdk/streamlib-python-wheel/tests/graphics_kernel_app.py create mode 100644 sdk/streamlib-python-wheel/tests/graphics_kernel_probes.py create mode 100644 sdk/streamlib-python-wheel/tests/ray_tracing_kernel_app.py create mode 100644 sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py create mode 100644 sdk/streamlib-python-wheel/tests/test_graphics_kernel.py create mode 100644 sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py diff --git a/docs/architecture/adapter-authoring.md b/docs/architecture/adapter-authoring.md index 31b0705c7..5d5f493a5 100644 --- a/docs/architecture/adapter-authoring.md +++ b/docs/architecture/adapter-authoring.md @@ -74,9 +74,9 @@ The canonical recipe: 5. **Runtime wiring is a single `install_setup_hook` call** at app startup (see [Runtime wiring](#runtime-wiring) below). The hook - captures whatever pre-start state the adapter needs, allocates + - registers host surfaces, and (for escalate-trigger adapters) - sets the bridge on `GpuContext`. + captures whatever pre-start state the adapter needs and + allocates + registers host surfaces. Nothing is installed on + `GpuContext` — every escalate op it answers is always present. That's the full shape. Every in-tree adapter follows it, with the only meaningful axis of variation being the **handle type** (DMA-BUF diff --git a/docs/architecture/adapter-runtime-integration.md b/docs/architecture/adapter-runtime-integration.md index 678184916..633b9df60 100644 --- a/docs/architecture/adapter-runtime-integration.md +++ b/docs/architecture/adapter-runtime-integration.md @@ -254,8 +254,8 @@ Every surface adapter's host-side wiring runs through [`Runner::install_setup_hook`][hook]. The hook fires exactly once per `start()`, after `GpuContext::init_for_platform_sync` has created the live `GpuContext` but before any processor's `setup()` -runs — the window where adapter bridges and pre-allocated host -surfaces have to be in place. +runs — the window where pre-allocated host surfaces have to be in +place. [hook]: ../../runtime/streamlib-engine/src/core/runtime/runtime.rs @@ -309,20 +309,22 @@ The shape of what the hook does varies by seam: adapter — and that submit signals `produce_done`. The cdylib signals `consume_done` from `end_read_access`. -The graphics / ray-tracing kernel bridges still follow the older shape -(`gpu.set_graphics_kernel_bridge`, `set_ray_tracing_kernel_bridge`) for -adapters that escalate kernel dispatch through the host RHI. Compute and CPU -readback have no bridge: `register_compute_kernel` / `run_compute_kernel`, -`run_cpu_readback_copy` / `try_run_cpu_readback_copy` and -`open_cpu_readback_staging` are always-present `GpuContext` capabilities -served by the escalate handler directly. +No kernel dispatch flavour uses a bridge. Compute, graphics, ray tracing +and CPU readback are always-present `GpuContext` capabilities served by the +escalate handler directly, with no installation step and no runtime-absent +case: `create_or_reuse_compute_kernel` / `compute_kernel_by_id`, +`create_or_reuse_graphics_kernel` / `graphics_kernel_by_id`, +`create_or_reuse_ray_tracing_kernel` / `ray_tracing_kernel_by_id`, +`register_acceleration_structure` / `acceleration_structure_by_id`, and +`run_cpu_readback_copy` / `try_run_cpu_readback_copy` / +`open_cpu_readback_staging`. The hook is the canonical opt-in registration point for adapters that need pre-start GpuContext access. Application authors call `install_setup_hook` exactly once per adapter they wire. Per-acquire host work on behalf of a subprocess is not an adapter -concern. Compute dispatch and CPU readback are `GpuContext` +concern. Kernel dispatch and CPU readback are `GpuContext` capabilities reached the same way by every caller, with no installation step and no runtime-absent case. @@ -430,7 +432,7 @@ With `install_setup_hook` the model is: 1. Add the adapter crate as a Cargo dep. 2. Call `runtime.install_setup_hook(...)` exactly once at app startup, doing the adapter's required pre-start work (allocate - host surfaces, register in surface-share, set bridge if needed). + host surfaces, register in surface-share). The cost: one extra line of wiring per adapter at the application's `main.rs`. Compile-time presence is not enough — you have to diff --git a/docs/architecture/graphics-kernel.md b/docs/architecture/graphics-kernel.md index 43635176b..79fcd6a01 100644 --- a/docs/architecture/graphics-kernel.md +++ b/docs/architecture/graphics-kernel.md @@ -23,6 +23,10 @@ declaration, the kernel: per-stage descriptor sets, and validates that: - Declared `bindings` match the merged shader declaration (kind + stage visibility). + - A spec that carries a `name` spells the slot the way the SPIR-V + does. + - No two stages name the same slot differently — bindings are + resolved by name, so one slot spelled two ways cannot be bound. - Push-constant size and stage visibility match. - Only descriptor set 0 is used (multi-set is out of scope). - Stage classification matches the SPIR-V (a Vertex stage's blob @@ -31,6 +35,13 @@ declaration, the kernel: Mismatches surface as a `Result::Err` at *kernel creation*, not as undefined GPU behavior at first draw. + Reflection also **adopts the shader's own name onto each spec**: + wherever the SPIR-V carries an `OpName` for a slot, the spec the + kernel stores and `bindings()` returns comes back with that name, + whether or not the caller supplied one. The numeric binding is what + the descriptor set is built from; the name is what a by-name draw + resolves against. + 2. **Builds the descriptor-set layout, descriptor pool, descriptor-set ring, pipeline layout, graphics pipeline (with on-disk pipeline cache), and a default linear-clamp sampler.** None of this is your @@ -105,8 +116,10 @@ serial-dispatch contract). 2. **Wire the shaders into `build.rs`.** Append vertex + fragment entries to the `shaders` array in `runtime/streamlib-engine/build.rs`. The - build script invokes `glslc -O` per-stage and writes SPIR-V into - `OUT_DIR`. SPIR-V is read at compile time via + build script invokes `glslc -g -O` per-stage and writes SPIR-V into + `OUT_DIR`. The `-g` is not optional: `-O` strips every `OpName`, and + a binding whose name is gone cannot be bound by name. SPIR-V is read at + compile time via `include_bytes!(concat!(env!("OUT_DIR"), "/..spv"))`. Do not commit `.spv` files to the source tree — they're build artifacts. @@ -119,6 +132,13 @@ serial-dispatch contract). GraphicsBindingSpec::sampled_texture(0, GraphicsShaderStageFlags::FRAGMENT), ]; + // Or assert the shader's spelling too — creation fails if the SPIR-V + // names slot 0 anything else: + let bindings = vec![ + GraphicsBindingSpec::sampled_texture(0, GraphicsShaderStageFlags::FRAGMENT) + .with_name("input_texture"), + ]; + let pipeline_state = GraphicsPipelineState { topology: PrimitiveTopology::TriangleList, vertex_input: VertexInputState::None, // gl_VertexIndex @@ -133,6 +153,14 @@ serial-dispatch contract). }; ``` + A `GraphicsBindingSpec` is `(binding, kind, stages, name)` — the + `sampled_texture` / `storage_buffer` / `uniform_buffer` / + `storage_image` constructors leave `name` as `None`, and `with_name` + fills it. A caller that knows the shader's name but not its slot + declares a `GraphicsBindingDeclaration` (name + kind + stages, no + slot) instead; it is reconciled against reflection by name, stage by + stage, and the slot comes back from the shader. + 4. **Create the kernel via `GpuContext::create_graphics_kernel`** at setup time and store the `Arc`: diff --git a/docs/architecture/ray-tracing-kernel.md b/docs/architecture/ray-tracing-kernel.md index 31f3f5b0a..4cb4e6377 100644 --- a/docs/architecture/ray-tracing-kernel.md +++ b/docs/architecture/ray-tracing-kernel.md @@ -34,8 +34,12 @@ binding declaration, the kernel: [`rspirv-reflect`](https://docs.rs/rspirv-reflect), merges the per-stage descriptor sets, and validates that: - Declared `bindings` match the merged shader declaration (kind + - stage visibility, including the new + stage visibility, including the `RayTracingBindingKind::AccelerationStructure` variant). + - A spec that carries a `name` spells the slot the way the SPIR-V + does, and no two stages name the same slot differently — + bindings are resolved by name, so one slot spelled two ways + cannot be bound. - Push-constant size matches the largest declared push-constant range across all stages. - Only descriptor set 0 is used (multi-set is out of scope). @@ -46,6 +50,13 @@ binding declaration, the kernel: Mismatches surface as `Result::Err` at *kernel creation*, not as undefined GPU behavior at first trace. + Reflection also **adopts the shader's own name onto each spec**: + wherever the SPIR-V carries an `OpName` for a slot, the spec the + kernel stores and `bindings()` returns comes back with that name, + whether or not the caller supplied one. The numeric binding is what + the descriptor set is built from; the name is what a by-name trace + resolves against. + 2. **Builds the descriptor-set layout, descriptor pool, descriptor set, pipeline layout, ray-tracing pipeline, and shader-binding table.** None of this is your code anymore. The SBT is laid out @@ -82,9 +93,11 @@ binding declaration, the kernel: 2. **Wire the shaders into `build.rs`.** Add an entry per stage to the `rt_shaders` array in `runtime/streamlib-engine/build.rs`. RT shaders are compiled with `--target-env=vulkan1.2 --target-spv=spv1.4` - so `SPV_KHR_ray_tracing` opcodes are available; the helper - handles the per-stage `-fshader-stage=rgen|rmiss|rchit|...` - flag. SPIR-V is read via + so `SPV_KHR_ray_tracing` opcodes are available, and with `-g -O`; + the helper handles the per-stage + `-fshader-stage=rgen|rmiss|rchit|...` flag. The `-g` is not + optional: `-O` strips every `OpName`, and a binding whose name is + gone cannot be bound by name. SPIR-V is read via `include_bytes!(concat!(env!("OUT_DIR"), "/.spv"))`. 3. **Declare stages, groups, bindings, and push constants as data.** @@ -108,8 +121,24 @@ binding declaration, the kernel: RayTracingBindingSpec::acceleration_structure(0, RayTracingShaderStageFlags::RAYGEN), RayTracingBindingSpec::storage_image(1, RayTracingShaderStageFlags::RAYGEN), ]; + + // Or assert the shader's spelling too — creation fails if the SPIR-V + // names slot 0 anything else: + let bindings = vec![ + RayTracingBindingSpec::acceleration_structure(0, RayTracingShaderStageFlags::RAYGEN) + .with_name("top_level_acceleration_structure"), + ]; ``` + A `RayTracingBindingSpec` is `(binding, kind, stages, name)` — the + `acceleration_structure` / `storage_image` / `storage_buffer` / + `uniform_buffer` / `sampled_texture` constructors leave `name` as + `None`, and `with_name` fills it. A caller that knows the shader's + name but not its slot declares a `RayTracingBindingDeclaration` + (name + kind + stages, no slot) instead; it is reconciled against + reflection by name, stage by stage, and the slot comes back from the + shader. + 4. **Build the acceleration structures.** Triangle BLASes come from `GpuContext::build_triangles_blas(label, vertices, indices)` — vertices are interleaved `[x, y, z, x, y, z, …]` (R32G32B32_SFLOAT, diff --git a/docs/plan/changes/python-kernel-surface.md b/docs/plan/changes/python-kernel-surface.md index c5c6f877b..b80a7edac 100644 --- a/docs/plan/changes/python-kernel-surface.md +++ b/docs/plan/changes/python-kernel-surface.md @@ -170,7 +170,7 @@ Bare patterns — the ship gate greps each line verbatim as a fixed string. - REMOVED: importing a foreign DMA-BUF is not reachable from a Python processor yet The refusal (`python_processor_context.rs:745-759`) and its stub entry (`_engine.pyi:381`). - REMOVED: writable: false - The `writable: false` texture arm (`surface_export_staging.rs:373`), which is the anchor this + The `writable: false` texture arm (`surface_export_staging.rs:394`), which is the anchor this bullet was re-cut to when #1774 implemented the readback half — the shared literal it named before ("device export is read-only") no longer distinguishes the two halves, because #1774 reworded that refusal to name the surface. Only the texture arm retires, with the diff --git a/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate.rs b/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate.rs index 1b0b8f2c1..db22c127d 100644 --- a/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate.rs +++ b/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate.rs @@ -27,61 +27,55 @@ use crate::core::rhi::GlslCompilationTargetStage; use crate::host_rhi::HostSurfaceStoreExt; use super::subprocess_escalate_wire_types::escalate_request::{ - EscalateComputeBindingKind, EscalateRequestAcquireImage, EscalateRequestAcquirePixelBuffer, - EscalateRequestAcquireTexture, EscalateRequestCopyDeviceExportStagingBackToSurface, - EscalateRequestLog, EscalateRequestLogLevel, EscalateRequestLogSource, - EscalateRequestOpenCpuReadbackStaging, EscalateRequestOpenDeviceExportStaging, - EscalateRequestRefillDeviceExportStaging, EscalateRequestRegisterAccelerationStructureBlas, + EscalateComputeBindingKind, EscalateGraphicsBindingKind, EscalateRayTracingBindingKind, + EscalateRequestAcquireImage, EscalateRequestAcquirePixelBuffer, EscalateRequestAcquireTexture, + EscalateRequestCopyDeviceExportStagingBackToSurface, EscalateRequestLog, + EscalateRequestLogLevel, EscalateRequestLogSource, EscalateRequestOpenCpuReadbackStaging, + EscalateRequestOpenDeviceExportStaging, EscalateRequestRefillDeviceExportStaging, + EscalateRequestRegisterAccelerationStructureBlas, EscalateRequestRegisterAccelerationStructureTlas, EscalateRequestRegisterComputeKernel, - EscalateRequestRegisterGraphicsKernel, EscalateRequestRegisterGraphicsKernelBindingKind, - EscalateRequestRegisterGraphicsKernelPipelineState, - EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDepthFormat, + EscalateRequestRegisterGraphicsKernel, EscalateRequestRegisterGraphicsKernelPipelineState, EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp, EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp, EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstAlphaFactor, EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstColorFactor, EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcAlphaFactor, EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcColorFactor, - EscalateRequestRegisterGraphicsKernelPipelineStateDepthCompareOp, EscalateRequestRegisterGraphicsKernelPipelineStateDynamicState, EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode, EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationFrontFace, EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode, EscalateRequestRegisterGraphicsKernelPipelineStateTopology, - EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttributeFormat, - EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBindingInputRate, - EscalateRequestRegisterRayTracingKernel, EscalateRequestRegisterRayTracingKernelBindingKind, - EscalateRequestRegisterRayTracingKernelGroupKind, + EscalateRequestRegisterRayTracingKernel, EscalateRequestRegisterRayTracingKernelGroupKind, EscalateRequestRegisterRayTracingKernelStageStage, EscalateRequestReleaseHandle, EscalateRequestRunComputeKernel, EscalateRequestRunComputeKernelBatch, EscalateRequestRunComputeKernelBinding, EscalateRequestRunCpuReadbackCopy, EscalateRequestRunCpuReadbackCopyDirection, EscalateRequestRunGraphicsDraw, - EscalateRequestRunGraphicsDrawBindingKind, EscalateRequestRunGraphicsDrawDrawKind, - EscalateRequestRunGraphicsDrawIndexBufferIndexType, EscalateRequestRunRayTracingKernel, - EscalateRequestRunRayTracingKernelBindingKind, EscalateRequestTryRunCpuReadbackCopy, - EscalateRequestTryRunCpuReadbackCopyDirection, EscalateRequestWaitDeviceIdle, + EscalateRequestRunGraphicsDrawDrawKind, EscalateRequestRunRayTracingKernel, + EscalateRequestTryRunCpuReadbackCopy, EscalateRequestTryRunCpuReadbackCopyDirection, + EscalateRequestWaitDeviceIdle, RAY_TRACING_STAGE_INDEX_NONE, +}; +// Each names a wire field the handler no longer reads: a depth attachment and +// either half of a vertex input are refused, so only the tests that prove the +// refusals still spell them. +#[cfg(test)] +use super::subprocess_escalate_wire_types::escalate_request::{ + EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDepthFormat, + EscalateRequestRegisterGraphicsKernelPipelineStateDepthCompareOp, + EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttributeFormat, + EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBindingInputRate, }; #[cfg(target_os = "linux")] -use super::subprocess_escalate_wire_types::escalate_response::EscalateResponseComputeBinding; +use super::subprocess_escalate_wire_types::escalate_response::EscalateResponseKernelBinding; use super::subprocess_escalate_wire_types::escalate_response::{ EscalateResponseContended, EscalateResponseErr, EscalateResponseOk, }; use super::subprocess_escalate_wire_types::{EscalateRequest, EscalateResponse}; use crate::core::context::GpuContextLimitedAccess; #[cfg(target_os = "linux")] -use crate::core::context::{ - BlasRegisterDecl, BlendFactorWire, BlendOpWire, CullModeWire, DepthCompareOpWire, - DepthFormatWire, DynamicStateWire, FrontFaceWire, GraphicsBindingDecl, GraphicsBindingKindWire, - GraphicsBindingValue, GraphicsDrawSpec, GraphicsIndexBufferBinding, GraphicsKernelBridge, - GraphicsKernelRegisterDecl, GraphicsKernelRunDraw, GraphicsPipelineStateWire, - GraphicsVertexBufferBinding, IndexTypeWire, PolygonModeWire, PrimitiveTopologyWire, - RAY_TRACING_STAGE_INDEX_NONE, RayTracingBindingDecl, RayTracingBindingKindWire, - RayTracingBindingValue, RayTracingKernelBridge, RayTracingKernelRegisterDecl, - RayTracingKernelRunDispatch, RayTracingShaderGroupWire, RayTracingShaderStageWire, - RayTracingStageDecl, ScissorRectWire, SurfaceExportStagingResidency, TlasInstanceDeclWire, - TlasRegisterDecl, VertexAttributeFormatWire, VertexInputAttributeDecl, VertexInputBindingDecl, - VertexInputRateWire, ViewportWire, -}; +use crate::core::context::SurfaceExportStagingResidency; +#[cfg(target_os = "linux")] +use crate::core::context::TextureRegistration; use crate::core::context::{PooledTextureHandle, TexturePoolDescriptor}; use crate::core::logging::{LogLevel, LogRecord, Source, push_polyglot_record}; use crate::core::rhi::{PixelBuffer, PixelFormat, TextureFormat, TextureUsages}; @@ -743,12 +737,18 @@ pub(crate) fn handle_escalate_op( handle_id, }) => { let removed = registry.remove_handle(&handle_id); - Some(if removed { + if removed { // Pixel-buffer / texture / image acquires were // checked into the surface-share service under the // returned handle_id; pair the registry eviction // with the matching service release. release_surface_share_surface(sandbox, &handle_id); + } + // An acceleration structure is registered against `GpuContext` + // rather than against the per-subprocess handle registry, so its id + // reaches the same release verb through the device gate. + let released = removed || release_acceleration_structure(sandbox, &handle_id); + Some(if released { EscalateResponse::Ok(EscalateResponseOk { request_id: rid, handle_id, @@ -1455,29 +1455,17 @@ fn handle_register_compute_kernel( }) .and_then(|(kernel_id, kernel)| { // The caller dispatches by name and only the shader knows which - // kind each name is, so the shape goes back with the id. Every - // binding this kernel holds came through reflection, which refuses - // an unnamed one — an absent name here is a broken invariant, not - // a case to skip over. + // kind each name is, so the shape goes back with the id. let bindings = kernel .bindings() .iter() .map(|spec| { - Ok(EscalateResponseComputeBinding { - kind: compute_binding_kind_to_wire(spec.kind), - name: spec - .name - .as_deref() - .ok_or_else(|| { - crate::core::error::Error::GpuError(format!( - "kernel {kernel_id} holds an unnamed binding at slot {}; \ - reflection refuses these, so this kernel did not come \ - through registration", - spec.binding - )) - })? - .to_string(), - }) + reflected_kernel_binding_response( + &kernel_id, + spec.binding, + compute_binding_kind_to_wire(spec.kind).wire_name(), + spec.name.as_deref(), + ) }) .collect::>>()?; Ok((kernel_id, bindings)) @@ -1550,7 +1538,7 @@ fn handle_run_compute_kernel( #[cfg(target_os = "linux")] use crate::core::context::{BatchedComputeKernelDispatch, BatchedComputeKernelDispatchBinding}; #[cfg(target_os = "linux")] -use crate::core::rhi::SurfaceBoundComputeBindingKind; +use crate::core::rhi::SurfaceBoundKernelBindingKind; #[cfg(target_os = "linux")] use crate::host_rhi::HostTextureExt as _; @@ -1560,7 +1548,7 @@ use crate::host_rhi::HostTextureExt as _; #[derive(Debug, PartialEq, Eq)] struct PlannedComputeBinding<'a> { binding: u32, - kind: SurfaceBoundComputeBindingKind, + kind: SurfaceBoundKernelBindingKind, name: &'a str, target_id: &'a str, } @@ -1636,8 +1624,8 @@ fn plan_supplied_compute_bindings<'a>( ))); } let surface_bound_kind = match spec.kind { - ComputeBindingKind::StorageImage => SurfaceBoundComputeBindingKind::StorageImage, - ComputeBindingKind::SampledTexture => SurfaceBoundComputeBindingKind::SampledTexture, + ComputeBindingKind::StorageImage => SurfaceBoundKernelBindingKind::StorageImage, + ComputeBindingKind::SampledTexture => SurfaceBoundKernelBindingKind::SampledTexture, ComputeBindingKind::SampledImage | ComputeBindingKind::StorageBuffer | ComputeBindingKind::UniformBuffer => { @@ -1709,7 +1697,7 @@ fn resolve_supplied_compute_bindings( // texture (`#` resolves through the same cache entry as // ``), so a string comparison would let the pair through to exactly // the dispatch this refuses. - for (index, binding) in resolved.iter().enumerate() { + for (index, (binding, plan)) in resolved.iter().zip(&planned).enumerate() { // A texture carrying no image is its own error, raised where the // descriptor would be written. Skipped rather than compared, because // two absent images are not one texture and refusing them here would @@ -1717,10 +1705,11 @@ fn resolve_supplied_compute_bindings( let Some(image) = binding.registration.texture().vulkan_inner().image() else { continue; }; - if let Some(other) = resolved[..index].iter().position(|prior| { + let clashing = resolved[..index].iter().zip(&planned).find(|(prior, _)| { prior.kind != binding.kind && prior.registration.texture().vulkan_inner().image() == Some(image) - }) { + }); + if let Some((prior, prior_plan)) = clashing { // Both ids, as the caller wrote them: a published frame id and its // pool slot are different strings for one texture, so naming only // one would leave the reader looking for a duplicate that is not @@ -1729,11 +1718,11 @@ fn resolve_supplied_compute_bindings( "bindings `{}` (surface {:?}) and `{}` (surface {:?}) name one texture but \ as {:?} and {:?}; no image layout satisfies both descriptors, so a \ dispatch reads and writes different surfaces or binds one of them alone", - planned[other].name, - planned[other].target_id, - planned[index].name, - planned[index].target_id, - resolved[other].kind, + prior_plan.name, + prior_plan.target_id, + plan.name, + plan.target_id, + prior.kind, binding.kind ))); } @@ -1869,331 +1858,893 @@ fn bind_and_dispatch_compute_kernel_batch( full.dispatch_compute_kernel_batch(&batch) } -/// Map a wire-format `register_graphics_kernel` request through the -/// registered [`GraphicsKernelBridge`]. +/// One binding a draw or a trace supplied, as the planner reads it — whichever +/// wire array it arrived in. +#[cfg(target_os = "linux")] +struct SuppliedKernelBindingUnderPlanning<'a> { + name: &'a str, + target_id: &'a str, + kind_wire_name: &'static str, +} + +/// One binding a kernel declares, as the planner reads it. +#[cfg(target_os = "linux")] +struct DeclaredKernelBindingUnderPlanning<'a> { + binding_slot: u32, + /// `None` on a binding reflection left unnamed, which nothing can resolve + /// by name. + name: Option<&'a str>, + kind_wire_name: &'static str, + /// `None` for a kind no surface can be named for — buffers, and the + /// acceleration structure a trace resolves through its own registry. + surface_bound_kind: Option, +} + +/// What one validated binding resolved to: the slot to write, the kind to write +/// it as, and the surface to look up. +#[cfg(target_os = "linux")] +struct PlannedSurfaceBoundKernelBinding<'a> { + binding_slot: u32, + kind: SurfaceBoundKernelBindingKind, + name: &'a str, + target_id: &'a str, +} + +/// One planned binding carried together with the device texture it names. /// -/// Resolves each stage's shader — GLSL source the engine compiles, or the -/// pre-compiled hex escape hatch — translates the wire-format -/// pipeline-state enums into the bridge's typed [`GraphicsPipelineStateWire`], -/// and asks the bridge to register the kernel. The bridge returns a -/// stable `kernel_id` (recommended: SHA-256 over a canonical -/// representation of all register-time inputs); identical re-registration -/// hits the bridge's cache and returns the same id. +/// The pair travels as one value rather than as two collections read at a +/// shared index: every step after resolution — the kind-clash check, the +/// pre-run barrier, the colour-target check, the `set_*` calls — needs the plan +/// and the registration together, and a shared index is a desynchronisation +/// waiting to be introduced. +#[cfg(target_os = "linux")] +struct ResolvedSurfaceBoundKernelBinding<'a> { + planned: PlannedSurfaceBoundKernelBinding<'a>, + registration: TextureRegistration, +} + +/// Refuse one binding name supplied twice in a single run's wire array. /// -/// Failure modes (each surfaced as an [`EscalateResponse::Err`] keyed -/// by the original request_id): -/// 1. A stage supplies neither `*_source` nor `*_spv_hex`, or both; its -/// `*_source` does not compile; or its `*_spv_hex` doesn't decode. -/// 2. No bridge is registered. -/// 3. Bridge `register` returned an error — typically reflection -/// failure, push-constant size mismatch, pipeline-state validation -/// failure, or pipeline build failure. +/// Shared with the trace path, which runs this over the whole array before +/// splitting the acceleration structures out of it — the planner never sees +/// those, and one rule reads as one message wherever it fires. The names it saw +/// come back, so a caller's missing-binding check does not walk the array again. #[cfg(target_os = "linux")] -fn handle_register_graphics_kernel( +fn refuse_a_kernel_binding_name_supplied_twice<'a>( + invocation_noun: &str, + supplied_names: impl IntoIterator, + declared_names: &[&str], +) -> crate::core::error::Result> { + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for name in supplied_names { + if !seen.insert(name) { + return Err(crate::core::error::Error::GpuError(format!( + "binding `{name}` was supplied twice; this kernel declares {}, each supplied \ + exactly once per {invocation_noun}", + crate::core::rhi::quote_declared_shader_binding_names(declared_names) + ))); + } + } + Ok(seen) +} + +/// Match a draw's or a trace's supplied bindings against the kernel's declared +/// ones. +/// +/// The graphics and ray-tracing twin of [`plan_supplied_compute_bindings`], +/// with the same rules: every failure raises before any resource is bound and +/// long before a submission, and every message names the kernel's own bindings. +/// Bindings do not persist on a kernel, so one run supplies all of them or +/// none. `invocation_noun` is what one run of this pipeline kind is called, so +/// the refusals read as the caller's op does. +#[cfg(target_os = "linux")] +fn plan_supplied_surface_bound_kernel_bindings<'a>( + invocation_noun: &str, + supplied: &[SuppliedKernelBindingUnderPlanning<'a>], + declared: &[DeclaredKernelBindingUnderPlanning<'a>], +) -> crate::core::error::Result>> { + use crate::core::error::Error; + + let declared_names: Vec<&str> = declared.iter().filter_map(|d| d.name).collect(); + // Built only when a refusal fires — this runs per frame, and the happy + // path should not pay for the error text. + let kernel_declares = || crate::core::rhi::quote_declared_shader_binding_names(&declared_names); + + let seen = refuse_a_kernel_binding_name_supplied_twice( + invocation_noun, + supplied.iter().map(|entry| entry.name), + &declared_names, + )?; + + for name in &declared_names { + if !seen.contains(name) { + return Err(Error::GpuError(format!( + "binding `{name}` was not supplied; bindings do not persist between \ + {invocation_noun}s, so every {invocation_noun} supplies all of {}", + kernel_declares() + ))); + } + } + + let mut planned = Vec::with_capacity(supplied.len()); + for entry in supplied { + let declaration = declared + .iter() + .find(|d| d.name == Some(entry.name)) + .ok_or_else(|| { + Error::GpuError(format!( + "binding `{}` is not one this kernel declares; it declares {}", + entry.name, + kernel_declares() + )) + })?; + if declaration.kind_wire_name != entry.kind_wire_name { + return Err(Error::GpuError(format!( + "binding `{}` was supplied as {} but this kernel declares it {}", + entry.name, entry.kind_wire_name, declaration.kind_wire_name + ))); + } + let kind = declaration.surface_bound_kind.ok_or_else(|| { + Error::GpuError(format!( + "binding `{}` is {}, which a {invocation_noun} cannot name a surface for — the \ + surface-backed kinds are storage_image and sampled_texture", + entry.name, declaration.kind_wire_name + )) + })?; + planned.push(PlannedSurfaceBoundKernelBinding { + binding_slot: declaration.binding_slot, + kind, + name: entry.name, + target_id: entry.target_id, + }); + } + Ok(planned) +} + +/// Resolve every planned binding to the device texture it names, keeping the +/// two together. +/// +/// Each registration is a refcount on the texture its descriptor will point at +/// — the caller holds them across the submission, because dropping the last one +/// before the GPU has run frees the image out from under it. +#[cfg(target_os = "linux")] +fn resolve_planned_surface_bound_kernel_bindings<'a>( + full: &crate::core::context::GpuContextFullAccess, + planned: Vec>, +) -> crate::core::error::Result>> { + use crate::core::error::Error; + + let mut resolved = Vec::with_capacity(planned.len()); + for binding in planned { + // Zero extent: a kernel binding names a surface the graph already has + // as a device texture, which resolves from the same-process cache or + // the surface-share service. The pixel-buffer fallback is the one path + // that consults the extent, and it refuses a zero one — a + // buffer-backed surface is not something a draw can bind. + let registration = full + .resolve_texture_registration_by_surface_id(binding.target_id, None, 0, 0) + .map_err(|e| { + Error::GpuError(format!( + "binding `{}` names surface {:?}, which this graph cannot resolve to a \ + device texture: {e}", + binding.name, binding.target_id + )) + })?; + resolved.push(ResolvedSurfaceBoundKernelBinding { + planned: binding, + registration, + }); + } + + // One image cannot serve two kinds in one run. The descriptor layouts are + // fixed and disagree — a combined image sampler is written + // SHADER_READ_ONLY_OPTIMAL and a storage image GENERAL — so whatever layout + // the texture is put in, one of the two descriptors is wrong. + // + // Compared after resolution and on the image, not on the id the caller + // wrote: a published frame id and its pool slot are two spellings of one + // texture (`#` resolves through the same cache entry as + // ``), so a string comparison would let the pair through to exactly + // the run this refuses. + for (index, binding) in resolved.iter().enumerate() { + // A texture carrying no image is its own error, raised where the + // descriptor would be written. Skipped rather than compared, because + // two absent images are not one texture and refusing them here would + // send the caller looking for a duplicate they did not write. + let Some(image) = binding.registration.texture().vulkan_inner().image() else { + continue; + }; + let clashing = resolved[..index].iter().find(|prior| { + prior.planned.kind != binding.planned.kind + && prior.registration.texture().vulkan_inner().image() == Some(image) + }); + if let Some(prior) = clashing { + // Both ids, as the caller wrote them: a published frame id and its + // pool slot are different strings for one texture, so naming only + // one would leave the reader looking for a duplicate that is not + // there on the page. + return Err(Error::GpuError(format!( + "bindings `{}` (surface {:?}) and `{}` (surface {:?}) name one texture but as \ + {:?} and {:?}; no image layout satisfies both descriptors, so this run reads \ + and writes different surfaces or binds one of them alone", + prior.planned.name, + prior.planned.target_id, + binding.planned.name, + binding.planned.target_id, + prior.planned.kind, + binding.planned.kind + ))); + } + } + Ok(resolved) +} + +/// Barrier every bound input into the layout its descriptor requires, and +/// publish the layout each one landed in. +/// +/// Neither `VulkanGraphicsKernel::offscreen_render` nor +/// `VulkanRayTracingKernel::trace_rays` barriers a bound input — the draw path +/// transitions its colour targets and nothing else — so a surface arriving in +/// `GENERAL` or `TRANSFER_DST_OPTIMAL` would be read through a descriptor its +/// layout does not satisfy. A run whose inputs already sit in the right layout +/// records nothing and mints no command buffer. +#[cfg(target_os = "linux")] +fn transition_bound_kernel_inputs_into_descriptor_layouts( + full: &crate::core::context::GpuContextFullAccess, + recorder_label: &str, + consuming_stage: crate::vulkan::rhi::VulkanStage, + bound_inputs: &[ResolvedSurfaceBoundKernelBinding<'_>], +) -> crate::core::error::Result<()> { + use crate::vulkan::rhi::{VulkanAccess, VulkanStage}; + + let mut images_already_barriered = Vec::new(); + let mut bindings_to_barrier = Vec::new(); + for binding in bound_inputs { + if binding.registration.current_layout() == binding.planned.kind.required_image_layout() { + continue; + } + // One texture bound at two slots is one image and one barrier — a + // second would name an oldLayout the first has already left, and the + // two slots agree on the layout anyway or the kind clash would have + // been refused already. + let image = binding.registration.texture().vulkan_inner().image(); + if images_already_barriered.contains(&image) { + continue; + } + images_already_barriered.push(image); + bindings_to_barrier.push(binding); + } + if bindings_to_barrier.is_empty() { + return Ok(()); + } + + let mut recorder = full.create_command_recorder(recorder_label)?; + recorder.begin()?; + for binding in &bindings_to_barrier { + // Whatever wrote this surface before the run is not this run's to know + // — a transfer upload, a camera, another node — so the source scope is + // the wide one every other entry-from-an-unknown-producer barrier in + // the engine uses. + let recorded = recorder.record_image_barrier( + binding.registration.texture(), + binding.registration.current_layout(), + binding.planned.kind.required_image_layout(), + VulkanStage::ALL_COMMANDS, + consuming_stage, + VulkanAccess::MEMORY_WRITE, + VulkanAccess::SHADER_READ | VulkanAccess::SHADER_WRITE, + ); + if let Err(e) = recorded { + recorder.abort_recording(); + return Err(e); + } + } + recorder.submit_and_wait()?; + // Published for every binding, not just the ones that were barriered: a + // cross-process import synthesizes a fresh registration per resolve, so two + // slots naming one surface hold two layout cells for the one image. + for binding in bound_inputs { + binding + .registration + .update_layout(binding.planned.kind.required_image_layout()); + } + Ok(()) +} + +/// One reflected binding as a register response spells it. +/// +/// Every binding a registered kernel holds came through reflection, which +/// refuses an unnamed one — an absent name here is a broken invariant, not a +/// case to skip over. +#[cfg(target_os = "linux")] +fn reflected_kernel_binding_response( + kernel_id: &str, + binding_slot: u32, + kind_wire_name: &str, + name: Option<&str>, +) -> crate::core::error::Result { + Ok(EscalateResponseKernelBinding { + kind: kind_wire_name.to_string(), + name: name + .ok_or_else(|| { + crate::core::error::Error::GpuError(format!( + "kernel {kernel_id} holds an unnamed binding at slot {binding_slot}; \ + reflection refuses these, so this kernel did not come through registration" + )) + })? + .to_string(), + }) +} + +/// The RHI binding kind a graphics wire enum names. +#[cfg(target_os = "linux")] +fn graphics_binding_kind_from_wire( + kind: EscalateGraphicsBindingKind, +) -> crate::core::rhi::GraphicsBindingKind { + use crate::core::rhi::GraphicsBindingKind; + match kind { + EscalateGraphicsBindingKind::SampledTexture => GraphicsBindingKind::SampledTexture, + EscalateGraphicsBindingKind::StorageBuffer => GraphicsBindingKind::StorageBuffer, + EscalateGraphicsBindingKind::StorageImage => GraphicsBindingKind::StorageImage, + EscalateGraphicsBindingKind::UniformBuffer => GraphicsBindingKind::UniformBuffer, + } +} + +/// The wire enum for a graphics binding kind. +#[cfg(target_os = "linux")] +fn graphics_binding_kind_to_wire( + kind: crate::core::rhi::GraphicsBindingKind, +) -> EscalateGraphicsBindingKind { + use crate::core::rhi::GraphicsBindingKind; + match kind { + GraphicsBindingKind::SampledTexture => EscalateGraphicsBindingKind::SampledTexture, + GraphicsBindingKind::StorageBuffer => EscalateGraphicsBindingKind::StorageBuffer, + GraphicsBindingKind::StorageImage => EscalateGraphicsBindingKind::StorageImage, + GraphicsBindingKind::UniformBuffer => EscalateGraphicsBindingKind::UniformBuffer, + } +} + +/// Whether a graphics binding kind is one a draw can name a surface for. +#[cfg(target_os = "linux")] +fn surface_bound_graphics_binding_kind( + kind: crate::core::rhi::GraphicsBindingKind, +) -> Option { + use crate::core::rhi::GraphicsBindingKind; + match kind { + GraphicsBindingKind::SampledTexture => Some(SurfaceBoundKernelBindingKind::SampledTexture), + GraphicsBindingKind::StorageImage => Some(SurfaceBoundKernelBindingKind::StorageImage), + GraphicsBindingKind::StorageBuffer | GraphicsBindingKind::UniformBuffer => None, + } +} + +/// The RHI binding kind a ray-tracing wire enum names. +#[cfg(target_os = "linux")] +fn ray_tracing_binding_kind_from_wire( + kind: EscalateRayTracingBindingKind, +) -> crate::core::rhi::RayTracingBindingKind { + use crate::core::rhi::RayTracingBindingKind; + match kind { + EscalateRayTracingBindingKind::AccelerationStructure => { + RayTracingBindingKind::AccelerationStructure + } + EscalateRayTracingBindingKind::SampledTexture => RayTracingBindingKind::SampledTexture, + EscalateRayTracingBindingKind::StorageBuffer => RayTracingBindingKind::StorageBuffer, + EscalateRayTracingBindingKind::StorageImage => RayTracingBindingKind::StorageImage, + EscalateRayTracingBindingKind::UniformBuffer => RayTracingBindingKind::UniformBuffer, + } +} + +/// The wire enum for a ray-tracing binding kind. +#[cfg(target_os = "linux")] +fn ray_tracing_binding_kind_to_wire( + kind: crate::core::rhi::RayTracingBindingKind, +) -> EscalateRayTracingBindingKind { + use crate::core::rhi::RayTracingBindingKind; + match kind { + RayTracingBindingKind::AccelerationStructure => { + EscalateRayTracingBindingKind::AccelerationStructure + } + RayTracingBindingKind::SampledTexture => EscalateRayTracingBindingKind::SampledTexture, + RayTracingBindingKind::StorageBuffer => EscalateRayTracingBindingKind::StorageBuffer, + RayTracingBindingKind::StorageImage => EscalateRayTracingBindingKind::StorageImage, + RayTracingBindingKind::UniformBuffer => EscalateRayTracingBindingKind::UniformBuffer, + } +} + +/// Whether a ray-tracing binding kind is one a trace can name a surface for. +/// +/// The acceleration structure is excluded because a trace resolves it through +/// the acceleration-structure registry, not through a surface. +#[cfg(target_os = "linux")] +fn surface_bound_ray_tracing_binding_kind( + kind: crate::core::rhi::RayTracingBindingKind, +) -> Option { + use crate::core::rhi::RayTracingBindingKind; + match kind { + RayTracingBindingKind::SampledTexture => { + Some(SurfaceBoundKernelBindingKind::SampledTexture) + } + RayTracingBindingKind::StorageImage => Some(SurfaceBoundKernelBindingKind::StorageImage), + RayTracingBindingKind::AccelerationStructure + | RayTracingBindingKind::StorageBuffer + | RayTracingBindingKind::UniformBuffer => None, + } +} + +/// Everything a `register_graphics_kernel` settles before it takes the device +/// gate: both stages compiled, the declaration read, the pipeline state +/// flattened. +#[cfg(target_os = "linux")] +struct PreparedGraphicsKernelRegistration { + label: String, + vertex_spv: Arc<[u8]>, + fragment_spv: Arc<[u8]>, + vertex_entry_point: String, + fragment_entry_point: String, + declared_bindings: Vec, + push_constants: crate::core::rhi::GraphicsPushConstants, + pipeline_state: crate::core::rhi::GraphicsPipelineState, + descriptor_sets_in_flight: u32, +} + +/// Read a `register_graphics_kernel` request into what `GpuContext` builds a +/// kernel from, without touching the device. +/// +/// Compilation is CPU work, and the escalate gate it would otherwise be holding +/// serializes every processor's device work — the same reason +/// [`RegisteredShaderStageSource::spirv`] takes the sandbox rather than a +/// `GpuContextFullAccess`. +#[cfg(target_os = "linux")] +fn prepare_graphics_kernel_registration( sandbox: &GpuContextLimitedAccess, - rid: String, req: EscalateRequestRegisterGraphicsKernel, -) -> EscalateResponse { - use std::sync::Arc; +) -> std::result::Result { + use crate::core::rhi::{ + GraphicsBindingDeclaration, GraphicsPushConstants, GraphicsShaderStageFlags, + }; - let stage_sources = registered_shader_stage_source( + let vertex_source = registered_shader_stage_source( "vertex_", &req.vertex_source, &req.vertex_spv_hex, GlslCompilationTargetStage::Vertex, &req.vertex_entry_point, - ) - .and_then(|vertex| { - registered_shader_stage_source( - "fragment_", - &req.fragment_source, - &req.fragment_spv_hex, - GlslCompilationTargetStage::Fragment, - &req.fragment_entry_point, - ) - .map(|fragment| (vertex, fragment)) - }); - let (vertex_source, fragment_source) = match stage_sources { - Ok(stage_sources) => stage_sources, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!("register_graphics_kernel: {e}"), - }); - } - }; - - let compiled = vertex_source - .spirv(sandbox) - .and_then(|vertex_spv| Ok((vertex_spv, fragment_source.spirv(sandbox)?))); - let (vertex_spv, fragment_spv) = match compiled { - Ok(compiled) => compiled, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!("register_graphics_kernel: {e}"), - }); - } - }; + )?; + let fragment_source = registered_shader_stage_source( + "fragment_", + &req.fragment_source, + &req.fragment_spv_hex, + GlslCompilationTargetStage::Fragment, + &req.fragment_entry_point, + )?; + let vertex_spv = vertex_source.spirv(sandbox).map_err(|e| e.to_string())?; + let fragment_spv = fragment_source.spirv(sandbox).map_err(|e| e.to_string())?; + + let mut declared_bindings = Vec::with_capacity(req.bindings.len()); + for wire in &req.bindings { + declared_bindings.push(GraphicsBindingDeclaration { + name: wire.name.clone(), + kind: graphics_binding_kind_from_wire(wire.kind), + stages: GraphicsShaderStageFlags::from_bits(wire.stages).ok_or_else(|| { + format!( + "binding `{}` names stages {:#b}, which sets a bit no graphics stage owns \ + (1 = vertex, 2 = fragment)", + wire.name, wire.stages + ) + })?, + }); + } - // Not re-prefixed with the op: this payload already opens with it, and - // `Error::Configuration` adds its own "Invalid configuration:" on top. - let bridge: Arc = match sandbox.escalate(|full| { - full.graphics_kernel_bridge().ok_or_else(|| { - crate::core::error::Error::Configuration( - "register_graphics_kernel: no GraphicsKernelBridge registered on GpuContext" - .to_string(), + let push_constants = GraphicsPushConstants { + size: req.push_constant_size, + stages: GraphicsShaderStageFlags::from_bits(req.push_constant_stages).ok_or_else(|| { + format!( + "push_constant_stages {:#b} sets a bit no graphics stage owns (1 = vertex, \ + 2 = fragment)", + req.push_constant_stages ) - }) - }) { - Ok(bridge) => bridge, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: e.to_string(), - }); - } + })?, }; - let bindings: Vec = req - .bindings - .into_iter() - .map(|b| GraphicsBindingDecl { - binding: b.binding, - kind: graphics_register_binding_kind_from_wire(b.kind), - stages: b.stages, - }) - .collect(); - - let pipeline_state = match graphics_pipeline_state_from_wire(req.pipeline_state) { - Ok(p) => p, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!("register_graphics_kernel: pipeline_state: {e}"), - }); - } - }; + let pipeline_state = graphics_pipeline_state_from_wire(req.pipeline_state) + .map_err(|e| format!("pipeline_state: {e}"))?; - let decl = GraphicsKernelRegisterDecl { + Ok(PreparedGraphicsKernelRegistration { label: req.label, - vertex_spv: vertex_spv.to_vec(), - fragment_spv: fragment_spv.to_vec(), + vertex_spv, + fragment_spv, vertex_entry_point: vertex_source.entry_point().to_string(), fragment_entry_point: fragment_source.entry_point().to_string(), - bindings, - push_constant_size: req.push_constant_size, - push_constant_stages: req.push_constant_stages, - descriptor_sets_in_flight: req.descriptor_sets_in_flight, + declared_bindings, + push_constants, pipeline_state, - }; - - match bridge.register(&decl) { - Ok(kernel_id) => EscalateResponse::Ok(EscalateResponseOk { - request_id: rid, - handle_id: kernel_id, - ..Default::default() - }), - Err(msg) => EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!("register_graphics_kernel bridge call failed: {msg}"), - }), - } + descriptor_sets_in_flight: req.descriptor_sets_in_flight, + }) } -/// Map a wire-format `run_graphics_draw` request through the registered -/// [`GraphicsKernelBridge`]. +/// Build a graphics kernel for a subprocess customer, against `GpuContext`. +/// +/// The graphics twin of [`handle_register_compute_kernel`]: reflection over +/// both stages derives the binding shape and its names, the request's own +/// declaration is checked against it rather than replacing it, and +/// re-registering an identical kernel is a cache hit that answers with the same +/// `kernel_id`. /// -/// Graphics dispatch on the host is synchronous (the bridge calls -/// [`crate::vulkan::rhi::VulkanGraphicsKernel::offscreen_render`] which -/// submits + waits on its own command buffer + fence), so by the time -/// this function returns `Ok`, the GPU work has retired and the host's -/// writes to the color attachments are visible. +/// Each stage arrives as GLSL `*_source` the engine compiles, or as the +/// pre-compiled `*_spv_hex` escape hatch. /// -/// Failure modes (each surfaced as an [`EscalateResponse::Err`] keyed -/// by the original request_id): -/// 1. `push_constants_hex` doesn't decode as hex bytes. -/// 2. Vertex/index buffer offset doesn't parse as decimal u64. -/// 3. No bridge is registered. -/// 4. Bridge `run_draw` returned an error — typically unrecognized -/// `kernel_id`, surface lookup failure, or Vulkan submit failure. +/// Failure modes (each an [`EscalateResponse::Err`] keyed by the request_id): +/// 1. A stage supplies neither `*_source` nor `*_spv_hex`, or both; its source +/// does not compile; or its hex doesn't decode. +/// 2. A binding's or the push-constant range's `stages` mask sets a bit no +/// graphics stage owns. +/// 3. `pipeline_state` names a shape a draw cannot run — MSAA, other than +/// exactly one colour attachment, `depth_stencil_enabled` or an +/// `attachment_depth_format` (the offscreen pass a draw runs through +/// attaches colour targets only), a `vertex_input_bindings` or +/// `vertex_input_attributes` entry (no escalate op mints a `VertexBuffer` to +/// fill one), an unknown colour format, or a write mask no channel owns. +/// 4. The blobs' `OpName` decorations were stripped — bindings resolve by name, +/// so an unnamed binding cannot be bound at all. +/// 5. The declaration disagrees with reflection on a name, a kind, or a stage. +/// 6. Push-constant size mismatch, or pipeline build failure. #[cfg(target_os = "linux")] -fn handle_run_graphics_draw( +fn handle_register_graphics_kernel( sandbox: &GpuContextLimitedAccess, rid: String, - req: EscalateRequestRunGraphicsDraw, + req: EscalateRequestRegisterGraphicsKernel, ) -> EscalateResponse { - use std::sync::Arc; + use crate::core::rhi::{GraphicsShaderStage, GraphicsStage}; - let push_constants = match decode_hex(&req.push_constants_hex) { - Ok(b) => b, + let prepared = match prepare_graphics_kernel_registration(sandbox, req) { + Ok(prepared) => prepared, Err(e) => { return EscalateResponse::Err(EscalateResponseErr { request_id: rid, - message: format!("run_graphics_draw: push_constants_hex decode: {e}"), + message: format!("register_graphics_kernel: {e}"), }); } }; - let bindings: Vec = req - .bindings - .into_iter() - .map(|b| GraphicsBindingValue { - binding: b.binding, - kind: graphics_run_binding_kind_from_wire(b.kind), - surface_uuid: b.surface_uuid, - }) - .collect(); + let stages = [ + GraphicsStage { + stage: GraphicsShaderStage::Vertex, + spv: &prepared.vertex_spv, + entry_point: &prepared.vertex_entry_point, + }, + GraphicsStage { + stage: GraphicsShaderStage::Fragment, + spv: &prepared.fragment_spv, + entry_point: &prepared.fragment_entry_point, + }, + ]; - let mut vertex_buffers: Vec = - Vec::with_capacity(req.vertex_buffers.len()); - for vb in req.vertex_buffers.into_iter() { - let offset = match vb.offset.parse::() { - Ok(v) => v, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!( - "run_graphics_draw: vertex_buffer.offset '{}' is not a decimal u64: {e}", - vb.offset - ), - }); - } - }; - vertex_buffers.push(GraphicsVertexBufferBinding { - binding: vb.binding, - surface_uuid: vb.surface_uuid, - offset, + let registered = sandbox + .escalate(|full| { + full.create_or_reuse_graphics_kernel( + &prepared.label, + &stages, + prepared.push_constants, + &prepared.pipeline_state, + prepared.descriptor_sets_in_flight, + &prepared.declared_bindings, + ) + }) + .and_then(|(kernel_id, kernel)| { + // The caller draws by name and only the shaders know which kind + // each name is, so the shape goes back with the id. + let bindings = kernel + .bindings() + .iter() + .map(|spec| { + reflected_kernel_binding_response( + &kernel_id, + spec.binding, + graphics_binding_kind_to_wire(spec.kind).wire_name(), + spec.name.as_deref(), + ) + }) + .collect::>>()?; + Ok((kernel_id, bindings)) }); + + match registered { + Ok((kernel_id, bindings)) => EscalateResponse::Ok(EscalateResponseOk { + request_id: rid, + handle_id: kernel_id, + bindings: Some(bindings), + ..Default::default() + }), + Err(e) => EscalateResponse::Err(EscalateResponseErr { + request_id: rid, + message: format!("register_graphics_kernel failed: {e}"), + }), } +} - let index_buffer = if let Some(ib) = req.index_buffer { - let offset = match ib.offset.parse::() { - Ok(v) => v, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!( - "run_graphics_draw: index_buffer.offset '{}' is not a decimal u64: {e}", - ib.offset - ), - }); - } - }; - Some(GraphicsIndexBufferBinding { - surface_uuid: ib.surface_uuid, - offset, - index_type: match ib.index_type { - EscalateRequestRunGraphicsDrawIndexBufferIndexType::Uint16 => IndexTypeWire::Uint16, - EscalateRequestRunGraphicsDrawIndexBufferIndexType::Uint32 => IndexTypeWire::Uint32, - }, - }) +/// Render one offscreen pass with a registered graphics kernel, its bindings +/// resolved by name. +/// +/// The draw is synchronous on the host — `offscreen_render` submits and waits +/// on its own fence — so by the time this emits an `Ok`, the GPU work has +/// retired and the writes to the colour targets are visible to any later +/// submission on the same device. +/// +/// Three shapes the wire carries have no host path and are refused rather than +/// silently dropped: +/// - `vertex_buffers` / `index_buffer` / an indexed draw. The setters take a +/// [`crate::core::rhi::VertexBuffer`] / [`crate::core::rhi::IndexBuffer`], +/// and no escalate op mints either — a helper can acquire a pixel buffer, a +/// texture or an image, none of which those setters accept. +/// - `depth_target_uuid`. The offscreen pass attaches colour targets only, so a +/// depth attachment would never be tested against. +/// +/// Every binding error raises before anything is submitted, and names the +/// kernel's own bindings so the caller can see what it should have supplied. +#[cfg(target_os = "linux")] +fn handle_run_graphics_draw( + sandbox: &GpuContextLimitedAccess, + rid: String, + req: EscalateRequestRunGraphicsDraw, +) -> EscalateResponse { + let unsupported = if !req.vertex_buffers.is_empty() { + Some(format!( + "vertex_buffers names {} buffer(s), and no escalate op mints a VertexBuffer — a \ + helper can acquire a pixel buffer, a texture or an image, and the vertex-buffer \ + setter takes none of them. Fabricate vertices from gl_VertexIndex instead", + req.vertex_buffers.len() + )) + } else if req.index_buffer.is_some() + || matches!( + req.draw.kind, + EscalateRequestRunGraphicsDrawDrawKind::DrawIndexed + ) + { + Some( + "an indexed draw needs an IndexBuffer, and no escalate op mints one — a helper can \ + acquire a pixel buffer, a texture or an image, and the index-buffer setter takes \ + none of them" + .to_string(), + ) + } else if req.depth_target_uuid.is_some() { + Some( + "depth_target_uuid is set, and the offscreen pass this op drives attaches colour \ + targets only — the depth attachment would never be tested against" + .to_string(), + ) + } else if req.color_target_uuids.len() != 1 { + Some(format!( + "color_target_uuids names {} targets; the pipeline is built for exactly one colour \ + attachment", + req.color_target_uuids.len() + )) } else { None }; + if let Some(message) = unsupported { + return EscalateResponse::Err(EscalateResponseErr { + request_id: rid, + message: format!("run_graphics_draw: {message}"), + }); + } - let viewport = req.viewport.map(|v| ViewportWire { - x: v.x, - y: v.y, - width: v.width, - height: v.height, - min_depth: v.min_depth, - max_depth: v.max_depth, - }); - let scissor = req.scissor.map(|s| ScissorRectWire { - x: s.x, - y: s.y, - width: s.width, - height: s.height, - }); - - let draw = match req.draw.kind { - EscalateRequestRunGraphicsDrawDrawKind::Draw => GraphicsDrawSpec::Draw { - vertex_count: req.draw.vertex_count, - instance_count: req.draw.instance_count, - first_vertex: req.draw.first_vertex, - first_instance: req.draw.first_instance, - }, - EscalateRequestRunGraphicsDrawDrawKind::DrawIndexed => GraphicsDrawSpec::DrawIndexed { - index_count: req.draw.index_count, - instance_count: req.draw.instance_count, - first_index: req.draw.first_index, - vertex_offset: req.draw.vertex_offset, - first_instance: req.draw.first_instance, - }, - }; - - let kernel_id = req.kernel_id; - let domain = GraphicsKernelRunDraw { - kernel_id: kernel_id.clone(), - frame_index: req.frame_index, - bindings, - vertex_buffers, - index_buffer, - color_target_uuids: req.color_target_uuids, - depth_target_uuid: req.depth_target_uuid, - extent: (req.extent_width, req.extent_height), - push_constants, - viewport, - scissor, - draw, - }; - - let bridge: Arc = match sandbox.escalate(|full| { - full.graphics_kernel_bridge().ok_or_else(|| { - crate::core::error::Error::Configuration( - "run_graphics_draw: no GraphicsKernelBridge registered on GpuContext".to_string(), - ) - }) - }) { + let push_constants = match decode_hex(&req.push_constants_hex) { Ok(b) => b, Err(e) => { return EscalateResponse::Err(EscalateResponseErr { request_id: rid, - message: e.to_string(), + message: format!("run_graphics_draw: push_constants_hex decode: {e}"), }); } }; - match bridge.run_draw(&domain) { + let drawn = sandbox.escalate(|full| { + let kernel = full.graphics_kernel_by_id(&req.kernel_id).ok_or_else(|| { + crate::core::error::Error::GpuError(format!( + "run_graphics_draw: no kernel registered under id {:?}", + req.kernel_id + )) + })?; + bind_and_render_graphics_kernel(full, &kernel, &req, &push_constants) + }); + + match drawn { Ok(()) => EscalateResponse::Ok(EscalateResponseOk { request_id: rid, - handle_id: kernel_id, + // Echo the kernel_id back — the draw is sync host-side, so no + // separate handle is allocated per draw. + handle_id: req.kernel_id, ..Default::default() }), - Err(msg) => EscalateResponse::Err(EscalateResponseErr { + Err(e) => EscalateResponse::Err(EscalateResponseErr { request_id: rid, - message: format!("run_graphics_draw bridge call failed: {msg}"), + message: format!("run_graphics_draw failed: {e}"), + }), + } +} + +/// Resolve every named binding onto the kernel's slots, render, and publish the +/// layout each colour target was left in. +/// +/// The plan is total and every surface is resolved before the first `set_*` +/// call, so a refused draw never leaves the kernel holding a mix of this draw's +/// bindings and the last one's. The kernel's staged bindings are shared across +/// every caller of the cache; interleaving is prevented by the escalate gate, +/// which serializes the whole surrounding scope runtime-wide. +#[cfg(target_os = "linux")] +fn bind_and_render_graphics_kernel( + full: &crate::core::context::GpuContextFullAccess, + kernel: &crate::vulkan::rhi::VulkanGraphicsKernel, + req: &EscalateRequestRunGraphicsDraw, + push_constants: &[u8], +) -> crate::core::error::Result<()> { + use crate::core::error::Error; + use crate::core::rhi::{DrawCall, ScissorRect, Viewport, VulkanLayout}; + use crate::vulkan::rhi::{OffscreenColorTarget, OffscreenDraw, VulkanStage}; + + let declared_specs = kernel.bindings(); + let declared: Vec> = declared_specs + .iter() + .map(|spec| DeclaredKernelBindingUnderPlanning { + binding_slot: spec.binding, + name: spec.name.as_deref(), + kind_wire_name: graphics_binding_kind_to_wire(spec.kind).wire_name(), + surface_bound_kind: surface_bound_graphics_binding_kind(spec.kind), + }) + .collect(); + let supplied: Vec> = req + .bindings + .iter() + .map(|wire| SuppliedKernelBindingUnderPlanning { + name: wire.name.as_str(), + target_id: wire.surface_uuid.as_str(), + kind_wire_name: wire.kind.wire_name(), + }) + .collect(); + let planned = plan_supplied_surface_bound_kernel_bindings("draw", &supplied, &declared)?; + + // Held across the render, not consumed by the bind loop: a registration is + // a refcount on the texture the descriptor set now points at, and dropping + // the last one before the GPU has run frees the image out from under it. + let bound_inputs = resolve_planned_surface_bound_kernel_bindings(full, planned)?; + transition_bound_kernel_inputs_into_descriptor_layouts( + full, + "escalate_graphics_draw_input_layouts", + VulkanStage::ALL_GRAPHICS, + &bound_inputs, + )?; + + let mut color_targets = Vec::with_capacity(req.color_target_uuids.len()); + for surface_id in &req.color_target_uuids { + let registration = full + .resolve_texture_registration_by_surface_id(surface_id, None, 0, 0) + .map_err(|e| { + Error::GpuError(format!( + "colour target {surface_id:?} is not something this graph can resolve to a \ + device texture: {e}" + )) + })?; + // A colour target enters the pass from UNDEFINED, which discards what + // it held — so a binding reading the very image this draw renders into + // reads discarded pixels. A target carrying no image is its own error, + // raised where the attachment is built. + let clashing_binding = + registration + .texture() + .vulkan_inner() + .image() + .and_then(|target_image| { + bound_inputs.iter().find(|input| { + input.registration.texture().vulkan_inner().image() == Some(target_image) + }) + }); + if let Some(clashing) = clashing_binding { + return Err(Error::GpuError(format!( + "binding `{}` (surface {:?}) and colour target {surface_id:?} name one texture; \ + the pass discards a colour target's contents on entry, so the binding would \ + read pixels this draw has already thrown away", + clashing.planned.name, clashing.planned.target_id + ))); + } + color_targets.push(registration); + } + + for binding in &bound_inputs { + let texture = binding.registration.texture(); + match binding.planned.kind { + SurfaceBoundKernelBindingKind::SampledTexture => kernel.set_sampled_texture( + req.frame_index, + binding.planned.binding_slot, + texture, + )?, + SurfaceBoundKernelBindingKind::StorageImage => { + kernel.set_storage_image(req.frame_index, binding.planned.binding_slot, texture)? + } + } + } + + // A kernel that declares push constants must be given them even when the + // payload is empty, so `set_push_constants` produces the size mismatch + // rather than the draw running against whatever the kernel's staged buffer + // last held. + if kernel.push_constant_size() > 0 || !push_constants.is_empty() { + kernel.set_push_constants(req.frame_index, push_constants)?; + } + + let draw = OffscreenDraw::Draw(DrawCall { + vertex_count: req.draw.vertex_count, + instance_count: req.draw.instance_count, + first_vertex: req.draw.first_vertex, + first_instance: req.draw.first_instance, + viewport: req.viewport.as_ref().map(|v| Viewport { + x: v.x, + y: v.y, + width: v.width, + height: v.height, + min_depth: v.min_depth, + max_depth: v.max_depth, + }), + scissor: req.scissor.as_ref().map(|s| ScissorRect { + x: s.x, + y: s.y, + width: s.width, + height: s.height, }), + }); + + // `clear_color: None` would load an attachment the pass has just + // transitioned from UNDEFINED, whose contents are undefined by then. The + // op carries no clear colour of its own, so transparent black is what a + // discarded target starts from. + let attachments: Vec> = color_targets + .iter() + .map(|registration| OffscreenColorTarget { + texture: registration.texture(), + clear_color: Some([0.0, 0.0, 0.0, 0.0]), + }) + .collect(); + let rendered = kernel.offscreen_render( + req.frame_index, + &attachments, + (req.extent_width, req.extent_height), + draw, + ); + drop(attachments); + + // `offscreen_render` transitions each colour target into + // COLOR_ATTACHMENT_OPTIMAL and never tells its registration, so the record + // would otherwise keep claiming the pre-draw layout and the next consumer's + // barrier would name the wrong oldLayout. A refused draw transitioned + // nothing, so only a rendered one publishes. + if rendered.is_ok() { + for registration in &color_targets { + registration.update_layout(VulkanLayout::COLOR_ATTACHMENT_OPTIMAL); + } } + drop(color_targets); + drop(bound_inputs); + rendered } -/// Map a wire-format `register_acceleration_structure_blas` request -/// through the registered [`RayTracingKernelBridge`]. +/// Build a triangle-geometry BLAS for a subprocess customer, against +/// `GpuContext`. /// -/// Decodes the hex-encoded vertex (`f32` triples) and index (`u32` -/// triples) blobs, validates triangle-shape consistency, and asks the -/// bridge to build a triangle BLAS. Returns the bridge-assigned -/// `as_id` on success. +/// Decodes the hex-encoded vertex (`f32` triples) and index (`u32` triples) +/// blobs, checks triangle-shape consistency, and registers the built structure +/// under a fresh `as_id` a later trace names it by. /// -/// Failure modes (each surfaced as an [`EscalateResponse::Err`] keyed -/// by the original request_id): +/// Failure modes (each an [`EscalateResponse::Err`] keyed by the request_id): /// 1. `vertices_hex` / `indices_hex` doesn't decode as hex bytes. -/// 2. Vertex blob length is not a multiple of 12 (one f32 = 4 bytes; -/// one vertex = 3 floats = 12 bytes). -/// 3. Index blob length is not a multiple of 12 (one u32 = 4 bytes; -/// one triangle = 3 indices = 12 bytes). -/// 4. No bridge is registered. -/// 5. Bridge `register_blas` returned an error — typically empty -/// geometry, missing RT extensions, or AS-build submit failure. +/// 2. Vertex blob length is not a multiple of 12 (one vertex = 3 × f32). +/// 3. Index blob length is not a multiple of 12 (one triangle = 3 × u32). +/// 4. The device does not expose the `VK_KHR_ray_tracing_pipeline` chain. +/// 5. Empty geometry, or an acceleration-structure build failure. #[cfg(target_os = "linux")] fn handle_register_acceleration_structure_blas( sandbox: &GpuContextLimitedAccess, rid: String, req: EscalateRequestRegisterAccelerationStructureBlas, ) -> EscalateResponse { - use std::sync::Arc; - let vertex_bytes = match decode_hex(&req.vertices_hex) { Ok(b) => b, Err(e) => { @@ -2242,65 +2793,44 @@ fn handle_register_acceleration_structure_blas( .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) .collect(); - let bridge: Arc = match sandbox.escalate(|full| { - full.ray_tracing_kernel_bridge().ok_or_else(|| { - crate::core::error::Error::Configuration( - "register_acceleration_structure_blas: no RayTracingKernelBridge \ - registered on GpuContext" - .to_string(), - ) - }) - }) { - Ok(b) => b, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: e.to_string(), - }); - } - }; + let registered = sandbox.escalate(|full| { + refuse_a_device_without_ray_tracing(full, "register_acceleration_structure_blas")?; + let blas = full.build_triangles_blas(&req.label, &vertices, &indices)?; + Ok(full.register_acceleration_structure(blas)) + }); - let decl = BlasRegisterDecl { - label: req.label, - vertices, - indices, - }; - match bridge.register_blas(&decl) { - Ok(as_id) => EscalateResponse::Ok(EscalateResponseOk { + match registered { + Ok(acceleration_structure_id) => EscalateResponse::Ok(EscalateResponseOk { request_id: rid, - handle_id: as_id, + handle_id: acceleration_structure_id, ..Default::default() }), - Err(msg) => EscalateResponse::Err(EscalateResponseErr { + Err(e) => EscalateResponse::Err(EscalateResponseErr { request_id: rid, - message: format!("register_acceleration_structure_blas bridge call failed: {msg}"), + message: format!("register_acceleration_structure_blas failed: {e}"), }), } } -/// Map a wire-format `register_acceleration_structure_tlas` request -/// through the registered [`RayTracingKernelBridge`]. +/// Build a TLAS over previously-registered BLASes, against `GpuContext`. /// -/// Validates each instance's transform layout (exactly 12 floats — -/// row-major 3×4) and 8-bit mask, then asks the bridge to build a -/// TLAS. The bridge resolves each `blas_id` against its own map. +/// Each instance's transform is exactly 12 floats (row-major 3×4) and its mask +/// is 8-bit; the `blas_id` resolves through the acceleration-structure registry +/// and must name a bottom-level structure. The TLAS keeps every referenced BLAS +/// alive for its own lifetime. /// -/// Failure modes (each surfaced as an [`EscalateResponse::Err`] keyed -/// by the original request_id): -/// 1. Instance `transform` length isn't 12 floats. -/// 2. Instance `mask` exceeds 0xff (the wire form is a u32). -/// 3. No bridge is registered. -/// 4. Bridge `register_tlas` returned an error — typically empty -/// instance list, unknown blas_id, kind mismatch (a TLAS appearing -/// as a BLAS reference), or AS-build submit failure. +/// Failure modes (each an [`EscalateResponse::Err`] keyed by the request_id): +/// 1. Empty instance list — a TLAS needs at least one instance per the spec. +/// 2. An instance transform is not 12 floats, or its mask exceeds 0xff. +/// 3. An instance's `flags` sets a bit no `VkGeometryInstanceFlagsKHR` owns. +/// 4. The device does not expose the `VK_KHR_ray_tracing_pipeline` chain. +/// 5. An unknown `blas_id`, a `blas_id` naming a TLAS, or a build failure. #[cfg(target_os = "linux")] fn handle_register_acceleration_structure_tlas( sandbox: &GpuContextLimitedAccess, rid: String, req: EscalateRequestRegisterAccelerationStructureTlas, ) -> EscalateResponse { - use std::sync::Arc; - if req.instances.is_empty() { return EscalateResponse::Err(EscalateResponseErr { request_id: rid, @@ -2309,8 +2839,7 @@ fn handle_register_acceleration_structure_tlas( .to_string(), }); } - let mut instances: Vec = Vec::with_capacity(req.instances.len()); - for (idx, inst) in req.instances.into_iter().enumerate() { + for (idx, inst) in req.instances.iter().enumerate() { if inst.transform.len() != 12 { return EscalateResponse::Err(EscalateResponseErr { request_id: rid, @@ -2331,62 +2860,89 @@ fn handle_register_acceleration_structure_tlas( ), }); } - let t = &inst.transform; - let transform = [ - [t[0], t[1], t[2], t[3]], - [t[4], t[5], t[6], t[7]], - [t[8], t[9], t[10], t[11]], - ]; - instances.push(TlasInstanceDeclWire { - blas_id: inst.blas_id, - transform, - custom_index: inst.custom_index, - mask: inst.mask as u8, - sbt_record_offset: inst.sbt_record_offset, - flags: inst.flags, - }); } - let bridge: Arc = match sandbox.escalate(|full| { - full.ray_tracing_kernel_bridge().ok_or_else(|| { - crate::core::error::Error::Configuration( - "register_acceleration_structure_tlas: no RayTracingKernelBridge \ - registered on GpuContext" - .to_string(), - ) - }) - }) { - Ok(b) => b, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: e.to_string(), + let registered = sandbox.escalate(|full| { + use crate::core::error::Error; + use crate::vulkan::rhi::{ + AccelerationStructureKind, TlasInstanceDesc, geometry_instance_flags_from_raw_bitmask, + }; + + refuse_a_device_without_ray_tracing(full, "register_acceleration_structure_tlas")?; + + let mut instances = Vec::with_capacity(req.instances.len()); + for (idx, inst) in req.instances.iter().enumerate() { + let blas = full + .acceleration_structure_by_id(&inst.blas_id) + .ok_or_else(|| { + Error::GpuError(format!( + "instance {idx} names no acceleration structure registered under id {:?}", + inst.blas_id + )) + })?; + if blas.kind() != AccelerationStructureKind::BottomLevel { + return Err(Error::GpuError(format!( + "instance {idx} names {:?}, which is a top-level structure; a TLAS instance \ + references a bottom-level one", + inst.blas_id + ))); + } + let t = &inst.transform; + instances.push(TlasInstanceDesc { + transform: [ + [t[0], t[1], t[2], t[3]], + [t[4], t[5], t[6], t[7]], + [t[8], t[9], t[10], t[11]], + ], + custom_index: inst.custom_index, + mask: inst.mask as u8, + sbt_record_offset: inst.sbt_record_offset, + flags: geometry_instance_flags_from_raw_bitmask(inst.flags) + .map_err(|e| Error::GpuError(format!("instance {idx}: {e}")))?, + blas: (*blas).clone(), }); } - }; - let decl = TlasRegisterDecl { - label: req.label, - instances, - }; - match bridge.register_tlas(&decl) { - Ok(as_id) => EscalateResponse::Ok(EscalateResponseOk { + let tlas = full.build_tlas(&req.label, &instances)?; + Ok(full.register_acceleration_structure(tlas)) + }); + + match registered { + Ok(acceleration_structure_id) => EscalateResponse::Ok(EscalateResponseOk { request_id: rid, - handle_id: as_id, + handle_id: acceleration_structure_id, ..Default::default() }), - Err(msg) => EscalateResponse::Err(EscalateResponseErr { + Err(e) => EscalateResponse::Err(EscalateResponseErr { request_id: rid, - message: format!("register_acceleration_structure_tlas bridge call failed: {msg}"), + message: format!("register_acceleration_structure_tlas failed: {e}"), }), } } +/// Refuse an op that needs the ray-tracing pipeline on a device without it. +/// +/// Raised before any build so the caller gets the device's own answer rather +/// than an extension-missing failure from inside a structure build. +#[cfg(target_os = "linux")] +fn refuse_a_device_without_ray_tracing( + full: &crate::core::context::GpuContextFullAccess, + op: &str, +) -> crate::core::error::Result<()> { + if full.supports_ray_tracing_pipeline() { + return Ok(()); + } + Err(crate::core::error::Error::GpuError(format!( + "{op}: this device does not expose the VK_KHR_ray_tracing_pipeline extension chain, so \ + it can build neither acceleration structures nor ray-tracing pipelines" + ))) +} + /// The compiler's name for a ray-tracing wire stage. /// /// Distinct from [`ray_tracing_stage_from_wire`], which maps the same wire -/// value to the bridge's stage vocabulary: one names a pipeline stage to -/// compile for, the other names a stage to build a shader group from. +/// value to the stage a shader group is built from: one names a pipeline stage +/// to compile for, the other names the stage a module fills. #[cfg(target_os = "linux")] fn ray_tracing_pipeline_stage_from_wire( stage: EscalateRequestRegisterRayTracingKernelStageStage, @@ -2403,248 +2959,419 @@ fn ray_tracing_pipeline_stage_from_wire( } } -/// Map a wire-format `register_ray_tracing_kernel` request through -/// the registered [`RayTracingKernelBridge`]. -/// -/// Resolves each stage's shader — GLSL source the engine compiles, or the -/// pre-compiled hex escape hatch — translates the wire-format -/// stage / group / binding kinds into the bridge's typed mirrors, and -/// asks the bridge to register the kernel. The bridge returns a -/// stable `kernel_id` (typically SHA-256 over a canonical -/// representation of all register-time inputs); identical -/// re-registration hits the bridge's cache and returns the same id. -/// -/// Failure modes (each surfaced as an [`EscalateResponse::Err`] keyed -/// by the original request_id): -/// 1. Any stage supplies neither `source` nor `spv_hex`, or both; its -/// `source` does not compile; or its `spv_hex` doesn't decode. -/// 2. No bridge is registered. -/// 3. Bridge `register_kernel` returned an error — typically -/// reflection failure, push-constant size mismatch, group/stage -/// inconsistency, or pipeline build failure. +/// One compiled ray-tracing stage: which pipeline stage it fills, the SPIR-V +/// that fills it, and the entry point inside that blob. #[cfg(target_os = "linux")] -fn handle_register_ray_tracing_kernel( +struct PreparedRayTracingKernelStage { + stage: crate::core::rhi::RayTracingShaderStage, + spirv: Arc<[u8]>, + entry_point: String, +} + +/// Everything a `register_ray_tracing_kernel` settles before it takes the +/// device gate: every stage compiled, the group layout read, the declaration +/// read. +#[cfg(target_os = "linux")] +struct PreparedRayTracingKernelRegistration { + label: String, + stages: Vec, + groups: Vec, + declared_bindings: Vec, + push_constants: crate::core::rhi::RayTracingPushConstants, + max_recursion_depth: u32, +} + +/// Read a `register_ray_tracing_kernel` request into what `GpuContext` builds a +/// kernel from, without touching the device. +#[cfg(target_os = "linux")] +fn prepare_ray_tracing_kernel_registration( sandbox: &GpuContextLimitedAccess, - rid: String, req: EscalateRequestRegisterRayTracingKernel, -) -> EscalateResponse { - use std::sync::Arc; +) -> std::result::Result { + use crate::core::rhi::{ + RayTracingBindingDeclaration, RayTracingPushConstants, RayTracingShaderGroup, + RayTracingShaderStageFlags, + }; - // One consuming pass pairs each stage's shader with the bridge stage it - // fills, so nothing downstream has to keep two vectors index-aligned. - let mut stage_sources = Vec::with_capacity(req.stages.len()); - for (idx, st) in req.stages.into_iter().enumerate() { - match registered_shader_stage_source( + let mut stages = Vec::with_capacity(req.stages.len()); + for (idx, st) in req.stages.iter().enumerate() { + let stage_source = registered_shader_stage_source( &format!("stages[{idx}]."), &st.source, &st.spv_hex, ray_tracing_pipeline_stage_from_wire(st.stage), &st.entry_point, - ) { - Ok(stage_source) => { - stage_sources.push((stage_source, ray_tracing_stage_from_wire(st.stage))); - } - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!("register_ray_tracing_kernel: {e}"), - }); - } - } + )?; + stages.push(PreparedRayTracingKernelStage { + stage: ray_tracing_stage_from_wire(st.stage), + spirv: stage_source.spirv(sandbox).map_err(|e| e.to_string())?, + entry_point: stage_source.entry_point().to_string(), + }); } - let resolved_stages = stage_sources - .iter() - .map(|(stage_source, bridge_stage)| { - Ok(RayTracingStageDecl { - stage: *bridge_stage, - spv: stage_source.spirv(sandbox)?.to_vec(), - entry_point: stage_source.entry_point().to_string(), - }) - }) - .collect::>>(); - let stages = match resolved_stages { - Ok(stages) => stages, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!("register_ray_tracing_kernel: {e}"), - }); - } - }; - - let mut groups: Vec = Vec::with_capacity(req.groups.len()); - for (idx, g) in req.groups.into_iter().enumerate() { - let group = match g.kind { + let mut groups: Vec = Vec::with_capacity(req.groups.len()); + for (idx, g) in req.groups.iter().enumerate() { + groups.push(match g.kind { EscalateRequestRegisterRayTracingKernelGroupKind::General => { - RayTracingShaderGroupWire::General { - general_stage: g.general_stage, + RayTracingShaderGroup::General { + general: g.general_stage, } } EscalateRequestRegisterRayTracingKernelGroupKind::TrianglesHit => { - RayTracingShaderGroupWire::TrianglesHit { - closest_hit_stage: optional_stage(g.closest_hit_stage), - any_hit_stage: optional_stage(g.any_hit_stage), + RayTracingShaderGroup::TrianglesHit { + closest_hit: optional_stage(g.closest_hit_stage), + any_hit: optional_stage(g.any_hit_stage), } } EscalateRequestRegisterRayTracingKernelGroupKind::ProceduralHit => { if g.intersection_stage == RAY_TRACING_STAGE_INDEX_NONE { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!( - "register_ray_tracing_kernel: groups[{idx}] procedural_hit \ - must set intersection_stage (got {RAY_TRACING_STAGE_INDEX_NONE} \ - which is the absent-sentinel)" - ), - }); + return Err(format!( + "groups[{idx}] procedural_hit must set intersection_stage (got \ + {RAY_TRACING_STAGE_INDEX_NONE} which is the absent-sentinel)" + )); } - RayTracingShaderGroupWire::ProceduralHit { - intersection_stage: g.intersection_stage, - closest_hit_stage: optional_stage(g.closest_hit_stage), - any_hit_stage: optional_stage(g.any_hit_stage), + RayTracingShaderGroup::ProceduralHit { + intersection: g.intersection_stage, + closest_hit: optional_stage(g.closest_hit_stage), + any_hit: optional_stage(g.any_hit_stage), } } - }; - groups.push(group); + }); } - let bindings: Vec = req - .bindings - .into_iter() - .map(|b| RayTracingBindingDecl { - binding: b.binding, - kind: ray_tracing_register_binding_kind_from_wire(b.kind), - stages: b.stages, - }) - .collect(); + let mut declared_bindings = Vec::with_capacity(req.bindings.len()); + for wire in &req.bindings { + declared_bindings.push(RayTracingBindingDeclaration { + name: wire.name.clone(), + kind: ray_tracing_binding_kind_from_wire(wire.kind), + stages: RayTracingShaderStageFlags::from_bits(wire.stages).ok_or_else(|| { + format!( + "binding `{}` names stages {:#b}, which sets a bit no ray-tracing stage owns \ + (1 = ray_gen, 2 = miss, 4 = closest_hit, 8 = any_hit, 16 = intersection, \ + 32 = callable)", + wire.name, wire.stages + ) + })?, + }); + } - let bridge: Arc = match sandbox.escalate(|full| { - full.ray_tracing_kernel_bridge().ok_or_else(|| { - crate::core::error::Error::Configuration( - "register_ray_tracing_kernel: no RayTracingKernelBridge registered on \ - GpuContext" - .to_string(), - ) - }) - }) { - Ok(b) => b, - Err(e) => { - return EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: e.to_string(), - }); - } + let push_constants = RayTracingPushConstants { + size: req.push_constant_size, + stages: RayTracingShaderStageFlags::from_bits(req.push_constant_stages).ok_or_else( + || { + format!( + "push_constant_stages {:#b} sets a bit no ray-tracing stage owns", + req.push_constant_stages + ) + }, + )?, }; - let decl = RayTracingKernelRegisterDecl { + Ok(PreparedRayTracingKernelRegistration { label: req.label, stages, groups, - bindings, - push_constant_size: req.push_constant_size, - push_constant_stages: req.push_constant_stages, + declared_bindings, + push_constants, max_recursion_depth: req.max_recursion_depth, - }; - - match bridge.register_kernel(&decl) { - Ok(kernel_id) => EscalateResponse::Ok(EscalateResponseOk { - request_id: rid, - handle_id: kernel_id, - ..Default::default() - }), - Err(msg) => EscalateResponse::Err(EscalateResponseErr { - request_id: rid, - message: format!("register_ray_tracing_kernel bridge call failed: {msg}"), - }), - } + }) } -/// Map a wire-format `run_ray_tracing_kernel` request through the -/// registered [`RayTracingKernelBridge`]. +/// Build a ray-tracing kernel for a subprocess customer, against `GpuContext`. /// -/// RT dispatch on the host is synchronous (the bridge calls -/// [`crate::vulkan::rhi::VulkanRayTracingKernel::trace_rays`] which -/// submits + waits on its own command buffer + fence), so by the time -/// this function returns `Ok`, the GPU work has retired and the -/// host's writes to the storage image are visible. +/// The ray-tracing twin of [`handle_register_compute_kernel`], over N stages +/// rather than one: reflection across every stage derives the binding shape and +/// its names, the request's own declaration is checked against it, and +/// re-registering an identical kernel is a cache hit that answers with the same +/// `kernel_id`. /// -/// Failure modes (each surfaced as an [`EscalateResponse::Err`] keyed -/// by the original request_id): -/// 1. `push_constants_hex` doesn't decode as hex bytes. -/// 2. No bridge is registered. -/// 3. Bridge `run_kernel` returned an error — typically unrecognized -/// `kernel_id`, target lookup failure (binding `target_id` doesn't -/// resolve in the bridge's surface / AS map), or Vulkan submit +/// Failure modes (each an [`EscalateResponse::Err`] keyed by the request_id): +/// 1. A stage supplies neither `source` nor `spv_hex`, or both; its source does +/// not compile; or its hex doesn't decode. +/// 2. A `procedural_hit` group leaves `intersection_stage` at the sentinel. +/// 3. A binding's or the push-constant range's `stages` mask sets a bit no +/// ray-tracing stage owns. +/// 4. The device does not expose the `VK_KHR_ray_tracing_pipeline` chain. +/// 5. The blobs' `OpName` decorations were stripped, or the declaration +/// disagrees with reflection on a name, a kind, or a stage. +/// 6. Group/stage inconsistency, push-constant size mismatch, or pipeline build /// failure. #[cfg(target_os = "linux")] -fn handle_run_ray_tracing_kernel( +fn handle_register_ray_tracing_kernel( sandbox: &GpuContextLimitedAccess, rid: String, - req: EscalateRequestRunRayTracingKernel, + req: EscalateRequestRegisterRayTracingKernel, ) -> EscalateResponse { - use std::sync::Arc; + use crate::core::rhi::RayTracingStage; - let push_constants = match decode_hex(&req.push_constants_hex) { - Ok(b) => b, + let prepared = match prepare_ray_tracing_kernel_registration(sandbox, req) { + Ok(prepared) => prepared, Err(e) => { return EscalateResponse::Err(EscalateResponseErr { request_id: rid, - message: format!("run_ray_tracing_kernel: push_constants_hex decode: {e}"), + message: format!("register_ray_tracing_kernel: {e}"), }); } }; - let bindings: Vec = req - .bindings - .into_iter() - .map(|b| RayTracingBindingValue { - binding: b.binding, - kind: ray_tracing_run_binding_kind_from_wire(b.kind), - target_id: b.target_id, + let stages: Vec> = prepared + .stages + .iter() + .map(|prepared_stage| RayTracingStage { + stage: prepared_stage.stage, + spv: &prepared_stage.spirv, + entry_point: &prepared_stage.entry_point, }) .collect(); - let bridge: Arc = match sandbox.escalate(|full| { - full.ray_tracing_kernel_bridge().ok_or_else(|| { - crate::core::error::Error::Configuration( - "run_ray_tracing_kernel: no RayTracingKernelBridge registered on \ - GpuContext" - .to_string(), + let registered = sandbox + .escalate(|full| { + refuse_a_device_without_ray_tracing(full, "register_ray_tracing_kernel")?; + full.create_or_reuse_ray_tracing_kernel( + &prepared.label, + &stages, + &prepared.groups, + prepared.push_constants, + prepared.max_recursion_depth, + &prepared.declared_bindings, ) }) - }) { + .and_then(|(kernel_id, kernel)| { + let bindings = kernel + .bindings() + .iter() + .map(|spec| { + reflected_kernel_binding_response( + &kernel_id, + spec.binding, + ray_tracing_binding_kind_to_wire(spec.kind).wire_name(), + spec.name.as_deref(), + ) + }) + .collect::>>()?; + Ok((kernel_id, bindings)) + }); + + match registered { + Ok((kernel_id, bindings)) => EscalateResponse::Ok(EscalateResponseOk { + request_id: rid, + handle_id: kernel_id, + bindings: Some(bindings), + ..Default::default() + }), + Err(e) => EscalateResponse::Err(EscalateResponseErr { + request_id: rid, + message: format!("register_ray_tracing_kernel failed: {e}"), + }), + } +} + +/// Trace one grid with a registered ray-tracing kernel, its bindings resolved +/// by name. +/// +/// The trace is synchronous on the host — `trace_rays` submits and waits on its +/// own fence — so by the time this emits an `Ok`, the GPU work has retired and +/// the writes to the output storage image are visible to any later submission +/// on the same device. +/// +/// An `acceleration_structure` binding names an `as_id` a prior +/// `register_acceleration_structure_tlas` returned; every other kind names a +/// surface. Every binding error raises before anything is submitted, and names +/// the kernel's own bindings. +#[cfg(target_os = "linux")] +fn handle_run_ray_tracing_kernel( + sandbox: &GpuContextLimitedAccess, + rid: String, + req: EscalateRequestRunRayTracingKernel, +) -> EscalateResponse { + let push_constants = match decode_hex(&req.push_constants_hex) { Ok(b) => b, Err(e) => { return EscalateResponse::Err(EscalateResponseErr { request_id: rid, - message: e.to_string(), + message: format!("run_ray_tracing_kernel: push_constants_hex decode: {e}"), }); } }; - let kernel_id = req.kernel_id; - let dispatch = RayTracingKernelRunDispatch { - kernel_id: kernel_id.clone(), - bindings, - push_constants, - width: req.width, - height: req.height, - depth: req.depth, - }; + let traced = sandbox.escalate(|full| { + let kernel = full + .ray_tracing_kernel_by_id(&req.kernel_id) + .ok_or_else(|| { + crate::core::error::Error::GpuError(format!( + "run_ray_tracing_kernel: no kernel registered under id {:?}", + req.kernel_id + )) + })?; + bind_and_trace_ray_tracing_kernel(full, &kernel, &req, &push_constants) + }); - match bridge.run_kernel(&dispatch) { + match traced { Ok(()) => EscalateResponse::Ok(EscalateResponseOk { request_id: rid, - handle_id: kernel_id, + // Echo the kernel_id back — the trace is sync host-side, so no + // separate handle is allocated per trace. + handle_id: req.kernel_id, ..Default::default() }), - Err(msg) => EscalateResponse::Err(EscalateResponseErr { + Err(e) => EscalateResponse::Err(EscalateResponseErr { request_id: rid, - message: format!("run_ray_tracing_kernel bridge call failed: {msg}"), + message: format!("run_ray_tracing_kernel failed: {e}"), }), } } +/// Resolve every named binding onto the kernel's slots, then trace. +/// +/// The plan is total and every target is resolved before the first `set_*` +/// call, so a refused trace never leaves the kernel holding a mix of this +/// trace's bindings and the last one's. +#[cfg(target_os = "linux")] +fn bind_and_trace_ray_tracing_kernel( + full: &crate::core::context::GpuContextFullAccess, + kernel: &crate::vulkan::rhi::VulkanRayTracingKernel, + req: &EscalateRequestRunRayTracingKernel, + push_constants: &[u8], +) -> crate::core::error::Result<()> { + use crate::core::error::Error; + use crate::core::rhi::RayTracingBindingKind; + use crate::vulkan::rhi::{AccelerationStructureKind, VulkanStage}; + + let declared_specs = kernel.bindings(); + + // Checked over the whole array before it is split, so a name supplied twice + // is refused whichever half each copy would land in. + let declared_names: Vec<&str> = declared_specs + .iter() + .filter_map(|spec| spec.name.as_deref()) + .collect(); + refuse_a_kernel_binding_name_supplied_twice( + "trace", + req.bindings.iter().map(|wire| wire.name.as_str()), + &declared_names, + )?; + + // The acceleration structures come out first: they resolve through their + // own registry rather than through a surface, so the surface planner never + // sees them and the kernel's declaration for them is checked here. + let mut acceleration_structure_bindings = Vec::new(); + let mut surface_supplied = Vec::with_capacity(req.bindings.len()); + for wire in &req.bindings { + let declared_as_acceleration_structure = declared_specs + .iter() + .find(|spec| spec.name.as_deref() == Some(wire.name.as_str())) + .filter(|spec| spec.kind == RayTracingBindingKind::AccelerationStructure); + let Some(declaration) = declared_as_acceleration_structure else { + surface_supplied.push(SuppliedKernelBindingUnderPlanning { + name: wire.name.as_str(), + target_id: wire.target_id.as_str(), + kind_wire_name: wire.kind.wire_name(), + }); + continue; + }; + if ray_tracing_binding_kind_from_wire(wire.kind) + != RayTracingBindingKind::AccelerationStructure + { + return Err(Error::GpuError(format!( + "binding `{}` was supplied as {} but this kernel declares it \ + acceleration_structure", + wire.name, + wire.kind.wire_name() + ))); + } + let slot = declaration.binding; + let tlas = full + .acceleration_structure_by_id(&wire.target_id) + .ok_or_else(|| { + Error::GpuError(format!( + "binding `{}` names no acceleration structure registered under id {:?}", + wire.name, wire.target_id + )) + })?; + if tlas.kind() != AccelerationStructureKind::TopLevel { + return Err(Error::GpuError(format!( + "binding `{}` names {:?}, which is a bottom-level structure; a trace binds the \ + top-level one a `register_acceleration_structure_tlas` returned", + wire.name, wire.target_id + ))); + } + acceleration_structure_bindings.push((slot, tlas)); + } + + // Declared acceleration structures are dropped from the surface planner's + // view of the declaration too, so its missing-binding check counts only + // what it is responsible for. + let declared: Vec> = declared_specs + .iter() + .filter(|spec| spec.kind != RayTracingBindingKind::AccelerationStructure) + .map(|spec| DeclaredKernelBindingUnderPlanning { + binding_slot: spec.binding, + name: spec.name.as_deref(), + kind_wire_name: ray_tracing_binding_kind_to_wire(spec.kind).wire_name(), + surface_bound_kind: surface_bound_ray_tracing_binding_kind(spec.kind), + }) + .collect(); + let planned = + plan_supplied_surface_bound_kernel_bindings("trace", &surface_supplied, &declared)?; + for spec in declared_specs + .iter() + .filter(|spec| spec.kind == RayTracingBindingKind::AccelerationStructure) + { + if acceleration_structure_bindings + .iter() + .any(|(slot, _)| *slot == spec.binding) + { + continue; + } + let declared_name = spec.name.as_deref().ok_or_else(|| { + Error::GpuError(format!( + "this kernel holds an unnamed acceleration-structure binding at slot {}; \ + reflection refuses these, so this kernel did not come through registration", + spec.binding + )) + })?; + return Err(Error::GpuError(format!( + "binding `{declared_name}` was not supplied; bindings do not persist between traces, \ + so every trace supplies all of them" + ))); + } + + let bound_inputs = resolve_planned_surface_bound_kernel_bindings(full, planned)?; + transition_bound_kernel_inputs_into_descriptor_layouts( + full, + "escalate_ray_tracing_trace_input_layouts", + VulkanStage::ALL_COMMANDS, + &bound_inputs, + )?; + + for (slot, tlas) in &acceleration_structure_bindings { + kernel.set_acceleration_structure(*slot, tlas)?; + } + for binding in &bound_inputs { + let texture = binding.registration.texture(); + match binding.planned.kind { + SurfaceBoundKernelBindingKind::SampledTexture => { + kernel.set_sampled_texture(binding.planned.binding_slot, texture)? + } + SurfaceBoundKernelBindingKind::StorageImage => { + kernel.set_storage_image(binding.planned.binding_slot, texture)? + } + } + } + + // A kernel that declares push constants must be given them even when the + // payload is empty, so `set_push_constants` produces the size mismatch + // rather than the trace running against whatever the kernel's staged buffer + // last held. + if kernel.push_constant_size() > 0 || !push_constants.is_empty() { + kernel.set_push_constants(push_constants)?; + } + + let traced = kernel.trace_rays(req.width, req.height, req.depth); + drop(bound_inputs); + traced +} + /// Convert a sentinel-encoded wire stage index back into an /// `Option`. The wire form uses `0xFFFFFFFF` to mean "absent" /// because the field is always present on the wire. @@ -2657,364 +3384,275 @@ fn optional_stage(idx: u32) -> Option { } } +/// The pipeline stage a ray-tracing wire stage's module fills. #[cfg(target_os = "linux")] fn ray_tracing_stage_from_wire( stage: EscalateRequestRegisterRayTracingKernelStageStage, -) -> RayTracingShaderStageWire { +) -> crate::core::rhi::RayTracingShaderStage { + use crate::core::rhi::RayTracingShaderStage; use EscalateRequestRegisterRayTracingKernelStageStage as W; match stage { - W::RayGen => RayTracingShaderStageWire::RayGen, - W::Miss => RayTracingShaderStageWire::Miss, - W::ClosestHit => RayTracingShaderStageWire::ClosestHit, - W::AnyHit => RayTracingShaderStageWire::AnyHit, - W::Intersection => RayTracingShaderStageWire::Intersection, - W::Callable => RayTracingShaderStageWire::Callable, - } -} - -#[cfg(target_os = "linux")] -fn ray_tracing_register_binding_kind_from_wire( - kind: EscalateRequestRegisterRayTracingKernelBindingKind, -) -> RayTracingBindingKindWire { - use EscalateRequestRegisterRayTracingKernelBindingKind as W; - match kind { - W::StorageBuffer => RayTracingBindingKindWire::StorageBuffer, - W::UniformBuffer => RayTracingBindingKindWire::UniformBuffer, - W::SampledTexture => RayTracingBindingKindWire::SampledTexture, - W::StorageImage => RayTracingBindingKindWire::StorageImage, - W::AccelerationStructure => RayTracingBindingKindWire::AccelerationStructure, - } -} - -#[cfg(target_os = "linux")] -fn ray_tracing_run_binding_kind_from_wire( - kind: EscalateRequestRunRayTracingKernelBindingKind, -) -> RayTracingBindingKindWire { - use EscalateRequestRunRayTracingKernelBindingKind as W; - match kind { - W::StorageBuffer => RayTracingBindingKindWire::StorageBuffer, - W::UniformBuffer => RayTracingBindingKindWire::UniformBuffer, - W::SampledTexture => RayTracingBindingKindWire::SampledTexture, - W::StorageImage => RayTracingBindingKindWire::StorageImage, - W::AccelerationStructure => RayTracingBindingKindWire::AccelerationStructure, + W::RayGen => RayTracingShaderStage::RayGen, + W::Miss => RayTracingShaderStage::Miss, + W::ClosestHit => RayTracingShaderStage::ClosestHit, + W::AnyHit => RayTracingShaderStage::AnyHit, + W::Intersection => RayTracingShaderStage::Intersection, + W::Callable => RayTracingShaderStage::Callable, } } +/// One arm-for-arm mapping from a wire blend-factor enum to the RHI's. +/// +/// A macro rather than a function per enum: the wire carries four separate +/// factor enums with identical arms, and four hand-written copies of the same +/// fifteen-arm match is four things to keep in step. #[cfg(target_os = "linux")] -fn graphics_register_binding_kind_from_wire( - kind: EscalateRequestRegisterGraphicsKernelBindingKind, -) -> GraphicsBindingKindWire { - match kind { - EscalateRequestRegisterGraphicsKernelBindingKind::SampledTexture => { - GraphicsBindingKindWire::SampledTexture - } - EscalateRequestRegisterGraphicsKernelBindingKind::StorageBuffer => { - GraphicsBindingKindWire::StorageBuffer - } - EscalateRequestRegisterGraphicsKernelBindingKind::UniformBuffer => { - GraphicsBindingKindWire::UniformBuffer - } - EscalateRequestRegisterGraphicsKernelBindingKind::StorageImage => { - GraphicsBindingKindWire::StorageImage +macro_rules! blend_factor_from_wire { + ($enum:ident, $value:expr) => {{ + use crate::core::rhi::BlendFactor; + use $enum as W; + match $value { + W::Zero => BlendFactor::Zero, + W::One => BlendFactor::One, + W::SrcColor => BlendFactor::SrcColor, + W::OneMinusSrcColor => BlendFactor::OneMinusSrcColor, + W::DstColor => BlendFactor::DstColor, + W::OneMinusDstColor => BlendFactor::OneMinusDstColor, + W::SrcAlpha => BlendFactor::SrcAlpha, + W::OneMinusSrcAlpha => BlendFactor::OneMinusSrcAlpha, + W::DstAlpha => BlendFactor::DstAlpha, + W::OneMinusDstAlpha => BlendFactor::OneMinusDstAlpha, + W::ConstantColor => BlendFactor::ConstantColor, + W::OneMinusConstantColor => BlendFactor::OneMinusConstantColor, + W::ConstantAlpha => BlendFactor::ConstantAlpha, + W::OneMinusConstantAlpha => BlendFactor::OneMinusConstantAlpha, + W::SrcAlphaSaturate => BlendFactor::SrcAlphaSaturate, } - } + }}; } +/// One arm-for-arm mapping from a wire blend-op enum to the RHI's, for the same +/// reason [`blend_factor_from_wire`] is a macro. #[cfg(target_os = "linux")] -fn graphics_run_binding_kind_from_wire( - kind: EscalateRequestRunGraphicsDrawBindingKind, -) -> GraphicsBindingKindWire { - match kind { - EscalateRequestRunGraphicsDrawBindingKind::SampledTexture => { - GraphicsBindingKindWire::SampledTexture - } - EscalateRequestRunGraphicsDrawBindingKind::StorageBuffer => { - GraphicsBindingKindWire::StorageBuffer - } - EscalateRequestRunGraphicsDrawBindingKind::UniformBuffer => { - GraphicsBindingKindWire::UniformBuffer - } - EscalateRequestRunGraphicsDrawBindingKind::StorageImage => { - GraphicsBindingKindWire::StorageImage +macro_rules! blend_op_from_wire { + ($enum:ident, $value:expr) => {{ + use crate::core::rhi::BlendOp; + use $enum as W; + match $value { + W::Add => BlendOp::Add, + W::Subtract => BlendOp::Subtract, + W::ReverseSubtract => BlendOp::ReverseSubtract, + W::Min => BlendOp::Min, + W::Max => BlendOp::Max, } - } + }}; } +/// Flatten the wire's one-level pipeline state into the RHI's nested one. +/// +/// The wire is flat because JSON has no sum types: every field is present and +/// the flags decide which ones mean anything. The RHI's sum types are what the +/// pipeline is actually built from, so the two shapes meet here. +/// +/// Refuses what a draw over this op has no path for — MSAA beyond one sample, +/// other than exactly one colour attachment, either half of a depth attachment, +/// either half of a vertex input, a colour format the texture vocabulary doesn't +/// name, and a write mask naming a bit no channel owns. #[cfg(target_os = "linux")] fn graphics_pipeline_state_from_wire( p: EscalateRequestRegisterGraphicsKernelPipelineState, -) -> std::result::Result { +) -> std::result::Result { + use crate::core::rhi::{ + AttachmentFormats, ColorBlendAttachment, ColorBlendState, ColorWriteMask, CullMode, + DepthStencilState, FrontFace, GraphicsDynamicState, GraphicsPipelineState, + MultisampleState, PolygonMode, PrimitiveTopology, RasterizationState, VertexInputState, + }; + + if p.multisample_samples != 1 { + return Err(format!( + "multisample_samples is {}; the graphics kernel builds single-sampled pipelines only", + p.multisample_samples + )); + } + if p.attachment_color_formats.len() != 1 { + return Err(format!( + "attachment_color_formats names {} formats; the graphics kernel targets exactly one \ + colour attachment", + p.attachment_color_formats.len() + )); + } + // The offscreen pass a draw runs through attaches colour targets only, so a + // pipeline declaring a depth attachment mismatches the rendering info at + // every draw. `run_graphics_draw` refuses `depth_target_uuid` for the same + // reason; refusing only there would let the mismatch be built at register + // time and surface as a driver error a draw away from its cause. + if p.depth_stencil_enabled { + return Err( + "depth_stencil_enabled is set, and the offscreen pass a draw runs through attaches \ + colour targets only — a depth-testing pipeline has no attachment to test against" + .to_string(), + ); + } + if p.attachment_depth_format.is_some() { + return Err( + "attachment_depth_format names a depth attachment, and the offscreen pass a draw runs \ + through attaches colour targets only — the pipeline's formats would disagree with \ + the pass at every draw" + .to_string(), + ); + } + + // A pipeline pulling from a vertex binding could register and then never + // draw: `run_graphics_draw` refuses `vertex_buffers` because no escalate op + // mints a `VertexBuffer`, and the kernel refuses a declared binding whose + // buffer was never set at every draw. Refused here, the caller meets the + // reason where the shape is asked for rather than a submission away from it. + if !p.vertex_input_bindings.is_empty() { + return Err(format!( + "vertex_input_bindings names {} binding(s), and no escalate op mints a VertexBuffer to \ + fill one — a helper can acquire a pixel buffer, a texture or an image, and the \ + vertex-buffer setter takes none of them, so this pipeline would register and then be \ + refused at every draw. Fabricate vertices from gl_VertexIndex instead", + p.vertex_input_bindings.len() + )); + } + if !p.vertex_input_attributes.is_empty() { + return Err(format!( + "vertex_input_attributes names {} attribute(s), and an attribute is pulled from a \ + vertex binding no escalate op can mint a buffer for. Fabricate vertices from \ + gl_VertexIndex instead", + p.vertex_input_attributes.len() + )); + } + let topology = match p.topology { EscalateRequestRegisterGraphicsKernelPipelineStateTopology::PointList => { - PrimitiveTopologyWire::PointList + PrimitiveTopology::PointList } EscalateRequestRegisterGraphicsKernelPipelineStateTopology::LineList => { - PrimitiveTopologyWire::LineList + PrimitiveTopology::LineList } EscalateRequestRegisterGraphicsKernelPipelineStateTopology::LineStrip => { - PrimitiveTopologyWire::LineStrip + PrimitiveTopology::LineStrip } EscalateRequestRegisterGraphicsKernelPipelineStateTopology::TriangleList => { - PrimitiveTopologyWire::TriangleList + PrimitiveTopology::TriangleList } EscalateRequestRegisterGraphicsKernelPipelineStateTopology::TriangleStrip => { - PrimitiveTopologyWire::TriangleStrip + PrimitiveTopology::TriangleStrip } EscalateRequestRegisterGraphicsKernelPipelineStateTopology::TriangleFan => { - PrimitiveTopologyWire::TriangleFan - } - }; - let vertex_input_bindings = p - .vertex_input_bindings - .into_iter() - .map(|b| VertexInputBindingDecl { - binding: b.binding, - stride: b.stride, - input_rate: match b.input_rate { - EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBindingInputRate::Vertex => { - VertexInputRateWire::Vertex - } - EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBindingInputRate::Instance => { - VertexInputRateWire::Instance - } - }, - }) - .collect::>(); - let vertex_input_attributes = p - .vertex_input_attributes - .into_iter() - .map(|a| { - Ok::<_, String>(VertexInputAttributeDecl { - location: a.location, - binding: a.binding, - format: vertex_attribute_format_from_wire(a.format), - offset: a.offset, - }) - }) - .collect::, _>>()?; - let rasterization_polygon_mode = match p.rasterization_polygon_mode { - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Fill => { - PolygonModeWire::Fill - } - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Line => { - PolygonModeWire::Line - } - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Point => { - PolygonModeWire::Point + PrimitiveTopology::TriangleFan } }; - let rasterization_cull_mode = match p.rasterization_cull_mode { - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::None => { - CullModeWire::None - } - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::Front => { - CullModeWire::Front - } - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::Back => { - CullModeWire::Back - } - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::FrontAndBack => { - CullModeWire::FrontAndBack - } + + let rasterization = RasterizationState { + polygon_mode: match p.rasterization_polygon_mode { + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Fill => { + PolygonMode::Fill + } + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Line => { + PolygonMode::Line + } + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Point => { + PolygonMode::Point + } + }, + cull_mode: match p.rasterization_cull_mode { + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::None => { + CullMode::None + } + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::Front => { + CullMode::Front + } + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::Back => { + CullMode::Back + } + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::FrontAndBack => { + CullMode::FrontAndBack + } + }, + front_face: match p.rasterization_front_face { + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationFrontFace::CounterClockwise => { + FrontFace::CounterClockwise + } + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationFrontFace::Clockwise => { + FrontFace::Clockwise + } + }, + line_width: p.rasterization_line_width, }; - let rasterization_front_face = match p.rasterization_front_face { - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationFrontFace::CounterClockwise => { - FrontFaceWire::CounterClockwise - } - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationFrontFace::Clockwise => { - FrontFaceWire::Clockwise - } + + let color_write_mask = ColorWriteMask::from_bits(p.color_write_mask).ok_or_else(|| { + format!( + "color_write_mask {:#b} sets a bit no colour channel owns (1 = R, 2 = G, 4 = B, \ + 8 = A)", + p.color_write_mask + ) + })?; + let color_blend = if p.color_blend_enabled { + ColorBlendState::Enabled(ColorBlendAttachment { + src_color_blend_factor: blend_factor_from_wire!( + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcColorFactor, + p.color_blend_src_color_factor + ), + dst_color_blend_factor: blend_factor_from_wire!( + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstColorFactor, + p.color_blend_dst_color_factor + ), + color_blend_op: blend_op_from_wire!( + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp, + p.color_blend_color_op + ), + src_alpha_blend_factor: blend_factor_from_wire!( + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcAlphaFactor, + p.color_blend_src_alpha_factor + ), + dst_alpha_blend_factor: blend_factor_from_wire!( + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstAlphaFactor, + p.color_blend_dst_alpha_factor + ), + alpha_blend_op: blend_op_from_wire!( + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp, + p.color_blend_alpha_op + ), + color_write_mask, + }) + } else { + ColorBlendState::Disabled { color_write_mask } }; - let depth_compare_op = depth_compare_op_from_wire(p.depth_compare_op); - let color_blend_src_color_factor = - blend_factor_from_wire_src_color(p.color_blend_src_color_factor); - let color_blend_dst_color_factor = - blend_factor_from_wire_dst_color(p.color_blend_dst_color_factor); - let color_blend_color_op = blend_op_from_wire_color(p.color_blend_color_op); - let color_blend_src_alpha_factor = - blend_factor_from_wire_src_alpha(p.color_blend_src_alpha_factor); - let color_blend_dst_alpha_factor = - blend_factor_from_wire_dst_alpha(p.color_blend_dst_alpha_factor); - let color_blend_alpha_op = blend_op_from_wire_alpha(p.color_blend_alpha_op); - let attachment_depth_format = p.attachment_depth_format.map(|d| match d { - EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDepthFormat::D16Unorm => { - DepthFormatWire::D16Unorm - } - EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDepthFormat::D32Sfloat => { - DepthFormatWire::D32Sfloat - } - EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDepthFormat::D24UnormS8Uint => { - DepthFormatWire::D24UnormS8Uint - } - }); + + let mut color = Vec::with_capacity(p.attachment_color_formats.len()); + for format in &p.attachment_color_formats { + color.push( + parse_texture_format(format).map_err(|e| format!("attachment_color_formats: {e}"))?, + ); + } + let attachment_formats = AttachmentFormats { color, depth: None }; + let dynamic_state = match p.dynamic_state { EscalateRequestRegisterGraphicsKernelPipelineStateDynamicState::None => { - DynamicStateWire::None + GraphicsDynamicState::None } EscalateRequestRegisterGraphicsKernelPipelineStateDynamicState::ViewportScissor => { - DynamicStateWire::ViewportScissor + GraphicsDynamicState::ViewportScissor } }; - Ok(GraphicsPipelineStateWire { + Ok(GraphicsPipelineState { topology, - vertex_input_bindings, - vertex_input_attributes, - rasterization_polygon_mode, - rasterization_cull_mode, - rasterization_front_face, - rasterization_line_width: p.rasterization_line_width, - multisample_samples: p.multisample_samples, - depth_stencil_enabled: p.depth_stencil_enabled, - depth_compare_op, - depth_write: p.depth_write, - color_blend_enabled: p.color_blend_enabled, - color_write_mask: p.color_write_mask, - color_blend_src_color_factor, - color_blend_dst_color_factor, - color_blend_color_op, - color_blend_src_alpha_factor, - color_blend_dst_alpha_factor, - color_blend_alpha_op, - attachment_color_formats: p.attachment_color_formats, - attachment_depth_format, + vertex_input: VertexInputState::None, + rasterization, + multisample: MultisampleState { + samples: p.multisample_samples, + }, + depth_stencil: DepthStencilState::Disabled, + color_blend, + attachment_formats, dynamic_state, }) } -#[cfg(target_os = "linux")] -fn vertex_attribute_format_from_wire( - fmt: EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttributeFormat, -) -> VertexAttributeFormatWire { - use EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttributeFormat as W; - match fmt { - W::R32Float => VertexAttributeFormatWire::R32Float, - W::Rg32Float => VertexAttributeFormatWire::Rg32Float, - W::Rgb32Float => VertexAttributeFormatWire::Rgb32Float, - W::Rgba32Float => VertexAttributeFormatWire::Rgba32Float, - W::R32Uint => VertexAttributeFormatWire::R32Uint, - W::Rg32Uint => VertexAttributeFormatWire::Rg32Uint, - W::Rgb32Uint => VertexAttributeFormatWire::Rgb32Uint, - W::Rgba32Uint => VertexAttributeFormatWire::Rgba32Uint, - W::R32Sint => VertexAttributeFormatWire::R32Sint, - W::Rg32Sint => VertexAttributeFormatWire::Rg32Sint, - W::Rgb32Sint => VertexAttributeFormatWire::Rgb32Sint, - W::Rgba32Sint => VertexAttributeFormatWire::Rgba32Sint, - W::Rgba8Unorm => VertexAttributeFormatWire::Rgba8Unorm, - W::Rgba8Snorm => VertexAttributeFormatWire::Rgba8Snorm, - } -} - -#[cfg(target_os = "linux")] -fn depth_compare_op_from_wire( - op: EscalateRequestRegisterGraphicsKernelPipelineStateDepthCompareOp, -) -> DepthCompareOpWire { - use EscalateRequestRegisterGraphicsKernelPipelineStateDepthCompareOp as W; - match op { - W::Never => DepthCompareOpWire::Never, - W::Less => DepthCompareOpWire::Less, - W::Equal => DepthCompareOpWire::Equal, - W::LessOrEqual => DepthCompareOpWire::LessOrEqual, - W::Greater => DepthCompareOpWire::Greater, - W::NotEqual => DepthCompareOpWire::NotEqual, - W::GreaterOrEqual => DepthCompareOpWire::GreaterOrEqual, - W::Always => DepthCompareOpWire::Always, - } -} - -#[cfg(target_os = "linux")] -macro_rules! blend_factor_match { - ($enum:ident, $val:expr) => {{ - use $enum as W; - match $val { - W::Zero => BlendFactorWire::Zero, - W::One => BlendFactorWire::One, - W::SrcColor => BlendFactorWire::SrcColor, - W::OneMinusSrcColor => BlendFactorWire::OneMinusSrcColor, - W::DstColor => BlendFactorWire::DstColor, - W::OneMinusDstColor => BlendFactorWire::OneMinusDstColor, - W::SrcAlpha => BlendFactorWire::SrcAlpha, - W::OneMinusSrcAlpha => BlendFactorWire::OneMinusSrcAlpha, - W::DstAlpha => BlendFactorWire::DstAlpha, - W::OneMinusDstAlpha => BlendFactorWire::OneMinusDstAlpha, - W::ConstantColor => BlendFactorWire::ConstantColor, - W::OneMinusConstantColor => BlendFactorWire::OneMinusConstantColor, - W::ConstantAlpha => BlendFactorWire::ConstantAlpha, - W::OneMinusConstantAlpha => BlendFactorWire::OneMinusConstantAlpha, - W::SrcAlphaSaturate => BlendFactorWire::SrcAlphaSaturate, - } - }}; -} - -#[cfg(target_os = "linux")] -macro_rules! blend_op_match { - ($enum:ident, $val:expr) => {{ - use $enum as W; - match $val { - W::Add => BlendOpWire::Add, - W::Subtract => BlendOpWire::Subtract, - W::ReverseSubtract => BlendOpWire::ReverseSubtract, - W::Min => BlendOpWire::Min, - W::Max => BlendOpWire::Max, - } - }}; -} - -#[cfg(target_os = "linux")] -fn blend_factor_from_wire_src_color( - f: EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcColorFactor, -) -> BlendFactorWire { - blend_factor_match!( - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcColorFactor, - f - ) -} -#[cfg(target_os = "linux")] -fn blend_factor_from_wire_dst_color( - f: EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstColorFactor, -) -> BlendFactorWire { - blend_factor_match!( - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstColorFactor, - f - ) -} -#[cfg(target_os = "linux")] -fn blend_factor_from_wire_src_alpha( - f: EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcAlphaFactor, -) -> BlendFactorWire { - blend_factor_match!( - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcAlphaFactor, - f - ) -} -#[cfg(target_os = "linux")] -fn blend_factor_from_wire_dst_alpha( - f: EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstAlphaFactor, -) -> BlendFactorWire { - blend_factor_match!( - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstAlphaFactor, - f - ) -} -#[cfg(target_os = "linux")] -fn blend_op_from_wire_color( - o: EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp, -) -> BlendOpWire { - blend_op_match!( - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp, - o - ) -} -#[cfg(target_os = "linux")] -fn blend_op_from_wire_alpha( - o: EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp, -) -> BlendOpWire { - blend_op_match!( - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp, - o - ) -} - /// Decode lowercase hex into bytes, returning a clean error message on /// any malformed character or odd-length input. Empty string decodes to /// an empty Vec — the caller validates push-constant size separately @@ -3045,11 +3683,32 @@ fn decode_hex(s: &str) -> std::result::Result, String> { Ok(out) } -/// Best-effort surface-share service release paired with registry eviction on Linux. +/// Drop `GpuContext`'s strong reference to an acceleration structure, answering +/// whether the id named one. /// -/// The registry drop alone releases the host's strong refcount on the -/// underlying resource, but the surface-share service still holds a dup of the DMA-BUF FD -/// until we explicitly call `release`. Errors here are logged, not returned — +/// A structure the caller built and then let go of is the only escalate-minted +/// resource whose device memory is proportional to what the caller supplied, so +/// it is the one a long-running helper must be able to hand back. Off Linux +/// nothing can have built one, so nothing can be released. +fn release_acceleration_structure(sandbox: &GpuContextLimitedAccess, handle_id: &str) -> bool { + #[cfg(target_os = "linux")] + { + sandbox + .escalate(|full| Ok(full.release_acceleration_structure(handle_id))) + .unwrap_or(false) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (sandbox, handle_id); + false + } +} + +/// Best-effort surface-share service release paired with registry eviction on Linux. +/// +/// The registry drop alone releases the host's strong refcount on the +/// underlying resource, but the surface-share service still holds a dup of the DMA-BUF FD +/// until we explicitly call `release`. Errors here are logged, not returned — /// the subprocess is not waiting on the surface-share service handshake at this point. #[allow(unused_variables)] fn release_surface_share_surface(sandbox: &GpuContextLimitedAccess, handle_id: &str) { @@ -4140,10 +4799,7 @@ void main() { assert_eq!(planned.len(), 2); assert_eq!(planned[0].name, "output_image"); assert_eq!(planned[0].binding, 1); - assert_eq!( - planned[0].kind, - SurfaceBoundComputeBindingKind::StorageImage - ); + assert_eq!(planned[0].kind, SurfaceBoundKernelBindingKind::StorageImage); assert_eq!(planned[0].target_id, "surface-out"); assert_eq!(planned[1].name, "source_image"); assert_eq!(planned[1].binding, 0); @@ -4373,11 +5029,11 @@ void main() { assert_eq!( bindings .iter() - .map(|b| (b.name.as_str(), b.kind)) + .map(|b| (b.name.as_str(), b.kind.as_str())) .collect::>(), vec![ - ("source_image", EscalateComputeBindingKind::SampledTexture), - ("output_image", EscalateComputeBindingKind::StorageImage), + ("source_image", "sampled_texture"), + ("output_image", "storage_image"), ], "the two bindings differ in name and in kind, so binding by slot order \ rather than by name would swap them" @@ -5494,132 +6150,134 @@ void main() { } /// Host-Rust unit tests for the `register_graphics_kernel` / - /// `run_graphics_draw` escalate handlers. Mirrors the - /// `compute_kernel_dispatch` shape — the synthetic - /// `RecordingGraphicsBridge` keeps tests independent of a working - /// VkDevice, so handler-shape regressions surface even on - /// machines without a GPU. + /// `run_graphics_draw` escalate handlers. + /// + /// Mirrors `compute_kernel_dispatch`: the binding planner and the wire→RHI + /// pipeline-state translation are pure functions that run everywhere CI + /// does, and only the tests that build a real pipeline need a device. #[cfg(target_os = "linux")] mod graphics_kernel_dispatch { use super::super::*; use super::EscalateHandleRegistry; - use std::sync::{Arc, Mutex}; use crate::core::compiler::compiler_ops::subprocess_escalate_wire_types::escalate_request::{ EscalateRequestRegisterGraphicsKernelBinding, EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttribute, EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBinding, EscalateRequestRunGraphicsDrawBinding, EscalateRequestRunGraphicsDrawDraw, - EscalateRequestRunGraphicsDrawIndexBuffer, EscalateRequestRunGraphicsDrawScissor, - EscalateRequestRunGraphicsDrawVertexBuffer, EscalateRequestRunGraphicsDrawViewport, - }; - use crate::core::context::{ - GpuContext, GpuContextLimitedAccess, GraphicsKernelBridge, GraphicsKernelRegisterDecl, - GraphicsKernelRunDraw, + EscalateRequestRunGraphicsDrawIndexBuffer, + EscalateRequestRunGraphicsDrawIndexBufferIndexType, + EscalateRequestRunGraphicsDrawScissor, EscalateRequestRunGraphicsDrawVertexBuffer, }; + use crate::core::context::GpuContext; + use crate::core::rhi::GraphicsBindingKind; - /// Synthetic bridge — registers any caller-provided vertex+fragment - /// SPIR-V (no SPV reflection or pipeline build), keys the kernel id by - /// SHA-256 over the canonicalized inputs so identical descriptors - /// hit the cache, and records each `run_draw` for later assertion. - struct RecordingGraphicsBridge { - registered: Mutex>, - runs: Mutex>, + /// Graphics is an always-present capability now, so there is no bridge + /// to install — only a device to have or not have. + fn make_gpu_sandbox_if_available() -> Option { + GpuContext::init_for_platform_sync() + .ok() + .map(GpuContextLimitedAccess::new) } - impl RecordingGraphicsBridge { - fn new() -> Arc { - Arc::new(Self { - registered: Mutex::new(std::collections::HashMap::new()), - runs: Mutex::new(Vec::new()), - }) - } - - fn registered_count(&self) -> usize { - self.registered.lock().unwrap().len() - } - - fn last_registered(&self) -> Option { - // The tests register at most one descriptor each so - // returning a snapshot of the first entry is enough. - self.registered.lock().unwrap().values().next().cloned() - } - - fn runs(&self) -> Vec { - self.runs.lock().unwrap().clone() - } - - fn key(decl: &GraphicsKernelRegisterDecl) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(b"v="); - h.update(&decl.vertex_spv); - h.update(b"|f="); - h.update(&decl.fragment_spv); - h.update(b"|ve="); - h.update(decl.vertex_entry_point.as_bytes()); - h.update(b"|fe="); - h.update(decl.fragment_entry_point.as_bytes()); - h.update(b"|pcs="); - h.update(&decl.push_constant_size.to_le_bytes()); - h.update(b"|pcst="); - h.update(&decl.push_constant_stages.to_le_bytes()); - h.update(b"|dsi="); - h.update(&decl.descriptor_sets_in_flight.to_le_bytes()); - h.update(b"|nb="); - h.update(&(decl.bindings.len() as u32).to_le_bytes()); - format!("{:x}", h.finalize()) - } - } - - impl GraphicsKernelBridge for RecordingGraphicsBridge { - fn register( - &self, - decl: &GraphicsKernelRegisterDecl, - ) -> std::result::Result { - let id = Self::key(decl); - self.registered - .lock() - .unwrap() - .entry(id.clone()) - .or_insert_with(|| decl.clone()); - Ok(id) - } - - fn run_draw(&self, draw: &GraphicsKernelRunDraw) -> std::result::Result<(), String> { - if !self - .registered - .lock() - .unwrap() - .contains_key(&draw.kernel_id) - { - return Err(format!( - "kernel_id '{}' not registered with this bridge", - draw.kernel_id - )); - } - self.runs.lock().unwrap().push(draw.clone()); - Ok(()) + fn refusal_message(response: EscalateResponse) -> String { + match response { + EscalateResponse::Err(err) => err.message, + other => panic!("expected Err, got {other:?}"), } } - fn make_sandbox_with_bridge( - bridge: Option>, - ) -> Option { - let gpu = match GpuContext::init_for_platform_sync() { - Ok(g) => g, - Err(_) => return None, - }; - if let Some(b) = bridge { - gpu.set_graphics_kernel_bridge(b); - } - Some(GpuContextLimitedAccess::new(gpu)) - } + /// Fabricates a full-screen triangle out of `gl_VertexIndex` alone — + /// the only vertex source a draw over this op can have, since no + /// escalate op mints a vertex buffer. + const FULL_SCREEN_TRIANGLE_VERTEX_GLSL: &str = "\ +#version 450 +void main() { + vec2 corner = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} +"; + + /// Inverts the sampled input's colour and keeps its alpha, so the + /// rendered pixels prove which surface the named binding resolved to. + const INVERT_SAMPLED_INPUT_FRAGMENT_GLSL: &str = "\ +#version 450 +layout(set = 0, binding = 0) uniform sampler2D source_image; +layout(location = 0) out vec4 painted_colour; +void main() { + vec4 source = texelFetch(source_image, ivec2(gl_FragCoord.xy), 0); + painted_colour = vec4(vec3(1.0) - source.rgb, source.a); +} +"; + + /// The same pass with the fragment constant folded in, so registering + /// it produces a different pipeline — and therefore a different kernel + /// id — from [`INVERT_SAMPLED_INPUT_FRAGMENT_GLSL`]. + const HALVE_SAMPLED_INPUT_FRAGMENT_GLSL: &str = "\ +#version 450 +layout(set = 0, binding = 0) uniform sampler2D source_image; +layout(location = 0) out vec4 painted_colour; +void main() { + vec4 source = texelFetch(source_image, ivec2(gl_FragCoord.xy), 0); + painted_colour = vec4(source.rgb * 0.5, source.a); +} +"; + + /// Each seed channel inverts exactly in unorm8: out = 255 - in. + const SEED_RGBA: [u8; 4] = [10, 20, 30, 255]; + const INVERTED_RGBA: [u8; 4] = [245, 235, 225, 255]; - /// Build a baseline `register_graphics_kernel` request — vertex - /// + fragment SPIR-V hex, default-shaped TriangleList pipeline - /// state with no blending and no depth. Tests that need a - /// specific shape mutate fields after calling. + /// Seeded into a colour target no draw fully covers: neither stage of + /// the kernel writes it, so a pixel still carrying it was loaded rather + /// than cleared. + const UNCOVERED_SENTINEL_RGBA: [u8; 4] = [3, 5, 7, 255]; + + /// What the handler's clear colour leaves in a pixel the draw missed. + const TRANSPARENT_BLACK_RGBA: [u8; 4] = [0, 0, 0, 0]; + + /// The baseline pipeline state every register request starts from — + /// TriangleList, no blending, no depth, one `rgba8_unorm` attachment. + fn baseline_pipeline_state() -> EscalateRequestRegisterGraphicsKernelPipelineState { + EscalateRequestRegisterGraphicsKernelPipelineState { + topology: EscalateRequestRegisterGraphicsKernelPipelineStateTopology::TriangleList, + vertex_input_bindings: Vec::new(), + vertex_input_attributes: Vec::new(), + rasterization_polygon_mode: + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Fill, + rasterization_cull_mode: + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::None, + rasterization_front_face: + EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationFrontFace::CounterClockwise, + rasterization_line_width: 1.0, + multisample_samples: 1, + depth_stencil_enabled: false, + depth_compare_op: + EscalateRequestRegisterGraphicsKernelPipelineStateDepthCompareOp::Always, + depth_write: false, + color_blend_enabled: false, + color_write_mask: 0b1111, + color_blend_src_color_factor: + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcColorFactor::One, + color_blend_dst_color_factor: + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstColorFactor::Zero, + color_blend_color_op: + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp::Add, + color_blend_src_alpha_factor: + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcAlphaFactor::One, + color_blend_dst_alpha_factor: + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstAlphaFactor::Zero, + color_blend_alpha_op: + EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp::Add, + attachment_color_formats: vec!["rgba8_unorm".to_string()], + dynamic_state: + EscalateRequestRegisterGraphicsKernelPipelineStateDynamicState::ViewportScissor, + attachment_depth_format: None, + } + } + + /// A `register_graphics_kernel` request carrying pre-compiled SPIR-V + /// hex for both stages. Tests that need a specific shape mutate fields + /// after calling. fn make_register_req( request_id: &str, vertex_hex: &str, @@ -5638,47 +6296,25 @@ void main() { push_constant_size: 0, push_constant_stages: 0, descriptor_sets_in_flight: 2, - pipeline_state: EscalateRequestRegisterGraphicsKernelPipelineState { - topology: EscalateRequestRegisterGraphicsKernelPipelineStateTopology::TriangleList, - vertex_input_bindings: Vec::new(), - vertex_input_attributes: Vec::new(), - rasterization_polygon_mode: - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Fill, - rasterization_cull_mode: - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::None, - rasterization_front_face: - EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationFrontFace::CounterClockwise, - rasterization_line_width: 1.0, - multisample_samples: 1, - depth_stencil_enabled: false, - depth_compare_op: - EscalateRequestRegisterGraphicsKernelPipelineStateDepthCompareOp::Always, - depth_write: false, - color_blend_enabled: false, - color_write_mask: 0b1111, - color_blend_src_color_factor: - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcColorFactor::One, - color_blend_dst_color_factor: - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstColorFactor::Zero, - color_blend_color_op: - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp::Add, - color_blend_src_alpha_factor: - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcAlphaFactor::One, - color_blend_dst_alpha_factor: - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstAlphaFactor::Zero, - color_blend_alpha_op: - EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp::Add, - attachment_color_formats: vec!["rgba8_unorm".to_string()], - dynamic_state: - EscalateRequestRegisterGraphicsKernelPipelineStateDynamicState::ViewportScissor, - attachment_depth_format: None, - }, + pipeline_state: baseline_pipeline_state(), } } - /// Baseline `run_graphics_draw` request — vertex-fabricating - /// (no vertex buffers, no index buffer), single color target, - /// 320x240 extent, simple Draw of 3 vertices. + /// The same request built from GLSL, which is what the wire carries + /// now that the engine owns compilation. + fn register_from_glsl( + request_id: &str, + fragment_source: &str, + ) -> EscalateRequestRegisterGraphicsKernel { + let mut req = make_register_req(request_id, "", ""); + req.vertex_source = FULL_SCREEN_TRIANGLE_VERTEX_GLSL.to_string(); + req.fragment_source = fragment_source.to_string(); + req + } + + /// Baseline `run_graphics_draw` request — vertex-fabricating (no vertex + /// buffers, no index buffer), one colour target, a simple Draw of the + /// full-screen triangle's three vertices. fn make_run_req( request_id: &str, kernel_id: &str, @@ -5711,648 +6347,1376 @@ void main() { } } - #[test] - fn register_without_bridge_returns_err() { - let sandbox = match make_sandbox_with_bridge(None) { - Some(s) => s, - None => { - println!("register_without_bridge_returns_err: no GPU — skipping"); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RegisterGraphicsKernel(make_register_req( - "req-reg-1", - "deadbeef", - "cafebabe", - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); + fn register_graphics_kernel_or_panic( + sandbox: &GpuContextLimitedAccess, + registry: &EscalateHandleRegistry, + req: EscalateRequestRegisterGraphicsKernel, + ) -> EscalateResponseOk { + let response = handle_escalate_op( + sandbox, + registry, + EscalateRequest::RegisterGraphicsKernel(req), + ) + .expect("must produce a response"); match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-reg-1"); - assert!( - err.message.contains("GraphicsKernelBridge"), - "expected bridge-not-registered error, got: {}", - err.message - ); - } - other => panic!("expected Err when no bridge registered, got {other:?}"), + EscalateResponse::Ok(ok) => ok, + other => panic!("registering the graphics kernel failed: {other:?}"), } } - #[test] - fn run_without_bridge_returns_err() { - let sandbox = match make_sandbox_with_bridge(None) { - Some(s) => s, - None => { - println!("run_without_bridge_returns_err: no GPU — skipping"); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RunGraphicsDraw(make_run_req( - "req-run-1", - "kernel-x", - "surface-y", - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-run-1"); - assert!( - err.message.contains("GraphicsKernelBridge"), - "expected bridge-not-registered error, got: {}", - err.message - ); - } - other => panic!("expected Err when no bridge registered, got {other:?}"), - } + // ----- the binding planner -------------------------------------- + // + // Pure wire validation shared with the trace path, driven here through + // the graphics kinds: no device, so these run everywhere CI does. + + fn declared_graphics_bindings( + entries: &'static [(u32, &'static str, GraphicsBindingKind)], + ) -> Vec> { + entries + .iter() + .map( + |(binding_slot, name, kind)| DeclaredKernelBindingUnderPlanning { + binding_slot: *binding_slot, + name: Some(name), + kind_wire_name: graphics_binding_kind_to_wire(*kind).wire_name(), + surface_bound_kind: surface_bound_graphics_binding_kind(*kind), + }, + ) + .collect() } - #[test] - fn register_with_invalid_vertex_hex_returns_err() { - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("register_with_invalid_vertex_hex_returns_err: no GPU — skipping"); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RegisterGraphicsKernel(make_register_req( - "req-bad-v", - "xyz123", - "cafebabe", - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-v"); - assert!( - err.message.contains("vertex_spv_hex"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for malformed vertex hex, got {other:?}"), - } - assert_eq!( - bridge.registered_count(), - 0, - "bridge.register must not have been called on the parse-error path" - ); + /// The shape the planner tests measure against: one sampled input and + /// one storage output, deliberately different kinds so binding by slot + /// order rather than by name would swap them. + const A_DRAWING_KERNELS_BINDINGS: &[(u32, &str, GraphicsBindingKind)] = &[ + (0, "source_image", GraphicsBindingKind::SampledTexture), + (1, "painted_output", GraphicsBindingKind::StorageImage), + ]; + + /// A kernel whose one binding is a uniform buffer — a kind a draw + /// cannot name a surface for. + const A_TINTING_KERNELS_BINDINGS: &[(u32, &str, GraphicsBindingKind)] = + &[(0, "tint_parameters", GraphicsBindingKind::UniformBuffer)]; + + fn supplied_graphics_bindings<'a>( + entries: &'a [(&'a str, EscalateGraphicsBindingKind, &'a str)], + ) -> Vec> { + entries + .iter() + .map( + |(name, kind, target_id)| SuppliedKernelBindingUnderPlanning { + name, + target_id, + kind_wire_name: kind.wire_name(), + }, + ) + .collect() } - #[test] - fn register_with_invalid_fragment_hex_returns_err() { - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("register_with_invalid_fragment_hex_returns_err: no GPU — skipping"); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RegisterGraphicsKernel(make_register_req( - "req-bad-f", - "deadbeef", - "qq", - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-f"); - assert!( - err.message.contains("fragment_spv_hex"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for malformed fragment hex, got {other:?}"), - } - assert_eq!(bridge.registered_count(), 0); + fn draw_plan_refusal( + declared: &'static [(u32, &'static str, GraphicsBindingKind)], + supplied: &[(&str, EscalateGraphicsBindingKind, &str)], + ) -> String { + let declared = declared_graphics_bindings(declared); + let supplied = supplied_graphics_bindings(supplied); + plan_supplied_surface_bound_kernel_bindings("draw", &supplied, &declared) + .err() + .expect("expected the plan to be refused") + .to_string() } #[test] - fn run_with_invalid_push_constants_hex_returns_err() { - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("run_with_invalid_push_constants_hex_returns_err: no GPU — skipping"); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let mut req = make_run_req("req-bad-push", "kernel-x", "surface-y"); - req.push_constants_hex = "xyz".to_string(); - let response = - handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(req)) - .expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-push"); - assert!( - err.message.contains("push_constants_hex"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for malformed push hex, got {other:?}"), - } - assert!(bridge.runs().is_empty()); + fn a_complete_draw_resolves_every_name_to_its_slot() { + let declared = declared_graphics_bindings(A_DRAWING_KERNELS_BINDINGS); + let supplied = supplied_graphics_bindings(&[ + ( + "painted_output", + EscalateGraphicsBindingKind::StorageImage, + "surface-out", + ), + ( + "source_image", + EscalateGraphicsBindingKind::SampledTexture, + "surface-in", + ), + ]); + let planned = plan_supplied_surface_bound_kernel_bindings("draw", &supplied, &declared) + .expect("a complete, correctly-typed draw"); + + // Resolution is by name, so the order the caller supplied them in + // is not the order the shaders declared them in — and that is fine. + assert_eq!(planned.len(), 2); + assert_eq!(planned[0].name, "painted_output"); + assert_eq!(planned[0].binding_slot, 1); + assert_eq!(planned[0].kind, SurfaceBoundKernelBindingKind::StorageImage); + assert_eq!(planned[0].target_id, "surface-out"); + assert_eq!(planned[1].name, "source_image"); + assert_eq!(planned[1].binding_slot, 0); + assert_eq!( + planned[1].kind, + SurfaceBoundKernelBindingKind::SampledTexture + ); } + /// Not expressible in a Python mapping — a dict cannot carry one key + /// twice — so the wire array is the only layer that can guard it. #[test] - fn run_with_malformed_vertex_buffer_offset_returns_err() { - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "run_with_malformed_vertex_buffer_offset_returns_err: no GPU — skipping" - ); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let mut req = make_run_req("req-bad-vb", "kernel-x", "surface-y"); - req.vertex_buffers = vec![EscalateRequestRunGraphicsDrawVertexBuffer { - binding: 0, - surface_uuid: "vb-uuid".to_string(), - offset: "not-a-number".to_string(), - }]; - let response = - handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(req)) - .expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-vb"); - assert!( - err.message.contains("vertex_buffer.offset"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for malformed vb.offset, got {other:?}"), - } - assert!(bridge.runs().is_empty()); + fn a_name_supplied_twice_is_refused() { + let message = draw_plan_refusal( + A_DRAWING_KERNELS_BINDINGS, + &[ + ( + "source_image", + EscalateGraphicsBindingKind::SampledTexture, + "surface-in", + ), + ( + "source_image", + EscalateGraphicsBindingKind::SampledTexture, + "surface-other", + ), + ( + "painted_output", + EscalateGraphicsBindingKind::StorageImage, + "surface-out", + ), + ], + ); + assert!( + message.contains("binding `source_image` was supplied twice"), + "must name the duplicate, got: {message}" + ); + assert!( + message.contains("`source_image`, `painted_output`"), + "must name the kernel's declared bindings, got: {message}" + ); } #[test] - fn register_returns_stable_kernel_id_for_identical_descriptor() { - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "register_returns_stable_kernel_id_for_identical_descriptor: no GPU — skipping" - ); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let make_req = |rid: &str| { - EscalateRequest::RegisterGraphicsKernel(make_register_req( - rid, - "deadbeefcafebabe", - "00112233445566778899aabbccddeeff", - )) - }; - let id1 = match handle_escalate_op(&sandbox, ®istry, make_req("a")).unwrap() { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("first register expected Ok, got {other:?}"), - }; - let id2 = match handle_escalate_op(&sandbox, ®istry, make_req("b")).unwrap() { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("second register expected Ok, got {other:?}"), - }; - assert_eq!( - id1, id2, - "identical descriptor must produce the same kernel_id" + fn a_name_the_shaders_do_not_declare_is_refused() { + let message = draw_plan_refusal( + A_DRAWING_KERNELS_BINDINGS, + &[ + ( + "source_image", + EscalateGraphicsBindingKind::SampledTexture, + "surface-in", + ), + ( + "painted_output", + EscalateGraphicsBindingKind::StorageImage, + "surface-out", + ), + ( + "sharpen_amount", + EscalateGraphicsBindingKind::UniformBuffer, + "surface-x", + ), + ], + ); + assert!( + message.contains("binding `sharpen_amount` is not one this kernel declares"), + "must name the unknown binding, got: {message}" + ); + assert!( + message.contains("`source_image`, `painted_output`"), + "must name the kernel's declared bindings, got: {message}" ); } - /// The wire accepts GLSL for graphics too, and this is the only test - /// that the acceptance is wired to anything: it asserts the bridge was - /// handed real SPIR-V, by its magic number, rather than the text. + /// No implicit default and no carried-over value: the kernel holds no + /// binding state between draws to fall back on. #[test] - fn glsl_source_reaches_the_graphics_bridge_as_compiled_spirv() { - let bridge = RecordingGraphicsBridge::new(); - let Some(sandbox) = make_sandbox_with_bridge(Some(bridge.clone())) else { - println!("glsl_source_reaches_the_graphics_bridge: no GPU — skipping"); - return; - }; - let registry = EscalateHandleRegistry::new(); - let mut req = make_register_req("glsl", "", ""); - req.vertex_source = - "#version 450\nvoid main() { gl_Position = vec4(0.0); }\n".to_string(); - req.fragment_source = "#version 450\nlayout(location = 0) out vec4 colour;\n\ - void main() { colour = vec4(1.0); }\n" - .to_string(); - let response = handle_escalate_op( - &sandbox, - ®istry, - EscalateRequest::RegisterGraphicsKernel(req), - ) - .expect("must produce a response"); - let kernel_id = match response { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("expected Ok, got {other:?}"), - }; - let registered = bridge.registered.lock().unwrap(); - let decl = registered - .get(&kernel_id) - .expect("the bridge saw the kernel"); - for (stage, spv) in [ - ("vertex", &decl.vertex_spv), - ("fragment", &decl.fragment_spv), - ] { - assert_eq!( - spv.get(..4), - Some(&SPIRV_MAGIC_LE[..]), - "the {stage} stage reached the bridge as something other than SPIR-V" - ); - } + fn a_declared_binding_left_out_is_refused() { + let message = draw_plan_refusal( + A_DRAWING_KERNELS_BINDINGS, + &[( + "source_image", + EscalateGraphicsBindingKind::SampledTexture, + "surface-in", + )], + ); + assert!( + message.contains("binding `painted_output` was not supplied"), + "must name the missing binding, got: {message}" + ); + assert!( + message.contains("do not persist between draws"), + "must say why there is no fallback, got: {message}" + ); } #[test] - fn register_returns_distinct_kernel_ids_for_different_spirv() { - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "register_returns_distinct_kernel_ids_for_different_spirv: no GPU — skipping" - ); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let req_a = EscalateRequest::RegisterGraphicsKernel(make_register_req( - "a", "deadbeef", "cafebabe", - )); - let req_b = EscalateRequest::RegisterGraphicsKernel(make_register_req( - "b", "11223344", "cafebabe", - )); - let id_a = match handle_escalate_op(&sandbox, ®istry, req_a).unwrap() { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("expected Ok, got {other:?}"), - }; - let id_b = match handle_escalate_op(&sandbox, ®istry, req_b).unwrap() { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("expected Ok, got {other:?}"), - }; - assert_ne!( - id_a, id_b, - "different vertex SPIR-V must produce different kernel_ids" + fn a_binding_supplied_as_the_wrong_kind_is_refused() { + let message = draw_plan_refusal( + A_DRAWING_KERNELS_BINDINGS, + &[ + ( + "source_image", + EscalateGraphicsBindingKind::SampledTexture, + "surface-in", + ), + ( + "painted_output", + EscalateGraphicsBindingKind::StorageBuffer, + "surface-out", + ), + ], + ); + assert!( + message.contains("binding `painted_output` was supplied as storage_buffer"), + "must name the binding and the kind supplied, got: {message}" + ); + assert!( + message.contains("declares it storage_image"), + "must name the kind the kernel declares, got: {message}" ); } - /// Lock in the wire→domain pipeline-state translation. Mentally - /// reverting any single arm of `graphics_pipeline_state_from_wire` - /// (e.g. swapping `BlendOpWire::Add ↔ Subtract`) must fail this - /// test — the synthetic `RecordingGraphicsBridge` accepts the - /// translated `GraphicsPipelineStateWire` value but doesn't itself - /// validate any arm, so without this test the ~200 lines of enum - /// mapping in the handler would have no regression coverage. + /// A buffer binding is legal in a shader and legal on the wire, but no + /// escalate op mints a buffer a descriptor can point at — so the draw + /// that would need one is refused rather than silently unbound. #[test] - fn pipeline_state_translates_every_enum_arm() { - use crate::core::context::{ - BlendFactorWire, BlendOpWire, CullModeWire, DepthCompareOpWire, DepthFormatWire, - DynamicStateWire, FrontFaceWire, GraphicsBindingKindWire, PolygonModeWire, - PrimitiveTopologyWire, VertexAttributeFormatWire, VertexInputRateWire, - }; + fn a_binding_of_a_kind_no_surface_can_back_is_refused() { + let message = draw_plan_refusal( + A_TINTING_KERNELS_BINDINGS, + &[( + "tint_parameters", + EscalateGraphicsBindingKind::UniformBuffer, + "surface-x", + )], + ); + assert!( + message.contains("binding `tint_parameters` is uniform_buffer"), + "must name the binding and its kind, got: {message}" + ); + assert!( + message.contains("storage_image and sampled_texture"), + "must name the kinds a draw can bind, got: {message}" + ); + } - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("pipeline_state_translates_every_enum_arm: no GPU — skipping"); - return; - } + /// A kernel with no bindings at all draws — the empty case is not an + /// error, and the "missing" rule has nothing to fire on. + #[test] + fn a_kernel_declaring_nothing_needs_nothing_supplied() { + let planned = plan_supplied_surface_bound_kernel_bindings("draw", &[], &[]) + .expect("an unbound kernel draws"); + assert!(planned.is_empty()); + } + + // ----- wire → RHI pipeline state -------------------------------- + + /// Lock in the wire→RHI pipeline-state translation. Mentally reverting + /// any single arm of `graphics_pipeline_state_from_wire` (e.g. swapping + /// `Add ↔ Subtract`) must fail this test — nothing else checks the + /// ~200 lines of enum mapping in the handler, and a wrong arm builds a + /// pipeline the caller did not ask for without complaint. + #[test] + fn pipeline_state_translates_every_enum_arm() { + use crate::core::rhi::{ + BlendFactor, BlendOp, ColorBlendState, ColorWriteMask, CullMode, DepthStencilState, + FrontFace, GraphicsDynamicState, PolygonMode, PrimitiveTopology, VertexInputState, }; - let registry = EscalateHandleRegistry::new(); - // Build a request that uses non-default values for every - // pipeline-state arm we want to lock down. Each value is - // chosen to be DIFFERENT from the matching default so a - // wrong arm in the translation would land in the wrong - // wire-mirror variant and the assertion would fail. - let mut req = make_register_req("req-translate", "deadbeef", "cafebabe"); - req.bindings = vec![EscalateRequestRegisterGraphicsKernelBinding { - binding: 7, - kind: EscalateRequestRegisterGraphicsKernelBindingKind::UniformBuffer, - stages: 3, // VERTEX | FRAGMENT - }]; - req.pipeline_state.topology = + // Every value is chosen to differ from the matching default, so a + // wrong arm in the translation lands in the wrong RHI variant and + // the assertion fails. + let mut wire = baseline_pipeline_state(); + wire.topology = EscalateRequestRegisterGraphicsKernelPipelineStateTopology::TriangleStrip; - req.pipeline_state.vertex_input_bindings = vec![ - EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBinding { - binding: 2, - stride: 28, - input_rate: - EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBindingInputRate::Instance, - }, - ]; - req.pipeline_state.vertex_input_attributes = vec![ - EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttribute { - location: 5, - binding: 2, - format: - EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttributeFormat::Rgb32Float, - offset: 12, - }, - ]; - req.pipeline_state.rasterization_polygon_mode = + wire.rasterization_polygon_mode = EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationPolygonMode::Line; - req.pipeline_state.rasterization_cull_mode = + wire.rasterization_cull_mode = EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationCullMode::Back; - req.pipeline_state.rasterization_front_face = + wire.rasterization_front_face = EscalateRequestRegisterGraphicsKernelPipelineStateRasterizationFrontFace::Clockwise; - req.pipeline_state.rasterization_line_width = 2.5; - req.pipeline_state.depth_stencil_enabled = true; - req.pipeline_state.depth_compare_op = - EscalateRequestRegisterGraphicsKernelPipelineStateDepthCompareOp::LessOrEqual; - req.pipeline_state.depth_write = true; - req.pipeline_state.color_blend_enabled = true; - req.pipeline_state.color_write_mask = 0b0101; // R | B only - req.pipeline_state.color_blend_src_color_factor = + wire.rasterization_line_width = 2.5; + wire.color_blend_enabled = true; + wire.color_write_mask = 0b0101; // R | B only + wire.color_blend_src_color_factor = EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcColorFactor::SrcAlpha; - req.pipeline_state.color_blend_dst_color_factor = + wire.color_blend_dst_color_factor = EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstColorFactor::OneMinusSrcAlpha; - req.pipeline_state.color_blend_color_op = + wire.color_blend_color_op = EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp::Subtract; - req.pipeline_state.color_blend_src_alpha_factor = + wire.color_blend_src_alpha_factor = EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcAlphaFactor::ConstantAlpha; - req.pipeline_state.color_blend_dst_alpha_factor = + wire.color_blend_dst_alpha_factor = EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstAlphaFactor::OneMinusConstantAlpha; - req.pipeline_state.color_blend_alpha_op = + wire.color_blend_alpha_op = EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp::Max; - req.pipeline_state.attachment_color_formats = vec!["bgra8_unorm_srgb".to_string()]; - req.pipeline_state.attachment_depth_format = Some( - EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDepthFormat::D32Sfloat, - ); - req.pipeline_state.dynamic_state = + wire.attachment_color_formats = vec!["bgra8_unorm_srgb".to_string()]; + wire.dynamic_state = EscalateRequestRegisterGraphicsKernelPipelineStateDynamicState::None; - req.push_constant_size = 16; - req.push_constant_stages = 3; - req.descriptor_sets_in_flight = 4; - let response = handle_escalate_op( - &sandbox, - ®istry, - EscalateRequest::RegisterGraphicsKernel(req), - ) - .expect("must produce a response"); - match response { - EscalateResponse::Ok(ok) => assert_eq!(ok.request_id, "req-translate"), - other => panic!("expected Ok, got {other:?}"), + let state = graphics_pipeline_state_from_wire(wire).expect("a buildable shape"); + + assert_eq!(state.topology, PrimitiveTopology::TriangleStrip); + // Not a translated arm: both halves of a vertex input are refused + // below, so the only vertex-input state this can produce is the + // gl_VertexIndex-driven one. + assert!( + matches!(state.vertex_input, VertexInputState::None), + "expected the gl_VertexIndex-driven shape, got {:?}", + state.vertex_input + ); + assert_eq!(state.rasterization.polygon_mode, PolygonMode::Line); + assert_eq!(state.rasterization.cull_mode, CullMode::Back); + assert_eq!(state.rasterization.front_face, FrontFace::Clockwise); + assert_eq!(state.rasterization.line_width, 2.5); + assert_eq!(state.multisample.samples, 1); + // Not a translated arm: both halves of a depth attachment are + // refused above, so the only depth state this can produce is off. + assert_eq!(state.depth_stencil, DepthStencilState::Disabled); + match state.color_blend { + ColorBlendState::Enabled(attachment) => { + assert_eq!(attachment.src_color_blend_factor, BlendFactor::SrcAlpha); + assert_eq!( + attachment.dst_color_blend_factor, + BlendFactor::OneMinusSrcAlpha + ); + assert_eq!(attachment.color_blend_op, BlendOp::Subtract); + assert_eq!( + attachment.src_alpha_blend_factor, + BlendFactor::ConstantAlpha + ); + assert_eq!( + attachment.dst_alpha_blend_factor, + BlendFactor::OneMinusConstantAlpha + ); + assert_eq!(attachment.alpha_blend_op, BlendOp::Max); + assert_eq!( + attachment.color_write_mask, + ColorWriteMask::R | ColorWriteMask::B + ); + } + other => panic!("expected blending on, got {other:?}"), + } + assert_eq!( + state.attachment_formats.color, + vec![TextureFormat::Bgra8UnormSrgb] + ); + assert_eq!(state.attachment_formats.depth, None); + assert_eq!(state.dynamic_state, GraphicsDynamicState::None); + } + + /// The four blend-factor fields and the two blend-op fields share one + /// macro each, so a swapped arm there is wrong in every field at once + /// and the single-value test above would only catch the arm it picked. + #[test] + fn every_blend_factor_and_blend_op_arm_reaches_the_rhi_attachment() { + use crate::core::rhi::{BlendFactor, BlendOp, ColorBlendState}; + use EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp as AlphaOpWire; + use EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp as ColorOpWire; + use EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstAlphaFactor as DstAlphaWire; + use EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendDstColorFactor as DstColorWire; + use EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcAlphaFactor as SrcAlphaWire; + use EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendSrcColorFactor as SrcColorWire; + + let factor_arms = [ + ( + SrcColorWire::Zero, + DstColorWire::Zero, + SrcAlphaWire::Zero, + DstAlphaWire::Zero, + BlendFactor::Zero, + ), + ( + SrcColorWire::One, + DstColorWire::One, + SrcAlphaWire::One, + DstAlphaWire::One, + BlendFactor::One, + ), + ( + SrcColorWire::SrcColor, + DstColorWire::SrcColor, + SrcAlphaWire::SrcColor, + DstAlphaWire::SrcColor, + BlendFactor::SrcColor, + ), + ( + SrcColorWire::OneMinusSrcColor, + DstColorWire::OneMinusSrcColor, + SrcAlphaWire::OneMinusSrcColor, + DstAlphaWire::OneMinusSrcColor, + BlendFactor::OneMinusSrcColor, + ), + ( + SrcColorWire::DstColor, + DstColorWire::DstColor, + SrcAlphaWire::DstColor, + DstAlphaWire::DstColor, + BlendFactor::DstColor, + ), + ( + SrcColorWire::OneMinusDstColor, + DstColorWire::OneMinusDstColor, + SrcAlphaWire::OneMinusDstColor, + DstAlphaWire::OneMinusDstColor, + BlendFactor::OneMinusDstColor, + ), + ( + SrcColorWire::SrcAlpha, + DstColorWire::SrcAlpha, + SrcAlphaWire::SrcAlpha, + DstAlphaWire::SrcAlpha, + BlendFactor::SrcAlpha, + ), + ( + SrcColorWire::OneMinusSrcAlpha, + DstColorWire::OneMinusSrcAlpha, + SrcAlphaWire::OneMinusSrcAlpha, + DstAlphaWire::OneMinusSrcAlpha, + BlendFactor::OneMinusSrcAlpha, + ), + ( + SrcColorWire::DstAlpha, + DstColorWire::DstAlpha, + SrcAlphaWire::DstAlpha, + DstAlphaWire::DstAlpha, + BlendFactor::DstAlpha, + ), + ( + SrcColorWire::OneMinusDstAlpha, + DstColorWire::OneMinusDstAlpha, + SrcAlphaWire::OneMinusDstAlpha, + DstAlphaWire::OneMinusDstAlpha, + BlendFactor::OneMinusDstAlpha, + ), + ( + SrcColorWire::ConstantColor, + DstColorWire::ConstantColor, + SrcAlphaWire::ConstantColor, + DstAlphaWire::ConstantColor, + BlendFactor::ConstantColor, + ), + ( + SrcColorWire::OneMinusConstantColor, + DstColorWire::OneMinusConstantColor, + SrcAlphaWire::OneMinusConstantColor, + DstAlphaWire::OneMinusConstantColor, + BlendFactor::OneMinusConstantColor, + ), + ( + SrcColorWire::ConstantAlpha, + DstColorWire::ConstantAlpha, + SrcAlphaWire::ConstantAlpha, + DstAlphaWire::ConstantAlpha, + BlendFactor::ConstantAlpha, + ), + ( + SrcColorWire::OneMinusConstantAlpha, + DstColorWire::OneMinusConstantAlpha, + SrcAlphaWire::OneMinusConstantAlpha, + DstAlphaWire::OneMinusConstantAlpha, + BlendFactor::OneMinusConstantAlpha, + ), + ( + SrcColorWire::SrcAlphaSaturate, + DstColorWire::SrcAlphaSaturate, + SrcAlphaWire::SrcAlphaSaturate, + DstAlphaWire::SrcAlphaSaturate, + BlendFactor::SrcAlphaSaturate, + ), + ]; + for (src_color, dst_color, src_alpha, dst_alpha, expected) in factor_arms { + let mut wire = baseline_pipeline_state(); + wire.color_blend_enabled = true; + wire.color_blend_src_color_factor = src_color; + wire.color_blend_dst_color_factor = dst_color; + wire.color_blend_src_alpha_factor = src_alpha; + wire.color_blend_dst_alpha_factor = dst_alpha; + let state = graphics_pipeline_state_from_wire(wire).expect("a buildable shape"); + match state.color_blend { + ColorBlendState::Enabled(attachment) => { + assert_eq!(attachment.src_color_blend_factor, expected); + assert_eq!(attachment.dst_color_blend_factor, expected); + assert_eq!(attachment.src_alpha_blend_factor, expected); + assert_eq!(attachment.dst_alpha_blend_factor, expected); + } + other => panic!("expected blending on, got {other:?}"), + } } - let registered = bridge - .last_registered() - .expect("bridge should have stored the descriptor"); + let op_arms = [ + (ColorOpWire::Add, AlphaOpWire::Add, BlendOp::Add), + ( + ColorOpWire::Subtract, + AlphaOpWire::Subtract, + BlendOp::Subtract, + ), + ( + ColorOpWire::ReverseSubtract, + AlphaOpWire::ReverseSubtract, + BlendOp::ReverseSubtract, + ), + (ColorOpWire::Min, AlphaOpWire::Min, BlendOp::Min), + (ColorOpWire::Max, AlphaOpWire::Max, BlendOp::Max), + ]; + for (color_op, alpha_op, expected) in op_arms { + let mut wire = baseline_pipeline_state(); + wire.color_blend_enabled = true; + wire.color_blend_color_op = color_op; + wire.color_blend_alpha_op = alpha_op; + let state = graphics_pipeline_state_from_wire(wire).expect("a buildable shape"); + match state.color_blend { + ColorBlendState::Enabled(attachment) => { + assert_eq!(attachment.color_blend_op, expected); + assert_eq!(attachment.alpha_blend_op, expected); + } + other => panic!("expected blending on, got {other:?}"), + } + } + } - // Top-level fields. - assert_eq!(registered.label, "test-graphics"); - assert_eq!(registered.vertex_spv, vec![0xde, 0xad, 0xbe, 0xef]); - assert_eq!(registered.fragment_spv, vec![0xca, 0xfe, 0xba, 0xbe]); - assert_eq!(registered.push_constant_size, 16); - assert_eq!(registered.push_constant_stages, 3); - assert_eq!(registered.descriptor_sets_in_flight, 4); + /// The wire promises these refusals and nothing downstream enforces + /// them: an MSAA pipeline, a multi-attachment one, and either half of a + /// depth attachment or of a vertex input are shapes a draw over this op + /// has no path for. + #[test] + fn a_pipeline_state_the_kernel_cannot_build_is_refused() { + let mut multisampled = baseline_pipeline_state(); + multisampled.multisample_samples = 4; + let message = graphics_pipeline_state_from_wire(multisampled) + .err() + .expect("MSAA must be refused"); + assert!(message.contains("single-sampled"), "{message}"); - // Bindings translation. - assert_eq!(registered.bindings.len(), 1); - assert_eq!(registered.bindings[0].binding, 7); - assert_eq!( - registered.bindings[0].kind, - GraphicsBindingKindWire::UniformBuffer - ); - assert_eq!(registered.bindings[0].stages, 3); + let mut two_attachments = baseline_pipeline_state(); + two_attachments.attachment_color_formats = + vec!["rgba8_unorm".to_string(), "rgba8_unorm".to_string()]; + let message = graphics_pipeline_state_from_wire(two_attachments) + .err() + .expect("two colour attachments must be refused"); + assert!(message.contains("exactly one"), "{message}"); + + // The draw op refuses `depth_target_uuid` for the same reason; a + // pipeline built with depth state would otherwise disagree with the + // colour-only pass at every draw, a submission away from its cause. + let mut depth_testing = baseline_pipeline_state(); + depth_testing.depth_stencil_enabled = true; + let message = graphics_pipeline_state_from_wire(depth_testing) + .err() + .expect("depth testing must be refused"); + assert!(message.contains("colour targets only"), "{message}"); - let p = ®istered.pipeline_state; - assert_eq!(p.topology, PrimitiveTopologyWire::TriangleStrip); - assert_eq!(p.vertex_input_bindings.len(), 1); - assert_eq!(p.vertex_input_bindings[0].binding, 2); - assert_eq!(p.vertex_input_bindings[0].stride, 28); - assert_eq!( - p.vertex_input_bindings[0].input_rate, - VertexInputRateWire::Instance - ); - assert_eq!(p.vertex_input_attributes.len(), 1); - assert_eq!(p.vertex_input_attributes[0].location, 5); - assert_eq!(p.vertex_input_attributes[0].binding, 2); - assert_eq!( - p.vertex_input_attributes[0].format, - VertexAttributeFormatWire::Rgb32Float - ); - assert_eq!(p.vertex_input_attributes[0].offset, 12); - assert_eq!(p.rasterization_polygon_mode, PolygonModeWire::Line); - assert_eq!(p.rasterization_cull_mode, CullModeWire::Back); - assert_eq!(p.rasterization_front_face, FrontFaceWire::Clockwise); - assert_eq!(p.rasterization_line_width, 2.5); - assert_eq!(p.multisample_samples, 1); - assert!(p.depth_stencil_enabled); - assert_eq!(p.depth_compare_op, DepthCompareOpWire::LessOrEqual); - assert!(p.depth_write); - assert!(p.color_blend_enabled); - assert_eq!(p.color_write_mask, 0b0101); - assert_eq!(p.color_blend_src_color_factor, BlendFactorWire::SrcAlpha); - assert_eq!( - p.color_blend_dst_color_factor, - BlendFactorWire::OneMinusSrcAlpha - ); - assert_eq!(p.color_blend_color_op, BlendOpWire::Subtract); - assert_eq!( - p.color_blend_src_alpha_factor, - BlendFactorWire::ConstantAlpha + let mut depth_attachment = baseline_pipeline_state(); + depth_attachment.attachment_depth_format = Some( + EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDepthFormat::D32Sfloat, ); - assert_eq!( - p.color_blend_dst_alpha_factor, - BlendFactorWire::OneMinusConstantAlpha + let message = graphics_pipeline_state_from_wire(depth_attachment) + .err() + .expect("a depth attachment must be refused"); + assert!(message.contains("colour targets only"), "{message}"); + + let mut unowned_write_mask = baseline_pipeline_state(); + unowned_write_mask.color_write_mask = 0b1_0000; + let message = graphics_pipeline_state_from_wire(unowned_write_mask) + .err() + .expect("a bit no channel owns must be refused"); + assert!(message.contains("no colour channel owns"), "{message}"); + + // The draw op refuses `vertex_buffers` for the same reason. A + // pipeline pulling from a vertex binding would otherwise register + // and then be refused at every draw, for a buffer no escalate op + // can mint to fill it. + let mut buffer_fed_vertices = baseline_pipeline_state(); + buffer_fed_vertices.vertex_input_bindings = vec![ + EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBinding { + binding: 0, + stride: 12, + input_rate: + EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBindingInputRate::Vertex, + }, + ]; + let message = graphics_pipeline_state_from_wire(buffer_fed_vertices) + .err() + .expect("a vertex binding no buffer can fill must be refused"); + assert!( + message.contains("no escalate op mints a VertexBuffer"), + "{message}" ); - assert_eq!(p.color_blend_alpha_op, BlendOpWire::Max); - assert_eq!(p.attachment_color_formats, vec!["bgra8_unorm_srgb"]); - assert_eq!(p.attachment_depth_format, Some(DepthFormatWire::D32Sfloat)); - assert_eq!(p.dynamic_state, DynamicStateWire::None); + assert!(message.contains("gl_VertexIndex"), "{message}"); + + let mut unfed_attributes = baseline_pipeline_state(); + unfed_attributes.vertex_input_attributes = vec![ + EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttribute { + location: 0, + binding: 0, + format: + EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttributeFormat::Rgb32Float, + offset: 0, + }, + ]; + let message = graphics_pipeline_state_from_wire(unfed_attributes) + .err() + .expect("an attribute with no binding it could be fed from must be refused"); + assert!(message.contains("pulled from a"), "{message}"); + assert!(message.contains("gl_VertexIndex"), "{message}"); } + // ----- the handlers --------------------------------------------- + + /// Both hex fields are decoded before the escalate hop, so a malformed + /// one is refused without touching the GPU at all. #[test] - fn run_with_unregistered_kernel_id_returns_err() { - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("run_with_unregistered_kernel_id_returns_err: no GPU — skipping"); - return; - } + fn register_with_invalid_vertex_hex_returns_err() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_with_invalid_vertex_hex: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RunGraphicsDraw(make_run_req( - "req-bad-id", - "never-registered", - "surface-y", - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); + let response = handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterGraphicsKernel(make_register_req( + "req-bad-v", + "xyz123", + "cafebabe", + )), + ) + .expect("must produce a response"); match response { EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-id"); + assert_eq!(err.request_id, "req-bad-v"); assert!( - err.message.contains("not registered") - || err.message.contains("never-registered"), + err.message.contains("vertex_spv_hex"), "got: {}", err.message ); } - other => panic!("expected Err for unregistered kernel_id, got {other:?}"), + other => panic!("expected Err for malformed vertex hex, got {other:?}"), } } #[test] - fn run_forwards_payload_to_bridge_and_echoes_kernel_id() { - let bridge = RecordingGraphicsBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "run_forwards_payload_to_bridge_and_echoes_kernel_id: no GPU — skipping" + fn register_with_invalid_fragment_hex_returns_err() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_with_invalid_fragment_hex: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let response = handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterGraphicsKernel(make_register_req( + "req-bad-f", + "deadbeef", + "qq", + )), + ) + .expect("must produce a response"); + match response { + EscalateResponse::Err(err) => { + assert_eq!(err.request_id, "req-bad-f"); + assert!( + err.message.contains("fragment_spv_hex"), + "got: {}", + err.message ); - return; } + other => panic!("expected Err for malformed fragment hex, got {other:?}"), + } + } + + #[test] + fn run_with_invalid_push_constants_hex_returns_err() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("run_with_invalid_push_constants_hex: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); + let mut req = make_run_req("req-bad-push", "kernel-x", "surface-y"); + req.push_constants_hex = "xyz".to_string(); + let response = + handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(req)) + .expect("must produce a response"); + match response { + EscalateResponse::Err(err) => { + assert_eq!(err.request_id, "req-bad-push"); + assert!( + err.message.contains("push_constants_hex"), + "got: {}", + err.message + ); + } + other => panic!("expected Err for malformed push hex, got {other:?}"), + } + } - // Register first so the bridge has the kernel_id cached. - let reg = EscalateRequest::RegisterGraphicsKernel(make_register_req( - "reg", - "abcdef0123456789", - "fedcba9876543210", - )); - let kernel_id = match handle_escalate_op(&sandbox, ®istry, reg).unwrap() { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("register expected Ok, got {other:?}"), + /// The three shapes the wire carries that the host has no path for. + /// Each is refused rather than silently dropped: a caller who sent one + /// would otherwise get a draw that ignored half of what it asked for. + #[test] + fn a_draw_naming_a_resource_no_escalate_op_mints_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_draw_naming_an_unmintable_resource: no GPU — skipping"); + return; }; + let registry = EscalateHandleRegistry::new(); - // Indexed draw with a vertex buffer + push constants — exercises - // every translation arm in the wire→domain mapper. - let mut run = make_run_req("run", &kernel_id, "color-target-uuid"); - run.frame_index = 1; - run.bindings = vec![EscalateRequestRunGraphicsDrawBinding { - binding: 0, - kind: EscalateRequestRunGraphicsDrawBindingKind::SampledTexture, - surface_uuid: "tex-uuid".to_string(), - }]; - run.vertex_buffers = vec![EscalateRequestRunGraphicsDrawVertexBuffer { + let mut with_vertex_buffer = make_run_req("req-vb", "kernel-x", "surface-y"); + with_vertex_buffer.vertex_buffers = vec![EscalateRequestRunGraphicsDrawVertexBuffer { binding: 0, surface_uuid: "vb-uuid".to_string(), offset: "128".to_string(), }]; - run.index_buffer = Some(EscalateRequestRunGraphicsDrawIndexBuffer { - surface_uuid: "ib-uuid".to_string(), - offset: "64".to_string(), - index_type: EscalateRequestRunGraphicsDrawIndexBufferIndexType::Uint32, - }); - run.push_constants_hex = "00112233aabbccdd".to_string(); - run.draw = EscalateRequestRunGraphicsDrawDraw { - kind: EscalateRequestRunGraphicsDrawDrawKind::DrawIndexed, - vertex_count: 0, - index_count: 6, - instance_count: 2, - first_vertex: 0, - first_instance: 1, - first_index: 3, - vertex_offset: -4, - }; - run.viewport = Some(EscalateRequestRunGraphicsDrawViewport { - x: 0.0, - y: 0.0, - width: 320.0, - height: 240.0, - min_depth: 0.0, - max_depth: 1.0, - }); + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RunGraphicsDraw(with_vertex_buffer), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("no escalate op mints a VertexBuffer"), + "must say what is missing, got: {message}" + ); + + let mut indexed = make_run_req("req-ib", "kernel-x", "surface-y"); + indexed.index_buffer = Some(EscalateRequestRunGraphicsDrawIndexBuffer { + surface_uuid: "ib-uuid".to_string(), + offset: "64".to_string(), + index_type: EscalateRequestRunGraphicsDrawIndexBufferIndexType::Uint32, + }); + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RunGraphicsDraw(indexed), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("an indexed draw needs an IndexBuffer"), + "must say what is missing, got: {message}" + ); + + // The index buffer is what a `draw_indexed` names its indices in, + // so the draw kind alone is refused for the same reason. + let mut indexed_without_a_buffer = make_run_req("req-ib-kind", "kernel-x", "surface-y"); + indexed_without_a_buffer.draw.kind = + EscalateRequestRunGraphicsDrawDrawKind::DrawIndexed; + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RunGraphicsDraw(indexed_without_a_buffer), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("an indexed draw needs an IndexBuffer"), + "must say what is missing, got: {message}" + ); + } + + /// The offscreen pass attaches colour targets only, so a depth target + /// would never be tested against — and a caller who set one is asking + /// for depth testing that would not happen. + #[test] + fn a_draw_naming_a_depth_target_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_draw_naming_a_depth_target: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let mut req = make_run_req("req-depth", "kernel-x", "surface-y"); + req.depth_target_uuid = Some("depth-uuid".to_string()); + let message = refusal_message( + handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(req)) + .expect("must produce a response"), + ); + assert!( + message.contains("depth_target_uuid is set"), + "must name the field, got: {message}" + ); + assert!( + message.contains("colour targets only"), + "must say why, got: {message}" + ); + } + + #[test] + fn a_draw_naming_other_than_one_colour_target_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_draw_naming_other_than_one_colour_target: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let mut req = make_run_req("req-targets", "kernel-x", "surface-y"); + req.color_target_uuids = vec!["a".to_string(), "b".to_string()]; + let message = refusal_message( + handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(req)) + .expect("must produce a response"), + ); + assert!( + message.contains("exactly one colour attachment"), + "got: {message}" + ); + } + + #[test] + fn drawing_with_an_unregistered_kernel_id_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("drawing_with_an_unregistered_kernel_id: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RunGraphicsDraw(make_run_req( + "req-bad-id", + "never-registered", + "surface-y", + )), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("no kernel registered under id") + && message.contains("never-registered"), + "got: {message}" + ); + } + + /// A stage mask is a bitfield the caller writes by hand, and a bit + /// outside vertex|fragment names a stage a graphics pipeline has no + /// module for at all. + #[test] + fn a_binding_declared_for_a_stage_no_graphics_pipeline_has_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_binding_declared_for_an_unowned_stage: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let mut req = register_from_glsl("req-stage", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL); + req.bindings = vec![EscalateRequestRegisterGraphicsKernelBinding { + kind: EscalateGraphicsBindingKind::SampledTexture, + name: "source_image".to_string(), + stages: 0b100, + }]; + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterGraphicsKernel(req), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("no graphics stage owns"), + "must say the bit belongs to no stage, got: {message}" + ); + } + + /// GLSL where bytes used to go: the engine compiles each stage itself, + /// and what it hands the pipeline is a module rather than the text. + #[test] + fn glsl_for_each_stage_reaches_the_engine_as_compiled_spirv() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("glsl_for_each_stage_reaches_the_engine: no GPU — skipping"); + return; + }; + for (field_prefix, source, stage) in [ + ( + "vertex_", + FULL_SCREEN_TRIANGLE_VERTEX_GLSL, + GlslCompilationTargetStage::Vertex, + ), + ( + "fragment_", + INVERT_SAMPLED_INPUT_FRAGMENT_GLSL, + GlslCompilationTargetStage::Fragment, + ), + ] { + let compiled = registered_shader_stage_source(field_prefix, source, "", stage, "") + .expect("GLSL alone is one of the two alternatives") + .spirv(&sandbox) + .expect("the engine compiles it"); + assert_eq!( + compiled.get(..4), + Some(&SPIRV_MAGIC_LE[..]), + "the {stage:?} stage reached the pipeline as something other than SPIR-V" + ); + } + } + + /// Registration hands back the shape a draw needs: the shaders' own + /// names, each with the kind only the shaders know. No bridge is + /// installed — graphics is a capability the context always has. + #[test] + fn registration_answers_with_the_shaders_binding_names_and_kinds() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("registration_answers_with_the_shaders_bindings: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let ok = register_graphics_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("reg", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL), + ); + let bindings = ok.bindings.expect("a register response carries the shape"); + assert_eq!( + bindings + .iter() + .map(|binding| (binding.name.as_str(), binding.kind.as_str())) + .collect::>(), + vec![("source_image", "sampled_texture")], + "the fragment shader's own binding, named and kinded as it declares it" + ); + } + + /// Re-registering an identical kernel is free and keeps its id; a + /// different fragment stage is a different pipeline and gets its own. + #[test] + fn an_identical_registration_keeps_its_kernel_id_and_a_different_one_gets_its_own() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("an_identical_registration_keeps_its_kernel_id: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let first = register_graphics_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("a", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL), + ) + .handle_id; + let second = register_graphics_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("b", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL), + ) + .handle_id; + assert_eq!( + first, second, + "an identical descriptor must produce the same kernel_id" + ); + + let other = register_graphics_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("c", HALVE_SAMPLED_INPUT_FRAGMENT_GLSL), + ) + .handle_id; + assert_ne!( + first, other, + "a different fragment stage must produce a different kernel_id" + ); + + let held = sandbox + .escalate(|full| { + Ok(( + full.graphics_kernel_by_id(&first), + full.graphics_kernel_by_id(&second), + )) + }) + .expect("the cache answers inside an escalate scope"); + let (a, b) = (held.0.expect("cached"), held.1.expect("cached")); + assert!( + std::sync::Arc::ptr_eq(&a, &b), + "the second registration must reuse the first kernel, not build another" + ); + } + + /// The cache key covers the shaders and the pipeline, not the caller's + /// assertion — so a wrong declaration refuses identically whether or + /// not somebody registered this kernel first. + #[test] + fn a_wrong_declaration_is_refused_even_when_the_kernel_is_cached() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_wrong_declaration_is_refused_when_cached: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + register_graphics_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("warm", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL), + ); + + let mut req = register_from_glsl("reg-wrong", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL); + req.bindings = vec![EscalateRequestRegisterGraphicsKernelBinding { + kind: EscalateGraphicsBindingKind::StorageBuffer, + name: "sharpen_amount".to_string(), + stages: 0, + }]; + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterGraphicsKernel(req), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("`sharpen_amount`") && message.contains("`source_image`"), + "the refusal must name the bogus binding and the shaders' own: {message}" + ); + } + + /// The op end to end, over a real device: a draw resolves its binding + /// by the fragment shader's own name, renders into the surface the + /// request named, and leaves the engine's layout record agreeing with + /// the layout the pass left the image in. + /// + /// The source is seeded with a known value and the target is read back + /// and compared against the shader's own arithmetic, so a draw that + /// bound nothing — or bound the target to itself — fails on the pixels + /// rather than passing silently. The seeded source is then moved to + /// `GENERAL`, which a combined image sampler does not satisfy: a draw + /// that did not barrier its bound inputs would read it through a + /// descriptor its layout disagrees with, and would leave the engine's + /// record still saying `GENERAL`. + #[test] + fn a_draw_reads_the_surface_its_binding_names_and_publishes_the_targets_layout() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_draw_reads_the_surface_its_binding_names: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let kernel_id = register_graphics_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("reg-draw", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL), + ) + .handle_id; + + // Held for the draw: dropping a pooled handle hands its slot back, + // and the registration would then name a recycled texture. + let held = sandbox + .escalate(|full| { + let source = full.acquire_texture( + &TexturePoolDescriptor::new(64, 64, TextureFormat::Rgba8Unorm) + .with_usage(TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST), + )?; + let target = full.acquire_texture( + &TexturePoolDescriptor::new(64, 64, TextureFormat::Rgba8Unorm) + .with_usage(TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC), + )?; + full.register_texture("draw-source", source.texture().clone()); + full.register_texture("draw-target", target.texture().clone()); + + let (_pool_id, seed_buffer) = + full.acquire_pixel_buffer(64, 64, PixelFormat::Rgba32)?; + let plane = seed_buffer.buffer_ref().plane_base_address(0); + unsafe { + for pixel in 0..(64 * 64) { + std::ptr::copy_nonoverlapping( + SEED_RGBA.as_ptr(), + plane.add(pixel * 4), + 4, + ); + } + } + full.copy_pixel_buffer_to_texture( + &seed_buffer, + source.texture(), + "draw-source", + 64, + 64, + )?; + + // The seed publishes SHADER_READ_ONLY_OPTIMAL — the very + // layout a sampled binding wants — so the draw's input + // barrier would have nothing to do and this test would end + // by re-reading what setup established. GENERAL is a layout + // the descriptor does not satisfy, which is the state a + // storage-image producer upstream leaves behind. + let mut recorder = full.create_command_recorder("draw_source_into_general")?; + recorder.begin()?; + recorder.record_image_barrier( + source.texture(), + crate::core::rhi::VulkanLayout::SHADER_READ_ONLY_OPTIMAL, + crate::core::rhi::VulkanLayout::GENERAL, + crate::vulkan::rhi::VulkanStage::ALL_COMMANDS, + crate::vulkan::rhi::VulkanStage::ALL_COMMANDS, + crate::vulkan::rhi::VulkanAccess::MEMORY_WRITE, + crate::vulkan::rhi::VulkanAccess::MEMORY_READ, + )?; + recorder.submit_and_wait()?; + full.resolve_texture_registration_by_surface_id("draw-source", None, 64, 64)? + .update_layout(crate::core::rhi::VulkanLayout::GENERAL); + Ok((source, target)) + }) + .expect("a seeded source and a colour target"); + + let mut run = make_run_req("run-draw", &kernel_id, "draw-target"); + run.bindings = vec![EscalateRequestRunGraphicsDrawBinding { + kind: EscalateGraphicsBindingKind::SampledTexture, + name: "source_image".to_string(), + surface_uuid: "draw-source".to_string(), + }]; + run.extent_width = 64; + run.extent_height = 64; + let response = + handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(run)) + .expect("must produce a response"); + match response { + EscalateResponse::Ok(ok) => { + assert_eq!(ok.request_id, "run-draw"); + assert_eq!( + ok.handle_id, kernel_id, + "the run response echoes the kernel_id" + ); + assert!( + ok.timeline_value.is_none(), + "run_graphics_draw responses carry no timeline" + ); + } + other => panic!("the draw failed: {other:?}"), + } + + // Asserted before the readback, which transitions the image itself: + // `offscreen_render` leaves every colour target in + // COLOR_ATTACHMENT_OPTIMAL and tells no registration, so an + // unpublished layout would leave the next consumer's barrier + // naming an oldLayout the image has already left. + let published = sandbox + .escalate(|full| { + Ok(full + .resolve_texture_registration_by_surface_id("draw-target", None, 64, 64)? + .current_layout()) + }) + .expect("the colour target still resolves"); + assert_eq!( + published, + streamlib_consumer_rhi::VulkanLayout::COLOR_ATTACHMENT_OPTIMAL, + "the draw must publish the layout it left the colour target in" + ); + + let rendered = sandbox + .escalate(|full| { + let readback = full.create_texture_readback( + "draw-readback", + 64, + 64, + TextureFormat::Rgba8Unorm, + )?; + let ticket = readback.submit( + held.1.texture(), + crate::core::rhi::TextureSourceLayout::ColorAttachment, + )?; + Ok(readback.wait_and_read(ticket, 2_000_000_000)?.to_vec()) + }) + .expect("the colour target reads back"); + for (pixel_index, pixel) in rendered.chunks_exact(4).enumerate() { + assert_eq!( + pixel, INVERTED_RGBA, + "pixel {pixel_index} must be the inverted seed — the draw read \ + `source_image`, by name, and painted the target it was given" + ); + } + + // The bound input left GENERAL for the layout its descriptor + // required, which is where the next consumer's barrier starts from. + let source_layout = sandbox + .escalate(|full| { + Ok(full + .resolve_texture_registration_by_surface_id("draw-source", None, 64, 64)? + .current_layout()) + }) + .expect("the source still resolves"); + assert_eq!( + source_layout, + streamlib_consumer_rhi::VulkanLayout::SHADER_READ_ONLY_OPTIMAL, + "a sampled binding is barriered out of GENERAL into the layout its descriptor \ + requires" + ); + drop(held); + } + + /// The pixels a draw does not cover are the load op's, and this op + /// carries no clear colour of its own — so the handler's choice of + /// transparent black over `LOAD` is what they read. + /// + /// The draw is scissored to the left half of a target seeded with a + /// sentinel no stage writes, so nothing but the load op ever touches the + /// right half. `LOAD` there reads an attachment the pass has just + /// transitioned from `UNDEFINED`, whose contents the spec stops defining + /// at that point — on this device the seeded sentinel survives it, which + /// is what makes the assertion discriminate. + #[test] + fn the_pixels_a_draw_does_not_cover_read_transparent_black() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("the_pixels_a_draw_does_not_cover: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let kernel_id = register_graphics_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("reg-scissored", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL), + ) + .handle_id; + + let held = sandbox + .escalate(|full| { + let source = full.acquire_texture( + &TexturePoolDescriptor::new(64, 64, TextureFormat::Rgba8Unorm) + .with_usage(TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST), + )?; + let target = full.acquire_texture( + &TexturePoolDescriptor::new(64, 64, TextureFormat::Rgba8Unorm).with_usage( + TextureUsages::RENDER_ATTACHMENT + | TextureUsages::COPY_SRC + | TextureUsages::COPY_DST, + ), + )?; + full.register_texture("scissored-source", source.texture().clone()); + full.register_texture("scissored-target", target.texture().clone()); + + for (texture, surface_id, seed) in [ + (source.texture(), "scissored-source", SEED_RGBA), + ( + target.texture(), + "scissored-target", + UNCOVERED_SENTINEL_RGBA, + ), + ] { + let (_pool_id, seed_buffer) = + full.acquire_pixel_buffer(64, 64, PixelFormat::Rgba32)?; + let plane = seed_buffer.buffer_ref().plane_base_address(0); + unsafe { + for pixel in 0..(64 * 64) { + std::ptr::copy_nonoverlapping( + seed.as_ptr(), + plane.add(pixel * 4), + 4, + ); + } + } + full.copy_pixel_buffer_to_texture( + &seed_buffer, + texture, + surface_id, + 64, + 64, + )?; + } + Ok((source, target)) + }) + .expect("a seeded source and a seeded colour target"); + + let mut run = make_run_req("run-scissored", &kernel_id, "scissored-target"); + run.bindings = vec![EscalateRequestRunGraphicsDrawBinding { + kind: EscalateGraphicsBindingKind::SampledTexture, + name: "source_image".to_string(), + surface_uuid: "scissored-source".to_string(), + }]; + run.extent_width = 64; + run.extent_height = 64; run.scissor = Some(EscalateRequestRunGraphicsDrawScissor { x: 0, y: 0, - width: 320, - height: 240, + width: 32, + height: 64, }); + match handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(run)) + .expect("must produce a response") + { + EscalateResponse::Ok(_) => {} + other => panic!("the scissored draw failed: {other:?}"), + } - let response = - handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(run)) - .unwrap(); - match response { - EscalateResponse::Ok(ok) => { - assert_eq!(ok.request_id, "run"); + let rendered = sandbox + .escalate(|full| { + let readback = full.create_texture_readback( + "scissored-readback", + 64, + 64, + TextureFormat::Rgba8Unorm, + )?; + let ticket = readback.submit( + held.1.texture(), + crate::core::rhi::TextureSourceLayout::ColorAttachment, + )?; + Ok(readback.wait_and_read(ticket, 2_000_000_000)?.to_vec()) + }) + .expect("the colour target reads back"); + for (pixel_index, pixel) in rendered.chunks_exact(4).enumerate() { + if pixel_index % 64 < 32 { assert_eq!( - ok.handle_id, kernel_id, - "run response handle_id must echo the kernel_id" + pixel, INVERTED_RGBA, + "pixel {pixel_index} is inside the scissor and must be the inverted seed" ); - assert!( - ok.timeline_value.is_none(), - "run_graphics_draw responses carry no timeline" + } else { + assert_eq!( + pixel, TRANSPARENT_BLACK_RGBA, + "pixel {pixel_index} is outside the scissor, so nothing painted it — the \ + pass must have cleared it rather than loaded contents its own transition \ + from UNDEFINED had already discarded" ); } - other => panic!("run expected Ok, got {other:?}"), - } - let runs = bridge.runs(); - assert_eq!(runs.len(), 1, "bridge.run_draw must have been called once"); - let r = &runs[0]; - assert_eq!(r.kernel_id, kernel_id); - assert_eq!(r.frame_index, 1); - assert_eq!(r.color_target_uuids, vec!["color-target-uuid".to_string()]); - assert_eq!(r.extent, (320, 240)); - assert_eq!(r.bindings.len(), 1); - assert_eq!(r.bindings[0].surface_uuid, "tex-uuid"); - assert_eq!(r.vertex_buffers.len(), 1); - assert_eq!(r.vertex_buffers[0].surface_uuid, "vb-uuid"); - assert_eq!(r.vertex_buffers[0].offset, 128); - let ib = r.index_buffer.as_ref().expect("index_buffer present"); - assert_eq!(ib.surface_uuid, "ib-uuid"); - assert_eq!(ib.offset, 64); - assert_eq!(ib.index_type, IndexTypeWire::Uint32); - assert_eq!(r.push_constants.len(), 8); - assert!(r.viewport.is_some()); - assert!(r.scissor.is_some()); - match r.draw { - GraphicsDrawSpec::DrawIndexed { - index_count, - instance_count, - first_index, - vertex_offset, - first_instance, - } => { - assert_eq!(index_count, 6); - assert_eq!(instance_count, 2); - assert_eq!(first_index, 3); - assert_eq!(vertex_offset, -4); - assert_eq!(first_instance, 1); - } - other => panic!("expected DrawIndexed, got {other:?}"), } + drop(held); + } + + /// A draw whose binding and colour target are one texture is refused: + /// the pass discards a colour target's contents on entry, so the + /// binding would read pixels the draw has already thrown away. + #[test] + fn a_draw_binding_its_own_colour_target_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_draw_binding_its_own_colour_target: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let kernel_id = register_graphics_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("reg-alias", INVERT_SAMPLED_INPUT_FRAGMENT_GLSL), + ) + .handle_id; + let held = sandbox + .escalate(|full| { + let texture = full.acquire_texture( + &TexturePoolDescriptor::new(64, 64, TextureFormat::Rgba8Unorm).with_usage( + TextureUsages::TEXTURE_BINDING | TextureUsages::RENDER_ATTACHMENT, + ), + )?; + full.register_texture("alias-surface", texture.texture().clone()); + Ok(texture) + }) + .expect("one texture to name twice"); + + let mut run = make_run_req("run-alias", &kernel_id, "alias-surface"); + run.bindings = vec![EscalateRequestRunGraphicsDrawBinding { + kind: EscalateGraphicsBindingKind::SampledTexture, + name: "source_image".to_string(), + surface_uuid: "alias-surface".to_string(), + }]; + run.extent_width = 64; + run.extent_height = 64; + let message = refusal_message( + handle_escalate_op(&sandbox, ®istry, EscalateRequest::RunGraphicsDraw(run)) + .expect("must produce a response"), + ); + assert!( + message.contains("already thrown away"), + "must say why the alias is refused, got: {message}" + ); + drop(held); } } - /// Tests for the ray-tracing-kernel + acceleration-structure - /// escalate ops (issue #667). + /// Host-Rust unit tests for the acceleration-structure and ray-tracing + /// escalate handlers. /// - /// Mirrors the `graphics_kernel_dispatch` mod above: a synthetic - /// `RecordingRayTracingBridge` keeps the tests independent of a - /// working `VkDevice` (and an RT-capable GPU), so handler-shape - /// regressions surface even on machines without a GPU. + /// Mirrors `graphics_kernel_dispatch`: the wire validation that raises + /// before the device gate runs everywhere CI does, and everything that + /// builds a structure or a pipeline gates on a device that exposes the + /// ray-tracing extension chain. #[cfg(target_os = "linux")] mod ray_tracing_kernel_dispatch { use super::super::*; use super::EscalateHandleRegistry; - use std::sync::{Arc, Mutex}; use crate::core::compiler::compiler_ops::subprocess_escalate_wire_types::escalate_request::{ EscalateRequestRegisterAccelerationStructureTlasInstance, @@ -6361,204 +7725,157 @@ void main() { EscalateRequestRegisterRayTracingKernelStage, EscalateRequestRunRayTracingKernelBinding, }; - use crate::core::context::{ - BlasRegisterDecl, GpuContext, GpuContextLimitedAccess, RAY_TRACING_STAGE_INDEX_NONE, - RayTracingKernelBridge, RayTracingKernelRegisterDecl, RayTracingKernelRunDispatch, - TlasRegisterDecl, - }; + use crate::core::context::GpuContext; + use crate::core::rhi::{RayTracingShaderStage, RayTracingShaderStageFlags}; - /// Synthetic bridge — accepts any caller-provided BLAS/TLAS/kernel - /// (no SPIR-V reflection or AS build), keys handles by SHA-256 - /// over the canonicalized inputs so identical descriptors hit - /// the cache, and records every `run_kernel` for later assertion. - struct RecordingRayTracingBridge { - blases: Mutex>, - tlases: Mutex>, - kernels: Mutex>, - runs: Mutex>, - } - - impl RecordingRayTracingBridge { - fn new() -> Arc { - Arc::new(Self { - blases: Mutex::new(std::collections::HashMap::new()), - tlases: Mutex::new(std::collections::HashMap::new()), - kernels: Mutex::new(std::collections::HashMap::new()), - runs: Mutex::new(Vec::new()), - }) - } + /// Ray tracing is a device capability rather than an installed bridge, + /// so there is nothing to set up — only a device to have or not have. + fn make_gpu_sandbox_if_available() -> Option { + GpuContext::init_for_platform_sync() + .ok() + .map(GpuContextLimitedAccess::new) + } - fn blas_count(&self) -> usize { - self.blases.lock().unwrap().len() - } + /// A sandbox whose device exposes the `VK_KHR_ray_tracing_pipeline` + /// chain — what every structure build and every pipeline build needs. + fn make_ray_tracing_sandbox_if_available() -> Option { + let sandbox = make_gpu_sandbox_if_available()?; + let ray_tracing_capable = sandbox + .escalate(|full| Ok(full.supports_ray_tracing_pipeline())) + .unwrap_or(false); + ray_tracing_capable.then_some(sandbox) + } - fn tlas_count(&self) -> usize { - self.tlases.lock().unwrap().len() + fn refusal_message(response: EscalateResponse) -> String { + match response { + EscalateResponse::Err(err) => err.message, + other => panic!("expected Err, got {other:?}"), } + } - fn kernel_count(&self) -> usize { - self.kernels.lock().unwrap().len() - } + /// Traces one ray per pixel straight down `-Z` at the bound structure + /// and writes whatever the hit or miss stage left in the payload. Both + /// bindings are resolved by the names this source gives them. + const TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL: &str = "\ +#version 460 +#extension GL_EXT_ray_tracing : require +layout(set = 0, binding = 0) uniform accelerationStructureEXT scene_geometry; +layout(set = 0, binding = 1, rgba8) uniform writeonly image2D traced_output; +layout(location = 0) rayPayloadEXT vec3 traced_colour; +void main() { + vec2 pixel_centre = vec2(gl_LaunchIDEXT.xy) + vec2(0.5); + vec2 normalized_device_coordinate = + pixel_centre / vec2(gl_LaunchSizeEXT.xy) * 2.0 - 1.0; + traced_colour = vec3(0.0); + traceRayEXT( + scene_geometry, + gl_RayFlagsOpaqueEXT, + 0xff, + 0, 0, 0, + vec3(normalized_device_coordinate.x, -normalized_device_coordinate.y, 1.0), + 0.001, + vec3(0.0, 0.0, -1.0), + 100.0, + 0 + ); + imageStore(traced_output, ivec2(gl_LaunchIDEXT.xy), vec4(traced_colour, 1.0)); +} +"; - fn last_kernel(&self) -> Option { - self.kernels.lock().unwrap().values().next().cloned() - } + /// The same pass with the payload inverted before it is stored, so + /// registering it produces a different pipeline — and therefore a + /// different kernel id — from [`TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL`]. + const TRACE_AND_INVERT_RAY_GEN_GLSL: &str = "\ +#version 460 +#extension GL_EXT_ray_tracing : require +layout(set = 0, binding = 0) uniform accelerationStructureEXT scene_geometry; +layout(set = 0, binding = 1, rgba8) uniform writeonly image2D traced_output; +layout(location = 0) rayPayloadEXT vec3 traced_colour; +void main() { + vec2 pixel_centre = vec2(gl_LaunchIDEXT.xy) + vec2(0.5); + vec2 normalized_device_coordinate = + pixel_centre / vec2(gl_LaunchSizeEXT.xy) * 2.0 - 1.0; + traced_colour = vec3(0.0); + traceRayEXT( + scene_geometry, + gl_RayFlagsOpaqueEXT, + 0xff, + 0, 0, 0, + vec3(normalized_device_coordinate.x, -normalized_device_coordinate.y, 1.0), + 0.001, + vec3(0.0, 0.0, -1.0), + 100.0, + 0 + ); + imageStore( + traced_output, + ivec2(gl_LaunchIDEXT.xy), + vec4(vec3(1.0) - traced_colour, 1.0) + ); +} +"; - fn last_tlas(&self) -> Option { - self.tlases.lock().unwrap().values().next().cloned() - } + /// A ray that hit nothing paints black. + const MISS_PAINTS_BLACK_GLSL: &str = "\ +#version 460 +#extension GL_EXT_ray_tracing : require +layout(location = 0) rayPayloadInEXT vec3 traced_colour; +void main() { + traced_colour = vec3(0.0); +} +"; - fn runs(&self) -> Vec { - self.runs.lock().unwrap().clone() - } + /// A ray that hit the scene's one triangle paints white, so a traced + /// pixel says which of the two stages ran for it. + const CLOSEST_HIT_PAINTS_WHITE_GLSL: &str = "\ +#version 460 +#extension GL_EXT_ray_tracing : require +layout(location = 0) rayPayloadInEXT vec3 traced_colour; +void main() { + traced_colour = vec3(1.0); +} +"; - fn blas_key(decl: &BlasRegisterDecl) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(b"blas|v="); - for f in &decl.vertices { - h.update(&f.to_le_bytes()); - } - h.update(b"|i="); - for i in &decl.indices { - h.update(&i.to_le_bytes()); - } - format!("{:x}", h.finalize()) - } - - fn tlas_key(decl: &TlasRegisterDecl) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(b"tlas|n="); - h.update(&(decl.instances.len() as u32).to_le_bytes()); - for inst in &decl.instances { - h.update(b"|b="); - h.update(inst.blas_id.as_bytes()); - h.update(b"|c="); - h.update(&inst.custom_index.to_le_bytes()); - h.update(b"|m="); - h.update(&[inst.mask]); - } - format!("{:x}", h.finalize()) - } + /// A pixel the ray hit, a pixel it missed, and the sentinel the storage + /// image is seeded with so an untouched pixel is distinguishable from + /// either. + const HIT_RGBA: [u8; 4] = [255, 255, 255, 255]; + const MISSED_RGBA: [u8; 4] = [0, 0, 0, 255]; + const UNTRACED_SENTINEL_RGBA: [u8; 4] = [255, 0, 255, 255]; - fn kernel_key(decl: &RayTracingKernelRegisterDecl) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(b"k|s="); - h.update(&(decl.stages.len() as u32).to_le_bytes()); - for s in &decl.stages { - h.update(&s.spv); - h.update(b"|"); - } - h.update(b"|g="); - h.update(&(decl.groups.len() as u32).to_le_bytes()); - h.update(b"|nb="); - h.update(&(decl.bindings.len() as u32).to_le_bytes()); - h.update(b"|pcs="); - h.update(&decl.push_constant_size.to_le_bytes()); - h.update(b"|mrd="); - h.update(&decl.max_recursion_depth.to_le_bytes()); - format!("{:x}", h.finalize()) - } - } - - impl RayTracingKernelBridge for RecordingRayTracingBridge { - fn register_blas( - &self, - decl: &BlasRegisterDecl, - ) -> std::result::Result { - if decl.vertices.is_empty() || decl.indices.is_empty() { - return Err("BLAS requires non-empty vertices + indices".into()); - } - let id = Self::blas_key(decl); - self.blases - .lock() - .unwrap() - .entry(id.clone()) - .or_insert_with(|| decl.clone()); - Ok(id) - } - - fn register_tlas( - &self, - decl: &TlasRegisterDecl, - ) -> std::result::Result { - if decl.instances.is_empty() { - return Err("TLAS must have at least one instance".into()); - } - let blases = self.blases.lock().unwrap(); - for (i, inst) in decl.instances.iter().enumerate() { - if !blases.contains_key(&inst.blas_id) { - return Err(format!( - "TLAS instance {i} references unknown blas_id '{}'", - inst.blas_id - )); - } - } - drop(blases); - let id = Self::tlas_key(decl); - self.tlases - .lock() - .unwrap() - .entry(id.clone()) - .or_insert_with(|| decl.clone()); - Ok(id) - } - - fn register_kernel( - &self, - decl: &RayTracingKernelRegisterDecl, - ) -> std::result::Result { - if decl.stages.is_empty() { - return Err("kernel requires at least one shader stage".into()); - } - if decl.groups.is_empty() { - return Err("kernel requires at least one shader group".into()); - } - let id = Self::kernel_key(decl); - self.kernels - .lock() - .unwrap() - .entry(id.clone()) - .or_insert_with(|| decl.clone()); - Ok(id) - } - - fn run_kernel( - &self, - dispatch: &RayTracingKernelRunDispatch, - ) -> std::result::Result<(), String> { - if !self - .kernels - .lock() - .unwrap() - .contains_key(&dispatch.kernel_id) - { - return Err(format!( - "kernel_id '{}' not registered with this bridge", - dispatch.kernel_id - )); - } - self.runs.lock().unwrap().push(dispatch.clone()); - Ok(()) + /// One triangle facing the launch grid, centred on the origin so a + /// trace over the whole grid both hits and misses it. + const A_SCENES_TRIANGLE_VERTICES: &[f32] = &[0.0, -0.5, 0.0, -0.5, 0.5, 0.0, 0.5, 0.5, 0.0]; + const A_SCENES_TRIANGLE_INDICES: &[u32] = &[0, 1, 2]; + + const TRACED_GRID_WIDTH: u32 = 64; + const TRACED_GRID_HEIGHT: u32 = 64; + + fn bytes_to_hex(bytes: &[u8]) -> String { + let mut hex = String::with_capacity(bytes.len() * 2); + for byte in bytes { + hex.push_str(&format!("{byte:02x}")); } + hex } - fn make_sandbox_with_bridge( - bridge: Option>, - ) -> Option { - let gpu = match GpuContext::init_for_platform_sync() { - Ok(g) => g, - Err(_) => return None, - }; - if let Some(b) = bridge { - gpu.set_ray_tracing_kernel_bridge(b); + /// Encode `[f32]` as the lowercase hex blob the wire expects. + fn vertex_hex(vertices: &[f32]) -> String { + let mut bytes = Vec::with_capacity(vertices.len() * 4); + for vertex in vertices { + bytes.extend_from_slice(&vertex.to_le_bytes()); } - Some(GpuContextLimitedAccess::new(gpu)) + bytes_to_hex(&bytes) } - // ----- BLAS register tests -------------------------------------- + /// Encode `[u32]` as the lowercase hex blob the wire expects. + fn index_hex(indices: &[u32]) -> String { + let mut bytes = Vec::with_capacity(indices.len() * 4); + for index in indices { + bytes.extend_from_slice(&index.to_le_bytes()); + } + bytes_to_hex(&bytes) + } fn make_blas_req( request_id: &str, @@ -6567,555 +7884,501 @@ void main() { ) -> EscalateRequestRegisterAccelerationStructureBlas { EscalateRequestRegisterAccelerationStructureBlas { request_id: request_id.to_string(), - label: "test-blas".to_string(), + label: "a-scenes-triangle".to_string(), vertices_hex: vertices_hex.to_string(), indices_hex: indices_hex.to_string(), } } - /// Encode `[f32]` as the lowercase hex blob the wire expects. - fn vertex_hex(vs: &[f32]) -> String { - let mut bytes = Vec::with_capacity(vs.len() * 4); - for v in vs { - bytes.extend_from_slice(&v.to_le_bytes()); + fn make_tlas_req( + request_id: &str, + blas_id: &str, + ) -> EscalateRequestRegisterAccelerationStructureTlas { + EscalateRequestRegisterAccelerationStructureTlas { + request_id: request_id.to_string(), + label: "a-scenes-instance".to_string(), + instances: vec![EscalateRequestRegisterAccelerationStructureTlasInstance { + blas_id: blas_id.to_string(), + transform: vec![ + 1.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, + ], + custom_index: 7, + mask: 0xff, + sbt_record_offset: 0, + flags: 0, + }], } - bytes_to_hex(&bytes) } - /// Encode `[u32]` as the lowercase hex blob the wire expects. - fn index_hex(is: &[u32]) -> String { - let mut bytes = Vec::with_capacity(is.len() * 4); - for i in is { - bytes.extend_from_slice(&i.to_le_bytes()); + fn general_group(stage_index: u32) -> EscalateRequestRegisterRayTracingKernelGroup { + EscalateRequestRegisterRayTracingKernelGroup { + kind: EscalateRequestRegisterRayTracingKernelGroupKind::General, + general_stage: stage_index, + closest_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, + any_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, + intersection_stage: RAY_TRACING_STAGE_INDEX_NONE, } - bytes_to_hex(&bytes) } - fn bytes_to_hex(b: &[u8]) -> String { - let mut s = String::with_capacity(b.len() * 2); - for &x in b { - s.push_str(&format!("{:02x}", x)); + fn triangles_hit_group( + closest_hit_stage_index: u32, + ) -> EscalateRequestRegisterRayTracingKernelGroup { + EscalateRequestRegisterRayTracingKernelGroup { + kind: EscalateRequestRegisterRayTracingKernelGroupKind::TrianglesHit, + general_stage: RAY_TRACING_STAGE_INDEX_NONE, + closest_hit_stage: closest_hit_stage_index, + any_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, + intersection_stage: RAY_TRACING_STAGE_INDEX_NONE, } - s } - const TRIANGLE_VERTS: &[f32] = &[ - 0.0, 0.5, 0.0, // top - -0.5, -0.5, 0.0, // bottom-left - 0.5, -0.5, 0.0, // bottom-right - ]; - const TRIANGLE_INDICES: &[u32] = &[0, 1, 2]; + fn stage_from_glsl( + stage: EscalateRequestRegisterRayTracingKernelStageStage, + source: &str, + ) -> EscalateRequestRegisterRayTracingKernelStage { + EscalateRequestRegisterRayTracingKernelStage { + entry_point: String::new(), + source: source.to_string(), + spv_hex: String::new(), + stage, + } + } - #[test] - fn register_blas_without_bridge_returns_err() { - let sandbox = match make_sandbox_with_bridge(None) { - Some(s) => s, - None => { - println!("register_blas_without_bridge_returns_err: no GPU — skipping"); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( - "req-blas-1", - &vertex_hex(TRIANGLE_VERTS), - &index_hex(TRIANGLE_INDICES), - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); + /// The three-stage kernel every registration test starts from, built + /// from GLSL — which is what the wire carries now that the engine owns + /// compilation. Tests that need a specific shape mutate fields after + /// calling. + fn register_from_glsl( + request_id: &str, + ray_gen_source: &str, + ) -> EscalateRequestRegisterRayTracingKernel { + EscalateRequestRegisterRayTracingKernel { + bindings: vec![ + EscalateRequestRegisterRayTracingKernelBinding { + kind: EscalateRayTracingBindingKind::AccelerationStructure, + name: "scene_geometry".to_string(), + stages: RayTracingShaderStageFlags::RAYGEN.bits(), + }, + EscalateRequestRegisterRayTracingKernelBinding { + kind: EscalateRayTracingBindingKind::StorageImage, + name: "traced_output".to_string(), + stages: RayTracingShaderStageFlags::RAYGEN.bits(), + }, + ], + groups: vec![general_group(0), general_group(1), triangles_hit_group(2)], + label: "a-tracing-kernel".to_string(), + max_recursion_depth: 1, + push_constant_size: 0, + push_constant_stages: 0, + request_id: request_id.to_string(), + stages: vec![ + stage_from_glsl( + EscalateRequestRegisterRayTracingKernelStageStage::RayGen, + ray_gen_source, + ), + stage_from_glsl( + EscalateRequestRegisterRayTracingKernelStageStage::Miss, + MISS_PAINTS_BLACK_GLSL, + ), + stage_from_glsl( + EscalateRequestRegisterRayTracingKernelStageStage::ClosestHit, + CLOSEST_HIT_PAINTS_WHITE_GLSL, + ), + ], + } + } + + /// Baseline `run_ray_tracing_kernel` request — the scene bound by the + /// raygen's own name for it, the storage image by its own. + fn make_run_req( + request_id: &str, + kernel_id: &str, + tlas_id: &str, + output_surface_uuid: &str, + ) -> EscalateRequestRunRayTracingKernel { + EscalateRequestRunRayTracingKernel { + bindings: vec![ + EscalateRequestRunRayTracingKernelBinding { + kind: EscalateRayTracingBindingKind::AccelerationStructure, + name: "scene_geometry".to_string(), + target_id: tlas_id.to_string(), + }, + EscalateRequestRunRayTracingKernelBinding { + kind: EscalateRayTracingBindingKind::StorageImage, + name: "traced_output".to_string(), + target_id: output_surface_uuid.to_string(), + }, + ], + depth: 1, + height: TRACED_GRID_HEIGHT, + kernel_id: kernel_id.to_string(), + push_constants_hex: String::new(), + request_id: request_id.to_string(), + width: TRACED_GRID_WIDTH, + } + } + + fn register_ray_tracing_kernel_or_panic( + sandbox: &GpuContextLimitedAccess, + registry: &EscalateHandleRegistry, + req: EscalateRequestRegisterRayTracingKernel, + ) -> EscalateResponseOk { + let response = handle_escalate_op( + sandbox, + registry, + EscalateRequest::RegisterRayTracingKernel(req), + ) + .expect("must produce a response"); match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-blas-1"); - assert!( - err.message.contains("RayTracingKernelBridge"), - "expected bridge-not-registered error, got: {}", - err.message - ); - } - other => panic!("expected Err when no bridge registered, got {other:?}"), + EscalateResponse::Ok(ok) => ok, + other => panic!("registering the ray-tracing kernel failed: {other:?}"), } } - #[test] - fn register_blas_with_invalid_vertex_hex_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "register_blas_with_invalid_vertex_hex_returns_err: no GPU — skipping" - ); - return; - } - }; - let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( - "req-bad-v", - "xyz123", - &index_hex(TRIANGLE_INDICES), - )); + fn register_acceleration_structure_or_panic( + sandbox: &GpuContextLimitedAccess, + registry: &EscalateHandleRegistry, + req: EscalateRequest, + ) -> String { let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); + handle_escalate_op(sandbox, registry, req).expect("must produce a response"); match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-v"); - assert!(err.message.contains("vertices_hex"), "got: {}", err.message); - } - other => panic!("expected Err for bad vertices_hex, got {other:?}"), + EscalateResponse::Ok(ok) => ok.handle_id, + other => panic!("registering the acceleration structure failed: {other:?}"), } - assert_eq!(bridge.blas_count(), 0); } - #[test] - fn register_blas_with_misaligned_vertex_blob_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "register_blas_with_misaligned_vertex_blob_returns_err: no GPU — skipping" - ); - return; - } - }; + /// One registered kernel over one registered scene, writing into one + /// registered storage image seeded with [`UNTRACED_SENTINEL_RGBA`]. + /// + /// The pooled handle is held for the caller's lifetime: dropping it + /// hands the slot back, and the registration would then name a recycled + /// texture. + struct ARayTracedSceneUnderTest { + sandbox: GpuContextLimitedAccess, + registry: std::sync::Arc, + kernel_id: String, + blas_id: String, + tlas_id: String, + _held_output: PooledTextureHandle, + } + + const A_TRACED_SCENES_OUTPUT_SURFACE_UUID: &str = "traced-output-surface"; + + fn make_ray_traced_scene_if_available() -> Option { + let sandbox = make_ray_tracing_sandbox_if_available()?; let registry = EscalateHandleRegistry::new(); - // 11 bytes (not a multiple of 12 — should be rejected before the - // bridge is even called). - let req = EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( - "req-misaligned-v", - &"00".repeat(11), - &index_hex(TRIANGLE_INDICES), - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-misaligned-v"); - assert!( - err.message.contains("multiple of 12"), - "got: {}", - err.message + let blas_id = register_acceleration_structure_or_panic( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( + "scene-blas", + &vertex_hex(A_SCENES_TRIANGLE_VERTICES), + &index_hex(A_SCENES_TRIANGLE_INDICES), + )), + ); + let tlas_id = register_acceleration_structure_or_panic( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureTlas(make_tlas_req( + "scene-tlas", + &blas_id, + )), + ); + let kernel_id = register_ray_tracing_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("scene-kernel", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL), + ) + .handle_id; + + let held_output = sandbox + .escalate(|full| { + let output = full.acquire_texture( + &TexturePoolDescriptor::new( + TRACED_GRID_WIDTH, + TRACED_GRID_HEIGHT, + TextureFormat::Rgba8Unorm, + ) + .with_usage( + TextureUsages::STORAGE_BINDING + | TextureUsages::COPY_DST + | TextureUsages::COPY_SRC, + ), + )?; + full.register_texture( + A_TRACED_SCENES_OUTPUT_SURFACE_UUID, + output.texture().clone(), ); - } - other => panic!("expected Err for misaligned vertex blob, got {other:?}"), + + let (_pool_id, seed_buffer) = full.acquire_pixel_buffer( + TRACED_GRID_WIDTH, + TRACED_GRID_HEIGHT, + PixelFormat::Rgba32, + )?; + let plane = seed_buffer.buffer_ref().plane_base_address(0); + unsafe { + for pixel in 0..(TRACED_GRID_WIDTH as usize * TRACED_GRID_HEIGHT as usize) { + std::ptr::copy_nonoverlapping( + UNTRACED_SENTINEL_RGBA.as_ptr(), + plane.add(pixel * 4), + 4, + ); + } + } + full.copy_pixel_buffer_to_texture( + &seed_buffer, + output.texture(), + A_TRACED_SCENES_OUTPUT_SURFACE_UUID, + TRACED_GRID_WIDTH, + TRACED_GRID_HEIGHT, + )?; + Ok(output) + }) + .expect("a seeded storage image to trace into"); + + Some(ARayTracedSceneUnderTest { + sandbox, + registry, + kernel_id, + blas_id, + tlas_id, + _held_output: held_output, + }) + } + + impl ARayTracedSceneUnderTest { + fn run_req(&self, request_id: &str) -> EscalateRequestRunRayTracingKernel { + make_run_req( + request_id, + &self.kernel_id, + &self.tlas_id, + A_TRACED_SCENES_OUTPUT_SURFACE_UUID, + ) + } + + fn trace(&self, req: EscalateRequestRunRayTracingKernel) -> EscalateResponse { + handle_escalate_op( + &self.sandbox, + &self.registry, + EscalateRequest::RunRayTracingKernel(req), + ) + .expect("must produce a response") } - assert_eq!(bridge.blas_count(), 0); } + // ----- wire validation, before any device ----------------------- + + /// Both blobs are decoded before the escalate hop, so a malformed one + /// is refused without touching the GPU at all. #[test] - fn register_blas_with_misaligned_index_blob_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "register_blas_with_misaligned_index_blob_returns_err: no GPU — skipping" - ); - return; - } + fn register_blas_with_invalid_vertex_hex_returns_err() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_blas_with_invalid_vertex_hex: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - // 8 bytes (not a multiple of 12 — should be rejected). - let req = EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( - "req-misaligned-i", - &vertex_hex(TRIANGLE_VERTS), - &"00".repeat(8), - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-misaligned-i"); - assert!( - err.message.contains("multiple of 12"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for misaligned index blob, got {other:?}"), - } - assert_eq!(bridge.blas_count(), 0); + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( + "blas-bad-vertices", + "xyz123", + &index_hex(A_SCENES_TRIANGLE_INDICES), + )), + ) + .expect("must produce a response"), + ); + assert!(message.contains("vertices_hex"), "got: {message}"); } #[test] - fn register_blas_succeeds_and_caches() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("register_blas_succeeds_and_caches: no GPU — skipping"); - return; - } + fn register_blas_with_invalid_index_hex_returns_err() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_blas_with_invalid_index_hex: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - let req1 = EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( - "req-blas-a", - &vertex_hex(TRIANGLE_VERTS), - &index_hex(TRIANGLE_INDICES), - )); - let resp1 = - handle_escalate_op(&sandbox, ®istry, req1).expect("must produce a response"); - let id1 = match resp1 { - EscalateResponse::Ok(ok) => { - assert_eq!(ok.request_id, "req-blas-a"); - ok.handle_id - } - other => panic!("expected Ok, got {other:?}"), - }; - // Re-register identical descriptor — bridge cache hit, same id. - let req2 = EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( - "req-blas-b", - &vertex_hex(TRIANGLE_VERTS), - &index_hex(TRIANGLE_INDICES), - )); - let resp2 = - handle_escalate_op(&sandbox, ®istry, req2).expect("must produce a response"); - let id2 = match resp2 { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("expected Ok on re-register, got {other:?}"), - }; - assert_eq!(id1, id2, "identical BLAS descriptors must collide on as_id"); - assert_eq!(bridge.blas_count(), 1, "cache must coalesce identical BLAS"); - } - - // ----- TLAS register tests -------------------------------------- - - fn make_tlas_req( - request_id: &str, - blas_id: &str, - ) -> EscalateRequestRegisterAccelerationStructureTlas { - EscalateRequestRegisterAccelerationStructureTlas { - request_id: request_id.to_string(), - label: "test-tlas".to_string(), - instances: vec![EscalateRequestRegisterAccelerationStructureTlasInstance { - blas_id: blas_id.to_string(), - transform: vec![ - 1.0, 0.0, 0.0, 0.0, // row 0 - 0.0, 1.0, 0.0, 0.0, // row 1 - 0.0, 0.0, 1.0, 0.0, // row 2 - ], - custom_index: 7, - mask: 0xff, - sbt_record_offset: 0, - flags: 0, - }], - } + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( + "blas-bad-indices", + &vertex_hex(A_SCENES_TRIANGLE_VERTICES), + "xyz123", + )), + ) + .expect("must produce a response"), + ); + assert!(message.contains("indices_hex"), "got: {message}"); } + /// A blob that is not a whole number of vertices — or of triangles — + /// names geometry that does not exist, and is refused before a build. #[test] - fn register_tlas_with_wrong_transform_length_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "register_tlas_with_wrong_transform_length_returns_err: no GPU — skipping" - ); - return; - } + fn register_blas_with_a_partial_vertex_or_triangle_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_blas_with_a_partial_vertex_or_triangle: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - let mut req = make_tlas_req("req-bad-tx", "blas-x"); - req.instances[0].transform = vec![1.0; 11]; // wrong length - let response = handle_escalate_op( - &sandbox, - ®istry, - EscalateRequest::RegisterAccelerationStructureTlas(req), - ) - .expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-tx"); - assert!(err.message.contains("transform"), "got: {}", err.message); - } - other => panic!("expected Err for wrong-length transform, got {other:?}"), + for (request_id, vertices_hex, indices_hex) in [ + ( + "blas-partial-vertex", + "00".repeat(11), + index_hex(A_SCENES_TRIANGLE_INDICES), + ), + ( + "blas-partial-triangle", + vertex_hex(A_SCENES_TRIANGLE_VERTICES), + "00".repeat(8), + ), + ] { + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( + request_id, + &vertices_hex, + &indices_hex, + )), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("multiple of 12"), + "{request_id} got: {message}" + ); } - assert_eq!(bridge.tlas_count(), 0); } #[test] - fn register_tlas_with_oversized_mask_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("register_tlas_with_oversized_mask_returns_err: no GPU — skipping"); - return; - } + fn register_tlas_with_no_instances_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_tlas_with_no_instances: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - let mut req = make_tlas_req("req-bad-mask", "blas-x"); - req.instances[0].mask = 0xfff; // > 0xff, should be rejected - let response = handle_escalate_op( - &sandbox, - ®istry, - EscalateRequest::RegisterAccelerationStructureTlas(req), - ) - .expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-mask"); - assert!(err.message.contains("mask"), "got: {}", err.message); - } - other => panic!("expected Err for oversized mask, got {other:?}"), - } - assert_eq!(bridge.tlas_count(), 0); + let mut req = make_tlas_req("tlas-empty", "unused"); + req.instances.clear(); + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureTlas(req), + ) + .expect("must produce a response"), + ); + assert!(message.contains("at least one instance"), "got: {message}"); } #[test] - fn register_tlas_succeeds_after_blas() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("register_tlas_succeeds_after_blas: no GPU — skipping"); - return; - } + fn register_tlas_with_a_transform_that_is_not_a_row_major_3x4_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_tlas_with_a_wrong_length_transform: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - // 1. Register a BLAS first to obtain a real as_id. - let blas_req = EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( - "req-blas", - &vertex_hex(TRIANGLE_VERTS), - &index_hex(TRIANGLE_INDICES), - )); - let blas_resp = - handle_escalate_op(&sandbox, ®istry, blas_req).expect("must produce a response"); - let blas_id = match blas_resp { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("expected Ok for BLAS register, got {other:?}"), - }; - // 2. Now register a TLAS pointing at it. - let tlas_req = EscalateRequest::RegisterAccelerationStructureTlas(make_tlas_req( - "req-tlas", &blas_id, - )); - let tlas_resp = - handle_escalate_op(&sandbox, ®istry, tlas_req).expect("must produce a response"); - let tlas_id = match tlas_resp { - EscalateResponse::Ok(ok) => { - assert_eq!(ok.request_id, "req-tlas"); - ok.handle_id - } - other => panic!("expected Ok for TLAS register, got {other:?}"), - }; - assert!(!tlas_id.is_empty(), "TLAS id must be non-empty"); - // Verify the bridge actually saw the right shape. - let tlas_decl = bridge - .last_tlas() - .expect("bridge must have stored the TLAS decl"); - assert_eq!(tlas_decl.instances.len(), 1); - assert_eq!(tlas_decl.instances[0].blas_id, blas_id); - assert_eq!(tlas_decl.instances[0].custom_index, 7); - assert_eq!(tlas_decl.instances[0].mask, 0xff); - assert_eq!( - tlas_decl.instances[0].transform, - [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - ] + let mut req = make_tlas_req("tlas-bad-transform", "unused"); + req.instances[0].transform = vec![1.0; 11]; + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureTlas(req), + ) + .expect("must produce a response"), ); + assert!(message.contains("transform"), "got: {message}"); } #[test] - fn register_tlas_with_unknown_blas_id_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("register_tlas_with_unknown_blas_id_returns_err: no GPU — skipping"); - return; - } + fn register_tlas_with_a_mask_wider_than_eight_bits_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_tlas_with_an_oversized_mask: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RegisterAccelerationStructureTlas(make_tlas_req( - "req-tlas-bad", - "definitely-not-a-real-blas-id", - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-tlas-bad"); - assert!( - err.message.contains("unknown blas_id"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for unknown blas_id, got {other:?}"), - } - assert_eq!(bridge.tlas_count(), 0); - } - - // ----- Kernel register + run tests ------------------------------ - - fn make_kernel_req(request_id: &str) -> EscalateRequestRegisterRayTracingKernel { - EscalateRequestRegisterRayTracingKernel { - request_id: request_id.to_string(), - label: "test-rt-kernel".to_string(), - stages: vec![ - EscalateRequestRegisterRayTracingKernelStage { - source: "".to_string(), - stage: EscalateRequestRegisterRayTracingKernelStageStage::RayGen, - spv_hex: "deadbeef".to_string(), - entry_point: "main".to_string(), - }, - EscalateRequestRegisterRayTracingKernelStage { - source: "".to_string(), - stage: EscalateRequestRegisterRayTracingKernelStageStage::Miss, - spv_hex: "cafebabe".to_string(), - entry_point: "main".to_string(), - }, - EscalateRequestRegisterRayTracingKernelStage { - source: "".to_string(), - stage: EscalateRequestRegisterRayTracingKernelStageStage::ClosestHit, - spv_hex: "facefeed".to_string(), - entry_point: "main".to_string(), - }, - ], - groups: vec![ - EscalateRequestRegisterRayTracingKernelGroup { - kind: EscalateRequestRegisterRayTracingKernelGroupKind::General, - general_stage: 0, - closest_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, - any_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, - intersection_stage: RAY_TRACING_STAGE_INDEX_NONE, - }, - EscalateRequestRegisterRayTracingKernelGroup { - kind: EscalateRequestRegisterRayTracingKernelGroupKind::General, - general_stage: 1, - closest_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, - any_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, - intersection_stage: RAY_TRACING_STAGE_INDEX_NONE, - }, - EscalateRequestRegisterRayTracingKernelGroup { - kind: EscalateRequestRegisterRayTracingKernelGroupKind::TrianglesHit, - general_stage: RAY_TRACING_STAGE_INDEX_NONE, - closest_hit_stage: 2, - any_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, - intersection_stage: RAY_TRACING_STAGE_INDEX_NONE, - }, - ], - bindings: vec![ - EscalateRequestRegisterRayTracingKernelBinding { - binding: 0, - kind: EscalateRequestRegisterRayTracingKernelBindingKind::AccelerationStructure, - stages: 1, // RAYGEN - }, - EscalateRequestRegisterRayTracingKernelBinding { - binding: 1, - kind: EscalateRequestRegisterRayTracingKernelBindingKind::StorageImage, - stages: 1, // RAYGEN - }, - ], - push_constant_size: 16, - push_constant_stages: 1, // RAYGEN - max_recursion_depth: 1, - } - } - - fn make_run_req(request_id: &str, kernel_id: &str) -> EscalateRequestRunRayTracingKernel { - EscalateRequestRunRayTracingKernel { - request_id: request_id.to_string(), - kernel_id: kernel_id.to_string(), - bindings: vec![ - EscalateRequestRunRayTracingKernelBinding { - binding: 0, - kind: EscalateRequestRunRayTracingKernelBindingKind::AccelerationStructure, - target_id: "test-tlas-uuid".to_string(), - }, - EscalateRequestRunRayTracingKernelBinding { - binding: 1, - kind: EscalateRequestRunRayTracingKernelBindingKind::StorageImage, - target_id: "test-storage-uuid".to_string(), - }, - ], - push_constants_hex: "00".repeat(16), - width: 1280, - height: 720, - depth: 1, - } + let mut req = make_tlas_req("tlas-bad-mask", "unused"); + req.instances[0].mask = 0xfff; + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureTlas(req), + ) + .expect("must produce a response"), + ); + assert!(message.contains("mask"), "got: {message}"); } #[test] - fn register_kernel_without_bridge_returns_err() { - let sandbox = match make_sandbox_with_bridge(None) { - Some(s) => s, - None => { - println!("register_kernel_without_bridge_returns_err: no GPU — skipping"); - return; - } + fn run_with_invalid_push_constants_hex_returns_err() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("run_with_invalid_push_constants_hex: no GPU — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RegisterRayTracingKernel(make_kernel_req("req-k-1")); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-k-1"); - assert!( - err.message.contains("RayTracingKernelBridge"), - "expected bridge-not-registered error, got: {}", - err.message - ); - } - other => panic!("expected Err when no bridge registered, got {other:?}"), - } + let mut req = make_run_req("trace-bad-push", "kernel-x", "tlas-x", "surface-x"); + req.push_constants_hex = "qq".to_string(); + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RunRayTracingKernel(req), + ) + .expect("must produce a response"), + ); + assert!(message.contains("push_constants_hex"), "got: {message}"); } + /// A stage mask is a bitfield the caller writes by hand, and a bit + /// outside the six ray-tracing stages names a stage no pipeline has a + /// module for at all. #[test] - fn register_kernel_with_invalid_stage_hex_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "register_kernel_with_invalid_stage_hex_returns_err: no GPU — skipping" - ); - return; - } + fn a_stage_mask_naming_a_bit_no_ray_tracing_stage_owns_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_stage_mask_naming_an_unowned_bit: no GPU — skipping"); + return; }; - let registry = EscalateHandleRegistry::new(); - let mut req = make_kernel_req("req-bad-stage"); - req.stages[1].spv_hex = "qq".to_string(); - let response = handle_escalate_op( - &sandbox, - ®istry, - EscalateRequest::RegisterRayTracingKernel(req), - ) - .expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-stage"); - assert!( - err.message.contains("stages[1].spv_hex"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for bad stage SPIR-V hex, got {other:?}"), - } - assert_eq!(bridge.kernel_count(), 0); + let bit_no_stage_owns = RayTracingShaderStageFlags::ALL.bits() + 1; + + let mut declaration = register_from_glsl("k", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL); + declaration.bindings[0].stages = bit_no_stage_owns; + let message = prepare_ray_tracing_kernel_registration(&sandbox, declaration) + .err() + .expect("a binding declared for a stage no pipeline has must be refused"); + assert!( + message.contains("scene_geometry") && message.contains("no ray-tracing stage owns"), + "must name the binding and why the mask is wrong, got: {message}" + ); + + let mut push_constants = register_from_glsl("k", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL); + push_constants.push_constant_stages = bit_no_stage_owns; + let message = prepare_ray_tracing_kernel_registration(&sandbox, push_constants) + .err() + .expect("a push-constant range declared for the same stage must be refused"); + assert!( + message.contains("push_constant_stages"), + "must name the field, got: {message}" + ); } + /// A procedural hit group without an intersection stage is a group with + /// nothing to intersect, and the sentinel is what "absent" looks like on + /// a wire where the field is always present. #[test] - fn register_kernel_with_procedural_missing_intersection_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "register_kernel_with_procedural_missing_intersection_returns_err: \ - no GPU — skipping" - ); - return; - } + fn a_procedural_group_leaving_intersection_at_the_sentinel_is_refused() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("a_procedural_group_without_an_intersection: no GPU — skipping"); + return; }; - let registry = EscalateHandleRegistry::new(); - let mut req = make_kernel_req("req-bad-proc"); - // Replace the third group with a procedural_hit that lacks - // an intersection stage (sentinel-encoded "absent"). + let mut req = register_from_glsl("k-proc", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL); req.groups[2] = EscalateRequestRegisterRayTracingKernelGroup { kind: EscalateRequestRegisterRayTracingKernelGroupKind::ProceduralHit, general_stage: RAY_TRACING_STAGE_INDEX_NONE, @@ -7123,293 +8386,604 @@ void main() { any_hit_stage: RAY_TRACING_STAGE_INDEX_NONE, intersection_stage: RAY_TRACING_STAGE_INDEX_NONE, }; - let response = handle_escalate_op( - &sandbox, - ®istry, - EscalateRequest::RegisterRayTracingKernel(req), - ) - .expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-proc"); - assert!( - err.message.contains("procedural_hit"), - "got: {}", - err.message - ); - } - other => panic!( - "expected Err for procedural_hit missing intersection_stage, got {other:?}" - ), - } - assert_eq!(bridge.kernel_count(), 0); + let message = prepare_ray_tracing_kernel_registration(&sandbox, req) + .err() + .expect("a procedural group with no intersection stage must be refused"); + assert!(message.contains("procedural_hit"), "got: {message}"); + } + + /// A stage's hex is decoded before the escalate hop, and the refusal + /// names the stage it came from rather than just "the kernel". + #[test] + fn register_with_invalid_stage_hex_returns_err() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("register_with_invalid_stage_hex: no GPU — skipping"); + return; + }; + let registry = EscalateHandleRegistry::new(); + let mut req = register_from_glsl("k-bad-hex", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL); + req.stages[1].source = String::new(); + req.stages[1].spv_hex = "qq".to_string(); + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterRayTracingKernel(req), + ) + .expect("must produce a response"), + ); + assert!(message.contains("stages[1].spv_hex"), "got: {message}"); } - /// Every ray-tracing stage the wire can name maps to the pipeline stage - /// the compiler builds for. `ray_tracing_pipeline_stage_from_wire` is a - /// fresh six-arm mapping, and a swapped pair would compile a miss - /// shader as a closest-hit without complaint — so each arm is driven - /// through the handler with source only that stage can compile. + /// Every ray-tracing stage the wire can name compiles for the stage it + /// names, and reaches the kernel classified as that stage. + /// + /// Two separate six-arm mappings run per stage — + /// `ray_tracing_pipeline_stage_from_wire` picks what the compiler + /// targets and `ray_tracing_stage_from_wire` picks what the shader + /// group is built from — so a swapped pair in either would build a miss + /// shader as a closest-hit without complaint. Each body below is legal + /// only in its own stage: `rayPayloadEXT` is raygen-only, + /// `rayPayloadInEXT` is miss/hit-only, `reportIntersectionEXT` is + /// intersection-only and `callableDataInEXT` is callable-only, so a + /// mis-mapped compile target fails to compile rather than quietly + /// producing the wrong module. #[test] - fn every_ray_tracing_wire_stage_compiles_glsl_for_the_stage_it_names() { - let bridge = RecordingRayTracingBridge::new(); - let Some(sandbox) = make_sandbox_with_bridge(Some(bridge.clone())) else { - println!("ray-tracing GLSL stage mapping: no GPU — skipping"); + fn every_ray_tracing_wire_stage_compiles_for_the_stage_it_names() { + let Some(sandbox) = make_gpu_sandbox_if_available() else { + println!("every_ray_tracing_wire_stage_compiles: no GPU — skipping"); return; }; - let registry = EscalateHandleRegistry::new(); - // Each body is legal only in its own stage: `rayPayloadEXT` is - // raygen-only, `rayPayloadInEXT` is miss/hit-only, and - // `reportIntersectionEXT` is intersection-only. A mis-mapped arm - // fails to compile rather than quietly producing the wrong module. let stages = [ ( EscalateRequestRegisterRayTracingKernelStageStage::RayGen, - "layout(location = 0) rayPayloadEXT vec3 p;\nvoid main() { p = vec3(1.0); }", + RayTracingShaderStage::RayGen, + "layout(location = 0) rayPayloadEXT vec3 payload;\nvoid main() { payload = vec3(1.0); }", ), ( EscalateRequestRegisterRayTracingKernelStageStage::Miss, - "layout(location = 0) rayPayloadInEXT vec3 p;\nvoid main() { p = vec3(0.0); }", + RayTracingShaderStage::Miss, + "layout(location = 0) rayPayloadInEXT vec3 payload;\nvoid main() { payload = vec3(0.0); }", ), ( EscalateRequestRegisterRayTracingKernelStageStage::ClosestHit, - "layout(location = 0) rayPayloadInEXT vec3 p;\nvoid main() { p = vec3(0.5); }", + RayTracingShaderStage::ClosestHit, + "layout(location = 0) rayPayloadInEXT vec3 payload;\nvoid main() { payload = vec3(0.5); }", ), ( EscalateRequestRegisterRayTracingKernelStageStage::AnyHit, - "layout(location = 0) rayPayloadInEXT vec3 p;\nvoid main() { ignoreIntersectionEXT; }", + RayTracingShaderStage::AnyHit, + "layout(location = 0) rayPayloadInEXT vec3 payload;\nvoid main() { ignoreIntersectionEXT; }", ), ( EscalateRequestRegisterRayTracingKernelStageStage::Intersection, - "hitAttributeEXT vec2 a;\nvoid main() { reportIntersectionEXT(1.0, 0u); }", + RayTracingShaderStage::Intersection, + "hitAttributeEXT vec2 barycentric;\nvoid main() { reportIntersectionEXT(1.0, 0u); }", ), ( EscalateRequestRegisterRayTracingKernelStageStage::Callable, - "layout(location = 0) callableDataInEXT vec3 c;\nvoid main() { c = vec3(1.0); }", + RayTracingShaderStage::Callable, + "layout(location = 0) callableDataInEXT vec3 callable_payload;\nvoid main() { callable_payload = vec3(1.0); }", ), ]; - for (index, (wire_stage, body)) in stages.into_iter().enumerate() { - let mut req = make_kernel_req(&format!("rt-glsl-{index}")); - req.stages.truncate(1); - req.stages[0].stage = wire_stage; - req.stages[0].spv_hex = String::new(); - req.stages[0].source = - format!("#version 460\n#extension GL_EXT_ray_tracing : require\n{body}\n"); - let response = handle_escalate_op( - &sandbox, - ®istry, - EscalateRequest::RegisterRayTracingKernel(req), - ) - .expect("must produce a response"); - let kernel_id = match response { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("{wire_stage:?} was refused: {other:?}"), - }; - let kernels = bridge.kernels.lock().unwrap(); - let decl = kernels.get(&kernel_id).expect("the bridge saw the kernel"); + for (index, (wire_stage, expected_stage, body)) in stages.into_iter().enumerate() { + let mut req = register_from_glsl( + &format!("rt-glsl-{index}"), + TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL, + ); + req.bindings = Vec::new(); + req.groups = vec![general_group(0)]; + req.stages = vec![stage_from_glsl( + wire_stage, + &format!("#version 460\n#extension GL_EXT_ray_tracing : require\n{body}\n"), + )]; + let prepared = prepare_ray_tracing_kernel_registration(&sandbox, req) + .unwrap_or_else(|e| panic!("{wire_stage:?} did not compile as itself: {e}")); assert_eq!( - decl.stages[0].spv.get(..4), + prepared.stages[0].spirv.get(..4), Some(&SPIRV_MAGIC_LE[..]), - "{wire_stage:?} reached the bridge as something other than SPIR-V" + "{wire_stage:?} reached the kernel as something other than SPIR-V" ); assert_eq!( - decl.stages[0].stage, - ray_tracing_stage_from_wire(wire_stage) + prepared.stages[0].stage, expected_stage, + "{wire_stage:?} was classified as the wrong pipeline stage" ); } } + // ----- registration, over a ray-tracing device ------------------ + + /// Registration hands back the shape a trace needs: the shaders' own + /// names, each with the kind only the shaders know. #[test] - fn register_kernel_succeeds_and_caches() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("register_kernel_succeeds_and_caches: no GPU — skipping"); - return; - } + fn registration_answers_with_the_shaders_binding_names_and_kinds() { + let Some(sandbox) = make_ray_tracing_sandbox_if_available() else { + println!("registration_answers_with_the_shaders_bindings: no RT device — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - let req1 = EscalateRequest::RegisterRayTracingKernel(make_kernel_req("req-k-a")); - let resp1 = - handle_escalate_op(&sandbox, ®istry, req1).expect("must produce a response"); - let id1 = match resp1 { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("expected Ok, got {other:?}"), + let ok = register_ray_tracing_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("reg", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL), + ); + let bindings = ok.bindings.expect("a register response carries the shape"); + assert_eq!( + bindings + .iter() + .map(|binding| (binding.name.as_str(), binding.kind.as_str())) + .collect::>(), + vec![ + ("scene_geometry", "acceleration_structure"), + ("traced_output", "storage_image"), + ], + "the raygen's own bindings, named and kinded as it declares them" + ); + } + + /// Re-registering an identical kernel is free and keeps its id; a + /// different raygen stage is a different pipeline and gets its own. + #[test] + fn an_identical_registration_keeps_its_kernel_id_and_a_different_one_gets_its_own() { + let Some(sandbox) = make_ray_tracing_sandbox_if_available() else { + println!("an_identical_registration_keeps_its_kernel_id: no RT device — skipping"); + return; }; - let req2 = EscalateRequest::RegisterRayTracingKernel(make_kernel_req("req-k-b")); - let resp2 = - handle_escalate_op(&sandbox, ®istry, req2).expect("must produce a response"); - let id2 = match resp2 { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("expected Ok, got {other:?}"), + let registry = EscalateHandleRegistry::new(); + let first = register_ray_tracing_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("a", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL), + ) + .handle_id; + let second = register_ray_tracing_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("b", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL), + ) + .handle_id; + assert_eq!( + first, second, + "an identical descriptor must produce the same kernel_id" + ); + + let other = register_ray_tracing_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("c", TRACE_AND_INVERT_RAY_GEN_GLSL), + ) + .handle_id; + assert_ne!( + first, other, + "a different raygen stage must produce a different kernel_id" + ); + + let held = sandbox + .escalate(|full| { + Ok(( + full.ray_tracing_kernel_by_id(&first), + full.ray_tracing_kernel_by_id(&second), + )) + }) + .expect("the cache answers inside an escalate scope"); + let (a, b) = (held.0.expect("cached"), held.1.expect("cached")); + assert!( + std::sync::Arc::ptr_eq(&a, &b), + "the second registration must reuse the first kernel, not build another" + ); + } + + /// The cache key covers the shaders and the pipeline, not the caller's + /// assertion — so a wrong declaration refuses identically whether or + /// not somebody registered this kernel first. + #[test] + fn a_wrong_declaration_is_refused_even_when_the_kernel_is_cached() { + let Some(sandbox) = make_ray_tracing_sandbox_if_available() else { + println!("a_wrong_declaration_is_refused_when_cached: no RT device — skipping"); + return; }; - assert_eq!(id1, id2, "identical kernel descriptors must collide on id"); - assert_eq!(bridge.kernel_count(), 1); + let registry = EscalateHandleRegistry::new(); + register_ray_tracing_kernel_or_panic( + &sandbox, + ®istry, + register_from_glsl("warm", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL), + ); - // Verify the bridge stored what we sent — sanity check on the - // wire→domain conversion. - let stored = bridge.last_kernel().expect("must have a stored decl"); - assert_eq!(stored.stages.len(), 3); - assert_eq!(stored.groups.len(), 3); - assert_eq!(stored.bindings.len(), 2); - assert_eq!(stored.push_constant_size, 16); - assert_eq!(stored.max_recursion_depth, 1); + let mut req = register_from_glsl("reg-wrong", TRACE_ONE_RAY_PER_PIXEL_RAY_GEN_GLSL); + req.bindings = vec![EscalateRequestRegisterRayTracingKernelBinding { + kind: EscalateRayTracingBindingKind::StorageBuffer, + name: "scene_parameters".to_string(), + stages: 0, + }]; + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterRayTracingKernel(req), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("`scene_parameters`") && message.contains("`scene_geometry`"), + "the refusal must name the bogus binding and the shaders' own: {message}" + ); } + // ----- acceleration structures, over a ray-tracing device -------- + + /// Unlike a kernel, a structure holds device memory proportional to its + /// mesh — so every registration mints its own id rather than colliding + /// on content, and a TLAS is a different structure from its BLAS. #[test] - fn run_kernel_without_bridge_returns_err() { - let sandbox = match make_sandbox_with_bridge(None) { - Some(s) => s, - None => { - println!("run_kernel_without_bridge_returns_err: no GPU — skipping"); - return; - } + fn every_acceleration_structure_registration_gets_its_own_id() { + let Some(sandbox) = make_ray_tracing_sandbox_if_available() else { + println!("every_acceleration_structure_registration: no RT device — skipping"); + return; }; let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RunRayTracingKernel(make_run_req("req-run-1", "kernel-x")); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-run-1"); - assert!( - err.message.contains("RayTracingKernelBridge"), - "expected bridge-not-registered error, got: {}", - err.message - ); - } - other => panic!("expected Err when no bridge registered, got {other:?}"), - } + let first_blas = register_acceleration_structure_or_panic( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( + "blas-a", + &vertex_hex(A_SCENES_TRIANGLE_VERTICES), + &index_hex(A_SCENES_TRIANGLE_INDICES), + )), + ); + let second_blas = register_acceleration_structure_or_panic( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( + "blas-b", + &vertex_hex(A_SCENES_TRIANGLE_VERTICES), + &index_hex(A_SCENES_TRIANGLE_INDICES), + )), + ); + assert_ne!( + first_blas, second_blas, + "an identical mesh registered twice is two structures, not one" + ); + + let tlas = register_acceleration_structure_or_panic( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureTlas(make_tlas_req( + "tlas-a", + &first_blas, + )), + ); + assert_ne!(tlas, first_blas); + assert_ne!(tlas, second_blas); } + /// A structure is the one escalate-minted resource whose device memory + /// is proportional to what the caller supplied, so a long-running helper + /// has to be able to hand it back — the same `release_handle` a surface + /// is handed back through, since nothing else would reach the registry. #[test] - fn run_kernel_with_invalid_push_constants_hex_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!( - "run_kernel_with_invalid_push_constants_hex_returns_err: no GPU — skipping" - ); - return; - } + fn a_registered_acceleration_structure_is_released_through_release_handle() { + let Some(sandbox) = make_ray_tracing_sandbox_if_available() else { + println!( + "a_registered_acceleration_structure_is_released: no RT device — skipping" + ); + return; }; let registry = EscalateHandleRegistry::new(); - let mut req = make_run_req("req-bad-push", "kernel-x"); - req.push_constants_hex = "qq".to_string(); - let response = handle_escalate_op( + let blas = register_acceleration_structure_or_panic( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureBlas(make_blas_req( + "blas-to-release", + &vertex_hex(A_SCENES_TRIANGLE_VERTICES), + &index_hex(A_SCENES_TRIANGLE_INDICES), + )), + ); + + let released = handle_escalate_op( &sandbox, ®istry, - EscalateRequest::RunRayTracingKernel(req), + EscalateRequest::ReleaseHandle(EscalateRequestReleaseHandle { + request_id: "release-blas".to_string(), + handle_id: blas.clone(), + }), ) .expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-bad-push"); - assert!( - err.message.contains("push_constants_hex"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for malformed push hex, got {other:?}"), - } - assert!(bridge.runs().is_empty()); + assert!( + matches!(released, EscalateResponse::Ok(_)), + "releasing a registered structure must succeed: {released:?}" + ); + + // The id is gone, not merely unreferenced: a second release finds + // nothing, and a trace naming it would too. + let released_twice = handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::ReleaseHandle(EscalateRequestReleaseHandle { + request_id: "release-blas-again".to_string(), + handle_id: blas, + }), + ) + .expect("must produce a response"); + let message = refusal_message(released_twice); + assert!(message.contains("not found in registry"), "{message}"); } #[test] - fn run_kernel_with_unknown_kernel_id_returns_err() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("run_kernel_with_unknown_kernel_id_returns_err: no GPU — skipping"); - return; - } + fn a_tlas_instance_naming_an_unregistered_structure_is_refused() { + let Some(sandbox) = make_ray_tracing_sandbox_if_available() else { + println!( + "a_tlas_instance_naming_an_unregistered_structure: no RT device — skipping" + ); + return; }; let registry = EscalateHandleRegistry::new(); - let req = EscalateRequest::RunRayTracingKernel(make_run_req( - "req-run-x", - "definitely-not-a-real-kernel-id", - )); - let response = - handle_escalate_op(&sandbox, ®istry, req).expect("must produce a response"); - match response { - EscalateResponse::Err(err) => { - assert_eq!(err.request_id, "req-run-x"); - assert!( - err.message.contains("not registered"), - "got: {}", - err.message - ); - } - other => panic!("expected Err for unknown kernel_id, got {other:?}"), - } - assert!(bridge.runs().is_empty()); + let message = refusal_message( + handle_escalate_op( + &sandbox, + ®istry, + EscalateRequest::RegisterAccelerationStructureTlas(make_tlas_req( + "tlas-unknown", + "definitely-not-a-registered-structure", + )), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("names no acceleration structure registered under id"), + "got: {message}" + ); } + /// A TLAS instance references a bottom-level structure. Naming a + /// top-level one is a caller mistake the registry can catch, and every + /// id looks alike from the outside. #[test] - fn run_kernel_succeeds_after_register() { - let bridge = RecordingRayTracingBridge::new(); - let sandbox = match make_sandbox_with_bridge(Some(bridge.clone())) { - Some(s) => s, - None => { - println!("run_kernel_succeeds_after_register: no GPU — skipping"); - return; - } + fn a_tlas_instance_naming_a_top_level_structure_is_refused() { + let Some(scene) = make_ray_traced_scene_if_available() else { + println!("a_tlas_instance_naming_a_top_level_structure: no RT device — skipping"); + return; }; - let registry = EscalateHandleRegistry::new(); - // 1. Register the kernel. - let kernel_req = EscalateRequest::RegisterRayTracingKernel(make_kernel_req("req-k")); - let kernel_resp = handle_escalate_op(&sandbox, ®istry, kernel_req) - .expect("must produce a response"); - let kernel_id = match kernel_resp { - EscalateResponse::Ok(ok) => ok.handle_id, - other => panic!("expected Ok for kernel register, got {other:?}"), + let message = refusal_message( + handle_escalate_op( + &scene.sandbox, + &scene.registry, + EscalateRequest::RegisterAccelerationStructureTlas(make_tlas_req( + "tlas-over-tlas", + &scene.tlas_id, + )), + ) + .expect("must produce a response"), + ); + assert!( + message.contains("is a top-level structure"), + "got: {message}" + ); + } + + // ----- tracing, over a ray-tracing device ------------------------ + + #[test] + fn tracing_with_an_unregistered_kernel_id_is_refused() { + let Some(scene) = make_ray_traced_scene_if_available() else { + println!("tracing_with_an_unregistered_kernel_id: no RT device — skipping"); + return; + }; + let mut req = scene.run_req("trace-unknown-kernel"); + req.kernel_id = "definitely-not-a-registered-kernel".to_string(); + let message = refusal_message(scene.trace(req)); + assert!( + message.contains("no kernel registered under id"), + "got: {message}" + ); + } + + /// An acceleration-structure binding resolves through the structure + /// registry rather than through a surface, so an id no registration + /// minted is refused there rather than falling through to the surface + /// planner and getting a surface's error text. + #[test] + fn a_trace_naming_an_unregistered_acceleration_structure_is_refused() { + let Some(scene) = make_ray_traced_scene_if_available() else { + println!("a_trace_naming_an_unregistered_structure: no RT device — skipping"); + return; + }; + let mut req = scene.run_req("trace-unknown-structure"); + req.bindings[0].target_id = "definitely-not-a-registered-structure".to_string(); + let message = refusal_message(scene.trace(req)); + assert!( + message.contains("binding `scene_geometry` names no acceleration structure"), + "must name the binding, got: {message}" + ); + } + + /// The structure a trace binds is the top-level one a + /// `register_acceleration_structure_tlas` returned; a BLAS id is the + /// same shape of string and would otherwise reach the descriptor. + #[test] + fn a_trace_binding_a_bottom_level_structure_is_refused() { + let Some(scene) = make_ray_traced_scene_if_available() else { + println!("a_trace_binding_a_bottom_level_structure: no RT device — skipping"); + return; + }; + let mut req = scene.run_req("trace-blas"); + req.bindings[0].target_id = scene.blas_id.clone(); + let message = refusal_message(scene.trace(req)); + assert!( + message.contains("is a bottom-level structure"), + "got: {message}" + ); + } + + /// The acceleration structure never reaches the surface planner, so its + /// missing-binding rule is enforced separately — and has to fire. + #[test] + fn a_declared_acceleration_structure_left_out_is_refused() { + let Some(scene) = make_ray_traced_scene_if_available() else { + println!("a_declared_acceleration_structure_left_out: no RT device — skipping"); + return; }; - // 2. Now dispatch it. - let run_req = - EscalateRequest::RunRayTracingKernel(make_run_req("req-run-k", &kernel_id)); - let run_resp = - handle_escalate_op(&sandbox, ®istry, run_req).expect("must produce a response"); - match run_resp { + let mut req = scene.run_req("trace-no-structure"); + req.bindings + .retain(|binding| binding.name != "scene_geometry"); + let message = refusal_message(scene.trace(req)); + assert!( + message.contains("binding `scene_geometry` was not supplied"), + "must name the missing binding, got: {message}" + ); + assert!( + message.contains("do not persist between traces"), + "must say why there is no fallback, got: {message}" + ); + } + + /// Supplying the structure under another kind would otherwise send it + /// to the surface planner, which would look for a surface named by an + /// `as_id`. + #[test] + fn an_acceleration_structure_supplied_as_another_kind_is_refused() { + let Some(scene) = make_ray_traced_scene_if_available() else { + println!("an_acceleration_structure_supplied_as_another_kind: no RT — skipping"); + return; + }; + let mut req = scene.run_req("trace-wrong-kind"); + req.bindings[0].kind = EscalateRayTracingBindingKind::StorageImage; + let message = refusal_message(scene.trace(req)); + assert!( + message.contains("binding `scene_geometry` was supplied as storage_image"), + "must name the binding and the kind supplied, got: {message}" + ); + assert!( + message.contains("declares it acceleration_structure"), + "must name the kind the kernel declares, got: {message}" + ); + } + + /// The whole array is checked before the acceleration structures are + /// split out of it, so a name supplied twice is refused whichever half + /// the second copy would land in — and by the one rule the surface + /// planner spells, not a second wording of it. + #[test] + fn a_name_supplied_twice_is_refused() { + let Some(scene) = make_ray_traced_scene_if_available() else { + println!("a_name_supplied_twice: no RT device — skipping"); + return; + }; + let mut req = scene.run_req("trace-twice"); + let structure_binding = req.bindings[0].clone(); + assert_eq!( + structure_binding.name, "scene_geometry", + "the duplicate has to be the structure, which the surface planner never sees" + ); + req.bindings.push(structure_binding); + let message = refusal_message(scene.trace(req)); + assert!( + message.contains("binding `scene_geometry` was supplied twice"), + "must name the duplicate, got: {message}" + ); + assert!( + message.contains("`traced_output`"), + "must name every binding this kernel declares, got: {message}" + ); + assert!( + message.contains("exactly once per trace"), + "must state the rule in the caller's own noun, got: {message}" + ); + } + + /// The op end to end, over a real device: a trace resolves the scene + /// and the storage image by the raygen's own names for them, launches + /// the grid, and leaves the engine's layout record agreeing with the + /// layout the trace left the image in. + /// + /// The storage image is seeded by a transfer with a sentinel no stage + /// can produce, so a trace that bound nothing fails on the pixels + /// rather than passing on undefined contents — and because that seed + /// leaves the image in `TRANSFER_DST_OPTIMAL`, a trace that did not + /// barrier its bound inputs would write it through a descriptor its + /// layout does not satisfy. Both the hit and the miss stage must have + /// run, which is what proves the structure reached the descriptor. + #[test] + fn a_trace_resolves_its_bindings_by_name_and_writes_the_storage_image() { + let Some(scene) = make_ray_traced_scene_if_available() else { + println!("a_trace_resolves_its_bindings_by_name: no RT device — skipping"); + return; + }; + match scene.trace(scene.run_req("trace-scene")) { EscalateResponse::Ok(ok) => { - assert_eq!(ok.request_id, "req-run-k"); + assert_eq!(ok.request_id, "trace-scene"); assert_eq!( - ok.handle_id, kernel_id, - "Ok response must echo kernel_id back" + ok.handle_id, scene.kernel_id, + "the trace response echoes the kernel_id" + ); + assert!( + ok.timeline_value.is_none(), + "run_ray_tracing_kernel responses carry no timeline" ); } - other => panic!("expected Ok for run, got {other:?}"), - } - // Verify the bridge actually saw the dispatch with the right - // shape. - let runs = bridge.runs(); - assert_eq!(runs.len(), 1); - assert_eq!(runs[0].kernel_id, kernel_id); - assert_eq!(runs[0].width, 1280); - assert_eq!(runs[0].height, 720); - assert_eq!(runs[0].depth, 1); - assert_eq!(runs[0].bindings.len(), 2); - assert_eq!(runs[0].push_constants.len(), 16); - // Lock the per-binding wire→domain conversion: a handler - // bug that swapped, dropped, or overwrote `target_id` - // during conversion would slip past the length check - // alone. The test request used "test-tlas-uuid" for - // binding 0 (acceleration_structure) and - // "test-storage-uuid" for binding 1 (storage_image). - assert_eq!(runs[0].bindings[0].binding, 0); - assert_eq!(runs[0].bindings[0].target_id, "test-tlas-uuid"); + other => panic!("the trace failed: {other:?}"), + } + + // Asserted before the readback, which transitions the image itself: + // an unpublished layout would leave the next consumer's barrier + // naming an oldLayout the image has already left. + let published = scene + .sandbox + .escalate(|full| { + Ok(full + .resolve_texture_registration_by_surface_id( + A_TRACED_SCENES_OUTPUT_SURFACE_UUID, + None, + TRACED_GRID_WIDTH, + TRACED_GRID_HEIGHT, + )? + .current_layout()) + }) + .expect("the storage image still resolves"); assert_eq!( - runs[0].bindings[0].kind, - RayTracingBindingKindWire::AccelerationStructure + published, + streamlib_consumer_rhi::VulkanLayout::GENERAL, + "the trace must publish the layout it left the storage image in" ); - assert_eq!(runs[0].bindings[1].binding, 1); - assert_eq!(runs[0].bindings[1].target_id, "test-storage-uuid"); - assert_eq!( - runs[0].bindings[1].kind, - RayTracingBindingKindWire::StorageImage + + let traced = scene + .sandbox + .escalate(|full| { + let readback = full.create_texture_readback( + "trace-readback", + TRACED_GRID_WIDTH, + TRACED_GRID_HEIGHT, + TextureFormat::Rgba8Unorm, + )?; + let ticket = readback.submit( + scene._held_output.texture(), + crate::core::rhi::TextureSourceLayout::General, + )?; + Ok(readback.wait_and_read(ticket, 2_000_000_000)?.to_vec()) + }) + .expect("the storage image reads back"); + + let mut hit_pixels = 0usize; + let mut missed_pixels = 0usize; + for (pixel_index, pixel) in traced.chunks_exact(4).enumerate() { + if pixel == HIT_RGBA { + hit_pixels += 1; + } else if pixel == MISSED_RGBA { + missed_pixels += 1; + } else { + panic!( + "pixel {pixel_index} is {pixel:?}, which no stage of this kernel writes — \ + the trace left the seeded sentinel, so `traced_output` was never written" + ); + } + } + assert!( + hit_pixels > 0, + "no pixel hit the scene's triangle — `scene_geometry` did not reach the descriptor" + ); + assert!( + missed_pixels > 0, + "every pixel hit, so the launch grid never left the triangle and the miss stage \ + proved nothing" ); } } diff --git a/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_request.rs b/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_request.rs index 8c8a5e118..2897e0a6c 100644 --- a/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_request.rs +++ b/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_request.rs @@ -308,9 +308,9 @@ pub(crate) struct EscalateRequestRegisterAccelerationStructureBlas { /// `VulkanAccelerationStructure::build_triangles_blas`. pub(crate) indices_hex: String, - /// Human-readable label used in error messages and tracing on the host. - /// Echoed in the returned `as_id` derivation only via its bytes — purely - /// diagnostic. + /// Human-readable label the host gives the structure: it names this BLAS in + /// RHI errors and in validation-layer messages. The returned `as_id` is a + /// fresh UUID and derives nothing from it. pub(crate) label: String, /// Correlates request with response. UUID string. @@ -359,15 +359,15 @@ pub(crate) struct EscalateRequestRegisterAccelerationStructureTlasInstance { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EscalateRequestRegisterAccelerationStructureTlas { - /// One TLAS instance per entry. The host resolves `blas_id` - /// to a previously-registered BLAS via the bridge's `as_id - /// → Arc` map and forwards to + /// One TLAS instance per entry. The host resolves each `blas_id` through + /// `GpuContext`'s acceleration-structure registry and forwards to /// `VulkanAccelerationStructure::build_tlas`. Empty array is rejected (TLAS /// must have at least one instance). pub(crate) instances: Vec, - /// Human-readable label used in error messages and tracing on the host. - /// Diagnostic only. + /// Human-readable label the host gives the structure: it names this TLAS in + /// RHI errors and in validation-layer messages. The returned `as_id` is a + /// fresh UUID and derives nothing from it. pub(crate) label: String, /// Correlates request with response. UUID string. @@ -393,6 +393,20 @@ pub(crate) enum EscalateComputeBindingKind { UniformBuffer, } +impl EscalateComputeBindingKind { + /// This kind's spelling on the wire — what a register response hands back + /// and the next dispatch echoes. + pub(crate) const fn wire_name(self) -> &'static str { + match self { + Self::SampledImage => "sampled_image", + Self::SampledTexture => "sampled_texture", + Self::StorageBuffer => "storage_buffer", + Self::StorageImage => "storage_image", + Self::UniformBuffer => "uniform_buffer", + } + } +} + /// One binding a compute kernel declares at registration, named as the shader /// names it. The slot number is not on the wire — it comes from reflection, /// and the name is what a dispatch resolves against. @@ -464,9 +478,13 @@ pub(crate) struct EscalateRequestRegisterComputeKernel { pub(crate) spv_hex: String, } -/// Resource kind for this binding slot. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) enum EscalateRequestRegisterGraphicsKernelBindingKind { +/// Resource kind for a graphics binding slot. +/// +/// One enum for the register array, the draw array and the register response — +/// they name the same four kinds, and two spellings of one set is two things to +/// keep in lockstep forever. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum EscalateGraphicsBindingKind { #[serde(rename = "sampled_texture")] SampledTexture, @@ -480,16 +498,39 @@ pub(crate) enum EscalateRequestRegisterGraphicsKernelBindingKind { UniformBuffer, } +impl EscalateGraphicsBindingKind { + /// This kind's spelling on the wire — what a register response hands back + /// and the next draw echoes. + pub(crate) const fn wire_name(self) -> &'static str { + match self { + Self::SampledTexture => "sampled_texture", + Self::StorageBuffer => "storage_buffer", + Self::StorageImage => "storage_image", + Self::UniformBuffer => "uniform_buffer", + } + } +} + +/// One binding a graphics kernel declares at registration, named as the shaders +/// name it. The slot number is not on the wire — it comes from reflection, and +/// the name is what a draw resolves against. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EscalateRequestRegisterGraphicsKernelBinding { - pub(crate) binding: u32, + /// Resource kind the caller expects at this name. Checked against + /// reflection at registration; a mismatch is an `err` response. + pub(crate) kind: EscalateGraphicsBindingKind, - /// Resource kind for this binding slot. - pub(crate) kind: EscalateRequestRegisterGraphicsKernelBindingKind, + /// The shaders' own name for the binding. + pub(crate) name: String, /// Bitmask of stages the binding is visible to. `1 = VERTEX`, `2 = - /// FRAGMENT`, `3 = VERTEX_FRAGMENT`. + /// FRAGMENT`, `3 = VERTEX_FRAGMENT`. `0` asserts nothing about stages. + /// + /// A declaration may widen a binding's visibility past what the shaders + /// read, never narrow it below; naming a stage this kernel has no module + /// for is refused at registration, where the multi-stage declaration is + /// built. pub(crate) stages: u32, } @@ -916,8 +957,8 @@ pub(crate) enum EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDept /// Fixed-function pipeline state plus attachment formats for the graphics /// pipeline. Mirrors the host `GraphicsPipelineState` shape; unsupported -/// combinations (multi-attachment color blend, MSAA samples > 1, etc.) are -/// rejected with an `err` response. +/// combinations — MSAA samples > 1, other than one colour attachment, either +/// half of a depth attachment — are rejected with an `err` response. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EscalateRequestRegisterGraphicsKernelPipelineState { @@ -953,13 +994,16 @@ pub(crate) struct EscalateRequestRegisterGraphicsKernelPipelineState { /// attachment's `color_write_mask` when enabled. pub(crate) color_write_mask: u32, - /// Depth compare op. Ignored when `depth_stencil_enabled` is false; the - /// wire field must still carry a valid value (use `always` as the default - /// placeholder when disabled). + /// Never read: `depth_stencil_enabled` must be false, so there is no depth + /// test to configure. Required on the wire, so send `always`. pub(crate) depth_compare_op: EscalateRequestRegisterGraphicsKernelPipelineStateDepthCompareOp, + /// Must be false. The offscreen pass a draw runs through attaches colour + /// targets only, so a depth-testing pipeline has no attachment to test + /// against; true is an `err` response. pub(crate) depth_stencil_enabled: bool, + /// Never read, for the same reason `depth_compare_op` is not. Send false. pub(crate) depth_write: bool, /// Which pipeline state is set dynamically per draw vs baked into the @@ -985,21 +1029,24 @@ pub(crate) struct EscalateRequestRegisterGraphicsKernelPipelineState { pub(crate) topology: EscalateRequestRegisterGraphicsKernelPipelineStateTopology, - /// Vertex attributes pulled from the bindings. Must be empty when - /// `vertex_input_bindings` is empty. + /// Must be empty, for the same reason `vertex_input_bindings` must be: an + /// attribute is pulled from a binding. A non-empty array is an `err` + /// response. pub(crate) vertex_input_attributes: Vec, - /// Vertex buffer binding slots — stride and step rate per binding. Empty - /// array selects the `VertexInputState::None` (gl_VertexIndex-driven) - /// shape; non-empty selects `VertexInputState::Buffers` with the given - /// bindings + attributes. + /// Must be empty. No escalate op mints a `VertexBuffer` to fill a binding — + /// a helper can acquire a pixel buffer, a texture or an image, and the + /// vertex-buffer setter takes none of them — so a pipeline pulling from one + /// would register and then be refused at every draw. Vertices are + /// fabricated from `gl_VertexIndex`; a non-empty array is an `err` response. pub(crate) vertex_input_bindings: Vec, - /// Depth attachment format. Absent disables depth attachments — the - /// depth_stencil flags must be consistent (`depth_stencil_enabled = false` - /// when this is absent). + /// Must be absent, for the same reason `depth_stencil_enabled` must be + /// false: the pass attaches colour targets only, so a pipeline declaring a + /// depth format would disagree with it at every draw. A present one is an + /// `err` response. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) attachment_depth_format: Option, @@ -1033,14 +1080,17 @@ pub(crate) struct EscalateRequestRegisterGraphicsKernel { #[serde(default)] pub(crate) fragment_spv_hex: String, - /// Human-readable label used in error messages and tracing on the host. - /// Echoed in `kernel_id` derivation only via its bytes — purely diagnostic. + /// Human-readable label the host gives the pipeline: it names this kernel + /// in RHI errors and in validation-layer messages. Outside the `kernel_id` + /// derivation — two registrations differing only in label are one pipeline, + /// and the first one's label is what the driver keeps. pub(crate) label: String, /// Fixed-function pipeline state plus attachment formats for the graphics - /// pipeline. Mirrors the host `GraphicsPipelineState` shape; unsupported - /// combinations (multi-attachment color blend, MSAA samples > 1, etc.) are - /// rejected with an `err` response. + /// pipeline. Mirrors the host `GraphicsPipelineState` shape; a shape a draw + /// cannot run is an `err` response — MSAA, other than exactly one colour + /// attachment, either half of a depth attachment, either half of a vertex + /// input, or a write mask no channel owns. pub(crate) pipeline_state: EscalateRequestRegisterGraphicsKernelPipelineState, /// Push-constant range size in bytes, validated against the merged shader @@ -1072,9 +1122,13 @@ pub(crate) struct EscalateRequestRegisterGraphicsKernel { pub(crate) vertex_spv_hex: String, } -/// Resource kind for this binding slot. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) enum EscalateRequestRegisterRayTracingKernelBindingKind { +/// Resource kind for a ray-tracing binding slot. +/// +/// One enum for the register array, the dispatch array and the register +/// response — they name the same five kinds, and two spellings of one set is +/// two things to keep in lockstep forever. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum EscalateRayTracingBindingKind { #[serde(rename = "acceleration_structure")] AccelerationStructure, @@ -1091,20 +1145,50 @@ pub(crate) enum EscalateRequestRegisterRayTracingKernelBindingKind { UniformBuffer, } +impl EscalateRayTracingBindingKind { + /// This kind's spelling on the wire — what a register response hands back + /// and the next dispatch echoes. + pub(crate) const fn wire_name(self) -> &'static str { + match self { + Self::AccelerationStructure => "acceleration_structure", + Self::SampledTexture => "sampled_texture", + Self::StorageBuffer => "storage_buffer", + Self::StorageImage => "storage_image", + Self::UniformBuffer => "uniform_buffer", + } + } +} + +/// One binding a ray-tracing kernel declares at registration, named as the +/// shaders name it. The slot number is not on the wire — it comes from +/// reflection, and the name is what a dispatch resolves against. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EscalateRequestRegisterRayTracingKernelBinding { - pub(crate) binding: u32, + /// Resource kind the caller expects at this name. Checked against + /// reflection at registration; a mismatch is an `err` response. + pub(crate) kind: EscalateRayTracingBindingKind, - /// Resource kind for this binding slot. - pub(crate) kind: EscalateRequestRegisterRayTracingKernelBindingKind, + /// The shaders' own name for the binding. + pub(crate) name: String, /// Bitmask of RT stages the binding is visible to. Bits: `1=RAYGEN`, /// `2=MISS`, `4=CLOSEST_HIT`, `8=ANY_HIT`, `16=INTERSECTION`, - /// `32=CALLABLE`. + /// `32=CALLABLE`. `0` asserts nothing about stages. + /// + /// A declaration may widen a binding's visibility past what the shaders + /// read, never narrow it below; naming a stage this kernel has no module + /// for is refused at registration, where the multi-stage declaration is + /// built. A ray-tracing kernel's stage set varies per kernel, so that is + /// the case this refusal exists for. pub(crate) stages: u32, } +/// Value a shader-group's optional stage index carries when the group names no +/// stage there. Every stage-index field is always present on the wire, so +/// "absent" needs a value rather than an omission. +pub(crate) const RAY_TRACING_STAGE_INDEX_NONE: u32 = u32::MAX; + /// - `general`: contributes one ray-gen, miss, or /// callable stage via `general_stage`. /// - `triangles_hit`: triangle hit group; sets at least @@ -1212,8 +1296,10 @@ pub(crate) struct EscalateRequestRegisterRayTracingKernel { /// stage indices into `stages`. pub(crate) groups: Vec, - /// Human-readable label used in error messages and tracing. Diagnostic - /// only. + /// Human-readable label the host gives the pipeline: it names this kernel + /// in RHI errors and in validation-layer messages. Outside the `kernel_id` + /// derivation — two registrations differing only in label are one pipeline, + /// and the first one's label is what the driver keeps. pub(crate) label: String, /// Maximum ray recursion depth. Must be ≤ device's `maxRayRecursionDepth`. @@ -1406,28 +1492,22 @@ pub(crate) struct EscalateRequestRunCpuReadbackCopy { pub(crate) surface_id: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) enum EscalateRequestRunGraphicsDrawBindingKind { - #[serde(rename = "sampled_texture")] - SampledTexture, - - #[serde(rename = "storage_buffer")] - StorageBuffer, - - #[serde(rename = "storage_image")] - StorageImage, - - #[serde(rename = "uniform_buffer")] - UniformBuffer, -} - +/// One resource bound for a single draw, named as the shaders name it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EscalateRequestRunGraphicsDrawBinding { - pub(crate) binding: u32, + /// Resource kind the caller believes is at this name. Must match the + /// kernel's reflected kind; a mismatch is an `err` response raised before + /// anything is submitted. + pub(crate) kind: EscalateGraphicsBindingKind, - pub(crate) kind: EscalateRequestRunGraphicsDrawBindingKind, + /// The shaders' own name for the binding. Resolved against the kernel's + /// reflected bindings — a name the shaders do not declare, or a declared + /// name this array omits, is an `err` response. + pub(crate) name: String, + /// Surface id of the resource to bind, as the host registered it. The host + /// resolves it through `resolve_texture_registration_by_surface_id`. pub(crate) surface_uuid: String, } @@ -1532,9 +1612,10 @@ pub(crate) struct EscalateRequestRunGraphicsDrawViewport { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EscalateRequestRunGraphicsDraw { - /// Per-draw bindings — each slot's `surface_uuid` must resolve through - /// the host bridge's UUID → resource map. `kind` must match the binding's - /// declared kind from register time. + /// Per-draw bindings, each named as the shaders name it. `kind` must match + /// the kind reflection found at register time, and `surface_uuid` must be a + /// surface the host can resolve to a device texture. Bindings do not + /// persist between draws, so every draw supplies all of them. pub(crate) bindings: Vec, /// UUIDs of color attachment textures. v1 requires exactly one entry — @@ -1542,11 +1623,11 @@ pub(crate) struct EscalateRequestRunGraphicsDraw { /// host-side `Texture` registered as a render target. pub(crate) color_target_uuids: Vec, - /// Draw call. `kind = "draw"` selects non-indexed (`vertex_count`-driven), - /// `kind = "draw_indexed"` requires `index_buffer` to be set and uses - /// `index_count` / `first_index` / `vertex_offset`. Fields not used by - /// the selected kind are ignored host-side; subprocesses should still send - /// valid placeholder values (zero is fine) to keep the wire shape regular. + /// Draw call. `kind = "draw"` is the only kind the host runs: it is + /// non-indexed and `vertex_count`-driven. `kind = "draw_indexed"` is + /// refused, because it needs an index buffer and no escalate op mints one. + /// The indexed fields — `index_count` / `first_index` / `vertex_offset` — + /// stay on the wire so its shape is regular; send zeros. pub(crate) draw: EscalateRequestRunGraphicsDrawDraw, /// Render-area height in pixels. @@ -1574,21 +1655,22 @@ pub(crate) struct EscalateRequestRunGraphicsDraw { /// Correlates request with response. UUID string. pub(crate) request_id: String, - /// Per-draw vertex buffer bindings. Each entry's `surface_uuid` must - /// resolve to a host-side `PixelBuffer`. `offset` is the byte offset into - /// the buffer where vertex data starts (decimal-encoded u64 — JSON has no - /// 64-bit integer). Empty for vertex-fabricating shaders (`gl_VertexIndex` - /// patterns). + /// Per-draw vertex buffer bindings. Always empty: the host's vertex-buffer + /// setter takes a `VertexBuffer`, and no escalate op mints one — a helper + /// can acquire a pixel buffer, a texture or an image, none of which that + /// setter accepts. A non-empty array is an `err` response; a vertex stage + /// fabricates its positions from `gl_VertexIndex` instead. pub(crate) vertex_buffers: Vec, - /// UUID of a depth attachment texture. Reserved for future use — v1 rejects - /// depth attachments with an `err` response. + /// UUID of a depth attachment texture. Always absent: the offscreen pass + /// this op drives attaches colour targets only, so a depth attachment would + /// never be tested against. A present one is an `err` response. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) depth_target_uuid: Option, - /// Required when `draw.kind == "draw_indexed"`, must be absent otherwise. - /// `surface_uuid` resolves to a `PixelBuffer`; `offset` is the byte offset - /// into it. + /// Always absent, for the same reason `vertex_buffers` is always empty: the + /// host's index-buffer setter takes an `IndexBuffer` and no escalate op + /// mints one. A present one is an `err` response. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) index_buffer: Option, @@ -1603,44 +1685,43 @@ pub(crate) struct EscalateRequestRunGraphicsDraw { pub(crate) viewport: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) enum EscalateRequestRunRayTracingKernelBindingKind { - #[serde(rename = "acceleration_structure")] - AccelerationStructure, - - #[serde(rename = "sampled_texture")] - SampledTexture, - - #[serde(rename = "storage_buffer")] - StorageBuffer, - - #[serde(rename = "storage_image")] - StorageImage, - - #[serde(rename = "uniform_buffer")] - UniformBuffer, -} - +/// One resource bound for a single trace, named as the shaders name it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EscalateRequestRunRayTracingKernelBinding { - pub(crate) binding: u32, + /// Resource kind the caller believes is at this name. Must match the + /// kernel's reflected kind; a mismatch is an `err` response raised before + /// anything is submitted. + pub(crate) kind: EscalateRayTracingBindingKind, - pub(crate) kind: EscalateRequestRunRayTracingKernelBindingKind, + /// The shaders' own name for the binding. Resolved against the kernel's + /// reflected bindings — a name the shaders do not declare, or a declared + /// name this array omits, is an `err` response. + pub(crate) name: String, + /// What to bind. For `acceleration_structure` this is an `as_id` from a + /// prior `register_acceleration_structure_tlas`; for every other kind it is + /// a surface id the host resolves through + /// `resolve_texture_registration_by_surface_id`. pub(crate) target_id: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EscalateRequestRunRayTracingKernel { - /// Per-trace bindings. `kind` must match the binding's declared kind from - /// register time. The host bridge resolves `target_id` based on `kind`: - - /// `acceleration_structure`: `target_id` is an `as_id` - /// from a prior `register_acceleration_structure_tlas`. - /// - all other kinds: `target_id` is the surface-share UUID - /// of a host-side `PixelBuffer` / `Texture` - /// (same convention compute and graphics use). + /// Per-trace bindings, each named as the shaders name it. `kind` must match + /// the kind reflection found at register time, and decides how the host + /// resolves `target_id`: + /// - `acceleration_structure`: an `as_id` from a prior + /// `register_acceleration_structure_tlas`, resolved through + /// `GpuContext`'s acceleration-structure registry. + /// - `sampled_texture` / `storage_image`: a surface id the host resolves to + /// a device texture, the same convention compute and graphics use. + /// - `storage_buffer` / `uniform_buffer`: refused — a trace cannot name a + /// surface for a buffer binding. + /// + /// Bindings do not persist between traces, so every trace supplies all of + /// them. pub(crate) bindings: Vec, /// vkCmdTraceRaysKHR depth (usually 1 for 2D output). diff --git a/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_response.rs b/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_response.rs index 314dc3535..c240ad63b 100644 --- a/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_response.rs +++ b/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_response.rs @@ -42,12 +42,21 @@ pub(crate) struct EscalateResponseErr { pub(crate) request_id: String, } -/// One binding of a registered compute kernel, as reflection found it. +/// One binding of a registered kernel of any pipeline kind, as reflection +/// found it. +/// +/// `kind` is the wire spelling rather than one of the three request enums, +/// because compute, graphics and ray tracing do not name the same set of kinds +/// and a caller only ever echoes this value back on the next dispatch. The +/// bytes are identical either way. +/// +/// Stages are deliberately absent: a binding's stage visibility is settled at +/// construction, and no dispatch carries it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub(crate) struct EscalateResponseComputeBinding { +pub(crate) struct EscalateResponseKernelBinding { /// Resource kind, in the same spelling the request's binding arrays use. - pub(crate) kind: super::escalate_request::EscalateComputeBindingKind, + pub(crate) kind: String, /// The shader's own name for the binding — what a dispatch supplies it by. pub(crate) name: String, @@ -71,13 +80,13 @@ pub(crate) struct EscalateResponseOk { pub(crate) request_id: String, /// The kernel's binding shape as reflection found it, in slot order. Set on - /// `register_compute_kernel` responses. + /// every `register_*_kernel` response. /// /// The caller needs it to dispatch: bindings resolve by name, and only the /// shader knows which kind each name is. Without this the caller would have /// to guess a kind for every binding it supplies. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) bindings: Option>, + pub(crate) bindings: Option>, /// Decimal-string-encoded u64 row pitch of the device-export staging, /// derived from the staging's own geometry rather than from the requesting diff --git a/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_wire_encoding_tests.rs b/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_wire_encoding_tests.rs index b562e965c..5fd8c8795 100644 --- a/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_wire_encoding_tests.rs +++ b/runtime/streamlib-engine/src/core/compiler/compiler_ops/subprocess_escalate_wire_types/escalate_wire_encoding_tests.rs @@ -12,8 +12,8 @@ //! which carry an explicit null. use super::escalate_request::{ - EscalateComputeBindingKind, EscalateRequestLogLevel, EscalateRequestLogSource, - EscalateRequestRegisterGraphicsKernelBindingKind, + EscalateComputeBindingKind, EscalateGraphicsBindingKind, EscalateRayTracingBindingKind, + EscalateRequestLogLevel, EscalateRequestLogSource, EscalateRequestRegisterGraphicsKernelPipelineStateAttachmentDepthFormat, EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendAlphaOp, EscalateRequestRegisterGraphicsKernelPipelineStateColorBlendColorOp, @@ -29,12 +29,10 @@ use super::escalate_request::{ EscalateRequestRegisterGraphicsKernelPipelineStateTopology, EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputAttributeFormat, EscalateRequestRegisterGraphicsKernelPipelineStateVertexInputBindingInputRate, - EscalateRequestRegisterRayTracingKernelBindingKind, EscalateRequestRegisterRayTracingKernelGroupKind, EscalateRequestRegisterRayTracingKernelStageStage, EscalateRequestRunCpuReadbackCopyDirection, - EscalateRequestRunGraphicsDrawBindingKind, EscalateRequestRunGraphicsDrawDrawKind, - EscalateRequestRunGraphicsDrawIndexBufferIndexType, - EscalateRequestRunRayTracingKernelBindingKind, EscalateRequestTryRunCpuReadbackCopyDirection, + EscalateRequestRunGraphicsDrawDrawKind, EscalateRequestRunGraphicsDrawIndexBufferIndexType, + EscalateRequestTryRunCpuReadbackCopyDirection, }; use super::escalate_response::EscalateResponseOk; use super::{EscalateRequest, EscalateResponse}; @@ -79,6 +77,31 @@ macro_rules! assert_wire_spellings { }; } +/// Assert every listed variant's `wire_name()` is exactly what serde writes for +/// it, and refuse to compile when a variant is left off the list. +macro_rules! assert_wire_name_is_the_serde_spelling { + ($($enum_type:ident { $($variant:ident),+ $(,)? })+) => { + $({ + // Total only over the listed arms: a variant added to the enum and + // not to this list is a compile error, not a silently unpinned + // spelling. + let _every_variant_is_listed = |kind: $enum_type| match kind { + $($enum_type::$variant => ()),+ + }; + $( + assert_eq!( + serde_json::to_string(&$enum_type::$variant).unwrap(), + format!("\"{}\"", $enum_type::$variant.wire_name()), + concat!( + stringify!($enum_type), "::", stringify!($variant), + ": wire_name() drifted from the serde spelling" + ) + ); + )+ + })+ + }; +} + /// Every `EscalateRequest` variant survives a decode/encode round trip unchanged. #[test] fn escalate_request_vectors_round_trip() { @@ -94,14 +117,14 @@ fn escalate_request_vectors_round_trip() { RegisterAccelerationStructureBlas => r#"{"op":"register_acceleration_structure_blas","indices_hex":"register_acceleration_structure_blas.indices_hex-31","label":"register_acceleration_structure_blas.label-32","request_id":"register_acceleration_structure_blas.request_id-33","vertices_hex":"register_acceleration_structure_blas.vertices_hex-34"}"#, RegisterAccelerationStructureTlas => r#"{"op":"register_acceleration_structure_tlas","instances":[{"blas_id":"register_acceleration_structure_tlas.instances[0].blas_id-35","custom_index":36,"flags":37,"mask":38,"sbt_record_offset":39,"transform":[40.5,41.5]},{"blas_id":"register_acceleration_structure_tlas.instances[1].blas_id-42","custom_index":43,"flags":44,"mask":45,"sbt_record_offset":46,"transform":[47.5,48.5]}],"label":"register_acceleration_structure_tlas.label-49","request_id":"register_acceleration_structure_tlas.request_id-50"}"#, RegisterComputeKernel => r#"{"op":"register_compute_kernel","bindings":[{"kind":"storage_buffer","name":"register_compute_kernel.bindings[0].name-51"},{"kind":"storage_image","name":"register_compute_kernel.bindings[1].name-52"}],"push_constant_size":53,"request_id":"register_compute_kernel.request_id-54","source":"register_compute_kernel.source-210","stage":"register_compute_kernel.stage-211","entry_point":"register_compute_kernel.entry_point-212","spv_hex":"register_compute_kernel.spv_hex-55"}"#, - RegisterGraphicsKernel => r#"{"op":"register_graphics_kernel","bindings":[{"binding":54,"kind":"uniform_buffer","stages":56},{"binding":57,"kind":"storage_image","stages":59}],"descriptor_sets_in_flight":60,"fragment_entry_point":"register_graphics_kernel.fragment_entry_point-61","fragment_source":"register_graphics_kernel.fragment_source-213","fragment_spv_hex":"register_graphics_kernel.fragment_spv_hex-62","label":"register_graphics_kernel.label-63","pipeline_state":{"attachment_color_formats":["register_graphics_kernel.pipeline_state.attachment_color_formats[0]-64","register_graphics_kernel.pipeline_state.attachment_color_formats[1]-65"],"color_blend_alpha_op":"max","color_blend_color_op":"min","color_blend_dst_alpha_factor":"one_minus_dst_color","color_blend_dst_color_factor":"one_minus_src_alpha","color_blend_enabled":true,"color_blend_src_alpha_factor":"src_alpha","color_blend_src_color_factor":"src_alpha_saturate","color_write_mask":73,"depth_compare_op":"greater","depth_stencil_enabled":false,"depth_write":true,"dynamic_state":"viewport_scissor","multisample_samples":78,"rasterization_cull_mode":"none","rasterization_front_face":"clockwise","rasterization_line_width":81.5,"rasterization_polygon_mode":"line","topology":"triangle_strip","vertex_input_attributes":[{"binding":84,"format":"r32_sint","location":86,"offset":87},{"binding":88,"format":"rg32_uint","location":90,"offset":91}],"vertex_input_bindings":[{"binding":92,"input_rate":"vertex","stride":94},{"binding":95,"input_rate":"instance","stride":97}],"attachment_depth_format":"d32_sfloat"},"push_constant_size":99,"push_constant_stages":100,"request_id":"register_graphics_kernel.request_id-101","vertex_entry_point":"register_graphics_kernel.vertex_entry_point-102","vertex_source":"register_graphics_kernel.vertex_source-214","vertex_spv_hex":"register_graphics_kernel.vertex_spv_hex-103"}"#, - RegisterRayTracingKernel => r#"{"op":"register_ray_tracing_kernel","bindings":[{"binding":104,"kind":"acceleration_structure","stages":106},{"binding":107,"kind":"storage_image","stages":109}],"groups":[{"any_hit_stage":110,"closest_hit_stage":111,"general_stage":112,"intersection_stage":113,"kind":"general"},{"any_hit_stage":115,"closest_hit_stage":116,"general_stage":117,"intersection_stage":118,"kind":"triangles_hit"}],"label":"register_ray_tracing_kernel.label-120","max_recursion_depth":121,"push_constant_size":122,"push_constant_stages":123,"request_id":"register_ray_tracing_kernel.request_id-124","stages":[{"entry_point":"register_ray_tracing_kernel.stages[0].entry_point-125","source":"register_ray_tracing_kernel.stages[0].source-215","spv_hex":"register_ray_tracing_kernel.stages[0].spv_hex-126","stage":"callable"},{"entry_point":"register_ray_tracing_kernel.stages[1].entry_point-128","source":"register_ray_tracing_kernel.stages[1].source-216","spv_hex":"register_ray_tracing_kernel.stages[1].spv_hex-129","stage":"miss"}]}"#, + RegisterGraphicsKernel => r#"{"op":"register_graphics_kernel","bindings":[{"kind":"uniform_buffer","name":"register_graphics_kernel.bindings[0].name-54","stages":56},{"kind":"storage_image","name":"register_graphics_kernel.bindings[1].name-57","stages":59}],"descriptor_sets_in_flight":60,"fragment_entry_point":"register_graphics_kernel.fragment_entry_point-61","fragment_source":"register_graphics_kernel.fragment_source-213","fragment_spv_hex":"register_graphics_kernel.fragment_spv_hex-62","label":"register_graphics_kernel.label-63","pipeline_state":{"attachment_color_formats":["register_graphics_kernel.pipeline_state.attachment_color_formats[0]-64","register_graphics_kernel.pipeline_state.attachment_color_formats[1]-65"],"color_blend_alpha_op":"max","color_blend_color_op":"min","color_blend_dst_alpha_factor":"one_minus_dst_color","color_blend_dst_color_factor":"one_minus_src_alpha","color_blend_enabled":true,"color_blend_src_alpha_factor":"src_alpha","color_blend_src_color_factor":"src_alpha_saturate","color_write_mask":73,"depth_compare_op":"greater","depth_stencil_enabled":false,"depth_write":true,"dynamic_state":"viewport_scissor","multisample_samples":78,"rasterization_cull_mode":"none","rasterization_front_face":"clockwise","rasterization_line_width":81.5,"rasterization_polygon_mode":"line","topology":"triangle_strip","vertex_input_attributes":[{"binding":84,"format":"r32_sint","location":86,"offset":87},{"binding":88,"format":"rg32_uint","location":90,"offset":91}],"vertex_input_bindings":[{"binding":92,"input_rate":"vertex","stride":94},{"binding":95,"input_rate":"instance","stride":97}],"attachment_depth_format":"d32_sfloat"},"push_constant_size":99,"push_constant_stages":100,"request_id":"register_graphics_kernel.request_id-101","vertex_entry_point":"register_graphics_kernel.vertex_entry_point-102","vertex_source":"register_graphics_kernel.vertex_source-214","vertex_spv_hex":"register_graphics_kernel.vertex_spv_hex-103"}"#, + RegisterRayTracingKernel => r#"{"op":"register_ray_tracing_kernel","bindings":[{"kind":"acceleration_structure","name":"register_ray_tracing_kernel.bindings[0].name-104","stages":106},{"kind":"storage_image","name":"register_ray_tracing_kernel.bindings[1].name-107","stages":109}],"groups":[{"any_hit_stage":110,"closest_hit_stage":111,"general_stage":112,"intersection_stage":113,"kind":"general"},{"any_hit_stage":115,"closest_hit_stage":116,"general_stage":117,"intersection_stage":118,"kind":"triangles_hit"}],"label":"register_ray_tracing_kernel.label-120","max_recursion_depth":121,"push_constant_size":122,"push_constant_stages":123,"request_id":"register_ray_tracing_kernel.request_id-124","stages":[{"entry_point":"register_ray_tracing_kernel.stages[0].entry_point-125","source":"register_ray_tracing_kernel.stages[0].source-215","spv_hex":"register_ray_tracing_kernel.stages[0].spv_hex-126","stage":"callable"},{"entry_point":"register_ray_tracing_kernel.stages[1].entry_point-128","source":"register_ray_tracing_kernel.stages[1].source-216","spv_hex":"register_ray_tracing_kernel.stages[1].spv_hex-129","stage":"miss"}]}"#, ReleaseHandle => r#"{"op":"release_handle","handle_id":"release_handle.handle_id-131","request_id":"release_handle.request_id-132"}"#, RunComputeKernel => r#"{"op":"run_compute_kernel","bindings":[{"kind":"sampled_texture","name":"run_compute_kernel.bindings[0].name-133","target_id":"run_compute_kernel.bindings[0].target_id-134"},{"kind":"storage_image","name":"run_compute_kernel.bindings[1].name-135","target_id":"run_compute_kernel.bindings[1].target_id-136"}],"group_count_x":137,"group_count_y":138,"group_count_z":139,"kernel_id":"run_compute_kernel.kernel_id-140","push_constants_hex":"run_compute_kernel.push_constants_hex-141","request_id":"run_compute_kernel.request_id-142"}"#, RunComputeKernelBatch => r#"{"op":"run_compute_kernel_batch","dispatches":[{"bindings":[{"kind":"sampled_texture","name":"run_compute_kernel_batch.dispatches[0].bindings[0].name-217","target_id":"run_compute_kernel_batch.dispatches[0].bindings[0].target_id-218"},{"kind":"storage_image","name":"run_compute_kernel_batch.dispatches[0].bindings[1].name-219","target_id":"run_compute_kernel_batch.dispatches[0].bindings[1].target_id-220"}],"group_count_x":221,"group_count_y":222,"group_count_z":223,"kernel_id":"run_compute_kernel_batch.dispatches[0].kernel_id-224","push_constants_hex":"run_compute_kernel_batch.dispatches[0].push_constants_hex-225"},{"bindings":[{"kind":"sampled_texture","name":"run_compute_kernel_batch.dispatches[1].bindings[0].name-226","target_id":"run_compute_kernel_batch.dispatches[1].bindings[0].target_id-227"},{"kind":"storage_image","name":"run_compute_kernel_batch.dispatches[1].bindings[1].name-228","target_id":"run_compute_kernel_batch.dispatches[1].bindings[1].target_id-229"}],"group_count_x":230,"group_count_y":231,"group_count_z":232,"kernel_id":"run_compute_kernel_batch.dispatches[1].kernel_id-233","push_constants_hex":"run_compute_kernel_batch.dispatches[1].push_constants_hex-234"}],"request_id":"run_compute_kernel_batch.request_id-235"}"#, RunCpuReadbackCopy => r#"{"op":"run_cpu_readback_copy","direction":"buffer_to_image","request_id":"run_cpu_readback_copy.request_id-141","surface_id":"run_cpu_readback_copy.surface_id-142"}"#, - RunGraphicsDraw => r#"{"op":"run_graphics_draw","bindings":[{"binding":143,"kind":"sampled_texture","surface_uuid":"run_graphics_draw.bindings[0].surface_uuid-145"},{"binding":146,"kind":"uniform_buffer","surface_uuid":"run_graphics_draw.bindings[1].surface_uuid-148"}],"color_target_uuids":["run_graphics_draw.color_target_uuids[0]-149","run_graphics_draw.color_target_uuids[1]-150"],"draw":{"first_index":151,"first_instance":152,"first_vertex":153,"index_count":154,"instance_count":155,"kind":"draw","vertex_count":157,"vertex_offset":158},"extent_height":159,"extent_width":160,"frame_index":161,"kernel_id":"run_graphics_draw.kernel_id-162","push_constants_hex":"run_graphics_draw.push_constants_hex-163","request_id":"run_graphics_draw.request_id-164","vertex_buffers":[{"binding":165,"offset":"run_graphics_draw.vertex_buffers[0].offset-166","surface_uuid":"run_graphics_draw.vertex_buffers[0].surface_uuid-167"},{"binding":168,"offset":"run_graphics_draw.vertex_buffers[1].offset-169","surface_uuid":"run_graphics_draw.vertex_buffers[1].surface_uuid-170"}],"depth_target_uuid":"run_graphics_draw.depth_target_uuid-171","index_buffer":{"index_type":"uint16","offset":"run_graphics_draw.index_buffer.offset-173","surface_uuid":"run_graphics_draw.index_buffer.surface_uuid-174"},"scissor":{"height":175,"width":176,"x":177,"y":178},"viewport":{"height":179.5,"max_depth":180.5,"min_depth":181.5,"width":182.5,"x":183.5,"y":184.5}}"#, - RunRayTracingKernel => r#"{"op":"run_ray_tracing_kernel","bindings":[{"binding":185,"kind":"sampled_texture","target_id":"run_ray_tracing_kernel.bindings[0].target_id-187"},{"binding":188,"kind":"uniform_buffer","target_id":"run_ray_tracing_kernel.bindings[1].target_id-190"}],"depth":191,"height":192,"kernel_id":"run_ray_tracing_kernel.kernel_id-193","push_constants_hex":"run_ray_tracing_kernel.push_constants_hex-194","request_id":"run_ray_tracing_kernel.request_id-195","width":196}"#, + RunGraphicsDraw => r#"{"op":"run_graphics_draw","bindings":[{"kind":"sampled_texture","name":"run_graphics_draw.bindings[0].name-143","surface_uuid":"run_graphics_draw.bindings[0].surface_uuid-145"},{"kind":"uniform_buffer","name":"run_graphics_draw.bindings[1].name-146","surface_uuid":"run_graphics_draw.bindings[1].surface_uuid-148"}],"color_target_uuids":["run_graphics_draw.color_target_uuids[0]-149","run_graphics_draw.color_target_uuids[1]-150"],"draw":{"first_index":151,"first_instance":152,"first_vertex":153,"index_count":154,"instance_count":155,"kind":"draw","vertex_count":157,"vertex_offset":158},"extent_height":159,"extent_width":160,"frame_index":161,"kernel_id":"run_graphics_draw.kernel_id-162","push_constants_hex":"run_graphics_draw.push_constants_hex-163","request_id":"run_graphics_draw.request_id-164","vertex_buffers":[{"binding":165,"offset":"run_graphics_draw.vertex_buffers[0].offset-166","surface_uuid":"run_graphics_draw.vertex_buffers[0].surface_uuid-167"},{"binding":168,"offset":"run_graphics_draw.vertex_buffers[1].offset-169","surface_uuid":"run_graphics_draw.vertex_buffers[1].surface_uuid-170"}],"depth_target_uuid":"run_graphics_draw.depth_target_uuid-171","index_buffer":{"index_type":"uint16","offset":"run_graphics_draw.index_buffer.offset-173","surface_uuid":"run_graphics_draw.index_buffer.surface_uuid-174"},"scissor":{"height":175,"width":176,"x":177,"y":178},"viewport":{"height":179.5,"max_depth":180.5,"min_depth":181.5,"width":182.5,"x":183.5,"y":184.5}}"#, + RunRayTracingKernel => r#"{"op":"run_ray_tracing_kernel","bindings":[{"kind":"sampled_texture","name":"run_ray_tracing_kernel.bindings[0].name-185","target_id":"run_ray_tracing_kernel.bindings[0].target_id-187"},{"kind":"uniform_buffer","name":"run_ray_tracing_kernel.bindings[1].name-188","target_id":"run_ray_tracing_kernel.bindings[1].target_id-190"}],"depth":191,"height":192,"kernel_id":"run_ray_tracing_kernel.kernel_id-193","push_constants_hex":"run_ray_tracing_kernel.push_constants_hex-194","request_id":"run_ray_tracing_kernel.request_id-195","width":196}"#, TryRunCpuReadbackCopy => r#"{"op":"try_run_cpu_readback_copy","direction":"image_to_buffer","request_id":"try_run_cpu_readback_copy.request_id-198","surface_id":"try_run_cpu_readback_copy.surface_id-199"}"#, WaitDeviceIdle => r#"{"op":"wait_device_idle","request_id":"wait_device_idle.request_id-200"}"#, ); @@ -138,7 +161,14 @@ fn escalate_enum_variants_keep_their_wire_spelling() { StorageImage => "storage_image", UniformBuffer => "uniform_buffer", } - EscalateRequestRegisterGraphicsKernelBindingKind { + EscalateGraphicsBindingKind { + SampledTexture => "sampled_texture", + StorageBuffer => "storage_buffer", + StorageImage => "storage_image", + UniformBuffer => "uniform_buffer", + } + EscalateRayTracingBindingKind { + AccelerationStructure => "acceleration_structure", SampledTexture => "sampled_texture", StorageBuffer => "storage_buffer", StorageImage => "storage_image", @@ -288,13 +318,6 @@ fn escalate_enum_variants_keep_their_wire_spelling() { D24UnormS8Uint => "d24_unorm_s8_uint", D32Sfloat => "d32_sfloat", } - EscalateRequestRegisterRayTracingKernelBindingKind { - AccelerationStructure => "acceleration_structure", - SampledTexture => "sampled_texture", - StorageBuffer => "storage_buffer", - StorageImage => "storage_image", - UniformBuffer => "uniform_buffer", - } EscalateRequestRegisterRayTracingKernelGroupKind { General => "general", ProceduralHit => "procedural_hit", @@ -312,12 +335,6 @@ fn escalate_enum_variants_keep_their_wire_spelling() { BufferToImage => "buffer_to_image", ImageToBuffer => "image_to_buffer", } - EscalateRequestRunGraphicsDrawBindingKind { - SampledTexture => "sampled_texture", - StorageBuffer => "storage_buffer", - StorageImage => "storage_image", - UniformBuffer => "uniform_buffer", - } EscalateRequestRunGraphicsDrawDrawKind { Draw => "draw", DrawIndexed => "draw_indexed", @@ -326,13 +343,6 @@ fn escalate_enum_variants_keep_their_wire_spelling() { Uint16 => "uint16", Uint32 => "uint32", } - EscalateRequestRunRayTracingKernelBindingKind { - AccelerationStructure => "acceleration_structure", - SampledTexture => "sampled_texture", - StorageBuffer => "storage_buffer", - StorageImage => "storage_image", - UniformBuffer => "uniform_buffer", - } EscalateRequestTryRunCpuReadbackCopyDirection { BufferToImage => "buffer_to_image", ImageToBuffer => "image_to_buffer", @@ -340,6 +350,39 @@ fn escalate_enum_variants_keep_their_wire_spelling() { } } +/// A binding kind's `wire_name()` is the same string its `#[serde(rename)]` +/// writes. +/// +/// The two are hand-written copies of one spelling: a register response hands +/// the name back through `wire_name()`, and the next run echoes it as a `kind` +/// serde has to decode. A drift between them makes every such echo undecodable, +/// and nothing but this test connects the pair. +#[test] +fn every_binding_kinds_wire_name_is_its_serde_spelling() { + assert_wire_name_is_the_serde_spelling! { + EscalateComputeBindingKind { + SampledImage, + SampledTexture, + StorageBuffer, + StorageImage, + UniformBuffer, + } + EscalateGraphicsBindingKind { + SampledTexture, + StorageBuffer, + StorageImage, + UniformBuffer, + } + EscalateRayTracingBindingKind { + AccelerationStructure, + SampledTexture, + StorageBuffer, + StorageImage, + UniformBuffer, + } + } +} + /// An absent optional is omitted from the encoding, never written as null. #[test] fn absent_optionals_are_omitted_on_a_response() { diff --git a/runtime/streamlib-engine/src/core/context/gpu_context.rs b/runtime/streamlib-engine/src/core/context/gpu_context.rs index be5602fa4..a1887d9f1 100644 --- a/runtime/streamlib-engine/src/core/context/gpu_context.rs +++ b/runtime/streamlib-engine/src/core/context/gpu_context.rs @@ -3,6 +3,8 @@ use crate::core::context::TextureRegistration; use crate::core::media_clock::MediaClock; +#[cfg(target_os = "linux")] +use crate::core::rhi::KernelShaderStageMask; use crate::core::rhi::{ CommandBuffer, GpuDevice, PixelBuffer, PixelBufferDescriptor, PixelBufferPoolSlotId, PixelFormat, PublishedPixelBufferFrameId, RhiBlitter, RhiColorConverter, RhiCommandQueue, @@ -53,10 +55,6 @@ impl RhiBlitter for NoOpBlitter { fn clear_cache(&self) {} } -#[cfg(target_os = "linux")] -use super::graphics_kernel_bridge::GraphicsKernelBridge; -#[cfg(target_os = "linux")] -use super::ray_tracing_kernel_bridge::RayTracingKernelBridge; use super::surface_store::SurfaceStore; use super::texture_pool::{ PooledTextureHandle, TexturePool, TexturePoolConfig, TexturePoolDescriptor, @@ -222,6 +220,116 @@ fn compute_kernel_cache_key(spv: &[u8], push_constant_size: u32, entry_point: &s format!("{:x}", hasher.finalize()) } +/// Digest a variable-length part of a cache key. +/// +/// The length prefix is what keeps two different splits of the same +/// concatenated bytes from hashing the same. +#[cfg(target_os = "linux")] +fn digest_length_prefixed(hasher: &mut sha2::Sha256, bytes: &[u8]) { + use sha2::Digest as _; + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); +} + +/// Cache key for a graphics kernel. +/// +/// Everything that changes the pipeline the driver builds is in the digest. +/// The fixed-function state goes in through its `Debug` rendering because that +/// is total: a field added to `GraphicsPipelineState` joins the key without +/// anyone remembering to add it, which a hand-enumerated digest cannot promise. +/// The key never leaves this process, so its stability across builds buys +/// nothing that would justify the alternative. +#[cfg(target_os = "linux")] +fn graphics_kernel_cache_key( + stages: &[crate::core::rhi::GraphicsStage<'_>], + push_constants: crate::core::rhi::GraphicsPushConstants, + pipeline_state: &crate::core::rhi::GraphicsPipelineState, + descriptor_sets_in_flight: u32, +) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update((stages.len() as u64).to_le_bytes()); + for stage in stages { + hasher.update((stage.stage as u32).to_le_bytes()); + digest_length_prefixed(&mut hasher, stage.spv); + digest_length_prefixed(&mut hasher, stage.entry_point.as_bytes()); + } + hasher.update(push_constants.size.to_le_bytes()); + hasher.update(push_constants.stages.bits().to_le_bytes()); + hasher.update(descriptor_sets_in_flight.to_le_bytes()); + digest_length_prefixed(&mut hasher, format!("{pipeline_state:?}").as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Cache key for a ray-tracing kernel. +/// +/// Same shape as the graphics key; the shader-group layout and the recursion +/// depth take the place of the fixed-function state, since those are what the +/// driver builds the pipeline and its binding table from. +#[cfg(target_os = "linux")] +fn ray_tracing_kernel_cache_key( + stages: &[crate::core::rhi::RayTracingStage<'_>], + groups: &[crate::core::rhi::RayTracingShaderGroup], + push_constants: crate::core::rhi::RayTracingPushConstants, + max_recursion_depth: u32, +) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update((stages.len() as u64).to_le_bytes()); + for stage in stages { + hasher.update((stage.stage as u32).to_le_bytes()); + digest_length_prefixed(&mut hasher, stage.spv); + digest_length_prefixed(&mut hasher, stage.entry_point.as_bytes()); + } + hasher.update(push_constants.size.to_le_bytes()); + hasher.update(push_constants.stages.bits().to_le_bytes()); + hasher.update(max_recursion_depth.to_le_bytes()); + digest_length_prefixed(&mut hasher, format!("{groups:?}").as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Settle a caller's push-constant declaration against what the shaders +/// reflect, for a kernel kind whose push-constant range carries a stage mask. +/// +/// The size must agree outright. The stage mask follows the same rule a +/// binding's does — a declaration may widen visibility past what the shaders +/// read, never narrow it below — and an empty mask asserts nothing, so the +/// reflected one stands. +/// +/// Generic over the mask rather than written once per kernel kind, for the same +/// reason the shared binding reconciliation is: graphics and ray tracing differ +/// only in which unrelated `u32` newtype they name, and +/// [`KernelShaderStageMask`] is the seam that already spans both. +#[cfg(target_os = "linux")] +fn reconciled_push_constant_stages( + kernel_kind_label: &str, + declared_size: u32, + declared_stages: Stages, + reflected_size: u32, + reflected_stages: Stages, +) -> Result { + if declared_size != reflected_size { + return Err(Error::GpuError(format!( + "{kernel_kind_label} kernel declares {declared_size} push-constant bytes but its \ + shaders reflect {reflected_size}" + ))); + } + if declared_stages.names_no_stage() { + return Ok(reflected_stages); + } + if !declared_stages.contains_every_stage_in(reflected_stages) { + return Err(Error::GpuError(format!( + "{kernel_kind_label} kernel declares its push constants for {} but its shaders also \ + read them from {}", + crate::core::rhi::quote_shader_stage_names(&declared_stages.named_stages()), + crate::core::rhi::quote_shader_stage_names( + &reflected_stages.stages_missing_from(declared_stages) + ) + ))); + } + Ok(declared_stages) +} + impl PixelBufferPoolManager { fn new(device: Arc) -> Self { Self { @@ -639,7 +747,7 @@ pub struct BatchedComputeKernelDispatchBinding { pub binding: u32, /// How this dispatch uses the surface, which decides both the descriptor /// write and the layout its barrier moves the texture into. - pub kind: crate::core::rhi::SurfaceBoundComputeBindingKind, + pub kind: crate::core::rhi::SurfaceBoundKernelBindingKind, /// The bound texture and the layout it is currently tracked in — the /// barrier's source layout. pub registration: TextureRegistration, @@ -655,13 +763,13 @@ impl BatchedComputeKernelDispatchBinding { &self, kernel: &crate::vulkan::rhi::VulkanComputeKernel, ) -> Result<()> { - use crate::core::rhi::SurfaceBoundComputeBindingKind; + use crate::core::rhi::SurfaceBoundKernelBindingKind; let texture = self.registration.texture(); match self.kind { - SurfaceBoundComputeBindingKind::StorageImage => { + SurfaceBoundKernelBindingKind::StorageImage => { kernel.set_storage_image(self.binding, texture) } - SurfaceBoundComputeBindingKind::SampledTexture => { + SurfaceBoundKernelBindingKind::SampledTexture => { kernel.set_sampled_texture(self.binding, texture) } } @@ -768,25 +876,28 @@ pub struct GpuContext { #[cfg(target_os = "linux")] batched_compute_dispatch_recorder: Arc>>, - /// Host-side bridge for the graphics-kernel escalate ops - /// (`register_graphics_kernel`, `run_graphics_draw`). Wired by - /// application code that exposes the host's - /// [`crate::vulkan::rhi::VulkanGraphicsKernel`] to subprocess customers; - /// left unset on hosts that don't expose graphics dispatch (the - /// escalate handler responds with an `Err` in that case). - #[cfg(target_os = "linux")] - graphics_kernel_bridge: Arc>>>, - /// Host-side bridge for the ray-tracing-kernel escalate ops - /// (`register_acceleration_structure_blas`, - /// `register_acceleration_structure_tlas`, `register_ray_tracing_kernel`, - /// `run_ray_tracing_kernel`). Wired by application code that exposes the - /// host's [`crate::vulkan::rhi::VulkanRayTracingKernel`] + - /// [`crate::vulkan::rhi::VulkanAccelerationStructure`] to subprocess - /// customers; left unset on hosts that don't expose RT dispatch (the - /// escalate handler responds with an `Err` in that case, as does any - /// device that lacks the `VK_KHR_ray_tracing_pipeline` extension chain). - #[cfg(target_os = "linux")] - ray_tracing_kernel_bridge: Arc>>>, + /// Graphics kernels built for the `register_graphics_kernel` escalate op, + /// keyed the same way `compute_kernel_cache` is and with the same + /// lifetime. + #[cfg(target_os = "linux")] + graphics_kernel_cache: + Arc>>>, + /// Ray-tracing kernels built for the `register_ray_tracing_kernel` + /// escalate op, keyed the same way `compute_kernel_cache` is and with the + /// same lifetime. + #[cfg(target_os = "linux")] + ray_tracing_kernel_cache: + Arc>>>, + /// Acceleration structures built for the + /// `register_acceleration_structure_blas` / `_tlas` escalate ops. + /// + /// A registry, not a cache: two builds of the same geometry are two + /// structures under two ids, because an acceleration structure holds + /// device memory proportional to its mesh and deduplicating them by + /// content would retain every mesh any helper ever built. + #[cfg(target_os = "linux")] + acceleration_structure_registry: + Arc>>>, } impl GpuContext { @@ -817,9 +928,11 @@ impl GpuContext { #[cfg(target_os = "linux")] batched_compute_dispatch_recorder: Arc::new(parking_lot::Mutex::new(None)), #[cfg(target_os = "linux")] - graphics_kernel_bridge: Arc::new(Mutex::new(None)), + graphics_kernel_cache: Arc::new(Mutex::new(HashMap::new())), + #[cfg(target_os = "linux")] + ray_tracing_kernel_cache: Arc::new(Mutex::new(HashMap::new())), #[cfg(target_os = "linux")] - ray_tracing_kernel_bridge: Arc::new(Mutex::new(None)), + acceleration_structure_registry: Arc::new(Mutex::new(HashMap::new())), } } @@ -850,9 +963,11 @@ impl GpuContext { #[cfg(target_os = "linux")] batched_compute_dispatch_recorder: Arc::new(parking_lot::Mutex::new(None)), #[cfg(target_os = "linux")] - graphics_kernel_bridge: Arc::new(Mutex::new(None)), + graphics_kernel_cache: Arc::new(Mutex::new(HashMap::new())), #[cfg(target_os = "linux")] - ray_tracing_kernel_bridge: Arc::new(Mutex::new(None)), + ray_tracing_kernel_cache: Arc::new(Mutex::new(HashMap::new())), + #[cfg(target_os = "linux")] + acceleration_structure_registry: Arc::new(Mutex::new(HashMap::new())), } } @@ -2745,6 +2860,274 @@ impl GpuContext { .map(Arc::clone) } + /// Build a graphics kernel for the `register_graphics_kernel` escalate op, + /// or hand back one an identical earlier registration already built. + /// + /// The twin of [`Self::create_or_reuse_compute_kernel`], and the same + /// contract: reflection is the source of truth for the binding shape, the + /// caller's declaration is checked against it by name rather than + /// replacing it, and the cache key is what the caller gets back as the + /// kernel id. + /// + /// `label` names the pipeline in RHI errors and validation-layer messages, + /// and is deliberately outside the cache key — two registrations differing + /// only in label are one pipeline, and the first one's label is the one the + /// driver keeps. + #[cfg(target_os = "linux")] + pub fn create_or_reuse_graphics_kernel( + &self, + label: &str, + stages: &[crate::core::rhi::GraphicsStage<'_>], + declared_push_constants: crate::core::rhi::GraphicsPushConstants, + pipeline_state: &crate::core::rhi::GraphicsPipelineState, + descriptor_sets_in_flight: u32, + declared_bindings: &[crate::core::rhi::GraphicsBindingDeclaration], + ) -> Result<(String, Arc)> { + use crate::core::rhi::GraphicsShaderStageFlags; + + // Both stages are always present — `VulkanGraphicsKernel::new` refuses + // anything else — so every graphics kernel is built from both. + let stages_the_kernel_was_built_from = GraphicsShaderStageFlags::VERTEX_FRAGMENT; + let kernel_id = graphics_kernel_cache_key( + stages, + declared_push_constants, + pipeline_state, + descriptor_sets_in_flight, + ); + let cached_kernel = self + .graphics_kernel_cache + .lock() + .unwrap() + .get(&kernel_id) + .map(Arc::clone); + if let Some(cached) = cached_kernel { + // The declaration is checked on the hit path too: the cache key + // covers the shaders and the pipeline, not the caller's assertion, + // and a wrong assertion must refuse identically whether or not + // somebody registered this kernel first. + crate::core::rhi::reconcile_graphics_binding_declarations( + declared_bindings, + &cached.bindings(), + stages_the_kernel_was_built_from, + )?; + tracing::debug!( + rhi_op = "create_or_reuse_graphics_kernel", + kernel_id, + "GpuContext::create_or_reuse_graphics_kernel — cache hit" + ); + return Ok((kernel_id, Arc::clone(&cached))); + } + + let (reflected, reflected_push_constants) = + crate::core::rhi::derive_bindings_from_spirv_multistage(stages)?; + crate::core::rhi::reconcile_graphics_binding_declarations( + declared_bindings, + &reflected, + stages_the_kernel_was_built_from, + )?; + let push_constants = crate::core::rhi::GraphicsPushConstants { + size: reflected_push_constants.size, + stages: reconciled_push_constant_stages( + "graphics", + declared_push_constants.size, + declared_push_constants.stages, + reflected_push_constants.size, + reflected_push_constants.stages, + )?, + }; + + let kernel = Arc::new(self.create_graphics_kernel( + &crate::core::rhi::GraphicsKernelDescriptor { + label, + stages, + bindings: &reflected, + push_constants, + pipeline_state: pipeline_state.clone(), + descriptor_sets_in_flight, + }, + )?); + + Ok(( + kernel_id.clone(), + Arc::clone( + self.graphics_kernel_cache + .lock() + .unwrap() + .entry(kernel_id) + .or_insert(kernel), + ), + )) + } + + /// Look up a graphics kernel a prior `create_or_reuse_graphics_kernel` + /// returned. + #[cfg(target_os = "linux")] + pub fn graphics_kernel_by_id( + &self, + kernel_id: &str, + ) -> Option> { + self.graphics_kernel_cache + .lock() + .unwrap() + .get(kernel_id) + .map(Arc::clone) + } + + /// Build a ray-tracing kernel for the `register_ray_tracing_kernel` + /// escalate op, or hand back one an identical earlier registration already + /// built. + /// + /// The twin of [`Self::create_or_reuse_compute_kernel`]. Unlike graphics, + /// a ray-tracing kernel's stage set varies per kernel, so the stages it was + /// actually built from are what a declaration naming a stage is checked + /// against. + /// + /// `label` names the pipeline in RHI errors and validation-layer messages, + /// and is deliberately outside the cache key — two registrations differing + /// only in label are one pipeline, and the first one's label is the one the + /// driver keeps. + #[cfg(target_os = "linux")] + pub fn create_or_reuse_ray_tracing_kernel( + &self, + label: &str, + stages: &[crate::core::rhi::RayTracingStage<'_>], + groups: &[crate::core::rhi::RayTracingShaderGroup], + declared_push_constants: crate::core::rhi::RayTracingPushConstants, + max_recursion_depth: u32, + declared_bindings: &[crate::core::rhi::RayTracingBindingDeclaration], + ) -> Result<(String, Arc)> { + let stages_the_kernel_was_built_from = + crate::core::rhi::ray_tracing_stages_covered_by(stages); + let kernel_id = ray_tracing_kernel_cache_key( + stages, + groups, + declared_push_constants, + max_recursion_depth, + ); + let cached_kernel = self + .ray_tracing_kernel_cache + .lock() + .unwrap() + .get(&kernel_id) + .map(Arc::clone); + if let Some(cached) = cached_kernel { + crate::core::rhi::reconcile_ray_tracing_binding_declarations( + declared_bindings, + &cached.bindings(), + stages_the_kernel_was_built_from, + )?; + tracing::debug!( + rhi_op = "create_or_reuse_ray_tracing_kernel", + kernel_id, + "GpuContext::create_or_reuse_ray_tracing_kernel — cache hit" + ); + return Ok((kernel_id, Arc::clone(&cached))); + } + + let (reflected, reflected_push_constants) = + crate::core::rhi::derive_ray_tracing_bindings_from_spirv_multistage(stages)?; + crate::core::rhi::reconcile_ray_tracing_binding_declarations( + declared_bindings, + &reflected, + stages_the_kernel_was_built_from, + )?; + let push_constants = crate::core::rhi::RayTracingPushConstants { + size: reflected_push_constants.size, + stages: reconciled_push_constant_stages( + "ray-tracing", + declared_push_constants.size, + declared_push_constants.stages, + reflected_push_constants.size, + reflected_push_constants.stages, + )?, + }; + + let kernel = Arc::new(self.create_ray_tracing_kernel( + &crate::core::rhi::RayTracingKernelDescriptor { + label, + stages, + groups, + bindings: &reflected, + push_constants, + max_recursion_depth, + }, + )?); + + Ok(( + kernel_id.clone(), + Arc::clone( + self.ray_tracing_kernel_cache + .lock() + .unwrap() + .entry(kernel_id) + .or_insert(kernel), + ), + )) + } + + /// Look up a ray-tracing kernel a prior + /// `create_or_reuse_ray_tracing_kernel` returned. + #[cfg(target_os = "linux")] + pub fn ray_tracing_kernel_by_id( + &self, + kernel_id: &str, + ) -> Option> { + self.ray_tracing_kernel_cache + .lock() + .unwrap() + .get(kernel_id) + .map(Arc::clone) + } + + /// Take ownership of a freshly built acceleration structure and return the + /// id a later trace names it by. + /// + /// Every call mints a fresh id: an acceleration structure holds device + /// memory proportional to its mesh, so unlike a kernel it is registered + /// rather than deduplicated by content. + #[cfg(target_os = "linux")] + pub fn register_acceleration_structure( + &self, + acceleration_structure: crate::vulkan::rhi::VulkanAccelerationStructure, + ) -> String { + let acceleration_structure_id = uuid::Uuid::new_v4().to_string(); + self.acceleration_structure_registry.lock().unwrap().insert( + acceleration_structure_id.clone(), + Arc::new(acceleration_structure), + ); + acceleration_structure_id + } + + /// Look up an acceleration structure a prior + /// `register_acceleration_structure` returned the id of. + #[cfg(target_os = "linux")] + pub fn acceleration_structure_by_id( + &self, + acceleration_structure_id: &str, + ) -> Option> { + self.acceleration_structure_registry + .lock() + .unwrap() + .get(acceleration_structure_id) + .map(Arc::clone) + } + + /// Drop the registry's strong reference to an acceleration structure, + /// answering whether an entry was there to drop. + /// + /// This is what a Rust caller's `VulkanAccelerationStructure` going out of + /// scope does. The device memory returns once the last reference does — a + /// TLAS holds its own reference to every BLAS it instances, so releasing a + /// BLAS a scene still uses frees nothing until the scene goes too. + #[cfg(target_os = "linux")] + pub fn release_acceleration_structure(&self, acceleration_structure_id: &str) -> bool { + self.acceleration_structure_registry + .lock() + .unwrap() + .remove(acceleration_structure_id) + .is_some() + } + /// Record every dispatch in `batch` into one command buffer, submit once, /// and return when that submission has retired. /// @@ -2926,48 +3309,6 @@ impl GpuContext { Ok(layout_during_recording) } - // ========================================================================= - // GraphicsKernelBridge — host-side dispatch for the graphics-kernel escalate ops - // ========================================================================= - - /// Register a [`GraphicsKernelBridge`] implementation. The escalate handler - /// dispatches `register_graphics_kernel` and `run_graphics_draw` requests - /// through this bridge; until it is set, those requests fail with an - /// "unsupported" error response. Linux-only: graphics escalate uses the - /// Linux-side `VulkanGraphicsKernel`. - #[cfg(target_os = "linux")] - pub fn set_graphics_kernel_bridge(&self, bridge: Arc) { - *self.graphics_kernel_bridge.lock().unwrap() = Some(bridge); - } - - /// Get the registered [`GraphicsKernelBridge`], if any. - #[cfg(target_os = "linux")] - pub fn graphics_kernel_bridge(&self) -> Option> { - self.graphics_kernel_bridge.lock().unwrap().clone() - } - - // ========================================================================= - // RayTracingKernelBridge — host-side dispatch for the RT-kernel escalate ops - // ========================================================================= - - /// Register a [`RayTracingKernelBridge`] implementation. The escalate - /// handler dispatches `register_acceleration_structure_blas`, - /// `register_acceleration_structure_tlas`, `register_ray_tracing_kernel`, - /// and `run_ray_tracing_kernel` requests through this bridge; until it - /// is set, those requests fail with an "unsupported" error response. - /// Linux-only: RT escalate uses the Linux-side `VulkanRayTracingKernel` - /// + `VulkanAccelerationStructure`. - #[cfg(target_os = "linux")] - pub fn set_ray_tracing_kernel_bridge(&self, bridge: Arc) { - *self.ray_tracing_kernel_bridge.lock().unwrap() = Some(bridge); - } - - /// Get the registered [`RayTracingKernelBridge`], if any. - #[cfg(target_os = "linux")] - pub fn ray_tracing_kernel_bridge(&self) -> Option> { - self.ray_tracing_kernel_bridge.lock().unwrap().clone() - } - /// Check in a pixel buffer to the surface-share service, returning a surface ID. /// /// The surface ID can be shared with other processes (e.g., Python subprocesses) @@ -3600,8 +3941,6 @@ impl GpuContextFullAccess { /// Wait for the GPU device to become idle. /// - /// Mode-routed; see [`Self::create_compute_kernel`] for the - /// dispatch contract. pub fn wait_device_idle(&self) -> Result<()> { self.host_inner().wait_device_idle() } @@ -4018,8 +4357,6 @@ impl GpuContextFullAccess { /// Create a graphics kernel from a multi-stage SPIR-V set, binding /// declaration, and fixed-function pipeline state. /// - /// Mode-routed; see [`Self::create_compute_kernel`] for the - /// dispatch contract. #[cfg(target_os = "linux")] pub fn create_graphics_kernel( &self, @@ -4031,8 +4368,6 @@ impl GpuContextFullAccess { /// Create a ray-tracing kernel from shader stages, shader-group /// layout, binding declaration, and push-constant range. /// - /// Mode-routed; see [`Self::create_compute_kernel`] for the - /// dispatch contract. #[cfg(target_os = "linux")] pub fn create_ray_tracing_kernel( &self, @@ -4271,24 +4606,94 @@ impl GpuContextFullAccess { self.host_inner().dispatch_compute_kernel_batch(batch) } - /// Get the registered graphics-kernel bridge, if any. Reachable only inside - /// `escalate(|full| ...)` since it requires `FullAccess`. - /// - /// **Engine-only** — trait-object return, which no cross-DSO surface - /// can carry. + /// Runs the host's [`GpuContext::create_or_reuse_graphics_kernel`]. #[cfg(target_os = "linux")] - pub fn graphics_kernel_bridge(&self) -> Option> { - self.host_inner().graphics_kernel_bridge() + pub fn create_or_reuse_graphics_kernel( + &self, + label: &str, + stages: &[crate::core::rhi::GraphicsStage<'_>], + declared_push_constants: crate::core::rhi::GraphicsPushConstants, + pipeline_state: &crate::core::rhi::GraphicsPipelineState, + descriptor_sets_in_flight: u32, + declared_bindings: &[crate::core::rhi::GraphicsBindingDeclaration], + ) -> Result<(String, Arc)> { + self.host_inner().create_or_reuse_graphics_kernel( + label, + stages, + declared_push_constants, + pipeline_state, + descriptor_sets_in_flight, + declared_bindings, + ) } - /// Get the registered ray-tracing-kernel bridge, if any. Reachable only - /// inside `escalate(|full| ...)` since it requires `FullAccess`. - /// - /// **Engine-only** — trait-object return, which no cross-DSO surface - /// can carry. + /// Look up a graphics kernel a prior `create_or_reuse_graphics_kernel` + /// returned. + #[cfg(target_os = "linux")] + pub fn graphics_kernel_by_id( + &self, + kernel_id: &str, + ) -> Option> { + self.host_inner().graphics_kernel_by_id(kernel_id) + } + + /// Runs the host's [`GpuContext::create_or_reuse_ray_tracing_kernel`]. + #[cfg(target_os = "linux")] + pub fn create_or_reuse_ray_tracing_kernel( + &self, + label: &str, + stages: &[crate::core::rhi::RayTracingStage<'_>], + groups: &[crate::core::rhi::RayTracingShaderGroup], + declared_push_constants: crate::core::rhi::RayTracingPushConstants, + max_recursion_depth: u32, + declared_bindings: &[crate::core::rhi::RayTracingBindingDeclaration], + ) -> Result<(String, Arc)> { + self.host_inner().create_or_reuse_ray_tracing_kernel( + label, + stages, + groups, + declared_push_constants, + max_recursion_depth, + declared_bindings, + ) + } + + /// Look up a ray-tracing kernel a prior + /// `create_or_reuse_ray_tracing_kernel` returned. + #[cfg(target_os = "linux")] + pub fn ray_tracing_kernel_by_id( + &self, + kernel_id: &str, + ) -> Option> { + self.host_inner().ray_tracing_kernel_by_id(kernel_id) + } + + /// Runs the host's [`GpuContext::register_acceleration_structure`]. + #[cfg(target_os = "linux")] + pub fn register_acceleration_structure( + &self, + acceleration_structure: crate::vulkan::rhi::VulkanAccelerationStructure, + ) -> String { + self.host_inner() + .register_acceleration_structure(acceleration_structure) + } + + /// Look up an acceleration structure a prior + /// `register_acceleration_structure` returned the id of. #[cfg(target_os = "linux")] - pub fn ray_tracing_kernel_bridge(&self) -> Option> { - self.host_inner().ray_tracing_kernel_bridge() + pub fn acceleration_structure_by_id( + &self, + acceleration_structure_id: &str, + ) -> Option> { + self.host_inner() + .acceleration_structure_by_id(acceleration_structure_id) + } + + /// Runs the host's [`GpuContext::release_acceleration_structure`]. + #[cfg(target_os = "linux")] + pub fn release_acceleration_structure(&self, acceleration_structure_id: &str) -> bool { + self.host_inner() + .release_acceleration_structure(acceleration_structure_id) } } diff --git a/runtime/streamlib-engine/src/core/context/graphics_kernel_bridge.rs b/runtime/streamlib-engine/src/core/context/graphics_kernel_bridge.rs deleted file mode 100644 index ec7b83f7b..000000000 --- a/runtime/streamlib-engine/src/core/context/graphics_kernel_bridge.rs +++ /dev/null @@ -1,358 +0,0 @@ -// Copyright (c) 2025 Jonathan Fontanez -// SPDX-License-Identifier: BUSL-1.1 - -//! Host-side dispatch trait the escalate handler uses to drive graphics -//! kernel registration and per-draw invocation on behalf of subprocess -//! customers. -//! -//! The bridge shape: the subprocess sends a typed IPC, the host runs -//! privileged Vulkan work via its [`crate::core::context::GpuContextFullAccess`], -//! and the bridge keeps the FullAccess capability boundary on the host -//! side of the IPC seam. Compute retired its bridge for an always-present -//! `GpuContext` capability; graphics follows in its own change. -//! -//! Graphics is register-once-draw-many: the subprocess sends the -//! vertex + fragment SPIR-V plus the full pipeline state once; the -//! host reflects bindings + builds the -//! [`crate::vulkan::rhi::VulkanGraphicsKernel`] (with on-disk pipeline -//! cache persistence — same `STREAMLIB_PIPELINE_CACHE_DIR` knob as -//! compute), and caches it keyed by SHA-256 of a canonical -//! description blob. Subsequent `run_draw` calls reference the cached -//! kernel by handle. -//! -//! Subprocesses cannot pass `vk::CommandBuffer` across IPC, so the -//! bridge's `run_draw` always renders one offscreen pass into the -//! caller-provided color targets and submits + waits on the kernel's -//! own command buffer + fence. This matches -//! [`crate::vulkan::rhi::VulkanGraphicsKernel::offscreen_render`]. -//! -//! The trait lives here (in `streamlib`) because the escalate IPC -//! handler is here. Implementations live in application setup glue -//! (or in `streamlib-adapter-vulkan` test utilities) — those can -//! depend on `streamlib`; the reverse cannot. Register an impl via -//! [`crate::core::context::GpuContext::set_graphics_kernel_bridge`] -//! before spawning subprocesses that issue -//! `register_graphics_kernel` / `run_graphics_draw`. - -#![cfg(target_os = "linux")] - -/// Resource kind for a binding slot in the graphics kernel's -/// descriptor set 0. Wire-format mirror of -/// [`crate::core::rhi::GraphicsBindingKind`], kept separate so the -/// bridge surface does not move when the RHI enum does. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GraphicsBindingKindWire { - SampledTexture, - StorageBuffer, - UniformBuffer, - StorageImage, -} - -/// One binding declaration for register-time validation against -/// SPIR-V reflection. -#[derive(Debug, Clone, Copy)] -pub struct GraphicsBindingDecl { - pub binding: u32, - pub kind: GraphicsBindingKindWire, - /// Stage-visibility bitmask: `1 = VERTEX`, `2 = FRAGMENT`. - pub stages: u32, -} - -/// Per-vertex attribute format. Mirrors -/// [`crate::core::rhi::VertexAttributeFormat`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VertexAttributeFormatWire { - R32Float, - Rg32Float, - Rgb32Float, - Rgba32Float, - R32Uint, - Rg32Uint, - Rgb32Uint, - Rgba32Uint, - R32Sint, - Rg32Sint, - Rgb32Sint, - Rgba32Sint, - Rgba8Unorm, - Rgba8Snorm, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VertexInputRateWire { - Vertex, - Instance, -} - -#[derive(Debug, Clone, Copy)] -pub struct VertexInputBindingDecl { - pub binding: u32, - pub stride: u32, - pub input_rate: VertexInputRateWire, -} - -#[derive(Debug, Clone, Copy)] -pub struct VertexInputAttributeDecl { - pub location: u32, - pub binding: u32, - pub format: VertexAttributeFormatWire, - pub offset: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PrimitiveTopologyWire { - PointList, - LineList, - LineStrip, - TriangleList, - TriangleStrip, - TriangleFan, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PolygonModeWire { - Fill, - Line, - Point, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CullModeWire { - None, - Front, - Back, - FrontAndBack, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FrontFaceWire { - CounterClockwise, - Clockwise, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DepthCompareOpWire { - Never, - Less, - Equal, - LessOrEqual, - Greater, - NotEqual, - GreaterOrEqual, - Always, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BlendFactorWire { - Zero, - One, - SrcColor, - OneMinusSrcColor, - DstColor, - OneMinusDstColor, - SrcAlpha, - OneMinusSrcAlpha, - DstAlpha, - OneMinusDstAlpha, - ConstantColor, - OneMinusConstantColor, - ConstantAlpha, - OneMinusConstantAlpha, - SrcAlphaSaturate, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BlendOpWire { - Add, - Subtract, - ReverseSubtract, - Min, - Max, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DepthFormatWire { - D16Unorm, - D32Sfloat, - D24UnormS8Uint, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DynamicStateWire { - None, - ViewportScissor, -} - -/// Pipeline-state mirror used by the bridge `register` call. Owned (no -/// borrows) so the bridge can stash it in its kernel cache. -#[derive(Debug, Clone)] -pub struct GraphicsPipelineStateWire { - pub topology: PrimitiveTopologyWire, - pub vertex_input_bindings: Vec, - pub vertex_input_attributes: Vec, - pub rasterization_polygon_mode: PolygonModeWire, - pub rasterization_cull_mode: CullModeWire, - pub rasterization_front_face: FrontFaceWire, - pub rasterization_line_width: f32, - pub multisample_samples: u32, - pub depth_stencil_enabled: bool, - pub depth_compare_op: DepthCompareOpWire, - pub depth_write: bool, - pub color_blend_enabled: bool, - /// Color write-mask bits — `1=R`, `2=G`, `4=B`, `8=A`. - pub color_write_mask: u32, - pub color_blend_src_color_factor: BlendFactorWire, - pub color_blend_dst_color_factor: BlendFactorWire, - pub color_blend_color_op: BlendOpWire, - pub color_blend_src_alpha_factor: BlendFactorWire, - pub color_blend_dst_alpha_factor: BlendFactorWire, - pub color_blend_alpha_op: BlendOpWire, - /// Color attachment formats — wire-format strings (`"bgra8_unorm"`, - /// `"rgba8_unorm"`, …). The bridge translates these to - /// [`crate::core::rhi::TextureFormat`] before construction. - pub attachment_color_formats: Vec, - pub attachment_depth_format: Option, - pub dynamic_state: DynamicStateWire, -} - -/// Full register-time descriptor passed to -/// [`GraphicsKernelBridge::register`]. Owned mirror of the wire shape. -#[derive(Debug, Clone)] -pub struct GraphicsKernelRegisterDecl { - pub label: String, - pub vertex_spv: Vec, - pub fragment_spv: Vec, - pub vertex_entry_point: String, - pub fragment_entry_point: String, - pub bindings: Vec, - pub push_constant_size: u32, - pub push_constant_stages: u32, - pub descriptor_sets_in_flight: u32, - pub pipeline_state: GraphicsPipelineStateWire, -} - -/// Per-draw binding value. -#[derive(Debug, Clone)] -pub struct GraphicsBindingValue { - pub binding: u32, - pub kind: GraphicsBindingKindWire, - pub surface_uuid: String, -} - -#[derive(Debug, Clone)] -pub struct GraphicsVertexBufferBinding { - pub binding: u32, - pub surface_uuid: String, - pub offset: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IndexTypeWire { - Uint16, - Uint32, -} - -#[derive(Debug, Clone)] -pub struct GraphicsIndexBufferBinding { - pub surface_uuid: String, - pub offset: u64, - pub index_type: IndexTypeWire, -} - -#[derive(Debug, Clone, Copy)] -pub struct ViewportWire { - pub x: f32, - pub y: f32, - pub width: f32, - pub height: f32, - pub min_depth: f32, - pub max_depth: f32, -} - -#[derive(Debug, Clone, Copy)] -pub struct ScissorRectWire { - pub x: i32, - pub y: i32, - pub width: u32, - pub height: u32, -} - -/// Draw-call discriminator + parameters. Indexed and non-indexed share -/// the wire shape; the host bridge dispatches the right -/// `vkCmdDraw` / `vkCmdDrawIndexed` based on `kind`. -#[derive(Debug, Clone, Copy)] -pub enum GraphicsDrawSpec { - Draw { - vertex_count: u32, - instance_count: u32, - first_vertex: u32, - first_instance: u32, - }, - DrawIndexed { - index_count: u32, - instance_count: u32, - first_index: u32, - vertex_offset: i32, - first_instance: u32, - }, -} - -/// Full per-draw input passed to [`GraphicsKernelBridge::run_draw`]. -#[derive(Debug, Clone)] -pub struct GraphicsKernelRunDraw { - pub kernel_id: String, - pub frame_index: u32, - pub bindings: Vec, - pub vertex_buffers: Vec, - pub index_buffer: Option, - pub color_target_uuids: Vec, - pub depth_target_uuid: Option, - pub extent: (u32, u32), - pub push_constants: Vec, - pub viewport: Option, - pub scissor: Option, - pub draw: GraphicsDrawSpec, -} - -/// Dispatch trait the host runtime uses to drive graphics kernel -/// registration and per-draw invocation for subprocess customers. -/// -/// Graphics dispatch on the host is synchronous: the bridge's -/// `run_draw` blocks on the kernel's fence inside -/// [`crate::vulkan::rhi::VulkanGraphicsKernel::offscreen_render`] -/// before returning, so by the time this returns, the GPU work has -/// retired and the host's writes to the color attachments are visible -/// to any subsequent submission against the same VkDevice. The -/// subprocess can safely advance its surface-share timeline on -/// receipt of the `ok` response. -pub trait GraphicsKernelBridge: Send + Sync { - /// Register a graphics kernel. Returns a stable `kernel_id` — - /// re-registering an identical descriptor (same SPIR-V, same - /// pipeline state, same bindings) hits the host-side cache and - /// returns the same id without re-reflecting or rebuilding the - /// pipeline. - /// - /// The `kernel_id` shape is **implementation-defined** — the - /// escalate handler treats it as an opaque string and the - /// subprocess uses it only as a `run_draw` reference, so - /// implementations are free to canonicalize whichever subset of - /// `decl` makes sense for their cache shape. The recommended - /// pattern is SHA-256 hex over a canonical byte representation - /// of the inputs that *materially* determine the host-side - /// `VulkanGraphicsKernel` (mirroring compute's SHA-256(spv) - /// approach but extended to stage SPIR-V + pipeline state). - /// Identical descriptors must collide on `kernel_id` for the - /// register-cache to work; differing descriptors should not - /// collide, but the bridge — not the trait — owns that contract. - fn register(&self, decl: &GraphicsKernelRegisterDecl) -> Result; - - /// Run one draw against a previously-registered kernel. - /// - /// Resolves binding `surface_uuid`s through the application- - /// provided UUID → resource map, then submits + waits on the - /// kernel's own command buffer + fence (offscreen-render shape). - /// Errors include unrecognized `kernel_id`, surface lookup - /// failure, push-constant size mismatch, and Vulkan submit - /// failure. - fn run_draw(&self, draw: &GraphicsKernelRunDraw) -> Result<(), String>; -} diff --git a/runtime/streamlib-engine/src/core/context/mod.rs b/runtime/streamlib-engine/src/core/context/mod.rs index 3c2940642..3f4c3cca0 100644 --- a/runtime/streamlib-engine/src/core/context/mod.rs +++ b/runtime/streamlib-engine/src/core/context/mod.rs @@ -4,11 +4,7 @@ mod audio_clock; pub(crate) mod escalate_gate; mod gpu_context; -#[cfg(target_os = "linux")] -mod graphics_kernel_bridge; pub(crate) mod isolation; -#[cfg(target_os = "linux")] -mod ray_tracing_kernel_bridge; mod runtime_context; pub(crate) mod surface_check_out_lease_registry; #[cfg(target_os = "linux")] @@ -28,25 +24,8 @@ pub use gpu_context::GpuCapabilitiesSnapshot; #[cfg(target_os = "linux")] pub use gpu_context::{BatchedComputeKernelDispatch, BatchedComputeKernelDispatchBinding}; pub use gpu_context::{GpuContext, GpuContextFullAccess, GpuContextLimitedAccess}; -#[cfg(target_os = "linux")] -pub use graphics_kernel_bridge::{ - BlendFactorWire, BlendOpWire, CullModeWire, DepthCompareOpWire, DepthFormatWire, - DynamicStateWire, FrontFaceWire, GraphicsBindingDecl, GraphicsBindingKindWire, - GraphicsBindingValue, GraphicsDrawSpec, GraphicsIndexBufferBinding, GraphicsKernelBridge, - GraphicsKernelRegisterDecl, GraphicsKernelRunDraw, GraphicsPipelineStateWire, - GraphicsVertexBufferBinding, IndexTypeWire, PolygonModeWire, PrimitiveTopologyWire, - ScissorRectWire, VertexAttributeFormatWire, VertexInputAttributeDecl, VertexInputBindingDecl, - VertexInputRateWire, ViewportWire, -}; pub(crate) use isolation::FullAccessGrant; pub use isolation::IsolationTier; -#[cfg(target_os = "linux")] -pub use ray_tracing_kernel_bridge::{ - BlasRegisterDecl, RAY_TRACING_STAGE_INDEX_NONE, RayTracingBindingDecl, - RayTracingBindingKindWire, RayTracingBindingValue, RayTracingKernelBridge, - RayTracingKernelRegisterDecl, RayTracingKernelRunDispatch, RayTracingShaderGroupWire, - RayTracingShaderStageWire, RayTracingStageDecl, TlasInstanceDeclWire, TlasRegisterDecl, -}; pub use runtime_context::{RuntimeContext, RuntimeContextFullAccess, RuntimeContextLimitedAccess}; pub use surface_check_out_lease_registry::{ SurfaceCheckOutLeaseHandOff, SurfaceCheckOutLeaseHolderId, SurfaceCheckOutLeaseRegistry, diff --git a/runtime/streamlib-engine/src/core/context/ray_tracing_kernel_bridge.rs b/runtime/streamlib-engine/src/core/context/ray_tracing_kernel_bridge.rs deleted file mode 100644 index 415c5d40e..000000000 --- a/runtime/streamlib-engine/src/core/context/ray_tracing_kernel_bridge.rs +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) 2025 Jonathan Fontanez -// SPDX-License-Identifier: BUSL-1.1 - -//! Host-side dispatch trait the escalate handler uses to drive ray-tracing -//! kernel + acceleration-structure registration and per-trace invocation -//! on behalf of subprocess customers. -//! -//! Mirrors the [`super::graphics_kernel_bridge::GraphicsKernelBridge`] -//! shape: the subprocess sends a typed IPC, the host runs privileged -//! Vulkan work via its [`crate::core::context::GpuContextFullAccess`], and -//! the bridge keeps the FullAccess capability boundary on the host side -//! of the IPC seam. Compute retired its bridge for an always-present -//! `GpuContext` capability; ray tracing follows in its own change. -//! -//! Ray-tracing has two register ops where compute and graphics have one: -//! the bridge owns BLAS + TLAS construction (via -//! [`crate::vulkan::rhi::VulkanAccelerationStructure`]) AND kernel -//! construction (via [`crate::vulkan::rhi::VulkanRayTracingKernel`]). -//! Subprocess customers send opaque `as_id` / `kernel_id` handles plus -//! per-trace push constants, never raw `vkalia` calls. -//! -//! The trait lives here (in `streamlib`) because the escalate IPC -//! handler is here. Implementations live in application setup glue (or -//! in `streamlib-adapter-vulkan` test utilities) — those can depend on -//! `streamlib`; the reverse cannot. Register an impl via -//! [`crate::core::context::GpuContext::set_ray_tracing_kernel_bridge`] -//! before spawning subprocesses that issue -//! `register_ray_tracing_kernel` / `run_ray_tracing_kernel`. - -#![cfg(target_os = "linux")] - -/// Resource kind for a binding slot in the RT kernel's descriptor set 0. -/// Wire-format mirror of [`crate::core::rhi::RayTracingBindingKind`], -/// kept separate so the bridge surface does not move when the RHI -/// enum does. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RayTracingBindingKindWire { - StorageBuffer, - UniformBuffer, - SampledTexture, - StorageImage, - AccelerationStructure, -} - -/// One binding declaration for register-time validation against -/// SPIR-V reflection. -#[derive(Debug, Clone, Copy)] -pub struct RayTracingBindingDecl { - pub binding: u32, - pub kind: RayTracingBindingKindWire, - /// Stage-visibility bitmask (`1=RAYGEN`, `2=MISS`, `4=CLOSEST_HIT`, - /// `8=ANY_HIT`, `16=INTERSECTION`, `32=CALLABLE`). - pub stages: u32, -} - -/// Which RT stage a SPIR-V blob fills. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RayTracingShaderStageWire { - RayGen, - Miss, - ClosestHit, - AnyHit, - Intersection, - Callable, -} - -/// One stage of a ray-tracing pipeline. Owned mirror of -/// [`crate::core::rhi::RayTracingStage`]. -#[derive(Debug, Clone)] -pub struct RayTracingStageDecl { - pub stage: RayTracingShaderStageWire, - pub spv: Vec, - /// Empty string is normalized to `"main"` host-side. - pub entry_point: String, -} - -/// Sentinel value the wire format uses to mean "this optional stage -/// index is absent" (the field is always present on the wire). Mirrors what the -/// subprocess SDK serializes for absent group fields. -pub const RAY_TRACING_STAGE_INDEX_NONE: u32 = u32::MAX; - -/// One shader group entry. Mirrors -/// [`crate::core::rhi::RayTracingShaderGroup`] but uses sentinel-encoded -/// optional fields rather than `Option` so the wire shape and the -/// bridge-domain shape line up 1:1. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RayTracingShaderGroupWire { - /// Contributes one ray-gen, miss, or callable stage. - General { - /// Index into [`RayTracingKernelRegisterDecl::stages`]. - general_stage: u32, - }, - /// Triangle hit group: closest-hit and/or any-hit shader against - /// the built-in triangle intersection test. At least one of the - /// two stage indices must be set (non-sentinel). - TrianglesHit { - closest_hit_stage: Option, - any_hit_stage: Option, - }, - /// Procedural hit group: a custom intersection shader plus optional - /// closest-hit and any-hit shaders. - ProceduralHit { - intersection_stage: u32, - closest_hit_stage: Option, - any_hit_stage: Option, - }, -} - -/// Full register-time descriptor passed to -/// [`RayTracingKernelBridge::register_kernel`]. Owned mirror of the -/// wire shape. -#[derive(Debug, Clone)] -pub struct RayTracingKernelRegisterDecl { - pub label: String, - pub stages: Vec, - pub groups: Vec, - pub bindings: Vec, - pub push_constant_size: u32, - pub push_constant_stages: u32, - pub max_recursion_depth: u32, -} - -/// One TLAS instance descriptor passed to -/// [`RayTracingKernelBridge::register_tlas`]. The `blas_id` field -/// references a previously-registered BLAS; the bridge resolves it -/// against its own `as_id → Arc` map. -#[derive(Debug, Clone)] -pub struct TlasInstanceDeclWire { - pub blas_id: String, - /// Row-major 3×4 affine transform — exactly 12 floats laid out as - /// `[m00, m01, m02, m03, m10, ..., m23]`. Matches - /// `VkTransformMatrixKHR` directly. - pub transform: [[f32; 4]; 3], - pub custom_index: u32, - pub mask: u8, - pub sbt_record_offset: u32, - /// `VkGeometryInstanceFlagsKHR` bitmask passed through unchanged. - pub flags: u32, -} - -/// Full BLAS register call passed to -/// [`RayTracingKernelBridge::register_blas`]. -#[derive(Debug, Clone)] -pub struct BlasRegisterDecl { - pub label: String, - /// Interleaved `[x, y, z, x, y, z, ...]` (R32G32B32_SFLOAT, stride - /// 12 bytes). Length must be a multiple of 3. - pub vertices: Vec, - /// Three indices per triangle. Length must be a multiple of 3. - pub indices: Vec, -} - -/// Full TLAS register call passed to -/// [`RayTracingKernelBridge::register_tlas`]. -#[derive(Debug, Clone)] -pub struct TlasRegisterDecl { - pub label: String, - pub instances: Vec, -} - -/// Per-trace binding value passed to -/// [`RayTracingKernelBridge::run_kernel`]. `target_id` is interpreted -/// based on `kind`: -/// - [`RayTracingBindingKindWire::AccelerationStructure`]: an `as_id` -/// from a prior [`RayTracingKernelBridge::register_tlas`]. -/// - all other kinds: the surface-share UUID of a host-side -/// `PixelBuffer` / `Texture` (same convention compute and -/// graphics use). -#[derive(Debug, Clone)] -pub struct RayTracingBindingValue { - pub binding: u32, - pub kind: RayTracingBindingKindWire, - pub target_id: String, -} - -/// Full per-trace input passed to [`RayTracingKernelBridge::run_kernel`]. -#[derive(Debug, Clone)] -pub struct RayTracingKernelRunDispatch { - pub kernel_id: String, - pub bindings: Vec, - pub push_constants: Vec, - pub width: u32, - pub height: u32, - pub depth: u32, -} - -/// Dispatch trait the host runtime uses to drive ray-tracing -/// acceleration-structure construction, kernel registration, and -/// per-trace invocation for subprocess customers. -/// -/// RT dispatch on the host is synchronous: the bridge's `run_kernel` -/// blocks on the kernel's fence inside -/// [`crate::vulkan::rhi::VulkanRayTracingKernel::trace_rays`] before -/// returning, so by the time this returns, the GPU work has retired -/// and the host's writes to the output storage image are visible to -/// any subsequent submission against the same VkDevice. The -/// subprocess can safely advance its surface-share timeline on -/// receipt of the `ok` response. -pub trait RayTracingKernelBridge: Send + Sync { - /// Build a bottom-level acceleration structure from triangle - /// geometry. Returns a stable `as_id` — re-registering identical - /// geometry is allowed to hit a cache, but that is an - /// implementation choice; AS construction is rare enough in - /// practice that the bridge can simply build a fresh BLAS and - /// return a fresh id. - fn register_blas(&self, decl: &BlasRegisterDecl) -> Result; - - /// Build a top-level acceleration structure from a list of - /// instances referencing previously-registered BLASes. Returns a - /// stable `as_id`. The TLAS implementation must keep the - /// referenced BLASes alive for its lifetime (Vulkan spec). - fn register_tlas(&self, decl: &TlasRegisterDecl) -> Result; - - /// Register a ray-tracing kernel. Returns a stable `kernel_id` — - /// re-registering an identical descriptor (same SPIR-V stages, - /// same group layout, same bindings, same push-constants) hits - /// the host-side cache and returns the same id without - /// re-reflecting or rebuilding the pipeline. - /// - /// The recommended `kernel_id` shape is SHA-256 hex over a - /// canonical byte representation of the inputs that *materially* - /// determine the host-side `VulkanRayTracingKernel`. - fn register_kernel(&self, decl: &RayTracingKernelRegisterDecl) -> Result; - - /// Run one trace against a previously-registered kernel. - /// - /// Resolves binding `target_id`s through the application-provided - /// resolver (UUID → `PixelBuffer` / `Texture` for non-AS - /// kinds; `as_id` → `Arc` for the AS - /// kind), then submits + waits on the kernel's own command - /// buffer + fence. Errors include unrecognized `kernel_id`, - /// target lookup failure, push-constant size mismatch, and - /// Vulkan submit failure. - fn run_kernel(&self, dispatch: &RayTracingKernelRunDispatch) -> Result<(), String>; -} diff --git a/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs b/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs index 206283f69..c05cb9e8c 100644 --- a/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs @@ -36,14 +36,15 @@ pub enum ComputeBindingKind { StorageImage, } -/// The subset of [`ComputeBindingKind`] a dispatch can name a surface for. +/// The subset of any kernel's binding kinds that a dispatch, draw or trace +/// can name a surface for. /// /// Narrower than its parent on purpose: a caller holding this has already /// refused the buffer and samplerless kinds, so every match on it is total /// with no panic arm to keep in sync. It also carries the image layout the /// descriptor requires, which is what a batch's barriers move a texture into. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SurfaceBoundComputeBindingKind { +pub enum SurfaceBoundKernelBindingKind { /// Written through `imageStore`; the descriptor requires `GENERAL`. StorageImage, /// Read through a combined sampler; the descriptor requires @@ -52,8 +53,8 @@ pub enum SurfaceBoundComputeBindingKind { } #[cfg(target_os = "linux")] -impl SurfaceBoundComputeBindingKind { - /// The image layout this kind's descriptor requires at dispatch. +impl SurfaceBoundKernelBindingKind { + /// The image layout this kind's descriptor requires when the pipeline runs. pub fn required_image_layout(self) -> streamlib_consumer_rhi::VulkanLayout { match self { Self::StorageImage => streamlib_consumer_rhi::VulkanLayout::GENERAL, @@ -299,6 +300,7 @@ fn spirv_type_to_kind(ty: RDescriptorType) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::core::rhi::spirv_module_rewriting_for_tests::strip_every_debug_name_from_spirv_module; // SPIR-V test fixtures live next to `vulkan_compute_kernel.rs` and are // built by `libs/streamlib/build.rs`. Reflection is a host-architecture @@ -457,7 +459,7 @@ mod tests { // the engine only through the pre-compiled-SPIR-V escape hatch, and // it cannot be bound by name at all — so it fails here, at // construction, rather than confusingly at first dispatch. - let stripped = strip_debug_names(blend_spv(2)); + let stripped = strip_every_debug_name_from_spirv_module(blend_spv(2)); let err = derive_bindings_from_spirv(&stripped) .err() .expect("a name-stripped blob must be refused"); @@ -468,29 +470,6 @@ mod tests { ); } - /// Drop every `OpName` (opcode 5) from a SPIR-V module, reproducing what - /// `glslc -O` emits without `-g`. - fn strip_debug_names(spv: &[u8]) -> Vec { - const HEADER_WORDS: usize = 5; - const OP_NAME: u16 = 5; - let words: Vec = spv - .chunks_exact(4) - .map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])) - .collect(); - let mut kept: Vec = words[..HEADER_WORDS].to_vec(); - let mut at = HEADER_WORDS; - while at < words.len() { - let word_count = (words[at] >> 16) as usize; - let opcode = (words[at] & 0xffff) as u16; - assert!(word_count > 0, "malformed SPIR-V instruction"); - if opcode != OP_NAME { - kept.extend_from_slice(&words[at..at + word_count]); - } - at += word_count; - } - kept.iter().flat_map(|w| w.to_le_bytes()).collect() - } - #[test] fn sampled_image_spec_round_trips_kind() { let spec = ComputeBindingSpec::sampled_image(7); diff --git a/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs b/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs index a26f5d93d..477a5d918 100644 --- a/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs @@ -14,13 +14,14 @@ use std::borrow::Cow; -use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; +use rspirv_reflect::DescriptorType as RDescriptorType; -use crate::core::{Error, Result}; +use crate::core::Result; use super::TextureFormat; use super::kernel_binding_names::{ - KernelBindingUnderReconciliation, KernelShaderStageMask, + KernelBindingUnderReconciliation, KernelShaderStageMask, KernelShaderStageSpirvModule, + derive_staged_kernel_bindings_from_shader_reflection, reconcile_staged_kernel_binding_declarations, }; @@ -154,6 +155,10 @@ pub struct GraphicsBindingDeclaration { } impl KernelShaderStageMask for GraphicsShaderStageFlags { + fn mask_naming_no_stages() -> Self { + Self::NONE + } + fn named_stages(self) -> Vec<&'static str> { let mut names = Vec::new(); if self.contains(Self::VERTEX) { @@ -407,7 +412,7 @@ pub enum DepthCompareOp { /// Depth/stencil test state. Stencil testing is not exposed today (no /// in-tree consumer needs it). -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DepthStencilState { Disabled, Enabled { @@ -696,115 +701,32 @@ pub struct DrawIndexedCall { pub fn derive_bindings_from_spirv_multistage( stages: &[GraphicsStage<'_>], ) -> Result<(Vec, GraphicsPushConstants)> { - let mut merged: std::collections::BTreeMap< - u32, - (GraphicsBindingKind, GraphicsShaderStageFlags, String), - > = std::collections::BTreeMap::new(); - let mut push_size: u32 = 0; - let mut push_stages = GraphicsShaderStageFlags::NONE; - - for stage in stages { - let stage_flag = stage_to_flag(stage.stage); - let reflection = Reflection::new_from_spirv(stage.spv).map_err(|e| { - Error::GpuError(format!( - "Graphics kernel: failed to reflect SPIR-V for {:?} stage: {e:?}", - stage.stage - )) - })?; - let sets = reflection.get_descriptor_sets().map_err(|e| { - Error::GpuError(format!( - "Graphics kernel: failed to extract descriptor sets for {:?} stage: {e:?}", - stage.stage - )) - })?; - if sets.len() > 1 { - return Err(Error::GpuError(format!( - "Graphics kernel: only descriptor set 0 is supported; SPIR-V {:?} stage uses sets {:?}", - stage.stage, - sets.keys().collect::>() - ))); - } - if let Some(set0) = sets.get(&0) { - for (&binding, info) in set0 { - let kind = spirv_type_to_kind(info.ty).ok_or_else(|| { - Error::GpuError(format!( - "Graphics kernel: SPIR-V {:?} stage binding {binding} has unsupported descriptor type {:?}", - stage.stage, info.ty - )) - })?; - if info.name.is_empty() { - return Err(Error::GpuError(format!( - "Graphics kernel: SPIR-V {:?} stage binding {binding} ({kind:?}) carries \ - no name — its OpName decorations were stripped, and bindings are \ - resolved by name. Compile with debug info retained (`glslc -g`) so the \ - shader's own binding names survive optimization", - stage.stage - ))); - } - let entry = merged.entry(binding).or_insert(( - kind, - GraphicsShaderStageFlags::NONE, - info.name.clone(), - )); - if entry.0 != kind { - return Err(Error::GpuError(format!( - "Graphics kernel: binding {binding} kind conflict — {:?} vs {:?} (introduced by {:?})", - entry.0, kind, stage.stage - ))); - } - if entry.2 != info.name { - return Err(Error::GpuError(format!( - "Graphics kernel: binding {binding} is named `{}` by one stage and `{}` \ - by the {:?} stage; bindings are resolved by name, so one slot spelled \ - two ways cannot be bound", - entry.2, info.name, stage.stage - ))); - } - entry.1 |= stage_flag; - } - } - if let Some(info) = reflection.get_push_constant_range().map_err(|e| { - Error::GpuError(format!( - "Graphics kernel: failed to read push-constant range for {:?} stage: {e:?}", - stage.stage - )) - })? { - // Vulkan permits a push-constant block to span multiple stages - // with overlapping ranges; we report the maximum size touched - // by any stage and the union of stages. - push_size = push_size.max(info.size); - push_stages |= stage_flag; - } - } - - let bindings: Vec = merged - .into_iter() - .map(|(binding, (kind, stages, name))| GraphicsBindingSpec { - binding, - kind, - stages, - name: Some(Cow::Owned(name)), + let stage_modules: Vec<_> = stages + .iter() + .map(|stage| KernelShaderStageSpirvModule { + stage: stage.stage, + spirv: stage.spv, }) .collect(); - for (index, spec) in bindings.iter().enumerate() { - if let Some(earlier) = bindings[..index] - .iter() - .find(|earlier| earlier.name == spec.name) - { - return Err(Error::GpuError(format!( - "Graphics kernel: bindings {} and {} are both named `{}`; bindings are resolved \ - by name, so one name on two slots cannot be bound", - earlier.binding, - spec.binding, - spec.name.as_deref().unwrap_or_default() - ))); - } - } + let (derived, push_constants) = derive_staged_kernel_bindings_from_shader_reflection( + "Graphics kernel", + &stage_modules, + stage_to_flag, + spirv_type_to_kind, + )?; Ok(( - bindings, + derived + .into_iter() + .map(|binding| GraphicsBindingSpec { + binding: binding.binding, + kind: binding.kind, + stages: binding.stages, + name: Some(Cow::Owned(binding.name)), + }) + .collect(), GraphicsPushConstants { - size: push_size, - stages: push_stages, + size: push_constants.size, + stages: push_constants.stages, }, )) } @@ -825,3 +747,96 @@ fn spirv_type_to_kind(ty: RDescriptorType) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::rhi::spirv_module_rewriting_for_tests::{ + move_binding_to_another_slot_in_spirv_module, rename_binding_in_spirv_module, + strip_every_debug_name_from_spirv_module, + }; + + // Reflection is host-side work on bytes the build already produced, so + // every test here runs without a Vulkan device. + fn display_blit_vertex_spirv() -> &'static [u8] { + include_bytes!(concat!(env!("OUT_DIR"), "/display_blit.vert.spv")) + } + + fn display_blit_fragment_spirv() -> &'static [u8] { + include_bytes!(concat!(env!("OUT_DIR"), "/display_blit.frag.spv")) + } + + #[test] + fn reflection_keeps_the_shaders_own_name_for_every_binding() { + let stages = [ + GraphicsStage::vertex(display_blit_vertex_spirv()), + GraphicsStage::fragment(display_blit_fragment_spirv()), + ]; + let (bindings, _) = derive_bindings_from_spirv_multistage(&stages).expect("derive"); + let named: Vec<(u32, &str)> = bindings + .iter() + .map(|spec| { + ( + spec.binding, + spec.name.as_deref().expect("every binding is named"), + ) + }) + .collect(); + assert_eq!(named, vec![(0, "cameraTexture")]); + } + + #[test] + fn a_name_stripped_stage_is_refused_at_derive() { + let stripped = strip_every_debug_name_from_spirv_module(display_blit_fragment_spirv()); + let stages = [ + GraphicsStage::vertex(display_blit_vertex_spirv()), + GraphicsStage::fragment(&stripped), + ]; + let refusal = derive_bindings_from_spirv_multistage(&stages) + .expect_err("a name-stripped blob cannot be bound by name"); + let message = refusal.to_string(); + assert!( + message.contains("carries no name") && message.contains("glslc -g"), + "the refusal must name the cause and the fix: {message}" + ); + } + + #[test] + fn one_slot_spelled_two_ways_is_refused_at_derive() { + // display_blit's vertex stage declares no binding, so the second + // spelling of slot 0 has to come from a rewrite of the fragment blob. + let respelled = + rename_binding_in_spirv_module(display_blit_fragment_spirv(), "cameraTexture", "cam"); + let stages = [ + GraphicsStage::vertex(&respelled), + GraphicsStage::fragment(display_blit_fragment_spirv()), + ]; + let refusal = derive_bindings_from_spirv_multistage(&stages) + .expect_err("one slot cannot carry two names"); + let message = refusal.to_string(); + assert!( + message.contains("binding 0 is named `cam`") + && message.contains("`cameraTexture`") + && message.contains("one slot spelled two ways"), + "the refusal must name the slot and both spellings: {message}" + ); + } + + #[test] + fn one_name_on_two_slots_is_refused_at_derive() { + let moved = + move_binding_to_another_slot_in_spirv_module(display_blit_fragment_spirv(), 0, 1); + let stages = [ + GraphicsStage::vertex(&moved), + GraphicsStage::fragment(display_blit_fragment_spirv()), + ]; + let refusal = derive_bindings_from_spirv_multistage(&stages) + .expect_err("one name cannot identify two slots"); + let message = refusal.to_string(); + assert!( + message.contains("bindings 0 and 1 are both named `cameraTexture`") + && message.contains("one name on two slots"), + "the refusal must name both slots and the name: {message}" + ); + } +} diff --git a/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs b/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs index c72bef86d..b2e9592ba 100644 --- a/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs +++ b/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs @@ -5,23 +5,21 @@ //! //! Every pipeline kind reads its binding names out of SPIR-V reflection and //! checks a caller's declaration against them rather than letting the -//! declaration replace them. Compute has no stage axis and reconciles in -//! [`super::compute_kernel`]; graphics and ray tracing scope each binding to -//! the stages that read it, and share the reconciliation here because the only -//! thing that differs between them is which two newtypes they name. +//! declaration replace them. Compute has no stage axis and both derives and +//! reconciles in [`super::compute_kernel`]; graphics and ray tracing scope each +//! binding to the stages that read it, and share both the multi-stage +//! reflection merge and the reconciliation here because the only thing that +//! differs between them is which newtypes they name. + +use std::collections::BTreeMap; + +use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; use crate::core::{Error, Result}; /// Render a shader's declared binding names for an error message. pub fn quote_declared_shader_binding_names(names: &[&str]) -> String { - if names.is_empty() { - return "no named bindings".to_string(); - } - names - .iter() - .map(|name| format!("`{name}`")) - .collect::>() - .join(", ") + quote_names(names, "no named bindings") } /// The stage mask a graphics or ray-tracing binding carries. @@ -29,6 +27,10 @@ pub fn quote_declared_shader_binding_names(names: &[&str]) -> String { /// The two flag types are unrelated newtypes over `u32`, so the reconciliation /// they share is generic over this rather than over either of them. pub trait KernelShaderStageMask: Copy + PartialEq { + /// The mask that names no stage at all, which every accumulation starts + /// from. + fn mask_naming_no_stages() -> Self; + /// Every stage this mask names, spelled as the wire and the shader spell /// them. fn named_stages(self) -> Vec<&'static str>; @@ -45,7 +47,12 @@ pub trait KernelShaderStageMask: Copy + PartialEq { /// Render a stage mask for an error message. fn quote_stage_mask(stages: Stages) -> String { - quote_names(&stages.named_stages(), "no stage") + quote_shader_stage_names(&stages.named_stages()) +} + +/// Render a set of shader-stage names for an error message. +pub fn quote_shader_stage_names(names: &[&str]) -> String { + quote_names(names, "no stage") } fn quote_names(names: &[&str], when_empty: &str) -> String { @@ -170,6 +177,235 @@ where Ok(()) } +/// Refuse a binding the shader left unnamed. +/// +/// One of the three ways a name can fail to identify one binding slot. Every +/// path that merges reflection across stages runs all three, so a name resolves +/// to exactly one slot no matter which path built the kernel. +pub fn refuse_a_binding_the_shader_left_unnamed( + kernel_kind_label: &str, + stage: Stage, + binding: u32, + kind: Kind, + name_the_shader_spells: &str, +) -> Result<()> +where + Stage: std::fmt::Debug, + Kind: std::fmt::Debug, +{ + if !name_the_shader_spells.is_empty() { + return Ok(()); + } + Err(Error::GpuError(format!( + "{kernel_kind_label}: SPIR-V {stage:?} stage binding {binding} ({kind:?}) carries no \ + name — its OpName decorations were stripped, and bindings are resolved by name. Compile \ + with debug info retained (`glslc -g`) so the shader's own binding names survive \ + optimization" + ))) +} + +/// Refuse one binding slot two of a kernel's stages spell differently. +pub fn refuse_one_binding_slot_two_stages_spell_differently( + kernel_kind_label: &str, + stage: Stage, + binding: u32, + name_an_earlier_stage_spelled: &str, + name_this_stage_spells: &str, +) -> Result<()> +where + Stage: std::fmt::Debug, +{ + if name_an_earlier_stage_spelled == name_this_stage_spells { + return Ok(()); + } + Err(Error::GpuError(format!( + "{kernel_kind_label}: binding {binding} is named `{name_an_earlier_stage_spelled}` by one \ + stage and `{name_this_stage_spells}` by the {stage:?} stage; bindings are resolved by \ + name, so one slot spelled two ways cannot be bound" + ))) +} + +/// Refuse one binding name that identifies two of a kernel's slots. +/// +/// Takes the merged slots in slot order, which is the order the refusal names +/// the colliding pair in. +pub fn refuse_one_binding_name_that_identifies_two_slots<'a>( + kernel_kind_label: &str, + names_in_binding_slot_order: impl IntoIterator, +) -> Result<()> { + let mut slots_already_walked: Vec<(u32, &str)> = Vec::new(); + for (binding, name) in names_in_binding_slot_order { + if let Some(&(earlier_binding, _)) = slots_already_walked + .iter() + .find(|(_, earlier_name)| *earlier_name == name) + { + return Err(Error::GpuError(format!( + "{kernel_kind_label}: bindings {earlier_binding} and {binding} are both named \ + `{name}`; bindings are resolved by name, so one name on two slots cannot be bound" + ))); + } + slots_already_walked.push((binding, name)); + } + Ok(()) +} + +/// One shader module handed to the multi-stage reflection merge, paired with +/// the pipeline stage it fills. +#[derive(Debug, Clone, Copy)] +pub struct KernelShaderStageSpirvModule<'a, Stage> { + pub stage: Stage, + pub spirv: &'a [u8], +} + +/// One binding the multi-stage reflection merge found, carrying the shader's +/// own name for it and every stage that reads it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KernelBindingDerivedFromShaderReflection { + pub binding: u32, + pub kind: Kind, + pub stages: Stages, + pub name: String, +} + +/// The push-constant range the multi-stage reflection merge found. +#[derive(Debug, Clone, Copy)] +pub struct KernelPushConstantRangeDerivedFromShaderReflection { + pub size: u32, + pub stages: Stages, +} + +/// Reflect every stage of a multi-stage pipeline and merge the result into one +/// binding shape plus one push-constant range, for a kernel kind whose bindings +/// carry stage masks. +/// +/// Each stage's reflection is unioned: a binding two stages read is reported +/// once with both stages set. Only descriptor set 0 is supported. +/// +/// Every derived binding carries the shader's own name, and the three ways a +/// name can fail to identify one binding are rejected here rather than at +/// dispatch: a blob whose `OpName` decorations were stripped, a slot two stages +/// spell differently, and one name on two slots. +/// +/// `stage_flag_of` maps a stage to its one-bit mask and +/// `binding_kind_of_descriptor_type` maps a reflected descriptor type to the +/// kernel kind's own binding kind, which is everything graphics and ray tracing +/// do not share. +pub fn derive_staged_kernel_bindings_from_shader_reflection( + kernel_kind_label: &str, + stage_modules: &[KernelShaderStageSpirvModule<'_, Stage>], + stage_flag_of: impl Fn(Stage) -> Stages, + binding_kind_of_descriptor_type: impl Fn(RDescriptorType) -> Option, +) -> Result<( + Vec>, + KernelPushConstantRangeDerivedFromShaderReflection, +)> +where + Stage: Copy + std::fmt::Debug, + Kind: Copy + PartialEq + std::fmt::Debug, + Stages: KernelShaderStageMask + std::ops::BitOrAssign, +{ + let mut merged: BTreeMap = BTreeMap::new(); + let mut push_size: u32 = 0; + let mut push_stages = Stages::mask_naming_no_stages(); + + for module in stage_modules { + let stage = module.stage; + let stage_flag = stage_flag_of(stage); + let reflection = Reflection::new_from_spirv(module.spirv).map_err(|e| { + Error::GpuError(format!( + "{kernel_kind_label}: failed to reflect SPIR-V for {stage:?} stage: {e:?}" + )) + })?; + let sets = reflection.get_descriptor_sets().map_err(|e| { + Error::GpuError(format!( + "{kernel_kind_label}: failed to extract descriptor sets for {stage:?} stage: {e:?}" + )) + })?; + if sets.len() > 1 { + return Err(Error::GpuError(format!( + "{kernel_kind_label}: only descriptor set 0 is supported; SPIR-V {stage:?} stage \ + uses sets {:?}", + sets.keys().collect::>() + ))); + } + if let Some(set0) = sets.get(&0) { + for (&binding, info) in set0 { + let kind = binding_kind_of_descriptor_type(info.ty).ok_or_else(|| { + Error::GpuError(format!( + "{kernel_kind_label}: SPIR-V {stage:?} stage binding {binding} has \ + unsupported descriptor type {:?}", + info.ty + )) + })?; + refuse_a_binding_the_shader_left_unnamed( + kernel_kind_label, + stage, + binding, + kind, + &info.name, + )?; + let entry = merged.entry(binding).or_insert(( + kind, + Stages::mask_naming_no_stages(), + info.name.clone(), + )); + if entry.0 != kind { + return Err(Error::GpuError(format!( + "{kernel_kind_label}: binding {binding} kind conflict — {:?} vs {kind:?} \ + (introduced by {stage:?})", + entry.0 + ))); + } + refuse_one_binding_slot_two_stages_spell_differently( + kernel_kind_label, + stage, + binding, + &entry.2, + &info.name, + )?; + entry.1 |= stage_flag; + } + } + if let Some(info) = reflection.get_push_constant_range().map_err(|e| { + Error::GpuError(format!( + "{kernel_kind_label}: failed to read push-constant range for {stage:?} stage: \ + {e:?}" + )) + })? { + // Vulkan permits a push-constant block to span multiple stages + // with overlapping ranges; we report the maximum size touched + // by any stage and the union of stages. + push_size = push_size.max(info.size); + push_stages |= stage_flag; + } + } + + let bindings: Vec> = merged + .into_iter() + .map( + |(binding, (kind, stages, name))| KernelBindingDerivedFromShaderReflection { + binding, + kind, + stages, + name, + }, + ) + .collect(); + refuse_one_binding_name_that_identifies_two_slots( + kernel_kind_label, + bindings + .iter() + .map(|derived| (derived.binding, derived.name.as_str())), + )?; + Ok(( + bindings, + KernelPushConstantRangeDerivedFromShaderReflection { + size: push_size, + stages: push_stages, + }, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -185,6 +421,10 @@ mod tests { } impl KernelShaderStageMask for TestStageMask { + fn mask_naming_no_stages() -> Self { + Self::NONE + } + fn named_stages(self) -> Vec<&'static str> { let mut names = Vec::new(); if self.0 & 0b01 != 0 { diff --git a/runtime/streamlib-engine/src/core/rhi/mod.rs b/runtime/streamlib-engine/src/core/rhi/mod.rs index fbd801dfe..66373dc29 100644 --- a/runtime/streamlib-engine/src/core/rhi/mod.rs +++ b/runtime/streamlib-engine/src/core/rhi/mod.rs @@ -22,6 +22,8 @@ mod pixel_buffer; mod pixel_buffer_pool; mod pixel_buffer_ref; mod ray_tracing_kernel; +#[cfg(test)] +pub(crate) mod spirv_module_rewriting_for_tests; mod storage_buffer; pub(crate) mod texture; mod texture_cache; @@ -41,7 +43,7 @@ pub use command_queue::RhiCommandQueue; pub(crate) use compute_kernel::reconcile_compute_binding_declarations; pub use compute_kernel::{ ComputeBindingDeclaration, ComputeBindingKind, ComputeBindingSpec, ComputeKernelDescriptor, - SurfaceBoundComputeBindingKind, derive_bindings_from_spirv, + SurfaceBoundKernelBindingKind, derive_bindings_from_spirv, }; pub use device::GpuDevice; pub use external_handle::{RhiExternalHandle, RhiPixelBufferExport, RhiPixelBufferImport}; @@ -50,6 +52,7 @@ pub use gl_interop::{GlContext, GlTextureBinding, gl_constants}; pub use glsl_shader_source_compiler::{ DEFAULT_SHADER_ENTRY_POINT, GlslCompilationTargetStage, GlslShaderSourceToSpirvCompiler, }; +pub(crate) use graphics_kernel::reconcile_graphics_binding_declarations; pub use graphics_kernel::{ AttachmentFormats, BlendFactor, BlendOp, ColorBlendAttachment, ColorBlendState, ColorWriteMask, CullMode, DepthCompareOp, DepthFormat, DepthStencilState, DrawCall, DrawIndexedCall, FrontFace, @@ -64,15 +67,20 @@ pub use graphics_kernel::{ pub use host_timeline_semaphore::HostTimelineSemaphore; #[cfg(target_os = "linux")] pub use index_buffer::IndexBuffer; -pub(crate) use kernel_binding_names::quote_declared_shader_binding_names; +#[cfg(target_os = "linux")] +pub(crate) use kernel_binding_names::KernelShaderStageMask; +pub(crate) use kernel_binding_names::{ + quote_declared_shader_binding_names, quote_shader_stage_names, +}; +pub(crate) use kernel_binding_names::{ + refuse_a_binding_the_shader_left_unnamed, refuse_one_binding_name_that_identifies_two_slots, + refuse_one_binding_slot_two_stages_spell_differently, +}; pub use pixel_buffer::PixelBuffer; pub use pixel_buffer_pool::{ PixelBufferDescriptor, PixelBufferPoolSlotId, PublishedPixelBufferFrameId, pool_slot_key_of_surface_id, split_pool_slot_and_frame_generation, }; -pub(crate) use ray_tracing_kernel::{ - ray_tracing_spirv_type_to_kind, reconcile_ray_tracing_binding_declarations, -}; pub use ray_tracing_kernel::{ RayTracingBindingDeclaration, RayTracingBindingKind, RayTracingBindingSpec, RayTracingKernelDescriptor, RayTracingPushConstants, RayTracingShaderGroup, @@ -80,6 +88,9 @@ pub use ray_tracing_kernel::{ derive_ray_tracing_bindings_from_spirv_multistage, ray_tracing_stages_covered_by, validate_shader_groups, }; +pub(crate) use ray_tracing_kernel::{ + ray_tracing_spirv_type_to_kind, reconcile_ray_tracing_binding_declarations, +}; #[cfg(target_os = "linux")] pub use storage_buffer::StorageBuffer; #[cfg(target_os = "linux")] diff --git a/runtime/streamlib-engine/src/core/rhi/ray_tracing_kernel.rs b/runtime/streamlib-engine/src/core/rhi/ray_tracing_kernel.rs index 8c951c18c..2574d094b 100644 --- a/runtime/streamlib-engine/src/core/rhi/ray_tracing_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/ray_tracing_kernel.rs @@ -15,12 +15,13 @@ use std::borrow::Cow; -use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; +use rspirv_reflect::DescriptorType as RDescriptorType; use crate::core::{Error, Result}; use super::kernel_binding_names::{ - KernelBindingUnderReconciliation, KernelShaderStageMask, + KernelBindingUnderReconciliation, KernelShaderStageMask, KernelShaderStageSpirvModule, + derive_staged_kernel_bindings_from_shader_reflection, reconcile_staged_kernel_binding_declarations, }; @@ -196,6 +197,10 @@ pub struct RayTracingBindingDeclaration { } impl KernelShaderStageMask for RayTracingShaderStageFlags { + fn mask_naming_no_stages() -> Self { + Self::NONE + } + fn named_stages(self) -> Vec<&'static str> { [ (Self::RAYGEN, "ray_gen"), @@ -276,115 +281,32 @@ pub(crate) fn reconcile_ray_tracing_binding_declarations( pub fn derive_ray_tracing_bindings_from_spirv_multistage( stages: &[RayTracingStage<'_>], ) -> Result<(Vec, RayTracingPushConstants)> { - let mut merged: std::collections::BTreeMap< - u32, - (RayTracingBindingKind, RayTracingShaderStageFlags, String), - > = std::collections::BTreeMap::new(); - let mut push_size: u32 = 0; - let mut push_stages = RayTracingShaderStageFlags::NONE; - - for stage in stages { - let stage_flag = ray_tracing_stage_to_flag(stage.stage); - let reflection = Reflection::new_from_spirv(stage.spv).map_err(|e| { - Error::GpuError(format!( - "Ray-tracing kernel: failed to reflect SPIR-V for {:?} stage: {e:?}", - stage.stage - )) - })?; - let sets = reflection.get_descriptor_sets().map_err(|e| { - Error::GpuError(format!( - "Ray-tracing kernel: failed to extract descriptor sets for {:?} stage: {e:?}", - stage.stage - )) - })?; - if sets.len() > 1 { - return Err(Error::GpuError(format!( - "Ray-tracing kernel: only descriptor set 0 is supported; SPIR-V {:?} stage uses \ - sets {:?}", - stage.stage, - sets.keys().collect::>() - ))); - } - if let Some(set0) = sets.get(&0) { - for (&binding, info) in set0 { - let kind = ray_tracing_spirv_type_to_kind(info.ty).ok_or_else(|| { - Error::GpuError(format!( - "Ray-tracing kernel: SPIR-V {:?} stage binding {binding} has unsupported \ - descriptor type {:?}", - stage.stage, info.ty - )) - })?; - if info.name.is_empty() { - return Err(Error::GpuError(format!( - "Ray-tracing kernel: SPIR-V {:?} stage binding {binding} ({kind:?}) \ - carries no name — its OpName decorations were stripped, and bindings are \ - resolved by name. Compile with debug info retained (`glslc -g`) so the \ - shader's own binding names survive optimization", - stage.stage - ))); - } - let entry = merged.entry(binding).or_insert(( - kind, - RayTracingShaderStageFlags::NONE, - info.name.clone(), - )); - if entry.0 != kind { - return Err(Error::GpuError(format!( - "Ray-tracing kernel: binding {binding} kind conflict — {:?} vs {:?} \ - (introduced by {:?})", - entry.0, kind, stage.stage - ))); - } - if entry.2 != info.name { - return Err(Error::GpuError(format!( - "Ray-tracing kernel: binding {binding} is named `{}` by one stage and \ - `{}` by the {:?} stage; bindings are resolved by name, so one slot \ - spelled two ways cannot be bound", - entry.2, info.name, stage.stage - ))); - } - entry.1 |= stage_flag; - } - } - if let Some(info) = reflection.get_push_constant_range().map_err(|e| { - Error::GpuError(format!( - "Ray-tracing kernel: failed to read push-constant range for {:?} stage: {e:?}", - stage.stage - )) - })? { - push_size = push_size.max(info.size); - push_stages |= stage_flag; - } - } - - let bindings: Vec = merged - .into_iter() - .map(|(binding, (kind, stages, name))| RayTracingBindingSpec { - binding, - kind, - stages, - name: Some(Cow::Owned(name)), + let stage_modules: Vec<_> = stages + .iter() + .map(|stage| KernelShaderStageSpirvModule { + stage: stage.stage, + spirv: stage.spv, }) .collect(); - for (index, spec) in bindings.iter().enumerate() { - if let Some(earlier) = bindings[..index] - .iter() - .find(|earlier| earlier.name == spec.name) - { - return Err(Error::GpuError(format!( - "Ray-tracing kernel: bindings {} and {} are both named `{}`; bindings are \ - resolved by name, so one name on two slots cannot be bound", - earlier.binding, - spec.binding, - spec.name.as_deref().unwrap_or_default() - ))); - } - } + let (derived, push_constants) = derive_staged_kernel_bindings_from_shader_reflection( + "Ray-tracing kernel", + &stage_modules, + ray_tracing_stage_to_flag, + ray_tracing_spirv_type_to_kind, + )?; Ok(( - bindings, + derived + .into_iter() + .map(|binding| RayTracingBindingSpec { + binding: binding.binding, + kind: binding.kind, + stages: binding.stages, + name: Some(Cow::Owned(binding.name)), + }) + .collect(), RayTracingPushConstants { - size: push_size, - stages: push_stages, + size: push_constants.size, + stages: push_constants.stages, }, )) } @@ -411,9 +333,7 @@ const fn ray_tracing_stage_to_flag(stage: RayTracingShaderStage) -> RayTracingSh /// The binding kind a reflected descriptor type names, or `None` for a type no /// ray-tracing binding kind covers. -pub(crate) fn ray_tracing_spirv_type_to_kind( - ty: RDescriptorType, -) -> Option { +pub(crate) fn ray_tracing_spirv_type_to_kind(ty: RDescriptorType) -> Option { match ty { RDescriptorType::STORAGE_BUFFER => Some(RayTracingBindingKind::StorageBuffer), RDescriptorType::UNIFORM_BUFFER => Some(RayTracingBindingKind::UniformBuffer), @@ -688,6 +608,94 @@ fn expect_stage( #[cfg(test)] mod tests { use super::*; + use crate::core::rhi::spirv_module_rewriting_for_tests::{ + rename_binding_in_spirv_module, strip_every_debug_name_from_spirv_module, + }; + + // Reflection is host-side work on bytes the build already produced, so + // every test here runs without a Vulkan device. + fn ray_tracing_test_ray_gen_spirv() -> &'static [u8] { + include_bytes!(concat!(env!("OUT_DIR"), "/raytracing_test.rgen.spv")) + } + + fn ray_tracing_test_miss_spirv() -> &'static [u8] { + include_bytes!(concat!(env!("OUT_DIR"), "/raytracing_test.rmiss.spv")) + } + + #[test] + fn reflection_keeps_the_shaders_own_name_for_every_binding() { + let stages = [ + RayTracingStage::ray_gen(ray_tracing_test_ray_gen_spirv()), + RayTracingStage::miss(ray_tracing_test_miss_spirv()), + ]; + let (bindings, _) = + derive_ray_tracing_bindings_from_spirv_multistage(&stages).expect("derive"); + let named: Vec<(u32, &str)> = bindings + .iter() + .map(|spec| { + ( + spec.binding, + spec.name.as_deref().expect("every binding is named"), + ) + }) + .collect(); + assert_eq!(named, vec![(0, "topLevelAS"), (1, "outputImage")]); + } + + #[test] + fn a_name_stripped_stage_is_refused_at_derive() { + let stripped = strip_every_debug_name_from_spirv_module(ray_tracing_test_ray_gen_spirv()); + let stages = [RayTracingStage::ray_gen(&stripped)]; + let refusal = derive_ray_tracing_bindings_from_spirv_multistage(&stages) + .expect_err("a name-stripped blob cannot be bound by name"); + let message = refusal.to_string(); + assert!( + message.contains("carries no name") && message.contains("glslc -g"), + "the refusal must name the cause and the fix: {message}" + ); + } + + #[test] + fn one_slot_spelled_two_ways_is_refused_at_derive() { + // The miss and closest-hit shaders declare no binding, so the second + // spelling of slot 0 has to come from a rewrite of the ray-gen blob. + let respelled = rename_binding_in_spirv_module( + ray_tracing_test_ray_gen_spirv(), + "topLevelAS", + "sceneTlas", + ); + let stages = [ + RayTracingStage::ray_gen(ray_tracing_test_ray_gen_spirv()), + RayTracingStage::miss(&respelled), + ]; + let refusal = derive_ray_tracing_bindings_from_spirv_multistage(&stages) + .expect_err("one slot cannot carry two names"); + let message = refusal.to_string(); + assert!( + message.contains("binding 0 is named `topLevelAS`") + && message.contains("`sceneTlas`") + && message.contains("one slot spelled two ways"), + "the refusal must name the slot and both spellings: {message}" + ); + } + + #[test] + fn one_name_on_two_slots_is_refused_at_derive() { + let collided = rename_binding_in_spirv_module( + ray_tracing_test_ray_gen_spirv(), + "outputImage", + "topLevelAS", + ); + let stages = [RayTracingStage::ray_gen(&collided)]; + let refusal = derive_ray_tracing_bindings_from_spirv_multistage(&stages) + .expect_err("one name cannot identify two slots"); + let message = refusal.to_string(); + assert!( + message.contains("bindings 0 and 1 are both named `topLevelAS`") + && message.contains("one name on two slots"), + "the refusal must name both slots and the name: {message}" + ); + } fn dummy_stage(stage: RayTracingShaderStage) -> RayTracingStage<'static> { RayTracingStage { diff --git a/runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs b/runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs new file mode 100644 index 000000000..ba6bdc5ff --- /dev/null +++ b/runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs @@ -0,0 +1,142 @@ +// Copyright (c) 2025 Jonathan Fontanez +// SPDX-License-Identifier: BUSL-1.1 + +//! Rewriting a compiled SPIR-V module so a test can reach a binding-name +//! refusal no shader in the tree can express. +//! +//! The build compiles only well-formed shaders with `-g`, so a stripped name, a +//! slot two stages spell differently, and one name on two slots have no fixture +//! that produces them. Each is reached by editing the debug and decoration +//! instructions of a real compiled module. + +/// A SPIR-V module opens with five header words, then a stream of +/// instructions whose first word packs word count and opcode (SPIR-V 1.6 +/// §2.3). +const SPIRV_HEADER_WORD_COUNT: usize = 5; +const OP_NAME: u16 = 5; +const OP_DECORATE: u16 = 71; +const DECORATION_BINDING: u32 = 33; + +/// Drop every `OpName` from a module, reproducing what `glslc -O` emits +/// without `-g`. +pub(crate) fn strip_every_debug_name_from_spirv_module(spirv: &[u8]) -> Vec { + let (stripped, stripped_count) = + rewrite_spirv_instructions(spirv, |opcode, _| (opcode == OP_NAME).then(Vec::new)); + assert!(stripped_count > 0, "the module carries no OpName to strip"); + stripped +} + +/// Respell one binding, leaving its slot and every other instruction alone. +pub(crate) fn rename_binding_in_spirv_module( + spirv: &[u8], + current_name: &str, + replacement_name: &str, +) -> Vec { + let (renamed, renamed_count) = rewrite_spirv_instructions(spirv, |opcode, instruction| { + if opcode != OP_NAME + || instruction.len() < 3 + || decode_spirv_literal_string(&instruction[2..]) != current_name + { + return None; + } + let mut rewritten = vec![0, instruction[1]]; + rewritten.extend(encode_spirv_literal_string(replacement_name)); + rewritten[0] = ((rewritten.len() as u32) << 16) | u32::from(OP_NAME); + Some(rewritten) + }); + assert!( + renamed_count > 0, + "the module names nothing `{current_name}`" + ); + renamed +} + +/// Move one binding to another slot, leaving its name and every other +/// instruction alone. +pub(crate) fn move_binding_to_another_slot_in_spirv_module( + spirv: &[u8], + current_slot: u32, + replacement_slot: u32, +) -> Vec { + let (moved, moved_count) = rewrite_spirv_instructions(spirv, |opcode, instruction| { + if opcode != OP_DECORATE + || instruction.len() < 4 + || instruction[2] != DECORATION_BINDING + || instruction[3] != current_slot + { + return None; + } + let mut rewritten = instruction.to_vec(); + rewritten[3] = replacement_slot; + Some(rewritten) + }); + assert!( + moved_count > 0, + "the module decorates nothing with binding {current_slot}" + ); + moved +} + +/// Walk a module instruction by instruction, replacing each one the rewriter +/// returns words for and dropping each one it returns no words for; reports how +/// many instructions it touched. +fn rewrite_spirv_instructions( + spirv: &[u8], + mut rewrite_instruction: impl FnMut(u16, &[u32]) -> Option>, +) -> (Vec, usize) { + let words = spirv_module_words(spirv); + let mut rewritten: Vec = words[..SPIRV_HEADER_WORD_COUNT].to_vec(); + let mut rewritten_instruction_count = 0; + let mut at = SPIRV_HEADER_WORD_COUNT; + while at < words.len() { + let word_count = (words[at] >> 16) as usize; + let opcode = (words[at] & 0xffff) as u16; + assert!(word_count > 0, "malformed SPIR-V instruction"); + let instruction = &words[at..at + word_count]; + match rewrite_instruction(opcode, instruction) { + Some(replacement) => { + rewritten.extend_from_slice(&replacement); + rewritten_instruction_count += 1; + } + None => rewritten.extend_from_slice(instruction), + } + at += word_count; + } + (spirv_module_bytes(&rewritten), rewritten_instruction_count) +} + +fn spirv_module_words(spirv: &[u8]) -> Vec { + assert_eq!( + spirv.len() % 4, + 0, + "a SPIR-V module is a whole number of 32-bit words" + ); + spirv + .chunks_exact(4) + .map(|word| u32::from_le_bytes([word[0], word[1], word[2], word[3]])) + .collect() +} + +fn spirv_module_bytes(words: &[u32]) -> Vec { + words.iter().flat_map(|word| word.to_le_bytes()).collect() +} + +/// A SPIR-V literal string is null-terminated UTF-8 packed little-endian into +/// whole words and zero-padded to a word boundary (SPIR-V 1.6 §2.2.1). +fn decode_spirv_literal_string(words: &[u32]) -> String { + let bytes = spirv_module_bytes(words); + let terminator = bytes + .iter() + .position(|&byte| byte == 0) + .unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..terminator]).into_owned() +} + +fn encode_spirv_literal_string(literal: &str) -> Vec { + let mut bytes = literal.as_bytes().to_vec(); + bytes.push(0); + while bytes.len() % 4 != 0 { + bytes.push(0); + } + spirv_module_words(&bytes) +} diff --git a/runtime/streamlib-engine/src/vulkan/rhi/mod.rs b/runtime/streamlib-engine/src/vulkan/rhi/mod.rs index 002467577..214a458eb 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/mod.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/mod.rs @@ -121,6 +121,7 @@ mod vulkan_acceleration_structure; #[cfg(target_os = "linux")] pub use vulkan_acceleration_structure::{ AccelerationStructureKind, IDENTITY_TRANSFORM, TlasInstanceDesc, VulkanAccelerationStructure, + geometry_instance_flags_from_raw_bitmask, }; // `VulkanAccelerationStructureInner` is `pub(crate)`-shaped — only // the host's clone/drop callbacks in `core::plugin::host_services` diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_acceleration_structure.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_acceleration_structure.rs index c3f57ab81..9ead8fd3f 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_acceleration_structure.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_acceleration_structure.rs @@ -77,6 +77,25 @@ impl TlasInstanceDesc { } } +/// The `VkGeometryInstanceFlagsKHR` a raw bitmask names. +/// +/// Exists for callers that receive the mask as an integer — an IPC payload has +/// no way to name the typed constants — and keeps them out of `vulkanalia`. +/// A bit no flag owns is refused rather than masked off: the caller meant a +/// behaviour by it, and dropping it silently would build a different instance +/// than the one asked for. +pub fn geometry_instance_flags_from_raw_bitmask( + raw_bitmask: u32, +) -> Result { + vk::GeometryInstanceFlagsKHR::from_bits(raw_bitmask).ok_or_else(|| { + Error::GpuError(format!( + "geometry instance flags {raw_bitmask:#x} set a bit no VkGeometryInstanceFlagsKHR \ + value owns; the defined bits are {:#x}", + vk::GeometryInstanceFlagsKHR::all().bits() + )) + }) +} + /// Row-major 3×4 identity transform. pub const IDENTITY_TRANSFORM: [[f32; 4]; 3] = [ [1.0, 0.0, 0.0, 0.0], @@ -1034,3 +1053,46 @@ fn instance_bytes(desc: &TlasInstanceDesc) -> [u8; INSTANCE_BYTES] { out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_defined_geometry_instance_flag_survives_the_raw_bitmask() { + let defined = vk::GeometryInstanceFlagsKHR::all(); + assert_eq!( + geometry_instance_flags_from_raw_bitmask(defined.bits()).expect("every defined bit"), + defined + ); + assert_eq!( + geometry_instance_flags_from_raw_bitmask( + vk::GeometryInstanceFlagsKHR::FORCE_OPAQUE.bits() + ) + .expect("one defined bit"), + vk::GeometryInstanceFlagsKHR::FORCE_OPAQUE + ); + } + + #[test] + fn a_bit_no_geometry_instance_flag_owns_is_refused() { + let undefined_bit = !vk::GeometryInstanceFlagsKHR::all().bits() & (1 << 31); + assert_ne!(undefined_bit, 0, "bit 31 must stay undefined for this test"); + let refusal = geometry_instance_flags_from_raw_bitmask( + vk::GeometryInstanceFlagsKHR::FORCE_OPAQUE.bits() | undefined_bit, + ) + .expect_err("a bit no flag owns cannot be silently dropped"); + let message = refusal.to_string(); + assert!( + message.contains("set a bit no VkGeometryInstanceFlagsKHR value owns"), + "the refusal must say the bit is unowned: {message}" + ); + assert!( + message.contains(&format!( + "{:#x}", + vk::GeometryInstanceFlagsKHR::all().bits() + )), + "the refusal must name the bits that are defined: {message}" + ); + } +} diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs index 189bbb880..088fc2ba3 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs @@ -50,7 +50,9 @@ use crate::core::rhi::{ GraphicsBindingSpec, GraphicsDynamicState, GraphicsKernelDescriptor, GraphicsPipelineState, GraphicsShaderStage, GraphicsShaderStageFlags, GraphicsStage, IndexType, PolygonMode, PrimitiveTopology, ScissorRect, Texture, TextureFormat, VertexAttributeFormat, VertexInputRate, - VertexInputState, Viewport, + VertexInputState, Viewport, refuse_a_binding_the_shader_left_unnamed, + refuse_one_binding_name_that_identifies_two_slots, + refuse_one_binding_slot_two_stages_spell_differently, }; use crate::core::{Error, Result}; @@ -1459,6 +1461,7 @@ fn validate_against_spirv( ) -> Result> { use std::collections::BTreeMap; + let kernel_kind_label = format!("Graphics kernel '{}'", descriptor.label); let mut merged: BTreeMap = BTreeMap::new(); let mut spirv_push_size: u32 = 0; @@ -1488,6 +1491,13 @@ fn validate_against_spirv( } if let Some(set0) = sets.get(&0) { for (&binding, info) in set0 { + refuse_a_binding_the_shader_left_unnamed( + &kernel_kind_label, + stage.stage, + binding, + info.ty, + &info.name, + )?; let entry = merged.entry(binding).or_insert(( info.ty, GraphicsShaderStageFlags::NONE, @@ -1499,14 +1509,13 @@ fn validate_against_spirv( descriptor.label, entry.0, info.ty, stage.stage ))); } - if entry.2 != info.name { - return Err(Error::GpuError(format!( - "Graphics kernel '{}': binding {binding} is named `{}` by one stage and \ - `{}` by the {:?} stage; bindings are resolved by name, so one slot \ - spelled two ways cannot be bound", - descriptor.label, entry.2, info.name, stage.stage - ))); - } + refuse_one_binding_slot_two_stages_spell_differently( + &kernel_kind_label, + stage.stage, + binding, + &entry.2, + &info.name, + )?; entry.1 |= stage_flag; } } @@ -1521,6 +1530,13 @@ fn validate_against_spirv( } } + refuse_one_binding_name_that_identifies_two_slots( + &kernel_kind_label, + merged + .iter() + .map(|(&binding, (_, _, name))| (binding, name.as_str())), + )?; + // Each declared binding must agree with merged shader declaration. The // shader's own name for the binding is adopted onto the returned spec — // that is what a by-name draw resolves against, and the shader is its @@ -1562,9 +1578,7 @@ fn validate_against_spirv( ))); } let mut adopted = spec.clone(); - if !merged_entry.2.is_empty() { - adopted.name = Some(std::borrow::Cow::Owned(merged_entry.2.clone())); - } + adopted.name = Some(std::borrow::Cow::Owned(merged_entry.2.clone())); reconciled.push(adopted); } @@ -2318,6 +2332,10 @@ fn atomic_write_pipeline_cache(path: &Path, data: &[u8]) -> std::io::Result<()> #[cfg(test)] mod tests { use super::*; + use crate::core::rhi::spirv_module_rewriting_for_tests::{ + move_binding_to_another_slot_in_spirv_module, rename_binding_in_spirv_module, + strip_every_debug_name_from_spirv_module, + }; use crate::core::rhi::{ AttachmentFormats, ColorBlendState, ColorWriteMask, DepthCompareOp, DepthStencilState, GraphicsBindingSpec, GraphicsDynamicState, GraphicsKernelDescriptor, GraphicsPipelineState, @@ -2410,6 +2428,104 @@ mod tests { // ---- Validation rejections (host-only, no GPU device required) -------- + #[test] + fn rejects_one_slot_the_two_stages_spell_differently() { + // display_blit's vertex stage declares no binding, so the second + // spelling of slot 0 has to come from a rewrite of the fragment blob. + let respelled = rename_binding_in_spirv_module(frag_spv(), "cameraTexture", "cam"); + let bindings = [GraphicsBindingSpec::sampled_texture( + 0, + GraphicsShaderStageFlags::VERTEX_FRAGMENT, + )]; + let stages = [ + GraphicsStage::vertex(&respelled), + GraphicsStage::fragment(frag_spv()), + ]; + let pipeline_state = default_pipeline_state(); + let descriptor = display_blit_descriptor(&stages, &bindings, &pipeline_state); + let err = validate_against_spirv(&descriptor) + .err() + .expect("one slot cannot carry two names"); + let msg = format!("{err}"); + assert!( + msg.contains("binding 0 is named `cam`") + && msg.contains("`cameraTexture`") + && msg.contains("one slot spelled two ways"), + "expected both spellings of slot 0, got: {msg}" + ); + } + + #[test] + fn rejects_a_name_stripped_stage() { + let stripped = strip_every_debug_name_from_spirv_module(frag_spv()); + let bindings = [GraphicsBindingSpec::sampled_texture( + 0, + GraphicsShaderStageFlags::FRAGMENT, + )]; + let stages = [ + GraphicsStage::vertex(vert_spv()), + GraphicsStage::fragment(&stripped), + ]; + let pipeline_state = default_pipeline_state(); + let descriptor = display_blit_descriptor(&stages, &bindings, &pipeline_state); + let err = validate_against_spirv(&descriptor) + .err() + .expect("a name-stripped blob cannot be bound by name"); + let msg = format!("{err}"); + assert!( + msg.contains("carries no name") && msg.contains("glslc -g"), + "the refusal must name the cause and the fix, got: {msg}" + ); + } + + #[test] + fn rejects_one_name_the_two_stages_put_on_two_slots() { + let moved = move_binding_to_another_slot_in_spirv_module(frag_spv(), 0, 1); + let bindings = [ + GraphicsBindingSpec::sampled_texture(0, GraphicsShaderStageFlags::VERTEX_FRAGMENT), + GraphicsBindingSpec::sampled_texture(1, GraphicsShaderStageFlags::VERTEX_FRAGMENT), + ]; + let stages = [ + GraphicsStage::vertex(&moved), + GraphicsStage::fragment(frag_spv()), + ]; + let pipeline_state = default_pipeline_state(); + let descriptor = display_blit_descriptor(&stages, &bindings, &pipeline_state); + let err = validate_against_spirv(&descriptor) + .err() + .expect("one name cannot identify two slots"); + let msg = format!("{err}"); + assert!( + msg.contains("bindings 0 and 1 are both named `cameraTexture`") + && msg.contains("one name on two slots"), + "expected both slots and the name, got: {msg}" + ); + } + + #[test] + fn rejects_a_declared_name_the_shader_spells_differently() { + let bindings = + [ + GraphicsBindingSpec::sampled_texture(0, GraphicsShaderStageFlags::FRAGMENT) + .with_name("sourceTexture"), + ]; + let stages = [ + GraphicsStage::vertex(vert_spv()), + GraphicsStage::fragment(frag_spv()), + ]; + let pipeline_state = default_pipeline_state(); + let descriptor = display_blit_descriptor(&stages, &bindings, &pipeline_state); + let err = validate_against_spirv(&descriptor) + .err() + .expect("a declared name the shader does not use must be refused"); + let msg = format!("{err}"); + assert!( + msg.contains("declared name `sourceTexture`") + && msg.contains("SPIR-V names it `cameraTexture`"), + "expected both the declared and the reflected name, got: {msg}" + ); + } + #[test] fn rejects_descriptor_with_mismatched_binding_kind() { // SPIR-V binding 0 is SampledTexture; declaring it as StorageBuffer must fail. diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs index 9a999d16a..369dde951 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs @@ -34,7 +34,9 @@ use std::ffi::c_void; use crate::core::rhi::{ RayTracingBindingKind, RayTracingBindingSpec, RayTracingKernelDescriptor, RayTracingShaderGroup, RayTracingShaderStage, RayTracingShaderStageFlags, RayTracingStage, - Texture, ray_tracing_spirv_type_to_kind, validate_shader_groups, + Texture, ray_tracing_spirv_type_to_kind, refuse_a_binding_the_shader_left_unnamed, + refuse_one_binding_name_that_identifies_two_slots, + refuse_one_binding_slot_two_stages_spell_differently, validate_shader_groups, }; use crate::core::{Error, Result}; @@ -1083,6 +1085,8 @@ fn validate_bindings_against_spirv( ) -> Result> { use std::collections::BTreeMap; + let kernel_kind_label = format!("Ray-tracing kernel '{}'", descriptor.label); + // Merge per-stage SPIR-V reflection into a single map. let mut merged: BTreeMap = BTreeMap::new(); @@ -1117,6 +1121,13 @@ fn validate_bindings_against_spirv( descriptor.label, binding, stage.stage, info.ty )) })?; + refuse_a_binding_the_shader_left_unnamed( + &kernel_kind_label, + stage.stage, + binding, + kind, + &info.name, + )?; let entry = merged.entry(binding).or_insert(( kind, RayTracingShaderStageFlags::NONE, @@ -1128,19 +1139,25 @@ fn validate_bindings_against_spirv( descriptor.label, binding, entry.0, kind ))); } - if entry.2 != info.name { - return Err(Error::GpuError(format!( - "Ray-tracing kernel '{}': binding {} is named `{}` by one stage and `{}` \ - by the {:?} stage; bindings are resolved by name, so one slot spelled \ - two ways cannot be bound", - descriptor.label, binding, entry.2, info.name, stage.stage - ))); - } + refuse_one_binding_slot_two_stages_spell_differently( + &kernel_kind_label, + stage.stage, + binding, + &entry.2, + &info.name, + )?; entry.1 |= stage_flag; } } } + refuse_one_binding_name_that_identifies_two_slots( + &kernel_kind_label, + merged + .iter() + .map(|(&binding, (_, _, name))| (binding, name.as_str())), + )?; + // Every declared binding must exist in the merged SPIR-V map. The shader's // own name for the binding is adopted onto the returned spec — that is what // a by-name dispatch resolves against, and the shader is its source of @@ -1175,9 +1192,7 @@ fn validate_bindings_against_spirv( ))); } let mut adopted = spec.clone(); - if !spirv_name.is_empty() { - adopted.name = Some(std::borrow::Cow::Owned(spirv_name.clone())); - } + adopted.name = Some(std::borrow::Cow::Owned(spirv_name.clone())); reconciled.push(adopted); } @@ -1822,6 +1837,9 @@ fn drop_sbt(sbt: &Sbt, vulkan_device: &Arc) { #[cfg(test)] mod tests { use super::*; + use crate::core::rhi::spirv_module_rewriting_for_tests::{ + rename_binding_in_spirv_module, strip_every_debug_name_from_spirv_module, + }; use crate::core::rhi::{ RayTracingBindingSpec, RayTracingKernelDescriptor, RayTracingPushConstants, RayTracingShaderGroup, RayTracingShaderStageFlags, RayTracingStage, Texture, @@ -1897,6 +1915,118 @@ mod tests { ) } + // ---- Validation rejections (host-only, no GPU device required) -------- + + fn binding_validation_descriptor<'a>( + stages: &'a [RayTracingStage<'a>], + groups: &'a [RayTracingShaderGroup], + bindings: &'a [RayTracingBindingSpec], + ) -> RayTracingKernelDescriptor<'a> { + RayTracingKernelDescriptor { + label: "rt-binding-validation", + stages, + groups, + bindings, + push_constants: RayTracingPushConstants::NONE, + max_recursion_depth: 1, + } + } + + #[test] + fn rejects_one_slot_the_two_stages_spell_differently() { + // The miss and closest-hit shaders declare no binding, so the second + // spelling of slot 0 has to come from a rewrite of the ray-gen blob. + let respelled = + rename_binding_in_spirv_module(rt_test_rgen_spv(), "topLevelAS", "sceneTlas"); + let stages = [ + RayTracingStage::ray_gen(rt_test_rgen_spv()), + RayTracingStage::miss(&respelled), + ]; + let groups = [RayTracingShaderGroup::General { general: 0 }]; + let bindings = [ + RayTracingBindingSpec::acceleration_structure(0, RayTracingShaderStageFlags::RAYGEN), + RayTracingBindingSpec::storage_image(1, RayTracingShaderStageFlags::RAYGEN), + ]; + let err = validate_bindings_against_spirv(&binding_validation_descriptor( + &stages, &groups, &bindings, + )) + .err() + .expect("one slot cannot carry two names"); + let msg = format!("{err}"); + assert!( + msg.contains("binding 0 is named `topLevelAS`") + && msg.contains("`sceneTlas`") + && msg.contains("one slot spelled two ways"), + "expected both spellings of slot 0, got: {msg}" + ); + } + + #[test] + fn rejects_a_name_stripped_stage() { + let stripped = strip_every_debug_name_from_spirv_module(rt_test_rgen_spv()); + let stages = [RayTracingStage::ray_gen(&stripped)]; + let groups = [RayTracingShaderGroup::General { general: 0 }]; + let bindings = [ + RayTracingBindingSpec::acceleration_structure(0, RayTracingShaderStageFlags::RAYGEN), + RayTracingBindingSpec::storage_image(1, RayTracingShaderStageFlags::RAYGEN), + ]; + let err = validate_bindings_against_spirv(&binding_validation_descriptor( + &stages, &groups, &bindings, + )) + .err() + .expect("a name-stripped blob cannot be bound by name"); + let msg = format!("{err}"); + assert!( + msg.contains("carries no name") && msg.contains("glslc -g"), + "the refusal must name the cause and the fix, got: {msg}" + ); + } + + #[test] + fn rejects_one_name_the_shader_puts_on_two_slots() { + let collided = + rename_binding_in_spirv_module(rt_test_rgen_spv(), "outputImage", "topLevelAS"); + let stages = [RayTracingStage::ray_gen(&collided)]; + let groups = [RayTracingShaderGroup::General { general: 0 }]; + let bindings = [ + RayTracingBindingSpec::acceleration_structure(0, RayTracingShaderStageFlags::RAYGEN), + RayTracingBindingSpec::storage_image(1, RayTracingShaderStageFlags::RAYGEN), + ]; + let err = validate_bindings_against_spirv(&binding_validation_descriptor( + &stages, &groups, &bindings, + )) + .err() + .expect("one name cannot identify two slots"); + let msg = format!("{err}"); + assert!( + msg.contains("bindings 0 and 1 are both named `topLevelAS`") + && msg.contains("one name on two slots"), + "expected both slots and the name, got: {msg}" + ); + } + + #[test] + fn rejects_a_declared_name_the_shader_spells_differently() { + let stages = [RayTracingStage::ray_gen(rt_test_rgen_spv())]; + let groups = [RayTracingShaderGroup::General { general: 0 }]; + let bindings = [ + RayTracingBindingSpec::acceleration_structure(0, RayTracingShaderStageFlags::RAYGEN) + .with_name("sceneTlas"), + RayTracingBindingSpec::storage_image(1, RayTracingShaderStageFlags::RAYGEN), + ]; + let err = validate_bindings_against_spirv(&binding_validation_descriptor( + &stages, &groups, &bindings, + )) + .err() + .expect("a declared name the shader does not use must be refused"); + let msg = format!("{err}"); + assert!( + msg.contains("declared name `sceneTlas`") + && msg.contains("SPIR-V names it `topLevelAS`"), + "expected both the declared and the reflected name, got: {msg}" + ); + } + #[cfg_attr( not(feature = "hardware-tests"), ignore = "hardware integration — set --features streamlib/hardware-tests + run with --test-threads=1. See docs/testing-hardware.md" diff --git a/sdk/streamlib-python-wheel/python/streamlib/_engine.pyi b/sdk/streamlib-python-wheel/python/streamlib/_engine.pyi index cdd9b4e53..c369076e5 100644 --- a/sdk/streamlib-python-wheel/python/streamlib/_engine.pyi +++ b/sdk/streamlib-python-wheel/python/streamlib/_engine.pyi @@ -15,7 +15,7 @@ binary no longer exports still reads as complete. from pathlib import Path from types import TracebackType -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from typing import Any, Literal, TypeVar, final, overload from typing_extensions import disjoint_base @@ -24,9 +24,12 @@ _EscalateResult = TypeVar("_EscalateResult") _BagReadTarget = TypeVar("_BagReadTarget") __all__ = [ + "AccelerationStructureHandle", "AddedProcessor", "ComputeKernel", + "GraphicsKernel", "KernelDispatchBatch", + "RayTracingKernel", "GpuContextFullAccess", "GpuContextLimitedAccess", "GpuSurfaceCheckOutLease", @@ -440,6 +443,106 @@ class GpuContextFullAccess: `storage_image`, `uniform_buffer`. """ + def create_graphics_kernel( + self, + color_attachment_formats: Sequence[str], + vertex_source: str | None = None, + vertex_spirv: bytes | None = None, + vertex_entry_point: str = "main", + fragment_source: str | None = None, + fragment_spirv: bytes | None = None, + fragment_entry_point: str = "main", + push_constant_size: int = 0, + bindings: dict[str, str | tuple[str, Sequence[str]]] | None = None, + label: str = "", + topology: str = "triangle_list", + polygon_mode: str = "fill", + cull_mode: str = "none", + front_face: str = "counter_clockwise", + line_width: float = 1.0, + color_write_channels: str = "rgba", + color_blend: Mapping[str, str] | None = None, + dynamic_state: str = "viewport_scissor", + ) -> GraphicsKernel: + """Build a graphics kernel from GLSL sources, or from pre-compiled SPIR-V. + + Constructed once in `setup()`, drawn per frame in `process()`. The + engine compiles both stages and reflects them at construction, taking + its binding names from them — those names are what `draw` resolves + against. Re-creating an identical kernel is free of compilation. + + Each stage takes `*_source` or `*_spirv`, never both. The vertices are + the shaders' own: no vertex or index buffer is reachable from a Python + processor, so a vertex stage fabricates its positions from + `gl_VertexIndex`. The pass attaches colour targets only, so the + pipeline carries no depth state. + + `bindings` optionally asserts the shape against reflection — `{name: + kind}`, or `{name: (kind, stages)}` to assert which stages read a + binding. Each kind is one of `sampled_texture`, `storage_buffer`, + `storage_image`, `uniform_buffer`; each stage is `vertex` or + `fragment`. + + `color_blend` is `None` for no blending, or a mapping of any of + `src_color_factor`, `dst_color_factor`, `color_op`, + `src_alpha_factor`, `dst_alpha_factor`, `alpha_op` — the rest default + to source-alpha-over. + """ + + def create_ray_tracing_kernel( + self, + stages: Sequence[Mapping[str, Any]], + groups: Sequence[Mapping[str, Any]], + max_recursion_depth: int = 1, + push_constant_size: int = 0, + bindings: dict[str, str | tuple[str, Sequence[str]]] | None = None, + label: str = "", + ) -> RayTracingKernel: + """Build a ray-tracing kernel from GLSL sources, or from pre-compiled SPIR-V. + + `stages` is one mapping per shader module — `{"stage": "ray_gen", + "source": …}`, where `stage` is one of `ray_gen`, `miss`, + `closest_hit`, `any_hit`, `intersection`, `callable`, and the module + itself is `source` or `spirv` with an optional `entry_point`. + + `groups` says how the shader binding table is laid out over them: + `{"kind": "general", "general_stage": 0}`, `{"kind": "triangles_hit", + "closest_hit_stage": 2}`, or `{"kind": "procedural_hit", + "intersection_stage": 3}`. A group names its modules by index into + `stages`, because two modules can fill the same stage. + + `bindings` takes the same shape `create_graphics_kernel` does, plus the + `acceleration_structure` kind. + """ + + def build_triangles_blas( + self, + vertices: Sequence[float], + indices: Sequence[int], + label: str = "", + ) -> AccelerationStructureHandle: + """Build a bottom-level acceleration structure over triangle geometry. + + `vertices` is `[x, y, z, x, y, z, …]` and `indices` is three per + triangle. The returned handle is what `build_tlas` places in a scene. + """ + + def build_tlas( + self, + instances: Sequence[Mapping[str, Any]], + label: str = "", + ) -> AccelerationStructureHandle: + """Build the top-level acceleration structure a trace binds. + + Each instance names its `blas` and, optionally, the row-major 3×4 + `transform` that places it (12 floats, identity by default), its 8-bit + `mask`, its 24-bit `custom_index`, its `sbt_record_offset`, and its + geometry `flags` — some of `triangle_facing_cull_disable`, + `triangle_flip_facing`, `force_opaque`, `force_no_opaque`. + + The structure keeps every bottom-level one it references alive. + """ + def kernel_dispatch_batch(self) -> KernelDispatchBatch: """Open a scope that records several dispatches and runs them as one. @@ -592,6 +695,92 @@ class ComputeKernel: Returns when the GPU work has retired and the writes are visible. """ +@final +class GraphicsKernel: + """A graphics kernel the engine built and holds, drawn by name. + + Constructed in `setup()` where the capability is Full, drawn per frame in + `process()`. No kernel handle string, fence, timeline or slot number + reaches Python — the object is the handle. + """ + + @property + def binding_names(self) -> list[str]: + """The shaders' own names for this kernel's bindings, in slot order.""" + + def draw( + self, + bindings: dict[str, GpuSurfaceHandle | str], + color_targets: Sequence[GpuSurfaceHandle | str], + extent: tuple[int, int], + vertex_count: int, + instance_count: int = 1, + first_vertex: int = 0, + first_instance: int = 0, + push_constants: bytes | None = None, + ) -> None: + """Render one offscreen pass, binding each declared resource by name. + + Exactly one colour target, `extent` pixels of it. The pass discards + what the target held and starts from transparent black, so a draw + paints the whole frame it publishes. + + Bindings never persist on the kernel, so every draw supplies all of + them. Supplying an unknown name or omitting a declared one raises + before anything is submitted. Each binding's kind comes from the + shaders' own reflection, never from the caller. + + Returns when the GPU work has retired and the pixels are visible. + """ + +@final +class RayTracingKernel: + """A ray-tracing kernel the engine built and holds, traced by name. + + Constructed in `setup()` where the capability is Full, traced per frame in + `process()`. No kernel handle string, fence, timeline or slot number + reaches Python — the object is the handle. + """ + + @property + def binding_names(self) -> list[str]: + """The shaders' own names for this kernel's bindings, in slot order.""" + + def trace( + self, + bindings: dict[str, GpuSurfaceHandle | AccelerationStructureHandle | str], + grid: tuple[int, int, int], + push_constants: bytes | None = None, + ) -> None: + """Trace a `(width, height, depth)` grid of rays. + + An `acceleration_structure` binding takes the handle `build_tlas` + returned; every other kind takes a surface. Bindings never persist on + the kernel, so every trace supplies all of them, and an unknown or + omitted name raises before anything is submitted. + + Returns when the GPU work has retired and the writes are visible. + """ + +@final +class AccelerationStructureHandle: + """An acceleration structure the engine built and holds. + + The object is the handle: a bottom-level structure is placed in a scene by + `build_tlas`, and the top-level one it returns is what a trace binds. No id + string reaches Python, and nothing publishes an acceleration structure for + another processor to resolve. + + The engine holds the structure's device memory for as long as this object + lives, and releases it when the last reference goes away. A scene keeps + every bottom-level structure it instances alive, so dropping a BLAS a live + TLAS uses frees nothing until the TLAS goes too. + """ + + @property + def label(self) -> str: + """The name this structure was built under, as engine logs show it.""" + @final class KernelDispatchBatch: """Several dispatches recorded as one: one submission, one fence wait. diff --git a/sdk/streamlib-python-wheel/src/lib.rs b/sdk/streamlib-python-wheel/src/lib.rs index 3fca0d8e3..11717d3eb 100644 --- a/sdk/streamlib-python-wheel/src/lib.rs +++ b/sdk/streamlib-python-wheel/src/lib.rs @@ -52,6 +52,9 @@ fn _engine(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; module.add_class::()?; module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; diff --git a/sdk/streamlib-python-wheel/src/python_helper_process_pixel_exchange.rs b/sdk/streamlib-python-wheel/src/python_helper_process_pixel_exchange.rs index da49b02a6..ad17ec017 100644 --- a/sdk/streamlib-python-wheel/src/python_helper_process_pixel_exchange.rs +++ b/sdk/streamlib-python-wheel/src/python_helper_process_pixel_exchange.rs @@ -25,6 +25,8 @@ use std::path::PathBuf; use pyo3::prelude::*; use pyo3::types::PyDict; +#[cfg(target_os = "linux")] +use pyo3::types::PyList; #[cfg(target_os = "linux")] use pyo3::exceptions::PyRuntimeError; @@ -44,6 +46,8 @@ use streamlib_consumer_rhi::{ #[cfg(target_os = "linux")] use crate::python_cuda_pixel_exchange::CudaImportedSurface; +#[cfg(target_os = "linux")] +use crate::python_processor_context::ReflectedKernelBinding; use streamlib::sdk::rhi::PixelFormat; @@ -366,6 +370,74 @@ pub(crate) struct HelperProcessGpuExchangeClient { #[cfg(target_os = "linux")] const DEVICE_EXPORT_REFILL_WAIT_TIMEOUT_NS: u64 = 2_000_000_000; +/// The binding shape a `register_*_kernel` response carries, in slot order. +/// +/// All three register ops answer with the same array — a dispatch resolves by +/// name and only the shaders know which kind each name is — so all three read +/// it here. +#[cfg(target_os = "linux")] +fn reflected_kernel_bindings_in( + response: &Bound<'_, PyAny>, +) -> PyResult> { + let mut reflected = Vec::new(); + for entry in response_field(response, "bindings")?.try_iter()? { + let entry = entry?; + reflected.push(ReflectedKernelBinding { + name: entry.get_item("name")?.extract()?, + kind: entry.get_item("kind")?.extract()?, + }); + } + Ok(reflected) +} + +/// Everything a `register_graphics_kernel` carries beyond the op name. +/// +/// A struct rather than seven adjacent `&str` arguments: swapping two shader +/// blobs or two entry points compiles clean and lands as a stage that will not +/// link, one round trip away from the call that got it wrong. +#[cfg(target_os = "linux")] +pub(crate) struct HelperProcessGraphicsKernelRegistration<'a, 'py> { + pub(crate) label: &'a str, + pub(crate) vertex_source: &'a str, + pub(crate) vertex_spirv_hex: &'a str, + pub(crate) vertex_entry_point: &'a str, + pub(crate) fragment_source: &'a str, + pub(crate) fragment_spirv_hex: &'a str, + pub(crate) fragment_entry_point: &'a str, + pub(crate) push_constant_size: u32, + pub(crate) declared_bindings: &'a Bound<'py, PyList>, + pub(crate) pipeline_state: &'a Bound<'py, PyDict>, +} + +/// One graphics draw as the wire carries it. +/// +/// A struct rather than six adjacent `u32` arguments: an extent transposed with +/// an instance count compiles clean and renders the wrong thing. +#[cfg(target_os = "linux")] +pub(crate) struct HelperProcessGraphicsDraw<'a, 'py> { + pub(crate) kernel_id: &'a str, + pub(crate) bindings: &'a Bound<'py, PyList>, + pub(crate) color_target_surface_ids: &'a Bound<'py, PyList>, + pub(crate) push_constants_hex: &'a str, + pub(crate) vertex_count: u32, + pub(crate) instance_count: u32, + pub(crate) first_vertex: u32, + pub(crate) first_instance: u32, + pub(crate) extent_width: u32, + pub(crate) extent_height: u32, +} + +/// Everything a `register_ray_tracing_kernel` carries beyond the op name. +#[cfg(target_os = "linux")] +pub(crate) struct HelperProcessRayTracingKernelRegistration<'a, 'py> { + pub(crate) label: &'a str, + pub(crate) stages: &'a Bound<'py, PyList>, + pub(crate) groups: &'a Bound<'py, PyList>, + pub(crate) declared_bindings: &'a Bound<'py, PyList>, + pub(crate) max_recursion_depth: u32, + pub(crate) push_constant_size: u32, +} + /// One compute dispatch as the wire carries it. /// /// The single-dispatch op and one entry of a batch are the same six fields, so @@ -554,10 +626,7 @@ impl HelperProcessGpuExchangeClient { entry_point: &str, push_constant_size: u32, declared_bindings: &Bound<'_, PyAny>, - ) -> PyResult<( - String, - Vec, - )> { + ) -> PyResult<(String, Vec)> { let op = PyDict::new(python); op.set_item("op", "register_compute_kernel")?; op.set_item("source", source)?; @@ -569,15 +638,7 @@ impl HelperProcessGpuExchangeClient { let response = escalate_round_trip_to_parent(python, &self.escalate_request_to_parent, &op)?; let kernel_id: String = response_field(&response, "handle_id")?.extract()?; - let mut reflected = Vec::new(); - for entry in response_field(&response, "bindings")?.try_iter()? { - let entry = entry?; - reflected.push(crate::python_processor_context::ReflectedComputeBinding { - name: entry.get_item("name")?.extract()?, - kind: entry.get_item("kind")?.extract()?, - }); - } - Ok((kernel_id, reflected)) + Ok((kernel_id, reflected_kernel_bindings_in(&response)?)) } /// Dispatch a registered compute kernel with its bindings supplied by name. @@ -630,6 +691,202 @@ impl HelperProcessGpuExchangeClient { Ok(()) } + /// Build a graphics kernel in the parent and take back its id plus the + /// binding shape reflection found across both stages. + #[cfg(target_os = "linux")] + pub(crate) fn register_graphics_kernel( + &self, + python: Python<'_>, + registration: &HelperProcessGraphicsKernelRegistration<'_, '_>, + ) -> PyResult<(String, Vec)> { + let op = PyDict::new(python); + op.set_item("op", "register_graphics_kernel")?; + op.set_item("label", registration.label)?; + op.set_item("vertex_source", registration.vertex_source)?; + op.set_item("vertex_spv_hex", registration.vertex_spirv_hex)?; + op.set_item("vertex_entry_point", registration.vertex_entry_point)?; + op.set_item("fragment_source", registration.fragment_source)?; + op.set_item("fragment_spv_hex", registration.fragment_spirv_hex)?; + op.set_item("fragment_entry_point", registration.fragment_entry_point)?; + op.set_item("bindings", registration.declared_bindings)?; + op.set_item("pipeline_state", registration.pipeline_state)?; + op.set_item("push_constant_size", registration.push_constant_size)?; + // An empty stage mask asserts nothing and the host adopts what the + // shaders reflect, which is the only source this side has for it. + op.set_item("push_constant_stages", 0u32)?; + // One descriptor set, drawn at index 0 forever. Dispatch is + // synchronous, so a ring of sets buys nothing, and its index is exactly + // the kind of slot number this surface keeps out of Python. + op.set_item("descriptor_sets_in_flight", 1u32)?; + let response = + escalate_round_trip_to_parent(python, &self.escalate_request_to_parent, &op)?; + let kernel_id: String = response_field(&response, "handle_id")?.extract()?; + Ok((kernel_id, reflected_kernel_bindings_in(&response)?)) + } + + /// Render one offscreen pass with a registered graphics kernel. + /// + /// Returns when the parent's draw has retired: the offscreen render is + /// synchronous host-side, so the colour target's pixels are visible on + /// return and no timeline value crosses back for this side to wait on. + #[cfg(target_os = "linux")] + pub(crate) fn run_graphics_draw( + &self, + python: Python<'_>, + draw: &HelperProcessGraphicsDraw<'_, '_>, + ) -> PyResult<()> { + let draw_call = PyDict::new(python); + draw_call.set_item("kind", "draw")?; + draw_call.set_item("vertex_count", draw.vertex_count)?; + draw_call.set_item("instance_count", draw.instance_count)?; + draw_call.set_item("first_vertex", draw.first_vertex)?; + draw_call.set_item("first_instance", draw.first_instance)?; + // Present because the wire shape is regular, and ignored host-side for + // a non-indexed draw — which is the only kind reachable from here, + // since no escalate op mints an index buffer. + draw_call.set_item("first_index", 0u32)?; + draw_call.set_item("index_count", 0u32)?; + draw_call.set_item("vertex_offset", 0i32)?; + + let op = PyDict::new(python); + op.set_item("op", "run_graphics_draw")?; + op.set_item("kernel_id", draw.kernel_id)?; + op.set_item("bindings", draw.bindings)?; + op.set_item("color_target_uuids", draw.color_target_surface_ids)?; + op.set_item("draw", &draw_call)?; + op.set_item("extent_width", draw.extent_width)?; + op.set_item("extent_height", draw.extent_height)?; + op.set_item("frame_index", 0u32)?; + op.set_item("push_constants_hex", draw.push_constants_hex)?; + // No escalate op mints a VertexBuffer, so this array is always empty + // and the shaders fabricate their vertices from `gl_VertexIndex`. The + // field is required on the wire, and the host refuses a non-empty one. + op.set_item("vertex_buffers", PyList::empty(python))?; + // `viewport` / `scissor` are omitted: the host fills them with the + // render area, which is the whole target this op ever draws into. + escalate_round_trip_to_parent(python, &self.escalate_request_to_parent, &op)?; + Ok(()) + } + + /// Build a ray-tracing kernel in the parent and take back its id plus the + /// binding shape reflection found across every stage. + #[cfg(target_os = "linux")] + pub(crate) fn register_ray_tracing_kernel( + &self, + python: Python<'_>, + registration: &HelperProcessRayTracingKernelRegistration<'_, '_>, + ) -> PyResult<(String, Vec)> { + let op = PyDict::new(python); + op.set_item("op", "register_ray_tracing_kernel")?; + op.set_item("label", registration.label)?; + op.set_item("stages", registration.stages)?; + op.set_item("groups", registration.groups)?; + op.set_item("bindings", registration.declared_bindings)?; + op.set_item("max_recursion_depth", registration.max_recursion_depth)?; + op.set_item("push_constant_size", registration.push_constant_size)?; + // Empty asserts nothing; the host adopts the reflected stage mask. + op.set_item("push_constant_stages", 0u32)?; + let response = + escalate_round_trip_to_parent(python, &self.escalate_request_to_parent, &op)?; + let kernel_id: String = response_field(&response, "handle_id")?.extract()?; + Ok((kernel_id, reflected_kernel_bindings_in(&response)?)) + } + + /// Trace one grid with a registered ray-tracing kernel. + /// + /// Returns when the parent's trace has retired, for the same reason a + /// compute dispatch does: the host submits and waits on its own fence. + #[cfg(target_os = "linux")] + pub(crate) fn run_ray_tracing_kernel( + &self, + python: Python<'_>, + kernel_id: &str, + bindings: &Bound<'_, PyList>, + push_constants_hex: &str, + grid: (u32, u32, u32), + ) -> PyResult<()> { + let op = PyDict::new(python); + op.set_item("op", "run_ray_tracing_kernel")?; + op.set_item("kernel_id", kernel_id)?; + op.set_item("bindings", bindings)?; + op.set_item("push_constants_hex", push_constants_hex)?; + op.set_item("width", grid.0)?; + op.set_item("height", grid.1)?; + op.set_item("depth", grid.2)?; + escalate_round_trip_to_parent(python, &self.escalate_request_to_parent, &op)?; + Ok(()) + } + + /// Build a triangle-geometry bottom-level acceleration structure in the + /// parent and take back the id it registered the result under. + #[cfg(target_os = "linux")] + pub(crate) fn register_acceleration_structure_blas( + &self, + python: Python<'_>, + label: &str, + vertices_hex: &str, + indices_hex: &str, + ) -> PyResult { + let op = PyDict::new(python); + op.set_item("op", "register_acceleration_structure_blas")?; + op.set_item("label", label)?; + op.set_item("vertices_hex", vertices_hex)?; + op.set_item("indices_hex", indices_hex)?; + let response = + escalate_round_trip_to_parent(python, &self.escalate_request_to_parent, &op)?; + response_field(&response, "handle_id")?.extract() + } + + /// Build a top-level acceleration structure over already-built bottom-level + /// ones and take back the id it registered the result under. + #[cfg(target_os = "linux")] + pub(crate) fn register_acceleration_structure_tlas( + &self, + python: Python<'_>, + label: &str, + instances: &Bound<'_, PyList>, + ) -> PyResult { + let op = PyDict::new(python); + op.set_item("op", "register_acceleration_structure_tlas")?; + op.set_item("label", label)?; + op.set_item("instances", instances)?; + let response = + escalate_round_trip_to_parent(python, &self.escalate_request_to_parent, &op)?; + response_field(&response, "handle_id")?.extract() + } + + /// Hand an acceleration structure this helper built back to the parent, + /// which drops the registry's strong reference and with it the device + /// memory the structure holds. + /// + /// Best-effort: this runs from the handle's drop, which has no caller to + /// raise into, and a parent that is already gone released everything with + /// the connection — so a failure is logged, never raised. + #[cfg(target_os = "linux")] + pub(crate) fn release_acceleration_structure( + &self, + python: Python<'_>, + acceleration_structure_id: &str, + ) { + let released: PyResult<()> = (|| { + let op = PyDict::new(python); + op.set_item("op", "release_handle")?; + op.set_item("handle_id", acceleration_structure_id)?; + escalate_round_trip_to_parent(python, &self.escalate_request_to_parent, &op)?; + Ok(()) + })(); + if let Err(release_failure) = released { + warn_through_the_childs_log_module( + python, + format!( + "releasing acceleration structure {acceleration_structure_id} to the parent \ + failed ({release_failure}); its device memory returns when this helper's \ + connection closes" + ), + ); + } + } + /// Open this surface's device export, importing the parent's staging /// into CUDA on first ask and memoising it for this child. /// diff --git a/sdk/streamlib-python-wheel/src/python_processor_context.rs b/sdk/streamlib-python-wheel/src/python_processor_context.rs index ed14f70f8..a96bd696f 100644 --- a/sdk/streamlib-python-wheel/src/python_processor_context.rs +++ b/sdk/streamlib-python-wheel/src/python_processor_context.rs @@ -37,8 +37,9 @@ use crate::python_gpu_surface_pixel_exchange::{ use crate::python_helper_process_pixel_exchange::HelperProcessGpuExchangeClient; #[cfg(target_os = "linux")] use crate::python_helper_process_pixel_exchange::{ - HelperAcquiredTexture, HelperCheckedOutPixelSurface, HelperSurfaceCheckOutLeaseDebt, - HelperSurfaceReleaseDebt, + HelperAcquiredTexture, HelperCheckedOutPixelSurface, HelperProcessGraphicsDraw, + HelperProcessGraphicsKernelRegistration, HelperProcessRayTracingKernelRegistration, + HelperSurfaceCheckOutLeaseDebt, HelperSurfaceReleaseDebt, }; use crate::python_logging::monotonic_clock_now_ns; use crate::python_processor_link_data_access::PythonProcessorLinkDataAccess; @@ -829,6 +830,288 @@ impl PythonGpuContextFullAccess { Err(gpu_unreachable_from_a_helper_process_error()) } + /// Build a graphics kernel from GLSL source, or from pre-compiled SPIR-V. + /// + /// Constructed once in `setup()`, drawn per frame in `process()`. The + /// engine compiles both stages and reflects them at construction, taking + /// its binding names from them — those names are what `draw` resolves + /// against. Re-creating an identical kernel is free of compilation. + /// + /// The vertices are the shaders' own: no escalate op mints a vertex or + /// index buffer, so a vertex stage fabricates its positions from + /// `gl_VertexIndex`, and the pipeline carries no vertex input state. The + /// pass attaches colour targets only, so there is no depth state either. + #[pyo3(signature = ( + color_attachment_formats, + vertex_source = None, + vertex_spirv = None, + vertex_entry_point = "main", + fragment_source = None, + fragment_spirv = None, + fragment_entry_point = "main", + push_constant_size = 0, + bindings = None, + label = "", + topology = "triangle_list", + polygon_mode = "fill", + cull_mode = "none", + front_face = "counter_clockwise", + line_width = 1.0, + color_write_channels = "rgba", + color_blend = None, + dynamic_state = "viewport_scissor", + ))] + #[expect( + clippy::too_many_arguments, + reason = "the pipeline state is keyword arguments mirroring the wire's own flat shape" + )] + fn create_graphics_kernel( + &self, + python: Python<'_>, + color_attachment_formats: Vec, + vertex_source: Option<&str>, + vertex_spirv: Option<&[u8]>, + vertex_entry_point: &str, + fragment_source: Option<&str>, + fragment_spirv: Option<&[u8]>, + fragment_entry_point: &str, + push_constant_size: u32, + bindings: Option<&Bound<'_, PyDict>>, + label: &str, + topology: &str, + polygon_mode: &str, + cull_mode: &str, + front_face: &str, + line_width: f32, + color_write_channels: &str, + color_blend: Option<&Bound<'_, PyDict>>, + dynamic_state: &str, + ) -> PyResult { + #[cfg(target_os = "linux")] + if let Some(exchange_client) = &self.helper_process_exchange_client { + let declared = declared_staged_kernel_bindings_to_wire( + python, + bindings, + "graphics binding kind", + GRAPHICS_BINDING_KIND_WIRE_NAMES, + "graphics stage", + GRAPHICS_SHADER_STAGE_WIRE_BITS, + )?; + let pipeline_state = graphics_pipeline_state_to_wire( + python, + &GraphicsPipelineStateArguments { + color_attachment_formats: &color_attachment_formats, + topology, + polygon_mode, + cull_mode, + front_face, + line_width, + color_write_channels, + color_blend, + dynamic_state, + }, + )?; + // Neither and both are refused engine-side, in the one place the + // rule is written; forwarding both fields keeps the wheel from + // becoming a second spelling of it that can drift. + let vertex_spirv_hex = vertex_spirv.map(encode_lowercase_hex).unwrap_or_default(); + let fragment_spirv_hex = fragment_spirv.map(encode_lowercase_hex).unwrap_or_default(); + let (kernel_id, reflected_binding_kinds) = exchange_client.register_graphics_kernel( + python, + &HelperProcessGraphicsKernelRegistration { + label, + vertex_source: vertex_source.unwrap_or_default(), + vertex_spirv_hex: &vertex_spirv_hex, + vertex_entry_point, + fragment_source: fragment_source.unwrap_or_default(), + fragment_spirv_hex: &fragment_spirv_hex, + fragment_entry_point, + push_constant_size, + declared_bindings: &declared, + pipeline_state: &pipeline_state, + }, + )?; + return Ok(PythonGraphicsKernel { + kernel_id, + push_constant_size, + reflected_binding_kinds, + helper_process_exchange_client: Arc::clone(exchange_client), + }); + } + let _ = ( + python, + color_attachment_formats, + vertex_source, + vertex_spirv, + vertex_entry_point, + fragment_source, + fragment_spirv, + fragment_entry_point, + push_constant_size, + bindings, + label, + topology, + polygon_mode, + cull_mode, + front_face, + line_width, + color_write_channels, + color_blend, + dynamic_state, + ); + Err(gpu_unreachable_from_a_helper_process_error()) + } + + /// Build a ray-tracing kernel from GLSL sources, or from pre-compiled + /// SPIR-V. + /// + /// `stages` is one mapping per shader module — `{"stage": "ray_gen", + /// "source": …}` — and `groups` says how the shader binding table is laid + /// out over them, each group naming its modules by index into `stages`. + /// Two modules can fill the same stage, which is why a group points at an + /// index rather than a name. + #[pyo3(signature = ( + stages, + groups, + max_recursion_depth = 1, + push_constant_size = 0, + bindings = None, + label = "", + ))] + #[expect( + clippy::too_many_arguments, + reason = "each is one field of the registration the wire carries" + )] + fn create_ray_tracing_kernel( + &self, + python: Python<'_>, + stages: &Bound<'_, PyAny>, + groups: &Bound<'_, PyAny>, + max_recursion_depth: u32, + push_constant_size: u32, + bindings: Option<&Bound<'_, PyDict>>, + label: &str, + ) -> PyResult { + #[cfg(target_os = "linux")] + if let Some(exchange_client) = &self.helper_process_exchange_client { + let wire_stages = ray_tracing_stages_to_wire(python, stages)?; + let wire_groups = ray_tracing_shader_groups_to_wire(python, groups, wire_stages.len())?; + let declared = declared_staged_kernel_bindings_to_wire( + python, + bindings, + "ray-tracing binding kind", + RAY_TRACING_BINDING_KIND_WIRE_NAMES, + "ray-tracing stage", + RAY_TRACING_SHADER_STAGE_WIRE_BITS, + )?; + let (kernel_id, reflected_binding_kinds) = exchange_client + .register_ray_tracing_kernel( + python, + &HelperProcessRayTracingKernelRegistration { + label, + stages: &wire_stages, + groups: &wire_groups, + declared_bindings: &declared, + max_recursion_depth, + push_constant_size, + }, + )?; + return Ok(PythonRayTracingKernel { + kernel_id, + push_constant_size, + reflected_binding_kinds, + helper_process_exchange_client: Arc::clone(exchange_client), + }); + } + let _ = ( + python, + stages, + groups, + max_recursion_depth, + push_constant_size, + bindings, + label, + ); + Err(gpu_unreachable_from_a_helper_process_error()) + } + + /// Build a bottom-level acceleration structure over triangle geometry. + /// + /// `vertices` is `[x, y, z, x, y, z, …]` and `indices` is three per + /// triangle. The returned handle is what `build_tlas` places in a scene. + #[pyo3(signature = (vertices, indices, label = ""))] + fn build_triangles_blas( + &self, + python: Python<'_>, + vertices: Vec, + indices: Vec, + label: &str, + ) -> PyResult { + if !vertices.len().is_multiple_of(3) { + return Err(PyValueError::new_err(format!( + "{} vertex floats were supplied; a vertex is three of them, interleaved as \ + [x, y, z, x, y, z, …]", + vertices.len() + ))); + } + if !indices.len().is_multiple_of(3) { + return Err(PyValueError::new_err(format!( + "{} indices were supplied; a triangle is three of them", + indices.len() + ))); + } + #[cfg(target_os = "linux")] + if let Some(exchange_client) = &self.helper_process_exchange_client { + let acceleration_structure_id = exchange_client.register_acceleration_structure_blas( + python, + label, + &encode_little_endian_f32_hex(&vertices), + &encode_little_endian_u32_hex(&indices), + )?; + return Ok(PythonAccelerationStructureHandle { + acceleration_structure_id, + is_top_level: false, + structure_label: label.to_string(), + helper_process_exchange_client: Some(Arc::clone(exchange_client)), + }); + } + let _ = (python, vertices, indices, label); + Err(gpu_unreachable_from_a_helper_process_error()) + } + + /// Build the top-level acceleration structure a trace binds, over + /// already-built bottom-level ones. + /// + /// Each instance is a mapping naming its `blas` and, optionally, the + /// row-major 3×4 `transform` that places it, its 8-bit `mask`, its 24-bit + /// `custom_index`, its `sbt_record_offset` and its geometry `flags`. + /// The structure keeps every bottom-level one it references alive. + #[pyo3(signature = (instances, label = ""))] + fn build_tlas( + &self, + python: Python<'_>, + instances: &Bound<'_, PyAny>, + label: &str, + ) -> PyResult { + #[cfg(target_os = "linux")] + if let Some(exchange_client) = &self.helper_process_exchange_client { + let wire_instances = tlas_instances_to_wire(python, instances)?; + let acceleration_structure_id = exchange_client.register_acceleration_structure_tlas( + python, + label, + &wire_instances, + )?; + return Ok(PythonAccelerationStructureHandle { + acceleration_structure_id, + is_top_level: true, + structure_label: label.to_string(), + helper_process_exchange_client: Some(Arc::clone(exchange_client)), + }); + } + let _ = (python, instances, label); + Err(gpu_unreachable_from_a_helper_process_error()) + } + /// Open a scope that records several dispatches and runs them as one. /// /// The Python equivalent of the engine's command-recorder flow, and the @@ -1315,8 +1598,52 @@ fn encode_lowercase_hex(bytes: &[u8]) -> String { ) } -/// The binding kinds the wire spells, validated the same way texture formats -/// are so the error text cannot drift from the accepted set. +/// A geometry blob as the wire carries it: little-endian `f32`s, lowercase hex. +#[cfg(target_os = "linux")] +fn encode_little_endian_f32_hex(values: &[f32]) -> String { + encode_lowercase_hex( + &values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ) +} + +/// An index blob as the wire carries it: little-endian `u32`s, lowercase hex. +#[cfg(target_os = "linux")] +fn encode_little_endian_u32_hex(values: &[u32]) -> String { + encode_lowercase_hex( + &values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ) +} + +/// One word of a fixed wire vocabulary, or the refusal naming the whole set. +/// +/// Every enum the escalate wire spells travels as a string the host parses, so +/// checking the spelling here is what keeps a typo on the caller's own stack +/// rather than arriving as an escalate failure a round trip later. +#[cfg(target_os = "linux")] +fn parse_wire_vocabulary_word( + vocabulary_label: &str, + supplied: &str, + accepted: &[&'static str], +) -> PyResult<&'static str> { + accepted + .iter() + .find(|known| **known == supplied) + .copied() + .ok_or_else(|| { + PyValueError::new_err(format!( + "unknown {vocabulary_label} {supplied:?}; the accepted spellings are {}", + accepted.join(", ") + )) + }) +} + +/// The binding kinds a compute kernel's wire spells. #[cfg(target_os = "linux")] const COMPUTE_BINDING_KIND_WIRE_NAMES: &[&str] = &[ "sampled_image", @@ -1326,18 +1653,92 @@ const COMPUTE_BINDING_KIND_WIRE_NAMES: &[&str] = &[ "uniform_buffer", ]; +/// The binding kinds a graphics kernel's wire spells. No `sampled_image`: the +/// graphics pipeline has no samplerless-texture descriptor. #[cfg(target_os = "linux")] -fn parse_compute_binding_kind(kind: &str) -> PyResult<&'static str> { - COMPUTE_BINDING_KIND_WIRE_NAMES - .iter() - .find(|known| **known == kind) - .copied() - .ok_or_else(|| { - PyValueError::new_err(format!( - "unknown binding kind {kind:?}; a compute binding is one of {}", - COMPUTE_BINDING_KIND_WIRE_NAMES.join(", ") - )) - }) +const GRAPHICS_BINDING_KIND_WIRE_NAMES: &[&str] = &[ + "sampled_texture", + "storage_buffer", + "storage_image", + "uniform_buffer", +]; + +/// The binding kinds a ray-tracing kernel's wire spells. +#[cfg(target_os = "linux")] +const RAY_TRACING_BINDING_KIND_WIRE_NAMES: &[&str] = &[ + ACCELERATION_STRUCTURE_BINDING_KIND_WIRE_NAME, + "sampled_texture", + "storage_buffer", + "storage_image", + "uniform_buffer", +]; + +/// The one binding kind whose value is an acceleration structure rather than a +/// surface, which is why the dispatch path branches on it by name. +#[cfg(target_os = "linux")] +const ACCELERATION_STRUCTURE_BINDING_KIND_WIRE_NAME: &str = "acceleration_structure"; + +/// The stage bits a graphics binding declaration may name. Host counterpart: +/// `GraphicsShaderStageFlags`. +#[cfg(target_os = "linux")] +const GRAPHICS_SHADER_STAGE_WIRE_BITS: &[(&str, u32)] = &[("vertex", 1), ("fragment", 2)]; + +/// The stage bits a ray-tracing binding declaration may name. Host +/// counterpart: `RayTracingShaderStageFlags`. +#[cfg(target_os = "linux")] +const RAY_TRACING_SHADER_STAGE_WIRE_BITS: &[(&str, u32)] = &[ + ("ray_gen", 1), + ("miss", 2), + ("closest_hit", 4), + ("any_hit", 8), + ("intersection", 16), + ("callable", 32), +]; + +/// The stages a ray-tracing kernel's shader modules may fill. +#[cfg(target_os = "linux")] +const RAY_TRACING_SHADER_STAGE_WIRE_NAMES: &[&str] = &[ + "any_hit", + "callable", + "closest_hit", + "intersection", + "miss", + "ray_gen", +]; + +/// The shader-group kinds a ray-tracing kernel's binding table is built from. +#[cfg(target_os = "linux")] +const RAY_TRACING_GROUP_KIND_WIRE_NAMES: &[&str] = &["general", "procedural_hit", "triangles_hit"]; + +/// What a shader group's stage index carries when the group names no stage +/// there. Every stage-index field is present on the wire, so absent needs a +/// value; host counterpart: `RAY_TRACING_STAGE_INDEX_NONE`. +#[cfg(target_os = "linux")] +const RAY_TRACING_STAGE_INDEX_NONE: u32 = u32::MAX; + +/// Turn a sequence of spelled-out names into the bitmask the wire carries. +/// +/// Every bitmask the escalate wire carries — a binding's stage visibility, a +/// TLAS instance's geometry flags — is spelled here rather than handed over as +/// a raw integer, so a caller never writes a bit position. An empty sequence is +/// an empty mask, which for stages asserts nothing and lets reflection stand. +#[cfg(target_os = "linux")] +fn named_bits_to_wire_bitmask( + vocabulary_label: &str, + named: &Bound<'_, PyAny>, + bit_vocabulary: &[(&'static str, u32)], +) -> PyResult { + let accepted: Vec<&'static str> = bit_vocabulary.iter().map(|(name, _)| *name).collect(); + let mut mask = 0u32; + for name in named.try_iter()? { + let name: String = name?.extract()?; + let named = parse_wire_vocabulary_word(vocabulary_label, &name, &accepted)?; + mask |= bit_vocabulary + .iter() + .find(|(candidate, _)| *candidate == named) + .map_or(0, |(_, bit)| *bit); + } + Ok(mask) } /// Turn `{name: kind}` into the wire's declaration array. @@ -1353,21 +1754,700 @@ fn declared_compute_bindings_to_wire<'py>( let kind: String = kind.extract()?; let entry = PyDict::new(python); entry.set_item("name", name)?; - entry.set_item("kind", parse_compute_binding_kind(&kind)?)?; + entry.set_item( + "kind", + parse_wire_vocabulary_word( + "compute binding kind", + &kind, + COMPUTE_BINDING_KIND_WIRE_NAMES, + )?, + )?; wire.append(entry)?; } } Ok(wire) } -/// One binding of a registered kernel as reflection found it: the shader's +/// Turn `{name: kind}` or `{name: (kind, stages)}` into the wire's declaration +/// array, for a kernel kind whose bindings carry a stage mask. +/// +/// Graphics and ray tracing differ only in which two vocabularies they name, +/// which is also why the host reconciles both through one function. +#[cfg(target_os = "linux")] +fn declared_staged_kernel_bindings_to_wire<'py>( + python: Python<'py>, + declared: Option<&Bound<'py, PyDict>>, + binding_kind_label: &str, + binding_kind_vocabulary: &[&'static str], + stage_label: &str, + stage_bits: &[(&'static str, u32)], +) -> PyResult> { + let wire = PyList::empty(python); + let Some(declared) = declared else { + return Ok(wire); + }; + for (name, declaration) in declared.iter() { + let name: String = name.extract()?; + let (kind, stages) = match declaration.extract::() { + Ok(kind) => (kind, 0), + Err(_) => { + let (kind, named_stages) = declaration + .extract::<(String, Bound<'_, PyAny>)>() + .map_err(|_| { + PyTypeError::new_err(format!( + "binding {name:?} must be declared as a kind, or as a (kind, stages) \ + pair naming the stages that read it" + )) + })?; + ( + kind, + named_bits_to_wire_bitmask(stage_label, &named_stages, stage_bits)?, + ) + } + }; + let entry = PyDict::new(python); + entry.set_item("name", name)?; + entry.set_item( + "kind", + parse_wire_vocabulary_word(binding_kind_label, &kind, binding_kind_vocabulary)?, + )?; + entry.set_item("stages", stages)?; + wire.append(entry)?; + } + Ok(wire) +} + +/// One entry of a list-of-mappings argument, refused by name when it is not a +/// mapping. +#[cfg(target_os = "linux")] +fn mapping_argument_entry<'py>( + argument_label: &str, + index: usize, + entry: &Bound<'py, PyAny>, +) -> PyResult> { + entry + .cast::() + .cloned() + .map_err(|_| PyTypeError::new_err(format!("{argument_label} {index} must be a dict"))) +} + +/// Refuse a mapping carrying a key this argument does not accept. +/// +/// A misspelled key would otherwise travel as an absent one the wire fills with +/// a default, which is the silently-wrong-result shape. +#[cfg(target_os = "linux")] +fn refuse_unaccepted_mapping_keys( + mapping_label: &str, + mapping: &Bound<'_, PyDict>, + accepted: &[&str], +) -> PyResult<()> { + for key in mapping.keys() { + let key: String = key.extract()?; + if !accepted.contains(&key.as_str()) { + return Err(PyValueError::new_err(format!( + "{mapping_label} was given an unknown key {key:?}; it accepts {}", + accepted.join(", ") + ))); + } + } + Ok(()) +} + +/// The `u32` at `key`, or `None` when the mapping does not carry it. +#[cfg(target_os = "linux")] +fn optional_u32_in(mapping: &Bound<'_, PyDict>, key: &str) -> PyResult> { + match mapping.get_item(key)? { + Some(value) => Ok(Some(value.extract()?)), + None => Ok(None), + } +} + +/// The string at `key`, or `None` when the mapping does not carry it. +#[cfg(target_os = "linux")] +fn optional_string_in(mapping: &Bound<'_, PyDict>, key: &str) -> PyResult> { + match mapping.get_item(key)? { + Some(value) => Ok(Some(value.extract()?)), + None => Ok(None), + } +} + +/// The primitive topologies a graphics pipeline can assemble. +#[cfg(target_os = "linux")] +const GRAPHICS_TOPOLOGY_WIRE_NAMES: &[&str] = &[ + "line_list", + "line_strip", + "point_list", + "triangle_fan", + "triangle_list", + "triangle_strip", +]; + +#[cfg(target_os = "linux")] +const GRAPHICS_POLYGON_MODE_WIRE_NAMES: &[&str] = &["fill", "line", "point"]; + +#[cfg(target_os = "linux")] +const GRAPHICS_CULL_MODE_WIRE_NAMES: &[&str] = &["back", "front", "front_and_back", "none"]; + +#[cfg(target_os = "linux")] +const GRAPHICS_FRONT_FACE_WIRE_NAMES: &[&str] = &["clockwise", "counter_clockwise"]; + +#[cfg(target_os = "linux")] +const GRAPHICS_DYNAMIC_STATE_WIRE_NAMES: &[&str] = &["none", "viewport_scissor"]; + +#[cfg(target_os = "linux")] +const COLOR_BLEND_FACTOR_WIRE_NAMES: &[&str] = &[ + "constant_alpha", + "constant_color", + "dst_alpha", + "dst_color", + "one", + "one_minus_constant_alpha", + "one_minus_constant_color", + "one_minus_dst_alpha", + "one_minus_dst_color", + "one_minus_src_alpha", + "one_minus_src_color", + "src_alpha", + "src_alpha_saturate", + "src_color", + "zero", +]; + +#[cfg(target_os = "linux")] +const COLOR_BLEND_OP_WIRE_NAMES: &[&str] = &["add", "max", "min", "reverse_subtract", "subtract"]; + +/// The keys the `color_blend` argument accepts, each defaulting to the +/// conventional source-alpha-over blend when the mapping omits it. +#[cfg(target_os = "linux")] +const COLOR_BLEND_ARGUMENT_KEYS: &[&str] = &[ + "alpha_op", + "color_op", + "dst_alpha_factor", + "dst_color_factor", + "src_alpha_factor", + "src_color_factor", +]; + +/// The colour channels a draw writes, as the bitmask the wire carries. +#[cfg(target_os = "linux")] +fn color_write_channels_to_wire(channels: &str) -> PyResult { + let mut mask = 0u32; + for channel in channels.chars() { + mask |= match channel { + 'r' => 1, + 'g' => 2, + 'b' => 4, + 'a' => 8, + _ => { + return Err(PyValueError::new_err(format!( + "unknown colour channel {channel:?} in {channels:?}; a write mask names some \ + of \"rgba\"" + ))); + } + }; + } + Ok(mask) +} + +/// The fixed-function state and attachment formats `create_graphics_kernel` was +/// asked for. +#[cfg(target_os = "linux")] +struct GraphicsPipelineStateArguments<'a, 'py> { + color_attachment_formats: &'a [String], + topology: &'a str, + polygon_mode: &'a str, + cull_mode: &'a str, + front_face: &'a str, + line_width: f32, + color_write_channels: &'a str, + color_blend: Option<&'a Bound<'py, PyDict>>, + dynamic_state: &'a str, +} + +/// Flatten the pipeline state into the one-level document the wire carries. +/// +/// Every field is present because the wire is flat — JSON has no sum types — +/// and the flags decide which ones mean anything. Three groups are pinned here +/// rather than offered as arguments, because a caller could only ever set them +/// to a shape that fails: +/// - `multisample_samples`, since the host builds single-sampled pipelines only. +/// - the vertex-input arrays, since no escalate op mints a vertex buffer for a +/// draw to pull through them. +/// - the depth fields, since the offscreen pass a draw runs attaches colour +/// targets only. +#[cfg(target_os = "linux")] +fn graphics_pipeline_state_to_wire<'py>( + python: Python<'py>, + state: &GraphicsPipelineStateArguments<'_, '_>, +) -> PyResult> { + if let Some(color_blend) = state.color_blend { + refuse_unaccepted_mapping_keys("color_blend", color_blend, COLOR_BLEND_ARGUMENT_KEYS)?; + } + let blend_word = |key: &str, + when_absent: &'static str, + vocabulary: &[&'static str]| + -> PyResult<&'static str> { + let Some(color_blend) = state.color_blend else { + return Ok(when_absent); + }; + match optional_string_in(color_blend, key)? { + Some(spelled) => parse_wire_vocabulary_word(key, &spelled, vocabulary), + None => Ok(when_absent), + } + }; + + let color_formats = PyList::empty(python); + for format in state.color_attachment_formats { + color_formats.append(parse_texture_format_name(format)?)?; + } + + let wire = PyDict::new(python); + wire.set_item("attachment_color_formats", color_formats)?; + wire.set_item( + "topology", + parse_wire_vocabulary_word("topology", state.topology, GRAPHICS_TOPOLOGY_WIRE_NAMES)?, + )?; + wire.set_item( + "rasterization_polygon_mode", + parse_wire_vocabulary_word( + "polygon mode", + state.polygon_mode, + GRAPHICS_POLYGON_MODE_WIRE_NAMES, + )?, + )?; + wire.set_item( + "rasterization_cull_mode", + parse_wire_vocabulary_word("cull mode", state.cull_mode, GRAPHICS_CULL_MODE_WIRE_NAMES)?, + )?; + wire.set_item( + "rasterization_front_face", + parse_wire_vocabulary_word( + "front face", + state.front_face, + GRAPHICS_FRONT_FACE_WIRE_NAMES, + )?, + )?; + wire.set_item("rasterization_line_width", state.line_width)?; + wire.set_item("multisample_samples", 1u32)?; + wire.set_item("vertex_input_bindings", PyList::empty(python))?; + wire.set_item("vertex_input_attributes", PyList::empty(python))?; + wire.set_item("depth_stencil_enabled", false)?; + wire.set_item("depth_write", false)?; + wire.set_item("depth_compare_op", "always")?; + wire.set_item( + "color_write_mask", + color_write_channels_to_wire(state.color_write_channels)?, + )?; + wire.set_item("color_blend_enabled", state.color_blend.is_some())?; + wire.set_item( + "color_blend_src_color_factor", + blend_word( + "src_color_factor", + "src_alpha", + COLOR_BLEND_FACTOR_WIRE_NAMES, + )?, + )?; + wire.set_item( + "color_blend_dst_color_factor", + blend_word( + "dst_color_factor", + "one_minus_src_alpha", + COLOR_BLEND_FACTOR_WIRE_NAMES, + )?, + )?; + wire.set_item( + "color_blend_color_op", + blend_word("color_op", "add", COLOR_BLEND_OP_WIRE_NAMES)?, + )?; + wire.set_item( + "color_blend_src_alpha_factor", + blend_word("src_alpha_factor", "one", COLOR_BLEND_FACTOR_WIRE_NAMES)?, + )?; + wire.set_item( + "color_blend_dst_alpha_factor", + blend_word( + "dst_alpha_factor", + "one_minus_src_alpha", + COLOR_BLEND_FACTOR_WIRE_NAMES, + )?, + )?; + wire.set_item( + "color_blend_alpha_op", + blend_word("alpha_op", "add", COLOR_BLEND_OP_WIRE_NAMES)?, + )?; + wire.set_item( + "dynamic_state", + parse_wire_vocabulary_word( + "dynamic state", + state.dynamic_state, + GRAPHICS_DYNAMIC_STATE_WIRE_NAMES, + )?, + )?; + Ok(wire) +} + +/// The keys one entry of the `stages` argument accepts. +#[cfg(target_os = "linux")] +const RAY_TRACING_STAGE_ARGUMENT_KEYS: &[&str] = &["entry_point", "source", "spirv", "stage"]; + +/// Turn `stages=[…]` into the wire's shader-stage array. +/// +/// `source` and `spirv` both travel: exactly-one-of is refused host-side, in +/// the one place that rule is written. +#[cfg(target_os = "linux")] +fn ray_tracing_stages_to_wire<'py>( + python: Python<'py>, + stages: &Bound<'_, PyAny>, +) -> PyResult> { + let wire = PyList::empty(python); + for (index, stage) in stages.try_iter()?.enumerate() { + let stage = mapping_argument_entry("stage", index, &stage?)?; + refuse_unaccepted_mapping_keys( + &format!("stage {index}"), + &stage, + RAY_TRACING_STAGE_ARGUMENT_KEYS, + )?; + let named_stage = optional_string_in(&stage, "stage")?.ok_or_else(|| { + PyValueError::new_err(format!( + "stage {index} names no `stage`; every shader module says which stage it fills" + )) + })?; + let spirv: Vec = match stage.get_item("spirv")? { + Some(blob) => blob.extract()?, + None => Vec::new(), + }; + let entry = PyDict::new(python); + entry.set_item( + "stage", + parse_wire_vocabulary_word( + "ray-tracing stage", + &named_stage, + RAY_TRACING_SHADER_STAGE_WIRE_NAMES, + )?, + )?; + entry.set_item( + "source", + optional_string_in(&stage, "source")?.unwrap_or_default(), + )?; + entry.set_item("spv_hex", encode_lowercase_hex(&spirv))?; + entry.set_item( + "entry_point", + optional_string_in(&stage, "entry_point")?.unwrap_or_else(|| "main".to_string()), + )?; + wire.append(entry)?; + } + Ok(wire) +} + +/// The keys one entry of the `groups` argument accepts. +#[cfg(target_os = "linux")] +const RAY_TRACING_GROUP_ARGUMENT_KEYS: &[&str] = &[ + "any_hit_stage", + "closest_hit_stage", + "general_stage", + "intersection_stage", + "kind", +]; + +/// Turn `groups=[…]` into the wire's shader-group array. +/// +/// A group names its stages by index into the `stages` argument — the shader +/// binding table is built in this order, and two modules can fill the same +/// stage, so there is no name to use instead. Absent indices become the wire's +/// sentinel here rather than in the caller's source. +#[cfg(target_os = "linux")] +fn ray_tracing_shader_groups_to_wire<'py>( + python: Python<'py>, + groups: &Bound<'_, PyAny>, + stage_count: usize, +) -> PyResult> { + let wire = PyList::empty(python); + for (index, group) in groups.try_iter()?.enumerate() { + let group = mapping_argument_entry("group", index, &group?)?; + refuse_unaccepted_mapping_keys( + &format!("group {index}"), + &group, + RAY_TRACING_GROUP_ARGUMENT_KEYS, + )?; + let kind = optional_string_in(&group, "kind")? + .ok_or_else(|| PyValueError::new_err(format!("group {index} names no `kind`")))?; + let kind = parse_wire_vocabulary_word( + "shader group kind", + &kind, + RAY_TRACING_GROUP_KIND_WIRE_NAMES, + )?; + + let named_stage = |key: &str| -> PyResult> { + let Some(stage_index) = optional_u32_in(&group, key)? else { + return Ok(None); + }; + if stage_index as usize >= stage_count { + return Err(PyValueError::new_err(format!( + "group {index} names {key} {stage_index}, and only {stage_count} shader \ + module(s) were supplied" + ))); + } + Ok(Some(stage_index)) + }; + let general = named_stage("general_stage")?; + let closest_hit = named_stage("closest_hit_stage")?; + let any_hit = named_stage("any_hit_stage")?; + let intersection = named_stage("intersection_stage")?; + + match kind { + "general" if general.is_none() => { + return Err(PyValueError::new_err(format!( + "group {index} is `general` and names no `general_stage`; a general group is \ + the one ray-gen, miss or callable module it points at" + ))); + } + "triangles_hit" if closest_hit.is_none() && any_hit.is_none() => { + return Err(PyValueError::new_err(format!( + "group {index} is `triangles_hit` and names neither `closest_hit_stage` nor \ + `any_hit_stage`; a hit group needs at least one of them" + ))); + } + "procedural_hit" if intersection.is_none() => { + return Err(PyValueError::new_err(format!( + "group {index} is `procedural_hit` and names no `intersection_stage`, which \ + is the module a procedural group intersects with" + ))); + } + _ => {} + } + + let entry = PyDict::new(python); + entry.set_item("kind", kind)?; + entry.set_item( + "general_stage", + general.unwrap_or(RAY_TRACING_STAGE_INDEX_NONE), + )?; + entry.set_item( + "closest_hit_stage", + closest_hit.unwrap_or(RAY_TRACING_STAGE_INDEX_NONE), + )?; + entry.set_item( + "any_hit_stage", + any_hit.unwrap_or(RAY_TRACING_STAGE_INDEX_NONE), + )?; + entry.set_item( + "intersection_stage", + intersection.unwrap_or(RAY_TRACING_STAGE_INDEX_NONE), + )?; + wire.append(entry)?; + } + Ok(wire) +} + +/// The keys one entry of `build_tlas`'s `instances` argument accepts. +#[cfg(target_os = "linux")] +const TLAS_INSTANCE_ARGUMENT_KEYS: &[&str] = &[ + "blas", + "custom_index", + "flags", + "mask", + "sbt_record_offset", + "transform", +]; + +/// The `VkGeometryInstanceFlagsKHR` bits an instance can name, spelled rather +/// than passed as a raw mask. +#[cfg(target_os = "linux")] +const GEOMETRY_INSTANCE_FLAG_WIRE_BITS: &[(&str, u32)] = &[ + ("triangle_facing_cull_disable", 1), + ("triangle_flip_facing", 2), + ("force_opaque", 4), + ("force_no_opaque", 8), +]; + +/// Row-major 3×4 identity — where an instance that names no transform sits. +#[cfg(target_os = "linux")] +const IDENTITY_TLAS_INSTANCE_TRANSFORM: [f32; 12] = [ + 1.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, +]; + +/// The widest value an instance's 24-bit `gl_InstanceCustomIndexEXT` can carry. +/// The host masks the high byte off silently, so it is refused here. +#[cfg(target_os = "linux")] +const WIDEST_TLAS_INSTANCE_CUSTOM_INDEX: u32 = 0x00ff_ffff; + +/// Turn `instances=[…]` into the wire's TLAS instance array. +#[cfg(target_os = "linux")] +fn tlas_instances_to_wire<'py>( + python: Python<'py>, + instances: &Bound<'_, PyAny>, +) -> PyResult> { + let wire = PyList::empty(python); + for (index, instance) in instances.try_iter()?.enumerate() { + let instance = mapping_argument_entry("instance", index, &instance?)?; + refuse_unaccepted_mapping_keys( + &format!("instance {index}"), + &instance, + TLAS_INSTANCE_ARGUMENT_KEYS, + )?; + let named_blas = instance.get_item("blas")?.ok_or_else(|| { + PyValueError::new_err(format!( + "instance {index} names no `blas`; an instance places one bottom-level structure \ + in the scene" + )) + })?; + let bottom_level = named_blas + .extract::>() + .map_err(|_| { + PyTypeError::new_err(format!( + "instance {index}'s `blas` must be the handle `build_triangles_blas` returned" + )) + })?; + if bottom_level.is_top_level { + return Err(PyValueError::new_err(format!( + "instance {index}'s `blas` is a top-level structure; an instance places a \ + bottom-level one, and the top-level structure is what a trace binds" + ))); + } + let transform: Vec = match instance.get_item("transform")? { + Some(transform) => transform.extract()?, + None => IDENTITY_TLAS_INSTANCE_TRANSFORM.to_vec(), + }; + if transform.len() != 12 { + return Err(PyValueError::new_err(format!( + "instance {index}'s transform has {} floats; it is a row-major 3×4 affine, so \ + exactly 12", + transform.len() + ))); + } + let mask = optional_u32_in(&instance, "mask")?.unwrap_or(0xff); + if mask > 0xff { + return Err(PyValueError::new_err(format!( + "instance {index}'s mask is {mask}; a visibility mask is 8-bit, and a ray hits \ + the instance when `mask & cull_mask` is non-zero" + ))); + } + let custom_index = optional_u32_in(&instance, "custom_index")?.unwrap_or(0); + if custom_index > WIDEST_TLAS_INSTANCE_CUSTOM_INDEX { + return Err(PyValueError::new_err(format!( + "instance {index}'s custom_index is {custom_index}; it reaches hit shaders as a \ + 24-bit `gl_InstanceCustomIndexEXT`, so anything above \ + {WIDEST_TLAS_INSTANCE_CUSTOM_INDEX} would arrive truncated" + ))); + } + let flags = match instance.get_item("flags")? { + Some(named_flags) => named_bits_to_wire_bitmask( + "geometry instance flag", + &named_flags, + GEOMETRY_INSTANCE_FLAG_WIRE_BITS, + )?, + None => 0, + }; + + let entry = PyDict::new(python); + entry.set_item("blas_id", bottom_level.acceleration_structure_id.as_str())?; + entry.set_item("transform", transform)?; + entry.set_item("mask", mask)?; + entry.set_item("custom_index", custom_index)?; + entry.set_item( + "sbt_record_offset", + optional_u32_in(&instance, "sbt_record_offset")?.unwrap_or(0), + )?; + entry.set_item("flags", flags)?; + wire.append(entry)?; + } + Ok(wire) +} + +/// One binding of a registered kernel as reflection found it: the shaders' /// name and the wire spelling of its kind. -pub(crate) struct ReflectedComputeBinding { +/// +/// One type for all three pipeline kinds, because a register response carries +/// the same two fields whichever op asked for it. +pub(crate) struct ReflectedKernelBinding { pub(crate) name: String, #[cfg_attr(not(target_os = "linux"), expect(dead_code))] pub(crate) kind: String, } +/// The shaders' own names for a kernel's bindings, in slot order. +fn reflected_binding_names(reflected: &[ReflectedKernelBinding]) -> Vec { + reflected + .iter() + .map(|binding| binding.name.clone()) + .collect() +} + +/// The kind the shaders declare `name` as. +/// +/// An unknown name is refused here rather than sent — the round trip would +/// refuse it too, but the caller's own stack is where the mistake is. +#[cfg(target_os = "linux")] +fn reflected_kind_of_binding<'a>( + reflected: &'a [ReflectedKernelBinding], + name: &str, +) -> PyResult<&'a str> { + reflected + .iter() + .find(|binding| binding.name == name) + .map(|binding| binding.kind.as_str()) + .ok_or_else(|| { + PyValueError::new_err(format!( + "no binding named {name:?}; these shaders declare {}", + reflected_binding_names(reflected) + .iter() + .map(|declared| format!("{declared:?}")) + .collect::>() + .join(", ") + )) + }) +} + +/// Refuse a push-constant payload that is not the size the kernel declares. +/// +/// The engine reconciles the declared size against reflection at construction, +/// so a kernel that exists agrees with its shaders and this check is the +/// shaders' own. +#[cfg(target_os = "linux")] +fn require_declared_push_constant_size(declared_size: u32, supplied: &[u8]) -> PyResult<()> { + if supplied.len() != declared_size as usize { + return Err(PyValueError::new_err(format!( + "this kernel declares {declared_size} push-constant bytes but {} were supplied", + supplied.len() + ))); + } + Ok(()) +} + +/// One dispatch's bindings as the wire carries them, each resolved by the kind +/// the shaders declare it. +/// +/// `wire_target_field_name` is the wire's own name for the bound resource — +/// `surface_uuid` on a graphics draw, `target_id` everywhere else. An +/// `acceleration_structure` binding resolves through its own registry rather +/// than through a surface, so it is the one kind that takes a different handle. +#[cfg(target_os = "linux")] +fn supplied_kernel_bindings_to_wire<'py>( + python: Python<'py>, + reflected: &[ReflectedKernelBinding], + supplied: &Bound<'py, PyDict>, + wire_target_field_name: &str, +) -> PyResult> { + let wire_bindings = PyList::empty(python); + for (name, bound_to) in supplied.iter() { + let name: String = name.extract()?; + let kind = reflected_kind_of_binding(reflected, &name)?.to_string(); + let target_id = if kind == ACCELERATION_STRUCTURE_BINDING_KIND_WIRE_NAME { + bound_acceleration_structure_id(&name, &bound_to)? + } else { + bound_surface_id(&name, &bound_to)? + }; + let entry = PyDict::new(python); + entry.set_item(wire_target_field_name, target_id)?; + entry.set_item("name", name)?; + entry.set_item("kind", kind)?; + wire_bindings.append(entry)?; + } + Ok(wire_bindings) +} + /// A compute kernel the engine built and holds, dispatched by name. /// /// Constructed in `setup()` where the capability is Full; dispatched per frame @@ -1385,7 +2465,7 @@ pub(crate) struct PythonComputeKernel { push_constant_size: u32, /// The caller supplies surfaces by name; which kind each name is, is the /// shader's to say, so it is carried rather than guessed per dispatch. - reflected_binding_kinds: Vec, + reflected_binding_kinds: Vec, #[cfg_attr(not(target_os = "linux"), expect(dead_code))] helper_process_exchange_client: Arc, } @@ -1395,10 +2475,7 @@ impl PythonComputeKernel { /// The shader's own names for this kernel's bindings, in slot order. #[getter] fn binding_names(&self) -> Vec { - self.reflected_binding_kinds - .iter() - .map(|binding| binding.name.clone()) - .collect() + reflected_binding_names(&self.reflected_binding_kinds) } /// Dispatch this kernel, binding each of the shader's declared resources @@ -1451,48 +2528,15 @@ impl PythonComputeKernel { push_constants: Option<&[u8]>, ) -> PyResult<(Bound<'py, PyList>, String)> { let push_constants = push_constants.unwrap_or_default(); - if push_constants.len() != self.push_constant_size as usize { - return Err(PyValueError::new_err(format!( - "this kernel declares {} push-constant bytes but {} were supplied", - self.push_constant_size, - push_constants.len() - ))); - } - - let wire_bindings = PyList::empty(python); - for (name, bound_to) in bindings.iter() { - let name: String = name.extract()?; - let kind = self.reflected_kind_of(&name)?.to_string(); - let entry = PyDict::new(python); - entry.set_item("target_id", bound_surface_id(&name, &bound_to)?)?; - entry.set_item("name", name)?; - entry.set_item("kind", kind)?; - wire_bindings.append(entry)?; - } + require_declared_push_constant_size(self.push_constant_size, push_constants)?; + let wire_bindings = supplied_kernel_bindings_to_wire( + python, + &self.reflected_binding_kinds, + bindings, + "target_id", + )?; Ok((wire_bindings, encode_lowercase_hex(push_constants))) } - - /// The kind the shader declares this name as. - /// - /// An unknown name is refused here rather than sent — the round trip would - /// refuse it too, but the caller's own stack is where the mistake is. - #[cfg(target_os = "linux")] - fn reflected_kind_of(&self, name: &str) -> PyResult<&str> { - self.reflected_binding_kinds - .iter() - .find(|binding| binding.name == name) - .map(|binding| binding.kind.as_str()) - .ok_or_else(|| { - PyValueError::new_err(format!( - "no binding named {name:?}; this shader declares {}", - self.binding_names() - .iter() - .map(|declared| format!("{declared:?}")) - .collect::>() - .join(", ") - )) - }) - } } /// One recorded dispatch: the wire entry to send, and the kernel it names. @@ -1679,6 +2723,251 @@ impl PythonKernelDispatchBatch { } } +/// A graphics kernel the engine built and holds, drawn by name. +/// +/// Constructed in `setup()` where the capability is Full; drawn per frame in +/// `process()`. No kernel handle string, fence, timeline or descriptor slot +/// number reaches Python — the object is the handle. +/// +/// Defined on every platform so the stub's surface is honest everywhere; off +/// Linux it is unconstructible, because `create_graphics_kernel` refuses before +/// reaching it. +#[pyclass(name = "GraphicsKernel", module = "streamlib", frozen)] +pub(crate) struct PythonGraphicsKernel { + #[cfg_attr(not(target_os = "linux"), expect(dead_code))] + kernel_id: String, + #[cfg_attr(not(target_os = "linux"), expect(dead_code))] + push_constant_size: u32, + /// The caller supplies surfaces by name; which kind each name is, is the + /// shaders' to say, so it is carried rather than guessed per draw. + reflected_binding_kinds: Vec, + #[cfg_attr(not(target_os = "linux"), expect(dead_code))] + helper_process_exchange_client: Arc, +} + +#[pymethods] +impl PythonGraphicsKernel { + /// The shaders' own names for this kernel's bindings, in slot order. + #[getter] + fn binding_names(&self) -> Vec { + reflected_binding_names(&self.reflected_binding_kinds) + } + + /// Render one offscreen pass into `color_targets`, binding each of the + /// shaders' declared resources by name. + /// + /// Bindings never persist on the kernel, so every draw supplies all of + /// them. The pass discards each colour target's previous contents and + /// starts from transparent black. Returns when the GPU work has retired and + /// the pixels are visible. + #[pyo3(signature = ( + bindings, + color_targets, + extent, + vertex_count, + instance_count = 1, + first_vertex = 0, + first_instance = 0, + push_constants = None, + ))] + #[expect( + clippy::too_many_arguments, + reason = "each is one field of the draw the wire carries; a bundle would hide them" + )] + fn draw( + &self, + python: Python<'_>, + bindings: &Bound<'_, PyDict>, + color_targets: &Bound<'_, PyAny>, + extent: (u32, u32), + vertex_count: u32, + instance_count: u32, + first_vertex: u32, + first_instance: u32, + push_constants: Option<&[u8]>, + ) -> PyResult<()> { + #[cfg(target_os = "linux")] + { + let push_constants = push_constants.unwrap_or_default(); + require_declared_push_constant_size(self.push_constant_size, push_constants)?; + let target_surface_ids = PyList::empty(python); + for (index, target) in color_targets.try_iter()?.enumerate() { + target_surface_ids.append(bound_surface_id( + &format!("colour target {index}"), + &target?, + )?)?; + } + if target_surface_ids.len() != 1 { + return Err(PyValueError::new_err(format!( + "this draw names {} colour targets; the pipeline is built for exactly one \ + colour attachment", + target_surface_ids.len() + ))); + } + let wire_bindings = supplied_kernel_bindings_to_wire( + python, + &self.reflected_binding_kinds, + bindings, + "surface_uuid", + )?; + self.helper_process_exchange_client.run_graphics_draw( + python, + &HelperProcessGraphicsDraw { + kernel_id: &self.kernel_id, + bindings: &wire_bindings, + color_target_surface_ids: &target_surface_ids, + push_constants_hex: &encode_lowercase_hex(push_constants), + vertex_count, + instance_count, + first_vertex, + first_instance, + extent_width: extent.0, + extent_height: extent.1, + }, + ) + } + #[cfg(not(target_os = "linux"))] + { + let _ = ( + python, + bindings, + color_targets, + extent, + vertex_count, + instance_count, + first_vertex, + first_instance, + push_constants, + ); + Err(gpu_unreachable_from_a_helper_process_error()) + } + } +} + +/// A ray-tracing kernel the engine built and holds, traced by name. +/// +/// Constructed in `setup()` where the capability is Full; traced per frame in +/// `process()`. Like the other two kernel objects, nothing about the engine's +/// handle for it reaches Python. +/// +/// Defined on every platform so the stub's surface is honest everywhere; off +/// Linux it is unconstructible, because `create_ray_tracing_kernel` refuses +/// before reaching it. +#[pyclass(name = "RayTracingKernel", module = "streamlib", frozen)] +pub(crate) struct PythonRayTracingKernel { + #[cfg_attr(not(target_os = "linux"), expect(dead_code))] + kernel_id: String, + #[cfg_attr(not(target_os = "linux"), expect(dead_code))] + push_constant_size: u32, + /// The caller supplies targets by name; which kind each name is, is the + /// shaders' to say, and it is also what decides whether a name takes a + /// surface or an acceleration structure. + reflected_binding_kinds: Vec, + #[cfg_attr(not(target_os = "linux"), expect(dead_code))] + helper_process_exchange_client: Arc, +} + +#[pymethods] +impl PythonRayTracingKernel { + /// The shaders' own names for this kernel's bindings, in slot order. + #[getter] + fn binding_names(&self) -> Vec { + reflected_binding_names(&self.reflected_binding_kinds) + } + + /// Trace a `(width, height, depth)` grid of rays, binding each of the + /// shaders' declared resources by name. + /// + /// An `acceleration_structure` binding takes the handle `build_tlas` + /// returned; every other kind takes a surface. Bindings never persist on + /// the kernel, so every trace supplies all of them. Returns when the GPU + /// work has retired and the writes are visible. + #[pyo3(signature = (bindings, grid, push_constants = None))] + fn trace( + &self, + python: Python<'_>, + bindings: &Bound<'_, PyDict>, + grid: (u32, u32, u32), + push_constants: Option<&[u8]>, + ) -> PyResult<()> { + #[cfg(target_os = "linux")] + { + let push_constants = push_constants.unwrap_or_default(); + require_declared_push_constant_size(self.push_constant_size, push_constants)?; + let wire_bindings = supplied_kernel_bindings_to_wire( + python, + &self.reflected_binding_kinds, + bindings, + "target_id", + )?; + self.helper_process_exchange_client.run_ray_tracing_kernel( + python, + &self.kernel_id, + &wire_bindings, + &encode_lowercase_hex(push_constants), + grid, + ) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (python, bindings, grid, push_constants); + Err(gpu_unreachable_from_a_helper_process_error()) + } + } +} + +/// An acceleration structure the engine built and holds. +/// +/// The object is the handle: a bottom-level structure is placed in a scene by +/// `build_tlas`, and the top-level one it returns is what a trace binds. No id +/// string reaches Python, and nothing publishes an acceleration structure for +/// another processor to resolve. +/// +/// Defined on every platform so the stub's surface is honest everywhere; off +/// Linux it is unconstructible, because both builders refuse before reaching +/// it. +#[pyclass(name = "AccelerationStructureHandle", module = "streamlib", frozen)] +pub(crate) struct PythonAccelerationStructureHandle { + #[cfg_attr(not(target_os = "linux"), expect(dead_code))] + acceleration_structure_id: String, + /// Which of the two builders minted this, so binding a bottom-level + /// structure at a trace — or instancing a top-level one — refuses in the + /// caller's own stack. + #[cfg_attr(not(target_os = "linux"), expect(dead_code))] + is_top_level: bool, + structure_label: String, + /// The release this handle owes the engine, paid on drop. `None` only in + /// tests, which mint a handle without a parent to hand anything back to. + #[cfg(target_os = "linux")] + helper_process_exchange_client: Option>, +} + +#[cfg(target_os = "linux")] +impl Drop for PythonAccelerationStructureHandle { + /// The engine holds a structure's device memory for as long as the handle + /// naming it lives, which is the lifetime a Rust caller's + /// `VulkanAccelerationStructure` has. A scene keeps every bottom-level + /// structure it instances alive, so letting go of a BLAS a live TLAS uses + /// frees nothing until the TLAS goes too. + fn drop(&mut self) { + let Some(exchange_client) = self.helper_process_exchange_client.take() else { + return; + }; + Python::attach(|python| { + exchange_client.release_acceleration_structure(python, &self.acceleration_structure_id); + }); + } +} + +#[pymethods] +impl PythonAccelerationStructureHandle { + /// The name this structure was built under, as it appears in engine logs. + #[getter] + fn label(&self) -> String { + self.structure_label.clone() + } +} + /// The surface id a value bound at `name` names. #[cfg(target_os = "linux")] fn bound_surface_id(name: &str, bound_to: &Bound<'_, PyAny>) -> PyResult { @@ -1692,6 +2981,31 @@ fn bound_surface_id(name: &str, bound_to: &Bound<'_, PyAny>) -> PyResult }) } +/// The acceleration structure a value bound at `name` names. +/// +/// The only binding kind that is not a surface, and the only one whose handle +/// cannot be spelled as an id string — nothing publishes an acceleration +/// structure for another processor to resolve, so the object a build returned +/// is the whole way to name it. +#[cfg(target_os = "linux")] +fn bound_acceleration_structure_id(name: &str, bound_to: &Bound<'_, PyAny>) -> PyResult { + let structure = bound_to + .extract::>() + .map_err(|_| { + PyTypeError::new_err(format!( + "binding {name:?} is an acceleration_structure; bind the handle `build_tlas` \ + returned" + )) + })?; + if !structure.is_top_level { + return Err(PyValueError::new_err(format!( + "binding {name:?} was given a bottom-level structure; a trace binds the top-level one \ + `build_tlas` returned, which is what holds the instances" + ))); + } + Ok(structure.acceleration_structure_id.clone()) +} + /// The typed cast's claim, over a real link and a real surface-share service. /// /// What is proven here is the seam, not a type: a bag crosses a wired link, the @@ -1955,6 +3269,300 @@ class FrameSomebodyElseWrote: } } +/// What a caller can get wrong building a graphics or ray-tracing kernel, +/// refused before anything is sent. +/// +/// Each of these travels as a plain field of a `#[serde(deny_unknown_fields)]` +/// escalate document, so a mistake the wheel forwards comes back as a parse +/// failure naming a wire field the author never wrote. Provable with no GPU: +/// nothing here reaches the exchange client. +#[cfg(all(test, target_os = "linux"))] +mod kernel_argument_tests { + use super::*; + + fn wire_entries<'py>(wire: &Bound<'py, PyList>) -> Vec> { + wire.iter().collect() + } + + fn wire_text(entry: &Bound<'_, PyAny>, field: &str) -> String { + entry.get_item(field).unwrap().extract().unwrap() + } + + fn wire_number(entry: &Bound<'_, PyAny>, field: &str) -> u32 { + entry.get_item(field).unwrap().extract().unwrap() + } + + fn bottom_level_structure(python: Python<'_>) -> Py { + Py::new( + python, + PythonAccelerationStructureHandle { + acceleration_structure_id: "blas-under-test".to_string(), + is_top_level: false, + structure_label: "floor".to_string(), + helper_process_exchange_client: None, + }, + ) + .unwrap() + } + + /// A declaration asserts the kind; naming stages is optional, and naming + /// none of them asserts nothing so reflection stands. + #[test] + fn a_binding_declaration_carries_the_stages_it_names_and_no_others() { + Python::initialize(); + Python::attach(|python| { + let declared = PyDict::new(python); + declared + .set_item("scene_texture", "sampled_texture") + .unwrap(); + declared + .set_item( + "output_image", + ("storage_image", vec!["vertex", "fragment"]), + ) + .unwrap(); + + let wire = declared_staged_kernel_bindings_to_wire( + python, + Some(&declared), + "graphics binding kind", + GRAPHICS_BINDING_KIND_WIRE_NAMES, + "graphics stage", + GRAPHICS_SHADER_STAGE_WIRE_BITS, + ) + .unwrap(); + + let entries = wire_entries(&wire); + assert_eq!(wire_text(&entries[0], "name"), "scene_texture"); + assert_eq!(wire_text(&entries[0], "kind"), "sampled_texture"); + assert_eq!( + wire_number(&entries[0], "stages"), + 0, + "a declaration that names no stage must assert nothing about stages" + ); + assert_eq!(wire_number(&entries[1], "stages"), 0b11); + }); + } + + #[test] + fn a_binding_kind_the_pipeline_does_not_have_is_refused_naming_the_set() { + Python::initialize(); + Python::attach(|python| { + let declared = PyDict::new(python); + declared.set_item("scene_texture", "sampled_image").unwrap(); + let refusal = declared_staged_kernel_bindings_to_wire( + python, + Some(&declared), + "graphics binding kind", + GRAPHICS_BINDING_KIND_WIRE_NAMES, + "graphics stage", + GRAPHICS_SHADER_STAGE_WIRE_BITS, + ) + .expect_err("a graphics pipeline has no samplerless-texture descriptor"); + let refusal = refusal.to_string(); + assert!(refusal.contains("sampled_image"), "{refusal}"); + assert!(refusal.contains("sampled_texture"), "{refusal}"); + }); + } + + #[test] + fn a_stage_no_graphics_pipeline_runs_is_refused() { + Python::initialize(); + Python::attach(|python| { + let declared = PyDict::new(python); + declared + .set_item("output_image", ("storage_image", vec!["ray_gen"])) + .unwrap(); + let refusal = declared_staged_kernel_bindings_to_wire( + python, + Some(&declared), + "graphics binding kind", + GRAPHICS_BINDING_KIND_WIRE_NAMES, + "graphics stage", + GRAPHICS_SHADER_STAGE_WIRE_BITS, + ) + .expect_err("a graphics binding cannot be read from a ray-generation stage"); + assert!(refusal.to_string().contains("ray_gen"), "{refusal}"); + }); + } + + /// The wire has no way to omit a stage index, so a group that names none + /// carries the sentinel — which is the wheel's job, not the author's. + #[test] + fn a_shader_group_fills_the_stages_it_does_not_name_with_the_sentinel() { + Python::initialize(); + Python::attach(|python| { + let hit_group = PyDict::new(python); + hit_group.set_item("kind", "triangles_hit").unwrap(); + hit_group.set_item("closest_hit_stage", 1u32).unwrap(); + let groups = PyList::new(python, [hit_group]).unwrap(); + + let wire = ray_tracing_shader_groups_to_wire(python, groups.as_any(), 2).unwrap(); + let entries = wire_entries(&wire); + assert_eq!(wire_number(&entries[0], "closest_hit_stage"), 1); + assert_eq!( + wire_number(&entries[0], "any_hit_stage"), + RAY_TRACING_STAGE_INDEX_NONE + ); + assert_eq!( + wire_number(&entries[0], "general_stage"), + RAY_TRACING_STAGE_INDEX_NONE + ); + }); + } + + #[test] + fn a_shader_group_naming_a_module_that_was_not_supplied_is_refused() { + Python::initialize(); + Python::attach(|python| { + let group = PyDict::new(python); + group.set_item("kind", "general").unwrap(); + group.set_item("general_stage", 4u32).unwrap(); + let groups = PyList::new(python, [group]).unwrap(); + + let refusal = ray_tracing_shader_groups_to_wire(python, groups.as_any(), 2) + .expect_err("a group cannot point past the modules it was built from"); + assert!(refusal.to_string().contains("general_stage 4"), "{refusal}"); + }); + } + + #[test] + fn a_general_shader_group_that_names_no_module_is_refused() { + Python::initialize(); + Python::attach(|python| { + let group = PyDict::new(python); + group.set_item("kind", "general").unwrap(); + let groups = PyList::new(python, [group]).unwrap(); + + let refusal = ray_tracing_shader_groups_to_wire(python, groups.as_any(), 2) + .expect_err("a general group is the module it points at"); + assert!(refusal.to_string().contains("general_stage"), "{refusal}"); + }); + } + + #[test] + fn a_misspelled_group_key_is_refused_rather_than_silently_dropped() { + Python::initialize(); + Python::attach(|python| { + let group = PyDict::new(python); + group.set_item("kind", "general").unwrap(); + group.set_item("general_stag", 0u32).unwrap(); + let groups = PyList::new(python, [group]).unwrap(); + + let refusal = ray_tracing_shader_groups_to_wire(python, groups.as_any(), 1) + .expect_err("a misspelled key would otherwise read as an absent one"); + assert!(refusal.to_string().contains("general_stag"), "{refusal}"); + }); + } + + /// An instance that names only its structure sits at the origin, visible + /// to every cull mask — the placement a caller means by saying nothing. + #[test] + fn a_tlas_instance_that_names_only_its_structure_gets_the_conventional_placement() { + Python::initialize(); + Python::attach(|python| { + let instance = PyDict::new(python); + instance + .set_item("blas", bottom_level_structure(python)) + .unwrap(); + let instances = PyList::new(python, [instance]).unwrap(); + + let wire = tlas_instances_to_wire(python, instances.as_any()).unwrap(); + let entries = wire_entries(&wire); + assert_eq!(wire_text(&entries[0], "blas_id"), "blas-under-test"); + assert_eq!(wire_number(&entries[0], "mask"), 0xff); + assert_eq!(wire_number(&entries[0], "custom_index"), 0); + assert_eq!(wire_number(&entries[0], "flags"), 0); + let transform: Vec = entries[0].get_item("transform").unwrap().extract().unwrap(); + assert_eq!(transform, IDENTITY_TLAS_INSTANCE_TRANSFORM.to_vec()); + }); + } + + #[test] + fn a_tlas_instance_transform_that_is_not_a_three_by_four_affine_is_refused() { + Python::initialize(); + Python::attach(|python| { + let instance = PyDict::new(python); + instance + .set_item("blas", bottom_level_structure(python)) + .unwrap(); + instance.set_item("transform", vec![1.0f32; 16]).unwrap(); + let instances = PyList::new(python, [instance]).unwrap(); + + let refusal = tlas_instances_to_wire(python, instances.as_any()) + .expect_err("a 4×4 transform is not what VkTransformMatrixKHR carries"); + assert!(refusal.to_string().contains("16 floats"), "{refusal}"); + }); + } + + /// The host masks the high byte off a custom index without saying so, so a + /// value that would arrive truncated is refused where it was written. + #[test] + fn a_tlas_instance_custom_index_wider_than_its_24_bits_is_refused() { + Python::initialize(); + Python::attach(|python| { + let instance = PyDict::new(python); + instance + .set_item("blas", bottom_level_structure(python)) + .unwrap(); + instance.set_item("custom_index", 0x0100_0000u32).unwrap(); + let instances = PyList::new(python, [instance]).unwrap(); + + let refusal = tlas_instances_to_wire(python, instances.as_any()) + .expect_err("a 25-bit custom index cannot reach a hit shader intact"); + assert!(refusal.to_string().contains("truncated"), "{refusal}"); + }); + } + + #[test] + fn a_tlas_instance_naming_a_top_level_structure_is_refused() { + Python::initialize(); + Python::attach(|python| { + let top_level = Py::new( + python, + PythonAccelerationStructureHandle { + acceleration_structure_id: "tlas-under-test".to_string(), + is_top_level: true, + structure_label: "scene".to_string(), + helper_process_exchange_client: None, + }, + ) + .unwrap(); + let instance = PyDict::new(python); + instance.set_item("blas", top_level).unwrap(); + let instances = PyList::new(python, [instance]).unwrap(); + + let refusal = tlas_instances_to_wire(python, instances.as_any()) + .expect_err("a scene cannot instance itself"); + assert!(refusal.to_string().contains("top-level"), "{refusal}"); + }); + } + + /// The other half of the same rule: a trace binds the top-level structure, + /// and the bottom-level one it was built from is not a scene. + #[test] + fn binding_a_bottom_level_structure_at_a_trace_is_refused() { + Python::initialize(); + Python::attach(|python| { + let bottom_level = bottom_level_structure(python); + let refusal = + bound_acceleration_structure_id("scene", bottom_level.bind(python).as_any()) + .expect_err("a trace binds the structure `build_tlas` returned"); + assert!(refusal.to_string().contains("bottom-level"), "{refusal}"); + }); + } + + #[test] + fn a_colour_write_mask_names_its_channels() { + assert_eq!(color_write_channels_to_wire("rgba").unwrap(), 0b1111); + assert_eq!(color_write_channels_to_wire("rg").unwrap(), 0b0011); + assert_eq!(color_write_channels_to_wire("").unwrap(), 0); + let refusal = color_write_channels_to_wire("rgbx") + .expect_err("a colour write mask names only rgba channels"); + assert!(refusal.to_string().contains('x'), "{refusal}"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/sdk/streamlib-python-wheel/tests/graphics_kernel_app.py b/sdk/streamlib-python-wheel/tests/graphics_kernel_app.py new file mode 100644 index 000000000..c64805809 --- /dev/null +++ b/sdk/streamlib-python-wheel/tests/graphics_kernel_app.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025 Jonathan Fontanez +# SPDX-License-Identifier: BUSL-1.1 + +"""Scenarios that run one graphics-kernel probe in its real placement. + +Run as a real `python app.py`: the probe builds and draws its kernel from a +helper process, and its observation reaches this app — and the test driving it — +over the child→parent log forwarding. +""" + +import sys + +import streamlib + +import graphics_kernel_probes + + +def scenario_standalone_probe(probe_class_name: str) -> None: + """A kernel probe needs no upstream: it acquires its own input texture and + colour target and reports from `setup`.""" + runtime = streamlib.Runtime() + runtime.add(getattr(graphics_kernel_probes, probe_class_name)) + runtime.run() + print("MARKER:CLEAN_EXIT", flush=True) + + +if __name__ == "__main__": + scenario_standalone_probe(sys.argv[1]) diff --git a/sdk/streamlib-python-wheel/tests/graphics_kernel_probes.py b/sdk/streamlib-python-wheel/tests/graphics_kernel_probes.py new file mode 100644 index 000000000..1037822e1 --- /dev/null +++ b/sdk/streamlib-python-wheel/tests/graphics_kernel_probes.py @@ -0,0 +1,433 @@ +# Copyright (c) 2025 Jonathan Fontanez +# SPDX-License-Identifier: BUSL-1.1 + +"""Probes for named-binding graphics draws, from where a kernel really runs. + +A kernel is an object: built in `setup()` where the capability is Full, drawn +per frame in `process()`. Every probe runs in its own helper process and +reports one `MARKER:PROBE_RESULT` JSON line. + +What is worth breaking a build over is that a Python processor can render a +pass — a fullscreen triangle sampling an acquired texture into an acquired +colour target, with no application-supplied bridge and no vertex buffer +anywhere — and that every way of getting the bindings wrong is refused by name: +the stage ones before a kernel exists, the rest before any GPU work is +submitted. +""" + +import json +import os +import traceback +from collections.abc import Sequence + +from streamlib import ( + GpuContextFullAccess, + RuntimeContextFullAccess, + RuntimeContextLimitedAccess, + log, + processor, +) + +SURFACE_WIDTH = 64 +SURFACE_HEIGHT = 64 + +COLOR_ATTACHMENT_FORMAT = "rgba8_unorm" + +# A colour target must carry RENDER_ATTACHMENT; a sampled input must not have +# to, which is the whole difference between the two acquires. +SAMPLED_INPUT_TEXTURE_USAGE = [ + "texture_binding", + "storage_binding", + "copy_src", + "copy_dst", +] +COLOR_TARGET_TEXTURE_USAGE = [ + "render_attachment", + "texture_binding", + "copy_src", + "copy_dst", +] + +RESULT_MARKER = "MARKER:PROBE_RESULT " + +# The fragment stage's own name for the texture it samples. Nothing but the +# shader declares it, and a draw resolves against it. +SOURCE_BINDING = "source_image" + +# The vertices are the shaders' own: no escalate op mints a vertex buffer, so +# the positions come out of `gl_VertexIndex`. +FULL_SCREEN_TRIANGLE_VERTEX_GLSL = """\ +#version 450 +void main() { + vec2 corner = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} +""" + +INVERT_SAMPLED_INPUT_FRAGMENT_GLSL = """\ +#version 450 +layout(set = 0, binding = 0) uniform sampler2D source_image; +layout(location = 0) out vec4 painted_colour; +void main() { + vec4 source = texelFetch(source_image, ivec2(gl_FragCoord.xy), 0); + painted_colour = vec4(vec3(1.0) - source.rgb, source.a); +} +""" + +# A second binding no draw can ever name a surface for: the only by-surface-id +# resolution the engine has is texture-shaped. +TINTED_SAMPLED_INPUT_FRAGMENT_GLSL = """\ +#version 450 +layout(set = 0, binding = 0) uniform sampler2D source_image; +layout(set = 0, binding = 1) uniform TintBlock { vec4 tint; } tint_block; +layout(location = 0) out vec4 painted_colour; +void main() { + vec4 source = texelFetch(source_image, ivec2(gl_FragCoord.xy), 0); + painted_colour = source * tint_block.tint; +} +""" + +# The fragment stage is the only one that reads the texture, which is what a +# declaration naming the vertex stage contradicts. Spelled out because a dict's +# value type is invariant: the shape has to be the parameter's own, not the +# narrower one this literal would otherwise infer. +DECLARED_BINDINGS: dict[str, str | tuple[str, Sequence[str]]] = { + SOURCE_BINDING: ("sampled_texture", ["fragment"]) +} + + +def _report(probe_body) -> None: + """One result line per probe, success or failure — the failure carries the + traceback so the test fails on the cause rather than a missing marker.""" + try: + observation = probe_body() + except BaseException: # noqa: BLE001 — re-raised by the asserting test + observation = {"failure": traceback.format_exc()} + log.info(RESULT_MARKER + json.dumps({"pid": os.getpid(), **observation})) + + +def _refusal_of(refused_call) -> str: + """The message a wrong call raises, or a failure if it did not raise.""" + try: + refused_call() + except Exception as refusal: # noqa: BLE001 — the refusal is the subject + return str(refusal) + raise AssertionError("the call was accepted; it should have been refused") + + +def _refusal_traceback_of(refused_call) -> str: + """The traceback a wrong call raises, so a test can assert *which line* + refused — construction or dispatch — rather than only what it said.""" + try: + refused_call() + except Exception: # noqa: BLE001 — the refusal is the subject + return traceback.format_exc() + raise AssertionError("the call was accepted; it should have been refused") + + +def _invert_sampled_input_graphics_kernel( + gpu: GpuContextFullAccess, + bindings: dict[str, str | tuple[str, Sequence[str]]], +): + """The conformance pass: a fullscreen triangle sampling one texture into + one colour target.""" + return gpu.create_graphics_kernel( + color_attachment_formats=[COLOR_ATTACHMENT_FORMAT], + vertex_source=FULL_SCREEN_TRIANGLE_VERTEX_GLSL, + fragment_source=INVERT_SAMPLED_INPUT_FRAGMENT_GLSL, + bindings=bindings, + label="python-fullscreen-triangle", + ) + + +class _GraphicsKernelProbeBase: + """Builds the conformance kernel in `setup`, reports from `setup`. + + Nothing upstream is needed: the probe acquires both surfaces itself, which + is the point — a draw's colour target is an engine-owned texture the + processor names, not something handed to it. + """ + + # Declared, not merely assigned: `setup` assigns it inside a nested + # closure, which a type checker does not walk for attribute inference. + gpu_full_access: GpuContextFullAccess + + def setup(self, ctx: RuntimeContextFullAccess) -> None: + def observe() -> dict: + gpu = ctx.gpu_full_access + # Held for probes whose observation needs the capability itself + # (a refusal at construction is observed by constructing). + self.gpu_full_access = gpu + kernel = _invert_sampled_input_graphics_kernel(gpu, DECLARED_BINDINGS) + source = gpu.acquire_texture( + SURFACE_WIDTH, + SURFACE_HEIGHT, + COLOR_ATTACHMENT_FORMAT, + SAMPLED_INPUT_TEXTURE_USAGE, + ) + color_target = gpu.acquire_texture( + SURFACE_WIDTH, + SURFACE_HEIGHT, + COLOR_ATTACHMENT_FORMAT, + COLOR_TARGET_TEXTURE_USAGE, + ) + return self.observe(kernel, source, color_target) + + _report(observe) + + def process(self, ctx: RuntimeContextLimitedAccess) -> None: + pass + + def draw_the_conformance_pass(self, kernel, source, color_target) -> None: + """The one draw every probe here spells the same way.""" + kernel.draw( + bindings={SOURCE_BINDING: source}, + color_targets=[color_target], + extent=(SURFACE_WIDTH, SURFACE_HEIGHT), + vertex_count=3, + ) + + def observe(self, kernel, source, color_target) -> dict: + raise NotImplementedError + + +@processor( + execution="manual", + description="Draws a fullscreen triangle sampling one texture into another", +) +class FullscreenTriangleDrawProbe(_GraphicsKernelProbeBase): + """The demo: a Python processor renders a pass with named bindings. + + No bridge is installed anywhere, no vertex buffer exists, and the colour + target is a texture this processor acquired rather than one an application + handed it. + """ + + def observe(self, kernel, source, color_target) -> dict: + self.draw_the_conformance_pass(kernel, source, color_target) + # Twice, over the same surfaces: the kernel keeps no binding state, so + # a second draw that needed the first one's descriptors would fail + # here rather than in whatever runs next. + self.draw_the_conformance_pass(kernel, source, color_target) + return { + "drew": True, + "binding_names": list(kernel.binding_names), + "source_surface_id": source.surface_id, + "color_target_surface_id": color_target.surface_id, + "surfaces_are_distinct": source.surface_id != color_target.surface_id, + } + + +@processor( + execution="manual", + description="Every way of getting a draw's bindings wrong, refused by name", +) +class GraphicsBindingRefusalProbe(_GraphicsKernelProbeBase): + """Unknown, missing, kind-mismatched and unresolvable, each raising with a + message naming what the shaders actually declare.""" + + def observe(self, kernel, source, color_target) -> dict: + gpu = self.gpu_full_access + + unknown_at_construction = _refusal_of( + lambda: _invert_sampled_input_graphics_kernel( + gpu, {"tint_amount": ("sampled_texture", ["fragment"])} + ) + ) + kind_mismatch_at_construction = _refusal_of( + lambda: _invert_sampled_input_graphics_kernel( + gpu, {SOURCE_BINDING: ("storage_image", ["fragment"])} + ) + ) + # A declaration is total: leaving one of the shaders' bindings out is + # how a draw silently binds nothing. + undeclared_at_construction = _refusal_of( + lambda: gpu.create_graphics_kernel( + color_attachment_formats=[COLOR_ATTACHMENT_FORMAT], + vertex_source=FULL_SCREEN_TRIANGLE_VERTEX_GLSL, + fragment_source=TINTED_SAMPLED_INPUT_FRAGMENT_GLSL, + bindings=DECLARED_BINDINGS, + ) + ) + + unknown_at_draw = _refusal_of( + lambda: kernel.draw( + bindings={SOURCE_BINDING: source, "tint_amount": source}, + color_targets=[color_target], + extent=(SURFACE_WIDTH, SURFACE_HEIGHT), + vertex_count=3, + ) + ) + missing_at_draw = _refusal_of( + lambda: kernel.draw( + bindings={}, + color_targets=[color_target], + extent=(SURFACE_WIDTH, SURFACE_HEIGHT), + vertex_count=3, + ) + ) + unregistered_surface_at_draw = _refusal_of( + lambda: kernel.draw( + bindings={SOURCE_BINDING: "no-such-surface"}, + color_targets=[color_target], + extent=(SURFACE_WIDTH, SURFACE_HEIGHT), + vertex_count=3, + ) + ) + + # The kernel still draws: every refusal above raised before anything + # was submitted, so none of them left it holding half a draw's state. + self.draw_the_conformance_pass(kernel, source, color_target) + return { + "unknown_at_construction": unknown_at_construction, + "kind_mismatch_at_construction": kind_mismatch_at_construction, + "undeclared_at_construction": undeclared_at_construction, + "unknown_at_draw": unknown_at_draw, + "missing_at_draw": missing_at_draw, + "unregistered_surface_at_draw": unregistered_surface_at_draw, + "drew_after_the_refusals": True, + "binding_names": list(kernel.binding_names), + } + + +@processor( + execution="manual", + description="A binding declared for a stage the shaders do not read it in", +) +class GraphicsStageMismatchProbe(_GraphicsKernelProbeBase): + """The ticket's named validation case, at the line it belongs to. + + A graphics kernel is always built from both stages, so the stage claim a + declaration can get wrong is *which* of them reads the binding. A draw + never revisits that, so the mistake has to refuse at construction — and the + traceback proves it did, because there is no kernel object to draw with + afterwards. + """ + + def observe(self, kernel, source, color_target) -> dict: + gpu = self.gpu_full_access + + def declare_the_texture_for_a_stage_that_does_not_read_it() -> None: + gpu.create_graphics_kernel( + color_attachment_formats=[COLOR_ATTACHMENT_FORMAT], + vertex_source=FULL_SCREEN_TRIANGLE_VERTEX_GLSL, + fragment_source=INVERT_SAMPLED_INPUT_FRAGMENT_GLSL, + bindings={SOURCE_BINDING: ("sampled_texture", ["vertex"])}, + ) + + stage_mismatch = _refusal_of( + declare_the_texture_for_a_stage_that_does_not_read_it + ) + stage_mismatch_traceback = _refusal_traceback_of( + declare_the_texture_for_a_stage_that_does_not_read_it + ) + + # The same shaders with the stage claim corrected build and draw, so + # what the refusal rejected is the declaration and not the pass. + self.draw_the_conformance_pass(kernel, source, color_target) + return { + "stage_mismatch": stage_mismatch, + "stage_mismatch_traceback": stage_mismatch_traceback, + "the_corrected_declaration_drew": True, + } + + +@processor( + execution="manual", + description="A buffer-kind binding a draw cannot name a surface for", +) +class GraphicsBufferBindingRefusalProbe(_GraphicsKernelProbeBase): + """A uniform-buffer binding is reflected, declared and refused at the draw. + + The only by-surface-id resolution the engine has is texture-shaped, so a + draw that accepted a surface here would bind whatever the descriptor last + held. The name is read back off the kernel rather than spelled here — how + reflection names a uniform block is the shader's business, and the refusal + has to name whatever it named. + """ + + def observe(self, kernel, source, color_target) -> dict: + del kernel + tinted = self.gpu_full_access.create_graphics_kernel( + color_attachment_formats=[COLOR_ATTACHMENT_FORMAT], + vertex_source=FULL_SCREEN_TRIANGLE_VERTEX_GLSL, + fragment_source=TINTED_SAMPLED_INPUT_FRAGMENT_GLSL, + label="python-tinted-fullscreen-triangle", + ) + binding_names = list(tinted.binding_names) + buffer_binding = binding_names[1] + buffer_kind_binding = _refusal_of( + lambda: tinted.draw( + bindings={SOURCE_BINDING: source, buffer_binding: source}, + color_targets=[color_target], + extent=(SURFACE_WIDTH, SURFACE_HEIGHT), + vertex_count=3, + ) + ) + return { + "binding_names": binding_names, + "buffer_binding": buffer_binding, + "buffer_kind_binding": buffer_kind_binding, + } + + +@processor( + execution="manual", + description="Pass shapes a Python draw cannot ask for", +) +class GraphicsPassShapeRefusalProbe(_GraphicsKernelProbeBase): + """Vertex buffers, an index buffer and a depth target are not arguments. + + No escalate op mints a vertex or an index buffer, and the offscreen pass a + draw runs attaches colour targets only — so rather than accepting the + argument and dropping it, the surface has no such argument, and the + attachment count it does take is checked before anything is submitted. + """ + + def observe(self, kernel, source, color_target) -> dict: + gpu = self.gpu_full_access + + def draw_with(**unsupported) -> None: + kernel.draw( + bindings={SOURCE_BINDING: source}, + color_targets=[color_target], + extent=(SURFACE_WIDTH, SURFACE_HEIGHT), + vertex_count=3, + **unsupported, + ) + + two_color_targets = _refusal_of( + lambda: kernel.draw( + bindings={SOURCE_BINDING: source}, + color_targets=[color_target, color_target], + extent=(SURFACE_WIDTH, SURFACE_HEIGHT), + vertex_count=3, + ) + ) + no_color_target = _refusal_of( + lambda: kernel.draw( + bindings={SOURCE_BINDING: source}, + color_targets=[], + extent=(SURFACE_WIDTH, SURFACE_HEIGHT), + vertex_count=3, + ) + ) + two_attachment_formats = _refusal_of( + lambda: gpu.create_graphics_kernel( + color_attachment_formats=[ + COLOR_ATTACHMENT_FORMAT, + COLOR_ATTACHMENT_FORMAT, + ], + vertex_source=FULL_SCREEN_TRIANGLE_VERTEX_GLSL, + fragment_source=INVERT_SAMPLED_INPUT_FRAGMENT_GLSL, + bindings=DECLARED_BINDINGS, + ) + ) + return { + "vertex_buffers": _refusal_of(lambda: draw_with(vertex_buffers=[])), + "index_buffer": _refusal_of(lambda: draw_with(index_buffer=None)), + "depth_target": _refusal_of(lambda: draw_with(depth_target=color_target)), + "two_color_targets": two_color_targets, + "no_color_target": no_color_target, + "two_attachment_formats": two_attachment_formats, + } diff --git a/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_app.py b/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_app.py new file mode 100644 index 000000000..ab7cf4b2a --- /dev/null +++ b/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_app.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025 Jonathan Fontanez +# SPDX-License-Identifier: BUSL-1.1 + +"""Scenarios that run one ray-tracing-kernel probe in its real placement. + +Run as a real `python app.py`: the probe builds its scene, its kernel and its +traced output from a helper process, and its observation reaches this app — and +the test driving it — over the child→parent log forwarding. +""" + +import sys + +import streamlib + +import ray_tracing_kernel_probes + + +def scenario_standalone_probe(probe_class_name: str) -> None: + """A kernel probe needs no upstream: it builds its own acceleration + structures, acquires its own storage image and reports from `setup`.""" + runtime = streamlib.Runtime() + runtime.add(getattr(ray_tracing_kernel_probes, probe_class_name)) + runtime.run() + print("MARKER:CLEAN_EXIT", flush=True) + + +if __name__ == "__main__": + scenario_standalone_probe(sys.argv[1]) diff --git a/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py b/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py new file mode 100644 index 000000000..09f35e9ac --- /dev/null +++ b/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py @@ -0,0 +1,473 @@ +# Copyright (c) 2025 Jonathan Fontanez +# SPDX-License-Identifier: BUSL-1.1 + +"""Probes for named-binding ray tracing, from where a kernel really runs. + +A probe builds its own scene — a bottom-level structure over triangle +geometry, a top-level one placing it — and its own kernel in `setup()` where +the capability is Full, then traces into a storage image it acquired. Every +probe runs in its own helper process and reports one `MARKER:PROBE_RESULT` +JSON line. + +What is worth breaking a build over is that a Python processor can trace at +all — no application-supplied bridge, no acceleration-structure id string, the +handle a build returned being the whole way to name it — and that every way of +getting the bindings wrong is refused by name, the stage ones before a kernel +exists. + +A device without `VK_KHR_ray_tracing_pipeline` can build neither structures nor +pipelines, so a probe that meets that refusal reports it rather than failing: +it is a capability the `requires_gpu` marker does not cover. +""" + +import json +import os +import traceback +from collections.abc import Sequence + +from streamlib import ( + GpuContextFullAccess, + GpuSurfaceHandle, + RuntimeContextFullAccess, + RuntimeContextLimitedAccess, + log, + processor, +) +from streamlib._engine import AccelerationStructureHandle, RayTracingKernel + +SURFACE_WIDTH = 64 +SURFACE_HEIGHT = 64 + +TRACED_OUTPUT_FORMAT = "rgba8_unorm" +TRACED_OUTPUT_TEXTURE_USAGE = [ + "storage_binding", + "texture_binding", + "copy_src", + "copy_dst", +] + +RESULT_MARKER = "MARKER:PROBE_RESULT " + +# What the escalate handler says when the device has no ray-tracing chain. A +# probe that sees it reports it, and the test skips on it. +RAY_TRACING_UNAVAILABLE = "VK_KHR_ray_tracing_pipeline" + +# The ray-gen stage's own names for the two resources it binds. One takes the +# handle `build_tlas` returned; the other takes a surface. +SCENE_BINDING = "scene_structure" +TRACED_OUTPUT_BINDING = "traced_output_image" + +RAY_GENERATION_GLSL = """\ +#version 460 +#extension GL_EXT_ray_tracing : require +layout(set = 0, binding = 0) uniform accelerationStructureEXT scene_structure; +layout(set = 0, binding = 1, rgba8) uniform writeonly image2D traced_output_image; +layout(location = 0) rayPayloadEXT vec3 ray_payload_colour; +void main() { + vec2 in_view = + (vec2(gl_LaunchIDEXT.xy) + vec2(0.5)) / vec2(gl_LaunchSizeEXT.xy) * 2.0 - 1.0; + ray_payload_colour = vec3(0.0); + traceRayEXT(scene_structure, gl_RayFlagsOpaqueEXT, 0xff, 0, 0, 0, + vec3(in_view, 0.0), 0.001, vec3(0.0, 0.0, 1.0), 10.0, 0); + imageStore(traced_output_image, ivec2(gl_LaunchIDEXT.xy), + vec4(ray_payload_colour, 1.0)); +} +""" + +MISS_GLSL = """\ +#version 460 +#extension GL_EXT_ray_tracing : require +layout(location = 0) rayPayloadInEXT vec3 ray_payload_colour; +void main() { ray_payload_colour = vec3(0.0, 0.0, 1.0); } +""" + +CLOSEST_HIT_GLSL = """\ +#version 460 +#extension GL_EXT_ray_tracing : require +layout(location = 0) rayPayloadInEXT vec3 ray_payload_colour; +void main() { ray_payload_colour = vec3(0.0, 1.0, 0.0); } +""" + +# A third binding no trace can ever name a surface for: the only by-surface-id +# resolution the engine has is texture-shaped. +RAY_GENERATION_WITH_A_UNIFORM_BUFFER_GLSL = """\ +#version 460 +#extension GL_EXT_ray_tracing : require +layout(set = 0, binding = 0) uniform accelerationStructureEXT scene_structure; +layout(set = 0, binding = 1, rgba8) uniform writeonly image2D traced_output_image; +layout(set = 0, binding = 2) uniform TintBlock { vec4 tint; } tint_block; +layout(location = 0) rayPayloadEXT vec3 ray_payload_colour; +void main() { + vec2 in_view = + (vec2(gl_LaunchIDEXT.xy) + vec2(0.5)) / vec2(gl_LaunchSizeEXT.xy) * 2.0 - 1.0; + ray_payload_colour = vec3(0.0); + traceRayEXT(scene_structure, gl_RayFlagsOpaqueEXT, 0xff, 0, 0, 0, + vec3(in_view, 0.0), 0.001, vec3(0.0, 0.0, 1.0), 10.0, 0); + imageStore(traced_output_image, ivec2(gl_LaunchIDEXT.xy), + vec4(ray_payload_colour * tint_block.tint.rgb, 1.0)); +} +""" + +# One triangle in front of every ray the grid casts. +TRIANGLE_VERTICES = [-1.0, -1.0, 0.5, 3.0, -1.0, 0.5, -1.0, 3.0, 0.5] +TRIANGLE_INDICES = [0, 1, 2] + +# Two general groups — ray-gen and miss — and one triangles hit group, in the +# order the shader binding table is laid out. A group names its modules by +# index into `stages`, because two modules can fill the same stage. +RAY_TRACING_STAGES = [ + {"stage": "ray_gen", "source": RAY_GENERATION_GLSL}, + {"stage": "miss", "source": MISS_GLSL}, + {"stage": "closest_hit", "source": CLOSEST_HIT_GLSL}, +] +RAY_TRACING_GROUPS = [ + {"kind": "general", "general_stage": 0}, + {"kind": "general", "general_stage": 1}, + {"kind": "triangles_hit", "closest_hit_stage": 2}, +] + +# Only the ray-gen module reads either binding, and this kernel has no any-hit +# module at all — which is the stage claim no trace could ever make true. +# Spelled out because a dict's value type is invariant: the shape has to be the +# parameter's own, not the narrower one this literal would otherwise infer. +DECLARED_BINDINGS: dict[str, str | tuple[str, Sequence[str]]] = { + SCENE_BINDING: ("acceleration_structure", ["ray_gen"]), + TRACED_OUTPUT_BINDING: ("storage_image", ["ray_gen"]), +} + + +def _report(probe_body) -> None: + """One result line per probe, success or failure — the failure carries the + traceback so the test fails on the cause rather than a missing marker.""" + try: + observation = probe_body() + except BaseException: # noqa: BLE001 — re-raised by the asserting test + observation = {"failure": traceback.format_exc()} + log.info(RESULT_MARKER + json.dumps({"pid": os.getpid(), **observation})) + + +def _refusal_of(refused_call) -> str: + """The message a wrong call raises, or a failure if it did not raise.""" + try: + refused_call() + except Exception as refusal: # noqa: BLE001 — the refusal is the subject + return str(refusal) + raise AssertionError("the call was accepted; it should have been refused") + + +def _refusal_traceback_of(refused_call) -> str: + """The traceback a wrong call raises, so a test can assert *which line* + refused — construction or dispatch — rather than only what it said.""" + try: + refused_call() + except Exception: # noqa: BLE001 — the refusal is the subject + return traceback.format_exc() + raise AssertionError("the call was accepted; it should have been refused") + + +def _traced_triangle_kernel( + gpu: GpuContextFullAccess, + bindings: dict[str, str | tuple[str, Sequence[str]]], +): + """The conformance kernel: ray-gen, miss and closest-hit over one scene.""" + return gpu.create_ray_tracing_kernel( + stages=RAY_TRACING_STAGES, + groups=RAY_TRACING_GROUPS, + bindings=bindings, + label="python-traced-triangle", + ) + + +class _RayTracingKernelProbeBase: + """Builds the scene, the kernel and the traced output in `setup`, and + reports from `setup`. + + Nothing upstream is needed: the probe acquires its own storage image, which + is the point — a trace's output is an engine-owned texture the processor + names, not something handed to it. + """ + + # Declared, not merely assigned: `setup` assigns them inside a nested + # closure, which a type checker does not walk for attribute inference. + gpu_full_access: GpuContextFullAccess + kernel: RayTracingKernel + bottom_level_structure: AccelerationStructureHandle + top_level_structure: AccelerationStructureHandle + traced_output: GpuSurfaceHandle + + def setup(self, ctx: RuntimeContextFullAccess) -> None: + def observe() -> dict: + gpu = ctx.gpu_full_access + self.gpu_full_access = gpu + try: + self.bottom_level_structure = gpu.build_triangles_blas( + vertices=TRIANGLE_VERTICES, + indices=TRIANGLE_INDICES, + label="python-triangle-blas", + ) + self.top_level_structure = gpu.build_tlas( + instances=[{"blas": self.bottom_level_structure}], + label="python-triangle-tlas", + ) + self.kernel = _traced_triangle_kernel(gpu, DECLARED_BINDINGS) + except Exception as refusal: # noqa: BLE001 — reported, then skipped on + if RAY_TRACING_UNAVAILABLE in str(refusal): + return {"ray_tracing_unavailable": str(refusal)} + raise + self.traced_output = gpu.acquire_texture( + SURFACE_WIDTH, + SURFACE_HEIGHT, + TRACED_OUTPUT_FORMAT, + TRACED_OUTPUT_TEXTURE_USAGE, + ) + return self.observe() + + _report(observe) + + def process(self, ctx: RuntimeContextLimitedAccess) -> None: + pass + + def trace_the_conformance_grid(self) -> None: + """The one trace every probe here spells the same way.""" + self.kernel.trace( + bindings={ + SCENE_BINDING: self.top_level_structure, + TRACED_OUTPUT_BINDING: self.traced_output, + }, + grid=(SURFACE_WIDTH, SURFACE_HEIGHT, 1), + ) + + def observe(self) -> dict: + raise NotImplementedError + + +@processor( + execution="manual", + description="Builds a BLAS and a TLAS and traces them into a storage image", +) +class TracedTriangleProbe(_RayTracingKernelProbeBase): + """The demo: a Python processor builds a scene and traces it. + + No bridge is installed anywhere, and no acceleration-structure id string + reaches Python — the handle each build returned is the whole way to name + it. + """ + + def observe(self) -> dict: + self.trace_the_conformance_grid() + # Twice, over the same scene: the kernel keeps no binding state, so a + # second trace that needed the first one's descriptors would fail here + # rather than in whatever runs next. + self.trace_the_conformance_grid() + return { + "traced": True, + "binding_names": list(self.kernel.binding_names), + "bottom_level_label": self.bottom_level_structure.label, + "top_level_label": self.top_level_structure.label, + "traced_output_surface_id": self.traced_output.surface_id, + } + + +@processor( + execution="manual", + description="Every way of getting a trace's bindings wrong, refused by name", +) +class RayTracingBindingRefusalProbe(_RayTracingKernelProbeBase): + """Unknown, missing and kind-mismatched, each raising with a message naming + what the shaders actually declare.""" + + def observe(self) -> dict: + gpu = self.gpu_full_access + grid = (SURFACE_WIDTH, SURFACE_HEIGHT, 1) + + unknown_at_construction = _refusal_of( + lambda: _traced_triangle_kernel( + gpu, + { + **DECLARED_BINDINGS, + "ambient_occlusion_radius": ("storage_image", ["ray_gen"]), + }, + ) + ) + kind_mismatch_at_construction = _refusal_of( + lambda: _traced_triangle_kernel( + gpu, + { + SCENE_BINDING: ("storage_image", ["ray_gen"]), + TRACED_OUTPUT_BINDING: ("storage_image", ["ray_gen"]), + }, + ) + ) + + unknown_at_trace = _refusal_of( + lambda: self.kernel.trace( + bindings={ + SCENE_BINDING: self.top_level_structure, + TRACED_OUTPUT_BINDING: self.traced_output, + "ambient_occlusion_radius": self.traced_output, + }, + grid=grid, + ) + ) + missing_output_at_trace = _refusal_of( + lambda: self.kernel.trace( + bindings={SCENE_BINDING: self.top_level_structure}, + grid=grid, + ) + ) + missing_scene_at_trace = _refusal_of( + lambda: self.kernel.trace( + bindings={TRACED_OUTPUT_BINDING: self.traced_output}, + grid=grid, + ) + ) + + # The kernel still traces: every refusal above raised before anything + # was submitted, so none of them left it holding half a trace's state. + self.trace_the_conformance_grid() + return { + "unknown_at_construction": unknown_at_construction, + "kind_mismatch_at_construction": kind_mismatch_at_construction, + "unknown_at_trace": unknown_at_trace, + "missing_output_at_trace": missing_output_at_trace, + "missing_scene_at_trace": missing_scene_at_trace, + "traced_after_the_refusals": True, + "binding_names": list(self.kernel.binding_names), + } + + +@processor( + execution="manual", + description="A binding declared for a stage this kernel has no module for", +) +class RayTracingStageMismatchProbe(_RayTracingKernelProbeBase): + """The ticket's named validation case, at the line it belongs to. + + This kernel is built from ray-gen, miss and closest-hit modules and no + other, so declaring a binding for `any_hit` is a claim no trace could ever + make true — and a trace never revisits which stage reads what. The + traceback proves the `create_ray_tracing_kernel` line raised: there is no + kernel object to trace with afterwards. + """ + + def observe(self) -> dict: + gpu = self.gpu_full_access + + def declare_the_scene_for_a_stage_this_kernel_has_no_module_for() -> None: + _traced_triangle_kernel( + gpu, + { + SCENE_BINDING: ("acceleration_structure", ["any_hit"]), + TRACED_OUTPUT_BINDING: ("storage_image", ["ray_gen"]), + }, + ) + + stage_mismatch = _refusal_of( + declare_the_scene_for_a_stage_this_kernel_has_no_module_for + ) + stage_mismatch_traceback = _refusal_traceback_of( + declare_the_scene_for_a_stage_this_kernel_has_no_module_for + ) + + # The same modules with the stage claim corrected build and trace, so + # what the refusal rejected is the declaration and not the scene. + self.trace_the_conformance_grid() + return { + "stage_mismatch": stage_mismatch, + "stage_mismatch_traceback": stage_mismatch_traceback, + "the_corrected_declaration_traced": True, + } + + +@processor( + execution="manual", + description="A buffer-kind binding a trace cannot name a surface for", +) +class RayTracingBufferBindingRefusalProbe(_RayTracingKernelProbeBase): + """A uniform-buffer binding is reflected, and refused at the trace. + + The name is read back off the kernel rather than spelled here — how + reflection names a uniform block is the shader's business, and the refusal + has to name whatever it named. + """ + + def observe(self) -> dict: + tinted = self.gpu_full_access.create_ray_tracing_kernel( + stages=[ + {"stage": "ray_gen", "source": RAY_GENERATION_WITH_A_UNIFORM_BUFFER_GLSL}, + {"stage": "miss", "source": MISS_GLSL}, + {"stage": "closest_hit", "source": CLOSEST_HIT_GLSL}, + ], + groups=RAY_TRACING_GROUPS, + label="python-tinted-traced-triangle", + ) + binding_names = list(tinted.binding_names) + buffer_binding = binding_names[2] + buffer_kind_binding = _refusal_of( + lambda: tinted.trace( + bindings={ + SCENE_BINDING: self.top_level_structure, + TRACED_OUTPUT_BINDING: self.traced_output, + buffer_binding: self.traced_output, + }, + grid=(SURFACE_WIDTH, SURFACE_HEIGHT, 1), + ) + ) + return { + "binding_names": binding_names, + "buffer_binding": buffer_binding, + "buffer_kind_binding": buffer_kind_binding, + } + + +@processor( + execution="manual", + description="An acceleration structure is named by its handle, or not at all", +) +class AccelerationStructureHandleRefusalProbe(_RayTracingKernelProbeBase): + """Nothing publishes an acceleration structure for another processor to + resolve, so a surface id is not a spelling for one — and the structure a + trace binds is the top-level one, which is what holds the instances.""" + + def observe(self) -> dict: + gpu = self.gpu_full_access + grid = (SURFACE_WIDTH, SURFACE_HEIGHT, 1) + + a_surface_where_a_structure_belongs = _refusal_of( + lambda: self.kernel.trace( + bindings={ + SCENE_BINDING: self.traced_output, + TRACED_OUTPUT_BINDING: self.traced_output, + }, + grid=grid, + ) + ) + a_bottom_level_structure_at_the_trace = _refusal_of( + lambda: self.kernel.trace( + bindings={ + SCENE_BINDING: self.bottom_level_structure, + TRACED_OUTPUT_BINDING: self.traced_output, + }, + grid=grid, + ) + ) + a_top_level_structure_as_an_instance = _refusal_of( + lambda: gpu.build_tlas(instances=[{"blas": self.top_level_structure}]) + ) + vertices_that_are_not_triangles = _refusal_of( + lambda: gpu.build_triangles_blas( + vertices=[0.0, 1.0, 2.0, 3.0], indices=TRIANGLE_INDICES + ) + ) + indices_that_are_not_triangles = _refusal_of( + lambda: gpu.build_triangles_blas( + vertices=TRIANGLE_VERTICES, indices=[0, 1] + ) + ) + return { + "a_surface_where_a_structure_belongs": a_surface_where_a_structure_belongs, + "a_bottom_level_structure_at_the_trace": a_bottom_level_structure_at_the_trace, + "a_top_level_structure_as_an_instance": a_top_level_structure_as_an_instance, + "vertices_that_are_not_triangles": vertices_that_are_not_triangles, + "indices_that_are_not_triangles": indices_that_are_not_triangles, + } diff --git a/sdk/streamlib-python-wheel/tests/test_graphics_kernel.py b/sdk/streamlib-python-wheel/tests/test_graphics_kernel.py new file mode 100644 index 000000000..98fb22f2b --- /dev/null +++ b/sdk/streamlib-python-wheel/tests/test_graphics_kernel.py @@ -0,0 +1,294 @@ +# Copyright (c) 2025 Jonathan Fontanez +# SPDX-License-Identifier: BUSL-1.1 + +"""Named-binding graphics draws, from Python. + +Graphics dispatch is an always-present capability of `GpuContext`: there is no +installation step and no runtime-absent case, so the pass here — a fullscreen +triangle sampling an acquired texture into an acquired colour target — needs +nothing from the application but the processor itself. + +A kernel is an object: built in `setup()` from GLSL text the engine compiles, +drawn per frame in `process()`, with bindings passed at draw by the shaders' +own names and never persisting on the kernel. What is worth breaking a build +over is that the draw runs, and that every way of getting the bindings wrong is +refused with a message naming what the shaders actually declare — the stage +claim before a kernel exists, the rest before any GPU work is submitted. + +The device-dependent tests are `requires_gpu` and execute on the rig only; CI +green is not proof for them. The two that read the surface itself are not, and +they are what keeps "no vertex buffer, no index buffer, no depth target" from +being a claim only prose makes. + +Every probe runs in its own helper process and reports one +`MARKER:PROBE_RESULT` JSON line; the tests drive the app out of process and +assert on that line. +""" + +import inspect +import json +import re +from pathlib import Path + +import pytest + +from graphics_kernel_probes import SOURCE_BINDING +from streamlib._engine import GpuContextFullAccess, GraphicsKernel + +APP = Path(__file__).parent / "graphics_kernel_app.py" + +PROBE_RESULT = re.compile(r"MARKER:PROBE_RESULT (\{.*\})") + + +def run_probe(start_app_under_test, probe_class_name: str) -> dict: + """One probe, one observation dict — or a failure carrying the probe's own + traceback, which names the cause better than a missing marker.""" + app = start_app_under_test(APP, probe_class_name) + app.await_output_containing( + "MARKER:PROBE_RESULT", f"the {probe_class_name} result" + ) + app.interrupt() + app.await_marker("CLEAN_EXIT") + app.await_clean_exit() + match = PROBE_RESULT.search(app.output) + assert match is not None, f"no parseable probe result:\n{app.output}" + observation = json.loads(match.group(1)) + if "failure" in observation: + pytest.fail(f"the probe raised in its helper process:\n{observation['failure']}") + return observation + + +def spelled_the_same_way(message: str) -> str: + """A message with the two spellings of a binding kind — the wire's + `storage_image` and the engine type's `StorageImage` — made comparable.""" + return message.lower().replace("_", "") + + +def test_a_draw_takes_no_vertex_buffer_no_index_buffer_and_no_depth_target(): + """The recon constraints, stated where a caller meets them. + + No escalate op mints a `VertexBuffer` or an `IndexBuffer`, and the + offscreen pass a draw runs attaches colour targets only — so the honest + surface is one that cannot ask for them at all. Asserted against the + signature rather than a refusal message, because a parameter that quietly + reappears is exactly what this forbids. + """ + parameters = inspect.signature(GraphicsKernel.draw).parameters + unsupported = [ + name + for name in parameters + if "vertex_buffer" in name or "index_buffer" in name or "depth" in name + ] + assert unsupported == [], ( + f"a draw cannot honour {unsupported}: no escalate op mints a vertex or " + "index buffer, and the pass attaches colour targets only" + ) + assert "vertex_count" in parameters, ( + "the vertices are the shaders' own — a draw still says how many of them" + ) + + +def test_a_graphics_kernel_carries_no_depth_or_vertex_input_state(): + """The pipeline the wire builds has no depth attachment and no vertex + input, so neither is a knob `create_graphics_kernel` offers.""" + parameters = inspect.signature(GpuContextFullAccess.create_graphics_kernel).parameters + unsupported = [ + name + for name in parameters + if "depth" in name or "vertex_input" in name or "multisample" in name + ] + assert unsupported == [], ( + f"the graphics kernel builds single-sampled colour-only pipelines: {unsupported}" + ) + + +@pytest.mark.requires_gpu +def test_a_python_processor_draws_through_a_graphics_kernel(start_app_under_test): + """The demo: a pass rendered from a helper process, with named bindings and + no application-supplied bridge.""" + observed = run_probe(start_app_under_test, "FullscreenTriangleDrawProbe") + + assert observed["drew"] is True + assert observed["surfaces_are_distinct"], ( + "the sampled input and the colour target must be different surfaces — " + "the pass discards its target's contents on entry" + ) + assert observed["binding_names"] == [SOURCE_BINDING], ( + "the kernel reports the shaders' own binding names, which is what a " + "draw resolves against" + ) + + +@pytest.mark.requires_gpu +def test_a_binding_declared_for_a_stage_that_does_not_read_it_is_refused_at_construction( + start_app_under_test, +): + """The ticket's named validation case. + + A draw never revisits which stage reads what — the descriptor set layout is + built once — so a wrong stage claim has to refuse where the multi-stage + declaration is built. The traceback is asserted on, not just the message: + "at construction" means the `create_graphics_kernel` line raised and no + kernel object was ever handed back to draw with. + """ + observed = run_probe(start_app_under_test, "GraphicsStageMismatchProbe") + + stage_mismatch = observed["stage_mismatch"] + assert SOURCE_BINDING in stage_mismatch, stage_mismatch + assert "vertex" in stage_mismatch and "fragment" in stage_mismatch, ( + f"must name both the stage claimed and the stage the shaders read it in: " + f"{stage_mismatch}" + ) + + raised_at = observed["stage_mismatch_traceback"] + assert "create_graphics_kernel" in raised_at, ( + f"the refusal must come from the construction line: {raised_at}" + ) + assert "kernel.draw(" not in raised_at, ( + f"a stage mismatch caught at the draw is caught too late: {raised_at}" + ) + assert observed["the_corrected_declaration_drew"] is True, ( + "the same shaders with the stage claim corrected must still draw, or " + "the refusal rejected the pass rather than the declaration" + ) + + +@pytest.mark.requires_gpu +def test_a_binding_the_shaders_do_not_declare_is_refused_at_construction( + start_app_under_test, +): + """`bindings={name: (kind, stages)}` asserts against reflection: a name the + shaders lack refuses before a kernel exists, naming what they have.""" + observed = run_probe(start_app_under_test, "GraphicsBindingRefusalProbe") + + unknown = observed["unknown_at_construction"] + assert "tint_amount" in unknown, f"must name the unknown binding: {unknown}" + assert SOURCE_BINDING in unknown, ( + f"must name what the shaders do declare: {unknown}" + ) + + +@pytest.mark.requires_gpu +def test_a_binding_declared_as_the_wrong_kind_is_refused_at_construction( + start_app_under_test, +): + observed = run_probe(start_app_under_test, "GraphicsBindingRefusalProbe") + + mismatch = observed["kind_mismatch_at_construction"] + assert SOURCE_BINDING in mismatch, mismatch + assert "storageimage" in spelled_the_same_way(mismatch), ( + f"must name the kind claimed: {mismatch}" + ) + assert "sampledtexture" in spelled_the_same_way(mismatch), ( + f"must name the kind the shaders declare: {mismatch}" + ) + + +@pytest.mark.requires_gpu +def test_leaving_one_of_the_shaders_bindings_undeclared_is_refused( + start_app_under_test, +): + """A declaration is total: an unmentioned binding is how a draw silently + binds nothing.""" + observed = run_probe(start_app_under_test, "GraphicsBindingRefusalProbe") + + undeclared = observed["undeclared_at_construction"] + assert "undeclared" in undeclared, undeclared + assert "accounted for" in undeclared, undeclared + + +@pytest.mark.requires_gpu +def test_a_binding_the_shaders_do_not_declare_is_refused_at_the_draw( + start_app_under_test, +): + observed = run_probe(start_app_under_test, "GraphicsBindingRefusalProbe") + + unknown = observed["unknown_at_draw"] + assert "tint_amount" in unknown, f"must name the unknown binding: {unknown}" + assert SOURCE_BINDING in unknown, ( + f"must name what the shaders do declare: {unknown}" + ) + + +@pytest.mark.requires_gpu +def test_an_unsupplied_binding_is_refused_naming_the_shaders_bindings( + start_app_under_test, +): + """No implicit default and no carried-over value: the kernel holds no + binding state between draws to fall back on.""" + observed = run_probe(start_app_under_test, "GraphicsBindingRefusalProbe") + + missing = observed["missing_at_draw"] + assert SOURCE_BINDING in missing, f"must name the missing binding: {missing}" + assert "not supplied" in missing, missing + assert "do not persist between draws" in missing, ( + f"must say why there is no fallback: {missing}" + ) + + +@pytest.mark.requires_gpu +def test_a_binding_naming_an_unknown_surface_is_refused(start_app_under_test): + observed = run_probe(start_app_under_test, "GraphicsBindingRefusalProbe") + + unresolvable = observed["unregistered_surface_at_draw"] + assert "no-such-surface" in unresolvable, ( + f"must name the surface it could not resolve: {unresolvable}" + ) + assert SOURCE_BINDING in unresolvable, ( + f"must name the binding that named it: {unresolvable}" + ) + + +@pytest.mark.requires_gpu +def test_a_refused_draw_leaves_the_kernel_drawable(start_app_under_test): + """Every refusal above raises before anything is submitted, so none of them + strands the kernel holding half a draw's bindings.""" + observed = run_probe(start_app_under_test, "GraphicsBindingRefusalProbe") + assert observed["drew_after_the_refusals"] is True + + +@pytest.mark.requires_gpu +def test_a_buffer_kind_binding_is_refused_naming_the_kinds_a_draw_can_bind( + start_app_under_test, +): + """The only by-surface-id resolution the engine has is texture-shaped, so a + uniform-buffer binding is refused rather than pointed at a texture.""" + observed = run_probe(start_app_under_test, "GraphicsBufferBindingRefusalProbe") + + refusal = observed["buffer_kind_binding"] + assert observed["buffer_binding"] in refusal, ( + f"must name the binding it cannot resolve: {refusal}" + ) + assert "uniform_buffer" in refusal, refusal + assert "storage_image" in refusal and "sampled_texture" in refusal, ( + f"must name the kinds a draw can bind a surface for: {refusal}" + ) + + +@pytest.mark.requires_gpu +def test_a_draw_naming_anything_but_one_colour_target_is_refused( + start_app_under_test, +): + observed = run_probe(start_app_under_test, "GraphicsPassShapeRefusalProbe") + + for observation_key in ("two_color_targets", "no_color_target"): + refusal = observed[observation_key] + assert "exactly one" in refusal, f"{observation_key}: {refusal}" + + two_formats = observed["two_attachment_formats"] + assert "attachment_color_formats" in two_formats, two_formats + assert "exactly one" in two_formats, two_formats + + +@pytest.mark.requires_gpu +def test_a_draw_offers_no_argument_for_the_shapes_the_host_cannot_honour( + start_app_under_test, +): + """The signature test's runtime twin: passing one anyway is a `TypeError` + naming the keyword, not a silently dropped argument.""" + observed = run_probe(start_app_under_test, "GraphicsPassShapeRefusalProbe") + + for keyword in ("vertex_buffers", "index_buffer", "depth_target"): + refusal = observed[keyword] + assert keyword in refusal, f"must name the keyword it refuses: {refusal}" + assert "unexpected keyword argument" in refusal, refusal diff --git a/sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py b/sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py new file mode 100644 index 000000000..c74657793 --- /dev/null +++ b/sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py @@ -0,0 +1,273 @@ +# Copyright (c) 2025 Jonathan Fontanez +# SPDX-License-Identifier: BUSL-1.1 + +"""Named-binding ray tracing, from Python. + +Ray tracing is an always-present capability of `GpuContext` on a device whose +extension chain carries `VK_KHR_ray_tracing_pipeline`; a device without it +refuses by name. Building a scene and tracing it — a bottom-level structure +over triangle geometry, a top-level one placing it, a trace into an acquired +storage image — needs nothing from the application but the processor itself. + +The acceleration structures are objects, not ids: nothing publishes one for +another processor to resolve, so the handle a build returned is the whole way +to name it, and a trace binds the top-level one because that is what holds the +instances. + +The stage claim is the case the plan singles out. A ray-tracing kernel's stage +set varies per kernel, so a binding declared for a stage this kernel has no +module for is a claim no trace could ever make true — and it is refused at the +`create_ray_tracing_kernel` line, where the multi-stage declaration is built. + +These tests are `requires_gpu` and execute on the rig only; CI green is not +proof for them. `requires_gpu` does not cover the extension chain, so a device +without `VK_KHR_ray_tracing_pipeline` reports that refusal and the test skips +on it rather than failing on a capability the machine does not have. +""" + +import json +import re +from pathlib import Path + +import pytest + +from ray_tracing_kernel_probes import SCENE_BINDING, TRACED_OUTPUT_BINDING + +pytestmark = pytest.mark.requires_gpu + +APP = Path(__file__).parent / "ray_tracing_kernel_app.py" + +PROBE_RESULT = re.compile(r"MARKER:PROBE_RESULT (\{.*\})") + + +def run_probe(start_app_under_test, probe_class_name: str) -> dict: + """One probe, one observation dict — or a failure carrying the probe's own + traceback, which names the cause better than a missing marker. + + A device with no ray-tracing chain skips: the probe reports the engine's + own refusal, which is a capability statement rather than a defect. + """ + app = start_app_under_test(APP, probe_class_name) + app.await_output_containing( + "MARKER:PROBE_RESULT", f"the {probe_class_name} result" + ) + app.interrupt() + app.await_marker("CLEAN_EXIT") + app.await_clean_exit() + match = PROBE_RESULT.search(app.output) + assert match is not None, f"no parseable probe result:\n{app.output}" + observation = json.loads(match.group(1)) + if "ray_tracing_unavailable" in observation: + pytest.skip(observation["ray_tracing_unavailable"]) + if "failure" in observation: + pytest.fail(f"the probe raised in its helper process:\n{observation['failure']}") + return observation + + +def spelled_the_same_way(message: str) -> str: + """A message with the two spellings of a binding kind — the wire's + `storage_image` and the engine type's `StorageImage` — made comparable.""" + return message.lower().replace("_", "") + + +def test_a_python_processor_builds_a_scene_and_traces_it(start_app_under_test): + """The demo: a BLAS, a TLAS placing it, and a trace into a storage image, + all from a helper process with no application-supplied bridge.""" + observed = run_probe(start_app_under_test, "TracedTriangleProbe") + + assert observed["traced"] is True + assert observed["binding_names"] == [SCENE_BINDING, TRACED_OUTPUT_BINDING], ( + "the kernel reports the shaders' own binding names, which is what a " + "trace resolves against" + ) + assert observed["bottom_level_label"] == "python-triangle-blas" + assert observed["top_level_label"] == "python-triangle-tlas", ( + "the label is all a structure hands back — no id string reaches Python" + ) + assert observed["traced_output_surface_id"], ( + "the traced output is a surface this processor acquired for itself" + ) + + +def test_a_binding_declared_for_a_stage_this_kernel_has_no_module_for_is_refused_at_construction( + start_app_under_test, +): + """The ticket's named validation case. + + A trace never revisits which stage reads what — the descriptor set layout + is built once — so the mistake has to refuse where the multi-stage + declaration is built. The traceback is asserted on, not just the message: + "at construction" means the `create_ray_tracing_kernel` line raised and no + kernel object was ever handed back to trace with. + """ + observed = run_probe(start_app_under_test, "RayTracingStageMismatchProbe") + + stage_mismatch = observed["stage_mismatch"] + assert SCENE_BINDING in stage_mismatch, stage_mismatch + assert "any_hit" in stage_mismatch, ( + f"must name the stage that was claimed: {stage_mismatch}" + ) + assert "no shader module" in stage_mismatch, ( + f"must say why the claim can never come true: {stage_mismatch}" + ) + assert "ray_gen" in stage_mismatch, ( + f"must name the stages the kernel was built from: {stage_mismatch}" + ) + + raised_at = observed["stage_mismatch_traceback"] + assert "create_ray_tracing_kernel" in raised_at, ( + f"the refusal must come from the construction line: {raised_at}" + ) + assert ".trace(" not in raised_at, ( + f"a stage mismatch caught at the trace is caught too late: {raised_at}" + ) + assert observed["the_corrected_declaration_traced"] is True, ( + "the same modules with the stage claim corrected must still trace, or " + "the refusal rejected the scene rather than the declaration" + ) + + +def test_a_binding_the_shaders_do_not_declare_is_refused_at_construction( + start_app_under_test, +): + observed = run_probe(start_app_under_test, "RayTracingBindingRefusalProbe") + + unknown = observed["unknown_at_construction"] + assert "ambient_occlusion_radius" in unknown, ( + f"must name the unknown binding: {unknown}" + ) + assert SCENE_BINDING in unknown and TRACED_OUTPUT_BINDING in unknown, ( + f"must name what the shaders do declare: {unknown}" + ) + + +def test_a_binding_declared_as_the_wrong_kind_is_refused_at_construction( + start_app_under_test, +): + observed = run_probe(start_app_under_test, "RayTracingBindingRefusalProbe") + + mismatch = observed["kind_mismatch_at_construction"] + assert SCENE_BINDING in mismatch, mismatch + assert "storageimage" in spelled_the_same_way(mismatch), ( + f"must name the kind claimed: {mismatch}" + ) + assert "accelerationstructure" in spelled_the_same_way(mismatch), ( + f"must name the kind the shaders declare: {mismatch}" + ) + + +def test_a_binding_the_shaders_do_not_declare_is_refused_at_the_trace( + start_app_under_test, +): + observed = run_probe(start_app_under_test, "RayTracingBindingRefusalProbe") + + unknown = observed["unknown_at_trace"] + assert "ambient_occlusion_radius" in unknown, ( + f"must name the unknown binding: {unknown}" + ) + assert SCENE_BINDING in unknown and TRACED_OUTPUT_BINDING in unknown, ( + f"must name what the shaders do declare: {unknown}" + ) + + +def test_an_unsupplied_binding_is_refused_naming_the_shaders_bindings( + start_app_under_test, +): + """No implicit default and no carried-over value — for the surface-bound + binding and for the acceleration structure alike, which resolve through + different registries and so are two separate refusals.""" + observed = run_probe(start_app_under_test, "RayTracingBindingRefusalProbe") + + missing_output = observed["missing_output_at_trace"] + assert TRACED_OUTPUT_BINDING in missing_output, missing_output + assert "not supplied" in missing_output, missing_output + assert "do not persist between traces" in missing_output, ( + f"must say why there is no fallback: {missing_output}" + ) + + missing_scene = observed["missing_scene_at_trace"] + assert SCENE_BINDING in missing_scene, missing_scene + assert "not supplied" in missing_scene, missing_scene + + +def test_a_refused_trace_leaves_the_kernel_traceable(start_app_under_test): + """Every refusal above raises before anything is submitted, so none of them + strands the kernel holding half a trace's bindings.""" + observed = run_probe(start_app_under_test, "RayTracingBindingRefusalProbe") + assert observed["traced_after_the_refusals"] is True + + +def test_a_buffer_kind_binding_is_refused_naming_the_kinds_a_trace_can_bind( + start_app_under_test, +): + """The only by-surface-id resolution the engine has is texture-shaped, so a + uniform-buffer binding is refused rather than pointed at a texture.""" + observed = run_probe(start_app_under_test, "RayTracingBufferBindingRefusalProbe") + + refusal = observed["buffer_kind_binding"] + assert observed["buffer_binding"] in refusal, ( + f"must name the binding it cannot resolve: {refusal}" + ) + assert "uniform_buffer" in refusal, refusal + assert "storage_image" in refusal and "sampled_texture" in refusal, ( + f"must name the kinds a trace can bind a surface for: {refusal}" + ) + + +def test_an_acceleration_structure_binding_takes_a_handle_not_a_surface( + start_app_under_test, +): + """It is the one binding kind that cannot be spelled as an id string: + nothing publishes an acceleration structure for another processor to + resolve.""" + observed = run_probe( + start_app_under_test, "AccelerationStructureHandleRefusalProbe" + ) + + refusal = observed["a_surface_where_a_structure_belongs"] + assert SCENE_BINDING in refusal, refusal + assert "build_tlas" in refusal, ( + f"must name the builder whose handle it wants: {refusal}" + ) + + +def test_a_trace_binds_the_top_level_structure_not_a_bottom_level_one( + start_app_under_test, +): + """The top-level structure is what holds the instances, so binding the + bottom-level one traces an empty scene — refused instead.""" + observed = run_probe( + start_app_under_test, "AccelerationStructureHandleRefusalProbe" + ) + + refusal = observed["a_bottom_level_structure_at_the_trace"] + assert SCENE_BINDING in refusal, refusal + assert "bottom-level" in refusal and "top-level" in refusal, refusal + + +def test_an_instance_places_a_bottom_level_structure(start_app_under_test): + """The other direction of the same discipline: a scene is built out of + bottom-level structures, so a top-level one is not an instance.""" + observed = run_probe( + start_app_under_test, "AccelerationStructureHandleRefusalProbe" + ) + + refusal = observed["a_top_level_structure_as_an_instance"] + assert "top-level" in refusal, refusal + assert "instance 0" in refusal, ( + f"must name which instance was wrong: {refusal}" + ) + + +def test_geometry_that_is_not_whole_triangles_is_refused(start_app_under_test): + """A vertex is three floats and a triangle is three indices; a blob that is + neither would build a structure over misread memory.""" + observed = run_probe( + start_app_under_test, "AccelerationStructureHandleRefusalProbe" + ) + + vertices = observed["vertices_that_are_not_triangles"] + assert "vertex" in vertices and "three" in vertices, vertices + + indices = observed["indices_that_are_not_triangles"] + assert "triangle" in indices and "three" in indices, indices From 3b4b9b0da0a655084dc9663a993e59fed4593ebf Mon Sep 17 00:00:00 2001 From: Jonathan Fontanez Date: Tue, 18 Aug 2026 01:26:16 -0400 Subject: [PATCH 3/3] fix(engine): a descriptor set other than set 0 is refused, not dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard rejected more than one descriptor set. It did not reject a single set numbered something other than zero — reflection keys its map by the decorated set number, so a stage declaring only `layout(set = 1, ...)` stayed silent, `sets.get(&0)` came back empty, and every binding in that stage was dropped with no error. The completeness check then iterated an empty list and saw nothing missing, so the kernel built an empty descriptor set layout against a shader that reads set 1, and the validation layers that would catch it are opt-in. Reachable only through caller-supplied SPIR-V or GLSL, which is exactly what the Python surface accepts, and every shader in the tree declares set 0 — so nothing in the suite was covering it. The same shape sat in five places: the shared graphics and ray-tracing derive this change introduced, and the four compute and validator siblings it was copied from. They are one rule, so they are now one refusal all five call, rather than five copies that already disagreed about whether they had the bug. Each site gets a test that builds real in-tree SPIR-V with its descriptor-set decoration rewritten to 1. A BLAS index past the last vertex is refused at the same time. It is undefined behaviour with no robustness escape hatch — an indexed draw has one, an acceleration-structure build does not — and no layer could see it, because the index values reach the driver in device memory. The check belongs at the RHI where every language's callers pass through, not in the wheel that surfaced it. Refs #1777 Co-Authored-By: Claude Opus 5 (1M context) --- .../adapter-runtime-integration.md | 24 ++++-- .../src/core/rhi/compute_kernel.rs | 36 ++++++--- .../src/core/rhi/graphics_kernel.rs | 34 ++++++++- .../src/core/rhi/kernel_binding_names.rs | 48 ++++++++++-- runtime/streamlib-engine/src/core/rhi/mod.rs | 11 +-- .../rhi/spirv_module_rewriting_for_tests.rs | 27 +++++++ .../rhi/vulkan_acceleration_structure.rs | 49 +++++++++++++ .../src/vulkan/rhi/vulkan_compute_kernel.rs | 41 ++++++++--- .../src/vulkan/rhi/vulkan_graphics_kernel.rs | 40 +++++++--- .../vulkan/rhi/vulkan_ray_tracing_kernel.rs | 73 ++++++++++++++++--- .../tests/ray_tracing_kernel_probes.py | 6 ++ .../tests/test_ray_tracing_kernel.py | 13 ++++ 12 files changed, 342 insertions(+), 60 deletions(-) diff --git a/docs/architecture/adapter-runtime-integration.md b/docs/architecture/adapter-runtime-integration.md index 633b9df60..90c01c962 100644 --- a/docs/architecture/adapter-runtime-integration.md +++ b/docs/architecture/adapter-runtime-integration.md @@ -310,14 +310,22 @@ The shape of what the hook does varies by seam: signals `consume_done` from `end_read_access`. No kernel dispatch flavour uses a bridge. Compute, graphics, ray tracing -and CPU readback are always-present `GpuContext` capabilities served by the -escalate handler directly, with no installation step and no runtime-absent -case: `create_or_reuse_compute_kernel` / `compute_kernel_by_id`, -`create_or_reuse_graphics_kernel` / `graphics_kernel_by_id`, -`create_or_reuse_ray_tracing_kernel` / `ray_tracing_kernel_by_id`, -`register_acceleration_structure` / `acceleration_structure_by_id`, and -`run_cpu_readback_copy` / `try_run_cpu_readback_copy` / -`open_cpu_readback_staging`. +and CPU readback are `GpuContext` capabilities served by the escalate handler +directly, with no installation step and no runtime-absent case. The set is +Linux-only: the kernel and acceleration-structure methods are +`#[cfg(target_os = "linux")]`, and every op in it answers `… is only available +on Linux` off it. The methods are `create_or_reuse_compute_kernel` / +`compute_kernel_by_id`, `create_or_reuse_graphics_kernel` / +`graphics_kernel_by_id`, `create_or_reuse_ray_tracing_kernel` / +`ray_tracing_kernel_by_id` and `register_acceleration_structure` / +`acceleration_structure_by_id`. CPU readback has no `GpuContext` method of its +own name — `run_cpu_readback_copy`, `try_run_cpu_readback_copy` and +`open_cpu_readback_staging` are escalate ops, and they land on +`GpuContextLimitedAccess`'s `refill_surface_export_staging` / +`try_refill_surface_export_staging`, +`copy_surface_export_staging_back_to_surface` / +`try_copy_surface_export_staging_back_to_surface` and +`surface_export_staging` + `share_surface_export_staging`. The hook is the canonical opt-in registration point for adapters that need pre-start GpuContext access. Application authors call diff --git a/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs b/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs index c05cb9e8c..3dd03a9b8 100644 --- a/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs @@ -14,7 +14,9 @@ use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; use crate::core::{Error, Result}; -use super::kernel_binding_names::quote_declared_shader_binding_names; +use super::kernel_binding_names::{ + quote_declared_shader_binding_names, refuse_a_descriptor_set_other_than_set_0, +}; /// Kind of resource bound at a particular slot in a compute kernel's /// descriptor set. @@ -219,7 +221,7 @@ pub struct ComputeKernelDescriptor<'a> { /// derives the descriptor shape from reflection alone. Keeps the wire format /// minimal and the binding-shape source-of-truth in the shader. /// -/// Rejects multi-set kernels — only descriptor set 0 is supported, matching +/// Rejects any descriptor set other than set 0, matching /// `VulkanComputeKernel`'s contract. /// /// Every derived spec carries the shader's own name for its binding. A blob @@ -236,12 +238,7 @@ pub fn derive_bindings_from_spirv(spv: &[u8]) -> Result<(Vec )) })?; - if sets.len() > 1 { - return Err(Error::GpuError(format!( - "Only descriptor set 0 is supported; SPIR-V uses sets {:?}", - sets.keys().collect::>() - ))); - } + refuse_a_descriptor_set_other_than_set_0("Compute kernel", None::<&str>, sets.keys().copied())?; let mut bindings: Vec = Vec::new(); if let Some(set0) = sets.get(&0) { @@ -300,7 +297,10 @@ fn spirv_type_to_kind(ty: RDescriptorType) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::core::rhi::spirv_module_rewriting_for_tests::strip_every_debug_name_from_spirv_module; + use crate::core::rhi::spirv_module_rewriting_for_tests::{ + move_binding_to_another_descriptor_set_in_spirv_module, + strip_every_debug_name_from_spirv_module, + }; // SPIR-V test fixtures live next to `vulkan_compute_kernel.rs` and are // built by `libs/streamlib/build.rs`. Reflection is a host-architecture @@ -488,6 +488,24 @@ mod tests { ); } + #[test] + fn a_shader_whose_only_set_is_not_set_0_is_refused_at_derive() { + let moved_to_set_1 = + move_binding_to_another_descriptor_set_in_spirv_module(blend_spv(2), 0, 1); + let message = match derive_bindings_from_spirv(&moved_to_set_1) { + Ok((derived, _)) => panic!( + "a binding outside set 0 cannot be bound, so it cannot be dropped in silence; \ + derive returned {} binding(s)", + derived.len() + ), + Err(refusal) => refusal.to_string(), + }; + assert!( + message.contains("only descriptor set 0 is supported") && message.contains('1'), + "the refusal must name the unsupported set: {message}" + ); + } + #[test] fn rejects_truncated_spirv() { let err = derive_bindings_from_spirv(&[0u8; 7]) diff --git a/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs b/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs index 477a5d918..b06164561 100644 --- a/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs @@ -689,8 +689,8 @@ pub struct DrawIndexedCall { /// `VERTEX | FRAGMENT`. /// /// Rejects descriptor-type conflicts (same binding declared as -/// StorageBuffer in vertex and UniformBuffer in fragment) and multi-set -/// kernels (only descriptor set 0 supported). +/// StorageBuffer in vertex and UniformBuffer in fragment) and any descriptor +/// set other than set 0. /// /// Every derived spec carries the shader's own name for its binding, and the /// two ways a name can fail to identify one binding are rejected here rather @@ -752,6 +752,7 @@ fn spirv_type_to_kind(ty: RDescriptorType) -> Option { mod tests { use super::*; use crate::core::rhi::spirv_module_rewriting_for_tests::{ + move_binding_to_another_descriptor_set_in_spirv_module, move_binding_to_another_slot_in_spirv_module, rename_binding_in_spirv_module, strip_every_debug_name_from_spirv_module, }; @@ -839,4 +840,33 @@ mod tests { "the refusal must name both slots and the name: {message}" ); } + + #[test] + fn a_stage_whose_only_set_is_not_set_0_is_refused_at_derive() { + let moved_to_set_1 = move_binding_to_another_descriptor_set_in_spirv_module( + display_blit_fragment_spirv(), + 0, + 1, + ); + let stages = [ + GraphicsStage::vertex(display_blit_vertex_spirv()), + GraphicsStage::fragment(&moved_to_set_1), + ]; + let message = match derive_bindings_from_spirv_multistage(&stages) { + Ok((derived, _)) => panic!( + "a binding outside set 0 cannot be bound, so it cannot be dropped in silence; \ + derive returned {} binding(s): {:?}", + derived.len(), + derived + .iter() + .map(|spec| spec.name.as_deref()) + .collect::>() + ), + Err(refusal) => refusal.to_string(), + }; + assert!( + message.contains("only descriptor set 0 is supported") && message.contains('1'), + "the refusal must name the unsupported set: {message}" + ); + } } diff --git a/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs b/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs index b2e9592ba..a509a6e87 100644 --- a/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs +++ b/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs @@ -9,7 +9,8 @@ //! reconciles in [`super::compute_kernel`]; graphics and ray tracing scope each //! binding to the stages that read it, and share both the multi-stage //! reflection merge and the reconciliation here because the only thing that -//! differs between them is which newtypes they name. +//! differs between them is which newtypes they name. The refusals every +//! pipeline kind owes its callers live here too, compute included. use std::collections::BTreeMap; @@ -177,6 +178,39 @@ where Ok(()) } +/// Refuse a descriptor set the kernel's single-set pipeline layout has no place +/// for. +/// +/// Reflection keys its result by the set number the shader decorated, so a +/// shader whose only set is set 1 reports exactly one set — a count cannot tell +/// it apart from a shader that uses set 0, and every binding in it would be +/// dropped by a walk that reads set 0 alone. +pub fn refuse_a_descriptor_set_other_than_set_0( + kernel_kind_label: &str, + stage_the_spirv_fills: Option, + descriptor_set_numbers_the_shader_declares: impl IntoIterator, +) -> Result<()> +where + Stage: std::fmt::Debug, +{ + let declared_sets: Vec = descriptor_set_numbers_the_shader_declares + .into_iter() + .collect(); + if !declared_sets.iter().any(|&set| set != 0) { + return Ok(()); + } + Err(Error::GpuError(match stage_the_spirv_fills { + Some(stage) => format!( + "{kernel_kind_label}: only descriptor set 0 is supported; SPIR-V {stage:?} stage uses \ + sets {declared_sets:?}" + ), + None => format!( + "{kernel_kind_label}: only descriptor set 0 is supported; SPIR-V uses sets \ + {declared_sets:?}" + ), + })) +} + /// Refuse a binding the shader left unnamed. /// /// One of the three ways a name can fail to identify one binding slot. Every @@ -321,13 +355,11 @@ where "{kernel_kind_label}: failed to extract descriptor sets for {stage:?} stage: {e:?}" )) })?; - if sets.len() > 1 { - return Err(Error::GpuError(format!( - "{kernel_kind_label}: only descriptor set 0 is supported; SPIR-V {stage:?} stage \ - uses sets {:?}", - sets.keys().collect::>() - ))); - } + refuse_a_descriptor_set_other_than_set_0( + kernel_kind_label, + Some(stage), + sets.keys().copied(), + )?; if let Some(set0) = sets.get(&0) { for (&binding, info) in set0 { let kind = binding_kind_of_descriptor_type(info.ty).ok_or_else(|| { diff --git a/runtime/streamlib-engine/src/core/rhi/mod.rs b/runtime/streamlib-engine/src/core/rhi/mod.rs index 66373dc29..311152fff 100644 --- a/runtime/streamlib-engine/src/core/rhi/mod.rs +++ b/runtime/streamlib-engine/src/core/rhi/mod.rs @@ -40,6 +40,7 @@ pub use color_converter::{ }; pub use command_buffer::CommandBuffer; pub use command_queue::RhiCommandQueue; +#[cfg(target_os = "linux")] pub(crate) use compute_kernel::reconcile_compute_binding_declarations; pub use compute_kernel::{ ComputeBindingDeclaration, ComputeBindingKind, ComputeBindingSpec, ComputeKernelDescriptor, @@ -52,6 +53,7 @@ pub use gl_interop::{GlContext, GlTextureBinding, gl_constants}; pub use glsl_shader_source_compiler::{ DEFAULT_SHADER_ENTRY_POINT, GlslCompilationTargetStage, GlslShaderSourceToSpirvCompiler, }; +#[cfg(target_os = "linux")] pub(crate) use graphics_kernel::reconcile_graphics_binding_declarations; pub use graphics_kernel::{ AttachmentFormats, BlendFactor, BlendOp, ColorBlendAttachment, ColorBlendState, ColorWriteMask, @@ -68,12 +70,10 @@ pub use host_timeline_semaphore::HostTimelineSemaphore; #[cfg(target_os = "linux")] pub use index_buffer::IndexBuffer; #[cfg(target_os = "linux")] -pub(crate) use kernel_binding_names::KernelShaderStageMask; pub(crate) use kernel_binding_names::{ - quote_declared_shader_binding_names, quote_shader_stage_names, -}; -pub(crate) use kernel_binding_names::{ - refuse_a_binding_the_shader_left_unnamed, refuse_one_binding_name_that_identifies_two_slots, + KernelShaderStageMask, quote_declared_shader_binding_names, quote_shader_stage_names, + refuse_a_binding_the_shader_left_unnamed, refuse_a_descriptor_set_other_than_set_0, + refuse_one_binding_name_that_identifies_two_slots, refuse_one_binding_slot_two_stages_spell_differently, }; pub use pixel_buffer::PixelBuffer; @@ -88,6 +88,7 @@ pub use ray_tracing_kernel::{ derive_ray_tracing_bindings_from_spirv_multistage, ray_tracing_stages_covered_by, validate_shader_groups, }; +#[cfg(target_os = "linux")] pub(crate) use ray_tracing_kernel::{ ray_tracing_spirv_type_to_kind, reconcile_ray_tracing_binding_declarations, }; diff --git a/runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs b/runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs index ba6bdc5ff..3e5beec2d 100644 --- a/runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs +++ b/runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs @@ -16,6 +16,7 @@ const SPIRV_HEADER_WORD_COUNT: usize = 5; const OP_NAME: u16 = 5; const OP_DECORATE: u16 = 71; const DECORATION_BINDING: u32 = 33; +const DECORATION_DESCRIPTOR_SET: u32 = 34; /// Drop every `OpName` from a module, reproducing what `glslc -O` emits /// without `-g`. @@ -77,6 +78,32 @@ pub(crate) fn move_binding_to_another_slot_in_spirv_module( moved } +/// Move one binding to another descriptor set, leaving its slot, its name and +/// every other instruction alone. +pub(crate) fn move_binding_to_another_descriptor_set_in_spirv_module( + spirv: &[u8], + current_set: u32, + replacement_set: u32, +) -> Vec { + let (moved, moved_count) = rewrite_spirv_instructions(spirv, |opcode, instruction| { + if opcode != OP_DECORATE + || instruction.len() < 4 + || instruction[2] != DECORATION_DESCRIPTOR_SET + || instruction[3] != current_set + { + return None; + } + let mut rewritten = instruction.to_vec(); + rewritten[3] = replacement_set; + Some(rewritten) + }); + assert!( + moved_count > 0, + "the module decorates nothing with descriptor set {current_set}" + ); + moved +} + /// Walk a module instruction by instruction, replacing each one the rewriter /// returns words for and dropping each one it returns no words for; reports how /// many instructions it touched. diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_acceleration_structure.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_acceleration_structure.rs index 9ead8fd3f..e3f011cfe 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_acceleration_structure.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_acceleration_structure.rs @@ -96,6 +96,33 @@ pub fn geometry_instance_flags_from_raw_bitmask( }) } +/// Refuse an index that names a vertex the caller did not supply. +/// +/// A triangle-geometry build reads `vertexData` through a buffer device address +/// bounded only by `maxVertex` +/// (VUID-VkAccelerationStructureBuildRangeInfoKHR-vertexData-10418), and an +/// acceleration-structure build's input reads have no `robustBufferAccess` +/// escape hatch the way an indexed draw's do. An index past the last vertex is +/// therefore an out-of-bounds device read — garbage geometry or a device fault, +/// reported by nothing, not even the validation layers, which cannot see index +/// values that live in device memory. +fn refuse_an_index_past_the_last_vertex( + acceleration_structure_label: &str, + vertex_count: u32, + indices: &[u32], +) -> Result<()> { + let Some(&index_past_the_last_vertex) = indices.iter().find(|&&index| index >= vertex_count) + else { + return Ok(()); + }; + Err(Error::GpuError(format!( + "Acceleration structure '{acceleration_structure_label}': index \ + {index_past_the_last_vertex} names a vertex outside the {vertex_count} supplied; every \ + index must be less than {vertex_count}, and the build would otherwise read past the \ + vertex buffer" + ))) +} + /// Row-major 3×4 identity transform. pub const IDENTITY_TRANSFORM: [[f32; 4]; 3] = [ [1.0, 0.0, 0.0, 0.0], @@ -190,6 +217,7 @@ impl VulkanAccelerationStructureInner { let device = vulkan_device.device(); let triangle_count = (indices.len() / 3) as u32; let vertex_count = (vertices.len() / 3) as u32; + refuse_an_index_past_the_last_vertex(label, vertex_count, indices)?; let vertex_bytes = mem::size_of_val(vertices) as vk::DeviceSize; let index_bytes = mem::size_of_val(indices) as vk::DeviceSize; @@ -1074,6 +1102,27 @@ mod tests { ); } + #[test] + fn every_index_inside_the_supplied_vertices_is_accepted() { + refuse_an_index_past_the_last_vertex("in-range", 3, &[0, 1, 2]) + .expect("three indices over three vertices name only vertices that exist"); + } + + #[test] + fn an_index_past_the_last_vertex_is_refused() { + let refusal = refuse_an_index_past_the_last_vertex("one-past-the-end", 3, &[0, 1, 3]) + .expect_err("index 3 over three vertices reads past the vertex buffer"); + let message = refusal.to_string(); + assert!( + message.contains("index 3") && message.contains("outside the 3 supplied"), + "the refusal must name the index and the vertex count: {message}" + ); + assert!( + message.contains("one-past-the-end"), + "the refusal must name the acceleration structure: {message}" + ); + } + #[test] fn a_bit_no_geometry_instance_flag_owns_is_refused() { let undefined_bit = !vk::GeometryInstanceFlagsKHR::all().bits() & (1 << 31); diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_compute_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_compute_kernel.rs index 28b375c1d..aa4b7eb3a 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_compute_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_compute_kernel.rs @@ -26,7 +26,10 @@ use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; use std::ffi::{CStr, c_void}; -use crate::core::rhi::{ComputeBindingKind, ComputeBindingSpec, ComputeKernelDescriptor, Texture}; +use crate::core::rhi::{ + ComputeBindingKind, ComputeBindingSpec, ComputeKernelDescriptor, Texture, + refuse_a_descriptor_set_other_than_set_0, +}; use crate::core::{Error, Result}; /// Env var that overrides the default pipeline-cache directory. Used by tests @@ -1174,14 +1177,11 @@ fn validate_against_spirv( )) })?; - // Reject multi-set kernels — out of scope. - if sets.len() > 1 { - return Err(Error::GpuError(format!( - "Compute kernel '{}': only descriptor set 0 is supported; SPIR-V uses sets {:?}", - descriptor.label, - sets.keys().collect::>() - ))); - } + refuse_a_descriptor_set_other_than_set_0( + &format!("Compute kernel '{}'", descriptor.label), + None::<&str>, + sets.keys().copied(), + )?; let set0 = sets.get(&0); @@ -1631,6 +1631,7 @@ fn vk_image_view_for(texture: &Texture) -> Result { mod tests { use super::*; use crate::core::rhi::PixelBuffer; + use crate::core::rhi::spirv_module_rewriting_for_tests::move_binding_to_another_descriptor_set_in_spirv_module; use crate::vulkan::rhi::HostVulkanBuffer; fn try_vulkan_device() -> Option> { @@ -1692,6 +1693,28 @@ mod tests { } } + #[test] + fn a_shader_whose_only_set_is_not_set_0_is_refused_at_validation() { + let moved_to_set_1 = + move_binding_to_another_descriptor_set_in_spirv_module(blend_spv(2), 0, 1); + let bindings = blend_descriptor(2); + let descriptor = ComputeKernelDescriptor { + label: "blend-in-set-1", + spv: &moved_to_set_1, + entry_point: "main", + bindings: &bindings, + push_constant_size: 0, + }; + let refusal = validate_against_spirv(&descriptor) + .err() + .expect("a binding outside set 0 cannot be bound, so it cannot be dropped in silence"); + let message = refusal.to_string(); + assert!( + message.contains("only descriptor set 0 is supported") && message.contains('1'), + "the refusal must name the unsupported set: {message}" + ); + } + fn run_blend_kernel_for( device: &Arc, input_count: u32, diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs index 088fc2ba3..fc8edc089 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_graphics_kernel.rs @@ -51,7 +51,7 @@ use crate::core::rhi::{ GraphicsShaderStage, GraphicsShaderStageFlags, GraphicsStage, IndexType, PolygonMode, PrimitiveTopology, ScissorRect, Texture, TextureFormat, VertexAttributeFormat, VertexInputRate, VertexInputState, Viewport, refuse_a_binding_the_shader_left_unnamed, - refuse_one_binding_name_that_identifies_two_slots, + refuse_a_descriptor_set_other_than_set_0, refuse_one_binding_name_that_identifies_two_slots, refuse_one_binding_slot_two_stages_spell_differently, }; use crate::core::{Error, Result}; @@ -1481,14 +1481,11 @@ fn validate_against_spirv( descriptor.label, stage.stage )) })?; - if sets.len() > 1 { - return Err(Error::GpuError(format!( - "Graphics kernel '{}': only descriptor set 0 is supported; SPIR-V {:?} stage uses sets {:?}", - descriptor.label, - stage.stage, - sets.keys().collect::>() - ))); - } + refuse_a_descriptor_set_other_than_set_0( + &kernel_kind_label, + Some(stage.stage), + sets.keys().copied(), + )?; if let Some(set0) = sets.get(&0) { for (&binding, info) in set0 { refuse_a_binding_the_shader_left_unnamed( @@ -2333,6 +2330,7 @@ fn atomic_write_pipeline_cache(path: &Path, data: &[u8]) -> std::io::Result<()> mod tests { use super::*; use crate::core::rhi::spirv_module_rewriting_for_tests::{ + move_binding_to_another_descriptor_set_in_spirv_module, move_binding_to_another_slot_in_spirv_module, rename_binding_in_spirv_module, strip_every_debug_name_from_spirv_module, }; @@ -2455,6 +2453,30 @@ mod tests { ); } + #[test] + fn a_stage_whose_only_set_is_not_set_0_is_refused_at_validation() { + let moved_to_set_1 = + move_binding_to_another_descriptor_set_in_spirv_module(frag_spv(), 0, 1); + let bindings = [GraphicsBindingSpec::sampled_texture( + 0, + GraphicsShaderStageFlags::FRAGMENT, + )]; + let stages = [ + GraphicsStage::vertex(vert_spv()), + GraphicsStage::fragment(&moved_to_set_1), + ]; + let pipeline_state = default_pipeline_state(); + let descriptor = display_blit_descriptor(&stages, &bindings, &pipeline_state); + let refusal = validate_against_spirv(&descriptor) + .err() + .expect("a binding outside set 0 cannot be bound, so it cannot be dropped in silence"); + let message = format!("{refusal}"); + assert!( + message.contains("only descriptor set 0 is supported") && message.contains('1'), + "the refusal must name the unsupported set: {message}" + ); + } + #[test] fn rejects_a_name_stripped_stage() { let stripped = strip_every_debug_name_from_spirv_module(frag_spv()); diff --git a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs index 369dde951..a14637191 100644 --- a/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs +++ b/runtime/streamlib-engine/src/vulkan/rhi/vulkan_ray_tracing_kernel.rs @@ -35,7 +35,7 @@ use crate::core::rhi::{ RayTracingBindingKind, RayTracingBindingSpec, RayTracingKernelDescriptor, RayTracingShaderGroup, RayTracingShaderStage, RayTracingShaderStageFlags, RayTracingStage, Texture, ray_tracing_spirv_type_to_kind, refuse_a_binding_the_shader_left_unnamed, - refuse_one_binding_name_that_identifies_two_slots, + refuse_a_descriptor_set_other_than_set_0, refuse_one_binding_name_that_identifies_two_slots, refuse_one_binding_slot_two_stages_spell_differently, validate_shader_groups, }; use crate::core::{Error, Result}; @@ -1104,14 +1104,11 @@ fn validate_bindings_against_spirv( descriptor.label, stage.stage )) })?; - if sets.len() > 1 { - return Err(Error::GpuError(format!( - "Ray-tracing kernel '{}': only descriptor set 0 supported; stage {:?} uses sets {:?}", - descriptor.label, - stage.stage, - sets.keys().collect::>() - ))); - } + refuse_a_descriptor_set_other_than_set_0( + &kernel_kind_label, + Some(stage.stage), + sets.keys().copied(), + )?; let stage_flag = stage_to_stage_flag(stage.stage); if let Some(set0) = sets.get(&0) { for (&binding, info) in set0 { @@ -1838,7 +1835,8 @@ fn drop_sbt(sbt: &Sbt, vulkan_device: &Arc) { mod tests { use super::*; use crate::core::rhi::spirv_module_rewriting_for_tests::{ - rename_binding_in_spirv_module, strip_every_debug_name_from_spirv_module, + move_binding_to_another_descriptor_set_in_spirv_module, rename_binding_in_spirv_module, + strip_every_debug_name_from_spirv_module, }; use crate::core::rhi::{ RayTracingBindingSpec, RayTracingKernelDescriptor, RayTracingPushConstants, @@ -1961,6 +1959,28 @@ mod tests { ); } + #[test] + fn a_stage_whose_only_set_is_not_set_0_is_refused_at_validation() { + let moved_to_set_1 = + move_binding_to_another_descriptor_set_in_spirv_module(rt_test_rgen_spv(), 0, 1); + let stages = [RayTracingStage::ray_gen(&moved_to_set_1)]; + let groups = [RayTracingShaderGroup::General { general: 0 }]; + let bindings = [ + RayTracingBindingSpec::acceleration_structure(0, RayTracingShaderStageFlags::RAYGEN), + RayTracingBindingSpec::storage_image(1, RayTracingShaderStageFlags::RAYGEN), + ]; + let refusal = validate_bindings_against_spirv(&binding_validation_descriptor( + &stages, &groups, &bindings, + )) + .err() + .expect("a binding outside set 0 cannot be bound, so it cannot be dropped in silence"); + let message = format!("{refusal}"); + assert!( + message.contains("only descriptor set 0 is supported") && message.contains('1'), + "the refusal must name the unsupported set: {message}" + ); + } + #[test] fn rejects_a_name_stripped_stage() { let stripped = strip_every_debug_name_from_spirv_module(rt_test_rgen_spv()); @@ -2130,6 +2150,39 @@ mod tests { ); } + /// An index past the last vertex must be refused before the build, not + /// handed to the driver: the AS build reads `vertexData` through a buffer + /// device address that no robustness guarantee bounds, so a device that + /// accepts it reads out of bounds and reports nothing. + #[cfg_attr( + not(feature = "hardware-tests"), + ignore = "hardware integration — set --features streamlib/hardware-tests + run with --test-threads=1. See docs/testing-hardware.md" + )] + #[test] + fn a_blas_build_refuses_an_index_past_the_last_vertex() { + let Some(device) = try_ray_tracing_device() else { + return; + }; + let vertices: [f32; 9] = [ + -0.6, -0.6, 0.5, // + 0.6, -0.6, 0.5, // + 0.0, 0.6, 0.5, // + ]; + let refusal = VulkanAccelerationStructure::build_triangles_blas( + &device, + "rt-test-blas-out-of-range-index", + &vertices, + &[0, 1, 3], + ) + .err() + .expect("index 3 over three vertices cannot be built in silence"); + let message = refusal.to_string(); + assert!( + message.contains("index 3") && message.contains("outside the 3 supplied"), + "the refusal must name the index and the vertex count: {message}" + ); + } + /// Build a 1-triangle BLAS, single-instance TLAS, and run trace-rays /// against a 64×64 storage image. Reads the result back and checks /// that the centre pixel is hit (barycentric color, mostly red) and diff --git a/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py b/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py index 09f35e9ac..9cce9a5d4 100644 --- a/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py +++ b/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py @@ -464,10 +464,16 @@ def observe(self) -> dict: vertices=TRIANGLE_VERTICES, indices=[0, 1] ) ) + an_index_past_the_last_vertex = _refusal_of( + lambda: gpu.build_triangles_blas( + vertices=TRIANGLE_VERTICES, indices=[0, 1, 3] + ) + ) return { "a_surface_where_a_structure_belongs": a_surface_where_a_structure_belongs, "a_bottom_level_structure_at_the_trace": a_bottom_level_structure_at_the_trace, "a_top_level_structure_as_an_instance": a_top_level_structure_as_an_instance, "vertices_that_are_not_triangles": vertices_that_are_not_triangles, "indices_that_are_not_triangles": indices_that_are_not_triangles, + "an_index_past_the_last_vertex": an_index_past_the_last_vertex, } diff --git a/sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py b/sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py index c74657793..18cb0a0c0 100644 --- a/sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py +++ b/sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py @@ -271,3 +271,16 @@ def test_geometry_that_is_not_whole_triangles_is_refused(start_app_under_test): indices = observed["indices_that_are_not_triangles"] assert "triangle" in indices and "three" in indices, indices + + +def test_an_index_past_the_last_vertex_is_refused(start_app_under_test): + """The build reads the vertex buffer through a device address no robustness + guarantee bounds, so an index naming a vertex the caller did not supply + reads out of bounds — and no validation layer can see it, because the index + values live in device memory.""" + observed = run_probe( + start_app_under_test, "AccelerationStructureHandleRefusalProbe" + ) + + refusal = observed["an_index_past_the_last_vertex"] + assert "index 3" in refusal and "outside the 3 supplied" in refusal, refusal