Bilinear-sample upscaled raster proxies (smooth, not blocky)

The canvas blit used a nearest sampler, so the upscaled low-res proxy looked
blocky. Added a Linear sampler + `CanvasBlitPipeline::blit_smooth`; the raster
render uses it only for the proxy path (full-res canvas stays nearest/crisp). The
bind-group layout already declares the canvas texture filterable, so no layout
change was needed.
This commit is contained in:
Skyler Lehmkuhl 2026-06-20 21:14:37 -04:00
parent 3c302f0215
commit a35cc6fa9f
2 changed files with 57 additions and 8 deletions

View File

@ -1970,6 +1970,9 @@ pub struct CanvasBlitPipeline {
pub pipeline: wgpu::RenderPipeline, pub pipeline: wgpu::RenderPipeline,
pub bg_layout: wgpu::BindGroupLayout, pub bg_layout: wgpu::BindGroupLayout,
pub sampler: wgpu::Sampler, pub sampler: wgpu::Sampler,
/// Bilinear sampler for smooth upscaling (used by `blit_smooth`, e.g. low-res
/// proxies). The default `sampler` stays nearest to keep the real canvas crisp.
pub linear_sampler: wgpu::Sampler,
/// Nearest-neighbour sampler used for the selection mask texture. /// Nearest-neighbour sampler used for the selection mask texture.
pub mask_sampler: wgpu::Sampler, pub mask_sampler: wgpu::Sampler,
} }
@ -2145,6 +2148,17 @@ impl CanvasBlitPipeline {
..Default::default() ..Default::default()
}); });
let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("canvas_blit_linear_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let mask_sampler = device.create_sampler(&wgpu::SamplerDescriptor { let mask_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("canvas_mask_sampler"), label: Some("canvas_mask_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_u: wgpu::AddressMode::ClampToEdge,
@ -2156,7 +2170,7 @@ impl CanvasBlitPipeline {
..Default::default() ..Default::default()
}); });
Self { pipeline, bg_layout, sampler, mask_sampler } Self { pipeline, bg_layout, sampler, linear_sampler, mask_sampler }
} }
/// Render the canvas texture into `target_view` (Rgba16Float) with the given camera. /// Render the canvas texture into `target_view` (Rgba16Float) with the given camera.
@ -2164,6 +2178,7 @@ impl CanvasBlitPipeline {
/// `target_view` is cleared to transparent before writing. /// `target_view` is cleared to transparent before writing.
/// `mask_view` is an R8Unorm texture in canvas-pixel space: 255 = keep, 0 = discard. /// `mask_view` is an R8Unorm texture in canvas-pixel space: 255 = keep, 0 = discard.
/// Pass `None` to use the built-in 1×1 all-white default (no masking). /// Pass `None` to use the built-in 1×1 all-white default (no masking).
/// Blit with the default nearest-neighbour sampler (crisp; for real canvas pixels).
pub fn blit( pub fn blit(
&self, &self,
device: &wgpu::Device, device: &wgpu::Device,
@ -2172,6 +2187,32 @@ impl CanvasBlitPipeline {
target_view: &wgpu::TextureView, target_view: &wgpu::TextureView,
transform: &BlitTransform, transform: &BlitTransform,
mask_view: Option<&wgpu::TextureView>, mask_view: Option<&wgpu::TextureView>,
) {
self.blit_with(device, queue, canvas_view, target_view, transform, mask_view, &self.sampler);
}
/// Blit with a bilinear sampler — smooth upscaling for low-res sources (proxies).
pub fn blit_smooth(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
canvas_view: &wgpu::TextureView,
target_view: &wgpu::TextureView,
transform: &BlitTransform,
mask_view: Option<&wgpu::TextureView>,
) {
self.blit_with(device, queue, canvas_view, target_view, transform, mask_view, &self.linear_sampler);
}
fn blit_with(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
canvas_view: &wgpu::TextureView,
target_view: &wgpu::TextureView,
transform: &BlitTransform,
mask_view: Option<&wgpu::TextureView>,
canvas_sampler: &wgpu::Sampler,
) { ) {
// When no mask is provided, create a temporary 1×1 all-white texture. // When no mask is provided, create a temporary 1×1 all-white texture.
// (queue is already available here, unlike in new()) // (queue is already available here, unlike in new())
@ -2224,7 +2265,7 @@ impl CanvasBlitPipeline {
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 1, binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler), resource: wgpu::BindingResource::Sampler(canvas_sampler),
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 2, binding: 2,

View File

@ -1313,21 +1313,29 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
let blit = if let Some(use_kf_id) = full_kf { let blit = if let Some(use_kf_id) = full_kf {
gpu_brush.canvases.get(&use_kf_id) gpu_brush.canvases.get(&use_kf_id)
.or_else(|| gpu_brush.raster_layer_cache.get(&use_kf_id)) .or_else(|| gpu_brush.raster_layer_cache.get(&use_kf_id))
.map(|c| (c, c.width, c.height)) .map(|c| (c, c.width, c.height, false))
} else if let Some((pkf, lw, lh)) = raster_proxy_blit { } else if let Some((pkf, lw, lh)) = raster_proxy_blit {
gpu_brush.get_proxy_texture(&pkf).map(|c| (c, lw, lh)) gpu_brush.get_proxy_texture(&pkf).map(|c| (c, lw, lh, true))
} else { } else {
None None
}; };
if let Some((canvas, logical_w, logical_h)) = blit { if let Some((canvas, logical_w, logical_h, is_proxy)) = blit {
let bt = crate::gpu_brush::BlitTransform::new( let bt = crate::gpu_brush::BlitTransform::new(
*layer_transform, *layer_transform,
logical_w, logical_h, logical_w, logical_h,
width, height, width, height,
); );
shared.canvas_blit.blit( // Proxies are upscaled, so sample them bilinearly;
device, queue, canvas.src_view(), hdr_layer_view, &bt, None, // the real canvas stays nearest (crisp pixels).
); if is_proxy {
shared.canvas_blit.blit_smooth(
device, queue, canvas.src_view(), hdr_layer_view, &bt, None,
);
} else {
shared.canvas_blit.blit(
device, queue, canvas.src_view(), hdr_layer_view, &bt, None,
);
}
} }
} }
let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new( let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new(