7.2 色调映射

把 Reinhard、扩展 Reinhard(白点)与 ACES filmic 近似三条色调映射曲线写成全整数,喂同一份《烛影山谷》HDR 辐射度(曝光 6.0 后 max 4.33),实测输出曲线与 8 位直方图:Reinhard 均值 165.89 不裁剪、扩展 Reinhard 裁掉 156 像素、ACES 均值 213.43。

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):

xReinhard扩展 ReinhardACES
0.5218452218640389
1.0327683481652677
2.0436905461359955
4.05242810485763793

读法:在 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 色彩管理的整数化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配