光线追踪(Path Tracing)
Rust + WebAssembly 逐像素模拟光线的反射、折射和漫射,画面随采样累积逐渐变清晰
🖱️ 拖拽 / 👆 单指 旋转视角 | 滚轮 / 双指 缩放 | 视角一变就重新累积,停下来看噪点慢慢消失
📖 原理说明
光栅化只画「光直接照到哪里」,路径追踪则模拟光在场景里的完整旅程:从相机出发的光线打到墙上,随机弹向下一个方向,再打到别的物体……直到碰到光源或者逃逸到天空。每个像素的颜色就是大量随机路径的平均值(蒙特卡洛积分)。路径越多越准确:刚开始满屏噪点,随着采样累积,柔和阴影、玻璃折射、墙面互相「染色」这些效果会逐渐清晰地浮现。
🧮算法原理
L = Le + ∫ f · Li · cos θ dω渲染方程:看到的光 = 自身发光 + 从各个方向照进来并被反射的光
像素颜色 ≈ (1/N) Σ 第 k 条路径带回的光蒙特卡洛估计:误差按 1/√N 减小,采样数翻 4 倍噪点减半
- 漫反射与余弦采样
- 粗糙表面把光均匀地散向各个方向,越接近法线方向贡献越大。按 cos θ 的概率分布挑选反弹方向,正好抵消掉公式里的 cos θ,同样的采样数噪点更少。
- 直接光采样(NEE)
- 天花板上的灯很小,随机反弹的光线很少恰好打中它,画面会一直很噪。所以每次打到粗糙表面时,额外朝灯上随机一点发一条「阴影光线」:没被挡住就直接加上灯的贡献。为了不重复计算,漫反射之后再撞到灯时就不再计入它的发光。
- 金属与玻璃
- 金属按反射定律镜面反射,加一点随机扰动就成了磨砂金属。玻璃按斯涅尔定律折射,再用 Schlick 近似算出反射比例,随机决定这条光线是反射还是折射;从玻璃内部以大角度射出时发生全反射。
- 俄罗斯轮盘赌
- 路径每反弹一次,携带的能量就被表面颜色吸收一部分。反弹 3 次以后,按剩余能量的大小随机决定是否继续:终止的路径直接丢弃,继续的路径能量相应放大,这样既节省计算,平均结果又没有偏差。
- 渐进式累积
- 每一「遍」给每个像素追踪一条新路径,加到累积缓冲区里,显示的是平均值。视角一变,之前的采样就作废,从头开始累积。偶尔出现的极亮路径(萤火虫噪点,多来自经玻璃聚焦的焦散)会被限制亮度,代价是焦散略暗一点。
- 多线程渲染
- 每一行的随机数种子只由(行号,第几遍)决定,各行又写在缓冲区里互不重叠的位置,所以可以直接交给 rayon 线程池并行计算,得到的图像和单线程逐位相同。浏览器里的 WASM 线程就是多个 Web Worker 共享同一块
SharedArrayBuffer 内存,这要求页面「跨源隔离」(服务器返回 COOP/COEP 响应头),标准库也要用 nightly 工具链带着 atomics 特性重新编译,所以多线程版是单独构建的 pkg/pathtracer-mt。
🔄Rust 与 JavaScript 的分工
- JS页面启动一个 Web Worker,由它加载 WASM:满足条件时加载多线程版并调用
initThreadPool(线程数),否则加载单线程版。rayon 在等待各线程时会阻塞调用它的线程,而浏览器禁止阻塞主线程,所以渲染必须放在 Worker 里——单线程版也这样做,页面因此始终流畅。
- JS主线程只负责交互:拖动、缩放时发送
pt_set_camera,并始终保持一个渲染请求在途,每批遍数按上一批的耗时调整到约 30ms。每次视角变化都带一个编号,旧视角渲染出的帧到达后直接丢弃。
- RustRust 逐行追踪路径(多线程版把各行分给线程池),累加进缓冲区;
pt_frame() 把平均值做 gamma 校正后返回 RGBA 字节,Worker 把它以可转移对象发回主线程,主线程用 putImageData 显示。累积到 2048 采样后自动停止。
⚡性能要点
- 实测(桌面 Chromium,单线程):康奈尔盒子 320×240 每遍约 32ms(约 240 万条路径/秒);天空场景光线很快逃逸到天空,每遍约 7ms(约 1000 万条路径/秒)。房间是封闭的,光要反弹很多次才被吸收,所以比天空场景慢得多。
- 路径追踪的计算量和像素数成正比:640×480 的像素是 320×240 的 4 倍,每遍耗时也约为 4 倍。
- 实测多线程(康奈尔盒子 320×240,20 个逻辑核心的桌面 CPU):单线程每遍 32ms,2 线程 18ms(1.8 倍),4 线程 9.1ms(3.5 倍),16 线程 3.4ms(约 9.5 倍);多线程渲染出的图像与单线程逐字节相同。线程越多,各线程等待最慢那几行、以及每遍同步的开销占比越大,所以加速比低于线程数。
- 多线程版需要跨源隔离:本项目的
scripts/serve.py 已返回 COOP/COEP 响应头;静态托管(如 Gitee Pages)通常不能设置响应头,页面会自动退回单线程。「线程」下拉框里不可用的选项会注明原因。
- 康奈尔盒子的正面在经典场景里是敞开的,这里把房间向相机方向延长并封闭,这样转动视角时不会看到盒子外面的虚空;房间里相机原地转向,天空场景里相机绕着球旋转。
源码crates/graphics/pathtracer/src/lib.rscrates/graphics/pathtracer/src/scene.rswww/pathtracer/worker.jswww/pathtracer/index.js