organize raster buffers better

This commit is contained in:
Skyler Lehmkuhl 2026-03-02 11:24:12 -05:00
parent 73ef9e3b9c
commit 885c52c02a
5 changed files with 571 additions and 185 deletions

View File

@ -82,6 +82,10 @@ pub struct RasterFloatingSelection {
/// undo (via `RasterStrokeAction`) when the float is committed, and for /// undo (via `RasterStrokeAction`) when the float is committed, and for
/// Cancel (Escape) to restore the canvas without creating an undo entry. /// Cancel (Escape) to restore the canvas without creating an undo entry.
pub canvas_before: Vec<u8>, pub canvas_before: Vec<u8>,
/// Key for this float's GPU canvas in `GpuBrushEngine::canvases`.
/// Allows painting strokes directly onto the float buffer (B) without
/// touching the layer canvas (A).
pub canvas_id: Uuid,
} }
/// Tracks the most recently selected thing(s) across the entire document. /// Tracks the most recently selected thing(s) across the entire document.

View File

@ -512,6 +512,8 @@ pub struct CanvasBlitPipeline {
pub pipeline: wgpu::RenderPipeline, pub pipeline: wgpu::RenderPipeline,
pub bg_layout: wgpu::BindGroupLayout, pub bg_layout: wgpu::BindGroupLayout,
pub sampler: wgpu::Sampler, pub sampler: wgpu::Sampler,
/// Nearest-neighbour sampler used for the selection mask texture.
pub mask_sampler: wgpu::Sampler,
} }
/// Camera parameters uniform for canvas_blit.wgsl. /// Camera parameters uniform for canvas_blit.wgsl.
@ -567,6 +569,24 @@ impl CanvasBlitPipeline {
}, },
count: 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,
},
], ],
}, },
); );
@ -621,12 +641,25 @@ impl CanvasBlitPipeline {
..Default::default() ..Default::default()
}); });
Self { pipeline, bg_layout, sampler } 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, bg_layout, sampler, mask_sampler }
} }
/// Render the canvas texture into `target_view` (Rgba8Unorm) with the given camera. /// Render the canvas texture into `target_view` (Rgba8Unorm) with the given camera.
/// ///
/// `target_view` is cleared to transparent before writing. /// `target_view` is cleared to transparent before writing.
/// `mask_view` is an R8Unorm texture in canvas-pixel space: 255 = keep, 0 = discard.
/// Pass `None` to use the built-in 1×1 all-white default (no masking).
pub fn blit( pub fn blit(
&self, &self,
device: &wgpu::Device, device: &wgpu::Device,
@ -634,7 +667,40 @@ impl CanvasBlitPipeline {
canvas_view: &wgpu::TextureView, canvas_view: &wgpu::TextureView,
target_view: &wgpu::TextureView, target_view: &wgpu::TextureView,
camera: &CameraParams, camera: &CameraParams,
mask_view: Option<&wgpu::TextureView>,
) { ) {
// 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 camera params // Upload camera params
let cam_buf = device.create_buffer(&wgpu::BufferDescriptor { let cam_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("canvas_blit_cam_buf"), label: Some("canvas_blit_cam_buf"),
@ -660,6 +726,14 @@ impl CanvasBlitPipeline {
binding: 2, binding: 2,
resource: cam_buf.as_entire_binding(), 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),
},
], ],
}); });

View File

@ -1919,7 +1919,7 @@ impl EditorApp {
use lightningbeam_core::actions::RasterStrokeAction; use lightningbeam_core::actions::RasterStrokeAction;
let Some(float) = self.selection.raster_floating.take() else { return }; let Some(float) = self.selection.raster_floating.take() else { return };
self.selection.raster_selection = None; let sel = self.selection.raster_selection.take();
let document = self.action_executor.document_mut(); let document = self.action_executor.document_mut();
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
@ -1930,11 +1930,36 @@ impl EditorApp {
if kf.raw_pixels.len() != expected { if kf.raw_pixels.len() != expected {
kf.raw_pixels.resize(expected, 0); kf.raw_pixels.resize(expected, 0);
} }
Self::composite_over(
&mut kf.raw_pixels, kf.width, kf.height, // Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels,
&float.pixels, float.width, float.height, // masked by the selection C when present.
float.x, float.y, for row in 0..float.height {
); let dy = float.y + row as i32;
if dy < 0 || dy >= kf.height as i32 { continue; }
for col in 0..float.width {
let dx = float.x + col as i32;
if dx < 0 || dx >= kf.width as i32 { continue; }
// Apply selection mask C (if selection exists, only composite where inside)
if let Some(ref s) = sel {
if !s.contains_pixel(dx, dy) { continue; }
}
let si = ((row * float.width + col) * 4) as usize;
let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize;
let sa = float.pixels[si + 3] as u32;
if sa == 0 { continue; }
let da = kf.raw_pixels[di + 3] as u32;
let out_a = sa + da * (255 - sa) / 255;
kf.raw_pixels[di + 3] = out_a as u8;
if out_a > 0 {
for c in 0..3 {
let v = float.pixels[si + c] as u32 * 255
+ kf.raw_pixels[di + c] as u32 * (255 - sa);
kf.raw_pixels[di + c] = (v / 255).min(255) as u8;
}
}
}
}
let canvas_after = kf.raw_pixels.clone(); let canvas_after = kf.raw_pixels.clone();
let w = kf.width; let w = kf.width;
let h = kf.height; let h = kf.height;
@ -2393,6 +2418,7 @@ impl EditorApp {
layer_id, layer_id,
time: self.playback_time, time: self.playback_time,
canvas_before, canvas_before,
canvas_id: uuid::Uuid::new_v4(),
}); });
// Update the marquee to show the floating selection bounds. // Update the marquee to show the floating selection bounds.
self.selection.raster_selection = Some(RasterSelection::Rect( self.selection.raster_selection = Some(RasterSelection::Rect(

View File

@ -21,6 +21,10 @@ struct CameraParams {
@group(0) @binding(0) var canvas_tex: texture_2d<f32>; @group(0) @binding(0) var canvas_tex: texture_2d<f32>;
@group(0) @binding(1) var canvas_sampler: sampler; @group(0) @binding(1) var canvas_sampler: sampler;
@group(0) @binding(2) var<uniform> camera: CameraParams; @group(0) @binding(2) var<uniform> camera: CameraParams;
/// Selection mask: R8Unorm, 255 = inside selection (keep), 0 = outside (discard).
/// A 1×1 all-white texture is bound when no selection is active.
@group(0) @binding(3) var mask_tex: texture_2d<f32>;
@group(0) @binding(4) var mask_sampler: sampler;
struct VertexOutput { struct VertexOutput {
@builtin(position) position: vec4<f32>, @builtin(position) position: vec4<f32>,
@ -77,11 +81,13 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// src = (premul_r, premul_g, premul_b, a) output = premul_r * a = r * a² // src = (premul_r, premul_g, premul_b, a) output = premul_r * a = r * a²
// which produces a dark halo over transparent regions. // which produces a dark halo over transparent regions.
let c = textureSample(canvas_tex, canvas_sampler, canvas_uv); let c = textureSample(canvas_tex, canvas_sampler, canvas_uv);
let mask = textureSample(mask_tex, mask_sampler, canvas_uv).r;
let masked_a = c.a * mask;
let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6); let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6);
return vec4<f32>( return vec4<f32>(
linear_to_srgb(c.r * inv_a), linear_to_srgb(c.r * inv_a),
linear_to_srgb(c.g * inv_a), linear_to_srgb(c.g * inv_a),
linear_to_srgb(c.b * inv_a), linear_to_srgb(c.b * inv_a),
c.a, masked_a,
); );
} }

View File

@ -409,6 +409,9 @@ struct VelloRenderContext {
painting_canvas: Option<(uuid::Uuid, uuid::Uuid)>, painting_canvas: Option<(uuid::Uuid, uuid::Uuid)>,
/// GPU canvas keyframe to remove at the top of this prepare() call. /// GPU canvas keyframe to remove at the top of this prepare() call.
pending_canvas_removal: Option<uuid::Uuid>, pending_canvas_removal: Option<uuid::Uuid>,
/// True while the current stroke targets the float buffer (B) rather than
/// the layer canvas (A). Used in prepare() to route the GPU canvas blit.
painting_float: bool,
} }
/// Callback for Vello rendering within egui /// Callback for Vello rendering within egui
@ -500,6 +503,24 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
} }
} }
// Lazy float GPU canvas initialization.
// If a float exists but its GPU canvas hasn't been created yet, upload float.pixels now.
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
if let Ok(mut gpu_brush) = shared.gpu_brush.lock() {
if !gpu_brush.canvases.contains_key(&float_sel.canvas_id) {
gpu_brush.ensure_canvas(device, float_sel.canvas_id, float_sel.width, float_sel.height);
if let Some(canvas) = gpu_brush.canvases.get(&float_sel.canvas_id) {
let pixels = if float_sel.pixels.is_empty() {
vec![0u8; (float_sel.width * float_sel.height * 4) as usize]
} else {
float_sel.pixels.clone()
};
canvas.upload(queue, &pixels);
}
}
}
}
// --- GPU brush dispatch --- // --- GPU brush dispatch ---
// Dispatch the compute shader for any pending raster dabs from this frame's // Dispatch the compute shader for any pending raster dabs from this frame's
// input event. Must happen before compositing so the updated canvas texture // input event. Must happen before compositing so the updated canvas texture
@ -643,6 +664,64 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
buffer_pool.release(bg_srgb_handle); buffer_pool.release(bg_srgb_handle);
buffer_pool.release(bg_hdr_handle); buffer_pool.release(bg_hdr_handle);
// Build a float-local R8 selection mask for the float canvas blit.
// Computed every frame from raster_selection so it is always correct
// (during strokes and during idle move/drag).
let float_mask_texture: Option<wgpu::Texture> =
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
if let Some(ref sel) = self.ctx.selection.raster_selection {
let fw = float_sel.width;
let fh = float_sel.height;
let fx = float_sel.x;
let fy = float_sel.y;
let mut pixels = vec![0u8; (fw * fh) as usize];
let (x0, y0, x1, y1) = sel.bounding_rect();
let bx0 = (x0 - fx).max(0) as u32;
let by0 = (y0 - fy).max(0) as u32;
let bx1 = ((x1 - fx) as u32).min(fw);
let by1 = ((y1 - fy) as u32).min(fh);
for py in by0..by1 {
for px in bx0..bx1 {
if sel.contains_pixel(fx + px as i32, fy + py as i32) {
pixels[(py * fw + px) as usize] = 255;
}
}
}
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("float_mask_tex"),
size: wgpu::Extent3d { width: fw, height: fh, 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: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(fw),
rows_per_image: Some(fh),
},
wgpu::Extent3d { width: fw, height: fh, depth_or_array_layers: 1 },
);
Some(tex)
} else {
None
}
} else {
None
};
let float_mask_view: Option<wgpu::TextureView> =
float_mask_texture.as_ref().map(|t| t.create_view(&Default::default()));
// Lock effect processor // Lock effect processor
let mut effect_processor = shared.effect_processor.lock().unwrap(); let mut effect_processor = shared.effect_processor.lock().unwrap();
@ -651,9 +730,16 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Check if this raster layer has a live GPU canvas that should be // Check if this raster layer has a live GPU canvas that should be
// blitted every frame, even when no new dabs arrived this frame. // blitted every frame, even when no new dabs arrived this frame.
// `painting_canvas` persists for the entire stroke duration. // `painting_canvas` persists for the entire stroke duration.
let gpu_canvas_kf: Option<uuid::Uuid> = self.ctx.painting_canvas // When painting into float (B), the GPU canvas is B's canvas — don't
.filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id) // use it to replace the Vello scene for the layer (A must still render
.map(|(_, kf_id)| kf_id); // via Vello).
let gpu_canvas_kf: Option<uuid::Uuid> = if self.ctx.painting_float {
None
} else {
self.ctx.painting_canvas
.filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id)
.map(|(_, kf_id)| kf_id)
};
if !rendered_layer.has_content && gpu_canvas_kf.is_none() { if !rendered_layer.has_content && gpu_canvas_kf.is_none() {
continue; continue;
@ -692,6 +778,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
canvas.src_view(), canvas.src_view(),
srgb_view, srgb_view,
&camera, &camera,
None, // no mask on layer canvas blit
); );
used = true; used = true;
} }
@ -914,6 +1001,59 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
buffer_pool.release(clip_hdr_handle); buffer_pool.release(clip_hdr_handle);
} }
// Blit the float GPU canvas on top of all composited layers.
// The float_mask_view clips to the selection shape (None = full float visible).
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
let float_canvas_id = float_sel.canvas_id;
let float_x = float_sel.x;
let float_y = float_sel.y;
let float_w = float_sel.width;
let float_h = float_sel.height;
if let Ok(gpu_brush) = shared.gpu_brush.lock() {
if let Some(canvas) = gpu_brush.canvases.get(&float_canvas_id) {
let float_srgb_handle = buffer_pool.acquire(device, layer_spec);
let float_hdr_handle = buffer_pool.acquire(device, hdr_spec);
if let (Some(fsrgb_view), Some(fhdr_view), Some(hdr_view)) = (
buffer_pool.get_view(float_srgb_handle),
buffer_pool.get_view(float_hdr_handle),
&instance_resources.hdr_texture_view,
) {
let fcamera = crate::gpu_brush::CameraParams {
pan_x: self.ctx.pan_offset.x + float_x as f32 * self.ctx.zoom,
pan_y: self.ctx.pan_offset.y + float_y as f32 * self.ctx.zoom,
zoom: self.ctx.zoom,
canvas_w: float_w as f32,
canvas_h: float_h as f32,
viewport_w: width as f32,
viewport_h: height as f32,
_pad: 0.0,
};
shared.canvas_blit.blit(
device, queue,
canvas.src_view(),
fsrgb_view,
&fcamera,
float_mask_view.as_ref(),
);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("float_canvas_srgb_to_linear"),
});
shared.srgb_to_linear.convert(device, &mut enc, fsrgb_view, fhdr_view);
queue.submit(Some(enc.finish()));
let float_layer = lightningbeam_core::gpu::CompositorLayer::normal(float_hdr_handle, 1.0);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("float_canvas_composite"),
});
shared.compositor.composite(device, queue, &mut enc, &[float_layer], &buffer_pool, hdr_view, None);
queue.submit(Some(enc.finish()));
}
buffer_pool.release(float_srgb_handle);
buffer_pool.release(float_hdr_handle);
}
}
}
// Advance frame counter for buffer cleanup // Advance frame counter for buffer cleanup
buffer_pool.next_frame(); buffer_pool.next_frame();
drop(buffer_pool); drop(buffer_pool);
@ -2288,6 +2428,9 @@ pub struct StagePane {
/// Pixels outside the selection are restored from `buffer_before` so strokes /// Pixels outside the selection are restored from `buffer_before` so strokes
/// only affect the area inside the selection outline. /// only affect the area inside the selection outline.
stroke_clip_selection: Option<lightningbeam_core::selection::RasterSelection>, stroke_clip_selection: Option<lightningbeam_core::selection::RasterSelection>,
/// True while the current stroke is being painted onto the float buffer (B)
/// rather than the layer canvas (A).
painting_float: bool,
/// Synthetic drag/click override for test mode replay (debug builds only) /// Synthetic drag/click override for test mode replay (debug builds only)
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
replay_override: Option<ReplayDragState>, replay_override: Option<ReplayDragState>,
@ -2410,6 +2553,7 @@ impl StagePane {
painting_canvas: None, painting_canvas: None,
pending_canvas_removal: None, pending_canvas_removal: None,
stroke_clip_selection: None, stroke_clip_selection: None,
painting_float: false,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
replay_override: None, replay_override: None,
} }
@ -4395,7 +4539,7 @@ impl StagePane {
else { else {
return; return;
}; };
shared.selection.raster_selection = None; let sel = shared.selection.raster_selection.take();
let document = shared.action_executor.document_mut(); let document = shared.action_executor.document_mut();
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else {
@ -4403,13 +4547,24 @@ impl StagePane {
}; };
let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
// Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels. // Ensure the canvas buffer is allocated (empty Vec = blank transparent canvas).
let expected = (kf.width * kf.height * 4) as usize;
if kf.raw_pixels.len() != expected {
kf.raw_pixels.resize(expected, 0);
}
// Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels,
// masked by the selection C when present.
for row in 0..float.height { for row in 0..float.height {
let dy = float.y + row as i32; let dy = float.y + row as i32;
if dy < 0 || dy >= kf.height as i32 { continue; } if dy < 0 || dy >= kf.height as i32 { continue; }
for col in 0..float.width { for col in 0..float.width {
let dx = float.x + col as i32; let dx = float.x + col as i32;
if dx < 0 || dx >= kf.width as i32 { continue; } if dx < 0 || dx >= kf.width as i32 { continue; }
// Apply selection mask C (if selection exists, only composite where inside)
if let Some(ref s) = sel {
if !s.contains_pixel(dx, dy) { continue; }
}
let si = ((row * float.width + col) * 4) as usize; let si = ((row * float.width + col) * 4) as usize;
let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize; let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize;
let sa = float.pixels[si + 3] as u32; let sa = float.pixels[si + 3] as u32;
@ -4445,6 +4600,52 @@ impl StagePane {
/// Call this immediately after a marquee / lasso selection is finalized so /// Call this immediately after a marquee / lasso selection is finalized so
/// that all downstream operations (drag-move, copy, cut, stroke-masking) /// that all downstream operations (drag-move, copy, cut, stroke-masking)
/// see a consistent `raster_floating` whenever a selection is active. /// see a consistent `raster_floating` whenever a selection is active.
/// Build an R8 mask buffer (0 = outside, 255 = inside) from a selection.
fn build_selection_mask(
sel: &lightningbeam_core::selection::RasterSelection,
width: u32,
height: u32,
) -> Vec<u8> {
let mut mask = vec![0u8; (width * height) as usize];
let (x0, y0, x1, y1) = sel.bounding_rect();
let bx0 = x0.max(0) as u32;
let by0 = y0.max(0) as u32;
let bx1 = (x1 as u32).min(width);
let by1 = (y1 as u32).min(height);
for y in by0..by1 {
for x in bx0..bx1 {
if sel.contains_pixel(x as i32, y as i32) {
mask[(y * width + x) as usize] = 255;
}
}
}
mask
}
/// Build an R8 mask buffer for the float canvas (0 = outside selection, 255 = inside).
/// Coordinates are in float-local space: pixel (fx, fy) corresponds to document pixel
/// (float_x+fx, float_y+fy).
fn build_float_mask(
sel: &lightningbeam_core::selection::RasterSelection,
float_x: i32, float_y: i32,
float_w: u32, float_h: u32,
) -> Vec<u8> {
let mut mask = vec![0u8; (float_w * float_h) as usize];
let (x0, y0, x1, y1) = sel.bounding_rect();
let bx0 = (x0 - float_x).max(0) as u32;
let by0 = (y0 - float_y).max(0) as u32;
let bx1 = ((x1 - float_x) as u32).min(float_w);
let by1 = ((y1 - float_y) as u32).min(float_h);
for fy in by0..by1 {
for fx in bx0..bx1 {
if sel.contains_pixel(float_x + fx as i32, float_y + fy as i32) {
mask[(fy * float_w + fx) as usize] = 255;
}
}
}
mask
}
fn lift_selection_to_float(shared: &mut SharedPaneState) { fn lift_selection_to_float(shared: &mut SharedPaneState) {
use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::selection::RasterFloatingSelection; use lightningbeam_core::selection::RasterFloatingSelection;
@ -4493,6 +4694,7 @@ impl StagePane {
layer_id, layer_id,
time, time,
canvas_before, canvas_before,
canvas_id: uuid::Uuid::new_v4(),
}); });
} }
@ -4552,95 +4754,157 @@ impl StagePane {
// Mouse down: capture buffer_before, start stroke, compute first dab // Mouse down: capture buffer_before, start stroke, compute first dab
// ---------------------------------------------------------------- // ----------------------------------------------------------------
if self.rsp_drag_started(response) || self.rsp_clicked(response) { if self.rsp_drag_started(response) || self.rsp_clicked(response) {
// Save selection BEFORE commit clears it — used after readback to // Determine if we are painting into the float (B) or the layer (A).
// mask the stroke result so only pixels inside the outline change. let painting_float = shared.selection.raster_floating.is_some();
self.painting_float = painting_float;
self.stroke_clip_selection = shared.selection.raster_selection.clone(); self.stroke_clip_selection = shared.selection.raster_selection.clone();
// Commit any floating selection synchronously so buffer_before and if painting_float {
// the GPU canvas initial upload see the fully-composited canvas. // ---- Paint onto float buffer B ----
Self::commit_raster_floating_now(shared); // Do NOT commit the float. Use the float's own GPU canvas.
let (canvas_id, float_x, float_y, canvas_width, canvas_height,
buffer_before, layer_id, time) = {
let float = shared.selection.raster_floating.as_ref().unwrap();
let buf = float.pixels.clone();
(float.canvas_id, float.x, float.y, float.width, float.height,
buf, float.layer_id, float.time)
};
let (doc_width, doc_height) = { // Compute first dab (same arithmetic as the layer case).
let doc = shared.action_executor.document(); let mut stroke_state = StrokeState::new();
(doc.width as u32, doc.height as u32) stroke_state.distance_since_last_dab = f32::MAX;
}; // Convert to float-local space: dabs must be in canvas pixel coords.
let first_pt = StrokePoint {
x: world_pos.x - float_x as f32,
y: world_pos.y - float_y as f32,
pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0,
};
let single = StrokeRecord {
brush_settings: brush.clone(),
color,
blend_mode,
points: vec![first_pt.clone()],
};
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state);
// Ensure the keyframe exists BEFORE reading its ID, so we always get self.painting_canvas = Some((layer_id, canvas_id));
// the real UUID. Previously we read the ID first and fell back to a self.pending_undo_before = Some((
// randomly-generated UUID when no keyframe existed; that fake UUID was layer_id,
// stored in painting_canvas but subsequent drag frames used the real UUID time,
// from keyframe_at(), causing the GPU canvas to be a different object from canvas_width,
// the one being composited. canvas_height,
{ buffer_before,
let doc = shared.action_executor.document_mut(); ));
if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&active_layer_id) { self.pending_raster_dabs = Some(PendingRasterDabs {
rl.ensure_keyframe_at(*shared.playback_time, doc_width, doc_height); keyframe_id: canvas_id,
} layer_id,
} time,
canvas_width,
canvas_height,
initial_pixels: None, // canvas already initialized via lazy GPU init
dabs,
dab_bbox,
wants_final_readback: false,
});
self.raster_stroke_state = Some((
layer_id,
time,
stroke_state,
Vec::new(),
));
self.raster_last_point = Some(first_pt);
*shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] };
// Now read the guaranteed-to-exist keyframe to get the real UUID. } else {
let (keyframe_id, canvas_width, canvas_height, buffer_before, initial_pixels) = { // ---- Paint onto layer canvas A (existing behavior) ----
let doc = shared.action_executor.document(); // Commit any floating selection synchronously so buffer_before and
if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&active_layer_id) { // the GPU canvas initial upload see the fully-composited canvas.
if let Some(kf) = rl.keyframe_at(*shared.playback_time) { Self::commit_raster_floating_now(shared);
let raw = kf.raw_pixels.clone();
let init = if raw.is_empty() { let (doc_width, doc_height) = {
vec![0u8; (kf.width * kf.height * 4) as usize] let doc = shared.action_executor.document();
} else { (doc.width as u32, doc.height as u32)
raw.clone() };
};
(kf.id, kf.width, kf.height, raw, init) // Ensure the keyframe exists BEFORE reading its ID, so we always get
} else { // the real UUID. Previously we read the ID first and fell back to a
return; // shouldn't happen after ensure_keyframe_at // randomly-generated UUID when no keyframe existed; that fake UUID was
// stored in painting_canvas but subsequent drag frames used the real UUID
// from keyframe_at(), causing the GPU canvas to be a different object from
// the one being composited.
{
let doc = shared.action_executor.document_mut();
if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&active_layer_id) {
rl.ensure_keyframe_at(*shared.playback_time, doc_width, doc_height);
} }
} else {
return;
} }
};
// Compute the first dab (single-point tap) // Now read the guaranteed-to-exist keyframe to get the real UUID.
let mut stroke_state = StrokeState::new(); let (keyframe_id, canvas_width, canvas_height, buffer_before, initial_pixels) = {
stroke_state.distance_since_last_dab = f32::MAX; let doc = shared.action_executor.document();
if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&active_layer_id) {
if let Some(kf) = rl.keyframe_at(*shared.playback_time) {
let raw = kf.raw_pixels.clone();
let init = if raw.is_empty() {
vec![0u8; (kf.width * kf.height * 4) as usize]
} else {
raw.clone()
};
(kf.id, kf.width, kf.height, raw, init)
} else {
return; // shouldn't happen after ensure_keyframe_at
}
} else {
return;
}
};
let first_pt = StrokePoint { // Compute the first dab (single-point tap)
x: world_pos.x, y: world_pos.y, let mut stroke_state = StrokeState::new();
pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, stroke_state.distance_since_last_dab = f32::MAX;
};
let single = StrokeRecord {
brush_settings: brush.clone(),
color,
blend_mode,
points: vec![first_pt.clone()],
};
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state);
self.painting_canvas = Some((active_layer_id, keyframe_id)); let first_pt = StrokePoint {
self.pending_undo_before = Some(( x: world_pos.x, y: world_pos.y,
active_layer_id, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0,
*shared.playback_time, };
canvas_width, let single = StrokeRecord {
canvas_height, brush_settings: brush.clone(),
buffer_before, color,
)); blend_mode,
self.pending_raster_dabs = Some(PendingRasterDabs { points: vec![first_pt.clone()],
keyframe_id, };
layer_id: active_layer_id, let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state);
time: *shared.playback_time,
canvas_width, // Layer strokes apply selection masking at readback time via stroke_clip_selection.
canvas_height,
initial_pixels: Some(initial_pixels), self.painting_canvas = Some((active_layer_id, keyframe_id));
dabs, self.pending_undo_before = Some((
dab_bbox, active_layer_id,
wants_final_readback: false, *shared.playback_time,
}); canvas_width,
self.raster_stroke_state = Some(( canvas_height,
active_layer_id, buffer_before,
*shared.playback_time, ));
stroke_state, self.pending_raster_dabs = Some(PendingRasterDabs {
Vec::new(), // buffer_before now lives in pending_undo_before keyframe_id,
)); layer_id: active_layer_id,
self.raster_last_point = Some(first_pt); time: *shared.playback_time,
*shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] }; canvas_width,
canvas_height,
initial_pixels: Some(initial_pixels),
dabs,
dab_bbox,
wants_final_readback: false,
});
self.raster_stroke_state = Some((
active_layer_id,
*shared.playback_time,
stroke_state,
Vec::new(), // buffer_before now lives in pending_undo_before
));
self.raster_last_point = Some(first_pt);
*shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] };
}
} }
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@ -4649,45 +4913,55 @@ impl StagePane {
if self.rsp_dragged(response) { if self.rsp_dragged(response) {
if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state { if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state {
if let Some(prev_pt) = self.raster_last_point.take() { if let Some(prev_pt) = self.raster_last_point.take() {
let curr_pt = StrokePoint { // Get canvas info and float offset now (used for both distance check
x: world_pos.x, y: world_pos.y, // and dab dispatch). prev_pt is already in canvas-local space.
let canvas_info = if self.painting_float {
shared.selection.raster_floating.as_ref().map(|f| {
(f.canvas_id, f.width, f.height, f.x as f32, f.y as f32)
})
} else {
let doc = shared.action_executor.document();
if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) {
if let Some(kf) = rl.keyframe_at(time) {
Some((kf.id, kf.width, kf.height, 0.0f32, 0.0f32))
} else { None }
} else { None }
};
let Some((canvas_id, cw, ch, cx, cy)) = canvas_info else {
self.raster_last_point = Some(prev_pt);
return;
};
// Convert current world position to canvas-local space.
let curr_local = StrokePoint {
x: world_pos.x - cx, y: world_pos.y - cy,
pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0,
}; };
const MIN_DIST_SQ: f32 = 1.5 * 1.5; const MIN_DIST_SQ: f32 = 1.5 * 1.5;
let dx = curr_pt.x - prev_pt.x; let dx = curr_local.x - prev_pt.x;
let dy = curr_pt.y - prev_pt.y; let dy = curr_local.y - prev_pt.y;
let moved_pt = if dx * dx + dy * dy >= MIN_DIST_SQ { let moved_pt = if dx * dx + dy * dy >= MIN_DIST_SQ {
curr_pt.clone() curr_local.clone()
} else { } else {
prev_pt.clone() prev_pt.clone()
}; };
if dx * dx + dy * dy >= MIN_DIST_SQ { if dx * dx + dy * dy >= MIN_DIST_SQ {
// Get keyframe info (needed for canvas dimensions)
let (kf_id, kw, kh) = {
let doc = shared.action_executor.document();
if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) {
if let Some(kf) = rl.keyframe_at(time) {
(kf.id, kf.width, kf.height)
} else { self.raster_last_point = Some(moved_pt); return; }
} else { self.raster_last_point = Some(moved_pt); return; }
};
let seg = StrokeRecord { let seg = StrokeRecord {
brush_settings: brush.clone(), brush_settings: brush.clone(),
color, color,
blend_mode, blend_mode,
points: vec![prev_pt, curr_pt], points: vec![prev_pt, curr_local],
}; };
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state); let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state);
self.pending_raster_dabs = Some(PendingRasterDabs { self.pending_raster_dabs = Some(PendingRasterDabs {
keyframe_id: kf_id, keyframe_id: canvas_id,
layer_id, layer_id,
time, time,
canvas_width: kw, canvas_width: cw,
canvas_height: kh, canvas_height: ch,
initial_pixels: None, initial_pixels: None,
dabs, dabs,
dab_bbox, dab_bbox,
@ -4718,12 +4992,17 @@ impl StagePane {
self.pending_undo_before.as_ref() self.pending_undo_before.as_ref()
{ {
let (ub_layer, ub_time, ub_cw, ub_ch) = (*ub_layer, *ub_time, *ub_cw, *ub_ch); let (ub_layer, ub_time, ub_cw, ub_ch) = (*ub_layer, *ub_time, *ub_cw, *ub_ch);
// Get keyframe_id for the canvas texture lookup // Get canvas_id for the canvas texture lookup.
let kf_id = shared.action_executor.document() // When painting into the float, use float.canvas_id; otherwise the keyframe id.
.get_layer(&ub_layer) let kf_id = if self.painting_float {
.and_then(|l| if let AnyLayer::Raster(rl) = l { self.painting_canvas.map(|(_, cid)| cid)
rl.keyframe_at(ub_time).map(|kf| kf.id) } else {
} else { None }); shared.action_executor.document()
.get_layer(&ub_layer)
.and_then(|l| if let AnyLayer::Raster(rl) = l {
rl.keyframe_at(ub_time).map(|kf| kf.id)
} else { None })
};
if let Some(kf_id) = kf_id { if let Some(kf_id) = kf_id {
self.pending_raster_dabs = Some(PendingRasterDabs { self.pending_raster_dabs = Some(PendingRasterDabs {
keyframe_id: kf_id, keyframe_id: kf_id,
@ -7322,7 +7601,7 @@ impl StagePane {
/// Render raster selection overlays: /// Render raster selection overlays:
/// - Animated "marching ants" around the active raster selection (marquee or lasso) /// - Animated "marching ants" around the active raster selection (marquee or lasso)
/// - Floating selection pixels as an egui texture composited at the float position /// - (Float pixels are rendered through the Vello HDR pipeline in prepare(), not here)
fn render_raster_selection_overlays( fn render_raster_selection_overlays(
&mut self, &mut self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -7332,8 +7611,7 @@ impl StagePane {
use lightningbeam_core::selection::RasterSelection; use lightningbeam_core::selection::RasterSelection;
let has_sel = shared.selection.raster_selection.is_some(); let has_sel = shared.selection.raster_selection.is_some();
let has_float = shared.selection.raster_floating.is_some(); if !has_sel { return; }
if !has_sel && !has_float { return; }
let time = ui.input(|i| i.time) as f32; let time = ui.input(|i| i.time) as f32;
// 8px/s scroll rate → repeating every 1 s // 8px/s scroll rate → repeating every 1 s
@ -7358,37 +7636,6 @@ impl StagePane {
} }
} }
// ── Floating selection texture overlay ────────────────────────────────
if let Some(float) = &shared.selection.raster_floating {
let tex_id = format!("raster_float_{}_{}", float.layer_id, float.time.to_bits());
// Upload pixels as an egui texture (re-uploaded every frame the float exists;
// egui caches by name so this is a no-op when the pixels haven't changed).
let color_image = egui::ColorImage::from_rgba_premultiplied(
[float.width as usize, float.height as usize],
&float.pixels,
);
let texture = ui.ctx().load_texture(
&tex_id,
color_image,
egui::TextureOptions::NEAREST,
);
// Position in screen space
let sx = rect.min.x + pan.x + float.x as f32 * zoom;
let sy = rect.min.y + pan.y + float.y as f32 * zoom;
let sw = float.width as f32 * zoom;
let sh = float.height as f32 * zoom;
let float_rect = egui::Rect::from_min_size(egui::pos2(sx, sy), egui::vec2(sw, sh));
painter.image(
texture.id(),
float_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
// Keep animating while a selection is visible // Keep animating while a selection is visible
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80)); ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
} }
@ -7538,43 +7785,71 @@ impl PaneRenderer for StagePane {
.get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new()))) .get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new())))
.lock() { .lock() {
if let Some(readback) = results.remove(&self.instance_id) { if let Some(readback) = results.remove(&self.instance_id) {
if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() { if self.painting_float {
use lightningbeam_core::actions::RasterStrokeAction; // Float stroke: update float.pixels, don't create a layer RasterStrokeAction.
// If a selection was active at stroke-start, restore any pixels if let Some((_, _, w, h, buffer_before)) = self.pending_undo_before.take() {
// outside the selection outline to their pre-stroke values. if let Some(ref mut float) = shared.selection.raster_floating {
let canvas_after = match self.stroke_clip_selection.take() { // Apply float-local selection mask: restore pixels outside C to
None => readback.pixels, // pre-stroke values so the stroke only affects the selected area.
Some(sel) => { let mut pixels = readback.pixels;
let mut masked = readback.pixels; if let Some(ref sel) = self.stroke_clip_selection {
for y in 0..h { for fy in 0..h {
for x in 0..w { for fx in 0..w {
if !sel.contains_pixel(x as i32, y as i32) { if !sel.contains_pixel(float.x + fx as i32, float.y + fy as i32) {
let i = ((y * w + x) * 4) as usize; let i = ((fy * w + fx) * 4) as usize;
masked[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]); pixels[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]);
}
} }
} }
} }
masked float.pixels = pixels;
} }
}; }
let action = RasterStrokeAction::new( self.stroke_clip_selection = None;
layer_id, self.painting_float = false;
time, // Keep float GPU canvas alive for the next stroke on the float.
buffer_before, // Don't schedule canvas_removal — just clear painting_canvas.
canvas_after, self.painting_canvas = None;
w, } else {
h, // Layer stroke: existing behavior — create RasterStrokeAction on raw_pixels.
); if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() {
// execute() sets raw_pixels = buffer_after so future Vello renders use lightningbeam_core::actions::RasterStrokeAction;
// and file saves see the completed stroke. // If a selection was active at stroke-start, restore any pixels
let _ = shared.action_executor.execute(Box::new(action)); // outside the selection outline to their pre-stroke values.
} let canvas_after = match self.stroke_clip_selection.take() {
// raw_pixels is now up to date; switch compositing back to the Vello None => readback.pixels,
// scene. Schedule the GPU canvas for removal at the start of the next Some(sel) => {
// prepare() — keeping it alive for this frame's composite avoids a let mut masked = readback.pixels;
// one-frame flash of the stale Vello scene. for y in 0..h {
if let Some((_, kf_id)) = self.painting_canvas.take() { for x in 0..w {
self.pending_canvas_removal = Some(kf_id); if !sel.contains_pixel(x as i32, y as i32) {
let i = ((y * w + x) * 4) as usize;
masked[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]);
}
}
}
masked
}
};
let action = RasterStrokeAction::new(
layer_id,
time,
buffer_before,
canvas_after,
w,
h,
);
// execute() sets raw_pixels = buffer_after so future Vello renders
// and file saves see the completed stroke.
let _ = shared.action_executor.execute(Box::new(action));
}
// raw_pixels is now up to date; switch compositing back to the Vello
// scene. Schedule the GPU canvas for removal at the start of the next
// prepare() — keeping it alive for this frame's composite avoids a
// one-frame flash of the stale Vello scene.
if let Some((_, kf_id)) = self.painting_canvas.take() {
self.pending_canvas_removal = Some(kf_id);
}
} }
} }
} }
@ -7929,6 +8204,7 @@ impl PaneRenderer for StagePane {
instance_id_for_readback: self.instance_id, instance_id_for_readback: self.instance_id,
painting_canvas: self.painting_canvas, painting_canvas: self.painting_canvas,
pending_canvas_removal: self.pending_canvas_removal.take(), pending_canvas_removal: self.pending_canvas_removal.take(),
painting_float: self.painting_float,
}}; }};
let cb = egui_wgpu::Callback::new_paint_callback( let cb = egui_wgpu::Callback::new_paint_callback(