//! 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 = 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(¶ms), 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(¶ms), 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(¶ms), 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(¶ms), 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, Rgba16Float, sampled, not filterable) /// 1 = tex_c (texture_2d, Rgba16Float, sampled, not filterable) /// 2 = tex_b (texture_storage_2d) /// 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` 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, /// Lazily created on first warp/liquify use. warp_apply_pipeline: Option, /// Lazily created on first liquify brush use. liquify_brush_pipeline: Option, /// Lazily created on first gradient fill use. gradient_fill_pipeline: Option, /// Lazily created on first unified-tool composite dispatch. composite_pipeline: Option, /// Lazily-created pipeline converting the canvas to sRGB for fast readback. readback_srgb_pipeline: Option, /// Reused scratch (texture + staging buffer) for `readback_canvas`, recreated /// only when the canvas size changes, to avoid per-stroke GPU allocations. readback_scratch: Option, /// Canvas texture pairs keyed by keyframe UUID. pub canvases: HashMap, /// Displacement map buffers keyed by a caller-supplied UUID. pub displacement_bufs: HashMap, /// 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, /// 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, /// 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, proxy_layer_lru: Vec, } /// 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::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(¶ms_buf, 0, bytemuck::bytes_of(¶ms)); 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` (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> { // 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` 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 { use std::sync::OnceLock; static SCRATCH_ID: OnceLock = 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| -> 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::() 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())); } }