diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs index d434101..418c52f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs @@ -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, + // Screen-blend tint ((0,0,0) = no tint). Used by onion-skin ghosts. + tint: vec4, } @group(0) @binding(0) var source_tex: texture_2d; @@ -526,7 +541,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // 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(src.rgb * src_alpha, src_alpha); + return vec4(tinted * src_alpha, src_alpha); } "#; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 5842638..5fe99bc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -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);