viewport_lib/resources/memory.rs
1//! GPU memory accounting and the hardware VRAM budget query.
2
3/// Texture memory usage reported by [`DeviceResources::texture_memory_stats`].
4///
5/// Counts bytes and textures uploaded via both the sync and async paths.
6/// Internal resources (shadow maps, colourmaps, post-process targets) are
7/// not included.
8#[derive(Debug, Clone, Copy, Default)]
9pub struct TextureMemoryStats {
10 /// Bytes currently allocated on the GPU for user-uploaded textures.
11 pub used_bytes: u64,
12 /// Number of live user-uploaded textures.
13 pub texture_count: u32,
14}
15
16/// Resident GPU bytes for the user-uploaded working set, from
17/// [`DeviceResources::resident_bytes`].
18///
19/// These are the classes a streaming or eviction policy frees and re-uploads:
20/// meshes (`upload_mesh_data` and friends), user textures (`upload_texture` and
21/// friends), Gaussian splats, marching-cubes volumes, and the pre-uploaded
22/// scivis curves. The query is cheap enough to poll each frame.
23///
24/// Built-in resources are not counted: colourmap and matcap LUTs, IBL maps, the
25/// shadow atlas, and post-process render targets. They are created once (or
26/// resized with the viewport) and are not part of the evictable working set.
27#[derive(Debug, Clone, Copy, Default)]
28pub struct ResidentBytes {
29 /// GPU buffer bytes across every resident mesh (geometry, attributes,
30 /// overrides, per-object uniforms).
31 pub mesh_bytes: u64,
32 /// GPU bytes across every resident user-uploaded texture.
33 pub texture_bytes: u64,
34 /// GPU buffer bytes across every resident Gaussian splat set (position,
35 /// scale, rotation, opacity, and SH source buffers; per-viewport sort
36 /// scratch is not counted).
37 pub gaussian_splat_bytes: u64,
38 /// GPU buffer bytes across every resident marching-cubes volume (all slab
39 /// buffers of every live volume).
40 pub mc_volume_bytes: u64,
41 /// GPU buffer bytes across every pre-uploaded scivis curve resource
42 /// (polylines, tubes, streamtubes, ribbons, point clouds, glyph sets,
43 /// tensor glyph sets, and sprite sets).
44 pub scivis_bytes: u64,
45}
46
47impl ResidentBytes {
48 /// Total resident bytes across every counted class.
49 pub fn total(&self) -> u64 {
50 self.mesh_bytes
51 + self.texture_bytes
52 + self.gaussian_splat_bytes
53 + self.mc_volume_bytes
54 + self.scivis_bytes
55 }
56}
57
58/// Hardware VRAM figures for the GPU a viewport runs on, from
59/// [`vram_budget`](crate::resources::vram_budget).
60///
61/// Pair this with [`ResidentBytes`] to drive an eviction budget: pick a ceiling
62/// as a fraction of `total_bytes` and free resources (`free_mesh` /
63/// `free_texture` / `free_lod_group`) as [`ResidentBytes::total`] approaches it.
64///
65/// `total_bytes` is the total device-local VRAM the backend reports and is
66/// available everywhere. `available_bytes` is the backend's live free-memory
67/// estimate: `Some` on Metal, and `None` on Vulkan, whose live figure needs
68/// `VK_EXT_memory_budget`, which wgpu does not enable. When it is `None`, size
69/// the budget against `total_bytes` and track usage with `ResidentBytes`.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct VramBudget {
72 /// Total device-local VRAM in bytes.
73 pub total_bytes: u64,
74 /// Backend-reported free VRAM in bytes, where available.
75 pub available_bytes: Option<u64>,
76}
77
78impl crate::resources::DeviceResources {
79 /// Resident GPU bytes for the user-uploaded working set: meshes, user
80 /// textures, Gaussian splats, marching-cubes volumes, and pre-uploaded
81 /// scivis curves.
82 ///
83 /// Cheap enough to poll per frame: mesh, texture, and splat totals are
84 /// running counters, and the volume / curve totals sum a handful of live
85 /// entries. A streaming or eviction policy compares [`ResidentBytes::total`]
86 /// against its own byte budget and calls the matching `free_*` to stay under
87 /// it. Built-in LUTs, IBL maps, and render targets are not counted; see
88 /// [`ResidentBytes`].
89 pub fn resident_bytes(&self) -> crate::resources::types::ResidentBytes {
90 let scivis_bytes = self.content.polyline_store.allocated_bytes()
91 + self.content.streamtube_store.allocated_bytes()
92 + self.content.tube_store.allocated_bytes()
93 + self.content.ribbon_store.allocated_bytes()
94 + self.content.point_cloud_store.allocated_bytes()
95 + self.content.glyph_set_store.allocated_bytes()
96 + self.content.tensor_glyph_set_store.allocated_bytes()
97 + self.content.sprite_set_store.allocated_bytes()
98 + self.content.sprite_instance_set_store.allocated_bytes();
99 crate::resources::types::ResidentBytes {
100 mesh_bytes: self.mesh_store.allocated_bytes(),
101 texture_bytes: self.content.textures.allocated_bytes(),
102 gaussian_splat_bytes: self.content.gaussian_splat_store.allocated_bytes(),
103 mc_volume_bytes: self.mc_volume_resident_bytes(),
104 scivis_bytes,
105 }
106 }
107
108 /// Query the GPU's device-local VRAM budget for `device`.
109 ///
110 /// A thin wrapper over [`vram_budget`](crate::resources::vram_budget) so the
111 /// hardware total sits next to [`resident_bytes`](Self::resident_bytes): a
112 /// policy sizes an eviction budget as a fraction of `total_bytes` and
113 /// compares [`ResidentBytes::total`](crate::resources::ResidentBytes::total)
114 /// against it. Returns `None` on backends that cannot be introspected. See
115 /// [`VramBudget`](crate::resources::VramBudget) for what `available_bytes`
116 /// reports per backend.
117 pub fn vram_budget(
118 &self,
119 device: &wgpu::Device,
120 ) -> Option<crate::resources::types::VramBudget> {
121 vram_budget(device)
122 }
123}
124
125/// Query device-local VRAM for `device`.
126///
127/// Returns `None` when the backend cannot be introspected: WebGPU and GL report
128/// nothing, and a `device` whose backend does not match this build resolves to
129/// `None`. `total_bytes` is the total device-local memory; `available_bytes` is
130/// the backend's live free estimate, present on Metal and `None` on Vulkan.
131pub fn vram_budget(device: &wgpu::Device) -> Option<VramBudget> {
132 #[cfg(any(target_os = "macos", target_os = "ios"))]
133 {
134 vram_budget_metal(device)
135 }
136 #[cfg(not(any(target_os = "macos", target_os = "ios")))]
137 {
138 vram_budget_vulkan(device)
139 }
140}
141
142#[cfg(any(target_os = "macos", target_os = "ios"))]
143fn vram_budget_metal(device: &wgpu::Device) -> Option<VramBudget> {
144 // Safety: we only read the MTLDevice's reported sizes and drop the hal
145 // guard immediately; the device is never destroyed or mutated.
146 let hal_device = unsafe { device.as_hal::<wgpu::hal::api::Metal>() }?;
147 let raw = hal_device.raw_device().lock();
148 let total = raw.recommended_max_working_set_size();
149 let used = raw.current_allocated_size() as u64;
150 Some(VramBudget {
151 total_bytes: total,
152 available_bytes: Some(total.saturating_sub(used)),
153 })
154}
155
156#[cfg(not(any(target_os = "macos", target_os = "ios")))]
157fn vram_budget_vulkan(device: &wgpu::Device) -> Option<VramBudget> {
158 // Safety: we read the physical device's memory-heap sizes through the
159 // instance and drop the hal guard immediately; nothing is destroyed.
160 let hal_device = unsafe { device.as_hal::<wgpu::hal::api::Vulkan>() }?;
161 let phys = hal_device.raw_physical_device();
162 let instance = hal_device.shared_instance().raw_instance();
163 let props = unsafe { instance.get_physical_device_memory_properties(phys) };
164 let total: u64 = props.memory_heaps[..props.memory_heap_count as usize]
165 .iter()
166 .filter(|heap| heap.flags.contains(ash::vk::MemoryHeapFlags::DEVICE_LOCAL))
167 .map(|heap| heap.size)
168 .sum();
169 if total == 0 {
170 return None;
171 }
172 // Live free VRAM needs VK_EXT_memory_budget, which wgpu does not enable, so
173 // only the total is reported. A policy sizes against it and tracks usage
174 // with `ResidentBytes`.
175 Some(VramBudget {
176 total_bytes: total,
177 available_bytes: None,
178 })
179}
180
181#[cfg(test)]
182mod tests {
183 use super::vram_budget;
184
185 fn try_make_device() -> Option<wgpu::Device> {
186 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
187 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
188 power_preference: wgpu::PowerPreference::LowPower,
189 compatible_surface: None,
190 force_fallback_adapter: false,
191 }))
192 .ok()?;
193 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))
194 .ok()
195 .map(|(device, _queue)| device)
196 }
197
198 #[test]
199 fn vram_budget_reports_a_consistent_total() {
200 let Some(device) = try_make_device() else {
201 eprintln!("skipping: no wgpu adapter available");
202 return;
203 };
204 // On an unsupported backend the query returns None, which is a valid
205 // outcome; when it returns a budget the numbers must be self-consistent.
206 if let Some(budget) = vram_budget(&device) {
207 assert!(
208 budget.total_bytes > 0,
209 "total device-local VRAM must be non-zero"
210 );
211 if let Some(available) = budget.available_bytes {
212 assert!(
213 available <= budget.total_bytes,
214 "available VRAM must not exceed the total"
215 );
216 }
217 }
218
219 // The `DeviceResources::vram_budget` wrapper must agree with the free
220 // function it delegates to.
221 let resources =
222 crate::DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
223 assert_eq!(resources.vram_budget(&device), vram_budget(&device));
224 }
225}