From 2b5db8501b20f49a9d16bb4368df90b08c7e7a04 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 28 May 2026 18:12:10 +0300 Subject: [PATCH] fix(viewport): clip MSAA to canvas, crop view_proj instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous off-canvas fix sized each per-viewport MSAA / depth / resolve texture to the full viewport rect, then UV-cropped the blit. With deep paper-space zoom that rect can grow past wgpu's 8192-pixel texture limit and the program panics in `Device::create_texture` ("Dimension Y value 9493 exceeds the limit of 8192"). `viewport_data_for` now clips the screen rect to the canvas before returning the `ViewportData`, so the per-viewport textures are bounded by the surface (≤ canvas size, always within the GPU limit). The camera frustum is still built at the *full* vp aspect (so the world it projects matches the viewport entity), and a new `crop_view_proj` post-multiplies the standard view_proj by a clip-space transform that remaps the visible sub-rect to NDC `[-1, 1]^2` — the off-center ortho that lets the visible-sized MSAA carry the correct portion of the view. `viewport_size` is set to the visible size for the wire shader's screen-space line extrusion; `world_per_pixel` is crop-invariant (the `vs` factor cancels) so the value `Uniforms::new` computes from the full bounds remains correct. Viewports whose screen rect doesn't intersect the canvas at all return `None` and are filtered out before any inner pipeline is allocated. Co-Authored-By: Claude Opus 4.7 --- src/scene/render.rs | 91 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/src/scene/render.rs b/src/scene/render.rs index 261daf8c5..cd175ce5b 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -339,6 +339,32 @@ fn uv_crop_axis(pos: f32, size: f32) -> (f32, f32) { (left_off / size, visible / size) } +/// Apply a clip-space crop to `view_proj` so the sub-rect of the original +/// view defined by UV offset `(uo, vo)` + scale `(us, vs)` is remapped to +/// NDC `[-1, 1]^2`. Identity transform when the sub-rect is the whole +/// view (`uo=vo=0`, `us=vs=1`). Used by viewports that hang off the +/// canvas — the camera frustum stays at full-vp aspect, but only the +/// visible portion lands in the MSAA target. +fn crop_view_proj(view_proj: glam::Mat4, uo: f32, vo: f32, us: f32, vs: f32) -> glam::Mat4 { + // Build the matrix that maps the visible clip-space sub-rect + // x ∈ [2uo - 1, 2(uo+us) - 1] + // y ∈ [1 - 2(vo+vs), 1 - 2vo] + // back to NDC [-1, 1]^2. (Texture v is top-down → camera y flips.) + let us = us.max(1e-6); + let vs = vs.max(1e-6); + let sx = 1.0 / us; + let sy = 1.0 / vs; + let tx = (1.0 - 2.0 * uo - us) / us; + let ty = -(1.0 - 2.0 * vo - vs) / vs; + let crop = glam::Mat4::from_cols_array(&[ + sx, 0.0, 0.0, 0.0, // col 0 + 0.0, sy, 0.0, 0.0, // col 1 + 0.0, 0.0, 1.0, 0.0, // col 2 + tx, ty, 0.0, 1.0, // col 3 + ]); + crop * view_proj +} + // ── Render-style helpers (impl Scene) ──────────────────────────────────── impl Scene { @@ -510,7 +536,7 @@ impl Scene { let bg_color = [0.0, 0.0, 0.0, 0.0]; let viewports: Vec = instances .iter() - .map(|inst| self.viewport_data_for(inst, canvas, hover_region)) + .filter_map(|inst| self.viewport_data_for(inst, canvas, hover_region)) .collect(); // Empty viewports → blit nothing. The widget container's // background (model bg or the PaperCanvas widget below) stays @@ -527,10 +553,33 @@ impl Scene { inst: &ViewportInstance, canvas: (f32, f32), hover_region: Option, - ) -> ViewportData { + ) -> Option { let flags = render_mode_flags(inst.render_mode); let view_wireframe = !flags.face3d_fill; + // Clip the viewport rect to the canvas; size the per-viewport MSAA + // / depth / resolve textures to that visible portion. Sizing them + // to the full vp rect would blow past wgpu's per-dimension texture + // limit (8192 on common GPUs) once paper-space zoom grows the rect + // far enough off the canvas. + let full = inst.screen_rect; + if full.width <= 0.0 || full.height <= 0.0 { + return None; + } + let visible_x = full.x.max(0.0); + let visible_y = full.y.max(0.0); + let visible_x_end = (full.x + full.width).min(canvas.0); + let visible_y_end = (full.y + full.height).min(canvas.1); + let visible_w = (visible_x_end - visible_x).max(0.0); + let visible_h = (visible_y_end - visible_y).max(0.0); + if visible_w < 1.0 || visible_h < 1.0 { + return None; + } + let uo = ((visible_x - full.x) / full.width).clamp(0.0, 1.0); + let vo = ((visible_y - full.y) / full.height).clamp(0.0, 1.0); + let us = (visible_w / full.width).clamp(0.0, 1.0); + let vs = (visible_h / full.height).clamp(0.0, 1.0); + let base_arc = if inst.handle == acadrust::Handle::NULL { self.entity_wires_arc() } else { @@ -548,25 +597,41 @@ impl Scene { Arc::new(v) }; - // Camera uniforms use the viewport's pixel size for aspect / ortho. - let bounds_px = Rectangle { + // Build the camera at the *full* viewport's aspect so the ortho + // frustum matches what the viewport entity stores, then post- + // multiply by a clip-space "zoom into the visible sub-rect" that + // maps the visible portion to NDC [-1, 1]. Geometry passes + // rasterize into a visible-sized MSAA, so `viewport_size` (used + // by the wire shader to extrude line thickness in screen pixels) + // must be the visible size — but `world_per_pixel` is invariant + // under cropping (full_h cancels with vs) so the value computed + // from the full bounds is the one we want. + let full_bounds = Rectangle { x: 0.0, y: 0.0, - width: inst.screen_rect.width.max(1.0), - height: inst.screen_rect.height.max(1.0), + width: full.width.max(1.0), + height: full.height.max(1.0), }; let mut uniforms = - Uniforms::new(&inst.camera, bounds_px, self.document.header.lineweight_display); + Uniforms::new(&inst.camera, full_bounds, self.document.header.lineweight_display); + uniforms.view_proj = crop_view_proj(uniforms.view_proj, uo, vo, us, vs); + uniforms.viewport_size = [visible_w, visible_h]; uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 }; + // `screen_rect` carries the *visible* sub-rectangle in normalized + // canvas coords — that's what `Pipeline::prepare` uses to size + // the per-viewport textures and what `Primitive::render` uses to + // pick the surface destination. The UV crop uniform reads as + // identity here, since the texture already covers exactly the + // visible portion. let screen_rect = Rectangle { - x: inst.screen_rect.x / canvas.0, - y: inst.screen_rect.y / canvas.1, - width: inst.screen_rect.width / canvas.0, - height: inst.screen_rect.height / canvas.1, + x: visible_x / canvas.0, + y: visible_y / canvas.1, + width: visible_w / canvas.0, + height: visible_h / canvas.1, }; - ViewportData { + Some(ViewportData { wires: all_wires, face3d_wires: Arc::new(face3d_wires), hatches: self.hatch_models_arc(), @@ -586,7 +651,7 @@ impl Scene { geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, screen_rect, - } + }) } /// Update viewcube hover state from cursor position within `bounds`.