diff --git a/coverage-exemptions.toml b/coverage-exemptions.toml index 4b5ab2cd..256fc017 100644 --- a/coverage-exemptions.toml +++ b/coverage-exemptions.toml @@ -141,7 +141,7 @@ reason = "The DepthUnsupported refusal for a depth-carrying frame on a depthless [[exempt]] file = "crates/rhi/src/vk/pipeline.rs" -lines = [1414, 1415, 1416] +lines = [1460, 1461, 1462] 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]] diff --git a/crates/rhi/src/vk/pipeline.rs b/crates/rhi/src/vk/pipeline.rs index f35e35dd..c5be98f8 100644 --- a/crates/rhi/src/vk/pipeline.rs +++ b/crates/rhi/src/vk/pipeline.rs @@ -735,6 +735,56 @@ 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, + // Both faces, as on `depth_mesh`: a caster's silhouette is + // the union of what it covers, and culling a face here would + // punch holes in a shadow rather than save work worth having. + facing: Facing::Both, + 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 @@ -1821,6 +1871,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 d73c8636..e562e80c 100644 --- a/crates/rhi/tests/golden.rs +++ b/crates/rhi/tests/golden.rs @@ -1185,6 +1185,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); +} + #[expect( clippy::too_many_lines, reason = "one narrative: a depth-only pass writes, a sampling pass reads it back, and the oracle checks the whole path -- splitting it would hide which half a failure came from. It crossed the bound when deriving the mesh stride from the layout made the constructor wrap."