Compare commits

..

No commits in common. "ad22ff8c76629232c9590ed18f09901d2e2c54e3" and "f7c67f987274bdf49fe3ed73ee7b74c2de3b1760" have entirely different histories.

37 changed files with 676 additions and 11228 deletions

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
/target/
/Cargo.lock
**/pipeline_cache.bin

View file

@ -12,12 +12,11 @@ members = [
"crates/window",
"crates/game",
"crates/text",
"crates/bevy_vulkan_render", "crates/renderer_device", "crates/renderer_resources", "crates/renderer_rendergraph", "crates/datastructures",
"crates/bevy_vulkan_render", "crates/renderer_device", "crates/renderer_resources", "crates/renderer_rendergraph",
]
[dependencies]
bevy_vulkan_render = { path = "crates/bevy_vulkan_render" }
bitflags.workspace = true
[dev-dependencies]
bevy = { workspace = true }
@ -29,7 +28,6 @@ debug = true
[workspace.dependencies]
anyhow = "1.0.89"
criterion = "0.7"
thiserror = "2.0"
derive_more = { version = "1.0.0", features = ["deref", "debug", "deref_mut"] }
@ -43,15 +41,15 @@ thread_local = "1.1.8"
ash = "0.38.0"
ash-window = "0.13.0"
vk-mem = "0.5.0"
# vk-mem = "0.5.0"
gpu-allocator = { git = "https://github.com/janis-bhm/gpu-allocator", branch = "main" }
rectangle-pack = "0.4.2"
# vk-sync = "0.1.6"
arrayvec = "0.7.6"
tinyvec = { version = "1.8", features = ["alloc"] }
tinyvec = "1.8"
indexmap = "2"
petgraph = "0.7"
fixedbitset = "0.5"
# itertools = "0.14.0"
ahash = "0.8"

View file

@ -1,6 +0,0 @@
[package]
name = "datastructures"
version = "0.1.0"
edition = "2024"
[dependencies]

View file

@ -1,471 +0,0 @@
//! A map that stores values over disjoint, half-open intervals.
use std::collections::BTreeMap;
/// Operations applied to the covered and uncovered parts of a range.
pub trait IntervalUpdateOps<K, V> {
/// Updates the value of a subrange that was already present in the map.
fn update(&mut self, range: core::range::Range<K>, value: &mut V);
/// Creates a value for a subrange that was not previously present.
fn infill(&mut self, range: core::range::Range<K>) -> V;
}
impl<K, V, T> IntervalUpdateOps<K, V> for &mut T
where
T: IntervalUpdateOps<K, V> + ?Sized,
{
fn update(&mut self, range: core::range::Range<K>, value: &mut V) {
(**self).update(range, value);
}
fn infill(&mut self, range: core::range::Range<K>) -> V {
(**self).infill(range)
}
}
/// An interval and its associated value.
#[derive(Debug, Clone)]
struct Entry<K, V> {
/// The covered interval; its start is always the BTreeMap key.
range: core::range::Range<K>,
value: V,
}
/// A map from disjoint, half-open ranges of ordered keys to values.
///
/// Stored ranges are pairwise disjoint and sorted by their start key.
#[derive(Debug, Clone)]
pub struct DisjointIntervalMap<K, V> {
map: BTreeMap<K, Entry<K, V>>,
}
impl<K, V> DisjointIntervalMap<K, V> {
/// Creates an empty interval map.
pub const fn new() -> Self {
Self {
map: BTreeMap::new(),
}
}
/// Applies operations to every existing and missing subrange within `range`.
///
/// On return, every point in the non-empty range is covered by exactly one
/// map entry. Existing values are updated, and missing intervals are
/// created through [`IntervalUpdateOps::infill`].
pub fn split_apply(
&mut self,
range: core::range::Range<K>,
mut ops: impl IntervalUpdateOps<K, V>,
) where
K: Ord + Copy,
V: Clone,
{
if range.is_empty() {
return;
}
self.split(range.start);
self.split(range.end);
let mut cursor = range.start;
let mut gaps = Vec::new();
for entry in self
.map
.range_mut(range.start..range.end)
.map(|(_, entry)| entry)
{
if cursor < entry.range.start {
gaps.push(core::range::Range {
start: cursor,
end: entry.range.start,
});
}
let entry_range = entry.range;
ops.update(entry_range, &mut entry.value);
cursor = entry_range.end;
}
if cursor < range.end {
gaps.push(core::range::Range {
start: cursor,
end: range.end,
});
}
for gap in gaps {
let value = ops.infill(gap);
self.map.insert(gap.start, Entry { range: gap, value });
}
}
/// Inserts `range` unless it is empty or overlaps an existing interval.
#[allow(clippy::result_unit_err)]
pub fn try_insert(&mut self, range: core::range::Range<K>, value: V) -> Result<(), ()>
where
K: Ord + Copy,
{
if range.is_empty() {
return Err(());
}
if self
.map
.range(..range.end)
.next_back()
.is_some_and(|(_, entry)| entry.range.end > range.start)
{
return Err(());
}
self.map.insert(range.start, Entry { range, value });
Ok(())
}
/// Removes all mapped portions covered by `range`.
pub fn remove(&mut self, range: core::range::Range<K>)
where
K: Ord + Copy,
V: Clone,
{
if range.is_empty() {
return;
}
self.split(range.start);
self.split(range.end);
let keys: Vec<K> = self
.map
.range(range.start..range.end)
.map(|(key, _)| *key)
.collect();
for key in keys {
self.map.remove(&key);
}
}
/// Splits the interval containing `pos` into two intervals at `pos`.
///
/// Does nothing if no interval contains `pos` or an interval already
/// starts there.
pub fn split(&mut self, pos: K)
where
K: Ord + Copy,
V: Clone,
{
let Some((key, interval)) = self
.map
.range_mut(..=pos)
.next_back()
.map(|(key, entry)| (*key, entry.range))
else {
return;
};
if key == pos || pos >= interval.end {
return;
}
let entry = self.map.remove(&key).expect("candidate entry must exist");
let right_value = entry.value.clone();
let left_range = core::range::Range {
start: key,
end: pos,
};
let right_range = core::range::Range {
start: pos,
end: entry.range.end,
};
self.map.insert(
key,
Entry {
range: left_range,
value: entry.value,
},
);
self.map.insert(
pos,
Entry {
range: right_range,
value: right_value,
},
);
}
/// Returns the value of the interval containing `pos`, if any.
pub fn get(&self, pos: K) -> Option<&V>
where
K: Ord,
{
let (_, entry) = self
.map
.range((std::ops::Bound::Unbounded, std::ops::Bound::Included(&pos)))
.next_back()?;
(pos < entry.range.end).then_some(&entry.value)
}
/// Returns a mutable reference to the value of the interval containing `pos`.
pub fn get_mut(&mut self, pos: K) -> Option<&mut V>
where
K: Ord,
{
let (_, entry) = self
.map
.range_mut((std::ops::Bound::Unbounded, std::ops::Bound::Included(&pos)))
.next_back()?;
(pos < entry.range.end).then_some(&mut entry.value)
}
/// Iterates over intervals and their values in start-key order.
pub fn iter(&self) -> impl Iterator<Item = (&core::range::Range<K>, &V)> {
self.map.values().map(|entry| (&entry.range, &entry.value))
}
/// Iterates over values in start-key order.
pub fn values(&self) -> impl Iterator<Item = &V> {
self.map.values().map(|entry| &entry.value)
}
/// Iterates over intervals in start-key order.
pub fn keys(&self) -> impl Iterator<Item = &core::range::Range<K>> {
self.map.values().map(|entry| &entry.range)
}
/// Returns the number of stored intervals.
pub fn len(&self) -> usize {
self.map.len()
}
/// Returns whether the map contains no intervals.
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
/// Removes all intervals from the map.
pub fn clear(&mut self) {
self.map.clear();
}
}
impl<K, V> Default for DisjointIntervalMap<K, V> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::{DisjointIntervalMap, IntervalUpdateOps};
fn r(start: i32, end: i32) -> core::range::Range<i32> {
core::range::Range::from(start..end)
}
#[derive(Default)]
struct CountingOps {
updates: Vec<(i32, i32)>,
infills: Vec<(i32, i32)>,
}
impl IntervalUpdateOps<i32, i32> for CountingOps {
fn update(&mut self, range: core::range::Range<i32>, value: &mut i32) {
*value += 1;
self.updates.push((range.start, range.end));
}
fn infill(&mut self, range: core::range::Range<i32>) -> i32 {
self.infills.push((range.start, range.end));
100 + range.start
}
}
fn intervals(map: &DisjointIntervalMap<i32, i32>) -> Vec<(i32, i32, i32)> {
map.iter()
.map(|(range, value)| (range.start, range.end, *value))
.collect()
}
#[test]
fn try_insert_overlap_adjacency_and_lookup() {
let mut map = DisjointIntervalMap::new();
assert!(map.try_insert(r(5, 10), 1).is_ok());
assert!(map.try_insert(r(0, 5), 2).is_ok());
assert!(map.try_insert(r(10, 15), 3).is_ok());
for overlap in [r(4, 6), r(9, 12), r(6, 8), r(4, 11)] {
assert_eq!(map.try_insert(overlap, 9), Err(()));
}
assert_eq!(map.try_insert(r(2, 2), 9), Err(()));
assert_eq!(map.get(0), Some(&2));
assert_eq!(map.get(5), Some(&1));
assert_eq!(map.get(14), Some(&3));
assert_eq!(map.get(15), None);
assert_eq!(map.get(-1), None);
*map.get_mut(5).unwrap() = 7;
assert_eq!(map.get(5), Some(&7));
assert!(map.get_mut(15).is_none());
}
#[test]
fn split_boundaries_and_interior() {
let mut map = DisjointIntervalMap::new();
assert!(map.try_insert(r(0, 10), 4).is_ok());
map.split(5);
assert_eq!(intervals(&map), vec![(0, 5, 4), (5, 10, 4)]);
for pos in [0, 10, -1, 11] {
let before = intervals(&map);
map.split(pos);
assert_eq!(intervals(&map), before);
}
let mut empty = DisjointIntervalMap::<i32, i32>::new();
empty.split(1);
assert!(empty.is_empty());
}
#[test]
fn remove_exact_partial_spanning_and_noop_ranges() {
let mut exact = DisjointIntervalMap::new();
exact.try_insert(r(0, 5), 1).unwrap();
exact.remove(r(0, 5));
assert!(exact.is_empty());
let mut partial = DisjointIntervalMap::new();
partial.try_insert(r(0, 5), 1).unwrap();
partial.try_insert(r(5, 10), 2).unwrap();
partial.remove(r(3, 7));
assert_eq!(intervals(&partial), vec![(0, 3, 1), (7, 10, 2)]);
partial.remove(r(8, 12));
assert_eq!(intervals(&partial), vec![(0, 3, 1), (7, 8, 2)]);
let mut spanning = DisjointIntervalMap::new();
for start in [0, 5, 10] {
spanning.try_insert(r(start, start + 5), start).unwrap();
}
spanning.remove(r(2, 13));
assert_eq!(intervals(&spanning), vec![(0, 2, 0), (13, 15, 10)]);
let before = intervals(&spanning);
spanning.remove(r(20, 25));
spanning.remove(r(4, 4));
assert_eq!(intervals(&spanning), before);
}
#[test]
fn split_apply_on_empty_and_fully_covered_maps() {
let mut empty = DisjointIntervalMap::new();
let mut ops = CountingOps::default();
empty.split_apply(r(2, 6), &mut ops);
assert_eq!(intervals(&empty), vec![(2, 6, 102)]);
assert!(ops.updates.is_empty());
assert_eq!(ops.infills, vec![(2, 6)]);
let mut covered = DisjointIntervalMap::new();
covered.try_insert(r(0, 10), 5).unwrap();
let mut ops = CountingOps::default();
covered.split_apply(r(0, 10), &mut ops);
assert_eq!(intervals(&covered), vec![(0, 10, 6)]);
assert_eq!(ops.updates, vec![(0, 10)]);
assert!(ops.infills.is_empty());
}
#[test]
fn split_apply_mixed_coverage_splits_and_fills_gaps() {
let mut map = DisjointIntervalMap::new();
map.try_insert(r(0, 4), 1).unwrap();
map.try_insert(r(6, 10), 2).unwrap();
let mut ops = CountingOps::default();
map.split_apply(r(2, 8), &mut ops);
assert_eq!(
intervals(&map),
vec![(0, 2, 1), (2, 4, 2), (4, 6, 104), (6, 8, 3), (8, 10, 2)]
);
assert_eq!(ops.updates, vec![(2, 4), (6, 8)]);
assert_eq!(ops.infills, vec![(4, 6)]);
}
#[test]
fn split_apply_inside_one_entry_and_empty_range() {
let mut map = DisjointIntervalMap::new();
map.try_insert(r(0, 10), 7).unwrap();
let mut ops = CountingOps::default();
map.split_apply(r(3, 5), &mut ops);
assert_eq!(intervals(&map), vec![(0, 3, 7), (3, 5, 8), (5, 10, 7)]);
assert_eq!(ops.updates, vec![(3, 5)]);
assert!(ops.infills.is_empty());
let before = intervals(&map);
let prior_ops = ops.updates.clone();
let prior_infills = ops.infills.clone();
map.split_apply(r(4, 4), &mut ops);
assert_eq!(intervals(&map), before);
assert_eq!(ops.updates, prior_ops);
assert_eq!(ops.infills, prior_infills);
}
#[test]
fn split_apply_mutable_ops_accumulate_state() {
let mut map = DisjointIntervalMap::new();
let mut ops = CountingOps::default();
map.split_apply(r(0, 2), &mut ops);
map.split_apply(r(4, 6), &mut ops);
assert_eq!(ops.infills, vec![(0, 2), (4, 6)]);
assert_eq!(map.len(), 2);
}
#[test]
fn iteration_and_collection_basics() {
let mut map = DisjointIntervalMap::new();
assert!(map.is_empty());
map.try_insert(r(5, 8), 2).unwrap();
map.try_insert(r(0, 3), 1).unwrap();
assert_eq!(map.len(), 2);
assert!(!map.is_empty());
assert_eq!(
map.iter()
.map(|(range, value)| ((range.start, range.end), *value))
.collect::<Vec<_>>(),
vec![((0, 3), 1), ((5, 8), 2)]
);
assert_eq!(
map.keys()
.map(|range| (range.start, range.end))
.collect::<Vec<_>>(),
vec![(0, 3), (5, 8)]
);
assert_eq!(map.values().copied().collect::<Vec<_>>(), vec![1, 2]);
map.clear();
assert!(map.is_empty());
assert_eq!(map.len(), 0);
}
#[test]
fn operations_preserve_disjoint_nonempty_sorted_invariant() {
let mut map = DisjointIntervalMap::new();
map.try_insert(r(0, 4), 1).unwrap();
map.try_insert(r(6, 10), 2).unwrap();
map.split(2);
map.remove(r(3, 7));
let mut ops = CountingOps::default();
map.split_apply(r(-2, 12), &mut ops);
map.try_insert(r(20, 24), 3).unwrap();
map.split(22);
map.remove(r(1, 2));
let ranges = map.keys().collect::<Vec<_>>();
for range in &ranges {
assert!(!range.is_empty());
}
for pair in ranges.windows(2) {
assert!(pair[0].start < pair[1].start);
assert!(pair[0].end <= pair[1].start);
}
}
}

View file

@ -35,7 +35,7 @@
- `RenderGraph::add_resource` / `import_image` / `import_buffer` / `import_framebuffer` register graph resources; `add_pass` records read/write declarations and a callback; `resolve` creates internal resources and returns a command list with graph transitions.
- `rendering::Wireframe` is an example higher-level renderer; egui pass functions are re-exported from `rendering` rather than the private `egui_pass` module.
- Resource types are defined in `renderer_resources`: `Buffer`/`BufferDesc`; `Image`/`ImageDesc`, `ImageView`/`ImageViewDesc`, `MipRange`, `Extent`, and `Offset`; and pipeline, descriptor, shader, and sampler types.
- Buffer/image descriptors carry Vulkan usage, `renderer_device::device::MemoryLocation`, queue-sharing flags, and allocation strategy; `Buffer::map`/`map_mut` expose mapped memory for VMA-mapped allocations (CPU-mapped locations require host-visible, host-coherent memory).
- Buffer/image descriptors carry Vulkan usage, memory location, queue-sharing flags, and allocation strategy; `Buffer::map`/`map_mut` expose mapped memory only when the allocator returns a mapped allocation.
- The egui integration uploads texture deltas to GPU images and records passes through the legacy `RenderGraph`; shader binaries remain under `crates/renderer/shaders`, and renderer's hardcoded shader paths are unchanged.
- `ImageDesc` includes image shape, format, layout, usage, queue family selection, and allocation options. The resource crate's `Image::new_with_allocation` checks allocation size and memory-type compatibility before binding.
- `ImageViewDesc` selects view type, format, aspects, mip range, and array layers; the resource crate's `Image::create_view` normalizes `REMAINING_MIP_LEVELS` / `REMAINING_ARRAY_LAYERS` and checks image/view compatibility.
@ -60,7 +60,7 @@
- Device ownership is delegated to `renderer_device`: wrappers here strongly retain `Device` through its `DeviceObject` or explicit fields. Do not place a device-owning wrapper back inside `DeviceInner`.
- `Image::from_swapchain_raw(image, device, format, extent, usage)` wraps a swapchain-owned raw image without taking image ownership. `Swapchain::acquire_image` passes the swapchain device, configured format/usage, and configured extent with depth 1. Its descriptor remains TYPE_2D, one mip/layer, TYPE_1 samples, OPTIMAL tiling, PRESENT queue flags, UNDEFINED layout, and GpuOnly memory. Do not destroy that image as an independently allocated image.
- `ImageView` retains an `Arc<Image>` so its source image outlives the Vulkan view. `Image::create_view` in `renderer_resources` returns `ManuallyDrop<ImageView>`; renderer call sites transfer it deliberately (the swapchain path uses `ManuallyDrop::into_inner`).
- For allocated images, `Allocation::Owned` frees VMA allocation memory with its `DeviceObject`; `Allocation::Shared` permits shared ownership and only exposes mutable access when uniquely held; `Unmanaged` carries no allocation.
- For allocated images, `Allocation::Owned` frees allocation memory with its `DeviceObject`; `Allocation::Shared` permits shared ownership and only exposes mutable access when uniquely held; `Unmanaged` carries no allocator allocation.
- `Swapchain` owns raw swapchain images and manually created acquire/release semaphores. Its drop path waits idle and destroys those semaphores; the `DeviceObject` destroys the swapchain handle.
- `SwapchainImage` / `SwapchainFrame` are `#[must_use]` acquired-frame values. Presentation consumes the frame; preserve its `Arc<Swapchain>` so image ownership stays valid until presentation.
- `Surface` uses a `DropGuard` for surface destruction and keeps its `Instance`; field order drops the swapchain lock first, then the guard, then the instance.
@ -89,7 +89,7 @@
- The crate root also exposes `Extent2D` and `utils` alongside renderer modules and device re-exports.
- `SamplerDesc` implements float-aware equality and hashing with utility helpers; keep those implementations consistent if adding sampler fields.
- `Pipeline::new_graphics` supports dynamic rendering state through `RenderingState`; its render-pass and rendering choices are supplied in `GraphicsPipelineDesc`.
- `Buffer` and `Image` creation in `renderer_resources` allocates/binds through `DeviceInner::allocate_for_buffer` / `allocate_for_image` and `bind_buffer_memory` / `bind_image_memory`; device ownership and final VMA allocation release are handled by the device-layer wrappers.
- `Buffer` and `Image` creation in `renderer_resources` allocates/binds via `DeviceInner.alloc2`; device ownership and final allocation release are handled by the device-layer wrappers.
- Resource types live in `renderer_resources`; the `renderer` aliases are crate-private and are not externally reachable as `renderer::buffers::...` etc.
- `Renderer2::new` selects an adapter with `None` for the surface, then creates the logical device using its retained display handle. `choose_adapter_default` does not evaluate raw surface compatibility.
- `render_graph/legacy.rs` implements the active public graph resolver. Its resources are ordered by `GraphResourceId`, and passes declare reads/writes through `PassDesc` so the resolver can derive dependencies and transitions.
@ -123,7 +123,7 @@
- The `egui` pre-pass currently packs texture updates into a staging image/buffer and registers graph work; texture-free deltas and upload ordering deserve explicit coverage when changing the integration.
- The private font-atlas image is CPU memory, not a Vulkan `images::Image`; do not conflate the similarly named `Image` types when editing `text.rs`.
- `weak_vec` moved to `renderer_resources::util` and is not re-exported by `renderer`; it prunes dead weak references on capacity pressure. Image-side view tracking should remain weak to avoid ownership cycles.
- Render graph IDs are distinct: legacy `GraphResourceId` wraps `u32`; the newer internal `resources::ResourceId` also wraps `u32` (constructed via `ResourceId::from_u32`).
- Render graph IDs are distinct: legacy `GraphResourceId` wraps `u32`; the newer internal `resources::ResourceId` wraps `u64`.
- Public `commands::traits::CommandBufferExt` contains raw recording convenience methods; those methods assume valid command-buffer state and do not all enforce it at runtime.
- `SwapchainConfiguration.image_count` is treated as the additional desired in-flight count during validation, while creation asks for minimum surface image count plus that configured value. The fallback-configuration helper assigns a total count.
- The supported surface capability extension query can filter present modes based on the selected mode; its behavior depends on optional `InstanceExtensions` support.

View file

@ -24,6 +24,7 @@ tracing = { workspace = true }
ash = { workspace = true }
ash-window = { workspace = true }
# vk-mem = { workspace = true }
gpu-allocator = { workspace = true }
rectangle-pack = { workspace = true }
raw-window-handle = { workspace = true }

View file

@ -5,13 +5,14 @@ use std::{
};
use ash::vk;
use gpu_allocator::MemoryLocation;
use indexmap::IndexMap;
use crate::{
EguiState,
buffers::{Buffer, BufferDesc},
commands::traits::CommandBufferExt,
device::{self, AllocationStrategy, MemoryLocation},
device::{self, AllocationStrategy},
images::{Image, ImageDesc, ImageViewDesc},
render_graph::{
Access, Barrier, GraphResourceDesc, GraphResourceId, PassDesc, RecordFn, RenderContext,
@ -571,7 +572,7 @@ pub fn egui_pass(
.values()
.map(|entry| {
let texture = texture_handler.get_texture(entry.id).unwrap();
let view = Arc::new(
let view = Arc::new(ManuallyDrop::into_inner(
texture
.create_view(
ImageViewDesc::color_2d()
@ -579,7 +580,7 @@ pub fn egui_pass(
.with_layer_range(0..1),
)
.unwrap(),
);
));
let sampler = samplers.get_sampler_owned(entry.as_sampler_desc()).unwrap();
(view, sampler)
})
@ -770,7 +771,7 @@ pub fn egui_pass(
let color_attachment = &vk::RenderingAttachmentInfo::default()
.image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.image_view(ManuallyDrop::new(target.create_view(ImageViewDesc::color_2d())?).raw())
.image_view(target.create_view(ImageViewDesc::color_2d())?.raw())
.load_op(vk::AttachmentLoadOp::LOAD)
.store_op(vk::AttachmentStoreOp::STORE);

View file

@ -4,9 +4,7 @@ use ash::vk;
use derive_more::derive::{Deref, DerefMut};
use crate::{
buffers,
device::MemoryLocation,
images,
buffers, images,
render_graph::{
recorder::CommandList,
resources::{self, ResourceId},
@ -50,7 +48,7 @@ impl<'r> GraphBuilder<'r> {
&self,
_size: u64,
_usage: vk::BufferUsageFlags,
_location: MemoryLocation,
_location: gpu_allocator::MemoryLocation,
) -> Buffer<'r> {
todo!()
}
@ -59,7 +57,7 @@ impl<'r> GraphBuilder<'r> {
&self,
_size: u64,
_usage: vk::BufferUsageFlags,
_location: MemoryLocation,
_location: gpu_allocator::MemoryLocation,
) -> Buffer<'r> {
todo!()
}
@ -68,7 +66,7 @@ impl<'r> GraphBuilder<'r> {
&self,
_size: u64,
_usage: vk::BufferUsageFlags,
_location: MemoryLocation,
_location: gpu_allocator::MemoryLocation,
) -> Buffer<'r> {
todo!()
}
@ -86,6 +84,7 @@ mod api_demo {
use super::*;
use ash::vk::{self, BufferUsageFlags};
use gpu_allocator::MemoryLocation;
use indexmap::IndexMap;
fn egui_graph<'a>(

View file

@ -228,17 +228,7 @@ mod impls {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(transparent)]
pub struct ResourceId(u32);
impl ResourceId {
pub fn from_u32(index: u32) -> Self {
Self(index)
}
pub fn index(self) -> usize {
self.0 as usize
}
}
pub struct ResourceId(pub u64);
#[derive(Debug, Clone, Copy)]
pub enum ResourceRange {

View file

@ -1,18 +1,19 @@
use std::{mem::ManuallyDrop, sync::Arc};
pub use crate::egui_pass::{egui_pass, egui_pre_pass};
use std::sync::Arc;
use ash::vk;
use glam::{
camera::rh::{proj, view},
vec3,
};
use gpu_allocator::MemoryLocation;
pub use crate::egui_pass::{egui_pass, egui_pre_pass};
use crate::{
Result,
buffers::{Buffer, BufferDesc},
commands::{self, traits::CommandBufferExt},
device::{Device, MemoryLocation},
device::Device,
images::ImageViewDesc,
pipeline,
render_graph::{
@ -329,9 +330,7 @@ impl Wireframe {
let color_attachment = &vk::RenderingAttachmentInfo::default()
.image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.image_view(
ManuallyDrop::new(target.create_view(ImageViewDesc::color_2d())?).raw(),
)
.image_view(target.create_view(ImageViewDesc::color_2d())?.raw())
.load_op(vk::AttachmentLoadOp::LOAD)
.store_op(vk::AttachmentStoreOp::STORE);

View file

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

View file

@ -11,12 +11,11 @@
- `sync.rs`: fence/semaphore wrappers and pools, `FenceFuture`, and background fence waiters.
- `pipeline_cache.rs`: keyed Vulkan pipeline cache with `pipeline_cache.bin` load/export.
- `debug.rs`: Vulkan debug callback forwarding messages to tracing.
- `testing.rs` (feature `testing`): `TestVulkan` creates real validation-enabled Vulkan devices and captures validation messages for smoke/integration tests.
- `renderer_macros/src/lib.rs`: separate workspace proc-macro source; derives `DeviceRef` for a struct field marked `#[device]`.
## Key types and ownership
- `Device(Arc<DeviceInner>)` is the cloneable owning handle. It dereferences to `DeviceInner`; equality is `Arc::ptr_eq`.
- `DeviceInner` keeps the raw `ash::Device`, VMA (`vk_mem`) allocator mutex, adapter and instance, queues, synchronization threadpool, extension loaders, and inline `DevicePools`.
- `DeviceInner` keeps the raw `ash::Device`, allocator mutex, adapter and instance, queues, synchronization threadpool, extension loaders, and inline `DevicePools`.
- `Device::enabled_features()` returns the feature snapshot requested for logical-device creation; it is distinct from `Device::features()`, which reports adapter-supported features. Descriptor capability validation must use the enabled snapshot, not support data.
- `PhysicalDeviceInfo::create_logical_device` selects queues, enables requested/supported extensions and features, initializes the allocator/cache, and constructs `DeviceInner` with `Arc::new_cyclic`.
- `DeviceInner::dev`, `instance`, `queues`, `physical_device`, `main_queue`, `compute_queue`, and `transfer_queue` are the main convenience accessors for downstream code.
@ -27,19 +26,17 @@
- The `DeviceRef` proc macro is implemented in the separate `renderer_macros` package and emits paths rooted at `::renderer_device::`.
- `sync::Fence` and `sync::Semaphore` distinguish `Dedicated` (`DeviceObject`) from `Pooled` (`PoolObject`) ownership. `SemaphoreInner`, `BinarySemaphore`, and `TimelineSemaphore` represent semaphore-kind handles.
- `PhysicalDeviceInfo`, `PhysicalDeviceFeatures`, `PhysicalDeviceProperties`, and `SurfaceCapabilities` carry adapter/capability data. `Instance::choose_adapter_default` accepts `Option<vk::SurfaceKHR>` (a raw handle), not a swapchain/surface wrapper.
- `Error` and `Result<T>` are the crate-wide error types; `Error` covers Vulkan/VMA (`vk_mem`), loading, I/O, and renderer-facing resource/capability failures.
- `Error` and `Result<T>` are the crate-wide error types; `Error` covers Vulkan, loading, allocator, I/O, and renderer-facing resource/capability failures.
- `InstanceDesc` configures borrowed instance extensions/layers, optional app metadata, and a raw display handle; its default requests surface and validation layers, with validation settings in debug builds. `Instance::new` creates a shared `Instance` and requests debug-utils plus optional capability-query extensions alongside requested/platform extensions.
- `Instance::expose_adapter` queries one adapter. `choose_adapter_default` ranks enumerated adapters against requested extensions/features and prefers discrete devices, then newer APIs, device-local memory, and texture size.
- `PhysicalDeviceFeatures` stores core, Vulkan 1.1/1.2/1.3, and optional mesh-shader feature structures; `push_to_device_create_info` chains enabled feature structs.
- `PhysicalDeviceProperties` stores supported extensions, core/versioned properties, optional mesh-shader properties, memory properties, and API version; `supports_extension` checks name and minimum version.
- `get_physical_device_features` and `get_physical_device_properties` query the feature/property chains. Extension query results are consumed by the instance and logical-device setup helpers.
- `DeviceExtensions` holds the debug-utils device loader and optional swapchain/mesh-shader loaders. `DeviceInner.enabled_extensions` records enabled extension names.
- `MemoryLocation` is the public allocator-independent memory preference; `AllocationDesc` combines it with a resource name and `AllocationStrategy` (`AllocatorManaged` or `Dedicated`). `GpuAllocation` wraps the private `vk_mem::Allocation` and retains memory properties needed by callers.
- `DeviceInner::allocate_for_buffer` / `allocate_for_image` obtain VMA allocations, and `bind_buffer_memory` / `bind_image_memory` bind them to Vulkan resources without exposing `vk_mem` types to upper layers. CPU-mapped locations require host-visible, host-coherent memory.
- `Allocation` represents `Owned`, `Shared`, or `Unmanaged` GPU allocations.
- `AllocationStrategy` selects allocator-managed blocks or a dedicated allocation. `Allocation` represents `Owned`, `Shared`, or `Unmanaged` GPU allocations.
- `get_extensions` returns supported/enabled and unsupported requested extension sets; `get_layers` returns available requested layers or the supported/unsupported split.
- `Queue` shares a `QueueInner` through `Arc`; `with_locked` serializes operations on the Vulkan queue. `QueueFamily` records index, count, flags, granularity, and timestamp bits.
- `DeviceQueues` provides graphics, compute, and transfer queues; absent compute/transfer queues alias the graphics queue. Queue selection requires presentation support only when a display handle is provided.
- `DeviceQueues` provides graphics, compute, and transfer queues; absent compute/transfer queues alias the graphics queue. Queue selection requires the selected graphics family to support presentation.
- `DeviceQueueInfos` chooses queue families and produces `vk::DeviceQueueCreateInfo` values before `retrieve_queues` obtains the raw Vulkan queues.
- `QueueFlags` in `device.rs` describes requested graphics/async-compute/transfer/present families; `queue::QueueFlags` describes discovered Vulkan family capabilities. They are distinct types.
- `FenceFuture` waits and resets a fence, and `SyncThreadpool` schedules blocking fence waits for async wakers. `Fence::from_pool` and `Semaphore::from_pool` wrap recycled pool entries.
@ -48,7 +45,6 @@
- Pooled timeline semaphores are created at value zero; `Semaphore::from_pool` signals the requested initial value when it obtains one.
- `PipelineCache::new` computes its key from adapter vendor/API/device/driver properties and tries to load compatible data; cache persistence uses a magic and format version.
- `DebugName` is the wrapper for Vulkan object debug names.
- `testing::TestVulkan::acquire` returns a real Vulkan test device or skips with a reason; `expect` requires Vulkan. `take_messages`, `assert_no_validation_errors`, and `assert_no_warnings` inspect the captured debug-utils callback output. Run the device smoke tests using `direnv exec . cargo test -p renderer_device --features testing` from the workspace root.
- `Extension` is the name/version pair used by extension helpers and adapter creation. `make_extension!`, `get_extensions`, `get_layers`, and the support/intersection functions centralize Vulkan extension discovery.
- `SurfaceCapabilities` returns raw `SurfaceCapabilitiesKHR` plus the effective minimum image count, surface formats, and present modes queried for the raw surface.
- `DebugUtilsCreateInfo` is the small severity/message-type configuration record used with debug utilities; `DebugUtils` itself owns the instance loader and messenger handle.
@ -81,7 +77,7 @@
- `queue::QueueFlags` and `device::QueueFlags` intentionally serve different purposes. Keep imports qualified when adding queue-family selection logic.
- `PipelineCache` persistence uses a single working-directory path and one cache record. Changes to the key or file header should account for compatibility with existing cache files.
- Logical-device setup always includes the swapchain extension in its required extension list, regardless of the caller's extra requested extensions.
- Queue-family selection uses the optional display handle to query presentation support and requires its chosen graphics family to be present-capable only when a display handle is provided.
- Queue-family selection uses the optional display handle to query presentation support and currently asserts that its chosen graphics family is present-capable.
- Extension selectors and feature checks drive selection; do not infer that every optional extension is enabled just because a loader type exists.
- `Instance::query_presentation_support` handles platform-specific display variants and has an unimplemented XCB branch.
- `DebugUtils` is stored inside `InstanceInner`; its destructor destroys the messenger, so retain the loader/messenger for the instance lifetime.
@ -96,7 +92,7 @@
- `Device::PartialEq` compares `Arc` identity, not raw Vulkan handles or physical-device identity.
- `Instance::get_adapter_surface_capabilities` receives the surface loader separately from the raw handle and returns format/mode data alongside generic capabilities.
- In debug builds `DebugName` stores its string; in non-debug builds its dereference returns a placeholder and does not retain the supplied name. `debug_name_object` assigns names through debug utils.
- GPU allocation free is routed through `DeviceObject<GpuAllocation>` into the VMA allocator mutex with `free_memory`. Do not call allocator free on an allocation after ownership has moved or dropped.
- GPU allocation free is routed through `DeviceObject<GpuAllocation>` into the allocator mutex. Do not call allocator free on an allocation after ownership has moved or dropped.
- `ExternallyManagedObject<Device>` must destroy each owned handle once. The `vk::CommandBuffer` implementation is intentionally a no-op: its outer command-buffer wrapper frees it through its command pool. `vk::CommandPool` itself is really destroyed.
- Device handle types are hand-written around `DeviceObject` with `DeviceOwned` implementations where needed; the old `define_device_owned_handle!` macro no longer exists.

View file

@ -3,13 +3,10 @@ name = "renderer_device"
version = "0.1.0"
edition = "2024"
[features]
testing = []
[dependencies]
ash = { workspace = true }
ash-window = { workspace = true }
vk-mem = { workspace = true }
gpu-allocator = { workspace = true }
parking_lot = { workspace = true }
tracing = { workspace = true }
raw-window-handle = { workspace = true }

View file

@ -3,8 +3,7 @@ use std::{
collections::{BTreeSet, HashMap, HashSet},
ffi::CStr,
ops::{Deref, DerefMut},
ptr::NonNull,
sync::{Arc, OnceLock, Weak},
sync::{Arc, Weak},
};
use ash::{ext, khr, prelude::VkResult, vk};
@ -67,7 +66,7 @@ impl From<String> for DebugName {
}
}
use crate::{
Error, Instance, PhysicalDeviceFeatures, PhysicalDeviceInfo, Result,
Instance, PhysicalDeviceFeatures, PhysicalDeviceInfo, Result,
device::asdf::traits::ExternallyManagedObject,
pipeline_cache::PipelineCache,
queue::{DeviceQueueInfos, DeviceQueues, Queue},
@ -109,89 +108,12 @@ pub struct DeviceExtensions {
pub mesh_shader: Option<ext::mesh_shader::Device>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MemoryLocation {
#[default]
Unknown,
GpuOnly,
CpuToGpu,
GpuToCpu,
}
#[derive(Clone, Copy)]
struct SendSyncPtr(NonNull<u8>);
unsafe impl Send for SendSyncPtr {}
unsafe impl Sync for SendSyncPtr {}
impl core::fmt::Debug for SendSyncPtr {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_tuple("SendSyncPtr").field(&self.0).finish()
}
}
pub struct GpuAllocation {
raw: vk_mem::Allocation,
memory: vk::DeviceMemory,
offset: u64,
size: u64,
memory_type_index: u32,
mapped: Option<SendSyncPtr>,
dedicated_memory: bool,
}
unsafe impl Send for GpuAllocation {}
unsafe impl Sync for GpuAllocation {}
impl core::fmt::Debug for GpuAllocation {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("GpuAllocation")
.field("memory", &self.memory)
.field("offset", &self.offset)
.field("size", &self.size)
.field("memory_type_index", &self.memory_type_index)
.field("mapped", &self.mapped)
.field("dedicated_memory", &self.dedicated_memory)
.finish_non_exhaustive()
}
}
impl GpuAllocation {
pub fn memory(&self) -> vk::DeviceMemory {
self.memory
}
pub fn offset(&self) -> u64 {
self.offset
}
pub fn size(&self) -> u64 {
self.size
}
pub fn memory_type_index(&self) -> u32 {
self.memory_type_index
}
pub fn is_dedicated(&self) -> bool {
self.dedicated_memory
}
pub fn mapped_slice(&self) -> Option<&[u8]> {
let mapped = self.mapped?;
Some(unsafe { core::slice::from_raw_parts(mapped.0.as_ptr(), self.size as usize) })
}
pub fn mapped_slice_mut(&mut self) -> Option<&mut [u8]> {
let mapped = self.mapped?;
Some(unsafe { core::slice::from_raw_parts_mut(mapped.0.as_ptr(), self.size as usize) })
}
}
type GpuAllocation = gpu_allocator::vulkan::Allocation;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AllocationStrategy {
#[default]
/// Let the allocator manage the memory for this allocation, sub-allocating
/// Let gpu_allocator manage the memory for this allocation, sub-allocating
/// from larger blocks as needed.
AllocatorManaged,
/// Allocate a dedicated block of memory for this allocation. This is
@ -200,84 +122,11 @@ pub enum AllocationStrategy {
Dedicated,
}
pub struct AllocationDesc<'a> {
pub name: &'a str,
pub location: MemoryLocation,
pub strategy: AllocationStrategy,
}
/// Vulkan memory requirements for a buffer or image.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemoryRequirements {
pub size: vk::DeviceSize,
pub alignment: vk::DeviceSize,
pub memory_type_bits: u32,
pub requires_dedicated_allocation: bool,
pub prefers_dedicated_allocation: bool,
}
/// A buffer or image to bind into a shared allocation at an allocation-local offset.
#[derive(Debug, Clone, Copy)]
pub enum MemoryBinding {
Buffer {
buffer: vk::Buffer,
offset: vk::DeviceSize,
requirements: MemoryRequirements,
},
Image {
image: vk::Image,
offset: vk::DeviceSize,
requirements: MemoryRequirements,
},
}
/// A failed grouped bind and the number of earlier members that remain bound.
#[derive(Debug)]
pub struct GroupBindError {
pub error: crate::Error,
pub bound_count: usize,
}
/// Returns the first memory type allowed by `memory_type_bits` with all requested properties.
fn find_memory_type_index(
memory_properties: &vk::PhysicalDeviceMemoryProperties,
memory_type_bits: u32,
property_flags: vk::MemoryPropertyFlags,
) -> Result<u32> {
(0..memory_properties.memory_type_count)
.find(|&index| {
memory_type_bits & (1 << index) != 0
&& memory_properties.memory_types[index as usize]
.property_flags
.contains(property_flags)
})
.ok_or(Error::NoCompatibleMemoryType {
memory_type_bits,
property_flags,
})
}
impl From<vk::MemoryRequirements> for MemoryRequirements {
fn from(requirements: vk::MemoryRequirements) -> Self {
Self {
size: requirements.size,
alignment: requirements.alignment,
memory_type_bits: requirements.memory_type_bits,
requires_dedicated_allocation: false,
prefers_dedicated_allocation: false,
}
}
}
#[derive(Debug)]
pub enum Allocation {
Owned(DeviceObject<GpuAllocation>),
Shared(Arc<DeviceObject<GpuAllocation>>),
/// An unbound resource. Requirements are a pure function of its Vulkan handle, so
/// resources can safely cache the first query here for their lifetime.
Unmanaged {
requirements: OnceLock<MemoryRequirements>,
},
Unmanaged,
}
impl Allocation {
@ -285,7 +134,7 @@ impl Allocation {
match self {
Allocation::Owned(obj) => Some(obj),
Allocation::Shared(arc) => Some(arc.as_ref()),
Allocation::Unmanaged { .. } => None,
Allocation::Unmanaged => None,
}
}
@ -293,13 +142,13 @@ impl Allocation {
match self {
Allocation::Owned(obj) => Some(obj),
Allocation::Shared(arc) => Arc::get_mut(arc).map(DerefMut::deref_mut),
Allocation::Unmanaged { .. } => None,
Allocation::Unmanaged => None,
}
}
}
pub struct DeviceInner {
pub allocator: Mutex<vk_mem::Allocator>,
pub alloc2: Mutex<gpu_allocator::vulkan::Allocator>,
pub raw: ash::Device,
pub adapter: PhysicalDeviceInfo,
pub instance: Instance,
@ -589,32 +438,25 @@ impl PhysicalDeviceInfo {
},
};
let mut allocator_info =
vk_mem::AllocatorCreateInfo::new(&instance.inner.raw, &device, self.pdev);
allocator_info.preferred_large_heap_block_size = 64 * 1024 * 1024;
allocator_info.vulkan_api_version = instance
.inner
.api_version
.min(self.properties.device_api_version);
if allocator_info.vulkan_api_version < vk::API_VERSION_1_1 {
if enabled_extensions.contains(&khr::dedicated_allocation::NAME)
&& enabled_extensions.contains(&khr::get_memory_requirements2::NAME)
{
allocator_info.flags |= vk_mem::AllocatorCreateFlags::KHR_DEDICATED_ALLOCATION;
}
if enabled_extensions.contains(&khr::bind_memory2::NAME) {
allocator_info.flags |= vk_mem::AllocatorCreateFlags::KHR_BIND_MEMORY2;
}
}
let allocator = unsafe { vk_mem::Allocator::new(allocator_info) }?;
let alloc2 =
gpu_allocator::vulkan::Allocator::new(&gpu_allocator::vulkan::AllocatorCreateDesc {
instance: instance.inner.raw.clone(),
device: device.clone(),
physical_device: self.pdev,
debug_settings: Default::default(),
buffer_device_address: false,
allocation_sizes: {
const MB: u64 = 1024 * 1024;
gpu_allocator::AllocationSizes::new(8 * MB, 64 * MB)
.with_max_host_memblock_size(256 * MB)
.with_max_device_memblock_size(256 * MB)
},
})?;
let pipeline_cache = PipelineCache::new(&device, &self).unwrap();
let shared = Arc::new_cyclic(|inner: &Weak<DeviceInner>| DeviceInner {
raw: device.clone(),
allocator: Mutex::new(allocator),
alloc2: Mutex::new(alloc2),
instance: instance.clone(),
adapter: self,
queues: device_queues,
@ -736,495 +578,7 @@ where
}
}
impl Device {
/// Allocates aliasable memory compatible with `requirements` and `property_flags`.
///
/// The returned `Arc` is an allocation handle intended to be transferred into each bound
/// resource's `Allocation::Shared`. Binding consumes one Arc reference; the allocation is
/// freed by its `DeviceObject` when the last bound resource drops. This helper does not
/// retain a reference after a successful bind.
pub fn allocate_memory(
&self,
requirements: &MemoryRequirements,
property_flags: vk::MemoryPropertyFlags,
) -> Result<Arc<DeviceObject<GpuAllocation>>> {
let allocation = self
.0
.allocate_aliasable_memory(requirements, property_flags)?;
Ok(Arc::new(DeviceObject::new(self.clone(), allocation)))
}
}
impl DeviceInner {
fn api_version(&self) -> u32 {
self.instance
.inner
.api_version
.min(self.adapter.properties.device_api_version)
}
fn has_enabled_extension(&self, name: &CStr) -> bool {
self.enabled_extensions.contains(&name)
}
fn has_memory2_extension_set(&self) -> bool {
self.has_enabled_extension(khr::get_memory_requirements2::NAME)
&& self.has_enabled_extension(khr::bind_memory2::NAME)
&& self.has_enabled_extension(khr::dedicated_allocation::NAME)
}
fn require_offset_binding_support(&self) -> Result<()> {
if self.api_version() < vk::API_VERSION_1_1 && !self.has_memory2_extension_set() {
return Err(Error::OffsetBindingRequiresApi11);
}
Ok(())
}
/// Finds a compatible memory type satisfying `property_flags`.
pub fn find_memory_type_index(
&self,
requirements: &MemoryRequirements,
property_flags: vk::MemoryPropertyFlags,
) -> Result<u32> {
find_memory_type_index(
&self.adapter.properties.memory_properties,
requirements.memory_type_bits,
property_flags,
)
}
/// Queries buffer memory requirements, including dedicated-allocation hints when available.
pub fn buffer_memory_requirements(&self, buffer: vk::Buffer) -> Result<MemoryRequirements> {
let api11 = self.api_version() >= vk::API_VERSION_1_1;
let get_requirements2 =
api11 || self.has_enabled_extension(khr::get_memory_requirements2::NAME);
let dedicated_available =
api11 || self.has_enabled_extension(khr::dedicated_allocation::NAME);
if !get_requirements2 {
let requirements = unsafe { self.raw.get_buffer_memory_requirements(buffer) };
return Ok(requirements.into());
}
let mut dedicated = vk::MemoryDedicatedRequirements::default();
let mut requirements = vk::MemoryRequirements2::default();
if dedicated_available {
requirements = requirements.push_next(&mut dedicated);
}
let info = vk::BufferMemoryRequirementsInfo2::default().buffer(buffer);
unsafe {
if api11 {
self.raw
.get_buffer_memory_requirements2(&info, &mut requirements);
} else {
khr::get_memory_requirements2::Device::new(&self.instance.inner.raw, &self.raw)
.get_buffer_memory_requirements2(&info, &mut requirements);
}
}
Ok(MemoryRequirements {
size: requirements.memory_requirements.size,
alignment: requirements.memory_requirements.alignment,
memory_type_bits: requirements.memory_requirements.memory_type_bits,
requires_dedicated_allocation: dedicated_available
&& dedicated.requires_dedicated_allocation == vk::TRUE,
prefers_dedicated_allocation: dedicated_available
&& dedicated.prefers_dedicated_allocation == vk::TRUE,
})
}
/// Queries image memory requirements, including dedicated-allocation hints when available.
pub fn image_memory_requirements(&self, image: vk::Image) -> Result<MemoryRequirements> {
let api11 = self.api_version() >= vk::API_VERSION_1_1;
let get_requirements2 =
api11 || self.has_enabled_extension(khr::get_memory_requirements2::NAME);
let dedicated_available =
api11 || self.has_enabled_extension(khr::dedicated_allocation::NAME);
if !get_requirements2 {
let requirements = unsafe { self.raw.get_image_memory_requirements(image) };
return Ok(requirements.into());
}
let mut dedicated = vk::MemoryDedicatedRequirements::default();
let mut requirements = vk::MemoryRequirements2::default();
if dedicated_available {
requirements = requirements.push_next(&mut dedicated);
}
let info = vk::ImageMemoryRequirementsInfo2::default().image(image);
unsafe {
if api11 {
self.raw
.get_image_memory_requirements2(&info, &mut requirements);
} else {
khr::get_memory_requirements2::Device::new(&self.instance.inner.raw, &self.raw)
.get_image_memory_requirements2(&info, &mut requirements);
}
}
Ok(MemoryRequirements {
size: requirements.memory_requirements.size,
alignment: requirements.memory_requirements.alignment,
memory_type_bits: requirements.memory_requirements.memory_type_bits,
requires_dedicated_allocation: dedicated_available
&& dedicated.requires_dedicated_allocation == vk::TRUE,
prefers_dedicated_allocation: dedicated_available
&& dedicated.prefers_dedicated_allocation == vk::TRUE,
})
}
/// Allocates aliasable memory compatible with `requirements` and `property_flags`.
///
/// Resources that require a dedicated allocation cannot use this resource-independent path.
fn allocate_aliasable_memory(
&self,
requirements: &MemoryRequirements,
property_flags: vk::MemoryPropertyFlags,
) -> Result<GpuAllocation> {
use vk_mem::{Alloc as _, AllocationCreateFlags, AllocationCreateInfo};
if requirements.requires_dedicated_allocation {
return Err(Error::DedicatedAllocationRequiresResource);
}
let memory_type_index = self.find_memory_type_index(requirements, property_flags)?;
let create_info = AllocationCreateInfo {
flags: AllocationCreateFlags::CAN_ALIAS,
required_flags: property_flags,
memory_type_bits: 1 << memory_type_index,
..Default::default()
};
let raw_requirements = vk::MemoryRequirements {
size: requirements.size,
alignment: requirements.alignment,
memory_type_bits: requirements.memory_type_bits,
};
let allocator = self.allocator.lock();
let raw = unsafe { allocator.allocate_memory(&raw_requirements, &create_info) }?;
let allocation_info = allocator.get_allocation_info2(&raw);
let dedicated_memory = allocation_info.dedicated_memory;
let allocation_info = allocation_info.allocation_info;
let mapped = NonNull::new(allocation_info.mapped_data.cast::<u8>()).map(SendSyncPtr);
let allocation = GpuAllocation {
raw,
memory: allocation_info.device_memory,
offset: allocation_info.offset,
size: allocation_info.size,
memory_type_index: allocation_info.memory_type,
mapped,
dedicated_memory,
};
Ok(allocation)
}
/// Binds a buffer to aliasable memory at an allocation-local offset.
///
/// On success, ownership of `allocation` transfers to the returned `Allocation::Shared`;
/// store it in the bound resource and drop any caller-side references. The allocation is
/// freed when the last bound resource drops. The resource must be destroyed before its
/// allocation ownership is dropped. `requirements` must be the buffer's own requirements,
/// as returned by the buffer requirements query. If the supplied requirements are wrong,
/// the Vulkan bind (`vkBindBufferMemory2` via VMA) remains the authoritative validation.
/// Calls are externally synchronized by VMA.
pub fn bind_buffer_at_offset(
&self,
buffer: vk::Buffer,
requirements: &MemoryRequirements,
allocation: Arc<DeviceObject<GpuAllocation>>,
local_offset: vk::DeviceSize,
) -> Result<Allocation> {
self.require_offset_binding_support()?;
self.validate_shared_binding(&allocation, local_offset, *requirements)?;
unsafe {
self.allocator.lock().bind_buffer_memory2(
&allocation.raw,
local_offset,
buffer,
core::ptr::null(),
)?;
}
Ok(Allocation::Shared(allocation))
}
/// Binds an image to aliasable memory at an allocation-local offset.
///
/// On success, ownership of `allocation` transfers to the returned `Allocation::Shared`;
/// store it in the bound resource and drop any caller-side references. The allocation is
/// freed when the last bound resource drops. The resource must be destroyed before its
/// allocation ownership is dropped. `requirements` must be the image's own requirements,
/// as returned by the image requirements query. If the supplied requirements are wrong,
/// the Vulkan bind (`vkBindImageMemory2` via VMA) remains the authoritative validation.
/// Calls are externally synchronized by VMA.
pub fn bind_image_at_offset(
&self,
image: vk::Image,
requirements: &MemoryRequirements,
allocation: Arc<DeviceObject<GpuAllocation>>,
local_offset: vk::DeviceSize,
) -> Result<Allocation> {
self.require_offset_binding_support()?;
self.validate_shared_binding(&allocation, local_offset, *requirements)?;
unsafe {
self.allocator.lock().bind_image_memory2(
&allocation.raw,
local_offset,
image,
core::ptr::null(),
)?;
}
Ok(Allocation::Shared(allocation))
}
/// Binds a group of buffers and images into one aliasable allocation.
///
/// Every member is validated before any Vulkan bind is attempted. Binds then run in slice
/// order under one allocator lock; if a bind fails, the first error is returned and earlier
/// successful binds remain bound, matching repeated single-resource binds. `bound_count`
/// identifies that successfully bound prefix so callers can retain the allocation for it.
pub fn bind_group_at_offset(
&self,
allocation: &Arc<DeviceObject<GpuAllocation>>,
bindings: &[MemoryBinding],
) -> core::result::Result<(), GroupBindError> {
self.require_offset_binding_support()
.map_err(|error| GroupBindError {
error,
bound_count: 0,
})?;
for binding in bindings {
let (offset, requirements) = match binding {
MemoryBinding::Buffer {
offset,
requirements,
..
}
| MemoryBinding::Image {
offset,
requirements,
..
} => (*offset, *requirements),
};
self.validate_shared_binding(allocation, offset, requirements)
.map_err(|error| GroupBindError {
error,
bound_count: 0,
})?;
}
let allocator = self.allocator.lock();
for (bound_count, binding) in bindings.iter().enumerate() {
unsafe {
match binding {
MemoryBinding::Buffer { buffer, offset, .. } => {
allocator
.bind_buffer_memory2(
&allocation.raw,
*offset,
*buffer,
core::ptr::null(),
)
.map_err(|error| GroupBindError {
error: error.into(),
bound_count,
})?;
}
MemoryBinding::Image { image, offset, .. } => {
allocator
.bind_image_memory2(&allocation.raw, *offset, *image, core::ptr::null())
.map_err(|error| GroupBindError {
error: error.into(),
bound_count,
})?;
}
}
}
}
Ok(())
}
fn validate_shared_binding(
&self,
allocation: &DeviceObject<GpuAllocation>,
local_offset: vk::DeviceSize,
requirements: MemoryRequirements,
) -> Result<()> {
if !core::ptr::eq(Arc::as_ptr(&allocation.device().0), self) {
return Err(Error::AllocationDeviceMismatch);
}
if requirements.requires_dedicated_allocation {
return Err(Error::DedicatedAllocationRequiresResource);
}
let gpu_allocation: &GpuAllocation = allocation;
if requirements.memory_type_bits & (1 << gpu_allocation.memory_type_index()) == 0 {
return Err(Error::IncompatibleAllocationMemoryType {
memory_type_index: gpu_allocation.memory_type_index(),
memory_type_bits: requirements.memory_type_bits,
});
}
let end = local_offset.checked_add(requirements.size);
if requirements.alignment == 0
|| !local_offset.is_multiple_of(requirements.alignment)
|| end.is_none_or(|end| end > gpu_allocation.size())
{
return Err(Error::InvalidAllocationOffset {
offset: local_offset,
alignment: requirements.alignment,
required_size: requirements.size,
allocation_size: gpu_allocation.size(),
});
}
Ok(())
}
fn allocation_create_info(
&self,
desc: &AllocationDesc<'_>,
) -> Result<vk_mem::AllocationCreateInfo> {
use vk_mem::{AllocationCreateFlags, AllocationCreateInfo, MemoryUsage};
#[allow(deprecated)]
let mut info = AllocationCreateInfo {
usage: match desc.location {
MemoryLocation::Unknown => MemoryUsage::Unknown,
MemoryLocation::GpuOnly => MemoryUsage::GpuOnly,
MemoryLocation::CpuToGpu => MemoryUsage::CpuToGpu,
MemoryLocation::GpuToCpu => MemoryUsage::GpuToCpu,
},
..Default::default()
};
if matches!(
desc.location,
MemoryLocation::CpuToGpu | MemoryLocation::GpuToCpu
) {
let memory_properties = &self.adapter.properties.memory_properties;
let coherent_host_types = memory_properties
.memory_types
.iter()
.take(memory_properties.memory_type_count as usize)
.enumerate()
.filter(|(_, memory_type)| {
memory_type.property_flags.contains(
vk::MemoryPropertyFlags::HOST_VISIBLE
| vk::MemoryPropertyFlags::HOST_COHERENT,
)
})
.fold(0_u32, |bits, (index, _)| bits | (1 << index));
if coherent_host_types == 0 {
return Err(vk::Result::ERROR_FEATURE_NOT_PRESENT.into());
}
info.memory_type_bits = coherent_host_types;
info.flags |= AllocationCreateFlags::MAPPED;
}
let api_version = self
.instance
.inner
.api_version
.min(self.adapter.properties.device_api_version);
if desc.strategy == AllocationStrategy::Dedicated && api_version < vk::API_VERSION_1_1 {
return Err(Error::DedicatedAllocationRequiresApi11);
}
if desc.strategy == AllocationStrategy::Dedicated {
info.flags |= AllocationCreateFlags::DEDICATED_MEMORY;
}
Ok(info)
}
pub fn allocate_for_buffer(
&self,
buffer: vk::Buffer,
desc: AllocationDesc<'_>,
) -> Result<GpuAllocation> {
use vk_mem::Alloc as _;
let create_info = self.allocation_create_info(&desc)?;
let allocator = self.allocator.lock();
let raw = unsafe { allocator.allocate_memory_for_buffer(buffer, &create_info) }?;
let allocation_info = allocator.get_allocation_info2(&raw);
let dedicated_memory = allocation_info.dedicated_memory;
let allocation_info = allocation_info.allocation_info;
let mapped = NonNull::new(allocation_info.mapped_data.cast::<u8>()).map(SendSyncPtr);
Ok(GpuAllocation {
raw,
memory: allocation_info.device_memory,
offset: allocation_info.offset,
size: allocation_info.size,
memory_type_index: allocation_info.memory_type,
mapped,
dedicated_memory,
})
}
pub fn allocate_for_image(
&self,
image: vk::Image,
desc: AllocationDesc<'_>,
) -> Result<GpuAllocation> {
use vk_mem::Alloc as _;
let create_info = self.allocation_create_info(&desc)?;
let allocator = self.allocator.lock();
let raw = unsafe { allocator.allocate_memory_for_image(image, &create_info) }?;
let allocation_info = allocator.get_allocation_info2(&raw);
let dedicated_memory = allocation_info.dedicated_memory;
let allocation_info = allocation_info.allocation_info;
let mapped = NonNull::new(allocation_info.mapped_data.cast::<u8>()).map(SendSyncPtr);
Ok(GpuAllocation {
raw,
memory: allocation_info.device_memory,
offset: allocation_info.offset,
size: allocation_info.size,
memory_type_index: allocation_info.memory_type,
mapped,
dedicated_memory,
})
}
pub fn bind_buffer_memory(&self, alloc: &GpuAllocation, buffer: vk::Buffer) -> Result<()> {
unsafe {
self.allocator
.lock()
.bind_buffer_memory(&alloc.raw, buffer)?
};
Ok(())
}
pub fn bind_image_memory(&self, alloc: &GpuAllocation, image: vk::Image) -> Result<()> {
unsafe { self.allocator.lock().bind_image_memory(&alloc.raw, image)? };
Ok(())
}
pub fn sync_threadpool(&self) -> &sync::SyncThreadpool {
&self.sync_threadpool
}
@ -1718,8 +1072,8 @@ pub mod asdf {
}
impl traits::ExternallyManagedObject<Device> for GpuAllocation {
unsafe fn destroy(mut self, device: &Device) {
unsafe { device.allocator.lock().free_memory(&mut self.raw) };
unsafe fn destroy(self, device: &Device) {
_ = device.alloc2.lock().free(self);
}
}
@ -1801,11 +1155,4 @@ pub mod asdf {
vk::FALSE
);
}
#[test]
fn gpu_allocation_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<super::GpuAllocation>();
}
}

View file

@ -26,13 +26,10 @@ pub struct InstanceExtensions {
}
pub struct InstanceInner {
// Messengers must be destroyed while the raw instance is still alive. Keep this first so
// it drops before `raw` during instance teardown.
pub _debug_utils: DebugUtils,
pub raw: ash::Instance,
pub api_version: u32,
pub extensions: InstanceExtensions,
pub entry: Entry,
pub _debug_utils: DebugUtils,
}
#[derive(Clone)]
@ -76,7 +73,7 @@ const DEBUG_LAYER_SETTINGS: &[vk::LayerSettingEXT] = &[
p_layer_name: VALIDATION_LAYER.as_ptr(),
p_setting_name: c"validate_best_practices".as_ptr(),
value_count: 1,
p_values: &[vk::TRUE] as *const u32 as _,
p_values: &[1u8; 1] as *const u8 as _,
ty: vk::LayerSettingTypeEXT::BOOL32,
_marker: core::marker::PhantomData,
},
@ -84,7 +81,7 @@ const DEBUG_LAYER_SETTINGS: &[vk::LayerSettingEXT] = &[
p_layer_name: VALIDATION_LAYER.as_ptr(),
p_setting_name: c"validate_best_practices_amd".as_ptr(),
value_count: 1,
p_values: &[vk::TRUE] as *const u32 as _,
p_values: &[1u8; 1] as *const u8 as _,
ty: vk::LayerSettingTypeEXT::BOOL32,
_marker: core::marker::PhantomData,
},
@ -92,7 +89,7 @@ const DEBUG_LAYER_SETTINGS: &[vk::LayerSettingEXT] = &[
p_layer_name: VALIDATION_LAYER.as_ptr(),
p_setting_name: c"validate_sync".as_ptr(),
value_count: 1,
p_values: &[vk::TRUE] as *const u32 as _,
p_values: &[1u8; 1] as *const u8 as _,
ty: vk::LayerSettingTypeEXT::BOOL32,
_marker: core::marker::PhantomData,
},
@ -124,14 +121,16 @@ impl Instance {
unsafe { entry.try_enumerate_instance_version()? }.unwrap_or(vk::API_VERSION_1_0);
let app_info = vk::ApplicationInfo::default()
// ash/VMA support Vulkan 1.3, and the application must not request a
// version newer than the loader supports.
.api_version(version.min(vk::API_VERSION_1_3))
.api_version(if version < vk::API_VERSION_1_1 {
vk::API_VERSION_1_0
} else {
// ash doesn't support 1.4 yet
vk::API_VERSION_1_3
})
.application_name(&app_name)
.application_version(desc.app_version)
.engine_name(c"Bevy Engine")
.engine_version(vk::make_api_version(0, 0, 1, 0));
let api_version = app_info.api_version;
let mut validation_info =
vk::LayerSettingsCreateInfoEXT::default().settings(desc.layer_settings);
@ -151,9 +150,8 @@ impl Instance {
let mut requested_extensions = desc.instance_extensions.to_vec();
requested_extensions.push(make_extension!(ext::debug_utils as 1));
if !desc.layer_settings.is_empty() {
#[cfg(debug_assertions)]
requested_extensions.push(make_extension!(ext::layer_settings));
}
// These are wanted for surface capabilities querying, but not strictly
// required, and are only supported on newer devices, especially on
@ -225,10 +223,9 @@ impl Instance {
};
let instance = Arc::new(InstanceInner {
_debug_utils: debug_utils,
raw: instance,
api_version,
extensions,
_debug_utils: debug_utils,
entry,
});

View file

@ -16,9 +16,6 @@ pub mod instance;
pub mod pipeline_cache;
pub mod queue;
pub mod sync;
#[cfg(feature = "testing")]
#[doc(hidden)]
pub mod testing;
pub use device::{Device, DeviceOwned, DeviceRef, Extension};
pub use instance::{DebugUtils, DebugUtilsCreateInfo, Instance};
@ -77,45 +74,10 @@ pub enum Error {
Utf8Error(#[from] core::str::Utf8Error),
#[error(transparent)]
NulError(#[from] std::ffi::NulError),
#[error(transparent)]
GpuAllocatorError(#[from] gpu_allocator::AllocationError),
#[error("No Physical Device found.")]
NoPhysicalDevice,
#[error(
"Dedicated allocation requires Vulkan 1.1 or both VK_KHR_dedicated_allocation and VK_KHR_get_memory_requirements2 extensions."
)]
DedicatedAllocationRequiresApi11,
#[error(
"Offset-aware memory binding requires Vulkan 1.1 or VK_KHR_get_memory_requirements2, VK_KHR_bind_memory2, and VK_KHR_dedicated_allocation to be enabled."
)]
OffsetBindingRequiresApi11,
#[error(
"No memory type matches requirements mask {memory_type_bits:#010x} and required flags {property_flags:?}."
)]
NoCompatibleMemoryType {
memory_type_bits: u32,
property_flags: vk::MemoryPropertyFlags,
},
#[error(
"Memory allocation requirements request a dedicated allocation, which cannot be allocated without its resource."
)]
DedicatedAllocationRequiresResource,
#[error(
"Allocation offset {offset} is invalid: alignment is {alignment}, requested size is {required_size}, allocation size is {allocation_size}."
)]
InvalidAllocationOffset {
offset: u64,
alignment: u64,
required_size: u64,
allocation_size: u64,
},
#[error(
"Allocation memory type {memory_type_index} is not allowed by requirements mask {memory_type_bits:#010x}."
)]
IncompatibleAllocationMemoryType {
memory_type_index: u32,
memory_type_bits: u32,
},
#[error("The allocation and resource belong to different Vulkan devices.")]
AllocationDeviceMismatch,
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(

View file

@ -364,12 +364,10 @@ impl DeviceQueueInfos {
})
.expect("No graphics queue family found");
if display_handle.is_some() {
assert!(
graphics.flags.contains(QueueFlags::PRESENT),
"Selected graphics queue family does not support presentation"
);
}
let compute = queue_families
.iter()

View file

@ -1,513 +0,0 @@
//! Real Vulkan test devices with Khronos validation output captured for assertions.
//!
//! Run the device smoke tests with `cargo test -p renderer_device --features testing`.
use std::{
ffi::{CStr, c_void},
fmt,
sync::{Arc, Mutex},
};
use ash::{Entry, ext, khr, vk};
use crate::{
Device, Instance, PhysicalDeviceFeatures,
instance::{DebugUtils, InstanceDesc, VALIDATION_LAYER},
};
const TEST_LAYER_SETTINGS: &[vk::LayerSettingEXT<'static>] = &[
vk::LayerSettingEXT {
p_layer_name: VALIDATION_LAYER.as_ptr(),
p_setting_name: c"validate_sync".as_ptr(),
value_count: 1,
p_values: &[vk::TRUE] as *const u32 as _,
ty: vk::LayerSettingTypeEXT::BOOL32,
_marker: core::marker::PhantomData,
},
vk::LayerSettingEXT {
p_layer_name: VALIDATION_LAYER.as_ptr(),
p_setting_name: c"validate_best_practices".as_ptr(),
value_count: 1,
p_values: &[vk::TRUE] as *const u32 as _,
ty: vk::LayerSettingTypeEXT::BOOL32,
_marker: core::marker::PhantomData,
},
];
/// One validation callback message captured from a real Vulkan instance.
#[derive(Debug, Clone)]
pub struct CapturedMessage {
pub severity: vk::DebugUtilsMessageSeverityFlagsEXT,
pub types: vk::DebugUtilsMessageTypeFlagsEXT,
pub message: String,
}
impl fmt::Display for CapturedMessage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?} {:?}: {}", self.severity, self.types, self.message)
}
}
/// A Vulkan instance and logical device configured for validation-backed tests.
///
/// Instances are independent; tests may acquire their own device without sharing Vulkan state.
pub struct TestVulkan {
device: Device,
// Drop the callback messenger while its instance and user data are still alive.
_messenger: DebugUtils,
messages: Arc<Mutex<Vec<CapturedMessage>>>,
instance: Instance,
}
impl TestVulkan {
/// Attempt to create a Vulkan instance/device, printing a reason and returning `None` when
/// Vulkan, a physical device, or the Khronos validation layer is unavailable.
pub fn acquire() -> Option<Self> {
match Self::create() {
Ok(test) => {
eprintln!(
"renderer_device Vulkan test adapter: {}",
test.device
.properties()
.core
.device_name_as_c_str()
.map(CStr::to_string_lossy)
.unwrap_or_else(|_| "<invalid device name>".into())
);
Some(test)
}
Err(reason) => {
eprintln!("Skipping Vulkan test: {reason}");
None
}
}
}
/// Create a validation-backed Vulkan test device or panic. Useful for CI jobs where Vulkan is
/// a required test dependency.
pub fn expect() -> Self {
Self::acquire().expect("Vulkan test device is required")
}
fn create() -> Result<Self, String> {
// Do not rewrite loader environment variables here. The loader's default search paths and
// environment provided by the development shell are both supported.
let entry = unsafe { Entry::load() }.map_err(|error| format!("loading Vulkan: {error}"))?;
let layers = unsafe { entry.enumerate_instance_layer_properties() }
.map_err(|error| format!("enumerating instance layers: {error}"))?;
if !layers
.iter()
.any(|layer| layer.layer_name_as_c_str().ok() == Some(VALIDATION_LAYER))
{
return Err("VK_LAYER_KHRONOS_validation is not available".into());
}
let instance = Instance::new(&InstanceDesc {
app_name: Some("renderer_device validation tests"),
instance_extensions: &[crate::make_extension!(khr::surface)],
layer_settings: TEST_LAYER_SETTINGS,
layers: &[VALIDATION_LAYER],
display_handle: None,
..Default::default()
})
.map_err(|error| format!("creating Vulkan instance: {error}"))?;
let messages = Arc::new(Mutex::new(Vec::new()));
let debug_loader =
ext::debug_utils::Instance::new(&instance.inner.entry, &instance.inner.raw);
let create_info = vk::DebugUtilsMessengerCreateInfoEXT::default()
.message_severity(
vk::DebugUtilsMessageSeverityFlagsEXT::ERROR
| vk::DebugUtilsMessageSeverityFlagsEXT::WARNING,
)
.message_type(
vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
| vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
| vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
)
.user_data(Arc::as_ptr(&messages) as *mut c_void)
.pfn_user_callback(Some(capture_callback));
let raw_messenger =
unsafe { debug_loader.create_debug_utils_messenger(&create_info, None) }
.map_err(|error| format!("creating validation message messenger: {error}"))?;
let messenger = DebugUtils {
instance: debug_loader,
messenger: raw_messenger,
};
let adapter = instance
.choose_adapter_default(None, &[], None)
.map_err(|error| format!("selecting physical device: {error}"))?;
let supported = &adapter.features;
let requested = PhysicalDeviceFeatures {
core: vk::PhysicalDeviceFeatures {
shader_sampled_image_array_dynamic_indexing: supported
.core
.shader_sampled_image_array_dynamic_indexing,
..Default::default()
},
core12: vk::PhysicalDeviceVulkan12Features {
descriptor_indexing: supported.core12.descriptor_indexing,
descriptor_binding_partially_bound: supported
.core12
.descriptor_binding_partially_bound,
descriptor_binding_update_unused_while_pending: supported
.core12
.descriptor_binding_update_unused_while_pending,
descriptor_binding_sampled_image_update_after_bind: supported
.core12
.descriptor_binding_sampled_image_update_after_bind,
descriptor_binding_storage_buffer_update_after_bind: supported
.core12
.descriptor_binding_storage_buffer_update_after_bind,
runtime_descriptor_array: supported.core12.runtime_descriptor_array,
timeline_semaphore: supported.core12.timeline_semaphore,
..Default::default()
},
core13: vk::PhysicalDeviceVulkan13Features {
synchronization2: supported.core13.synchronization2,
dynamic_rendering: supported.core13.dynamic_rendering,
maintenance4: supported.core13.maintenance4,
..Default::default()
},
..Default::default()
};
let device = adapter
.create_logical_device(&instance, &[], requested, None)
.map_err(|error| format!("creating logical device: {error}"))?;
Ok(Self {
device,
_messenger: messenger,
messages,
instance,
})
}
pub fn instance(&self) -> &Instance {
&self.instance
}
pub fn device(&self) -> &Device {
&self.device
}
pub fn physical_device(&self) -> &crate::PhysicalDeviceInfo {
self.device.physical_device()
}
/// Prints all warnings and fails only for validation errors.
pub fn assert_no_validation_errors(&self) {
let messages = self.take_messages();
for message in &messages {
if message
.severity
.contains(vk::DebugUtilsMessageSeverityFlagsEXT::WARNING)
{
eprintln!("Vulkan validation warning: {message}");
}
}
let errors = messages
.into_iter()
.filter(|message| {
message
.severity
.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR)
})
.collect::<Vec<_>>();
assert!(
errors.is_empty(),
"Vulkan validation errors:\n{}",
format_messages(&errors)
);
}
/// Fail for warnings as well as errors, for tests that explicitly require a clean log.
pub fn assert_no_warnings(&self) {
let messages = self.take_messages();
assert!(
messages.is_empty(),
"Vulkan validation messages:\n{}",
format_messages(&messages)
);
}
/// Drain captured messages; useful for tests that intentionally exercise a validation error.
pub fn take_messages(&self) -> Vec<CapturedMessage> {
std::mem::take(
&mut *self
.messages
.lock()
.expect("validation message lock poisoned"),
)
}
}
fn format_messages(messages: &[CapturedMessage]) -> String {
messages
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
}
unsafe extern "system" fn capture_callback(
severity: vk::DebugUtilsMessageSeverityFlagsEXT,
types: vk::DebugUtilsMessageTypeFlagsEXT,
callback_data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>,
user_data: *mut c_void,
) -> vk::Bool32 {
if callback_data.is_null() || user_data.is_null() {
return vk::FALSE;
}
let data = unsafe { &*callback_data };
let message = if data.p_message.is_null() {
"<empty validation message>".into()
} else {
unsafe { CStr::from_ptr(data.p_message) }
.to_string_lossy()
.into_owned()
};
let messages = unsafe { &*(user_data as *const Mutex<Vec<CapturedMessage>>) };
if let Ok(mut messages) = messages.lock() {
messages.push(CapturedMessage {
severity,
types,
message,
});
}
vk::FALSE
}
#[cfg(test)]
mod tests {
use super::*;
use crate::device::{
Allocation, AllocationDesc, AllocationStrategy, DeviceObject, MemoryLocation,
};
#[test]
fn instance_and_device_creation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
assert_ne!(test.instance().inner.raw.handle(), vk::Instance::null());
assert_ne!(test.device().dev().handle(), vk::Device::null());
test.device().wait_idle().expect("device wait idle");
test.assert_no_validation_errors();
}
#[test]
fn buffer_lifecycle_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let info = vk::BufferCreateInfo::default()
.size(256)
.usage(vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let buffer = unsafe { device.dev().create_buffer(&info, None) }.expect("create buffer");
let buffer = DeviceObject::new(device.clone(), buffer);
let allocation = device
.allocate_for_buffer(
*buffer,
AllocationDesc {
name: "renderer_device validation smoke buffer",
location: MemoryLocation::CpuToGpu,
strategy: AllocationStrategy::AllocatorManaged,
},
)
.expect("allocate buffer memory");
let allocation = DeviceObject::new(device.clone(), allocation);
device
.bind_buffer_memory(&allocation, *buffer)
.expect("bind buffer memory");
drop(buffer);
drop(allocation);
test.assert_no_validation_errors();
}
#[test]
fn aliasable_buffer_allocation_binds_at_zero_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let create_info = vk::BufferCreateInfo::default()
.size(256)
.usage(vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let buffer = unsafe { device.dev().create_buffer(&create_info, None) }
.expect("create aliasable buffer");
let buffer = DeviceObject::new(device.clone(), buffer);
let requirements = device
.buffer_memory_requirements(*buffer)
.expect("query buffer requirements");
let memory = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate aliasable buffer memory");
let binding = device
.bind_buffer_at_offset(*buffer, &requirements, memory, 0)
.expect("bind aliasable buffer memory");
assert!(matches!(binding, Allocation::Shared(_)));
drop(buffer);
drop(binding);
test.assert_no_validation_errors();
}
#[test]
fn aliasable_image_allocation_binds_at_zero_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let create_info = vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
.extent(vk::Extent3D {
width: 16,
height: 16,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(vk::ImageUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED);
let image = unsafe { device.dev().create_image(&create_info, None) }
.expect("create aliasable image");
let image = DeviceObject::new(device.clone(), image);
let requirements = device
.image_memory_requirements(*image)
.expect("query image requirements");
let memory = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate aliasable image memory");
let binding = device
.bind_image_at_offset(*image, &requirements, memory, 0)
.expect("bind aliasable image memory");
assert!(matches!(binding, Allocation::Shared(_)));
drop(image);
drop(binding);
test.assert_no_validation_errors();
}
#[test]
fn aliasable_binding_rejects_out_of_bounds_and_unaligned_offsets() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let create_info = vk::BufferCreateInfo::default()
.size(256)
.usage(vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let make_buffer = || {
let raw = unsafe { device.dev().create_buffer(&create_info, None) }
.expect("create offset validation buffer");
DeviceObject::new(device.clone(), raw)
};
let buffer = make_buffer();
let requirements = device
.buffer_memory_requirements(*buffer)
.expect("query offset validation requirements");
let memory = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate offset validation memory");
assert!(matches!(
device.bind_buffer_at_offset(*buffer, &requirements, memory, requirements.size),
Err(crate::Error::InvalidAllocationOffset { .. })
));
drop(buffer);
let buffer = make_buffer();
let requirements = device
.buffer_memory_requirements(*buffer)
.expect("query unaligned validation requirements");
if requirements.alignment > 1 {
let memory = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate unaligned validation memory");
assert!(matches!(
device.bind_buffer_at_offset(*buffer, &requirements, memory, 1),
Err(crate::Error::InvalidAllocationOffset { .. })
));
}
drop(buffer);
test.assert_no_validation_errors();
}
#[test]
fn validation_capture_works() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
let buffer_info = vk::BufferCreateInfo::default()
.size(16)
.usage(vk::BufferUsageFlags::TRANSFER_DST)
.sharing_mode(vk::SharingMode::EXCLUSIVE);
let buffer = unsafe { device.dev().create_buffer(&buffer_info, None) }
.expect("create validation probe buffer");
let buffer = DeviceObject::new(device.clone(), buffer);
let allocation = device
.allocate_for_buffer(
*buffer,
AllocationDesc {
name: "renderer_device validation probe buffer",
location: MemoryLocation::CpuToGpu,
strategy: AllocationStrategy::AllocatorManaged,
},
)
.expect("allocate validation probe buffer");
let allocation = DeviceObject::new(device.clone(), allocation);
device
.bind_buffer_memory(&allocation, *buffer)
.expect("bind validation probe buffer");
let pool_info = vk::CommandPoolCreateInfo::default()
.queue_family_index(device.queues.graphics().family.index.index());
let pool = unsafe { device.dev().create_command_pool(&pool_info, None) }
.expect("create command pool");
let allocate_info = vk::CommandBufferAllocateInfo::default()
.command_pool(pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
let command_buffer = unsafe { device.dev().allocate_command_buffers(&allocate_info) }
.expect("allocate command buffer")[0];
unsafe {
device
.dev()
.begin_command_buffer(command_buffer, &vk::CommandBufferBeginInfo::default())
.expect("begin command buffer");
// The size must be a multiple of four. The invalid command is never submitted.
device
.dev()
.cmd_fill_buffer(command_buffer, *buffer, 0, 3, 0);
device
.dev()
.end_command_buffer(command_buffer)
.expect("end command buffer");
device.dev().destroy_command_pool(pool, None);
}
drop(buffer);
drop(allocation);
let messages = test.take_messages();
assert!(
messages.iter().any(|message| {
message
.severity
.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR)
&& message.message.contains("VUID-vkCmdFillBuffer-size")
}),
"expected a vkCmdFillBuffer size validation error, got:\n{}",
format_messages(&messages)
);
}
}

View file

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

View file

@ -1,381 +0,0 @@
//! 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. The resolve benchmarks
//! include device-dependent placement, alias-barrier rebuilding, and pass hoisting
//! in the timed region.
use std::{hint::black_box, sync::Arc, time::Duration};
use ash::vk;
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use renderer_device::device::{MemoryLocation, QueueFlags};
use renderer_device::testing::TestVulkan;
use renderer_rendergraph::{
Buffer, BufferRange, GraphBufferDesc, RenderGraph,
commands::{BeginRendering, Command, EndRendering},
recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap},
resources::{ResourceState, SubresourceScope, SubresourceScopeInner},
};
use renderer_resources::{buffers::Buffer as DeviceBuffer, buffers::BufferDesc};
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(())
}
}
struct UseWithBridge {
buffer: Buffer,
bridge: Buffer,
}
impl Command for UseWithBridge {
fn side_effects(&self, command_index: CommandIndex, map: &mut SideEffectMap) {
for buffer in [self.buffer, self.bridge] {
map.insert(
command_index,
buffer.id(),
SubresourceScope {
stage: vk::PipelineStageFlags2::COMPUTE_SHADER,
access: vk::AccessFlags2::SHADER_WRITE,
subresource: SubresourceScopeInner::Buffer(BufferRange::full()),
},
);
}
}
}
impl RecordedCommand for UseWithBridge {
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 build_overlapping(n_groups: usize, per: usize) -> RenderGraph {
let mut graph = RenderGraph::new();
let buffers: 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()
});
graph.push(BeginRendering {
color: vec![],
depth: None,
});
for _ in 0..per {
for &buffer in &buffers {
graph.push(Use {
buffer,
write: true,
});
}
}
graph.push(Use {
buffer: shared,
write: true,
});
graph.push(EndRendering);
graph.push(BeginRendering {
color: vec![],
depth: None,
});
for _ in 0..per {
for &buffer in &buffers {
graph.push(Use {
buffer,
write: true,
});
}
}
graph.push(Use {
buffer: shared,
write: false,
});
graph.push(EndRendering);
graph
}
fn build_aliasing(
n_groups: usize,
per: usize,
bridge: &Arc<DeviceBuffer>,
) -> 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 bridge = graph
.import_buffer(bridge.clone(), ResourceState::default())
.unwrap();
// Keep the first set outside the pass; render-pass resources interfere with
// each other by design and otherwise prevent placement reuse.
for _ in 0..per {
for &buffer in &a {
graph.push(Use {
buffer,
write: true,
});
}
}
for &buffer in &a {
graph.push(UseWithBridge { buffer, bridge });
}
graph.push(BeginRendering {
color: vec![],
depth: None,
});
for &buffer in &b {
graph.push(UseWithBridge { buffer, bridge });
}
for _ in 0..per {
for &buffer in &b {
graph.push(Use {
buffer,
write: true,
});
}
}
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();
}
fn resolve_benchmarks(criterion: &mut Criterion, device: &renderer_device::Device) {
let bridge = Arc::new(
DeviceBuffer::new(
device.clone(),
BufferDesc {
size: 4096,
usage: vk::BufferUsageFlags::STORAGE_BUFFER,
queue_families: QueueFlags::empty(),
mem_location: MemoryLocation::GpuOnly,
..Default::default()
},
)
.expect("create alias benchmark bridge buffer"),
);
let mut group = criterion.benchmark_group("compile_resolve");
group
.measurement_time(Duration::from_secs(4))
.sample_size(20);
for (name, overlapping) in [("alias", false), ("no_alias", true)] {
for per in [20, 50, 100] {
let graph = if overlapping {
build_overlapping(50, per)
} else {
build_aliasing(50, per, &bridge)
};
let mut probe = graph.compile().unwrap();
probe.resolve(device).unwrap();
assert_eq!(
probe
.resource_lifetimes()
.values()
.any(|lifetime| lifetime.placement.aliased),
!overlapping,
"unexpected placement aliasing in {name}/per_{per}"
);
let alias_barriers = probe
.barriers()
.iter()
.flat_map(|barrier| &barrier.resources)
.filter(|resource| {
probe
.resource_lifetimes()
.get(&resource.id)
.is_some_and(|lifetime| lifetime.placement.aliased)
&& resource.origin != renderer_rendergraph::BarrierOrigin::Initial
})
.count();
assert_eq!(
alias_barriers > 0,
!overlapping,
"unexpected alias barriers in {name}/per_{per}"
);
group.bench_function(format!("{name}/per_{per}"), |bencher| {
bencher.iter_batched(
|| {
if overlapping {
build_overlapping(50, per)
} else {
build_aliasing(50, per, &bridge)
}
},
|graph| {
let mut compiled = graph.compile().unwrap();
compiled.resolve(device).unwrap();
black_box(compiled.barriers().len())
},
BatchSize::LargeInput,
);
});
}
}
group.finish();
}
fn benchmarks(criterion: &mut Criterion) {
let test = TestVulkan::acquire();
compile_benchmarks(criterion);
if let Some(test) = &test {
resolve_benchmarks(criterion, test.device());
} else {
eprintln!("Skipping compile_resolve benchmarks: no Vulkan device is available");
}
}
criterion_group!(benches, benchmarks);
criterion_main!(benches);

View file

@ -1,7 +1,4 @@
use std::{cell::RefCell, sync::Arc};
use ash::vk;
use renderer_resources::images::{ImageView, ImageViewDesc};
use crate::{
recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap},
@ -12,243 +9,6 @@ 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
/// itself to a Vulkan command buffer after resource binding.
pub trait Command: RecordedCommand {
@ -299,10 +59,6 @@ impl Command for CopyBuffer {
}
impl RecordedCommand for CopyBuffer {
fn kind(&self) -> CommandKind {
CommandKind::Transfer
}
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
let region = vk::BufferCopy2::default()
.src_offset(self.src_range.offset)
@ -348,10 +104,6 @@ impl Command for ClearTexture {
}
impl RecordedCommand for ClearTexture {
fn kind(&self) -> CommandKind {
CommandKind::Transfer
}
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
let value = vk::ClearColorValue {
float32: self.color,
@ -467,14 +219,6 @@ impl Command 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> {
validate_managed_descriptor_sets(&self.descriptor_sets).map(|_| ())
}
@ -591,160 +335,6 @@ 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(
sets: &[GraphDescriptorSet],
) -> Result<Vec<vk::DescriptorSet>, RecordError> {

File diff suppressed because it is too large Load diff

View file

@ -1,250 +0,0 @@
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,19 +3,10 @@
pub mod commands;
pub mod graph;
mod graph_compiler;
pub mod recorder;
pub mod resources;
pub use commands::{
AttachmentView, BeginRendering, ColorAttachment, CommandKind, DepthStencilAttachment,
EndRendering, RenderPassDesc,
};
pub use graph::{
BarrierOrigin, CompiledGraph, GraphError, HappensBefore, MemoryBarrier, Placement, RenderGraph,
RenderPassSpan, ResourceFrontiers, ResourceInterference, ResourceLifetime, ResourceUse,
aliasing_permitted,
};
pub use graph::{CompiledGraph, GraphError, RenderGraph, ResourceInterference};
pub use recorder::{CommandIndex, CommandList, CommandRecorder, SideEffectMap};
pub use resources::{
Buffer, BufferRange, BufferSlice, DescriptorResourceAccess, DescriptorResourceIdentity,

View file

@ -1,11 +1,6 @@
use std::{
cell::RefCell,
collections::{BTreeMap, BTreeSet, HashMap},
sync::Arc,
};
use std::collections::BTreeMap;
use ash::vk;
use renderer_resources::images::{ImageView, ImageViewDesc};
use crate::resources::{
BufferRange, ResourceId, Subresource, SubresourceScope, SubresourceScopeInner,
@ -43,7 +38,6 @@ pub struct ResourceAccess {
#[derive(Debug, Default, Clone)]
pub struct SideEffectMap {
accesses: Vec<Vec<ResourceAccess>>,
lifetime_only: BTreeSet<(usize, ResourceId)>,
}
impl SideEffectMap {
@ -87,23 +81,6 @@ impl SideEffectMap {
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] {
self.accesses
.get(command_index.0)
@ -115,12 +92,10 @@ impl SideEffectMap {
}
}
pub type BoxedCommand = Box<dyn RecordedCommand>;
/// A safe, type-erased temporary command buffer. Each command is owned until
/// compilation/recording, while its access declarations are stored separately.
pub struct CommandList {
pub(crate) commands: Vec<BoxedCommand>,
pub(crate) commands: Vec<Box<dyn RecordedCommand>>,
pub side_effects: SideEffectMap,
}
@ -130,18 +105,6 @@ pub trait RecordedCommand {
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>;
}
@ -186,7 +149,6 @@ impl Default for CommandList {
pub enum RecordError {
MissingBuffer(ResourceId),
MissingImage(ResourceId),
MissingView(ResourceId),
UnresolvedCreatedBuffer(ResourceId),
UnresolvedCreatedImage(ResourceId),
DescriptorSet(String),
@ -199,18 +161,6 @@ pub struct CommandRecorder<'a> {
pub command_buffer: vk::CommandBuffer,
pub buffers: BTreeMap<ResourceId, vk::Buffer>,
pub images: BTreeMap<ResourceId, vk::Image>,
pub views: HashMap<(ResourceId, ImageViewDesc), Arc<ImageView>>,
}
#[derive(Default)]
struct BarrierScratch {
memory: Vec<vk::MemoryBarrier2<'static>>,
buffers: Vec<vk::BufferMemoryBarrier2<'static>>,
images: Vec<vk::ImageMemoryBarrier2<'static>>,
}
thread_local! {
static BARRIER_SCRATCH: RefCell<BarrierScratch> = RefCell::new(BarrierScratch::default());
}
impl<'a> CommandRecorder<'a> {
@ -220,7 +170,6 @@ impl<'a> CommandRecorder<'a> {
command_buffer,
buffers: BTreeMap::new(),
images: BTreeMap::new(),
views: HashMap::new(),
}
}
@ -238,44 +187,14 @@ impl<'a> CommandRecorder<'a> {
.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) {
BARRIER_SCRATCH.with(|scratch| {
let mut scratch = scratch.borrow_mut();
scratch.memory.clear();
scratch
.memory
.extend(barrier.memory_barriers.iter().map(|resource| {
vk::MemoryBarrier2::default()
.src_stage_mask(resource.src_stage)
.src_access_mask(resource.src_access)
.dst_stage_mask(resource.dst_stage)
.dst_access_mask(resource.dst_access)
}));
scratch.buffers.clear();
scratch
.buffers
.extend(
barrier
let buffer_barriers: Vec<_> = barrier
.resources
.iter()
.filter_map(|resource| match resource.range {
Subresource::Buffer(BufferRange { offset, size }) => {
self.buffers.get(&resource.id).map(|&buffer| {
let (src, dst) =
if let Some(queue_transfer) = resource.queue_transfer {
let (src, dst) = if let Some(queue_transfer) = resource.queue_transfer {
(queue_transfer.src.index(), queue_transfer.dst.index())
} else {
(vk::QUEUE_FAMILY_IGNORED, vk::QUEUE_FAMILY_IGNORED)
@ -294,62 +213,35 @@ impl<'a> CommandRecorder<'a> {
})
}
Subresource::Texture(_) => None,
}),
);
})
.collect();
scratch.images.clear();
scratch
.images
.extend(
barrier
let image_barriers: Vec<_> = barrier
.resources
.iter()
.filter_map(|resource| match resource.range {
Subresource::Texture(range) => {
self.images.get(&resource.id).map(|&image| {
Subresource::Texture(range) => self.images.get(&resource.id).map(|&image| {
vk::ImageMemoryBarrier2::default()
.src_stage_mask(resource.src_stage)
.src_access_mask(resource.src_access)
.dst_stage_mask(resource.dst_stage)
.dst_access_mask(resource.dst_access)
.old_layout(
resource
.old_layout
.unwrap_or(vk::ImageLayout::UNDEFINED),
)
.new_layout(
resource.new_layout.unwrap_or(vk::ImageLayout::GENERAL),
)
.old_layout(resource.old_layout.unwrap_or(vk::ImageLayout::UNDEFINED))
.new_layout(resource.new_layout.unwrap_or(vk::ImageLayout::GENERAL))
.image(image)
.subresource_range(range)
})
}
Subresource::Buffer(_) => None,
}),
);
Subresource::Buffer(_) => None,
})
.collect();
let dependency = vk::DependencyInfo::default()
.memory_barriers(&scratch.memory)
.buffer_memory_barriers(&scratch.buffers)
.image_memory_barriers(&scratch.images);
.buffer_memory_barriers(&buffer_barriers)
.image_memory_barriers(&image_barriers);
unsafe {
self.device
.cmd_pipeline_barrier2(self.command_buffer, &dependency);
}
});
}
}
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,
}
}
@ -363,58 +255,6 @@ pub(crate) fn scope_range(scope: SubresourceScope) -> Subresource {
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{MemoryBarrier, PipelineBarrier};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
static MEMORY_BARRIER_COUNT: AtomicU32 = AtomicU32::new(0);
static MEMORY_BARRIER_SRC_STAGE: AtomicU64 = AtomicU64::new(0);
unsafe extern "system" fn capture_pipeline_barrier(
_device: vk::Device,
_command_buffer: vk::CommandBuffer,
dependency: *const vk::DependencyInfo<'_>,
) {
let dependency = unsafe { &*dependency };
MEMORY_BARRIER_COUNT.store(dependency.memory_barrier_count, Ordering::SeqCst);
let memory = unsafe { &*dependency.p_memory_barriers };
MEMORY_BARRIER_SRC_STAGE.store(memory.src_stage_mask.as_raw(), Ordering::SeqCst);
}
#[test]
fn pipeline_barrier_emits_memory_barriers_in_dependency_info() {
let device = unsafe {
ash::Device::load_with(
|name| {
if name.to_bytes() == b"vkCmdPipelineBarrier2" {
capture_pipeline_barrier as *const () as *const std::ffi::c_void
} else {
std::ptr::null()
}
},
vk::Device::null(),
)
};
let barrier = PipelineBarrier {
before_command: CommandIndex(0),
memory_barriers: vec![MemoryBarrier {
src_stage: vk::PipelineStageFlags2::COMPUTE_SHADER,
src_access: vk::AccessFlags2::SHADER_STORAGE_WRITE,
dst_stage: vk::PipelineStageFlags2::FRAGMENT_SHADER,
dst_access: vk::AccessFlags2::SHADER_READ,
}],
resources: tinyvec::TinyVec::new(),
};
MEMORY_BARRIER_COUNT.store(0, Ordering::SeqCst);
MEMORY_BARRIER_SRC_STAGE.store(0, Ordering::SeqCst);
CommandRecorder::new(&device, vk::CommandBuffer::null()).pipeline_barrier(&barrier);
assert_eq!(MEMORY_BARRIER_COUNT.load(Ordering::SeqCst), 1);
assert_eq!(
MEMORY_BARRIER_SRC_STAGE.load(Ordering::SeqCst),
vk::PipelineStageFlags2::COMPUTE_SHADER.as_raw()
);
}
fn read_scope(
offset: u64,
@ -435,7 +275,7 @@ mod tests {
let command = map.begin_command();
map.insert(
command,
ResourceId::from_u32(0),
ResourceId(0),
read_scope(
0,
64,
@ -445,7 +285,7 @@ mod tests {
);
map.insert(
command,
ResourceId::from_u32(0),
ResourceId(0),
read_scope(
16,
16,
@ -476,7 +316,7 @@ mod tests {
let command = reverse.begin_command();
reverse.insert(
command,
ResourceId::from_u32(1),
ResourceId(1),
read_scope(
16,
16,
@ -486,7 +326,7 @@ mod tests {
);
reverse.insert(
command,
ResourceId::from_u32(1),
ResourceId(1),
read_scope(
0,
64,
@ -520,7 +360,7 @@ mod tests {
let command = map.begin_command();
map.insert(
command,
ResourceId::from_u32(0),
ResourceId(0),
read_scope(
0,
16,
@ -530,7 +370,7 @@ mod tests {
);
map.insert(
command,
ResourceId::from_u32(0),
ResourceId(0),
read_scope(
8,
16,
@ -548,7 +388,7 @@ mod tests {
let command = map.begin_command();
map.insert(
command,
ResourceId::from_u32(0),
ResourceId(0),
read_scope(
0,
64,
@ -558,7 +398,7 @@ mod tests {
);
map.insert(
command,
ResourceId::from_u32(0),
ResourceId(0),
read_scope(
16,
16,

View file

@ -1,7 +1,6 @@
use std::{borrow::Cow, sync::Arc};
use ash::vk;
use renderer_device::device::MemoryLocation;
use renderer_resources::{
buffers::Buffer as BufferResource,
images::{Image as ImageResource, ImageSubresourceExt},
@ -14,7 +13,7 @@ use renderer_resources::{
pub struct GraphBufferDesc {
pub size: u64,
pub name: Option<Cow<'static, str>>,
pub mem_location: MemoryLocation,
pub mem_location: gpu_allocator::MemoryLocation,
/// Additional Vulkan usages not visible in the graph's recorded commands.
pub extra_usage: vk::BufferUsageFlags,
}
@ -25,7 +24,7 @@ impl Default for GraphBufferDesc {
size: 0,
name: None,
// Transient graph resources default to device-local memory.
mem_location: MemoryLocation::GpuOnly,
mem_location: gpu_allocator::MemoryLocation::GpuOnly,
extra_usage: vk::BufferUsageFlags::empty(),
}
}
@ -44,7 +43,7 @@ pub struct GraphTextureDesc {
pub tiling: vk::ImageTiling,
pub layout: vk::ImageLayout,
pub name: Option<Cow<'static, str>>,
pub mem_location: MemoryLocation,
pub mem_location: gpu_allocator::MemoryLocation,
/// Additional Vulkan usages not visible in the graph's recorded commands.
pub extra_usage: vk::ImageUsageFlags,
}
@ -67,7 +66,7 @@ impl Default for GraphTextureDesc {
tiling: vk::ImageTiling::OPTIMAL,
layout: vk::ImageLayout::UNDEFINED,
name: None,
mem_location: MemoryLocation::GpuOnly,
mem_location: gpu_allocator::MemoryLocation::GpuOnly,
extra_usage: vk::ImageUsageFlags::empty(),
}
}
@ -75,14 +74,10 @@ impl Default for GraphTextureDesc {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(transparent)]
pub struct ResourceId(u32);
pub struct ResourceId(pub u64);
impl ResourceId {
pub fn from_u32(index: u32) -> Self {
Self(index)
}
pub fn index(self) -> usize {
pub(crate) fn index(self) -> usize {
self.0 as usize
}
}

View file

@ -1,552 +0,0 @@
use std::sync::Arc;
use ash::vk;
use renderer_device::{
device::{MemoryLocation, QueueFlags},
testing::TestVulkan,
};
use renderer_rendergraph::{
BeginRendering, ColorAttachment, EndRendering, RenderGraph,
commands::CopyBuffer,
commands::{ClearTexture, Command},
recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap},
resources::{
BufferRange, GraphBufferDesc, GraphTextureDesc, ResourceId, ResourceState,
SubresourceScope, SubresourceScopeInner,
},
};
use renderer_resources::{
buffers::{Buffer, BufferDesc},
images::ImageViewDesc,
};
struct ScopedNoop(Vec<(ResourceId, SubresourceScope)>);
impl Command for ScopedNoop {
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 ScopedNoop {
fn apply(&mut self, _recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
Ok(())
}
}
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 {
SubresourceScope {
stage: vk::PipelineStageFlags2::COPY,
access: vk::AccessFlags2::TRANSFER_WRITE,
subresource: SubresourceScopeInner::Buffer(BufferRange {
offset: 0,
size: 256,
}),
}
}
fn image_scope() -> SubresourceScope {
SubresourceScope {
stage: vk::PipelineStageFlags2::COMPUTE_SHADER,
access: vk::AccessFlags2::SHADER_STORAGE_WRITE,
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::GENERAL,
},
}
}
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) {
let device = test.device();
let pool_info = vk::CommandPoolCreateInfo::default()
.queue_family_index(device.queues.graphics().family.index.index());
let pool = unsafe { device.dev().create_command_pool(&pool_info, None) }
.expect("create alias test command pool");
let allocation_info = vk::CommandBufferAllocateInfo::default()
.command_pool(pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
let command_buffer = unsafe { device.dev().allocate_command_buffers(&allocation_info) }
.expect("allocate alias test command buffer")[0];
unsafe {
device
.dev()
.begin_command_buffer(command_buffer, &vk::CommandBufferBeginInfo::default())
.expect("begin alias test command buffer");
}
let mut recorder = CommandRecorder::new(device.dev(), command_buffer);
graph
.record_commands(&mut recorder)
.expect("record alias graph");
unsafe {
device
.dev()
.end_command_buffer(command_buffer)
.expect("end alias test command buffer");
let command_buffers = [command_buffer];
let submit = vk::SubmitInfo::default().command_buffers(&command_buffers);
device
.dev()
.queue_submit(device.queues.graphics().raw, &[submit], vk::Fence::null())
.expect("submit alias graph");
}
device.wait_idle().expect("wait for alias graph idle");
unsafe { device.dev().destroy_command_pool(pool, None) };
}
#[test]
fn imported_buffer_copy_records_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
if device.enabled_features().core13.synchronization2 != vk::TRUE {
eprintln!("Skipping graph recording: synchronization2 is unsupported");
return;
}
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 graph test buffer"),
)
};
let source = make_buffer();
let destination = make_buffer();
let mut graph = RenderGraph::new();
let source_id = graph
.import_buffer(source, ResourceState::default())
.expect("import source buffer");
let destination_id = graph
.import_buffer(destination, ResourceState::default())
.expect("import destination buffer");
graph.push(CopyBuffer {
src: source_id,
src_range: BufferRange {
offset: 0,
size: 256,
},
dst: destination_id,
dst_range: BufferRange {
offset: 0,
size: 256,
},
});
let mut graph = graph.compile().expect("compile imported buffer graph");
let pool_info = vk::CommandPoolCreateInfo::default()
.queue_family_index(device.queues.graphics().family.index.index());
let pool = unsafe { device.dev().create_command_pool(&pool_info, None) }
.expect("create graph command pool");
let allocation_info = vk::CommandBufferAllocateInfo::default()
.command_pool(pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
let command_buffer = unsafe { device.dev().allocate_command_buffers(&allocation_info) }
.expect("allocate graph command buffer")[0];
unsafe {
device
.dev()
.begin_command_buffer(command_buffer, &vk::CommandBufferBeginInfo::default())
.expect("begin graph command buffer");
}
let mut recorder = CommandRecorder::new(device.dev(), command_buffer);
graph
.record_commands(&mut recorder)
.expect("record imported buffer graph");
unsafe {
device
.dev()
.end_command_buffer(command_buffer)
.expect("end graph command buffer");
device.dev().destroy_command_pool(pool, None);
}
device.wait_idle().expect("wait for graph device idle");
test.assert_no_validation_errors();
}
#[test]
fn resolved_buffers_alias_and_submit_with_scoped_barrier() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
if device.enabled_features().core13.synchronization2 != vk::TRUE {
eprintln!("Skipping graph aliasing: synchronization2 is unsupported");
return;
}
let bridge = Arc::new(
Buffer::new(
device.clone(),
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::TRANSFER_DST,
mem_location: MemoryLocation::CpuToGpu,
..Default::default()
},
)
.expect("create dependency bridge buffer"),
);
let mut graph = RenderGraph::new();
let first = graph.create_buffer(GraphBufferDesc {
size: 256,
..Default::default()
});
let second = graph.create_buffer(GraphBufferDesc {
size: 256,
..Default::default()
});
let bridge = graph
.import_buffer(bridge, ResourceState::default())
.expect("import dependency bridge");
graph.push(ScopedNoop(vec![
(first.id(), buffer_scope()),
(bridge.id(), buffer_scope()),
]));
graph.push(ScopedNoop(vec![
(bridge.id(), buffer_scope()),
(second.id(), buffer_scope()),
]));
let mut compiled = graph.compile().expect("compile buffer alias graph");
compiled
.resolve(device)
.expect("resolve buffer alias graph");
compiled
.resolve(device)
.expect("resolving an already-resolved graph is idempotent");
let first_placement = compiled.resource_lifetimes()[&first.id()].placement;
let second_placement = compiled.resource_lifetimes()[&second.id()].placement;
assert_eq!(first_placement.pool, second_placement.pool);
assert_eq!(first_placement.offset, second_placement.offset);
assert!(first_placement.aliased && second_placement.aliased);
let alias_barrier = compiled
.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);
test.assert_no_validation_errors();
drop(compiled);
device
.wait_idle()
.expect("wait before shared allocation teardown");
test.assert_no_validation_errors();
}
#[test]
fn resolved_images_alias_and_submit_with_undefined_transition() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device();
if device.enabled_features().core13.synchronization2 != vk::TRUE {
eprintln!("Skipping graph aliasing: synchronization2 is unsupported");
return;
}
let bridge = Arc::new(
Buffer::new(
device.clone(),
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::TRANSFER_DST,
mem_location: MemoryLocation::CpuToGpu,
..Default::default()
},
)
.expect("create dependency bridge buffer"),
);
let mut graph = RenderGraph::new();
let first = graph.create_texture(GraphTextureDesc {
format: vk::Format::R8G8B8A8_UNORM,
extent: vk::Extent3D {
width: 16,
height: 16,
depth: 1,
},
extra_usage: vk::ImageUsageFlags::STORAGE,
..Default::default()
});
let second = graph.create_texture(GraphTextureDesc {
format: vk::Format::R8G8B8A8_UNORM,
extent: vk::Extent3D {
width: 16,
height: 16,
depth: 1,
},
extra_usage: vk::ImageUsageFlags::STORAGE,
..Default::default()
});
let bridge = graph
.import_buffer(bridge, ResourceState::default())
.expect("import dependency bridge");
graph.push(ScopedNoop(vec![
(first.id(), image_scope()),
(bridge.id(), buffer_scope()),
]));
graph.push(ScopedNoop(vec![
(bridge.id(), buffer_scope()),
(second.id(), image_scope()),
]));
let mut compiled = graph.compile().expect("compile image alias graph");
compiled.resolve(device).expect("resolve image alias graph");
compiled
.resolve(device)
.expect("resolving an already-resolved graph is idempotent");
let first_placement = compiled.resource_lifetimes()[&first.id()].placement;
let second_placement = compiled.resource_lifetimes()[&second.id()].placement;
assert_eq!(first_placement.pool, second_placement.pool);
assert_eq!(first_placement.offset, second_placement.offset);
assert!(first_placement.aliased && second_placement.aliased);
assert!(compiled.barriers().iter().any(|barrier| {
barrier.before_command == CommandIndex(1)
&& barrier.resources.iter().any(|resource| {
resource.id == second.id()
&& resource.old_layout == Some(vk::ImageLayout::UNDEFINED)
&& resource.src_access == vk::AccessFlags2::SHADER_STORAGE_WRITE
})
}));
submit_graph(&test, &mut compiled);
test.assert_no_validation_errors();
drop(compiled);
device
.wait_idle()
.expect("wait before shared allocation teardown");
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

@ -4,21 +4,19 @@
`renderer_resources` owns Vulkan buffer, image, view, descriptor, sampler, shader-module, and pipeline construction on the `renderer_device` layer. It handles resource memory and handle lifetimes, but contains no window, swapchain, or presentation orchestration; those remain in `renderer`. It must not depend on `renderer`.
## Module map
- `lib.rs`: re-exports `Device`, `Error`, `Result`, `device`, and `make_extension` from `renderer_device`; declares public buffer, image, memory, pipeline, utility, and descriptor modules.
- `buffers.rs`: `BufferDesc` and `Buffer`; allocated and unallocated Vulkan buffer creation, memory requirement query/binding, raw access, and host mapping.
- `images.rs`: `ImageDesc`, `Image`, `ImageViewDesc`, `ImageView`, subresource ranges, extents, offsets, and queue ownership. `ImageInner` distinguishes allocated/unallocated images from borrowed swapchain images.
- `memory.rs`: re-exports device `MemoryRequirements`; exposes memory validation errors, `MemoryMember`, and `MemoryGroup` for aligned shared buffer/image allocations.
- `lib.rs`: re-exports `Device`, `Error`, `Result`, `device`, and `make_extension` from `renderer_device`; declares the public resource modules, including descriptor metadata and validation.
- `buffers.rs`: `BufferDesc` and `Buffer`; Vulkan buffer creation, GPU allocation/binding, raw access, and host mapping.
- `images.rs`: `ImageDesc`, `Image`, `ImageViewDesc`, `ImageView`, subresource ranges, extents, offsets, and queue ownership. `ImageInner` distinguishes allocated images from borrowed swapchain images.
- `pipeline.rs`: shader-stage and graphics/compute pipeline descriptions; `DescriptorPool`, `DescriptorSetLayout`, `PipelineLayout`, `Sampler`, `ShaderModule`, and `Pipeline`.
- `descriptors.rs`: managed descriptor binding/resource metadata, validation plans, errors, and pool-generation helpers; it remains independent of render-graph types.
- `util.rs`: `eq_f32`, `ord_f32`, `hash_f32`, `F32`, and the crate-private `weak_vec` module / `WeakVec` used by image view tracking.
## Key types and API
- These modules are public from `renderer_resources`; the crate root re-exports the core device types and `device` module from `renderer_device` for resource construction.
- `BufferDesc` configures Vulkan buffer flags, size, usage, queue families, `renderer_device::device::MemoryLocation`, allocation strategy, and optional debug name. `Buffer::new` requests a VMA allocation through `DeviceInner::allocate_for_buffer` and binds it through `bind_buffer_memory`; `Buffer::new_unallocated` creates only the Vulkan handle. Its `memory_requirements` includes dedicated-allocation hints, and `bind_allocation(Arc<DeviceObject<GpuAllocation>>, offset)` validates then binds shared memory. `map` and `map_mut` return mapped slices when available.
- `Buffer::raw` returns the Vulkan handle and `len` returns the configured size. Mapping is bounded to the descriptor size, not the VMA allocation block size. CPU-mapped locations select host-visible, host-coherent memory.
- `ImageDesc` configures image type, format, extent, layers/mips, samples, tiling, usage, initial layout, queue sharing, and allocation strategy/location. `Image::new` uses the device's VMA allocation and image-binding helpers.
- `Image::new` creates an allocated image; `Image::new_unallocated` creates an unbound image. `memory_requirements` includes dedicated-allocation hints, and `bind_allocation(Arc<DeviceObject<GpuAllocation>>, offset)` validates then binds shared memory. `Image::new_with_allocation` accepts an explicit `renderer_device::device::Allocation` and validates size and memory-type compatibility.
- `MemoryGroup::new(memory_type_index, property_flags, size, alignment)` fixes the shared pool's memory type and capacity. `add(MemoryMember::Buffer(&mut buffer))` / `add(MemoryMember::Image(&mut image))` queries requirements, checks that the selected type is allowed for every member, and packs each at an aligned offset; `allocate(&device)` creates aliasable memory, then consuming `bind_all()` transfers allocation references into the resources. The group does not retain its own allocation after binding.
- `BufferDesc` configures Vulkan buffer flags, size, usage, queue families, memory location, allocation strategy, and optional debug name. `Buffer::new` allocates/binds memory; `map` and `map_mut` return mapped slices when available.
- `Buffer::raw` returns the Vulkan handle and `len` returns the configured size. Mapping is bounded to the descriptor size, not the allocator block size.
- `ImageDesc` configures image type, format, extent, layers/mips, samples, tiling, usage, initial layout, queue sharing, and allocation.
- `Image::new` creates an allocated image. `Image::new_with_allocation` accepts an explicit `renderer_device::device::Allocation` and validates size and memory-type compatibility.
- `Image::from_swapchain_raw(image, device, format, extent, usage)` builds an image wrapper for an image owned by a swapchain; renderer calls it from `Swapchain::acquire_image`.
- `ImageViewDesc` describes a view's type, format, component mapping, aspect, mip range, and layer range. `Image::create_view` validates compatibility and returns `ManuallyDrop<ImageView>`.
- `Image::create_view` resolves remaining mip/layer sentinels against the parent image, defaults an undefined view format to the image format, and checks image/view kind and format compatibility.
@ -48,8 +46,6 @@
- `from_swapchain_raw` records a 2D, one-mip, one-layer, single-sample, optimal-tiled image with PRESENT queue sharing and UNDEFINED initial layout; the caller supplies format, extent, and usage.
- `from_swapchain_raw` retains a `Device` for views and device queries, but not ownership of the image handle or swapchain lifetime; the renderer's frame/swapchain token must keep the swapchain alive.
- Allocated images and buffers use `DeviceObject` for Vulkan handles and allocation ownership. Keep resource destruction ordered before freeing bound memory.
- Unallocated resources use `Allocation::Unmanaged` until bound. Shared bind APIs are one-shot; they validate offset alignment, allowed memory type, and allocation bounds before calling the device layer. Keep `DeviceObject<GpuAllocation>` on the resource after a successful bind so Vulkan handles are destroyed before their allocation reference.
- `MemoryGroup` only accepts members whose `memory_type_bits` include its selected type (the intersection across all members); it rejects dedicated-required resources. Group offsets use aligned bump packing, and allocation ownership transfers into each bound member at `bind_all`.
- `Buffer` equality compares the raw buffer handle; `Image` equality compares the raw image handle. Descriptor equality is separate from resource object identity.
- `Allocation::Owned` owns a device-managed allocation; `Shared` shares it through `Arc` and permits mutable access only when uniquely held; `Unmanaged` carries no allocation.
- `ImageView` retains `Arc<Image>` so the source image outlives the view. `Image::create_view` returns `ManuallyDrop<ImageView>`; callers must transfer/drop it deliberately.

View file

@ -9,10 +9,8 @@ testing = []
[dependencies]
renderer_device = { path = "../renderer_device" }
ash = { workspace = true }
gpu-allocator = { workspace = true }
parking_lot = { workspace = true }
bytemuck = { workspace = true }
tracing = { workspace = true }
ash-formats = { git = "https://github.com/janis-bhm/ash-formats", branch = "main" }
[dev-dependencies]
renderer_device = { path = "../renderer_device", features = ["testing"] }

View file

@ -1,13 +1,12 @@
use std::{borrow::Cow, sync::Arc};
use ash::vk;
use gpu_allocator::vulkan::AllocationScheme;
use renderer_device::queue::QueueFamilyIndices;
use crate::{
Device,
device::{
Allocation, AllocationDesc, AllocationStrategy, DeviceObject, MemoryLocation, QueueFlags,
},
device::{Allocation, AllocationStrategy, DeviceObject, QueueFlags},
};
#[derive(Clone)]
@ -18,7 +17,7 @@ pub struct BufferDesc {
pub usage: vk::BufferUsageFlags,
pub queue_families: QueueFlags,
pub mem_location: MemoryLocation,
pub mem_location: gpu_allocator::MemoryLocation,
pub alloc_scheme: AllocationStrategy,
}
@ -66,7 +65,7 @@ impl Default for BufferDesc {
size: Default::default(),
usage: Default::default(),
queue_families: QueueFlags::empty(),
mem_location: MemoryLocation::Unknown,
mem_location: gpu_allocator::MemoryLocation::Unknown,
alloc_scheme: AllocationStrategy::default(),
}
}
@ -103,122 +102,46 @@ impl Buffer {
}
pub fn new(device: Device, desc: BufferDesc) -> crate::Result<Self> {
let buffer = Self::new_raw(device.clone(), &desc)?;
let alloc = device.allocate_for_buffer(
*buffer,
AllocationDesc {
let (buffer, requirements) = Self::new_raw(device.clone(), &desc)?;
let alloc =
device
.alloc2
.lock()
.allocate(&gpu_allocator::vulkan::AllocationCreateDesc {
name: desc.name.as_deref().unwrap_or_default(),
requirements,
location: desc.mem_location,
strategy: desc.alloc_scheme,
linear: true,
allocation_scheme: match desc.alloc_scheme {
AllocationStrategy::AllocatorManaged => {
AllocationScheme::GpuAllocatorManaged
}
AllocationStrategy::Dedicated => AllocationScheme::DedicatedBuffer(buffer),
},
)?;
})?;
let buffer = Self {
buffer: BufferHandle::Owned(buffer),
desc,
alloc: Allocation::Owned(DeviceObject::new(device.clone(), alloc)),
};
device.bind_buffer_memory(
buffer.alloc.allocation().expect("owned buffer allocation"),
buffer.raw(),
)?;
Ok(buffer)
unsafe {
device
.raw
.bind_buffer_memory(buffer, alloc.memory(), alloc.offset())?;
}
/// Creates a Vulkan buffer without allocating or binding memory.
pub fn new_unallocated(device: Device, desc: BufferDesc) -> crate::Result<Self> {
let buffer = Self::new_raw(device, &desc)?;
Ok(Self {
buffer: BufferHandle::Owned(buffer),
buffer: BufferHandle::Owned(DeviceObject::new_debug_named(
device.clone(),
buffer,
desc.name.clone(),
)),
desc,
alloc: Allocation::Unmanaged {
requirements: std::sync::OnceLock::new(),
},
alloc: Allocation::Owned(DeviceObject::new(device, alloc)),
})
}
/// Returns the Vulkan memory requirements, including dedicated-allocation hints.
pub fn memory_requirements(&self) -> crate::memory::Result<crate::memory::MemoryRequirements> {
let Some(buffer) = self.owned() else {
return Err(crate::memory::Error::RequirementQuery(
crate::Error::Unspecified,
));
};
let requirements_cache = match &self.alloc {
Allocation::Unmanaged { requirements } => Some(requirements),
Allocation::Owned(_) | Allocation::Shared(_) => None,
};
if let Some(requirements) = requirements_cache.and_then(std::sync::OnceLock::get) {
return Ok(*requirements);
}
let requirements = buffer
.device()
.buffer_memory_requirements(**buffer)
.map_err(crate::memory::Error::RequirementQuery)?;
if let Some(cache) = requirements_cache {
let _ = cache.set(requirements);
}
Ok(requirements)
}
/// Binds this buffer into an aliasable allocation at an allocation-local offset.
///
/// Requirements are queried once and cached in the resource. The Vulkan bind remains the
/// authoritative validation.
pub fn bind_allocation(
&mut self,
allocation: Arc<DeviceObject<crate::device::GpuAllocation>>,
offset: u64,
) -> crate::memory::Result<()> {
if !matches!(self.alloc, Allocation::Unmanaged { .. }) {
return Err(crate::memory::Error::AlreadyBound);
}
let requirements = self.memory_requirements()?;
crate::memory::validate_binding(&requirements, allocation.as_ref(), offset)?;
let device = self.owned().expect("owned buffer").device();
self.alloc = device
.bind_buffer_at_offset(self.raw(), &requirements, allocation, offset)
.map_err(crate::memory::Error::Bind)?;
Ok(())
}
pub(crate) fn validate_unallocated(&self) -> crate::memory::Result<()> {
if matches!(self.alloc, Allocation::Unmanaged { .. }) {
Ok(())
} else {
Err(crate::memory::Error::AlreadyBound)
}
}
pub(crate) fn validate_device(&self, device: &Device) -> crate::memory::Result<()> {
if self.owned().expect("unallocated buffer is owned").device() == device {
Ok(())
} else {
Err(crate::memory::Error::Bind(
crate::Error::AllocationDeviceMismatch,
))
}
}
pub(crate) fn set_shared_allocation(
&mut self,
allocation: Arc<DeviceObject<crate::device::GpuAllocation>>,
) {
debug_assert!(matches!(self.alloc, Allocation::Unmanaged { .. }));
self.alloc = Allocation::Shared(allocation);
}
fn new_raw(device: Device, desc: &BufferDesc) -> crate::Result<DeviceObject<vk::Buffer>> {
fn new_raw(
device: Device,
desc: &BufferDesc,
) -> crate::Result<(vk::Buffer, vk::MemoryRequirements)> {
let queue_families = device.queues.family_indices(desc.queue_families);
let sharing_mode = if queue_families.len() > 1 {
@ -233,11 +156,9 @@ impl Buffer {
.sharing_mode(sharing_mode);
let buffer = unsafe { device.dev().create_buffer(&create_info, None)? };
Ok(DeviceObject::new_debug_named(
device,
buffer,
desc.name.clone(),
))
let mem_reqs = unsafe { device.dev().get_buffer_memory_requirements(buffer) };
Ok((buffer, mem_reqs))
}
pub fn map(&mut self) -> Option<&[u8]> {
@ -284,313 +205,11 @@ impl Buffer {
size,
..Default::default()
},
alloc: Allocation::Unmanaged {
requirements: std::sync::OnceLock::new(),
},
alloc: Allocation::Unmanaged,
}
}
}
#[cfg(test)]
mod allocation_validation_tests {
use ash::vk;
use renderer_device::testing::TestVulkan;
use super::{Buffer, BufferDesc};
use crate::{
device::MemoryLocation,
images::{Image, ImageDesc},
};
use renderer_device::device::AllocationStrategy;
#[test]
fn vma_mapping_and_dedicated_resources_under_validation() {
let test = TestVulkan::expect();
let device = test.device().clone();
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Buffer>();
assert_send_sync::<Image>();
let mut mapped_buffer = Buffer::new(
device.clone(),
BufferDesc {
size: 64,
usage: vk::BufferUsageFlags::TRANSFER_SRC,
mem_location: MemoryLocation::CpuToGpu,
..Default::default()
},
)
.expect("create CpuToGpu buffer");
let allocation = mapped_buffer
.alloc
.allocation()
.expect("CpuToGpu buffer allocation");
let memory_type = test
.physical_device()
.properties
.memory_properties
.memory_types[allocation.memory_type_index() as usize];
assert!(memory_type.property_flags.contains(
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT
));
let expected = (0..64).map(|byte| byte as u8).collect::<Vec<_>>();
mapped_buffer
.map_mut()
.expect("CpuToGpu buffer is mapped")
.copy_from_slice(&expected);
assert_eq!(
mapped_buffer.map().expect("CpuToGpu buffer remains mapped"),
expected
);
let dedicated_buffer = Buffer::new(
device.clone(),
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::TRANSFER_DST,
alloc_scheme: AllocationStrategy::Dedicated,
..Default::default()
},
)
.expect("create dedicated buffer");
assert!(
dedicated_buffer
.alloc
.allocation()
.expect("dedicated buffer allocation")
.is_dedicated(),
"VMA should allocate the dedicated buffer from its own memory block"
);
let dedicated_image = Image::new(
device,
ImageDesc {
format: vk::Format::R8G8B8A8_UNORM,
extent: vk::Extent3D {
width: 16,
height: 16,
depth: 1,
},
usage: vk::ImageUsageFlags::SAMPLED,
alloc_scheme: AllocationStrategy::Dedicated,
..Default::default()
},
)
.expect("create dedicated image");
assert!(
dedicated_image
.allocation()
.expect("dedicated image allocation")
.allocation()
.expect("dedicated image VMA allocation")
.is_dedicated(),
"VMA should allocate the dedicated image from its own memory block"
);
drop(dedicated_image);
drop(dedicated_buffer);
drop(mapped_buffer);
test.device().wait_idle().expect("wait for device idle");
test.assert_no_validation_errors();
}
}
#[cfg(all(test, feature = "testing"))]
mod unallocated_memory_tests {
use ash::vk;
use renderer_device::testing::TestVulkan;
use super::{Buffer, BufferDesc};
use crate::{
device::MemoryLocation,
images::{Image, ImageDesc},
memory::{Error, MemoryGroup, MemoryMember},
};
fn buffer_desc() -> BufferDesc {
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::TRANSFER_DST,
mem_location: MemoryLocation::GpuOnly,
..Default::default()
}
}
#[test]
fn unallocated_resources_query_requirements_and_bind() {
let test = TestVulkan::expect();
let device = test.device().clone();
let mut buffer = Buffer::new_unallocated(device.clone(), buffer_desc())
.expect("create unallocated buffer");
let buffer_requirements = buffer.memory_requirements().expect("buffer requirements");
assert!(buffer_requirements.size >= buffer.len());
assert!(matches!(
&buffer.alloc,
crate::device::Allocation::Unmanaged { .. }
));
let mut image = Image::new_unallocated(
device.clone(),
ImageDesc {
format: vk::Format::R8G8B8A8_UNORM,
extent: vk::Extent3D {
width: 16,
height: 16,
depth: 1,
},
usage: vk::ImageUsageFlags::TRANSFER_DST,
..Default::default()
},
)
.expect("create unallocated image");
let image_requirements = image.memory_requirements().expect("image requirements");
assert!(image_requirements.size > 0);
assert!(
image
.allocation()
.is_some_and(|allocation| allocation.allocation().is_none())
);
let buffer_memory = device
.allocate_memory(&buffer_requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate buffer memory");
buffer
.bind_allocation(buffer_memory, 0)
.expect("bind buffer at offset zero");
assert!(matches!(
&buffer.alloc,
crate::device::Allocation::Shared(_)
));
let image_memory = device
.allocate_memory(&image_requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate image memory");
image
.bind_allocation(image_memory, 0)
.expect("bind image at offset zero");
assert!(matches!(
image.allocation(),
Some(crate::device::Allocation::Shared(_))
));
drop(image);
drop(buffer);
test.device().wait_idle().expect("wait for device idle");
test.assert_no_validation_errors();
}
#[test]
fn bind_rejects_invalid_offsets_and_rebinding() {
let test = TestVulkan::expect();
let device = test.device().clone();
let mut buffer = Buffer::new_unallocated(device.clone(), buffer_desc())
.expect("create buffer for offset checks");
let requirements = buffer.memory_requirements().expect("buffer requirements");
let allocation = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate offset-check memory");
if requirements.alignment > 1 {
assert!(matches!(
buffer.bind_allocation(allocation.clone(), 1),
Err(Error::MisalignedOffset { .. })
));
}
assert!(matches!(
buffer.bind_allocation(allocation.clone(), requirements.size),
Err(Error::OutOfBounds { .. })
));
buffer
.bind_allocation(allocation.clone(), 0)
.expect("first buffer bind");
assert!(matches!(
buffer.bind_allocation(allocation, 0),
Err(Error::AlreadyBound)
));
let allocated = device
.allocate_memory(&requirements, vk::MemoryPropertyFlags::empty())
.expect("allocate for synthetic type-bits validation");
let impossible = crate::memory::MemoryRequirements {
memory_type_bits: 0,
..requirements
};
assert!(matches!(
crate::memory::validate_binding(&impossible, allocated.as_ref(), 0),
Err(Error::IncompatibleMemoryType { .. })
));
drop(buffer);
test.device().wait_idle().expect("wait for device idle");
test.assert_no_validation_errors();
}
#[test]
fn memory_group_packs_and_binds_two_buffers() {
let test = TestVulkan::expect();
let device = test.device().clone();
let mut first =
Buffer::new_unallocated(device.clone(), buffer_desc()).expect("create first buffer");
let mut second =
Buffer::new_unallocated(device.clone(), buffer_desc()).expect("create second buffer");
let first_requirements = first.memory_requirements().expect("first requirements");
let second_requirements = second.memory_requirements().expect("second requirements");
let common_bits =
first_requirements.memory_type_bits & second_requirements.memory_type_bits;
assert_ne!(common_bits, 0, "the test adapter needs a common type");
let memory_type_index = common_bits.trailing_zeros();
let alignment = first_requirements
.alignment
.max(second_requirements.alignment);
let first_end = first_requirements.size;
let expected_second_offset = first_end
.checked_add(alignment - 1)
.expect("offset arithmetic")
/ alignment
* alignment;
let size = expected_second_offset + second_requirements.size;
let mut group = MemoryGroup::new(
memory_type_index,
vk::MemoryPropertyFlags::empty(),
size,
alignment,
)
.expect("create memory group");
let first_offset = group
.add(MemoryMember::Buffer(&mut first))
.expect("pack first buffer");
let second_offset = group
.add(MemoryMember::Buffer(&mut second))
.expect("pack second buffer");
assert_eq!(first_offset, 0);
assert_eq!(second_offset, expected_second_offset);
assert_eq!(second_offset % alignment, 0);
group.allocate(&device).expect("allocate shared memory");
group.bind_all().expect("bind group members");
assert!(matches!(&first.alloc, crate::device::Allocation::Shared(_)));
assert!(matches!(
&second.alloc,
crate::device::Allocation::Shared(_)
));
assert_eq!(
first
.alloc
.allocation()
.map(crate::device::GpuAllocation::memory),
second
.alloc
.allocation()
.map(crate::device::GpuAllocation::memory),
"both buffers retain the shared Vulkan memory block"
);
drop(first);
drop(second);
test.device().wait_idle().expect("wait for device idle");
test.assert_no_validation_errors();
}
}
/// Byte range selected by a buffer view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BufferRange {

View file

@ -1,19 +1,19 @@
use std::{
borrow::Cow,
mem::ManuallyDrop,
ops::{Add, Sub},
sync::Arc,
};
use crate::{
device::{
Allocation, AllocationDesc, AllocationStrategy, DeviceObject, MemoryLocation, QueueFlags,
},
device::{Allocation, AllocationStrategy, DeviceObject, QueueFlags},
util::WeakVec,
};
use super::Device;
use ash::vk::{self, Handle};
use ash_formats::FormatExt;
use gpu_allocator::vulkan::{AllocationCreateDesc, AllocationScheme};
use parking_lot::Mutex;
use renderer_device::queue::QueueFamilyIndices;
@ -32,7 +32,7 @@ pub struct ImageDesc {
pub queue_families: QueueFlags,
pub layout: vk::ImageLayout,
pub mem_location: MemoryLocation,
pub mem_location: gpu_allocator::MemoryLocation,
pub alloc_scheme: AllocationStrategy,
}
@ -123,7 +123,7 @@ impl Default for ImageDesc {
usage: Default::default(),
queue_families: QueueFlags::empty(),
layout: vk::ImageLayout::UNDEFINED,
mem_location: MemoryLocation::Unknown,
mem_location: gpu_allocator::MemoryLocation::Unknown,
alloc_scheme: AllocationStrategy::AllocatorManaged,
}
}
@ -187,15 +187,17 @@ pub struct Image {
impl Image {
pub fn new(device: Device, desc: ImageDesc) -> crate::Result<Self> {
let image = Self::new_raw(device.clone(), &desc)?;
let alloc = device.allocate_for_image(
*image,
AllocationDesc {
let (image, requirements) = Self::new_raw(device.clone(), &desc)?;
let alloc = device.alloc2.lock().allocate(&AllocationCreateDesc {
name: desc.name.as_deref().unwrap_or(""),
requirements,
location: desc.mem_location,
strategy: desc.alloc_scheme,
linear: desc.tiling == vk::ImageTiling::LINEAR,
allocation_scheme: match desc.alloc_scheme {
AllocationStrategy::AllocatorManaged => AllocationScheme::GpuAllocatorManaged,
AllocationStrategy::Dedicated => AllocationScheme::DedicatedImage(image),
},
)?;
})?;
Self::new_with_allocation_unchecked(
device.clone(),
@ -205,138 +207,37 @@ impl Image {
)
}
/// Creates a Vulkan image without allocating or binding memory.
pub fn new_unallocated(device: Device, desc: ImageDesc) -> crate::Result<Self> {
let image = Self::new_raw(device, &desc)?;
fn new_with_allocation_unchecked(
device: Device,
image: vk::Image,
allocation: Allocation,
desc: ImageDesc,
) -> crate::Result<Self> {
// bind memory
if let Some(alloc) = allocation.allocation() {
unsafe {
device
.raw
.bind_image_memory(image, alloc.memory(), alloc.offset())?;
}
}
Ok(Self {
image: ImageInner::Allocated(
image,
Allocation::Unmanaged {
requirements: std::sync::OnceLock::new(),
},
DeviceObject::new_debug_named(device, image, desc.name.clone()),
allocation,
),
desc,
views: Default::default(),
})
}
/// Returns the Vulkan memory requirements, including dedicated-allocation hints.
pub fn memory_requirements(&self) -> crate::memory::Result<crate::memory::MemoryRequirements> {
let ImageInner::Allocated(image, allocation) = &self.image else {
return Err(crate::memory::Error::RequirementQuery(
crate::Error::Unspecified,
));
};
let requirements_cache = match allocation {
Allocation::Unmanaged { requirements } => Some(requirements),
Allocation::Owned(_) | Allocation::Shared(_) => None,
};
if let Some(requirements) = requirements_cache.and_then(std::sync::OnceLock::get) {
return Ok(*requirements);
}
let requirements = image
.device()
.image_memory_requirements(**image)
.map_err(crate::memory::Error::RequirementQuery)?;
if let Some(cache) = requirements_cache {
let _ = cache.set(requirements);
}
Ok(requirements)
}
/// Binds this image into an aliasable allocation at an allocation-local offset.
///
/// Requirements are queried once and cached in the resource. The Vulkan bind remains the
/// authoritative validation.
pub fn bind_allocation(
&mut self,
allocation: Arc<DeviceObject<crate::device::GpuAllocation>>,
offset: u64,
) -> crate::memory::Result<()> {
let ImageInner::Allocated(_, current_allocation) = &self.image else {
return Err(crate::memory::Error::AlreadyBound);
};
if !matches!(current_allocation, Allocation::Unmanaged { .. }) {
return Err(crate::memory::Error::AlreadyBound);
}
let requirements = self.memory_requirements()?;
crate::memory::validate_binding(&requirements, allocation.as_ref(), offset)?;
let ImageInner::Allocated(image, current_allocation) = &mut self.image else {
unreachable!("image kind checked above")
};
*current_allocation = image
.device()
.bind_image_at_offset(**image, &requirements, allocation, offset)
.map_err(crate::memory::Error::Bind)?;
Ok(())
}
pub(crate) fn validate_unallocated(&self) -> crate::memory::Result<()> {
match &self.image {
ImageInner::Allocated(_, Allocation::Unmanaged { .. }) => Ok(()),
ImageInner::Allocated(_, _) | ImageInner::Swapchain(_, _) => {
Err(crate::memory::Error::AlreadyBound)
}
#[cfg(feature = "testing")]
ImageInner::Testing(_) => Err(crate::memory::Error::AlreadyBound),
}
}
pub(crate) fn validate_device(&self, device: &Device) -> crate::memory::Result<()> {
if self.image.device() == device {
Ok(())
} else {
Err(crate::memory::Error::Bind(
crate::Error::AllocationDeviceMismatch,
))
}
}
pub(crate) fn set_shared_allocation(
&mut self,
allocation: Arc<DeviceObject<crate::device::GpuAllocation>>,
) {
let ImageInner::Allocated(_, current_allocation) = &mut self.image else {
unreachable!("group members are owned images")
};
debug_assert!(matches!(current_allocation, Allocation::Unmanaged { .. }));
*current_allocation = Allocation::Shared(allocation);
}
fn new_with_allocation_unchecked(
device: Device,
image: DeviceObject<vk::Image>,
allocation: Allocation,
desc: ImageDesc,
) -> crate::Result<Self> {
let image = Self {
image: ImageInner::Allocated(image, allocation),
desc,
views: Default::default(),
};
// Bind after ownership is established so failures destroy the image before freeing memory.
if let ImageInner::Allocated(raw_image, allocation) = &image.image {
if let Some(alloc) = allocation.allocation() {
device.bind_image_memory(alloc, **raw_image)?;
}
}
Ok(image)
}
pub fn new_with_allocation(
device: Device,
allocation: Allocation,
desc: ImageDesc,
) -> crate::Result<Self> {
let image = Self::new_raw(device.clone(), &desc)?;
let requirements = unsafe { device.dev().get_image_memory_requirements(*image) };
let (image, requirements) = Self::new_raw(device.clone(), &desc)?;
// validate allocation
let alloc_size = allocation
@ -385,7 +286,7 @@ impl Image {
usage,
queue_families: QueueFlags::PRESENT,
layout: vk::ImageLayout::UNDEFINED,
mem_location: MemoryLocation::GpuOnly,
mem_location: gpu_allocator::MemoryLocation::GpuOnly,
..Default::default()
},
views: Default::default(),
@ -406,7 +307,10 @@ impl Image {
}
}
fn new_raw(device: Device, desc: &ImageDesc) -> crate::Result<DeviceObject<vk::Image>> {
fn new_raw(
device: Device,
desc: &ImageDesc,
) -> crate::Result<(vk::Image, vk::MemoryRequirements)> {
tracing::trace!("new image with desc={desc:?}");
let ImageDesc {
flags,
@ -468,11 +372,9 @@ impl Image {
let image = unsafe { device.raw.create_image(&info, None)? };
Ok(DeviceObject::new_debug_named(
device,
image,
desc.name.clone(),
))
let requirements = unsafe { device.raw.get_image_memory_requirements(image) };
Ok((image, requirements))
}
pub fn raw(&self) -> vk::Image {
@ -554,7 +456,10 @@ impl Image {
// }
// }
pub fn create_view(self: &Arc<Self>, mut desc: ImageViewDesc) -> crate::Result<ImageView> {
pub fn create_view(
self: &Arc<Self>,
mut desc: ImageViewDesc,
) -> crate::Result<ManuallyDrop<ImageView>> {
// validate
if !view_kind_compatible(self.desc.kind, desc.kind) {
tracing::error!(
@ -614,7 +519,7 @@ impl Image {
let device = self.image.device();
let view = unsafe { device.raw.create_image_view(&create_info, None)? };
Ok(ImageView {
Ok(ManuallyDrop::new(ImageView {
view: ImageViewHandle::Owned(DeviceObject::new_debug_named(
device.clone(),
view,
@ -622,7 +527,7 @@ impl Image {
)),
desc,
image: self.clone(),
})
}))
}
}
@ -1185,6 +1090,26 @@ fn image_view_subresource_range(desc: &ImageViewDesc) -> vk::ImageSubresourceRan
.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 src: u32,
pub dst: u32,
@ -1312,23 +1237,3 @@ impl ImageSubresourceExt for vk::ImageSubresource {
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

@ -3,6 +3,5 @@ pub use renderer_device::{Device, Error, Result, device, make_extension};
pub mod buffers;
pub mod descriptors;
pub mod images;
pub mod memory;
pub mod pipeline;
pub mod util;

View file

@ -1,411 +0,0 @@
//! Shared aliasable memory allocation helpers for buffers and images.
use std::sync::Arc;
use ash::vk;
use crate::{
Device,
buffers::Buffer,
device::{DeviceObject, GpuAllocation, MemoryBinding},
images::Image,
};
/// Vulkan memory requirements returned by the device layer.
pub use crate::device::MemoryRequirements;
/// Errors from resource requirement queries, validation, and shared binding.
#[derive(Debug)]
pub enum Error {
RequirementQuery(crate::Error),
Bind(crate::Error),
Allocation(crate::Error),
MisalignedOffset {
offset: u64,
alignment: u64,
},
IncompatibleMemoryType {
memory_type_index: u32,
memory_type_bits: u32,
},
OutOfBounds {
offset: u64,
end: u64,
allocation_size: u64,
},
AlreadyBound,
AlreadyAllocated,
RequiresDedicatedAllocation,
GroupMemoryTypeMismatch {
memory_type_index: u32,
},
GroupCapacityExceeded {
requested_end: u64,
capacity: u64,
},
EmptyGroup,
NotAllocated,
InvalidGroupRequirements,
OffsetOverflow,
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::RequirementQuery(error) => {
write!(f, "failed to query resource memory requirements: {error}")
}
Self::Bind(error) => write!(f, "failed to bind resource memory: {error}"),
Self::Allocation(error) => write!(f, "failed to allocate group memory: {error}"),
Self::MisalignedOffset { offset, alignment } => write!(
f,
"allocation offset {offset} is not aligned to {alignment}"
),
Self::IncompatibleMemoryType {
memory_type_index,
memory_type_bits,
} => write!(
f,
"allocation memory type {memory_type_index} is not allowed by mask {memory_type_bits:#010x}"
),
Self::OutOfBounds {
offset,
end,
allocation_size,
} => write!(
f,
"resource range [{offset}, {end}) does not fit allocation size {allocation_size}"
),
Self::AlreadyBound => f.write_str("resource is already bound to an allocation"),
Self::AlreadyAllocated => f.write_str("memory group has already been allocated"),
Self::RequiresDedicatedAllocation => f.write_str(
"resources requiring dedicated allocations cannot be added to a memory group",
),
Self::GroupMemoryTypeMismatch { memory_type_index } => write!(
f,
"resource memory requirements do not include memory type {memory_type_index}"
),
Self::GroupCapacityExceeded {
requested_end,
capacity,
} => write!(
f,
"memory group capacity exceeded: requested end {requested_end}, capacity {capacity}"
),
Self::EmptyGroup => f.write_str("memory group has no members"),
Self::NotAllocated => f.write_str("memory group has not been allocated"),
Self::InvalidGroupRequirements => {
f.write_str("memory group size or alignment is invalid")
}
Self::OffsetOverflow => f.write_str("memory group offset arithmetic overflowed"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::RequirementQuery(error) | Self::Bind(error) | Self::Allocation(error) => {
Some(error)
}
_ => None,
}
}
}
pub type Result<T> = core::result::Result<T, Error>;
pub(crate) fn validate_binding(
requirements: &MemoryRequirements,
allocation: &DeviceObject<GpuAllocation>,
offset: u64,
) -> Result<()> {
let alignment = requirements.alignment;
if alignment == 0 || !offset.is_multiple_of(alignment) {
return Err(Error::MisalignedOffset { offset, alignment });
}
let memory_type_index = allocation.memory_type_index();
if requirements.memory_type_bits & (1 << memory_type_index) == 0 {
return Err(Error::IncompatibleMemoryType {
memory_type_index,
memory_type_bits: requirements.memory_type_bits,
});
}
let end = offset
.checked_add(requirements.size)
.ok_or(Error::OutOfBounds {
offset,
end: u64::MAX,
allocation_size: allocation.size(),
})?;
if end > allocation.size() {
return Err(Error::OutOfBounds {
offset,
end,
allocation_size: allocation.size(),
});
}
Ok(())
}
enum Member<'a> {
Buffer {
resource: &'a mut Buffer,
offset: u64,
},
Image {
resource: &'a mut Image,
offset: u64,
},
}
/// Packs resources into a single aliasable allocation with a fixed memory type.
///
/// Members are exclusively borrowed until `bind_all` consumes the group. Their
/// allocation ownership is then transferred to each resource; the group retains
/// no allocation handle after binding.
pub struct MemoryGroup<'a> {
memory_type_index: u32,
property_flags: vk::MemoryPropertyFlags,
size: u64,
alignment: u64,
next_offset: u64,
members: Vec<Member<'a>>,
allocation: Option<Arc<DeviceObject<GpuAllocation>>>,
}
impl<'a> MemoryGroup<'a> {
/// Creates an empty group for a fixed memory type and allocation capacity.
pub fn new(
memory_type_index: u32,
property_flags: vk::MemoryPropertyFlags,
size: u64,
alignment: u64,
) -> Result<Self> {
if memory_type_index >= u32::BITS || size == 0 || alignment == 0 {
return Err(Error::InvalidGroupRequirements);
}
Ok(Self {
memory_type_index,
property_flags,
size,
alignment,
next_offset: 0,
members: Vec::new(),
allocation: None,
})
}
/// Adds a buffer, placing it at the next suitably aligned offset.
pub fn add_buffer(&mut self, resource: &'a mut Buffer) -> Result<u64> {
if self.allocation.is_some() {
return Err(Error::AlreadyAllocated);
}
let requirements = resource.memory_requirements()?;
self.add_member_type_check(&requirements)?;
let offset = self.next_member_offset(&requirements)?;
let next_offset = offset
.checked_add(requirements.size)
.ok_or(Error::OffsetOverflow)?;
self.members.push(Member::Buffer { resource, offset });
self.next_offset = next_offset;
Ok(offset)
}
/// Adds either resource kind through a uniform entry point.
pub fn add(&mut self, member: MemoryMember<'a>) -> Result<u64> {
match member {
MemoryMember::Buffer(resource) => self.add_buffer(resource),
MemoryMember::Image(resource) => self.add_image(resource),
}
}
/// Adds an image, placing it at the next suitably aligned offset.
pub fn add_image(&mut self, resource: &'a mut Image) -> Result<u64> {
if self.allocation.is_some() {
return Err(Error::AlreadyAllocated);
}
let requirements = resource.memory_requirements()?;
self.add_member_type_check(&requirements)?;
let offset = self.next_member_offset(&requirements)?;
let next_offset = offset
.checked_add(requirements.size)
.ok_or(Error::OffsetOverflow)?;
self.members.push(Member::Image { resource, offset });
self.next_offset = next_offset;
Ok(offset)
}
/// Allocates the group's shared backing memory.
pub fn allocate(&mut self, device: &Device) -> Result<()> {
if self.members.is_empty() {
return Err(Error::EmptyGroup);
}
if self.allocation.is_some() {
return Err(Error::AlreadyAllocated);
}
let requirements = MemoryRequirements {
size: self.size,
alignment: self.alignment,
memory_type_bits: 1 << self.memory_type_index,
requires_dedicated_allocation: false,
prefers_dedicated_allocation: false,
};
let allocation = device
.allocate_memory(&requirements, self.property_flags)
.map_err(Error::Allocation)?;
for member in &self.members {
let (requirements, offset) = match member {
Member::Buffer { resource, offset } => (resource.memory_requirements()?, *offset),
Member::Image { resource, offset } => (resource.memory_requirements()?, *offset),
};
validate_binding(&requirements, allocation.as_ref(), offset)?;
}
self.allocation = Some(allocation);
Ok(())
}
/// Binds every member and transfers the allocation reference into each resource.
pub fn bind_all(mut self) -> Result<()> {
let allocation = self.allocation.take().ok_or(Error::NotAllocated)?;
// Validate resources first to preserve the same resource-level error mapping as
// individual binding, then prepare the ordered device batch from cached requirements.
let mut bindings = Vec::with_capacity(self.members.len());
for member in &self.members {
match member {
Member::Buffer { resource, offset } => {
resource.validate_unallocated()?;
let requirements = resource.memory_requirements()?;
validate_binding(&requirements, allocation.as_ref(), *offset)?;
resource.validate_device(allocation.device())?;
bindings.push(MemoryBinding::Buffer {
buffer: resource.raw(),
offset: *offset,
requirements,
});
}
Member::Image { resource, offset } => {
resource.validate_unallocated()?;
let requirements = resource.memory_requirements()?;
validate_binding(&requirements, allocation.as_ref(), *offset)?;
resource.validate_device(allocation.device())?;
bindings.push(MemoryBinding::Image {
image: resource.raw(),
offset: *offset,
requirements,
});
}
}
}
if let Err(bind_error) = allocation
.device()
.bind_group_at_offset(&allocation, &bindings)
{
// A Vulkan bind failure leaves its earlier prefix bound; retain its allocation just
// as repeated `bind_allocation` calls would have done.
for (index, member) in self.members.into_iter().enumerate() {
if index >= bind_error.bound_count {
break;
}
match member {
Member::Buffer { resource, .. } => {
resource.set_shared_allocation(allocation.clone());
}
Member::Image { resource, .. } => {
resource.set_shared_allocation(allocation.clone());
}
}
}
return Err(Error::Bind(bind_error.error));
}
for member in self.members {
match member {
Member::Buffer { resource, .. } => {
resource.set_shared_allocation(allocation.clone());
}
Member::Image { resource, .. } => {
resource.set_shared_allocation(allocation.clone());
}
}
}
// `allocation` drops here. Only successfully bound resources retain it.
Ok(())
}
fn add_member_type_check(&self, requirements: &MemoryRequirements) -> Result<()> {
if requirements.requires_dedicated_allocation {
return Err(Error::RequiresDedicatedAllocation);
}
if requirements.memory_type_bits & (1 << self.memory_type_index) == 0 {
return Err(Error::GroupMemoryTypeMismatch {
memory_type_index: self.memory_type_index,
});
}
Ok(())
}
fn next_member_offset(&self, requirements: &MemoryRequirements) -> Result<u64> {
if requirements.alignment == 0 {
return Err(Error::InvalidGroupRequirements);
}
let alignment = checked_lcm(requirements.alignment, self.alignment)?;
let offset = (self
.next_offset
.checked_add(alignment - 1)
.ok_or(Error::OffsetOverflow)?
/ alignment)
.checked_mul(alignment)
.ok_or(Error::OffsetOverflow)?;
let end = offset
.checked_add(requirements.size)
.ok_or(Error::OffsetOverflow)?;
if end > self.size {
return Err(Error::GroupCapacityExceeded {
requested_end: end,
capacity: self.size,
});
}
Ok(offset)
}
}
fn checked_lcm(lhs: u64, rhs: u64) -> Result<u64> {
let gcd = {
let (mut lhs, mut rhs) = (lhs, rhs);
while rhs != 0 {
(lhs, rhs) = (rhs, lhs % rhs);
}
lhs
};
lhs.checked_div(gcd)
.and_then(|lhs| lhs.checked_mul(rhs))
.ok_or(Error::OffsetOverflow)
}
/// A resource accepted by [`MemoryGroup::add`].
pub enum MemoryMember<'a> {
Buffer(&'a mut Buffer),
Image(&'a mut Image),
}

View file

@ -1,90 +0,0 @@
use std::sync::Arc;
use ash::vk;
use renderer_device::{
device::{MemoryLocation, QueueFlags},
testing::TestVulkan,
};
use renderer_resources::{
buffers::{Buffer, BufferDesc},
pipeline::{
BufferDescriptor, DescriptorPool, DescriptorPoolDesc, DescriptorSetAllocDesc,
DescriptorSetLayout, DescriptorSetLayoutBindingDesc, DescriptorSetLayoutDesc,
},
};
#[test]
fn managed_set_write_and_pool_reset_under_validation() {
let Some(test) = TestVulkan::acquire() else {
return;
};
let device = test.device().clone();
let buffer = Arc::new(
Buffer::new(
device.clone(),
BufferDesc {
size: 256,
usage: vk::BufferUsageFlags::UNIFORM_BUFFER,
queue_families: QueueFlags::empty(),
mem_location: MemoryLocation::CpuToGpu,
..Default::default()
},
)
.expect("create descriptor buffer"),
);
let bindings = [DescriptorSetLayoutBindingDesc {
binding: 0,
count: 1,
kind: vk::DescriptorType::UNIFORM_BUFFER,
stage: vk::ShaderStageFlags::VERTEX,
flags: None,
}];
let layout = DescriptorSetLayout::new(
device.clone(),
DescriptorSetLayoutDesc {
bindings: &bindings,
..Default::default()
},
)
.expect("create descriptor set layout");
let sizes = [vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::UNIFORM_BUFFER)
.descriptor_count(1)];
let pool = DescriptorPool::new(
device,
DescriptorPoolDesc {
sizes: &sizes,
max_sets: 1,
..Default::default()
},
)
.expect("create descriptor pool");
let mut set = pool
.allocate_managed(DescriptorSetAllocDesc {
name: None,
layout: &layout,
})
.expect("allocate managed descriptor set");
let mut writer = set.writer().expect("create descriptor writer");
writer
.write_buffer(
0,
0,
&[BufferDescriptor {
buffer: &buffer,
offset: 0,
range: 256,
}],
)
.expect("stage buffer descriptor");
writer.commit().expect("commit buffer descriptor");
test.device().wait_idle().expect("wait for device idle");
drop(set);
// SAFETY: no command uses this set, the device is idle, and this test excludes concurrent use.
unsafe { pool.reset() }.expect("reset descriptor pool");
drop(pool);
drop(layout);
drop(buffer);
test.assert_no_validation_errors();
}

View file

@ -1,326 +0,0 @@
# Migration Plan: gpu-allocator → VMA via vk-mem
Status: proposed — rev 2 (Luna review fixes applied)
Date: 2026-10-26
Scope: workspace-wide memory-allocator swap
## Goal
Replace the `gpu-allocator` crate with VMA (Vulkan Memory Allocator) via the
`vk-mem` bindings crate, preserving current allocation behavior (buffer/image
create/bind/destroy, mapping, dedicated allocations) and the existing real-Vulkan
tests.
## Architecture decision
### Requirements
- Hard: remove `gpu-allocator`; use `vk-mem`/VMA; preserve the dependency
direction `bevy_vulkan_render` → `renderer` → `renderer_resources` →
`renderer_device`; keep buffer/image create/bind/destroy and mapping behavior;
keep real-Vulkan tests working.
- Nice: dedicated allocations, allocation naming/stats, defragmentation (not
required now).
### Constraints
- Nightly toolchain, edition 2024.
- `renderer_resources` must not depend on `renderer`.
- `renderer_device` owns device/allocator state.
- `renderer_resources`/`renderer` should not expose `vk_mem` types.
- The `testing` feature gates real-device tests.
- No per-frame allocation is acceptable on current paths.
- `renderer_rendergraph` is also a workspace member and directly uses
`gpu_allocator::MemoryLocation`, so it must be included (it is not mentioned in
the workspace guide).
### Current architecture
- Deps: root `Cargo.toml:45` git `gpu-allocator` (line 44 has a commented
`# vk-mem = "0.5.0"`); `renderer_device/Cargo.toml:12`,
`renderer_resources/Cargo.toml:12`, `renderer/Cargo.toml:27`,
`renderer_rendergraph/Cargo.toml:8`. `Cargo.lock:3423-3435` is the git package;
`Cargo.lock:3409-3420` is the registry copy pulled by `wgpu-hal` via the Bevy
dev-dependency.
- Allocator state: `crates/renderer_device/src/device.rs:151`
`pub alloc2: Mutex<gpu_allocator::vulkan::Allocator>`, initialized at
`:441-456` (`AllocatorCreateDesc`, `AllocationSizes::new(8MB,64MB)` with 256MB
maxima). `GpuAllocation` is the private alias at `:111`; `AllocationStrategy`
at `:114-124`; `Allocation::{Owned,Shared,Unmanaged}` at `:126-147`. Free path
is `ExternallyManagedObject<Device> for GpuAllocation` at `:1074-1077`, which
calls `alloc2.lock().free(...)`.
- Errors: `crates/renderer_device/src/lib.rs:81`
`GpuAllocatorError(#[from] gpu_allocator::AllocationError)`.
- Buffer flow: `crates/renderer_resources/src/buffers.rs:4` imports
`AllocationScheme`; `:20,68` `MemoryLocation`; `:108-122` allocates from
`alloc2`; `:128-130` binds with raw `vkBindBufferMemory`; `:137` wraps in
`Allocation::Owned(DeviceObject<GpuAllocation>)`; `:164-181` maps via
`mapped_slice`/`mapped_slice_mut`.
- Image flow: `crates/renderer_resources/src/images.rs:16` imports VMA-named
`AllocationCreateDesc/Scheme`; `:35,126,289` `MemoryLocation`; `:191-205`
allocates and wraps; `:217-223` binds; `:244-260` validates size/memory-type
for `new_with_allocation`; `:418-428` exposes allocation.
- Dedicated logic: `buffers.rs:116-121` uses
`AllocationScheme::DedicatedBuffer(buffer)`; `images.rs:196-199` uses
`DedicatedImage(image)`; `renderer_rendergraph/src/graph.rs:1352-1357` chooses
dedicated at 64 MiB.
- Direct test allocations: `crates/renderer_device/src/testing.rs:312-319` and
`:351-357` allocate/free directly through `device.alloc2`.
- Other type consumers: `renderer/src/rendering/mod.rs:8,56,77,87,97`;
`renderer/src/egui.rs:8,74-75,140,157,510,531,538,545,555`;
`renderer/src/render_graph/graph_builder.rs:51,60,69,87,168-183`;
`renderer_rendergraph/src/resources.rs:16,27,46,69`; tests
`renderer_resources/tests/managed_set_validation.rs:26` and
`renderer_rendergraph/tests/graph_validation.rs:31`.
- No stats/reporting or defragmentation call sites exist today. `Buffer`/`Image`
drop field order: buffer/image handle first, then allocation
(`buffers.rs:74-78`, `images.rs:144-148`).
### Alternatives considered
1. **Swap allocator inside `renderer_device`, add a private wrapper and a
workspace-owned `MemoryLocation` (chosen)**: least invasive, upper crates stay
VMA-free.
2. Re-export `vk_mem` types as public resource API: least code, but leaks VMA
into `renderer_resources`/`renderer` and locks the workspace API to `vk-mem`.
3. Use `vk_mem::create_buffer/create_image` directly in `renderer_resources`:
fewer Vulkan calls and idiomatic VMA, but larger ownership refactor and
couples the resource crate to `vk_mem`.
4. Add a memory-backend trait: easy A/B testing, but unnecessary indirection and
lifetime complexity for a one-time swap.
### Decision rationale (feasibility verified)
- `vk-mem 0.5.0` bundles **VMA 3.3.0** and depends on **ash 0.38** (workspace ash
is `0.38.0+1.3.281`), so there is one ash and no duplicate-ash problem.
- `vk_mem::Allocator` is `Send + Sync` and internally synchronized unless
`EXTERNALLY_SYNCHRONIZED` is set; keep the `Mutex`.
- `vk_mem::Allocation` is `Copy, Send, Sync` and has no inherent
`size/memory/offset` accessors, so the wrapper must retain `AllocationInfo`.
## Target design
- `renderer_device::device::MemoryLocation { Unknown, GpuOnly, CpuToGpu,
GpuToCpu }` with `Default = Unknown`, `Copy/Eq/Hash`; use it as the public
`mem_location` type.
- `pub struct GpuAllocation { raw: vk_mem::Allocation, memory: vk::DeviceMemory,
offset: u64, size: u64, memory_type_index: u32, mapped: Option<SendSyncPtr> }`.
`SendSyncPtr` is a private `NonNull<u8>` wrapper with unsafe `Send/Sync`,
mirroring `gpu_allocator`'s pointer wrapper. Implement `Debug`,
`memory()/offset()/size()/memory_type_index()/mapped_slice()/mapped_slice_mut()`.
- `pub struct AllocationDesc<'a> { name: &'a str, location: MemoryLocation,
strategy: AllocationStrategy }` and
`DeviceInner::{allocate_for_buffer, allocate_for_image, bind_buffer_memory,
bind_image_memory}`. Note the call shapes: `allocate_memory_for_buffer/image`
live on the `vk_mem::Alloc` trait (import `vk_mem::Alloc as _`) and are
`unsafe` in `vk-mem 0.5.0`; `bind_buffer_memory`/`bind_image_memory` are
inherent (also `unsafe`) `Allocator` methods, not trait methods.
- `DeviceInner.alloc2: Mutex<vk_mem::Allocator>` remains the single allocator
owner. Keep `Allocation::{Owned(DeviceObject<GpuAllocation>),
Shared(Arc<...>), Unmanaged}` unchanged in shape.
- `renderer_resources`/`renderer`/`renderer_rendergraph` use only
`renderer_device::device::MemoryLocation`, `Allocation`, and the new `Device`
helpers; no `vk_mem` imports outside `renderer_device`.
Suggested interface:
```rust
impl DeviceInner {
pub fn allocate_for_buffer(&self, buffer: vk::Buffer, desc: AllocationDesc<'_>) -> Result<GpuAllocation>;
pub fn allocate_for_image(&self, image: vk::Image, desc: AllocationDesc<'_>) -> Result<GpuAllocation>;
pub fn bind_buffer_memory(&self, alloc: &GpuAllocation, buffer: vk::Buffer) -> Result<()>;
pub fn bind_image_memory(&self, alloc: &GpuAllocation, image: vk::Image) -> Result<()>;
}
```
## Dependency changes
- Root `Cargo.toml`: add `vk-mem = "0.5.0"` to `[workspace.dependencies]` (the
commented line at `:44` anticipates this); remove the git `gpu-allocator` entry
in phase 3.
- `renderer_device/Cargo.toml`: add `vk-mem = { workspace = true }`; remove
`gpu-allocator` in phase 2 (after the swap compiles).
- `renderer_resources/Cargo.toml`, `renderer/Cargo.toml`,
`renderer_rendergraph/Cargo.toml`: remove `gpu-allocator` in phase 3.
- Ash alignment: `vk-mem 0.5.0` pins ash 0.38; an ash upgrade requires a
matching `vk-mem` release or fork.
## API mapping
| gpu-allocator | VMA / `vk-mem` |
|---|---|
| `AllocatorCreateDesc` instance/device/physical_device | `AllocatorCreateInfo::new(&instance, &device, physical_device)` |
| `debug_settings`, allocation sizes | VMA compile-time debug macros; `preferred_large_heap_block_size` only (loss of min/max block tuning) |
| `buffer_device_address` | `AllocatorCreateFlags::BUFFER_DEVICE_ADDRESS` (off here) |
| `MemoryLocation::{Unknown,GpuOnly,CpuToGpu,GpuToCpu}` | `MemoryUsage::{Unknown,GpuOnly,CpuToGpu,GpuToCpu}` |
| `AllocationScheme::GpuAllocatorManaged` | default allocation (`AllocationCreateFlags::empty()`) |
| `DedicatedBuffer(buffer)` / `DedicatedImage(image)` | `DEDICATED_MEMORY` + `allocate_memory_for_buffer/image` (passes `VkMemoryDedicatedAllocateInfo` when API version ≥ 1.1) |
| `AllocationCreateDesc.linear` | implicit: use `allocate_memory_for_buffer/image`, not generic allocation |
| `Allocation.memory()/offset()/size()/memory_type_index()` | `AllocationInfo.device_memory/offset/size/memory_type` captured at allocation |
| `mapped_slice()/mapped_slice_mut()` | `AllocationInfo.mapped_data` from `MAPPED` allocation, or `Allocator::map_memory` |
| `Allocator::free(allocation)` | `Allocator::free_memory(&mut allocation)` |
| `bind_buffer_memory`/`bind_image_memory` | `Allocator::{bind_buffer_memory, bind_image_memory}` |
| stats report | `calculate_statistics()` / `get_heap_budgets()`; no allocation names in `vk-mem 0.5.0` |
| debug names | unchanged: `DeviceObject::new_debug_named` sets Vulkan object names; VMA allocation names are not exposed |
## Migration phases
### Phase 0 — baseline
Run `cargo check --workspace`, `cargo test -p renderer_device --features
testing`, inspect `git status`. No edits.
### Phase 1 — dependency/type scaffolding (non-breaking)
Add `vk-mem = "0.5.0"` to root `[workspace.dependencies]`; add
`vk-mem = { workspace = true }` to `renderer_device/Cargo.toml`; add
`MemoryLocation`, `GpuAllocation` skeleton, and `AllocationDesc` in
`crates/renderer_device/src/device.rs` while leaving the old allocator
untouched.
Verify: `cargo check -p renderer_device`.
### Phase 2 — atomic allocator swap (one compile unit)
In `crates/renderer_device/src/device.rs` replace `alloc2`/creation/
`GpuAllocation` alias/free impl with VMA; remove `GpuAllocatorError` from
`crates/renderer_device/src/lib.rs:81`; rewrite `testing.rs:312-319,351-357`;
in `crates/renderer_resources/src/buffers.rs` and `images.rs` replace
`alloc2.allocate` + raw bind with `allocate_for_buffer/image` + `bind_*`, adding
handle guards in the same pass (raw handle wrapped in `DeviceObject` immediately
after creation, allocation ownership established before binding — see
implementation notes), but temporarily keep the public fields as
`gpu_allocator::MemoryLocation` and convert at the call site.
Verify: `cargo check --workspace` and `cargo test -p renderer_device --features
testing`.
### Phase 3 — public type/dependency cleanup
Change `BufferDesc.mem_location`/`ImageDesc.mem_location` to
`renderer_device::device::MemoryLocation`; update `renderer/src/rendering/mod.rs`,
`renderer/src/egui.rs`, `renderer/src/render_graph/graph_builder.rs`,
`renderer_rendergraph/src/resources.rs`, and both tests
(`renderer_resources/tests/managed_set_validation.rs`,
`renderer_rendergraph/tests/graph_validation.rs`); remove `gpu-allocator` from
`renderer_resources`, `renderer`, `renderer_rendergraph`, and root manifests.
Verify: `cargo check --workspace`; `rg -n 'gpu_allocator|gpu-allocator' --glob
'!Cargo.lock'` should be empty.
### Phase 4 — hardening/docs
- Ensure `GpuAllocation` is `Send + Sync`.
- Add handle guards: wrap raw buffer/image handles in `DeviceObject` right after
creation and establish allocation ownership before binding, so allocation and
bind failures never leak (see implementation notes).
- Add a validation test for `CpuToGpu` mapping that asserts the allocated memory
type is `HOST_VISIBLE | HOST_COHERENT` and round-trips a CPU write through
`map_mut`, plus dedicated buffers/images.
- Update `crates/renderer_device/AGENTS.md`,
`crates/renderer_resources/AGENTS.md`, `crates/renderer/AGENTS.md`, and the
root workspace guide to say VMA/`vk-mem`.
- Optionally rename `alloc2` to `allocator`.
Verify: full checklist below.
## Implementation notes
- Allocator creation: `unsafe { vk_mem::Allocator::new(create_info) }?`; set
`preferred_large_heap_block_size` to ~64 MiB (VMA's default is 256 MiB).
`vulkan_api_version` must not exceed the instance's requested version or the
selected physical device's supported version: record the instance request
(add `pub api_version: u32` to `InstanceInner` when building `ApplicationInfo`
in `instance.rs:126-135`) and clamp it with the physical device's
`limits.api_version` at device selection, passing the effective minimum (a
safe floor is 1.1 when both sides support it, else 1.0). Dedicated-allocation
structs require API ≥ 1.1; on Vulkan 1.0 devices require
`VK_KHR_dedicated_allocation` (+ `VK_KHR_get_memory_requirements2`) at device
creation or skip `DEDICATED_MEMORY` there.
- Do not enable `EXTERNALLY_SYNCHRONIZED`; keep the `Mutex`.
- Allocation flags: `MAPPED` for `CpuToGpu`/`GpuToCpu`, plus `DEDICATED_MEMORY`
for `AllocationStrategy::Dedicated`.
- **Coherence policy (required)**: VMA's `CpuToGpu`/`GpuToCpu` usages guarantee
host-visible memory, not host-coherent memory, but the current mapping API
(`mapped_slice`/`mapped_slice_mut`) has no flush/invalidate step. Require
coherent memory: compute the memory-type bitmask of types with
`HOST_VISIBLE | HOST_COHERENT` from the physical device's memory properties
and set `AllocationCreateInfo.memory_type_bits` for CPU-mapped allocations;
fail the allocation if no such type exists (stricter than gpu-allocator,
which silently falls back to non-coherent types). If a non-coherent-only
device must be supported later, switch to explicit
`Allocator::flush_allocation`/`invalidate_allocation` around map windows and
change the API contract accordingly — do not ship a silent fallback.
- **Handle guards before allocation**: the buffer/image flows create raw Vulkan
handles before allocating (`buffers.rs:108-130`, `images.rs:191-223`); wrap
each raw handle in `DeviceObject` immediately after creation and establish
`Allocation` ownership (`DeviceObject<GpuAllocation>`) before binding, so
every failure path — allocation failure or bind failure — releases the handle
and the allocation in the correct order (destroy handle first, then
`free_memory`). No raw handle may escape a fallible span unowned.
- Free with `free_memory` after the buffer/image handle is destroyed; do not
also call `destroy_buffer/destroy_image` for the same allocation.
## Validation checklist
- `cargo check -p renderer_device`, `-p renderer_resources`, `-p renderer`,
`-p renderer_rendergraph`, then `cargo check --workspace`.
- `cargo test -p renderer_device` and `cargo test -p renderer_device --features
testing` (real Vulkan/validation; skips if unavailable).
- `cargo test -p renderer_resources` and `cargo test -p renderer_rendergraph`
(both exercise `TestVulkan` through their dev-dependency features).
- `cargo tree -d | rg '^ash'` should show one ash. The lockfile has two
`gpu-allocator 0.28.0` packages (registry + git), so `cargo tree -i
gpu-allocator` is ambiguous; use source-qualified package IDs:
`cargo tree -i 'registry+https://github.com/rust-lang/crates.io-index#gpu-allocator@0.28.0'`
should list only `wgpu-hal` via the Bevy dev-dependency, and
`cargo tree -i 'https://github.com/janis-bhm/gpu-allocator#gpu-allocator@0.28.0'`
should be empty after phase 3.
`rg -n 'gpu_allocator|gpu-allocator' --glob '!Cargo.lock'` should return
nothing.
- Re-run the baseline Vulkan smoke tests and, if a display is available,
`cargo build --examples`.
## Risks & gotchas
- **FFI/lifetimes**: `vk_mem::Allocator` is a raw-pointer owner; it must outlive
every `Allocation`. The existing `DeviceInner` field order (`alloc2` before
`_drop`) is correct; do not move the allocator after `_drop`. `GpuAllocation`
must not be `Copy` and must free exactly once through
`DeviceObject`/`ExternallyManagedObject`.
- **Thread safety**: `AllocationInfo.mapped_data` is `*mut c_void`, which is not
`Send/Sync`; storing it directly breaks `Arc<Buffer>` thread-safety. Use a
private `SendSyncPtr` or unsafe `Send/Sync` wrapper and add a compile-time
`assert_send_sync::<Buffer>()`/`Image` test.
- **Mapping differs**: `gpu-allocator` mapped host-visible blocks
opportunistically; VMA maps only with `MAPPED`. `CpuToGpu`/`GpuToCpu` keep
working; `GpuOnly` mapping on integrated GPUs may now return `None`.
**Coherence**: VMA only guarantees host-visible (not host-coherent) memory
for CPU usages; this plan requires coherent types via `memory_type_bits`
(see implementation notes) so the existing flush-free `map`/`map_mut` usage
stays correct. Non-coherent support would need explicit
`flush_allocation`/`invalidate_allocation`; current code does not
flush/invalidate either.
- **Dedicated allocation**: use `allocate_memory_for_buffer/image` with
`DEDICATED_MEMORY`; generic `allocate_memory` would create a separate block
without dedicated buffer/image info. `VkMemoryDedicatedAllocateInfo` is only
emitted when the runtime API version is ≥ 1.1 (or the KHR dedicated
extensions are enabled); pass the clamped effective version per the
implementation notes and require the extensions on Vulkan 1.0 devices.
- **OOM handling**: `gpu_allocator::AllocationError` variants disappear; VMA
returns `vk::Result` (`ERROR_OUT_OF_DEVICE_MEMORY`,
`ERROR_FEATURE_NOT_PRESENT`, etc.), already handled by `Error::Result`. No
call site pattern-matches the old variant.
- **Build environment**: `vk-mem 0.5.0` compiles VMA C++ (`wrapper.cpp`, C++17,
links stdc++ on Linux); ensure CI has a C++ compiler. It pins ash 0.38; an ash
upgrade requires a matching `vk-mem` release/fork. The crate is less actively
maintained than `gpu-allocator`.
- **Lost features**: no `vk_mem` allocation naming setter
(`vmaSetAllocationName`) and no `build_stats_string` in 0.5.0; VMA
stats/heap budgets remain available. Defragmentation is intentionally out of
scope; if enabled later, cached `memory/offset/mapped_data` in the wrapper
must be re-queried.
- **Pre-existing hazard**: `Image::new_with_allocation` still creates the raw
image before validation (`images.rs:235-260`); the handle-guard requirement
(implementation notes) covers this by owning the raw image in `DeviceObject`
from creation onward.

View file

@ -1,575 +0,0 @@
# 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).

View file

@ -1,202 +0,0 @@
# Plan: Correct Transient-Resource Aliasing in `renderer_rendergraph`
Status: proposed — rev 3 (code-verified; rev-2 review + follow-up comments applied)
Date: 2026-10-26
Scope: `crates/renderer_rendergraph`, `crates/renderer_resources`, `crates/renderer_device`
## Goal
Fix the broken transient-resource aliasing in the rendergraph: lifetimes must be
derived from the graph's real ordering (dependency DAG + batches, not command
indices), alias reuse must be synchronized with barriers, and the resources
crate must gain the infrastructure to compute correct memory requirements so
correctly sized pools can be allocated and bound to graph-created resources.
## Diagnosis (verified against code)
| # | Defect | Evidence |
|---|--------|----------|
| 1 | Lifetimes are command-index intervals (`first_command`/`last_command`), but commands within a `topo_batches` batch are conceptually concurrent — two resources used by different commands of the same batch are wrongly treated as non-overlapping | `graph.rs:66-79, 862-877` |
| 2 | Batch index is **not** a happens-before proof: the DAG only contains edges for conflicting same-resource accesses and is discarded after `topo_batches`; e.g. test `independent_commands_share_a_batch...` produces batches `[0,2],[1]` with no edge `2→1` | `graph.rs:643-657, 683-697, 699-714` |
| 3 | Queue transfers unmodeled: `QueueTransfer` exists but every barrier sets `None`; no command→queue assignment exists anywhere (`derive_queue_capabilities` is a resource-wide stage union, not a per-command queue) | `graph.rs:41-58, 792, 822, 1461-1541` |
| 4 | No alias-safety barriers: `build_barriers` only tracks prior uses of the *same* resource; nothing orders last use of alias A before first use of alias B sharing memory | `graph.rs:728-835` |
| 5 | Packing uses `texture_size_estimate`/`desc.size`, ignoring real `VkMemoryRequirements`, alignment, memory-type bits, `bufferImageGranularity`, and dedicated-allocation requirements; offsets are never bound to resources | `graph.rs:888-919, 1543-1588` |
| 6 | No offset-aware bind path: `bind_buffer_memory`/`bind_image_memory` bind whole VMA allocations; `Allocation::Shared(Arc<DeviceObject<GpuAllocation>>)` exists but is unused for pooling | `device.rs:209-214, 766-778` |
## Design decisions (post-review)
### D1 — Happens-before, not batch numbers
Retain the dependency adjacency from `GraphCompiler::dependencies()` in
`CompiledGraph` and add an explicit `happens_before(a, b)` reachability query.
Alias A→B is permitted only if *every* terminal use of A happens-before *every*
initial use of B. Batch intervals are computed (for reporting/limits) but never
used as the safety criterion. Same-batch uses always interfere.
`happens_before` is computed as a **transitive closure at compile time**: one
bitset per command (`Vec<FixedBitSet>`-style reachability rows, or bitset
columns if that packs better), built once in `compile()` from the Kahn
adjacency. Queries are then O(1) and deterministic. Rationale: the command
count is bounded by the recorded command list (thousands at most), closure is
O(V·E/64) once, and per-query BFS would be re-run per alias-candidate pair
during placement for no benefit. Note the closure's semantics: edges encode
only conflicting same-resource accesses, so reachability means exactly
"provably ordered via a resource-hazard chain" — precisely the aliasing-safety
criterion. Commands mutually unreachable (including same-batch pairs) interfere.
### D2 — Phase split
`compile()` stays device-free: dependency order, batch lifetimes, interference.
A new device-dependent `plan_placements(device)` (called from `resolve`) does
requirements queries, pool placement, alias-barrier construction, and binding.
Tests that call `bind_lifetimes()` directly are updated.
### D3 — Conservative queue policy
There is no command→queue assignment today and all commands record into one
command buffer. Aliasing is restricted to the single-queue case; any pair whose
accesses can't be proven ordered on one queue interferes. Cross-queue aliasing
(with ownership transfers/semaphores) is explicitly out of scope and documented
as a follow-up — no fake `QueueTransfer` support.
### D4 — Alias synchronization done correctly
- For aliased **buffers**: emit a `VkMemoryBarrier2` (new `MemoryBarrier` +
`PipelineBarrier.memory_barriers`) before the new occupant's *first-use
command* (a concrete command, not "batch start"), with `src` = old occupant's
aggregated terminal stage/access (writes; execution-only if read-only),
`dst` = new occupant's aggregated first-use scope.
- For aliased **images**: fold the old occupant's terminal scope into the new
image's first-use image barrier (`srcStageMask`/`srcAccessMask` = old scope,
`oldLayout = UNDEFINED`) — a bare memory barrier does not chain with a
following `srcStageMask=NONE` layout transition.
- Aggregate multi-scope accesses per command (OR all stage/access for the
resource at that command).
- Placement is range-based: a new resource's `[offset, offset+size)` is tested
against **all** occupied ranges; barriers include every old occupant
intersecting the range.
### D5 — Vulkan aliasing correctness details
- `bufferImageGranularity`: **separate pools per class** (linear-only and
optimal-only) is the primary strategy — first-fit stays simple and no
padding logic is needed. Intra-pool offsets are rounded only to each
resource's `alignment`. (Intra-pool interleaving with granularity-driven
offset rounding is a deliberate non-goal; revisit only if pool-class split
waste ever shows up in practice.)
- Set `VK_IMAGE_CREATE_ALIAS_BIT` on pooled images.
- Exclude from pools: `requiresDedicatedAllocation` resources, explicit
`AllocationStrategy::Dedicated` (incl. the 64 MiB heuristic), and
host-visible (`CpuToGpu`/`GpuToCpu`) resources — `Buffer::map()` returns
slices from the allocation base and would silently map wrong bytes unless
suballocation offsets are tracked.
- Created-but-unused resources: skip creation entirely (or allocate outside the
pool) — decided as "skip unused transients", with a test.
- Pooled images must have `GraphTextureDesc.layout == UNDEFINED`:
`plan_placements` rejects non-UNDEFINED declared initial layouts on pooled
images with a graph error (a created image's declared initial layout cannot
be honored by reused memory, whose contents are always undefined at first
use). Non-pooled images keep today's behavior.
### D6 — Device/resources infra (new APIs, not existing ones)
- `renderer_device`: `allocate_memory(requirements, …)` via
`vk_mem::Alloc::allocate_memory` with `CAN_ALIAS`; `find_memory_type_index`;
`bind_buffer_at_offset`/`bind_image_at_offset` via `bind_*_memory2` with
`allocation_local_offset`; require API ≥1.1 or enable the full
`get_memory_requirements2`/`bind_memory2`/`dedicated_allocation` extension
set with a clear error otherwise (same guard pattern as the existing
dedicated-allocation check in `device.rs`); define shared-allocation
ownership/cleanup: at bind time the group **transfers** the
`Arc<DeviceObject<GpuAllocation>>` into each bound resource's
`Allocation::Shared` and drops its own reference — the underlying memory is
freed when the last bound resource drops (via `DeviceObject` drop), matching
the existing `Owned`/`Shared` semantics. The group never retains a post-bind
handle.
- `renderer_resources`: symmetric `Buffer::new_unallocated` /
`Image::new_unallocated`, `memory_requirements()` (incl.
`VkMemoryDedicatedRequirements`), `bind_allocation(alloc, offset)` with
alignment/type-bits/bounds validation; a `memory` module with
`MemoryRequirements` + `MemoryGroup` (bind → `Allocation::Shared`). Pool
groups are keyed by a memory type that is in **every member's**
`memoryTypeBits` (intersection), not by any single member's individually
best type; a member whose bits exclude the group's type moves to another
group or its own allocation. New `renderer_resources::Error` variants for
failed requirement queries / bind-time validation (alignment, type-bits,
bounds); `renderer_rendergraph` maps these into new `GraphError` variants
(e.g. `PlacementFailed(ResourceId)`, `BindFailed(ResourceId)`) — bind/placement
failures surface through `resolve`/`plan_placements`'s existing
`renderer_resources::Result` boundary and map at the call site. No graph
types leak into this crate.
## Task breakdown (ordered, dependency-explicit)
1. **`renderer_device`**: memory-type query, aliasable shared allocation,
offset-aware bind, API/extension guards, error variants, ownership docs.
Tests with `TestVulkan` (`--features testing`).
2. **`renderer_resources`**: unbound create + requirements + bind APIs for
buffer *and* image; `memory` module (`MemoryRequirements`, `MemoryGroup`);
update crate AGENTS.md. Existing `Buffer::new`/`Image::new` unchanged.
3. **`renderer_rendergraph` (analysis)**: retain dependency DAG in
`CompiledGraph`; `happens_before` query; batch-mapped
`ResourceLifetime {first_batch, last_batch}`; replace `memory_offset: u64`
with a typed `Placement {offset, size, alignment, pool, aliased}`; imported
resources tracked but excluded from packing; queue-conservatism rule per D3.
4. **`renderer_rendergraph` (synchronization)**: `MemoryBarrier` +
`PipelineBarrier.memory_barriers`; recorder emits `vk::MemoryBarrier2` in
`DependencyInfo` (anchored via the existing `recording_steps`
`before_command` mechanism, like today's image/barrier steps); alias-barrier
construction per D4 (buffer and image variants, aggregation,
first-use-command anchoring); new `GraphError` variants for placement/bind
failures per D6, mapped from `renderer_resources::Error` at the
`resolve` boundary.
5. **`renderer_rendergraph` (placement)**: `plan_placements(device)` **replaces
`resolve()`'s current eager create+allocate loop** (graph.rs:1218-1276) for
pooled resources — create unbound, query requirements, group by a memory
type common to every member's `memoryTypeBits`, granularity-aware first-fit
over ranges (D4/D5, pool-per-class), allocate groups, bind, store
placements in `CompiledGraph`. Non-poolable resources (dedicated,
host-visible, imports) keep an allocate-and-bind-whole path equivalent to
today's. "Skip unused transients" lands here: created resources absent from
`lifetimes` are never created (no behavior change needed in
`record_commands`, which already skips ids absent from `lifetimes` at
graph.rs:1290). Transactional and idempotent; remove
`texture_size_estimate` from allocation decisions.
6. **Docs/tests**: update stale TODOs (`graph.rs:882`, `1315-1319`), document
the single-queue submission contract and the future multi-queue invariant
(alias transitions become semaphore edges).
## Verification
- Device-free unit tests: same-batch interference; unordered-but-distinct-batch
interference; proven-ordered reuse succeeds; granularity boundary (buffer
next to optimal image); range-overlap first-fit; dedicated/host-visible
exclusion; unused-transient skip; pooled-image non-UNDEFINED initial layout
rejected; alias-barrier scope correctness incl. read-only old occupant and
image `UNDEFINED` transition chaining.
- GPU-gated (`tests/graph_validation.rs`): buffer-buffer and image-image alias
reuse through the real barrier path, submit, `device_wait_idle`,
`assert_no_validation_errors`; teardown of shared allocation after all
resources drop.
- Commands: `cargo check --workspace`, `cargo test -p renderer_rendergraph`,
`cargo test -p renderer_resources --features testing`,
`cargo test -p renderer_device --features testing` (from workspace root,
pinned toolchain).
## Review history
- Drafted independently by deepseek/planner and luna/planner; cross-reviewed by
deepseek/reviewer and luna/reviewer (both verdicts: CHANGES REQUIRED).
- Blockers folded into this revision: batch-index ≠ happens-before (D1),
compile/resolve phase split (D2), `bufferImageGranularity` handling (D5),
alias-barrier/layout-transition chaining (D4), offset-aware bind APIs are new
work not existing capability (D6), queue model restricted to single-queue
(D3).
- Rev 3: diagnosis re-verified against code and `vk-mem 0.5.0` sources
(all six rows and the D6 API names confirmed). Follow-up comments folded in:
`happens_before` = compile-time transitive closure (D1), pool-per-class
granularity strategy picked (D5), pooled-image UNDEFINED initial-layout
rule (D5), pool groups keyed by intersecting `memoryTypeBits` (D6),
`MemoryGroup` transfers the `Arc` at bind time instead of retaining it (D6),
placement/bind error surfacing through `GraphError` (D6/task 4),
`plan_placements` explicitly replaces `resolve`'s eager loop (task 5).