10.3 色调与调色
站内 /graphics-post-processing/ 那篇把调色排在链尾,理由是它最好做、也最容易被滥做;本节要说明「最好做」在整数下到底意味着什么。调色的产业做法是颜色查找表(LUT):美术在 DaVinci 或 Photoshop 里调出一版满意的色调,导出成一张 LUT 贴图,引擎运行时只做查表。这样调色风格与渲染代码解耦,改色不用重编译。
整数 LUT 有两个必须量化的代价。第一,8 位量化不可逆:正向 LUT 再乘逆 LUT,回不到原值。第二,三维 LUT 是插值近似:节点越稀,误差越大。这一节把两者都测出来,最后再把整条链搬到 WebGL2 片元着色器上,用声明容差而不是逐位相等来验收。
三张一维 LUT
对比度与色温都是逐通道的标量映射,用三张 256 项的一维 LUT 就够了,不必上三维。对比度绕 128 旋转(128 是不动点,亮部更亮、暗部更暗),色温用逐通道偏置(暖调 R 抬、B 压):
# snippet: 节选自 lantern/sim/cg_shade_10_3_grading.py 的 LUT 构造与饱和度
def build_grade_lut(contrast_q16, bias):
"""三张 1D LUT:对比度绕 128 + 逐通道色温偏置。返回 (3, 256) int。"""
i = np.arange(256, dtype=np.int64)
lut = np.empty((3, 256), dtype=np.int64)
for c in range(3):
v = ((i - 128) * contrast_q16 >> 16) + 128 + bias[c]
lut[c] = np.clip(v, 0, 255)
return lut
def apply_lut1d(img, lut):
"""逐通道查表。"""
out = np.empty_like(img)
for c in range(3):
out[:, :, c] = lut[c][img[:, :, c]]
return out
def saturate(img, s_q16):
"""整数饱和度:out = luma + (s * (in - luma) >> 16)。"""
l = luma(img)[:, :, None]
return np.clip(l + ((s_q16 * (img - l)) >> 16), 0, 255)
三张表在几个采样点上的值:
| 输入 | R 通道 | G 通道 | B 通道 |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 64 | 56 | 48 | 38 |
| 128 | 136 | 128 | 118 |
| 192 | 216 | 208 | 198 |
| 255 | 255 | 255 | 255 |
LUT[128] = [136, 128, 118]:R 比 128 高 8、B 低 10,正是色温偏置 [8, 0, -10] 原样透出来;对比度 1.25 在不动点处不起作用。端点 0 与 255 都被 clip 夹住,所以 LUT[255] = [255, 255, 255]——高光被推爆了,这是对比度 1.25 加色温偏置的直接后果,不是 bug。
饱和度不在 LUT 里,它是逐像素的整数式 out = luma + (s·(in-luma) >> 16),其中 luma 用与 10.2 完全相同的 Rec.601 整数亮度。饱和度 1.20 时 s_q16 = 78643。
8 位量化的往返代价
把一张非线性目标变换 T(v) = v²/255 采成 LUT,再用逆 LUT 查回来,理想情况应该回到原值。实测回不去:
帧 : 320x240 (out/shade_hollow.rgb)
对比度 1.25 色温偏置 [8, 0, -10] 饱和度 1.20
LUT[128] : [136, 128, 118] LUT[255] = [255, 255, 255]
调色帧均值 : 34.5039 sha256 1ef2424771e42083…
LUT 往返误差 : MAE = 4.5963 最大 = 15 改动像素 = 67047
3D LUT 三线性(目标 T(v) = v*v/255):
每轴节点 条目数 MAE 最大差
2 8 35.4751 63
4 64 3.3684 7
8 512 0.6169 2
往返 MAE = 4.5963、最大差 15;整帧 76800 个像素里有 67047 个(87%)至少有一个通道被改动。原因很直白:T 是二次的,T(0) 到 T(1) 只差 0,而 T(255) 附近一步跨 2;逆 LUT 用「最近邻反查」重建,凡是多个输入映射到同一个输出值的地方,信息就已经丢了。这条误差不是实现缺陷,是 8 位管线的信息论下限——要它变小只能加深位宽。
三维 LUT 的插值误差
真正表达色彩风格(如胶片曲线、分离色调)需要三维 LUT:每个轴 0..255 采 N 个节点,共 N³ 个条目,逐像素做整数三线性插值。节点数直接决定误差:
| 每轴节点 | 条目数 | MAE vs 精确 | 最大差 |
|---|---|---|---|
| 2 | 8 | 35.4751 | 63 |
| 4 | 64 | 3.3684 | 7 |
| 8 | 512 | 0.6169 | 2 |
每轴 2 个节点时误差高达 35——相当于只有「黑」和「白」两个锚点,中间全靠线性猜。4 个节点(64 条目)就把 MAE 压到 3.37,8 个节点(512 条目)压到 0.62。从 4 到 8,条目数涨 8 倍,MAE 只降 5.5 倍,收益开始递减。工程上常见的 17³ 或 33³ LUT 正是这个折中曲线的延长。
W 层:同一条链的浮点版,按声明容差验收
把整条链(可分离模糊 → 亮部 → 降采样链 → 泛光合成 → 调色)搬进 WebGL2 片元着色器,用离屏 FBO 逐级渲染,再把像素读回来和 S 层的产物比。这里必须换一把尺子:S 层是 Q16.16 整数加算术右移,W 层是浮点乘加加纹理采样,两条路径的舍入点完全不同,逐位相等是不可能的。
所以判据改成三条声明容差:MAE ≤ 1.0、PSNR ≥ 30 dB、通道差 ≤ 2 的像素占比 ≥ 980‰。同时必须有一个负例——一个故意写错的版本,用来证明这套判据不是「永远通过」:
// file: lantern/web/verify_shade10.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_shade10.mjs
// 卷二 ch10 的 W 层校验器:用 Playwright 打开 web/dist/shade10.html,把页面里
// WebGL2 渲染出的模糊 / 泛光 / 调色像素缓冲取回,与 S 层(Python + numpy)产物
// 按**声明容差**比对:MAE ≤ 1.0、PSNR ≥ 30 dB、通道差 ≤ 2 的像素占比 ≥ 980‰。
//
// 本机 headless 的 WebGL2 后端是 ANGLE/SwiftShader **软件**实现,不是真实 GPU;
// 差异来源是浮点着色器与 Q16.16 定点的舍入路径,不得声称逐位相等。
// 负例 GRADE_BAD(跳过 LUT)必须被同一判据拒绝。
//
// 老坑:冷启动首个 page 读 canvas 恒空白 —— 先开一个预热页,再开正式页。
import { readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const here = dirname(fileURLToPath(import.meta.url));
const ROOT = join(here, "..");
const PAGE = pathToFileURL(join(here, "dist", "shade10.html")).href;
const TOL = { mae: 1.0, psnr: 30.0, within2_permille: 980 };
const goldens = {
blur: readFileSync(join(ROOT, "out", "cg_shade_10_1_blur.rgb")),
bloom: readFileSync(join(ROOT, "out", "cg_shade_10_2_bloom.rgb")),
grade: readFileSync(join(ROOT, "out", "cg_shade_10_3_grade.rgb")),
};
/** 在页面里解码金标准并逐像素比对,只把指标带回 Node 侧。 */
const MEASURE = ({ got, gold, w, h }) => {
const dec = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const a = dec(got);
const b = dec(gold);
let sum = 0;
let mse = 0;
let within2 = 0;
let max = 0;
const n = w * h * 3;
for (let i = 0; i < n; i += 1) {
const d = Math.abs(a[i] - b[i]);
sum += d;
mse += d * d;
if (d > max) max = d;
if (d <= 2) within2 += 1;
}
const mae = sum / n;
return {
mae,
psnr: mse === 0 ? Infinity : 10 * Math.log10((255 * 255) / (mse / n)),
max,
within2_permille: (within2 * 1000) / n,
};
};
const READ = (which) => {
const toB64 = (arr) => {
let s = "";
for (let i = 0; i < arr.length; i += 0x8000) {
s += String.fromCharCode.apply(null, arr.slice(i, i + 0x8000));
}
return btoa(s);
};
const L = globalThis.__lantern;
if (!L) return null;
return {
which,
buf: toB64(L[which].rgb),
w: L[which].w,
h: L[which].h,
meta: L.meta,
gl: L.gl,
canvasDrawn: L.canvasDrawn,
};
};
const browser = await chromium.launch();
// 预热页:先跑一次 WebGL2,避开「冷启动首个 page 读 canvas 恒空白」。
// 注意预热页自己也要 preserveDrawingBuffer: true —— 否则帧一旦被合成,
// 后备缓冲就被清空,readPixels 读到的是全 0,预热页自身反而验证不了。
const warm = await browser.newPage();
await warm.setContent("<canvas id='w' width='8' height='8'></canvas>");
const warmPx = await warm.evaluate(() => {
const g = document.getElementById("w").getContext("webgl2", { preserveDrawingBuffer: true });
g.clearColor(0.2, 0.4, 0.6, 1);
g.clear(g.COLOR_BUFFER_BIT);
const p = new Uint8Array(4);
g.readPixels(0, 0, 1, 1, g.RGBA, g.UNSIGNED_BYTE, p);
return Array.from(p);
});
await warm.waitForTimeout(150);
await warm.close();
console.log(`预热页像素 [${warmPx.join(",")}](clearColor 0.2/0.4/0.6 -> 应约 51/102/153)`);
const page = await browser.newPage();
await page.goto(PAGE, { waitUntil: "load" });
await page.waitForFunction(() => globalThis.__lantern && globalThis.__lantern.canvasDrawn, null, { timeout: 20000 });
const gl = await page.evaluate(() => globalThis.__lantern.gl);
console.log(`WebGL: ${gl.version}`);
console.log(`渲染器: ${gl.renderer} (软件后端,非真实 GPU)`);
console.log(`gl.getError() = ${gl.error}`);
const report = { gl, tolerances: TOL, checks: [], negative: null };
const payload = await page.evaluate(READ, "blur");
report.meta = payload.meta;
console.log(`级别尺寸: ${JSON.stringify(payload.meta.levelSizes)}`);
for (const which of ["blur", "bloom", "grade"]) {
const got = await page.evaluate(READ, which);
const m = await page.evaluate(MEASURE, {
got: got.buf, gold: goldens[which].toString("base64"), w: got.w, h: got.h,
});
const pass = m.mae <= TOL.mae && m.psnr >= TOL.psnr && m.within2_permille >= TOL.within2_permille;
report.checks.push({ name: which, ...m, pass });
console.log(
`[${pass ? "PASS" : "FAIL"}] ${which.padEnd(6)} MAE=${m.mae.toFixed(4)} `
+ `PSNR=${m.psnr.toFixed(2)}dB max=${m.max} 差≤2占比=${m.within2_permille.toFixed(1)}‰`,
);
}
// 确定性:同一上下文内重跑一次模糊链,两次读回的像素必须逐位相同。
const a = await page.evaluate(READ, "blur");
const b = await page.evaluate(READ, "blur2");
const det = a.buf === b.buf;
report.deterministic = det;
console.log(`[${det ? "PASS" : "FAIL"}] 确定性 同上下文重跑两次模糊链像素逐位相同`);
// 负例:跳过 LUT 的调色必须被同一判据拒绝。
const bad = await page.evaluate(READ, "gradeBad");
const badM = await page.evaluate(MEASURE, {
got: bad.buf, gold: goldens.grade.toString("base64"), w: bad.w, h: bad.h,
});
const badRejected = badM.mae > TOL.mae || badM.psnr < TOL.psnr;
report.negative = { ...badM, rejected: badRejected };
console.log(
`[${badRejected ? "PASS" : "FAIL"}] 负例 GRADE_BAD MAE=${badM.mae.toFixed(4)} `
+ `PSNR=${badM.psnr.toFixed(2)}dB → ${badRejected ? "被容差判据拒绝" : "竟通过,判据失效"}`,
);
await browser.close();
const ok = report.checks.every((c) => c.pass) && det && badRejected;
report.ok = ok;
writeFileSync(join(ROOT, "out", "cg_shade_10_web.json"), `${JSON.stringify(report, null, 2)}\n`);
console.log(`\nW 层总体: ${ok ? "PASS" : "FAIL"}`);
process.exit(ok ? 0 : 1);
预热页像素 [51,102,153,255](clearColor 0.2/0.4/0.6 -> 应约 51/102/153)
WebGL: WebGL 2.0 (OpenGL ES 3.0 Chromium)
渲染器: ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver) (软件后端,非真实 GPU)
gl.getError() = 0
级别尺寸: [[320,240],[160,120],[80,60],[40,30]]
[PASS] blur MAE=0.5086 PSNR=50.62dB max=2 差≤2占比=1000.0‰
[PASS] bloom MAE=0.1320 PSNR=52.74dB max=53 差≤2占比=999.7‰
[PASS] grade MAE=0.1953 PSNR=55.22dB max=1 差≤2占比=1000.0‰
[PASS] 确定性 同上下文重跑两次模糊链像素逐位相同
[PASS] 负例 GRADE_BAD MAE=15.3089 PSNR=23.32dB → 被容差判据拒绝
W 层总体: PASS
三点值得说明。
渲染器是软件后端。 输出里那行 ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device ...)) 是本机 headless 环境的真实身份:它是 SwiftShader 软件光栅化,不是真实 GPU。所以这里的 MAE 不能读成「GPU 与 CPU 的差异」,只能读成「浮点路径与定点路径的舍入差异」——把软件后端的结果说成硬件差异,是这类验证里最常见的过度解读。
三条链的误差量级不同。 调色 MAE = 0.1953(最好,因为 1D 查表是精确整数映射,只有 luma 那一步有舍入);模糊 MAE = 0.5086、max = 2(两趟 9 抽头卷积,每趟都有一轮舍入);泛光 MAE = 0.1320 但 max = 53(大部分像素几乎完全一致,少数高光像素因为浮点 clamp 的时机不同差得较大——差≤2 占比 = 999.7‰ 说明这样的像素只有约 70 个通道值)。
负例被拒绝。 GRADE_BAD 跳过 LUT 直接对输入做饱和度,MAE = 15.3089、PSNR = 23.32 dB,远在容差线之外。这条负例的作用是:如果哪天判据写错成「只要不抛异常就算过」,它会立刻把门禁顶红。一个只会通过的判据,等于没有判据。
还有一条隐含结论:级别尺寸: [[320,240],[160,120],[80,60],[40,30]] 说明降采样链在 W 层是按 4 级搭的,与 S 层金标准的级数一致。更关键的是参数来源——阈值 96、强度 1.0、级数 4 与 S 层金标准取的是同一组值,而饱和度 78643 与三张 256 项 LUT 是直接从 S 层产物 out/cg_shade_10_3_grading.json 里读出来再塞进着色器的,不是两端各写一份常量。共享同一份配置,比对才有意义;各写各的常量,比的就只是「两边有没有写错同一个数」。
阅读导航:上一节:10.2 Bloom 与泛光 · 下一节:11.1 时间抗锯齿 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。