手写光线追踪:1800 行渲染一只玻璃花瓶
原创 · 约 20 分钟阅读 · 阅读 --

手写光线追踪:1800 行渲染一只玻璃花瓶

作者: Alex Xiang


本文完全由 WorkBuddy 独立收集资料、调试代码并完成文章写作。

扫码注册 WorkBuddy 即可获取 2000 积分

我是 Alex Xiang,前百度/微博工程师,现在专注于 AI 工程与工具产品。更多文章欢迎关注微信公众号「字与码」。

一只花瓶,一盏灯,一块桌面。没有 Blender,没有模型文件,也没有任何一行 3D 图形库的代码。

花瓶的形状是 18 个数字算出来的,光线是自己发射、自己求交、自己反弹的,连最后的色调映射都是手写的。1800 行 JavaScript,跑在浏览器里,八个核一起算,64 采样渲染一张玻璃花瓶大约 4.5 秒。

它现在开放在网上,可以拖着转视角、换材质、调采样数,看着画面从一片噪点里慢慢浮出来。

浏览器里运行的光线追踪渲染器

先把约束说清楚

这个项目真正有意思的地方不是「能渲染」,而是「不许用库」。

不能用 three.js,那等于让别人替你算交点,你只是填参数。不能用 WebGL 写 shader,虽然快一个数量级,但把 BVH 的栈遍历搬进 GLSL 之后,代码会变成一团没人愿意读的东西——而「能读懂每一行」恰恰是这件事的意义。

剩下的选择只有 CPU 加 JavaScript。性能就得自己想办法,这也是它为什么有 1800 行而不是 300 行。

语言上没什么纠结。C++ 转 WASM 能快三到五倍,但工具链、内存边界、调试成本一起上来,第一版不值得。Python 在浏览器里跑不动。TypeScript 的类型在这个规模上收益有限,索性用纯 ES module,零构建、零依赖,双击 index.html 之外什么都不需要。

花瓶是用一条曲线转出来的

手工建模最难的是「手工」两个字。

办法是旋转体:在半径-高度平面上画一条曲线,绕 Y 轴转一圈,就是花瓶。vase.js 里躺在这么一张表:

[0.148, 0.000],  // 圈足外缘
[0.342, 0.252],  // 腹部最宽
[0.168, 0.640],  // 颈部最细
[0.243, 0.868],  // 瓶口

一共 18 个点,中间用 Catmull-Rom 插值补成光滑曲线,绕轴旋转 96 段,得到 12864 个三角形。改这张表就能改造型,比拖控制点直观得多。

真正巧妙的地方在壁厚。

花瓶不是实心的,是有厚度的薄壳——只有薄壳才有真实的折射。做法是把剖面当成一条闭合多边形:底心 → 外壁向上 → 瓶口唇边 → 内壁向下 → 内底心 → 回到中轴。绕轴转一圈,这条多边形围出来的就是玻璃壁本身。

剖面曲线绕轴旋转生成花瓶网格

好处是法线。对闭合多边形,每个点的外法线只由前后两点决定。转成三维之后:外壁朝外、内壁朝瓶腔、口沿朝上、底面朝下——一条多边形自动得到了全部朝向正确的法线。

这一点省掉了非常多麻烦。不用判断三角形绕序,不用为内外表面准备两套材质,玻璃的内壁和外壁用同一份代码就对了。

光线怎么找到它

12864 个三角形,一条光线挨个测就是一万多次求交。BVH 把它压到几十次。

建树用 SAH:每个节点试三个轴、每个轴分八个桶,算表面积代价,挑最便宜的切法。叶子放四个三角形。

遍历做了两件事。

第一是近的先走。原本是先左后右无脑遍历,现在是先算出两个子节点各自的进入距离,远的先压栈、近的后压栈。后进先出,最近的那个先被处理,远处子树常常直接被「当前最近命中距离」剪掉。

第二是把顶点预存成边向量。三角形求交每次都要算 v1-v0、v2-v0,六个减法。改成建树重排时就存成 [v0, e1, e2],求交时直接读。

有个数据值得记一下:我一度以为三角形太多,把网格从 24000 减到 12864,速度只快了 8%。这说明瓶颈从来不是三角形数量,而是节点访问的次数和内存跳转。微优化到这个程度,收益开始变得很小——这也是为什么我没继续往 SIMD 或 BVH4 上走。

两套积分器

界面上的「路径追踪 / 快速」是两套完全不同的算法,共用同一份 BVH 和材质。

快速模式是经典的 Whitted 风格:命中玻璃就按菲涅尔拆成反射和折射两条,各自递归;命中陶瓷就采样光源算直接光。确定性算法,没有噪点,一遍就干净。0.8 秒出图,用来拖视角。

路径追踪每次只走一条路:反射还是折射按概率决定,漫反射按余弦分布采样下一个方向,每个命中点都做一次光源采样。有噪点,但累积下去会收敛到真正的答案。

玻璃为什么难渲染

薄壁玻璃花瓶,一条视线穿过去要经过四个界面:外壁进、内壁出、对面内壁进、对面外壁出。

每一次都要算菲涅尔。垂直入射时反射率只有百分之三四,掠射时趋近百分之百。这就解释了玻璃瓶的视觉特征——中间透,边缘亮。

64 采样渲染的薄壁玻璃花瓶

还有全反射。光线从玻璃射向空气,入射角超过约 41 度就出不去了,全部反射回来。薄壁花瓶曲率大,这个角度很容易达到,于是光被「困」在玻璃壁里来回弹。

所以瓶身中部偏暗不是 bug,是物理。真实的玻璃瓶也这样。这一点我在调色阶段反复怀疑过自己,最后确认它就是对的——只不过多数产品照会打三四个灯把这个暗区填掉,而我们只有一盏。

同一条剖面生成的青花花瓶

同一份网格换个材质就是另一只瓶子。青花瓷的白釉和缠枝纹是着色时按 UV 现场算的,没有贴图文件——横竖两三行三角函数,比加载一张 png 还便宜。

陶瓷最后放弃了间接光

青花瓷第一版很脏,白瓷表面糊着一层沙粒。

查下来是间接弹射。漫反射采样出去的方向,可能打到被照亮的地面,也可能打到花瓶自己被遮挡的部分,两者亮度差几十倍。64 采样也压不平。

试过把弹射限制到一次,还是脏。最后干脆不弹射了,改用半球环境辐照直接近似:

const amb = 0.32 + 0.24 * ny;
L += throughput * albedo * amb;

一句话,噪点没了,速度还快了两秒。代价是失去真正的环境光遮蔽——但在这个场景里,看不出来。

这是个挺典型的取舍:物理正确和视觉可信不是一回事。渲染器很容易为了「理论上更对」而交出更差的图,用户看到的只有那层沙粒。

把八个核用起来

主线程只做三件事:切 tile、收结果、上屏。

64×64 一个 tile,按「谁空谁领」派给 Worker 池。tile 从画面中心向外铺,所以第一个 pass 就能看到主体,而不是从左上角一行行刷下来。

主线程与 Worker 池的分块调度结构

像素用 Float32 累积。每个 tile 算完通过 Transferable 传回主线程,零拷贝。主线程把结果加进累积缓冲,按当前样本数归一化,做 ACES 色调映射和 gamma,再一次性 putImageData。

七十毫秒刷一次屏,不是每个 tile 都刷——否则光拷贝就吃掉了渲染时间。

拖动视角时自动降到 0.36 分辨率跑快速模式,手一停 260 毫秒后回到全质量重渲。所以转起来是跟手的。

快速模式渲染,用于交互预览

四个真正花了时间的坑

地面从来没被渲染过。 求交函数用 -1 表示「没命中」,而地面平面的距离永远比 -1 大,于是永远比不过。渲出来是一张只有背景渐变、花瓶悬在空中的图。这个 bug 读代码看不出来,是盯着图问了一句「地呢」才发现的。

specular 这个名字骗了我。 它本来的意思是「上一次弹射是不是镜面」,我写成了「是不是第一次弹射」。结果间接弹射之后不再做光源采样,间接光只能靠极小概率恰好命中光源。表现是又暗又噪,而且加采样加不干净。

玻璃完全没有高光。 因为光源不是几何体,BVH 里没有它,反射射线打过去是空的。必须单独写一个矩形面光的求交,在 miss 分支里判断「这条光线是不是打到了灯」。加上之后玻璃立刻活了——之前它像一块磨砂塑料。

青花纹理被冲掉了。 我先用 UV 算出釉色,但直接光那一步用的是写死的白色反照率。主力照明全是白的,纹理自然看不见。顺序错了,改一行就好。

这四个 bug 有一个共同点:它们都不报错。渲染器的调试没有异常栈,只有一张看着不对劲的图。

现在可以玩

https://vase-raytracer.app.workbuddy.host/?sharecode=3HMXSHCX7F

  • 拖动旋转视角,滚轮推拉
  • 积分器切「路径追踪 / 快速」
  • 材质切「薄壁玻璃 / 陶瓷青花」
  • 采样数 4–128,分辨率 50%–160%,弹射深度 2–9
  • 「存图」直接导出 PNG

手机上也能开,只是核少,会慢一些。

代码结构是这样的,按读的顺序排:

vase.js     剖面控制点 → 旋转成网格(含解析法线)
bvh.js      SAH 建树 + 近先遍历 + 遮挡测试
scene.js    场景装配:花瓶、地面、面光
render.js   积分器、材质、相机、tile 渲染
worker.js   线程入口
main.js     调度、累积、色调映射、交互

如果只能从这个项目里带走一句话,大概是这句:不用库的价值不在于「更厉害」,而在于每一行你都知道它为什么在那里。 那个把地面删掉的 bug,用 three.js 的人一辈子也遇不到——但也一辈子不会知道光线是怎么落到桌上的。

关于作者

我是 Alex Xiang,前百度 / 微博工程师,现在专注 AI 工程与工具产品。这类「把黑盒拆开、自己重写一遍」的实验,我一般都写成文章发在公众号「字与码」——不只讲结论,也讲中间踩了哪些不报错的坑。

微信公众号 字与码

长按识别二维码关注「字与码」

参考资料

  • James T. Whitted, An Improved Illumination Model for Shaded Display, Communications of the ACM, 1980
  • Tomas Möller, Ben Trumbore, Fast, Minimum Storage Ray-Triangle Intersection, Journal of Graphics Tools, 1997
  • Ingo Wald, On Fast Construction of SAH-based Bounding Volume Hierarchies, IEEE Symposium on Interactive Ray Tracing, 2007
  • 本项目的可交互版本:https://vase-raytracer.app.workbuddy.host/?sharecode=3HMXSHCX7F
打开原图 ↗