rendergraph: dynamic-rendering render passes, single-pass compiler, perf

- Add BeginRendering/EndRendering commands with imported/created attachment
  views, view plumbing, and aspect-gated depth/stencil slots
- Derive and validate render-pass spans at compile time; contract spans into
  single DAG nodes before hazard analysis; hoist cross-boundary barriers to
  pass begins with in-pass policy and created-view resolution
- Replace the four-pass ordering pipeline with a single command walk
  (build_command_orderings): span derivation, validation, lifetime-only
  injection, dependency edges, and Kahn batching in one pass; batches are
  ranges into execution_order and RenderPassSpan keeps only begin/end
- Perf: node-collapsed frontier analysis, dense command/resource indexing
  (no SipHash on hot paths), inline barrier storage, exact preallocation,
  lazy Kahn ready heap, scoped barrier fast paths
- Scope buffer and image alias barriers to the incoming resource per the
  Vulkan resource-memory-aliasing rules (availability unscoped, visibility
  scoped to the new owner)
- Add criterion compile benchmarks (two_pass/{no_pass,pass}/per_{20,50,100})
This commit is contained in:
janis 2026-10-29 00:51:52 +01:00
parent 4bf2d874b0
commit afb322fa93
Signed by: janis
SSH key fingerprint: SHA256:bB1qbbqmDXZNT0KKD5c2Dfjg53JGhj7B3CFcLIzSqq8
14 changed files with 5211 additions and 517 deletions

View file

@ -17,6 +17,7 @@ members = [
[dependencies] [dependencies]
bevy_vulkan_render = { path = "crates/bevy_vulkan_render" } bevy_vulkan_render = { path = "crates/bevy_vulkan_render" }
bitflags.workspace = true
[dev-dependencies] [dev-dependencies]
bevy = { workspace = true } bevy = { workspace = true }
@ -28,6 +29,7 @@ debug = true
[workspace.dependencies] [workspace.dependencies]
anyhow = "1.0.89" anyhow = "1.0.89"
criterion = "0.7"
thiserror = "2.0" thiserror = "2.0"
derive_more = { version = "1.0.0", features = ["deref", "debug", "deref_mut"] } derive_more = { version = "1.0.0", features = ["deref", "debug", "deref_mut"] }
@ -46,7 +48,7 @@ rectangle-pack = "0.4.2"
# vk-sync = "0.1.6" # vk-sync = "0.1.6"
arrayvec = "0.7.6" arrayvec = "0.7.6"
tinyvec = "1.8" tinyvec = { version = "1.8", features = ["alloc"] }
indexmap = "2" indexmap = "2"
petgraph = "0.7" petgraph = "0.7"
fixedbitset = "0.5" fixedbitset = "0.5"

View file

@ -571,7 +571,7 @@ pub fn egui_pass(
.values() .values()
.map(|entry| { .map(|entry| {
let texture = texture_handler.get_texture(entry.id).unwrap(); let texture = texture_handler.get_texture(entry.id).unwrap();
let view = Arc::new(ManuallyDrop::into_inner( let view = Arc::new(
texture texture
.create_view( .create_view(
ImageViewDesc::color_2d() ImageViewDesc::color_2d()
@ -579,7 +579,7 @@ pub fn egui_pass(
.with_layer_range(0..1), .with_layer_range(0..1),
) )
.unwrap(), .unwrap(),
)); );
let sampler = samplers.get_sampler_owned(entry.as_sampler_desc()).unwrap(); let sampler = samplers.get_sampler_owned(entry.as_sampler_desc()).unwrap();
(view, sampler) (view, sampler)
}) })
@ -770,7 +770,7 @@ pub fn egui_pass(
let color_attachment = &vk::RenderingAttachmentInfo::default() let color_attachment = &vk::RenderingAttachmentInfo::default()
.image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) .image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.image_view(target.create_view(ImageViewDesc::color_2d())?.raw()) .image_view(ManuallyDrop::new(target.create_view(ImageViewDesc::color_2d())?).raw())
.load_op(vk::AttachmentLoadOp::LOAD) .load_op(vk::AttachmentLoadOp::LOAD)
.store_op(vk::AttachmentStoreOp::STORE); .store_op(vk::AttachmentStoreOp::STORE);

View file

@ -1,4 +1,4 @@
use std::sync::Arc; use std::{mem::ManuallyDrop, sync::Arc};
pub use crate::egui_pass::{egui_pass, egui_pre_pass}; pub use crate::egui_pass::{egui_pass, egui_pre_pass};
@ -329,7 +329,9 @@ impl Wireframe {
let color_attachment = &vk::RenderingAttachmentInfo::default() let color_attachment = &vk::RenderingAttachmentInfo::default()
.image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) .image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.image_view(target.create_view(ImageViewDesc::color_2d())?.raw()) .image_view(
ManuallyDrop::new(target.create_view(ImageViewDesc::color_2d())?).raw(),
)
.load_op(vk::AttachmentLoadOp::LOAD) .load_op(vk::AttachmentLoadOp::LOAD)
.store_op(vk::AttachmentStoreOp::STORE); .store_op(vk::AttachmentStoreOp::STORE);

View file

@ -1,5 +1,4 @@
use std::{ use std::{
mem::ManuallyDrop,
num::NonZero, num::NonZero,
ops::Deref, ops::Deref,
sync::{ sync::{
@ -589,7 +588,7 @@ impl Swapchain {
SwapchainImage { SwapchainImage {
index: idx as u32, index: idx as u32,
swapchain: self, swapchain: self,
view: ManuallyDrop::into_inner(view), view,
acquire, acquire,
release, release,
}, },

View file

@ -6,10 +6,17 @@ edition = "2024"
[dependencies] [dependencies]
ash.workspace = true ash.workspace = true
fixedbitset.workspace = true fixedbitset.workspace = true
tinyvec.workspace = true
renderer_device = { path = "../renderer_device" } renderer_device = { path = "../renderer_device" }
renderer_resources = { path = "../renderer_resources" } renderer_resources = { path = "../renderer_resources" }
ash-formats = { git = "https://github.com/janis-bhm/ash-formats", branch = "main" } ash-formats = { git = "https://github.com/janis-bhm/ash-formats", branch = "main" }
bitflags.workspace = true
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true }
renderer_resources = { path = "../renderer_resources", features = ["testing"] } renderer_resources = { path = "../renderer_resources", features = ["testing"] }
renderer_device = { path = "../renderer_device", features = ["testing"] } renderer_device = { path = "../renderer_device", features = ["testing"] }
[[bench]]
name = "compile"
harness = false

View file

@ -0,0 +1,141 @@
//! These graph shapes compare command-level batching with render-pass span
//! contraction and barrier hoisting. Each has two disjoint buffer access sets
//! joined by a shared-buffer dependency across the passes.
use std::{hint::black_box, time::Duration};
use ash::vk;
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use renderer_rendergraph::{
Buffer, BufferRange, GraphBufferDesc, RenderGraph,
commands::{BeginRendering, Command, EndRendering},
recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap},
resources::{SubresourceScope, SubresourceScopeInner},
};
struct Use {
buffer: Buffer,
write: bool,
}
impl Command for Use {
fn side_effects(&self, command_index: CommandIndex, map: &mut SideEffectMap) {
map.insert(
command_index,
self.buffer.id(),
SubresourceScope {
stage: vk::PipelineStageFlags2::COMPUTE_SHADER,
access: if self.write {
vk::AccessFlags2::SHADER_WRITE
} else {
vk::AccessFlags2::SHADER_READ
},
subresource: SubresourceScopeInner::Buffer(BufferRange::full()),
},
);
}
}
impl RecordedCommand for Use {
fn allowed_in_render_pass(&self) -> bool {
true
}
fn apply(&mut self, _recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
Ok(())
}
}
fn build(n_groups: usize, per: usize, pass: bool) -> RenderGraph {
let mut graph = RenderGraph::new();
let a: Vec<Buffer> = (0..n_groups)
.map(|_| {
graph.create_buffer(GraphBufferDesc {
size: 4096,
..Default::default()
})
})
.collect();
let b: Vec<Buffer> = (0..n_groups)
.map(|_| {
graph.create_buffer(GraphBufferDesc {
size: 4096,
..Default::default()
})
})
.collect();
let shared = graph.create_buffer(GraphBufferDesc {
size: 4096,
..Default::default()
});
if pass {
graph.push(BeginRendering {
color: vec![],
depth: None,
});
}
for _ in 0..per {
for &buffer in &a {
graph.push(Use {
buffer,
write: true,
});
}
}
graph.push(Use {
buffer: shared,
write: true,
});
if pass {
graph.push(EndRendering);
graph.push(BeginRendering {
color: vec![],
depth: None,
});
}
for _ in 0..per {
for &buffer in &b {
graph.push(Use {
buffer,
write: true,
});
}
}
graph.push(Use {
buffer: shared,
write: false,
});
if pass {
graph.push(EndRendering);
}
graph
}
fn compile_benchmarks(criterion: &mut Criterion) {
let mut group = criterion.benchmark_group("compile");
group
.measurement_time(Duration::from_secs(4))
.sample_size(20);
for (pass_name, pass) in [("no_pass", false), ("pass", true)] {
for per in [20, 50, 100] {
group.bench_function(format!("two_pass/{pass_name}/per_{per}"), |bencher| {
bencher.iter_batched(
|| build(50, per, pass),
|graph| {
let compiled = graph.compile().unwrap();
black_box(compiled.barriers().len())
},
BatchSize::LargeInput,
);
});
}
}
group.finish();
}
criterion_group!(benches, compile_benchmarks);
criterion_main!(benches);

View file

@ -1,4 +1,7 @@
use std::{cell::RefCell, sync::Arc};
use ash::vk; use ash::vk;
use renderer_resources::images::{ImageView, ImageViewDesc};
use crate::{ use crate::{
recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap}, recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap},
@ -9,6 +12,243 @@ use crate::{
}, },
}; };
/// Classification used by the compiler to validate render-pass spans.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandKind {
Begin,
End,
Graphics,
Transfer,
Compute,
Other,
}
/// A view used by a render-pass attachment.
#[derive(Debug, Clone)]
pub enum AttachmentView {
/// A view the graph must create after resource resolution, described concretely.
Created(ImageViewDesc),
/// A caller-supplied view over an imported image.
Imported(Arc<ImageView>),
}
/// A color attachment used by dynamic rendering.
#[derive(Clone)]
pub struct ColorAttachment {
pub texture: Texture,
pub view: AttachmentView,
/// Layout used while rendering (must be a legal rendering layout; validated later).
pub layout: vk::ImageLayout,
pub load_op: vk::AttachmentLoadOp,
pub store_op: vk::AttachmentStoreOp,
pub clear_value: vk::ClearValue,
/// Concrete subresource range covered by the view. Created-view sentinels are preserved for
/// compile-time validation.
pub range: vk::ImageSubresourceRange,
}
impl std::fmt::Debug for ColorAttachment {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ColorAttachment")
.field("texture", &self.texture)
.field("view", &self.view)
.field("layout", &self.layout)
.field("load_op", &self.load_op)
.field("store_op", &self.store_op)
.field("clear_value", &"<Vulkan clear value>")
.field("range", &self.range)
.finish()
}
}
impl ColorAttachment {
/// Builds an attachment around a caller-supplied view of an imported image.
pub fn imported(
texture: Texture,
view: Arc<ImageView>,
layout: vk::ImageLayout,
load_op: vk::AttachmentLoadOp,
store_op: vk::AttachmentStoreOp,
clear_value: vk::ClearValue,
) -> Self {
let range = view.subresource_range();
Self {
texture,
view: AttachmentView::Imported(view),
layout,
load_op,
store_op,
clear_value,
range,
}
}
/// Builds an attachment whose view will be created after graph resolution.
pub fn created(
texture: Texture,
desc: ImageViewDesc,
layout: vk::ImageLayout,
load_op: vk::AttachmentLoadOp,
store_op: vk::AttachmentStoreOp,
clear_value: vk::ClearValue,
) -> Self {
let range = image_view_desc_range(&desc);
Self {
texture,
view: AttachmentView::Created(desc),
layout,
load_op,
store_op,
clear_value,
range,
}
}
}
/// A depth/stencil attachment used by dynamic rendering. The view's aspect mask
/// selects which depth/stencil attachment slots receive the view.
#[derive(Debug, Clone)]
pub struct DepthStencilAttachment {
pub texture: Texture,
pub view: AttachmentView,
pub layout: vk::ImageLayout,
pub depth_load_op: vk::AttachmentLoadOp,
pub depth_store_op: vk::AttachmentStoreOp,
pub stencil_load_op: vk::AttachmentLoadOp,
pub stencil_store_op: vk::AttachmentStoreOp,
pub clear_depth: f32,
pub clear_stencil: u32,
pub range: vk::ImageSubresourceRange,
}
impl DepthStencilAttachment {
/// Builds an attachment around a caller-supplied view of an imported image.
pub fn imported(
texture: Texture,
view: Arc<ImageView>,
layout: vk::ImageLayout,
depth_load_op: vk::AttachmentLoadOp,
depth_store_op: vk::AttachmentStoreOp,
stencil_load_op: vk::AttachmentLoadOp,
stencil_store_op: vk::AttachmentStoreOp,
clear_depth: f32,
clear_stencil: u32,
) -> Self {
let range = view.subresource_range();
Self {
texture,
view: AttachmentView::Imported(view),
layout,
depth_load_op,
depth_store_op,
stencil_load_op,
stencil_store_op,
clear_depth,
clear_stencil,
range,
}
}
/// Builds an attachment whose view will be created after graph resolution.
pub fn created(
texture: Texture,
desc: ImageViewDesc,
layout: vk::ImageLayout,
depth_load_op: vk::AttachmentLoadOp,
depth_store_op: vk::AttachmentStoreOp,
stencil_load_op: vk::AttachmentLoadOp,
stencil_store_op: vk::AttachmentStoreOp,
clear_depth: f32,
clear_stencil: u32,
) -> Self {
let range = image_view_desc_range(&desc);
Self {
texture,
view: AttachmentView::Created(desc),
layout,
depth_load_op,
depth_store_op,
stencil_load_op,
stencil_store_op,
clear_depth,
clear_stencil,
range,
}
}
}
fn image_view_desc_range(desc: &ImageViewDesc) -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange::default()
.aspect_mask(desc.aspect)
.base_mip_level(desc.mip_range.start)
.level_count(if desc.mip_range.end == vk::REMAINING_MIP_LEVELS {
vk::REMAINING_MIP_LEVELS
} else {
desc.mip_range.end - desc.mip_range.start
})
.base_array_layer(desc.layer_range.start)
.layer_count(if desc.layer_range.end == vk::REMAINING_ARRAY_LAYERS {
vk::REMAINING_ARRAY_LAYERS
} else {
desc.layer_range.end - desc.layer_range.start
})
}
/// A borrowed summary of a render-pass attachment configuration.
#[derive(Debug, Clone, Copy)]
pub struct RenderPassDesc<'a> {
pub color: &'a [ColorAttachment],
pub depth: Option<&'a DepthStencilAttachment>,
}
/// Begins a dynamic-rendering instance.
#[derive(Debug, Clone)]
pub struct BeginRendering {
pub color: Vec<ColorAttachment>,
pub depth: Option<DepthStencilAttachment>,
}
/// Ends a dynamic-rendering instance.
#[derive(Debug, Clone, Copy, Default)]
pub struct EndRendering;
#[derive(Default)]
struct RenderingScratch {
color_attachments: Vec<vk::RenderingAttachmentInfo<'static>>,
}
thread_local! {
static RENDERING_SCRATCH: RefCell<RenderingScratch> = RefCell::new(RenderingScratch::default());
}
fn resolve_attachment_view(
texture: Texture,
view: &AttachmentView,
recorder: &CommandRecorder<'_>,
) -> Result<(vk::ImageView, vk::Extent2D), RecordError> {
recorder.image(texture.id())?;
let view = match view {
AttachmentView::Created(desc) => recorder.view(texture.id(), desc)?,
AttachmentView::Imported(view) => view.clone(),
};
let range = view.subresource_range();
let image_extent = view.image().extent_2d();
let mip = range.base_mip_level.min(u32::BITS - 1);
let extent = vk::Extent2D {
width: (image_extent.width >> mip).max(1),
height: (image_extent.height >> mip).max(1),
};
Ok((view.raw(), extent))
}
fn rendering_extent(current: Option<vk::Extent2D>, next: vk::Extent2D) -> vk::Extent2D {
current.map_or(next, |current| vk::Extent2D {
width: current.width.min(next.width),
height: current.height.min(next.height),
})
}
/// A recorded operation declares its accesses before compilation and applies /// A recorded operation declares its accesses before compilation and applies
/// itself to a Vulkan command buffer after resource binding. /// itself to a Vulkan command buffer after resource binding.
pub trait Command: RecordedCommand { pub trait Command: RecordedCommand {
@ -59,6 +299,10 @@ impl Command for CopyBuffer {
} }
impl RecordedCommand for CopyBuffer { impl RecordedCommand for CopyBuffer {
fn kind(&self) -> CommandKind {
CommandKind::Transfer
}
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> { fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
let region = vk::BufferCopy2::default() let region = vk::BufferCopy2::default()
.src_offset(self.src_range.offset) .src_offset(self.src_range.offset)
@ -104,6 +348,10 @@ impl Command for ClearTexture {
} }
impl RecordedCommand for ClearTexture { impl RecordedCommand for ClearTexture {
fn kind(&self) -> CommandKind {
CommandKind::Transfer
}
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> { fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
let value = vk::ClearColorValue { let value = vk::ClearColorValue {
float32: self.color, float32: self.color,
@ -219,6 +467,14 @@ impl Command for Draw {
} }
impl RecordedCommand for Draw { impl RecordedCommand for Draw {
fn kind(&self) -> CommandKind {
CommandKind::Graphics
}
fn allowed_in_render_pass(&self) -> bool {
true
}
fn preflight(&self) -> Result<(), RecordError> { fn preflight(&self) -> Result<(), RecordError> {
validate_managed_descriptor_sets(&self.descriptor_sets).map(|_| ()) validate_managed_descriptor_sets(&self.descriptor_sets).map(|_| ())
} }
@ -335,6 +591,160 @@ impl RecordedCommand for Draw {
} }
} }
impl Command for BeginRendering {
fn side_effects(&self, command_index: CommandIndex, map: &mut SideEffectMap) {
for attachment in &self.color {
let mut mask = vk::AccessFlags2::COLOR_ATTACHMENT_WRITE;
if attachment.load_op == vk::AttachmentLoadOp::LOAD {
mask |= vk::AccessFlags2::COLOR_ATTACHMENT_READ;
}
map.insert(
command_index,
attachment.texture.id(),
access(
vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT,
mask,
SubresourceScopeInner::Texture {
range: attachment.range,
layout: attachment.layout,
},
),
);
}
if let Some(attachment) = &self.depth {
let mut mask = vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_WRITE;
if attachment.depth_load_op == vk::AttachmentLoadOp::LOAD
|| attachment.stencil_load_op == vk::AttachmentLoadOp::LOAD
{
mask |= vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_READ;
}
map.insert(
command_index,
attachment.texture.id(),
access(
vk::PipelineStageFlags2::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags2::LATE_FRAGMENT_TESTS,
mask,
SubresourceScopeInner::Texture {
range: attachment.range,
layout: attachment.layout,
},
),
);
}
}
}
impl RecordedCommand for BeginRendering {
fn kind(&self) -> CommandKind {
CommandKind::Begin
}
fn render_pass_desc(&self) -> Option<RenderPassDesc<'_>> {
Some(RenderPassDesc {
color: &self.color,
depth: self.depth.as_ref(),
})
}
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
RENDERING_SCRATCH.with(|scratch| {
let mut scratch = scratch.borrow_mut();
let attachments = &mut scratch.color_attachments;
attachments.clear();
let mut extent = None;
for attachment in &self.color {
let (view, attachment_extent) =
resolve_attachment_view(attachment.texture, &attachment.view, recorder)?;
extent = Some(rendering_extent(extent, attachment_extent));
attachments.push(
vk::RenderingAttachmentInfo::default()
.image_view(view)
.image_layout(attachment.layout)
.load_op(attachment.load_op)
.store_op(attachment.store_op)
.clear_value(attachment.clear_value),
);
}
let mut depth_info = None;
let mut stencil_info = None;
if let Some(attachment) = &self.depth {
let (view, attachment_extent) =
resolve_attachment_view(attachment.texture, &attachment.view, recorder)?;
extent = Some(rendering_extent(extent, attachment_extent));
let clear_value = vk::ClearValue {
depth_stencil: vk::ClearDepthStencilValue {
depth: attachment.clear_depth,
stencil: attachment.clear_stencil,
},
};
if attachment.range.aspect_mask.contains(vk::ImageAspectFlags::DEPTH) {
depth_info = Some(
vk::RenderingAttachmentInfo::default()
.image_view(view)
.image_layout(attachment.layout)
.load_op(attachment.depth_load_op)
.store_op(attachment.depth_store_op)
.clear_value(clear_value),
);
}
if attachment.range.aspect_mask.contains(vk::ImageAspectFlags::STENCIL) {
stencil_info = Some(
vk::RenderingAttachmentInfo::default()
.image_view(view)
.image_layout(attachment.layout)
.load_op(attachment.stencil_load_op)
.store_op(attachment.stencil_store_op)
.clear_value(clear_value),
);
}
}
let extent = extent.unwrap_or(vk::Extent2D {
width: 0,
height: 0,
});
let mut info = vk::RenderingInfo::default()
.render_area(vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent,
})
.layer_count(1)
.color_attachments(attachments);
if let Some(depth) = depth_info.as_ref() {
info = info.depth_attachment(depth);
}
if let Some(stencil) = stencil_info.as_ref() {
info = info.stencil_attachment(stencil);
}
unsafe {
recorder
.device
.cmd_begin_rendering(recorder.command_buffer, &info)
};
Ok(())
})
}
}
impl Command for EndRendering {
fn side_effects(&self, _command_index: CommandIndex, _map: &mut SideEffectMap) {}
}
impl RecordedCommand for EndRendering {
fn kind(&self) -> CommandKind {
CommandKind::End
}
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
unsafe { recorder.device.cmd_end_rendering(recorder.command_buffer) };
Ok(())
}
}
fn validate_managed_descriptor_sets( fn validate_managed_descriptor_sets(
sets: &[GraphDescriptorSet], sets: &[GraphDescriptorSet],
) -> Result<Vec<vk::DescriptorSet>, RecordError> { ) -> Result<Vec<vk::DescriptorSet>, RecordError> {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,250 @@
use ash::vk;
use crate::{
CommandIndex, GraphError, SideEffectMap,
graph::{Kahn, RenderPassSpan},
recorder::BoxedCommand,
resources::{ResourceDesc, SubresourceScope, SubresourceScopeInner},
};
struct SpanBuilder {
spans: Vec<RenderPassSpan>,
current: Option<CommandIndex>,
}
impl SpanBuilder {
fn new() -> Self {
Self {
spans: Vec::new(),
current: None,
}
}
fn in_span(&self) -> bool {
self.current.is_some()
}
fn current_begin(&self) -> Option<CommandIndex> {
self.current
}
fn begin(&mut self, index: CommandIndex) -> Result<(), GraphError> {
if self.current.is_some() {
Err(GraphError::NestedRenderPass)
} else {
self.current = Some(index);
Ok(())
}
}
fn end(&mut self, end: CommandIndex) -> Result<(), GraphError> {
if let Some(begin) = self.current.take() {
self.spans.push(RenderPassSpan { begin, end });
Ok(())
} else {
Err(GraphError::UnbalancedRenderPass)
}
}
fn finish(self) -> Result<Vec<RenderPassSpan>, GraphError> {
if self.current.is_some() {
Err(GraphError::UnbalancedRenderPass)
} else {
Ok(self.spans)
}
}
}
pub(crate) struct Batch {
pub(crate) start: usize,
pub(crate) count: usize,
}
pub(crate) struct CommandOrderings {
pub(crate) execution_order: Vec<CommandIndex>,
pub(crate) batches: Vec<Batch>,
pub(crate) spans: Vec<RenderPassSpan>,
pub(crate) node_of_command: Vec<usize>,
pub(crate) node_representatives: Vec<CommandIndex>,
pub(crate) dependency_edges: Vec<Vec<usize>>,
}
pub(crate) fn build_command_orderings(
resources: &[ResourceDesc],
commands: &[BoxedCommand],
side_effects: &mut SideEffectMap,
) -> Result<CommandOrderings, GraphError> {
let per_command_resource_access = side_effects.command_accesses();
let mut kahn = Kahn::new(commands.len());
let mut access_by_resource: Vec<Vec<(CommandIndex, Vec<SubresourceScope>)>> =
vec![Vec::new(); resources.len()];
let mut span_builder = SpanBuilder::new();
let mut node_of_command = vec![usize::MAX; commands.len()];
let mut node_representatives = Vec::new();
for (index, (command, accesses)) in commands
.iter()
.zip(per_command_resource_access)
.enumerate()
.map(|(i, c)| (CommandIndex::from(i), c))
{
let rep = span_builder.current_begin().unwrap_or(index);
kahn.add_node(rep.index());
// Node ids follow first encounter of each representative in command order.
let node = if node_of_command[rep.index()] == usize::MAX {
let node = node_representatives.len();
node_representatives.push(rep);
node
} else {
node_of_command[rep.index()]
};
node_of_command[index.index()] = node;
for access in accesses {
let Some(resource_access) = access_by_resource.get_mut(access.resource.index()) else {
// `validate_resources` reports invalid ids after dependency analysis.
continue;
};
let priors = resource_access
.iter()
.take_while(|&&(c, _)| c < rep)
.filter(|(_, scopes)| scopes.iter().any(|scope| scope.conflicts(&access.scope)));
for &(prior, _) in priors {
kahn.add_edge(prior.index(), rep.index());
}
if let Some(&mut (last, ref mut scopes)) = resource_access.last_mut()
&& last == rep
{
scopes.push(access.scope);
} else {
resource_access.push((rep, vec![access.scope]))
}
}
match command.kind() {
crate::CommandKind::Begin => {
if let Some(desc) = command.render_pass_desc() {
crate::graph::validate_render_pass_attachments(resources, desc)?;
}
span_builder.begin(index)?;
}
crate::CommandKind::Graphics => {
if !command.allowed_in_render_pass() {
return Err(GraphError::CommandNotAllowedInRenderPass(index));
}
if !span_builder.in_span() {
return Err(GraphError::DrawOutsideRenderPass(index));
}
}
crate::CommandKind::Transfer
| crate::CommandKind::Compute
| crate::CommandKind::Other => {
if span_builder.in_span() && !command.allowed_in_render_pass() {
return Err(GraphError::CommandNotAllowedInRenderPass(index));
}
}
crate::CommandKind::End => {
span_builder.end(index)?;
}
}
}
// spans are ordered by beginning command
let spans = span_builder.finish()?;
for span in &spans {
// Snapshot accesses intentionally predate lifetime-only attachment injection:
// these accesses extend lifetimes but must not add hazard edges.
if let Some(desc) = commands[span.begin.index()].render_pass_desc() {
for attachment in desc.color {
side_effects.insert_lifetime_only(
span.end,
attachment.texture.id(),
SubresourceScope {
stage: vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT,
access: vk::AccessFlags2::COLOR_ATTACHMENT_WRITE,
subresource: SubresourceScopeInner::Texture {
range: attachment.range,
layout: attachment.layout,
},
},
);
}
if let Some(attachment) = desc.depth {
side_effects.insert_lifetime_only(
span.end,
attachment.texture.id(),
SubresourceScope {
stage: vk::PipelineStageFlags2::EARLY_FRAGMENT_TESTS
| vk::PipelineStageFlags2::LATE_FRAGMENT_TESTS,
access: vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_WRITE,
subresource: SubresourceScopeInner::Texture {
range: attachment.range,
layout: attachment.layout,
},
},
);
}
}
}
let mut execution_order: Vec<CommandIndex> = Vec::with_capacity(commands.len());
let mut batches = Vec::<Batch>::new();
let mut span_iter = spans.iter().peekable();
while !kahn.is_complete() {
let first = execution_order.len();
for cmd in kahn.iter_batch().map(CommandIndex::from) {
use core::cmp::Ordering;
match span_iter.peek().map(|s| s.begin.cmp(&cmd)) {
Some(Ordering::Equal) => {
let span = span_iter.next().unwrap();
execution_order
.extend((span.begin.index()..=span.end.index()).map(CommandIndex::from));
}
_ => execution_order.push(cmd),
}
}
let count = execution_order.len() - first;
if count == 0 {
return Err(GraphError::DependencyCycle);
}
for command in &execution_order[first..] {
kahn.complete(command.index());
}
batches.push(Batch {
start: first,
count,
});
}
let mut dependency_edges = vec![Vec::new(); node_representatives.len()];
for (node, representative) in node_representatives.iter().enumerate() {
let representative = representative.index();
let node_edges = &mut dependency_edges[node];
for &edge in &kahn.edges[representative] {
node_edges.push(node_of_command[edge]);
}
}
Ok(CommandOrderings {
execution_order,
batches,
spans,
node_of_command,
node_representatives,
dependency_edges,
})
}

View file

@ -3,12 +3,18 @@
pub mod commands; pub mod commands;
pub mod graph; pub mod graph;
mod graph_compiler;
pub mod recorder; pub mod recorder;
pub mod resources; pub mod resources;
pub use commands::{
AttachmentView, BeginRendering, ColorAttachment, CommandKind, DepthStencilAttachment,
EndRendering, RenderPassDesc,
};
pub use graph::{ pub use graph::{
CompiledGraph, GraphError, HappensBefore, MemoryBarrier, Placement, RenderGraph, BarrierOrigin, CompiledGraph, GraphError, HappensBefore, MemoryBarrier, Placement, RenderGraph,
ResourceInterference, ResourceLifetime, ResourceUse, aliasing_permitted, RenderPassSpan, ResourceFrontiers, ResourceInterference, ResourceLifetime, ResourceUse,
aliasing_permitted,
}; };
pub use recorder::{CommandIndex, CommandList, CommandRecorder, SideEffectMap}; pub use recorder::{CommandIndex, CommandList, CommandRecorder, SideEffectMap};
pub use resources::{ pub use resources::{

View file

@ -1,6 +1,11 @@
use std::{cell::RefCell, collections::BTreeMap}; use std::{
cell::RefCell,
collections::{BTreeMap, BTreeSet, HashMap},
sync::Arc,
};
use ash::vk; use ash::vk;
use renderer_resources::images::{ImageView, ImageViewDesc};
use crate::resources::{ use crate::resources::{
BufferRange, ResourceId, Subresource, SubresourceScope, SubresourceScopeInner, BufferRange, ResourceId, Subresource, SubresourceScope, SubresourceScopeInner,
@ -38,6 +43,7 @@ pub struct ResourceAccess {
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct SideEffectMap { pub struct SideEffectMap {
accesses: Vec<Vec<ResourceAccess>>, accesses: Vec<Vec<ResourceAccess>>,
lifetime_only: BTreeSet<(usize, ResourceId)>,
} }
impl SideEffectMap { impl SideEffectMap {
@ -81,6 +87,23 @@ impl SideEffectMap {
accesses.push(ResourceAccess { resource, scope }); accesses.push(ResourceAccess { resource, scope });
} }
/// Record an access used only to extend a resource's lifetime through a command.
pub fn insert_lifetime_only(
&mut self,
command_index: CommandIndex,
resource: ResourceId,
scope: SubresourceScope,
) {
self.insert(command_index, resource, scope);
self.lifetime_only.insert((command_index.index(), resource));
}
/// Whether this command/resource access exists only for lifetime analysis.
pub fn is_lifetime_only(&self, command_index: CommandIndex, resource: ResourceId) -> bool {
self.lifetime_only
.contains(&(command_index.index(), resource))
}
pub fn accesses(&self, command_index: CommandIndex) -> &[ResourceAccess] { pub fn accesses(&self, command_index: CommandIndex) -> &[ResourceAccess] {
self.accesses self.accesses
.get(command_index.0) .get(command_index.0)
@ -92,10 +115,12 @@ impl SideEffectMap {
} }
} }
pub type BoxedCommand = Box<dyn RecordedCommand>;
/// A safe, type-erased temporary command buffer. Each command is owned until /// A safe, type-erased temporary command buffer. Each command is owned until
/// compilation/recording, while its access declarations are stored separately. /// compilation/recording, while its access declarations are stored separately.
pub struct CommandList { pub struct CommandList {
pub(crate) commands: Vec<Box<dyn RecordedCommand>>, pub(crate) commands: Vec<BoxedCommand>,
pub side_effects: SideEffectMap, pub side_effects: SideEffectMap,
} }
@ -105,6 +130,18 @@ pub trait RecordedCommand {
Ok(()) Ok(())
} }
fn kind(&self) -> crate::commands::CommandKind {
crate::commands::CommandKind::Other
}
fn allowed_in_render_pass(&self) -> bool {
false
}
fn render_pass_desc(&self) -> Option<crate::commands::RenderPassDesc<'_>> {
None
}
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError>; fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError>;
} }
@ -149,6 +186,7 @@ impl Default for CommandList {
pub enum RecordError { pub enum RecordError {
MissingBuffer(ResourceId), MissingBuffer(ResourceId),
MissingImage(ResourceId), MissingImage(ResourceId),
MissingView(ResourceId),
UnresolvedCreatedBuffer(ResourceId), UnresolvedCreatedBuffer(ResourceId),
UnresolvedCreatedImage(ResourceId), UnresolvedCreatedImage(ResourceId),
DescriptorSet(String), DescriptorSet(String),
@ -161,6 +199,7 @@ pub struct CommandRecorder<'a> {
pub command_buffer: vk::CommandBuffer, pub command_buffer: vk::CommandBuffer,
pub buffers: BTreeMap<ResourceId, vk::Buffer>, pub buffers: BTreeMap<ResourceId, vk::Buffer>,
pub images: BTreeMap<ResourceId, vk::Image>, pub images: BTreeMap<ResourceId, vk::Image>,
pub views: HashMap<(ResourceId, ImageViewDesc), Arc<ImageView>>,
} }
#[derive(Default)] #[derive(Default)]
@ -181,6 +220,7 @@ impl<'a> CommandRecorder<'a> {
command_buffer, command_buffer,
buffers: BTreeMap::new(), buffers: BTreeMap::new(),
images: BTreeMap::new(), images: BTreeMap::new(),
views: HashMap::new(),
} }
} }
@ -198,6 +238,18 @@ impl<'a> CommandRecorder<'a> {
.ok_or(RecordError::MissingImage(id)) .ok_or(RecordError::MissingImage(id))
} }
/// Resolve a cached image view by graph resource and concrete view description.
pub fn view(
&self,
id: ResourceId,
desc: &ImageViewDesc,
) -> Result<Arc<ImageView>, RecordError> {
self.views
.get(&(id, image_view_desc_without_name(desc)))
.cloned()
.ok_or(RecordError::MissingView(id))
}
pub fn pipeline_barrier(&self, barrier: &super::graph::PipelineBarrier) { pub fn pipeline_barrier(&self, barrier: &super::graph::PipelineBarrier) {
BARRIER_SCRATCH.with(|scratch| { BARRIER_SCRATCH.with(|scratch| {
let mut scratch = scratch.borrow_mut(); let mut scratch = scratch.borrow_mut();
@ -288,6 +340,19 @@ impl<'a> CommandRecorder<'a> {
} }
} }
fn image_view_desc_without_name(desc: &ImageViewDesc) -> ImageViewDesc {
ImageViewDesc {
name: None,
flags: desc.flags,
kind: desc.kind,
format: desc.format,
components: desc.components,
aspect: desc.aspect,
mip_range: desc.mip_range,
layer_range: desc.layer_range,
}
}
pub(crate) fn scope_range(scope: SubresourceScope) -> Subresource { pub(crate) fn scope_range(scope: SubresourceScope) -> Subresource {
match scope.subresource { match scope.subresource {
SubresourceScopeInner::Buffer(range) => Subresource::Buffer(range), SubresourceScopeInner::Buffer(range) => Subresource::Buffer(range),
@ -337,7 +402,7 @@ mod tests {
dst_stage: vk::PipelineStageFlags2::FRAGMENT_SHADER, dst_stage: vk::PipelineStageFlags2::FRAGMENT_SHADER,
dst_access: vk::AccessFlags2::SHADER_READ, dst_access: vk::AccessFlags2::SHADER_READ,
}], }],
resources: Vec::new(), resources: tinyvec::TinyVec::new(),
}; };
MEMORY_BARRIER_COUNT.store(0, Ordering::SeqCst); MEMORY_BARRIER_COUNT.store(0, Ordering::SeqCst);
MEMORY_BARRIER_SRC_STAGE.store(0, Ordering::SeqCst); MEMORY_BARRIER_SRC_STAGE.store(0, Ordering::SeqCst);

View file

@ -6,16 +6,19 @@ use renderer_device::{
testing::TestVulkan, testing::TestVulkan,
}; };
use renderer_rendergraph::{ use renderer_rendergraph::{
RenderGraph, BeginRendering, ColorAttachment, EndRendering, RenderGraph,
commands::Command,
commands::CopyBuffer, commands::CopyBuffer,
commands::{ClearTexture, Command},
recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap}, recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap},
resources::{ resources::{
BufferRange, GraphBufferDesc, GraphTextureDesc, ResourceId, ResourceState, BufferRange, GraphBufferDesc, GraphTextureDesc, ResourceId, ResourceState,
SubresourceScope, SubresourceScopeInner, SubresourceScope, SubresourceScopeInner,
}, },
}; };
use renderer_resources::buffers::{Buffer, BufferDesc}; use renderer_resources::{
buffers::{Buffer, BufferDesc},
images::ImageViewDesc,
};
struct ScopedNoop(Vec<(ResourceId, SubresourceScope)>); struct ScopedNoop(Vec<(ResourceId, SubresourceScope)>);
@ -33,6 +36,26 @@ impl RecordedCommand for ScopedNoop {
} }
} }
struct PassNoop(Vec<(ResourceId, SubresourceScope)>);
impl Command for PassNoop {
fn side_effects(&self, command_index: CommandIndex, map: &mut SideEffectMap) {
for &(resource, scope) in &self.0 {
map.insert(command_index, resource, scope);
}
}
}
impl RecordedCommand for PassNoop {
fn allowed_in_render_pass(&self) -> bool {
true
}
fn apply(&mut self, _recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
Ok(())
}
}
fn buffer_scope() -> SubresourceScope { fn buffer_scope() -> SubresourceScope {
SubresourceScope { SubresourceScope {
stage: vk::PipelineStageFlags2::COPY, stage: vk::PipelineStageFlags2::COPY,
@ -61,6 +84,47 @@ fn image_scope() -> SubresourceScope {
} }
} }
fn pass_buffer_read_scope() -> SubresourceScope {
SubresourceScope {
stage: vk::PipelineStageFlags2::VERTEX_SHADER,
access: vk::AccessFlags2::SHADER_READ,
subresource: SubresourceScopeInner::Buffer(BufferRange {
offset: 0,
size: 256,
}),
}
}
fn pass_texture_read_scope() -> SubresourceScope {
SubresourceScope {
stage: vk::PipelineStageFlags2::FRAGMENT_SHADER,
access: vk::AccessFlags2::SHADER_SAMPLED_READ,
subresource: SubresourceScopeInner::Texture {
range: vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
},
layout: vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
},
}
}
fn supports_render_pass_test(test: &TestVulkan) -> bool {
let features = test.device().enabled_features().core13;
if features.synchronization2 != vk::TRUE {
eprintln!("Skipping graph render pass: synchronization2 is unsupported");
return false;
}
if features.dynamic_rendering != vk::TRUE {
eprintln!("Skipping graph render pass: dynamic_rendering is unsupported");
return false;
}
true
}
fn submit_graph(test: &TestVulkan, graph: &mut renderer_rendergraph::CompiledGraph) { fn submit_graph(test: &TestVulkan, graph: &mut renderer_rendergraph::CompiledGraph) {
let device = test.device(); let device = test.device();
let pool_info = vk::CommandPoolCreateInfo::default() let pool_info = vk::CommandPoolCreateInfo::default()
@ -179,7 +243,7 @@ fn imported_buffer_copy_records_under_validation() {
} }
#[test] #[test]
fn resolved_buffers_alias_and_submit_with_memory_barrier() { fn resolved_buffers_alias_and_submit_with_scoped_barrier() {
let Some(test) = TestVulkan::acquire() else { let Some(test) = TestVulkan::acquire() else {
return; return;
}; };
@ -233,9 +297,21 @@ fn resolved_buffers_alias_and_submit_with_memory_barrier() {
assert_eq!(first_placement.pool, second_placement.pool); assert_eq!(first_placement.pool, second_placement.pool);
assert_eq!(first_placement.offset, second_placement.offset); assert_eq!(first_placement.offset, second_placement.offset);
assert!(first_placement.aliased && second_placement.aliased); assert!(first_placement.aliased && second_placement.aliased);
assert!(compiled.barriers().iter().any(|barrier| { let alias_barrier = compiled
barrier.before_command == CommandIndex(1) && !barrier.memory_barriers.is_empty() .barriers()
})); .iter()
.find(|barrier| barrier.before_command == CommandIndex(1))
.expect("incoming buffer's first use has an alias barrier");
assert!(alias_barrier.memory_barriers.is_empty());
let incoming = alias_barrier
.resources
.iter()
.find(|resource| resource.id == second.id())
.expect("alias barrier is scoped to the incoming buffer");
assert_eq!(incoming.src_stage, vk::PipelineStageFlags2::COPY);
assert_eq!(incoming.src_access, vk::AccessFlags2::TRANSFER_WRITE);
assert_eq!(incoming.dst_stage, vk::PipelineStageFlags2::COPY);
assert_eq!(incoming.dst_access, vk::AccessFlags2::TRANSFER_WRITE);
submit_graph(&test, &mut compiled); submit_graph(&test, &mut compiled);
test.assert_no_validation_errors(); test.assert_no_validation_errors();
@ -328,3 +404,149 @@ fn resolved_images_alias_and_submit_with_undefined_transition() {
.expect("wait before shared allocation teardown"); .expect("wait before shared allocation teardown");
test.assert_no_validation_errors(); test.assert_no_validation_errors();
} }
#[test]
fn created_render_pass_records_submits_and_validates() {
let Some(test) = TestVulkan::acquire() else {
return;
};
if !supports_render_pass_test(&test) {
return;
}
let device = test.device();
let make_buffer = || {
Arc::new(
Buffer::new(
device.clone(),
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::TRANSFER_DST,
queue_families: QueueFlags::empty(),
mem_location: MemoryLocation::CpuToGpu,
..Default::default()
},
)
.expect("create render-pass test buffer"),
)
};
let producer_source = make_buffer();
let producer_destination = make_buffer();
let unrelated_destination = make_buffer();
let mut graph = RenderGraph::new();
let target = graph.create_texture(GraphTextureDesc {
format: vk::Format::R8G8B8A8_UNORM,
extent: vk::Extent3D {
width: 16,
height: 16,
depth: 1,
},
..Default::default()
});
let producer_source = graph
.import_buffer(producer_source, ResourceState::default())
.expect("import producer source");
let producer_destination = graph
.import_buffer(producer_destination, ResourceState::default())
.expect("import producer destination");
let unrelated_destination = graph
.import_buffer(unrelated_destination, ResourceState::default())
.expect("import unrelated destination");
let buffer_range = BufferRange {
offset: 0,
size: 256,
};
let image_range = vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
};
graph.push(ClearTexture {
texture: target,
range: image_range,
layout: vk::ImageLayout::TRANSFER_DST_OPTIMAL,
color: [0.1, 0.2, 0.3, 1.0],
});
let producer = graph.push(CopyBuffer {
src: producer_source,
src_range: buffer_range,
dst: producer_destination,
dst_range: buffer_range,
});
let begin = graph.push(BeginRendering {
color: vec![ColorAttachment::created(
target,
ImageViewDesc::color_2d(),
vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL,
vk::AttachmentLoadOp::LOAD,
vk::AttachmentStoreOp::STORE,
vk::ClearValue::default(),
)],
depth: None,
});
let interior = graph.push(PassNoop(vec![(
producer_destination.id(),
pass_buffer_read_scope(),
)]));
let end = graph.push(EndRendering);
let post_end = graph.push(ScopedNoop(vec![(target.id(), pass_texture_read_scope())]));
let unrelated = graph.push(CopyBuffer {
src: producer_destination,
src_range: buffer_range,
dst: unrelated_destination,
dst_range: buffer_range,
});
let mut compiled = graph.compile().expect("compile render-pass graph");
let execution_order = compiled.execution_order();
let position = |command| {
execution_order
.iter()
.position(|&item| item == command)
.unwrap()
};
assert!(position(begin) < position(interior));
assert!(position(interior) < position(end));
assert!(position(unrelated) > position(end));
let begin_barrier = compiled
.barriers()
.iter()
.find(|barrier| barrier.before_command == begin)
.expect("incoming barriers are hoisted before BeginRendering");
assert!(begin_barrier.resources.iter().any(|resource| {
resource.id == target.id()
&& resource.old_layout == Some(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
&& resource.new_layout == Some(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
}));
assert!(begin_barrier.resources.iter().any(|resource| {
resource.id == producer_destination.id()
&& resource.origin == renderer_rendergraph::BarrierOrigin::Command(producer)
}));
assert!(compiled.barriers().iter().any(|barrier| {
barrier.before_command == post_end
&& barrier.resources.iter().any(|resource| {
resource.id == target.id()
&& resource.old_layout == Some(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
&& resource.new_layout == Some(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
})
}));
compiled
.resolve(device)
.expect("resolve created attachment view");
assert!(
compiled
.created_view(target.id(), &ImageViewDesc::color_2d())
.is_some()
);
submit_graph(&test, &mut compiled);
test.assert_no_validation_errors();
drop(compiled);
device
.wait_idle()
.expect("wait before render target teardown");
test.assert_no_validation_errors();
}

View file

@ -1,6 +1,5 @@
use std::{ use std::{
borrow::Cow, borrow::Cow,
mem::ManuallyDrop,
ops::{Add, Sub}, ops::{Add, Sub},
sync::Arc, sync::Arc,
}; };
@ -555,10 +554,7 @@ impl Image {
// } // }
// } // }
pub fn create_view( pub fn create_view(self: &Arc<Self>, mut desc: ImageViewDesc) -> crate::Result<ImageView> {
self: &Arc<Self>,
mut desc: ImageViewDesc,
) -> crate::Result<ManuallyDrop<ImageView>> {
// validate // validate
if !view_kind_compatible(self.desc.kind, desc.kind) { if !view_kind_compatible(self.desc.kind, desc.kind) {
tracing::error!( tracing::error!(
@ -618,7 +614,7 @@ impl Image {
let device = self.image.device(); let device = self.image.device();
let view = unsafe { device.raw.create_image_view(&create_info, None)? }; let view = unsafe { device.raw.create_image_view(&create_info, None)? };
Ok(ManuallyDrop::new(ImageView { Ok(ImageView {
view: ImageViewHandle::Owned(DeviceObject::new_debug_named( view: ImageViewHandle::Owned(DeviceObject::new_debug_named(
device.clone(), device.clone(),
view, view,
@ -626,7 +622,7 @@ impl Image {
)), )),
desc, desc,
image: self.clone(), image: self.clone(),
})) })
} }
} }
@ -1189,26 +1185,6 @@ fn image_view_subresource_range(desc: &ImageViewDesc) -> vk::ImageSubresourceRan
.layer_count(desc.layer_range.len()) .layer_count(desc.layer_range.len())
} }
#[cfg(test)]
mod descriptor_range_tests {
use super::*;
#[test]
fn normalized_range_accessor_resolves_remaining_sentinels() {
let mut desc = ImageViewDesc::color_2d()
.with_mip_range(1..)
.with_layer_range(2..);
desc.mip_range.set_max_end(6);
desc.layer_range.set_max_end(9);
let range = image_view_subresource_range(&desc);
assert_eq!(range.aspect_mask, vk::ImageAspectFlags::COLOR);
assert_eq!(range.base_mip_level, 1);
assert_eq!(range.level_count, 5);
assert_eq!(range.base_array_layer, 2);
assert_eq!(range.layer_count, 7);
}
}
pub struct QueueOwnership { pub struct QueueOwnership {
pub src: u32, pub src: u32,
pub dst: u32, pub dst: u32,
@ -1336,3 +1312,23 @@ impl ImageSubresourceExt for vk::ImageSubresource {
MipRange::single(self.mip_level) MipRange::single(self.mip_level)
} }
} }
#[cfg(test)]
mod descriptor_range_tests {
use super::*;
#[test]
fn normalized_range_accessor_resolves_remaining_sentinels() {
let mut desc = ImageViewDesc::color_2d()
.with_mip_range(1..)
.with_layer_range(2..);
desc.mip_range.set_max_end(6);
desc.layer_range.set_max_end(9);
let range = image_view_subresource_range(&desc);
assert_eq!(range.aspect_mask, vk::ImageAspectFlags::COLOR);
assert_eq!(range.base_mip_level, 1);
assert_eq!(range.level_count, 5);
assert_eq!(range.base_array_layer, 2);
assert_eq!(range.layer_count, 7);
}
}

View file

@ -0,0 +1,575 @@
# Plan: Render-Pass Support (`BeginRendering` / `EndRendering`) in `renderer_rendergraph`
Status: proposed — rev 5 (rev-1 correctness, rev-2 performance, rev-3 adversarial, and rev-4 re-review applied; rev-4 verdict was REJECT, findings resolved here)
Date: 2026-10-27
Scope: `crates/renderer_rendergraph`
## Goal
Add dynamic-rendering render-pass support to the rendergraph: `BeginRendering`
and `EndRendering` commands that bracket a span of graph commands, with the
compiler guaranteeing that every command recorded between the two is emitted
inside the rendering instance — i.e. commands that must be in a rendergraph
pass are ordered by the begin/end rendering commands, and no foreign command
may be scheduled between them.
## Current state (verified against code)
- Commands implement `Command` (declares `side_effects` → `SubresourceScope`
accesses) + `RecordedCommand` (`preflight`/`apply`), and are pushed via
`RenderGraph::push` into `CommandList` (`commands.rs`, `recorder.rs`).
- `GraphCompiler::dependencies()` (`graph.rs:820-863`) builds hazard edges
purely from resource conflicts; `topo_batches_and_dependencies()`
(`graph.rs:870-889`) Kahn-sorts commands into `batches`;
`record_commands` (`graph.rs:2500-2554`) flattens batches, emitting barriers
(keyed `before_command` via `grouped_barrier_indices`) immediately before
each command.
- There is no notion of a render pass. Nothing prevents the flattener from
emitting an unrelated command between two commands the user recorded
adjacently: Kahn batching interleaves hazard-free commands freely.
- `CommandRecorder` (`recorder.rs:159-164`) maps `ResourceId → vk::Buffer` /
`vk::Image` only; there is no image-view plumbing. Descriptor imports read
`Arc<ImageView>` bindings at import time (`graph.rs:507-513`) but retain
only the underlying image (`StagedImportResource::Texture(view.image().clone())`,
`graph.rs:524-532`) — attachment views are new plumbing, not an existing
pattern to reuse.
- `CommandList` stores type-erased `Box<dyn RecordedCommand>` (`recorder.rs:97-109`);
the `Command` trait exposes only `side_effects` (`commands.rs:14-16`), so
there is currently no way to classify a command or introspect its render-pass
descriptor.
- The graph documents a single-queue submission contract (`graph.rs:8-10`);
ordinary barriers set `queue_transfer: None` (`graph.rs:972-982, 1001-1012`).
- Compile-time cost profile (recomputed independently): `dependencies()` is
`O(Σ_r u_r²)` pair checks (plus a per-pair scope cross-product factor);
`HappensBefore::from_dag` allocates one `FixedBitSet` row per node
(`graph.rs:213-217`, `O(N²/64)` memory) and unions per edge
(`O(E·N/64)` time). `ResourceInterference::from_resource_uses` **already**
computes terminal/initial frontier sets once per resource
(`graph.rs:1348-1356`); the remaining recomputations are in
`first_fit_placement` (`graph.rs:1642-1649`), the `overlapping` loop in
`plan_placements` (`graph.rs:2384-2399`), and `build_alias_barriers`
(`graph.rs:1088, 1109-1117`). Contraction does not remove those; see D8.
- The crate is not yet referenced by `renderer`/`bevy_vulkan_render`; the
current per-frame path is the legacy `renderer::render_graph`. If this
crate replaces it, `compile()` becomes per-frame, so compile cost matters.
- `dynamic_rendering` and `synchronization2` are requested/enabled
(`crates/renderer/src/lib.rs:352-357`, `crates/renderer_device/src/testing.rs:166-172`).
- **Pre-existing defect (reproduced):** `build_barriers` records only the last
*conflicting* producer's layout (`graph.rs:951-982`) and never chains
`old_layout` through earlier barriers. Two same-layout readers of one
producer yield two image barriers both claiming `oldLayout = producerLayout`;
the second is stale (Vulkan requires `oldLayout` be the current layout or
`UNDEFINED`). Fixing this generally (per-subresource current-layout map) is
part of Task 3 — the plan's own tests hit it.
- `Image::create_view` returns `Result<ManuallyDrop<ImageView>>`
(`renderer_resources/src/images.rs:558-621`); storing it directly leaks the
`vk::ImageView` and the `Arc<Image>` (the existing renderer calls `.raw()`
on the `ManuallyDrop`, `crates/renderer/src/rendering/mod.rs:332`, which
leaks today). New code must use `ManuallyDrop::into_inner`.
- `ImageView` holds `Arc<Image>` (`images.rs:1123-1127`), so a view cache
keeps its image alive; drop-order interaction with `CompiledGraph` is benign
provided the `ManuallyDrop` is unwrapped.
## Design decisions
### D1 — Dynamic rendering, not `VkRenderPass`
New commands `BeginRendering` / `EndRendering` wrapping `vkCmdBeginRendering`
/ `vkCmdEndRendering` (Vulkan 1.3 `dynamic_rendering`). Attachments are graph
`Texture` handles plus a view specification (see D9 for the created-texture
problem).
### D2 — The pass span is an atomic scheduling unit, contracted *before* hazard-edge construction
The compiler contracts the `[begin, end]` command span into a single DAG node
("pass node"). This is the mechanism that satisfies the ordering contract:
- No foreign command can ever be scheduled inside the span (Kahn batching
would otherwise interleave hazard-free commands into it).
- The whole span is ordered relative to everything else by the begin/end
hazard edges.
Contraction happens **before** `dependencies()` builds hazard edges. This
removes the `O(Σ u_r²)` interior-draw edge construction (edges that would be
discarded as self-edges anyway) and shrinks the `O(N²/64)` closure memory/time
by roughly `(N_c/N)²`.
The contraction mapping used by every consumer:
- **Type-safe node indexing:** use `NodeId(NonZeroU32)` newtypes and
`Option<NodeId>` rather than raw `u32` sentinels mixed into `CommandIndex`
spaces. To minimize churn, keep `batches: Vec<Vec<CommandIndex>>` with the
`BeginRendering` command index as the documented pass-node representative;
`spans: Vec<Span>` on `CompiledGraph` records `{ begin, end, interior }`
for expansion. Document the semantic change to the public `batches()` /
`dependency_edges()` accessors. Closure rows are indexed `0..N_c`.
- **HappensBefore is span-aware internally:** `HappensBefore` (public,
`lib.rs:10`) stores the command→node map and span slices and translates in
`contains`/`happens_before` (`graph.rs:244-251`), so command-space queries
keep working and interior indices never silently return `false` from a
node-space row lookup.
- **Intra-span record order is NOT a happens-before fact.** Interior commands
map to the pass node and same-node uses are treated as **unordered** by the
closure used for aliasing/interference. This is a memory-correctness
requirement, not a style choice: if intra-span record order were visible to
`resources_alias_in_order`, a created attachment `A` (single use at `Begin`)
and a created storage image `B` used only by an interior command would be
judged ordered, `first_fit_placement` could place `B` over `A`'s memory, and
`build_alias_barriers` would either emit no barrier or an illegal in-pass
`UNDEFINED → layout` transition — discarding a live render target mid-pass.
Intra-span record order is needed only by `build_barriers` (which iterates
execution order directly) and by recording; it must not leak into the
aliasing/interference closure.
- **Attachment liveness is modeled through `End`.** `EndRendering` carries
synthetic accesses (the attachment layout, `COLOR_ATTACHMENT_WRITE` /
`DEPTH_STENCIL_ATTACHMENT_WRITE` at the attachment stage) used **only** for
lifetime/alias analysis — excluded from barrier generation (the begin's
merged scope already covers synchronization) and from usage derivation.
This makes `terminal_uses(attachment) == End`, so an attachment is never
judged dead before the store.
- **Barrier analysis:** `build_barriers` walks scheduled command indices and
reads only `accesses[command_index]` (`graph.rs:930-934`). Barrier analysis
runs over the *uncontracted* access list in **execution order** (see D6),
with each interior command's scheduling position mapped to the pass node.
This preserves, e.g., the barrier for an outside producer consumed only by
an interior `Draw`.
- **Lifetimes:** `bind_lifetimes` indexes `batch_for_command` for every
original access (`graph.rs:1258-1275`); interior commands map to the pass
node's batch index so they are never left at `usize::MAX`. Placement uses
command-level interference, not batch intervals, so placement quality is
unaffected; external consumers of `resource_lifetimes()` get coarser
intervals for interior commands (documented).
- **Self-edges:** intra-span hazard pairs are skipped during edge
construction (they become intra-span execution-order constraints handled by
`build_barriers`, not DAG edges). Note `dependencies()` only adds
prior→current edges (`graph.rs:825-861`), so the DAG is acyclic regardless;
only a literal self-edge would trip `Kahn`, and skipping intra-span pairs
prevents exactly that.
### D3 — Access declaration: one merged scope per attachment; interior accesses preserved
- `BeginRendering` declares **one access per attachment** with
`access = COLOR_ATTACHMENT_WRITE | (LOAD ? COLOR_ATTACHMENT_READ : 0)` at
the same stage (`COLOR_ATTACHMENT_OUTPUT` / `EARLY|LATE_FRAGMENT_TESTS`),
subresource range from the view (concrete range rules in D9). Declaring
read and write as two separate accesses is a blocker:
`SideEffectMap::insert` deliberately keeps opposite-polarity scopes separate
(`recorder.rs:55-82`), and `build_barriers` then emits **two**
`ImageMemoryBarrier2` entries for the same image/range/layout in one
`vkCmdPipelineBarrier2` (reproduced). A single merged scope is handled
correctly by `SideEffectMap`, `conflicts`, `derive_image_usage`
(`graph.rs:2696-2709`), and the barrier builder.
- `EndRendering`'s synthetic accesses (D2) are tagged so barrier generation
and usage derivation skip them.
- Interior command accesses are *not* replaced by the begin declarations;
they remain in the access list (see D2 barrier analysis) so the compiler
can emit barriers for outside producers consumed inside the pass.
- Because the span is contracted, any later consumer is ordered after the
*entire pass node*, i.e. after `EndRendering` — matching store-visibility
semantics (verified: the consumer's barrier takes
`COLOR_ATTACHMENT_OUTPUT`/`COLOR_ATTACHMENT_WRITE` as src, and hoisting a
barrier before `BeginRendering` with those dst stages is sufficient
visibility for LOAD reads and in-pass attachment reads).
- **Intra-span hazard policy (narrowed):**
- **Allowed inside a span:** any barrier with `old_layout == new_layout` —
memory barriers, buffer barriers, *and* image barriers with no layout
change (a storage-image read/write hazard at `GENERAL` needs an
`ImageMemoryBarrier2`; buffer barriers do not synchronize image
accesses). These are legal inside a rendering instance.
- **Rejected at compile time** (`GraphError::UnsupportedIntraPassDependency`):
intra-span hazards requiring an image layout transition (`old != new`).
The rejection operates on the **generated barrier plan** (D5), not on an
independent declared-layout scan.
- **Feedback loops:** defined as any interior access (read *or* write)
whose `(aspect, mip, layer)` set intersects a `Begin` attachment
subresource, excluding the begin's own load operation. A storage-image
write to an attachment subresource is equally a feedback loop; an
interior read of a disjoint mip/layer is legal. Genuine feedback loops
require splitting.
- In-pass barrier emission is part of Task 4, not a follow-up: the recorder
emits the barrier between the two interior commands in execution order.
- **Aliasing vs. interior first uses is a placement constraint, not a
validation message.** For image resources, add an interference edge (or an
explicit `aliasing_allowed` predicate consulted by `plan_placements` /
`first_fit_placement`) whenever a required alias transition would land at a
command that maps to a pass node — covering interior *and* boundary
(`Begin`/`End`) positions. There is no "reject aliasing" hook after
`plan_placements` commits offsets, so this must be enforced before
placement. Attachments themselves are safe: with D2's `End` modeling,
their first use is `Begin` and their terminal use is `End`, both boundary
commands of the pass node itself.
### D4 — Barrier placement: hoist cross-boundary barriers to BeginRendering, with per-subresource layout handling
Barriers whose `before_command` targets an interior command but whose origin
lies outside the span are re-keyed to the span's `BeginRendering` command,
after batching when spans are known. Constraints:
- **Barrier origin must be explicit.** `BarrierResource` (`graph.rs:84-95`)
records only masks/layout — no source command and no initial-vs-command
origin — so a post-pass hoisting pass cannot distinguish an outside-producer
barrier from an initial-state transition. Add
`src_command: Option<CommandIndex>` or a
`BarrierOrigin::{Initial, Command(CommandIndex)}` to `BarrierResource`.
**Initial-state transitions for interior first uses are hoisted to
`Begin`** and are not covered by the intra-span rejection (a transient
storage image first used inside a pass is the common case, not an edge
case).
- **Coalesce hoisted image transitions per `(resource, exact range,
target_layout)` into one barrier** with a unioned `dst_stage/dst_access`
(safe: the dst scope is a wait target; unioning only makes all consumers
wait). Layout chaining is defined **per `(aspect, mip, layer)`**, not per
barrier: for overlapping-but-unequal ranges, transition only the
subresources still in the producer layout; for disjoint ranges, emit
independent `producer → layout` transitions. The earlier "chain different
target layouts" rule is removed: for same-pass overlapping ranges it is
unreachable (D3 rejects the required in-pass transition), and for disjoint
ranges chaining `producer → A → B` is simply wrong (the second range is
still in the producer layout).
- The general `build_barriers` fix (per-subresource current-layout map, not
"last conflicting access's layout") makes non-hoisted multi-reader cases
correct too; D4's rules then only govern the hoisted subset.
- After hoisting, rebuild `barrier_indices_by_command` and
`ordinary_barrier_count` (`O(B)`), and apply the same coalescing in
`rebuild_barriers` (`graph.rs:2092-2106`) for alias barriers.
**Sequencing:** hoisting runs before `build_alias_barriers`, and hoisted
barriers live strictly within the ordinary prefix so `rebuild_barriers`'
truncation to `ordinary_barrier_count` (`graph.rs:2098`) cannot drop or
duplicate them; `rebuild_barriers` re-runs hoisting after the alias rebuild.
- Consumers after the pass keep their own barrier keys; expansion emits them
after `EndRendering` naturally. No barrier targets `EndRendering` itself
(its synthetic accesses are excluded from barrier generation).
- In-pass barriers (per D3) are emitted between the two interior commands in
execution order; they are not hoisted.
### D5 — Compile-time validation on the generated barrier plan, all linear
New `GraphError` variants for:
- unbalanced begin/end; nested/overlapping spans (dynamic rendering cannot
nest); `EndRendering` before its `BeginRendering` in record order;
- graphics commands outside any span (`GraphError::DrawOutsideRenderPass`),
and **transfer/clear commands inside a span** (`CopyBuffer`,
`ClearTexture`, and any custom command not marked render-pass-legal —
`vkCmdCopyBuffer2`/`vkCmdClearColorImage` are illegal inside a rendering
instance). Classification uses a `fn kind(&self) -> CommandKind` method on
**`RecordedCommand`** (not `Command` — a method on `Command` is
unreachable after `Box<dyn RecordedCommand>` erasure), returning
`CommandKind::{Begin, End, Graphics, Transfer, Compute, Other}` plus a
`fn allowed_in_render_pass(&self) -> bool` (default false; `Draw` → true;
test/custom no-op commands may override so Task 5's in-pass barrier tests
can exist). **Note:** five existing unit tests compile graphs containing
`Draw` with no pass (pushes at `graph.rs:3563, 3749, 3815, 4329, 4469`)
and must be updated (wrap in begin/end or make the restriction opt-in) —
added to Task 5.
- unsupported intra-span dependencies (per D3). **The check operates on the
generated barrier plan after hoisting/chaining, not on an independent
declared-layout scan:** after hoisting, any barrier whose `before_command`
is interior and whose `old_layout != new_layout` is either hoistable
(initial/outside origin) or must be rejected. This is exact, `O(B)`, and
removes the fragile per-subresource validation machinery whose
`previous`-selection (`graph.rs:951-982`) can diverge from declared
layouts (e.g. producer `P` at layout `P0`, outside reader `R1` transitions
to `A`, interior reader `R2` reads at `A`: a declared-layout scan sees
`A → A` and accepts, while the un-fixed builder would emit `P0 → A` at
`R2` inside the pass).
- attachment consistency: each attachment view must refer to the same image
as its graph `Texture` — compare **raw handles** (`Image: PartialEq`
compares raw handles, `images.rs:484-488`; `Arc::ptr_eq` would
false-positive), consistent with import dedup by raw handle
(`graph.rs:389-399`). Applies to the imported case at compile time and to
the created case after view creation (D9). The recorder binds graph images
by `ResourceId` (`graph.rs:2513-2529`); this check closes that gap.
- attachment layout legality: the caller-specified layout must be a legal
rendering layout (`COLOR_ATTACHMENT_OPTIMAL`, `GENERAL`, etc.). The
previously proposed "layout must match compiler-produced state" rule is
vacuous — the barrier's `new_layout` is derived from the begin's own
declared access, so no independent state exists.
- duplicate attachment subresources: two attachments covering the same
subresource would be silently merged by `SideEffectMap::insert`
(`recorder.rs:55-82`), making them invisible from `command_accesses`;
reject via the introspection hook (below), using a per-subresource set
(`O(covered subresources)`, not pairwise `O(a²)`).
- queue ownership: passes cannot cross queues or require ownership
transfers; the single-queue submission contract (`graph.rs:8-10`) applies
unchanged, and any access requiring a queue transfer inside or across a
span is rejected until paired transfer/barrier support exists.
- **Introspection hook:** commands are type-erased, and `CommandKind` carries
no data, so Task 2's attachment validations (view/image identity, duplicate
subresources, layout legality) need a `fn render_pass_desc(&self) ->
Option<&RenderPassDesc>` method on `RecordedCommand` (or a validation hook
called from `compile()`, or an attachment summary stored in
`CommandList::push`, which is generic over `C: Command`). Without one,
these checks are not implementable as written.
- **Error-path safety:** `record_commands` returning mid-pass must not leave
an unclosed rendering instance; track an `in_render_pass` flag in the
recorder/compiled graph, end the rendering instance (or discard the command
buffer) on the error path, and reset the flag (`graph.rs:2540-2553`).
### D6 — Execution order is precomputed at compile time
Compile an `execution_order: Vec<CommandIndex>` once: expanded contracted
batches — begin → interiors in **original record order** → end. Barrier
analysis and recording both iterate it; `batches` is retained (begin-index
representative) for future parallel recording. This avoids per-frame
expansion branches and keeps `recording_steps_cached` (`graph.rs:2595-2608`)
allocation-free. Interior commands are emitted in record order, not topo
order: ordering within the pass is the user's responsibility, matching the
"ordered by begin/end" contract.
**Batch-index assertion fix:** `build_barriers_with_initial_states` asserts
`batch_indices[previous] < batch_indices[current]` (`graph.rs:962-966`).
With interior commands mapped to the pass node's batch index, an allowed
intra-span hazard gives equal batch indices → debug panic. Use
**execution-order positions** for that assertion, not batch indices.
### D7 — Recording is allocation-free per frame
- `BeginRendering::apply` precomputes `vk::RenderingAttachmentInfo`s in the
command (or uses `ArrayVec`/thread-local scratch, mirroring
`pipeline_barrier`'s `BARRIER_SCRATCH` pattern, `recorder.rs:217-292`) so
no `Vec` is built per frame. Resolving attachment images per frame is
`O(a log R)` `BTreeMap` lookups — negligible.
- **View plumbing into `apply`:** `RecordedCommand::apply` receives only
`CommandRecorder` (`recorder.rs:108`), which cannot see `CompiledGraph`'s
view cache. Extend `CommandRecorder` with a view map
(`ResourceId → Arc<ImageView>`, or `ResourceId → Arc<Image>` plus
command-local view cache populated via `&mut self`) populated by
`record_commands`. `BeginRendering::apply` looks up the view; a missing or
unresolved view is a new `RecordError::MissingView`. Views are created
with `ManuallyDrop::into_inner(create_view(...))` — never store the
`ManuallyDrop` directly (leak).
- **Resolve idempotence:** views are created only after a fully successful
placement pass, atomically; a failed view creation must not leave a
partially-populated cache that later makes `Arc::get_mut`
(`graph.rs:2472-2480`) fail on a retried placement. Repeated `resolve()`
calls are idempotent; `create_view` failures map to `GraphError` variants.
### D8 — Frontier reuse for placement and alias barriers (performance amendment, corrected)
`ResourceInterference::from_resource_uses` already caches frontier sets per
resource (`graph.rs:1348-1356`); the plan does not claim to change it. The
actual recomputation hot spots contraction does not touch are
`first_fit_placement` (`graph.rs:1642-1649`), the `overlapping` loop in
`plan_placements` (`graph.rs:2384-2399`), and `build_alias_barriers`
(`terminal_uses` per old resource per new resource at `graph.rs:1109-1117`;
`initial_uses` per new at `graph.rs:1088`). Compute terminal/initial
frontiers **once per resource** and reuse them across all three sites, so
the performance rationale is honest: contraction removes the dependency-edge
and closure quadratics; frontier reuse removes the placement/alias ones.
### D9 — Attachment API for created textures
`RenderGraph::create_texture` returns only a `Texture` id (`graph.rs:352-354`);
the `Arc<Image>` is created privately in `plan_placements`/`resolve`
(`graph.rs:2129-2495`) and `CompiledGraph` exposes no accessor, so a caller
can never supply an `Arc<ImageView>` for a transient render target — the
main use case. Resolution:
- `ColorAttachment.view` is an enum:
`AttachmentView::Created(ImageViewDesc) | Imported(Arc<ImageView>)`.
- **`Created(ImageViewDesc)` must carry concrete aspect/mip/layer ranges**
(`REMAINING_*` sentinels are rejected at validation): `side_effects` runs
at `push` time (`recorder.rs:119-124`), before `resolve()` creates any
image, `image_view_subresource_range` is private (`images.rs:1183`), and
`create_view` normalizes/mutates the desc (`set_max_end`, format fill-in,
`images.rs:566-589`; `MipRange` addition panics on overflow in debug for
`base > 0` with a `REMAINING` count, `images.rs:1319-1333`). The declared
access range therefore comes from the desc, and is validated against the
image's mip/layer counts at compile time (from `GraphTextureDesc` /
imported `ImageDesc`).
- The crate creates and caches views after `resolve()` (`Image::create_view`
needs `&Arc<Image>`, `images.rs:558`), keyed by `(Texture, ImageViewDesc)`;
`BeginRendering::apply` consumes the cached view via the D7 recorder view
map.
- The D5 view/image consistency check applies to the imported case at
compile time and to the created case after view creation.
## Work items (all in `crates/renderer_rendergraph`)
### Task 1 — New commands in `src/commands.rs`
- `BeginRendering { color: Vec<ColorAttachment>, depth: Option<DepthStencilAttachment>, ... }`
with `ColorAttachment { texture: Texture, view: AttachmentView (D9),
layout: vk::ImageLayout, load_op, store_op, clear_value }` (depth/stencil
analogous). `EndRendering` (unit struct).
- `Command::side_effects`: `BeginRendering` inserts **one merged scope per
attachment** (write + conditional LOAD read, same stage, concrete range
from the desc/view — D3, D9). `EndRendering` inserts synthetic
lifetime-only accesses (D2), tagged to be skipped by barrier generation
and usage derivation.
- Add `fn kind(&self) -> CommandKind` and
`fn allowed_in_render_pass(&self) -> bool` and
`fn render_pass_desc(&self) -> Option<&RenderPassDesc>` to
`RecordedCommand` (D5): `Draw` → `Graphics`/allowed; begin/end →
`Begin`/`End`; `CopyBuffer`/`ClearTexture` → `Transfer`/not allowed;
default `Other`/not allowed.
- `RecordedCommand::apply`: `BeginRendering` builds `vk::RenderingInfo` +
precomputed `vk::RenderingAttachmentInfo`s (resolving `vk::Image` via
`recorder.image(...)`, using the cached/created view via the D7 view map)
and calls `cmd_begin_rendering`; `EndRendering` calls `cmd_end_rendering`.
Allocation-free per D7.
- Export both from `lib.rs`.
### Task 2 — Span tracking & validation in `src/graph.rs`
- Derive spans in `compile()` by scanning command record order using
`CommandKind`; build `node_of_command` and `spans`.
- Validation pass implementing the D5 rules, including the raw-handle
view/image consistency check (imported case), layout legality, duplicate
attachment subresources (via the introspection hook), and the
classification-based inside/outside-pass checks. Linear per D5.
- Update the five existing unit tests that compile `Draw` without a pass
(pushes at `graph.rs:3563, 3749, 3815, 4329, 4469`).
### Task 3 — Span contraction in the compiler
- Contract spans **before** `dependencies()` per D2: aggregate access scopes
per pass node, skip intra-span pairs during edge construction, begin-index
node representation, contracted `dependency_edges`; `HappensBefore` stores
the command→node map and translates in queries.
- Fix the pre-existing stale-`old_layout` defect in `build_barriers`
(`graph.rs:951-982`) with a per-subresource current-layout map (D4).
- Add `BarrierOrigin` to `BarrierResource` (D4).
- Barrier analysis runs over the uncontracted access list in execution order
with interior positions mapped to the pass node (D2); switch the
`batch_indices` debug assertion to execution-order positions (D6).
- `bind_lifetimes`, `HappensBefore::from_dag`, `uses_by_resource`, and
`build_alias_barriers` thread the span map through per D2; all closure
consumers route through the span-aware query; same-node uses are unordered
for aliasing/interference (D2 blocker fix).
- Precompute `execution_order` per D6.
- Frontier reuse per D8 (`first_fit_placement`, `plan_placements` overlapping
loop, `build_alias_barriers`).
### Task 4 — Barrier hoisting, in-pass barriers & recording
- Hoist cross-boundary barriers per D4, including initial-state transitions
for interior first uses (`BarrierOrigin::Initial`); coalesce per
`(resource, exact range, target_layout)` with unioned dst masks;
per-`(aspect, mip, layer)` layout handling for overlapping/disjoint ranges.
- Enforce the aliasing placement constraint per D3 (interference edge /
`aliasing_allowed` predicate) before placement commits offsets.
- Emit in-pass barriers (any `old_layout == new_layout`) between interior
commands in execution order for allowed intra-span hazards (D3).
- Hoisting sequenced before `build_alias_barriers`, hoisted barriers within
the ordinary prefix, `rebuild_barriers` re-runs hoisting (D4).
- `recording_steps_cached` iterates `execution_order`, expanding pass nodes:
barriers (hoisted) → `BeginRendering` → interior commands in record order
(with in-pass barriers interleaved) → `EndRendering`.
- `record_commands`: expanded steps, D7 view map population, and the D5
error-path safety (`in_render_pass` flag, no unclosed rendering instance
on mid-pass `RecordError`).
### Task 5 — Tests
- Unit tests in `graph.rs`:
- span validation errors (unbalanced, nested, draw-outside-pass,
transfer-inside-pass, view/image mismatch, layout legality, duplicate
attachment subresources, feedback-loop rejection);
- contraction correctness (foreign command never appears between begin and
end in `execution_order`; interior accesses still produce barriers;
lifetimes resolved for interior commands);
- barrier hoisting (barrier for an outside producer consumed by an interior
command is keyed to begin; **two interior consumers of one
outside-produced image coalesce into one barrier with unioned dst masks
and correct per-subresource `old_layout`** — regression test for the
reproduced blocker; also cover the non-hoisted two-reader case fixed by
the general `build_barriers` change);
- initial-state hoisting (transient storage image first used inside a pass
gets its `UNDEFINED → GENERAL` transition before `BeginRendering`);
- in-pass barrier emission (interior storage-buffer *and* storage-image
same-layout hazards produce barriers between the interior commands, no
pass split; layout-transition hazard is rejected);
- **aliasing safety (regression test for the rev-4 blocker):** created
attachment `A` + interior-only storage image `B` must **not** share
memory, and no barrier may target an interior command for `A`/`B`;
attachment `terminal_uses == End`;
- ordering of both incoming and outgoing dependencies around the whole
span;
- span-aware closure queries (`happens_before` with interior endpoints;
same-node uses unordered for aliasing).
- Integration tests in `tests/graph_validation.rs` (following the existing
`TestVulkan` pattern):
- skip when `core13.dynamic_rendering != TRUE` in addition to the existing
`synchronization2` check (`tests/graph_validation.rs:108-111`);
- the previously proposed `Draw`-based graph is **not constructible**
(`CopyBuffer` accepts only buffers, `commands.rs:30-79`; `Draw` has no
pipeline handle and never binds one, `commands.rs:143-153, 226-335`).
Use a custom no-op command (like `ScopedNoop`) with a texture read
access as the post-`End` consumer:
`ClearTexture → BeginRendering(color=target) → allowed no-op (texture
read) → EndRendering → no-op (texture read)` compiles, records,
submits, and validates. Adding a real `BindPipeline` command (with a
documented sequence edge to the following `Draw`, since pipeline binding
declares no resources) is an optional separate work item;
- a case proving a hazard-free foreign command is emitted *after*
`EndRendering`, not inside the pass;
- an outside-producer → interior-consumer case asserting the hoisted
barrier appears before `BeginRendering`.
### Task 6 — Existing test/API churn
- `batches()` assertions and helpers that assume command-space batches:
`graph.rs:3774, 4285, 4388-4393, 4674, 5237, 5245, 5257, 5337, 5343, 5397,
5409, 5414` (including `recording_steps(compiled.batches(), …)` at 5343).
- `GraphCompiler::new` / `build_barriers` signature changes (execution order +
span map) break direct unit-test call sites at `graph.rs:3177-3178, 4125,
4242, 4716-4718, 4792-4800, 4846-4854, 4893-4901, 4962-4976, 5051-5052,
5102-5104, 5142-5145, 5171-5173`; `HappensBefore::from_dag` is called
directly by a test (`graph.rs:4140-4155`) and its signature changes.
- Wrapping a `Draw` in a pass shifts barrier indices in tests such as
`sampled_image_import_sets_usage_and_layout_barrier` (asserts
`compiled.barriers()[0]`, `graph.rs:3837-3845`).
- Provide compatibility constructors where cheap, or update all listed call
sites; `cargo test -p renderer_rendergraph` must pass at the end.
## Sequencing
Tasks 1–2 and 3–4 share types, so execute strictly in order: **1 → 2 → 3 →
4 → 5 → 6**, with `cargo check -p renderer_rendergraph` after each and
`cargo test -p renderer_rendergraph` at the end. No changes to other crates;
`renderer`'s legacy `render_graph` module is out of scope.
## Performance summary (from rev-2 review, corrected in revs 3–5)
- **Contraction before `dependencies()`** removes the interior-draw
`O(Σ u_r²)` edge construction and shrinks the `O(N²/64)` closure
memory/time by roughly `(N_c/N)²` (a pass spanning 90% of commands →
~100× less closure work).
- **Contraction does not touch** the placement/alias frontiers; D8's
frontier reuse addresses `first_fit_placement`, `plan_placements`'
overlapping loop, and `build_alias_barriers` (interference frontier
caching already exists).
- **Barrier hoisting is cost-neutral to slightly positive** provided
coalescing follows D4 (per-target-layout, per-subresource handling,
unioned dst masks only for identical ranges/layouts).
- **In-pass barriers for same-layout hazards** avoid the significant
tiled-GPU regression of forced pass splits (attachment store/load
round-trips); only layout transitions and feedback loops force splits.
- **Validation is linear** with the barrier-plan-based intra-span check
(`O(B)`) and per-subresource-set duplicate detection; the existing
layout-conflict precheck grows by `O(a²)` pair iterations per pass
(28 for 8 attachments with the merged single access) — negligible.
- **Recording stays allocation-free** via precomputed attachment infos,
thread-local scratch, precomputed `execution_order`, and begin-index node
representation.
## Risks / open points
- **API shape:** raw `push(BeginRendering)` / `push(EndRendering)` vs. a
scoped `render_pass(desc, |graph| ...)` builder that pushes begin, runs the
closure, and pushes end while recording span command indices. The scoped
builder makes unbalanced spans unrepresentable; raw push is more flexible.
Decide before Task 1.
- **Created-texture views (D9):** view creation after `resolve()` adds a
cache keyed by `(Texture, ImageViewDesc)`; `ImageView` holds `Arc<Image>`
so lifetime is safe; resolve idempotence and atomic creation per D7.
- **Public API semantics:** `batches()` / `dependency_edges()` keep
command-space indices with the begin index as pass-node representative;
document the semantics and consider adding `execution_order()` as the
primary accessor.
- **`BindPipeline`:** out of scope for this plan; without it, submitted
tests cannot include real draws. If added later, it needs a sequence edge
to the following `Draw` (pipeline binding declares no resources, so the
hazard graph will not order it).