forked from 1b-agent/opencadstudio-upstream
Merge pull request #1017: Pr/viewport slot memory - Give back the device memory of viewports nobody is drawing
This commit is contained in:
commit
a03973bc6e
6 files changed
+533
-97
No files matched your search
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user