内容工厂. / 演示空间 / PBR 高级效果 · 第 7 课 · 环境与输出 段 1 / 3
PBR 高级效果 | 第 7 课 | 上一课:介质与透射

环境与输出

读时 约 3 分钟 本课 3 段 · 已上线 3 段 每段 三件套 保真度 示意 · 等价重现(全课) 页面版本 v7
课题骨架(本课题只声明一次)

着色不是「给表面涂颜色」,而是解一个积分:对每个着色点,把所有入射方向的贡献按 BRDF 加权累加。 PBR 的全部内容就是——怎么把这一个积分算得既接近物理、又跑得动。

本课把这个积分的两头补齐:前六课的入射光一直是我们指定的光源(点光、面光、一颗太阳), 算完就直接写进像素。可真实场景里最大的那个光源不是灯 —— 是整个世界:天空、墙、地面、别的物体。 而算完的那串数也不是像素:它是一串辐亮度,中间还隔着曝光、色调映射与编码。 本课先把「入射光」从一个光源换成一整张环境图,再把「输出」这一段补上。

7.1
一张环境图变成照亮
7.2
缝里的光去哪了
7.3
从辐亮度到像素
7.1

环境不是一盏灯:一张图怎么变成照亮

示意 · 等价重现

前面六课的入射光都是解析的:点光是一个方向 + 一个强度,面光是「在哪个四边形上做积分」, 6.5 那颗太阳是「一个方向 + 一个相函数」。写出式子就能算。可这些式子都建立在同一件事上 —— 我们知道光从哪儿来。

真实场景里最大的那个光源不是灯,是整个世界:天空、墙、地面、别的物体都在发光、也都在反射。 它没法写成「一个方向 + 一个强度」,只能拿一张环境图(每个方向一个辐亮度 L(ω))来表示。 于是前六课那个积分换了一个输入: Lo = ∫ L(ω) · fr · cosθ dω —— 这里 L(ω) 不再是一盏灯, 而是一张图里每个方向的值。 本段只增加一样东西:入射光从「一个光源」换成「一整张环境图」。三件反直觉的事: ① 这个积分不能每帧硬算:一个像素要对上半球积几千个方向,而它其实只依赖两件事 —— 法线(漫反射那份)与反射方向 + 粗糙度(镜面那份)。于是真实引擎把这张图预加工成两份小表: 一份是低频的辐照度表(球谐 9 个系数就装得下),一份是按粗糙度预过滤过的镜面链。 ② 两份表的"糊法"完全不同:辐照度那一份被余弦瓣重重糊过,所以球谐 9 个系数就够; 镜面那一份只按粗糙度糊,所以粗糙度低的时候它还留着环境的形状 —— 一张图,两种分辨率。 ③ 把太阳交给球谐会糊掉,而且错在别处:球谐只有 9 个系数,装不下一个 0.268° 的太阳盘; 代价不是"太阳那处少了一点",而是整球被摊平 —— 最差的那一处落在背着太阳的一侧 (判据②量出这个偏差,并如实报出它有多大)—— 这正是真实引擎把太阳单独拿出来当直接光的原因。 保真度如实标「示意 · 等价重现」:环境光在图形 API 层不可观测,本页按写明的算式逐像素重现 (件一框里那几行就是件二真正在跑的)。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只增加一样东西:入射光换成一张环境图 同一张图,两条路:一条被余弦瓣重重糊过(低频),一条只按粗糙度糊 环境图 L(ω):程序生成,但每一项都定标到真实值 上半球:天顶深蓝 → 地平线乳白 (CIE 晴空梯度,地平线是天顶的 3.65 倍) 下半球:地面(暖褐灰、近地平线更亮)—— 球的下半吃的是它 法线 n 反射方向 R 辐照度表(SH-9) 预过滤镜面链 (按粗糙度分档) 两份表都从同一张环境图预加工出来 —— 三件同源就靠这一条 左列:这一步吃什么 环境图 · 法线 · 粗糙度 辐照度 E(n)(漫反射) — 预过滤镜面 — 右列:出来多少 与件三、与画面同源 SH 的 9 个系数(0 阶) — 镜面项 A(F₀·A+B) — 环境光 · 件二真正在跑的那几行 const E = irradiance71(nx, ny, nz); const pf = preEnv71(rx, ry, rz, rough); const ab = brdfLut71(nv, rough); const dif = kd * E[c] / PI; out[c] = dif + pf[c] * (f0 * ab[0] + ab[1]);
上半画的是同一张环境图被加工成两份表:左边那份走余弦瓣卷积(对每个法线,把上半球按 cosθ 加权积一遍)—— 这一步把图糊得非常厉害,所以 9 个球谐系数就够装; 右边那份只按粗糙度糊(粗糙度低就几乎不糊),所以它还留着环境的形状 —— 但留着的是天光与地面,不含太阳盘:真实引擎把太阳从环境图里扣掉、单独当直接光 (Unity HDRP 的 Include Sun In Baking、Filament 的独立 SUN 光都是这件事), 本页照做(预过滤链与球谐求积都走"不含盘"那一份)。右列读数与件三、与画面同源。 球的两半因此吃到的是不同的东西:上半吃到天空,下半吃到地面 —— 这正是「环境光」与「一盏灯」最直观的差别。 下半框里那几行是件二画每个像素时真正执行的算式:E 来自辐照度表、pf 来自预过滤链、 ab 来自那张 BRDF 积分表(下面那句 f0 * ab[0] + ab[1] 就是 split-sum 的后半段)。 本段只做「环境」这一头:算完的 out 直接夹到 0–1 就写进像素了 —— 为什么这一步不能这么干,留给 7.3。

① 先看明暗分布:球上最亮的一圈不在正中、偏上偏右(那是太阳的方向 —— 太阳在镜头背后, 高度 30°、方位 152°),往下是一条连续的过渡,到底缘只剩 30 出头。 天光是整个上半球在照它,所以这个过渡里没有任何一条硬边 —— 这是「环境光」与「一盏灯」最直观的差别。

② 拖粗糙度(0.02 → 1):低粗糙度时镜面那一笔能分辨出地平线,一路拖到 1 就糊成一片; 但糊的方向只跟粗糙度有关、和「法线朝哪」无关 —— 这就是「预过滤」这三个字的字面意思。

③ 把金属度推到 1:漫反射那一笔归零(金属没有漫反射),球变成一面镜子。 注意它反而更暗了(实测球面均值 142 → 33)—— 这不是 bug:镜子显示的是环境的辐亮度 (天光 0.1~0.4 量级),而刚才那个亮球是「太阳照亮的漫反射面」,本来就比天光辐亮度亮三四倍。 「被照亮」和「反射」是两件事,量级也不同,这句话在算式里就是 albedo·E/π 与 pf 的差别。

④ 按「只取一个方向」:球上立刻出现一个硬边的亮斑(法线正好指着太阳那一点), 而真 IBL 那一侧是糊开的 —— 因为积分把太阳摊到了整个上半球。 这一对是「环境光是一张图」与「环境光是一个样本」的差别,也是件三②里那个对照(十个方向里中位差 55%)。

⑤ 拖环境旋转:镜面那一层跟着转(实测这一点的值变了 69%),而漫反射那一层逐位不动 —— 辐照度是 9 个球谐系数重建出来的,转运算是正交变换,各阶能量不变(判据⑤量的就是这一条)。 转到太阳出现在画面里,还能看见它那一圈被削成纯白:本段没做色调映射,而这正是 7.3 要补的那一步。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 一张环境图里的一个球
上:环境图里的一个球(背景就是那张图) | 下:那张环境图的等距柱面展开
启动中…
本段新增 · 入射光是一整张图
开关(用来做对照)

四个杆都是本段新增的:粗糙度决定镜面那一笔糊到什么程度、 金属度决定漫反射那一笔还剩多少、环境旋转用来核「低频那层不跟着转」、 曝光是写像素前的那一下(7.3 才讲它)。

定点观察(三步):① 把金属度拖到 1、粗糙度拖到最左 —— 球变成一面镜子;② 看球的下半,以及下面那条等距柱面展开里地平线以下的那一半; ③ 结论:球下半映出来的不是"一片平涂的灰",而是地上明暗不一的斑块与偏暖 / 偏冷的色块 —— 那就是地面自己的反照率,不是哪盏灯照出来的;而且这些色块越靠地平线越淡, 是被气幕按透过率压下去的(不是把整块颜色"混向天空色"—— 混色会让远处的斑块照样满对比度)。 再把粗糙度往回拖,这些结构会被镜面那一笔糊掉:环境图里的高低频,只有镜面这一笔分得开。

看什么:上面的球是逐像素算出来的 —— 每个像素算一次法线、一次反射方向, 然后查两份表(辐照度、预过滤镜面)再叠上 F₀·A + B 那一项; 背景不是贴图,是同一张环境图在这个视线方向上的值(所以球"待在图里")。 下面那条是同一张环境图的等距柱面展开(能看见天顶的云、地平线、地面与那头的地平线雾), 用来对照球上那两半吃的是什么。
如实标的五处:① 环境图是程序生成的,但每一项都对着真实值定标 —— 天空亮度按 CIE 标准一般天空(ISO 15469)的晴空梯度 φ(Z)=1−e^(−0.32/cos Z) (地平线 / 天顶 = 3.65)、颜色从天顶深蓝(CCT > 10000 K)到地平线乳白、 太阳角半径 0.268°(Bruneton & Neyret 的 0.004675 rad;第 6 课 6.5 用的也是这个量级)、 盘与天空的辐亮度差约 1e5(≈16.5 stops,真实约 1.6e5)、地面反照率取 0.10~0.15 那一档, 天上有一层随方位与仰角缓变的云(覆盖率约四成,且云自己有明暗:厚实处暗、朝太阳的边缘亮), 地上有随方位与距离缓变的反照率斑块 + 坡度明暗 + 色度斑块(远处按气幕透过率压淡)。 它仍然不是 HDR 照片:够用来讲「一张图怎么变成照亮」,但不代表真实环境的统计; ② 球谐只取到 2 阶(9 个系数),这是实时引擎的常见档位,代价是太阳那一份被摊平 —— 而最吃不住的那一处不在太阳方向上:朝太阳那侧只是少给一点,真正差得多的是 背着太阳的那一侧,因为那里本来就暗,凭空多给一点就是几十个百分点的相对差 (判据②量的是「十个可复现方向里最差的那个」,并把太阳与反太阳都放进方向集; 若改成取一圈平均,这几个数会缩掉一个数量级,看着像"球谐挺好",如表②); ③ 预过滤链只有 5 档、每面 24×24(真实引擎用 6–8 档、256² 起步),一格约 3.8° 宽 —— 比那个 0.268° 的盘大一百倍,所以盘在任何一档都采不到。真实引擎的解法是把它单独当直接光 (本页照做:预过滤链与球谐求积都走"不含盘"那一份),所以镜面那一笔里没有太阳亮点、 只有天光与地面 —— 这是对的,不是缺件; ④ 没做色调映射(也没有 bloom):算完直接夹到 0–1。默认取景里太阳在镜头背后, 所以夹白是 0%;把环境转到太阳进画面、或者把曝光推到 3,就能看见它那一圈被削平 (读数与表②里那个"夹白占比"会跟着涨)。这正是 7.3 要讲的那一步,本段故意不做; ⑤ 环境旋转是"转系数"(l=1 一块 3×3、l=2 一块 5×5),不是重新投影 —— 判据⑤把两条路都量了:转系数逐位不变,重投影差 5%(那是求积走样)。
代价如实记:预加工(SH 投影 + 预过滤链 + BRDF 表)在开机时算一次,约 0.2 秒 (其中预过滤链那 829k 次 GGX 采样占八成 —— 真实引擎这一份是离线算好、或者用 compute shader 铺到几百个线程上); 画面每像素只查表,帧时不随粗糙度变(拖杆时内部分辨率降到 0.7 倍)。
件三 中间量数值表 · 与件一、件二同源
① 球谐 9 个系数与它重建的辐照度(手算可核:白炉那一行)
阶系数(R / G / B)本阶能量占比重建 E 与直接积分的相对差
② 两份表 · 本段的正题
项值
① 第一行是 0 阶:它等于 ∫L dω / (4π),也就是「整个环境平均有多亮」; 能量占比那一列说明这份图里低频占了多少(占比越高,9 个系数越够用)。 最后一列是重建误差:拿 9 个系数重建的 E(n) 与逐方向直接积分比(取十个方向里最差的那个)。 表②里那一行把这个误差拆成三档:整球平滑渐变只有千分之几(球谐该做到的)、加地面升到几个百分点、 再加太阳就升到几十个百分点 —— 那是球谐装不下"小而极亮的源"的代价, 而且涨得最大的地方在暗的那一侧(表②那行把这件事摊开,如实报,不藏)。 ② 第一组是两份表的尺寸与预加工耗时与那条「0 texel 占比」(用来盯住预过滤链有没有整块采空); 中间几行是 split-sum 的两项 A / B、SH 误差的三档、旋转不变性的两条路、 与「预过滤镜面 vs 一次采样」的对照;最后一行如实报出本页那一档的帧时、像素数与夹白比例。 表里每一个数都能追到件一框里那几行算式 —— 三件同源不是口号,是同一份预加工数据在三个地方显示。
本节获得

本段只把「入射光」换成了「一张图」,课题三那个积分的输入就补齐了:

  • 积分没变,输入变了:Lo = ∫ L(ω)·fr·cosθ dω 这条式子从第 1 课一路用到第 7 课, 变的是 L(ω) 从「一个方向」变成「一张图」—— 于是核算不过来了,必须换预加工的做法。
  • 预加工的依据是「这一份有多糊」:漫反射那份被余弦瓣糊得最狠(所以 9 个球谐系数就够), 镜面那份只按粗糙度糊(所以要一整条 mip 链)。分辨率不是玄学,是积分核决定的。
  • 球谐的低频是有边界的,而且它的错会长在别处:它装不下太阳那样一个小而极亮的源, 但代价不是"太阳那处少了一点",而是整球被摊平 —— 最差的一处是背着太阳的那一侧 (判据②量出这个偏差,方向集里含太阳与反太阳),于是「为什么引擎把太阳单独当直接光」 不再是一句经验之谈,而是有数的。
  • 一份图,两笔账,量级还不同:albedo·E/π(被照亮)与 prefiltered·(F₀A+B)(反射); 金属度把它们连起来 —— 金属没有漫反射,正是 kd = (1−金属度) 这一笔归零。 而两笔账的量级差三四倍:镜子显示的是环境的辐亮度,亮球显示的是照度 —— 这就是第七课要与「输出」连起来的那件事。
7.2

缝里的光去哪了:一个点看不见整个上半球

示意 · 等价重现

7.1 那个积分里有一项一直被当成 1 用 —— 可见性。前六课的光源都是"指定的", 看不看得见一目了然;7.1 换成环境图之后,活例里那个球孤零零悬在空中,于是每个点都看得见整个上半球, V(ω) ≡ 1 这件事也就没人追究。

真实物体不是孤零零的:墙角、缝、沟、褶皱、两只手之间那道缝 —— 那些地方看不全天。 把它们也算进那个积分,式子就多一项: Lo = ∫ V(ω) · L(ω) · fr · cosθ dω。 本段只增加一样东西:积分里多一个 V(ω)(这个方向到底通不通)。三件事: ① V 是一个 0 / 1 的函数,它只回答"这个方向通不通",从不回答"挡掉了多少光" —— 于是引擎里那个标量近似(环境光遮蔽 AO)只有在环境各处一样亮的时候才等于真值; ② 缝里的光没有消失,是没进来:沟底看得见的那条天只占整个上半球的一小块, "那一块有多大"与"那一块值多少"是两笔账,本段把它们分开摊开; ③ 反直觉的那一处:AO 只统计挡掉了多少方向,不统计挡掉的是哪些方向 —— 把环境从"均匀天光"换成一扇窗,AO 给出的数会偏得很厉害(判据⑥把"什么时候好、什么时候坏"两个数都量出来)。 保真度如实标「示意 · 等价重现」:可见性在图形 API 层不可观测,本页按写明的算式逐列重现 (件一框里那几行就是件二真正在跑的)。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只增加一样东西:积分里多一个 V(ω) 本段的几何:一条沿 z 拉伸的剖面(沟)—— 遮挡只取决于"抬高角" 探针(沟底) 左地平线角 hL 右地平线角 hR 两条线之间 = 看得见的天 环境图 L(ω) 剖面 h(x)(一个开合参数) 法线 n 同一条剖面沿 z 拉伸 ⇒ 遮挡只跟"抬高角"比 ⇒ V(ω) 有闭式,不用求交 左列:这一步吃什么 环境图 · 剖面 · 法线 无遮蔽辐照度 E(n) — 遮蔽后 E_occ(n) — 右列:出来多少 与件三、与画面同源 AO(余弦加权的可见率) — AO 预测 ÷ 真值(倍数) — 可见性 · 件二真正在跑的那几行 const hR = horizon72(ix, 1).h; const hL = horizon72(ix, -1).h; const cw = nx * PWX72[m] + ny * PWY72[m]; const dw = DW72 * cw; cs += dw; const v = vis72(PWY72[m], PS72[m], PCF72[m], hR, hL); if (v) { ao += dw; e1[0] += dw * L[0]; }
上面那张剖面就是本段唯一的几何:一条沿 z 拉伸的剖面。所以一条视线通不通, 只取决于它的抬高角有没有越过那一侧的地平线角 hR / hL —— 不用求交、不用加速度结构, V(ω) 有闭式,本页才敢逐列把它积出来。两条虚线之间那一小块楔形就是沟底看得见的那条天。 下面框里那几行是件二每一列真正执行的算式:先扫出两个地平线角,再对积分网格的每个方向判一次通断、 把 cos 加权的那一份分别记进 ao / Eo / Eu 三个累加器。 本段只做「可见性」这一头:L(ω) 用的还是 7.1 那张环境图(同一个函数,三件同源), 太阳盘照旧不含在内(与 7.1 同一条约定:它单独当直接光)。
件二 可操作活例 · 本页自算(示意 · 等价重现) · 一条按剖面算出来的沟
上:谷地剖面 | 中:V(ω) 积分网格 | 下:AO(x) 与 E_occ/E_un(x)
启动中…
本段新增 · 积分里多一个 V(ω)
环境(三档)

环境绕 Y 旋转这一根与 7.1 同名、同范围、同一个位置 —— 同一个动作,看新现象。另外四根是本段新增的:剖面开合决定沟有多深(0 就是平地, 用来读白炉那一行)、AO 半径决定地平线往两边扫多远(真实引擎里 AO 就是这个半径)、 探针位置决定中、下两块看的是哪一列、积分网格用来核"加密网格结果不变"。

定点观察(三步):① 环境切到均匀天光,把剖面开合从 0 拖到 1; ② 看下块里沟底那一段的三条曲线,以及表②第一行的两个数;③ 结论:真值那条与 AO 那条完全重合 —— 在"环境各处一样亮"时,AO 就是"看得见多少天"的恒等式(判据⑥量出倍数偏差 = 1.000000000), 所以它不是经验技巧。再把环境切到一扇窗:默认方位 0° 时窗落在剖面平面里、 被沟壁整个吃掉 —— 真值那条塌到 0.22(全是背景那点余光),AO 照样报 0.475,给多了 2.2 倍; 把「环境绕 Y 旋转」拖到 90°,窗转到垂直于剖面的方向、沟壁挡不住它 —— 真值回到 0.66,AO 还是 0.475,这回少给了 1.4 倍。同一个几何、同一扇窗,两次都错、方向相反: AO 数的是"挡掉了多少方向",从来不是"挡掉的是哪些方向"。

看什么:上块的剖面每一点都按它自己的账着色 —— 颜色来自那一列真实的遮蔽辐照度 (反照率 · E_occ / π,绿通道),所以沟底的暗是真算出来的,不是画上去的。 中块是探针那一列的积分网格:横轴方位、纵轴从"正上方"到"贴着地平线", 亮格子 = 这个方向通、暗格子 = 被剖面挡住,两条曲线就是 hR / hL —— 这块图就是那个积分本身,读者可以数格子核对表①。 下块横轴沿剖面,两条曲线分别是 AO(x)(几何量)与 E_occ/E_un(x)(真值), 三种环境各一组。
如实标的三处:① 几何是一条沿 z 拉伸的剖面,不是一般三维场景 —— 正因为它平移不变,V(ω) 才有闭式;真实的 AO 要靠射线求交或屏幕空间的深度缓冲, 那是本页这一条剖面做不到、也不打算假装的事; ② AO 半径是一个有限值(默认 0.30):真实引擎里它同样是个有限半径(否则一堵远处的墙会把 AO 压死), 所以"离得远的东西不该算进 AO"这一点,本页与引擎是同一件事; ③ 环境图还是 7.1 那张程序生成的那张(离太阳那一段的辐亮度按 1e5 定标), 不含太阳盘 —— 而真实的太阳恰恰是"小而极亮"的那种源, 把它算进来只会让 AO 的偏差更大(一个 0.268° 的盘被挡住,AO 只会按立体角比例少给一点点)。

① 先看沟底为什么会暗:把剖面开合从 0 拖到 1,上块两壁越立越高,中块的两条地平线曲线越压越低, 亮格子被吃掉一大片。到最深处,沟底看得见的天只剩顶上一小块 —— 光没有消失,是没进来。

② 把「AO 半径」从 0.30 拖到 0.06:沟底反而变亮。这不是 bug,是 AO 这个量的定义: 半径小 ⇒ 只把附近的东西算作遮挡 ⇒ 远处的坡不算数。真实引擎里这根杆就在那里, 所以两套管线的 AO 贴图互相之间是不可比的,除非半径与衰减写在一起看。

③ 把环境在「均匀天光 / 7.1 那张环境图 / 一扇窗」之间切:注意下块里三条曲线的间距。 均匀天光下真值那条与 AO 那条完全重合(判据⑥量出倍数偏差 = 1.000000000 —— 那不是"接近",是恒等式); 切到那张真实环境图,间距来自天顶与地平线的亮度差 —— 几何挡掉的往往是地平线那一圈,而那一圈最亮, 所以真值低于 AO(全列最差 1.139 倍);切到一扇窗、窗在方位 0° 时被沟壁整个吃掉: 沟底真值塌到 0.22 而 AO 仍是 0.475(给多了 2.2 倍), 左壁上那一列最夸张 —— 8.5 倍(那面墙正对着窗,却看不见它); 把窗转到方位 90°(垂直于剖面、挡不住),沟底真值回到 0.66、AO 还是 0.475(少给了 1.4 倍, 全列最差 1.49 倍)。 同一个几何、同一扇窗,两个方向都错 —— 这就是本段最想让人看见的那件事:AO 是一个把"挡住多少方向"当成"挡住多少光"的近似。

④ 拖「积分网格」:8 → 32 之间结果基本不动(实测 0.4404 → 0.4712 → 0.4746, 16 → 32 只动 0.72%)。收敛慢的那一头在方位方向:V 是 0/1 的、 在边界上是个跳变,中点法在那里的误差只有 O(1/N);仰角方向是按 cosθ 均匀取样的, 对 cos 这一项是精确的(判据①就是拿它当白炉)。这也顺带说明 为什么引擎敢在屏幕空间里用二三十个样本去估它。

⑤ 拖「环境绕 Y 旋转」:AO 那一组曲线逐位不动(它是几何量,与环境无关), 而 E_occ 那一组跟着转 —— 判据⑤量的就是这一条。这一对差别正是件三②里三行数字的来历。

件三 中间量数值表 · 与件一、件二同源
① 探针那一列的地平线扫描(手算可核:取最大值)
步 kx抬高角 atan(Δh / t)hR 累积
② 三种环境的账 · 本段的正题
环境AO / E_occ÷E_un / AO 近似的相对差
① 每一行就是一次扫描:从探针朝右走一步 t,算 atan(Δh / t), 取过程中的最大值 —— 那个最大值就是 hR(左向 hL 同理)。 表里最后两行给出这一列的两个地平线角与网格合计(Σ V·cos·dω / π), 末行是守恒核对:平地且没有遮挡时这个合计必须逐位等于 1。 ② 三种环境三行:AO 是几何量(与环境无关,所以三行应当逐位相同)、 E_occ / E_un 是真值之比、最后一列是"把 AO 当成标量去乘无遮蔽辐照度"带来的相对差。 均匀天光那一行是 AO 的适用区(差值几个百分点),一扇窗那一行是它的失效区。 表里每个数都能追到件一框里那几行算式 —— 三件同源不是口号,是同一份积分在三个地方显示。
本节获得

本段给那个积分补上了最后一项输入 —— 它到底看不看得见环境:

  • 积分一项没多、也没少:Lo = ∫ V(ω)·L(ω)·fr·cosθ dω。 前六课 V 恒为 1(光源是我们指的),7.1 里 L(ω) 变成了图、 V 还当 1 用 —— 本段把 V 补上,这个积分的三个因子就齐了。
  • V 是 0/1 的,但它带来的量不是 0/1:真正被改的是"有多少 cos 加权的立体角还通着", 而这正好就是 AO 的定义。所以 AO 在环境均匀时不是一个经验技巧,而是一个恒等式; 在环境不均匀时它是一个把方向信息压成一个标量的近似。
  • "什么时候坏"是可以量出来的:本段把均匀天光与一扇窗两个极端都跑出来, 判据⑥同时报出"好的那档差多少"与"坏的那档差多少" —— 一个只报好数的判据在这里毫无意义。
  • 下一段要收的尾:到这里 Lo 算出来了 —— 它是一串辐亮度, 太阳那一带能到 1e5 量级,而屏幕只有 0–1。7.1 与 7.2 都故意在最后一步直接夹住, 7.3 才讲那一步(曝光 → 色调映射 → 编码)。
7.3

从辐亮度到像素:一串数怎么变成 0–255

示意 · 等价重现

7.1 与 7.2 这两段的活例,最后一步都是同一句话:算完直接夹到 0–1 就写进像素。 那句"直接夹"在真实管线里不是一句话,是三步。

那里算出来的 Lo 是辐亮度:天上那片蓝是 0.3 量级, 那颗太阳盘是 4e4 —— 同一张画面里差了 17 stops。而屏幕只有 0–1、寄存器只有 8 位。 本段只增加一样东西:算完的辐亮度在写进像素之前,多经过「曝光 → 色调映射 → 编码」这条链。三件事: ① 曝光是线性的:它只是把整条数轴乘一个系数 —— 所以"看见暗部"和"看见亮部"是互相排斥的。 这不是参数没调好,是这条链的形状决定的; ② 色调映射是有界的、而且必须单调:它要把 [0, ∞) 压进 [0, 1], 又不能把亮的地方映得比暗的地方还暗。代价是亮部的对比被压扁 —— 所以"过曝"真正的意思不是"太亮了",是"那一段的信息被搬进了 1 个字节里"; ③ 编码不是可选的:8 位只有 256 档,线性写进去时暗部(0–0.02)只占 5 档, 于是暗处出现台阶(banding)—— 这一条就是 sRGB 那个"看起来多余的"分段函数存在的唯一理由。 保真度如实标「示意 · 等价重现」:传输函数与色调映射算子在图形 API 层不可观测, 本页按写明的算式逐像素重现(件一框里那几行就是件二真正在跑的)。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只增加一样东西:写像素前多一条链 曝光(线性)→ 色调映射(有界 · 单调)→ 编码(sRGB 传递函数) 一张画面里的动态范围:横轴是对数的辐亮度 10⁻² 10⁰ 10² 10⁴ 0–1(屏幕能表示的) 天光 0.1–0.5 地面 0.05–0.15 太阳盘 4e4 两边差了 17 stops ⇒ 中间那三步不是"调颜色",是必须做的搬运 左列:这一步吃什么 辐亮度 L(HDR)· 曝光杆 太阳盘心的 L(绿) — 当前曝光 2^EV — 右列:出来什么 与件三、与画面同源 它写进像素的那个字节 — 被削平(= 255)的占比 — 输出链 · 件二真正在跑的那几行 const e = Math.pow(2, state73.ev); const x = L[ch] * e; const y = tm73(x, state73.tm); const v = enc73(y, state73.enc); const b = byte73(v);
上面那条是对数数轴:这张画面里最暗的地面与最亮的太阳盘差了 17 stops, 而左边那个绿色方块 —— 0–1 —— 才是屏幕能表示的。所以问题不是"颜色不对", 而是要把一条 17 stops 的数轴塞进一个方块,而这一步要动三下: 曝光把整条轴平移(乘一个系数)、色调映射把它折起来(有界、单调)、 编码把它切成 256 刀(切得均匀还是切得对数,决定了暗部有没有台阶)。 下面框里那五行就是件二每个像素真正执行的算式 —— 三件同源就靠这一条: 件一的读数、件三的表、件二的四条带,调的是同一份 L。 本段只做「输出」这一头:L 用的还是 7.1 那张环境图(同一个函数), 只是这次把太阳盘放回了图里 —— 7.1 讲光照时它是被单独拿出去的,这一段讲动态范围,它必须在场。
件二 可操作活例 · 本页自算(示意 · 等价重现) · 同一条 HDR 扫线的四遍处理
上:对数直方图 | 中:四条链 | 下:算子曲线
启动中…
本段新增 · 写像素前的这条链
色调映射
编码

环境绕 Y 旋转这一根与 7.1、7.2 同名、同范围、同一个位置(三段的第三个控件都是它)—— 同一个动作,看新现象:把太阳转进这条扫线里。另外两根(曝光、色调映射、编码)就是本段新增的那一样东西。

定点观察(三步,一次只动一根):① 把色调映射切到「无」、编码切到「线性」—— 看第三条带一路退回第一条带的样子(那就是 7.1 / 7.2 干的事:什么都没做); ② 再把色调映射切回 ACES、把曝光从 0 拖到 +3,看第二条带(只加曝光)与第三条带(这条链) 以及右边"被削平的像素占比";③ 结论:只加曝光那条带整片糊成白色 —— 曝光是线性的,它把亮部推出 1 之后什么也留不下(读数从 0.002% 跳到 64%); 而第三条带在同一档曝光下,天光的明暗还在,只是对比被压扁了。 三根杆各有各的职责,把任何一根当成"调亮度"都会有一头坏掉。

看什么:上块是这条扫线的对数直方图(横轴 = log₁₀ L,纵轴 = 像素占比), 绿框标出 0–1 这个"屏幕窗口"占多窄一段;中块是同一条扫线的四遍处理, 每一条带左边写着它是什么: ① 直接夹(7.1/7.2 干的事,无视所有滑块)、② 只加曝光、 ③ 本段这条链(曝光 → 色调映射 → 编码,跟着三根杆走)、 ④ 不编码(曝光 → 色调映射 → 直接线性写 8 位)—— ③ 与 ④ 只差最后那一步,用来单独看编码。下块是算子曲线,横轴与直方图对齐, 所以"直方图上哪一段被曲线压平了"可以直接对过去看。
如实标的四处:① 场景是 7.1 那张程序生成的环境图的一条等距柱面扫线(820×66), 底图取「天光梯度 + 地面」那一份(去云),再按 7.1 同一个函数把环日增亮与太阳盘加回来 —— 去云是为了可复现:云盖住太阳时那一格会变成"一朵云的亮度"(实测被压到 1.4e3), 那本身没错(云确实挡在太阳前面),但它会随云的花样变,读者没法复算;盘按方向解析加, 与 7.1 把盘解析加进球谐是同一手。它的动态范围是"地面 0.05 + 天空 0.1–0.5 + 盘 3.9e4",约 17 stops, 与真实户外 HDRI 同量级,但不是同一份统计; ② 顺带量到一件事:这张扫线一格 0.44°×2.5°,装不下一个 0.268° 的盘 —— 盘落的那一格读到的是 9.1e3(这一格立体角内的平均值),而盘那个方向上是 3.9e4。 同一件事 7.1 也遇到过("盘平均只占球谐求积的 0.02 个格子"),只不过那次发生在球谐求积里; ③ 色调映射用的是两条例子里最常被引用的近似(Reinhard 2002;ACES 的 Narkowicz 2015 拟合曲线), 真实的 ACES RRT+ODT 是一整套色彩管理,本页不假装是它; ④ 不做 bloom、不做局部色调映射(那是另外两件事)—— 本段只讲这条逐像素的链。

① 先把曝光推到 +3:第二条带(只加曝光)整片变白,天光的明暗没了。 这不是"太亮了"这么简单:辐亮度乘 8 之后,凡是大于 1 的像素都变成同一个字节 255 —— 那一段的信息真的被扔掉了(读数里"被削平的像素占比"会从几跳到一大半)。

② 把色调映射切到 ACES:同样 +3 曝光,天光的明暗回来了。 因为色调映射是一条有界的曲线 —— 它先弯、再慢慢逼近 1,所以大于 1 的那一段 不是被扔掉,而是被挤进 1 附近的一小段。看下块:ACES 那条曲线在右边几乎水平, "压平"就是它字面的意思。

③ 把曝光从 −6 拖到 +6:注意第三条带从头到尾都是有内容的, 而第二条带只在很窄的一小段里好看。这就是"曝光是线性的"这句话的全部后果: 它只能整体平移,不能压缩,所以总有一个地方被削平。真实相机调曝光就是在选"削哪里"。

④ 把编码切到「线性」:对比第三条带与第四条带。两条带的亮部几乎一样, 但暗部不同 —— 线性那条在暗处出现台阶(同一片渐变被切成几块)。 读数里"暗部(最暗 25%)占几档"会给出来:④ 不编码那条是 14 档、③ 是 31 档; 判据⑥另在 [0, 0.02] 这一段上单独量,线性只有 6 档、sRGB 有 40 档。 这是 sRGB 那个分段函数唯一的用处:让 256 刀按人眼的对数感觉去切。

⑤ 拖「环境绕 Y 旋转」把太阳转进扫线:直方图最右端那一撮就是它。 注意它离绿框有多远 —— 那个距离就是"直接夹"要付出的代价; 也注意它在 ACES 曲线上落在了哪一段(几乎水平的那一段)—— 所以太阳在成品里永远是一个白点心,区别只在于它周围那一圈有没有层次。

件三 中间量数值表 · 与件一、件二同源
① 五个代表方向的完整账(手算可核:拿件一的公式算一遍)
方向Lx = L·ey = f(x)编码后字节
② 四条链的账 · 本段的正题
项值
① 五行分别是太阳盘心 / 离太阳 10° / 天顶 / 地平线 / 天底(都是可复现的方向,不是随手挑的像素)。 每一行把它走完整条链:x 是乘完曝光的辐亮度、y 是色调映射之后、 编码后 是 0–1 的传输函数输出、字节 是最终写进像素的整数 —— 拿件一框里那五行手算一遍就能对上(ACES 那条式子是 y=(x(2.51x+0.03))/(x(2.43x+0.59)+0.14))。 ② 上半是四条带各自的读数(被削平的像素占比 / 唯一字节档数 / 暗部占几档), 下半是判据读数:恒等、单调、有界、编码往返、削平对比、暗部台阶、同源。 表里每个数都能追到件一框里那几行算式 —— 三件同源不是口号,是同一份 L 在三个地方显示。
本节获得

本段把那个积分的出口补上了 —— 到此,从入射光一路到屏幕上的一个字节,链条是完整的:

  • 积分算出来的不是像素,是辐亮度:Lo 的单位量级由场景决定 (本页这一份从 0.05 到 4e4),而屏幕只有 0–1。所以"变成像素"这一步不是收尾杂活, 而是必须做的搬运,而且它要丢信息 —— 问题只在于丢哪一段。
  • 三下各有各的职责,不能互相替代:曝光是线性的(只平移,不压缩,所以救不了动态范围); 色调映射是有界且单调的(压缩,代价是亮部对比); 编码是重新分配 256 刀的(不改变范围,只改变哪里切得密)。把三件事混成一件,就必然有一头坏掉。
  • "过曝"的正确读法是"信息被搬走":色调映射把亮部压扁之后, 那一段的对比并没有消失到无处可去 —— 它落进了 1 附近的一小段字节里。 所以"有没有细节"变成了一句可量的话:那一段里还有多少个不同的字节。
  • 课题三到此收束:这个积分从第 1 课一路解到第 7 课 —— 入射项(1 课)→ 被积函数(2–5 课)→ 可见性(4 课、7.2)→ 介质里的积分(6 课)→ 输入换成一张图(7.1)→ 出口(7.3)。 骨架那句"着色即解一个积分",到这里每一个因子都摊开量过了。
本课其余段落 · 未上线
7.4 (本课已收束) 第 7 课三段全部上线:7.1 环境图 · 7.2 遮蔽 · 7.3 输出。课题三「PBR 高级效果」到此收束。 已收束
运行时自检 等待脚本…

本段的活例由本页自己计算(示意 · 等价重现,非硬件产出):不依赖 WebGPU,任何设备都能跑。同一页的示意图与数值表照常可读。


  

页面版本 v7(v7 只修字的几何,算式与判据一行未动 —— 把 svg-bbox.mjs 补全到五条判据(原先少了「文字戳出所属面板」这一条)后复量本页三张件一, 查出 5 处「字戳出格子」(格子有可见描边,越过 4px 就看得出字压线): 7.1 的「辐照度 E(n)(漫反射那份)」越出 18.8 px(收成「(漫反射)」)、「镜面那笔(F₀·A + B)的 A」越出 17.7 px(收成「镜面项 A(F₀·A+B)」); 7.2 的两个三元组读数越出 6.7 px(分隔符由 / 收到 /); 7.3 的「被削平(= 255)的像素占比」越出 18.8 px(收成「被削平(= 255)的占比」)。 另如实记一处轻微不去动的:7.1 的「SH 的 9 个系数(0 阶)」越出 3.3 px(≤4 px,描边 0.44 alpha,几乎看不出)。 v6 = 第 7 课「环境与输出」第 3 段 · 7.3「从辐亮度到像素:一串数怎么变成 0–255」上线;本课三段到此全部上线,课题三收束。 本段补的是那个积分的出口:算完的那串数是辐亮度,不是像素 —— 中间隔着三步,各有各的职责、不能互相替代。 曝光是线性的(只平移不压缩 ⇒ 救不了动态范围);色调映射有界且单调(把 [0, ∞) 压进 [0, 1],代价是亮部对比被压扁); 编码重新分配 256 刀(sRGB 传递函数只改哪里切得密 ⇒ 修的是暗部 banding)。 件二把四条链并排:① 直接夹 / ② 只加曝光 / ③ 本段这条链 / ④ 曝光 + 色调映射但不编码 —— 于是「削平了多少」可以按字节数出来。 参考曲线全部取一手源:Reinhard 2002 的 x/(1+x)、ACES 的 Narkowicz 2015 拟合、sRGB 传递函数 IEC 61966-2-1(分段点 0.0031308 / 12.92 / 1.055 / 2.4 / 0.055)。 判据八条:恒等(EV 0、不调、线性编码下本段与"什么都不做"逐字节相同,最大差 0)/ 单调(3606 点下降 0 次)/ 有界(Reinhard 0.999999、ACES 白点 x ≈ 5.62)/ 编码往返(差 0;中灰 0.18 → 118/255 —— 这条是 sRGB 曲线的自证)/ 削平的账(EV +3:只加曝光 63.96% → 本段这条链 0.00185%)/ 暗部台阶([0, 0.02] 这一段上线性只有 6 档、sRGB 有 40 档)/ 画面 / 与 7.1 同源(dSrc 0)。 四处如实标:① 扫线去云(云盖太阳会随云的花样变,读者没法复算);② 这张扫线一格 0.44°×2.5°,装不下一个 0.268° 的盘 —— 盘落的那一格读到的是 9.1e3(该格立体角内的平均值),而盘那个方向上是 3.9e4;③ 暗部档数的门槛取扫线亮度的下四分位(Lq25 = 0.0817),不是硬编码; ④ 顺带修了 7.1 里潜伏的一处 bug:azFast71(0, 0) 是 0/0(方位在极点没有定义),7.1 的求积方向从不正好落极点所以一直没露,7.3 把"天底"列进可复现方向表当场 NaN。 v5 = 第 2 段 7.2「缝里的光去哪了:一个点看不见整个上半球」。本段只给那个积分加一样东西:可见性 V(ω) —— 7.1 里它被当成 1 用(球孤零零悬在空中,每个点都看得见整个上半球)。 几何选的是一条沿 z 拉伸的剖面(沟):平移不变 ⇒ 一条视线通不通只取决于它的抬高角有没有越过那一侧的地平线角 ⇒ V(ω) 有闭式(一次比较),不用求交、不用加速结构,于是本页敢把 257 列的积分全算出来摊开给读者笔算。 两处实现细节是这一段的骨头:① 仰角方向按 cosθ 均匀取样(PBRT 的 cosine-weighted 半球那一手), 于是 Σcosθ·dω = π 逐位 —— 判据①的白炉核的就是这一步(按 θ 均匀取样会差 1e−3,而那个差与环境无关, 看着像"物理"、其实是求积的错);② AO 用归一化定义(÷ Σcosθ·dω),所以"没有遮挡 ⇒ AO = 1"是逐位的。 判据八条:白炉测度 / 单调(沟越深越暗)/ 收敛 / 镜像对称 / 旋转(AO 逐位不动、E_occ 跟着转 67%)/ 近似失效 / 画面 / 与 7.1 同源(档 1 逐位等于 7.1 的 envL71)。 最要紧的那两个数:均匀天光下 AO 的预测 ÷ 真值 = 1.000000000(恒等式,不是"接近"); 一扇窗下同一个几何、同一扇窗、两个极端方位都错且方向相反 —— 窗被沟壁整个吃掉时 AO 给多 8.52 倍,窗垂直于剖面挡不住时 AO 少给 1.49 倍。 AO 数的是"挡掉了多少方向",从来不是"挡掉的是哪些方向"。 同批补上了段链联动(滚动更新顶栏段号与轨道高亮 —— 站内课页的既有约定,本页先前只有一段所以没装)。 v4 = 第 1 段 7.1 的画质重做:环境光不是一盏灯 —— IBL 的两笔账:球谐辐照度 + 预过滤镜面链 + split-sum BRDF 表;判据七条。v4 是再一遍画质,两处病灶都是"量出来的":① 天顶塌成一点 —— 云原先按"云层是一个平面"投影,水平距离 ∝ 1/tan(仰角),仰角 → 90° 处坐标必然归零,整片天顶是一个常数;改成方向空间的三维噪声(水平两轴仍按 1/(sin仰角+常数) 缩放保住透视收缩,第三轴交进 sin仰角 本身)。② 地面是一片竖条纹 —— 原先只按方位做一维表,同一列一个值;而它是画面里面积最大的一块(39%)。改成与云同源的平面投影表(远处斑块自然被压密),并加坡度明暗(噪声当高度场,沿太阳水平方位看高差)与独立的色度一路(只由明暗派生的话地面只有"一个参数":66 档亮度只对应 245 个颜色)。云也补了第二路云自己的明暗(厚实的中心暗、被太阳打亮的边缘亮),气幕改成加上去的 airlight(透射率 T 乘在斑块上,远处对比度自然衰减)。v4 还修了一个潜伏的越界 NaN:双线性查表在最后一行会读到表外,`undefined * fv` 是 NaN 不是 0;云只在 dy > 0 用所以从没碰到,地面用了 dy < 0 那一半,一碰就把整张环境图变成 NaN。实测:整帧唯一色数 7043 → 10200、天空 887 → 3203、地面 221 → 1176(区内梯度 p95 0.21 → 0.93)、判据七条仍全过。代价如实报:查表调用变多,帧时 88 → 96 ms(已量过与表体积无关 —— 把表缩小 16 倍毫无变化,所以不是缓存;拖动滑块时质量因子 0.7 会自动降分辨率)。v3 是按一手资料把环境图与球重做了一遍:天空用 CIE 标准晴空梯度(地平线/天顶 = 3.65)、太阳换成真实角半径 0.268° 并按球冠解析投影、环日增亮改成 Perez 那种乘性项、地面按反照率量级重定标、加了远山脊;太阳挪到镜头背后(原来球看得见的那半面永远是背光面)。v3 修掉三个真缺陷:① 预过滤链的半向量没做切空间→世界空间的变换,六个面里四个面 82~84% 的 texel 是精确的 0;② 环境旋转由"重投影 4096 个方向"改成转系数;③ 视口顶端那条 4 px 暗带。v1 首版、v2 只修了光晕宽度)| 看不到新内容时请硬刷新(Ctrl+Shift+R),或给地址加 ?t=N —— 本站的 HTML 不带缓存指令。