Merge pull request #1017: Pr/viewport slot memory - Give back the device memory of viewports nobody is drawing

This commit is contained in:
Hakan Seven committed 2026-09-05 19:28:33 +03:00
commit a03973bc6e
6 files changed
+533 -97

No files matched your search

+60 -6
View File
@@ -16,6 +16,9 @@ fn block_edits_preserve_cache_coordinates_and_arena_partition() {
.expect("GPU device");
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
// Definition geometry is shared across viewport slots, so it lives on the
// MultiPipeline; a single-slot test supplies its own.
let mut block_geometry = wire_gpu::BlockGeometryCache::default();
let block = |handle: u64, x: f64| WireModel {
name: handle.to_string(),
points: vec![[x as f32, 0.0, 0.0], [x as f32 + 1.0, 0.0, 0.0]],
@@ -28,25 +31,25 @@ fn block_edits_preserve_cache_coordinates_and_arena_partition() {
let first = block(1, 10.0);
let second = block(2, 20.0);
let depth = rustc_hash::FxHashMap::from_iter([(1, [0.1, 0.01]), (2, [0.2, 0.01])]);
let original = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth);
let unchanged = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth);
let original = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry);
let unchanged = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry);
assert_eq!(original[0].geometry_id(), unchanged[0].geometry_id());
assert_eq!(original[0].instance_count, 2);
let removed = pipeline.upload_block_wires(&device, &queue, &[&second], &depth);
let removed = pipeline.upload_block_wires(&device, &queue, &[&second], &depth, &mut block_geometry);
assert_ne!(
original[0].geometry_id(),
removed[0].geometry_id(),
"removing the base instance must replace vertices baked at its old position"
);
let moved = block(2, 30.0);
let moved_gpu = pipeline.upload_block_wires(&device, &queue, &[&moved], &depth);
let moved_gpu = pipeline.upload_block_wires(&device, &queue, &[&moved], &depth, &mut block_geometry);
assert_ne!(removed[0].geometry_id(), moved_gpu[0].geometry_id());
let restored = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth);
let restored = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry);
assert_ne!(moved_gpu[0].geometry_id(), restored[0].geometry_id());
assert_eq!(restored[0].instance_count, 2);
assert_eq!(
pipeline.block_geometry.len(),
block_geometry.len(),
1,
"discard obsolete geometry"
);
@@ -265,3 +268,54 @@ fn bounded_gpu_uploads_preserve_geometry_and_instancing() {
"GPU validation failed"
);
}
/// The shadow map is 16 MiB and every slot used to hold one whether or not its
/// visual style enabled shadows. Now it is allocated on the transition, which
/// means the frame bind group is rebuilt while the device is watching — the one
/// path the other GPU tests never take.
#[test]
#[ignore = "requires a GPU adapter"]
fn toggling_shadows_reallocates_without_upsetting_the_device() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
assert!(
pipeline.shadow_full.is_none(),
"a fresh slot must not hold a shadow map"
);
let camera = crate::scene::view::camera::Camera::default();
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(64.0, 64.0));
let with_shadows = |on: bool| {
let mut u = Uniforms::new(&camera, bounds, false);
u.shadow_params[0] = if on { 1.0 } else { 0.0 };
u
};
// Off → on → off → on, so both transitions are exercised twice and the
// second `on` proves the release did not leave the bind group stale.
for expected in [true, false, true] {
pipeline.upload_uniforms(&device, &queue, &with_shadows(expected));
assert_eq!(
pipeline.shadow_full.is_some(),
expected,
"shadow target should follow shadow_params[0]"
);
}
// Releasing a cold slot gives it back and must rebuild the bind group too.
pipeline.release_heavy_resources(&device);
assert!(pipeline.shadow_full.is_none(), "a released slot holds none");
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert!(
block_on(validation.pop()).is_none(),
"GPU validation failed"
);
}
+363 -69
View File
@@ -150,8 +150,16 @@ pub struct Pipeline {
background_sampler: wgpu::Sampler,
background_source_id: usize,
environment_source_id: usize,
_shadow_texture: wgpu::Texture,
shadow_view: wgpu::TextureView,
/// The shadow depth target, allocated only while this viewport actually
/// casts shadows. `SHADOW_MAP_SIZE` squared at `Depth32Float` is 16 MiB,
/// and every slot used to hold one whether or not its visual style enabled
/// shadows — 128 MiB across eight viewports, none of it ever sampled.
shadow_full: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Bound at the frame group's shadow slot while `shadow_full` is `None`.
/// The binding must be filled for the layout to be satisfied, but nothing
/// samples it when shadows are off, so 1x1 is the whole requirement.
_shadow_fallback_texture: wgpu::Texture,
shadow_fallback_view: wgpu::TextureView,
shadow_sampler: wgpu::Sampler,
shadow_enabled: bool,
wipeout_bgl1: wgpu::BindGroupLayout,
@@ -217,9 +225,6 @@ pub struct Pipeline {
/// shared resident buffer.
pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>,
pub(crate) gpu_block_wires: std::sync::Arc<Vec<BlockWireGpu>>,
/// Block geometry that survives an edit, keyed by the definition it
/// expands. See `wire_gpu::BlockGeometryCache`.
pub(crate) block_geometry: wire_gpu::BlockGeometryCache,
/// Persistent per-entity wire instance arena (capability-selected format).
/// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an
/// edit patches one entity's slab in place instead of rebuilding every wire.
@@ -492,21 +497,9 @@ impl Pipeline {
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
});
let shadow_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("shadow.texture"),
size: wgpu::Extent3d {
width: SHADOW_MAP_SIZE,
height: SHADOW_MAP_SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default());
let shadow_fallback_texture = create_shadow_texture(device, 1);
let shadow_fallback_view =
shadow_fallback_texture.create_view(&wgpu::TextureViewDescriptor::default());
let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("shadow.sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
@@ -545,7 +538,7 @@ impl Pipeline {
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&shadow_view),
resource: wgpu::BindingResource::TextureView(&shadow_fallback_view),
},
wgpu::BindGroupEntry {
binding: 6,
@@ -2279,8 +2272,9 @@ impl Pipeline {
background_sampler,
background_source_id: 0,
environment_source_id: 0,
_shadow_texture: shadow_texture,
shadow_view,
shadow_full: None,
_shadow_fallback_texture: shadow_fallback_texture,
shadow_fallback_view,
shadow_sampler,
shadow_enabled: false,
wipeout_bgl1,
@@ -2300,7 +2294,6 @@ impl Pipeline {
surface_format: format,
gpu_wires: std::sync::Arc::new(vec![]),
gpu_block_wires: std::sync::Arc::new(vec![]),
block_geometry: Default::default(),
wire_arena: None,
wire_arena_mesh: None,
wire_arena_fallback: std::sync::Arc::new(Vec::new()),
@@ -2360,6 +2353,7 @@ impl Pipeline {
queue: &wgpu::Queue,
wires: &[&WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
block_geometry: &mut wire_gpu::BlockGeometryCache,
) -> Vec<BlockWireGpu> {
BlockWireGpu::from_wires(
device,
@@ -2371,7 +2365,7 @@ impl Pipeline {
const_bgl: &self.block_wire_const_bgl,
mode: self.block_wire_mode,
},
Some(&mut self.block_geometry),
Some(block_geometry),
)
}
@@ -2382,6 +2376,7 @@ impl Pipeline {
queue: &wgpu::Queue,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
block_geometry: &mut wire_gpu::BlockGeometryCache,
) -> (
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<Vec<BlockWireGpu>>,
@@ -2440,7 +2435,7 @@ impl Pipeline {
const_bgl: &self.block_wire_const_bgl,
mode: self.block_wire_mode,
},
Some(&mut self.block_geometry),
Some(block_geometry),
);
// Parse handles in parallel, preserving wire order for hover slot lists.
@@ -3496,8 +3491,90 @@ impl Pipeline {
}
}
pub fn upload_uniforms(&mut self, queue: &wgpu::Queue, uniforms: &Uniforms) {
/// Whatever is bound at the frame group's shadow slot: the real depth
/// target when this viewport casts shadows, the 1x1 placeholder otherwise.
fn shadow_view(&self) -> &wgpu::TextureView {
match &self.shadow_full {
Some((_, view)) => view,
None => &self.shadow_fallback_view,
}
}
/// Rebuild the frame bind group from whatever this slot currently holds.
///
/// Three things can change what belongs in it — a background image, an
/// environment image, and whether the shadow target exists — and it used to
/// be assembled inline at each. One builder means a shadow transition
/// cannot forget the background the slot already had.
fn rebuild_frame_bind_group(&mut self, device: &wgpu::Device) {
let background_view = self
.background_texture
.create_view(&wgpu::TextureViewDescriptor::default());
let environment_view = self
.environment_texture
.create_view(&wgpu::TextureViewDescriptor::default());
self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("viewer.bind_group"),
layout: &self.frame_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&background_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&environment_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(self.shadow_view()),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
},
],
});
}
/// Allocate or drop the shadow target to match `shadow_enabled`.
///
/// Only on a transition: the frame bind group has to be rebuilt with the
/// new view, and a viewport that keeps its shadow setting should not pay
/// for that every frame.
fn sync_shadow_target(&mut self, device: &wgpu::Device) {
match (self.shadow_enabled, self.shadow_full.is_some()) {
(true, false) => {
let texture = create_shadow_texture(device, SHADOW_MAP_SIZE);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.shadow_full = Some((texture, view));
}
(false, true) => self.shadow_full = None,
_ => return,
}
self.rebuild_frame_bind_group(device);
}
pub fn upload_uniforms(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
uniforms: &Uniforms,
) {
self.shadow_enabled = uniforms.shadow_params[0] > 0.5;
self.sync_shadow_target(device);
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
}
@@ -3564,46 +3641,7 @@ impl Pipeline {
environment,
[128, 128, 128, 255],
);
let background_view = self
.background_texture
.create_view(&wgpu::TextureViewDescriptor::default());
let environment_view = self
.environment_texture
.create_view(&wgpu::TextureViewDescriptor::default());
self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("viewer.bind_group"),
layout: &self.frame_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&background_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&environment_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&self.shadow_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
},
],
});
self.rebuild_frame_bind_group(device);
self.background_source_id = background_id;
self.environment_source_id = environment_id;
}
@@ -3672,12 +3710,20 @@ impl Pipeline {
// render rectangle does the clipping).
let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 };
if self.shadow_enabled && !self.skip_geometry && !mesh_wireframe {
// `shadow_enabled` says the style wants shadows; the target existing
// says the device could give us one. Without the second test a failed
// 16 MiB allocation would render the pass into the 1x1 placeholder.
if let Some(shadow_target) = self
.shadow_full
.as_ref()
.map(|(_, view)| view)
.filter(|_| self.shadow_enabled && !self.skip_geometry && !mesh_wireframe)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("shadow.render_pass"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.shadow_view,
view: shadow_target,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
@@ -4595,6 +4641,99 @@ impl Pipeline {
}
}
/// Forget every cache key that describes this slot's uploaded content.
///
/// Shared by slot reuse and by [`Self::release_heavy_resources`] so the two
/// cannot disagree about what "this slot holds nothing you can trust"
/// means. Keys only — the buffers themselves are dropped by the caller that
/// wants the memory back.
pub(crate) fn forget_cached_keys(&mut self) {
self.cached_epoch = (u64::MAX, u64::MAX, u64::MAX);
self.cached_wire_id = u64::MAX;
self.cached_selection = (u64::MAX, u64::MAX);
self.cached_highlight_key = (u64::MAX, u64::MAX);
self.cached_mesh_content_id = u64::MAX;
self.cached_face3d_key = (u64::MAX, false, false, u64::MAX);
self.cached_solid_visibility = (u64::MAX, [u32::MAX; 3], u64::MAX);
self.cached_hatch_source = None;
self.cached_preview_hatch_source = None;
self.cached_wipeout_source = None;
self.cached_image_source = None;
self.cached_text_source = None;
self.cached_mesh_source = None;
self.cached_face3d_source = None;
self.cached_face3d_depth_source = None;
self.wire_cull_key = (u64::MAX, u64::MAX, 0, 0);
self.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
self.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false);
self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX);
self.render_sig = u64::MAX;
}
/// Give this slot's device memory back and forget what it held.
///
/// A slot nobody is drawing still owns its wire arena, its render targets,
/// its text atlas and every category buffer — tens of megabytes each. Held
/// while another viewport is asking for an allocation, that is the
/// difference between a degraded frame and a session that cannot recover.
///
/// Everything released here is derived state with a rebuild path guarded by
/// a cache key, and every one of those keys is reset, so the next frame that
/// needs this slot builds it again. The cost of releasing a slot that turns
/// out to be needed is one slow frame.
pub(crate) fn release_heavy_resources(&mut self, device: &wgpu::Device) {
self.forget_cached_keys();
self.gpu_wires = std::sync::Arc::new(Vec::new());
self.gpu_block_wires = std::sync::Arc::new(Vec::new());
self.wire_handle_index = std::sync::Arc::new(rustc_hash::FxHashMap::default());
self.wire_arena = None;
self.wire_arena_mesh = None;
self.wire_arena_fallback = std::sync::Arc::new(Vec::new());
self.wire_arena_fallback_kind = None;
self.wire_arena_fallback_handles.clear();
self.wire_arena_id = u64::MAX;
self.gpu_selected_wires.clear();
self.gpu_selected_block_wires.clear();
self.gpu_preview_wires.clear();
self.gpu_wipeouts.clear();
self.wipeout_skip_flags.clear();
self.gpu_images.clear();
self.gpu_mesh_batch.clear();
self.gpu_mesh_dynamic.clear();
self.mesh_disabled_chunks.clear();
self.mesh_dynamic_handles.clear();
self.mesh_ranges_by_handle.clear();
self.mesh_highlight_draws.clear();
self.silhouette_chunks.clear();
self.silhouette_source_groups.clear();
self.clip_boundary = None;
// A cold slot's shadow target goes back too. `shadow_enabled` is left
// alone, so `sync_shadow_target` reallocates on the next frame that
// still wants shadows.
if self.shadow_full.take().is_some() {
self.rebuild_frame_bind_group(device);
}
self.text_atlas_gpu = None;
self.text_gpu.clear();
self.block_text_gpu.clear();
self.block_text_highlight_gpu.clear();
self.text_highlight_gpu.clear();
self.text_preview_gpu.clear();
// Back to the smallest allocation the rounding allows (128x128, about
// 0.6 MiB against the ~70 MiB a full-canvas slot holds). Going through
// `ensure_depth_texture` rather than reaching for the textures directly
// keeps the blit bind group consistent with the views it samples.
self.ensure_depth_texture(device, Size::new(1, 1));
// ...and make the next real size a mismatch, so it reallocates.
self.alloc_size = Size::new(0, 0);
}
pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size<u32>) {
// Record the requested render size every frame (the blit UV scale reads
// it); only reallocate the textures when the *rounded* size changes.
@@ -4729,6 +4868,129 @@ fn aabb_below_pixel(
(max_px - min_px).max(max_py - min_py) < threshold_px
}
/// Device bytes a renderer is holding, counted from the sizes it allocated
/// rather than from process RSS — the allocator and the driver both keep
/// high-water memory that RSS cannot distinguish from live resources.
///
/// Partial by construction: it covers the categories large enough to decide
/// where the next fix goes, and says so rather than pretending to be a total.
#[derive(Default, Clone, Copy, PartialEq)]
pub(crate) struct GpuLiveBytes {
pub slots: usize,
pub shadow: u64,
pub render_targets: u64,
pub text_atlas: u64,
pub wire_arena: u64,
pub block_geometry: u64,
}
impl GpuLiveBytes {
pub(crate) fn total(&self) -> u64 {
self.shadow + self.render_targets + self.text_atlas + self.wire_arena + self.block_geometry
}
}
impl Pipeline {
/// This slot's share. `alloc_size` is the real allocation, which is rounded
/// up from the requested size and grows only.
fn gpu_live_bytes(&self) -> GpuLiveBytes {
// 4x MSAA colour (4 B/sample) + 4x MSAA depth-stencil (4 B/sample) +
// one single-sample resolve target.
const BYTES_PER_PIXEL: u64 = (MSAA_SAMPLES as u64) * 4 + (MSAA_SAMPLES as u64) * 4 + 4;
let pixels = u64::from(self.alloc_size.width) * u64::from(self.alloc_size.height);
GpuLiveBytes {
slots: 1,
// Only while this viewport casts shadows; the placeholder that
// stands in otherwise is 4 bytes.
shadow: match &self.shadow_full {
Some(_) => u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4,
None => 4,
},
render_targets: pixels * BYTES_PER_PIXEL,
text_atlas: self
.text_atlas_gpu
.as_ref()
.map(text_gpu::TextAtlasGpu::gpu_bytes)
.unwrap_or(0),
wire_arena: self
.wire_arena
.as_ref()
.map(wire_arena::PersistentWireArena::gpu_bytes)
.unwrap_or(0)
+ self
.wire_arena_mesh
.as_ref()
.map(wire_arena::PersistentWireArena::gpu_bytes)
.unwrap_or(0),
block_geometry: 0,
}
}
}
impl MultiPipeline {
/// Hand back the device memory of slots nobody is drawing.
///
/// `reserved` are the slots this frame is about to use; they are never
/// touched. Everything else is released once it has been idle for
/// `idle_frames` prepare calls — gradually, so an ordinary session pays
/// nothing and a layout the user keeps switching back to stays warm.
///
/// `urgent` drops that patience: the device has just refused an allocation,
/// and holding a viewport nobody can see is what turns a degraded frame
/// into a session that never recovers. Releasing early costs one slow frame
/// if the slot is wanted again; not releasing costs the drawing.
///
/// Returns how many slots were released.
pub(crate) fn release_idle_slots(
&mut self,
device: &wgpu::Device,
reserved: &[usize],
urgent: bool,
) -> usize {
const IDLE_FRAMES: u64 = 64;
let idle_frames = if urgent { 0 } else { IDLE_FRAMES };
let mut released = 0;
for index in 0..self.inners.len() {
if reserved.contains(&index) {
continue;
}
let idle = self.slot_clock.saturating_sub(self.slot_last_used[index]);
if idle < idle_frames {
continue;
}
// Nothing to give back — releasing again would only churn the
// render targets it just rebuilt at their smallest size.
let held = self.inners[index].gpu_live_bytes();
if held.wire_arena == 0
&& held.shadow <= 4
&& self.inners[index].alloc_size == Size::new(0, 0)
{
continue;
}
self.inners[index].release_heavy_resources(device);
released += 1;
}
released
}
/// Sum across slots, plus the caches held once for all of them.
pub(crate) fn gpu_live_bytes(&self) -> GpuLiveBytes {
let mut total = GpuLiveBytes {
block_geometry: wire_gpu::block_geometry_bytes(&self.block_geometry),
..Default::default()
};
for inner in &self.inners {
let slot = inner.gpu_live_bytes();
total.slots += slot.slots;
total.shadow += slot.shadow;
total.render_targets += slot.render_targets;
total.text_atlas += slot.text_atlas;
total.wire_arena += slot.wire_arena;
}
total
}
}
/// Bind one block-wire batch and draw it.
///
/// The two pipeline modes lay the vertex slots out differently — packed puts
@@ -4747,6 +5009,26 @@ fn bind_and_draw_block_wire(pass: &mut wgpu::RenderPass<'_>, wire: &wire_gpu::Bl
pass.draw(0..wire.vertex_count, 0..wire.instance_count);
}
/// A square `Depth32Float` render target for the shadow pass. `side` is 1 for
/// the placeholder that keeps the frame bind group satisfied while shadows are
/// off, and `SHADOW_MAP_SIZE` for the real thing.
fn create_shadow_texture(device: &wgpu::Device, side: u32) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("shadow.texture"),
size: wgpu::Extent3d {
width: side,
height: side,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
}
fn create_depth_texture(device: &wgpu::Device, size: Size<u32>) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer.depth_texture"),
@@ -4830,6 +5112,17 @@ pub struct MultiPipeline {
/// once between them instead of once per slot. Kept trim by dropping entries
/// no slot still references (`Arc::strong_count == 1`) once it grows past a
/// small bound.
/// Block geometry that survives an edit, keyed by the definition it
/// expands rather than by anything a viewport contributes. See
/// `wire_gpu::BlockGeometryCache`.
///
/// Shared across slots for the same reason `wire_buffer_cache` is: a
/// definition's segments are identical in every viewport that draws it, and
/// this is the largest upload the renderer makes — 59.5 MiB on the
/// reproducer. Held per slot, zooming a paper layout out until four or five
/// viewports were on screen uploaded it once each and ran a 2 GB card out
/// of memory.
pub(crate) block_geometry: wire_gpu::BlockGeometryCache,
pub(crate) wire_buffer_cache: rustc_hash::FxHashMap<
u64,
(
@@ -4985,6 +5278,7 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
install_gpu_error_handler(device);
Self {
block_geometry: Default::default(),
inners: vec![Pipeline::new(device, queue, format)],
format,
slot_by_instance: rustc_hash::FxHashMap::default(),
+5
View File
@@ -221,6 +221,11 @@ pub struct TextAtlasGpu {
}
impl TextAtlasGpu {
/// Device bytes this atlas holds. `R8`, so one byte per texel.
pub fn gpu_bytes(&self) -> u64 {
u64::from(self._texture.width()) * u64::from(self._texture.height())
}
/// Bind-group layout for group 1: `{ R8 atlas texture, linear sampler }`.
pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+12
View File
@@ -1369,6 +1369,18 @@ impl PersistentWireArena {
}
}
/// Device bytes this arena holds. The instance buffer is reserved with
/// headroom, so this is the allocation, not the occupied prefix — which is
/// what a memory budget has to answer for.
pub fn gpu_bytes(&self) -> u64 {
match &self.inner {
PersistentWireArenaKind::Indexed(arena) => {
arena.inst_buf.size() + arena.const_buf.size()
}
PersistentWireArenaKind::Packed(arena) => arena.inst_buf.size(),
}
}
pub fn wire_gpus_visible(
&self,
view_rot: glam::Mat4,
+14
View File
@@ -703,6 +703,15 @@ pub struct BlockWireTarget<'a> {
pub mode: WirePipelineMode,
}
/// Device bytes held by a whole block-geometry cache.
pub fn block_geometry_bytes(cache: &BlockGeometryCache) -> u64 {
cache
.values()
.flat_map(|chunks| chunks.iter())
.map(|chunk| chunk.gpu_bytes)
.sum()
}
/// `(source_id, mesh_edge, colour bits, base translation bits)`.
pub type BlockGeometryKey = (u64, bool, [u32; 4], [u64; 3]);
@@ -722,6 +731,9 @@ pub struct BlockGeometryChunk {
pub vertex_buffer: Option<wgpu::Buffer>,
pub vertex_count: u32,
pub bind_group: std::sync::Arc<wgpu::BindGroup>,
/// Device bytes this chunk holds. Recorded at build because in storage mode
/// the buffer is owned by `bind_group` and cannot be measured from here.
pub gpu_bytes: u64,
}
impl BlockWireGpu {
@@ -856,6 +868,7 @@ impl BlockWireGpu {
BlockGeometryChunk {
vertex_buffer: None,
vertex_count: chunk.len() as u32 * 6,
gpu_bytes: std::mem::size_of_val(chunk) as u64,
bind_group: std::sync::Arc::new(device.create_bind_group(
&wgpu::BindGroupDescriptor {
label: Some("block_wire.const.bg"),
@@ -897,6 +910,7 @@ impl BlockWireGpu {
vertices
.chunks(max_verts)
.map(|chunk| BlockGeometryChunk {
gpu_bytes: std::mem::size_of_val(chunk) as u64,
vertex_buffer: Some(super::gpu_upload::upload_buffer(
device,
queue,
+79 -22
View File
@@ -367,9 +367,18 @@ impl shader::Primitive for Primitive {
let scale = viewport.scale_factor() as f32;
let instance_ids: Vec<u64> = self.viewports.iter().map(|vp| vp.instance_id).collect();
let slots = pipeline.resolve_slots(device, queue, &instance_ids);
// Compared after the frame's uploads: a move means the device refused
// something, which changes how patient the slot release below can be.
let errors_at_entry = crate::scene::pipeline::gpu_errors_seen();
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[slots[i]];
// Compared at the end of this viewport's turn. Every "we hold this
// now" latch below is stamped after its upload, whether or not the
// device accepted it — and the wire latch gates the whole upload
// block, so one rejected allocation could leave a slotpermanently empty with
// nothing that would ever rebuild it.
let slot_errors_before = crate::scene::pipeline::gpu_errors_seen();
// Pipeline slots are addressed by list index, but off-canvas
// viewports are dropped from the list — so a slot can be reused by a
// DIFFERENT viewport across frames (e.g. the first viewport scrolls
@@ -381,26 +390,7 @@ impl shader::Primitive for Primitive {
// the surviving viewport's text/geometry vanish.
if inner.slot_id != vp.instance_id {
inner.slot_id = vp.instance_id;
inner.cached_epoch = (u64::MAX, u64::MAX, u64::MAX);
inner.cached_wire_id = u64::MAX;
inner.cached_selection = (u64::MAX, u64::MAX);
inner.cached_mesh_content_id = u64::MAX;
inner.cached_face3d_key = (u64::MAX, false, false, u64::MAX);
inner.cached_solid_visibility =
(u64::MAX, [u32::MAX; 3], u64::MAX);
inner.cached_hatch_source = None;
inner.cached_preview_hatch_source = None;
inner.cached_wipeout_source = None;
inner.cached_image_source = None;
inner.cached_text_source = None;
inner.cached_mesh_source = None;
inner.cached_face3d_source = None;
inner.cached_face3d_depth_source = None;
inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0);
inner.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
inner.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
inner.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false);
inner.render_sig = u64::MAX;
inner.forget_cached_keys();
}
// The MSAA / depth / resolve textures are always sized to the
// FULL viewport rectangle (not the on-canvas-visible portion)
@@ -434,7 +424,7 @@ impl shader::Primitive for Primitive {
vp.background_image.as_ref(),
vp.environment_image.as_ref(),
);
inner.upload_uniforms(queue, &vp.uniforms);
inner.upload_uniforms(device, queue, &vp.uniforms);
// ── Scene-render cache ────────────────────────────────────────
// A pure cursor move — or any frame where the view, geometry,
@@ -860,6 +850,7 @@ impl shader::Primitive for Primitive {
queue,
&instanced,
&draw_depths,
&mut pipeline.block_geometry,
),
);
if _patched {
@@ -936,7 +927,13 @@ impl shader::Primitive for Primitive {
let errors_before =
crate::scene::pipeline::gpu_errors_seen();
let entry =
inner.build_wire_buffers(device, queue, &vp_wires[..], &draw_depths);
inner.build_wire_buffers(
device,
queue,
&vp_wires[..],
&draw_depths,
&mut pipeline.block_geometry,
);
if let Some(start) = t_upload {
crate::perf_record!(
"[perf] wire-upload {:.1}ms wires={} content_id={}",
@@ -1189,6 +1186,15 @@ impl shader::Primitive for Primitive {
self.viewcube_text_color,
);
}
// A rejected allocation leaves buffers that are invalid but
// indistinguishable from good ones at every cache key. Forget
// them all, so the next frame rebuilds this slot instead of
// drawing nothing for the rest of the session — which is what
// sent a user to REGENALL to get the model back.
if crate::scene::pipeline::gpu_errors_seen() != slot_errors_before {
inner.forget_cached_keys();
}
}
let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0;
if let Some(sample) = self.nav_perf {
@@ -1210,6 +1216,21 @@ impl shader::Primitive for Primitive {
self.viewports.len(),
);
}
// Give back what nobody is drawing. Gradual while the device is
// healthy; immediate once it has refused an allocation, because the
// memory a hidden viewport is holding is exactly what the visible one
// could not get — and freeing it is what lets the operator close a
// viewport or two and carry on instead of restarting.
let errors_now = crate::scene::pipeline::gpu_errors_seen();
let urgent = errors_now != errors_at_entry;
let released = pipeline.release_idle_slots(device, &slots, urgent);
if released > 0 && crate::perf::enabled() {
crate::perf_record!(
"[perf] slots-released n={released} urgent={urgent} slots={}",
pipeline.inners.len(),
);
}
report_gpu_live(pipeline);
}
fn render(
@@ -1561,6 +1582,42 @@ fn solar_direction(
// ── Render-style helpers (impl Scene) ────────────────────────────────────
/// Report what the renderer is holding on the device, when it changes.
///
/// Only on change: `prepare` runs thousands of times a session and these
/// numbers move a handful of times. RSS is deliberately not used — the
/// allocator and the driver both retain high-water memory that RSS cannot tell
/// apart from live resources.
fn report_gpu_live(pipeline: &crate::scene::pipeline::MultiPipeline) {
use std::cell::Cell;
if !crate::perf::enabled() {
return;
}
thread_local! {
static LAST: Cell<Option<crate::scene::pipeline::GpuLiveBytes>> = const { Cell::new(None) };
}
let now = pipeline.gpu_live_bytes();
LAST.with(|last| {
if last.get() == Some(now) {
return;
}
last.set(Some(now));
const MIB: f64 = 1024.0 * 1024.0;
let mib = |bytes: u64| bytes as f64 / MIB;
crate::perf_record!(
"[perf] gpu-live slots={} total={:.1}MiB shadow={:.1} targets={:.1} \
text_atlas={:.1} wire_arena={:.1} block_geometry={:.1}",
now.slots,
mib(now.total()),
mib(now.shadow),
mib(now.render_targets),
mib(now.text_atlas),
mib(now.wire_arena),
mib(now.block_geometry),
);
});
}
impl Scene {
fn model_tile_vport(&self, index: usize) -> Option<&acadrust::tables::VPort> {
let rect = self.model_tiles.borrow().get(index)?.rect;