Make the view transform an operation the photographer can set
FR-DEV-3j gives the view transform two controls, contrast and the white point in stops above middle grey, persisted and held per mask layer like any other setting. A scene-referred pipeline whose white point cannot be moved hands the photographer a shoulder they cannot place. `view_transform` is a hand-written node in `Stage::View`, a new stage the composer emits last and emits whatever the node's state: a neutral operation is otherwise left out of the shader, but a photograph with no view transform is a scan. "Active" keeps meaning "moved from the defaults", so an untouched photograph writes nothing for it and `every_node_starts_neutral` still holds. A caller whose chain holds no view operation gets a default one. The composer's loop becomes an ordered list of steps — camera nodes, the matrix, scene nodes, layer-only nodes, the view — so each is emitted in exactly one place. The base curve could not be a node because it belonged to the camera; the ops README records why that argument went with it (D19). The panel shows it in the Light group as "Tone Mapping". Tests that counted the blocks of a neutral graph now count one, the view transform, and the two chain-wide tests that load a film expect the view transform to be absent, since a stock replaces it.
This commit is contained in:
@@ -74,6 +74,7 @@ pub mod distortion;
|
||||
pub mod film_sim;
|
||||
pub mod local_contrast;
|
||||
pub mod noise_reduction;
|
||||
pub mod view_transform;
|
||||
pub mod vignetting;
|
||||
|
||||
pub use aberration::Aberration;
|
||||
@@ -87,6 +88,7 @@ pub use film_sim::{FilmSim, FilmTables, PaperTables};
|
||||
// documentation for why that is two nodes and not one.
|
||||
pub use local_contrast::{Clarity, Texture};
|
||||
pub use noise_reduction::NoiseReduction;
|
||||
pub use view_transform::ViewTransform;
|
||||
pub use vignetting::Vignetting;
|
||||
|
||||
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! TRACES: FR-DEV-3j
|
||||
//! The view transform as an operation: the photographer's two numbers for
|
||||
//! the curve [`crate::view`] defines.
|
||||
//!
|
||||
//! # Why it is a node now, when the base curve could not be
|
||||
//!
|
||||
//! The base curve was kept out of the chain for reasons that were all about
|
||||
//! the *body*: it was looked up by camera model, so as a node it would have
|
||||
//! carried one camera's rendering onto another camera's file through a shared
|
||||
//! sidecar, shown a dead slider on an unprofiled body, and opened a profiled
|
||||
//! one reporting itself modified. D19 removed the premise. There is one view
|
||||
//! transform for every body, so its settings are a decision about the picture
|
||||
//! like any other, and they belong in the sidecar, the history and a mask
|
||||
//! layer.
|
||||
//!
|
||||
//! # Why it is composed at its defaults
|
||||
//!
|
||||
//! A neutral operation is normally left out of the shader, and "active" means
|
||||
//! "moved from its defaults". Both stay true here — an untouched photograph
|
||||
//! writes no view transform parameters, and `every_node_starts_neutral` still
|
||||
//! holds — but the composer emits this node whatever its state, because a
|
||||
//! photograph with no view transform is a scan, not a picture. See
|
||||
//! [`crate::operation::Stage::View`].
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
};
|
||||
use crate::operation::{Helper, Operation, Stage, Uniform};
|
||||
use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHITE_RANGE};
|
||||
|
||||
pub const ID: OpId = OpId("view_transform");
|
||||
pub const CONTRAST: ParamId = ParamId("contrast");
|
||||
pub const WHITE: ParamId = ParamId("white");
|
||||
|
||||
static HELPERS: [Helper; 1] = [Helper {
|
||||
name: "view_sigmoid",
|
||||
source: crate::view::VIEW_SIGMOID_WGSL,
|
||||
}];
|
||||
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// Tone: it is the tone response of the whole picture, and the panel's
|
||||
// Light group is where a photographer looks for the white point.
|
||||
attributes: vec![Attribute::Tone],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.view_transform"),
|
||||
params: vec![
|
||||
ParamDescriptor::scalar(
|
||||
"contrast",
|
||||
"param.view_transform.contrast",
|
||||
CONTRAST_RANGE.0,
|
||||
CONTRAST_RANGE.1,
|
||||
DEFAULT_CONTRAST,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"white",
|
||||
"param.view_transform.white",
|
||||
WHITE_RANGE.0,
|
||||
WHITE_RANGE.1,
|
||||
DEFAULT_WHITE,
|
||||
Unit::Stops,
|
||||
Scale::Linear,
|
||||
1,
|
||||
),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ViewTransform {
|
||||
contrast: f32,
|
||||
white: f32,
|
||||
}
|
||||
|
||||
impl Default for ViewTransform {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
contrast: DEFAULT_CONTRAST,
|
||||
white: DEFAULT_WHITE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewTransform {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The curve these settings solve to.
|
||||
pub fn sigmoid(&self) -> Sigmoid {
|
||||
Sigmoid::new(self.contrast, self.white)
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for ViewTransform {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
CONTRAST => self.contrast = value,
|
||||
WHITE => self.white = value,
|
||||
_ => log::warn!("view_transform: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
CONTRAST => self.contrast,
|
||||
WHITE => self.white,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
|
||||
}
|
||||
|
||||
fn stage(&self) -> Stage {
|
||||
Stage::View
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
// Skipped for an already-rendered source: a JPEG is a display rendering
|
||||
// already, and rendering it again would compress it twice.
|
||||
if (!non_linear) {
|
||||
c = view_sigmoid(c, slope, inv_k, peak);
|
||||
}"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
let s = self.sigmoid();
|
||||
vec![
|
||||
Uniform {
|
||||
name: "slope",
|
||||
value: s.n,
|
||||
},
|
||||
Uniform {
|
||||
name: "inv_k",
|
||||
value: s.inv_k,
|
||||
},
|
||||
Uniform {
|
||||
name: "peak",
|
||||
value: s.w,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &[Helper] {
|
||||
&HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_starts_neutral_and_says_so() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// Neutral in the sense every other node is: nothing moved, so nothing
|
||||
// is written. It is still composed — see the module documentation.
|
||||
let op = ViewTransform::new();
|
||||
assert!(!op.is_active());
|
||||
assert_eq!(op.sigmoid(), Sigmoid::default_curve());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_either_slider_makes_it_active() {
|
||||
let mut op = ViewTransform::new();
|
||||
op.set_param(WHITE, 6.0);
|
||||
assert!(op.is_active());
|
||||
let mut op = ViewTransform::new();
|
||||
op.set_param(CONTRAST, 2.0);
|
||||
assert!(op.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniforms_are_the_solved_curve() {
|
||||
let mut op = ViewTransform::new();
|
||||
op.set_param(CONTRAST, 2.0);
|
||||
op.set_param(WHITE, 6.0);
|
||||
let s = Sigmoid::new(2.0, 6.0);
|
||||
let u = op.uniforms();
|
||||
assert_eq!(
|
||||
u.iter().map(|u| u.value).collect::<Vec<_>>(),
|
||||
vec![s.n, s.inv_k, s.w]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_descriptor_defaults_are_the_curve_defaults() {
|
||||
// The sidecar treats a value equal to the descriptor's default as
|
||||
// unedited; the two disagreeing would make every photograph open
|
||||
// reporting a view transform edit it never had.
|
||||
let d = ViewTransform::new().descriptor();
|
||||
assert_eq!(
|
||||
d.param(CONTRAST).expect("contrast").default,
|
||||
DEFAULT_CONTRAST
|
||||
);
|
||||
assert_eq!(d.param(WHITE).expect("white").default, DEFAULT_WHITE);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user