Onion skinning: vector-layer ghosts (tinted)

- Core compositor gains an optional screen-blend tint per CompositorLayer
  ([0,0,0,0] default = no-op, so existing compositing is unaffected);
  CompositorLayer::with_tint sets it — giving the Vello/vector path a tint hook.
- For the active VECTOR layer with onion on, build ghost scenes at each neighbouring
  keyframe's time via render_layer_isolated (reusing the prepare's image cache), then
  render each scene → sRGB → linear → composite with warm/cool tint + opacity falloff,
  behind the current frame. Off during playback; active layer only.

Completes onion skinning for all layer types (raster + vector).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-20 23:49:33 -04:00
parent c2c7aefad1
commit 10b4aa481e
2 changed files with 99 additions and 2 deletions

View File

@ -105,6 +105,9 @@ pub struct CompositorLayer {
pub opacity: f32,
/// Blend mode for this layer
pub blend_mode: BlendMode,
/// Screen-blend RGB tint; `[0,0,0,0]` (the default) is a no-op. Used by
/// onion-skin ghosts (warm = past, cool = future).
pub tint: [f32; 4],
}
impl CompositorLayer {
@ -113,12 +116,19 @@ impl CompositorLayer {
buffer,
opacity: opacity.clamp(0.0, 1.0),
blend_mode,
tint: [0.0; 4],
}
}
pub fn normal(buffer: BufferHandle, opacity: f32) -> Self {
Self::new(buffer, opacity, BlendMode::Normal)
}
/// Screen-blend the layer toward an RGB tint (for onion-skin ghosts).
pub fn with_tint(mut self, r: f32, g: f32, b: f32) -> Self {
self.tint = [r, g, b, 0.0];
self
}
}
/// Uniform data for the composite shader
@ -129,8 +139,10 @@ pub struct CompositeUniforms {
pub opacity: f32,
/// Blend mode index
pub blend_mode: u32,
/// Padding for alignment
/// Padding to 16 bytes before the vec4 tint
pub _padding: [u32; 2],
/// Screen-blend tint ((0,0,0) = none); `.w` unused.
pub tint: [f32; 4],
}
/// Compositor for blending layers
@ -323,6 +335,7 @@ impl Compositor {
opacity: layer.opacity,
blend_mode: layer.blend_mode.to_index(),
_padding: [0, 0],
tint: layer.tint,
};
queue.write_buffer(&uniforms_buffer, 0, bytemuck::bytes_of(&uniforms));
@ -382,6 +395,8 @@ struct Uniforms {
opacity: f32,
blend_mode: u32,
_padding: vec2<u32>,
// Screen-blend tint ((0,0,0) = no tint). Used by onion-skin ghosts.
tint: vec4<f32>,
}
@group(0) @binding(0) var source_tex: texture_2d<f32>;
@ -526,7 +541,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Apply opacity
let src_alpha = src.a * uniforms.opacity;
// Screen-blend tint (recolors blacks toward the tint; no-op at tint=0).
let t = uniforms.tint.rgb;
let tinted = src.rgb + t - src.rgb * t;
// Output premultiplied alpha in linear color space
return vec4<f32>(src.rgb * src_alpha, src_alpha);
return vec4<f32>(tinted * src_alpha, src_alpha);
}
"#;

View File

@ -999,6 +999,50 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
true, // Draw checkerboard for transparent backgrounds in the UI
)
};
// Onion-skin ghost scenes for the active VECTOR layer (raster ghosts are
// handled in the render loop). Built at each neighbouring keyframe's time
// via the isolated-layer renderer; rendered + tinted before the active
// scene below. `ctx.onion.enabled` is already gated off during playback.
let mut onion_vector_ghosts: Vec<(vello::Scene, [f32; 3], f32)> = Vec::new();
if self.ctx.onion.enabled && !shared.is_cpu_renderer {
if let Some(active_id) = self.ctx.active_layer_id {
if let Some(layer) = self.ctx.document.get_layer(&active_id) {
if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer {
let onion = self.ctx.onion;
let after = vl.keyframes.partition_point(|kf| kf.time <= self.ctx.playback_time);
if after > 0 {
let cur = after - 1;
let mut want: Vec<(usize, [f32; 3], f32)> = Vec::new();
for d in 1..=onion.frames_before {
if cur >= d {
want.push((cur - d, crate::panes::OnionSkinSettings::PAST_TINT,
onion.ghost_opacity(d, onion.frames_before)));
}
}
for d in 1..=onion.frames_after {
if cur + d < vl.keyframes.len() {
want.push((cur + d, crate::panes::OnionSkinSettings::FUTURE_TINT,
onion.ghost_opacity(d, onion.frames_after)));
}
}
for (idx, tint, op) in want {
let kf_time = vl.keyframes[idx].time;
let rl = lightningbeam_core::renderer::render_layer_isolated(
&self.ctx.document, kf_time, layer, camera_transform,
&mut image_cache, &shared.video_manager,
self.ctx.webcam_frame.as_ref(),
);
if rl.has_content {
onion_vector_ghosts.push((rl.scene, tint, op));
}
}
}
}
}
}
}
drop(image_cache);
let _t_after_scene_build = std::time::Instant::now();
@ -1252,6 +1296,40 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
match &rendered_layer.layer_type {
RenderedLayerType::Vector => {
// Onion-skin ghosts for the active vector layer: each neighbour
// scene → sRGB → linear → composite with a screen tint + falloff,
// behind the current frame.
if Some(rendered_layer.layer_id) == self.ctx.active_layer_id {
for (ghost_scene, tint, opacity) in &onion_vector_ghosts {
let gsrgb = buffer_pool.acquire(device, layer_spec);
let ghdr = buffer_pool.acquire(device, hdr_spec);
if let (Some(gsrgb_view), Some(ghdr_view), Some(hdr_view)) = (
buffer_pool.get_view(gsrgb),
buffer_pool.get_view(ghdr),
&instance_resources.hdr_texture_view,
) {
if let Ok(mut renderer) = shared.renderer.lock() {
renderer.render_to_texture(device, queue, ghost_scene, gsrgb_view, &layer_render_params).ok();
}
let mut conv = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("onion_vec_ghost_convert"),
});
shared.srgb_to_linear.convert(device, &mut conv, gsrgb_view, ghdr_view);
queue.submit(Some(conv.finish()));
let cl = lightningbeam_core::gpu::CompositorLayer::new(
ghdr, rendered_layer.opacity * opacity, rendered_layer.blend_mode,
).with_tint(tint[0], tint[1], tint[2]);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("onion_vec_ghost_composite"),
});
shared.compositor.composite(device, queue, &mut enc, &[cl], &buffer_pool, hdr_view, None);
queue.submit(Some(enc.finish()));
}
buffer_pool.release(gsrgb);
buffer_pool.release(ghdr);
}
}
// Vector/group layer — render Vello scene → sRGB → linear → composite.
let srgb_handle = buffer_pool.acquire(device, layer_spec);
let hdr_layer_handle = buffer_pool.acquire(device, hdr_spec);