Onion skinning (raster): toggle + tinted ghosts of the active layer

- OnionSkinSettings (editor view state): enabled, frames_before/after (default 2/2),
  opacity (0.35), warm past / cool future tints, linear falloff. Threaded App →
  SharedPaneState (gated off during playback) → VelloRenderContext.
- Toggle: View ▸ Onion Skinning + the `O` shortcut (AppAction::ToggleOnionSkin).
- Tint: packed an RGB multiply into the canvas-blit matrix uniform's unused .w slots
  (1,1,1 = no tint for all normal blits); BlitTransform::with_tint. Shader multiplies.
- Render: for the ACTIVE raster layer only, blit the N neighbouring keyframes (full
  texture if resident, else the low-res proxy — uploaded on demand) tinted + faint,
  composited behind the current frame. Off during playback.

Next: a settings panel (frame count/opacity) and vector-layer ghosts.
This commit is contained in:
Skyler Lehmkuhl 2026-06-20 22:59:44 -04:00
parent f585c370e8
commit 53cae1bfe5
7 changed files with 167 additions and 6 deletions

View File

@ -2029,12 +2029,23 @@ impl BlitTransform {
// 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
// tint (1,1,1 = 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],
col0: [a as f32, b as f32, 0.0, 1.0],
col1: [c as f32, d as f32, 0.0, 1.0],
col2: [e as f32, f as f32, 1.0, 1.0],
}
}
/// Multiply the sampled color by an RGB tint (for onion-skin ghosts). 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 {

View File

@ -103,6 +103,7 @@ pub enum AppAction {
TogglePlayPause,
CancelAction,
ToggleDebugOverlay,
ToggleOnionSkin,
#[cfg(debug_assertions)]
ToggleTestMode,
@ -150,7 +151,7 @@ impl AppAction {
Self::ToolErase | Self::ToolSmudge | Self::ToolSelectLasso | Self::ToolSplit => "Tools",
Self::TogglePlayPause | Self::CancelAction |
Self::ToggleDebugOverlay => "Global",
Self::ToggleDebugOverlay | Self::ToggleOnionSkin => "Global",
#[cfg(debug_assertions)]
Self::ToggleTestMode => "Global",
@ -246,6 +247,7 @@ impl AppAction {
Self::TogglePlayPause => "Toggle Play/Pause",
Self::CancelAction => "Cancel / Escape",
Self::ToggleDebugOverlay => "Toggle Debug Overlay",
Self::ToggleOnionSkin => "Toggle Onion Skinning",
#[cfg(debug_assertions)]
Self::ToggleTestMode => "Toggle Test Mode",
Self::PianoRollDelete => "Piano Roll: Delete",
@ -281,7 +283,7 @@ impl AppAction {
Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon,
Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect,
Self::ToolErase, Self::ToolSmudge, Self::ToolSelectLasso, Self::ToolSplit,
Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay,
Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, Self::ToggleOnionSkin,
#[cfg(debug_assertions)]
Self::ToggleTestMode,
Self::PianoRollDelete, Self::StageDelete,
@ -330,6 +332,7 @@ impl From<MenuAction> for AppAction {
MenuAction::AddTestClip => Self::AddTestClip,
MenuAction::DeleteLayer => Self::DeleteLayer,
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
MenuAction::ToggleOnionSkin => Self::ToggleOnionSkin,
MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable
MenuAction::NewKeyframe => Self::NewKeyframe,
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
@ -392,6 +395,7 @@ impl TryFrom<AppAction> for MenuAction {
AppAction::AddTestClip => MenuAction::AddTestClip,
AppAction::DeleteLayer => MenuAction::DeleteLayer,
AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility,
AppAction::ToggleOnionSkin => MenuAction::ToggleOnionSkin,
AppAction::NewKeyframe => MenuAction::NewKeyframe,
AppAction::NewBlankKeyframe => MenuAction::NewBlankKeyframe,
AppAction::DeleteFrame => MenuAction::DeleteFrame,
@ -507,6 +511,7 @@ pub fn all_defaults() -> HashMap<AppAction, Option<Shortcut>> {
defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na)));
defaults.insert(AppAction::CancelAction, Some(Shortcut::new(ShortcutKey::Escape, nc, ns, na)));
defaults.insert(AppAction::ToggleDebugOverlay, Some(Shortcut::new(ShortcutKey::F3, nc, ns, na)));
defaults.insert(AppAction::ToggleOnionSkin, Some(Shortcut::new(ShortcutKey::O, nc, ns, na)));
#[cfg(debug_assertions)]
defaults.insert(AppAction::ToggleTestMode, Some(Shortcut::new(ShortcutKey::F5, nc, ns, na)));

View File

@ -1026,6 +1026,8 @@ struct EditorApp {
recording_mirror_rx: Option<rtrb::Consumer<f32>>,
/// Current file path (None if not yet saved)
current_file_path: Option<std::path::PathBuf>,
/// Onion-skinning view settings (toggle + frame counts + opacity).
onion_skin: crate::panes::OnionSkinSettings,
/// On-demand loader for raster keyframe pixels from the project `.beam` container.
raster_store: lightningbeam_core::raster_store::RasterStore,
/// Miss-sink: raster keyframe ids the canvas wanted but whose pixels weren't
@ -1313,6 +1315,7 @@ impl EditorApp {
waveform_result_tx,
recording_mirror_rx,
current_file_path: None, // No file loaded initially
onion_skin: crate::panes::OnionSkinSettings::default(),
raster_store: lightningbeam_core::raster_store::RasterStore::new(None),
raster_fault_requests: Default::default(),
raster_load_result_tx,
@ -3816,6 +3819,9 @@ impl EditorApp {
MenuAction::RecenterView => {
self.pending_view_action = Some(MenuAction::RecenterView);
}
MenuAction::ToggleOnionSkin => {
self.onion_skin.enabled = !self.onion_skin.enabled;
}
MenuAction::NextLayout => {
println!("Menu: Next Layout");
let next_index = (self.current_layout_index + 1) % self.layouts.len();
@ -6459,6 +6465,12 @@ impl eframe::App for EditorApp {
// Create render context
let mut ctx = RenderContext {
shared: panes::SharedPaneState {
onion: {
// Onion skinning is disabled during playback.
let mut o = self.onion_skin;
o.enabled = o.enabled && !self.is_playing;
o
},
tool_icon_cache: &mut self.tool_icon_cache,
icon_cache: &mut self.icon_cache,
selected_tool: &mut self.selected_tool,

View File

@ -332,6 +332,7 @@ pub enum MenuAction {
ZoomOut,
ActualSize,
RecenterView,
ToggleOnionSkin,
NextLayout,
PreviousLayout,
#[allow(dead_code)] // Handler exists in main.rs, menu item not yet wired
@ -432,6 +433,7 @@ impl MenuItemDef {
const ZOOM_OUT: Self = Self { label: "Zoom Out", action: MenuAction::ZoomOut, shortcut: Some(Shortcut::new(ShortcutKey::Minus, CTRL, NO_SHIFT, NO_ALT)) };
const ACTUAL_SIZE: Self = Self { label: "Actual Size", action: MenuAction::ActualSize, shortcut: Some(Shortcut::new(ShortcutKey::Num0, CTRL, NO_SHIFT, NO_ALT)) };
const RECENTER_VIEW: Self = Self { label: "Recenter View", action: MenuAction::RecenterView, shortcut: None };
const TOGGLE_ONION_SKIN: Self = Self { label: "Onion Skinning", action: MenuAction::ToggleOnionSkin, shortcut: Some(Shortcut::new(ShortcutKey::O, NO_CTRL, NO_SHIFT, NO_ALT)) };
const NEXT_LAYOUT: Self = Self { label: "Next Layout", action: MenuAction::NextLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketRight, CTRL, NO_SHIFT, NO_ALT)) };
const PREVIOUS_LAYOUT: Self = Self { label: "Previous Layout", action: MenuAction::PreviousLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketLeft, CTRL, NO_SHIFT, NO_ALT)) };
@ -455,6 +457,7 @@ impl MenuItemDef {
&Self::DELETE, &Self::SELECT_ALL, &Self::SELECT_NONE,
&Self::GROUP, &Self::ADD_LAYER, &Self::NEW_KEYFRAME,
&Self::ZOOM_IN, &Self::ZOOM_OUT, &Self::ACTUAL_SIZE,
&Self::TOGGLE_ONION_SKIN,
&Self::NEXT_LAYOUT, &Self::PREVIOUS_LAYOUT,
&Self::SETTINGS, &Self::CLOSE_WINDOW,
]
@ -566,6 +569,7 @@ impl MenuItemDef {
MenuDef::Item(&Self::ZOOM_OUT),
MenuDef::Item(&Self::ACTUAL_SIZE),
MenuDef::Item(&Self::RECENTER_VIEW),
MenuDef::Item(&Self::TOGGLE_ONION_SKIN),
MenuDef::Separator,
MenuDef::Submenu {
label: "Layout",

View File

@ -143,7 +143,40 @@ pub fn find_sampled_audio_track(document: &lightningbeam_core::document::Documen
}
/// Shared state that all panes can access
/// Onion-skinning view settings (editor-only; not saved with the document). Ghosts the
/// active layer's neighbouring keyframes, warm-tinted for past and cool for future.
#[derive(Clone, Copy, Debug)]
pub struct OnionSkinSettings {
pub enabled: bool,
pub frames_before: usize,
pub frames_after: usize,
/// Opacity of the nearest ghost; further ghosts fall off linearly.
pub opacity: f32,
}
impl Default for OnionSkinSettings {
fn default() -> Self {
Self { enabled: false, frames_before: 2, frames_after: 2, opacity: 0.35 }
}
}
impl OnionSkinSettings {
/// RGB tint multipliers for past (warm) and future (cool) ghosts.
pub const PAST_TINT: [f32; 3] = [1.0, 0.45, 0.45];
pub const FUTURE_TINT: [f32; 3] = [0.45, 0.6, 1.0];
/// Opacity for the `n`-th ghost away from the current frame (n = 1 is nearest),
/// linearly falling off so the furthest ghost is faintest.
pub fn ghost_opacity(&self, n: usize, total: usize) -> f32 {
if total == 0 { return self.opacity; }
let falloff = 1.0 - (n.saturating_sub(1) as f32) / (total as f32);
self.opacity * falloff.clamp(0.15, 1.0)
}
}
pub struct SharedPaneState<'a> {
/// Effective onion-skin settings (already gated to off during playback by main.rs).
pub onion: OnionSkinSettings,
pub tool_icon_cache: &'a mut crate::ToolIconCache,
#[allow(dead_code)] // Used by pane chrome rendering in main.rs
pub icon_cache: &'a mut crate::IconCache,

View File

@ -67,5 +67,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
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);
return vec4<f32>(c.r * inv_a, c.g * inv_a, c.b * inv_a, masked_a);
// RGB tint packed into the matrix uniform's unused .w slots (1,1,1 = no tint).
// Used by onion-skin ghosts (warm = past, cool = future).
let tint = vec3<f32>(transform.col0.w, transform.col1.w, transform.col2.w);
return vec4<f32>(c.r * inv_a * tint.r, c.g * inv_a * tint.g, c.b * inv_a * tint.b, masked_a);
}

View File

@ -435,6 +435,8 @@ struct VelloRenderContext {
document: std::sync::Arc<lightningbeam_core::document::Document>,
/// Current tool interaction state
tool_state: lightningbeam_core::tool::ToolState,
/// Onion-skinning settings (already gated off during playback).
onion: crate::panes::OnionSkinSettings,
/// Active layer for tool operations
active_layer_id: Option<uuid::Uuid>,
/// Delta for drag preview (world space)
@ -1296,6 +1298,96 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
buffer_pool.release(hdr_layer_handle);
}
RenderedLayerType::Raster { transform: layer_transform, .. } => {
// --- Onion-skin ghosts: the active layer's neighbouring keyframes,
// warm-tinted for past / cool for future, faint and behind the
// current frame. `ctx.onion.enabled` is already gated off during
// playback. Ghosts use the full texture if resident, else the proxy.
if self.ctx.onion.enabled
&& Some(rendered_layer.layer_id) == self.ctx.active_layer_id
{
let onion = self.ctx.onion;
let ghosts: Vec<(uuid::Uuid, u32, u32, [f32; 3], f32)> = {
let doc = &self.ctx.document;
match doc.get_layer(&rendered_layer.layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Raster(rl)) => {
let after = rl.keyframes.partition_point(|kf| kf.time <= self.ctx.playback_time);
let mut v = Vec::new();
if after > 0 {
let cur = after - 1;
for d in 1..=onion.frames_before {
if cur >= d {
let kf = &rl.keyframes[cur - d];
v.push((kf.id, kf.width, kf.height,
crate::panes::OnionSkinSettings::PAST_TINT,
onion.ghost_opacity(d, onion.frames_before)));
}
}
for d in 1..=onion.frames_after {
if cur + d < rl.keyframes.len() {
let kf = &rl.keyframes[cur + d];
v.push((kf.id, kf.width, kf.height,
crate::panes::OnionSkinSettings::FUTURE_TINT,
onion.ghost_opacity(d, onion.frames_after)));
}
}
}
v
}
_ => Vec::new(),
}
};
for (kf_id, lw, lh, tint, opacity) in ghosts {
let gh = buffer_pool.acquire(device, hdr_spec);
if let (Some(gv), Some(hdr_view)) = (
buffer_pool.get_view(gh),
&instance_resources.hdr_texture_view,
) {
let mut drew = false;
if let Ok(mut gpu_brush) = shared.gpu_brush.lock() {
let has_full = gpu_brush.raster_layer_cache.contains_key(&kf_id);
if !has_full {
// Upload the proxy on demand from the keyframe's pixels.
if let Some(lightningbeam_core::layer::AnyLayer::Raster(rl)) =
self.ctx.document.get_layer(&rendered_layer.layer_id)
{
if let Some(p) = rl.keyframes.iter().find(|k| k.id == kf_id)
.and_then(|k| k.proxy.as_ref())
{
gpu_brush.ensure_proxy_texture(device, queue, kf_id, &p.pixels, p.width, p.height);
}
}
}
let src = if has_full {
gpu_brush.raster_layer_cache.get(&kf_id).map(|c| (c, c.width, c.height, false))
} else {
gpu_brush.get_proxy_texture(&kf_id).map(|c| (c, lw, lh, true))
};
if let Some((canvas, sw, sh, is_proxy)) = src {
let bt = crate::gpu_brush::BlitTransform::new(*layer_transform, sw, sh, width, height)
.with_tint(tint[0], tint[1], tint[2]);
if is_proxy {
shared.canvas_blit.blit_smooth(device, queue, canvas.src_view(), gv, &bt, None);
} else {
shared.canvas_blit.blit(device, queue, canvas.src_view(), gv, &bt, None);
}
drew = true;
}
}
if drew {
let cl = lightningbeam_core::gpu::CompositorLayer::new(
gh, rendered_layer.opacity * opacity, rendered_layer.blend_mode,
);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("onion_ghost_encoder"),
});
shared.compositor.composite(device, queue, &mut enc, &[cl], &buffer_pool, hdr_view, None);
queue.submit(Some(enc.finish()));
}
}
buffer_pool.release(gh);
}
}
// Raster layer — GPU canvas blit directly to HDR (bypasses Vello).
// Tool override canvas (gpu_canvas_kf) takes priority over cached
// texture; if neither full-res source is present, the low-res proxy.
@ -11875,6 +11967,7 @@ impl PaneRenderer for StagePane {
instance_id: self.instance_id,
document: shared.action_executor.document_arc(),
tool_state: shared.tool_state.clone(),
onion: shared.onion,
active_layer_id: *shared.active_layer_id,
drag_delta,
selection: shared.selection.clone(),