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>
Film stocks
One file per stock in profiles/. Adding a stock is adding a
file — no code change, no shader, no new operation — for the same reason
dr-decode's base curves work that way: under the GPLv3 a stock should be
contributable without a release.
What a profile is
Three measured tables, all of them published in the manufacturer's datasheet:
| Field | What it decides |
|---|---|
log_sensitivity |
what each emulsion layer sees, per wavelength |
density_curves |
contrast, latitude, and where the stock clips |
dye_density |
what the developed stock looks like, per wavelength |
base_density |
the support: film base, and a colour negative's orange mask |
Plus kind (negative or positive), support (film or paper), and the two
illuminants the data is referenced to. A print paper is a stock like any
other; support exists so an interface can offer papers separately, not
because the renderer treats them differently.
Why it is not a LUT
Because the parameters stay physical. Opening up a stop moves the picture along the film's own characteristic curve — toe, shoulder and all — instead of scaling a number somebody baked at one exposure. A scanned negative comes out orange and inverted because that is what a negative is, and it becomes a photograph when a paper profile prints it, exactly as it would in a darkroom.
The data cost runs the other way from a LUT collection too: a stock is about 17 kB of measurements, where one HaldCLUT is roughly 800 kB of one person's grade.
How it runs
The spectral chain reduces to three tables, and the reduction is exact where it
matters — see src/bake.rs for the argument:
- A 3×3 matrix, linear sRGB to the three layers' exposure. Exact, not an approximation: the reconstructed scene spectrum is linear in the sRGB triple, so the integral collapses into nine numbers.
- Three 1D curves, log exposure to density, sampled at 256 points.
- One 32³ lookup, density to linear sRGB — dye absorption, the print through the negative, the paper, the viewing illuminant and the chromatic adaptation, all of which take exactly three numbers in.
Per pixel that is a matrix multiply, three curve taps and one texture fetch. Splitting 2 from 3, rather than baking one LUT over exposure, is measured rather than assumed: the curve carries all the sharp shape and the dye mixing is smooth, so folding the curve into the 3D lookup would need it three times larger for the same error. At 32³ the worst interpolation error is about 0.003 in linear sRGB, below one 8-bit code value, and there is a test that says so.
Adding a stock
If spektrafilm has it, add its name to STOCKS in
tools/film-profiles/convert.py and
re-run it. Otherwise write the YAML by hand from the datasheet; the loader
validates the table lengths and says which file and field is wrong.
Either way, list it in BUILT_IN in src/lib.rs to compile it
in — or drop it in the profile directory at runtime, which is the path meant
for stocks that ship separately from the binary.
Provenance
The shipped profiles are converted from
spektrafilm by Andrea Volpato,
licensed CC BY-SA 4.0. See profiles/LICENSE-PROFILES.txt
for the licence and profiles/CHANGELOG.txt for what
the conversion changed and what it deliberately did not.
The sRGB reflectance basis is Mallett & Yuksel (2019); the observer is the CIE 1931 2°.