fix color space for effects and enable them in video export

This commit is contained in:
Skyler Lehmkuhl 2025-12-08 10:20:50 -05:00
parent 7eb61ab0a8
commit c8a5cbfc89
8 changed files with 894 additions and 51 deletions

View File

@ -55,7 +55,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Vector(vl) => &vl.clip_instances,
AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances AnyLayer::Effect(el) => &el.clip_instances,
}; };
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -94,7 +94,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Vector(vl) => &vl.clip_instances,
AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances AnyLayer::Effect(el) => &el.clip_instances,
}; };
let instance = clip_instances.iter() let instance = clip_instances.iter()
@ -141,7 +141,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Vector(vl) => &mut vl.clip_instances,
AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances,
AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances,
AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances AnyLayer::Effect(el) => &mut el.clip_instances,
}; };
// Update timeline_start for each clip instance // Update timeline_start for each clip instance
@ -166,7 +166,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Vector(vl) => &mut vl.clip_instances,
AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances,
AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances,
AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances AnyLayer::Effect(el) => &mut el.clip_instances,
}; };
// Restore original timeline_start for each clip instance // Restore original timeline_start for each clip instance

View File

@ -98,7 +98,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Vector(vl) => &vl.clip_instances,
AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Effect(_) => continue, AnyLayer::Effect(el) => &el.clip_instances,
}; };
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -133,7 +133,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Vector(vl) => &vl.clip_instances,
AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Effect(_) => continue, AnyLayer::Effect(el) => &el.clip_instances,
}; };
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -175,7 +175,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Vector(vl) => &vl.clip_instances,
AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances AnyLayer::Effect(el) => &el.clip_instances,
}; };
let instance = clip_instances.iter() let instance = clip_instances.iter()
@ -266,7 +266,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Vector(vl) => &mut vl.clip_instances,
AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances,
AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances,
AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances AnyLayer::Effect(el) => &mut el.clip_instances,
}; };
// Apply trims // Apply trims
@ -304,7 +304,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Vector(vl) => &mut vl.clip_instances,
AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances,
AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances,
AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances AnyLayer::Effect(el) => &mut el.clip_instances,
}; };
// Restore original trim values // Restore original trim values

View File

@ -0,0 +1,215 @@
//! Color space conversion pipelines for GPU rendering
//!
//! Provides sRGB ↔ linear color space conversion passes for the HDR compositing pipeline.
//! These are used to convert Vello's sRGB output to linear HDR for compositing,
//! and to convert the final HDR result back to sRGB for display.
use super::HDR_FORMAT;
/// GPU pipeline for sRGB to linear color space conversion
///
/// Converts Rgba8Srgb textures to Rgba16Float linear textures.
/// Used after Vello rendering to prepare layers for HDR compositing.
pub struct SrgbToLinearConverter {
pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
}
impl SrgbToLinearConverter {
/// Create a new sRGB to linear converter
pub fn new(device: &wgpu::Device) -> Self {
// Create bind group layout
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("srgb_to_linear_bind_group_layout"),
entries: &[
// Source sRGB texture
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,
},
// Sampler
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
// Create pipeline layout
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("srgb_to_linear_pipeline_layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
// Create shader module
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("srgb_to_linear_shader"),
source: wgpu::ShaderSource::Wgsl(SRGB_TO_LINEAR_SHADER.into()),
});
// Create render pipeline - outputs to HDR format
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("srgb_to_linear_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: HDR_FORMAT,
blend: None, // No blending - direct write
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
// Create sampler
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("srgb_to_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::Nearest,
..Default::default()
});
Self {
pipeline,
bind_group_layout,
sampler,
}
}
/// Convert an sRGB texture to linear HDR
///
/// Reads from `source_view` (sRGB) and writes to `dest_view` (HDR linear).
pub fn convert(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
source_view: &wgpu::TextureView,
dest_view: &wgpu::TextureView,
) {
// Create bind group for this conversion
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("srgb_to_linear_bind_group"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
// Render pass
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("srgb_to_linear_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: dest_view,
resolve_target: 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,
});
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &bind_group, &[]);
render_pass.draw(0..4, 0..1);
}
}
/// WGSL shader for sRGB to linear conversion
const SRGB_TO_LINEAR_SHADER: &str = r#"
// sRGB to Linear color space conversion shader
@group(0) @binding(0) var source_tex: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// Fullscreen triangle strip
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput;
let x = f32((vertex_index & 1u) << 1u);
let y = f32(vertex_index & 2u);
out.position = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
out.uv = vec2<f32>(x, y);
return out;
}
// sRGB to linear color space conversion (per channel)
fn srgb_to_linear_channel(c: f32) -> f32 {
return select(
pow((c + 0.055) / 1.055, 2.4),
c / 12.92,
c <= 0.04045
);
}
fn srgb_to_linear(color: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
srgb_to_linear_channel(color.r),
srgb_to_linear_channel(color.g),
srgb_to_linear_channel(color.b)
);
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(source_tex, source_sampler, in.uv);
// Convert sRGB to linear
let linear_rgb = srgb_to_linear(src.rgb);
// Alpha stays unchanged
return vec4<f32>(linear_rgb, src.a);
}
"#;

View File

@ -406,25 +406,9 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
return out; return out;
} }
// sRGB to linear color space conversion
// Vello outputs sRGB-encoded colors, we need linear for correct HDR blending
fn srgb_to_linear_channel(c: f32) -> f32 {
return select(
pow((c + 0.055) / 1.055, 2.4),
c / 12.92,
c <= 0.04045
);
}
fn srgb_to_linear(color: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
srgb_to_linear_channel(color.r),
srgb_to_linear_channel(color.g),
srgb_to_linear_channel(color.b)
);
}
// Blend mode implementations // Blend mode implementations
// NOTE: All inputs are expected to be in linear HDR color space.
// sRGB to linear conversion happens in a separate pass before compositing.
fn blend_normal(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> { fn blend_normal(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return src; return src;
} }
@ -537,13 +521,11 @@ fn apply_blend(src: vec3<f32>, dst: vec3<f32>, mode: u32) -> vec3<f32> {
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(source_tex, source_sampler, in.uv); let src = textureSample(source_tex, source_sampler, in.uv);
// Convert Vello's sRGB output to linear for correct HDR blending // Input is already in linear HDR color space (converted in separate pass)
let linear_rgb = srgb_to_linear(src.rgb);
// Apply opacity // Apply opacity
let src_alpha = src.a * uniforms.opacity; let src_alpha = src.a * uniforms.opacity;
// Output premultiplied alpha in linear color space // Output premultiplied alpha in linear color space
return vec4<f32>(linear_rgb * src_alpha, src_alpha); return vec4<f32>(src.rgb * src_alpha, src_alpha);
} }
"#; "#;

View File

@ -4,13 +4,16 @@
// - Buffer pooling for efficient render target management // - Buffer pooling for efficient render target management
// - Compositor for layer blending with proper opacity // - Compositor for layer blending with proper opacity
// - Effect pipeline for GPU shader effects // - Effect pipeline for GPU shader effects
// - Color space conversion (sRGB ↔ linear)
pub mod buffer_pool; pub mod buffer_pool;
pub mod color_convert;
pub mod compositor; pub mod compositor;
pub mod effect_processor; pub mod effect_processor;
// Re-export commonly used types // Re-export commonly used types
pub use buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat}; pub use buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat};
pub use color_convert::SrgbToLinearConverter;
pub use compositor::{Compositor, CompositorLayer, BlendMode}; pub use compositor::{Compositor, CompositorLayer, BlendMode};
pub use effect_processor::{EffectProcessor, EffectUniforms}; pub use effect_processor::{EffectProcessor, EffectUniforms};

View File

@ -42,6 +42,8 @@ pub struct VideoExportState {
height: u32, height: u32,
/// Channel to send rendered frames to encoder thread /// Channel to send rendered frames to encoder thread
frame_tx: Option<Sender<VideoFrameMessage>>, frame_tx: Option<Sender<VideoFrameMessage>>,
/// HDR GPU resources for compositing pipeline (effects, color conversion)
gpu_resources: Option<video_exporter::ExportGpuResources>,
} }
/// Export orchestrator that manages the export process /// Export orchestrator that manages the export process
@ -619,6 +621,7 @@ impl ExportOrchestrator {
self.thread_handle = Some(handle); self.thread_handle = Some(handle);
// Initialize video export state // Initialize video export state
// GPU resources will be initialized lazily on first frame (needs device)
self.video_state = Some(VideoExportState { self.video_state = Some(VideoExportState {
current_frame: 0, current_frame: 0,
total_frames, total_frames,
@ -628,6 +631,7 @@ impl ExportOrchestrator {
width, width,
height, height,
frame_tx: Some(frame_tx), frame_tx: Some(frame_tx),
gpu_resources: None,
}); });
println!("🎬 [VIDEO EXPORT] Encoder thread spawned, ready for frames"); println!("🎬 [VIDEO EXPORT] Encoder thread spawned, ready for frames");
@ -741,6 +745,7 @@ impl ExportOrchestrator {
}); });
// Initialize video export state for incremental rendering // Initialize video export state for incremental rendering
// GPU resources will be initialized lazily on first frame (needs device)
self.video_state = Some(VideoExportState { self.video_state = Some(VideoExportState {
current_frame: 0, current_frame: 0,
total_frames, total_frames,
@ -750,6 +755,7 @@ impl ExportOrchestrator {
width: video_width, width: video_width,
height: video_height, height: video_height,
frame_tx: Some(frame_tx), frame_tx: Some(frame_tx),
gpu_resources: None,
}); });
// Initialize parallel export state // Initialize parallel export state
@ -800,6 +806,8 @@ impl ExportOrchestrator {
if let Some(tx) = state.frame_tx.take() { if let Some(tx) = state.frame_tx.take() {
tx.send(VideoFrameMessage::Done).ok(); tx.send(VideoFrameMessage::Done).ok();
} }
// Clean up GPU resources
state.gpu_resources = None;
return Ok(false); return Ok(false);
} }
@ -810,9 +818,16 @@ impl ExportOrchestrator {
let width = state.width; let width = state.width;
let height = state.height; let height = state.height;
// Render frame to RGBA buffer // Initialize GPU resources on first frame (needs device)
if state.gpu_resources.is_none() {
println!("🎬 [VIDEO EXPORT] Initializing HDR GPU resources for {}x{}", width, height);
state.gpu_resources = Some(video_exporter::ExportGpuResources::new(device, width, height));
}
// Render frame to RGBA buffer using HDR pipeline (with effects)
let mut rgba_buffer = vec![0u8; (width * height * 4) as usize]; let mut rgba_buffer = vec![0u8; (width * height * 4) as usize];
video_exporter::render_frame_to_rgba( let gpu_resources = state.gpu_resources.as_mut().unwrap();
video_exporter::render_frame_to_rgba_hdr(
document, document,
timestamp, timestamp,
width, width,
@ -822,6 +837,7 @@ impl ExportOrchestrator {
renderer, renderer,
image_cache, image_cache,
video_manager, video_manager,
gpu_resources,
&mut rgba_buffer, &mut rgba_buffer,
)?; )?;

View File

@ -8,8 +8,12 @@
use ffmpeg_next as ffmpeg; use ffmpeg_next as ffmpeg;
use std::sync::Arc; use std::sync::Arc;
use lightningbeam_core::document::Document; use lightningbeam_core::document::Document;
use lightningbeam_core::renderer::ImageCache; use lightningbeam_core::renderer::{ImageCache, render_document_for_compositing, RenderedLayerType};
use lightningbeam_core::video::VideoManager; use lightningbeam_core::video::VideoManager;
use lightningbeam_core::gpu::{
BufferPool, BufferSpec, BufferFormat, Compositor, CompositorLayer,
SrgbToLinearConverter, EffectProcessor, HDR_FORMAT,
};
/// Reusable frame buffers to avoid allocations /// Reusable frame buffers to avoid allocations
struct FrameBuffers { struct FrameBuffers {
@ -39,6 +43,227 @@ impl FrameBuffers {
} }
} }
/// GPU resources for HDR export pipeline
///
/// This mirrors the resources in stage.rs SharedVelloResources but is owned
/// by the export system to avoid lifetime/locking issues during export.
pub struct ExportGpuResources {
/// Buffer pool for intermediate render targets
pub buffer_pool: BufferPool,
/// HDR compositor for layer blending
pub compositor: Compositor,
/// sRGB to linear color converter
pub srgb_to_linear: SrgbToLinearConverter,
/// Effect processor for shader effects
pub effect_processor: EffectProcessor,
/// HDR accumulator texture for compositing
pub hdr_texture: wgpu::Texture,
/// View for HDR texture
pub hdr_texture_view: wgpu::TextureView,
/// Linear to sRGB blit pipeline for final output
pub linear_to_srgb_pipeline: wgpu::RenderPipeline,
/// Bind group layout for linear to sRGB blit
pub linear_to_srgb_bind_group_layout: wgpu::BindGroupLayout,
/// Sampler for linear to sRGB conversion
pub linear_to_srgb_sampler: wgpu::Sampler,
}
impl ExportGpuResources {
/// Create new export GPU resources for the given dimensions
pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
let buffer_pool = BufferPool::new();
let compositor = Compositor::new(device, HDR_FORMAT);
let srgb_to_linear = SrgbToLinearConverter::new(device);
let effect_processor = EffectProcessor::new(device, HDR_FORMAT);
// Create HDR accumulator texture
let hdr_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("export_hdr_texture"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: HDR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let hdr_texture_view = hdr_texture.create_view(&wgpu::TextureViewDescriptor::default());
// Create linear to sRGB blit pipeline
let linear_to_srgb_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("linear_to_srgb_bind_group_layout"),
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,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("linear_to_srgb_pipeline_layout"),
bind_group_layouts: &[&linear_to_srgb_bind_group_layout],
push_constant_ranges: &[],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("linear_to_srgb_shader"),
source: wgpu::ShaderSource::Wgsl(LINEAR_TO_SRGB_SHADER.into()),
});
let linear_to_srgb_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("linear_to_srgb_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let linear_to_srgb_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("linear_to_srgb_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::Nearest,
..Default::default()
});
Self {
buffer_pool,
compositor,
srgb_to_linear,
effect_processor,
hdr_texture,
hdr_texture_view,
linear_to_srgb_pipeline,
linear_to_srgb_bind_group_layout,
linear_to_srgb_sampler,
}
}
/// Resize the HDR texture if dimensions changed
pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
self.hdr_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("export_hdr_texture"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: HDR_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
self.hdr_texture_view = self.hdr_texture.create_view(&wgpu::TextureViewDescriptor::default());
}
}
/// WGSL shader for linear to sRGB conversion (for final export output)
const LINEAR_TO_SRGB_SHADER: &str = r#"
// Linear to sRGB color space conversion shader
@group(0) @binding(0) var source_tex: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// Fullscreen triangle strip
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput;
let x = f32((vertex_index & 1u) << 1u);
let y = f32(vertex_index & 2u);
out.position = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
out.uv = vec2<f32>(x, y);
return out;
}
// Linear to sRGB color space conversion (per channel)
fn linear_to_srgb_channel(c: f32) -> f32 {
return select(
1.055 * pow(c, 1.0 / 2.4) - 0.055,
c * 12.92,
c <= 0.0031308
);
}
fn linear_to_srgb(color: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
linear_to_srgb_channel(color.r),
linear_to_srgb_channel(color.g),
linear_to_srgb_channel(color.b)
);
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(source_tex, source_sampler, in.uv);
// Convert linear HDR to sRGB
let srgb = linear_to_srgb(src.rgb);
// Alpha stays unchanged
return vec4<f32>(srgb, src.a);
}
"#;
/// Convert RGBA8 pixels to YUV420p format using BT.709 color space /// Convert RGBA8 pixels to YUV420p format using BT.709 color space
/// ///
/// # Arguments /// # Arguments
@ -419,6 +644,375 @@ pub fn render_frame_to_rgba(
Ok(()) Ok(())
} }
/// Render a document frame using the HDR compositing pipeline with effects
///
/// This function uses the same rendering pipeline as the stage preview,
/// ensuring effects are applied correctly during export.
///
/// # Arguments
/// * `document` - Document to render (current_time will be modified)
/// * `timestamp` - Time in seconds to render at
/// * `width` - Frame width in pixels
/// * `height` - Frame height in pixels
/// * `device` - wgpu device
/// * `queue` - wgpu queue
/// * `renderer` - Vello renderer
/// * `image_cache` - Image cache for rendering
/// * `video_manager` - Video manager for video clips
/// * `gpu_resources` - HDR GPU resources for compositing
/// * `rgba_buffer` - Output buffer for RGBA pixels (must be width * height * 4 bytes)
///
/// # Returns
/// Ok(()) on success, Err with message on failure
pub fn render_frame_to_rgba_hdr(
document: &mut Document,
timestamp: f64,
width: u32,
height: u32,
device: &wgpu::Device,
queue: &wgpu::Queue,
renderer: &mut vello::Renderer,
image_cache: &mut ImageCache,
video_manager: &Arc<std::sync::Mutex<VideoManager>>,
gpu_resources: &mut ExportGpuResources,
rgba_buffer: &mut [u8],
) -> Result<(), String> {
use vello::kurbo::Affine;
// Set document time to the frame timestamp
document.current_time = timestamp;
// Use identity transform for export (document coordinates = pixel coordinates)
let base_transform = Affine::IDENTITY;
// Render document for compositing (returns per-layer scenes)
let composite_result = render_document_for_compositing(
document,
base_transform,
image_cache,
video_manager,
);
// Buffer specs for layer rendering
let layer_spec = BufferSpec::new(width, height, BufferFormat::Rgba8Srgb);
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
// Render parameters for Vello (transparent background for layers)
let layer_render_params = vello::RenderParams {
base_color: vello::peniko::Color::TRANSPARENT,
width,
height,
antialiasing_method: vello::AaConfig::Area,
};
// First, render background and composite it
let bg_srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
let bg_hdr_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let (Some(bg_srgb_view), Some(bg_hdr_view)) = (
gpu_resources.buffer_pool.get_view(bg_srgb_handle),
gpu_resources.buffer_pool.get_view(bg_hdr_handle),
) {
// Render background scene
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &layer_render_params)
.map_err(|e| format!("Failed to render background: {}", e))?;
// Convert sRGB to linear HDR
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_bg_srgb_to_linear_encoder"),
});
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view);
queue.submit(Some(convert_encoder.finish()));
// Composite background onto HDR texture (first layer, clears to black for export)
let bg_compositor_layer = CompositorLayer::normal(bg_hdr_handle, 1.0);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_bg_composite_encoder"),
});
// Clear to black for export (unlike stage preview which has gray background)
gpu_resources.compositor.composite(
device,
queue,
&mut encoder,
&[bg_compositor_layer],
&gpu_resources.buffer_pool,
&gpu_resources.hdr_texture_view,
Some([0.0, 0.0, 0.0, 1.0]),
);
queue.submit(Some(encoder.finish()));
}
gpu_resources.buffer_pool.release(bg_srgb_handle);
gpu_resources.buffer_pool.release(bg_hdr_handle);
// Now render and composite each layer incrementally
for rendered_layer in &composite_result.layers {
if !rendered_layer.has_content {
continue;
}
match &rendered_layer.layer_type {
RenderedLayerType::Content => {
// Regular content layer - render to sRGB, convert to linear, then composite
let srgb_handle = gpu_resources.buffer_pool.acquire(device, layer_spec);
let hdr_layer_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let (Some(srgb_view), Some(hdr_layer_view)) = (
gpu_resources.buffer_pool.get_view(srgb_handle),
gpu_resources.buffer_pool.get_view(hdr_layer_handle),
) {
// Render layer scene to sRGB buffer
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params)
.map_err(|e| format!("Failed to render layer: {}", e))?;
// Convert sRGB to linear HDR
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_layer_srgb_to_linear_encoder"),
});
gpu_resources.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
queue.submit(Some(convert_encoder.finish()));
// Composite this layer onto the HDR accumulator with its opacity
let compositor_layer = CompositorLayer::new(
hdr_layer_handle,
rendered_layer.opacity,
rendered_layer.blend_mode,
);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_layer_composite_encoder"),
});
gpu_resources.compositor.composite(
device,
queue,
&mut encoder,
&[compositor_layer],
&gpu_resources.buffer_pool,
&gpu_resources.hdr_texture_view,
None, // Don't clear - blend onto existing content
);
queue.submit(Some(encoder.finish()));
}
gpu_resources.buffer_pool.release(srgb_handle);
gpu_resources.buffer_pool.release(hdr_layer_handle);
}
RenderedLayerType::Effect { effect_instances } => {
// Effect layer - apply effects to the current HDR accumulator
let current_time = document.current_time;
for effect_instance in effect_instances {
// Get effect definition from document
let Some(effect_def) = document.get_effect_definition(&effect_instance.clip_id) else {
continue;
};
// Compile effect if needed
if !gpu_resources.effect_processor.is_compiled(&effect_def.id) {
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
if !success {
eprintln!("Failed to compile effect: {}", effect_def.name);
continue;
}
}
// Create EffectInstance from ClipInstance for the processor
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def,
effect_instance.timeline_start,
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION),
);
// Acquire temp buffer for effect output (HDR format)
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_effect_encoder"),
});
// Apply effect: HDR accumulator → effect output buffer
let applied = gpu_resources.effect_processor.apply_effect(
device,
queue,
&mut encoder,
effect_def,
&effect_inst,
&gpu_resources.hdr_texture_view,
effect_output_view,
width,
height,
current_time,
);
if applied {
queue.submit(Some(encoder.finish()));
// Copy effect output back to HDR accumulator
let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_effect_copy_encoder"),
});
// Use compositor to copy (replacing content)
let effect_layer = CompositorLayer::normal(
effect_output_handle,
rendered_layer.opacity, // Apply effect layer opacity
);
gpu_resources.compositor.composite(
device,
queue,
&mut copy_encoder,
&[effect_layer],
&gpu_resources.buffer_pool,
&gpu_resources.hdr_texture_view,
Some([0.0, 0.0, 0.0, 0.0]), // Clear with transparent (we're replacing)
);
queue.submit(Some(copy_encoder.finish()));
}
}
gpu_resources.buffer_pool.release(effect_output_handle);
}
}
}
}
// Advance frame counter for buffer cleanup
gpu_resources.buffer_pool.next_frame();
// Create output texture for final sRGB output
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("export_output_texture"),
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::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
// Convert HDR to sRGB for output
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("export_linear_to_srgb_bind_group"),
layout: &gpu_resources.linear_to_srgb_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&gpu_resources.hdr_texture_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&gpu_resources.linear_to_srgb_sampler),
},
],
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_linear_to_srgb_encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("export_linear_to_srgb_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &output_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
});
render_pass.set_pipeline(&gpu_resources.linear_to_srgb_pipeline);
render_pass.set_bind_group(0, &bind_group, &[]);
render_pass.draw(0..4, 0..1);
}
queue.submit(Some(encoder.finish()));
// GPU readback: Create staging buffer with proper alignment
let bytes_per_pixel = 4u32; // RGBA8
let bytes_per_row_alignment = 256u32;
let unpadded_bytes_per_row = width * bytes_per_pixel;
let bytes_per_row = ((unpadded_bytes_per_row + bytes_per_row_alignment - 1)
/ bytes_per_row_alignment) * bytes_per_row_alignment;
let buffer_size = (bytes_per_row * height) as u64;
let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export_staging_buffer"),
size: buffer_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Copy texture to staging buffer
let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("export_copy_encoder"),
});
copy_encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &output_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &staging_buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(height),
},
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
queue.submit(Some(copy_encoder.finish()));
// Map buffer and read pixels (synchronous)
let buffer_slice = staging_buffer.slice(..);
let (sender, receiver) = std::sync::mpsc::channel();
buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
sender.send(result).ok();
});
device.poll(wgpu::Maintain::Wait);
receiver
.recv()
.map_err(|_| "Failed to receive buffer mapping result")?
.map_err(|e| format!("Failed to map buffer: {:?}", e))?;
// Copy data from mapped buffer to output, removing padding
let data = buffer_slice.get_mapped_range();
for y in 0..height as usize {
let src_offset = y * bytes_per_row as usize;
let dst_offset = y * unpadded_bytes_per_row as usize;
let row_bytes = unpadded_bytes_per_row as usize;
rgba_buffer[dst_offset..dst_offset + row_bytes]
.copy_from_slice(&data[src_offset..src_offset + row_bytes]);
}
drop(data);
staging_buffer.unmap();
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -6,7 +6,7 @@
use eframe::egui; use eframe::egui;
use lightningbeam_core::action::Action; use lightningbeam_core::action::Action;
use lightningbeam_core::clip::ClipInstance; use lightningbeam_core::clip::ClipInstance;
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, HDR_FORMAT}; use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, HDR_FORMAT, SrgbToLinearConverter};
use lightningbeam_core::layer::{AnyLayer, AudioLayer, AudioLayerType, VideoLayer, VectorLayer}; use lightningbeam_core::layer::{AnyLayer, AudioLayer, AudioLayerType, VideoLayer, VectorLayer};
use lightningbeam_core::renderer::RenderedLayerType; use lightningbeam_core::renderer::RenderedLayerType;
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
@ -35,6 +35,8 @@ struct SharedVelloResources {
compositor: Compositor, compositor: Compositor,
/// Effect processor for GPU shader effects /// Effect processor for GPU shader effects
effect_processor: Mutex<EffectProcessor>, effect_processor: Mutex<EffectProcessor>,
/// sRGB to linear color converter (for Vello output)
srgb_to_linear: SrgbToLinearConverter,
} }
/// Per-instance Vello resources (created for each Stage pane) /// Per-instance Vello resources (created for each Stage pane)
@ -202,7 +204,10 @@ impl SharedVelloResources {
// Initialize effect processor for GPU shader effects // Initialize effect processor for GPU shader effects
let effect_processor = EffectProcessor::new(device, lightningbeam_core::gpu::HDR_FORMAT); let effect_processor = EffectProcessor::new(device, lightningbeam_core::gpu::HDR_FORMAT);
println!("✅ Vello shared resources initialized (renderer, shaders, HDR compositor, and effect processor)"); // Initialize sRGB to linear converter for Vello output
let srgb_to_linear = SrgbToLinearConverter::new(device);
println!("✅ Vello shared resources initialized (renderer, shaders, HDR compositor, effect processor, and color converter)");
Ok(Self { Ok(Self {
renderer: Arc::new(Mutex::new(renderer)), renderer: Arc::new(Mutex::new(renderer)),
@ -215,6 +220,7 @@ impl SharedVelloResources {
buffer_pool: Mutex::new(buffer_pool), buffer_pool: Mutex::new(buffer_pool),
compositor, compositor,
effect_processor: Mutex::new(effect_processor), effect_processor: Mutex::new(effect_processor),
srgb_to_linear,
}) })
} }
} }
@ -460,11 +466,19 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
antialiasing_method: vello::AaConfig::Msaa16, antialiasing_method: vello::AaConfig::Msaa16,
}; };
// HDR buffer spec for linear buffers
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
// First, render background and composite it // First, render background and composite it
// The background scene contains only a rectangle at document bounds, // The background scene contains only a rectangle at document bounds,
// so we use TRANSPARENT base_color to not fill the whole viewport // so we use TRANSPARENT base_color to not fill the whole viewport
let bg_handle = buffer_pool.acquire(device, layer_spec); let bg_srgb_handle = buffer_pool.acquire(device, layer_spec);
if let (Some(bg_view), Some(hdr_view)) = (buffer_pool.get_view(bg_handle), &instance_resources.hdr_texture_view) { let bg_hdr_handle = buffer_pool.acquire(device, hdr_spec);
if let (Some(bg_srgb_view), Some(bg_hdr_view), Some(hdr_view)) = (
buffer_pool.get_view(bg_srgb_handle),
buffer_pool.get_view(bg_hdr_handle),
&instance_resources.hdr_texture_view,
) {
// Render background scene with transparent base (scene has the bg rect) // Render background scene with transparent base (scene has the bg rect)
let bg_render_params = vello::RenderParams { let bg_render_params = vello::RenderParams {
base_color: vello::peniko::Color::TRANSPARENT, base_color: vello::peniko::Color::TRANSPARENT,
@ -474,15 +488,23 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
}; };
if let Ok(mut renderer) = shared.renderer.lock() { if let Ok(mut renderer) = shared.renderer.lock() {
renderer.render_to_texture(device, queue, &composite_result.background, bg_view, &bg_render_params).ok(); renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &bg_render_params).ok();
} }
// Convert sRGB to linear HDR
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("bg_srgb_to_linear_encoder"),
});
shared.srgb_to_linear.convert(device, &mut convert_encoder, bg_srgb_view, bg_hdr_view);
queue.submit(Some(convert_encoder.finish()));
// Composite background onto HDR texture (first layer, clears to dark gray for stage area) // Composite background onto HDR texture (first layer, clears to dark gray for stage area)
let bg_compositor_layer = lightningbeam_core::gpu::CompositorLayer::normal(bg_handle, 1.0); let bg_compositor_layer = lightningbeam_core::gpu::CompositorLayer::normal(bg_hdr_handle, 1.0);
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("bg_composite_encoder"), label: Some("bg_composite_encoder"),
}); });
// Clear to dark gray (stage background outside document bounds) // Clear to dark gray (stage background outside document bounds)
// Note: stage_bg values are already in linear space for HDR compositing
let stage_bg = [45.0 / 255.0, 45.0 / 255.0, 48.0 / 255.0, 1.0]; let stage_bg = [45.0 / 255.0, 45.0 / 255.0, 48.0 / 255.0, 1.0];
shared.compositor.composite( shared.compositor.composite(
device, device,
@ -495,10 +517,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
); );
queue.submit(Some(encoder.finish())); queue.submit(Some(encoder.finish()));
} }
buffer_pool.release(bg_handle); buffer_pool.release(bg_srgb_handle);
buffer_pool.release(bg_hdr_handle);
// HDR buffer spec for effect processing
let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float);
// Lock effect processor // Lock effect processor
let mut effect_processor = shared.effect_processor.lock().unwrap(); let mut effect_processor = shared.effect_processor.lock().unwrap();
@ -511,18 +531,30 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
match &rendered_layer.layer_type { match &rendered_layer.layer_type {
RenderedLayerType::Content => { RenderedLayerType::Content => {
// Regular content layer - render and composite as before // Regular content layer - render to sRGB, convert to linear, then composite
let layer_handle = buffer_pool.acquire(device, layer_spec); let srgb_handle = buffer_pool.acquire(device, layer_spec);
let hdr_layer_handle = buffer_pool.acquire(device, hdr_spec);
if let (Some(layer_view), Some(hdr_view)) = (buffer_pool.get_view(layer_handle), &instance_resources.hdr_texture_view) { if let (Some(srgb_view), Some(hdr_layer_view), Some(hdr_view)) = (
// Render layer scene to buffer buffer_pool.get_view(srgb_handle),
buffer_pool.get_view(hdr_layer_handle),
&instance_resources.hdr_texture_view,
) {
// Render layer scene to sRGB buffer
if let Ok(mut renderer) = shared.renderer.lock() { if let Ok(mut renderer) = shared.renderer.lock() {
renderer.render_to_texture(device, queue, &rendered_layer.scene, layer_view, &layer_render_params).ok(); renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params).ok();
} }
// Convert sRGB to linear HDR
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("layer_srgb_to_linear_encoder"),
});
shared.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
queue.submit(Some(convert_encoder.finish()));
// Composite this layer onto the HDR accumulator with its opacity // Composite this layer onto the HDR accumulator with its opacity
let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new( let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new(
layer_handle, hdr_layer_handle,
rendered_layer.opacity, rendered_layer.opacity,
rendered_layer.blend_mode, rendered_layer.blend_mode,
); );
@ -542,7 +574,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
queue.submit(Some(encoder.finish())); queue.submit(Some(encoder.finish()));
} }
buffer_pool.release(layer_handle); buffer_pool.release(srgb_handle);
buffer_pool.release(hdr_layer_handle);
} }
RenderedLayerType::Effect { effect_instances } => { RenderedLayerType::Effect { effect_instances } => {
// Effect layer - apply effects to the current HDR accumulator // Effect layer - apply effects to the current HDR accumulator