Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs

2373 lines
96 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! GPU-accelerated raster brush engine.
//!
//! [`GpuBrushEngine`] wraps the `brush_dab.wgsl` compute pipeline and manages
//! per-keyframe canvas texture pairs (ping-pong) used as the live canvas during
//! raster painting.
//!
//! ## Lifecycle
//!
//! 1. **Stroke start** — caller supplies the initial pixel data; the engine uploads
//! it to both canvas textures so either can serve as source/dest.
//! 2. **Each drag event** — [`GpuBrushEngine::render_dabs`] copies src→dst,
//! dispatches the compute shader, then swaps src/dst.
//! 3. **Stroke end** — [`GpuBrushEngine::readback_canvas`] copies the current
//! source texture into a staging buffer and returns the raw RGBA bytes
//! (blocking — uses `device.poll(Maintain::Wait)`).
//! 4. **Idle** — canvas textures are kept alive for the next stroke (no re-upload
//! needed if the layer has not changed).
use std::collections::HashMap;
use uuid::Uuid;
use lightningbeam_core::brush_engine::GpuDab;
// ---------------------------------------------------------------------------
// Colour-space helpers
// ---------------------------------------------------------------------------
use lightningbeam_core::gpu::srgb_to_linear;
// Lookup tables that keep the per-pixel `powf`/f16 conversions out of the canvas
// upload/readback loops. Doing the sRGB transfer per pixel was ~110ms for an
// 800x600 readback in a debug build; precomputing it once turns each channel
// into a table index.
/// Upload encode: sRGB byte → linear f16 little-endian bytes (for RGB channels).
fn srgb_to_linear_f16_lut() -> &'static [[u8; 2]; 256] {
static LUT: std::sync::OnceLock<[[u8; 2]; 256]> = std::sync::OnceLock::new();
LUT.get_or_init(|| {
let mut lut = [[0u8; 2]; 256];
for (i, out) in lut.iter_mut().enumerate() {
*out = half::f16::from_f32(srgb_to_linear(i as f32 / 255.0)).to_le_bytes();
}
lut
})
}
/// Upload encode: byte → linear f16 little-endian bytes (for the alpha channel,
/// which is not gamma-encoded).
fn linear_f16_lut() -> &'static [[u8; 2]; 256] {
static LUT: std::sync::OnceLock<[[u8; 2]; 256]> = std::sync::OnceLock::new();
LUT.get_or_init(|| {
let mut lut = [[0u8; 2]; 256];
for (i, out) in lut.iter_mut().enumerate() {
*out = half::f16::from_f32(i as f32 / 255.0).to_le_bytes();
}
lut
})
}
// ---------------------------------------------------------------------------
// Incremental ping-pong sync
// ---------------------------------------------------------------------------
/// Tile size (px) for incremental canvas sync copies between the ping-pong
/// textures. The brush keeps both textures identical; each frame only the tiles
/// touched by that frame's dabs are copied, so this bounds both the wasted bytes
/// per touched tile and the number of copy regions.
const SYNC_TILE: u32 = 128;
/// Coalesced copy rectangles (x, y, w, h) covering the tiles touched by `dabs`,
/// clamped to the canvas. Adjacent tiles in a row are merged into one rectangle
/// to keep the number of `copy_texture_to_texture` calls small.
fn dirty_tile_rects(dabs: &[GpuDab], canvas_w: u32, canvas_h: u32) -> Vec<(u32, u32, u32, u32)> {
if canvas_w == 0 || canvas_h == 0 || dabs.is_empty() {
return Vec::new();
}
let tiles_x = canvas_w.div_ceil(SYNC_TILE);
let tiles_y = canvas_h.div_ceil(SYNC_TILE);
let mut mask = vec![false; (tiles_x * tiles_y) as usize];
for d in dabs {
let r = d.radius + 1.0;
// Dab pixel bbox clamped to the canvas, then mapped to tile indices.
let px0 = (d.x - r).floor().clamp(0.0, (canvas_w - 1) as f32) as u32;
let py0 = (d.y - r).floor().clamp(0.0, (canvas_h - 1) as f32) as u32;
let px1 = (d.x + r).ceil().clamp(0.0, (canvas_w - 1) as f32) as u32;
let py1 = (d.y + r).ceil().clamp(0.0, (canvas_h - 1) as f32) as u32;
for ty in (py0 / SYNC_TILE)..=(py1 / SYNC_TILE) {
for tx in (px0 / SYNC_TILE)..=(px1 / SYNC_TILE) {
mask[(ty * tiles_x + tx) as usize] = true;
}
}
}
// Merge horizontal runs of set tiles in each tile-row into one rectangle.
let mut rects = Vec::new();
for ty in 0..tiles_y {
let mut tx = 0;
while tx < tiles_x {
if mask[(ty * tiles_x + tx) as usize] {
let run_start = tx;
while tx < tiles_x && mask[(ty * tiles_x + tx) as usize] {
tx += 1;
}
let x = run_start * SYNC_TILE;
let y = ty * SYNC_TILE;
let w = (tx * SYNC_TILE).min(canvas_w) - x;
let h = ((ty + 1) * SYNC_TILE).min(canvas_h) - y;
rects.push((x, y, w, h));
} else {
tx += 1;
}
}
}
rects
}
// ---------------------------------------------------------------------------
// Per-keyframe canvas texture pair (ping-pong)
// ---------------------------------------------------------------------------
/// A pair of textures used for double-buffered canvas rendering.
///
/// `current` indexes the texture that holds the up-to-date canvas state.
pub struct CanvasPair {
pub textures: [wgpu::Texture; 2],
pub views: [wgpu::TextureView; 2],
/// Index (0 or 1) of the texture that is the current "source" (authoritative).
pub current: usize,
pub width: u32,
pub height: u32,
}
impl CanvasPair {
pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
let desc = wgpu::TextureDescriptor {
label: Some("raster_canvas"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
};
let t0 = device.create_texture(&desc);
let t1 = device.create_texture(&desc);
let v0 = t0.create_view(&wgpu::TextureViewDescriptor::default());
let v1 = t1.create_view(&wgpu::TextureViewDescriptor::default());
Self {
textures: [t0, t1],
views: [v0, v1],
current: 0,
width,
height,
}
}
/// Upload raw RGBA bytes to both textures (call once at stroke start).
///
/// `pixels` is expected to be **sRGB-encoded premultiplied** (the format stored
/// in `raw_pixels` / PNG files). The values are decoded to linear premultiplied
/// before being written to the canvas, which operates entirely in linear space.
/// The canvas is `Rgba16Float`, so linear values are stored at 16-bit float
/// precision — storing linear light in 8 bits would band badly in shadows.
pub fn upload(&self, queue: &wgpu::Queue, pixels: &[u8]) {
// Decode sRGB-premultiplied → linear premultiplied f16 for the GPU canvas.
// LUT-driven so there is no per-pixel powf / float conversion.
let rgb_lut = srgb_to_linear_f16_lut();
let a_lut = linear_f16_lut();
let linear: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
let r = rgb_lut[p[0] as usize];
let g = rgb_lut[p[1] as usize];
let b = rgb_lut[p[2] as usize];
let a = a_lut[p[3] as usize];
[r[0], r[1], g[0], g[1], b[0], b[1], a[0], a[1]]
}).collect();
let layout = wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(self.width * 8), // Rgba16Float = 8 bytes/texel
rows_per_image: Some(self.height),
};
let extent = wgpu::Extent3d {
width: self.width,
height: self.height,
depth_or_array_layers: 1,
};
for tex in &self.textures {
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&linear,
layout,
extent,
);
}
}
/// Source (current, authoritative) texture.
pub fn src(&self) -> &wgpu::Texture { &self.textures[self.current] }
/// Source texture view.
pub fn src_view(&self) -> &wgpu::TextureView { &self.views[self.current] }
/// Destination (write target) texture.
pub fn dst(&self) -> &wgpu::Texture { &self.textures[1 - self.current] }
/// Destination texture view.
pub fn dst_view(&self) -> &wgpu::TextureView { &self.views[1 - self.current] }
/// Commit the just-completed dispatch: make dst the new source.
pub fn swap(&mut self) { self.current = 1 - self.current; }
}
// ---------------------------------------------------------------------------
// Raster affine-transform pipeline
// ---------------------------------------------------------------------------
/// CPU-side parameters for the raster transform compute shader.
/// Must match the `Params` struct in `raster_transform.wgsl` (48 bytes, 16-byte aligned).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct RasterTransformGpuParams {
pub a00: f32, pub a01: f32, // row 0 of 2×2 inverse affine matrix
pub a10: f32, pub a11: f32, // row 1
pub b0: f32, pub b1: f32, // translation (source pixel offset at output (0,0))
pub src_w: u32, pub src_h: u32,
pub dst_w: u32, pub dst_h: u32,
pub _pad0: u32, pub _pad1: u32,
}
/// Compute pipeline for GPU-accelerated affine resampling of raster floats.
/// Created lazily on first transform use.
struct RasterTransformPipeline {
pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout,
}
impl RasterTransformPipeline {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("raster_transform_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/raster_transform.wgsl").into(),
),
});
let bind_group_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("raster_transform_bgl"),
entries: &[
// 0: params uniform
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: source texture (anchor canvas, sampled)
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// 2: destination texture (float canvas dst, write-only storage)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba16Float,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("raster_transform_pl"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
},
);
let pipeline = device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some("raster_transform_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
},
);
Self { pipeline, bind_group_layout }
}
/// Dispatch the transform shader: reads from `src_view`, writes to `dst_view`.
/// The caller must call `dst_canvas.swap()` after this returns.
fn render(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
src_view: &wgpu::TextureView,
dst_view: &wgpu::TextureView,
params: RasterTransformGpuParams,
) {
use wgpu::util::DeviceExt;
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("raster_transform_params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("raster_transform_bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(src_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(dst_view),
},
],
});
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("raster_transform_enc") },
);
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("raster_transform_pass"), timestamp_writes: None },
);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
let wg_x = params.dst_w.div_ceil(8);
let wg_y = params.dst_h.div_ceil(8);
pass.dispatch_workgroups(wg_x, wg_y, 1);
}
queue.submit(Some(encoder.finish()));
}
}
// ---------------------------------------------------------------------------
// Displacement buffer (Warp / Liquify)
// ---------------------------------------------------------------------------
/// Per-pixel displacement map stored as a GPU buffer of `vec2f` values.
///
/// Each entry `disp[y * width + x]` stores `(dx, dy)` in canvas pixels.
/// Used by both the Warp tool (bilinear grid warp) and the Liquify tool
/// (brush-based freeform displacement).
pub struct DisplacementBuffer {
pub buf: wgpu::Buffer,
pub width: u32,
pub height: u32,
}
// ---------------------------------------------------------------------------
// Warp-apply pipeline
// ---------------------------------------------------------------------------
/// CPU-side parameters uniform for `warp_apply.wgsl`.
/// Must match the `Params` struct in the shader (32 bytes, 16-byte aligned).
/// grid_cols == 0 → per-pixel displacement buffer mode (Liquify).
/// grid_cols > 0 → control-point grid mode (Warp); disp[] has grid_cols*grid_rows entries.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct WarpApplyParams {
pub src_w: u32,
pub src_h: u32,
pub dst_w: u32,
pub dst_h: u32,
pub grid_cols: u32,
pub grid_rows: u32,
pub _pad0: u32,
pub _pad1: u32,
}
/// Compute pipeline that reads a displacement buffer + source texture → warped output.
/// Shared by the Warp tool and the Liquify tool's preview/commit pass.
struct WarpApplyPipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl WarpApplyPipeline {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("warp_apply_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/warp_apply.wgsl").into(),
),
});
let bg_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("warp_apply_bgl"),
entries: &[
// 0: params uniform
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: source texture (anchor canvas, sampled)
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// 2: displacement buffer (read-only storage)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 3: destination texture (display canvas, write-only storage)
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba16Float,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("warp_apply_pl"),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
},
);
let pipeline = device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some("warp_apply_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
},
);
Self { pipeline, bg_layout }
}
fn apply(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
src_view: &wgpu::TextureView,
disp_buf: &wgpu::Buffer,
dst_view: &wgpu::TextureView,
params: WarpApplyParams,
) {
use wgpu::util::DeviceExt;
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("warp_apply_params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("warp_apply_bg"),
layout: &self.bg_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src_view) },
wgpu::BindGroupEntry { binding: 2, resource: disp_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(dst_view) },
],
});
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("warp_apply_enc") },
);
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("warp_apply_pass"), timestamp_writes: None },
);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(params.dst_w.div_ceil(8), params.dst_h.div_ceil(8), 1);
}
queue.submit(Some(encoder.finish()));
}
}
// ---------------------------------------------------------------------------
// Liquify-brush pipeline
// ---------------------------------------------------------------------------
/// CPU-side parameters uniform for `liquify_brush.wgsl`.
/// Must match the `Params` struct in the shader (48 bytes, 16-byte aligned).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct LiquifyBrushParams {
pub cx: f32,
pub cy: f32,
pub radius: f32,
pub strength: f32,
pub dx: f32,
pub dy: f32,
pub mode: u32,
pub map_w: u32,
pub map_h: u32,
pub _pad0: u32,
pub _pad1: u32,
pub _pad2: u32,
}
/// Compute pipeline that updates a displacement map from a single brush step.
struct LiquifyBrushPipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl LiquifyBrushPipeline {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("liquify_brush_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/liquify_brush.wgsl").into(),
),
});
let bg_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("liquify_brush_bgl"),
entries: &[
// 0: params uniform
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: displacement buffer (read-write storage)
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("liquify_brush_pl"),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
},
);
let pipeline = device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some("liquify_brush_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
},
);
Self { pipeline, bg_layout }
}
fn update_displacement(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
disp_buf: &wgpu::Buffer,
params: LiquifyBrushParams,
) {
use wgpu::util::DeviceExt;
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("liquify_brush_params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("liquify_brush_bg"),
layout: &self.bg_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: disp_buf.as_entire_binding() },
],
});
let r = params.radius.ceil() as u32;
let wg_x = (2 * r + 1).div_ceil(8).max(1);
let wg_y = (2 * r + 1).div_ceil(8).max(1);
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("liquify_brush_enc") },
);
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("liquify_brush_pass"), timestamp_writes: None },
);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(wg_x, wg_y, 1);
}
queue.submit(Some(encoder.finish()));
}
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Gradient-fill pipeline
// ---------------------------------------------------------------------------
/// One gradient stop on the GPU side. Colors are sRGB straight-alpha [0..1].
/// Must be 32 bytes (8 × f32) to match `GradientStop` in `gradient_fill.wgsl`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuGradientStop {
pub position: f32,
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
pub _pad: [f32; 3],
}
impl GpuGradientStop {
/// Construct from sRGB u8 bytes (as stored in `ShapeColor`).
///
/// Stops are kept in sRGB (gamma) space so the shader interpolates between
/// them in gamma space — matching the CPU raster path (`Gradient::eval`) and
/// the vector/peniko path, and the gamma-space gradients users expect from
/// tools like Photoshop/Flash. The shader converts the interpolated color to
/// linear before compositing into the linear canvas.
pub fn from_srgb_u8(position: f32, r: u8, g: u8, b: u8, a: u8) -> Self {
Self {
position,
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: a as f32 / 255.0,
_pad: [0.0; 3],
}
}
}
/// CPU-side parameters uniform for `gradient_fill.wgsl`.
/// Must be 48 bytes (12 × u32/f32), 16-byte aligned.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct GradientFillParams {
canvas_w: u32,
canvas_h: u32,
start_x: f32,
start_y: f32,
end_x: f32,
end_y: f32,
opacity: f32,
extend_mode: u32, // 0 = Pad, 1 = Reflect, 2 = Repeat
num_stops: u32,
kind: u32, // 0 = Linear, 1 = Radial
_pad1: u32,
_pad2: u32,
}
/// Compute pipeline: composites a gradient over an anchor canvas → display canvas.
struct GradientFillPipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl GradientFillPipeline {
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gradient_fill_shader"),
source: wgpu::ShaderSource::Wgsl(
color_wgsl(include_str!("panes/shaders/gradient_fill.wgsl")).into(),
),
});
let bg_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("gradient_fill_bgl"),
entries: &[
// 0: params uniform
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: anchor (source) canvas
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// 2: gradient stops (read-only storage buffer)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 3: display (destination) canvas — write-only storage texture
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba16Float,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("gradient_fill_pl"),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
},
);
let pipeline = device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some("gradient_fill_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
},
);
Self { pipeline, bg_layout }
}
fn apply(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
src_view: &wgpu::TextureView,
stops_buf: &wgpu::Buffer,
dst_view: &wgpu::TextureView,
params: GradientFillParams,
) {
use wgpu::util::DeviceExt;
let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gradient_fill_params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gradient_fill_bg"),
layout: &self.bg_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: uniform_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src_view) },
wgpu::BindGroupEntry { binding: 2, resource: stops_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(dst_view) },
],
});
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("gradient_fill_enc") },
);
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("gradient_fill_pass"), timestamp_writes: None },
);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(params.canvas_w.div_ceil(8), params.canvas_h.div_ceil(8), 1);
}
queue.submit(Some(encoder.finish()));
}
}
// ── AlphaCompositePipeline ───────────────────────────────────────────────────
/// Compute pipeline: composites the scratch buffer C over the source A → output B.
///
/// Binding layout (see `alpha_composite.wgsl`):
/// 0 = tex_a (texture_2d<f32>, Rgba16Float, sampled, not filterable)
/// 1 = tex_c (texture_2d<f32>, Rgba16Float, sampled, not filterable)
/// 2 = tex_b (texture_storage_2d<rgba8unorm, write>)
/// Prepend the shared WGSL sRGB color functions ([`COLOR_WGSL`]) to a shader
/// source so the OETF/EOTF live in exactly one place.
fn color_wgsl(shader_src: &str) -> String {
format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, shader_src)
}
/// A compute `texture_2d<f32>` sampled binding (non-filterable).
fn sampled_tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}
}
/// A compute write-only storage-texture binding of the given format.
fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
}
}
/// Build a compute pipeline + matching bind-group layout from WGSL source and
/// layout entries (entry point `main`). Collapses the otherwise-identical
/// pipeline/layout construction boilerplate shared by the compute pipelines.
fn build_compute_pipeline(
device: &wgpu::Device,
label: &str,
shader_src: &str,
entries: &[wgpu::BindGroupLayoutEntry],
) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Wgsl(shader_src.into()),
});
let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries,
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(label),
layout: Some(&layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
(pipeline, bg_layout)
}
struct AlphaCompositePipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl AlphaCompositePipeline {
fn new(device: &wgpu::Device) -> Self {
let (pipeline, bg_layout) = build_compute_pipeline(
device,
"alpha_composite",
include_str!("panes/shaders/alpha_composite.wgsl"),
&[
sampled_tex_entry(0), // tex_a
sampled_tex_entry(1), // tex_c
storage_tex_entry(2, wgpu::TextureFormat::Rgba16Float), // tex_b (out)
],
);
Self { pipeline, bg_layout }
}
}
/// Compute pipeline that converts the linear-premultiplied `Rgba16Float` canvas
/// into an `Rgba8Unorm` sRGB-premultiplied texture for fast readback.
struct ReadbackSrgbPipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
}
impl ReadbackSrgbPipeline {
fn new(device: &wgpu::Device) -> Self {
let (pipeline, bg_layout) = build_compute_pipeline(
device,
"canvas_readback_srgb",
&color_wgsl(include_str!("panes/shaders/canvas_readback_srgb.wgsl")),
&[
sampled_tex_entry(0), // src (linear)
storage_tex_entry(1, wgpu::TextureFormat::Rgba8Unorm), // dst (sRGB)
],
);
Self { pipeline, bg_layout }
}
}
/// Reusable scratch for `readback_canvas`: the Rgba8Unorm conversion target plus
/// its MAP_READ staging buffer, kept across calls and rebuilt only on size change.
struct ReadbackScratch {
width: u32,
height: u32,
view: wgpu::TextureView,
tex: wgpu::Texture,
staging: wgpu::Buffer,
/// 256-aligned bytes-per-row of the staging buffer (Rgba8 = 4 B/texel).
bytes_per_row_aligned: u32,
}
impl ReadbackScratch {
fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("canvas_readback_srgb"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
let bytes_per_row_aligned = ((width * 4 + 255) / 256) * 256;
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("canvas_readback_buf"),
size: (bytes_per_row_aligned * height) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self { width, height, view, tex, staging, bytes_per_row_aligned }
}
}
// GpuBrushEngine
// ---------------------------------------------------------------------------
/// GPU brush engine — holds the compute pipeline and per-keyframe canvas pairs.
pub struct GpuBrushEngine {
compute_pipeline: wgpu::ComputePipeline,
compute_bg_layout: wgpu::BindGroupLayout,
/// Lazily created on first raster transform use.
transform_pipeline: Option<RasterTransformPipeline>,
/// Lazily created on first warp/liquify use.
warp_apply_pipeline: Option<WarpApplyPipeline>,
/// Lazily created on first liquify brush use.
liquify_brush_pipeline: Option<LiquifyBrushPipeline>,
/// Lazily created on first gradient fill use.
gradient_fill_pipeline: Option<GradientFillPipeline>,
/// Lazily created on first unified-tool composite dispatch.
composite_pipeline: Option<AlphaCompositePipeline>,
/// Lazily-created pipeline converting the canvas to sRGB for fast readback.
readback_srgb_pipeline: Option<ReadbackSrgbPipeline>,
/// Reused scratch (texture + staging buffer) for `readback_canvas`, recreated
/// only when the canvas size changes, to avoid per-stroke GPU allocations.
readback_scratch: Option<ReadbackScratch>,
/// Canvas texture pairs keyed by keyframe UUID.
pub canvases: HashMap<Uuid, CanvasPair>,
/// Displacement map buffers keyed by a caller-supplied UUID.
pub displacement_bufs: HashMap<Uuid, DisplacementBuffer>,
/// Persistent `Rgba16Float` textures for idle raster layers.
///
/// Keyed by keyframe UUID (same ID space as `canvases`). Entries are uploaded
/// once when `RasterKeyframe::texture_dirty` is set, then reused every frame.
/// Separate from `canvases` so tool teardown never accidentally removes them.
pub raster_layer_cache: HashMap<Uuid, CanvasPair>,
/// Recency order for `raster_layer_cache` (least-recent first), bumped on every
/// `ensure_layer_texture` so the visible frames stay at the back. Scrubbing a long
/// timeline would otherwise grow this map without bound (~w·h·16 bytes of VRAM per
/// entry); we evict the oldest once it exceeds `RASTER_LAYER_CACHE_MAX`.
raster_layer_lru: Vec<Uuid>,
/// Low-res proxy textures (one small `CanvasPair` per keyframe), shown while the
/// full-res pixels page in. Keyed by keyframe id; separate from
/// `raster_layer_cache` so a proxy and its full frame never collide. Bounded by
/// its own (generous, since each is tiny) recency LRU.
proxy_layer_cache: HashMap<Uuid, CanvasPair>,
proxy_layer_lru: Vec<Uuid>,
}
/// CPU-side parameters uniform for the compute shader.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct DabParams {
bbox_x0: i32,
bbox_y0: i32,
bbox_w: u32,
bbox_h: u32,
num_dabs: u32,
canvas_w: u32,
canvas_h: u32,
_pad: u32,
}
impl GpuBrushEngine {
/// Max number of idle raster-layer textures kept resident in VRAM. Scrubbing a
/// long timeline would otherwise cache one ~`w·h·16`-byte pair per visited frame
/// without bound; the least-recently-used are evicted past this (re-uploaded on
/// revisit from the faulted-in pixels).
const RASTER_LAYER_CACHE_MAX: usize = 12;
/// Proxies are ~1/100th the VRAM of a full frame, so we can keep many resident
/// for instant cold-scrub display before evicting the least-recently-used.
const RASTER_PROXY_CACHE_MAX: usize = 64;
/// Create the pipeline. Returns `Err` if the device lacks the required
/// storage-texture capability for `Rgba16Float`.
pub fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("brush_dab_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/brush_dab.wgsl").into(),
),
});
let compute_bg_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("brush_dab_bgl"),
entries: &[
// 0: dab storage buffer (read-only)
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: params uniform
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 2: canvas source (sampled)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// 3: canvas destination (write-only storage)
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba16Float,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("brush_dab_pl"),
bind_group_layouts: &[&compute_bg_layout],
push_constant_ranges: &[],
},
);
let compute_pipeline = device.create_compute_pipeline(
&wgpu::ComputePipelineDescriptor {
label: Some("brush_dab_pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
},
);
Self {
compute_pipeline,
compute_bg_layout,
transform_pipeline: None,
warp_apply_pipeline: None,
liquify_brush_pipeline: None,
gradient_fill_pipeline: None,
composite_pipeline: None,
readback_srgb_pipeline: None,
readback_scratch: None,
canvases: HashMap::new(),
displacement_bufs: HashMap::new(),
raster_layer_cache: HashMap::new(),
raster_layer_lru: Vec::new(),
proxy_layer_cache: HashMap::new(),
proxy_layer_lru: Vec::new(),
}
}
/// Ensure a canvas pair exists for `keyframe_id` at the given dimensions.
///
/// If the canvas exists but has different dimensions it is replaced.
pub fn ensure_canvas(
&mut self,
device: &wgpu::Device,
keyframe_id: Uuid,
width: u32,
height: u32,
) -> &mut CanvasPair {
let needs_new = self.canvases.get(&keyframe_id)
.map_or(true, |c| c.width != width || c.height != height);
if needs_new {
self.canvases.insert(keyframe_id, CanvasPair::new(device, width, height));
} else {
}
self.canvases.get_mut(&keyframe_id).unwrap()
}
/// Dispatch the brush compute shader for `dabs` onto the canvas of `keyframe_id`.
///
/// Paint/erase dabs are batched in a single GPU dispatch with a full canvas copy.
/// Smudge dabs are dispatched sequentially (one per dab) with a bbox-only copy
/// so each dab reads the canvas state written by the previous dab.
///
/// If `dabs` is empty, does nothing.
pub fn render_dabs(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
keyframe_id: Uuid,
dabs: &[GpuDab],
bbox: (i32, i32, i32, i32),
canvas_w: u32,
canvas_h: u32,
) {
if dabs.is_empty() { return; }
// render_dabs_batch keeps both ping-pong textures identical after every
// call, so smudge — whose each dab must read the previous dab's output —
// simply dispatches one dab at a time: each per-dab call leaves src fully
// authoritative for the next. Paint/erase dabs are independent within a
// frame (the shader accumulates them over the shared source), so they
// dispatch together as one batch.
let is_smudge = dabs.first().map(|d| d.blend_mode == 2).unwrap_or(false);
if is_smudge {
for dab in dabs {
let r = dab.radius + 1.0;
let cur_bbox = (
(dab.x - r).floor() as i32,
(dab.y - r).floor() as i32,
(dab.x + r).ceil() as i32,
(dab.y + r).ceil() as i32,
);
self.render_dabs_batch(device, queue, keyframe_id,
std::slice::from_ref(dab), cur_bbox, canvas_w, canvas_h);
}
} else {
self.render_dabs_batch(device, queue, keyframe_id, dabs, bbox, canvas_w, canvas_h);
}
}
/// Dispatch one batch of dabs and keep the ping-pong textures identical.
///
/// Reads `src` and writes the result over `dispatch_bbox` into `dst`, then
/// copies only the tiles the dabs touched back from `dst` to `src` so both
/// textures stay authoritative — no full-canvas copy. The textures start equal
/// (seeded by `upload` at stroke start) and this preserves that invariant, so a
/// single dab per call is enough for smudge's read-after-write dependency.
///
/// `dispatch_bbox` is the region dispatched to the compute shader (the union of
/// the batch's dab bboxes).
fn render_dabs_batch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
keyframe_id: Uuid,
dabs: &[GpuDab],
dispatch_bbox: (i32, i32, i32, i32),
canvas_w: u32,
canvas_h: u32,
) {
if dabs.is_empty() { return; }
let canvas = match self.canvases.get_mut(&keyframe_id) {
Some(c) => c,
None => return,
};
// Clamp the dispatch bounding box to canvas bounds.
let bbox = dispatch_bbox;
let x0 = bbox.0.max(0) as u32;
let y0 = bbox.1.max(0) as u32;
let x1 = (bbox.2 as u32).min(canvas_w);
let y1 = (bbox.3 as u32).min(canvas_h);
if x1 <= x0 || y1 <= y0 { return; }
let bbox_w = x1 - x0;
let bbox_h = y1 - y0;
// Step 1: Upload all dabs as a single storage buffer.
let dab_bytes = bytemuck::cast_slice(dabs);
let dab_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dab_storage_buf"),
size: dab_bytes.len() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&dab_buf, 0, dab_bytes);
let params = DabParams {
bbox_x0: x0 as i32,
bbox_y0: y0 as i32,
bbox_w,
bbox_h,
num_dabs: dabs.len() as u32,
canvas_w,
canvas_h,
_pad: 0,
};
let params_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dab_params_buf"),
size: std::mem::size_of::<DabParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&params_buf, 0, bytemuck::bytes_of(&params));
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("brush_dab_bg"),
layout: &self.compute_bg_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: dab_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: params_buf.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(canvas.src_view()) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(canvas.dst_view()) },
],
});
// Step 2: Single dispatch over the union bounding box.
let mut compute_enc = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") },
);
{
let mut pass = compute_enc.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("brush_dab_pass"), timestamp_writes: None },
);
pass.set_pipeline(&self.compute_pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1);
}
// Step 3: Sync only the tiles these dabs touched from dst back to src, so
// both ping-pong textures stay identical and authoritative — only the bytes
// that actually changed are moved (no full-canvas copy). The copies share
// the compute encoder, so wgpu orders them after the dispatch writes.
for (rx, ry, rw, rh) in dirty_tile_rects(dabs, canvas_w, canvas_h) {
compute_enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: canvas.dst(),
mip_level: 0,
origin: wgpu::Origin3d { x: rx, y: ry, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: canvas.src(),
mip_level: 0,
origin: wgpu::Origin3d { x: rx, y: ry, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d { width: rw, height: rh, depth_or_array_layers: 1 },
);
}
queue.submit(Some(compute_enc.finish()));
// Step 4: Swap once — dst (with all dabs applied) becomes the new src.
canvas.swap();
}
/// Read the current canvas back to a CPU `Vec<u8>` (sRGB-premultiplied RGBA,
/// row-major).
///
/// The linear→sRGB conversion runs on the GPU into an `Rgba8Unorm` scratch
/// texture, so the CPU side is just a per-row `memcpy` (no per-pixel decode).
/// **Blocks** until the GPU work is complete. Call at stroke end, not per frame.
///
/// Returns `None` if no canvas exists for `keyframe_id`.
pub fn readback_canvas(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
keyframe_id: Uuid,
) -> Option<Vec<u8>> {
// Lazily build the conversion pipeline and (re)create the cached scratch if
// the canvas size changed — these mutable borrows end before the shared
// borrows below.
if self.readback_srgb_pipeline.is_none() {
self.readback_srgb_pipeline = Some(ReadbackSrgbPipeline::new(device));
}
let (width, height) = {
let canvas = self.canvases.get(&keyframe_id)?;
(canvas.width, canvas.height)
};
if self.readback_scratch.as_ref().map_or(true, |s| s.width != width || s.height != height) {
self.readback_scratch = Some(ReadbackScratch::new(device, width, height));
}
let pipeline = self.readback_srgb_pipeline.as_ref().unwrap();
let scratch = self.readback_scratch.as_ref().unwrap();
let canvas = self.canvases.get(&keyframe_id)?;
// GPU pass: linear-premultiplied Rgba16Float → sRGB-premultiplied Rgba8Unorm.
// Reading back 8-bit sRGB lets the CPU just memcpy each row.
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("canvas_readback_srgb_bg"),
layout: &pipeline.bg_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(canvas.src_view()) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&scratch.view) },
],
});
let bytes_per_row_aligned = scratch.bytes_per_row_aligned;
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("canvas_readback_encoder") },
);
{
let mut pass = encoder.begin_compute_pass(
&wgpu::ComputePassDescriptor { label: Some("canvas_readback_srgb_pass"), timestamp_writes: None },
);
pass.set_pipeline(&pipeline.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &scratch.tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &scratch.staging,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row_aligned),
rows_per_image: Some(height),
},
},
wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
);
queue.submit(Some(encoder.finish()));
// Block until complete.
let slice = scratch.staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); });
let _ = device.poll(wgpu::PollType::wait_indefinitely());
if rx.recv().ok()?.is_err() { return None; }
let mapped = slice.get_mapped_range();
// De-stride with a per-row memcpy (dropping the 256-byte row padding). The
// bytes are already sRGB-premultiplied RGBA8 from the GPU pass, which is
// what Vello expects (ImageAlphaType::Premultiplied with sRGB channels).
let row_tight = (width * 4) as usize;
let row_src = bytes_per_row_aligned as usize;
let mut pixels = vec![0u8; (width * height * 4) as usize];
for row in 0..height as usize {
let src = &mapped[row * row_src .. row * row_src + row_tight];
pixels[row * row_tight .. (row + 1) * row_tight].copy_from_slice(src);
}
drop(mapped);
scratch.staging.unmap();
Some(pixels)
}
/// Render a set of dabs to an offscreen texture and return the raw pixels.
///
/// This is a **blocking** GPU readback — intended for one-time renders such as
/// brush preview thumbnails. Do not call every frame on the hot path.
///
/// The returned `Vec<u8>` is in **sRGB-encoded premultiplied RGBA** format,
/// suitable for creating an `egui::ColorImage` via
/// `ColorImage::from_rgba_premultiplied`.
///
/// A dedicated scratch canvas keyed by a fixed UUID is reused across calls so
/// no allocation is needed after the first invocation.
pub fn render_to_image(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
dabs: &[GpuDab],
width: u32,
height: u32,
) -> Vec<u8> {
use std::sync::OnceLock;
static SCRATCH_ID: OnceLock<Uuid> = OnceLock::new();
let scratch_id = *SCRATCH_ID.get_or_init(Uuid::new_v4);
// Ensure a correctly-sized scratch canvas exists.
self.ensure_canvas(device, scratch_id, width, height);
// Clear to transparent so previous renders don't bleed through.
let blank = vec![0u8; (width * height * 4) as usize];
if let Some(canvas) = self.canvases.get(&scratch_id) {
canvas.upload(queue, &blank);
}
if !dabs.is_empty() {
// Compute the union bounding box of all dabs.
let bbox = dabs.iter().fold(
(i32::MAX, i32::MAX, i32::MIN, i32::MIN),
|acc, d| {
let r = d.radius + 1.0;
(
acc.0.min((d.x - r).floor() as i32),
acc.1.min((d.y - r).floor() as i32),
acc.2.max((d.x + r).ceil() as i32),
acc.3.max((d.y + r).ceil() as i32),
)
},
);
self.render_dabs(device, queue, scratch_id, dabs, bbox, width, height);
}
self.readback_canvas(device, queue, scratch_id).unwrap_or_default()
}
/// Remove the canvas pair for a keyframe (e.g. when the layer is deleted).
pub fn remove_canvas(&mut self, keyframe_id: &Uuid) {
self.canvases.remove(keyframe_id);
}
// ── Raster-layer texture cache ────────────────────────────────────────────
/// Ensure a cached display texture exists for `kf_id`.
///
/// If `dirty` is `true` (or no entry exists), the canvas is (re)created and
/// `pixels` is uploaded. Call with `dirty = false` when only checking for
/// existence without re-uploading.
///
/// `pixels` must be sRGB-premultiplied RGBA with length `w * h * 4`.
/// Panics in debug builds if the length does not match.
pub fn ensure_layer_texture(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
kf_id: Uuid,
pixels: &[u8],
w: u32,
h: u32,
dirty: bool,
) {
debug_assert_eq!(
pixels.len(),
(w * h * 4) as usize,
"ensure_layer_texture: pixel buffer length mismatch (got {}, expected {})",
pixels.len(),
w * h * 4,
);
let needs_new = dirty || self.raster_layer_cache.get(&kf_id)
.map_or(true, |c| c.width != w || c.height != h);
if needs_new {
let canvas = CanvasPair::new(device, w, h);
if !pixels.is_empty() {
canvas.upload(queue, pixels);
}
self.raster_layer_cache.insert(kf_id, canvas);
}
// Bump recency (the frame was used this render) and evict the least-recently
// used textures over budget. The current frame is pushed to the back, so it
// (and every other frame rendered this pass) is never the eviction victim.
if let Some(pos) = self.raster_layer_lru.iter().position(|id| *id == kf_id) {
self.raster_layer_lru.remove(pos);
}
self.raster_layer_lru.push(kf_id);
let mut evicted = false;
while self.raster_layer_lru.len() > Self::RASTER_LAYER_CACHE_MAX {
let old = self.raster_layer_lru.remove(0);
self.raster_layer_cache.remove(&old);
evicted = true;
}
if needs_new || evicted {
self.report_raster_cache_vram();
}
}
/// Estimated VRAM footprint of `raster_layer_cache` (two `Rgba16Float` textures =
/// `w·h·16` bytes per entry), published to the F3 debug overlay.
fn report_raster_cache_vram(&self) {
let bytes = |cache: &HashMap<Uuid, CanvasPair>| -> usize {
cache.values().map(|c| (c.width as usize) * (c.height as usize) * 16).sum()
};
let total = bytes(&self.raster_layer_cache) + bytes(&self.proxy_layer_cache);
let count = self.raster_layer_cache.len() + self.proxy_layer_cache.len();
crate::debug_overlay::update_gpu_memory(count, total);
}
/// Ensure a low-res proxy texture exists for `kf_id` (uploaded once; proxies are
/// immutable). Bumps recency and evicts the least-recently-used past the budget.
/// `pixels` is sRGB-premultiplied RGBA of length `w * h * 4`.
pub fn ensure_proxy_texture(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
kf_id: Uuid,
pixels: &[u8],
w: u32,
h: u32,
) {
if pixels.len() != (w * h * 4) as usize {
return; // malformed proxy — skip rather than panic in the render loop
}
let mut changed = false;
if !self.proxy_layer_cache.contains_key(&kf_id) {
let canvas = CanvasPair::new(device, w, h);
canvas.upload(queue, pixels);
self.proxy_layer_cache.insert(kf_id, canvas);
changed = true;
}
if let Some(pos) = self.proxy_layer_lru.iter().position(|id| *id == kf_id) {
self.proxy_layer_lru.remove(pos);
}
self.proxy_layer_lru.push(kf_id);
while self.proxy_layer_lru.len() > Self::RASTER_PROXY_CACHE_MAX {
let old = self.proxy_layer_lru.remove(0);
self.proxy_layer_cache.remove(&old);
changed = true;
}
if changed {
self.report_raster_cache_vram();
}
}
/// Get the cached low-res proxy texture for a raster keyframe.
pub fn get_proxy_texture(&self, kf_id: &Uuid) -> Option<&CanvasPair> {
self.proxy_layer_cache.get(kf_id)
}
/// Remove the cached texture for a raster layer keyframe (e.g. when deleted or edited).
pub fn remove_layer_texture(&mut self, kf_id: &Uuid) {
let mut changed = self.raster_layer_cache.remove(kf_id).is_some();
if changed {
if let Some(pos) = self.raster_layer_lru.iter().position(|id| id == kf_id) {
self.raster_layer_lru.remove(pos);
}
}
// Also drop the low-res proxy: proxies are uploaded once and never refreshed, so a
// stale pre-edit proxy left here would be blitted (flashing old content) if the full-res
// texture is later evicted before the edited pixels page back in.
if self.proxy_layer_cache.remove(kf_id).is_some() {
if let Some(pos) = self.proxy_layer_lru.iter().position(|id| id == kf_id) {
self.proxy_layer_lru.remove(pos);
}
changed = true;
}
if changed {
self.report_raster_cache_vram();
}
}
/// Composite the accumulated-dab scratch buffer C over the source A, writing the
/// result into B: `B = C + A × (1 C.a)` (Porter-Duff src-over).
///
/// All three canvases must already exist in `self.canvases` (created by
/// [`ensure_canvas`] from the [`WorkspaceInitPacket`] in `prepare()`).
///
/// After dispatch, B's ping-pong index is swapped so `B.src_view()` holds the
/// composite result and the compositor can blit it.
pub fn composite_a_c_to_b(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
a_id: Uuid,
c_id: Uuid,
b_id: Uuid,
width: u32,
height: u32,
) {
// Init pipeline lazily.
if self.composite_pipeline.is_none() {
self.composite_pipeline = Some(AlphaCompositePipeline::new(device));
}
// Build bind group and command buffer (all immutable borrows of self).
let cmd_buf = {
let pipeline = self.composite_pipeline.as_ref().unwrap();
let Some(a) = self.canvases.get(&a_id) else { return; };
let Some(c) = self.canvases.get(&c_id) else { return; };
let Some(b) = self.canvases.get(&b_id) else { return; };
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("alpha_composite_bg"),
layout: &pipeline.bg_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(a.src_view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(c.src_view()),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(b.dst_view()),
},
],
});
let mut enc = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("alpha_composite_enc") },
);
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("alpha_composite"),
timestamp_writes: None,
});
pass.set_pipeline(&pipeline.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups((width + 7) / 8, (height + 7) / 8, 1);
}
enc.finish()
}; // Immutable borrows (pipeline, a, c, b) released here.
queue.submit(std::iter::once(cmd_buf));
// Swap B: src now holds the composite result.
if let Some(b) = self.canvases.get_mut(&b_id) {
b.swap();
}
}
/// Dispatch the affine-resample transform shader from `anchor_id` → `float_id`.
///
/// Reads from the anchor canvas's source view, writes into the float canvas's
/// destination view, then swaps the float canvas so the result becomes the new source.
///
/// `float_id` must already have been resized to `params.dst_w × params.dst_h` via
/// `ensure_canvas` before calling this.
pub fn render_transform(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
anchor_id: &Uuid,
float_id: &Uuid,
params: RasterTransformGpuParams,
) {
// Lazily create the transform pipeline.
let pipeline = self.transform_pipeline
.get_or_insert_with(|| RasterTransformPipeline::new(device));
// Borrow src_view and dst_view within a block so the borrows end before
// we call swap() on the float canvas.
let dispatched = {
let anchor = self.canvases.get(anchor_id);
let float = self.canvases.get(float_id);
if let (Some(anchor), Some(float)) = (anchor, float) {
pipeline.render(device, queue, anchor.src_view(), float.dst_view(), params);
true
} else {
false
}
};
if dispatched {
if let Some(float) = self.canvases.get_mut(float_id) {
float.swap();
}
}
}
// -----------------------------------------------------------------------
// Displacement buffer management
// -----------------------------------------------------------------------
/// Create a zero-initialised displacement buffer of `width × height` vec2f entries.
/// Returns the UUID under which it is stored.
pub fn create_displacement_buf(
&mut self,
device: &wgpu::Device,
id: Uuid,
width: u32,
height: u32,
) {
let byte_len = (width * height * 8) as u64; // 2 × f32 per pixel
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("displacement_buf"),
size: byte_len,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
self.displacement_bufs.insert(id, DisplacementBuffer { buf, width, height });
}
/// Zero out a displacement buffer (reset all displacements to (0,0)).
pub fn clear_displacement_buf(&self, queue: &wgpu::Queue, id: &Uuid) {
if let Some(db) = self.displacement_bufs.get(id) {
let zeros = vec![0u8; (db.width * db.height * 8) as usize];
queue.write_buffer(&db.buf, 0, &zeros);
}
}
/// Remove a displacement buffer (e.g. when the warp/liquify operation ends).
pub fn remove_displacement_buf(&mut self, id: &Uuid) {
self.displacement_bufs.remove(id);
}
// -----------------------------------------------------------------------
// Warp apply (shared by Warp and Liquify tools)
// -----------------------------------------------------------------------
/// Upload `disp_data` to the displacement buffer and then run the warp-apply
/// shader from `anchor_id` → `display_id`. The display canvas is swapped after.
///
/// If `disp_data` is `None` the buffer is not re-uploaded (used by Liquify which
/// updates the buffer in-place via `liquify_brush_step`).
/// Apply warp displacement to produce the display canvas.
///
/// `disp_data`: if `Some`, upload this data to the displacement buffer before running.
/// `grid_cols/grid_rows`: if > 0, the disp buffer contains only that many vec2f entries
/// (control-point grid mode). The shader does bilinear interpolation per pixel.
/// If 0, the buffer is a full per-pixel map (Liquify mode).
pub fn apply_warp(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
anchor_id: &Uuid,
disp_id: &Uuid,
display_id: &Uuid,
disp_data: Option<&[[f32; 2]]>,
grid_cols: u32,
grid_rows: u32,
) {
// Upload new displacement data if provided.
if let Some(data) = disp_data {
if let Some(db) = self.displacement_bufs.get(disp_id) {
queue.write_buffer(&db.buf, 0, bytemuck::cast_slice(data));
}
}
let pipeline = self.warp_apply_pipeline
.get_or_insert_with(|| WarpApplyPipeline::new(device));
let dispatched = {
let anchor = self.canvases.get(anchor_id);
let display = self.canvases.get(display_id);
let disp_b = self.displacement_bufs.get(disp_id);
if let (Some(anchor), Some(display), Some(db)) = (anchor, display, disp_b) {
let params = WarpApplyParams {
src_w: anchor.width,
src_h: anchor.height,
dst_w: display.width,
dst_h: display.height,
grid_cols,
grid_rows,
_pad0: 0,
_pad1: 0,
};
pipeline.apply(device, queue, anchor.src_view(), &db.buf, display.dst_view(), params);
true
} else {
false
}
};
if dispatched {
if let Some(display) = self.canvases.get_mut(display_id) {
display.swap();
}
}
}
// -----------------------------------------------------------------------
// Liquify brush step
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Gradient fill
// -----------------------------------------------------------------------
/// Composite a gradient over the anchor canvas into the display canvas.
///
/// - `anchor_id`: canvas holding the original pixels (read-only each frame).
/// - `display_id`: canvas to write the gradient result into.
/// - `stops`: gradient stops (linear straight-alpha, converted from sRGB by caller).
/// - `start`, `end`: gradient axis endpoints in canvas pixels.
/// - `opacity`: overall tool opacity [0..1].
/// - `extend_mode`: 0 = Pad, 1 = Reflect, 2 = Repeat.
pub fn apply_gradient_fill(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
anchor_id: &Uuid,
display_id: &Uuid,
stops: &[GpuGradientStop],
start: (f32, f32),
end: (f32, f32),
opacity: f32,
extend_mode: u32,
kind: u32,
) {
use wgpu::util::DeviceExt;
let pipeline = self.gradient_fill_pipeline
.get_or_insert_with(|| GradientFillPipeline::new(device));
// Build the stops storage buffer.
let stops_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gradient_stops_buf"),
contents: bytemuck::cast_slice(stops),
usage: wgpu::BufferUsages::STORAGE,
});
let dispatched = {
let anchor = self.canvases.get(anchor_id);
let display = self.canvases.get(display_id);
if let (Some(anchor), Some(display)) = (anchor, display) {
let params = GradientFillParams {
canvas_w: anchor.width,
canvas_h: anchor.height,
start_x: start.0,
start_y: start.1,
end_x: end.0,
end_y: end.1,
opacity,
extend_mode,
num_stops: stops.len() as u32,
kind,
_pad1: 0, _pad2: 0,
};
pipeline.apply(device, queue, anchor.src_view(), &stops_buf, display.dst_view(), params);
true
} else {
false
}
};
if dispatched {
if let Some(display) = self.canvases.get_mut(display_id) {
display.swap();
}
}
}
/// Dispatch the liquify-brush compute shader to update the displacement map.
pub fn liquify_brush_step(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
disp_id: &Uuid,
params: LiquifyBrushParams,
) {
if !self.displacement_bufs.contains_key(disp_id) { return; }
let pipeline = self.liquify_brush_pipeline
.get_or_insert_with(|| LiquifyBrushPipeline::new(device));
if let Some(db) = self.displacement_bufs.get(disp_id) {
pipeline.update_displacement(device, queue, &db.buf, params);
}
}
}
// ---------------------------------------------------------------------------
// Canvas blit pipeline (renders canvas texture to layer sRGB buffer)
// ---------------------------------------------------------------------------
/// Bind group layout + pipeline for blitting a canvas texture (at document
/// resolution) into a layer render buffer (at viewport resolution), applying
/// the camera transform.
pub struct CanvasBlitPipeline {
pub pipeline: wgpu::RenderPipeline,
/// Variant for straight-alpha sources (hardware-sRGB video frames): the
/// fragment shader skips the unpremultiply. See [`CanvasBlitPipeline::blit_straight`].
pub pipeline_straight: wgpu::RenderPipeline,
pub bg_layout: wgpu::BindGroupLayout,
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.
pub mask_sampler: wgpu::Sampler,
}
/// General affine blit transform for canvas_blit.wgsl.
///
/// Encodes the combined `viewport_uv → canvas_uv` mapping as a column-major 3×3
/// matrix packed into three `vec4` uniforms (std140 padding).
///
/// Build with [`BlitTransform::new`] by supplying:
/// * `layer_transform` — affine that maps **canvas pixels → viewport pixels**
/// (= `base_transform` from the renderer; includes camera pan/zoom and any
/// parent-clip affine for nested layers).
/// * `canvas_w`, `canvas_h` — canvas dimensions in pixels.
/// * `vp_w`, `vp_h` — viewport dimensions in pixels.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct BlitTransform {
/// Column 0 of the matrix (+ 1 padding float).
pub col0: [f32; 4],
/// Column 1 of the matrix (+ 1 padding float).
pub col1: [f32; 4],
/// Column 2 — translation column: `[tx, ty, 1.0, 0.0]`.
pub col2: [f32; 4],
}
impl BlitTransform {
/// Build from a `canvas_px → viewport_px` affine transform.
///
/// The resulting uniform maps **viewport UV [0,1]² → canvas UV [0,1]²** so
/// the fragment shader only needs a single `mat3x3 * vec3` multiply.
pub fn new(
layer_transform: kurbo::Affine,
canvas_w: u32,
canvas_h: u32,
vp_w: u32,
vp_h: u32,
) -> Self {
// Combined transform: viewport_uv → canvas_uv
// = scale_canvas_inv * layer_transform.inverse() * scale_vp
//
// scale_vp: viewport UV → viewport px
// layer_transform⁻¹: viewport px → canvas px
// scale_canvas_inv: canvas px → canvas UV
let scale_vp = kurbo::Affine::scale_non_uniform(vp_w as f64, vp_h as f64);
let scale_uv = kurbo::Affine::scale_non_uniform(
1.0 / canvas_w as f64,
1.0 / canvas_h as f64,
);
let combined = scale_uv * layer_transform.inverse() * scale_vp;
// kurbo::Affine coefficients: [a, b, c, d, e, f]
// x' = a*x + c*y + e
// y' = b*x + d*y + f
// Column-major 3×3: col0=(a,b,0), col1=(c,d,0), col2=(e,f,1)
let [a, b, c, d, e, f] = combined.as_coeffs();
// The .w slots are unused by the 3×3 matrix; the shader reads them as an RGB
// screen-blend tint ((0,0,0) = no tint — the default for all normal blits).
Self {
col0: [a as f32, b as f32, 0.0, 0.0],
col1: [c as f32, d as f32, 0.0, 0.0],
col2: [e as f32, f as f32, 1.0, 0.0],
}
}
/// Screen-blend the sampled color toward an RGB tint (for onion-skin ghosts), so
/// blacks/outlines pick up the tint. Packed into the matrix uniform's `.w` slots,
/// so no extra binding is needed.
pub fn with_tint(mut self, r: f32, g: f32, b: f32) -> Self {
self.col0[3] = r;
self.col1[3] = g;
self.col2[3] = b;
self
}
}
impl CanvasBlitPipeline {
pub fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("canvas_blit_shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("panes/shaders/canvas_blit.wgsl").into(),
),
});
let bg_layout = device.create_bind_group_layout(
&wgpu::BindGroupLayoutDescriptor {
label: Some("canvas_blit_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
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,
},
// Binding 3: selection mask texture (R8Unorm; 1×1 white = no mask)
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// Binding 4: nearest sampler for mask (sharp selection edges)
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
},
);
let pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("canvas_blit_pl"),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
},
);
let pipeline = device.create_render_pipeline(
&wgpu::RenderPipelineDescriptor {
label: Some("canvas_blit_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: None, // canvas already stores premultiplied alpha
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
},
);
// Variant pipeline for straight-alpha sources (hardware-sRGB video frames):
// identical except the fragment shader skips the unpremultiply.
let pipeline_straight = device.create_render_pipeline(
&wgpu::RenderPipelineDescriptor {
label: Some("canvas_blit_pipeline_straight"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main_straight"),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba16Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
},
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("canvas_blit_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::FilterMode::Nearest,
..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 {
label: Some("canvas_mask_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
Self { pipeline, pipeline_straight, bg_layout, sampler, linear_sampler, mask_sampler }
}
/// Render the canvas texture into `target_view` (Rgba16Float) with the given camera.
///
/// `target_view` is cleared to transparent before writing.
/// `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).
/// Blit with the default nearest-neighbour sampler (crisp; for real canvas pixels).
pub fn blit(
&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.sampler, &self.pipeline);
}
/// 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, &self.pipeline);
}
/// Blit a **straight-alpha** source (e.g. a video frame uploaded to an
/// `Rgba8UnormSrgb` texture, hardware-decoded to linear on sample). Uses the
/// `fs_main_straight` pipeline, which skips the unpremultiply that `blit` does.
/// Bilinear-sampled: video frames are scaled to the output size (document→export, or any
/// non-1:1 transform), and nearest sampling makes that look blocky.
pub fn blit_straight(
&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, &self.pipeline_straight);
}
#[allow(clippy::too_many_arguments)]
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,
pipeline: &wgpu::RenderPipeline,
) {
// When no mask is provided, create a temporary 1×1 all-white texture.
// (queue is already available here, unlike in new())
let tmp_mask_tex;
let tmp_mask_view;
let mask_view: &wgpu::TextureView = match mask_view {
Some(v) => v,
None => {
tmp_mask_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("canvas_default_mask"),
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tmp_mask_tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&[255u8],
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(1), rows_per_image: Some(1) },
wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
);
tmp_mask_view = tmp_mask_tex.create_view(&Default::default());
&tmp_mask_view
}
};
// Upload blit transform
let cam_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("canvas_blit_cam_buf"),
size: std::mem::size_of::<BlitTransform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&cam_buf, 0, bytemuck::bytes_of(transform));
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("canvas_blit_bg"),
layout: &self.bg_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(canvas_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(canvas_sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: cam_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(mask_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.mask_sampler),
},
],
});
let mut encoder = device.create_command_encoder(
&wgpu::CommandEncoderDescriptor { label: Some("canvas_blit_encoder") },
);
{
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("canvas_blit_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
});
rp.set_pipeline(pipeline);
rp.set_bind_group(0, &bg, &[]);
rp.draw(0..4, 0..1);
}
queue.submit(Some(encoder.finish()));
}
}