diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f4665a602..2b354730b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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()); } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 26407371f..4d8cb3251 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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, @@ -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, + vp_size: Size, + surface_dest: Rectangle, bg_color: [f32; 4], mesh_wireframe: bool, hidden_line: bool, show_3d_edges: bool, ) { - let vp = clip_bounds; + let vp = Rectangle:: { + 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; diff --git a/src/scene/render.rs b/src/scene/render.rs index 4d945c29c..261daf8c5 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -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, ) { + 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 { diff --git a/src/shaders/blit.wgsl b/src/shaders/blit.wgsl index de405dc4d..50f5c6409 100644 --- a/src/shaders/blit.wgsl +++ b/src/shaders/blit.wgsl @@ -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, @location(0) uv: vec2, } +struct BlitUniforms { + uv_offset: vec2, + uv_scale: vec2, +} + var POSITIONS: array, 6> = array, 6>( vec2(-1.0, 1.0), vec2(-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; @group(0) @binding(1) var s_resolved: sampler; +@group(0) @binding(2) var u_blit: BlitUniforms; @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - 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); }