Compare commits
8 commits
f7c67f9872
...
ad22ff8c76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad22ff8c76 | ||
|
|
33b42533ef | ||
|
|
afb322fa93 | ||
|
|
4bf2d874b0 | ||
|
|
5e4bc0ccf0 | ||
|
|
e116851b90 | ||
|
|
7b2adb2c03 | ||
|
|
0426e4039b |
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
/target/
|
||||
/Cargo.lock
|
||||
**/pipeline_cache.bin
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -12,11 +12,12 @@ members = [
|
|||
"crates/window",
|
||||
"crates/game",
|
||||
"crates/text",
|
||||
"crates/bevy_vulkan_render", "crates/renderer_device", "crates/renderer_resources", "crates/renderer_rendergraph",
|
||||
"crates/bevy_vulkan_render", "crates/renderer_device", "crates/renderer_resources", "crates/renderer_rendergraph", "crates/datastructures",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
bevy_vulkan_render = { path = "crates/bevy_vulkan_render" }
|
||||
bitflags.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
bevy = { workspace = true }
|
||||
|
|
@ -28,6 +29,7 @@ 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"] }
|
||||
|
||||
|
|
@ -41,15 +43,15 @@ thread_local = "1.1.8"
|
|||
|
||||
ash = "0.38.0"
|
||||
ash-window = "0.13.0"
|
||||
# vk-mem = "0.5.0"
|
||||
gpu-allocator = { git = "https://github.com/janis-bhm/gpu-allocator", branch = "main" }
|
||||
vk-mem = "0.5.0"
|
||||
rectangle-pack = "0.4.2"
|
||||
# vk-sync = "0.1.6"
|
||||
|
||||
arrayvec = "0.7.6"
|
||||
tinyvec = "1.8"
|
||||
tinyvec = { version = "1.8", features = ["alloc"] }
|
||||
indexmap = "2"
|
||||
petgraph = "0.7"
|
||||
fixedbitset = "0.5"
|
||||
# itertools = "0.14.0"
|
||||
ahash = "0.8"
|
||||
|
||||
|
|
|
|||
6
crates/datastructures/Cargo.toml
Normal file
6
crates/datastructures/Cargo.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[package]
|
||||
name = "datastructures"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
471
crates/datastructures/src/lib.rs
Normal file
471
crates/datastructures/src/lib.rs
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, memory location, queue-sharing flags, and allocation strategy; `Buffer::map`/`map_mut` expose mapped memory only when the allocator returns a mapped allocation.
|
||||
- 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).
|
||||
- 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 allocation memory with its `DeviceObject`; `Allocation::Shared` permits shared ownership and only exposes mutable access when uniquely held; `Unmanaged` carries no allocator allocation.
|
||||
- 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.
|
||||
- `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 via `DeviceInner.alloc2`; device ownership and final allocation release are handled by the device-layer wrappers.
|
||||
- `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.
|
||||
- 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` wraps `u64`.
|
||||
- Render graph IDs are distinct: legacy `GraphResourceId` wraps `u32`; the newer internal `resources::ResourceId` also wraps `u32` (constructed via `ResourceId::from_u32`).
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ 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 }
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@ use std::{
|
|||
};
|
||||
|
||||
use ash::vk;
|
||||
use gpu_allocator::MemoryLocation;
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use crate::{
|
||||
EguiState,
|
||||
buffers::{Buffer, BufferDesc},
|
||||
commands::traits::CommandBufferExt,
|
||||
device::{self, AllocationStrategy},
|
||||
device::{self, AllocationStrategy, MemoryLocation},
|
||||
images::{Image, ImageDesc, ImageViewDesc},
|
||||
render_graph::{
|
||||
Access, Barrier, GraphResourceDesc, GraphResourceId, PassDesc, RecordFn, RenderContext,
|
||||
|
|
@ -572,7 +571,7 @@ pub fn egui_pass(
|
|||
.values()
|
||||
.map(|entry| {
|
||||
let texture = texture_handler.get_texture(entry.id).unwrap();
|
||||
let view = Arc::new(ManuallyDrop::into_inner(
|
||||
let view = Arc::new(
|
||||
texture
|
||||
.create_view(
|
||||
ImageViewDesc::color_2d()
|
||||
|
|
@ -580,7 +579,7 @@ pub fn egui_pass(
|
|||
.with_layer_range(0..1),
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
);
|
||||
let sampler = samplers.get_sampler_owned(entry.as_sampler_desc()).unwrap();
|
||||
(view, sampler)
|
||||
})
|
||||
|
|
@ -771,7 +770,7 @@ pub fn egui_pass(
|
|||
|
||||
let color_attachment = &vk::RenderingAttachmentInfo::default()
|
||||
.image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
|
||||
.image_view(target.create_view(ImageViewDesc::color_2d())?.raw())
|
||||
.image_view(ManuallyDrop::new(target.create_view(ImageViewDesc::color_2d())?).raw())
|
||||
.load_op(vk::AttachmentLoadOp::LOAD)
|
||||
.store_op(vk::AttachmentStoreOp::STORE);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ use ash::vk;
|
|||
use derive_more::derive::{Deref, DerefMut};
|
||||
|
||||
use crate::{
|
||||
buffers, images,
|
||||
buffers,
|
||||
device::MemoryLocation,
|
||||
images,
|
||||
render_graph::{
|
||||
recorder::CommandList,
|
||||
resources::{self, ResourceId},
|
||||
|
|
@ -48,7 +50,7 @@ impl<'r> GraphBuilder<'r> {
|
|||
&self,
|
||||
_size: u64,
|
||||
_usage: vk::BufferUsageFlags,
|
||||
_location: gpu_allocator::MemoryLocation,
|
||||
_location: MemoryLocation,
|
||||
) -> Buffer<'r> {
|
||||
todo!()
|
||||
}
|
||||
|
|
@ -57,7 +59,7 @@ impl<'r> GraphBuilder<'r> {
|
|||
&self,
|
||||
_size: u64,
|
||||
_usage: vk::BufferUsageFlags,
|
||||
_location: gpu_allocator::MemoryLocation,
|
||||
_location: MemoryLocation,
|
||||
) -> Buffer<'r> {
|
||||
todo!()
|
||||
}
|
||||
|
|
@ -66,7 +68,7 @@ impl<'r> GraphBuilder<'r> {
|
|||
&self,
|
||||
_size: u64,
|
||||
_usage: vk::BufferUsageFlags,
|
||||
_location: gpu_allocator::MemoryLocation,
|
||||
_location: MemoryLocation,
|
||||
) -> Buffer<'r> {
|
||||
todo!()
|
||||
}
|
||||
|
|
@ -84,7 +86,6 @@ mod api_demo {
|
|||
|
||||
use super::*;
|
||||
use ash::vk::{self, BufferUsageFlags};
|
||||
use gpu_allocator::MemoryLocation;
|
||||
use indexmap::IndexMap;
|
||||
|
||||
fn egui_graph<'a>(
|
||||
|
|
|
|||
|
|
@ -228,7 +228,17 @@ mod impls {
|
|||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
#[repr(transparent)]
|
||||
pub struct ResourceId(pub u64);
|
||||
pub struct ResourceId(u32);
|
||||
|
||||
impl ResourceId {
|
||||
pub fn from_u32(index: u32) -> Self {
|
||||
Self(index)
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ResourceRange {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
use std::sync::Arc;
|
||||
use std::{mem::ManuallyDrop, sync::Arc};
|
||||
|
||||
pub use crate::egui_pass::{egui_pass, egui_pre_pass};
|
||||
|
||||
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,
|
||||
device::{Device, MemoryLocation},
|
||||
images::ImageViewDesc,
|
||||
pipeline,
|
||||
render_graph::{
|
||||
|
|
@ -330,7 +329,9 @@ impl Wireframe {
|
|||
|
||||
let color_attachment = &vk::RenderingAttachmentInfo::default()
|
||||
.image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
|
||||
.image_view(target.create_view(ImageViewDesc::color_2d())?.raw())
|
||||
.image_view(
|
||||
ManuallyDrop::new(target.create_view(ImageViewDesc::color_2d())?).raw(),
|
||||
)
|
||||
.load_op(vk::AttachmentLoadOp::LOAD)
|
||||
.store_op(vk::AttachmentStoreOp::STORE);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use std::{
|
||||
mem::ManuallyDrop,
|
||||
num::NonZero,
|
||||
ops::Deref,
|
||||
sync::{
|
||||
|
|
@ -589,7 +588,7 @@ impl Swapchain {
|
|||
SwapchainImage {
|
||||
index: idx as u32,
|
||||
swapchain: self,
|
||||
view: ManuallyDrop::into_inner(view),
|
||||
view,
|
||||
acquire,
|
||||
release,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@
|
|||
- `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`, allocator mutex, adapter and instance, queues, synchronization threadpool, extension loaders, and inline `DevicePools`.
|
||||
- `DeviceInner` keeps the raw `ash::Device`, VMA (`vk_mem`) 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.
|
||||
|
|
@ -26,17 +27,19 @@
|
|||
- 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, loading, allocator, I/O, and renderer-facing resource/capability failures.
|
||||
- `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.
|
||||
- `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.
|
||||
- `AllocationStrategy` selects allocator-managed blocks or a dedicated allocation. `Allocation` represents `Owned`, `Shared`, or `Unmanaged` GPU allocations.
|
||||
- `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.
|
||||
- `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 the selected graphics family to support presentation.
|
||||
- `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.
|
||||
- `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.
|
||||
|
|
@ -45,6 +48,7 @@
|
|||
- 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.
|
||||
|
|
@ -77,7 +81,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 currently asserts that its chosen graphics family is present-capable.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
@ -92,7 +96,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 allocator mutex. Do not call allocator free on an allocation after ownership has moved or dropped.
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ name = "renderer_device"
|
|||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
testing = []
|
||||
|
||||
[dependencies]
|
||||
ash = { workspace = true }
|
||||
ash-window = { workspace = true }
|
||||
gpu-allocator = { workspace = true }
|
||||
vk-mem = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
raw-window-handle = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ use std::{
|
|||
collections::{BTreeSet, HashMap, HashSet},
|
||||
ffi::CStr,
|
||||
ops::{Deref, DerefMut},
|
||||
sync::{Arc, Weak},
|
||||
ptr::NonNull,
|
||||
sync::{Arc, OnceLock, Weak},
|
||||
};
|
||||
|
||||
use ash::{ext, khr, prelude::VkResult, vk};
|
||||
|
|
@ -66,7 +67,7 @@ impl From<String> for DebugName {
|
|||
}
|
||||
}
|
||||
use crate::{
|
||||
Instance, PhysicalDeviceFeatures, PhysicalDeviceInfo, Result,
|
||||
Error, Instance, PhysicalDeviceFeatures, PhysicalDeviceInfo, Result,
|
||||
device::asdf::traits::ExternallyManagedObject,
|
||||
pipeline_cache::PipelineCache,
|
||||
queue::{DeviceQueueInfos, DeviceQueues, Queue},
|
||||
|
|
@ -108,12 +109,89 @@ pub struct DeviceExtensions {
|
|||
pub mesh_shader: Option<ext::mesh_shader::Device>,
|
||||
}
|
||||
|
||||
type GpuAllocation = gpu_allocator::vulkan::Allocation;
|
||||
#[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) })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum AllocationStrategy {
|
||||
#[default]
|
||||
/// Let gpu_allocator manage the memory for this allocation, sub-allocating
|
||||
/// Let the 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
|
||||
|
|
@ -122,11 +200,84 @@ 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>>),
|
||||
Unmanaged,
|
||||
/// 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>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Allocation {
|
||||
|
|
@ -134,7 +285,7 @@ impl Allocation {
|
|||
match self {
|
||||
Allocation::Owned(obj) => Some(obj),
|
||||
Allocation::Shared(arc) => Some(arc.as_ref()),
|
||||
Allocation::Unmanaged => None,
|
||||
Allocation::Unmanaged { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,13 +293,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 alloc2: Mutex<gpu_allocator::vulkan::Allocator>,
|
||||
pub allocator: Mutex<vk_mem::Allocator>,
|
||||
pub raw: ash::Device,
|
||||
pub adapter: PhysicalDeviceInfo,
|
||||
pub instance: Instance,
|
||||
|
|
@ -438,25 +589,32 @@ impl PhysicalDeviceInfo {
|
|||
},
|
||||
};
|
||||
|
||||
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 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 pipeline_cache = PipelineCache::new(&device, &self).unwrap();
|
||||
let shared = Arc::new_cyclic(|inner: &Weak<DeviceInner>| DeviceInner {
|
||||
raw: device.clone(),
|
||||
alloc2: Mutex::new(alloc2),
|
||||
allocator: Mutex::new(allocator),
|
||||
instance: instance.clone(),
|
||||
adapter: self,
|
||||
queues: device_queues,
|
||||
|
|
@ -578,7 +736,495 @@ 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
|
||||
}
|
||||
|
|
@ -1072,8 +1718,8 @@ pub mod asdf {
|
|||
}
|
||||
|
||||
impl traits::ExternallyManagedObject<Device> for GpuAllocation {
|
||||
unsafe fn destroy(self, device: &Device) {
|
||||
_ = device.alloc2.lock().free(self);
|
||||
unsafe fn destroy(mut self, device: &Device) {
|
||||
unsafe { device.allocator.lock().free_memory(&mut self.raw) };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1155,4 +1801,11 @@ pub mod asdf {
|
|||
vk::FALSE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_allocation_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
|
||||
assert_send_sync::<super::GpuAllocation>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@ 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)]
|
||||
|
|
@ -73,7 +76,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: &[1u8; 1] as *const u8 as _,
|
||||
p_values: &[vk::TRUE] as *const u32 as _,
|
||||
ty: vk::LayerSettingTypeEXT::BOOL32,
|
||||
_marker: core::marker::PhantomData,
|
||||
},
|
||||
|
|
@ -81,7 +84,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: &[1u8; 1] as *const u8 as _,
|
||||
p_values: &[vk::TRUE] as *const u32 as _,
|
||||
ty: vk::LayerSettingTypeEXT::BOOL32,
|
||||
_marker: core::marker::PhantomData,
|
||||
},
|
||||
|
|
@ -89,7 +92,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: &[1u8; 1] as *const u8 as _,
|
||||
p_values: &[vk::TRUE] as *const u32 as _,
|
||||
ty: vk::LayerSettingTypeEXT::BOOL32,
|
||||
_marker: core::marker::PhantomData,
|
||||
},
|
||||
|
|
@ -121,16 +124,14 @@ impl Instance {
|
|||
unsafe { entry.try_enumerate_instance_version()? }.unwrap_or(vk::API_VERSION_1_0);
|
||||
|
||||
let app_info = vk::ApplicationInfo::default()
|
||||
.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
|
||||
})
|
||||
// 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))
|
||||
.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);
|
||||
|
|
@ -150,8 +151,9 @@ impl Instance {
|
|||
let mut requested_extensions = desc.instance_extensions.to_vec();
|
||||
|
||||
requested_extensions.push(make_extension!(ext::debug_utils as 1));
|
||||
#[cfg(debug_assertions)]
|
||||
requested_extensions.push(make_extension!(ext::layer_settings));
|
||||
if !desc.layer_settings.is_empty() {
|
||||
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
|
||||
|
|
@ -223,9 +225,10 @@ impl Instance {
|
|||
};
|
||||
|
||||
let instance = Arc::new(InstanceInner {
|
||||
raw: instance,
|
||||
extensions,
|
||||
_debug_utils: debug_utils,
|
||||
raw: instance,
|
||||
api_version,
|
||||
extensions,
|
||||
entry,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ 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};
|
||||
|
|
@ -74,10 +77,45 @@ 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(
|
||||
|
|
|
|||
|
|
@ -364,10 +364,12 @@ impl DeviceQueueInfos {
|
|||
})
|
||||
.expect("No graphics queue family found");
|
||||
|
||||
assert!(
|
||||
graphics.flags.contains(QueueFlags::PRESENT),
|
||||
"Selected graphics queue family does not support presentation"
|
||||
);
|
||||
if display_handle.is_some() {
|
||||
assert!(
|
||||
graphics.flags.contains(QueueFlags::PRESENT),
|
||||
"Selected graphics queue family does not support presentation"
|
||||
);
|
||||
}
|
||||
|
||||
let compute = queue_families
|
||||
.iter()
|
||||
|
|
|
|||
513
crates/renderer_device/src/testing.rs
Normal file
513
crates/renderer_device/src/testing.rs
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
//! 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,18 @@ edition = "2024"
|
|||
|
||||
[dependencies]
|
||||
ash.workspace = true
|
||||
gpu-allocator.workspace = true
|
||||
fixedbitset.workspace = true
|
||||
tinyvec.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
|
||||
|
|
|
|||
381
crates/renderer_rendergraph/benches/compile.rs
Normal file
381
crates/renderer_rendergraph/benches/compile.rs
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
//! 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);
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
use std::{cell::RefCell, sync::Arc};
|
||||
|
||||
use ash::vk;
|
||||
use renderer_resources::images::{ImageView, ImageViewDesc};
|
||||
|
||||
use crate::{
|
||||
recorder::{CommandIndex, CommandRecorder, RecordError, RecordedCommand, SideEffectMap},
|
||||
|
|
@ -9,6 +12,243 @@ use crate::{
|
|||
},
|
||||
};
|
||||
|
||||
/// Classification used by the compiler to validate render-pass spans.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandKind {
|
||||
Begin,
|
||||
End,
|
||||
Graphics,
|
||||
Transfer,
|
||||
Compute,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A view used by a render-pass attachment.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AttachmentView {
|
||||
/// A view the graph must create after resource resolution, described concretely.
|
||||
Created(ImageViewDesc),
|
||||
/// A caller-supplied view over an imported image.
|
||||
Imported(Arc<ImageView>),
|
||||
}
|
||||
|
||||
/// A color attachment used by dynamic rendering.
|
||||
#[derive(Clone)]
|
||||
pub struct ColorAttachment {
|
||||
pub texture: Texture,
|
||||
pub view: AttachmentView,
|
||||
/// Layout used while rendering (must be a legal rendering layout; validated later).
|
||||
pub layout: vk::ImageLayout,
|
||||
pub load_op: vk::AttachmentLoadOp,
|
||||
pub store_op: vk::AttachmentStoreOp,
|
||||
pub clear_value: vk::ClearValue,
|
||||
/// Concrete subresource range covered by the view. Created-view sentinels are preserved for
|
||||
/// compile-time validation.
|
||||
pub range: vk::ImageSubresourceRange,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ColorAttachment {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ColorAttachment")
|
||||
.field("texture", &self.texture)
|
||||
.field("view", &self.view)
|
||||
.field("layout", &self.layout)
|
||||
.field("load_op", &self.load_op)
|
||||
.field("store_op", &self.store_op)
|
||||
.field("clear_value", &"<Vulkan clear value>")
|
||||
.field("range", &self.range)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorAttachment {
|
||||
/// Builds an attachment around a caller-supplied view of an imported image.
|
||||
pub fn imported(
|
||||
texture: Texture,
|
||||
view: Arc<ImageView>,
|
||||
layout: vk::ImageLayout,
|
||||
load_op: vk::AttachmentLoadOp,
|
||||
store_op: vk::AttachmentStoreOp,
|
||||
clear_value: vk::ClearValue,
|
||||
) -> Self {
|
||||
let range = view.subresource_range();
|
||||
Self {
|
||||
texture,
|
||||
view: AttachmentView::Imported(view),
|
||||
layout,
|
||||
load_op,
|
||||
store_op,
|
||||
clear_value,
|
||||
range,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an attachment whose view will be created after graph resolution.
|
||||
pub fn created(
|
||||
texture: Texture,
|
||||
desc: ImageViewDesc,
|
||||
layout: vk::ImageLayout,
|
||||
load_op: vk::AttachmentLoadOp,
|
||||
store_op: vk::AttachmentStoreOp,
|
||||
clear_value: vk::ClearValue,
|
||||
) -> Self {
|
||||
let range = image_view_desc_range(&desc);
|
||||
Self {
|
||||
texture,
|
||||
view: AttachmentView::Created(desc),
|
||||
layout,
|
||||
load_op,
|
||||
store_op,
|
||||
clear_value,
|
||||
range,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth/stencil attachment used by dynamic rendering. The view's aspect mask
|
||||
/// selects which depth/stencil attachment slots receive the view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DepthStencilAttachment {
|
||||
pub texture: Texture,
|
||||
pub view: AttachmentView,
|
||||
pub layout: vk::ImageLayout,
|
||||
pub depth_load_op: vk::AttachmentLoadOp,
|
||||
pub depth_store_op: vk::AttachmentStoreOp,
|
||||
pub stencil_load_op: vk::AttachmentLoadOp,
|
||||
pub stencil_store_op: vk::AttachmentStoreOp,
|
||||
pub clear_depth: f32,
|
||||
pub clear_stencil: u32,
|
||||
pub range: vk::ImageSubresourceRange,
|
||||
}
|
||||
|
||||
impl DepthStencilAttachment {
|
||||
/// Builds an attachment around a caller-supplied view of an imported image.
|
||||
pub fn imported(
|
||||
texture: Texture,
|
||||
view: Arc<ImageView>,
|
||||
layout: vk::ImageLayout,
|
||||
depth_load_op: vk::AttachmentLoadOp,
|
||||
depth_store_op: vk::AttachmentStoreOp,
|
||||
stencil_load_op: vk::AttachmentLoadOp,
|
||||
stencil_store_op: vk::AttachmentStoreOp,
|
||||
clear_depth: f32,
|
||||
clear_stencil: u32,
|
||||
) -> Self {
|
||||
let range = view.subresource_range();
|
||||
Self {
|
||||
texture,
|
||||
view: AttachmentView::Imported(view),
|
||||
layout,
|
||||
depth_load_op,
|
||||
depth_store_op,
|
||||
stencil_load_op,
|
||||
stencil_store_op,
|
||||
clear_depth,
|
||||
clear_stencil,
|
||||
range,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an attachment whose view will be created after graph resolution.
|
||||
pub fn created(
|
||||
texture: Texture,
|
||||
desc: ImageViewDesc,
|
||||
layout: vk::ImageLayout,
|
||||
depth_load_op: vk::AttachmentLoadOp,
|
||||
depth_store_op: vk::AttachmentStoreOp,
|
||||
stencil_load_op: vk::AttachmentLoadOp,
|
||||
stencil_store_op: vk::AttachmentStoreOp,
|
||||
clear_depth: f32,
|
||||
clear_stencil: u32,
|
||||
) -> Self {
|
||||
let range = image_view_desc_range(&desc);
|
||||
Self {
|
||||
texture,
|
||||
view: AttachmentView::Created(desc),
|
||||
layout,
|
||||
depth_load_op,
|
||||
depth_store_op,
|
||||
stencil_load_op,
|
||||
stencil_store_op,
|
||||
clear_depth,
|
||||
clear_stencil,
|
||||
range,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image_view_desc_range(desc: &ImageViewDesc) -> vk::ImageSubresourceRange {
|
||||
vk::ImageSubresourceRange::default()
|
||||
.aspect_mask(desc.aspect)
|
||||
.base_mip_level(desc.mip_range.start)
|
||||
.level_count(if desc.mip_range.end == vk::REMAINING_MIP_LEVELS {
|
||||
vk::REMAINING_MIP_LEVELS
|
||||
} else {
|
||||
desc.mip_range.end - desc.mip_range.start
|
||||
})
|
||||
.base_array_layer(desc.layer_range.start)
|
||||
.layer_count(if desc.layer_range.end == vk::REMAINING_ARRAY_LAYERS {
|
||||
vk::REMAINING_ARRAY_LAYERS
|
||||
} else {
|
||||
desc.layer_range.end - desc.layer_range.start
|
||||
})
|
||||
}
|
||||
|
||||
/// A borrowed summary of a render-pass attachment configuration.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RenderPassDesc<'a> {
|
||||
pub color: &'a [ColorAttachment],
|
||||
pub depth: Option<&'a DepthStencilAttachment>,
|
||||
}
|
||||
|
||||
/// Begins a dynamic-rendering instance.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BeginRendering {
|
||||
pub color: Vec<ColorAttachment>,
|
||||
pub depth: Option<DepthStencilAttachment>,
|
||||
}
|
||||
|
||||
/// Ends a dynamic-rendering instance.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct EndRendering;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RenderingScratch {
|
||||
color_attachments: Vec<vk::RenderingAttachmentInfo<'static>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static RENDERING_SCRATCH: RefCell<RenderingScratch> = RefCell::new(RenderingScratch::default());
|
||||
}
|
||||
|
||||
fn resolve_attachment_view(
|
||||
texture: Texture,
|
||||
view: &AttachmentView,
|
||||
recorder: &CommandRecorder<'_>,
|
||||
) -> Result<(vk::ImageView, vk::Extent2D), RecordError> {
|
||||
recorder.image(texture.id())?;
|
||||
let view = match view {
|
||||
AttachmentView::Created(desc) => recorder.view(texture.id(), desc)?,
|
||||
AttachmentView::Imported(view) => view.clone(),
|
||||
};
|
||||
let range = view.subresource_range();
|
||||
let image_extent = view.image().extent_2d();
|
||||
let mip = range.base_mip_level.min(u32::BITS - 1);
|
||||
let extent = vk::Extent2D {
|
||||
width: (image_extent.width >> mip).max(1),
|
||||
height: (image_extent.height >> mip).max(1),
|
||||
};
|
||||
Ok((view.raw(), extent))
|
||||
}
|
||||
|
||||
fn rendering_extent(current: Option<vk::Extent2D>, next: vk::Extent2D) -> vk::Extent2D {
|
||||
current.map_or(next, |current| vk::Extent2D {
|
||||
width: current.width.min(next.width),
|
||||
height: current.height.min(next.height),
|
||||
})
|
||||
}
|
||||
|
||||
/// A recorded operation declares its accesses before compilation and applies
|
||||
/// itself to a Vulkan command buffer after resource binding.
|
||||
pub trait Command: RecordedCommand {
|
||||
|
|
@ -59,6 +299,10 @@ 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)
|
||||
|
|
@ -104,6 +348,10 @@ 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,
|
||||
|
|
@ -219,6 +467,14 @@ 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(|_| ())
|
||||
}
|
||||
|
|
@ -335,6 +591,160 @@ impl RecordedCommand for Draw {
|
|||
}
|
||||
}
|
||||
|
||||
impl Command for BeginRendering {
|
||||
fn side_effects(&self, command_index: CommandIndex, map: &mut SideEffectMap) {
|
||||
for attachment in &self.color {
|
||||
let mut mask = vk::AccessFlags2::COLOR_ATTACHMENT_WRITE;
|
||||
if attachment.load_op == vk::AttachmentLoadOp::LOAD {
|
||||
mask |= vk::AccessFlags2::COLOR_ATTACHMENT_READ;
|
||||
}
|
||||
map.insert(
|
||||
command_index,
|
||||
attachment.texture.id(),
|
||||
access(
|
||||
vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT,
|
||||
mask,
|
||||
SubresourceScopeInner::Texture {
|
||||
range: attachment.range,
|
||||
layout: attachment.layout,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(attachment) = &self.depth {
|
||||
let mut mask = vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_WRITE;
|
||||
if attachment.depth_load_op == vk::AttachmentLoadOp::LOAD
|
||||
|| attachment.stencil_load_op == vk::AttachmentLoadOp::LOAD
|
||||
{
|
||||
mask |= vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_READ;
|
||||
}
|
||||
map.insert(
|
||||
command_index,
|
||||
attachment.texture.id(),
|
||||
access(
|
||||
vk::PipelineStageFlags2::EARLY_FRAGMENT_TESTS
|
||||
| vk::PipelineStageFlags2::LATE_FRAGMENT_TESTS,
|
||||
mask,
|
||||
SubresourceScopeInner::Texture {
|
||||
range: attachment.range,
|
||||
layout: attachment.layout,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordedCommand for BeginRendering {
|
||||
fn kind(&self) -> CommandKind {
|
||||
CommandKind::Begin
|
||||
}
|
||||
|
||||
fn render_pass_desc(&self) -> Option<RenderPassDesc<'_>> {
|
||||
Some(RenderPassDesc {
|
||||
color: &self.color,
|
||||
depth: self.depth.as_ref(),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
|
||||
RENDERING_SCRATCH.with(|scratch| {
|
||||
let mut scratch = scratch.borrow_mut();
|
||||
let attachments = &mut scratch.color_attachments;
|
||||
attachments.clear();
|
||||
|
||||
let mut extent = None;
|
||||
for attachment in &self.color {
|
||||
let (view, attachment_extent) =
|
||||
resolve_attachment_view(attachment.texture, &attachment.view, recorder)?;
|
||||
extent = Some(rendering_extent(extent, attachment_extent));
|
||||
attachments.push(
|
||||
vk::RenderingAttachmentInfo::default()
|
||||
.image_view(view)
|
||||
.image_layout(attachment.layout)
|
||||
.load_op(attachment.load_op)
|
||||
.store_op(attachment.store_op)
|
||||
.clear_value(attachment.clear_value),
|
||||
);
|
||||
}
|
||||
|
||||
let mut depth_info = None;
|
||||
let mut stencil_info = None;
|
||||
if let Some(attachment) = &self.depth {
|
||||
let (view, attachment_extent) =
|
||||
resolve_attachment_view(attachment.texture, &attachment.view, recorder)?;
|
||||
extent = Some(rendering_extent(extent, attachment_extent));
|
||||
let clear_value = vk::ClearValue {
|
||||
depth_stencil: vk::ClearDepthStencilValue {
|
||||
depth: attachment.clear_depth,
|
||||
stencil: attachment.clear_stencil,
|
||||
},
|
||||
};
|
||||
if attachment.range.aspect_mask.contains(vk::ImageAspectFlags::DEPTH) {
|
||||
depth_info = Some(
|
||||
vk::RenderingAttachmentInfo::default()
|
||||
.image_view(view)
|
||||
.image_layout(attachment.layout)
|
||||
.load_op(attachment.depth_load_op)
|
||||
.store_op(attachment.depth_store_op)
|
||||
.clear_value(clear_value),
|
||||
);
|
||||
}
|
||||
if attachment.range.aspect_mask.contains(vk::ImageAspectFlags::STENCIL) {
|
||||
stencil_info = Some(
|
||||
vk::RenderingAttachmentInfo::default()
|
||||
.image_view(view)
|
||||
.image_layout(attachment.layout)
|
||||
.load_op(attachment.stencil_load_op)
|
||||
.store_op(attachment.stencil_store_op)
|
||||
.clear_value(clear_value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let extent = extent.unwrap_or(vk::Extent2D {
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
let mut info = vk::RenderingInfo::default()
|
||||
.render_area(vk::Rect2D {
|
||||
offset: vk::Offset2D { x: 0, y: 0 },
|
||||
extent,
|
||||
})
|
||||
.layer_count(1)
|
||||
.color_attachments(attachments);
|
||||
if let Some(depth) = depth_info.as_ref() {
|
||||
info = info.depth_attachment(depth);
|
||||
}
|
||||
if let Some(stencil) = stencil_info.as_ref() {
|
||||
info = info.stencil_attachment(stencil);
|
||||
}
|
||||
unsafe {
|
||||
recorder
|
||||
.device
|
||||
.cmd_begin_rendering(recorder.command_buffer, &info)
|
||||
};
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Command for EndRendering {
|
||||
fn side_effects(&self, _command_index: CommandIndex, _map: &mut SideEffectMap) {}
|
||||
}
|
||||
|
||||
impl RecordedCommand for EndRendering {
|
||||
fn kind(&self) -> CommandKind {
|
||||
CommandKind::End
|
||||
}
|
||||
|
||||
fn apply(&mut self, recorder: &mut CommandRecorder<'_>) -> Result<(), RecordError> {
|
||||
unsafe { recorder.device.cmd_end_rendering(recorder.command_buffer) };
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_managed_descriptor_sets(
|
||||
sets: &[GraphDescriptorSet],
|
||||
) -> Result<Vec<vk::DescriptorSet>, RecordError> {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
250
crates/renderer_rendergraph/src/graph_compiler.rs
Normal file
250
crates/renderer_rendergraph/src/graph_compiler.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
use ash::vk;
|
||||
|
||||
use crate::{
|
||||
CommandIndex, GraphError, SideEffectMap,
|
||||
graph::{Kahn, RenderPassSpan},
|
||||
recorder::BoxedCommand,
|
||||
resources::{ResourceDesc, SubresourceScope, SubresourceScopeInner},
|
||||
};
|
||||
|
||||
struct SpanBuilder {
|
||||
spans: Vec<RenderPassSpan>,
|
||||
current: Option<CommandIndex>,
|
||||
}
|
||||
|
||||
impl SpanBuilder {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
spans: Vec::new(),
|
||||
current: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn in_span(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn current_begin(&self) -> Option<CommandIndex> {
|
||||
self.current
|
||||
}
|
||||
|
||||
fn begin(&mut self, index: CommandIndex) -> Result<(), GraphError> {
|
||||
if self.current.is_some() {
|
||||
Err(GraphError::NestedRenderPass)
|
||||
} else {
|
||||
self.current = Some(index);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn end(&mut self, end: CommandIndex) -> Result<(), GraphError> {
|
||||
if let Some(begin) = self.current.take() {
|
||||
self.spans.push(RenderPassSpan { begin, end });
|
||||
Ok(())
|
||||
} else {
|
||||
Err(GraphError::UnbalancedRenderPass)
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<Vec<RenderPassSpan>, GraphError> {
|
||||
if self.current.is_some() {
|
||||
Err(GraphError::UnbalancedRenderPass)
|
||||
} else {
|
||||
Ok(self.spans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Batch {
|
||||
pub(crate) start: usize,
|
||||
pub(crate) count: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct CommandOrderings {
|
||||
pub(crate) execution_order: Vec<CommandIndex>,
|
||||
pub(crate) batches: Vec<Batch>,
|
||||
pub(crate) spans: Vec<RenderPassSpan>,
|
||||
pub(crate) node_of_command: Vec<usize>,
|
||||
pub(crate) node_representatives: Vec<CommandIndex>,
|
||||
pub(crate) dependency_edges: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
pub(crate) fn build_command_orderings(
|
||||
resources: &[ResourceDesc],
|
||||
commands: &[BoxedCommand],
|
||||
side_effects: &mut SideEffectMap,
|
||||
) -> Result<CommandOrderings, GraphError> {
|
||||
let per_command_resource_access = side_effects.command_accesses();
|
||||
let mut kahn = Kahn::new(commands.len());
|
||||
let mut access_by_resource: Vec<Vec<(CommandIndex, Vec<SubresourceScope>)>> =
|
||||
vec![Vec::new(); resources.len()];
|
||||
|
||||
let mut span_builder = SpanBuilder::new();
|
||||
let mut node_of_command = vec![usize::MAX; commands.len()];
|
||||
let mut node_representatives = Vec::new();
|
||||
|
||||
for (index, (command, accesses)) in commands
|
||||
.iter()
|
||||
.zip(per_command_resource_access)
|
||||
.enumerate()
|
||||
.map(|(i, c)| (CommandIndex::from(i), c))
|
||||
{
|
||||
let rep = span_builder.current_begin().unwrap_or(index);
|
||||
kahn.add_node(rep.index());
|
||||
|
||||
// Node ids follow first encounter of each representative in command order.
|
||||
let node = if node_of_command[rep.index()] == usize::MAX {
|
||||
let node = node_representatives.len();
|
||||
node_representatives.push(rep);
|
||||
node
|
||||
} else {
|
||||
node_of_command[rep.index()]
|
||||
};
|
||||
node_of_command[index.index()] = node;
|
||||
|
||||
for access in accesses {
|
||||
let Some(resource_access) = access_by_resource.get_mut(access.resource.index()) else {
|
||||
// `validate_resources` reports invalid ids after dependency analysis.
|
||||
continue;
|
||||
};
|
||||
|
||||
let priors = resource_access
|
||||
.iter()
|
||||
.take_while(|&&(c, _)| c < rep)
|
||||
.filter(|(_, scopes)| scopes.iter().any(|scope| scope.conflicts(&access.scope)));
|
||||
|
||||
for &(prior, _) in priors {
|
||||
kahn.add_edge(prior.index(), rep.index());
|
||||
}
|
||||
|
||||
if let Some(&mut (last, ref mut scopes)) = resource_access.last_mut()
|
||||
&& last == rep
|
||||
{
|
||||
scopes.push(access.scope);
|
||||
} else {
|
||||
resource_access.push((rep, vec![access.scope]))
|
||||
}
|
||||
}
|
||||
|
||||
match command.kind() {
|
||||
crate::CommandKind::Begin => {
|
||||
if let Some(desc) = command.render_pass_desc() {
|
||||
crate::graph::validate_render_pass_attachments(resources, desc)?;
|
||||
}
|
||||
span_builder.begin(index)?;
|
||||
}
|
||||
crate::CommandKind::Graphics => {
|
||||
if !command.allowed_in_render_pass() {
|
||||
return Err(GraphError::CommandNotAllowedInRenderPass(index));
|
||||
}
|
||||
if !span_builder.in_span() {
|
||||
return Err(GraphError::DrawOutsideRenderPass(index));
|
||||
}
|
||||
}
|
||||
crate::CommandKind::Transfer
|
||||
| crate::CommandKind::Compute
|
||||
| crate::CommandKind::Other => {
|
||||
if span_builder.in_span() && !command.allowed_in_render_pass() {
|
||||
return Err(GraphError::CommandNotAllowedInRenderPass(index));
|
||||
}
|
||||
}
|
||||
crate::CommandKind::End => {
|
||||
span_builder.end(index)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spans are ordered by beginning command
|
||||
let spans = span_builder.finish()?;
|
||||
|
||||
for span in &spans {
|
||||
// Snapshot accesses intentionally predate lifetime-only attachment injection:
|
||||
// these accesses extend lifetimes but must not add hazard edges.
|
||||
if let Some(desc) = commands[span.begin.index()].render_pass_desc() {
|
||||
for attachment in desc.color {
|
||||
side_effects.insert_lifetime_only(
|
||||
span.end,
|
||||
attachment.texture.id(),
|
||||
SubresourceScope {
|
||||
stage: vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT,
|
||||
access: vk::AccessFlags2::COLOR_ATTACHMENT_WRITE,
|
||||
subresource: SubresourceScopeInner::Texture {
|
||||
range: attachment.range,
|
||||
layout: attachment.layout,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(attachment) = desc.depth {
|
||||
side_effects.insert_lifetime_only(
|
||||
span.end,
|
||||
attachment.texture.id(),
|
||||
SubresourceScope {
|
||||
stage: vk::PipelineStageFlags2::EARLY_FRAGMENT_TESTS
|
||||
| vk::PipelineStageFlags2::LATE_FRAGMENT_TESTS,
|
||||
access: vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_WRITE,
|
||||
subresource: SubresourceScopeInner::Texture {
|
||||
range: attachment.range,
|
||||
layout: attachment.layout,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut execution_order: Vec<CommandIndex> = Vec::with_capacity(commands.len());
|
||||
let mut batches = Vec::<Batch>::new();
|
||||
|
||||
let mut span_iter = spans.iter().peekable();
|
||||
|
||||
while !kahn.is_complete() {
|
||||
let first = execution_order.len();
|
||||
|
||||
for cmd in kahn.iter_batch().map(CommandIndex::from) {
|
||||
use core::cmp::Ordering;
|
||||
match span_iter.peek().map(|s| s.begin.cmp(&cmd)) {
|
||||
Some(Ordering::Equal) => {
|
||||
let span = span_iter.next().unwrap();
|
||||
execution_order
|
||||
.extend((span.begin.index()..=span.end.index()).map(CommandIndex::from));
|
||||
}
|
||||
_ => execution_order.push(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
let count = execution_order.len() - first;
|
||||
|
||||
if count == 0 {
|
||||
return Err(GraphError::DependencyCycle);
|
||||
}
|
||||
|
||||
for command in &execution_order[first..] {
|
||||
kahn.complete(command.index());
|
||||
}
|
||||
|
||||
batches.push(Batch {
|
||||
start: first,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
let mut dependency_edges = vec![Vec::new(); node_representatives.len()];
|
||||
for (node, representative) in node_representatives.iter().enumerate() {
|
||||
let representative = representative.index();
|
||||
let node_edges = &mut dependency_edges[node];
|
||||
for &edge in &kahn.edges[representative] {
|
||||
node_edges.push(node_of_command[edge]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommandOrderings {
|
||||
execution_order,
|
||||
batches,
|
||||
spans,
|
||||
node_of_command,
|
||||
node_representatives,
|
||||
dependency_edges,
|
||||
})
|
||||
}
|
||||
|
|
@ -3,10 +3,19 @@
|
|||
|
||||
pub mod commands;
|
||||
pub mod graph;
|
||||
mod graph_compiler;
|
||||
pub mod recorder;
|
||||
pub mod resources;
|
||||
|
||||
pub use graph::{CompiledGraph, GraphError, RenderGraph, ResourceInterference};
|
||||
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 recorder::{CommandIndex, CommandList, CommandRecorder, SideEffectMap};
|
||||
pub use resources::{
|
||||
Buffer, BufferRange, BufferSlice, DescriptorResourceAccess, DescriptorResourceIdentity,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use ash::vk;
|
||||
use renderer_resources::images::{ImageView, ImageViewDesc};
|
||||
|
||||
use crate::resources::{
|
||||
BufferRange, ResourceId, Subresource, SubresourceScope, SubresourceScopeInner,
|
||||
|
|
@ -38,6 +43,7 @@ pub struct ResourceAccess {
|
|||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SideEffectMap {
|
||||
accesses: Vec<Vec<ResourceAccess>>,
|
||||
lifetime_only: BTreeSet<(usize, ResourceId)>,
|
||||
}
|
||||
|
||||
impl SideEffectMap {
|
||||
|
|
@ -81,6 +87,23 @@ 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)
|
||||
|
|
@ -92,10 +115,12 @@ 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<Box<dyn RecordedCommand>>,
|
||||
pub(crate) commands: Vec<BoxedCommand>,
|
||||
pub side_effects: SideEffectMap,
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +130,18 @@ 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>;
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +186,7 @@ impl Default for CommandList {
|
|||
pub enum RecordError {
|
||||
MissingBuffer(ResourceId),
|
||||
MissingImage(ResourceId),
|
||||
MissingView(ResourceId),
|
||||
UnresolvedCreatedBuffer(ResourceId),
|
||||
UnresolvedCreatedImage(ResourceId),
|
||||
DescriptorSet(String),
|
||||
|
|
@ -161,6 +199,18 @@ 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> {
|
||||
|
|
@ -170,6 +220,7 @@ impl<'a> CommandRecorder<'a> {
|
|||
command_buffer,
|
||||
buffers: BTreeMap::new(),
|
||||
images: BTreeMap::new(),
|
||||
views: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,61 +238,118 @@ 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) {
|
||||
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 {
|
||||
(queue_transfer.src.index(), queue_transfer.dst.index())
|
||||
} else {
|
||||
(vk::QUEUE_FAMILY_IGNORED, vk::QUEUE_FAMILY_IGNORED)
|
||||
};
|
||||
|
||||
vk::BufferMemoryBarrier2::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)
|
||||
.src_queue_family_index(src)
|
||||
.dst_queue_family_index(dst)
|
||||
.buffer(buffer)
|
||||
.offset(offset)
|
||||
.size(size)
|
||||
})
|
||||
}
|
||||
Subresource::Texture(_) => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let image_barriers: Vec<_> = barrier
|
||||
.resources
|
||||
.iter()
|
||||
.filter_map(|resource| match resource.range {
|
||||
Subresource::Texture(range) => self.images.get(&resource.id).map(|&image| {
|
||||
vk::ImageMemoryBarrier2::default()
|
||||
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)
|
||||
.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,
|
||||
})
|
||||
.collect();
|
||||
}));
|
||||
|
||||
let dependency = vk::DependencyInfo::default()
|
||||
.buffer_memory_barriers(&buffer_barriers)
|
||||
.image_memory_barriers(&image_barriers);
|
||||
unsafe {
|
||||
self.device
|
||||
.cmd_pipeline_barrier2(self.command_buffer, &dependency);
|
||||
}
|
||||
scratch.buffers.clear();
|
||||
scratch
|
||||
.buffers
|
||||
.extend(
|
||||
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 {
|
||||
(queue_transfer.src.index(), queue_transfer.dst.index())
|
||||
} else {
|
||||
(vk::QUEUE_FAMILY_IGNORED, vk::QUEUE_FAMILY_IGNORED)
|
||||
};
|
||||
|
||||
vk::BufferMemoryBarrier2::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)
|
||||
.src_queue_family_index(src)
|
||||
.dst_queue_family_index(dst)
|
||||
.buffer(buffer)
|
||||
.offset(offset)
|
||||
.size(size)
|
||||
})
|
||||
}
|
||||
Subresource::Texture(_) => None,
|
||||
}),
|
||||
);
|
||||
|
||||
scratch.images.clear();
|
||||
scratch
|
||||
.images
|
||||
.extend(
|
||||
barrier
|
||||
.resources
|
||||
.iter()
|
||||
.filter_map(|resource| match resource.range {
|
||||
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),
|
||||
)
|
||||
.image(image)
|
||||
.subresource_range(range)
|
||||
})
|
||||
}
|
||||
Subresource::Buffer(_) => None,
|
||||
}),
|
||||
);
|
||||
|
||||
let dependency = vk::DependencyInfo::default()
|
||||
.memory_barriers(&scratch.memory)
|
||||
.buffer_memory_barriers(&scratch.buffers)
|
||||
.image_memory_barriers(&scratch.images);
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -255,6 +363,58 @@ 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,
|
||||
|
|
@ -275,7 +435,7 @@ mod tests {
|
|||
let command = map.begin_command();
|
||||
map.insert(
|
||||
command,
|
||||
ResourceId(0),
|
||||
ResourceId::from_u32(0),
|
||||
read_scope(
|
||||
0,
|
||||
64,
|
||||
|
|
@ -285,7 +445,7 @@ mod tests {
|
|||
);
|
||||
map.insert(
|
||||
command,
|
||||
ResourceId(0),
|
||||
ResourceId::from_u32(0),
|
||||
read_scope(
|
||||
16,
|
||||
16,
|
||||
|
|
@ -316,7 +476,7 @@ mod tests {
|
|||
let command = reverse.begin_command();
|
||||
reverse.insert(
|
||||
command,
|
||||
ResourceId(1),
|
||||
ResourceId::from_u32(1),
|
||||
read_scope(
|
||||
16,
|
||||
16,
|
||||
|
|
@ -326,7 +486,7 @@ mod tests {
|
|||
);
|
||||
reverse.insert(
|
||||
command,
|
||||
ResourceId(1),
|
||||
ResourceId::from_u32(1),
|
||||
read_scope(
|
||||
0,
|
||||
64,
|
||||
|
|
@ -360,7 +520,7 @@ mod tests {
|
|||
let command = map.begin_command();
|
||||
map.insert(
|
||||
command,
|
||||
ResourceId(0),
|
||||
ResourceId::from_u32(0),
|
||||
read_scope(
|
||||
0,
|
||||
16,
|
||||
|
|
@ -370,7 +530,7 @@ mod tests {
|
|||
);
|
||||
map.insert(
|
||||
command,
|
||||
ResourceId(0),
|
||||
ResourceId::from_u32(0),
|
||||
read_scope(
|
||||
8,
|
||||
16,
|
||||
|
|
@ -388,7 +548,7 @@ mod tests {
|
|||
let command = map.begin_command();
|
||||
map.insert(
|
||||
command,
|
||||
ResourceId(0),
|
||||
ResourceId::from_u32(0),
|
||||
read_scope(
|
||||
0,
|
||||
64,
|
||||
|
|
@ -398,7 +558,7 @@ mod tests {
|
|||
);
|
||||
map.insert(
|
||||
command,
|
||||
ResourceId(0),
|
||||
ResourceId::from_u32(0),
|
||||
read_scope(
|
||||
16,
|
||||
16,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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},
|
||||
|
|
@ -13,7 +14,7 @@ use renderer_resources::{
|
|||
pub struct GraphBufferDesc {
|
||||
pub size: u64,
|
||||
pub name: Option<Cow<'static, str>>,
|
||||
pub mem_location: gpu_allocator::MemoryLocation,
|
||||
pub mem_location: MemoryLocation,
|
||||
/// Additional Vulkan usages not visible in the graph's recorded commands.
|
||||
pub extra_usage: vk::BufferUsageFlags,
|
||||
}
|
||||
|
|
@ -24,7 +25,7 @@ impl Default for GraphBufferDesc {
|
|||
size: 0,
|
||||
name: None,
|
||||
// Transient graph resources default to device-local memory.
|
||||
mem_location: gpu_allocator::MemoryLocation::GpuOnly,
|
||||
mem_location: MemoryLocation::GpuOnly,
|
||||
extra_usage: vk::BufferUsageFlags::empty(),
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +44,7 @@ pub struct GraphTextureDesc {
|
|||
pub tiling: vk::ImageTiling,
|
||||
pub layout: vk::ImageLayout,
|
||||
pub name: Option<Cow<'static, str>>,
|
||||
pub mem_location: gpu_allocator::MemoryLocation,
|
||||
pub mem_location: MemoryLocation,
|
||||
/// Additional Vulkan usages not visible in the graph's recorded commands.
|
||||
pub extra_usage: vk::ImageUsageFlags,
|
||||
}
|
||||
|
|
@ -66,7 +67,7 @@ impl Default for GraphTextureDesc {
|
|||
tiling: vk::ImageTiling::OPTIMAL,
|
||||
layout: vk::ImageLayout::UNDEFINED,
|
||||
name: None,
|
||||
mem_location: gpu_allocator::MemoryLocation::GpuOnly,
|
||||
mem_location: MemoryLocation::GpuOnly,
|
||||
extra_usage: vk::ImageUsageFlags::empty(),
|
||||
}
|
||||
}
|
||||
|
|
@ -74,10 +75,14 @@ impl Default for GraphTextureDesc {
|
|||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[repr(transparent)]
|
||||
pub struct ResourceId(pub u64);
|
||||
pub struct ResourceId(u32);
|
||||
|
||||
impl ResourceId {
|
||||
pub(crate) fn index(self) -> usize {
|
||||
pub fn from_u32(index: u32) -> Self {
|
||||
Self(index)
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
}
|
||||
|
|
|
|||
552
crates/renderer_rendergraph/tests/graph_validation.rs
Normal file
552
crates/renderer_rendergraph/tests/graph_validation.rs
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
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();
|
||||
}
|
||||
|
|
@ -4,19 +4,21 @@
|
|||
`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 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.
|
||||
- `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.
|
||||
- `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, 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.
|
||||
- `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.
|
||||
- `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.
|
||||
|
|
@ -46,6 +48,8 @@
|
|||
- `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.
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ 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"] }
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
use std::{borrow::Cow, sync::Arc};
|
||||
|
||||
use ash::vk;
|
||||
use gpu_allocator::vulkan::AllocationScheme;
|
||||
use renderer_device::queue::QueueFamilyIndices;
|
||||
|
||||
use crate::{
|
||||
Device,
|
||||
device::{Allocation, AllocationStrategy, DeviceObject, QueueFlags},
|
||||
device::{
|
||||
Allocation, AllocationDesc, AllocationStrategy, DeviceObject, MemoryLocation, QueueFlags,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -17,7 +18,7 @@ pub struct BufferDesc {
|
|||
pub usage: vk::BufferUsageFlags,
|
||||
pub queue_families: QueueFlags,
|
||||
|
||||
pub mem_location: gpu_allocator::MemoryLocation,
|
||||
pub mem_location: MemoryLocation,
|
||||
pub alloc_scheme: AllocationStrategy,
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +66,7 @@ impl Default for BufferDesc {
|
|||
size: Default::default(),
|
||||
usage: Default::default(),
|
||||
queue_families: QueueFlags::empty(),
|
||||
mem_location: gpu_allocator::MemoryLocation::Unknown,
|
||||
mem_location: MemoryLocation::Unknown,
|
||||
alloc_scheme: AllocationStrategy::default(),
|
||||
}
|
||||
}
|
||||
|
|
@ -102,46 +103,122 @@ impl Buffer {
|
|||
}
|
||||
|
||||
pub fn new(device: Device, desc: BufferDesc) -> crate::Result<Self> {
|
||||
let (buffer, requirements) = Self::new_raw(device.clone(), &desc)?;
|
||||
let buffer = Self::new_raw(device.clone(), &desc)?;
|
||||
let alloc = device.allocate_for_buffer(
|
||||
*buffer,
|
||||
AllocationDesc {
|
||||
name: desc.name.as_deref().unwrap_or_default(),
|
||||
location: desc.mem_location,
|
||||
strategy: desc.alloc_scheme,
|
||||
},
|
||||
)?;
|
||||
|
||||
let alloc =
|
||||
device
|
||||
.alloc2
|
||||
.lock()
|
||||
.allocate(&gpu_allocator::vulkan::AllocationCreateDesc {
|
||||
name: desc.name.as_deref().unwrap_or_default(),
|
||||
requirements,
|
||||
location: desc.mem_location,
|
||||
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(),
|
||||
)?;
|
||||
|
||||
unsafe {
|
||||
device
|
||||
.raw
|
||||
.bind_buffer_memory(buffer, alloc.memory(), alloc.offset())?;
|
||||
}
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// 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(DeviceObject::new_debug_named(
|
||||
device.clone(),
|
||||
buffer,
|
||||
desc.name.clone(),
|
||||
)),
|
||||
buffer: BufferHandle::Owned(buffer),
|
||||
desc,
|
||||
alloc: Allocation::Owned(DeviceObject::new(device, alloc)),
|
||||
alloc: Allocation::Unmanaged {
|
||||
requirements: std::sync::OnceLock::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn new_raw(
|
||||
device: Device,
|
||||
desc: &BufferDesc,
|
||||
) -> crate::Result<(vk::Buffer, vk::MemoryRequirements)> {
|
||||
/// 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>> {
|
||||
let queue_families = device.queues.family_indices(desc.queue_families);
|
||||
|
||||
let sharing_mode = if queue_families.len() > 1 {
|
||||
|
|
@ -156,9 +233,11 @@ impl Buffer {
|
|||
.sharing_mode(sharing_mode);
|
||||
|
||||
let buffer = unsafe { device.dev().create_buffer(&create_info, None)? };
|
||||
let mem_reqs = unsafe { device.dev().get_buffer_memory_requirements(buffer) };
|
||||
|
||||
Ok((buffer, mem_reqs))
|
||||
Ok(DeviceObject::new_debug_named(
|
||||
device,
|
||||
buffer,
|
||||
desc.name.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn map(&mut self) -> Option<&[u8]> {
|
||||
|
|
@ -205,11 +284,313 @@ impl Buffer {
|
|||
size,
|
||||
..Default::default()
|
||||
},
|
||||
alloc: Allocation::Unmanaged,
|
||||
alloc: Allocation::Unmanaged {
|
||||
requirements: std::sync::OnceLock::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
use std::{
|
||||
borrow::Cow,
|
||||
mem::ManuallyDrop,
|
||||
ops::{Add, Sub},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
device::{Allocation, AllocationStrategy, DeviceObject, QueueFlags},
|
||||
device::{
|
||||
Allocation, AllocationDesc, AllocationStrategy, DeviceObject, MemoryLocation, 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: gpu_allocator::MemoryLocation,
|
||||
pub mem_location: 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: gpu_allocator::MemoryLocation::Unknown,
|
||||
mem_location: MemoryLocation::Unknown,
|
||||
alloc_scheme: AllocationStrategy::AllocatorManaged,
|
||||
}
|
||||
}
|
||||
|
|
@ -187,17 +187,15 @@ pub struct Image {
|
|||
|
||||
impl Image {
|
||||
pub fn new(device: Device, desc: ImageDesc) -> crate::Result<Self> {
|
||||
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,
|
||||
linear: desc.tiling == vk::ImageTiling::LINEAR,
|
||||
allocation_scheme: match desc.alloc_scheme {
|
||||
AllocationStrategy::AllocatorManaged => AllocationScheme::GpuAllocatorManaged,
|
||||
AllocationStrategy::Dedicated => AllocationScheme::DedicatedImage(image),
|
||||
let image = Self::new_raw(device.clone(), &desc)?;
|
||||
let alloc = device.allocate_for_image(
|
||||
*image,
|
||||
AllocationDesc {
|
||||
name: desc.name.as_deref().unwrap_or(""),
|
||||
location: desc.mem_location,
|
||||
strategy: desc.alloc_scheme,
|
||||
},
|
||||
})?;
|
||||
)?;
|
||||
|
||||
Self::new_with_allocation_unchecked(
|
||||
device.clone(),
|
||||
|
|
@ -207,37 +205,138 @@ impl Image {
|
|||
)
|
||||
}
|
||||
|
||||
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())?;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
Ok(Self {
|
||||
image: ImageInner::Allocated(
|
||||
DeviceObject::new_debug_named(device, image, desc.name.clone()),
|
||||
allocation,
|
||||
image,
|
||||
Allocation::Unmanaged {
|
||||
requirements: std::sync::OnceLock::new(),
|
||||
},
|
||||
),
|
||||
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, requirements) = Self::new_raw(device.clone(), &desc)?;
|
||||
let image = Self::new_raw(device.clone(), &desc)?;
|
||||
let requirements = unsafe { device.dev().get_image_memory_requirements(*image) };
|
||||
|
||||
// validate allocation
|
||||
let alloc_size = allocation
|
||||
|
|
@ -286,7 +385,7 @@ impl Image {
|
|||
usage,
|
||||
queue_families: QueueFlags::PRESENT,
|
||||
layout: vk::ImageLayout::UNDEFINED,
|
||||
mem_location: gpu_allocator::MemoryLocation::GpuOnly,
|
||||
mem_location: MemoryLocation::GpuOnly,
|
||||
..Default::default()
|
||||
},
|
||||
views: Default::default(),
|
||||
|
|
@ -307,10 +406,7 @@ impl Image {
|
|||
}
|
||||
}
|
||||
|
||||
fn new_raw(
|
||||
device: Device,
|
||||
desc: &ImageDesc,
|
||||
) -> crate::Result<(vk::Image, vk::MemoryRequirements)> {
|
||||
fn new_raw(device: Device, desc: &ImageDesc) -> crate::Result<DeviceObject<vk::Image>> {
|
||||
tracing::trace!("new image with desc={desc:?}");
|
||||
let ImageDesc {
|
||||
flags,
|
||||
|
|
@ -372,9 +468,11 @@ impl Image {
|
|||
|
||||
let image = unsafe { device.raw.create_image(&info, None)? };
|
||||
|
||||
let requirements = unsafe { device.raw.get_image_memory_requirements(image) };
|
||||
|
||||
Ok((image, requirements))
|
||||
Ok(DeviceObject::new_debug_named(
|
||||
device,
|
||||
image,
|
||||
desc.name.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn raw(&self) -> vk::Image {
|
||||
|
|
@ -456,10 +554,7 @@ impl Image {
|
|||
// }
|
||||
// }
|
||||
|
||||
pub fn create_view(
|
||||
self: &Arc<Self>,
|
||||
mut desc: ImageViewDesc,
|
||||
) -> crate::Result<ManuallyDrop<ImageView>> {
|
||||
pub fn create_view(self: &Arc<Self>, mut desc: ImageViewDesc) -> crate::Result<ImageView> {
|
||||
// validate
|
||||
if !view_kind_compatible(self.desc.kind, desc.kind) {
|
||||
tracing::error!(
|
||||
|
|
@ -519,7 +614,7 @@ impl Image {
|
|||
let device = self.image.device();
|
||||
let view = unsafe { device.raw.create_image_view(&create_info, None)? };
|
||||
|
||||
Ok(ManuallyDrop::new(ImageView {
|
||||
Ok(ImageView {
|
||||
view: ImageViewHandle::Owned(DeviceObject::new_debug_named(
|
||||
device.clone(),
|
||||
view,
|
||||
|
|
@ -527,7 +622,7 @@ impl Image {
|
|||
)),
|
||||
desc,
|
||||
image: self.clone(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1090,26 +1185,6 @@ 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,
|
||||
|
|
@ -1237,3 +1312,23 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ 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;
|
||||
|
|
|
|||
411
crates/renderer_resources/src/memory.rs
Normal file
411
crates/renderer_resources/src/memory.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
//! 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),
|
||||
}
|
||||
90
crates/renderer_resources/tests/managed_set_validation.rs
Normal file
90
crates/renderer_resources/tests/managed_set_validation.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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();
|
||||
}
|
||||
326
design-plans/gpu-allocator-to-vma-migration.md
Normal file
326
design-plans/gpu-allocator-to-vma-migration.md
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# 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.
|
||||
575
design-plans/rendergraph-render-passes.md
Normal file
575
design-plans/rendergraph-render-passes.md
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
# Plan: Render-Pass Support (`BeginRendering` / `EndRendering`) in `renderer_rendergraph`
|
||||
|
||||
Status: proposed — rev 5 (rev-1 correctness, rev-2 performance, rev-3 adversarial, and rev-4 re-review applied; rev-4 verdict was REJECT, findings resolved here)
|
||||
Date: 2026-10-27
|
||||
Scope: `crates/renderer_rendergraph`
|
||||
|
||||
## Goal
|
||||
|
||||
Add dynamic-rendering render-pass support to the rendergraph: `BeginRendering`
|
||||
and `EndRendering` commands that bracket a span of graph commands, with the
|
||||
compiler guaranteeing that every command recorded between the two is emitted
|
||||
inside the rendering instance — i.e. commands that must be in a rendergraph
|
||||
pass are ordered by the begin/end rendering commands, and no foreign command
|
||||
may be scheduled between them.
|
||||
|
||||
## Current state (verified against code)
|
||||
|
||||
- Commands implement `Command` (declares `side_effects` → `SubresourceScope`
|
||||
accesses) + `RecordedCommand` (`preflight`/`apply`), and are pushed via
|
||||
`RenderGraph::push` into `CommandList` (`commands.rs`, `recorder.rs`).
|
||||
- `GraphCompiler::dependencies()` (`graph.rs:820-863`) builds hazard edges
|
||||
purely from resource conflicts; `topo_batches_and_dependencies()`
|
||||
(`graph.rs:870-889`) Kahn-sorts commands into `batches`;
|
||||
`record_commands` (`graph.rs:2500-2554`) flattens batches, emitting barriers
|
||||
(keyed `before_command` via `grouped_barrier_indices`) immediately before
|
||||
each command.
|
||||
- There is no notion of a render pass. Nothing prevents the flattener from
|
||||
emitting an unrelated command between two commands the user recorded
|
||||
adjacently: Kahn batching interleaves hazard-free commands freely.
|
||||
- `CommandRecorder` (`recorder.rs:159-164`) maps `ResourceId → vk::Buffer` /
|
||||
`vk::Image` only; there is no image-view plumbing. Descriptor imports read
|
||||
`Arc<ImageView>` bindings at import time (`graph.rs:507-513`) but retain
|
||||
only the underlying image (`StagedImportResource::Texture(view.image().clone())`,
|
||||
`graph.rs:524-532`) — attachment views are new plumbing, not an existing
|
||||
pattern to reuse.
|
||||
- `CommandList` stores type-erased `Box<dyn RecordedCommand>` (`recorder.rs:97-109`);
|
||||
the `Command` trait exposes only `side_effects` (`commands.rs:14-16`), so
|
||||
there is currently no way to classify a command or introspect its render-pass
|
||||
descriptor.
|
||||
- The graph documents a single-queue submission contract (`graph.rs:8-10`);
|
||||
ordinary barriers set `queue_transfer: None` (`graph.rs:972-982, 1001-1012`).
|
||||
- Compile-time cost profile (recomputed independently): `dependencies()` is
|
||||
`O(Σ_r u_r²)` pair checks (plus a per-pair scope cross-product factor);
|
||||
`HappensBefore::from_dag` allocates one `FixedBitSet` row per node
|
||||
(`graph.rs:213-217`, `O(N²/64)` memory) and unions per edge
|
||||
(`O(E·N/64)` time). `ResourceInterference::from_resource_uses` **already**
|
||||
computes terminal/initial frontier sets once per resource
|
||||
(`graph.rs:1348-1356`); the remaining recomputations are in
|
||||
`first_fit_placement` (`graph.rs:1642-1649`), the `overlapping` loop in
|
||||
`plan_placements` (`graph.rs:2384-2399`), and `build_alias_barriers`
|
||||
(`graph.rs:1088, 1109-1117`). Contraction does not remove those; see D8.
|
||||
- The crate is not yet referenced by `renderer`/`bevy_vulkan_render`; the
|
||||
current per-frame path is the legacy `renderer::render_graph`. If this
|
||||
crate replaces it, `compile()` becomes per-frame, so compile cost matters.
|
||||
- `dynamic_rendering` and `synchronization2` are requested/enabled
|
||||
(`crates/renderer/src/lib.rs:352-357`, `crates/renderer_device/src/testing.rs:166-172`).
|
||||
- **Pre-existing defect (reproduced):** `build_barriers` records only the last
|
||||
*conflicting* producer's layout (`graph.rs:951-982`) and never chains
|
||||
`old_layout` through earlier barriers. Two same-layout readers of one
|
||||
producer yield two image barriers both claiming `oldLayout = producerLayout`;
|
||||
the second is stale (Vulkan requires `oldLayout` be the current layout or
|
||||
`UNDEFINED`). Fixing this generally (per-subresource current-layout map) is
|
||||
part of Task 3 — the plan's own tests hit it.
|
||||
- `Image::create_view` returns `Result<ManuallyDrop<ImageView>>`
|
||||
(`renderer_resources/src/images.rs:558-621`); storing it directly leaks the
|
||||
`vk::ImageView` and the `Arc<Image>` (the existing renderer calls `.raw()`
|
||||
on the `ManuallyDrop`, `crates/renderer/src/rendering/mod.rs:332`, which
|
||||
leaks today). New code must use `ManuallyDrop::into_inner`.
|
||||
- `ImageView` holds `Arc<Image>` (`images.rs:1123-1127`), so a view cache
|
||||
keeps its image alive; drop-order interaction with `CompiledGraph` is benign
|
||||
provided the `ManuallyDrop` is unwrapped.
|
||||
|
||||
## Design decisions
|
||||
|
||||
### D1 — Dynamic rendering, not `VkRenderPass`
|
||||
|
||||
New commands `BeginRendering` / `EndRendering` wrapping `vkCmdBeginRendering`
|
||||
/ `vkCmdEndRendering` (Vulkan 1.3 `dynamic_rendering`). Attachments are graph
|
||||
`Texture` handles plus a view specification (see D9 for the created-texture
|
||||
problem).
|
||||
|
||||
### D2 — The pass span is an atomic scheduling unit, contracted *before* hazard-edge construction
|
||||
|
||||
The compiler contracts the `[begin, end]` command span into a single DAG node
|
||||
("pass node"). This is the mechanism that satisfies the ordering contract:
|
||||
|
||||
- No foreign command can ever be scheduled inside the span (Kahn batching
|
||||
would otherwise interleave hazard-free commands into it).
|
||||
- The whole span is ordered relative to everything else by the begin/end
|
||||
hazard edges.
|
||||
|
||||
Contraction happens **before** `dependencies()` builds hazard edges. This
|
||||
removes the `O(Σ u_r²)` interior-draw edge construction (edges that would be
|
||||
discarded as self-edges anyway) and shrinks the `O(N²/64)` closure memory/time
|
||||
by roughly `(N_c/N)²`.
|
||||
|
||||
The contraction mapping used by every consumer:
|
||||
|
||||
- **Type-safe node indexing:** use `NodeId(NonZeroU32)` newtypes and
|
||||
`Option<NodeId>` rather than raw `u32` sentinels mixed into `CommandIndex`
|
||||
spaces. To minimize churn, keep `batches: Vec<Vec<CommandIndex>>` with the
|
||||
`BeginRendering` command index as the documented pass-node representative;
|
||||
`spans: Vec<Span>` on `CompiledGraph` records `{ begin, end, interior }`
|
||||
for expansion. Document the semantic change to the public `batches()` /
|
||||
`dependency_edges()` accessors. Closure rows are indexed `0..N_c`.
|
||||
- **HappensBefore is span-aware internally:** `HappensBefore` (public,
|
||||
`lib.rs:10`) stores the command→node map and span slices and translates in
|
||||
`contains`/`happens_before` (`graph.rs:244-251`), so command-space queries
|
||||
keep working and interior indices never silently return `false` from a
|
||||
node-space row lookup.
|
||||
- **Intra-span record order is NOT a happens-before fact.** Interior commands
|
||||
map to the pass node and same-node uses are treated as **unordered** by the
|
||||
closure used for aliasing/interference. This is a memory-correctness
|
||||
requirement, not a style choice: if intra-span record order were visible to
|
||||
`resources_alias_in_order`, a created attachment `A` (single use at `Begin`)
|
||||
and a created storage image `B` used only by an interior command would be
|
||||
judged ordered, `first_fit_placement` could place `B` over `A`'s memory, and
|
||||
`build_alias_barriers` would either emit no barrier or an illegal in-pass
|
||||
`UNDEFINED → layout` transition — discarding a live render target mid-pass.
|
||||
Intra-span record order is needed only by `build_barriers` (which iterates
|
||||
execution order directly) and by recording; it must not leak into the
|
||||
aliasing/interference closure.
|
||||
- **Attachment liveness is modeled through `End`.** `EndRendering` carries
|
||||
synthetic accesses (the attachment layout, `COLOR_ATTACHMENT_WRITE` /
|
||||
`DEPTH_STENCIL_ATTACHMENT_WRITE` at the attachment stage) used **only** for
|
||||
lifetime/alias analysis — excluded from barrier generation (the begin's
|
||||
merged scope already covers synchronization) and from usage derivation.
|
||||
This makes `terminal_uses(attachment) == End`, so an attachment is never
|
||||
judged dead before the store.
|
||||
- **Barrier analysis:** `build_barriers` walks scheduled command indices and
|
||||
reads only `accesses[command_index]` (`graph.rs:930-934`). Barrier analysis
|
||||
runs over the *uncontracted* access list in **execution order** (see D6),
|
||||
with each interior command's scheduling position mapped to the pass node.
|
||||
This preserves, e.g., the barrier for an outside producer consumed only by
|
||||
an interior `Draw`.
|
||||
- **Lifetimes:** `bind_lifetimes` indexes `batch_for_command` for every
|
||||
original access (`graph.rs:1258-1275`); interior commands map to the pass
|
||||
node's batch index so they are never left at `usize::MAX`. Placement uses
|
||||
command-level interference, not batch intervals, so placement quality is
|
||||
unaffected; external consumers of `resource_lifetimes()` get coarser
|
||||
intervals for interior commands (documented).
|
||||
- **Self-edges:** intra-span hazard pairs are skipped during edge
|
||||
construction (they become intra-span execution-order constraints handled by
|
||||
`build_barriers`, not DAG edges). Note `dependencies()` only adds
|
||||
prior→current edges (`graph.rs:825-861`), so the DAG is acyclic regardless;
|
||||
only a literal self-edge would trip `Kahn`, and skipping intra-span pairs
|
||||
prevents exactly that.
|
||||
|
||||
### D3 — Access declaration: one merged scope per attachment; interior accesses preserved
|
||||
|
||||
- `BeginRendering` declares **one access per attachment** with
|
||||
`access = COLOR_ATTACHMENT_WRITE | (LOAD ? COLOR_ATTACHMENT_READ : 0)` at
|
||||
the same stage (`COLOR_ATTACHMENT_OUTPUT` / `EARLY|LATE_FRAGMENT_TESTS`),
|
||||
subresource range from the view (concrete range rules in D9). Declaring
|
||||
read and write as two separate accesses is a blocker:
|
||||
`SideEffectMap::insert` deliberately keeps opposite-polarity scopes separate
|
||||
(`recorder.rs:55-82`), and `build_barriers` then emits **two**
|
||||
`ImageMemoryBarrier2` entries for the same image/range/layout in one
|
||||
`vkCmdPipelineBarrier2` (reproduced). A single merged scope is handled
|
||||
correctly by `SideEffectMap`, `conflicts`, `derive_image_usage`
|
||||
(`graph.rs:2696-2709`), and the barrier builder.
|
||||
- `EndRendering`'s synthetic accesses (D2) are tagged so barrier generation
|
||||
and usage derivation skip them.
|
||||
- Interior command accesses are *not* replaced by the begin declarations;
|
||||
they remain in the access list (see D2 barrier analysis) so the compiler
|
||||
can emit barriers for outside producers consumed inside the pass.
|
||||
- Because the span is contracted, any later consumer is ordered after the
|
||||
*entire pass node*, i.e. after `EndRendering` — matching store-visibility
|
||||
semantics (verified: the consumer's barrier takes
|
||||
`COLOR_ATTACHMENT_OUTPUT`/`COLOR_ATTACHMENT_WRITE` as src, and hoisting a
|
||||
barrier before `BeginRendering` with those dst stages is sufficient
|
||||
visibility for LOAD reads and in-pass attachment reads).
|
||||
- **Intra-span hazard policy (narrowed):**
|
||||
- **Allowed inside a span:** any barrier with `old_layout == new_layout` —
|
||||
memory barriers, buffer barriers, *and* image barriers with no layout
|
||||
change (a storage-image read/write hazard at `GENERAL` needs an
|
||||
`ImageMemoryBarrier2`; buffer barriers do not synchronize image
|
||||
accesses). These are legal inside a rendering instance.
|
||||
- **Rejected at compile time** (`GraphError::UnsupportedIntraPassDependency`):
|
||||
intra-span hazards requiring an image layout transition (`old != new`).
|
||||
The rejection operates on the **generated barrier plan** (D5), not on an
|
||||
independent declared-layout scan.
|
||||
- **Feedback loops:** defined as any interior access (read *or* write)
|
||||
whose `(aspect, mip, layer)` set intersects a `Begin` attachment
|
||||
subresource, excluding the begin's own load operation. A storage-image
|
||||
write to an attachment subresource is equally a feedback loop; an
|
||||
interior read of a disjoint mip/layer is legal. Genuine feedback loops
|
||||
require splitting.
|
||||
- In-pass barrier emission is part of Task 4, not a follow-up: the recorder
|
||||
emits the barrier between the two interior commands in execution order.
|
||||
- **Aliasing vs. interior first uses is a placement constraint, not a
|
||||
validation message.** For image resources, add an interference edge (or an
|
||||
explicit `aliasing_allowed` predicate consulted by `plan_placements` /
|
||||
`first_fit_placement`) whenever a required alias transition would land at a
|
||||
command that maps to a pass node — covering interior *and* boundary
|
||||
(`Begin`/`End`) positions. There is no "reject aliasing" hook after
|
||||
`plan_placements` commits offsets, so this must be enforced before
|
||||
placement. Attachments themselves are safe: with D2's `End` modeling,
|
||||
their first use is `Begin` and their terminal use is `End`, both boundary
|
||||
commands of the pass node itself.
|
||||
|
||||
### D4 — Barrier placement: hoist cross-boundary barriers to BeginRendering, with per-subresource layout handling
|
||||
|
||||
Barriers whose `before_command` targets an interior command but whose origin
|
||||
lies outside the span are re-keyed to the span's `BeginRendering` command,
|
||||
after batching when spans are known. Constraints:
|
||||
|
||||
- **Barrier origin must be explicit.** `BarrierResource` (`graph.rs:84-95`)
|
||||
records only masks/layout — no source command and no initial-vs-command
|
||||
origin — so a post-pass hoisting pass cannot distinguish an outside-producer
|
||||
barrier from an initial-state transition. Add
|
||||
`src_command: Option<CommandIndex>` or a
|
||||
`BarrierOrigin::{Initial, Command(CommandIndex)}` to `BarrierResource`.
|
||||
**Initial-state transitions for interior first uses are hoisted to
|
||||
`Begin`** and are not covered by the intra-span rejection (a transient
|
||||
storage image first used inside a pass is the common case, not an edge
|
||||
case).
|
||||
- **Coalesce hoisted image transitions per `(resource, exact range,
|
||||
target_layout)` into one barrier** with a unioned `dst_stage/dst_access`
|
||||
(safe: the dst scope is a wait target; unioning only makes all consumers
|
||||
wait). Layout chaining is defined **per `(aspect, mip, layer)`**, not per
|
||||
barrier: for overlapping-but-unequal ranges, transition only the
|
||||
subresources still in the producer layout; for disjoint ranges, emit
|
||||
independent `producer → layout` transitions. The earlier "chain different
|
||||
target layouts" rule is removed: for same-pass overlapping ranges it is
|
||||
unreachable (D3 rejects the required in-pass transition), and for disjoint
|
||||
ranges chaining `producer → A → B` is simply wrong (the second range is
|
||||
still in the producer layout).
|
||||
- The general `build_barriers` fix (per-subresource current-layout map, not
|
||||
"last conflicting access's layout") makes non-hoisted multi-reader cases
|
||||
correct too; D4's rules then only govern the hoisted subset.
|
||||
- After hoisting, rebuild `barrier_indices_by_command` and
|
||||
`ordinary_barrier_count` (`O(B)`), and apply the same coalescing in
|
||||
`rebuild_barriers` (`graph.rs:2092-2106`) for alias barriers.
|
||||
**Sequencing:** hoisting runs before `build_alias_barriers`, and hoisted
|
||||
barriers live strictly within the ordinary prefix so `rebuild_barriers`'
|
||||
truncation to `ordinary_barrier_count` (`graph.rs:2098`) cannot drop or
|
||||
duplicate them; `rebuild_barriers` re-runs hoisting after the alias rebuild.
|
||||
- Consumers after the pass keep their own barrier keys; expansion emits them
|
||||
after `EndRendering` naturally. No barrier targets `EndRendering` itself
|
||||
(its synthetic accesses are excluded from barrier generation).
|
||||
- In-pass barriers (per D3) are emitted between the two interior commands in
|
||||
execution order; they are not hoisted.
|
||||
|
||||
### D5 — Compile-time validation on the generated barrier plan, all linear
|
||||
|
||||
New `GraphError` variants for:
|
||||
|
||||
- unbalanced begin/end; nested/overlapping spans (dynamic rendering cannot
|
||||
nest); `EndRendering` before its `BeginRendering` in record order;
|
||||
- graphics commands outside any span (`GraphError::DrawOutsideRenderPass`),
|
||||
and **transfer/clear commands inside a span** (`CopyBuffer`,
|
||||
`ClearTexture`, and any custom command not marked render-pass-legal —
|
||||
`vkCmdCopyBuffer2`/`vkCmdClearColorImage` are illegal inside a rendering
|
||||
instance). Classification uses a `fn kind(&self) -> CommandKind` method on
|
||||
**`RecordedCommand`** (not `Command` — a method on `Command` is
|
||||
unreachable after `Box<dyn RecordedCommand>` erasure), returning
|
||||
`CommandKind::{Begin, End, Graphics, Transfer, Compute, Other}` plus a
|
||||
`fn allowed_in_render_pass(&self) -> bool` (default false; `Draw` → true;
|
||||
test/custom no-op commands may override so Task 5's in-pass barrier tests
|
||||
can exist). **Note:** five existing unit tests compile graphs containing
|
||||
`Draw` with no pass (pushes at `graph.rs:3563, 3749, 3815, 4329, 4469`)
|
||||
and must be updated (wrap in begin/end or make the restriction opt-in) —
|
||||
added to Task 5.
|
||||
- unsupported intra-span dependencies (per D3). **The check operates on the
|
||||
generated barrier plan after hoisting/chaining, not on an independent
|
||||
declared-layout scan:** after hoisting, any barrier whose `before_command`
|
||||
is interior and whose `old_layout != new_layout` is either hoistable
|
||||
(initial/outside origin) or must be rejected. This is exact, `O(B)`, and
|
||||
removes the fragile per-subresource validation machinery whose
|
||||
`previous`-selection (`graph.rs:951-982`) can diverge from declared
|
||||
layouts (e.g. producer `P` at layout `P0`, outside reader `R1` transitions
|
||||
to `A`, interior reader `R2` reads at `A`: a declared-layout scan sees
|
||||
`A → A` and accepts, while the un-fixed builder would emit `P0 → A` at
|
||||
`R2` inside the pass).
|
||||
- attachment consistency: each attachment view must refer to the same image
|
||||
as its graph `Texture` — compare **raw handles** (`Image: PartialEq`
|
||||
compares raw handles, `images.rs:484-488`; `Arc::ptr_eq` would
|
||||
false-positive), consistent with import dedup by raw handle
|
||||
(`graph.rs:389-399`). Applies to the imported case at compile time and to
|
||||
the created case after view creation (D9). The recorder binds graph images
|
||||
by `ResourceId` (`graph.rs:2513-2529`); this check closes that gap.
|
||||
- attachment layout legality: the caller-specified layout must be a legal
|
||||
rendering layout (`COLOR_ATTACHMENT_OPTIMAL`, `GENERAL`, etc.). The
|
||||
previously proposed "layout must match compiler-produced state" rule is
|
||||
vacuous — the barrier's `new_layout` is derived from the begin's own
|
||||
declared access, so no independent state exists.
|
||||
- duplicate attachment subresources: two attachments covering the same
|
||||
subresource would be silently merged by `SideEffectMap::insert`
|
||||
(`recorder.rs:55-82`), making them invisible from `command_accesses`;
|
||||
reject via the introspection hook (below), using a per-subresource set
|
||||
(`O(covered subresources)`, not pairwise `O(a²)`).
|
||||
- queue ownership: passes cannot cross queues or require ownership
|
||||
transfers; the single-queue submission contract (`graph.rs:8-10`) applies
|
||||
unchanged, and any access requiring a queue transfer inside or across a
|
||||
span is rejected until paired transfer/barrier support exists.
|
||||
- **Introspection hook:** commands are type-erased, and `CommandKind` carries
|
||||
no data, so Task 2's attachment validations (view/image identity, duplicate
|
||||
subresources, layout legality) need a `fn render_pass_desc(&self) ->
|
||||
Option<&RenderPassDesc>` method on `RecordedCommand` (or a validation hook
|
||||
called from `compile()`, or an attachment summary stored in
|
||||
`CommandList::push`, which is generic over `C: Command`). Without one,
|
||||
these checks are not implementable as written.
|
||||
- **Error-path safety:** `record_commands` returning mid-pass must not leave
|
||||
an unclosed rendering instance; track an `in_render_pass` flag in the
|
||||
recorder/compiled graph, end the rendering instance (or discard the command
|
||||
buffer) on the error path, and reset the flag (`graph.rs:2540-2553`).
|
||||
|
||||
### D6 — Execution order is precomputed at compile time
|
||||
|
||||
Compile an `execution_order: Vec<CommandIndex>` once: expanded contracted
|
||||
batches — begin → interiors in **original record order** → end. Barrier
|
||||
analysis and recording both iterate it; `batches` is retained (begin-index
|
||||
representative) for future parallel recording. This avoids per-frame
|
||||
expansion branches and keeps `recording_steps_cached` (`graph.rs:2595-2608`)
|
||||
allocation-free. Interior commands are emitted in record order, not topo
|
||||
order: ordering within the pass is the user's responsibility, matching the
|
||||
"ordered by begin/end" contract.
|
||||
|
||||
**Batch-index assertion fix:** `build_barriers_with_initial_states` asserts
|
||||
`batch_indices[previous] < batch_indices[current]` (`graph.rs:962-966`).
|
||||
With interior commands mapped to the pass node's batch index, an allowed
|
||||
intra-span hazard gives equal batch indices → debug panic. Use
|
||||
**execution-order positions** for that assertion, not batch indices.
|
||||
|
||||
### D7 — Recording is allocation-free per frame
|
||||
|
||||
- `BeginRendering::apply` precomputes `vk::RenderingAttachmentInfo`s in the
|
||||
command (or uses `ArrayVec`/thread-local scratch, mirroring
|
||||
`pipeline_barrier`'s `BARRIER_SCRATCH` pattern, `recorder.rs:217-292`) so
|
||||
no `Vec` is built per frame. Resolving attachment images per frame is
|
||||
`O(a log R)` `BTreeMap` lookups — negligible.
|
||||
- **View plumbing into `apply`:** `RecordedCommand::apply` receives only
|
||||
`CommandRecorder` (`recorder.rs:108`), which cannot see `CompiledGraph`'s
|
||||
view cache. Extend `CommandRecorder` with a view map
|
||||
(`ResourceId → Arc<ImageView>`, or `ResourceId → Arc<Image>` plus
|
||||
command-local view cache populated via `&mut self`) populated by
|
||||
`record_commands`. `BeginRendering::apply` looks up the view; a missing or
|
||||
unresolved view is a new `RecordError::MissingView`. Views are created
|
||||
with `ManuallyDrop::into_inner(create_view(...))` — never store the
|
||||
`ManuallyDrop` directly (leak).
|
||||
- **Resolve idempotence:** views are created only after a fully successful
|
||||
placement pass, atomically; a failed view creation must not leave a
|
||||
partially-populated cache that later makes `Arc::get_mut`
|
||||
(`graph.rs:2472-2480`) fail on a retried placement. Repeated `resolve()`
|
||||
calls are idempotent; `create_view` failures map to `GraphError` variants.
|
||||
|
||||
### D8 — Frontier reuse for placement and alias barriers (performance amendment, corrected)
|
||||
|
||||
`ResourceInterference::from_resource_uses` already caches frontier sets per
|
||||
resource (`graph.rs:1348-1356`); the plan does not claim to change it. The
|
||||
actual recomputation hot spots contraction does not touch are
|
||||
`first_fit_placement` (`graph.rs:1642-1649`), the `overlapping` loop in
|
||||
`plan_placements` (`graph.rs:2384-2399`), and `build_alias_barriers`
|
||||
(`terminal_uses` per old resource per new resource at `graph.rs:1109-1117`;
|
||||
`initial_uses` per new at `graph.rs:1088`). Compute terminal/initial
|
||||
frontiers **once per resource** and reuse them across all three sites, so
|
||||
the performance rationale is honest: contraction removes the dependency-edge
|
||||
and closure quadratics; frontier reuse removes the placement/alias ones.
|
||||
|
||||
### D9 — Attachment API for created textures
|
||||
|
||||
`RenderGraph::create_texture` returns only a `Texture` id (`graph.rs:352-354`);
|
||||
the `Arc<Image>` is created privately in `plan_placements`/`resolve`
|
||||
(`graph.rs:2129-2495`) and `CompiledGraph` exposes no accessor, so a caller
|
||||
can never supply an `Arc<ImageView>` for a transient render target — the
|
||||
main use case. Resolution:
|
||||
|
||||
- `ColorAttachment.view` is an enum:
|
||||
`AttachmentView::Created(ImageViewDesc) | Imported(Arc<ImageView>)`.
|
||||
- **`Created(ImageViewDesc)` must carry concrete aspect/mip/layer ranges**
|
||||
(`REMAINING_*` sentinels are rejected at validation): `side_effects` runs
|
||||
at `push` time (`recorder.rs:119-124`), before `resolve()` creates any
|
||||
image, `image_view_subresource_range` is private (`images.rs:1183`), and
|
||||
`create_view` normalizes/mutates the desc (`set_max_end`, format fill-in,
|
||||
`images.rs:566-589`; `MipRange` addition panics on overflow in debug for
|
||||
`base > 0` with a `REMAINING` count, `images.rs:1319-1333`). The declared
|
||||
access range therefore comes from the desc, and is validated against the
|
||||
image's mip/layer counts at compile time (from `GraphTextureDesc` /
|
||||
imported `ImageDesc`).
|
||||
- The crate creates and caches views after `resolve()` (`Image::create_view`
|
||||
needs `&Arc<Image>`, `images.rs:558`), keyed by `(Texture, ImageViewDesc)`;
|
||||
`BeginRendering::apply` consumes the cached view via the D7 recorder view
|
||||
map.
|
||||
- The D5 view/image consistency check applies to the imported case at
|
||||
compile time and to the created case after view creation.
|
||||
|
||||
## Work items (all in `crates/renderer_rendergraph`)
|
||||
|
||||
### Task 1 — New commands in `src/commands.rs`
|
||||
|
||||
- `BeginRendering { color: Vec<ColorAttachment>, depth: Option<DepthStencilAttachment>, ... }`
|
||||
with `ColorAttachment { texture: Texture, view: AttachmentView (D9),
|
||||
layout: vk::ImageLayout, load_op, store_op, clear_value }` (depth/stencil
|
||||
analogous). `EndRendering` (unit struct).
|
||||
- `Command::side_effects`: `BeginRendering` inserts **one merged scope per
|
||||
attachment** (write + conditional LOAD read, same stage, concrete range
|
||||
from the desc/view — D3, D9). `EndRendering` inserts synthetic
|
||||
lifetime-only accesses (D2), tagged to be skipped by barrier generation
|
||||
and usage derivation.
|
||||
- Add `fn kind(&self) -> CommandKind` and
|
||||
`fn allowed_in_render_pass(&self) -> bool` and
|
||||
`fn render_pass_desc(&self) -> Option<&RenderPassDesc>` to
|
||||
`RecordedCommand` (D5): `Draw` → `Graphics`/allowed; begin/end →
|
||||
`Begin`/`End`; `CopyBuffer`/`ClearTexture` → `Transfer`/not allowed;
|
||||
default `Other`/not allowed.
|
||||
- `RecordedCommand::apply`: `BeginRendering` builds `vk::RenderingInfo` +
|
||||
precomputed `vk::RenderingAttachmentInfo`s (resolving `vk::Image` via
|
||||
`recorder.image(...)`, using the cached/created view via the D7 view map)
|
||||
and calls `cmd_begin_rendering`; `EndRendering` calls `cmd_end_rendering`.
|
||||
Allocation-free per D7.
|
||||
- Export both from `lib.rs`.
|
||||
|
||||
### Task 2 — Span tracking & validation in `src/graph.rs`
|
||||
|
||||
- Derive spans in `compile()` by scanning command record order using
|
||||
`CommandKind`; build `node_of_command` and `spans`.
|
||||
- Validation pass implementing the D5 rules, including the raw-handle
|
||||
view/image consistency check (imported case), layout legality, duplicate
|
||||
attachment subresources (via the introspection hook), and the
|
||||
classification-based inside/outside-pass checks. Linear per D5.
|
||||
- Update the five existing unit tests that compile `Draw` without a pass
|
||||
(pushes at `graph.rs:3563, 3749, 3815, 4329, 4469`).
|
||||
|
||||
### Task 3 — Span contraction in the compiler
|
||||
|
||||
- Contract spans **before** `dependencies()` per D2: aggregate access scopes
|
||||
per pass node, skip intra-span pairs during edge construction, begin-index
|
||||
node representation, contracted `dependency_edges`; `HappensBefore` stores
|
||||
the command→node map and translates in queries.
|
||||
- Fix the pre-existing stale-`old_layout` defect in `build_barriers`
|
||||
(`graph.rs:951-982`) with a per-subresource current-layout map (D4).
|
||||
- Add `BarrierOrigin` to `BarrierResource` (D4).
|
||||
- Barrier analysis runs over the uncontracted access list in execution order
|
||||
with interior positions mapped to the pass node (D2); switch the
|
||||
`batch_indices` debug assertion to execution-order positions (D6).
|
||||
- `bind_lifetimes`, `HappensBefore::from_dag`, `uses_by_resource`, and
|
||||
`build_alias_barriers` thread the span map through per D2; all closure
|
||||
consumers route through the span-aware query; same-node uses are unordered
|
||||
for aliasing/interference (D2 blocker fix).
|
||||
- Precompute `execution_order` per D6.
|
||||
- Frontier reuse per D8 (`first_fit_placement`, `plan_placements` overlapping
|
||||
loop, `build_alias_barriers`).
|
||||
|
||||
### Task 4 — Barrier hoisting, in-pass barriers & recording
|
||||
|
||||
- Hoist cross-boundary barriers per D4, including initial-state transitions
|
||||
for interior first uses (`BarrierOrigin::Initial`); coalesce per
|
||||
`(resource, exact range, target_layout)` with unioned dst masks;
|
||||
per-`(aspect, mip, layer)` layout handling for overlapping/disjoint ranges.
|
||||
- Enforce the aliasing placement constraint per D3 (interference edge /
|
||||
`aliasing_allowed` predicate) before placement commits offsets.
|
||||
- Emit in-pass barriers (any `old_layout == new_layout`) between interior
|
||||
commands in execution order for allowed intra-span hazards (D3).
|
||||
- Hoisting sequenced before `build_alias_barriers`, hoisted barriers within
|
||||
the ordinary prefix, `rebuild_barriers` re-runs hoisting (D4).
|
||||
- `recording_steps_cached` iterates `execution_order`, expanding pass nodes:
|
||||
barriers (hoisted) → `BeginRendering` → interior commands in record order
|
||||
(with in-pass barriers interleaved) → `EndRendering`.
|
||||
- `record_commands`: expanded steps, D7 view map population, and the D5
|
||||
error-path safety (`in_render_pass` flag, no unclosed rendering instance
|
||||
on mid-pass `RecordError`).
|
||||
|
||||
### Task 5 — Tests
|
||||
|
||||
- Unit tests in `graph.rs`:
|
||||
- span validation errors (unbalanced, nested, draw-outside-pass,
|
||||
transfer-inside-pass, view/image mismatch, layout legality, duplicate
|
||||
attachment subresources, feedback-loop rejection);
|
||||
- contraction correctness (foreign command never appears between begin and
|
||||
end in `execution_order`; interior accesses still produce barriers;
|
||||
lifetimes resolved for interior commands);
|
||||
- barrier hoisting (barrier for an outside producer consumed by an interior
|
||||
command is keyed to begin; **two interior consumers of one
|
||||
outside-produced image coalesce into one barrier with unioned dst masks
|
||||
and correct per-subresource `old_layout`** — regression test for the
|
||||
reproduced blocker; also cover the non-hoisted two-reader case fixed by
|
||||
the general `build_barriers` change);
|
||||
- initial-state hoisting (transient storage image first used inside a pass
|
||||
gets its `UNDEFINED → GENERAL` transition before `BeginRendering`);
|
||||
- in-pass barrier emission (interior storage-buffer *and* storage-image
|
||||
same-layout hazards produce barriers between the interior commands, no
|
||||
pass split; layout-transition hazard is rejected);
|
||||
- **aliasing safety (regression test for the rev-4 blocker):** created
|
||||
attachment `A` + interior-only storage image `B` must **not** share
|
||||
memory, and no barrier may target an interior command for `A`/`B`;
|
||||
attachment `terminal_uses == End`;
|
||||
- ordering of both incoming and outgoing dependencies around the whole
|
||||
span;
|
||||
- span-aware closure queries (`happens_before` with interior endpoints;
|
||||
same-node uses unordered for aliasing).
|
||||
- Integration tests in `tests/graph_validation.rs` (following the existing
|
||||
`TestVulkan` pattern):
|
||||
- skip when `core13.dynamic_rendering != TRUE` in addition to the existing
|
||||
`synchronization2` check (`tests/graph_validation.rs:108-111`);
|
||||
- the previously proposed `Draw`-based graph is **not constructible**
|
||||
(`CopyBuffer` accepts only buffers, `commands.rs:30-79`; `Draw` has no
|
||||
pipeline handle and never binds one, `commands.rs:143-153, 226-335`).
|
||||
Use a custom no-op command (like `ScopedNoop`) with a texture read
|
||||
access as the post-`End` consumer:
|
||||
`ClearTexture → BeginRendering(color=target) → allowed no-op (texture
|
||||
read) → EndRendering → no-op (texture read)` compiles, records,
|
||||
submits, and validates. Adding a real `BindPipeline` command (with a
|
||||
documented sequence edge to the following `Draw`, since pipeline binding
|
||||
declares no resources) is an optional separate work item;
|
||||
- a case proving a hazard-free foreign command is emitted *after*
|
||||
`EndRendering`, not inside the pass;
|
||||
- an outside-producer → interior-consumer case asserting the hoisted
|
||||
barrier appears before `BeginRendering`.
|
||||
|
||||
### Task 6 — Existing test/API churn
|
||||
|
||||
- `batches()` assertions and helpers that assume command-space batches:
|
||||
`graph.rs:3774, 4285, 4388-4393, 4674, 5237, 5245, 5257, 5337, 5343, 5397,
|
||||
5409, 5414` (including `recording_steps(compiled.batches(), …)` at 5343).
|
||||
- `GraphCompiler::new` / `build_barriers` signature changes (execution order +
|
||||
span map) break direct unit-test call sites at `graph.rs:3177-3178, 4125,
|
||||
4242, 4716-4718, 4792-4800, 4846-4854, 4893-4901, 4962-4976, 5051-5052,
|
||||
5102-5104, 5142-5145, 5171-5173`; `HappensBefore::from_dag` is called
|
||||
directly by a test (`graph.rs:4140-4155`) and its signature changes.
|
||||
- Wrapping a `Draw` in a pass shifts barrier indices in tests such as
|
||||
`sampled_image_import_sets_usage_and_layout_barrier` (asserts
|
||||
`compiled.barriers()[0]`, `graph.rs:3837-3845`).
|
||||
- Provide compatibility constructors where cheap, or update all listed call
|
||||
sites; `cargo test -p renderer_rendergraph` must pass at the end.
|
||||
|
||||
## Sequencing
|
||||
|
||||
Tasks 1–2 and 3–4 share types, so execute strictly in order: **1 → 2 → 3 →
|
||||
4 → 5 → 6**, with `cargo check -p renderer_rendergraph` after each and
|
||||
`cargo test -p renderer_rendergraph` at the end. No changes to other crates;
|
||||
`renderer`'s legacy `render_graph` module is out of scope.
|
||||
|
||||
## Performance summary (from rev-2 review, corrected in revs 3–5)
|
||||
|
||||
- **Contraction before `dependencies()`** removes the interior-draw
|
||||
`O(Σ u_r²)` edge construction and shrinks the `O(N²/64)` closure
|
||||
memory/time by roughly `(N_c/N)²` (a pass spanning 90% of commands →
|
||||
~100× less closure work).
|
||||
- **Contraction does not touch** the placement/alias frontiers; D8's
|
||||
frontier reuse addresses `first_fit_placement`, `plan_placements`'
|
||||
overlapping loop, and `build_alias_barriers` (interference frontier
|
||||
caching already exists).
|
||||
- **Barrier hoisting is cost-neutral to slightly positive** provided
|
||||
coalescing follows D4 (per-target-layout, per-subresource handling,
|
||||
unioned dst masks only for identical ranges/layouts).
|
||||
- **In-pass barriers for same-layout hazards** avoid the significant
|
||||
tiled-GPU regression of forced pass splits (attachment store/load
|
||||
round-trips); only layout transitions and feedback loops force splits.
|
||||
- **Validation is linear** with the barrier-plan-based intra-span check
|
||||
(`O(B)`) and per-subresource-set duplicate detection; the existing
|
||||
layout-conflict precheck grows by `O(a²)` pair iterations per pass
|
||||
(28 for 8 attachments with the merged single access) — negligible.
|
||||
- **Recording stays allocation-free** via precomputed attachment infos,
|
||||
thread-local scratch, precomputed `execution_order`, and begin-index node
|
||||
representation.
|
||||
|
||||
## Risks / open points
|
||||
|
||||
- **API shape:** raw `push(BeginRendering)` / `push(EndRendering)` vs. a
|
||||
scoped `render_pass(desc, |graph| ...)` builder that pushes begin, runs the
|
||||
closure, and pushes end while recording span command indices. The scoped
|
||||
builder makes unbalanced spans unrepresentable; raw push is more flexible.
|
||||
Decide before Task 1.
|
||||
- **Created-texture views (D9):** view creation after `resolve()` adds a
|
||||
cache keyed by `(Texture, ImageViewDesc)`; `ImageView` holds `Arc<Image>`
|
||||
so lifetime is safe; resolve idempotence and atomic creation per D7.
|
||||
- **Public API semantics:** `batches()` / `dependency_edges()` keep
|
||||
command-space indices with the begin index as pass-node representative;
|
||||
document the semantics and consider adding `execution_order()` as the
|
||||
primary accessor.
|
||||
- **`BindPipeline`:** out of scope for this plan; without it, submitted
|
||||
tests cannot include real draws. If added later, it needs a sequence edge
|
||||
to the following `Draw` (pipeline binding declares no resources, so the
|
||||
hazard graph will not order it).
|
||||
202
design-plans/rendergraph-transient-aliasing.md
Normal file
202
design-plans/rendergraph-transient-aliasing.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# 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).
|
||||
Loading…
Reference in a new issue