web: grade the colour matrix before the tone pass

A SkPaint runs its shader BEFORE its colourFilter, so setting the exposure
matrix on the same paint as the tone shader landed the gain after the tone
pass: HIGHLIGHT -10 rolled a bright pixel back to 0.78, +EXPOSURE then
multiplied it by 1.2 and +0.15 and it clamped back to 1.0 — the HIGHLIGHT
slider looked dead the moment exposure went up.

The matrix now renders into its own image and the tone/cinema chain samples
that. Measured on the real engine (HIGHLIGHT -10 first, then EXPOSURE +10):
top end stays 0.780 (was 1.000), midtone 0.502 -> 0.722.
This commit is contained in:
2026-09-18 10:33:32 +07:00
parent 40177cb63f
commit 8e6c1493e8
+67 -46
View File
@@ -185,6 +185,23 @@ function screenSharpenImage(image: any, amount = 0.5): any {
return convolveImage(image, amount);
}
// The colour matrix, applied on its own into a fresh image. A SkPaint runs its
// shader before its colourFilter, so the graded pixels have to exist as an
// image before the tone/cinema pass can read them (see renderPhoto step 3).
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const p = Skia.Paint();
p.setColorFilter(colorFilter);
surf.getCanvas().drawImage(image, 0, 0, p);
p.dispose();
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
// Snapshot → clear → blur redraw: the blur must not sit on top of the sharp
// pixels it is meant to replace.
function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: number): void {
@@ -305,47 +322,60 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
const adjustments = recipe.adjustments;
// 3. Colour matrix (+2^EV gain unless the camera already biased the shot).
// The matrix grades into its OWN image first, because a SkPaint runs its
// shader before its colourFilter: with both on one paint the exposure gain
// landed AFTER the tone pass, so +EXPOSURE took the 0.78 a rolled-back
// highlight came out at and pushed it straight back to 1.0 — the HIGHLIGHT
// slider looked dead the moment exposure went up (measured, see the order
// probe). The tone shader's child is the graded image, so the highlight
// mask finally sees the exposed value it is meant to pull back.
const evStops = adjustments.exposureCompensation ?? 0;
const evMatrix = options?.evFromCamera
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
paint.setColorFilter(colorFilter);
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
// 3b. Tone shader.
const tone = getToneUniforms(adjustments, recipe.baseFilter);
let toneShader: any = null;
// 3c. Cinema seasonal grade (cinema → tone → image).
const cinema = getCinemaUniforms(recipe.cinema);
const cinemaOn = !!cinema && cinemaIsActive(cinema.flat);
let paintShader: any = null;
let gradeImage = skImage;
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
if (graded) {
const snapshot = gradeThrough(skImage, width, height, colorFilter);
if (snapshot) {
own(snapshot);
gradeImage = snapshot;
}
}
const imageShaderOf = () =>
own(
gradeImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
);
if (toneIsActive(tone)) {
const { toneEffect: effect } = effects();
if (effect) {
const imageShader = own(
skImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
);
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]));
if (toneShader) paint.setShader(toneShader);
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
}
}
// 3c. Cinema seasonal grade (cinema → tone → image).
const cinema = getCinemaUniforms(recipe.cinema);
let paintShader = toneShader;
if (cinema && cinemaIsActive(cinema.flat)) {
if (cinemaOn) {
const { cinemaEffect: effect } = effects();
if (effect) {
const child =
toneShader ??
own(skImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
));
const cs = own(effect.makeShaderWithChildren(cinema.flat, [child]));
if (cs) {
paintShader = cs;
paint.setShader(paintShader);
}
const cs = own(effect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
if (cs) paintShader = cs;
}
}
if (!paintShader && toneShader) paintShader = toneShader;
// Only one of the two may tint the pixels: the graded image already carries
// the matrix, an ungraded draw still needs it.
if (paintShader) paint.setShader(paintShader);
else if (!graded) paint.setColorFilter(colorFilter);
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
if (adjustments.denoise > 0) {
@@ -382,24 +412,22 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
const strength = (adjustments.hdf ?? 0) / 10;
const sigma = width * (0.006 + 0.024 * strength);
const { glowEffect: effect } = effects();
const srcShader =
paintShader ??
own(skImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
));
const srcShader = paintShader ?? imageShaderOf();
const glowShader =
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
const glowPaint = own(Skia.Paint());
glowPaint.setBlendMode(Skia.BlendMode.Screen);
glowPaint.setAlphaf(0.28 + 0.52 * strength);
// The bloom reads the graded pixels now (step 3), so the matrix must not
// land on them a second time; it only wraps the filter when ungraded.
const bloomGrade = (inner?: any) =>
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
const bloomBlur = () => own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null));
if (glowShader != null) {
glowPaint.setShader(glowShader);
glowPaint.setImageFilter(
own(Skia.ImageFilter.MakeCompose(
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
own(Skia.ImageFilter.MakeColorFilter(colorFilter, null))
))
);
const grade = bloomGrade();
const blur = bloomBlur();
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
const hiMatrix = [
@@ -408,23 +436,16 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
0, 0, 2.5, 0, -1.5,
0, 0, 0, 1, 0,
];
glowPaint.setImageFilter(
own(Skia.ImageFilter.MakeCompose(
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
own(Skia.ImageFilter.MakeColorFilter(
own(Skia.ColorFilter.MakeCompose(
own(Skia.ColorFilter.MakeMatrix(hiMatrix)),
colorFilter
)),
null
))
))
const grade = bloomGrade(
own(Skia.ColorFilter.MakeCompose(own(Skia.ColorFilter.MakeMatrix(hiMatrix)), colorFilter))
);
const blur = bloomBlur();
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
if (paintShader) {
glowPaint.setShader(paintShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
canvas.drawImage(skImage, 0, 0, glowPaint);
canvas.drawImage(gradeImage, 0, 0, glowPaint);
}
}
}