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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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]));
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user