7.2 色调映射
站内的 HDR 管线工程 关心的是 HDR 在整条管线里怎么流转:帧缓冲格式、元数据、显示端映射。本节不管这些,只做一件事:把三条最常见的色调映射曲线写成全整数,喂进同一份真实 HDR 辐射度,把它们的输出曲线与直方图量出来。上一节把颜色进出的门(sRGB 传递函数)钉成了整数,这一节处理门里面那段亮度压缩。
为什么需要色调映射?渲染出来的是线性辐射度,可以远大于 1.0(阳光、灯芯、高光),而显示设备只能表现 [0, 1]。直接 clamp 到 1 会把所有高光压成一片死白,丢掉的正是「亮处还有多少层次」这个信息。色调映射是一条把 [0, ∞) 单调压进 [0, 1] 的曲线,要求:暗部尽量不动、高光缓慢饱和、全程单调。
本节用的 HDR 输入不是合成数据:它来自《烛影山谷》的真实着色,只把 render_shade 的 Lambert 公式里的 clamp 去掉,得到 Q16.16 的线性辐射度。实测这一帧的辐射度上界是 47335 Q16.16(约 0.722),均值 7151.59;再乘一个曝光系数 6.0 把它推到 284010(约 4.33),才有足够的动态范围考验三条曲线。
三条曲线的整数形式
三条曲线全部写成有理式,运行期只有 >> 与整数除法,无浮点。
Reinhard:y = x / (1 + x)。这是最省的一条,Q16.16 下就是一次除法 fdiv(x, ONE + x)。它把 x = 1 映射到 0.5,x → ∞ 趋于 1。
扩展 Reinhard(白点 W):y = x(1 + (x/W)^2) / (1 + x)。多出来的 (x/W)^2 项让曲线在 x = W 处正好到 1,代价是超过 W 的部分被硬性截断。本节取 W = 4.0,即「比白点还亮的像素一律压到纯白」。
ACES filmic 近似:用 Narkowicz 的有理式拟合 y = x(ax+b) / (x(cx+d)+e),常量 a=2.51, b=0.03, c=2.43, d=0.59, e=0.14。五个常量全部转成 Q16.16 整数常量(164495 / 1966 / 159252 / 38666 / 9175),运行期两次乘法、一次除法。ACES 的中间调比 Reinhard 亮得多,这是它在影视里被偏好的原因。
三条曲线在几个关键横坐标上的输出(Q16.16):
| x | Reinhard | 扩展 Reinhard | ACES |
|---|---|---|---|
| 0.5 | 21845 | 22186 | 40389 |
| 1.0 | 32768 | 34816 | 52677 |
| 2.0 | 43690 | 54613 | 59955 |
| 4.0 | 52428 | 104857 | 63793 |
读法:在 x = 1.0 处,Reinhard 给 0.5,ACES 给 0.804——同一个输入,ACES 的中间调亮了六成。x = 4.0 处扩展 Reinhard 已经越过 1.0(104857),意味着它会裁剪。
同一份 HDR 输入下的输出
把曝光后的辐射度喂进三条曲线,再经 7.1 的 sRGB 编码得到 8 位输出,逐像素统计:
canvas : 320x240 覆盖 43731 三角形 896
HDR 线性辐射度 : max 47335 Q16.16 均值 7151.5882
曝光 6.0 后 max : 284010 Q16.16
reinhard : out max 53248 均值 165.8936 裁剪 0 黑 0 sha256 94532e403259a113…
reinhard_white : out max 115750 均值 168.2108 裁剪 156 黑 0 sha256 fe5a64b8d03435b1…
aces : out max 64092 均值 213.4301 裁剪 0 黑 0 sha256 91ff1d8acdec0255…
曲线采样 : 129 点 -> out/cg_shade_07_2_curve.csv / .svg
三条映射的差别一目了然。Reinhard 最保守:输出最大 53248(0.8125),一个像素都没裁,均值 165.89——画面整体偏暗但没有任何信息丢失。扩展 Reinhard 最激进:输出最大 115750,超出 1.0 的部分被压成纯白,直方图最右一档(bin 255)落了 161 个像素,均值升到 168.21。ACES 最亮:均值 213.43,比 Reinhard 高了近 48 个灰度级,且没有裁剪——它把中间调整体提上去,同时高光仍然平滑收敛到 0.978。
直方图把这件事说得更清楚。ACES 的分布在 bin 220 附近堆了 8190 个像素(占覆盖像素的三成),而 Reinhard 在这一档只有 29 个;反过来 Reinhard 在 bin 180 附近最密。同一帧、同一份辐射度,只是换了一条映射曲线,8 位输出的直方图形状就完全不同——这正是「色调映射是创作工具而非纯数学」的量化证据。
可运行产物:三条曲线 + 直方图 + 曲线图
下面的脚本把上面每个数字变成可复现的产物。它复用 render_shade.build_scene_shaded 拿几何,只替换着色回调,不重写任何定点数学。
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_07_2_tonemap.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_07_2_tonemap.py
"""7.2 色调映射:三条曲线的整数实现,以及同一 HDR 输入下的输出直方图。
HDR 输入取自《烛影山谷》的真实着色:把 `render_shade` 的 Lambert 公式改成**不 clamp**
的 Q16.16 线性辐射度,再乘一个曝光系数。三条映射全部用整数算术:
- Reinhard : x / (1 + x)
- 扩展 Reinhard : x(1 + (x/W)^2) / (1 + x) (白点 W)
- ACES filmic 近似 : x(ax+b) / (x(cx+d)+e) (Narkowicz 拟合,常量 Q16.16)
产物:out/cg_shade_07_hdr.bin、out/cg_shade_07_2_curve.csv / .svg、
out/cg_shade_07_2_hist.csv、out/cg_shade_07_2_*.png、out/cg_shade_07_2_tonemap.json。
"""
import hashlib
import json
import os
import sys
import xml.etree.ElementTree as ET
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cgshade # noqa: E402
import render_shade # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = 65536
EXPOSURE = 6 * ONE # 曝光系数 6.0
WHITE = 4 * ONE # 扩展 Reinhard 的白点 W = 4.0
ACES = (164495, 1966, 159252, 38666, 9175) # 2.51 / 0.03 / 2.43 / 0.59 / 0.14
def make_hdr_shader(spec, lut):
"""与 render_shade 同一 Lambert 公式,但不 clamp、albedo 先解码到线性。"""
dlight, ambient = spec["directionalLight"], spec["ambientQ16"]
dir_q16, intensity = tuple(dlight["dirQ16"]), dlight["intensityQ16"]
def shade(normal, albedo):
ndl = max(0, cglib.v_dot(normal, dir_q16))
k = ambient + cglib.fmul(ndl, intensity)
return tuple(cglib.fmul(lut[albedo[i]], k) for i in range(3))
return shade
def fmul(a, b):
return (a * b) >> 16
def fdiv(a, b):
return (a << 16) // b
def reinhard(x):
return fdiv(x, ONE + x)
def reinhard_white(x, w):
return fdiv(fmul(x, ONE + fdiv(fmul(x, x), fmul(w, w))), ONE + x)
def aces(x):
a, b, c, d, e = ACES
return np.minimum(fdiv(fmul(x, fmul(a, x) + b), fmul(x, fmul(c, x) + d) + e), ONE)
def write_svg(path, samples, series):
"""纯 xml.etree 的折线图:x 轴 [0, 4.0],y 轴 [0, 1.0]。"""
w, h, pad = 560, 300, 40
root = ET.Element("svg", xmlns="http://www.w3.org/2000/svg",
width=str(w), height=str(h), viewBox=f"0 0 {w} {h}")
ET.SubElement(root, "rect", x="0", y="0", width=str(w), height=str(h), fill="#11151c")
for k in range(5):
gy = pad + (h - 2 * pad) * k // 4
ET.SubElement(root, "line", x1=str(pad), y1=str(gy), x2=str(w - pad), y2=str(gy),
stroke="#2b3542", **{"stroke-width": "1"})
colors = {"reinhard": "#e0736b", "reinhard_white": "#6fb3e0", "aces": "#8fd07a"}
for name, ys in series.items():
pts = [f"{pad + (w - 2 * pad) * xv // (4 * ONE)},{(h - pad) - (h - 2 * pad) * min(yv, ONE) // ONE}"
for xv, yv in zip(samples, ys)]
ET.SubElement(root, "polyline", points=" ".join(pts), fill="none",
stroke=colors[name], **{"stroke-width": "2"})
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
def main():
spec = cglib.load_spec()
width, height = spec["width"], spec["height"]
with open(os.path.join(ROOT, "config", "cg_shade_07_srgb_lut.json"), encoding="utf-8") as fh:
d = np.array(json.load(fh)["srgb_to_linear_q16"], dtype=np.int64)
lut = [int(v) for v in d]
boundary = (d[1:] + d[:-1]) >> 1
tris, total, _dropped = render_shade.build_scene_shaded(spec)
hdr, _dep, covered, _cull = cgshade.rasterize_shaded(
tris, width, height, (0, 0, 0), make_hdr_shader(spec, lut)
)
x = fmul(hdr, EXPOSURE) # 曝光后的 HDR 线性辐射度
maps = {"reinhard": reinhard(x), "reinhard_white": reinhard_white(x, WHITE), "aces": aces(x)}
out8 = {k: np.searchsorted(boundary, np.minimum(v, ONE), side="right").astype(np.int64)
for k, v in maps.items()}
digests = {k: hashlib.sha256(v.astype(np.uint8).tobytes()).hexdigest() for k, v in out8.items()}
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "cg_shade_07_hdr.bin"), "wb") as fh:
fh.write(hdr.astype("<i4").tobytes())
for k, v in out8.items():
cglib.save_png(os.path.join(out, f"cg_shade_07_2_{k}.png"), v)
stats = {"canvas": [width, height], "covered_pixels": int(covered.sum()),
"exposure_q16": EXPOSURE, "white_q16": WHITE,
"hdr_max_q16": int(hdr[covered].max()),
"hdr_mean_q16": round(float(hdr[covered].mean()), 4),
"hdr_max_after_exposure_q16": int(x[covered].max()), "maps": {}}
hist = {}
for k, v in out8.items():
vals = v[covered].reshape(-1)
hist[k] = np.bincount(vals, minlength=256)
oc = maps[k][covered].reshape(-1)
stats["maps"][k] = {"sha256": digests[k], "out_max_q16": int(oc.max()),
"out_mean_q16": round(float(oc.mean()), 4),
"out_mean_8bit": round(float(vals.mean()), 4),
"clipped_pixels": int((oc >= ONE).sum()),
"black_pixels": int((vals == 0).sum())}
cglib.write_csv(os.path.join(out, "cg_shade_07_2_hist.csv"),
["bin", "reinhard", "reinhard_white", "aces"],
[(b, int(hist["reinhard"][b]), int(hist["reinhard_white"][b]), int(hist["aces"][b]))
for b in range(256)])
samples = list(range(0, 4 * ONE + 1, 4 * ONE // 128))
xs = np.array(samples, dtype=np.int64)
series = {"reinhard": [int(v) for v in reinhard(xs)],
"reinhard_white": [int(v) for v in reinhard_white(xs, WHITE)],
"aces": [int(v) for v in aces(xs)]}
write_svg(os.path.join(out, "cg_shade_07_2_curve.svg"), samples, series)
cglib.write_csv(os.path.join(out, "cg_shade_07_2_curve.csv"),
["x_q16", "reinhard", "reinhard_white", "aces"],
[(s, series["reinhard"][i], series["reinhard_white"][i], series["aces"][i])
for i, s in enumerate(samples)])
with open(os.path.join(out, "cg_shade_07_2_tonemap.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
print(f"canvas : {width}x{height} 覆盖 {int(covered.sum())} 三角形 {total}")
print(f"HDR 线性辐射度 : max {stats['hdr_max_q16']} Q16.16 均值 {stats['hdr_mean_q16']}")
print(f"曝光 6.0 后 max : {stats['hdr_max_after_exposure_q16']} Q16.16")
for k in ("reinhard", "reinhard_white", "aces"):
s = stats["maps"][k]
print(f"{k:<16}: out max {s['out_max_q16']:>6} 均值 {s['out_mean_8bit']:>7.4f}"
f" 裁剪 {s['clipped_pixels']:>5} 黑 {s['black_pixels']:>5} sha256 {s['sha256'][:16]}…")
print(f"曲线采样 : {len(samples)} 点 -> out/cg_shade_07_2_curve.csv / .svg")
return 0
if __name__ == "__main__":
sys.exit(main())
产物写进 out/:HDR 辐射度裸缓冲 cg_shade_07_hdr.bin(Q16.16 int32,供 P 层复算)、三条映射的 8 位 PNG、曲线采样 cg_shade_07_2_curve.csv 与纯 xml.etree 画的 cg_shade_07_2_curve.svg、256 档直方图 cg_shade_07_2_hist.csv,以及含全部数字与三张 sha256 的 cg_shade_07_2_tonemap.json。
跨语言断言:读同一份 HDR 缓冲
P 层不重建场景,而是读 S 层落盘的 cg_shade_07_hdr.bin,用整数内核重跑「曝光 + 三条曲线 + sRGB 编码」,再断言三张 8 位缓冲的 sha256 与 S 层逐字节相同。这样比对的对象收窄成色调映射内核本身。
// file: lantern/core/test/cg_shade_07_2_tonemap.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_07_2_tonemap.test.ts'
// 7.2 的跨语言断言:读 S 层落盘的 HDR 辐射度缓冲(out/cg_shade_07_hdr.bin),
// 用 P 层整数内核重跑曝光 + 三条色调映射 + sRGB 编码,断言三张 8 位缓冲的 sha256
// 与 sim/cg_shade_07_2_tonemap.py 逐字节相同,并含一条负例(改曝光即分叉)。
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
ONE,
applyExposure,
linearToSrgb8,
srgbEncode,
toneMap,
} from "../src/lantern/cg_shade_07_color.ts";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..", "..");
const stats = JSON.parse(readFileSync(join(root, "out", "cg_shade_07_2_tonemap.json"), "utf-8"));
const width = stats.canvas[0];
const height = stats.canvas[1];
const n = width * height * 3;
const raw = readFileSync(join(root, "out", "cg_shade_07_hdr.bin"));
const hdr = new Int32Array(raw.buffer, raw.byteOffset, n);
const MODES = ["reinhard", "reinhard_white", "aces"] as const;
function render(exposure: number): Record<string, string> {
const out: Record<string, Uint8Array> = {};
for (const m of MODES) out[m] = new Uint8Array(n);
const w = stats.white_q16;
for (let i = 0; i < n; i += 1) {
const x = applyExposure(hdr[i], exposure);
for (const m of MODES) out[m][i] = linearToSrgb8(toneMap(x, m, w));
}
const dig: Record<string, string> = {};
for (const m of MODES) dig[m] = createHash("sha256").update(Buffer.from(out[m])).digest("hex");
return dig;
}
test("P 层三条映射的像素缓冲 sha256 与 S 层逐字节相同", () => {
const dig = render(stats.exposure_q16);
for (const m of MODES) {
assert.equal(dig[m], stats.maps[m].sha256, m);
}
});
test("编码端点:线性 0/1 精确映射到 0/255", () => {
assert.equal(srgbEncode(0), 0);
assert.equal(srgbEncode(ONE), 255);
assert.equal(srgbEncode(ONE + 100), 255);
});
test("负例:曝光系数改动后三张缓冲的 sha256 必然分叉", () => {
const dig = render(stats.exposure_q16 + ONE); // 6.0 -> 7.0
for (const m of MODES) {
assert.notEqual(dig[m], stats.maps[m].sha256, m);
}
});
三条断言全绿:三张缓冲 sha256 逐字节相同、编码端点 0/1 精确、以及一条负例——把曝光系数从 6.0 改成 7.0,三张缓冲的 sha256 必然分叉。注意 P 层的除法写成 Math.floor((a * 65536) / b):因为所有输入非负、分母小于 2^35,float 除法的 floor 与精确整数除法一致,这才敢不用 BigInt。
下一节把这两块拼起来看代价:8 位量化到底丢了多少、线性插值在错误的色彩空间里会差多少、以及把 sRGB 接进卷二着色链路后端到端差多远。
阅读导航:上一节:7.1 线性空间与 sRGB · 下一节:7.3 色彩管理的整数化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。