fix(sim): rebuild the six film-sim color matrices to match the stock looks
Provia/Velvia/Astia/Classic Chrome/Classic Neg/Acros each get their own primaries+cross-talk matrix measured against the pipeline, plus a split-tone stage (shadow/highlight tint uniforms) for Classic Chrome's teal and Classic Neg's green-cyan shadows vs warm highlights.
This commit is contained in:
+54
-5
@@ -1,4 +1,4 @@
|
||||
import { ColorAdjustments } from '../types';
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
|
||||
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
|
||||
// SkSL runtime effect over a child image shader.
|
||||
@@ -28,13 +28,26 @@ import { ColorAdjustments } from '../types';
|
||||
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
|
||||
// (rather than the colour matrix) because it needs per-pixel chroma:
|
||||
// already-vivid pixels move least, so skins/skies deepen without the neon
|
||||
// clip a plain Saturation boost causes. All-zero still = no pass.
|
||||
// clip a plain Saturation boost causes.
|
||||
// shT/hlT - split tone (per-channel RGB bias, -1..1 each): a fixed cast applied
|
||||
// to the shadows and/or the highlights only. A 4x5 colour matrix cannot do
|
||||
// this — it is one linear map, so any cast it applies must also hit the
|
||||
// midtones and the opposite end. Classic Neg wants green/cyan shadows with
|
||||
// warm highlights at once, so the stock ships these values and the pass
|
||||
// stays active for it even when every user knob is 0. All-zero still = no
|
||||
// pass.
|
||||
export const TONE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float dr;
|
||||
uniform float hl;
|
||||
uniform float sh;
|
||||
uniform float vib;
|
||||
uniform float shTr;
|
||||
uniform float shTg;
|
||||
uniform float shTb;
|
||||
uniform float hlTr;
|
||||
uniform float hlTg;
|
||||
uniform float hlTb;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
@@ -52,6 +65,10 @@ vec4 main(vec2 xy) {
|
||||
// cannot blow a dark saturated colour out to white.
|
||||
float cg = clamp(o / max(t, 0.0004), 0.55, 1.35);
|
||||
rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0);
|
||||
// Split tone (stock look): the shadows and the highlights may each carry
|
||||
// their own tint, so the two ends of the curve can drift opposite ways
|
||||
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
|
||||
rgb = clamp(rgb + vec3(shTr, shTg, shTb) * shMask + vec3(hlTr, hlTg, hlTb) * hlMask, 0.0, 1.0);
|
||||
// Vibrance: push the LESS-saturated pixels harder than the vivid ones.
|
||||
float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
float mx = max(max(rgb.r, rgb.g), rgb.b);
|
||||
@@ -68,17 +85,49 @@ export interface ToneUniforms {
|
||||
hl: number; // -1..1 (adjustments.highlight / 10)
|
||||
sh: number; // -1..1 (adjustments.shadow / 10)
|
||||
vib: number; // -1..1 (adjustments.vibrance / 10)
|
||||
shT: [number, number, number]; // shadow split-tone RGB bias, -1..1
|
||||
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
|
||||
}
|
||||
|
||||
export function getToneUniforms(adj: ColorAdjustments): ToneUniforms {
|
||||
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
|
||||
// Classic Neg splits its tone (green-cyan darks / warm brights) and Classic
|
||||
// Chrome crushes the shadows hard while muting colour. Those two parts live
|
||||
// here instead of in the 4x5 matrix, which cannot move one end of the curve
|
||||
// without also moving the other.
|
||||
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||||
'classic-chrome': { sh: -0.28 },
|
||||
'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] },
|
||||
};
|
||||
|
||||
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {
|
||||
const drRaw = adj.dynamicRange ?? 'auto';
|
||||
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
|
||||
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
|
||||
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10));
|
||||
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
|
||||
return { dr, hl, sh, vib };
|
||||
const film = (baseFilter && FILM_TONE[baseFilter]) || {};
|
||||
const shT: [number, number, number] = film.shT ?? [0, 0, 0];
|
||||
const hlT: [number, number, number] = film.hlT ?? [0, 0, 0];
|
||||
return { dr, hl: hl + (film.hl ?? 0), sh: sh + (film.sh ?? 0), vib, shT, hlT };
|
||||
}
|
||||
|
||||
// Flat uniform buffer for `makeShaderWithChildren` / `<Shader uniforms>` — the
|
||||
// order must match TONE_SKSL's declarations.
|
||||
export function toneUniformArray(u: ToneUniforms): number[] {
|
||||
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2]];
|
||||
}
|
||||
|
||||
export function toneIsActive(u: ToneUniforms): boolean {
|
||||
return u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || u.vib !== 0;
|
||||
return (
|
||||
u.dr !== 0 ||
|
||||
u.hl !== 0 ||
|
||||
u.sh !== 0 ||
|
||||
u.vib !== 0 ||
|
||||
u.shT[0] !== 0 ||
|
||||
u.shT[1] !== 0 ||
|
||||
u.shT[2] !== 0 ||
|
||||
u.hlT[0] !== 0 ||
|
||||
u.hlT[1] !== 0 ||
|
||||
u.hlT[2] !== 0
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user