diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs b/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs index bdb2ec9..e477dca 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs @@ -75,7 +75,7 @@ impl EffectRegistry { INVERT_ID, "Invert", EffectCategory::Color, - include_str!("shaders/effect_invert.wgsl"), + format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_invert.wgsl")), vec![ EffectParameterDef::float_range("amount", "Amount", 1.0, 0.0, 1.0), ], @@ -88,7 +88,7 @@ impl EffectRegistry { BRIGHTNESS_CONTRAST_ID, "Brightness/Contrast", EffectCategory::Color, - include_str!("shaders/effect_brightness_contrast.wgsl"), + format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_brightness_contrast.wgsl")), vec![ EffectParameterDef::float_range("brightness", "Brightness", 0.0, -1.0, 1.0), EffectParameterDef::float_range("contrast", "Contrast", 1.0, 0.0, 3.0), @@ -102,7 +102,7 @@ impl EffectRegistry { HUE_SATURATION_ID, "Hue/Saturation", EffectCategory::Color, - include_str!("shaders/effect_hue_saturation.wgsl"), + format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_hue_saturation.wgsl")), vec![ EffectParameterDef::angle("hue", "Hue Shift", 0.0), EffectParameterDef::float_range("saturation", "Saturation", 1.0, 0.0, 3.0), diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs index 7f13e4f..9d78eca 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs @@ -6,6 +6,41 @@ use super::HDR_FORMAT; +/// Shared WGSL sRGB transfer functions — the single source of the sRGB OETF/EOTF +/// used by every gamma-aware shader. Prepend it to a shader's source (it defines +/// the functions before the body, so call order doesn't matter): +/// `srgb_to_linear_channel` / `linear_to_srgb_channel` (scalar) and +/// `srgb_to_linear` / `linear_to_srgb` (vec3). `linear_to_srgb_channel` clamps to +/// [0,1] (its outputs target 8-bit / SDR display surfaces). +pub const COLOR_WGSL: &str = r#" +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 linear_to_srgb_channel(c: f32) -> f32 { + let x = clamp(c, 0.0, 1.0); + return select(1.055 * pow(x, 1.0 / 2.4) - 0.055, x * 12.92, x <= 0.0031308); +} +fn srgb_to_linear(c: vec3) -> vec3 { + return vec3(srgb_to_linear_channel(c.r), srgb_to_linear_channel(c.g), srgb_to_linear_channel(c.b)); +} +fn linear_to_srgb(c: vec3) -> vec3 { + return vec3(linear_to_srgb_channel(c.r), linear_to_srgb_channel(c.g), linear_to_srgb_channel(c.b)); +} +"#; + +/// sRGB → linear for one channel in `[0, 1]` (CPU twin of the WGSL +/// `srgb_to_linear_channel`). The single source of the EOTF for CPU code. +pub fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// linear → sRGB for one channel, clamped to `[0, 1]` (CPU twin of the WGSL +/// `linear_to_srgb_channel`). The single source of the OETF for CPU code. +pub fn linear_to_srgb(c: f32) -> f32 { + let c = c.clamp(0.0, 1.0); + if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } +} + /// GPU pipeline for sRGB to linear color space conversion /// /// Converts Rgba8Srgb textures to Rgba16Float linear textures. diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs index c5ac285..2051002 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs @@ -14,7 +14,7 @@ pub mod yuv_converter; // Re-export commonly used types pub use buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat}; -pub use color_convert::SrgbToLinearConverter; +pub use color_convert::{SrgbToLinearConverter, COLOR_WGSL, srgb_to_linear, linear_to_srgb}; pub use compositor::{Compositor, CompositorLayer, BlendMode}; pub use effect_processor::{EffectProcessor, EffectUniforms}; pub use yuv_converter::YuvConverter; diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl index 92c2350..c8fafa8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl @@ -31,14 +31,23 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } +// The HDR pipeline feeds these shaders LINEAR light, but brightness/contrast +// (additive brightness, contrast pivoting around 0.5 perceptual mid-gray) are +// defined in gamma/display space. Convert to sRGB, adjust there, then convert +// back to linear so the controls behave like standard editors. +// sRGB helpers (linear_to_srgb / srgb_to_linear) come from the prepended +// COLOR_WGSL prelude. + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let src = textureSample(source_tex, source_sampler, in.uv); let brightness = uniforms.params0.x; // -1 to 1 let contrast = uniforms.params0.y; // 0 to 3 + let src_srgb = linear_to_srgb(src.rgb); + // Apply brightness (additive) - var color = src.rgb + vec3(brightness); + var color = src_srgb + vec3(brightness); // Apply contrast (multiply around midpoint 0.5) color = (color - vec3(0.5)) * contrast + vec3(0.5); @@ -46,6 +55,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Clamp to valid range color = clamp(color, vec3(0.0), vec3(1.0)); - let result = mix(src.rgb, color, uniforms.mix); - return vec4(result, src.a); + let result_srgb = mix(src_srgb, color, uniforms.mix); + return vec4(srgb_to_linear(result_srgb), src.a); } diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl index 3553417..6b1425a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl @@ -84,6 +84,12 @@ fn hsl_to_rgb(hsl: vec3) -> vec3 { ); } +// The HDR pipeline feeds this shader LINEAR light, but the HSL model (and the +// lightness/saturation axes users expect) is defined on gamma-encoded sRGB. +// Convert to sRGB, run the HSL adjustment there, then convert back to linear. +// sRGB helpers (linear_to_srgb / srgb_to_linear) come from the prepended +// COLOR_WGSL prelude. + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let src = textureSample(source_tex, source_sampler, in.uv); @@ -91,8 +97,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let saturation = uniforms.params0.y; // Multiplier (1.0 = no change) let lightness = uniforms.params0.z; // Additive (-1 to 1) + let src_srgb = linear_to_srgb(src.rgb); + // Convert to HSL - var hsl = rgb_to_hsl(src.rgb); + var hsl = rgb_to_hsl(src_srgb); // Apply adjustments hsl.x = fract(hsl.x + hue_shift); // Shift hue (wrapping) @@ -102,6 +110,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Convert back to RGB let adjusted = hsl_to_rgb(hsl); - let result = mix(src.rgb, adjusted, uniforms.mix); - return vec4(result, src.a); + let result_srgb = mix(src_srgb, adjusted, uniforms.mix); + return vec4(srgb_to_linear(result_srgb), src.a); } diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl index 8bb5b28..c396eaf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl @@ -33,13 +33,19 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } +// The HDR pipeline feeds these shaders LINEAR light, but "invert" is a +// perceptual operation defined in gamma/display space (Photoshop, GIMP, etc.). +// Convert to sRGB, invert there, then convert back to linear. The sRGB helpers +// (linear_to_srgb / srgb_to_linear) come from the prepended COLOR_WGSL prelude. + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let src = textureSample(source_tex, source_sampler, in.uv); let amount = uniforms.params0.x; // params[0] - let inverted = vec3(1.0) - src.rgb; - let result = mix(src.rgb, inverted, amount * uniforms.mix); + let src_srgb = linear_to_srgb(src.rgb); + let inverted = vec3(1.0) - src_srgb; + let result_srgb = mix(src_srgb, inverted, amount * uniforms.mix); - return vec4(result, src.a); + return vec4(srgb_to_linear(result_srgb), src.a); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs b/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs index 698b4a9..894f725 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs @@ -11,6 +11,36 @@ use uuid::Uuid; /// Size of effect thumbnails in pixels pub const EFFECT_THUMBNAIL_SIZE: u32 = 64; +use lightningbeam_core::gpu::{srgb_to_linear, linear_to_srgb}; + +/// sRGB-u8 RGBA → linear-`f16` RGBA bytes (little-endian). Feeds the effect +/// shaders linear light at float precision, matching the live HDR pipeline (an +/// 8-bit linear intermediate would band in shadows). RGB go through the sRGB +/// EOTF; alpha is linear. +fn srgb_image_to_linear_f16(rgba: &[u8]) -> Vec { + let mut out = Vec::with_capacity(rgba.len() * 2); + for px in rgba.chunks_exact(4) { + for &c in &px[..3] { + out.extend_from_slice(&half::f16::from_f32(srgb_to_linear(c as f32 / 255.0)).to_le_bytes()); + } + out.extend_from_slice(&half::f16::from_f32(px[3] as f32 / 255.0).to_le_bytes()); + } + out +} + +/// linear-`f16` RGBA bytes → sRGB-u8 RGBA. Inverse of [`srgb_image_to_linear_f16`]. +fn linear_f16_to_srgb_image(f16_rgba: &[u8]) -> Vec { + let mut out = Vec::with_capacity(f16_rgba.len() / 2); + for texel in f16_rgba.chunks_exact(8) { + let ch = |i: usize| half::f16::from_le_bytes([texel[i], texel[i + 1]]).to_f32(); + out.push((linear_to_srgb(ch(0)) * 255.0 + 0.5) as u8); + out.push((linear_to_srgb(ch(2)) * 255.0 + 0.5) as u8); + out.push((linear_to_srgb(ch(4)) * 255.0 + 0.5) as u8); + out.push((ch(6).clamp(0.0, 1.0) * 255.0 + 0.5) as u8); + } + out +} + /// Embedded still-life image for effect preview thumbnails const EFFECT_PREVIEW_IMAGE_BYTES: &[u8] = include_bytes!("../../../src/assets/still-life.jpg"); @@ -39,10 +69,18 @@ impl EffectThumbnailGenerator { /// Create a new effect thumbnail generator pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { // Load and decode the source image - let source_rgba = Self::load_source_image(); + // The effect shaders operate in LINEAR light (matching the live HDR + // pipeline, which feeds them a linear Rgba16Float texture). The preview + // image is sRGB-encoded, so linearize it before upload and re-encode the + // result after readback. This keeps thumbnails consistent with the live + // render for every effect, including the gamma-space perceptual ones. + // Linearize to f16 (float precision — an 8-bit linear intermediate would + // band in shadows, the reason the live canvas is Rgba16Float). + let source_f16 = srgb_image_to_linear_f16(&Self::load_source_image()); - // Create effect processor (using Rgba8Unorm for thumbnail output) - let effect_processor = EffectProcessor::new(device, wgpu::TextureFormat::Rgba8Unorm); + // Effect processor + textures use Rgba16Float linear, matching the live + // pipeline so thumbnails render identically to the on-canvas effect. + let effect_processor = EffectProcessor::new(device, wgpu::TextureFormat::Rgba16Float); // Create source texture let source_texture = device.create_texture(&wgpu::TextureDescriptor { @@ -55,12 +93,12 @@ impl EffectThumbnailGenerator { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); - // Upload source image data + // Upload source image data (Rgba16Float = 8 bytes/texel). queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &source_texture, @@ -68,10 +106,10 @@ impl EffectThumbnailGenerator { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - &source_rgba, + &source_f16, wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(EFFECT_THUMBNAIL_SIZE * 4), + bytes_per_row: Some(EFFECT_THUMBNAIL_SIZE * 8), rows_per_image: Some(EFFECT_THUMBNAIL_SIZE), }, wgpu::Extent3d { @@ -94,17 +132,15 @@ impl EffectThumbnailGenerator { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let dest_view = dest_texture.create_view(&wgpu::TextureViewDescriptor::default()); - // Create readback buffer - let _buffer_size = (EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as u64; - // Align to 256 bytes for wgpu requirements - let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256; + // Create readback buffer (Rgba16Float = 8 bytes/texel, rows 256-aligned). + let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 8 + 255) / 256) * 256; let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("effect_thumbnail_readback"), size: (aligned_bytes_per_row * EFFECT_THUMBNAIL_SIZE) as u64, @@ -248,8 +284,8 @@ impl EffectThumbnailGenerator { return None; } - // Copy result to readback buffer - let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256; + // Copy result to readback buffer (Rgba16Float = 8 bytes/texel). + let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 8 + 255) / 256) * 256; encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &self.dest_texture, @@ -291,20 +327,21 @@ impl EffectThumbnailGenerator { return None; } - // Copy data from mapped buffer (handling row alignment) + // De-stride the linear-f16 result (drop the 256-byte row padding). let data = buffer_slice.get_mapped_range(); - let mut rgba = Vec::with_capacity((EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as usize); - + let row_tight = (EFFECT_THUMBNAIL_SIZE * 8) as usize; + let mut f16_rgba = Vec::with_capacity(row_tight * EFFECT_THUMBNAIL_SIZE as usize); for row in 0..EFFECT_THUMBNAIL_SIZE { let row_start = (row * aligned_bytes_per_row) as usize; - let row_end = row_start + (EFFECT_THUMBNAIL_SIZE * 4) as usize; - rgba.extend_from_slice(&data[row_start..row_end]); + f16_rgba.extend_from_slice(&data[row_start..row_start + row_tight]); } drop(data); self.readback_buffer.unmap(); - Some(rgba) + // Result is linear f16 (the effect ran in linear light); re-encode to + // sRGB-u8 for display, mirroring the live pipeline's linear→sRGB output. + Some(linear_f16_to_srgb_image(&f16_rgba)) } /// Get all effect IDs that have pending thumbnail requests diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index ec8b7fc..352791e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -191,7 +191,9 @@ impl ExportGpuResources { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("linear_to_srgb_shader"), - source: wgpu::ShaderSource::Wgsl(LINEAR_TO_SRGB_SHADER.into()), + source: wgpu::ShaderSource::Wgsl( + format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, LINEAR_TO_SRGB_SHADER).into(), + ), }); let linear_to_srgb_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { @@ -310,32 +312,24 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { 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) -> vec3 { - return vec3( - linear_to_srgb_channel(color.r), - linear_to_srgb_channel(color.g), - linear_to_srgb_channel(color.b) - ); -} +// linear_to_srgb / linear_to_srgb_channel are provided by the prepended +// COLOR_WGSL prelude (see the create_shader_module call site). @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let src = textureSample(source_tex, source_sampler, in.uv); - // Convert linear HDR to sRGB - let srgb = linear_to_srgb(src.rgb); + // The compositor accumulates PREMULTIPLIED linear color. Unpremultiply + // before the sRGB OETF (srgb(rgb*a) != srgb(rgb)*a) and emit STRAIGHT + // alpha, which is what PNG export / the readback path expect. For opaque + // pixels (a == 1, the normal video case) this is an exact identity. + let a = src.a; + let straight = select(src.rgb / a, vec3(0.0), a <= 0.0); - // Alpha stays unchanged - return vec4(srgb, src.a); + // Convert linear HDR to sRGB + let srgb = linear_to_srgb(straight); + + return vec4(srgb, a); } "#; @@ -522,12 +516,27 @@ pub fn setup_video_encoder( encoder.set_bit_rate((bitrate_kbps * 1000) as usize); encoder.set_gop(framerate as u32); // 1 second GOP + // Tag the color metadata so players interpret the YUV correctly. Our + // RGB→YUV conversion uses the BT.709 matrix with FULL-range (0–255) luma + // and no transfer applied to the already-sRGB-encoded RGB. Tagging this + // as full-range BT.709 (matrix/primaries/transfer) prevents the level/ + // hue shift that occurs when a player assumes limited-range or BT.601. + // colorspace (matrix) and range have safe setters; primaries and trc are + // generic AVCodecContext options set via the open dictionary below. + encoder.set_colorspace(ffmpeg::color::Space::BT709); + encoder.set_color_range(ffmpeg::color::Range::JPEG); // full range + println!("📐 Video dimensions: {}×{} (aligned to {}×{} for H.264)", width, height, aligned_width, aligned_height); - // Open encoder with codec (like working MP3 export) + // Open encoder with codec (like working MP3 export). color_primaries and + // color_trc have no typed setter on the encoder, so pass them as generic + // AVCodecContext options (BT.709) through the open dictionary. + let mut color_opts = ffmpeg::Dictionary::new(); + color_opts.set("color_primaries", "bt709"); + color_opts.set("color_trc", "bt709"); let encoder = encoder - .open_as(codec) + .open_as_with(codec, color_opts) .map_err(|e| format!("Failed to open video encoder: {}", e))?; Ok((encoder, codec)) diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 5958737..c8a545f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -24,24 +24,94 @@ use lightningbeam_core::brush_engine::GpuDab; // Colour-space helpers // --------------------------------------------------------------------------- -/// Decode one sRGB-encoded byte to linear float [0, 1]. -fn srgb_to_linear(c: f32) -> f32 { - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } +use lightningbeam_core::gpu::srgb_to_linear; + +// Lookup tables that keep the per-pixel `powf`/f16 conversions out of the canvas +// upload/readback loops. Doing the sRGB transfer per pixel was ~110ms for an +// 800x600 readback in a debug build; precomputing it once turns each channel +// into a table index. + +/// Upload encode: sRGB byte → linear f16 little-endian bytes (for RGB channels). +fn srgb_to_linear_f16_lut() -> &'static [[u8; 2]; 256] { + static LUT: std::sync::OnceLock<[[u8; 2]; 256]> = std::sync::OnceLock::new(); + LUT.get_or_init(|| { + let mut lut = [[0u8; 2]; 256]; + for (i, out) in lut.iter_mut().enumerate() { + *out = half::f16::from_f32(srgb_to_linear(i as f32 / 255.0)).to_le_bytes(); + } + lut + }) } -/// Encode one linear float [0, 1] to an sRGB-encoded byte. -fn linear_to_srgb_byte(c: u8) -> u8 { - let f = c as f32 / 255.0; - let encoded = if f <= 0.0031308 { - f * 12.92 - } else { - 1.055 * f.powf(1.0 / 2.4) - 0.055 - }; - (encoded * 255.0 + 0.5) as u8 +/// Upload encode: byte → linear f16 little-endian bytes (for the alpha channel, +/// which is not gamma-encoded). +fn linear_f16_lut() -> &'static [[u8; 2]; 256] { + static LUT: std::sync::OnceLock<[[u8; 2]; 256]> = std::sync::OnceLock::new(); + LUT.get_or_init(|| { + let mut lut = [[0u8; 2]; 256]; + for (i, out) in lut.iter_mut().enumerate() { + *out = half::f16::from_f32(i as f32 / 255.0).to_le_bytes(); + } + lut + }) +} + +// --------------------------------------------------------------------------- +// Incremental ping-pong sync +// --------------------------------------------------------------------------- + +/// Tile size (px) for incremental canvas sync copies between the ping-pong +/// textures. The brush keeps both textures identical; each frame only the tiles +/// touched by that frame's dabs are copied, so this bounds both the wasted bytes +/// per touched tile and the number of copy regions. +const SYNC_TILE: u32 = 128; + +/// Coalesced copy rectangles (x, y, w, h) covering the tiles touched by `dabs`, +/// clamped to the canvas. Adjacent tiles in a row are merged into one rectangle +/// to keep the number of `copy_texture_to_texture` calls small. +fn dirty_tile_rects(dabs: &[GpuDab], canvas_w: u32, canvas_h: u32) -> Vec<(u32, u32, u32, u32)> { + if canvas_w == 0 || canvas_h == 0 || dabs.is_empty() { + return Vec::new(); + } + let tiles_x = canvas_w.div_ceil(SYNC_TILE); + let tiles_y = canvas_h.div_ceil(SYNC_TILE); + let mut mask = vec![false; (tiles_x * tiles_y) as usize]; + + for d in dabs { + let r = d.radius + 1.0; + // Dab pixel bbox clamped to the canvas, then mapped to tile indices. + let px0 = (d.x - r).floor().clamp(0.0, (canvas_w - 1) as f32) as u32; + let py0 = (d.y - r).floor().clamp(0.0, (canvas_h - 1) as f32) as u32; + let px1 = (d.x + r).ceil().clamp(0.0, (canvas_w - 1) as f32) as u32; + let py1 = (d.y + r).ceil().clamp(0.0, (canvas_h - 1) as f32) as u32; + for ty in (py0 / SYNC_TILE)..=(py1 / SYNC_TILE) { + for tx in (px0 / SYNC_TILE)..=(px1 / SYNC_TILE) { + mask[(ty * tiles_x + tx) as usize] = true; + } + } + } + + // Merge horizontal runs of set tiles in each tile-row into one rectangle. + let mut rects = Vec::new(); + for ty in 0..tiles_y { + let mut tx = 0; + while tx < tiles_x { + if mask[(ty * tiles_x + tx) as usize] { + let run_start = tx; + while tx < tiles_x && mask[(ty * tiles_x + tx) as usize] { + tx += 1; + } + let x = run_start * SYNC_TILE; + let y = ty * SYNC_TILE; + let w = (tx * SYNC_TILE).min(canvas_w) - x; + let h = ((ty + 1) * SYNC_TILE).min(canvas_h) - y; + rects.push((x, y, w, h)); + } else { + tx += 1; + } + } + } + rects } // --------------------------------------------------------------------------- @@ -68,7 +138,7 @@ impl CanvasPair { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC @@ -93,18 +163,24 @@ impl CanvasPair { /// `pixels` is expected to be **sRGB-encoded premultiplied** (the format stored /// in `raw_pixels` / PNG files). The values are decoded to linear premultiplied /// before being written to the canvas, which operates entirely in linear space. + /// The canvas is `Rgba16Float`, so linear values are stored at 16-bit float + /// precision — storing linear light in 8 bits would band badly in shadows. pub fn upload(&self, queue: &wgpu::Queue, pixels: &[u8]) { - // Decode sRGB-premultiplied → linear premultiplied for the GPU canvas. + // Decode sRGB-premultiplied → linear premultiplied f16 for the GPU canvas. + // LUT-driven so there is no per-pixel powf / float conversion. + let rgb_lut = srgb_to_linear_f16_lut(); + let a_lut = linear_f16_lut(); let linear: Vec = pixels.chunks_exact(4).flat_map(|p| { - let r = (srgb_to_linear(p[0] as f32 / 255.0) * 255.0 + 0.5) as u8; - let g = (srgb_to_linear(p[1] as f32 / 255.0) * 255.0 + 0.5) as u8; - let b = (srgb_to_linear(p[2] as f32 / 255.0) * 255.0 + 0.5) as u8; - [r, g, b, p[3]] + let r = rgb_lut[p[0] as usize]; + let g = rgb_lut[p[1] as usize]; + let b = rgb_lut[p[2] as usize]; + let a = a_lut[p[3] as usize]; + [r[0], r[1], g[0], g[1], b[0], b[1], a[0], a[1]] }).collect(); let layout = wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(self.width * 4), + bytes_per_row: Some(self.width * 8), // Rgba16Float = 8 bytes/texel rows_per_image: Some(self.height), }; let extent = wgpu::Extent3d { @@ -204,7 +280,7 @@ impl RasterTransformPipeline { visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, @@ -384,7 +460,7 @@ impl WarpApplyPipeline { visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, @@ -596,7 +672,7 @@ impl LiquifyBrushPipeline { // Gradient-fill pipeline // --------------------------------------------------------------------------- -/// One gradient stop on the GPU side. Colors are linear straight-alpha [0..1]. +/// One gradient stop on the GPU side. Colors are sRGB straight-alpha [0..1]. /// Must be 32 bytes (8 × f32) to match `GradientStop` in `gradient_fill.wgsl`. #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] @@ -611,13 +687,18 @@ pub struct GpuGradientStop { impl GpuGradientStop { /// Construct from sRGB u8 bytes (as stored in `ShapeColor`). - /// RGB is converted to linear; alpha is kept linear (not gamma-encoded). + /// + /// Stops are kept in sRGB (gamma) space so the shader interpolates between + /// them in gamma space — matching the CPU raster path (`Gradient::eval`) and + /// the vector/peniko path, and the gamma-space gradients users expect from + /// tools like Photoshop/Flash. The shader converts the interpolated color to + /// linear before compositing into the linear canvas. pub fn from_srgb_u8(position: f32, r: u8, g: u8, b: u8, a: u8) -> Self { Self { position, - r: srgb_to_linear(r as f32 / 255.0), - g: srgb_to_linear(g as f32 / 255.0), - b: srgb_to_linear(b as f32 / 255.0), + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, a: a as f32 / 255.0, _pad: [0.0; 3], } @@ -654,7 +735,7 @@ impl GradientFillPipeline { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("gradient_fill_shader"), source: wgpu::ShaderSource::Wgsl( - include_str!("panes/shaders/gradient_fill.wgsl").into(), + color_wgsl(include_str!("panes/shaders/gradient_fill.wgsl")).into(), ), }); @@ -701,7 +782,7 @@ impl GradientFillPipeline { visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, @@ -780,9 +861,76 @@ impl GradientFillPipeline { /// Compute pipeline: composites the scratch buffer C over the source A → output B. /// /// Binding layout (see `alpha_composite.wgsl`): -/// 0 = tex_a (texture_2d, Rgba8Unorm, sampled, not filterable) -/// 1 = tex_c (texture_2d, Rgba8Unorm, sampled, not filterable) +/// 0 = tex_a (texture_2d, Rgba16Float, sampled, not filterable) +/// 1 = tex_c (texture_2d, Rgba16Float, sampled, not filterable) /// 2 = tex_b (texture_storage_2d) +/// Prepend the shared WGSL sRGB color functions ([`COLOR_WGSL`]) to a shader +/// source so the OETF/EOTF live in exactly one place. +fn color_wgsl(shader_src: &str) -> String { + format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, shader_src) +} + +/// A compute `texture_2d` sampled binding (non-filterable). +fn sampled_tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + } +} + +/// A compute write-only storage-texture binding of the given format. +fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + } +} + +/// Build a compute pipeline + matching bind-group layout from WGSL source and +/// layout entries (entry point `main`). Collapses the otherwise-identical +/// pipeline/layout construction boilerplate shared by the compute pipelines. +fn build_compute_pipeline( + device: &wgpu::Device, + label: &str, + shader_src: &str, + entries: &[wgpu::BindGroupLayoutEntry], +) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label), + source: wgpu::ShaderSource::Wgsl(shader_src.into()), + }); + let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(label), + entries, + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(label), + bind_group_layouts: &[&bg_layout], + push_constant_ranges: &[], + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(label), + layout: Some(&layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + (pipeline, bg_layout) +} + struct AlphaCompositePipeline { pipeline: wgpu::ComputePipeline, bg_layout: wgpu::BindGroupLayout, @@ -790,56 +938,78 @@ struct AlphaCompositePipeline { impl AlphaCompositePipeline { fn new(device: &wgpu::Device) -> Self { - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("alpha_composite_shader"), - source: wgpu::ShaderSource::Wgsl( - include_str!("panes/shaders/alpha_composite.wgsl").into(), - ), - }); - let sampled_entry = |binding: u32| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }; - let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("alpha_composite_bgl"), - entries: &[ - sampled_entry(0), // tex_a - sampled_entry(1), // tex_c - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, - view_dimension: wgpu::TextureViewDimension::D2, - }, - count: None, - }, + let (pipeline, bg_layout) = build_compute_pipeline( + device, + "alpha_composite", + include_str!("panes/shaders/alpha_composite.wgsl"), + &[ + sampled_tex_entry(0), // tex_a + sampled_tex_entry(1), // tex_c + storage_tex_entry(2, wgpu::TextureFormat::Rgba16Float), // tex_b (out) ], - }); - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("alpha_composite_layout"), - bind_group_layouts: &[&bg_layout], - push_constant_ranges: &[], - }); - let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("alpha_composite_pipeline"), - layout: Some(&layout), - module: &shader, - entry_point: Some("main"), - compilation_options: Default::default(), - cache: None, - }); + ); Self { pipeline, bg_layout } } } +/// Compute pipeline that converts the linear-premultiplied `Rgba16Float` canvas +/// into an `Rgba8Unorm` sRGB-premultiplied texture for fast readback. +struct ReadbackSrgbPipeline { + pipeline: wgpu::ComputePipeline, + bg_layout: wgpu::BindGroupLayout, +} + +impl ReadbackSrgbPipeline { + fn new(device: &wgpu::Device) -> Self { + let (pipeline, bg_layout) = build_compute_pipeline( + device, + "canvas_readback_srgb", + &color_wgsl(include_str!("panes/shaders/canvas_readback_srgb.wgsl")), + &[ + sampled_tex_entry(0), // src (linear) + storage_tex_entry(1, wgpu::TextureFormat::Rgba8Unorm), // dst (sRGB) + ], + ); + Self { pipeline, bg_layout } + } +} + +/// Reusable scratch for `readback_canvas`: the Rgba8Unorm conversion target plus +/// its MAP_READ staging buffer, kept across calls and rebuilt only on size change. +struct ReadbackScratch { + width: u32, + height: u32, + view: wgpu::TextureView, + tex: wgpu::Texture, + staging: wgpu::Buffer, + /// 256-aligned bytes-per-row of the staging buffer (Rgba8 = 4 B/texel). + bytes_per_row_aligned: u32, +} + +impl ReadbackScratch { + fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("canvas_readback_srgb"), + 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::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let bytes_per_row_aligned = ((width * 4 + 255) / 256) * 256; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("canvas_readback_buf"), + size: (bytes_per_row_aligned * height) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + Self { width, height, view, tex, staging, bytes_per_row_aligned } + } +} + // GpuBrushEngine // --------------------------------------------------------------------------- @@ -860,13 +1030,20 @@ pub struct GpuBrushEngine { /// Lazily created on first unified-tool composite dispatch. composite_pipeline: Option, + /// Lazily-created pipeline converting the canvas to sRGB for fast readback. + readback_srgb_pipeline: Option, + + /// Reused scratch (texture + staging buffer) for `readback_canvas`, recreated + /// only when the canvas size changes, to avoid per-stroke GPU allocations. + readback_scratch: Option, + /// Canvas texture pairs keyed by keyframe UUID. pub canvases: HashMap, /// Displacement map buffers keyed by a caller-supplied UUID. pub displacement_bufs: HashMap, - /// Persistent `Rgba8Unorm` textures for idle raster layers. + /// Persistent `Rgba16Float` textures for idle raster layers. /// /// Keyed by keyframe UUID (same ID space as `canvases`). Entries are uploaded /// once when `RasterKeyframe::texture_dirty` is set, then reused every frame. @@ -890,7 +1067,7 @@ struct DabParams { impl GpuBrushEngine { /// Create the pipeline. Returns `Err` if the device lacks the required - /// storage-texture capability for `Rgba8Unorm`. + /// storage-texture capability for `Rgba16Float`. pub fn new(device: &wgpu::Device) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("brush_dab_shader"), @@ -942,7 +1119,7 @@ impl GpuBrushEngine { visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, @@ -978,6 +1155,8 @@ impl GpuBrushEngine { liquify_brush_pipeline: None, gradient_fill_pipeline: None, composite_pipeline: None, + readback_srgb_pipeline: None, + readback_scratch: None, canvases: HashMap::new(), displacement_bufs: HashMap::new(), raster_layer_cache: HashMap::new(), @@ -1022,12 +1201,14 @@ impl GpuBrushEngine { ) { if dabs.is_empty() { return; } - // Smudge dabs must be applied one at a time so each dab reads the canvas - // state written by the previous dab. Use bbox-only copies (union of current - // and previous dab) to avoid an expensive full-canvas copy per dab. + // render_dabs_batch keeps both ping-pong textures identical after every + // call, so smudge — whose each dab must read the previous dab's output — + // simply dispatches one dab at a time: each per-dab call leaves src fully + // authoritative for the next. Paint/erase dabs are independent within a + // frame (the shader accumulates them over the shared source), so they + // dispatch together as one batch. let is_smudge = dabs.first().map(|d| d.blend_mode == 2).unwrap_or(false); if is_smudge { - let mut prev_bbox: Option<(i32, i32, i32, i32)> = None; for dab in dabs { let r = dab.radius + 1.0; let cur_bbox = ( @@ -1036,31 +1217,24 @@ impl GpuBrushEngine { (dab.x + r).ceil() as i32, (dab.y + r).ceil() as i32, ); - // Expand copy region to include the previous dab's bbox so the - // pixels it wrote are visible as the source for this dab's smudge. - let copy_bbox = match prev_bbox { - Some(pb) => (cur_bbox.0.min(pb.0), cur_bbox.1.min(pb.1), - cur_bbox.2.max(pb.2), cur_bbox.3.max(pb.3)), - None => cur_bbox, - }; self.render_dabs_batch(device, queue, keyframe_id, - std::slice::from_ref(dab), cur_bbox, Some(copy_bbox), canvas_w, canvas_h); - prev_bbox = Some(cur_bbox); + std::slice::from_ref(dab), cur_bbox, canvas_w, canvas_h); } } else { - self.render_dabs_batch(device, queue, keyframe_id, dabs, bbox, None, canvas_w, canvas_h); + self.render_dabs_batch(device, queue, keyframe_id, dabs, bbox, canvas_w, canvas_h); } } - /// Inner batch dispatch. + /// Dispatch one batch of dabs and keep the ping-pong textures identical. /// - /// `dispatch_bbox` — region dispatched to the compute shader (usually the union of all dab bboxes). - /// `copy_bbox` — region to copy src→dst before dispatch: - /// - `None` → copy the full canvas (required for paint/erase batches so - /// dabs outside the current frame's region are preserved). - /// - `Some(r)` → copy only region `r` (sufficient for sequential smudge dabs - /// because both textures hold identical data outside previously - /// touched regions, so no full copy is needed). + /// Reads `src` and writes the result over `dispatch_bbox` into `dst`, then + /// copies only the tiles the dabs touched back from `dst` to `src` so both + /// textures stay authoritative — no full-canvas copy. The textures start equal + /// (seeded by `upload` at stroke start) and this preserves that invariant, so a + /// single dab per call is enough for smudge's read-after-write dependency. + /// + /// `dispatch_bbox` is the region dispatched to the compute shader (the union of + /// the batch's dab bboxes). fn render_dabs_batch( &mut self, device: &wgpu::Device, @@ -1068,7 +1242,6 @@ impl GpuBrushEngine { keyframe_id: Uuid, dabs: &[GpuDab], dispatch_bbox: (i32, i32, i32, i32), - copy_bbox: Option<(i32, i32, i32, i32)>, canvas_w: u32, canvas_h: u32, ) { @@ -1088,61 +1261,7 @@ impl GpuBrushEngine { let bbox_w = x1 - x0; let bbox_h = y1 - y0; - // Step 1: Copy src→dst. - // For paint/erase batches (copy_bbox = None): copy the ENTIRE canvas so dst - // starts with all previous dabs — a bbox-only copy would lose dabs outside - // this frame's region after swap. - // For smudge (copy_bbox = Some(r)): copy only the union of the current and - // previous dab bboxes. Outside that region both textures hold identical - // data so no full copy is needed. - let mut copy_enc = device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") }, - ); - match copy_bbox { - None => { - copy_enc.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: canvas.src(), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: canvas.dst(), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { width: canvas_w, height: canvas_h, depth_or_array_layers: 1 }, - ); - } - Some(cb) => { - let cx0 = cb.0.max(0) as u32; - let cy0 = cb.1.max(0) as u32; - let cx1 = (cb.2 as u32).min(canvas_w); - let cy1 = (cb.3 as u32).min(canvas_h); - if cx1 > cx0 && cy1 > cy0 { - copy_enc.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: canvas.src(), - mip_level: 0, - origin: wgpu::Origin3d { x: cx0, y: cy0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: canvas.dst(), - mip_level: 0, - origin: wgpu::Origin3d { x: cx0, y: cy0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { width: cx1 - cx0, height: cy1 - cy0, depth_or_array_layers: 1 }, - ); - } - } - } - queue.submit(Some(copy_enc.finish())); - - // Step 2: Upload all dabs as a single storage buffer. + // Step 1: Upload all dabs as a single storage buffer. let dab_bytes = bytemuck::cast_slice(dabs); let dab_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("dab_storage_buf"), @@ -1181,7 +1300,7 @@ impl GpuBrushEngine { ], }); - // Step 3: Single dispatch over the union bounding box. + // Step 2: Single dispatch over the union bounding box. let mut compute_enc = device.create_command_encoder( &wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") }, ); @@ -1193,52 +1312,98 @@ impl GpuBrushEngine { pass.set_bind_group(0, &bg, &[]); pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1); } + + // Step 3: Sync only the tiles these dabs touched from dst back to src, so + // both ping-pong textures stay identical and authoritative — only the bytes + // that actually changed are moved (no full-canvas copy). The copies share + // the compute encoder, so wgpu orders them after the dispatch writes. + for (rx, ry, rw, rh) in dirty_tile_rects(dabs, canvas_w, canvas_h) { + compute_enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: canvas.dst(), + mip_level: 0, + origin: wgpu::Origin3d { x: rx, y: ry, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: canvas.src(), + mip_level: 0, + origin: wgpu::Origin3d { x: rx, y: ry, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { width: rw, height: rh, depth_or_array_layers: 1 }, + ); + } queue.submit(Some(compute_enc.finish())); // Step 4: Swap once — dst (with all dabs applied) becomes the new src. canvas.swap(); } - /// Read the current canvas back to a CPU `Vec` (raw RGBA, row-major). + /// Read the current canvas back to a CPU `Vec` (sRGB-premultiplied RGBA, + /// row-major). /// - /// **Blocks** until the GPU work is complete (`Maintain::Wait`). - /// Should only be called at stroke end, not every frame. + /// The linear→sRGB conversion runs on the GPU into an `Rgba8Unorm` scratch + /// texture, so the CPU side is just a per-row `memcpy` (no per-pixel decode). + /// **Blocks** until the GPU work is complete. Call at stroke end, not per frame. /// /// Returns `None` if no canvas exists for `keyframe_id`. pub fn readback_canvas( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, keyframe_id: Uuid, ) -> Option> { + // Lazily build the conversion pipeline and (re)create the cached scratch if + // the canvas size changed — these mutable borrows end before the shared + // borrows below. + if self.readback_srgb_pipeline.is_none() { + self.readback_srgb_pipeline = Some(ReadbackSrgbPipeline::new(device)); + } + let (width, height) = { + let canvas = self.canvases.get(&keyframe_id)?; + (canvas.width, canvas.height) + }; + if self.readback_scratch.as_ref().map_or(true, |s| s.width != width || s.height != height) { + self.readback_scratch = Some(ReadbackScratch::new(device, width, height)); + } + + let pipeline = self.readback_srgb_pipeline.as_ref().unwrap(); + let scratch = self.readback_scratch.as_ref().unwrap(); let canvas = self.canvases.get(&keyframe_id)?; - let width = canvas.width; - let height = canvas.height; - // wgpu requires bytes_per_row to be a multiple of 256 - let bytes_per_row_aligned = - ((width * 4 + 255) / 256) * 256; - let total_bytes = (bytes_per_row_aligned * height) as u64; - - let staging = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("canvas_readback_buf"), - size: total_bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, + // GPU pass: linear-premultiplied Rgba16Float → sRGB-premultiplied Rgba8Unorm. + // Reading back 8-bit sRGB lets the CPU just memcpy each row. + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("canvas_readback_srgb_bg"), + layout: &pipeline.bg_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(canvas.src_view()) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&scratch.view) }, + ], }); + let bytes_per_row_aligned = scratch.bytes_per_row_aligned; let mut encoder = device.create_command_encoder( &wgpu::CommandEncoderDescriptor { label: Some("canvas_readback_encoder") }, ); + { + let mut pass = encoder.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("canvas_readback_srgb_pass"), timestamp_writes: None }, + ); + pass.set_pipeline(&pipeline.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { - texture: canvas.src(), + texture: &scratch.tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { - buffer: &staging, + buffer: &scratch.staging, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(bytes_per_row_aligned), @@ -1249,8 +1414,8 @@ impl GpuBrushEngine { ); queue.submit(Some(encoder.finish())); - // Block until complete - let slice = staging.slice(..); + // Block until complete. + let slice = scratch.staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = device.poll(wgpu::PollType::wait_indefinitely()); @@ -1258,27 +1423,19 @@ impl GpuBrushEngine { let mapped = slice.get_mapped_range(); - // De-stride: copy only `width * 4` bytes per row (drop alignment padding) - let bytes_per_row_tight = (width * 4) as usize; - let bytes_per_row_src = bytes_per_row_aligned as usize; + // De-stride with a per-row memcpy (dropping the 256-byte row padding). The + // bytes are already sRGB-premultiplied RGBA8 from the GPU pass, which is + // what Vello expects (ImageAlphaType::Premultiplied with sRGB channels). + let row_tight = (width * 4) as usize; + let row_src = bytes_per_row_aligned as usize; let mut pixels = vec![0u8; (width * height * 4) as usize]; for row in 0..height as usize { - let src = &mapped[row * bytes_per_row_src .. row * bytes_per_row_src + bytes_per_row_tight]; - let dst = &mut pixels[row * bytes_per_row_tight .. (row + 1) * bytes_per_row_tight]; - dst.copy_from_slice(src); + let src = &mapped[row * row_src .. row * row_src + row_tight]; + pixels[row * row_tight .. (row + 1) * row_tight].copy_from_slice(src); } drop(mapped); - staging.unmap(); - - // Encode linear premultiplied → sRGB-encoded premultiplied so the returned - // bytes match what Vello expects (ImageAlphaType::Premultiplied with sRGB - // channels). Alpha is left unchanged. - for pixel in pixels.chunks_exact_mut(4) { - pixel[0] = linear_to_srgb_byte(pixel[0]); - pixel[1] = linear_to_srgb_byte(pixel[1]); - pixel[2] = linear_to_srgb_byte(pixel[2]); - } + scratch.staging.unmap(); Some(pixels) } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl index 5012b86..8d02ada 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl @@ -5,12 +5,12 @@ // // B[px] = C[px] + A[px] * (1 − C[px].a) (Porter-Duff src-over, C over A) // -// All textures are Rgba8Unorm, linear premultiplied RGBA. +// All textures are Rgba16Float, linear premultiplied RGBA. // Dispatch: ceil(w/8) × ceil(h/8) × 1. @group(0) @binding(0) var tex_a: texture_2d; // source (A) @group(0) @binding(1) var tex_c: texture_2d; // accumulated dabs (C) -@group(0) @binding(2) var tex_b: texture_storage_2d; // output (B) +@group(0) @binding(2) var tex_b: texture_storage_2d; // output (B) @compute @workgroup_size(8, 8) fn main(@builtin(global_invocation_id) gid: vec3) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl index 134b3dd..d2bac69 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl @@ -37,7 +37,7 @@ struct Params { @group(0) @binding(0) var dabs: array; @group(0) @binding(1) var params: Params; @group(0) @binding(2) var canvas_src: texture_2d; -@group(0) @binding(3) var canvas_dst: texture_storage_2d; +@group(0) @binding(3) var canvas_dst: texture_storage_2d; // --------------------------------------------------------------------------- // Manual bilinear sample from canvas_src at sub-pixel coordinates (px, py). diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_readback_srgb.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_readback_srgb.wgsl new file mode 100644 index 0000000..2bf3a46 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_readback_srgb.wgsl @@ -0,0 +1,27 @@ +// Canvas readback color conversion. +// +// Converts the Rgba16Float linear-PREMULTIPLIED canvas into an Rgba8Unorm +// sRGB-encoded premultiplied texture so the CPU readback is a pure row memcpy +// instead of a per-pixel sRGB decode. Matches the bytes the previous CPU decode +// produced: the sRGB OETF is applied per channel to the premultiplied RGB, and +// alpha (which is not gamma-encoded) is passed through. + +// linear_to_srgb_channel is provided by the prepended COLOR_WGSL prelude. +@group(0) @binding(0) var src: texture_2d; // linear premultiplied +@group(0) @binding(1) var dst: texture_storage_2d; // sRGB premultiplied + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) gid: vec3) { + let dim = textureDimensions(src); + if gid.x >= dim.x || gid.y >= dim.y { + return; + } + let p = vec2(i32(gid.x), i32(gid.y)); + let c = textureLoad(src, p, 0); + let srgb = vec3( + linear_to_srgb_channel(c.r), + linear_to_srgb_channel(c.g), + linear_to_srgb_channel(c.b), + ); + textureStore(dst, p, vec4(srgb, c.a)); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl index 19e99db..286a16c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl @@ -2,8 +2,10 @@ // // Reads the anchor canvas (before_pixels), composites a gradient over it, and // writes the result to the display canvas. All color values in the canvas are -// linear premultiplied RGBA. The stop colors passed via `stops` are linear -// straight-alpha [0..1] (sRGB→linear conversion is done on the CPU). +// linear premultiplied RGBA. The stop colors passed via `stops` are sRGB +// straight-alpha [0..1]; the gradient is interpolated in sRGB (gamma) space to +// match the CPU raster and vector gradient paths, then the interpolated color is +// converted to linear before compositing. // // Dispatch: ceil(canvas_w / 8) × ceil(canvas_h / 8) × 1 @@ -25,7 +27,7 @@ struct Params { // 32 bytes per stop (8 × f32), matching `GpuGradientStop` on the Rust side. struct GradientStop { position: f32, - r: f32, // linear [0..1], straight-alpha + r: f32, // sRGB [0..1], straight-alpha g: f32, b: f32, a: f32, @@ -34,10 +36,11 @@ struct GradientStop { _pad2: f32, } +// srgb_to_linear_channel is provided by the prepended COLOR_WGSL prelude. @group(0) @binding(0) var params: Params; @group(0) @binding(1) var src: texture_2d; @group(0) @binding(2) var stops: array; -@group(0) @binding(3) var dst: texture_storage_2d; +@group(0) @binding(3) var dst: texture_storage_2d; fn apply_extend(t: f32) -> f32 { if params.extend_mode == 0u { @@ -122,7 +125,15 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } let t = apply_extend(t_raw); - let grad = eval_gradient(t); // straight-alpha linear RGBA + let grad = eval_gradient(t); // straight-alpha sRGB RGBA (interpolated in gamma space) + + // Convert the interpolated sRGB color to linear for compositing. Alpha is + // not gamma-encoded, so it passes through unchanged. + let grad_rgb_lin = vec3( + srgb_to_linear_channel(grad.r), + srgb_to_linear_channel(grad.g), + srgb_to_linear_channel(grad.b), + ); // Effective alpha: gradient alpha × tool opacity. let a = grad.a * params.opacity; @@ -131,7 +142,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // src_px.rgb is premultiplied (= straight_rgb * src_a). // Output is also premultiplied. let out_a = a + src_px.a * (1.0 - a); - let out_rgb = grad.rgb * a + src_px.rgb * (1.0 - a); + let out_rgb = grad_rgb_lin * a + src_px.rgb * (1.0 - a); textureStore(dst, vec2(i32(gid.x), i32(gid.y)), vec4(out_rgb, out_a)); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl index 491afde..8537377 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl @@ -30,27 +30,23 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { 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 - ); -} +// linear_to_srgb_channel is provided by the prepended COLOR_WGSL prelude. @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // Sample linear HDR texture + // Sample linear HDR texture. The compositor accumulates PREMULTIPLIED + // linear color, so unpremultiply before applying the sRGB OETF: + // srgb(rgb*a) != srgb(rgb)*a, so encoding premultiplied color directly + // corrupts antialiased edges and transparent pixels. We output straight + // alpha to match the straight-alpha display blit and PNG export. let linear = textureSample(input_tex, input_sampler, in.uv); + let a = linear.a; + let straight = select(linear.rgb / a, vec3(0.0), a <= 0.0); - // Convert from linear to sRGB for display (alpha stays linear) return vec4( - linear_to_srgb_channel(linear.r), - linear_to_srgb_channel(linear.g), - linear_to_srgb_channel(linear.b), - linear.a + linear_to_srgb_channel(straight.r), + linear_to_srgb_channel(straight.g), + linear_to_srgb_channel(straight.b), + a ); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl index 154376e..621f3ba 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl @@ -26,7 +26,7 @@ struct Params { @group(0) @binding(0) var params: Params; @group(0) @binding(1) var src: texture_2d; -@group(0) @binding(2) var dst: texture_storage_2d; +@group(0) @binding(2) var dst: texture_storage_2d; // Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders). fn bilinear_sample(px: f32, py: f32) -> vec4 { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl index 1bd04f3..e1af702 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl @@ -26,7 +26,7 @@ struct Params { @group(0) @binding(0) var params: Params; @group(0) @binding(1) var src: texture_2d; @group(0) @binding(2) var disp: array; -@group(0) @binding(3) var dst: texture_storage_2d; +@group(0) @binding(3) var dst: texture_storage_2d; // Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders). fn bilinear_sample(px: f32, py: f32) -> vec4 { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index e1f11b9..b2bed25 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -217,7 +217,9 @@ impl SharedVelloResources { // 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()), + source: wgpu::ShaderSource::Wgsl( + format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, include_str!("shaders/linear_to_srgb.wgsl")).into(), + ), }); let hdr_blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { diff --git a/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs b/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs index d15dad2..8244976 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs @@ -8,7 +8,7 @@ //! | **B** | Write-only | Output / display. Compositor shows B while the tool is active. | //! | **C** | Read+Write | Scratch. Dabs accumulate here across the stroke; composite A+C→B each frame. | //! -//! All three are `Rgba8Unorm` with the same pixel dimensions. The framework +//! All three are `Rgba16Float` with the same pixel dimensions. The framework //! allocates and validates them in [`begin_raster_workspace`]; tools only //! dispatch shaders. @@ -45,11 +45,11 @@ pub enum WorkspaceSource { /// commit or cancel. #[derive(Debug)] pub struct RasterWorkspace { - /// A canvas (Rgba8Unorm) — source pixels, uploaded at mousedown, read-only for tools. + /// A canvas (Rgba16Float) — source pixels, uploaded at mousedown, read-only for tools. pub a_canvas_id: Uuid, - /// B canvas (Rgba8Unorm) — output / display; compositor shows this while active. + /// B canvas (Rgba16Float) — output / display; compositor shows this while active. pub b_canvas_id: Uuid, - /// C canvas (Rgba8Unorm) — scratch; tools accumulate dabs here across the stroke. + /// C canvas (Rgba16Float) — scratch; tools accumulate dabs here across the stroke. pub c_canvas_id: Uuid, /// Optional R8Unorm selection mask (same pixel dimensions as A/B/C). /// `None` means the entire workspace is selected.