fix color space for effects and enable them in video export
This commit is contained in:
parent
7eb61ab0a8
commit
c8a5cbfc89
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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::*;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue