Files
DarkRoom/core/dr-film
dtourolle 37a6d99dc4 Replace the per-body base curve with a scene-referred view transform
The base curve was a five-point spline on the unit square, flat past its
last point: every value above 1.0 left it as the same number, per
channel. Exposure and highlight recovery put values up there, and the
curve threw them away, then handed the result on as though it were
still scene-linear. The six per-body curves were also, by their own
file's account, hand-tuned shapes rather than measurements, and not
enough is known about where they came from to keep them (D19).

In their place, one view transform for every body (FR-DEV-3j): a
log-logistic sigmoid per channel, with the middle channel put back
between the other two so a hue survives the shoulder. Its two free
constants are solved from two conditions rather than set: scene grey
0.13, where the retired default curve put it, lands on display 0.18,
and the scene white four stops above grey lands on 1.0. So a highlight
a stop past sensor saturation still rolls into white, and the midtones
stay within 0.26 EV of the retired default between scene 0.03 and 1.0.
`dr_pipeline::view` holds the CPU reference and the WGSL, and the tests
there are FR-DEV-3j's acceptance criteria.

It is still fixed and still in the fused pass's tail, so a detail stage
still sees rendered values; the next commits make it an operation and
move it after the detail stage. It is skipped for a JPEG, as the base
curve was, and absent from the camera-space tap.

The base curve's database, its lookup and its twelve uniform slots go.
`RawImage` and `DemosaicedImage` lose the field, and the GPU test that
proved a curve reached the shader is replaced by one that renders the
view transform against the CPU reference and shows two highlights above
1.0 still render apart. The JPEG-and-sensor test now asserts the two
differ by exactly the view transform, where before an identity fixture
curve had made them match.
2026-09-27 16:52:53 -04:00
..

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:

  1. 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.
  2. Three 1D curves, log exposure to density, sampled at 256 points — one row per development time the datasheet measures. Push picks between the rows, and interpolating them is exact, because density is linear in push between two measured processes.
  3. One 32³ lookup, density to linear sRGB — dye absorption, the viewing illuminant and the chromatic adaptation, all of which take exactly three numbers in. A printed negative is two: the film's cube ends at the paper's log exposure through the negative, the enlarger's exposure is added there, and the paper's own curve row and cube take it to linear sRGB.

Per pixel that is a matrix multiply, a handful of curve taps and one texture fetch — two for a print. 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.

No slider is baked. Camera exposure is a gain before the matrix, push chooses between curve rows, print exposure is the addition between the two cubes, and format sets the grain; each reaches the shader as a uniform that is linear in what it does. That is what lets a mask layer hold its own film settings, and a pixel under several layers take the weighted average of them. Only the enlarger's filtration is solved at bake time, against the photograph's exposure — an enlarger has one filtration for the whole print — so the film's Exposure, set on the whole photograph, is the one slider that rebakes. The stock and its paper are the photograph's; a layer has no picker.

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°.