Lutra under the hood
Architecture & every effect, explained for someone who knows a little GLSL
“Above all else, show the data.”
Lutra is a color-grading app. You load a photo, add adjustment layers (exposure, contrast, grain, vignette, …), drag sliders, and the image updates live. All image processing happens on the GPU via WebGPU compute shaders written in WGSL. This document walks through how the whole thing is built — from the pixel-level math of each effect up to the pass pipeline that renders them — assuming you’ve written a little GLSL but never touched WebGPU.
0 · Map of the code
The project is split into two packages, and the boundary is deliberate:
| Package | Owns | Knows about |
|---|---|---|
@lutra/engine |
The pure computational core: layer schemas, the registry of effects, and — the subject of this doc — every WGSL shader body and the assembler that stitches passes together. Also packs parameter values into uniform buffers. | No WebGPU API, no DOM, no browser. It just produces strings and numbers. |
@lutra/frontend |
The browser app: the UI, and the GpuBackend — device acquisition, texture upload, dispatching compute passes, blitting to the canvas, and export readback. |
The WebGPU API and the canvas. It calls the engine to build shaders, then runs them. |
The files that matter for this document:
packages/engine/src/shaders/chain-source.ts— the assembler: turns an ordered list of layers into an ordered list of WGSL compute passes.packages/engine/src/shaders/bodies/*.ts— one file per effect; each exports a function that emits the WGSL statements for that effect.packages/engine/src/shaders/colorspace.ts— the sRGB↔linear conversion functions embedded into shaders.packages/engine/src/render.ts— builds a render request: passes + packed uniforms + source image + frame counter.packages/frontend/src/gpu/backend.ts— the WebGPU runtime: textures, buffers, pipelines, dispatch loop, blit, snapshot.
1 · Compute shaders vs. the GLSL you know
If your GLSL background is fragment shaders, the mental model is: write a function that runs once per pixel; read a texture; write a color. WebGPU compute shaders are the same idea with a different framing. Instead of the GPU calling your function once per pixel implicitly, it calls it once per invocation, and you decide which pixel each invocation handles — usually just id.xy. You also get explicit control over how invocations are grouped into workgroups, which is what unlocks shared-memory tricks later (real film grain, local contrast).
| Concept | GLSL fragment shader | WGSL compute shader (as Lutra uses it) |
|---|---|---|
| Entry point | main(), once per pixel; position via gl_FragCoord |
fn main(@builtin(global_invocation_id) id: vec3<u32>) — id.xy is the pixel |
| Read a texel | texture(sampler2D, uv) — filtered, float coords |
textureLoad(tex, coord, 0) — raw texel, integer coords, no filtering |
| Write a pixel | gl_FragColor = ... / out vec4 |
textureStore(storageTex, coord, vec4) into a storage texture |
| Uniforms | uniform float x; |
var<uniform> u_params: LayerParams; — grouped in buffers, bound via bind groups |
| Thread grouping | implicit | workgroups of 16×16 = 256 invocations; you dispatch a grid of them |
Two consequences of textureLoad matter throughout this codebase. First, there is no bilinear filtering in compute — if you want smooth sampling you must write it yourself or use a separate pass with a sampler (Lutra does the former in the LUT body, see §7.11, and the latter for display, see §3). Second, you can write to a texture only if it was created with the storage usage flag, and only into formats WebGPU allows for storage.
Everything a shader touches — textures, buffers — is declared at the top of the shader with @group(0) @binding(n) attributes, and then bound at runtime by the frontend. Lutra uses layout: 'auto', which means WebGPU computes the binding layout from what the shader actually references: declare a binding the shader never uses, and it simply doesn’t exist in the layout. The frontend mirrors this — it only attaches bindings the shader statically uses.This is why the assembler reports usesFrame per pass: the frame-counter binding (binding 3) exists only in passes whose body mentions u_frame — currently only grain. If the frontend attached it anyway, createBindGroup would throw a validation error.
2 · One frame, from slider to screen
Here is the whole journey of a pixel, in one picture. The session (textures + buffers) is created once per image; the per-tick work is just writing uniforms and dispatching passes.
srcTex (rgba8unorm, sRGB)dstTexIn order of appearance:
- Upload. The source image is copied to the GPU once into
srcTex, anrgba8unormtexture. “unorm” means unsigned normalized: 8 bits per channel, and the shader sees values as floats in [0,1]. Crucially, the bytes are the file’s raw values — which are sRGB-encoded (see §4 for why that matters). - Compute passes. Each adjustment layer runs as its own compute pass. Pass 0 (or the first layer pass) decodes sRGB to linear light. Intermediate passes shuttle linear values through
rgba16floattextures (half-precision floats). The final pass encodes back to sRGB and writes the display texturedstTex. The one exception is the LUT layer, whose pass decodes to sRGB, applies the film cube, and re-encodes (§7.11). - Blit. A tiny fragment shader draws a fullscreen triangle, sampling
dstTexwith a bilinear sampler and writing to the canvas swapchain. This is the one place Lutra gets free texture filtering — and it doubles as the upscale/downscale step, since the canvas is sized to the image. No flip is needed: compute and canvas both have their origin at the top-left. - Export (not shown). Only on export does a frame leave the GPU:
dstTexis copied to a CPU buffer (rows padded to 256-byte alignment — a WebGPU requirement), un-padded, and turned into anImageBitmapfor PNG encoding.
The per-tick cost of dragging a slider is therefore: write the changed uniform values into buffers, dispatch one compute pass per layer, submit, and wait for the GPU to catch up. The frontend keeps at most one render in flight (queue.onSubmittedWorkDone) so a fast slider drag can’t backlog the GPU queue — the next render simply waits. Between the last pass and the blit, one more dispatch runs: the histogram pass bins the frame’s luma into 256 counters, and a 1KB copy of the bins crosses back to the UI — the display path’s single, scoped readback (§8).
3 · The chain model and why one pass per layer
The product model is an edit chain: an ordered list of adjustment layers. Each layer consumes the output of the previous one — there is no parallel compositing, no blend modes. Order matters: saturation before or after a white-balance shift produces different colors, and that’s a feature (it’s how looks get built).
This sequential-consumption model is exactly why the engine emits one compute pass per layer. A pass is the natural unit of “consume the previous output”: it reads one texture and writes another, so the chain maps onto a sequence of passes one-to-one — layer 0 reads the source, layer 1 reads layer 0’s output, and so on. Two properties of the effects make this mapping not just natural but necessary:
- Effects that sample other coordinates must read the accumulated result, not the raw source. Chromatic aberration reads pixels a few positions away to split color channels. If it shared a pass with the layers before it, the only texture available to sample would be the original source image — so a CA layer placed after an exposure layer would split channels that never saw the exposure. Chain semantics would be broken.
- Sampled texels must be linear light. The source texture is sRGB-encoded, but the pipeline works in linear light (§4) — mixing the two transfer functions in one color value is a subtle gamma bug.
Giving each layer its own pass solves both by construction: pass i reads the output of pass i−1, so anything a body samples is the accumulated result of all earlier layers, already in linear light.The cost is one dispatch per layer; on modern GPUs each pass is memory-bound and cheap. The structure also leaves room for the deferred features: real local-contrast clarity needs neighbor access, and FBM grain needs workgroup shared memory — both slot directly into this pass-per-layer shape.
4 · Color management: why every effect lives in linear light
Here is the single most important decision in the pipeline. Photographs store sRGB-encoded values: the byte values are not proportional to light intensity — they’ve been through a gamma-like curve so that 8 bits cover the range our eyes care about. The standard curve (IEC 61966-2-1) is piecewise:
fn srgbToLinear(c: vec3<f32>) -> vec3<f32> {
let lo = c / 12.92; // dark toe: linear
let hi = pow((c + 0.055) / 1.055, vec3<f32>(2.4)); // main curve
return select(lo, hi, c > vec3<f32>(0.04045));
}
If you do arithmetic on sRGB values — multiply, add, mix — the numbers don’t correspond to light, so the results are physically wrong and usually look muddy. Doubling an sRGB byte value does not double the light it represents. So the pipeline does what every serious color tool does:
In linear light, exposure is a true multiplicative gain (double the light = one stop brighter), contrast pivots on the correct perceptual mid-grey, and mixing colors behaves. The final clamp to [0,1] happens only at encode time, so intermediate passes can carry values above 1 — blown highlights stay “hot” instead of silently clipping mid-chain.
Two numbers are worth memorizing because they appear everywhere:
- 0.2140 — the linear value of sRGB mid-grey (the sRGB value 0.5, un-gamma’d). It’s the pivot for contrast. Photographers often quote 18% grey (0.18 linear) as “middle grey”; that’s a metering standard, not a display midpoint. Lutra pivots on the value that looks halfway between black and white on screen — sRGB 0.5, i.e. 0.2140 linear — which is what a contrast pivot should do.
- (0.2126, 0.7152, 0.0722) — the Rec. 709 luma coefficients: how much each channel contributes to perceived brightness. Green dominates, as your eyes would expect. Every “luma” in this codebase is this dot product.
The one exception: LUT. The film-emulation LUT layer deliberately steps out of linear light. Its cube — a 13³ color lookup table from the vendored G’MIC film presets (§7.11) — is authored against sRGB-encoded values, because G’MIC’s working space is sRGB. Apply it to linear values and the film curves land in the wrong place: lifted blacks wash out further, the S-curves shift. So the LUT pass decodes its linear input to sRGB, applies the cube, mixes by strength, and re-encodes to linear — skipping the round-trip at the chain ends, where the source and display textures are already sRGB (see docs/adr/0003). The strength mix happens in sRGB space, which is what “50% film look” perceptually means.
5 · Anatomy of a pass
Here is the full WGSL skeleton the assembler generates for one layer (this one happens to be a non-final, non-first pass, so no sRGB conversion at its boundaries):
struct LayerParams {
l0_stops: f32, // one member per field of this layer
}
@group(0) @binding(0) var srcTex: texture_2d<f32>; // previous pass's output
@group(0) @binding(1) var dstTex: texture_storage_2d<rgba16float, write>; // this pass's output
@group(0) @binding(2) var<uniform> u_resolution: vec2<f32>; // image size, for coord math
@group(0) @binding(4) var<uniform> u_params: LayerParams; // the slider values
@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let coord = id.xy;
if (coord.x >= u32(u_resolution.x) || coord.y >= u32(u_resolution.y)) { return; }
var src = textureLoad(srcTex, coord, 0);
var color = src.rgb; // already linear here
let alpha = src.a; // alpha is passed through untouched, always
let l0_stops = u_params.l0_stops; // “alias” — see below
// ... the layer's body is inlined right here ...
let outColor = color; // non-final passes store linear directly
textureStore(dstTex, coord, vec4<f32>(outColor, alpha));
}
Four details worth understanding:
- The alias trick. In WGSL, struct members are only reachable through the struct variable (
u_params.l0_stops), but each effect body is written to reference its parameters bare (l0_stops) — because bodies are written as self-contained snippets that read like math. The assembler injects onelet l0_stops = u_params.l0_stops;per field before the body, so the body’s bare names resolve. This is what keeps a body renderer as simple ascolor *= exp2(l0_stops). - The bounds check. Workgroups are dispatched in a grid that covers the image (16×16 invocations each), but the grid is rounded up, so edge invocations fall outside the image. The
if … returnguard keeps those from writing out of bounds. - 16×16. 256 invocations per workgroup — a sweet spot for occupancy on most desktop GPUs, and the frontend dispatches with the same constant.
- Boundary conversions. A pass either decodes its input (first layer), encodes its output (last layer), both (single layer), or neither (middle). The assembler computes these three booleans from the layer’s position and writes the corresponding expressions into the template. The colorspace functions are embedded in a shader only if one of its boundaries needs them.
The linearize pass
There is one more pass shape. If the first layer samples its input texture (currently: chromatic aberration), the assembler inserts a dedicated pass before it that does nothing but decode sRGB to linear and write an rgba16float intermediate. Why not let the first layer pass do the decoding as usual? Because when a body samples other coordinates, it samples them raw — if the texture were still sRGB, the sampled neighbors would be in the wrong transfer function. The standalone linearize pass guarantees every texture a body ever samples is already linear. The moment CA is not first, this pass disappears: the first layer’s pass decodes its own center texel and writes linear, which is all the later passes need.
Ping-pong intermediates
Between the layers live exactly two rgba16float textures, used alternately:
srcTex → writes inter[0]inter[0] → writes inter[1]inter[1] → writes inter[0]dstTexTwo textures suffice because passes run strictly in sequence — pass i only needs the output of i−1, which is always on the “other” texture. Why rgba16float (half precision, 2 bytes per channel) instead of 8-bit like the display texture? Every 8-bit intermediate would re-quantize the image to 256 levels per channel per pass — after ten layers you’d see banding in smooth gradients, especially in the dark toe where 8 bits are coarsest. Half floats carry the linear values losslessly enough that the only quantization the viewer ever sees is the final one.
The LUT pass inverts the boundaries. A LUT layer’s pass is the same skeleton with three differences. It declares an extra binding — lutTex, a 13³ texture_3d<f32> (rgba32float) at binding 6 — and it omits the sampler: 32-bit float textures are not filterable in WebGPU, so the body does its own trilinear interpolation with textureLoad over the baked LUT_SIZE (the body maps each color to texel space c·(SIZE−1), lerps the eight surrounding texels, and never touches edge-adjacent memory, so no bleed outside the cube). And its color-space boundaries are inverted: the input is decoded to sRGB (unless the pass reads the sRGB source directly) and the output is re-encoded from sRGB (unless the pass is last and writes the display texture). The body itself only reads and mixes — and it is the one body that is guaranteed sRGB-encoded input, by contract.
6 · How a slider value reaches a shader
When you drag a slider, the flow is:
- The UI updates the layer’s parameter in the model (e.g.
exposure.stops = 1.5). - The engine’s
createRenderRequestwalks the visible layers, asks the assembler for the pass list, and packs uniforms: for each pass, a flatFloat32Arraylaid out exactly like that pass’sLayerParamsstruct, with one slot per field. The assembler reports the slot order (layer index, field name, offset) and the packer fills it from the model — so the engine and the shader can never disagree about layout. - The frontend writes each pass’s array into that pass’s GPU uniform buffer (a plain
memcpy-style write) and dispatches the passes. - Inside the shader,
u_params.l0_stopsis the float you just wrote.
The frame counter works the same way: u_frame is a tiny uniform written once per render (not per pass), and only passes that mention it get the binding. Grain uses it to animate the noise across frames.
7 · The eleven effects
Every effect is a pure function of the pixel’s color — linear light for ten of them, sRGB-encoded for the LUT layer (§7.11) — and, for two of them, of the pass’s input texture at other coordinates. The slider ranges come from the registry (registry.ts); the math below is the whole story. l{i}_field means “this layer’s slider value.”
7.1 Exposure — stops ∈ [−3, 3] correct
let gain = exp2(l0_stops);
color *= gain;
A stop is a doubling of light. exp2(stops) converts stops to a multiplicative factor: +1 stop → 2×, −1 → ½×, +3 → 8×. This is the textbook exposure formula, and it only works because we’re in linear light — the same formula on sRGB values would be off by a gamma. Clipping of blown highlights is handled by the clamp at final encode.
7.2 Contrast — amount ∈ [−1, 1] correct
let gain = exp2(-l0_amount * 0.5);
let t = max(color, vec3<f32>(0.0)) / 0.2140; // guard: no NaN from negatives
color = pow(t, vec3<f32>(gain)) * 0.2140;
This is a power-curve S-curve pivoted on mid-grey. The pivot: 0.2140, the linear value of sRGB 0.5 (§4). The curve is c′ = 0.214 · (c / 0.214)γ with γ = 2−amount/2 — one “stop of curve” at each slider extreme. Positive amount gives γ < 1: every value below the pivot is lifted, every value above it is compressed. That’s the classic film look — shadows open up, highlights roll off gently — and it’s a true S shape when you plot output vs. input. Negative amount (γ > 1) does the reverse: tones get pushed away from the pivot, flattening the midtones and letting highlights run hot. The max(color, 0) guard exists because a previous layer (e.g. shadows at −1) can drive channels slightly negative, and pow of a negative base is NaN.
7.3 Shadows — amount ∈ [−1, 1] approximation
let luma = clamp(dot(color, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
let mask = 1.0 - smoothstep(0.0, 0.5, luma); // 1 at black, 0 by mid-grey
color += l0_amount * 0.15 * mask;
This is an additive lift with a tonal mask. The mask is 1 for pure black, falls off smoothly, and reaches 0 at luma 0.5 — so the effect touches only tones darker than mid-grey, strongest at the bottom. Positive amount brightens shadows (a “lift”), negative darkens them. The 0.15 multiplier is a tuning constant chosen so the −1…1 slider stays tasteful. This is a simplified shadows tool — a production tool would shape the tone curve more carefully (lift/gamma/gain separation) — but for v1 it’s honest, cheap, and does what it says.
7.4 Highlights — amount ∈ [−1, 1] approximation
let luma = clamp(dot(color, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
let mask = pow(luma, 2.2); // 0 at black, 1 at white
color += l0_amount * 0.2 * mask;
The mirror image of shadows: the mask rises from 0 at mid-grey-ish to 1 at white (the pow(luma, 2.2) shapes the ramp), so only bright tones are affected, most at the top. Positive amount lifts highlights (brightens them further); negative darkens them. Note that this is a lift, not a compression: positive amount does not “recover” blown detail — it pushes bright tones brighter. Real highlight recovery (rolling the tone curve over so highlights compress toward white) is a different operation and a future refinement.
7.5 White balance — temp, tint ∈ [−1, 1] approximation
// temp: -1 cool (blue) / +1 warm (amber)
color.r *= 1.0 - temp * 0.3;
color.b *= 1.0 + temp * 0.3;
// tint: -1 magenta / +1 green
color.g *= 1.0 + tint * 0.2;
color.r *= 1.0 - tint * 0.1;
color.b *= 1.0 - tint * 0.1;
Temperature is modeled as a red↔blue seesaw: warming scales red up and blue down by up to ±30% at the slider extremes; cooling does the opposite. Tint is green vs. magenta: scaling green against a smaller opposing pull on red and blue. This is the classic cheap approximation of white balance — multiplicative channel scaling in linear light.Physically rigorous tools convert a color temperature in Kelvin to a reference white and derive per-channel gains in a cone-response (LMS) space — Unity’s White Balance node is the canonical reference. Lutra’s direct channel scaling captures the look with a fraction of the math. Documented as an approximation; the Kelvin-based upgrade is on the roadmap. It does not shift green with temperature or red/blue with tint beyond the intended cross-coupling — good. The slider is normalized −1…1 (0 = neutral).
7.6 Saturation — amount ∈ [−1, 1] correct
let luma = dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
color = mix(vec3<f32>(luma), color, 1.0 + l0_amount);
The textbook saturation formula: blend each pixel between its gray equivalent (luma everywhere) and itself. mix(a, b, t) is a·(1−t) + b·t, so with t = 1 + amount: amount 0 is identity, −1 makes t = 0 and the image goes fully grayscale, +1 makes t = 2 — the color is pushed past its gray value, doubling chroma. Linear-light luma mixing keeps hues stable while desaturating, which naive per-channel averaging does not.
7.7 Grain — texture, size, blur ∈ [0, 1] correct
// 3-octave FBM over smooth value noise (integer lattice hash, quintic easing)
let f = 0.6667 * pow(0.15, l0_size); // cell: 1.5 px (0) → 10 px (1), log scale
let p = 0.6 - 0.45 * l0_blur; // octave persistence 0.6 → 0.15
let n = grainNoise(coord * f, u_frame) * inv
+ grainNoise(coord * f * 2 + offset1, u_frame * 3u + 17u) * (p * inv)
+ grainNoise(coord * f * 4 + offset2, u_frame * 5u + 29u) * (p * p * inv); // weights sum to 1
let noise = (n - 0.5) * 2.0; // ±1
let w = max(1.0 - abs(L - 0.5) * 1.4, 0.35); // midtone weighting
color += noise * l0_texture * 0.15 * w;
Grain is three-octave FBM over smooth value noise: the lattice is hashed with an integer hash (a few multiply/xor/shift operations on the pixel coordinate plus the frame counter — no transcendentals), and the hash values are interpolated with quintic easing. Because neighboring pixels share lattice points, the noise is spatially coherent — it clumps and swirls like real film grain, instead of the per-pixel static the mobile version shipped. The frame seed animates the whole field between frames, and octaves 2 and 3 use derived seeds so they decorrelate over time. Three knobs shape it:
texture— strength. Amplitude ±0.15 linear at full slider (≈ ±14 sRGB levels at midtone), masked to midtones: the trianglewpeaks at luma 0.5 and falls to a floor of 0.35 at the extremes, so blacks and whites stay relatively clean.Grain is added in linear light, but the display curve stretches dark values — so the same linear amplitude looks larger in the shadows on screen. AMD’s “Fine Art of Film Grain” (GPUOpen) discusses exactly this effect and how real pipelines scale grain with the signal. The midtone floor keeps blacks from lifting outright, but per-signal scaling is a future refinement.size— noise cell. Log scale from 1.5 px fine speckle to 10 px chunky grain.blur— octave persistence. 0.6 → 0.15; the octave weights are normalized by construction (1, p, p² ÷ their sum), so blur changes the character of the grain, not its loudness.
7.8 Vignette — amount, size correct
var uv = vec2<f32>(f32(coord.x), f32(coord.y)) / u_resolution * 2.0 - 1.0;
uv.x *= u_resolution.x / u_resolution.y; // aspect correction
let dist = length(uv);
let v = smoothstep(l0_size * 0.6, l0_size, dist);
let k = 1.0 - v * l0_amount;
color *= k;
A radial darkening toward the frame edges. Pixel coordinates are mapped to [−1, 1] around the center, then dist is the distance from center. The uv.x *= aspect line is the subtle-but-critical one: without it, a “circle” in uv space is an ellipse on a non-square photo (stretched along the long axis). Scaling x by width/height makes the falloff genuinely circular on any frame. smoothstep(size·0.6, size, dist) is a smooth ramp that stays 0 inside 60% of size and reaches 1 at size; k = 1 − v·amount then darkens (>0) or brightens (<0) the edges by multiplying the color. Note it’s a multiplicative vignette — it scales light, which is what a lens does.
7.9 Chromatic aberration — amount ∈ [−1, 1] correct
let d = vec2<f32>(coord) - u_resolution * 0.5; // pixels from center
let dist = length(d);
let dir = d / max(dist, 1.0); // radial direction
let radius = dist / min(u_resolution.x, u_resolution.y);
let shift = l0_amount * radius * radius * 4.0; // grows toward corners
let rOffset = vec2<i32>(round(dir * shift));
let rCoord = clamp(vec2<i32>(coord) + rOffset, vec2<i32>(0), vec2<i32>(u_resolution) - 1);
let bCoord = clamp(vec2<i32>(coord) - rOffset, vec2<i32>(0), vec2<i32>(u_resolution) - 1);
let rVal = textureLoad(srcTex, rCoord, 0).r;
let bVal = textureLoad(srcTex, bCoord, 0).b;
let strength = abs(l0_amount);
color.r = mix(color.r, rVal, strength);
color.b = mix(color.b, bVal, strength);
Lenses fail to focus all wavelengths on the same plane, so fringes of red and blue appear toward the frame edges — radial chromatic aberration. This body reproduces it: red is pulled outward from the center, blue inward, along the radial direction, with the offset growing quadratically with distance from center (zero at the center — which is why the effect naturally vanishes mid-frame and peaks at the corners, like the real artifact). The radius is normalized by the shorter image dimension so the effect’s strength is resolution-independent; the 4.0 constant caps it at a few pixels at the corners. The mix with |amount| blends the shifted channel in — it smooths the effect as the slider passes through small values, where a pure integer pixel offset would otherwise snap from nothing to one pixel.
Two properties come from the pass architecture rather than from this snippet: srcTex here is the previous pass’s output — so the split is applied to the fully graded image, not the raw source — and it is guaranteed linear light, so the sampled channels share the pipeline’s transfer function. The assembler inserts the standalone linearize pass when CA is the first layer (§5).
7.10 Clarity — amount ∈ [−1, 1] correct
let uv = (vec2<f32>(coord) + vec2<f32>(0.5)) / u_resolution;
let s = vec2<f32>(4.0) / u_resolution; // 4 px radius
let avg = (color // 9-tap box blur, bilinear-sampled
+ textureSampleLevel(srcTex, samp, uv + vec2<f32>(-s.x, 0.0), 0.0).rgb
+ textureSampleLevel(srcTex, samp, uv + vec2<f32>(s.x, 0.0), 0.0).rgb
+ … 8 neighbors …) * (1.0 / 9.0);
let luma = dot(avg, vec3<f32>(0.2126, 0.7152, 0.0722));
let mask = clamp(1.0 - 4.0 * (luma - 0.5) * (luma - 0.5), 0.0, 1.0); // midtone tent
color += l0_amount * mask * (color - avg) * 0.5; // unsharp-mask push away from the mean
Clarity is local contrast: a 9-tap box blur of the pass input (radius 4 px, sampled bilinearly so the sparse kernel stays smooth), then an unsharp-mask push away from the local mean — positive amount enhances structure, negative flattens it. The midtone mask keeps the effect off deep blacks and blown highlights where halos read as artifacts. This is the neighbor-sampling body the pass architecture exists for: srcTex is the previous pass’s output in linear light, so the neighborhood reflects all earlier adjustments. The radius is fixed at 4 px — a true wide-radius clarity would need a separable blur or mip pyramid.
7.11 LUT — amount ∈ [0, 1] correct
// color is sRGB-encoded here, by pass contract (§5)
let p = clamp(color, vec3<f32>(0.0), vec3<f32>(1.0)) * (LUT_SIZE - 1.0);
let x0 = vec3<i32>(floor(p));
let f = p - vec3<f32>(x0);
let c000 = textureLoad(lutTex, vec3<i32>(x0.x, x0.y, x0.z), 0).rgb;
// ...seven more corners, lerped by f (manual trilinear)...
let lutColor = mix(/* ... */);
color = mix(color, lutColor, l0_amount);
The LUT layer applies a film-emulation color cube — a 13³ lookup table from the vendored G’MIC film presets (296 cubes, mirrored from YahiaAngelo/Film-Luts, shipped as static assets and loaded on demand). The cube lives on the GPU as a 13³ rgba32float 3D texture. The format is deliberate: rgba16float would need an f32→f16 conversion on upload, and Chrome’s writeTexture conversion path is broken (raw f32 bytes land verbatim, corrupting rows) — rgba32float matches the Float32Array upload byte-for-byte, and 32-bit float textures are not filterable anyway, so the body reads the cube with textureLoad and performs manual trilinear interpolation over texel coordinates (the same texel-space mapping hardware filtering would use). The body operates on sRGB-encoded values (§4) — the pass boundary handles the round-trip — and amount mixes the graded color with the original in sRGB space, so 0 is identity and 1 is the full look. The LUT id is a layer field, not a uniform: the render request carries an id→cube map, and an unresolvable id fails the render rather than silently skipping the grade.
8 · The histogram overlay
“Above all else, show the data.” The one piece of UI that reads the image itself is the small histogram pinned to the stage’s bottom-right corner: a filled-area luminance curve of the frame currently on screen. It is a pure display widget — always on, no toggle, pointer-events-none so wheel and drag pass straight through — and it is screen-space: a sibling of the panned/zoomed image, so panning and zooming never move it. It is drawn as SVG in the foldkit view, a pure function of the model, and because it is UI rather than part of the blit, it never appears in exports.
What it measures is exactly what the eye is judging: dstTex, the sRGB-encoded display texture, after the whole chain has run — the graded output, not the source. Each texel contributes its luma, the Rec. 709 dot product (0.2126, 0.7152, 0.0722) you met in §4 — the same coefficients every body in §7 uses for its masks — into one of 256 bins. Luminance only: no per-channel traces.
Binning happens on the GPU, in the same command encoder as the render, with a scatter-write pass running between the last compute pass and the blit:
@group(0) @binding(0) var srcTex: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> bins: array<atomic<u32>, 256>;
@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let size = textureDimensions(srcTex);
if (gid.x >= size.x || gid.y >= size.y) { return; }
let color = textureLoad(srcTex, vec2<i32>(gid.xy), 0);
let luma = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
let bin = min(u32(luma * 256.0), 255u); // 1.0 clamps into the top bin
atomicAdd(&bins[bin], 1u);
}
Every texel is counted exactly once — the pass is full-resolution, one invocation per pixel, exactly like the layer passes.A histogram exists to show what a reduction hides. Downsampling before binning would average clipped highlights and specular peaks into the body of the curve — smoothing away precisely the artifacts a histogram is for. The shader is frontend-owned, like the blit (§2): the engine generates chain shaders; presentation- and analysis-side WGSL lives in GpuBackend.
And this is the display path’s one readback — the single exception to “the frame never leaves the GPU.” It is a scoped one: 1KB of aggregate statistics, never the frame, and it is the reason the readback machinery deserves its own diagram:
Two buffers, bridged by a copy. The pass writes a storage-only accumulator, because WebGPU forbids combining MAP_READ with STORAGE on one buffer — usage flags are mutually exclusive in exactly the combinations you’d want. The bins cross to the CPU through a MAP_READ | COPY_DST staging buffer, with the copy enqueued in the same encoder, so the GPU-to-GPU hop is free. The accumulator is zeroed once per render (a 1KB writeBuffer; atomics add, so bins would otherwise accumulate across frames) — which is why it also carries COPY_DST.
Three staging buffers, mapped a frame ahead. The readback slots rotate in a ring of three, and each frame’s map is issued inside execute, immediately after queue.onSubmittedWorkDone resolves — before any later render can submit.mapAsync is enqueued on the queue timeline behind every pending submission (gpuweb#2646) — even ones that never touch the buffer. Issued from the readback command instead, the map would queue behind the next render — and the next after that during a drag — landing one or more frames late, where the stamp guard would drop it. Issued at the end of the frame’s own submit, the queue behind it is empty, so the map resolves before the frame’s RenderedFrame is even handled. A slot is then mapped from that moment until readHistogram consumes it (map → read → unmap) in the same message cycle; three slots leave two full cycles of slack before a slot is reused, so a slow consumer can never collide with a copy. The abnormal flows are covered too: a slot whose map was never consumed (a dropped message) is reclaimed before reuse, and a readback for a session torn down mid-flight resolves with empty bins instead of failing.
The app side. Every RenderedFrame dispatches a ReadHistogram command — stale frames included, so their slots are always consumed — and the resulting HistogramComputed lands in the model only when its stamp is still fresh (the same guard RenderedFrame itself uses; bins that arrive after a newer mutation are dropped). HistogramFailed is observability-only: the frame is already on the canvas, and a 1KB map is not worth retrying. Clearing the image resets the bins. The view then draws them: an SVG area polygon at 25% ink opacity with a 1px stroke on top, normalized linearly so the tallest bin fills the 220×110 card — and a flat baseline when every bin is zero, as an all-black frame would produce.
The total cost of all this is one extra full-resolution dispatch per render and 1KB crossing back per frame, from three 1KB staging buffers and one session-scoped accumulator — no per-render allocations, no waiting, and the render loop never blocks on the map.
9 · Reference tables
| Constant | Value | Used by |
|---|---|---|
| Luma coefficients (Rec. 709) | 0.2126, 0.7152, 0.0722 | shadows, highlights, saturation, grain, clarity |
| Contrast pivot | 0.2140 (linear value of sRGB 0.5) | contrast |
| sRGB breakpoints | 0.04045 (encode), 0.0031308 (decode) | colorspace.ts |
| sRGB linear segment | ÷ / × 12.92 | colorspace.ts |
| Grain amplitude | ±0.15 linear at texture 1, midtone | grain |
| CA corner shift | ~4 px at amount 1 (16:9) | chromatic aberration |
| Vignette falloff band | size·0.6 → size (default 0.36 → 0.6) | vignette |
| Texture / buffer | Format | Role |
|---|---|---|
srcTex | rgba8unorm (sRGB-encoded bytes) | the uploaded image; read by pass 0 / linearize pass |
inter[0..1] | rgba16float (linear) | ping-pong intermediates between layer passes |
dstTex | rgba8unorm (sRGB after final encode) | display + export source |
u_resolution | vec2<f32> uniform | image size for coordinate math |
u_frame | u32 uniform | animation seed (grain); binding exists only when used |
u_params | one f32 per layer field | the slider values; binding exists only when the layer has fields |
lutTex | rgba32float 3D, 13³ (one per applied LUT, cached by id) | the film color cube; binding 6, only on LUT passes |
bins (accumulator) | 256 × u32 storage (STORAGE | COPY_SRC | COPY_DST) | histogram pass target; zeroed per render, copied out per render (§8) |
| readback ring | 3 × 1KB MAP_READ buffers | staging slots for the histogram bins; mapped a frame ahead (§8) |
10 · Known limitations and the roadmap
- White balance is channel-scale approximation — the CCT/LMS-based model is the upgrade path.
- Shadows/highlights are additive lifts — fine for v1; lift/gamma/gain separation would match professional tools.
- Alpha is passed through untouched — correct for photographs (opaque), a known limitation once compositing appears.
- LUTs load on demand — each film cube fetches (~131KB) the first time it is applied, then stays cached in memory and on the GPU; the 296-cube library is vendored, so nothing depends on a third-party server at runtime.
- The histogram is luminance-only — one Rec. 709 luma curve. Per-channel (RGB parade) or waveform traces are the natural extension: the readback ring already carries the counts, and the bins array would simply grow to 3 × 256.
- Every generated pass can be validated against
naga, the reference WGSL validator — the shaders are pure strings, so a syntax bug in a generated pass is caught before it ever reaches a browser.