From 54a29409e70da61c30af9299f9fb11326fd10887 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Jul 2026 18:20:27 +0300 Subject: [PATCH] fix(web): grow-only viewport textures to stop Windows-Firefox divider-drag freeze (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a model-tile pane_grid divider changed each pane's pixel size every frame, and `ensure_depth_texture` recreated the depth + 4×MSAA + resolve textures on any size delta. On Windows Firefox (WebGL2 → ANGLE → D3D11) this per-frame texture realloc hangs the D3D driver — the same hang class as the native DX12 lock in #120. Linux Firefox (native GL) and Chromium/Edge tolerate the churn, which is why the freeze is specific to Windows Firefox. Round the depth/MSAA/resolve allocation up to a 128px grid and only recreate when the rounded size changes, so a live drag reallocates at most once per 128px instead of every frame. Geometry passes still render at the real size into the [0,0,render] corner of the larger texture, and the blit UV is scaled by render/alloc so it samples exactly the filled region — visually a no-op on every platform. Co-Authored-By: Claude Opus 4.8 --- src/scene/pipeline/mod.rs | 50 ++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index a40b0a8c6..916189b2c 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -90,7 +90,16 @@ pub struct Pipeline { /// analogue of the selected-wire xray overlay). Rebuilt on selection change. text_highlight_vbuf: Option, text_highlight_vcount: u32, + /// Last requested render size (the full viewport rect, in pixels). The + /// geometry passes render at this size; the blit UV is scaled by + /// `depth_texture_size / alloc_size` so it samples only the filled region. depth_texture_size: Size, + /// Actual allocated size of the depth / MSAA / resolve textures. Rounded + /// up from the requested size to a coarse grid so a divider drag (which + /// changes the pane size a few pixels every frame) doesn't recreate these + /// textures every frame. Recreation is what hangs the ANGLE → D3D11 path + /// on Windows-Firefox (issue #191); grow-only bucketing makes it rare. + alloc_size: Size, depth_view: wgpu::TextureView, /// 4× MSAA color buffer for the main drawing passes. msaa_view: wgpu::TextureView, @@ -1167,6 +1176,9 @@ impl Pipeline { hatch_batched_bgl1, image_bgl1, depth_texture_size: Size::new(1, 1), + // (0, 0) forces the first `ensure_depth_texture` to allocate at the + // real rounded size — the constructor textures above are placeholders. + alloc_size: Size::new(0, 0), depth_view, msaa_view, resolve_view, @@ -1630,10 +1642,21 @@ impl Pipeline { /// Write the blit shader's UV crop uniform. Call in `prepare` (the only /// place with a `&Queue`) — `render` then just submits the draw call. pub fn upload_blit_uv(&self, queue: &wgpu::Queue, uv_offset: [f32; 2], uv_scale: [f32; 2]) { + // The geometry passes render at `depth_texture_size` into the top-left + // corner of the (possibly larger) `alloc_size` resolve texture, so the + // rendered image occupies UV [0, render/alloc]. Scale the incoming crop + // — computed in full-image-normalized units — down into that region. + let fx = self.depth_texture_size.width as f32 / self.alloc_size.width.max(1) as f32; + let fy = self.depth_texture_size.height as f32 / self.alloc_size.height.max(1) as f32; queue.write_buffer( &self.blit_uniform_buffer, 0, - bytemuck::cast_slice(&[uv_offset[0], uv_offset[1], uv_scale[0], uv_scale[1]]), + bytemuck::cast_slice(&[ + uv_offset[0] * fx, + uv_offset[1] * fy, + uv_scale[0] * fx, + uv_scale[1] * fy, + ]), ); } @@ -2255,9 +2278,11 @@ impl Pipeline { } } - // ── Resolve MSAA → clip-sized resolve texture ───────────────────── - // Both msaa_view and resolve_view are sized to clip_bounds, so the - // resolve does NOT touch any pixels outside the shader widget's area. + // ── Resolve MSAA → resolve texture ──────────────────────────────── + // Both are the same (rounded `alloc_size`) offscreen texture, so the + // resolve never touches the surface. Only the drawn [0, render_size] + // corner holds content; the rounded border stays the cleared bg color + // and is never sampled (the blit UV is scaled to render/alloc). { let _resolve = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("msaa.resolve_pass"), @@ -2314,7 +2339,12 @@ impl Pipeline { } pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size) { - if self.depth_texture_size != size { + // Record the requested render size every frame (the blit UV scale reads + // it); only reallocate the textures when the *rounded* size changes. + self.depth_texture_size = size; + let alloc = Size::new(round_up_tex(size.width), round_up_tex(size.height)); + if self.alloc_size != alloc { + let size = alloc; let depth_tex = create_depth_texture(device, size); self.depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default()); let msaa_tex = create_msaa_texture(device, size, self.surface_format); @@ -2340,11 +2370,19 @@ impl Pipeline { ], }); self.resolve_view = resolve_view; - self.depth_texture_size = size; + self.alloc_size = alloc; } } } +/// Round a texture dimension up to a coarse grid so a live divider drag +/// doesn't recreate the depth / MSAA / resolve textures on every frame. See +/// `alloc_size` — this is the mitigation for the Windows-Firefox freeze (#191). +fn round_up_tex(n: u32) -> u32 { + const GRID: u32 = 128; + ((n.max(1) + GRID - 1) / GRID) * GRID +} + /// Project a mesh's `world_aabb` and pick a LOD slot: /// diagonal > 200 px → 0 (HIGH) /// diagonal 50–200 px → 1 (MID)