Skip to main content

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}