Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion coverage-exemptions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
91 changes: 91 additions & 0 deletions crates/rhi/src/vk/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions crates/rhi/tests/golden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,79 @@ fn left_half_quad(depth: f32) -> Vec<u8> {
/// 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."
Expand Down
Loading