Author Eric K'DUAL
Published March 13, 2026
Reading time 12 minutes
Series Engineering & Craft

This is an engineering article about the linear light processing pipeline that separates professional-grade photo editors from consumer tools. It's technical, it's detailed, and it explains exactly why the choice of pixel format and tone mapping curve determines image quality. If you've ever wondered why professional tools like Lightroom process in 32-bit float, or what "scene-referred" means, or why your shadow detail falls apart in cheap software, this is the article that explains it. I'll show the math, the code, the standards involved, and where the bodies are buried in naive approaches.

The Gamma Problem

Every digital image you've ever viewed on a consumer display is stored in a nonlinear color space called sRGB, defined by IEC 61966-2-1. The encoding applies a gamma curve (approximately a power of 1/2.2) that compresses highlight values and expands shadow values. This encoding exists for a good historical reason: CRT monitors had a native gamma of roughly 2.2, and encoding images with an inverse curve meant that the CRT's nonlinearity and the encoding's nonlinearity canceled out, producing a perceptually uniform display. The encoding also happens to allocate more of the 256 available 8-bit code values to the shadows, where human vision is most sensitive to banding.

The problem is that sRGB values are not proportional to light. They're proportional to a perceptual encoding of light. When you do math on sRGB values directly, the results don't correspond to physical reality. Consider the simplest possible operation: averaging two pixel values.

Take two pixels: one at sRGB 50 (a dark shadow) and one at sRGB 200 (a bright highlight). The naive average is 125. But what are these values in linear light? sRGB 50 linearizes to approximately 0.031, and sRGB 200 linearizes to approximately 0.527. The true physical average (half the photons from each) is 0.279, which encodes back to sRGB ~143. The naive sRGB average of 125 corresponds to linear 0.184 — a 34% error relative to the correct linear result. The averaged pixel is too dark, because gamma-space averaging underweights the brighter value. This error is not a rounding artifact. It's a systematic bias baked into every operation that treats gamma-encoded values as if they were linear.

The practical consequence for photography: every blend, every filter, every brightness adjustment computed in gamma space produces subtly wrong results. Grain synthesis in gamma space concentrates noise in the highlights and starves the shadows. Contrast adjustments in gamma space have an asymmetric effect that doesn't match how paper or film responds. Dodge and burn operations shift midtones in ways that don't correspond to the exposure changes they're supposed to simulate.

The shadow precision problem is even worse. Here's how the 256 levels of 8-bit sRGB distribute across the linear light range:

sRGB Code Range Linear Light Range % of Linear Range Code Values Available Effective Precision
0 – 63 0.000 – 0.046 4.6% 64 Adequate — but only 4.6% of the light range
0 – 12 0.000 – 0.003 0.3% 13 Deep shadows: 13 levels for the bottom third of a stop
64 – 127 0.046 – 0.216 17.0% 64 Shadows to lower midtones
128 – 191 0.216 – 0.527 31.1% 64 Upper midtones
192 – 255 0.527 – 1.000 47.3% 64 Highlights: 64 codes covering nearly half the light

Read that table carefully. The bottom 25% of sRGB code values (0–63) map to only 4.6% of the linear light range. The top 25% (192–255) map to 47.3% of the linear range. This means that when you process in gamma-encoded 8-bit, you have roughly ten times more precision in the highlights than in the deep shadows. For B&W work, where shadow separation is everything — where the difference between Zone II and Zone III defines the emotional weight of an image — this is catastrophic.

The 16-level problem: In the deepest shadows (sRGB codes 0–12), you have exactly 13 discrete levels to represent all luminances below 0.3% of maximum light. After any processing step that quantizes back to 8-bit — which happens at every stage in an 8-bit pipeline — those 13 levels collapse further. Two or three processing steps and you're down to visible banding in the shadows. This is why cheap B&W conversions show posterization in dark tones. It's not a bug in the algorithm. It's a fundamental precision failure in the data representation.

What Linear Light Means

In a linear light space, values are directly proportional to the number of photons striking the sensor. A value of 0.5 represents exactly half the light energy of 1.0. A value of 0.25 represents exactly one quarter. Doubling a linear value doubles the brightness. Halving it halves the brightness. This is not a mathematical abstraction — it's the physical reality of how light works, and it's how every optical system in nature combines light.

When your processing pipeline operates in linear light, every mathematical operation corresponds to its physical meaning. Multiplication by 0.5 is equivalent to closing down one stop. Addition of two values is equivalent to combining two light sources. Averaging is equivalent to placing a 50% neutral density filter between two exposures. The math does what you think it does, because the values represent what you think they represent.

Here's a comparison of common operations in gamma space versus linear space:

Operation In Gamma Space In Linear Space Error Character
Brightness +1 stop Multiply by ~1.35 (varies by value) Multiply by 2.0 Gamma version shifts midtones more than shadows
50/50 blend of two images Average of gamma values (too dark) Average of linear values (physically correct) Up to 34% luminance error in gamma
Contrast increase S-curve in perceptual space (uneven) S-curve in linear space (symmetric around midpoint) Gamma version crushes shadows disproportionately
Grain synthesis Uniform noise in gamma (too strong in highlights) Uniform noise in linear (perceptually uniform) Grain character completely wrong in gamma
Dodge (shadow lift) Additive in gamma (nonlinear response) Additive in linear (matches exposure increase) Gamma version over-lifts near white, under-lifts near black
Vignette Multiply in gamma (halos at transition) Multiply in linear (clean falloff) Gamma multiply creates visible halo artifacts

The film industry understood this decades ago. Every VFX pipeline since the early 2000s has operated in linear light, because compositing in gamma space produces visible artifacts at every edge. The photography software world has been slower to adopt linear pipelines, partly because the computational cost is higher (you need more bits per channel to avoid banding in the shadows) and partly because the existing tools were "good enough" for most users. But for B&W work, where shadow tonality is the entire craft, "good enough" isn't.

From 8-bit to Half-Float: The Rgba16Float Upgrade

Many GPU-based image processors start with Rgba8UnormSrgb as their texture format. This is the standard 8-bit-per-channel format with automatic sRGB↔linear conversion on read/write. It's what most real-time applications use. It's fast, it's memory-efficient, and for game rendering it's perfectly adequate.

For photographic processing, it's a disaster.

With Rgba8UnormSrgb, even though the GPU hardware automatically linearizes values when sampling the texture, the source data has already been quantized to 256 levels in gamma space. The linearized values are reconstructed from those 256 levels, meaning the shadow precision is permanently limited by the gamma-encoded quantization. You can't recover precision that was discarded at the encoding stage.

The correct approach linearizes the source image on the CPU at load time — using a precomputed 256-entry lookup table for 8-bit sources, or direct power-function conversion for 16-bit sources — and uploads the result as Rgba16Float, the IEEE 754 half-precision floating-point format. This changes everything.

IEEE 754 half-float (binary16) has a 1-bit sign, 5-bit exponent, and 10-bit mantissa (plus one implicit leading bit, giving 11 bits of effective precision). The floating-point representation means the precision is relative, not absolute: you get the same number of discrete levels per stop across the entire dynamic range. In the shadows, where gamma-encoded 8-bit gives you 13 levels for the deepest stop, half-float gives you 2048. That's a 157x improvement in shadow precision.

Format Bits Per Channel Levels in Deepest Stop Total Dynamic Range Memory (4K RGBA) GPU Texture Support
Rgba8UnormSrgb 8 ~13 8 stops (linear) 31.6 MB Universal
Rgba16Float 16 (float) ~2048 30+ stops 63.3 MB Universal (since D3D10 / GL 3.0)
Rgba16Uint 16 (integer) ~8 16 stops (linear) 63.3 MB Universal, no filtering
Rgba32Float 32 (float) ~8.4M 127+ stops 126.6 MB Limited filtering support

This is the same approach used by every professional photo editor. Adobe Lightroom processes internally in 32-bit float on the CPU, then renders previews in 16-bit float on the GPU. darktable's "filmic" module operates entirely in 32-bit float linear light. Capture One uses 16-bit integer internally (which has uniform precision rather than the logarithmic precision of float, making it slightly less efficient for photographic content, but still vastly better than 8-bit). A well-designed GPU-based processor uses 16-bit float for texture storage and 32-bit float for all arithmetic within the fragment shader — the same precision split that Lightroom uses, but with the processing happening on the GPU instead of the CPU.

The linearization step happens once at image load. Here's the code that converts sRGB 8-bit to linear float:

// sRGB to linear conversion per IEC 61966-2-1 // The piecewise function handles the linear segment below 0.04045 // and the power curve above it. fn srgb_to_linear(s: f32) -> f32 { if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } } // For 8-bit input, we precompute a 256-entry LUT: let lut: [f32; 256] = std::array::from_fn(|i| { srgb_to_linear(i as f32 / 255.0) }); // Conversion is then a single table lookup per channel per pixel. // For a 45 MP image: ~135 million lookups, completes in <50ms.

The key insight is that this linearization is a one-way door. Once the data is in linear float, all subsequent operations are physically correct, and the precision is determined by the float format (11-bit mantissa for half-float, 23-bit mantissa for the 32-bit float arithmetic in the shader), not by the original 8-bit quantization. We're not interpolating between 256 levels. We're computing with 2048+ levels per stop of dynamic range.

ACES Filmic Tone Mapping

Processing in linear light creates a new problem: how do you map the result back to a displayable range? The naive approach is a hard clamp: clamp(value, 0.0, 1.0). Any value above 1.0 is crushed to white. Any value below 0.0 is crushed to black. This is what most real-time applications do, and it produces harsh, clinical-looking highlights with an abrupt transition from textured to pure white.

The film industry solved this problem with the Academy Color Encoding System (ACES), developed by the Science and Technology Council of the Academy of Motion Picture Arts and Sciences. The ACES Reference Rendering Transform (RRT) defines a perceptually motivated mapping from scene-referred linear light to display-referred output. It's the de facto standard for film and television production.

A practical GPU implementation can use the Narkowicz (2015) rational polynomial approximation of the ACES RRT. This is a single rational function that closely matches the full ACES RRT output transform, running in a few GPU instructions rather than the multi-stage lookup table of the full ACES pipeline. The function:

// ACES Filmic Tone Mapping // Attempt to approximate the ACES RRT (Reference Rendering Transform) // Source: Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve", 2015 // https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/ fn aces_filmic(x: f32) -> f32 { let a = 2.51; let b = 0.03; let c = 2.43; let d = 0.59; let e = 0.14; (x * (a * x + b)) / (x * (c * x + d) + e) } // In the WGSL shader: fn aces_tonemap(x: f32) -> f32 { let a = 2.51; let b = 0.03; let c = 2.43; let d = 0.59; let e = 0.14; let mapped = (x * (a * x + b)) / (x * (c * x + d) + e); // Normalize: ACES(1.0) = 0.8148, so divide by 0.8148 // to preserve white point. Equivalently, multiply input by 1.2273. return clamp(mapped / 0.8148, 0.0, 1.0); }

The normalization factor deserves explanation. The raw Narkowicz ACES function maps 1.0 to 0.8148, not to 1.0. Without correction, a pixel that should be pure white on screen would display at about 81% brightness — a noticeable dimming of the entire image. We divide the output by 0.8148 (equivalently, we could multiply the input by 1/0.8148 = 1.2273) to ensure that an input of 1.0 maps to an output of 1.0. The white point is preserved. Values below 1.0 are slightly lifted (the filmic look), and values above 1.0 are compressed into the range just below 1.0 (the highlight rolloff).

Here's the critical comparison — what happens to specific input values under hard clamp versus ACES Filmic:

Input (Linear) Physical Meaning Hard Clamp Output ACES Filmic Output Difference
0.00 True black 0.000 0.000 None
0.05 Deep shadow 0.050 0.054 +8% shadow lift — opens dark tones
0.18 18% gray (Zone V) 0.180 0.222 +23% midtone boost — the "filmic" warmth
0.50 Mid-highlight 0.500 0.579 +16% — visible luminosity increase
0.80 Bright surface 0.800 0.860 +7.5% — gentle highlight compression begins
1.00 Maximum white 1.000 1.000 None (normalized)
1.20 Slight overexposure 1.000 (clipped) 1.000 (compressed) Recoverable vs. destroyed
1.50 Bright cloud 1.000 (clipped) 1.000 (compressed) Texture preserved in highlight rolloff
2.00 Specular highlight 1.000 (clipped) 1.000 (compressed) Distinctions between 1.5 and 2.0 survive

Three effects are visible in that table. Shadow lift: ACES gently raises the deepest shadows, opening up detail in the darkest tones without making them look washed out. This is the same perceptual effect that film has naturally — unexposed silver halide crystals never produce absolute black, so film shadows have an inherent toe that digital capture lacks. Midtone boost: The 15–23% lift through the midtones gives images a luminosity and presence that photographers describe as "filmic." It's not brighter in a flat, washed-out way; it's more alive. Highlight rolloff: Instead of a hard wall at 1.0 where everything above clips to identical white, ACES compresses super-white values into a smooth shoulder. A cloud at 1.5 and a specular reflection at 2.0 both display as near-white, but the cloud is slightly less bright than the specular — the distinction survives. This is how film handles overexposure, and it's why film highlights look "creamy" while digital highlights look "burned."

Headroom: Why Values Above 1.0 Matter

In the old pipeline, every processing step used clamp(x, 0.0, 1.0) to keep values in the displayable range. This seems reasonable — your monitor can't display anything brighter than white, so why keep values above 1.0? The answer is that intermediate values above 1.0 carry information that later processing steps can use.

Consider this sequence: you increase brightness by +0.5 stops (multiply by 1.41), then increase contrast, then reduce brightness by -0.3 stops. In the old pipeline with clamp:

// Old pipeline: clamp at every step let pixel = 0.85; // bright surface let step1 = clamp(pixel * 1.41, 0.0, 1.0); // 1.199 → clamped to 1.0 let step2 = apply_contrast(step1); // operates on 1.0 (no headroom) let step3 = clamp(step2 * 0.812, 0.0, 1.0); // 0.812 — but detail is gone // New pipeline: max() preserves headroom, ACES compresses at the end let pixel = 0.85; let step1 = max(pixel * 1.41, 0.0); // 1.199 — preserved! let step2 = apply_contrast(step1); // operates on 1.199 (has information) let step3 = max(step2 * 0.812, 0.0); // 0.973 — detail recovered let output = aces_tonemap(step3); // final compression to display range

In the old pipeline, the clamp at step 1 destroyed the distinction between a pixel at 0.85 and a pixel at 0.95 — both became 1.0 after the brightness increase. That distinction is irrecoverable. No amount of later processing can separate them. In the new pipeline, the values 1.199 and 1.340 remain distinct through the entire chain. When brightness is later reduced, the original relationship between those pixels is restored. When ACES tone mapping is finally applied, both values are compressed into the displayable range, but their relative ordering and spacing is preserved.

This is what "scene-referred" processing means. The values in the pipeline refer to the physical scene — they represent actual light intensities, not display code values. A cloud reflecting 150% of the light that a white wall reflects is stored as 1.5, not clamped to 1.0. A specular highlight from a chrome surface might be 3.0 or 5.0. These super-white values flow through every processing step, carrying real information, until the final tone mapping compresses them to the display range.

The key code change was replacing clamp(x, 0.0, 1.0) with max(x, 0.0) in every brightness, contrast, and processing function. We still clamp at zero (negative light has no physical meaning), but we never clamp at 1.0. The only place where values are compressed to the 0–1 display range is the ACES tone mapping function, applied once, at the very end of the pipeline.

The Full Pipeline Architecture

Here's the complete processing chain from input file to display pixel:

sRGB Input File (JPEG/PNG 8-bit or TIFF 16-bit) | v CPU Linearization (LUT for 8-bit, pow(2.4) for 16-bit) [f32] | v GPU Upload: Rgba16Float Texture [IEEE 754 binary16] | v Fragment Shader — All Processing in Linear Light [f32 arithmetic] ├── Spectral channel mixing (5-channel B&W conversion) ├── Zone System classification and adjustment ├── Brightness / Exposure (multiply, max() floor) ├── Contrast (S-curve in linear space) ├── Dodge and Burn (spatial luminance adjustment) ├── Grain synthesis (Perlin noise, linear-correct) ├── Solarization / Lith effects ├── Vignette (multiply in linear — no halos) └── Values flow freely above 1.0 throughout | v ACES Filmic Tone Mapping (compress to 0.0–1.0) [f32 → f32] | v sRGB Surface Write (GPU fixed-function hardware encode) [linear → sRGB, zero cost] | v Display

Two details worth noting. First, the sRGB encoding on the output surface is performed by the GPU's fixed-function hardware, not by shader code. When you configure a wgpu surface with an sRGB format, the GPU's texture write units automatically apply the IEC 61966-2-1 transfer function as they write fragments to the framebuffer. This is a dedicated circuit on the GPU that runs at zero computational cost — no shader instructions, no performance penalty. The same hardware path has existed since OpenGL 2.1 / DirectX 9.

Second, all arithmetic inside the fragment shader operates at 32-bit float precision (f32), regardless of the source texture format. When the shader samples the Rgba16Float texture, the GPU's texture unit automatically promotes the 16-bit values to 32-bit for computation. The 11-bit mantissa of half-float determines the input precision, but every multiply, add, and function evaluation within the shader uses the full 23-bit mantissa of 32-bit float. This is the same precision architecture that Lightroom uses: 16-bit float for storage, 32-bit float for computation.

How This Compares

Every serious photo editor now processes in linear light with high bit depth. The differences are in the details: where the computation happens, what precision is used, what tone mapping is applied, and whether the pipeline is open or proprietary.

Feature Lightroom Capture One darktable GPU-Native (e.g. wgpu)
Processing engine CPU (multithreaded) CPU (multithreaded) CPU (OpenCL optional) GPU (wgpu compute)
Internal precision 32-bit float 16-bit integer 32-bit float 32-bit float (shader), 16-bit float (texture)
Color space ProPhoto RGB (linear) Proprietary per-camera ICC Linear Rec. 2020 or pipeline RGB Linear sRGB (B&W specialized)
Tone mapping Proprietary S-curve Proprietary ICC profile curves Filmic module (ACES-inspired) ACES Filmic (Narkowicz, open)
Highlight handling Proprietary highlight recovery Proprietary, per-camera tuned Filmic desaturation + rolloff ACES shoulder + super-white headroom
Real-time preview Cached tiles, ~200ms lag Smart previews, ~150ms lag Pixelpipe, ~300ms+ for full view <4ms at 45 MP (GPU native)
Tone mapping standard Proprietary (closed) Proprietary (closed) Open source (GPL) ACES (open standard, AMPAS)
B&W specialization Basic RGB mixer + presets Basic RGB mixer + layers Channel mixer + filmic B&W 5-channel spectral + 11-zone system

Lightroom and Capture One are vastly more capable as general-purpose photo editors. They handle raw demosaicing, lens corrections, color grading, local adjustments, and asset management. The advantage of a GPU-native approach lies in three properties: real-time processing without cached proxies, the possibility of using open-standard tone mapping curves like ACES, and the ability to build B&W-specific tools (spectral sensitivity, zone system) that general-purpose editors often treat as afterthoughts.

darktable's filmic module deserves special mention. Aurelien Pierre's work on the filmic module (introduced in darktable 3.0) brought scene-referred linear processing to open-source photography software, with an approach philosophically similar to what we've built. The filmic module's tone mapping uses a custom spline that approximates the characteristic curve of photographic film, with explicit control over the toe (shadows), latitude (midtones), and shoulder (highlights). The ACES approach is less configurable but more standardized — the curve is the curve, defined by the Academy. ACES is appealing because its behavior is well-documented, reproducible, and understood across the film and VFX industries. There's no ambiguity about what the tone mapping does or why.

Impact on Presets

Switching to ACES Filmic tone mapping requires recalibrating all presets. This isn't a trivial rescaling. The ACES curve adds approximately 15–20% midtone luminosity compared to a simple linear-to-sRGB pipeline. A preset that previously set contrast to 0.65 might need 0.52 in an ACES pipeline to achieve the same visual density. Exposure values shift similarly. Grain intensity needs to be reduced because linear-correct grain synthesis distributes noise more evenly across the tonal range, making it visually stronger at the same numerical intensity.

The recalibration process is best done manually. Each preset should be evaluated on a reference set of images spanning the full range of photographic content: high-key portrait, low-key portrait, street scene, landscape, architecture, night urban, forest interior, studio still life, concert, snow scene, backlit silhouette, and foggy morning. The goal for each preset is to match the intended aesthetic character (e.g., "Tri-X pushed, contrasty, grain-forward") while leveraging the pipeline's improved shadow separation and highlight rolloff.

The result is that ACES-calibrated presets use systematically lower values. Less aggressive contrast settings. Lower exposure offsets. Reduced grain intensity. This isn't because the presets are "weaker." It's because the tone mapping engine does more of the heavy lifting. ACES provides a beautiful baseline response with rich midtones and smooth highlights. The presets don't need to fight the pipeline for luminosity; they orient the aesthetic on top of a fundamentally better foundation.


Eric K'DUAL
Written by
Eric K'DUAL
Photographer & Writer
Eric K'DUAL is a French photographer and digital artist based in France. Passionate about code and black & white photography, he bridges traditional darkroom craft with modern computational imaging, building his own tools and chasing the decisive moment in monochrome.