Files
DarkRoom/core/dr-film/src/bake.rs
T
dtourolleandClaude Opus 5 4a2fcb6d22 Render a film stock on the GPU, and let it take over the rendering
The stock model landed in dr-film with no way to see it. This is the
pipeline node, the two texture bindings it reads, and the end-to-end test
that proves the shader agrees with the model.

The design point is that a film simulation is not an adjustment. Every
other node changes a picture; this one makes it. A stock's characteristic
curve does the camera profile's base curve's job -- from measurements
rather than from a curve somebody drew -- so running both renders the
scene twice: the camera's rendering, and then a film's rendering of that.
It looks like neither, and it reads as a colour-management bug with no
colour-management bug to find.

So `Operation::renders` is new. A node declaring it takes camera RGB and
hands back linear sRGB, and the composer emits neither the base curve nor
the conversion out of camera space. Both halves move together, and the
composer keeps them as one string precisely so that getting half of it
right is impossible.

The tables are not parameters, for the reason vignetting's coefficients
are not: they are measurements. dr-pipeline declares the layout as a plain
struct and keeps its no-dependency property; the two crates share no types
on purpose. `EditGraph::set_film_tables` offers them to every node rather
than to the one that wants them, because knowing which concrete type is
which is what the graph is organised not to know.

Bindings 4 and 5 follow the masks precedent: declared unconditionally so
one bind group layout serves every generated shader, bound to 1x1
placeholders when no stock is loaded. Both are interpolated by hand with
textureLoad -- this pipeline binds no sampler, and adding one for two
lookups would cost a binding in every shader. Uploads are keyed on content
so an unchanged stock does not push half a megabyte across the bus per
frame.

The end-to-end test earned its place immediately: it found the density
lookup being filled z-fastest while a 3D texture upload wants x-fastest,
so the red and blue axes were transposed. Green matched exactly, which is
what that bug looks like -- a plausible photograph of the wrong colour,
and one that every unit test on either side of the seam passes. dr-film
now pins the layout in a test that needs no device, and states it where
the field is declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:12:56 +02:00

501 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Turning a stock into something a shader can run.
//!
//! # The decomposition
//!
//! A physically-honest film simulation looks like it needs a spectral
//! integration per pixel, and vkdt's does exactly that. It does not have to,
//! and the reason is worth writing down because it is what makes this cheap
//! enough to run on a phone:
//!
//! 1. **Exposure is a 3×3 matrix.** A layer's exposure is
//! `∫ S(λ)·L(λ) dλ`, and the scene spectrum `L` reconstructed from an sRGB
//! triple is *linear* in that triple — that is what a spectral basis is. So
//! the whole integral collapses into nine numbers, computed once, exactly.
//! No approximation is involved.
//!
//! 2. **The characteristic curve is three 1D functions.** Sampled exactly, at
//! [`crate::profile::CURVE_SAMPLES`].
//!
//! 3. **Everything after that is a function of three densities.** The dye
//! transmittance, the print exposure through the negative, the paper's own
//! curves and dyes, the viewing illuminant, the adaptation — all of it takes
//! three numbers in and gives three numbers out. So it bakes into one small
//! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps
//! and one texture fetch.
//!
//! Splitting 2 from 3 rather than baking a single LUT over exposure is
//! deliberate and measured: the curve carries all of the sharp shape and the
//! dye mixing is smooth, so putting the curve in the 3D LUT would force it
//! three times larger for the same error.
use crate::profile::Profile;
use crate::spectrum::{illuminant, Spectrum, Viewing};
use crate::tables::{SPECTRUM, SRGB_BASIS};
/// The mid-grey a photographic exposure is reckoned from.
///
/// 18.4% rather than 18%: it is the value the upstream profiles are calibrated
/// against, and a profile calibrated at one grey and rendered at another is
/// off by a fraction of a stop everywhere.
pub const MID_GREY: f32 = 0.184;
/// The edge length of the baked density lookup.
///
/// 32 holds the worst-case interpolation error to about 0.003 in linear sRGB,
/// which is below one 8-bit code value, in 384 kB. Doubling it buys a factor
/// of four in error for eight times the memory, and there is nothing to spend
/// that on: the error is already under what the output can represent.
pub const LUT_SIZE: usize = 32;
/// What to develop, and how.
pub struct Recipe<'a> {
/// The stock the picture was taken on.
pub film: &'a Profile,
/// The paper it is printed on. `None` views the film directly, which is
/// what a reversal stock wants and what makes a negative come out orange
/// and inverted — that being what a negative actually looks like.
pub print: Option<&'a Profile>,
/// Camera exposure, in stops.
pub exposure_ev: f32,
/// Enlarger exposure, in stops. Ignored without a `print`.
pub print_exposure_ev: f32,
}
impl<'a> Recipe<'a> {
/// The straightforward reading of a stock: reversal viewed directly,
/// negative printed on the paper its datasheet names.
pub fn new(film: &'a Profile, print: Option<&'a Profile>) -> Self {
Self { film, print, exposure_ev: 0.0, print_exposure_ev: 0.0 }
}
}
/// A recipe reduced to three tables.
///
/// Plain `f32` with a documented layout, and no notion of a texture: what to
/// bind this to is dr-gpu's decision, and keeping it out of here is what lets
/// the whole model be tested on the CPU.
#[derive(Debug, Clone)]
pub struct Baked {
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`.
pub exposure_matrix: [[f32; 3]; 3],
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform
/// over `[curve_log_min, curve_log_max]`.
pub curves: Vec<[f32; 3]>,
pub curve_log_min: f32,
pub curve_log_max: f32,
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over
/// `[0, density_max]` on each axis.
///
/// **The red axis varies fastest**, then green, then blue — that is,
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order
/// this loop reads most naturally, and it is not arbitrary: it is the
/// order a 3D texture upload expects, so the consumer can hand the slice
/// straight to the driver. Filling it the other way round renders a
/// picture with red and blue transposed, which looks like a plausible
/// photograph of the wrong colour.
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
pub lut_size: usize,
}
impl Baked {
/// Look a colour up the way the shader will, for tests and for previews.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = self.exposure_matrix[l];
let e = m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2];
*slot = (e.max(0.0) + 1e-10).log10();
}
self.sample_lut(self.sample_curves(log_exposure))
}
fn sample_curves(&self, log_exposure: [f32; 3]) -> [f32; 3] {
let last = self.curves.len() - 1;
let span = self.curve_log_max - self.curve_log_min;
let mut out = [0.0f32; 3];
for (c, slot) in out.iter_mut().enumerate() {
let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32;
let i = (t.floor() as usize).min(last - 1);
let f = t - i as f32;
*slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f;
}
out
}
fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] {
let n = self.lut_size;
let mut base = [0usize; 3];
let mut frac = [0f32; 3];
for c in 0..3 {
let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32;
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32;
}
let mut out = [0.0f32; 3];
for dx in 0..2 {
for dy in 0..2 {
for dz in 0..2 {
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
let e = self.lut
[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
for c in 0..3 {
out[c] += w * e[c];
}
}
}
}
out
}
}
/// Linear sRGB to the three layers' exposure, mid-grey normalised.
///
/// Normalised on the *green* layer alone, one shared scalar for all three.
/// Doing it per layer is tempting and wrong: it would silently flatten the
/// film's own channel balance, which is a large part of what distinguishes one
/// stock from another. Where the balance genuinely has to come out — printing a
/// negative — it is [`print_balance`]'s job, which is also where it belongs
/// physically.
pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] {
let reference = illuminant(&film.reference_illuminant);
let sensitivity = film.sensitivity();
let mut m = [[0.0f32; 3]; 3];
let mut mid_grey = [0.0f32; 3];
for i in 0..SPECTRUM {
for layer in 0..3 {
let s = sensitivity[i][layer] * reference[i];
mid_grey[layer] += s * MID_GREY;
for channel in 0..3 {
m[layer][channel] += s * SRGB_BASIS[i][channel];
}
}
}
let scale = 1.0 / mid_grey[1];
for row in &mut m {
for v in row.iter_mut() {
*v *= scale;
}
}
m
}
/// The enlarger head's filtration, solved rather than dialled.
///
/// Returns the per-layer log exposure offsets that make a mid-grey scene print
/// as a neutral mid-grey. This is also where a colour negative's orange mask
/// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels
/// it — which is why a printed negative looks like a photograph while a scanned
/// one looks orange.
fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32, print_exposure_ev: f32) -> [f32; 3] {
let matrix = exposure_matrix(film);
let scene = MID_GREY * 2f32.powf(exposure_ev);
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = matrix[l];
*slot = ((m[0] + m[1] + m[2]) * scene + 1e-10).log10();
}
let mid_raw = paper_exposure(film, paper, film.density_at(log_exposure));
// Where on the paper's curve mid-grey belongs: the density that reflects
// 18%, read off the average of the three curves. Averaged because the
// point of the balance is that the three end up at the same place.
let target_density = -MID_GREY.log10() - mean(&paper.base_density);
let target = invert_mean_curve(paper, target_density);
let mut offsets = [0.0f32; 3];
for (l, slot) in offsets.iter_mut().enumerate() {
*slot = target - (mid_raw[l] + 1e-10).log10() + print_exposure_ev * 2f32.log10();
}
offsets
}
/// The paper's three layer exposures, printing through a negative at these
/// densities.
///
/// The one genuinely spectral step left in the chain — the negative's
/// transmittance is `10^-D`, so this is not a matrix and cannot be made into
/// one. It takes exactly three numbers in, which is what lets the whole
/// negative-and-print chain still bake into a 3D lookup.
fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3] {
let enlarger = illuminant(&paper.reference_illuminant);
let sensitivity = paper.sensitivity();
let transmittance = film.transmittance(density);
let mut raw = [0.0f32; 3];
for i in 0..SPECTRUM {
let light = transmittance[i] * enlarger[i];
for layer in 0..3 {
raw[layer] += light * sensitivity[i][layer];
}
}
raw
}
/// Bake a recipe into the tables a shader runs.
pub fn bake(recipe: &Recipe) -> Baked {
let film = recipe.film;
let mut matrix = exposure_matrix(film);
// Camera exposure rides in the matrix rather than in the shader: it is a
// scalar on a linear quantity, and folding it in here costs nothing and
// keeps the per-pixel work identical whether or not it has been moved.
let gain = 2f32.powf(recipe.exposure_ev);
for row in &mut matrix {
for v in row.iter_mut() {
*v *= gain;
}
}
let curves = film.density_curves.clone();
let density_max = film.max_density().max(1e-3);
let viewing = match recipe.print {
Some(paper) => Viewing::new(&paper.viewing_illuminant),
None => Viewing::new(&film.viewing_illuminant),
};
let balance = recipe
.print
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev));
let n = LUT_SIZE;
let mut lut = Vec::with_capacity(n * n * n);
// Blue outermost and red innermost, so the red axis varies fastest. See
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting
// it backwards transposes red and blue in the finished picture.
for b in 0..n {
for g in 0..n {
for r in 0..n {
let density = [
density_max * r as f32 / (n - 1) as f32,
density_max * g as f32 / (n - 1) as f32,
density_max * b as f32 / (n - 1) as f32,
];
lut.push(match recipe.print.zip(balance) {
Some((paper, offsets)) => {
let raw = paper_exposure(film, paper, density);
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
*slot = (raw[l] + 1e-10).log10() + offsets[l];
}
let paper_density = paper.density_at(log_exposure);
viewing.to_srgb(&paper.transmittance(paper_density))
}
None => viewing.to_srgb(&film.transmittance(density)),
});
}
}
}
Baked {
exposure_matrix: matrix,
curves,
curve_log_min: film.log_exposure_min,
curve_log_max: film.log_exposure_max,
lut,
density_max,
lut_size: n,
}
}
fn mean(s: &Spectrum) -> f32 {
s.iter().sum::<f32>() / SPECTRUM as f32
}
/// The log exposure at which the paper's average curve reaches `density`.
fn invert_mean_curve(paper: &Profile, density: f32) -> f32 {
let curves = &paper.density_curves;
let last = curves.len() - 1;
let span = paper.log_exposure_max - paper.log_exposure_min;
let at = |i: usize| (curves[i][0] + curves[i][1] + curves[i][2]) / 3.0;
let log_at = |i: usize| paper.log_exposure_min + span * i as f32 / last as f32;
// A paper is negative-working, so its mean curve rises. Walk it rather
// than binary-search: 256 samples is nothing, and a linear scan is correct
// even where the curve is flat, which a bisection is not.
let ascending = at(last) >= at(0);
for i in 0..last {
let (lo, hi) = (at(i), at(i + 1));
let brackets = if ascending { lo <= density && density <= hi } else { hi <= density && density <= lo };
if brackets && (hi - lo).abs() > f32::EPSILON {
let f = (density - lo) / (hi - lo);
return log_at(i) + (log_at(i + 1) - log_at(i)) * f;
}
}
// Off the end of the curve: the nearest end is the honest answer, and it
// keeps a badly-scaled contributed profile from producing a NaN that would
// propagate silently through the whole LUT.
if (density <= at(0)) == ascending { paper.log_exposure_min } else { paper.log_exposure_max }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::CURVE_SAMPLES;
fn profile(yaml: &str) -> Profile {
Profile::parse(yaml).unwrap()
}
fn portra() -> Profile {
profile(include_str!("../profiles/kodak_portra_400.yaml"))
}
fn endura() -> Profile {
profile(include_str!("../profiles/kodak_portra_endura.yaml"))
}
fn kodachrome() -> Profile {
profile(include_str!("../profiles/kodak_kodachrome_64.yaml"))
}
fn spread(rgb: [f32; 3]) -> f32 {
rgb.iter().cloned().fold(f32::MIN, f32::max) - rgb.iter().cloned().fold(f32::MAX, f32::min)
}
#[test]
fn the_exposure_matrix_is_diagonally_dominant() {
// Each layer must respond most strongly to its own primary. A matrix
// that failed this would mean the sensitivity table had been pasted in
// the wrong channel order, which produces a picture that renders
// perfectly and has its colours swapped.
for film in [portra(), kodachrome()] {
let m = exposure_matrix(&film);
for layer in 0..3 {
for channel in 0..3 {
if channel != layer {
assert!(
m[layer][layer] > m[layer][channel] * 3.0,
"{}: layer {layer} responds to channel {channel} too strongly: {m:?}",
film.stock
);
}
}
}
}
}
#[test]
fn a_reversal_stock_renders_a_positive() {
let film = kodachrome();
let baked = bake(&Recipe::new(&film, None));
let shadow = baked.apply([0.02; 3]);
let mid = baked.apply([MID_GREY; 3]);
let highlight = baked.apply([0.8; 3]);
assert!(shadow[1] < mid[1] && mid[1] < highlight[1], "{shadow:?} {mid:?} {highlight:?}");
}
#[test]
fn a_scanned_negative_is_inverted_and_orange() {
// Not a defect: it is what a negative looks like, and rendering it any
// other way would mean the print stage was silently applied.
let film = portra();
let baked = bake(&Recipe::new(&film, None));
let shadow = baked.apply([0.02; 3]);
let highlight = baked.apply([0.8; 3]);
assert!(shadow[1] > highlight[1], "not inverted: {shadow:?} -> {highlight:?}");
let mid = baked.apply([MID_GREY; 3]);
assert!(mid[0] > mid[2] * 4.0, "no orange mask: {mid:?}");
}
#[test]
fn printing_a_negative_restores_the_picture() {
// The property the whole print stage exists for: through the paper,
// the same negative is the right way up and neutral again.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let shadow = baked.apply([0.02; 3]);
let mid = baked.apply([MID_GREY; 3]);
let highlight = baked.apply([0.8; 3]);
assert!(
shadow[1] < mid[1] && mid[1] < highlight[1],
"print is not a positive: {shadow:?} {mid:?} {highlight:?}"
);
for grey in [shadow, mid, highlight] {
assert!(spread(grey) < 0.06, "print of a neutral is not neutral: {grey:?}");
}
}
#[test]
fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
let film = portra();
let paper = endura();
let brighter = bake(&Recipe { exposure_ev: 1.0, ..Recipe::new(&film, Some(&paper)) });
let base = bake(&Recipe::new(&film, Some(&paper)));
assert!(brighter.apply([MID_GREY; 3])[1] > base.apply([MID_GREY; 3])[1]);
}
#[test]
fn the_lut_holds_its_error_under_a_code_value() {
// The claim LUT_SIZE is chosen on. Compared against the same chain
// evaluated exactly, so it measures interpolation error and nothing
// else.
let film = kodachrome();
let baked = bake(&Recipe::new(&film, None));
let viewing = Viewing::new(&film.viewing_illuminant);
let mut worst = 0.0f32;
for i in 0..40 {
for j in 0..40 {
let rgb = [i as f32 / 39.0, j as f32 / 39.0, ((i + j) % 40) as f32 / 39.0];
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = baked.exposure_matrix[l];
*slot = ((m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]).max(0.0) + 1e-10).log10();
}
let exact = viewing.to_srgb(&film.transmittance(film.density_at(log_exposure)));
let approx = baked.apply(rgb);
for c in 0..3 {
worst = worst.max((exact[c] - approx[c]).abs());
}
}
}
assert!(worst < 1.0 / 255.0, "worst LUT error {worst} exceeds one code value");
}
#[test]
fn the_lut_stores_red_along_its_fastest_axis() {
// The layout a 3D texture upload expects, and the one bug this whole
// decomposition is most exposed to: fill it the other way round and
// the picture comes back with red and blue transposed -- entirely
// plausible-looking, and wrong. Asserted here rather than only in the
// end-to-end GPU test, because that one needs a device and this one
// does not.
let film = kodachrome();
let baked = bake(&Recipe::new(&film, None));
let n = baked.lut_size;
// Step one along each axis from the origin, and check that the entry
// found is the one the *density* moved along that axis should give.
let viewing = Viewing::new(&film.viewing_illuminant);
let step = baked.density_max / (n - 1) as f32;
for (axis, offset) in [(0usize, 1usize), (1, n), (2, n * n)] {
let mut density = [0.0f32; 3];
density[axis] = step;
let expected = viewing.to_srgb(&film.transmittance(density));
let stored = baked.lut[offset];
for c in 0..3 {
assert!(
(stored[c] - expected[c]).abs() < 1e-4,
"axis {axis} is not at stride {offset}: stored {stored:?}, \
the density one step along that axis gives {expected:?}"
);
}
}
}
#[test]
fn the_lut_is_the_size_it_says_it_is() {
let film = kodachrome();
let baked = bake(&Recipe::new(&film, None));
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
assert_eq!(baked.curves.len(), CURVE_SAMPLES);
}
}