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>
This commit is contained in:
2026-08-25 15:12:56 +02:00
co-authored by Claude Opus 5
parent b6a95e1965
commit 4a2fcb6d22
14 changed files with 1222 additions and 93 deletions
+54 -10
View File
@@ -28,7 +28,7 @@
//! 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, CURVE_SAMPLES};
use crate::profile::Profile;
use crate::spectrum::{illuminant, Spectrum, Viewing};
use crate::tables::{SPECTRUM, SRGB_BASIS};
@@ -84,8 +84,16 @@ pub struct Baked {
pub curves: Vec<[f32; 3]>,
pub curve_log_min: f32,
pub curve_log_max: f32,
/// Density to linear sRGB, `LUT_SIZE³` entries in x-major order, uniform
/// over `[0, density_max]` on each axis.
/// 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,
@@ -132,7 +140,8 @@ impl Baked {
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[0] + dx) * n + base[1] + dy) * n + base[2] + dz];
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];
}
@@ -255,13 +264,16 @@ pub fn bake(recipe: &Recipe) -> Baked {
let n = LUT_SIZE;
let mut lut = Vec::with_capacity(n * n * n);
for x in 0..n {
for y in 0..n {
for z in 0..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 * x as f32 / (n - 1) as f32,
density_max * y as f32 / (n - 1) as f32,
density_max * z as f32 / (n - 1) as f32,
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)) => {
@@ -323,6 +335,7 @@ fn invert_mean_curve(paper: &Profile, density: f32) -> f32 {
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::CURVE_SAMPLES;
fn profile(yaml: &str) -> Profile {
Profile::parse(yaml).unwrap()
@@ -446,6 +459,37 @@ mod tests {
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();