内容工厂. / 演示空间 / 全局光照 GI · 第 3 课 · 反弹一次 段 1 / 3
全局光照 GI | 第 3 课 | 上一课:把半球装进几个数

反弹一次

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

一个着色点接收到的光,有一部分来自别的物体反射的光;而别的物体,又接收到它反射的光。 要算清这一点,只能在场景里采样 —— 而一旦靠采样,就永远受 1/√N 支配。

这条骨架把 GI 收成三件事:在哪采样(探针 / 像素 / 路径)、采几次(一次反弹 / 多次反弹)、 怎么排(随机 / 分层 / 蓝噪声)。第 1 课量清了"不采样"的那一头, 第 2 课回答了第一件:在哪采样(探针 + 球谐,顺带量了它的三种错)。 本课回答第二件:采几次 —— 从一次反弹开始,而它有一个精确的入口:镜子。

3.1
镜面反射:把相机搬过去就够了
3.2
粗糙反射:一片糊的高光
3.3
一次反弹漏掉了什么
3.1

镜面反射:把相机搬过去就够了

示意 · 等价重现

前两课问的都是"光从哪儿来":1.1 把它压成一次乘法、1.3 换成眼前一屏的深度、 2.1–2.3 把它装进探针。它们全都在算入射。可骨架说的是另一件事 —— 光打在物体上会再飞出去,那个"再飞出去"的光,才是 GI 的本体。

要从"只算入射"跨到"光会再飞出去",最干净的入口是镜子:它把这件事做成了一条精确的几何命题 —— 镜面上每一点,只用一条反射线就够了(大学光学第一节课的内容)。 于是"一次反弹"在这里第一次有了一个能对答案的版本:参数化一支反射线, 逐点求交算出画面;再拿工程上真在用的做法(把相机搬到地板下面)去对 —— 两条路应当一模一样。 本段只加一样东西:一个能把地面法线扳歪的旋钮(镜面波纹 κ)。 κ = 0 就是一块平镜 —— 此时两条路逐像素相同(判据①); 而 κ 一旦不为 0,那句"把相机搬过去就够了"就开始说错话,错多少正是本段要量的东西。 三件量出来的事见下:判据①②给"平镜时是恒等式",判据④⑤给"弯了差多少", 判据⑥给"一次"这两个字的确切含义 —— 反射线只追一次,所以镜子里看不到"镜子里的镜子"。 保真度如实标「示意 · 等价重现」:整页是一台逐像素自算的光线投射器(CPU),不依赖 WebGPU; 件一框里那几行就是件二真正在跑的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:一个能把地面法线扳歪的旋钮(镜面波纹 κ) 地面是一面镜子:打到它的射线要按法线反射,再往前追一次 —— 只追一次 这一段的做法(一次反弹:反射线只追一次) ① 相机 E 出发,每个像素一条主射线 D    打到地面那一点 P、那一点的法线 N(由 κ 决定) ② 按法线反射得 r₁;把眼睛搬到地板下面得 E′ ⇒ r₂ = P − E′    两条路只差一行;平镜时逐位相同,κ ≠ 0 时分开 左列:这一段吃什么 相机(眼位) — 地面法线(波纹 κ) — 件二每像素跑的就是这几行 // ★ κ = 0(平镜)时两条路逐位相同 function mirrorDir31(D, N, P) { const r1 = sub(D, mul(N, 2 * dot(D, N))); const E2 = [C.e[0], C.e[1], -C.e[2]]; const r2 = nrm(sub(P, E2)); return METHOD31 ? r2 : r1; }右列:交给像素什么 两条路的方向夹角(最大 · 度) — 右块平均色差(探针 − 真值) — 一次反弹:反射线只测盒子与天,不测地面 —— 所以镜子里没有"镜子里的镜子"
看什么:左边两格是这一段要吃的输入 —— 一台相机和一层地面法线(波纹 κ = 0 时它们全是竖直的)。 中间框里是件二真正在跑的源码(函数 mirrorDir31 的正文,逐字未改): 它给出"打到地面的射线该往哪走",而它内部有两条路 —— r1 是教科书里的按法线反射,r2 是工程上真在用的"把眼睛搬到地板下面"。 右列是它交出来的两个数:两条路差多少度、以及画面差多少。
为什么"搬相机"会等于"按法线反射":设眼睛在 E、镜面是 z = 0, 射线打到镜面上的 P 点。把 E 关于镜面镜像得 E′,那么向量 P − E′ 与反射方向 r₁ 恰好平行 —— 这一步是可以拿笔算的(见件三表②),而且它只用到一件事:镜面是一个平面。 所以"镜子里看见的世界"="从 E′ 看见的世界",工程上就变成:把场景再渲染一遍, 相机搬到 E′,然后把结果贴在镜面上。这就是实时渲染里的平面反射。
如实标的三处:① 本页是一台CPU 光线投射器(逐像素自己算、写在 canvas 上), 不是 WebGPU 产出 —— 但"两条路是否相同"这条判据与后端无关; ② κ 只改地面的法线、不改地面的形状(几何仍是 z = 0,盒子照样站在地上)—— 这正是"水面"的模型:水面基本是平的,但法线可以被波纹扳歪; ③ 反射线只追一次:打到盒子就按太阳+天光着色、打到天就取天色,不再反射。 所以镜子里那个世界没有它自己的镜子(判据⑥逐条数过)。

① 先看左块(真值:逐点按法线反射):地面是一面镜子,盒子的侧面倒映在里面, 远处映出的是天。这就是一次反弹 —— 反射线跑到盒子上就停了。

② 把「镜面波纹 κ」从 0 拖到 0.30:左块里镜面开始起波纹,倒影被扯歪; 而中块(把相机搬过去的做法)还是那么干净 —— 两块开始不一样,右块亮起来。 结论:"搬相机"这句话的精确性,完全押在"地面是平的"上。

③ 把 κ 拖回 0:右块整个黑掉(判据①:两条路逐像素相同、一个格子都不差)—— 这不是"差不多",是恒等式。

④ 转「眼位方位」:相机绕着场景走,倒影跟着走 —— 因为镜面里看到的世界 等于"从 E′ 看到的世界",而 E′ 是 E 的镜像,它跟着动。

⑤ 把「天光强度」拖到 0:镜子里那片天黑了,可盒子的倒影还在(它靠的是太阳)—— 说明镜面只是把已经有的光搬了个位置,它自己不发光(判据⑧)。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 三块并排

左:逐点按法线反射(真值)| 中:把相机搬到地板下面 | 右:两者之差(灰阶)

启动中…

与前两课相同的三个控件 + 本课的两个

κ = 0 是一块平镜(两条路逐位相同); κ 越大,地面法线的倾角上限越大(几何仍是平的 —— 这是"水面"的模型)。

用来核判据的一个开关

控件与前两课只多不少:两个滑块与"盒子数"逐字相同; 另加本课新装的两样装置各一个旋钮 —— 相机(眼位方位)与镜面(波纹 κ)。 本段真正的新变量只有一个:κ。

定点观察(三步):① 看左块地面里盒子的倒影; ② 把 κ 从 0 拖到 0.30,看右块从全黑变成一片亮; ③ 结论:"把相机搬到地板下面"只在平面镜上成立 —— 镜面一弯,它给出的方向就与逐点反射分开了,而左块(逐点反射)才是对的。

看什么:三块并排,用的是同一个场景、同一台相机、同一条主射线, 只有"打到地面之后往哪走"这一样不同 —— 左 = r1(按法线反射,逐点求交), 中 = r2(把眼睛搬到地板下面,再看那个方向),右 = 两者的色差(灰阶)。
为什么 κ = 0 时右块全黑:这不是"误差很小",是恒等式。 对平面镜,E′ = (Ex, Ey, −Ez)、P 在镜面上,可以推出 P − E′ 与 D − 2(D·n)n 正好平行(件三表②逐点列出来,夹角一栏恒为 0)—— 所以两条路走的是同一条线,追到的是同一个物体。
为什么 κ ≠ 0 就分开了:法线一歪,反射方向就跟着歪; 而"把相机搬过去"给的是一个固定的方向场(它假定所有法线都竖直)。 于是它错多少,就等于法线被扳歪了多少 —— 右块上"亮"的那些地方, 就是波纹最陡的地方(判据④⑤把两件事一起量了)。
如实标的两处:① 右块画的是逐像素色差的灰阶(满量程 0.35),不区分正负; ② 左块与中块都是一次反弹:反射线打到盒子或天就停 —— 所以镜子里那个世界 没有它自己的倒影。★ 这条边界在 κ 拖到最大时会自己露头:约 5% 的格子 "反射方向指回了地面",一次反弹没有第二段可追,本页按"映到天"处理(判据⑥把这个数照实报出来)。
件三 中间量数值表 · 与件一、件二同源
① 镜面波纹 κ 扫描:两条路差多少(全画面)
κ方向夹角 平均 / 最大(度)右块平均色差右块最大色差法线最大倾角(度)
② 四个地面点上的逐条账(κ = 0,可拿笔验算)
地面点 P法线 Nr₁ 按法线反射r₂ 从 E′ 看 P夹角
① 固定相机与太阳,把 κ 从 0 扫到 0.30:第一列是两条路给出的方向之间的夹角 (全画面所有镜面像素取平均与最大),后两列是画面的平均色差与最大色差, 末列是地面法线偏离竖直的最大角。 κ = 0 那一行三个数全是 0 —— 这就是判据①;而 κ 越大三列一起涨(判据④)。
② 把 κ 钉在 0,挑四个地面点,把两条路的方向向量并排列出来。 要验算的恒等式只有一条:设 E = (Ex, Ey, Ez)、镜面 z = 0、P 在镜面上, 则 P − E′ 与反射方向 平行(因为 E′ 就是 E 关于镜面的镜像)—— 表里"夹角"那一栏因此恒为 0。第三列与第四列看起来是两个不同的向量, 但它们是同一个方向的两种写法(只差一个正的长度因子)。
两处如实标:① "方向夹角"是三维向量的夹角,用 acos 算, 所以 κ = 0 那一行显示 0.0000° 而不是"逐位相同"—— 逐位相同那一句是画面的(判据①数的是格子),两者不要混; ② 表里的数不随画面上的滑块变(判据在固定的那套配置上评估),免得读者随手一拖就以为判据错了。
本节获得

这一段第一次让"光会再飞出去"变成一件能对答案的事,入口是一面镜子。

  • "把相机搬到地板下面"不是近似,是恒等式(判据①②):对平面镜, P − E′ 与按法线反射的方向正好平行。所以工程上画平面反射的办法是 —— 把场景再渲染一遍(相机搬到 E′),把结果贴到镜面上。
  • 它精确的代价押在"平面"两个字上(判据④⑤):κ 一不为 0,两条路就分开, 而分开的量正好等于法线被扳歪的量。所以"镜面反射 = 镜像相机"这句话, 你只能在平面镜上引用;曲面镜得逐点算法线(那就是左块那一版)。
  • "一次反弹"里的"一次"是可以数的(判据⑥):反射线只测盒子与天、不测地面 —— 命中地面的次数恒为 0。这就是为什么镜子里看不到"镜子里的镜子", 也是后面 3.3 要收的那笔账:真实房间里最亮的地方常常是缝隙 —— 而缝隙需要多次反弹才亮得起来。
  • 镜面只搬光、不发光(判据⑧):把天光关掉,镜子里的天黑了,但盒子的倒影还在。 记住这一点,它是后面认"探针漏光"与"自发光"的指纹。
  • 下一段要接的:这一段的地面是一面完美的镜子 —— 只有纯镜面反射才配得上"搬相机"。 真实材质是粗糙的:反射方向要糊开一片。3.2 从"糊开"开始。
3.2

粗糙反射:一片糊的高光

示意 · 等价重现

3.1 的地面是一面完美的镜子:每个像素一条反射线,方向精确 —— 所以才有"把相机搬过去就够了"这条恒等式。可真实材质没有一面是完美的:打磨过的金属、水波、塑料, 反射方向总要糊开一片。

一旦糊开,"一条线"就不够了:那个方向上的答案只是这一片里一个点的抽样。 最直白的做法是多抽几条—— 但那就把 3.1 好不容易省下的"一条线"又赔回去了。 工程上的做法是另一条路:先把"环境"这张图按模糊程度预先糊好几份,再照旧只查一条线, 而糊到哪一份,按粗糙度选 —— 这正是 mip 那一套(连"多花 1/3 存储"这笔账都一样)。 本段只加一样东西:粗糙度 α。(α = 0 就是 3.1 那面完美镜。) 三件量出来的事见下:判据①②给"α = 0 时两条路逐位相同、α 一大就分开", 判据④⑤给"预先糊好的那张图能顶上多少、层级该怎么选",判据③⑥给画面与这笔账的价钱。 保真度如实标「示意 · 等价重现」:环境图、预滤波链、粗糙度分布都按定义在本页算出来; 为了把"糊"这件事单独拎出来,本段把"反射线上打到什么"换成了一张预先拍好的全景图 —— 也就是一枚反射探针(真机上这一步就是预先烤好的):一次拍完整个球,之后每个像素只查表。 代价是视差(一枚探针要服务横跨 2.5 × 2.4 的一整片地面,本页实测平均视差 0.94)—— 换来的是"糊"可以预先做掉。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:粗糙度 α(反射方向糊开多大一片) α = 0 是一面完美镜(一条线就够);α 一大,"那个方向"变成"那一片方向" 这一段的做法(先糊好几份,再只查一条线) ① 反射探针:在地面上一个固定点向 128 × 64 个方向各拍一条线    把它逐层糊下去(每层分辨率减半)⇒ 一条预滤波链 ② 按粗糙度 α 选一层;照旧只查一条反射线 ⇒ 得到"一片"的平均    参照:老老实实按分布抽 N 条线再平均(贵 N 倍) 左列:这一段吃什么 反射探针(全景图) — 粗糙度 α → 层号 — 件二每像素跑的就是这几行 // ★ 本段唯一改动:查表改成"选一层再查" function roughColor32(D, P) { const r = mirrorDir31(D, [0, 0, 1], P); const lv = mip32[level32(ALPHA32)]; return samplePano32(lv, nrm(r)); } 右列:交给像素什么 只用一条线(不糊)的平均差 — 选对层级后的平均差 — 预滤波链一共多花 1/3 存储(1 + 1/4 + 1/16 + … = 4/3)—— 与 mip 同一笔账
看什么:左边两格是这一段要吃的输入 —— 一枚反射探针(从地面中心拍出去的全景图) 和粗糙度(它决定"糊成哪一层")。中间框里是件二真正在跑的源码(函数 roughColor32 的正文,逐字未改): 先按法线算出那一条反射方向,再按粗糙度选一层、只查一次。右列是它交出来的两个数: 不糊会有多错、以及选对层级之后还剩多少错。
为什么"糊"可以预先做:粗糙表面的反射 = 把环境在那个方向周围一片上求平均。 求平均这件事与像素无关(只跟"糊多宽"有关),所以可以事先把环境糊好几份, 像素那边照旧只查一条线。这就是反射探针 + 预滤波,也是 mip 那一套 —— 代价是多存几份糊的版本(总存储 ×4/3),而不是每个像素多抽 N 条线。
如实标的五处:① 本段的"环境"是一张反射探针拍的全景图(从地面上一个固定点向 128 × 64 个方向各拍一条), 不是 3.1 那样逐像素往场景里追线 —— 真机上这张图也是预先烤好的,所以这是同一个做法; ② 所以它带着视差:一枚探针只坐在一个地方,而它要服务的地面点平均在 0.94 开外(最远 1.96)—— 判据①把它跟 3.1 那条精确反射线逐格比过,平均差 0.20;这是"改用探针"的价钱, 本段如实报出来("在哪采样"那笔账是第 2 课的); ③ 探针还必须落在两个盒子之外:AABB 求交从盒子内部出发一律返回 null, 盒子会整块"消失"而画面照样有内容、门禁也照样绿 —— 所以这一条写成了判据③里的哨兵; ④ 全景图拍的是整个球(真探针就是拍整个球),所以"地平线"处没有突变;但反射瓣只统计朝上的样本 (地面只接从上面来的光)—— α 一大就有一部分样本落到水平面以下被切掉(判据⑤报了这个数),那属于"又弹回地面",本段不追; ⑤ 多采样那一路用的是分层采样(不是随机),所以左边那块几乎是干净的 —— 随机采样会带来噪声,那是第 4 课的事。

① 先看左块(多采样,N = 6 × 6 = 36 条线):α 小时地面是锐利的倒影;把 α 拖大, 倒影糊成一片。这一块是"老实做法",代价是每像素 36 条线。

② 看中块(一条线 + 预滤波):它跟着一起糊,而且几乎与左块一样 —— 可它每像素只查了一条。右列两个读数就是这笔交易的价钱:不糊硬查那条线,误差有多大; 选对层级之后,误差还剩多少。

③ 把 α 一档一档拖过去:右块会在跨过某一档时轻轻跳一下(因为层号是整数)—— 这就是 mip 那个老现象:层内误差随 α 偏离而增大,一跨层就跳回去。

④ 看件三表②:它把"每个 α 该选哪一层"直接摊开(每个 α 一行,六层各一个误差, 最小的那个加粗)。该选哪层不是猜的,是量出来的 —— 表里那一条斜线就是"越粗糙、层越糊"。

⑤ 把「粗糙度」拖回 0:三块逐像素相同(判据①)—— α = 0 时那"一片"缩成了一个方向, 一条线又够了。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 三块并排

左:按分布抽 36 条线求平均(参照)| 中:一条线 + 预滤波 | 右:两者之差(灰阶)

启动中…

与上一段相同的五个控件 + 本段的一个

α = 0 是完美镜(一条线就够); α = 1 时反射方向糊开一大片,锐利的倒影全糊掉。

用来核判据的两个开关

控件与上一段只多不少:天光、太阳方位、眼位、盒子数逐字相同 (上一段的"镜面波纹 κ"在本段固定为 0 —— 地面是平的,这一段只问"糊"), 另加本段的新变量(粗糙度 α)与一个用来"故意选错一层"的开关。

定点观察(三步):① 看左块地面里那片倒影; ② 把 α 从 0 拖到 1,看它糊成一片; ③ 再看中块 —— 它也糊了,而且只花了一条线。结论:"糊"可以预算好, 不必每个像素多抽几条线;要糊到哪一层,按粗糙度选(件三表②那一条斜线)。

看什么:三块并排,用的是同一个场景、同一枚反射探针、同一台相机, 只有"那一片方向怎么求平均"这一样不同 —— 左 = 老老实实抽 36 条线再平均(参照), 中 = 一条线 + 一张预先糊好的图,右 = 两者的色差(灰阶)。
为什么 α = 0 时两块相同:α = 0 时那个分布缩成一个方向(δ), 36 条线全都落在同一点上,平均等于那一条 —— 所以"多采样"退化成"一条线", 而"预滤波第 0 层"就是原图。两边算出的是同一个方向上的同一个颜色。
为什么要"预滤波":多采样的价钱是线性的(N 条线就是 N 倍), 而预滤波的价钱是一次性的:把环境糊成一条链(总存储 ×4/3),之后像素那边永远只查一条线。 这正是 mip 之所以存在的理由 —— 只不过 mip 是"按距离"选层,这里是"按粗糙度"选层。
如实标的两处:① 右块是逐像素色差的灰阶(满量程 0.25),不区分正负; ② 层号是整数,所以"选对层级"这件事本身有量化误差 —— α 正好落在两层中间时误差最大,一跨层又跳回去(右块上那几道横纹就是这么来的)。
件三 中间量数值表 · 与件一、件二同源
① 粗糙度 α 扫描:一条线够不够
α只用一条线(不糊)选对层级后误差压到 1/N层号错一层最少涨
② 每个 α 下,六层各是多少误差(最小的一格加粗 = "该选哪层")
α层 0层 1层 2层 3层 4层 5
① 把 α 从 0 扫到 1:第二列是只用一条线、不糊时的平均色差,第三列是按 α 选对层级之后的, 第四列是两者的比(误差压到 1/N,N 越大说明预滤波越划算),第五列是选中的层号, 末列是选错一层里最便宜的那次会把误差放大几倍(∞ = 选错就完全不对)。 α = 0 那一行前两列都是 0(判据①)。
② 换个问法:到底该选哪一层? 每个 α 一行,六层各算一遍误差(都是对"36 条线的答案"而言), 最小的那一格加粗 —— 表里那条"越粗糙、层越深"的斜线就是答案(α 从 0 走到 1,层号 0 → 5)。 再看第四列:收益随 α 单调变大(1.7× → 7.7×)—— 粗糙度越大, "那一个方向上的答案"离"那一片的平均"越远,预先糊好的那张图就越值钱。 而第五列(层号)是整数,所以"选对"这件事本身带量化误差:一跨层就跳(判据④⑤)。
三处如实标:① 表里的"平均色差"是在整块画面上逐格取平均(与画面同一份循环、同一步长 1), 两张表口径一致;② 反射瓣只统计朝上的样本(地面只接从上面来的光),α 一大就有一部分样本 落到水平面以下被切掉 —— 判据⑤报出了这个数(α = 1 时约三成),那部分属于"又弹回地面",本段不追; ③ 表里的数不随画面上的滑块变(整张表按页首那套配置算一遍;判据另在固定的那套配置上评估), 免得读者随手一拖就以为判据错了。
本节获得

这一段把"反射"从一条线变成一片方向,并量出了它的价钱。

  • 粗糙 = 一片方向上的平均:α = 0 时那一片缩成一个方向(一条线就够,判据①); α 一大,单条线误差就单调增(判据②:α 从 0.2 到 1.0,误差涨 7.3 倍)—— 所以"一条线"不是近似,是偷工。
  • 求平均可以预先做(判据⑤):把环境糊成一条链,像素那边永远只查一条线。 每一档粗糙度都被压下来了(1.7× → 7.7×,随 α 单调变大)—— 这是"用存储换计算"的经典一步, 也是反射探针 / IBL 的全部内容。
  • 糊到哪一层要按粗糙度选(判据④):件三表②把这条斜线量了出来 —— α 从 0 走到 1,层号 0 → 5,每个 α 的"误差 vs 层号"都是单谷的(所以"该选哪层"有唯一答案); 选偏一层最少也要涨 1.19×,α 越大越贵(α = 1 时 3.79×),而 α = 0 时选错就完全不对(∞)。 层号是整数,所以"选对"这件事本身带量化误差:一跨层就跳 —— 件二右块那几道横纹就是这么来的。
  • 这笔账的价格是可以算的:预滤波链总存储 ×(1 + 1/4 + 1/16 + …) = ×4/3(判据⑥实测 1.3330), 而多采样是每像素 ×36(6 × 6 条线)。前者一次性、后者每帧都要付 —— 所以工程上选前者。
    ★ 探针该放在哪、以及它的价钱,都是量出来的(这一段返工过两次):第一版放在地面正中 —— 那里正在大盒子里面,AABB 求交从内部出发一律返回 null,全景图里盒子整块消失, 而画面照样有内容、门禁也照样绿(这类错只能靠"量全景图里各拍到多少"抓出来); 第二版放在地面上、盒子外面,但离得太远 —— 反射全是天,"糊不糊"几乎没区别; 最后选中 (0.60, 0.40):反射方向里有 44% 打到盒子(既有结构、又不是一堵墙)。 而它的价钱也照实报:探针要服务的地面点平均在 0.94 开外,跟 3.1 那条精确反射线逐格比平均差 0.20 (判据①)—— 这就是"从逐像素追线退到查一枚探针"换来的东西。
  • 下一段(3.3)要接的:3.1 与 3.2 讲的都是"光撞到东西之后往哪走",可它们都只让光再走一步 (3.1 甚至只能数出"这一步到不了的地方有 5.8%")。真实的房间不是这样的:光会撞很多次, 而一次反弹漏掉的光,恰恰全落在墙脚与缝里 —— 3.3 把"一次"和"多次"并排,并把漏掉的那份数出来。
3.3

一次反弹漏掉了什么

示意 · 等价重现

3.1 的镜子和 3.2 的粗糙镜子,都是"光撞到东西之后再走一步"。 3.1 甚至把"再走一步"数清楚了:有 5.8% 的格子反射方向指回地面 —— 一次反弹没有第二段可追,那一份光就被扔了。可真实的房间呢?光撞到墙、墙再撞到墙, 同一份光可以走很多步。

更要紧的是:被扔掉的往往不是"边角料"。房间里最暗的角落恰恰是被挡得最多的地方 —— 它看不见天、也常常晒不到太阳,可它的四面八方都是墙,而墙自己也在发亮。 "一次"只把光从邻居搬一趟;现实里这一趟要走很多次,而每多走一次,暗角落就再亮一点。 本段只加一样东西:间接反弹次数 N。
装置也随之换了一处(如实声明):地面与盒子从 3.1 / 3.2 的镜面换成白墙(漫反射,ρ = 0.85) —— 镜子把光原样弹走、不留一点"多次"的证据;白墙收到光会自己再发出去,"多次"这才有意义。 做法也彻底换了:把场景切成一片片面片,算出每片"看得见谁"(形状因子), 然后把"发出去的"接回"收进来的",一遍收集就是多一次反弹 —— 旋钮 N 就是收集的遍数。 保真度如实标「示意 · 等价重现」:面片常数辐射度、方向是分层抽出来的,两条边界都写在判据里。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:间接反弹次数 N 把"发出去的"接回"收进来的" —— 接几遍就是弹几次 这一段的做法 ① 把场景切成 2876 个面片(地面 50 × 54 + 盒子各个面) ② 每片先算它"直接收到的那一份":太阳(含遮挡)+ 天光 ⇒ E ③ 再抽 24 条方向,记下每条打到哪个面片 ⇒ 形状因子表 F ④ 把发出去的接回来,跑 N 遍:N = 0 只有直接光,N 越大越接近真实 左列:吃什么 面片 — 形状因子表 F — 白墙 ρ 0.85 件二每跑一遍,就是这几行 // ★ 本段唯一改动:把发出去的接回来 function bounce33(L, N) { let c = L; for (let k = 0; k < N; k++) c = once33(c); return c; } function once33(L) { const o = new Float64Array(NP33 * 3); for (const h of FF33) for (let q = 0; q < 3; q++) o[h.i * 3 + q] += RHO33 * h.w * L[h.j * 3 + q]; for (let i = 0; i < NP33 * 3; i++) o[i] += RHO33 * EDIR33[i] / PI; return o; }右列:交出什么 "一次"给的(占该带总光) — "两次及以上"给的(缝隙) — 每条方向抽几线 24 条 · 分层 一件不创造能量的证据:环境处处 L = 1 时,跑多少遍都恒等于 1(判据⑥,可笔算)
看什么:左边三格是这一段要吃的输入 —— 面片(场景被切成的那些小块)、 形状因子表 F("我这片看得见谁、权重多少")、以及白墙的反照率 ρ。 中间框里是件二真正在跑的源码(函数 bounce33 与 once33 的正文,逐字未改): 每一遍都把"邻居发出来的光"收一遍,再乘 ρ 发出去;跑 N 遍就是"多 N 次反弹"。 右列是它交出来的两个数:"一次"在总账里给的份额、以及"两次及以上"给的份额。
为什么"一遍收集"就等于"多一次反弹":一个面片出射的光 = 它自己收到的光 × ρ / π。 收到的光里有一份是邻居发出来的,而邻居发出来的又取决于邻居收到的……这就是那个"互相问"的循环。 把它拆成一遍一遍做:第 0 遍只填"直接光",第 1 遍把邻居的直接光收进来, 第 2 遍把邻居已经收过一轮的光收进来 —— 第 k 遍正好就是"走了 k 步的光"。
为什么一定能停下来:ρ = 0.85 < 1,而且每个面片"看得见的几何"最多占满整个上半球 ⇒ 每一遍的变化量都不到上一遍的 0.36(判据④实测)。
如实标的三处:① 每个面片只有一个数(常数辐射度),所以贴着墙脚那一条里的渐变是分段的; ② 形状因子是抽出来的(每片 24 条方向、分层 + 低差异序列,不是随机):抽得少就有误差 —— 本页量到它按约 1/M 落,而伪随机才是 1/√M(判据⑧,下一课的引子);③ 只算到"撞到东西"为止,不追折射与透射。

① 先看左块(N = 0,只有直接光):太阳照得到的地方是亮的,影子是纯黑的 —— 那里既没有太阳,也没有任何人给它光。这就是"一次都没弹"的世界。

② 把 N 从 0 拖到 4:中块从影子开始亮起来,而太阳照得到的地方几乎一动不动—— 一眼就能看出"多出来的光落在哪儿"。

③ 盯住右块(两者的差):那层"补回来的光"在贴着盒子脚那一圈最亮 —— 墙脚、两块盒子之间那条缝。开阔的地面上,这一层几乎是空的。

④ 看件三表②:它把"反弹在总账里占多少"直接摊开 —— 缝隙带收到的光里只有 17.8% 来自"一次",另有 24.4% 要两次以上才到得了; 而离墙远的那条带,这两个数是 6.9% 与 9.6%,全场面片平均只有 2.5% 与 1.1%。 多弹几次,涨得最狠的正是被挡得最多的地方(缝隙带整体涨 1.73×,全场只有 1.04×)。

⑤ 把「天光强度」拖到 0(只剩太阳):影子在 N = 0 时是 0,N ≥ 1 才有值 —— 因为这时"被挡掉的方向"上不再有天,只剩墙;而墙的那份光,正是"一次反弹漏掉"的那份。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 三块并排

左:只有直接光(N = 0)| 中:跑 N 遍 | 右:两者之差(灰阶)

启动中…

与上一段相同的四个控件 + 本段的一个

N = 0 是只有直接光(影子纯黑); N = 4 之后画面基本不动了(判据④给出了"每多一遍只剩多少")。

控件与上一段只多不少:天光、太阳方位、眼位、盒子数逐字相同。 上一段那两个开关(粗糙度 α、层号差一档)在本段不再有意义 —— 因为装置换成了白墙,"镜面"这件事整段都不见了;本段唯一的变量是那个新旋钮:间接反弹次数 N。

定点观察(三步):① 看右块(差)里最亮的那一圈; ② 把 N 从 1 拖到 4,看那一圈怎么一点点被填平; ③ 再把天光拖到 0 —— 影子在 N = 0 时是 0,N ≥ 1 才有值。 结论:"一次"漏掉的光,几乎全落在被挡得最多的墙脚与缝里, 而那里恰恰是 AO 判它最暗的地方。

看什么:三块并排,用的是同一个场景、同一批面片、同一台相机, 只有"发出去的光接回来几遍"这一样不同 —— 左 = N = 0(只有直接光), 中 = 跑 N 遍,右 = 两者的色差(灰阶)。
为什么左块在 N = 0 时影子里是黑的:N = 0 时每个面片的出射就是"直接收到的那一份 × ρ", 没有第二个人给它光。判据①把这一份跟逐面片的解析式 ρ·E/π 比过:逐位相同。
为什么这一段的"钱"花在影子里:太阳照得到的地方已经拿到了一大份(直接光), 多弹几次只会加上一丁点;而影子里的面片直接光那一份是 0 —— 它拿到的全部来自邻居, 所以"多弹一次"对它是从 0 到有的区别。判据②量出的正是这个差。
如实标的两处:① 右块的灰阶满量程是 0.25(不区分正负,反正差永远 ≥ 0); ② 面片是常数辐射度,所以贴着墙脚那一条里的亮度是分段的,放大看能看出格子。
件三 中间量数值表 · 与件一、件二同源
① N 扫描:多跑几遍还差多少
N全场缝隙带缝隙带涨了
② 反弹在"总账"里占多少(按带分)
带天可见度一次给的两次及以上给的多弹涨了
③ 每一遍的变化量(为什么能停下来)
由 N 到 N+1Δ 平均与上一遍之比
① 把 N 从 0 扫到 12(12 遍已经收敛,本页当"多次"的答案):第二列是全部面片的平均亮度、 第三列是缝隙带(贴着盒子脚、且晒不到太阳的那些地面面片)的平均亮度、 第四列是缝隙带相对"只有直接光"涨了几倍。全场几乎在动的就是影子,而缝隙带是被填得最狠的那一块。
② 换个问法:反弹在总账里到底占多少? 三行是同一批"晒不到太阳"的地面,只差被挡掉多少 (第一列就是 AO 的口径:看得见多少天)。后三列是"一次给的""两次及以上给的""多弹涨了几倍"。 一眼能看出:被挡得最多的那一行,几乎每一项都比别人大一个量级 —— 而它的"天可见度"恰恰是最小的,也就是 AO 判它最暗的那一批。
③ 每多跑一遍,画面变化多少:变化量一个比一个小,而且都在上一遍的 0.36 以下 —— 这就是"几何级数"的实测形状,也是本段能说"跑到收敛"的依据。
两处如实标:① 表里的数是面片上的平均值(不是逐像素),两条带是"晒不到太阳的地面里 被挡得最多的三分之一 / 最少的 三分之一";② 表里的数不随画面上的滑块变 (整张表按页首那套配置算一遍;判据另在固定的那套配置上评估)。
本节获得

这一段把"一次反弹"与"多次反弹"并排,量出了一次到底漏掉了什么。

  • 一遍收集 = 多一次反弹(判据①):第 0 遍只填直接光,第 k 遍就是"走了 k 步的光"。 这不是比喻:把发出去的光接回来一遍,代码上就是 bounce33 里那次循环。
  • "一次"漏掉的光几乎全落在影子里(判据②):太阳照得到的地方多弹几次只多一点点, 而影子里的面片那一份全部来自邻居 —— 对它来说,多弹一次是从 0 到有的区别。
  • 一次够不够,要看地方(判据② + 件三表①②):多弹几次,缝隙带整体涨 1.73×, 而全场面片平均只涨 1.04×。缝隙带收到的光里,17.8% 来自"一次"、另有 24.4% 要两次以上才到得了; 离墙远的那条带这两个数是 6.9% / 9.6%。所以"一次反弹"在开阔处是够用的近似,在墙脚与缝里不是。
  • AO 错在哪,这段把它量化了(判据⑤):AO 只数"看得见多少天", 于是把"被挡住的那 46.5% 个方向"当成没有光;可那些方向上是墙,墙也在发亮。 实测:AO 说缝隙只有外圈的 0.661 倍亮,真实收敛值是 0.963 倍 —— AO 把它判暗了 0.30(相对 46%),而弹得越多差得越远。 这正好接上第 1 课那句"AO 讲不出'缝隙是亮的'"。
  • 这笔账能停下来,而且能拿笔验算(判据④⑥):每多一遍,画面变化都在上一遍的 0.36 以下 (跑到第 6 遍只剩第 1 遍的 0.2%);把环境换成"处处 L = 1"、ρ = 1,收敛值恒等于 1.000000000000 —— 一件不创造能量。这两条都是逐位对得上的。
  • 下一课要接的:形状因子是抽出来的,判据⑧量出了两种抽法的区别 —— 本页的分层 + 低差异抽样,采样翻两番误差掉到 1/4 与 1/3.3(≈ 1/M); 换成伪随机就只剩 1/2.0 与 1/1.8(≈ 1/√M)。同一笔预算,分层快一档。 下一课要沿光路一次次抽,没法这么整齐地分层 —— 于是就要付 1/√N 的代价, 整条主线那句话就落在这里。
本课三段 · 全部上线
3.3 (本课已收束) 第 3 课三段全部上线:3.1 一面精确的镜(把相机搬过去就够了) · 3.2 一片糊的高光(把"糊"预算好,按粗糙度选一层) · 3.3 一次反弹漏掉了什么(把发出去的接回来,接 N 遍)。 三段落在一起,正好回答课题骨架的第二件事:采几次。下一课「反弹多次」把这件事推到像素上。 已收束
运行时自检 等待脚本…

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


  

页面版本 v3(课题四第 3 课「反弹一次」:三段全部上线 —— 3.1 镜面反射 / 3.2 粗糙反射 / 3.3 一次反弹漏掉了什么,本课收束)。
3.1 这一段只加一样东西:镜面波纹 κ。世界是前两课那个世界,但第一次给它装上相机与镜子: 地面成了一面镜面,每个像素的主射线打到地面上要按法线反射,再往前追一次就停。 量出来的四件事: ① 平镜时"搬相机"是恒等式:κ = 0 时"按法线反射"与"把眼睛搬到地板下面(E′)"两条路 在整块画面上逐像素相同(差 0 格),方向夹角平均 0.0000°; ② 弯了就分开,而且分开的量与 κ 成正比:κ 从 0 扫到 0.30,平均色差 0 → 0.0228 → 0.0446 → 0.0679 → 0.0892 ("色差 ÷ κ"是一个常数,最大偏离 5% 以内),而两条路的方向夹角正好是法线倾角的 2 倍 (判据⑤实测比值 1.997)—— 所以"镜面反射 = 镜像相机"只能用在平面镜上; ③ "一次"是可以数的:反射线只测盒子与天、从不测地面 —— 所以"镜子里看不到镜子里的镜子"不是形容词,是个数得出来的事实(反射线穿到地面以下的条数恒为 0); 而这条边界本身也会自己露头:κ 拖到最大时有约 5% 的格子"反射方向指回地面"(一次反弹没有第二段可追, 本页按"映到天"处理 —— 那个数判据⑥也照实报出来了); ④ 镜面只搬光、不发光:把天光关掉,镜子里的天黑了,可盒子的倒影还在。 判据八条:① 零位(κ = 0 时两条路逐像素相同)/ ② 恒等式(逐点验 P − E′ ∥ r₁)/ ③ 画面(写入格子数、主射线数、非法值 0)/ ④ 两边路分开且随 κ 单调增/ ⑤ 夹角 = 2 × 法线倾角(实测比值 1.997,可笔算一例)/ ⑥ 一次反弹(地面从不出现在反射线里,并照实报出边界)/ ⑦ 同源(件一框里那几行就是 mirrorDir31 的正文,重算逐位相同)/ ⑧ 天光关掉后镜子里的天也黑(镜面不发光)。
3.2 这一段只加一样东西:粗糙度 α。3.1 的镜子是完美的(一条反射线就够), 真实材质要糊开一片;本段把"反射线上打到什么"从逐像素追线换成一枚预先烤好的反射探针 (128 × 64 的全景图,从地面上一个固定点拍出去),然后把它逐层糊成一条链、按 α 选一层, 像素那边永远只查一条线。量出来的四件事: ① α = 0 时"一条线"与"36 条线的答案"逐格相同(那一片缩成一个方向,是恒等式); 而同时它和 3.1 那条精确反射线并不逐位相同 —— 平均差 0.20,那就是"改用一枚探针"的价钱 (探针要服务的地面点平均在 0.94 开外,视差如实报出); ② α 一大,"一条线"就分开,而且单调变坏:只用一条线的平均色差 0 → 0.0302 → 0.0840 → 0.1329 → 0.1775 → 0.2192 (α 从 0.2 到 1.0 涨 7.3 倍); ③ 求平均可以预先做:把环境糊成一条链(总存储 ×4/3 = 1.3330),按 α 选一层后误差压到 1/1.69 ~ 1/7.74(随 α 单调变大),而多采样是每像素 ×36; ④ "该选哪层"是量出来的:α 从 0 走到 1,层号 0 → 3 → 4 → 5,每个 α 的"误差 vs 层号"都是 单谷的;选偏一层最少涨 1.19×,α = 1 时涨 3.79×(α = 0 时选错就完全不对)。 判据八条:① 零位 + 探针的价钱/ ② 单条线误差随 α 单调增/ ③ 画面(含"探针不在盒子里""反射方向全朝上"两条哨兵)/ ④ 层号单调加深、误差-层单谷、选错一层有代价/ ⑤ 预滤波把误差压下来且随 α 单调变大/ ⑥ 存储 ×4/3 vs 每像素 ×36(可笔算)/ ⑦ 同源(件一框里那几行 = roughColor32 的正文,重算逐位相同)/ ⑧ 探针只搬光、不发光。
3.3 这一段只加一样东西:间接反弹次数 N。装置换了一处(如实声明):地面与盒子从"镜面"换成 白墙(漫反射 ρ = 0.85)—— 镜面把光原样弹走、不留一点"多次"的证据;白墙收到光会自己再发出去, "多次"才有意义。做法是把场景切成 2876 个面片、算出每片"看得见谁"(形状因子,每片 24 条方向), 然后把"发出去的"接回"收进来的"——一遍收集就是多一次反弹。量出来的四件事: ① N = 0 就是"每个面片的出射 = 直接光 × ρ"(对 2876 × 3 个通道逐位与解析式相同); ② "一次"漏掉的光几乎全落在影子里:多弹几次,缝隙带整体涨 1.73×,而全场面片平均只涨 1.04×; ③ 反弹在总账里的份额:缝隙带收到的光里 17.8% 来自"一次"、另有 24.4% 要两次以上才到得了; 离墙远的那条带是 6.9% / 9.6%,全场面片平均只有 2.5% / 1.1%; ④ AO 错在哪,被量化了:AO 把"被挡掉的那 46.5% 个方向"当成没有光,于是说缝隙只有外圈的 0.661 倍亮 —— 而真实收敛值是 0.963 倍,判暗了 0.30(相对 46%)。 判据八条:① 零位(N = 0 = 直接光 × ρ)/ ② "一次"让影子不再等于直接光/ ③ 画面(含"打到东西的格数""两遍逐字节相同")/ ④ 一遍比一遍小且都在上一遍的 0.36 以下/ ⑤ ★ AO 与真实的对比(主判据)/ ⑥ 白炉(环境处处 L = 1、ρ = 1 ⇒ 恒等于 1,可笔算)/ ⑦ 同源(件一框里那几行 = bounce33/once33 的正文,重算逐位相同)/ ⑧ 抽样误差怎么落:本页"分层 + 低差异"≈ 1/M(实测 1/4.15、1/3.28), 伪随机才是 1/√M(1/1.96、1/1.80)—— 下一课那句 1/√N 就落在这里。 ★ 与前两课共享同一个场景(同一组盒子坐标与高度、同一个太阳高度角与方位约定)—— 所以"这是同一个世界,只是第一次从别处看它"这句话是能对上的。 ★ 如实标:① 本页是 CPU 光线投射器(逐像素自算),不依赖 WebGPU;② κ 只改地面法线、 不改地面形状(几何仍是 z = 0)—— 这是"水面"的模型;③ 反射线只追一次,不递归; ④ 3.2 的反射瓣只统计朝上的样本(地面只接从上面来的光),α = 1 时约 34% 的样本被水平面切掉; ⑤ 3.2 的全景图只有 128 × 64,最糊那层是 4 × 2; ⑥ 3.3 的每个面片只有一个数(常数辐射度),所以窄缝里的渐变是分段的,而且只算到"撞到东西"为止。 | 看不到新内容时请硬刷新(Ctrl+Shift+R),或给地址加 ?t=N —— 本站的 HTML 不带缓存指令。