9.2 ray marching

把 9.1 的连续积分换成沿光线取 N 个样点的求和,就得到 ray marching。本节把步长与误差的关系量成三张表:中点法二阶收敛直到定点地板、透射率链式累乘的偏差随 N 漂到 −5.17%、一阶近似在 σ_t·Δs ≥ 1 时透射率塌成 0 的条带阈值,以及双层介质里误差的震荡。

9.2 ray marching

站内 /graphics-volumetric-clouds-atmosphere/ 那篇讲的是体积云工程:为了帧率,它必须用尽量少的步数把云画得像;本节反过来问一个更基础的问题——步数到底该取多少,以及步长过大时画面会坏成什么样。9.1 已经把透射率与相函数钉在整数上,本节把那个沿路径的积分真正换成求和,然后把「离散化」这件事量成一张表。

把积分换成求和

体积渲染方程是连续积分,计算机只会求和。最直接的换法是中点法(midpoint rule):把 [0, s] 等分成 N 段,每段长 Δs = s/N,用段中点的值代表整段:

∫_0^s f(t) dt  ≈  Σ_{i=0}^{N-1} f(t_0 + i·Δs + Δs/2) · Δs

中点法的误差是 O(Δs²)——步长减半,误差降到四分之一。这是理论。实际呢?本节量三件事:中点法单次散射相对解析解收敛多快、透射率「逐段连乘」的偏差怎么漂、以及一阶近似什么时候塌成条带。

步长 vs 误差:二阶收敛撞上定点地板

路径取 8 个世界单位,介质 σ_t = 0.5,于是整条路径的光学深度 τ_span = 4.0。解析单次散射(9.1 的闭式)在 Q16.16 下是 4095。脚本从 N = 1 扫到 N = 512(每档翻倍):

# snippet: sim/cg_trace_09_2_ray_marching.py(节选:中点法与两层介质)
# verify: /opt/homebrew/bin/python3 sim/cg_trace_09_2_ray_marching.py
def march_two_layer(n_steps, span, y_top, y_iface, sigma_hi, sigma_lo):
    """双层介质的透射率:从 `y_top` 往下走 `span`,`y_iface` 以下是另一层。

    每步用**步中点**所在层的 `σ_t`(ray marching 的标准做法)。步长与界面不重合时,
    跨界面那一步会用整段的单一 `σ_t`,于是误差随 `N` 震荡。
    """
    ds = span // n_steps
    if ds <= 0:
        return 0, 0
    acc = 0            # 累积光学深度 τ(Q16.16)
    for i in range(n_steps):
        y_mid = y_top - (i * ds + ds // 2)     # 步中点高度(往下走,y 减小)
        sigma = sigma_hi if y_mid >= y_iface else sigma_lo
        acc += fmul(sigma, ds)
    return med.fexp_neg(acc), acc
NΔs(Q16)σ_t·Δs中点法相对误差%比前档
15242884.00002259−44.8352—
22621442.00003486−14.87183.01
41310721.00003932−3.98053.74
8655360.50004056−0.95244.18
16327680.25004088−0.17095.57
32163840.125040950.0000inf
6481920.06254096+0.0244—
12840960.03124094−0.0244−1.00
25620480.01564089−0.14650.17
51210240.00784082−0.31750.46

这张表里有两段完全不同的行为,值得分开读:

  • N ≤ 16 是真正的二阶收敛。 误差从 −44.84% 降到 −0.17%,每翻一倍步数误差大约降到 1/4(比值列 3.01 → 3.74 → 4.18 → 5.57,在 4 附近浮动)。这一段是中点法本身的截断误差,符合 O(Δs²) 的理论预期。
  • N ≥ 32 之后误差不再单调下降,反而回升。 N = 32 恰好命中参考值 4095(误差 0),再加密到 512,误差从 0 变成 −0.32%。这不是中点法变差了,而是定点累加的地板:9.1 已经量出 exp(-x) 本身的相对误差上限是 2262 ppm ≈ 0.226%,N = 512 的 −0.3175% 已经落在这个量级附近。加密步长买不到比定点精度更准的结果。

结论很实用:本节这组系数下,N ≈ 32(σ_t·Δs = 0.125)就已经踩在定点地板上,再往上加步数纯属浪费。至于「画面看不看得出差别」,N = 8(误差 −0.95%)与 N = 32 之间的肉眼差异,得靠 9.3 的图像 MAE 去判,而不是靠感觉。

透射率链式累乘的偏差

实现里另一个常见写法是:不用解析的 exp(-σ_t·s),而是把路径切成 N 段、每段各算 exp(-σ_t·Δs) 再连乘。数学上 exp(-σ_t·s) = ∏ exp(-σ_t·Δs) 恒等,但每段的定点取整会一路累积。参考值仍是解析的 1200:

N链乘 T解析 T偏差%
1120012000.00000
212011200+0.08333
412011200+0.08333
812031200+0.25000
1612031200+0.25000
3212021200+0.16667
6411941200−0.50000
12811871200−1.08333
25611721200−2.33333
51211381200−5.16667

偏差不是单调的:N 小的时候偏高(0.08% → 0.25%),到 N = 64 之后转成偏低,并且越偏越多,N = 512 时已经 −5.17%。原因很直白——每段 exp(-σ_t·Δs) 的定点误差方向固定,段数一多,同号的取整误差就线性累积。这不是「步数越多越准」:它是一条 U 形曲线,最准的点在 N ≈ 4~32 之间。所以实现里宁可保留一个解析的 exp(-σ_t·s),也不要为了「通用」把透射率拆成 N 段连乘。

一阶近似:条带出现的阈值

比连乘更省的写法是把 exp(-σ_t·Δs) 一阶展开成 1 - σ_t·Δs。它在一段内很准,但一旦 σ_t·Δs ≥ 1,这个因子就 ≤ 0——透射率塌成 0:

Nσ_t·Δs线性 T坏步数
14.000001
22.000002
41.000004
80.50002560
160.25006560
320.12509100
640.062510470
1280.031211120
2560.015611320
5120.007811200

阈值就是 σ_t·Δs = 1:N ≤ 4 时每段的光学深度 ≥ 1,因子被钳到 0,整条链乘立刻归零。这就是条带(banding)的数学来源——不是「看起来有点假」,而是透射率在几何上直接断成 0。更隐蔽的是 N = 8~512 这一段:T 值本身在 256~1132 之间漂,且并不单调趋向解析值 1200(N = 256 的 1132 比 N = 64 的 1047 更接近,但 N = 512 的 1120 又退回去了)。一阶近似永远偏低,因为 1 - x ≤ e^{-x} 恒成立——这是它在任何步长下都消不掉的系统性偏差。

双层介质:误差会震荡

最后看一个把「步长与几何不重合」暴露得最清楚的例子:介质分两层,界面在 4 个单位处,上层 σ_hi = 0.5、下层 σ_lo = 1.0,光线往下走 6 个单位跨过界面。解析透射率是 exp(-(0.5·4 + 1.0·2)) = exp(-4) = 1200。

NΔs(Q16)march T相对误差%
13932163263+171.92
2196608728−39.33
313107212000.0000
4983041541+28.42
578643982−18.17
66553612000.0000
7561731385+15.42
8491521060−11.67
9436901201+0.0833
123276812000.0000

这里误差不收敛,而是震荡:N 是 3 的倍数时界面恰好落在步边界上,误差为 0;不是 3 的倍数时,跨界面那一步用整段的单一 σ_t,误差符号随 N 在正负之间翻。N = 1 时误差高达 +172%(整条 6 单位路径被当成上层 0.5 算,τ 少了一半多)。只要介质有内界面,增大 N 并不能保证误差变小——必须让界面与步边界对齐,否则误差会一直在 ±10% 量级来回跳。这也是为什么工程实现里会把「介质边界」和「步长」绑在一起考虑,而不是无脑加密。

P 层把数钉死

P 层用 TypeScript 重写同一条链路,全程 BigInt,断言对象是上面三张表加两层介质的每一行:

// file: lantern/core/test/cg_trace_09_2_march.test.ts
// tier: P
// verify: node --test 'core/test/cg_trace_09_2_march.test.ts'
// 9.2 的 P 复现:BigInt 内核重跑步长扫描、透射率链式累乘、一阶近似条带、双层介质,
// 逐位对齐 Python 侧 out/cg_trace_09_2_march.json。

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

import { ONE } from "../src/lantern/fixed.ts";
import {
  INV_4PI_Q16, analyticSingleScatter, marchSingleScatter, marchTransmittance, marchTwoLayer,
} from "../src/lantern/cg_trace_09_medium.ts";

const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const rep = JSON.parse(
  readFileSync(join(root, "out", "cg_trace_09_2_march.json"), "utf8"),
) as {
  sigmaSQ16: number; sigmaTQ16: number; spanQ16: number; refSingleQ16: number;
  refTransQ16: number;
  convergence: { nSteps: number; marchQ16: number; refQ16: number; relErrPct: number; tEndQ16: number }[];
  transmittanceChain: { nSteps: number; chainQ16: number; refQ16: number; badSteps: number }[];
  linearBanding: { nSteps: number; sigmaTdsQ16: number; linearQ16: number; badSteps: number }[];
  twoLayer: {
    yTopQ16: number; yIfaceQ16: number; sigmaHiQ16: number; sigmaLoQ16: number; walkQ16: number;
    refQ16: number;
    rows: { nSteps: number; tauQ16: number; marchQ16: number; relErrPct: number }[];
  };
};

const SIGMA_S = BigInt(rep.sigmaSQ16);
const SIGMA_T = BigInt(rep.sigmaTQ16);
const LIGHT = 65536n;
const SPAN = BigInt(rep.spanQ16);

test("【核心】中点法单次散射与解析解、与 Python 侧逐位相同", () => {
  const ref = analyticSingleScatter(SIGMA_S, SIGMA_T, SPAN, LIGHT, INV_4PI_Q16);
  assert.equal(ref, BigInt(rep.refSingleQ16));
  for (const r of rep.convergence) {
    const [val, tEnd] = marchSingleScatter(SIGMA_S, SIGMA_T, r.nSteps, SPAN, LIGHT, INV_4PI_Q16);
    assert.equal(val, BigInt(r.marchQ16), `N=${r.nSteps}`);
    assert.equal(tEnd, BigInt(r.tEndQ16), `N=${r.nSteps} tEnd`);
  }
});

test("【核心】透射率链式累乘与一阶近似条带逐位相同", () => {
  const refT = marchTransmittance(SIGMA_T, 1, SPAN, "exact")[0];
  assert.equal(refT, BigInt(rep.refTransQ16));
  for (const r of rep.transmittanceChain) {
    const [t, bad] = marchTransmittance(SIGMA_T, r.nSteps, SPAN, "exact");
    assert.equal(t, BigInt(r.chainQ16), `exact N=${r.nSteps}`);
    assert.equal(bad, r.badSteps);
  }
  for (const r of rep.linearBanding) {
    const [t, bad] = marchTransmittance(SIGMA_T, r.nSteps, SPAN, "linear");
    assert.equal(t, BigInt(r.linearQ16), `linear N=${r.nSteps}`);
    assert.equal(bad, r.badSteps);
  }
});

test("条带阈值:σ_t·Δs ≥ 1 时一阶近似透射率塌成 0", () => {
  for (const r of rep.linearBanding) {
    if (BigInt(r.sigmaTdsQ16) >= ONE) {
      assert.equal(r.linearQ16, 0, `N=${r.nSteps} 应塌成 0`);
      assert.ok(r.badSteps > 0);
    } else {
      assert.ok(r.linearQ16 > 0, `N=${r.nSteps} 不应为 0`);
      assert.equal(r.badSteps, 0);
    }
  }
});

test("中点法二阶收敛:N 翻倍误差约降到 1/4(N ≤ 16 段)", () => {
  const conv = rep.convergence;
  for (let i = 1; i < conv.length && conv[i].nSteps <= 16; i += 1) {
    const ratio = Math.abs(conv[i - 1].relErrPct) / Math.abs(conv[i].relErrPct);
    assert.ok(ratio > 2.5 && ratio < 6.0, `N=${conv[i].nSteps} ratio=${ratio}`);
  }
  // N = 32 时误差已落到 0(定点地板),继续加密反而回升
  const at32 = conv.find((r) => r.nSteps === 32)!;
  const at512 = conv.find((r) => r.nSteps === 512)!;
  assert.equal(at32.relErrPct, 0);
  assert.ok(Math.abs(at512.relErrPct) > 0);
});

test("【核心】双层介质(步长与界面不重合)逐位相同,且 N 为 3 的倍数时对齐", () => {
  const tl = rep.twoLayer;
  for (const r of tl.rows) {
    const [t, tau] = marchTwoLayer(
      r.nSteps, BigInt(tl.walkQ16), BigInt(tl.yTopQ16), BigInt(tl.yIfaceQ16),
      BigInt(tl.sigmaHiQ16), BigInt(tl.sigmaLoQ16),
    );
    assert.equal(t, BigInt(r.marchQ16), `N=${r.nSteps}`);
    assert.equal(tau, BigInt(r.tauQ16), `N=${r.nSteps} tau`);
  }
  // 界面在 4 单位、步长 6/N:N 为 3 的倍数时界面落在步边界上,误差为 0
  for (const r of tl.rows) {
    if (r.nSteps % 3 === 0 && r.nSteps <= 6) assert.equal(r.marchQ16, tl.refQ16, `N=${r.nSteps}`);
  }
  // 震荡:误差符号在相邻档之间翻转(N 从 1 到 6)
  const head = tl.rows.slice(0, 6);
  for (let i = 1; i < head.length; i += 1) {
    if (head[i].relErrPct !== 0 && head[i - 1].relErrPct !== 0) {
      assert.ok(Math.sign(head[i].relErrPct) !== Math.sign(head[i - 1].relErrPct));
    }
  }
});

跑一遍:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_09_2_ray_marching.py
== 9.2 ray marching ==
介质 σ_t=0.5,路径长 8.0 单位,τ_span=4.0
解析单次散射参考 = 4095(Q16.16)
    N  Δs(Q16)    σtΔs       中点法      相对误差%      比前档
    1   524288  4.0000      2259   -44.8352
    2   262144  2.0000      3486   -14.8718     3.01
    4   131072  1.0000      3932    -3.9805     3.74
    8    65536  0.5000      4056    -0.9524     4.18
   16    32768  0.2500      4088    -0.1709     5.57
   32    16384  0.1250      4095     0.0000      inf
   64     8192  0.0625      4096     0.0244
  128     4096  0.0312      4094    -0.0244    -1.00
  256     2048  0.0156      4089    -0.1465     0.17
  512     1024  0.0078      4082    -0.3175     0.46
透射率链式累乘偏差(精确指数逐段连乘 vs 解析):
    N     链乘 T     解析 T        偏差%
    1     1200     1200    0.00000
    2     1201     1200    0.08333
    4     1201     1200    0.08333
    8     1203     1200    0.25000
   16     1203     1200    0.25000
   32     1202     1200    0.16667
   64     1194     1200   -0.50000
  128     1187     1200   -1.08333
  256     1172     1200   -2.33333
  512     1138     1200   -5.16667
一阶近似条带(σ_t·Δs ≥ 1 时透射率塌成 0):
    N    σtΔs     线性 T     坏步数
    1  4.0000        0       1
    2  2.0000        0       2
    4  1.0000        0       4
    8  0.5000      256       0
   16  0.2500      656       0
   32  0.1250      910       0
   64  0.0625     1047       0
  128  0.0312     1112       0
  256  0.0156     1132       0
  512  0.0078     1120       0
双层介质(界面在中间,步长不重合 -> 震荡):
    N  Δs(Q16)  march T     解析 T      相对误差%
    1   393216     3263     1200   171.9167
    2   196608      728     1200   -39.3333
    3   131072     1200     1200     0.0000
    4    98304     1541     1200    28.4167
    5    78643      982     1200   -18.1667
    6    65536     1200     1200     0.0000
    7    56173     1385     1200    15.4167
    8    49152     1060     1200   -11.6667
    9    43690     1201     1200     0.0833
   10    39321     1327     1200    10.5833
   11    35746     1096     1200    -8.6667
   12    32768     1200     1200     0.0000
   13    30247     1296     1200     8.0000
   14    28086     1118     1200    -6.8333
   15    26214     1201     1200     0.0833
   16    24576     1278     1200     6.5000
   17    23130     1132     1200    -5.6667
   18    21845     1201     1200     0.0833
   19    20695     1266     1200     5.5000
   20    19660     1142     1200    -4.8333
   21    18724     1201     1200     0.0833
   22    17873     1256     1200     4.6667
   23    17096     1150     1200    -4.1667
   24    16384     1200     1200     0.0000

小结

  • 中点法是 O(Δs²):N 翻倍误差降到约 1/4,直到定点地板(≈2262 ppm)接手。
  • 本节系数下 N ≈ 32(σ_t·Δs = 0.125)已到地板,再加密只会在 ±0.3% 之间来回。
  • 透射率逐段连乘的偏差是 U 形:N = 512 时偏到 −5.17%,不如保留解析 exp(-σ_t·s)。
  • 一阶近似 1 - σ_t·Δs 在 σ_t·Δs ≥ 1 时透射率塌成 0——这就是条带的阈值。
  • 介质有内界面时误差震荡而非收敛,必须让界面与步边界对齐。

下一节把步进搬进真正带几何遮挡的场景:体积阴影、单次与二阶散射,以及各向异性相函数对整幅图的影响。

阅读导航:上一节:9.1 体积渲染方程 · 下一节:9.3 体积阴影与散射 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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