Render raws through Camera Raw's tone by default, after baseline exposure

The rendering half of camera-profiles.md §11-§12 (D21). The view
transform gains a curve choice — Camera Raw (the default) or D19's
sigmoid. Camera Raw converts to linear ProPhoto, clips to [0, 1], runs
the curve on the largest and smallest channel and places the middle
one at its old fraction between them (RefBaselineRGBTone), and
converts back: hue kept, saturation raised where the curve is steep,
which is where Adobe Standard's look desaturated. White sets the input
scale (1 at its default, so sensor white is display white) and
contrast bends the input about grey (1 at its default).

The curve rides in the profile buffer after the tables: the profile's
own, else the ACR3 default, which the placeholder every profile-less
source binds also carries — so a CR2 with no .dcp still gets Camera
Raw's tone. Baseline exposure is a gain folded into the rendering
matrix at upload; RawImage::color_matrix stays the file's for the
merge's linear DNG.

camera_raw::apply_reference is the CPU statement; GPU tests hold the
shader to it on 256 colours and on greys against the ACR3 table. The
sigmoid's own tests now choose it explicitly.
This commit is contained in:
2026-10-03 14:22:47 -04:00
parent 38d414912c
commit db7593f3dc
9 changed files with 383 additions and 45 deletions
+68 -24
View File
@@ -51,10 +51,11 @@ pub const DEFAULT_LOOK: f32 = 100.0;
/// Twice the profile's look: Lightroom's *Amount* reaches the same.
pub const MAX_LOOK: f32 = 200.0;
/// Entries of the buffer's header: one `vec4` describing each table —
/// `(hue divisions, saturation divisions, value divisions, sRGB-encoded)`,
/// zero hue divisions meaning absent — before the entries themselves.
pub const HEADER_ENTRIES: usize = 2;
/// Entries of the buffer's header, before the entries themselves: one
/// `vec4` describing each table — `(hue divisions, saturation divisions,
/// value divisions, sRGB-encoded)`, zero hue divisions meaning absent — and
/// a third whose `.x` is the tone curve's length (camera-profiles.md §12).
pub const HEADER_ENTRIES: usize = 3;
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
@@ -80,7 +81,7 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
/// Linear sRGB (the working space) to linear ProPhoto, and back, row-major,
/// each row scaled to sum to one so that working white is ProPhoto white
/// exactly and a neutral reaches the tables with zero saturation.
fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
pub(crate) fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
@@ -117,7 +118,7 @@ fn invert(m: &[f32; 9]) -> Option<[f32; 9]> {
Some(inv.map(|v| v as f32))
}
fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
pub(crate) fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2])
}
@@ -127,19 +128,48 @@ fn wgsl_mat(m: &[f32; 9]) -> String {
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
}
static HELPERS: LazyLock<[Helper; 1]> = LazyLock::new(|| {
/// The working space to ProPhoto and back, as WGSL constants, and where
/// the profile buffer's sections begin. A helper of its own because the
/// view transform's Camera Raw curve needs it too, and helpers are emitted
/// once each, in the order first asked for.
pub(crate) static PROPHOTO_HELPER: LazyLock<Helper> = LazyLock::new(|| {
let (to, back) = working_prophoto();
let source = format!(
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{LOOKUP_WGSL}",
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{SECTIONS_WGSL}",
wgsl_mat(to),
wgsl_mat(back)
);
[Helper {
name: "profile_apply",
Helper {
name: "profile_curve_base",
source: Box::leak(source.into_boxed_str()),
}]
}
});
static HELPERS: LazyLock<[Helper; 2]> = LazyLock::new(|| {
[
*PROPHOTO_HELPER,
Helper {
name: "profile_apply",
source: LOOKUP_WGSL,
},
]
});
/// Where each section of the profile buffer starts, from its header.
const SECTIONS_WGSL: &str = "
fn profile_entries(dims: vec4<f32>) -> u32 {
return u32(dims.x * dims.y * dims.z);
}
fn profile_look_base() -> u32 {
return 3u + profile_entries(profile_table[0]);
}
fn profile_curve_base() -> u32 {
return profile_look_base() + profile_entries(profile_table[1]);
}
";
/// The lookup, in WGSL. Mirrors [`apply_reference`] line for line.
const LOOKUP_WGSL: &str = r#"
fn profile_srgb_encode(v: f32) -> f32 {
@@ -327,12 +357,11 @@ if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
// A colour outside ProPhoto has no HSV the tables were made for; it
// passes through rather than being floored, which would clip it (D19).
if (min(p.r, min(p.g, p.b)) >= 0.0) {
let look_base = 2u + u32(hue_sat_dims.x * hue_sat_dims.y * hue_sat_dims.z);
if (hue_sat_dims.x > 0.0) {
p = profile_apply(hue_sat_dims, 2u, p, 1.0);
p = profile_apply(hue_sat_dims, 3u, p, 1.0);
}
if (look_dims.x > 0.0 && look > 0.0) {
p = profile_apply(look_dims, look_base, p, look);
p = profile_apply(look_dims, profile_look_base(), p, look);
}
c = PROFILE_TO_WORKING * p;
}
@@ -352,13 +381,16 @@ if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
}
}
/// TRACES: FR-DEV-3e
/// The storage buffer a source's tables are uploaded as: the two header
/// `vec4`s, the HueSatMap's entries, then the LookTable's, each entry
/// `(hue shift, saturation scale, value scale, 0)`.
/// TRACES: FR-DEV-3e | FR-DEV-3j
/// The storage buffer a source's profile is uploaded as: the three header
/// `vec4`s, the HueSatMap's entries, the LookTable's, each entry
/// `(hue shift, saturation scale, value scale, 0)`, then the tone curve's
/// samples in `.x`.
///
/// Two zero `vec4`s where there are no tables — the placeholder every source
/// without a profile binds, and what makes the fragment pass through.
/// The curve is always there: the profile's own where it has one, Camera
/// Raw's ACR3 default otherwise — including in the placeholder every source
/// without a profile binds, whose tables are absent, so a raw with no
/// profile still renders through Camera Raw's tone (D21).
pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
let header = |t: Option<&HueSatTable>| match t {
Some(t) => [
@@ -371,10 +403,18 @@ pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
};
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
let look = tables.and_then(|t| t.look.as_ref());
let mut out = vec![header(hue_sat), header(look)];
let curve: &[f32] = tables
.and_then(|t| t.tone_curve.as_deref())
.unwrap_or(&dr_types::tone::ACR3_DEFAULT);
let mut out = vec![
header(hue_sat),
header(look),
[curve.len() as f32, 0.0, 0.0, 0.0],
];
for t in [hue_sat, look].into_iter().flatten() {
out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0]));
}
out.extend(curve.iter().map(|&v| [v, 0.0, 0.0, 0.0]));
out
}
@@ -664,7 +704,10 @@ mod tests {
#[test]
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
assert_eq!(profile_buffer(None), vec![[0.0; 4]; 2]);
let bare = profile_buffer(None);
assert_eq!(bare[..2], [[0.0; 4]; 2], "no tables");
assert_eq!(bare[2][0], 1025.0, "and Camera Raw's default curve");
assert_eq!(bare.len(), HEADER_ENTRIES + 1025);
let t = tables(
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
@@ -672,8 +715,9 @@ mod tests {
let b = profile_buffer(Some(&t));
assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]);
assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]);
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12);
assert_eq!(b[2], [1.0, 2.0, 3.0, 0.0]);
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12 + 1025);
assert_eq!(b[HEADER_ENTRIES], [1.0, 2.0, 3.0, 0.0]);
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
assert_eq!(b[HEADER_ENTRIES + 16][0], dr_types::tone::ACR3_DEFAULT[0]);
}
}