diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index def4a18fc..72fe30e25 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -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" + ); +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 441704005..da2248a23 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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>, pub(crate) gpu_block_wires: std::sync::Arc>, - /// 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, + block_geometry: &mut wire_gpu::BlockGeometryCache, ) -> Vec { 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, + block_geometry: &mut wire_gpu::BlockGeometryCache, ) -> ( std::sync::Arc>, std::sync::Arc>, @@ -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) { // 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) -> 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(), diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index cb3e1e482..136e0945b 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -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 { diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 8a98b41a5..4fe85fab0 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -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, diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 8774ed251..ab1fd455b 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -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, pub vertex_count: u32, pub bind_group: std::sync::Arc, + /// 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, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 7d5d9eded..99ca3cf57 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -367,9 +367,18 @@ impl shader::Primitive for Primitive { let scale = viewport.scale_factor() as f32; let instance_ids: Vec = 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> = 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;