Develop a mask layer's film on its own settings

A layer offered the film's sliders and they moved nothing: its copy of
the node was never given the stock, so it stayed inactive. Film now
works in a layer the way the other adjustments do, as offsets to the
photograph's settings, but blended as settings rather than as results,
since a film is a rendering and cross-fading two developments is not
what a region on a pushed film looks like.

- dr-film bakes no slider. Exposure is a gain in the shader; push
  interpolates the stock's measured processes, one curve row each; the
  print is split at the paper's log exposure, so print exposure is an
  addition between two lookups and exact at any setting. The enlarger
  stays balanced at the photograph's exposure.
- film_sim reads all four settings as uniforms, format one-hot over a
  grain count per format, so every uniform is linear in what it does.
- Operation::blends_settings lets the composer average each overlapping
  layer's uniforms with the global ones by mask weight, the global
  setting taking whatever weight the layers leave, and run the fragment
  once. Three layers at full weight give the mean of their settings.
- The stock picker is hidden on a layer. Only the photograph's exposure
  re-solves the print balance; push, print exposure and format need no
  rebake at all now.
This commit is contained in:
2026-09-26 23:29:13 -04:00
parent 48c5e74fa8
commit 6b99f67f47
20 changed files with 1484 additions and 359 deletions
+443 -112
View File
@@ -21,14 +21,16 @@
//! 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.
//! and one texture fetch. A print is two: the film's lookup ends at the
//! paper's log exposure, where the enlarger's exposure is an addition, and
//! the paper's curve and lookup take it from there — see [`Paper`].
//!
//! 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::profile::{Profile, CURVE_SAMPLES};
use crate::spectrum::{illuminant, Spectrum, Viewing};
use crate::tables::{SPECTRUM, SRGB_BASIS};
@@ -47,7 +49,19 @@ pub const MID_GREY: f32 = 0.184;
/// that on: the error is already under what the output can represent.
pub const LUT_SIZE: usize = 32;
/// What to develop, and how.
/// TRACES: FR-DEV-3f
/// The most development times a stock may measure: one curve row, and one
/// push station, each. Every stock shipped measures five; the ceiling is what
/// the shader's fixed uniform block can hold.
pub const MAX_CURVE_ROWS: usize = 8;
/// What to develop: the materials, and where the enlarger is balanced.
///
/// **Not how far, and not how bright.** Push, print exposure and camera
/// exposure are [`Settings`], evaluated per pixel against these tables, so
/// that a mask layer can hold its own and a pixel under it can take the
/// weighted average of everyone's (FR-DEV-3f). What is left here is what a
/// photograph has one of.
pub struct Recipe<'a> {
/// The stock the picture was taken on.
pub film: &'a Profile,
@@ -55,17 +69,15 @@ pub struct Recipe<'a> {
/// 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,
/// TRACES: FR-DEV-3f
/// Development, in stops of push. Positive develops longer.
/// The camera exposure the enlarger is balanced at, in stops. Ignored
/// without a `print`.
///
/// Ignored by a stock measured at one process, of which there are many —
/// see [`crate::profile::Profile::curves_at_push`], which returns the one
/// measured curve rather than inventing a pushed one.
pub push_stops: f32,
/// The *photograph's* exposure, never a region's. An enlarger has one
/// filtration for the whole print: a negative exposed a stop brighter in
/// one corner prints a stop darker there, and that difference is the
/// picture — balancing it away per pixel would erase every local exposure
/// change a layer made.
pub exposure_ev: f32,
}
impl<'a> Recipe<'a> {
@@ -76,12 +88,27 @@ impl<'a> Recipe<'a> {
film,
print,
exposure_ev: 0.0,
print_exposure_ev: 0.0,
push_stops: 0.0,
}
}
}
/// TRACES: FR-DEV-3f
/// What a pixel is developed with, against a [`Baked`] stock.
///
/// The shader's uniforms, as the CPU sees them: every field is linear in what
/// the tables are indexed by, which is what lets the composer blend several
/// layers' settings into one before the fragment runs.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Settings {
/// Camera exposure, in stops: a gain on the scene.
pub exposure_ev: f32,
/// Development, in stops of push. Positive develops longer. Nothing for a
/// stock measured at one process, of which there are many.
pub push_stops: f32,
/// Enlarger exposure, in stops. Nothing without a print.
pub print_exposure_ev: f32,
}
/// A recipe reduced to three tables.
///
/// Plain `f32` with a documented layout, and no notion of a texture: what to
@@ -89,16 +116,31 @@ impl<'a> Recipe<'a> {
/// 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`.
/// Linear sRGB to the three layers' exposure, before the log — row `l`,
/// column `c` is layer `l`'s response to sRGB channel `c`. At unit gain:
/// [`Settings::exposure_ev`] is applied per pixel.
pub exposure_matrix: [[f32; 3]; 3],
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform
/// over `[curve_log_min, curve_log_max]`.
/// The characteristic curves: `curve_rows` rows of `CURVE_SAMPLES`
/// samples, row after row, each uniform over
/// `[curve_log_min, curve_log_max]`.
///
/// Row `r` is the stock as measured at its `r`th development time, which
/// is push [`Self::push_stations`]`[r]`. The rows are the measurements
/// themselves rather than a resampling: between two, density is linear in
/// push (development is interpolated in log time, and push is log time),
/// so interpolating the rows by push reproduces
/// [`Profile::curves_at_push`] exactly. A stock measured at one process
/// has one row.
pub curves: Vec<[f32; 3]>,
pub curve_rows: usize,
/// The push each row was developed to, ascending, one per row.
pub push_stations: Vec<f32>,
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.
/// Film density to what comes next, `LUT_SIZE³` entries uniform over
/// `[0, density_max]` on each axis: linear sRGB when the film is viewed
/// directly, and the paper's log₁₀ exposure through it, per layer, when it
/// is printed.
///
/// **The red axis varies fastest**, then green, then blue — that is,
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order
@@ -108,60 +150,142 @@ pub struct Baked {
/// picture with red and blue transposed, which looks like a plausible
/// photograph of the wrong colour.
pub lut: Vec<[f32; 3]>,
/// The paper, when there is one. See [`Paper`].
pub paper: Option<Paper>,
/// The deepest density any row develops to, so one lookup covers every
/// push.
pub density_max: f32,
pub lut_size: usize,
}
/// TRACES: FR-DEV-3f
/// The print half of a baked stock: enlarger to paper to viewing.
///
/// Split from the film's lookup at the paper's log exposure, for the reason
/// the film is split from its own curve. The enlarger's exposure is a shift
/// *in that log exposure*, the same stops on all three layers, so a print
/// exposure is an addition between the two lookups — exact at any value and
/// free per pixel. Baking it into one lookup instead needs a slice per
/// setting, and interpolating between slices misses by several code values,
/// because the paper's curve is the sharpest thing in the print.
#[derive(Debug, Clone)]
pub struct Paper {
/// The enlarger's filtration, per layer, in log₁₀ exposure: what makes a
/// mid-grey scene print neutral at the photograph's exposure. See
/// [`Recipe::exposure_ev`].
pub balance: [f32; 3],
/// The paper's characteristic curves, `CURVE_SAMPLES` samples uniform
/// over `[log_min, log_max]`.
pub curves: Vec<[f32; 3]>,
pub log_min: f32,
pub log_max: f32,
/// Paper density to linear sRGB, laid out as [`Baked::lut`] is, uniform
/// over `[0, density_max]`.
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
}
/// Where `push` falls among the rows: the lower row and the fraction toward
/// the next. Clamped at both ends, as `curves_at_push` clamps to the first and
/// last measured process.
fn push_row(stations: &[f32], push: f32) -> (usize, f32) {
if stations.len() < 2 {
return (0, 0.0);
}
let last = stations.len() - 1;
let hi = stations
.iter()
.position(|p| *p >= push)
.unwrap_or(last)
.max(1);
let lo = hi - 1;
let f = (push - stations[lo]) / (stations[hi] - stations[lo]).max(1e-6);
(lo, f.clamp(0.0, 1.0))
}
impl Baked {
/// Look a colour up the way the shader will, for tests and for previews.
/// Look a colour up the way the shader will, at the stock's own settings.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
self.apply_at(rgb, &Settings::default())
}
/// Look a colour up the way the shader will, for tests and for previews.
pub fn apply_at(&self, rgb: [f32; 3], settings: &Settings) -> [f32; 3] {
let gain = 2f32.powf(settings.exposure_ev);
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];
let e = gain * (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))
let density = self.sample_curves(log_exposure, settings.push_stops);
let through = sample_cube(&self.lut, self.lut_size, density, self.density_max);
let Some(paper) = &self.paper else {
return through;
};
let shift = settings.print_exposure_ev * 2f32.log10();
let paper_log = [0, 1, 2].map(|l| through[l] + paper.balance[l] + shift);
let paper_density = sample_curve(&paper.curves, paper.log_min, paper.log_max, paper_log);
sample_cube(&paper.lut, self.lut_size, paper_density, paper.density_max)
}
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;
fn sample_curves(&self, log_exposure: [f32; 3], push_stops: f32) -> [f32; 3] {
let (row, g) = push_row(&self.push_stations, push_stops);
let lo = self.sample_curve_row(log_exposure, row);
if self.curve_rows < 2 {
return lo;
}
out
let hi = self.sample_curve_row(log_exposure, row + 1);
[0, 1, 2].map(|c| lo[c] * (1.0 - g) + hi[c] * g)
}
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];
}
fn sample_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
let samples = self.curves.len() / self.curve_rows;
let curve = &self.curves[row * samples..(row + 1) * samples];
sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
}
}
/// Three curves sampled uniformly over `[log_min, log_max]`, read at a log
/// exposure per layer. Clamped at both ends, as the shader's is.
fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
let last = curve.len() - 1;
let span = log_max - log_min;
let mut out = [0.0f32; 3];
for (c, slot) in out.iter_mut().enumerate() {
let t = ((at[c] - 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 = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
}
out
}
/// A cube of `n³` triples over `[0, max]` per axis, red fastest, read
/// trilinearly.
fn sample_cube(lut: &[[f32; 3]], n: usize, density: [f32; 3], max: f32) -> [f32; 3] {
let mut base = [0usize; 3];
let mut frac = [0f32; 3];
for c in 0..3 {
let t = (density[c] / 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 = lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
for c in 0..3 {
out[c] += w * e[c];
}
}
}
out
}
out
}
/// Linear sRGB to the three layers' exposure, mid-grey normalised.
@@ -204,12 +328,7 @@ pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] {
/// 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] {
pub fn print_balance(film: &Profile, paper: &Profile, 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];
@@ -227,7 +346,7 @@ fn print_balance(
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();
*slot = target - (mid_raw[l] + 1e-10).log10();
}
offsets
}
@@ -257,77 +376,117 @@ fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3
/// 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;
}
}
// At unit gain. Camera exposure is a scalar on a linear quantity, so the
// shader applies it for the price of one multiply — and has to, since a
// layer may hold its own.
let matrix = exposure_matrix(film);
// TRACES: FR-DEV-3f
// Developed to the requested push before anything else reads the curves:
// the density ceiling, the print balance and the grain all depend on how
// far this film was taken, and a push that only reached one of them would
// be a contrast change wearing a push's name.
let curves = film.curves_at_push(recipe.push_stops);
let density_max = curves
.iter()
.flat_map(|row| row.iter())
.fold(0.0f32, |a, &b| a.max(b))
.max(1e-3);
let viewing = match recipe.print {
Some(paper) => Viewing::new(&paper.viewing_illuminant),
None => Viewing::new(&film.viewing_illuminant),
// Every measured process, not the one the slider is at: the shader
// interpolates between rows per pixel, so a layer can push a region.
// Resampled to one length because the rows share a texture.
let measured = film.development_curves.len() >= 2
&& film.development_times.len() == film.development_curves.len();
let (curves, push_stations): (Vec<[f32; 3]>, Vec<f32>) = if measured {
let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
(
film.development_curves[..rows]
.iter()
.flat_map(|c| resample(c))
.collect(),
film.development_times[..rows]
.iter()
.map(|t| 2.0 * (t / film.development_normal).log2())
.collect(),
)
} else {
(resample(&film.density_curves), vec![0.0])
};
let balance = recipe
.print
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev));
let curve_rows = push_stations.len();
// The ceiling of the deepest row, so one lookup covers every push.
let density_max = ceiling(&curves);
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)),
});
let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
let mut out = Vec::with_capacity(n * n * n);
for b in 0..n {
for g in 0..n {
for r in 0..n {
let step = max / (n - 1) as f32;
out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
}
}
}
}
out
};
let (lut, paper) = match recipe.print {
None => {
let viewing = Viewing::new(&film.viewing_illuminant);
(
cube(density_max, &|d| viewing.to_srgb(&film.transmittance(d))),
None,
)
}
Some(paper) => {
let viewing = Viewing::new(&paper.viewing_illuminant);
let curves = resample(&paper.density_curves);
let paper_max = ceiling(&curves);
let lut = cube(density_max, &|d| {
paper_exposure(film, paper, d).map(|raw| (raw + 1e-10).log10())
});
let paper = Paper {
balance: print_balance(film, paper, recipe.exposure_ev),
log_min: paper.log_exposure_min,
log_max: paper.log_exposure_max,
lut: cube(paper_max, &|d| viewing.to_srgb(&paper.transmittance(d))),
density_max: paper_max,
curves,
};
(lut, Some(paper))
}
};
Baked {
exposure_matrix: matrix,
curves,
curve_rows,
push_stations,
curve_log_min: film.log_exposure_min,
curve_log_max: film.log_exposure_max,
lut,
paper,
density_max,
lut_size: n,
}
}
/// A curve at `CURVE_SAMPLES`, uniform over the same domain it came in on.
fn resample(curve: &[[f32; 3]]) -> Vec<[f32; 3]> {
if curve.len() == CURVE_SAMPLES {
return curve.to_vec();
}
(0..CURVE_SAMPLES)
.map(|i| {
let at = i as f32 / (CURVE_SAMPLES - 1) as f32;
sample_curve(curve, 0.0, 1.0, [at; 3])
})
.collect()
}
/// The deepest density in a set of curves, floored so a lookup over it has
/// a width.
fn ceiling(curves: &[[f32; 3]]) -> f32 {
curves
.iter()
.flat_map(|row| row.iter())
.fold(0.0f32, |a, &b| a.max(b))
.max(1e-3)
}
fn mean(s: &Spectrum) -> f32 {
s.iter().sum::<f32>() / SPECTRUM as f32
}
@@ -467,6 +626,10 @@ mod tests {
#[test]
fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
// The photograph's exposure: the enlarger balanced at it, and the
// scene brighter by it. Mid-grey stays where the balance puts it —
// that is what the balance is for — so what a stop more does to a
// print is lift everything either side of it along the paper's curve.
let film = portra();
let paper = endura();
let brighter = bake(&Recipe {
@@ -474,7 +637,155 @@ mod tests {
..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]);
let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
for v in [0.02f32, 0.6] {
assert!(
brighter.apply_at([v; 3], &one_stop)[1] > base.apply([v; 3])[1],
"{v} did not print brighter a stop up"
);
}
let (a, b) = (
brighter.apply_at([MID_GREY; 3], &one_stop)[1],
base.apply([MID_GREY; 3])[1],
);
assert!(
(a - b).abs() < 1.0 / 255.0,
"the balance let mid-grey move: {a} vs {b}"
);
}
#[test]
fn a_region_exposed_brighter_prints_brighter_than_the_enlarger_expects() {
// TRACES: FR-DEV-3f
// A layer's exposure is the scene's, not the enlarger's: the balance
// stays where the photograph put it, so the region prints lighter by
// more than the whole photograph would, which is what dodging at the
// camera is.
let film = portra();
let paper = endura();
let base = bake(&Recipe::new(&film, Some(&paper)));
let rebalanced = bake(&Recipe {
exposure_ev: 1.0,
..Recipe::new(&film, Some(&paper))
});
let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
let local = base.apply_at([MID_GREY; 3], &one_stop)[1];
let global = rebalanced.apply_at([MID_GREY; 3], &one_stop)[1];
assert!(local > base.apply([MID_GREY; 3])[1], "not brighter at all");
assert!(
local > global,
"a region was rebalanced as though it were the whole print: {local} vs {global}"
);
}
#[test]
fn more_light_through_the_enlarger_darkens_the_print() {
// TRACES: FR-DEV-3f
// Paper is negative-working. Opening the enlarger a stop is burning
// in, and a slider that brightened would be the wrong way round for
// anyone who has printed.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let at = |stops: f32| {
baked.apply_at(
[MID_GREY; 3],
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
)[1]
};
assert!(at(1.0) < at(0.0) && at(0.0) < at(-1.0));
}
#[test]
fn a_push_on_a_row_is_the_measured_curve() {
// TRACES: FR-DEV-3f
// The rows are the measured processes, so at a row the table must be
// that curve exactly, and between rows — density being linear in push
// there — it must be `curves_at_push` to rounding.
let film = profile(include_str!("../profiles/kodak_doublex.yaml"));
let baked = bake(&Recipe::new(&film, None));
assert_eq!(
baked.curve_rows, 5,
"Double-X measures five development times"
);
let span = film.log_exposure_max - film.log_exposure_min;
let mut worst = 0.0f32;
let stations = baked.push_stations.clone();
let mut pushes: Vec<(f32, bool)> = stations.iter().map(|p| (*p, true)).collect();
for k in 0..=16 {
pushes.push((-1.0 + 4.0 * k as f32 / 16.0, false));
}
for (push, on_row) in pushes {
let exact = film.curves_at_push(push);
for i in (0..exact.len()).step_by(7) {
let log = film.log_exposure_min + span * i as f32 / (exact.len() - 1) as f32;
let got = baked.sample_curves([log; 3], push);
for c in 0..3 {
let err = (got[c] - exact[i][c]).abs();
if on_row {
assert!(err < 1e-4, "push {push} is a row but misses it by {err}");
}
worst = worst.max(err);
}
}
}
assert!(worst < 1e-3, "between rows the density is off by {worst}");
}
#[test]
fn a_print_exposure_is_exact_at_any_setting() {
// TRACES: FR-DEV-3f
// The enlarger's exposure is added between the two lookups rather than
// baked into either, so no setting is nearer the tables than another.
// Compared against the chain evaluated spectrally, end to end, at
// settings chosen off every half and whole stop.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let offsets = print_balance(&film, &paper, 0.0);
let viewing = Viewing::new(&paper.viewing_illuminant);
let mut worst = 0.0f32;
for stops in [-2.3f32, -0.6, 0.0, 0.35, 1.7] {
for i in 0..14 {
let v = 0.004 * 2f32.powf(i as f32 * 0.6);
let rgb = [v, v * 0.8, v * 1.1];
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 raw = paper_exposure(&film, &paper, film.density_at(log_exposure));
let paper_log =
[0, 1, 2].map(|l| (raw[l] + 1e-10).log10() + offsets[l] + stops * 2f32.log10());
let exact = viewing.to_srgb(&paper.transmittance(paper.density_at(paper_log)));
let approx = baked.apply_at(
rgb,
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
);
for c in 0..3 {
worst = worst.max((exact[c] - approx[c]).abs());
}
}
}
assert!(
worst < 1.0 / 255.0,
"the print misses the spectral chain by {worst}"
);
}
#[test]
@@ -550,5 +861,25 @@ mod tests {
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);
assert_eq!(baked.curve_rows, 1);
assert!(baked.paper.is_none());
// A print has a second lookup and a curve of its own; a development
// series a row per push. Neither is inferred from the other.
let negative = portra();
let paper = endura();
let printed = bake(&Recipe::new(&negative, Some(&paper)));
let print = printed
.paper
.as_ref()
.expect("a printed negative has a paper");
assert_eq!(print.lut.len(), LUT_SIZE.pow(3));
assert_eq!(print.curves.len(), CURVE_SAMPLES);
let pushable = profile(include_str!("../profiles/kodak_doublex.yaml"));
let rows = bake(&Recipe::new(&pushable, None));
assert_eq!(rows.curve_rows, pushable.development_times.len());
assert_eq!(rows.push_stations.len(), rows.curve_rows);
assert_eq!(rows.curves.len(), rows.curve_rows * CURVE_SAMPLES);
}
}
+4 -1
View File
@@ -680,15 +680,18 @@ fn film_tables() -> FilmTables {
FilmTables {
exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(),
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(),
density_max: baked.density_max,
lut_size: baked.lut_size,
// Viewed directly: `STOCK` is baked without a paper above.
paper: None,
// Grain off. It is a per-pixel hash and would be measured; it is also
// not part of every edit, and the chain being measured here is "every
// operation active", not "every option of every operation".
grain_particles: [0.0; 3],
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97,
}
+24 -4
View File
@@ -382,9 +382,23 @@ fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
t.curve_log_max,
t.density_max,
t.lut_size as f32,
t.curves.len() as f32,
t.lut.len() as f32,
] {
mix(v.to_bits());
}
for v in &t.push_stations {
mix(v.to_bits());
}
// The paper's balance is left out on purpose: it reaches the shader as
// uniforms, not texels, and it is what moves when the photograph's
// exposure does — keying on it would re-upload a megabyte per tick of a
// slider that changes three floats.
if let Some(p) = &t.paper {
for v in [p.log_min, p.log_max, p.density_max] {
mix(v.to_bits());
}
}
for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) {
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
}
@@ -431,12 +445,16 @@ impl AdjustPass {
return;
}
// One row per curve — the film's at each measured push, then the
// paper's — and one cube per stage stacked in depth. The shader reads
// the layout from `FilmTables`' uniforms, not from these sizes.
let samples = dr_pipeline::ops::film_sim::CURVE_SAMPLES as u32;
let curves = self.upload_film(
"adjust-film-curves",
wgpu::TextureDimension::D2,
wgpu::Extent3d {
width: t.curves.len() as u32,
height: 1,
width: samples,
height: t.curves.len() as u32 / samples,
depth_or_array_layers: 1,
},
&to_rgba(&t.curves),
@@ -448,7 +466,7 @@ impl AdjustPass {
wgpu::Extent3d {
width: n,
height: n,
depth_or_array_layers: n,
depth_or_array_layers: t.lut.len() as u32 / (n * n),
},
&to_rgba(&t.lut),
);
@@ -2303,7 +2321,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; N * N * N],
density_max: 3.0,
lut_size: N,
grain_particles: [0.0; 3],
push_stations: vec![0.0],
paper: None,
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
grain_density_max: [3.0; 3],
grain_uniformity: 0.97,
}
+263 -6
View File
@@ -16,9 +16,12 @@
//! the CPU model.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_film::bake::{bake, Recipe};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::ops::FilmTables;
use dr_film::bake::{bake, Recipe, Settings};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
use dr_pipeline::mask::{MaskLayer, MaskSource};
use dr_pipeline::ops::film_sim;
use dr_pipeline::ops::film_sim::FORMAT_COUNT;
use dr_pipeline::ops::{FilmTables, PaperTables};
use dr_pipeline::EditGraph;
const SIZE: u32 = 16;
@@ -77,15 +80,31 @@ fn tables(baked: &dr_film::Baked) -> FilmTables {
/// split a grain that never left the CPU would look exactly like a passing
/// test suite.
fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables {
// The paper, when there is one, rides behind the film: its curve as one
// more row, its cube stacked after the film's.
let mut curves = baked.curves.clone();
let mut lut = baked.lut.clone();
let paper = baked.paper.as_ref().map(|p| {
curves.extend_from_slice(&p.curves);
lut.extend_from_slice(&p.lut);
PaperTables {
balance: p.balance,
log_min: p.log_min,
log_max: p.log_max,
density_max: p.density_max,
}
});
FilmTables {
exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(),
curves,
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(),
lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
grain_particles: particles,
paper,
grain_particles: [particles; FORMAT_COUNT],
grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97,
}
@@ -241,3 +260,241 @@ fn grain_reaches_the_shader_and_scales_with_the_pixel() {
"grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}"
);
}
/// The same render, with the film's sliders set and mask layers laid over it.
///
/// Every layer is a `Regions` mask over a field splitting the frame down the
/// middle: region 0, the left half, at full weight, and the right half
/// untouched. Returns the left and right centre pixels, linear.
fn rendered_split(
ctx: &GpuContext,
level: u16,
tables: FilmTables,
global: Settings,
layers: Vec<MaskLayer>,
) -> ([f32; 3], [f32; 3]) {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let mut graph = EditGraph::default_chain();
graph.set_film(Some(dr_pipeline::graph::Film {
stock: "under_test".to_string(),
print: None,
tables: tables.clone(),
}));
graph.set_param(film_sim::ID, film_sim::EXPOSURE, global.exposure_ev);
graph.set_param(film_sim::ID, film_sim::PUSH, global.push_stops);
graph.set_param(
film_sim::ID,
film_sim::PRINT_EXPOSURE,
global.print_exposure_ev,
);
for layer in layers {
graph.masks_mut().push(layer);
}
let shader = graph.compose();
let labels: Vec<u32> = (0..SIZE * SIZE)
.map(|i| u32::from(i % SIZE >= SIZE / 2))
.collect();
let field = LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload");
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(graph.masks(), Some(&field), None, None, SIZE, SIZE)
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust.set_film(Some(&tables));
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let at = |x: u32| {
let c = (((SIZE / 2) * SIZE + x) * 4) as usize;
[0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0))
};
(at(SIZE / 4), at(3 * SIZE / 4))
}
/// A layer over the left half holding these film offsets.
fn left_half(id: &str, offsets: &[(dr_pipeline::descriptor::ParamId, f32)]) -> MaskLayer {
let mut layer = MaskLayer::new(
id,
MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
},
);
for (param, v) in offsets {
layer.set_param(film_sim::ID.0, *param, *v);
}
layer
}
fn assert_close(got: [f32; 3], want: [f32; 3], what: &str) {
for c in 0..3 {
assert!(
(got[c] - want[c]).abs() < 0.02,
"{what}, channel {c}: GPU gave {got:?}, the model says {want:?}"
);
}
}
#[test]
fn a_print_renders_on_the_gpu_the_way_it_does_on_the_cpu_at_any_setting() {
// TRACES: FR-DEV-3f
// The print path — the film's lookup into the paper's log exposure, the
// enlarger added between, the paper's curve and its own lookup — is read
// from the same two textures as the film, at offsets. Every one of those
// offsets is a way to render a plausible print of the wrong thing.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
for settings in [
Settings::default(),
Settings {
print_exposure_ev: -1.3,
..Settings::default()
},
Settings {
exposure_ev: 0.4,
print_exposure_ev: 0.8,
..Settings::default()
},
] {
for level in [6_000u16, 20_000] {
let input = f32::from(level) / f32::from(u16::MAX);
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
assert_close(
got,
baked.apply_at([input; 3], &settings),
&format!("{settings:?} at {level}"),
);
}
}
}
#[test]
fn a_push_between_two_measured_processes_renders_as_the_model_does() {
// TRACES: FR-DEV-3f
// Double-X measures five processes; a push between two is a mix of two
// rows of the curve texture, found by searching the stations uniform.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_doublex").expect("stock");
let baked = bake(&Recipe::new(film, None));
assert!(baked.curve_rows > 2, "Double-X has a development series");
for push in [-0.8f32, 0.4, 1.3, 2.9] {
let settings = Settings {
push_stops: push,
..Settings::default()
};
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
assert_close(
got,
baked.apply_at([input; 3], &settings),
&format!("push {push}"),
);
}
}
#[test]
fn a_layer_develops_its_region_on_its_own_settings() {
// TRACES: FR-DEV-3f
// Offsets to the photograph's: print exposure +1 on a photograph at +0.5
// is +1.5 under the layer, and the rest of the print is untouched. Before
// film was blended as settings the layer's sliders moved and nothing
// happened, because the layer's copy of the node had no stock.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let global = Settings {
print_exposure_ev: 0.5,
..Settings::default()
};
let layer = left_half(
"burn",
&[(film_sim::PRINT_EXPOSURE, 1.0), (film_sim::EXPOSURE, -0.5)],
);
let (left, right) = rendered_split(&ctx, level, tables(&baked), global, vec![layer]);
let under = Settings {
exposure_ev: -0.5,
print_exposure_ev: 1.5,
..Settings::default()
};
assert_close(left, baked.apply_at([input; 3], &under), "under the layer");
assert_close(right, baked.apply_at([input; 3], &global), "outside it");
assert!(
left[1] < right[1] - 0.01,
"the burn did not darken: {left:?} vs {right:?}"
);
}
#[test]
fn overlapping_layers_take_the_average_of_their_settings() {
// TRACES: FR-DEV-3f
// Three layers over the same pixels at full weight: the plain mean of what
// each asks for, and the global setting has no weight left. Summed, the
// offsets would be -2 stops of print exposure and +1 of exposure; the mean
// is (-1, -1, 0) / 3 and (0, 0, +1) / 3.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let layers = vec![
left_half("a", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
left_half("b", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
left_half("c", &[(film_sim::EXPOSURE, 1.0)]),
];
let (left, right) = rendered_split(&ctx, level, tables(&baked), Settings::default(), layers);
let mean = Settings {
exposure_ev: 1.0 / 3.0,
print_exposure_ev: -2.0 / 3.0,
..Settings::default()
};
let summed = Settings {
exposure_ev: 1.0,
print_exposure_ev: -2.0,
..Settings::default()
};
let (m, s) = (
baked.apply_at([input; 3], &mean)[1],
baked.apply_at([input; 3], &summed)[1],
);
// Three times the tolerance the GPU is held to below, or a sum could pass
// for a mean.
assert!(
(m - s).abs() > 0.06,
"the mean and the sum render alike ({m} vs {s}), so this proves nothing"
);
assert_close(left, baked.apply_at([input; 3], &mean), "under all three");
assert_close(right, baked.apply([input; 3]), "outside them");
}
+3 -1
View File
@@ -581,7 +581,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
+3 -1
View File
@@ -132,7 +132,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0,
lut_size: 32,
grain_particles: [0.0; 3],
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3],
grain_uniformity: 0.97,
},
+119 -10
View File
@@ -1656,7 +1656,7 @@ impl MaskLayer {
self.ops
.iter()
.map(|o| o.as_ref())
.filter(|o| o.is_active() && o.detail().is_none())
.filter(|o| moves(*o) && o.detail().is_none())
}
/// Whether any part of this mask belongs to a different segmentation.
@@ -2087,9 +2087,30 @@ pub(crate) struct LocalOp {
/// Empty when the offsets cancel the global setting back to neutral. That
/// is still an entry, because it still means something: inside the mask
/// this operation does nothing at all.
///
/// Empty, too, for an operation blended as settings: that entry stands
/// for this layer's uniforms, `mask{slot}_{op}_*`, which the composer
/// averages into the one fragment it runs.
pub fragment: String,
}
/// Whether a layer's copy of `op` holds an adjustment.
///
/// An operation's own answer, except for one blended as settings
/// ([`Operation::blends_settings`]): that one is active by what it *holds* —
/// a film is active when a stock is loaded — and a layer never holds a stock,
/// only offsets to the photograph's. So a layer's film counts as moved when
/// its sliders are, which is the question being asked.
fn moves(op: &dyn Operation) -> bool {
op.is_active()
|| (op.blends_settings()
&& op
.descriptor()
.params
.iter()
.any(|p| op.param(p.id) != p.default))
}
/// A layer's settings for one operation, applied as offsets to the global
/// operation's.
///
@@ -2215,7 +2236,7 @@ pub(crate) fn compose_layers_revealing(
let mut combined = layer_chain();
for (dst, local) in combined.iter_mut().zip(&layer.ops) {
let local = local.as_ref();
if !local.is_active() || local.detail().is_some() {
if !moves(local) || local.detail().is_some() {
continue;
}
let id = local.descriptor().id.0;
@@ -2223,9 +2244,22 @@ pub(crate) fn compose_layers_revealing(
.iter()
.map(|o| o.as_ref())
.find(|o| o.descriptor().id.0 == id);
// The photograph's stock, lent to the layer's copy: a layer holds
// offsets to a film, never one of its own.
if let Some(g) = g {
dst.set_film_tables(g.film_tables());
}
offset_onto(dst.as_mut(), local, g);
if !dst.is_active() {
if dst.blends_settings() {
// Its uniforms, and no fragment: the composer blends this
// layer's settings with the others' and runs the global
// fragment once. With no stock loaded there is nothing to
// blend, and the global side skips the operation too.
if !dst.is_active() {
continue;
}
} else if !dst.is_active() {
out.ops.push(LocalOp {
op: id,
slot,
@@ -2250,13 +2284,16 @@ pub(crate) fn compose_layers_revealing(
}
}
let mut fragment = dst.wgsl_body();
for u in &op_uniforms {
fragment = crate::operation::rewrite_uniform(
&fragment,
u.name,
&format!("u.{op_prefix}_{}", u.name),
);
let mut fragment = String::new();
if !dst.blends_settings() {
fragment = dst.wgsl_body();
for u in &op_uniforms {
fragment = crate::operation::rewrite_uniform(
&fragment,
u.name,
&format!("u.{op_prefix}_{}", u.name),
);
}
}
out.ops.push(LocalOp {
op: id,
@@ -2387,6 +2424,78 @@ mod tests {
layer
}
/// A stock the shader can index, with values that are not a real one's.
fn film_fixture() -> crate::graph::Film {
use crate::ops::film_sim::{CURVE_SAMPLES, FORMAT_COUNT};
crate::graph::Film {
stock: "fixture".into(),
print: None,
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5; 3]; CURVE_SAMPLES],
push_stations: vec![0.0],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5; 3]; 8],
density_max: 2.0,
lut_size: 2,
paper: None,
grain_particles: [[0.0; 3]; FORMAT_COUNT],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}
}
#[test]
fn a_layers_film_is_blended_as_settings_and_developed_once() {
// TRACES: FR-DEV-3f
// The film is a rendering: a layer's version of it run beside the
// global one and cross-faded would be the photograph developed twice.
// So the layer's uniforms are averaged into the global ones by weight
// and the fragment appears once.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
graph.set_film(Some(film_fixture()));
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PRINT_EXPOSURE, 1.0);
assert!(layer.is_active(), "a film offset is an adjustment");
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(source.contains("let set_w = mask_w0;"), "{source}");
assert!(source.contains("let set_g = max(1.0 - set_w, 0.0);"));
assert!(
source.contains("u.mask0_film_sim_pev"),
"the layer's setting is not read"
);
assert_eq!(
source.matches("let density = film_curve_pushed(").count(),
1,
"the film was developed more than once"
);
assert!(
!source.contains("let local_in"),
"the film was blended as a result"
);
}
#[test]
fn a_layers_film_without_a_stock_composes_to_nothing() {
// TRACES: FR-DEV-3f
// The offsets are kept — a stock chosen later brings them back — but
// with no film on the photograph there is nothing for them to offset.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PUSH, 1.0);
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(!source.contains("---- film_sim ----"), "{source}");
assert!(!source.contains("mask0_film_sim"));
}
#[test]
fn a_layer_with_no_adjustment_is_not_in_the_shader() {
let layer = MaskLayer::new("m1", regions(&[1]));
+105 -8
View File
@@ -307,6 +307,32 @@ pub trait Operation: Send + Sync {
/// shape it should take.
fn set_film_tables(&mut self, _tables: Option<&crate::ops::film_sim::FilmTables>) {}
/// TRACES: FR-DEV-3f
/// The stock's tables this operation holds, for a mask layer's copy of it.
///
/// The other half of [`Self::set_film_tables`]. A layer holds offsets,
/// never a stock of its own — the photograph is made on one film — so the
/// composer hands the global operation's tables to the layer's combined
/// copy through this pair.
fn film_tables(&self) -> Option<&crate::ops::film_sim::FilmTables> {
None
}
/// TRACES: FR-DEV-3 | FR-DEV-3f
/// Whether a mask layer's version of this operation is blended as
/// *settings* rather than as a result.
///
/// Default `false`: each layer's version runs on the same input as the
/// global one and the results are blended by weight, which is right for an
/// adjustment. An operation that answers `true` has every uniform linear
/// in what it controls, so the composer can blend the uniforms instead —
/// the weighted average of every overlapping layer's settings, the global
/// setting taking whatever weight the layers leave — and run the fragment
/// once. See `local_settings_block`.
fn blends_settings(&self) -> bool {
false
}
/// TRACES: FR-DEV-3e | FR-DEV-3f
/// Whether this operation *is* the rendering, rather than an adjustment to
/// one.
@@ -884,10 +910,11 @@ fn compose_inner(
let prefix = sanitise(id);
let mut fragment = String::new();
let mut op_uniforms = Vec::new();
if op.is_active() {
// Each op's uniforms are prefixed, so two operations may both
// declare a field called `amount` without colliding.
let op_uniforms = op.uniforms();
op_uniforms = op.uniforms();
if !op_uniforms.is_empty() {
let _ = writeln!(uniform_fields, " // {id}");
}
@@ -901,16 +928,25 @@ fn compose_inner(
helpers.push(*h);
}
}
// Rewrite bare uniform names to their prefixed struct fields, so a
// fragment is written without knowing about any other operation.
fragment = op.wgsl_body();
for u in &op_uniforms {
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
}
}
let _ = writeln!(body, "\n // ---- {id} ----");
if op.blends_settings() && !local.is_empty() {
// TRACES: FR-DEV-3f
body.push_str(&local_settings_block(
&fragment,
&prefix,
&op_uniforms,
&local,
));
continue;
}
// Rewrite bare uniform names to their prefixed struct fields, so a
// fragment is written without knowing about any other operation.
for u in &op_uniforms {
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
}
body.push_str(&local_block(&fragment, &local));
}
@@ -1283,6 +1319,65 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
}
}
/// TRACES: FR-DEV-3f
/// One operation's block when its layers are blended as *settings*: every
/// uniform the weighted average of the global value and each overlapping
/// layer's, and then the fragment once.
///
/// The weights: each layer's mask weight `w_i`, and the global setting
/// whatever the layers leave, `w_g = max(0, 1 − Σ w_i)`. So
///
/// ```text
/// p = (w_g·p_g + Σ w_i·p_i) / (w_g + Σ w_i)
/// ```
///
/// — one layer at weight `w` is `(1 − w)·p_g + w·p_1`, exactly the blend a
/// layer has always had, and three layers overlapping at full weight are the
/// plain mean of their three settings rather than one piled on another. A
/// layer's `p_i` is its combined setting (the global one plus its offset), so
/// the average is of what each layer *asks for*.
///
/// Only layers that move this operation take part. One that left it alone is
/// not voting for the global setting; it is not voting.
///
/// `fragment` is the operation's body with bare uniform names, as
/// `wgsl_body` wrote it: they are rewritten here to the blended values.
fn local_settings_block(
fragment: &str,
prefix: &str,
uniforms: &[Uniform],
local: &[&crate::mask::LocalOp],
) -> String {
let mut out = String::new();
let _ = writeln!(out, " {{");
let weights: Vec<String> = local.iter().map(|l| format!("mask_w{}", l.slot)).collect();
let _ = writeln!(out, " let set_w = {};", weights.join(" + "));
let _ = writeln!(out, " let set_g = max(1.0 - set_w, 0.0);");
// Never zero: the global weight is one wherever no layer reaches.
let _ = writeln!(out, " let set_n = max(set_g + set_w, 1e-6);");
let mut body = fragment.to_string();
for u in uniforms {
let mut sum = format!("set_g * u.{prefix}_{}", u.name);
for l in local {
let _ = write!(
sum,
" + mask_w{slot} * u.mask{slot}_{prefix}_{name}",
slot = l.slot,
name = u.name
);
}
let _ = writeln!(out, " let set_{} = ({sum}) / set_n;", u.name);
body = rewrite_uniform(&body, u.name, &format!("set_{}", u.name));
}
let _ = writeln!(out, " {{");
for line in body.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
out
}
/// One operation's block: its global fragment, and each layer's version of it
/// blended in by that layer's weight.
///
@@ -2158,7 +2253,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0,
lut_size: 32,
grain_particles: [0.0; 3],
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3],
grain_uniformity: 0.97,
}));
+234 -91
View File
@@ -22,9 +22,11 @@
//!
//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they
//! are measurements of a physical thing, not something a slider moves. The
//! sliders here are exposure and print exposure, which are what a photographer
//! and a printer actually control. `dr-film` turns a stock plus those two
//! numbers into [`FilmTables`]; this node knows only the layout.
//! sliders here are exposure, push, print exposure and format, which are what
//! a photographer and a printer actually control. `dr-film` turns a stock into
//! [`FilmTables`] that hold none of them; the shader applies all four per
//! pixel, which is what lets a mask layer hold its own (see
//! [`Operation::blends_settings`]). This node knows only the layout.
//!
//! Declared as a plain struct here rather than imported, so that dr-pipeline
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
@@ -63,6 +65,15 @@ static FORMATS: [LocalizedKey; 6] = [
/// take; [`FilmTables::is_well_formed`] is what stops the two drifting.
pub const CURVE_SAMPLES: usize = 256;
/// The most development times a stock may measure — a curve row and a push
/// station each. Must agree with `dr_film::bake::MAX_CURVE_ROWS`, for the
/// reason [`CURVE_SAMPLES`] must; the uniform block holds this many stations.
pub const MAX_CURVE_ROWS: usize = 8;
/// How many frames [`FORMATS`] offers, and so how many grain counts a stock
/// carries.
pub const FORMAT_COUNT: usize = 6;
/// The uniform field names the fragment reads the exposure matrix from.
///
/// A table rather than a formatted string, because a `Uniform`'s name is
@@ -74,6 +85,24 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
["m20", "m21", "m22"],
];
/// Grains per pixel, per format and layer: `gn{format}{layer}`.
static GRAIN_FIELDS: [[&str; 3]; FORMAT_COUNT] = [
["gn00", "gn01", "gn02"],
["gn10", "gn11", "gn12"],
["gn20", "gn21", "gn22"],
["gn30", "gn31", "gn32"],
["gn40", "gn41", "gn42"],
["gn50", "gn51", "gn52"],
];
/// The push each curve row was developed to, padded with the last.
static PUSH_FIELDS: [&str; MAX_CURVE_ROWS] =
["ps0", "ps1", "ps2", "ps3", "ps4", "ps5", "ps6", "ps7"];
/// Which format this is, one-hot. See [`FilmSim::uniforms`] for why a choice
/// reaches the shader as six weights rather than an index.
static FORMAT_FIELDS: [&str; FORMAT_COUNT] = ["fmt0", "fmt1", "fmt2", "fmt3", "fmt4", "fmt5"];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied
@@ -115,48 +144,86 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
/// Layout is the contract between the two crates, so it is written down here
/// and checked rather than assumed:
///
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel `c`.
/// - `curves` — `CURVE_SAMPLES` density triples, uniform over
/// `[curve_log_min, curve_log_max]`.
/// - `lut` — `lut_size³` linear sRGB triples, uniform over `[0, density_max]`
/// on each axis, with the **red axis varying fastest**: index
/// `(b * size + g) * size + r`. That is the order a 3D texture upload
/// expects, so the consumer hands the slice straight to the driver. Filling
/// it the other way round transposes red and blue in the finished picture —
/// which is a plausible photograph of the wrong colour, and which the unit
/// tests on both sides of this seam happily pass, because each side is
/// internally consistent. `dr-film` pins it; `dr-gpu`'s `film_sim` test
/// catches it end to end.
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel
/// `c`, at unit gain: camera exposure is a per-pixel setting.
/// - `curves` — one row of `CURVE_SAMPLES` density triples per
/// `push_stations` entry, uniform over `[curve_log_min, curve_log_max]`,
/// and then, when printed, one more row: the paper's, uniform over
/// `[paper.log_min, paper.log_max]`.
/// - `lut` — `lut_size³` triples uniform over `[0, density_max]` on each
/// axis, with the **red axis varying fastest**: index
/// `(b * size + g) * size + r`. Linear sRGB when the film is viewed
/// directly; the paper's log₁₀ exposure through the negative when it is
/// printed, followed by a second cube, paper density over
/// `[0, paper.density_max]` to linear sRGB. That is the order a 3D texture
/// upload expects with the cubes stacked in depth, so the consumer hands the
/// slice straight to the driver. Filling it the other way round transposes
/// red and blue in the finished picture — which is a plausible photograph
/// of the wrong colour, and which the unit tests on both sides of this seam
/// happily pass, because each side is internally consistent. `dr-film` pins
/// it; `dr-gpu`'s `film_sim` test catches it end to end.
///
/// Everything the sliders move — exposure, push, print exposure, format — is
/// absent. They are per-pixel settings the shader applies against these
/// tables, which is what lets a mask layer hold its own.
#[derive(Debug, Clone, PartialEq)]
pub struct FilmTables {
pub exposure_matrix: [[f32; 3]; 3],
pub curves: Vec<[f32; 3]>,
/// The push each film row was developed to, ascending: one entry for a
/// stock measured at a single process.
pub push_stations: Vec<f32>,
pub curve_log_min: f32,
pub curve_log_max: f32,
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
pub lut_size: usize,
/// The print, for a negative printed on paper.
pub paper: Option<PaperTables>,
/// TRACES: FR-DEV-3f
/// Grains in one pixel's patch of film, per layer, with the density
/// ceiling and uniformity the variance is taken against. Zero particles
/// means no grain, which is how the control is turned off.
pub grain_particles: [f32; 3],
/// Grains in one pixel's patch of film, per format and then per layer,
/// with the density ceiling and uniformity the variance is taken against.
/// Zero particles means no grain, which is how the control is turned off.
pub grain_particles: [[f32; 3]; FORMAT_COUNT],
pub grain_density_max: [f32; 3],
pub grain_uniformity: f32,
}
/// The print half of [`FilmTables`]: where the paper's row and cube are read.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaperTables {
/// The enlarger's filtration, per layer, in log₁₀ exposure.
pub balance: [f32; 3],
pub log_min: f32,
pub log_max: f32,
pub density_max: f32,
}
impl FilmTables {
/// Film rows, not counting the paper's.
pub fn curve_rows(&self) -> usize {
self.push_stations.len()
}
/// Whether these tables are the shape the shader will index them at.
///
/// Checked on the way in, because the failure otherwise is a shader
/// sampling past the end of a texture: undefined, silent, and different on
/// every driver.
pub fn is_well_formed(&self) -> bool {
self.curves.len() == CURVE_SAMPLES
let rows = self.curve_rows();
let printed = usize::from(self.paper.is_some());
let paper_ok = self
.paper
.is_none_or(|p| p.density_max > 0.0 && p.log_max > p.log_min);
(1..=MAX_CURVE_ROWS).contains(&rows)
&& self.push_stations.windows(2).all(|w| w[0] < w[1])
&& self.curves.len() == CURVE_SAMPLES * (rows + printed)
&& self.lut_size >= 2
&& self.lut.len() == self.lut_size.pow(3)
&& self.lut.len() == self.lut_size.pow(3) * (1 + printed)
&& self.density_max > 0.0
&& self.curve_log_max > self.curve_log_min
&& paper_ok
}
}
@@ -246,60 +313,80 @@ impl Operation for FilmSim {
self.set_tables(tables.cloned());
}
fn film_tables(&self) -> Option<&FilmTables> {
self.tables.as_ref()
}
/// TRACES: FR-DEV-3f
/// A layer's film is its settings, not its own picture blended over the
/// global one.
///
/// Blending outputs would be a photograph developed twice and cross-faded;
/// a region on a pushed film is not that. Every uniform below is linear
/// in what it controls, so the composer can take each layer's weighted
/// average of them and develop the pixel once.
fn blends_settings(&self) -> bool {
true
}
/// Every value here is linear in what the shader does with it, which is
/// what [`Self::blends_settings`] rests on. The format is the one that
/// needs arranging: an index averaged between layers is a format nobody
/// chose, so it goes out one-hot and the shader mixes the six grain
/// counts by it — two layers on 35 mm and 6x7 meet at the average grain.
fn uniforms(&self) -> Vec<Uniform> {
let Some(t) = &self.tables else {
return Vec::new();
};
let m = t.exposure_matrix;
// Exposure rides in the matrix on the CPU when the stock is baked, so
// what is left here is the *shader's* copy of the same nine numbers.
// Spelled out one at a time because a uniform is a named `f32` in this
// pipeline and a matrix would be a second kind of thing for one caller.
let mut out = Vec::with_capacity(MATRIX_FIELDS.len() + 5);
for (l, row) in m.iter().enumerate() {
let mut out = Vec::with_capacity(64);
let mut push = |name: &'static str, value: f32| out.push(Uniform { name, value });
for (l, row) in t.exposure_matrix.iter().enumerate() {
for (c, v) in row.iter().enumerate() {
out.push(Uniform {
name: MATRIX_FIELDS[l][c],
value: *v,
});
push(MATRIX_FIELDS[l][c], *v);
}
}
for (l, name) in ["gn0", "gn1", "gn2"].into_iter().enumerate() {
out.push(Uniform {
name,
value: t.grain_particles[l],
});
for (f, per_layer) in t.grain_particles.iter().enumerate() {
for (l, v) in per_layer.iter().enumerate() {
push(GRAIN_FIELDS[f][l], *v);
}
}
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
out.push(Uniform {
name,
value: t.grain_density_max[l],
});
push(name, t.grain_density_max[l]);
}
out.push(Uniform {
name: "grain_u",
value: t.grain_uniformity,
});
out.push(Uniform {
name: "log_min",
value: t.curve_log_min,
});
out.push(Uniform {
name: "log_max",
value: t.curve_log_max,
});
out.push(Uniform {
name: "density_max",
value: t.density_max,
});
out.push(Uniform {
name: "lut_size",
value: t.lut_size as f32,
});
out.push(Uniform {
name: "print_exposure",
value: self.print_exposure,
push("grain_u", t.grain_uniformity);
push("log_min", t.curve_log_min);
push("log_max", t.curve_log_max);
push("density_max", t.density_max);
push("lut_size", t.lut_size as f32);
let last = *t.push_stations.last().unwrap_or(&0.0);
for (i, name) in PUSH_FIELDS.into_iter().enumerate() {
push(name, t.push_stations.get(i).copied().unwrap_or(last));
}
push("rows", t.curve_rows() as f32);
let paper = t.paper.unwrap_or(PaperTables {
balance: [0.0; 3],
log_min: 0.0,
log_max: 1.0,
density_max: 1.0,
});
push("printed", if t.paper.is_some() { 1.0 } else { 0.0 });
for (l, name) in ["pb0", "pb1", "pb2"].into_iter().enumerate() {
push(name, paper.balance[l]);
}
push("plog_min", paper.log_min);
push("plog_max", paper.log_max);
push("pdmax", paper.density_max);
// The sliders.
push("ev", self.exposure);
push("push", self.push);
push("pev", self.print_exposure);
let chosen = (self.format.max(0.0).round() as usize).min(FORMAT_COUNT - 1);
for (f, name) in FORMAT_FIELDS.into_iter().enumerate() {
push(name, if f == chosen { 1.0 } else { 0.0 });
}
out
}
@@ -321,8 +408,9 @@ let scene = vec3<f32>(
// What each emulsion layer was exposed to. A matrix, exactly: the scene
// spectrum reconstructed from an sRGB triple is linear in that triple, so the
// integral over wavelength collapsed into these nine numbers when the stock
// was baked.
let exposure = vec3<f32>(
// was baked. The camera's exposure is a gain on it, applied here rather than
// baked in so that a layer can hold its own.
let exposure = exp2(ev) * vec3<f32>(
dot(vec3<f32>(m00, m01, m02), scene),
dot(vec3<f32>(m10, m11, m12), scene),
dot(vec3<f32>(m20, m21, m22), scene),
@@ -331,12 +419,16 @@ let exposure = vec3<f32>(
// the curve, and the toe is where it belongs.
let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10);
// The characteristic curve: what density each layer develops to. Clamped, not
// extrapolated — past the shoulder a real emulsion stops responding, and
// extrapolating would turn a blown highlight into a colour cast that grows the
// more it is overexposed.
let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
vec3<f32>(0.0), vec3<f32>(1.0)));
// The characteristic curve: what density each layer develops to, at this
// pixel's push. Clamped, not extrapolated — past the shoulder a real emulsion
// stops responding, and extrapolating would turn a blown highlight into a
// colour cast that grows the more it is overexposed.
let density = film_curve_pushed(
clamp((log_exposure - log_min) / (log_max - log_min), vec3<f32>(0.0), vec3<f32>(1.0)),
push,
array<f32, 8>(ps0, ps1, ps2, ps3, ps4, ps5, ps6, ps7),
u32(rows),
);
// TRACES: FR-DEV-3f
// Grain, on the density and before the dye.
@@ -346,16 +438,40 @@ let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
// through whatever density resulted. Adding noise to the finished colour --
// which is what an effect does -- tints the highlights wrong, because that
// noise never passes through the dye at all.
let grained = film_grain(density, source_px,
vec3<f32>(gn0, gn1, gn2),
//
// The format's grain count, mixed by the one-hot weights: exactly one format's
// on the whole photograph, and the weighted average under overlapping layers.
let particles = fmt0 * vec3<f32>(gn00, gn01, gn02)
+ fmt1 * vec3<f32>(gn10, gn11, gn12)
+ fmt2 * vec3<f32>(gn20, gn21, gn22)
+ fmt3 * vec3<f32>(gn30, gn31, gn32)
+ fmt4 * vec3<f32>(gn40, gn41, gn42)
+ fmt5 * vec3<f32>(gn50, gn51, gn52);
let grained = film_grain(density, source_px, particles,
vec3<f32>(gd0, gd1, gd2),
grain_u);
// Dye absorption, the print through the negative, the paper, the viewing
// illuminant and the chromatic adaptation — all of which take exactly three
// numbers in, which is why they fit in one lookup.
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);"
.into()
// Dye absorption through to what comes next — all of it takes exactly three
// numbers in, which is why it fits in one lookup. Viewed directly, that is
// the picture; printed, it is the light the paper receives through the
// negative, in log exposure.
let through = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, 0);
if (printed > 0.5) {
// The enlarger: its filtration, and then its exposure, the same stops on
// every layer — which is why print exposure is an addition here and not
// a table, and so exact at any setting.
let paper_log = through + vec3<f32>(pb0, pb1, pb2) + pev * 0.30103;
let paper_density = film_curve(
clamp((paper_log - plog_min) / (plog_max - plog_min), vec3<f32>(0.0), vec3<f32>(1.0)),
u32(rows),
);
c = film_lut(clamp(paper_density / pdmax, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, i32(lut_size));
} else {
c = through;
}"
.into()
}
fn helpers(&self) -> &'static [crate::operation::Helper] {
@@ -363,7 +479,7 @@ c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_s
}
}
static HELPERS: [crate::operation::Helper; 5] = [
static HELPERS: [crate::operation::Helper; 6] = [
crate::operation::Helper {
name: "film_hash",
source: "\
@@ -453,9 +569,9 @@ fn log10(v: vec3<f32>) -> vec3<f32> {
crate::operation::Helper {
name: "film_curve",
source: "\
// Three characteristic curves, sampled from a 256-wide texture and
// interpolated by hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>) -> vec3<f32> {
// Three characteristic curves, one row of a 256-wide texture, interpolated by
// hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>, row: u32) -> vec3<f32> {
let samples = u32(textureDimensions(film_curves).x);
let last = f32(samples - 1u);
var out = vec3<f32>(0.0);
@@ -463,20 +579,45 @@ fn film_curve(t: vec3<f32>) -> vec3<f32> {
let x = t[ch] * last;
let i = min(u32(floor(x)), samples - 2u);
let f = x - f32(i);
let a = textureLoad(film_curves, vec2<i32>(i32(i), 0), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, 0), 0);
let a = textureLoad(film_curves, vec2<i32>(i32(i), i32(row)), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, i32(row)), 0);
out[ch] = mix(a[ch], b[ch], f);
}
return out;
}",
},
crate::operation::Helper {
name: "film_curve_pushed",
source: "\
// The curves at a push between two measured processes. Development is
// interpolated in log time and push *is* log time, so a straight line between
// the neighbouring rows is the stock's own interpolation, not an estimate of
// it. Clamped to the first and last process, as the stock is.
fn film_curve_pushed(t: vec3<f32>, push: f32, stations: array<f32, 8>, rows: u32) -> vec3<f32> {
if (rows < 2u) {
return film_curve(t, 0u);
}
var at = stations;
var hi = rows - 1u;
for (var i = 1u; i < rows; i = i + 1u) {
if (at[i] >= push) {
hi = i;
break;
}
}
let lo = hi - 1u;
let f = clamp((push - at[lo]) / max(at[hi] - at[lo], 1e-6), 0.0, 1.0);
return mix(film_curve(t, lo), film_curve(t, hi), f);
}",
},
crate::operation::Helper {
name: "film_lut",
source: "\
// Trilinear interpolation of the density lookup, by hand for the same reason
// the curve above is: there is no sampler bound, and the eight loads are
// cache-neighbours.
fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
// Trilinear interpolation of one cube of the lookup, by hand for the same
// reason the curve above is: there is no sampler bound, and the eight loads
// are cache-neighbours. `z0` is where the cube starts in depth: the film's at
// zero, the paper's stacked after it.
fn film_lut(t: vec3<f32>, size: f32, z0: i32) -> vec3<f32> {
let n = i32(size);
let x = t * (size - 1.0);
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
@@ -489,7 +630,7 @@ fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
let wy = select(1.0 - f.y, f.y, dy == 1);
for (var dz = 0; dz < 2; dz = dz + 1) {
let wz = select(1.0 - f.z, f.z, dz == 1);
let p = base + vec3<i32>(dx, dy, dz);
let p = base + vec3<i32>(dx, dy, dz + z0);
out = out + wx * wy * wz
* textureLoad(film_lut_texture, p, 0).rgb;
}
@@ -513,7 +654,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0,
lut_size: 32,
grain_particles: [0.0; 3],
grain_particles: [[0.0; 3]; FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3],
grain_uniformity: 0.97,
}
+1 -1
View File
@@ -82,7 +82,7 @@ pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve;
pub use dehaze::Dehaze;
pub use distortion::Distortion;
pub use film_sim::{FilmSim, FilmTables};
pub use film_sim::{FilmSim, FilmTables, PaperTables};
// Clarity and texture are one implementation at two scales; see the module's
// documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture};
+3 -1
View File
@@ -1280,7 +1280,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
+3 -1
View File
@@ -2023,7 +2023,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
+3 -1
View File
@@ -235,7 +235,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
lut_size: 2,
grain_particles: [0.0; 3],
grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
+9
View File
@@ -434,6 +434,15 @@ reference implementation.
parameter — `core/dr-pipeline/src/sidecar.rs` records why an index was rejected (installing a
profile would silently change which film every existing photograph was developed on).
*Resolved 2026-09-26:* the film's settings — exposure, push, print exposure, format — are
**per-pixel**, evaluated by the shader against tables that hold none of them, so a mask layer can
hold its own. A layer's settings are offsets to the photograph's, and where layers overlap a pixel
takes the weighted average of what each asks for, the photograph's setting taking whatever weight
the layers leave (`operation::local_settings_block`). The stock and its paper stay
photograph-wide: a layer has no picker. The print is split at the paper's log exposure, so print
exposure is an addition between two lookups and exact at any setting; push interpolates the
stock's measured processes. Before this a layer offered the film's sliders and they moved nothing.
**FR-DEV-3g — AI denoise.** Learned denoising operating in the raw domain, ideally jointly with
demosaic.
File diff suppressed because one or more lines are too long
+6
View File
@@ -268,6 +268,12 @@ sensor does not. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the
black-and-white stocks at its end.
The film's sliders work on a mask as they do on the whole photograph, the way
a printer dodges and burns: in a layer, `Print exposure` darkens or lightens
that region of the print, `Push` develops it further, and the stock stays the
one the photograph was made on. Where layers overlap, the photograph takes the
average of what they ask for.
![Opening the film list, scrolling it, choosing Velvia, then holding Before](media/film.gif)
### History, snapshots, presets
+5
View File
@@ -327,6 +327,11 @@ named stock; below it, the print exposure and push controls a film has and a
sensor does not. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
black-and-white stocks at its end.</p>
<p>The film's sliders work on a mask as they do on the whole photograph, the way
a printer dodges and burns: in a layer, <code>Print exposure</code> darkens or lightens
that region of the print, <code>Push</code> develops it further, and the stock stays the
one the photograph was made on. Where layers overlap, the photograph takes the
average of what they ask for.</p>
<figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure>
<h3 id="history-snapshots-presets">History, snapshots, presets</h3>
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
+204 -76
View File
@@ -749,77 +749,103 @@ impl DevelopSession {
None
};
// TRACES: FR-DEV-3f
// Grain, at the scale this photograph is being sampled at.
//
// A digital frame has no film format, so simulating one means choosing
// what it *would have been* — 35 mm, because that is the format every
// published granularity figure and every intuition about how grainy a
// stock looks comes from. The sensor's width in pixels then says how
// much film one pixel covers, and the grain model needs nothing else
// to be correct at any zoom.
// TRACES: FR-DEV-3f
// The frame this is being simulated on, against the pixels it is being
// rendered to: together they are the enlargement, and the enlargement
// is what decides how grainy the result looks. A crystal is a fixed
// size in micrometres — the same emulsion on a sheet averages far more
// of them into each pixel than it does on 35 mm.
let format = dr_film::Format::from_index(
self.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::FORMAT,
)
.unwrap_or(0.0)
.max(0.0) as usize,
);
let (source_width, _) = self.demosaiced.size();
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um);
// The photograph's exposure, which is where the enlarger is balanced.
// Nothing else a slider moves is baked: push, print exposure and the
// format are per-pixel settings the shader applies, so that a mask
// layer can hold its own.
let exposure_ev = self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::EXPOSURE,
)
.unwrap_or(0.0);
let baked = dr_film::bake(&dr_film::Recipe {
film: profile,
print: paper,
exposure_ev: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::EXPOSURE,
)
.unwrap_or(0.0),
push_stops: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PUSH,
)
.unwrap_or(0.0),
print_exposure_ev: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PRINT_EXPOSURE,
)
.unwrap_or(0.0),
// Only the balance moves when the same stock and paper are chosen
// again — which is what a moved exposure does — and it is three
// numbers against a bake of two spectral lookups.
let same = self.graph.film().filter(|f| {
f.stock == profile.stock && f.print.as_deref() == paper.map(|p| p.stock.as_str())
});
let tables = match same {
Some(f) => {
let mut tables = f.tables.clone();
if let (Some(held), Some(paper)) = (tables.paper.as_mut(), paper) {
held.balance = dr_film::bake::print_balance(profile, paper, exposure_ev);
}
tables
}
None => self.bake_film(profile, paper, exposure_ev),
};
self.set_film(Some(dr_pipeline::graph::Film {
stock: profile.stock.clone(),
print: paper.map(|p| p.stock.clone()),
tables: dr_pipeline::ops::FilmTables {
exposure_matrix: baked.exposure_matrix,
curves: baked.curves,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
grain_particles: grain.particles,
grain_density_max: grain.density_max,
grain_uniformity: grain.uniformity,
},
tables,
}));
}
/// TRACES: FR-DEV-3f
/// A stock and its paper as the shader binds them.
///
/// The paper, when there is one, rides behind the film: its curve as one
/// more row, its lookup stacked after the film's. See `FilmTables`.
fn bake_film(
&self,
profile: &dr_film::Profile,
paper: Option<&dr_film::Profile>,
exposure_ev: f32,
) -> dr_pipeline::ops::FilmTables {
// TRACES: FR-DEV-3f
// Grain, at the scale this photograph is being sampled at, on every
// frame it could be simulated on.
//
// A digital frame has no film format, so simulating one means choosing
// what it *would have been*. A crystal is a fixed size in micrometres,
// so the sensor's width in pixels against the frame's width says how
// much film one pixel covers — and the same emulsion on a sheet
// averages far more of them into each pixel than it does on 35 mm.
// All six are baked because the format is a per-pixel setting: a
// layer may be on another one.
let (source_width, _) = self.demosaiced.size();
let grains = dr_film::Format::ALL.map(|format| {
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
dr_film::Grain::for_pixel_size(profile, pixel_size_um)
});
let baked = dr_film::bake(&dr_film::Recipe {
film: profile,
print: paper,
exposure_ev,
});
let mut curves = baked.curves;
let mut lut = baked.lut;
let paper = baked.paper.map(|p| {
curves.extend_from_slice(&p.curves);
lut.extend_from_slice(&p.lut);
dr_pipeline::ops::PaperTables {
balance: p.balance,
log_min: p.log_min,
log_max: p.log_max,
density_max: p.density_max,
}
});
dr_pipeline::ops::FilmTables {
exposure_matrix: baked.exposure_matrix,
curves,
push_stations: baked.push_stations,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
paper,
grain_particles: grains.map(|g| g.particles),
grain_density_max: grains[0].density_max,
grain_uniformity: grains[0].uniformity,
}
}
/// The stock and paper currently chosen, by id.
pub fn film(&self) -> Option<(&str, bool)> {
self.graph
@@ -827,33 +853,51 @@ impl DevelopSession {
.map(|f| (f.stock.as_str(), f.print.is_some()))
}
/// Re-bake if `op_index` names the film, and do nothing otherwise.
/// TRACES: FR-DEV-3f
/// Re-balance the enlarger if `op_index`/`param_index` name the
/// photograph's film exposure, and do nothing otherwise.
///
/// `op_index` counts over [`Self::scoped_capabilities`] — the same list
/// [`Self::lookup`] resolves a slider through — so that is the only list to
/// ask. An earlier version also indexed `rows()`, which is one entry per
/// *parameter* and filtered by the active tab: past its end the check
/// The one film slider the tables depend on: the print balance is solved
/// against the photograph's exposure, as an enlarger's filtration is set
/// for the negative in front of it. Everything else the film's sliders
/// move — push, print exposure, format, and exposure on a mask layer — is
/// a per-pixel setting the shader reads, and needs nothing from here.
///
/// Both indices count over [`Self::scoped_capabilities`] — the same list
/// [`Self::lookup`] resolves a slider through — so that is the only list
/// to ask. An earlier version also indexed `rows()`, which is one entry
/// per *parameter* and filtered by the active tab: past its end the check
/// short-circuited, the tables were never rebuilt, and the film's own
/// sliders moved nothing at all.
///
/// The test is here rather than at the call site so the callback in
/// `lib.rs` goes on naming no operation, which is the rule the whole panel
/// is built on (ARCH §4.3a).
pub fn rebake_film_if_affected(&mut self, op_index: i32) {
let is_film = usize::try_from(op_index)
pub fn rebake_film_if_affected(&mut self, op_index: i32, param_index: i32) {
if self.active_layer().is_some() {
return;
}
let caps = self.scoped_capabilities();
let Some(op) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return;
};
let param = usize::try_from(param_index)
.ok()
.and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id))
.is_some_and(|id| id == dr_pipeline::ops::film_sim::ID);
if is_film {
.and_then(|i| op.params.get(i))
.map(|p| p.id);
if op.id == dr_pipeline::ops::film_sim::ID
&& param == Some(dr_pipeline::ops::film_sim::EXPOSURE)
&& self.graph.film().is_some_and(|f| f.print.is_some())
{
self.rebake_film();
}
}
/// The stock, the paper, and how far it was developed.
/// The stock and the paper again, at the photograph's current exposure.
///
/// Push rides with the other two through every path that re-bakes, because
/// it is the same kind of fact: a decision about the material rather than
/// an adjustment to the picture it produced.
/// Only the enlarger's balance changes when they are the same stock and
/// paper, so this is cheap on the path a slider takes; see
/// [`Self::choose_film`].
pub fn rebake_film(&mut self) {
if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) {
self.choose_film(Some(&stock), print);
@@ -1249,6 +1293,90 @@ mod tests {
}
}
/// TRACES: FR-DEV-3f
/// A mask layer offers the film's settings and not the stock.
///
/// The layer holds offsets to the photograph's film, which the shader
/// blends per pixel, so its sliders belong on it. The stock does not: it
/// is what the whole photograph was made on, and a picker in the layer's
/// panel would change it for every pixel.
#[test]
fn a_mask_layer_offers_film_settings_but_not_a_stock() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.set_active_tab(-1);
assert!(
session.film_in_group(),
"the photograph has a stock to pick"
);
let id = session.add_gradient_mask(true).expect("radial");
session.set_active_mask(Some(&id));
assert!(!session.film_in_group(), "a layer has no stock of its own");
assert!(
session
.scoped_capabilities()
.iter()
.any(|c| c.id == dr_pipeline::ops::film_sim::ID),
"but it has the film's settings"
);
session.set_active_mask(None);
assert!(
session.film_in_group(),
"and the stock returns with the photograph"
);
}
/// TRACES: FR-DEV-3f
/// Only the photograph's exposure touches the tables, and only the
/// balance in them.
///
/// Everything else a film slider moves is a per-pixel setting. Re-baking
/// for those would cost two spectral lookups per tick of a slider for
/// nothing, and re-baking for a layer's exposure would rebalance the whole
/// print around one region.
#[test]
fn a_film_slider_rebalances_only_where_the_balance_depends_on_it() {
use dr_pipeline::ops::film_sim;
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.set_active_tab(-1);
session.choose_film(Some("kodak_portra_400"), true);
let balance = |s: &DevelopSession| {
s.graph
.film()
.and_then(|f| f.tables.paper)
.map(|p| p.balance)
};
let before = balance(&session).expect("a printed negative has a balance");
let caps = session.scoped_capabilities();
let op = caps
.iter()
.position(|c| c.id == film_sim::ID)
.expect("film");
let param = |id| caps[op].params.iter().position(|p| p.id == id).unwrap() as i32;
session.graph.set_param(film_sim::ID, film_sim::PUSH, 1.0);
session.rebake_film_if_affected(op as i32, param(film_sim::PUSH));
assert_eq!(
balance(&session),
Some(before),
"push is not the enlarger's business"
);
session
.graph
.set_param(film_sim::ID, film_sim::EXPOSURE, 1.0);
session.rebake_film_if_affected(op as i32, param(film_sim::EXPOSURE));
assert_ne!(
balance(&session),
Some(before),
"the balance follows the exposure"
);
}
/// A frame black on the left half and white on the right, at `size`
/// square. Both ends of the histogram are occupied and both clipping
/// counters are non-zero, and cropping to one half leaves exactly one of
+8
View File
@@ -90,7 +90,15 @@ impl DevelopSession {
/// the operation — what is this control *about* — and the panel is not
/// allowed to know. It asks the descriptor, so a stock that were ever
/// re-declared as something other than an effect would move on its own.
///
/// Never on a mask layer. A layer holds the film's *settings* — a region
/// pushed further, or burned in under the enlarger — but the stock is what
/// the whole photograph was made on, and a picker in a layer's panel would
/// change it for every pixel while looking as though it changed some.
pub fn film_in_group(&self) -> bool {
if self.active_layer().is_some() {
return false;
}
let Some(active) = self.active_tab else {
// "All" shows everything, the stock included.
return true;
+6 -7
View File
@@ -445,12 +445,11 @@ fn wire_adjustments(window: &AppWindow, w: &DevelopWiring) {
if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value);
// TRACES: FR-DEV-3f
// The film's own exposures ride *inside* the baked tables
// rather than arriving as uniforms, because the print balance
// is solved against them — an enlarger's filtration depends on
// how the negative was exposed. So moving one has to rebuild
// the lookup, which no other slider in the panel does.
s.rebake_film_if_affected(op);
// The print balance is solved against the photograph's
// exposure — an enlarger's filtration depends on how the
// negative was exposed — so moving it re-solves the
// balance, which no other slider in the panel does.
s.rebake_film_if_affected(op, param);
}
sync_rows(&w, &rows, &session);
redraw(&w);
@@ -506,7 +505,7 @@ fn wire_adjustments(window: &AppWindow, w: &DevelopWiring) {
(row.value + direction.signum() as f32 * step).clamp(row.minimum, row.maximum);
if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value);
s.rebake_film_if_affected(op);
s.rebake_film_if_affected(op, param);
}
sync_rows(&w, &rows, &session);
redraw(&w);