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..90c01c962 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,30 @@ 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 `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 `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 +440,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/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/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 79f944792..3dd03a9b8 100644 --- a/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/compute_kernel.rs @@ -14,6 +14,10 @@ use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; use crate::core::{Error, Result}; +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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -34,14 +38,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 @@ -50,8 +55,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, @@ -88,18 +93,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. /// @@ -228,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 @@ -245,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) { @@ -309,6 +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::{ + 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 @@ -467,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"); @@ -478,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); @@ -519,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 97fc17512..b06164561 100644 --- a/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs +++ b/runtime/streamlib-engine/src/core/rhi/graphics_kernel.rs @@ -12,11 +12,18 @@ //! creation, validates the declaration matches, and from that point on the //! caller binds resources by slot via simple typed setters. -use rspirv_reflect::{DescriptorType as RDescriptorType, Reflection}; +use std::borrow::Cow; -use crate::core::{Error, Result}; +use rspirv_reflect::DescriptorType as RDescriptorType; + +use crate::core::Result; use super::TextureFormat; +use super::kernel_binding_names::{ + KernelBindingUnderReconciliation, KernelShaderStageMask, KernelShaderStageSpirvModule, + derive_staged_kernel_bindings_from_shader_reflection, + reconcile_staged_kernel_binding_declarations, +}; /// Shader stages that contribute to a graphics pipeline. /// @@ -52,6 +59,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 +125,99 @@ 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 mask_naming_no_stages() -> Self { + Self::NONE + } + + 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 +226,7 @@ impl GraphicsBindingSpec { binding, kind: GraphicsBindingKind::SampledTexture, stages, + name: None, } } @@ -126,6 +235,7 @@ impl GraphicsBindingSpec { binding, kind: GraphicsBindingKind::StorageBuffer, stages, + name: None, } } @@ -134,6 +244,7 @@ impl GraphicsBindingSpec { binding, kind: GraphicsBindingKind::UniformBuffer, stages, + name: None, } } @@ -142,8 +253,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. @@ -293,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 { @@ -344,6 +463,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 { @@ -560,86 +689,44 @@ 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 +/// 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), - > = 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 - )) - })?; - let entry = merged - .entry(binding) - .or_insert((kind, GraphicsShaderStageFlags::NONE)); - if entry.0 != kind { - return Err(Error::GpuError(format!( - "Graphics kernel: binding {binding} kind conflict — {:?} vs {:?} (introduced by {:?})", - entry.0, kind, 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))| GraphicsBindingSpec { - binding, - kind, - stages, + let stage_modules: Vec<_> = stages + .iter() + .map(|stage| KernelShaderStageSpirvModule { + stage: stage.stage, + spirv: stage.spv, }) .collect(); + 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, }, )) } @@ -660,3 +747,126 @@ 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_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, + }; + + // 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}" + ); + } + + #[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 new file mode 100644 index 000000000..a509a6e87 --- /dev/null +++ b/runtime/streamlib-engine/src/core/rhi/kernel_binding_names.rs @@ -0,0 +1,667 @@ +// 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 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. The refusals every +//! pipeline kind owes its callers live here too, compute included. + +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 { + quote_names(names, "no named bindings") +} + +/// 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 { + /// 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>; + + /// 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_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 { + 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(()) +} + +/// 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 +/// 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:?}" + )) + })?; + 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(|| { + 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::*; + + #[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 mask_naming_no_stages() -> Self { + Self::NONE + } + + 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..311152fff 100644 --- a/runtime/streamlib-engine/src/core/rhi/mod.rs +++ b/runtime/streamlib-engine/src/core/rhi/mod.rs @@ -17,10 +17,13 @@ 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; mod ray_tracing_kernel; +#[cfg(test)] +pub(crate) mod spirv_module_rewriting_for_tests; mod storage_buffer; pub(crate) mod texture; mod texture_cache; @@ -37,12 +40,11 @@ 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, - SurfaceBoundComputeBindingKind, derive_bindings_from_spirv, -}; -pub(crate) use compute_kernel::{ - quote_declared_shader_binding_names, reconcile_compute_binding_declarations, + SurfaceBoundKernelBindingKind, derive_bindings_from_spirv, }; pub use device::GpuDevice; pub use external_handle::{RhiExternalHandle, RhiPixelBufferExport, RhiPixelBufferImport}; @@ -51,28 +53,44 @@ 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, 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; +#[cfg(target_os = "linux")] +pub(crate) use kernel_binding_names::{ + 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; pub use pixel_buffer_pool::{ PixelBufferDescriptor, PixelBufferPoolSlotId, PublishedPixelBufferFrameId, pool_slot_key_of_surface_id, split_pool_slot_and_frame_generation, }; 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(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; 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..2574d094b 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,18 @@ //! 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; + use crate::core::{Error, Result}; +use super::kernel_binding_names::{ + KernelBindingUnderReconciliation, KernelShaderStageMask, KernelShaderStageSpirvModule, + derive_staged_kernel_bindings_from_shader_reflection, + 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 +63,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 +167,183 @@ 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 mask_naming_no_stages() -> Self { + Self::NONE + } + + 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 stage_modules: Vec<_> = stages + .iter() + .map(|stage| KernelShaderStageSpirvModule { + stage: stage.stage, + spirv: stage.spv, + }) + .collect(); + 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(( + 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_constants.size, + stages: push_constants.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 +352,7 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::StorageBuffer, stages, + name: None, } } @@ -165,6 +361,7 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::UniformBuffer, stages, + name: None, } } @@ -173,6 +370,7 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::SampledTexture, stages, + name: None, } } @@ -181,6 +379,7 @@ impl RayTracingBindingSpec { binding, kind: RayTracingBindingKind::StorageImage, stages, + name: None, } } @@ -189,8 +388,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. @@ -401,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..3e5beec2d --- /dev/null +++ b/runtime/streamlib-engine/src/core/rhi/spirv_module_rewriting_for_tests.rs @@ -0,0 +1,169 @@ +// 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; +const DECORATION_DESCRIPTOR_SET: u32 = 34; + +/// 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 +} + +/// 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. +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..e3f011cfe 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,52 @@ 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() + )) + }) +} + +/// 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], @@ -171,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; @@ -1034,3 +1081,67 @@ 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 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); + 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_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 7780baa2f..fc8edc089 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_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}; @@ -213,7 +215,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 +314,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 +1454,16 @@ 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 kernel_kind_label = format!("Graphics kernel '{}'", descriptor.label); + let mut merged: BTreeMap = + BTreeMap::new(); let mut spirv_push_size: u32 = 0; let mut spirv_push_stages = GraphicsShaderStageFlags::NONE; @@ -1473,25 +1481,38 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( 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 { - let entry = merged - .entry(binding) - .or_insert((info.ty, GraphicsShaderStageFlags::NONE)); + 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, + 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 ))); } + refuse_one_binding_slot_two_stages_spell_differently( + &kernel_kind_label, + stage.stage, + binding, + &entry.2, + &info.name, + )?; entry.1 |= stage_flag; } } @@ -1506,7 +1527,18 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( } } - // Each declared binding must agree with merged shader declaration. + 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 + // 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 +1553,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 +1574,20 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( merged_entry.1.bits() ))); } + let mut adopted = spec.clone(); + 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 +1614,7 @@ fn validate_against_spirv(descriptor: &GraphicsKernelDescriptor<'_>) -> Result<( ))); } - Ok(()) + Ok(reconciled) } fn stage_to_flag(stage: GraphicsShaderStage) -> GraphicsShaderStageFlags { @@ -2285,6 +2329,11 @@ 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_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, + }; use crate::core::rhi::{ AttachmentFormats, ColorBlendState, ColorWriteMask, DepthCompareOp, DepthStencilState, GraphicsBindingSpec, GraphicsDynamicState, GraphicsKernelDescriptor, GraphicsPipelineState, @@ -2377,6 +2426,128 @@ 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 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()); + 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 463ac0ecb..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 @@ -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,9 @@ 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, 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, validate_shader_groups, }; use crate::core::{Error, Result}; @@ -130,7 +132,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 +363,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 +1078,17 @@ 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; + let kernel_kind_label = format!("Ray-tracing kernel '{}'", descriptor.label); + // 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 { @@ -1096,45 +1104,70 @@ fn validate_bindings_against_spirv(descriptor: &RayTracingKernelDescriptor<'_>) 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 { - 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)); + 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, + 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 ))); } + refuse_one_binding_slot_two_stages_spell_differently( + &kernel_kind_label, + stage.stage, + binding, + &entry.2, + &info.name, + )?; entry.1 |= stage_flag; } } } - // Every declared binding must exist in the merged SPIR-V map. + 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 + // 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 +1180,30 @@ 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(); + 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 +1235,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], @@ -1803,6 +1834,10 @@ fn drop_sbt(sbt: &Sbt, vulkan_device: &Arc) { #[cfg(test)] mod tests { use super::*; + use crate::core::rhi::spirv_module_rewriting_for_tests::{ + 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, RayTracingShaderGroup, RayTracingShaderStageFlags, RayTracingStage, Texture, @@ -1878,6 +1913,140 @@ 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 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()); + 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" @@ -1981,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/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..9cce9a5d4 --- /dev/null +++ b/sdk/streamlib-python-wheel/tests/ray_tracing_kernel_probes.py @@ -0,0 +1,479 @@ +# 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] + ) + ) + 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_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..18cb0a0c0 --- /dev/null +++ b/sdk/streamlib-python-wheel/tests/test_ray_tracing_kernel.py @@ -0,0 +1,286 @@ +# 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 + + +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