From 309dc1fc08e48f9e4cd06d8f08e3d12b6d585d12 Mon Sep 17 00:00:00 2001 From: CagdasErturk Date: Wed, 19 Aug 2026 16:22:49 +0300 Subject: [PATCH 1/2] feat(rhi): a pipeline that generates its own vertices can cast a shadow `PipelineDesc::depth_only(vertex_spirv, vertex_count)`: no per-vertex stream, no fragment stage, no colour attachment. Until now that shape could not be spelled. The only depth-only constructor hard-sets `vertex_input: Some(layout)`, and the frame contract asserts that an item names geometry exactly when its pipeline declares per-vertex input - so a pipeline that writes its own vertex list and draws an instance stream could reach a colour target and no depth-only one. A renderer of that shape could be seen and could not cast, which is not a limitation anybody chose. `vertex_count` is what the stage generates for one instance, kept for the same reason `Shaders` bundles it: too low renders part of the geometry and too high indexes past the end of the stage's own constant array. Two tests, at two distances from the hardware. The unit test pins that the two depth-only shapes differ in exactly one field, and that it is the one `Item`'s rule reads. `vertex_input` is not a detail of these constructors - it is the whole difference between them, one demanding geometry and one demanding its absence - so an edit that made them differ anywhere else is a compile-time-invisible change to what a caller may pass. The device test builds the pipeline and submits a depth pass whose item names no geometry, with no validation error. It deliberately does not read the depth back, and says so: the only stage available writes z = 0.0, which under reversed-Z is the clear value, so a readback cannot separate a draw that happened from one that did not. Claiming otherwise would be a test that looks like it checked something. Probed twice, both watched red and restored. Swapping `depth_only` for `depth_mesh` makes the frame contract refuse the item, in the words that made this constructor necessary. Giving `depth_only` a vertex input makes the unit test name the refusal it would cause. cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace all green on a real adapter with validation active, exit codes read directly. --- crates/rhi/src/vk/pipeline.rs | 87 +++++++++++++++++++++++++++++++++++ crates/rhi/tests/golden.rs | 73 +++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/crates/rhi/src/vk/pipeline.rs b/crates/rhi/src/vk/pipeline.rs index d6f18002..2a1c48cd 100644 --- a/crates/rhi/src/vk/pipeline.rs +++ b/crates/rhi/src/vk/pipeline.rs @@ -633,6 +633,52 @@ impl<'a> PipelineDesc<'a> { } } + /// A depth-only pipeline whose vertex stage writes its own vertex + /// list: no per-vertex stream, no fragment stage, no color + /// attachment. + /// + /// **The shape that lets a generative pipeline cast a shadow, and + /// until now there was not one.** [`Self::depth_mesh`] hard-sets + /// `vertex_input: Some(layout)`, and the frame contract asserts that + /// an item names geometry exactly when its pipeline declares + /// per-vertex input — so a pipeline that generates its own vertices + /// and draws an instance stream could reach a color target and no + /// depth-only one. A renderer of that shape could therefore be seen + /// and could not cast, which is not a limitation anybody chose. + /// + /// `vertex_count` is what the stage generates for one instance, the + /// same number [`Shaders`] bundles and for the same reason: passed + /// beside the SPIR-V it is a second value that compiles in any + /// combination, and too low renders part of the geometry while too + /// high indexes past the end of the stage's own constant array. + /// + /// Combine it with [`Self::instance_input`] for the instanced case. + /// Depth state must still be declared, exactly as on + /// [`Self::depth_mesh`]: a depth-only pipeline that neither tests nor + /// writes depth does nothing at all, and creation asserts it. + #[must_use] + pub fn depth_only(vertex_spirv: &'a [u8], vertex_count: u32) -> Self { + Self { + vertex_spirv, + // Structurally absent, as on `depth_mesh`: the depth-only + // format is what licenses the emptiness, and creation asserts + // the pairing in both directions. + fragment_spirv: &[], + target_format: TargetFormat::DepthOnly, + vertex_count, + blend: Blend::Opaque, + sampled_bindings: 0, + uniform_block: 0, + // The difference from `depth_mesh`, and the whole of this + // constructor: no per-vertex stream, so the frame contract + // expects an item that names no geometry. + vertex_input: None, + instance_input: None, + depth_state: None, + push_constant_size: 0, + } + } + /// Declare per-instance vertex input, in order. Locations and /// offsets are derived from position; the shader's `location(n)` /// list and this slice describe the same layout or the draw reads @@ -1701,6 +1747,47 @@ mod tests { assert_eq!(desc.address, AddressMode::ClampToEdge); } + /// **The two depth-only shapes differ in exactly one field, and it is + /// the one the frame contract reads.** + /// + /// `Item`'s rule is `pipeline.vertex_input == item.mesh.is_some()`, + /// asserted before any GPU call. So `vertex_input` is not a detail of + /// these two constructors, it is the whole difference between them: + /// one demands geometry and one demands its absence. Everything else + /// they set is identical, and pinning that here is what stops a later + /// edit from making them differ somewhere a caller cannot see. + #[test] + fn the_two_depth_only_shapes_differ_only_in_whether_they_want_geometry() { + const LAYOUT: &[VertexAttribute] = &[VertexAttribute::Vec3]; + let generative = PipelineDesc::depth_only(&[1, 2, 3, 4], 6); + let over_a_mesh = PipelineDesc::depth_mesh(&[1, 2, 3, 4], LAYOUT); + + assert!( + generative.vertex_input.is_none(), + "a generative pipeline declaring per-vertex input would be refused a mesh-less item" + ); + assert!( + over_a_mesh.vertex_input.is_some(), + "a mesh pipeline that declared none would be handed a mesh and ignore it" + ); + assert_eq!(generative.vertex_count, 6, "the stage's own count is kept"); + assert_eq!( + over_a_mesh.vertex_count, 0, + "a mesh pipeline takes its count from the geometry" + ); + for desc in [&generative, &over_a_mesh] { + assert_eq!(desc.target_format, TargetFormat::DepthOnly); + assert!( + desc.fragment_spirv.is_empty(), + "a depth-only pipeline carries no fragment bytes" + ); + assert!( + desc.depth_state.is_none(), + "depth state is the caller's to declare, and creation asserts it was" + ); + } + } + /// Every attribute's size and Vulkan spelling, every arm. Here rather /// than in the device suite for the reason the filter test above /// states: that suite skips wherever the validation layer is absent, diff --git a/crates/rhi/tests/golden.rs b/crates/rhi/tests/golden.rs index 598a469b..8b964e8c 100644 --- a/crates/rhi/tests/golden.rs +++ b/crates/rhi/tests/golden.rs @@ -917,6 +917,79 @@ fn left_half_quad(depth: f32) -> Vec { /// is the depth buffer itself: the quad's clip-space z where it /// covered, the reversed-Z far clear where it did not — a CPU oracle /// over the whole rendered-depth path. +/// **A pipeline that generates its own vertices can now cast.** Until +/// `PipelineDesc::depth_only` existed it could not: the only depth-only +/// constructor hard-set `vertex_input: Some(layout)`, and the frame +/// contract asserts that an item names geometry exactly when its pipeline +/// declares per-vertex input. So a generative renderer could be seen and +/// could not cast a shadow, which is not a limitation anybody chose. +/// +/// What this proves is the seam rather than the picture: the pipeline +/// builds, and a depth pass whose item names **no geometry** is accepted +/// and submitted with no validation error. It deliberately does not read +/// the depth back — the only stage available here writes `z = 0.0`, which +/// under reversed-Z is the clear value, so a readback cannot tell a draw +/// that happened from one that did not. Claiming otherwise would be a +/// test that looks like it checked something. +/// +/// Probed by swapping `depth_only` for `depth_mesh`: the contract refuses +/// the item, naming geometry it has no pipeline for. +#[test] +fn a_generative_pipeline_can_write_into_a_depth_target() { + const SIZE: u32 = 8; + let Some(device) = device_or_skip().expect("device bring-up") else { + return; + }; + let image = match device.create_render_image(&RenderImageDesc::new( + RenderImageKind::Depth, + Extent { + width: SIZE, + height: SIZE, + }, + )) { + Ok(image) => image, + Err(error) => { + assert!( + !strict(), + "RENEW_GOLDEN=1 but the depth render image was refused: {error}" + ); + eprintln!("SKIP: depth render image refused: {error}"); + return; + } + }; + let caster = device + .create_pipeline( + &PipelineDesc::depth_only(builtin::TRIANGLE_VS_SPV, 3) + .depth_state(DepthState::read_write()), + ) + .expect("a generative depth-only pipeline"); + let mut target = device + .create_offscreen_target(Extent { + width: SIZE, + height: SIZE, + }) + .expect("offscreen target"); + + // No mesh: the whole point. The stage writes its own three vertices. + let items = [Item::new(&caster)]; + let depth_ops = Attachment::new(LoadOp::Clear(ClearValue::Depth(0.0)), StoreOp::Store); + // A frame needs at least one surface pass, so the depth pass is + // followed by a bare clear. Nothing reads the depth image back — see + // the note above for why it could not tell us anything if it did. + let color = clear(Color::new(0.0, 0.0, 0.0, 1.0)); + let passes = [ + Pass::render_to(&image, depth_ops, &items), + Pass::new(&color, &[]), + ]; + target + .render(&RenderDesc::new(&passes)) + .expect("a generative caster renders"); + + drop(target); + drop(caster); + assert_no_validation_errors(&device); +} + #[test] fn a_depth_only_pass_writes_depth_a_sampler_reads_back() { const SIZE: u32 = 8; From 8c0b5779f0aee08e605de31dd3d55daa1279a431 Mon Sep 17 00:00:00 2001 From: CagdasErturk Date: Wed, 19 Aug 2026 16:54:45 +0300 Subject: [PATCH 2/2] chore(coverage): re-pin the pipeline exemption the new constructor moved Adding `depth_only` and its doc pushed crates/rhi/src/vk/pipeline.rs down forty-six lines, so the exemption for the DepthUnsupported pipeline-creation arm pointed at covered code while the arm itself pointed at nothing. Content compared rather than the offset trusted, because the checker warns that a block which moved and a block which changed look identical from where it stands: lines 1318-1324 on main and 1364-1370 here are byte-identical, the same `Some(format) => Some(format), None => return Err(DepthUnsupported)` match. Re-pinned; the reason is unchanged, since the arm is still unreachable on every measuring lane's adapter. --- coverage-exemptions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coverage-exemptions.toml b/coverage-exemptions.toml index d58546c7..11f7eb84 100644 --- a/coverage-exemptions.toml +++ b/coverage-exemptions.toml @@ -126,7 +126,7 @@ reason = "The DepthUnsupported refusal for a depth-carrying frame on a depthless [[exempt]] file = "crates/rhi/src/vk/pipeline.rs" -lines = [1320, 1321, 1322] +lines = [1366, 1367, 1368] reason = "The DepthUnsupported refusal for a depth-state pipeline on a depthless adapter - the pipeline-creation twin of the target-side refusal, unreachable for the same reason: the lanes' adapters offer a depth format and the void format query cannot be faulted." [[exempt]]