fix(viewport): blit UV crop for off-canvas viewports

Refactor the per-viewport blit so a content viewport whose screen rect
extends off the canvas composites the correct slice of its MSAA target
to the visible portion of the surface, instead of being slammed against
the canvas corner by an `(f32 as u32)` saturation on the negative
offset.

- blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` /
  `uv_scale`). `Primitive::prepare` computes the per-viewport crop from
  `screen_rect`'s on-canvas fraction and writes it via the new
  `upload_blit_uv` helper while it still has the `Queue`.
- `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size)
  and `surface_dest` (visible intersection — blit destination) and
  reads the crop from the uniform; passes that don't have a non-empty
  surface intersection are skipped.
- `Primitive::render` clips the full vp rect against the surface clip
  in `i32` and skips empty intersections. The earlier "skip GPU for
  inactive off-canvas viewports" workaround in `active_viewports` is
  no longer needed.
- ViewCube draws only when its hosting vp is fully on-canvas (its own
  pipeline writes directly to the surface and would distort under a
  clamped `set_viewport`).

With the GPU path correct for every vp position, drop
`viewport_content_wires` from `paper_canvas_wires` — the inactive
viewport CPU projection was only there to mask the blit bug and was
duplicating the unified shader's output. The hit-test / snap path
(`entity_wires_arc`, `wires_for_block`) still calls
`viewport_content_wires` directly, so picking through inactive
viewports keeps working.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-28 12:15:00 +03:00
1 parent 600717c1ec
commit 3d24dd5168
4 files changed
+160 -47

No files matched your search

+6 -21
View File
@@ -4644,30 +4644,12 @@ impl Scene {
) else {
continue;
};
let is_active = self.active_viewport == Some(h);
// Skip the GPU pass for inactive viewports whose screen rect
// extends beyond the canvas — the per-viewport pipeline blits
// the full MSAA at a u32-saturated (≥0) surface offset, so a
// negative offset would paint the content at the canvas
// corner instead of where the viewport actually sits. The CPU
// projection in PaperCanvas already handles those correctly
// clipped to the canvas, so the user simply sees that copy.
// The active viewport always renders through the GPU so its
// own render mode (Hidden Line, Gouraud, …) still applies.
if !is_active
&& (screen_rect.x < 0.0
|| screen_rect.y < 0.0
|| screen_rect.x + screen_rect.width > canvas_w
|| screen_rect.y + screen_rect.height > canvas_h)
{
continue;
}
out.push(ViewportInstance {
handle: h,
screen_rect,
camera,
render_mode: vp.render_mode,
active: is_active,
active: self.active_viewport == Some(h),
});
}
out
@@ -4745,9 +4727,12 @@ impl Scene {
}
}
}
let layout_block = self.current_layout_block_handle();
// The unified GPU shader draws every content viewport (active and
// inactive) directly through its own camera + scissor, so the
// paper canvas no longer needs to CPU-project model content onto
// the sheet. `paper_sheet_wires()` keeps the title-block /
// annotation / viewport-border 2-D pass that GPU does not handle.
let mut wires = self.paper_sheet_wires();
wires.extend(self.viewport_content_wires(layout_block, None, self.active_viewport));
if let Some(iw) = &self.interim_wire {
wires.push(iw.clone());
}
+67 -10
View File
@@ -8,6 +8,7 @@ pub mod viewcube;
pub mod wire_gpu;
use iced::wgpu;
use iced::wgpu::util::DeviceExt;
use iced::{Rectangle, Size};
pub use face3d_gpu::Face3DGpu;
@@ -69,6 +70,10 @@ pub struct Pipeline {
blit_bind_group_layout: wgpu::BindGroupLayout,
blit_sampler: wgpu::Sampler,
blit_bind_group: wgpu::BindGroup,
/// UV transform (offset + scale) consumed by the blit shader so a
/// partially off-canvas viewport still composites the right portion of
/// its resolve texture to the visible portion of the surface.
blit_uniform_buffer: wgpu::Buffer,
/// Cached texture format (needed to recreate MSAA / depth textures on resize).
surface_format: wgpu::TextureFormat,
gpu_wires: Vec<WireGpu>,
@@ -743,9 +748,28 @@ impl Pipeline {
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
// UV crop uniform: [uv_offset_x, uv_offset_y, uv_scale_x, uv_scale_y]
// padded to 16 bytes (std140 vec2 alignment). Defaulted to the
// identity crop (offset 0, scale 1) for the common on-canvas case.
let blit_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("blit.uniform_buffer"),
contents: bytemuck::cast_slice(&[0.0f32, 0.0, 1.0, 1.0]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let blit_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("blit.pipeline_layout"),
bind_group_layouts: &[&blit_bgl],
@@ -810,6 +834,10 @@ impl Pipeline {
binding: 1,
resource: wgpu::BindingResource::Sampler(&blit_sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: blit_uniform_buffer.as_entire_binding(),
},
],
});
@@ -837,6 +865,7 @@ impl Pipeline {
blit_bind_group_layout: blit_bgl,
blit_sampler,
blit_bind_group,
blit_uniform_buffer,
surface_format: format,
gpu_wires: vec![],
wire_pixel_scissors: vec![],
@@ -1030,17 +1059,39 @@ impl Pipeline {
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
}
/// 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]) {
queue.write_buffer(
&self.blit_uniform_buffer,
0,
bytemuck::cast_slice(&[uv_offset[0], uv_offset[1], uv_scale[0], uv_scale[1]]),
);
}
/// Render the geometry passes at `vp_size` (the full viewport size — the
/// MSAA / resolve textures are this size) and blit the resulting resolve
/// to `surface_dest` on the swap-chain. The UV crop is read from the
/// blit uniform buffer (written by `upload_blit_uv` during `prepare`)
/// so a viewport that hangs off the canvas still composites the correct
/// sub-rectangle to the visible portion of the surface.
pub fn render(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
clip_bounds: Rectangle<u32>,
vp_size: Size<u32>,
surface_dest: Rectangle<u32>,
bg_color: [f32; 4],
mesh_wireframe: bool,
hidden_line: bool,
show_3d_edges: bool,
) {
let vp = clip_bounds;
let vp = Rectangle::<u32> {
x: 0,
y: 0,
width: vp_size.width,
height: vp_size.height,
};
let msaa = &self.msaa_view;
let [r, g, b, a] = bg_color;
let clear_color = wgpu::Color {
@@ -1543,10 +1594,12 @@ impl Pipeline {
// No draw calls — the pass itself triggers the MSAA resolve.
}
// ── Blit resolve texture → surface target at clip_bounds position ──
// The viewport maps the full-screen NDC quad to exactly clip_bounds
// in the surface, leaving all other widgets untouched.
{
// ── Blit resolve texture → surface target at surface_dest position ──
// The viewport maps the NDC quad to exactly `surface_dest` in the
// swap-chain; `uv_offset` + `uv_scale` (passed through the blit
// uniform) crop the resolve so we sample only the visible portion
// of the full viewport's MSAA texture.
if surface_dest.width > 0 && surface_dest.height > 0 {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("blit.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
@@ -1563,10 +1616,10 @@ impl Pipeline {
occlusion_query_set: None,
});
pass.set_viewport(
vp.x as f32,
vp.y as f32,
vp.width as f32,
vp.height as f32,
surface_dest.x as f32,
surface_dest.y as f32,
surface_dest.width as f32,
surface_dest.height as f32,
0.0,
1.0,
);
@@ -1596,6 +1649,10 @@ impl Pipeline {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.blit_sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.blit_uniform_buffer.as_entire_binding(),
},
],
});
self.resolve_view = resolve_view;
+73 -12
View File
@@ -178,13 +178,28 @@ impl shader::Primitive for Primitive {
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[i];
// Physical MSAA/depth size for this viewport's sub-rect.
// The MSAA / depth / resolve textures are always sized to the
// FULL viewport rectangle (not the on-canvas-visible portion)
// so the camera matrices render at consistent aspect / scale.
// The blit step picks the visible sub-rectangle out via the
// shader's UV crop uniform, which lets partially off-canvas
// viewports composite to their visible surface area without
// drift.
let clip_size = Size::new(
(vp.screen_rect.width * bounds.width * scale).ceil().max(1.0) as u32,
(vp.screen_rect.height * bounds.height * scale).ceil().max(1.0) as u32,
);
inner.ensure_depth_texture(device, clip_size);
inner.viewcube.ensure_depth_texture(device, full_size);
// Compute the UV crop for this viewport. `screen_rect` is in
// normalized canvas units (0..1) but may extend negative or
// beyond 1 when the viewport hangs off the canvas. The on-
// canvas portion in viewport-local UV is straightforward to
// derive from how much sticks out on each side.
let sr = vp.screen_rect;
let (uo_x, us_x) = uv_crop_axis(sr.x, sr.width);
let (uo_y, us_y) = uv_crop_axis(sr.y, sr.height);
inner.upload_blit_uv(queue, [uo_x, uo_y], [us_x, us_y]);
inner.upload_uniforms(queue, &vp.uniforms);
let cur_key = (vp.geometry_epoch, vp.camera_generation);
let fill_mode = vp.fill_mode;
@@ -244,20 +259,35 @@ impl shader::Primitive for Primitive {
target: &iced::wgpu::TextureView,
clip: &Rectangle<u32>,
) {
let cw = clip.width as f32;
let ch = clip.height as f32;
let clip_right = clip.x + clip.width;
let clip_bottom = clip.y + clip.height;
for (i, vp) in self.viewports.iter().enumerate() {
let Some(inner) = pipeline.inners.get(i) else {
break;
};
// Derive this viewport's physical sub-clip from the normalized
// screen_rect and the surface clip.
let cw = clip.width as f32;
let ch = clip.height as f32;
let vp_clip = Rectangle {
x: clip.x + (vp.screen_rect.x * cw) as u32,
y: clip.y + (vp.screen_rect.y * ch) as u32,
width: (vp.screen_rect.width * cw).max(1.0) as u32,
height: (vp.screen_rect.height * ch).max(1.0) as u32,
// Where the viewport would land on the surface in absolute
// pixels (i32 because either edge may stick off the canvas).
let vp_full_x = clip.x as i32 + (vp.screen_rect.x * cw) as i32;
let vp_full_y = clip.y as i32 + (vp.screen_rect.y * ch) as i32;
let vp_full_w = (vp.screen_rect.width * cw).max(1.0) as i32;
let vp_full_h = (vp.screen_rect.height * ch).max(1.0) as i32;
// Intersect with the surface clip — that's the slice we blit.
let dest_x = vp_full_x.max(clip.x as i32);
let dest_y = vp_full_y.max(clip.y as i32);
let dest_right = (vp_full_x + vp_full_w).min(clip_right as i32);
let dest_bottom = (vp_full_y + vp_full_h).min(clip_bottom as i32);
if dest_right <= dest_x || dest_bottom <= dest_y {
continue;
}
let surface_dest = Rectangle {
x: dest_x as u32,
y: dest_y as u32,
width: (dest_right - dest_x) as u32,
height: (dest_bottom - dest_y) as u32,
};
let vp_size = Size::new(vp_full_w.max(1) as u32, vp_full_h.max(1) as u32);
// `mesh_fill` is false for Wireframe 2D / Wireframe 3D — flip
// the draw path so meshes use the wireframe pipeline + the
// pre-built triangle-edge index buffer.
@@ -265,19 +295,50 @@ impl shader::Primitive for Primitive {
inner.render(
encoder,
target,
vp_clip,
vp_size,
surface_dest,
self.bg_color,
mesh_wireframe,
vp.hidden_line,
vp.show_3d_edges,
);
if vp.show_viewcube {
// The ViewCube renders directly to the surface at the full
// viewport rect. Skip it when the viewport's top-right corner
// (where the cube sits) is off-canvas — wgpu's `set_viewport`
// rejects negative origins, and a clamped cube would scale
// distortedly. The active viewport is normally fully visible.
if vp.show_viewcube
&& vp_full_x >= clip.x as i32
&& vp_full_y >= clip.y as i32
&& vp_full_x + vp_full_w <= clip_right as i32
&& vp_full_y + vp_full_h <= clip_bottom as i32
{
let vp_clip = Rectangle {
x: vp_full_x as u32,
y: vp_full_y as u32,
width: vp_full_w as u32,
height: vp_full_h as u32,
};
inner.viewcube.render(encoder, target, vp_clip);
}
}
}
}
/// On-canvas-visible UV crop on one axis. `pos` and `size` are in the
/// shader widget's normalized 0..1 coords. Returns `(uv_offset, uv_scale)`
/// applied as `actual_uv = quad_uv * uv_scale + uv_offset` in the blit
/// shader — identity `(0.0, 1.0)` for fully on-canvas viewports.
fn uv_crop_axis(pos: f32, size: f32) -> (f32, f32) {
if size <= 0.0 {
return (0.0, 1.0);
}
let left_off = (-pos).max(0.0);
let right_off = (pos + size - 1.0).max(0.0);
let visible = (size - left_off - right_off).max(0.0);
(left_off / size, visible / size)
}
// ── Render-style helpers (impl Scene) ────────────────────────────────────
impl Scene {
+14 -4
View File
@@ -1,13 +1,21 @@
// blit.wgsl
// Copies the MSAA-resolved texture to the surface target.
// A full-screen NDC quad is drawn; the render pass viewport positions it
// exactly over the shader widget's clip bounds in the surface.
// Copies the MSAA-resolved texture to the surface target. A full-screen
// NDC quad is drawn; the render pass viewport places it on the visible
// sub-rectangle of the surface, and `u_blit.uv_scale / uv_offset` crops
// the resolve texture to the corresponding portion of the full viewport
// (so a viewport that extends off the canvas still composites correctly
// from the on-surface side).
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
struct BlitUniforms {
uv_offset: vec2<f32>,
uv_scale: vec2<f32>,
}
var<private> POSITIONS: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, -1.0),
@@ -37,8 +45,10 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput {
@group(0) @binding(0) var t_resolved: texture_2d<f32>;
@group(0) @binding(1) var s_resolved: sampler;
@group(0) @binding(2) var<uniform> u_blit: BlitUniforms;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(t_resolved, s_resolved, in.uv);
let uv = in.uv * u_blit.uv_scale + u_blit.uv_offset;
return textureSample(t_resolved, s_resolved, uv);
}