Fix gamma handling and improve brush canvas performance

Color correctness:
- Unpremultiply before the sRGB OETF on the display and export blits;
  encoding premultiplied color corrupted antialiased/transparent edges.
- Tag exported video as full-range BT.709 (matrix/primaries/transfer).
- Run perception effects (invert, brightness/contrast, hue/saturation)
  in gamma space to match standard editors.
- Interpolate gradients in gamma space across the raster and vector paths.
- Render effect thumbnails in the same linear space as the live pipeline.

Brush performance:
- Store the raster canvas as Rgba16Float (no shadow banding from 8-bit
  linear), with an incremental per-tile ping-pong sync replacing the
  per-frame full-canvas copy.
- Do the linear->sRGB readback conversion on the GPU and reuse a cached
  scratch texture, dropping a ~110ms-per-stroke CPU decode.

Cleanup:
- Single COLOR_WGSL prelude and shared CPU sRGB scalars instead of ~8
  duplicated copies of the transfer functions.
- Shared compute-pipeline builder; smudge folded onto the tile-sync path.
This commit is contained in:
Skyler Lehmkuhl 2026-06-16 08:32:34 -04:00
parent 7257cdf9c4
commit 318720f89d
18 changed files with 582 additions and 285 deletions

View File

@ -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),

View File

@ -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<f32>) -> vec3<f32> {
return vec3<f32>(srgb_to_linear_channel(c.r), srgb_to_linear_channel(c.g), srgb_to_linear_channel(c.b));
}
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
return vec3<f32>(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.

View File

@ -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;

View File

@ -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<f32> {
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<f32>(brightness);
var color = src_srgb + vec3<f32>(brightness);
// Apply contrast (multiply around midpoint 0.5)
color = (color - vec3<f32>(0.5)) * contrast + vec3<f32>(0.5);
@ -46,6 +55,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Clamp to valid range
color = clamp(color, vec3<f32>(0.0), vec3<f32>(1.0));
let result = mix(src.rgb, color, uniforms.mix);
return vec4<f32>(result, src.a);
let result_srgb = mix(src_srgb, color, uniforms.mix);
return vec4<f32>(srgb_to_linear(result_srgb), src.a);
}

View File

@ -84,6 +84,12 @@ fn hsl_to_rgb(hsl: vec3<f32>) -> vec3<f32> {
);
}
// 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<f32> {
let src = textureSample(source_tex, source_sampler, in.uv);
@ -91,8 +97,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
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<f32> {
// Convert back to RGB
let adjusted = hsl_to_rgb(hsl);
let result = mix(src.rgb, adjusted, uniforms.mix);
return vec4<f32>(result, src.a);
let result_srgb = mix(src_srgb, adjusted, uniforms.mix);
return vec4<f32>(srgb_to_linear(result_srgb), src.a);
}

View File

@ -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<f32> {
let src = textureSample(source_tex, source_sampler, in.uv);
let amount = uniforms.params0.x; // params[0]
let inverted = vec3<f32>(1.0) - src.rgb;
let result = mix(src.rgb, inverted, amount * uniforms.mix);
let src_srgb = linear_to_srgb(src.rgb);
let inverted = vec3<f32>(1.0) - src_srgb;
let result_srgb = mix(src_srgb, inverted, amount * uniforms.mix);
return vec4<f32>(result, src.a);
return vec4<f32>(srgb_to_linear(result_srgb), src.a);
}

View File

@ -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<u8> {
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<u8> {
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

View File

@ -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<f32>) -> vec3<f32> {
return vec3<f32>(
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<f32> {
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<f32>(0.0), a <= 0.0);
// Alpha stays unchanged
return vec4<f32>(srgb, src.a);
// Convert linear HDR to sRGB
let srgb = linear_to_srgb(straight);
return vec4<f32>(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 (0255) 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))

View File

@ -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
/// 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 {
1.055 * f.powf(1.0 / 2.4) - 0.055
};
(encoded * 255.0 + 0.5) as u8
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<u8> = 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,23 +861,18 @@ 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<f32>, Rgba8Unorm, sampled, not filterable)
/// 1 = tex_c (texture_2d<f32>, Rgba8Unorm, sampled, not filterable)
/// 0 = tex_a (texture_2d<f32>, Rgba16Float, sampled, not filterable)
/// 1 = tex_c (texture_2d<f32>, Rgba16Float, sampled, not filterable)
/// 2 = tex_b (texture_storage_2d<rgba8unorm, write>)
struct AlphaCompositePipeline {
pipeline: wgpu::ComputePipeline,
bg_layout: wgpu::BindGroupLayout,
/// 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)
}
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 {
/// A compute `texture_2d<f32>` sampled binding (non-filterable).
fn sampled_tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
@ -805,41 +881,135 @@ impl AlphaCompositePipeline {
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
}
}
/// 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: 2,
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: wgpu::TextureFormat::Rgba8Unorm,
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("alpha_composite_layout"),
label: Some(label),
bind_group_layouts: &[&bg_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("alpha_composite_pipeline"),
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,
}
impl AlphaCompositePipeline {
fn new(device: &wgpu::Device) -> Self {
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)
],
);
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<AlphaCompositePipeline>,
/// Lazily-created pipeline converting the canvas to sRGB for fast readback.
readback_srgb_pipeline: Option<ReadbackSrgbPipeline>,
/// Reused scratch (texture + staging buffer) for `readback_canvas`, recreated
/// only when the canvas size changes, to avoid per-stroke GPU allocations.
readback_scratch: Option<ReadbackScratch>,
/// Canvas texture pairs keyed by keyframe UUID.
pub canvases: HashMap<Uuid, CanvasPair>,
/// Displacement map buffers keyed by a caller-supplied UUID.
pub displacement_bufs: HashMap<Uuid, DisplacementBuffer>,
/// 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<u8>` (raw RGBA, row-major).
/// Read the current canvas back to a CPU `Vec<u8>` (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<Vec<u8>> {
// 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)?;
let width = canvas.width;
let height = canvas.height;
(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));
}
// 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 pipeline = self.readback_srgb_pipeline.as_ref().unwrap();
let scratch = self.readback_scratch.as_ref().unwrap();
let canvas = self.canvases.get(&keyframe_id)?;
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)
}

View File

@ -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<f32>; // source (A)
@group(0) @binding(1) var tex_c: texture_2d<f32>; // accumulated dabs (C)
@group(0) @binding(2) var tex_b: texture_storage_2d<rgba8unorm, write>; // output (B)
@group(0) @binding(2) var tex_b: texture_storage_2d<rgba16float, write>; // output (B)
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {

View File

@ -37,7 +37,7 @@ struct Params {
@group(0) @binding(0) var<storage, read> dabs: array<GpuDab>;
@group(0) @binding(1) var<uniform> params: Params;
@group(0) @binding(2) var canvas_src: texture_2d<f32>;
@group(0) @binding(3) var canvas_dst: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var canvas_dst: texture_storage_2d<rgba16float, write>;
// ---------------------------------------------------------------------------
// Manual bilinear sample from canvas_src at sub-pixel coordinates (px, py).

View File

@ -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<f32>; // linear premultiplied
@group(0) @binding(1) var dst: texture_storage_2d<rgba8unorm, write>; // sRGB premultiplied
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let dim = textureDimensions(src);
if gid.x >= dim.x || gid.y >= dim.y {
return;
}
let p = vec2<i32>(i32(gid.x), i32(gid.y));
let c = textureLoad(src, p, 0);
let srgb = vec3<f32>(
linear_to_srgb_channel(c.r),
linear_to_srgb_channel(c.g),
linear_to_srgb_channel(c.b),
);
textureStore(dst, p, vec4<f32>(srgb, c.a));
}

View File

@ -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] (sRGBlinear 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<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var<storage, read> stops: array<GradientStop>;
@group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var dst: texture_storage_2d<rgba16float, write>;
fn apply_extend(t: f32) -> f32 {
if params.extend_mode == 0u {
@ -122,7 +125,15 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
}
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<f32>(
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<u32>) {
// 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>(i32(gid.x), i32(gid.y)), vec4<f32>(out_rgb, out_a));
}

View File

@ -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<f32> {
// 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<f32>(0.0), a <= 0.0);
// 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
linear_to_srgb_channel(straight.r),
linear_to_srgb_channel(straight.g),
linear_to_srgb_channel(straight.b),
a
);
}

View File

@ -26,7 +26,7 @@ struct Params {
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var dst: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(2) var dst: texture_storage_2d<rgba16float, write>;
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {

View File

@ -26,7 +26,7 @@ struct Params {
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var<storage, read> disp: array<vec2f>;
@group(0) @binding(3) var dst: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var dst: texture_storage_2d<rgba16float, write>;
// Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders).
fn bilinear_sample(px: f32, py: f32) -> vec4<f32> {

View File

@ -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 {

View File

@ -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.