Composite layers in HDR color space
This commit is contained in:
parent
2caea564ac
commit
420f3bf7b9
|
|
@ -10,6 +10,10 @@ serde_json = { workspace = true }
|
|||
# UI framework (for Color32 conversion)
|
||||
egui = "0.31"
|
||||
|
||||
# GPU rendering infrastructure
|
||||
wgpu = { workspace = true }
|
||||
bytemuck = { version = "1.14", features = ["derive"] }
|
||||
|
||||
# Geometry and rendering
|
||||
kurbo = { workspace = true }
|
||||
vello = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
// Buffer pool for efficient render target management
|
||||
//
|
||||
// Provides acquire/release semantics for GPU textures used in the compositing pipeline.
|
||||
// Buffers are reused when possible to minimize allocation overhead.
|
||||
|
||||
use wgpu;
|
||||
|
||||
/// Handle to a pooled render buffer
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct BufferHandle(pub(crate) u32);
|
||||
|
||||
impl BufferHandle {
|
||||
/// Returns the raw handle ID (for debugging)
|
||||
pub fn id(&self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Texture format for render buffers
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum BufferFormat {
|
||||
/// 8-bit linear (Vello output format - needs STORAGE_BINDING)
|
||||
/// Note: Using Rgba8Unorm instead of Rgba8UnormSrgb because sRGB doesn't support storage binding
|
||||
Rgba8Srgb,
|
||||
/// 16-bit float HDR (internal processing format)
|
||||
Rgba16Float,
|
||||
}
|
||||
|
||||
impl BufferFormat {
|
||||
/// Convert to wgpu texture format
|
||||
pub fn to_wgpu(&self) -> wgpu::TextureFormat {
|
||||
match self {
|
||||
// Use Rgba8Unorm for Vello compatibility (STORAGE_BINDING required)
|
||||
// Vello handles color space conversion internally
|
||||
BufferFormat::Rgba8Srgb => wgpu::TextureFormat::Rgba8Unorm,
|
||||
BufferFormat::Rgba16Float => wgpu::TextureFormat::Rgba16Float,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Specification for a render buffer
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct BufferSpec {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: BufferFormat,
|
||||
}
|
||||
|
||||
impl BufferSpec {
|
||||
pub fn new(width: u32, height: u32, format: BufferFormat) -> Self {
|
||||
Self { width, height, format }
|
||||
}
|
||||
|
||||
pub fn hdr(width: u32, height: u32) -> Self {
|
||||
Self::new(width, height, BufferFormat::Rgba16Float)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal pooled buffer storage
|
||||
struct PooledBuffer {
|
||||
handle: BufferHandle,
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
spec: BufferSpec,
|
||||
in_use: bool,
|
||||
/// Frame counter when last used (for cleanup)
|
||||
last_used_frame: u64,
|
||||
}
|
||||
|
||||
/// Buffer pool for render target management
|
||||
///
|
||||
/// Provides efficient allocation and reuse of GPU textures for the compositing pipeline.
|
||||
/// Buffers are acquired for rendering and released when no longer needed.
|
||||
pub struct BufferPool {
|
||||
buffers: Vec<PooledBuffer>,
|
||||
next_id: u32,
|
||||
current_frame: u64,
|
||||
/// Maximum number of unused frames before a buffer is eligible for cleanup
|
||||
max_unused_frames: u64,
|
||||
}
|
||||
|
||||
impl BufferPool {
|
||||
/// Create a new empty buffer pool
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buffers: Vec::new(),
|
||||
next_id: 0,
|
||||
current_frame: 0,
|
||||
max_unused_frames: 60, // ~1 second at 60fps
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire a buffer matching the given specification
|
||||
///
|
||||
/// Returns a handle to a buffer that can be used for rendering.
|
||||
/// The buffer may be newly created or reused from the pool.
|
||||
pub fn acquire(&mut self, device: &wgpu::Device, spec: BufferSpec) -> BufferHandle {
|
||||
// First, try to find a free buffer with matching spec
|
||||
for buffer in &mut self.buffers {
|
||||
if !buffer.in_use && buffer.spec == spec {
|
||||
buffer.in_use = true;
|
||||
buffer.last_used_frame = self.current_frame;
|
||||
return buffer.handle;
|
||||
}
|
||||
}
|
||||
|
||||
// No matching buffer found, create a new one
|
||||
let handle = BufferHandle(self.next_id);
|
||||
self.next_id += 1;
|
||||
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(&format!("pool_buffer_{}", handle.0)),
|
||||
size: wgpu::Extent3d {
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: spec.format.to_wgpu(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
self.buffers.push(PooledBuffer {
|
||||
handle,
|
||||
texture,
|
||||
view,
|
||||
spec,
|
||||
in_use: true,
|
||||
last_used_frame: self.current_frame,
|
||||
});
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
/// Release a buffer back to the pool
|
||||
///
|
||||
/// The buffer becomes available for reuse by future acquire calls.
|
||||
pub fn release(&mut self, handle: BufferHandle) {
|
||||
if let Some(buffer) = self.buffers.iter_mut().find(|b| b.handle == handle) {
|
||||
buffer.in_use = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the texture view for a buffer handle
|
||||
pub fn get_view(&self, handle: BufferHandle) -> Option<&wgpu::TextureView> {
|
||||
self.buffers
|
||||
.iter()
|
||||
.find(|b| b.handle == handle)
|
||||
.map(|b| &b.view)
|
||||
}
|
||||
|
||||
/// Get the texture for a buffer handle
|
||||
pub fn get_texture(&self, handle: BufferHandle) -> Option<&wgpu::Texture> {
|
||||
self.buffers
|
||||
.iter()
|
||||
.find(|b| b.handle == handle)
|
||||
.map(|b| &b.texture)
|
||||
}
|
||||
|
||||
/// Get the spec for a buffer handle
|
||||
pub fn get_spec(&self, handle: BufferHandle) -> Option<BufferSpec> {
|
||||
self.buffers
|
||||
.iter()
|
||||
.find(|b| b.handle == handle)
|
||||
.map(|b| b.spec)
|
||||
}
|
||||
|
||||
/// Check if a buffer is currently in use
|
||||
pub fn is_in_use(&self, handle: BufferHandle) -> bool {
|
||||
self.buffers
|
||||
.iter()
|
||||
.find(|b| b.handle == handle)
|
||||
.map(|b| b.in_use)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Advance to the next frame
|
||||
///
|
||||
/// Call this once per frame to track buffer usage over time.
|
||||
pub fn next_frame(&mut self) {
|
||||
self.current_frame += 1;
|
||||
}
|
||||
|
||||
/// Clear buffers that haven't been used for a while
|
||||
///
|
||||
/// Removes buffers that are not in use and haven't been used for
|
||||
/// more than `max_unused_frames` frames.
|
||||
pub fn clear_unused(&mut self) {
|
||||
let current = self.current_frame;
|
||||
let max_unused = self.max_unused_frames;
|
||||
|
||||
self.buffers.retain(|b| {
|
||||
b.in_use || (current - b.last_used_frame) < max_unused
|
||||
});
|
||||
}
|
||||
|
||||
/// Force clear all unused buffers immediately
|
||||
pub fn clear_all_unused(&mut self) {
|
||||
self.buffers.retain(|b| b.in_use);
|
||||
}
|
||||
|
||||
/// Get statistics about the pool
|
||||
pub fn stats(&self) -> BufferPoolStats {
|
||||
let total = self.buffers.len();
|
||||
let in_use = self.buffers.iter().filter(|b| b.in_use).count();
|
||||
let total_bytes: u64 = self.buffers.iter().map(|b| {
|
||||
let bytes_per_pixel = match b.spec.format {
|
||||
BufferFormat::Rgba8Srgb => 4,
|
||||
BufferFormat::Rgba16Float => 8,
|
||||
};
|
||||
(b.spec.width as u64) * (b.spec.height as u64) * bytes_per_pixel
|
||||
}).sum();
|
||||
|
||||
BufferPoolStats {
|
||||
total_buffers: total,
|
||||
buffers_in_use: in_use,
|
||||
total_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BufferPool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics about buffer pool usage
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BufferPoolStats {
|
||||
pub total_buffers: usize,
|
||||
pub buffers_in_use: usize,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
impl BufferPoolStats {
|
||||
pub fn total_megabytes(&self) -> f64 {
|
||||
self.total_bytes as f64 / (1024.0 * 1024.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Note: These tests require a wgpu device, so they're marked as ignored
|
||||
// Run with: cargo test -- --ignored
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_buffer_pool_basics() {
|
||||
// Would need wgpu device setup for actual testing
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,549 @@
|
|||
// Compositor for blending layers with proper opacity
|
||||
//
|
||||
// Handles alpha-over compositing with per-layer opacity and blend modes.
|
||||
// All processing is done in HDR (RGBA16Float) linear color space.
|
||||
|
||||
use super::buffer_pool::{BufferHandle, BufferPool};
|
||||
|
||||
/// Blend mode for layer compositing
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum BlendMode {
|
||||
/// Standard alpha-over compositing (Porter-Duff "over")
|
||||
#[default]
|
||||
Normal,
|
||||
/// Additive blending (src + dst)
|
||||
Add,
|
||||
/// Multiply (src * dst)
|
||||
Multiply,
|
||||
/// Screen (1 - (1-src) * (1-dst))
|
||||
Screen,
|
||||
/// Overlay (multiply dark, screen light)
|
||||
Overlay,
|
||||
/// Soft light
|
||||
SoftLight,
|
||||
/// Hard light
|
||||
HardLight,
|
||||
/// Color dodge
|
||||
ColorDodge,
|
||||
/// Color burn
|
||||
ColorBurn,
|
||||
/// Darken (min)
|
||||
Darken,
|
||||
/// Lighten (max)
|
||||
Lighten,
|
||||
/// Difference (abs(src - dst))
|
||||
Difference,
|
||||
/// Exclusion
|
||||
Exclusion,
|
||||
}
|
||||
|
||||
impl BlendMode {
|
||||
/// Get the blend mode index for shader uniform
|
||||
pub fn to_index(&self) -> u32 {
|
||||
match self {
|
||||
BlendMode::Normal => 0,
|
||||
BlendMode::Add => 1,
|
||||
BlendMode::Multiply => 2,
|
||||
BlendMode::Screen => 3,
|
||||
BlendMode::Overlay => 4,
|
||||
BlendMode::SoftLight => 5,
|
||||
BlendMode::HardLight => 6,
|
||||
BlendMode::ColorDodge => 7,
|
||||
BlendMode::ColorBurn => 8,
|
||||
BlendMode::Darken => 9,
|
||||
BlendMode::Lighten => 10,
|
||||
BlendMode::Difference => 11,
|
||||
BlendMode::Exclusion => 12,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all available blend modes
|
||||
pub fn all() -> &'static [BlendMode] {
|
||||
&[
|
||||
BlendMode::Normal,
|
||||
BlendMode::Add,
|
||||
BlendMode::Multiply,
|
||||
BlendMode::Screen,
|
||||
BlendMode::Overlay,
|
||||
BlendMode::SoftLight,
|
||||
BlendMode::HardLight,
|
||||
BlendMode::ColorDodge,
|
||||
BlendMode::ColorBurn,
|
||||
BlendMode::Darken,
|
||||
BlendMode::Lighten,
|
||||
BlendMode::Difference,
|
||||
BlendMode::Exclusion,
|
||||
]
|
||||
}
|
||||
|
||||
/// Get display name for UI
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
BlendMode::Normal => "Normal",
|
||||
BlendMode::Add => "Add",
|
||||
BlendMode::Multiply => "Multiply",
|
||||
BlendMode::Screen => "Screen",
|
||||
BlendMode::Overlay => "Overlay",
|
||||
BlendMode::SoftLight => "Soft Light",
|
||||
BlendMode::HardLight => "Hard Light",
|
||||
BlendMode::ColorDodge => "Color Dodge",
|
||||
BlendMode::ColorBurn => "Color Burn",
|
||||
BlendMode::Darken => "Darken",
|
||||
BlendMode::Lighten => "Lighten",
|
||||
BlendMode::Difference => "Difference",
|
||||
BlendMode::Exclusion => "Exclusion",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer to be composited
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompositorLayer {
|
||||
/// Handle to the layer's rendered buffer
|
||||
pub buffer: BufferHandle,
|
||||
/// Layer opacity (0.0 to 1.0)
|
||||
pub opacity: f32,
|
||||
/// Blend mode for this layer
|
||||
pub blend_mode: BlendMode,
|
||||
}
|
||||
|
||||
impl CompositorLayer {
|
||||
pub fn new(buffer: BufferHandle, opacity: f32, blend_mode: BlendMode) -> Self {
|
||||
Self {
|
||||
buffer,
|
||||
opacity: opacity.clamp(0.0, 1.0),
|
||||
blend_mode,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normal(buffer: BufferHandle, opacity: f32) -> Self {
|
||||
Self::new(buffer, opacity, BlendMode::Normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uniform data for the composite shader
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct CompositeUniforms {
|
||||
/// Layer opacity (0.0 to 1.0)
|
||||
pub opacity: f32,
|
||||
/// Blend mode index
|
||||
pub blend_mode: u32,
|
||||
/// Padding for alignment
|
||||
pub _padding: [u32; 2],
|
||||
}
|
||||
|
||||
/// Compositor for blending layers
|
||||
///
|
||||
/// Handles the final compositing pass that combines all rendered layers
|
||||
/// with proper opacity and blend modes.
|
||||
pub struct Compositor {
|
||||
/// Render pipeline for compositing
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Bind group layout for layer textures
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// Sampler for texture sampling
|
||||
sampler: wgpu::Sampler,
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Create a new compositor
|
||||
pub fn new(device: &wgpu::Device, output_format: wgpu::TextureFormat) -> Self {
|
||||
// Create bind group layout
|
||||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("compositor_bind_group_layout"),
|
||||
entries: &[
|
||||
// Source layer 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,
|
||||
},
|
||||
// Uniforms
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Create pipeline layout
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("compositor_pipeline_layout"),
|
||||
bind_group_layouts: &[&bind_group_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
// Create shader module
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("compositor_shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(COMPOSITE_SHADER.into()),
|
||||
});
|
||||
|
||||
// Create render pipeline
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("compositor_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: output_format,
|
||||
// Use premultiplied alpha blending for compositing
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
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("compositor_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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a bind group for compositing a layer
|
||||
pub fn create_layer_bind_group(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
layer_view: &wgpu::TextureView,
|
||||
uniforms_buffer: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("compositor_layer_bind_group"),
|
||||
layout: &self.bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(layer_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: uniforms_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Composite layers onto the output texture
|
||||
///
|
||||
/// Layers are composited in order (first layer is bottom, last is top).
|
||||
/// The output texture should be cleared before calling this method.
|
||||
pub fn composite(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
layers: &[CompositorLayer],
|
||||
buffer_pool: &BufferPool,
|
||||
output: &wgpu::TextureView,
|
||||
clear_color: Option<[f32; 4]>,
|
||||
) {
|
||||
// Create uniforms buffer (reused for all layers)
|
||||
let uniforms_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("compositor_uniforms"),
|
||||
size: std::mem::size_of::<CompositeUniforms>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
let Some(layer_view) = buffer_pool.get_view(layer.buffer) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Update uniforms
|
||||
let uniforms = CompositeUniforms {
|
||||
opacity: layer.opacity,
|
||||
blend_mode: layer.blend_mode.to_index(),
|
||||
_padding: [0, 0],
|
||||
};
|
||||
queue.write_buffer(&uniforms_buffer, 0, bytemuck::bytes_of(&uniforms));
|
||||
|
||||
// Create bind group for this layer
|
||||
let bind_group = self.create_layer_bind_group(device, layer_view, &uniforms_buffer);
|
||||
|
||||
// Determine load operation (clear on first layer if requested)
|
||||
let load_op = if i == 0 {
|
||||
if let Some(color) = clear_color {
|
||||
wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: color[0] as f64,
|
||||
g: color[1] as f64,
|
||||
b: color[2] as f64,
|
||||
a: color[3] as f64,
|
||||
})
|
||||
} else {
|
||||
wgpu::LoadOp::Load
|
||||
}
|
||||
} else {
|
||||
wgpu::LoadOp::Load
|
||||
};
|
||||
|
||||
// Render pass for this layer
|
||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(&format!("composite_layer_{}", i)),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: output,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: load_op,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the bind group layout (for external use)
|
||||
pub fn bind_group_layout(&self) -> &wgpu::BindGroupLayout {
|
||||
&self.bind_group_layout
|
||||
}
|
||||
}
|
||||
|
||||
/// WGSL shader for layer compositing
|
||||
const COMPOSITE_SHADER: &str = r#"
|
||||
// Compositor shader - blends a source layer onto the destination with opacity and blend modes
|
||||
|
||||
struct Uniforms {
|
||||
opacity: f32,
|
||||
blend_mode: u32,
|
||||
_padding: vec2<u32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var source_tex: texture_2d<f32>;
|
||||
@group(0) @binding(1) var source_sampler: sampler;
|
||||
@group(0) @binding(2) var<uniform> uniforms: Uniforms;
|
||||
|
||||
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
|
||||
// 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
|
||||
fn blend_normal(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return src;
|
||||
}
|
||||
|
||||
fn blend_add(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return src + dst;
|
||||
}
|
||||
|
||||
fn blend_multiply(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return src * dst;
|
||||
}
|
||||
|
||||
fn blend_screen(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return 1.0 - (1.0 - src) * (1.0 - dst);
|
||||
}
|
||||
|
||||
fn blend_overlay_channel(s: f32, d: f32) -> f32 {
|
||||
return select(
|
||||
1.0 - 2.0 * (1.0 - s) * (1.0 - d),
|
||||
2.0 * s * d,
|
||||
d < 0.5
|
||||
);
|
||||
}
|
||||
|
||||
fn blend_overlay(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return vec3<f32>(
|
||||
blend_overlay_channel(src.r, dst.r),
|
||||
blend_overlay_channel(src.g, dst.g),
|
||||
blend_overlay_channel(src.b, dst.b)
|
||||
);
|
||||
}
|
||||
|
||||
fn blend_soft_light_channel(s: f32, d: f32) -> f32 {
|
||||
return select(
|
||||
d - (1.0 - 2.0 * s) * d * (1.0 - d),
|
||||
d + (2.0 * s - 1.0) * (select(
|
||||
((16.0 * d - 12.0) * d + 4.0) * d,
|
||||
sqrt(d),
|
||||
d > 0.25
|
||||
) - d),
|
||||
s <= 0.5
|
||||
);
|
||||
}
|
||||
|
||||
fn blend_soft_light(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return vec3<f32>(
|
||||
blend_soft_light_channel(src.r, dst.r),
|
||||
blend_soft_light_channel(src.g, dst.g),
|
||||
blend_soft_light_channel(src.b, dst.b)
|
||||
);
|
||||
}
|
||||
|
||||
fn blend_hard_light(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
// Hard light is overlay with src and dst swapped
|
||||
return blend_overlay(dst, src);
|
||||
}
|
||||
|
||||
fn blend_color_dodge(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return select(
|
||||
min(vec3<f32>(1.0), dst / (1.0 - src)),
|
||||
vec3<f32>(1.0),
|
||||
src.r >= 1.0 || src.g >= 1.0 || src.b >= 1.0
|
||||
);
|
||||
}
|
||||
|
||||
fn blend_color_burn(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return select(
|
||||
1.0 - min(vec3<f32>(1.0), (1.0 - dst) / src),
|
||||
vec3<f32>(0.0),
|
||||
src.r <= 0.0 || src.g <= 0.0 || src.b <= 0.0
|
||||
);
|
||||
}
|
||||
|
||||
fn blend_darken(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return min(src, dst);
|
||||
}
|
||||
|
||||
fn blend_lighten(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return max(src, dst);
|
||||
}
|
||||
|
||||
fn blend_difference(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return abs(src - dst);
|
||||
}
|
||||
|
||||
fn blend_exclusion(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
|
||||
return src + dst - 2.0 * src * dst;
|
||||
}
|
||||
|
||||
fn apply_blend(src: vec3<f32>, dst: vec3<f32>, mode: u32) -> vec3<f32> {
|
||||
switch (mode) {
|
||||
case 0u: { return blend_normal(src, dst); } // Normal
|
||||
case 1u: { return blend_add(src, dst); } // Add
|
||||
case 2u: { return blend_multiply(src, dst); } // Multiply
|
||||
case 3u: { return blend_screen(src, dst); } // Screen
|
||||
case 4u: { return blend_overlay(src, dst); } // Overlay
|
||||
case 5u: { return blend_soft_light(src, dst); } // Soft Light
|
||||
case 6u: { return blend_hard_light(src, dst); } // Hard Light
|
||||
case 7u: { return blend_color_dodge(src, dst); } // Color Dodge
|
||||
case 8u: { return blend_color_burn(src, dst); } // Color Burn
|
||||
case 9u: { return blend_darken(src, dst); } // Darken
|
||||
case 10u: { return blend_lighten(src, dst); } // Lighten
|
||||
case 11u: { return blend_difference(src, dst); } // Difference
|
||||
case 12u: { return blend_exclusion(src, dst); } // Exclusion
|
||||
default: { return blend_normal(src, dst); }
|
||||
}
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let src = textureSample(source_tex, source_sampler, in.uv);
|
||||
|
||||
// Convert Vello's sRGB output to linear for correct HDR blending
|
||||
let linear_rgb = srgb_to_linear(src.rgb);
|
||||
|
||||
// Apply opacity
|
||||
let src_alpha = src.a * uniforms.opacity;
|
||||
|
||||
// Output premultiplied alpha in linear color space
|
||||
return vec4<f32>(linear_rgb * src_alpha, src_alpha);
|
||||
}
|
||||
"#;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// GPU rendering infrastructure for HDR compositing pipeline
|
||||
//
|
||||
// This module provides:
|
||||
// - Buffer pooling for efficient render target management
|
||||
// - Compositor for layer blending with proper opacity
|
||||
// - Effect pipeline for GPU shader effects
|
||||
|
||||
pub mod buffer_pool;
|
||||
pub mod compositor;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat};
|
||||
pub use compositor::{Compositor, CompositorLayer, BlendMode};
|
||||
|
||||
/// Standard HDR internal texture format (16-bit float per channel)
|
||||
pub const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// Display output format (8-bit sRGB)
|
||||
pub const DISPLAY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Lightningbeam Core Library
|
||||
// Shared data structures and types
|
||||
|
||||
pub mod gpu;
|
||||
pub mod layout;
|
||||
pub mod pane;
|
||||
pub mod tool;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
//! Rendering system for Lightningbeam documents
|
||||
//!
|
||||
//! Renders documents to Vello scenes for GPU-accelerated display.
|
||||
//!
|
||||
//! This module supports two rendering modes:
|
||||
//! 1. **Legacy mode**: All layers rendered to a single Scene (simple, fast)
|
||||
//! 2. **Compositing mode**: Each layer rendered to its own Scene for HDR compositing
|
||||
//!
|
||||
//! The compositing mode enables proper per-layer opacity, blend modes, and effects.
|
||||
|
||||
use crate::animation::TransformProperty;
|
||||
use crate::clip::ImageAsset;
|
||||
use crate::document::Document;
|
||||
use crate::gpu::BlendMode;
|
||||
use crate::layer::{AnyLayer, LayerTrait, VectorLayer};
|
||||
use kurbo::{Affine, Shape};
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -75,6 +82,225 @@ fn decode_image_asset(asset: &ImageAsset) -> Option<Image> {
|
|||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-Layer Rendering for HDR Compositing Pipeline
|
||||
// ============================================================================
|
||||
|
||||
/// Metadata for a rendered layer, used for compositing
|
||||
pub struct RenderedLayer {
|
||||
/// The layer's unique identifier
|
||||
pub layer_id: Uuid,
|
||||
/// The Vello scene containing the layer's rendered content
|
||||
pub scene: Scene,
|
||||
/// Layer opacity (0.0 to 1.0)
|
||||
pub opacity: f32,
|
||||
/// Blend mode for compositing
|
||||
pub blend_mode: BlendMode,
|
||||
/// Whether this layer has any visible content
|
||||
pub has_content: bool,
|
||||
}
|
||||
|
||||
impl RenderedLayer {
|
||||
/// Create a new rendered layer with default settings
|
||||
pub fn new(layer_id: Uuid) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
scene: Scene::new(),
|
||||
opacity: 1.0,
|
||||
blend_mode: BlendMode::Normal,
|
||||
has_content: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with specific opacity and blend mode
|
||||
pub fn with_settings(layer_id: Uuid, opacity: f32, blend_mode: BlendMode) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
scene: Scene::new(),
|
||||
opacity,
|
||||
blend_mode,
|
||||
has_content: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of rendering a document for compositing
|
||||
pub struct CompositeRenderResult {
|
||||
/// Background scene (rendered separately for potential optimization)
|
||||
pub background: Scene,
|
||||
/// Rendered layers in bottom-to-top order
|
||||
pub layers: Vec<RenderedLayer>,
|
||||
/// Document dimensions
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// Render a document for the HDR compositing pipeline
|
||||
///
|
||||
/// Unlike `render_document_with_transform`, this function renders each visible
|
||||
/// layer to its own Scene, enabling proper per-layer opacity, blend modes,
|
||||
/// and effects in the GPU compositor.
|
||||
///
|
||||
/// Layers are returned in bottom-to-top order for compositing.
|
||||
pub fn render_document_for_compositing(
|
||||
document: &Document,
|
||||
base_transform: Affine,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
) -> CompositeRenderResult {
|
||||
let time = document.current_time;
|
||||
|
||||
// Render background to its own scene
|
||||
let mut background = Scene::new();
|
||||
render_background(document, &mut background, base_transform);
|
||||
|
||||
// Check if any layers are soloed
|
||||
let any_soloed = document.visible_layers().any(|layer| layer.soloed());
|
||||
|
||||
// Collect layers to render
|
||||
let layers_to_render: Vec<_> = document
|
||||
.visible_layers()
|
||||
.filter(|layer| {
|
||||
if any_soloed {
|
||||
layer.soloed()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Render each layer to its own scene
|
||||
let mut rendered_layers = Vec::with_capacity(layers_to_render.len());
|
||||
|
||||
for layer in layers_to_render {
|
||||
let rendered = render_layer_isolated(
|
||||
document,
|
||||
time,
|
||||
layer,
|
||||
base_transform,
|
||||
image_cache,
|
||||
video_manager,
|
||||
);
|
||||
rendered_layers.push(rendered);
|
||||
}
|
||||
|
||||
CompositeRenderResult {
|
||||
background,
|
||||
layers: rendered_layers,
|
||||
width: document.width,
|
||||
height: document.height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single layer to its own isolated Scene
|
||||
///
|
||||
/// The layer is rendered with full opacity in its scene; the actual opacity
|
||||
/// will be applied during compositing. This enables proper alpha blending
|
||||
/// for nested clips and complex layer hierarchies.
|
||||
pub fn render_layer_isolated(
|
||||
document: &Document,
|
||||
time: f64,
|
||||
layer: &AnyLayer,
|
||||
base_transform: Affine,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
) -> RenderedLayer {
|
||||
let layer_id = layer.id();
|
||||
let opacity = layer.opacity() as f32;
|
||||
|
||||
// TODO: When we add blend mode support to layers, read it here
|
||||
let blend_mode = BlendMode::Normal;
|
||||
|
||||
let mut rendered = RenderedLayer::with_settings(layer_id, opacity, blend_mode);
|
||||
|
||||
// Render layer content with full opacity (1.0) - opacity applied during compositing
|
||||
match layer {
|
||||
AnyLayer::Vector(vector_layer) => {
|
||||
render_vector_layer_to_scene(
|
||||
document,
|
||||
time,
|
||||
vector_layer,
|
||||
&mut rendered.scene,
|
||||
base_transform,
|
||||
1.0, // Full opacity - layer opacity handled in compositing
|
||||
image_cache,
|
||||
video_manager,
|
||||
);
|
||||
rendered.has_content = !vector_layer.shape_instances.is_empty()
|
||||
|| !vector_layer.clip_instances.is_empty();
|
||||
}
|
||||
AnyLayer::Audio(_) => {
|
||||
// Audio layers don't render visually
|
||||
rendered.has_content = false;
|
||||
}
|
||||
AnyLayer::Video(video_layer) => {
|
||||
let mut video_mgr = video_manager.lock().unwrap();
|
||||
render_video_layer_to_scene(
|
||||
document,
|
||||
time,
|
||||
video_layer,
|
||||
&mut rendered.scene,
|
||||
base_transform,
|
||||
1.0, // Full opacity - layer opacity handled in compositing
|
||||
&mut video_mgr,
|
||||
);
|
||||
rendered.has_content = !video_layer.clip_instances.is_empty();
|
||||
}
|
||||
}
|
||||
|
||||
rendered
|
||||
}
|
||||
|
||||
/// Render a vector layer to an isolated scene (for compositing pipeline)
|
||||
fn render_vector_layer_to_scene(
|
||||
document: &Document,
|
||||
time: f64,
|
||||
layer: &VectorLayer,
|
||||
scene: &mut Scene,
|
||||
base_transform: Affine,
|
||||
parent_opacity: f64,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
) {
|
||||
// Render using the existing function but to this isolated scene
|
||||
render_vector_layer(
|
||||
document,
|
||||
time,
|
||||
layer,
|
||||
scene,
|
||||
base_transform,
|
||||
parent_opacity,
|
||||
image_cache,
|
||||
video_manager,
|
||||
);
|
||||
}
|
||||
|
||||
/// Render a video layer to an isolated scene (for compositing pipeline)
|
||||
fn render_video_layer_to_scene(
|
||||
document: &Document,
|
||||
time: f64,
|
||||
layer: &crate::layer::VideoLayer,
|
||||
scene: &mut Scene,
|
||||
base_transform: Affine,
|
||||
parent_opacity: f64,
|
||||
video_manager: &mut crate::video::VideoManager,
|
||||
) {
|
||||
// Render using the existing function but to this isolated scene
|
||||
render_video_layer(
|
||||
document,
|
||||
time,
|
||||
layer,
|
||||
scene,
|
||||
base_transform,
|
||||
parent_opacity,
|
||||
video_manager,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy Single-Scene Rendering (kept for backwards compatibility)
|
||||
// ============================================================================
|
||||
|
||||
/// Render a document to a Vello scene
|
||||
pub fn render_document(
|
||||
document: &Document,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
// Linear to sRGB color space conversion (fragment shader)
|
||||
//
|
||||
// Blits from HDR composite texture to display output.
|
||||
// Input: RGBA16Float HDR texture in LINEAR color space
|
||||
// Output: RGBA8Unorm sRGB for display
|
||||
//
|
||||
// The HDR texture contains linear color values (compositor converts
|
||||
// Vello's sRGB output to linear). This shader converts back to sRGB
|
||||
// for correct display on standard monitors.
|
||||
|
||||
@group(0) @binding(0) var input_tex: texture_2d<f32>;
|
||||
@group(0) @binding(1) var input_sampler: sampler;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
// Fullscreen triangle vertex shader (3 vertices for a full-screen triangle)
|
||||
@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 conversion for a single channel
|
||||
// Formula: c <= 0.0031308 ? c*12.92 : 1.055*pow(c, 1/2.4) - 0.055
|
||||
fn linear_to_srgb_channel(c: f32) -> f32 {
|
||||
let clamped = clamp(c, 0.0, 1.0);
|
||||
return select(
|
||||
1.055 * pow(clamped, 1.0 / 2.4) - 0.055,
|
||||
clamped * 12.92,
|
||||
clamped <= 0.0031308
|
||||
);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Sample linear HDR texture
|
||||
let linear = textureSample(input_tex, input_sampler, in.uv);
|
||||
|
||||
// Convert from linear to sRGB for display (alpha stays linear)
|
||||
return vec4<f32>(
|
||||
linear_to_srgb_channel(linear.r),
|
||||
linear_to_srgb_channel(linear.g),
|
||||
linear_to_srgb_channel(linear.b),
|
||||
linear.a
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// sRGB to Linear color space conversion (compute shader)
|
||||
//
|
||||
// Converts an sRGB texture to linear color space for HDR processing.
|
||||
// Input: RGBA8 sRGB texture
|
||||
// Output: RGBA16Float linear texture
|
||||
|
||||
@group(0) @binding(0) var input_tex: texture_2d<f32>;
|
||||
@group(0) @binding(1) var output_tex: texture_storage_2d<rgba16float, write>;
|
||||
|
||||
// sRGB to linear conversion for a single channel
|
||||
// Formula: c <= 0.04045 ? c/12.92 : pow((c+0.055)/1.055, 2.4)
|
||||
fn srgb_to_linear(c: f32) -> f32 {
|
||||
return select(
|
||||
pow((c + 0.055) / 1.055, 2.4),
|
||||
c / 12.92,
|
||||
c <= 0.04045
|
||||
);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let dims = textureDimensions(input_tex);
|
||||
|
||||
// Bounds check
|
||||
if (gid.x >= dims.x || gid.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load sRGB pixel
|
||||
let srgb = textureLoad(input_tex, gid.xy, 0);
|
||||
|
||||
// Convert RGB channels to linear (alpha stays linear)
|
||||
let linear = vec4<f32>(
|
||||
srgb_to_linear(srgb.r),
|
||||
srgb_to_linear(srgb.g),
|
||||
srgb_to_linear(srgb.b),
|
||||
srgb.a
|
||||
);
|
||||
|
||||
// Store linear result
|
||||
textureStore(output_tex, gid.xy, linear);
|
||||
}
|
||||
|
|
@ -1,32 +1,50 @@
|
|||
/// Stage pane - main animation canvas with Vello rendering
|
||||
///
|
||||
/// Renders composited layers using Vello GPU renderer via egui callbacks.
|
||||
/// Supports HDR compositing pipeline with per-layer buffers and effects.
|
||||
|
||||
use eframe::egui;
|
||||
use lightningbeam_core::action::Action;
|
||||
use lightningbeam_core::clip::ClipInstance;
|
||||
use lightningbeam_core::gpu::{BufferPool, Compositor};
|
||||
use lightningbeam_core::layer::{AnyLayer, AudioLayer, AudioLayerType, VideoLayer, VectorLayer};
|
||||
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use vello::kurbo::Shape;
|
||||
|
||||
/// Enable HDR compositing pipeline (per-layer rendering with proper opacity)
|
||||
/// Set to true to use the new pipeline, false for legacy single-scene rendering
|
||||
const USE_HDR_COMPOSITING: bool = true; // Enabled for testing
|
||||
|
||||
/// Shared Vello resources (created once, reused by all Stage panes)
|
||||
struct SharedVelloResources {
|
||||
renderer: Arc<Mutex<vello::Renderer>>,
|
||||
blit_pipeline: wgpu::RenderPipeline,
|
||||
blit_bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// HDR to sRGB blit pipeline (linear→sRGB conversion for display)
|
||||
hdr_blit_pipeline: wgpu::RenderPipeline,
|
||||
sampler: wgpu::Sampler,
|
||||
/// Shared image cache for avoiding re-decoding images every frame
|
||||
image_cache: Mutex<lightningbeam_core::renderer::ImageCache>,
|
||||
/// Video manager for video decoding and frame caching
|
||||
video_manager: std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>,
|
||||
/// Buffer pool for HDR compositing pipeline
|
||||
buffer_pool: Mutex<BufferPool>,
|
||||
/// Compositor for layer blending
|
||||
compositor: Compositor,
|
||||
}
|
||||
|
||||
/// Per-instance Vello resources (created for each Stage pane)
|
||||
struct InstanceVelloResources {
|
||||
/// Output texture (Rgba8Unorm for legacy, used for final blit)
|
||||
texture: Option<wgpu::Texture>,
|
||||
texture_view: Option<wgpu::TextureView>,
|
||||
blit_bind_group: Option<wgpu::BindGroup>,
|
||||
/// HDR composite texture (Rgba16Float for internal compositing)
|
||||
hdr_texture: Option<wgpu::Texture>,
|
||||
hdr_texture_view: Option<wgpu::TextureView>,
|
||||
/// Bind group for HDR to sRGB conversion
|
||||
hdr_blit_bind_group: Option<wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
/// Container for all Vello instances, stored in egui's CallbackResources
|
||||
|
|
@ -118,6 +136,47 @@ impl SharedVelloResources {
|
|||
cache: None,
|
||||
});
|
||||
|
||||
// Create HDR blit pipeline (linear→sRGB conversion for display output)
|
||||
// Uses linear_to_srgb.wgsl which reads from Rgba16Float HDR texture
|
||||
let hdr_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("hdr_blit_shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/linear_to_srgb.wgsl").into()),
|
||||
});
|
||||
|
||||
let hdr_blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("hdr_blit_pipeline"),
|
||||
layout: Some(&pipeline_layout), // Reuse same layout (texture + sampler)
|
||||
vertex: wgpu::VertexState {
|
||||
module: &hdr_shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &hdr_shader,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm, // Output to display-ready texture
|
||||
blend: None, // No blending - direct replacement
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
strip_index_format: None,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: None,
|
||||
unclipped_depth: false,
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Create sampler
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("vello_blit_sampler"),
|
||||
|
|
@ -130,15 +189,25 @@ impl SharedVelloResources {
|
|||
..Default::default()
|
||||
});
|
||||
|
||||
println!("✅ Vello shared resources initialized (renderer and shaders)");
|
||||
// Initialize buffer pool for HDR compositing
|
||||
let buffer_pool = BufferPool::new();
|
||||
|
||||
// Initialize compositor for layer blending
|
||||
// Use HDR format for internal compositing
|
||||
let compositor = Compositor::new(device, lightningbeam_core::gpu::HDR_FORMAT);
|
||||
|
||||
println!("✅ Vello shared resources initialized (renderer, shaders, and HDR compositor)");
|
||||
|
||||
Ok(Self {
|
||||
renderer: Arc::new(Mutex::new(renderer)),
|
||||
blit_pipeline,
|
||||
blit_bind_group_layout,
|
||||
hdr_blit_pipeline,
|
||||
sampler,
|
||||
image_cache: Mutex::new(lightningbeam_core::renderer::ImageCache::new()),
|
||||
video_manager,
|
||||
buffer_pool: Mutex::new(buffer_pool),
|
||||
compositor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -149,6 +218,9 @@ impl InstanceVelloResources {
|
|||
texture: None,
|
||||
texture_view: None,
|
||||
blit_bind_group: None,
|
||||
hdr_texture: None,
|
||||
hdr_texture_view: None,
|
||||
hdr_blit_bind_group: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +244,11 @@ impl InstanceVelloResources {
|
|||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
// RENDER_ATTACHMENT needed for HDR blit, STORAGE_BINDING for Vello
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
|
|
@ -198,6 +274,57 @@ impl InstanceVelloResources {
|
|||
self.texture_view = Some(texture_view);
|
||||
self.blit_bind_group = Some(bind_group);
|
||||
}
|
||||
|
||||
/// Ensure HDR texture exists for compositing pipeline
|
||||
fn ensure_hdr_texture(&mut self, device: &wgpu::Device, shared: &SharedVelloResources, width: u32, height: u32) {
|
||||
// Clamp to GPU limits
|
||||
let max_texture_size = 8192;
|
||||
let width = width.min(max_texture_size);
|
||||
let height = height.min(max_texture_size);
|
||||
|
||||
// Only recreate if size changed
|
||||
if let Some(tex) = &self.hdr_texture {
|
||||
if tex.width() == width && tex.height() == height {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create HDR texture (Rgba16Float for internal compositing)
|
||||
let hdr_texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("hdr_composite_output"),
|
||||
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: lightningbeam_core::gpu::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 bind group for HDR to sRGB conversion (uses same layout as blit)
|
||||
let hdr_blit_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("hdr_blit_bind_group"),
|
||||
layout: &shared.blit_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&hdr_texture_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&shared.sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
self.hdr_texture = Some(hdr_texture);
|
||||
self.hdr_texture_view = Some(hdr_texture_view);
|
||||
self.hdr_blit_bind_group = Some(hdr_blit_bind_group);
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback for Vello rendering within egui
|
||||
|
|
@ -287,24 +414,144 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
|
||||
instance_resources.ensure_texture(device, &shared, width, height);
|
||||
|
||||
// Build Vello scene using the document renderer
|
||||
let mut scene = vello::Scene::new();
|
||||
|
||||
// Build camera transform: translate for pan, scale for zoom
|
||||
use vello::kurbo::Affine;
|
||||
let camera_transform = Affine::translate((self.pan_offset.x as f64, self.pan_offset.y as f64))
|
||||
* Affine::scale(self.zoom as f64);
|
||||
|
||||
// Render the document to the scene with camera transform
|
||||
let mut image_cache = shared.image_cache.lock().unwrap();
|
||||
lightningbeam_core::renderer::render_document_with_transform(
|
||||
&self.document,
|
||||
&mut scene,
|
||||
camera_transform,
|
||||
&mut image_cache,
|
||||
&shared.video_manager,
|
||||
);
|
||||
drop(image_cache); // Explicitly release lock before other operations
|
||||
// Choose rendering path based on HDR compositing flag
|
||||
let mut scene = if USE_HDR_COMPOSITING {
|
||||
// HDR Compositing Pipeline: render each layer separately for proper opacity
|
||||
// Uses incremental compositing: render layer → composite onto accumulator → release buffer
|
||||
// This means we only need 1 layer buffer at a time (plus the HDR accumulator)
|
||||
instance_resources.ensure_hdr_texture(device, &shared, width, height);
|
||||
|
||||
let mut image_cache = shared.image_cache.lock().unwrap();
|
||||
let composite_result = lightningbeam_core::renderer::render_document_for_compositing(
|
||||
&self.document,
|
||||
camera_transform,
|
||||
&mut image_cache,
|
||||
&shared.video_manager,
|
||||
);
|
||||
drop(image_cache);
|
||||
|
||||
// Get buffer pool for layer rendering
|
||||
let mut buffer_pool = shared.buffer_pool.lock().unwrap();
|
||||
|
||||
// Buffer spec for layer rendering (Vello outputs Rgba8)
|
||||
let layer_spec = lightningbeam_core::gpu::BufferSpec::new(
|
||||
width,
|
||||
height,
|
||||
lightningbeam_core::gpu::BufferFormat::Rgba8Srgb,
|
||||
);
|
||||
|
||||
// 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::Msaa16,
|
||||
};
|
||||
|
||||
// First, render background and composite it
|
||||
// The background scene contains only a rectangle at document bounds,
|
||||
// so we use TRANSPARENT base_color to not fill the whole viewport
|
||||
let bg_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) {
|
||||
// Render background scene with transparent base (scene has the bg rect)
|
||||
let bg_render_params = vello::RenderParams {
|
||||
base_color: vello::peniko::Color::TRANSPARENT,
|
||||
width,
|
||||
height,
|
||||
antialiasing_method: vello::AaConfig::Msaa16,
|
||||
};
|
||||
|
||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||
renderer.render_to_texture(device, queue, &composite_result.background, bg_view, &bg_render_params).ok();
|
||||
}
|
||||
|
||||
// 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 mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("bg_composite_encoder"),
|
||||
});
|
||||
// Clear to dark gray (stage background outside document bounds)
|
||||
let stage_bg = [45.0 / 255.0, 45.0 / 255.0, 48.0 / 255.0, 1.0];
|
||||
shared.compositor.composite(
|
||||
device,
|
||||
queue,
|
||||
&mut encoder,
|
||||
&[bg_compositor_layer],
|
||||
&buffer_pool,
|
||||
hdr_view,
|
||||
Some(stage_bg),
|
||||
);
|
||||
queue.submit(Some(encoder.finish()));
|
||||
}
|
||||
buffer_pool.release(bg_handle);
|
||||
|
||||
// Now render and composite each layer incrementally
|
||||
for rendered_layer in &composite_result.layers {
|
||||
if !rendered_layer.has_content {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Acquire a buffer for this layer
|
||||
let layer_handle = buffer_pool.acquire(device, layer_spec);
|
||||
|
||||
if let (Some(layer_view), Some(hdr_view)) = (buffer_pool.get_view(layer_handle), &instance_resources.hdr_texture_view) {
|
||||
// Render layer scene to buffer
|
||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||
renderer.render_to_texture(device, queue, &rendered_layer.scene, layer_view, &layer_render_params).ok();
|
||||
}
|
||||
|
||||
// Composite this layer onto the HDR accumulator with its opacity
|
||||
let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new(
|
||||
layer_handle,
|
||||
rendered_layer.opacity,
|
||||
rendered_layer.blend_mode,
|
||||
);
|
||||
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("layer_composite_encoder"),
|
||||
});
|
||||
shared.compositor.composite(
|
||||
device,
|
||||
queue,
|
||||
&mut encoder,
|
||||
&[compositor_layer],
|
||||
&buffer_pool,
|
||||
hdr_view,
|
||||
None, // Don't clear - blend onto existing content
|
||||
);
|
||||
queue.submit(Some(encoder.finish()));
|
||||
}
|
||||
|
||||
// Release buffer immediately - it can be reused for next layer
|
||||
buffer_pool.release(layer_handle);
|
||||
}
|
||||
|
||||
// Advance frame counter for buffer cleanup
|
||||
buffer_pool.next_frame();
|
||||
drop(buffer_pool);
|
||||
|
||||
// For drag preview and other overlays, we still need a scene
|
||||
// Create an empty scene - the composited result is already in hdr_texture
|
||||
vello::Scene::new()
|
||||
} else {
|
||||
// Legacy single-scene rendering
|
||||
let mut scene = vello::Scene::new();
|
||||
let mut image_cache = shared.image_cache.lock().unwrap();
|
||||
lightningbeam_core::renderer::render_document_with_transform(
|
||||
&self.document,
|
||||
&mut scene,
|
||||
camera_transform,
|
||||
&mut image_cache,
|
||||
&shared.video_manager,
|
||||
);
|
||||
drop(image_cache);
|
||||
scene
|
||||
};
|
||||
|
||||
// Render drag preview objects with transparency
|
||||
if let (Some(delta), Some(active_layer_id)) = (self.drag_delta, self.active_layer_id) {
|
||||
|
|
@ -1292,17 +1539,97 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
|
||||
// Render scene to texture using shared renderer
|
||||
if let Some(texture_view) = &instance_resources.texture_view {
|
||||
let render_params = vello::RenderParams {
|
||||
base_color: vello::peniko::Color::from_rgb8(45, 45, 48), // Dark background
|
||||
width,
|
||||
height,
|
||||
antialiasing_method: vello::AaConfig::Msaa16,
|
||||
};
|
||||
if USE_HDR_COMPOSITING {
|
||||
// HDR mode: First render overlays to HDR texture, then blit to output
|
||||
|
||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||
renderer
|
||||
.render_to_texture(device, queue, &scene, texture_view, &render_params)
|
||||
.ok();
|
||||
// Step 1: Render overlay scene (selection handles, drag previews, etc.) to HDR texture
|
||||
// The overlay scene was built above with all the UI elements
|
||||
if let Some(hdr_view) = &instance_resources.hdr_texture_view {
|
||||
let mut buffer_pool = shared.buffer_pool.lock().unwrap();
|
||||
let overlay_spec = lightningbeam_core::gpu::BufferSpec::new(
|
||||
width,
|
||||
height,
|
||||
lightningbeam_core::gpu::BufferFormat::Rgba8Srgb,
|
||||
);
|
||||
let overlay_handle = buffer_pool.acquire(device, overlay_spec);
|
||||
|
||||
if let Some(overlay_view) = buffer_pool.get_view(overlay_handle) {
|
||||
// Render overlay scene to temp buffer
|
||||
let overlay_params = vello::RenderParams {
|
||||
base_color: vello::peniko::Color::TRANSPARENT,
|
||||
width,
|
||||
height,
|
||||
antialiasing_method: vello::AaConfig::Msaa16,
|
||||
};
|
||||
|
||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||
renderer.render_to_texture(device, queue, &scene, overlay_view, &overlay_params).ok();
|
||||
}
|
||||
|
||||
// Composite overlay onto HDR texture (sRGB→linear conversion happens in compositor)
|
||||
let overlay_layer = lightningbeam_core::gpu::CompositorLayer::normal(overlay_handle, 1.0);
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("overlay_composite_encoder"),
|
||||
});
|
||||
shared.compositor.composite(
|
||||
device,
|
||||
queue,
|
||||
&mut encoder,
|
||||
&[overlay_layer],
|
||||
&buffer_pool,
|
||||
hdr_view,
|
||||
None, // Don't clear - blend onto existing content
|
||||
);
|
||||
queue.submit(Some(encoder.finish()));
|
||||
}
|
||||
|
||||
buffer_pool.release(overlay_handle);
|
||||
drop(buffer_pool);
|
||||
}
|
||||
|
||||
// Step 2: Blit HDR texture to output with linear→sRGB conversion
|
||||
if let Some(hdr_bind_group) = &instance_resources.hdr_blit_bind_group {
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("hdr_to_srgb_encoder"),
|
||||
});
|
||||
|
||||
{
|
||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("hdr_to_srgb_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: texture_view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
|
||||
render_pass.set_pipeline(&shared.hdr_blit_pipeline);
|
||||
render_pass.set_bind_group(0, hdr_bind_group, &[]);
|
||||
render_pass.draw(0..3, 0..1); // Full-screen triangle (3 vertices)
|
||||
}
|
||||
|
||||
queue.submit(Some(encoder.finish()));
|
||||
}
|
||||
} else {
|
||||
// Legacy mode: Direct single-scene rendering
|
||||
let render_params = vello::RenderParams {
|
||||
base_color: vello::peniko::Color::from_rgb8(45, 45, 48), // Dark background
|
||||
width,
|
||||
height,
|
||||
antialiasing_method: vello::AaConfig::Msaa16,
|
||||
};
|
||||
|
||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||
renderer
|
||||
.render_to_texture(device, queue, &scene, texture_view, &render_params)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue