手写光线追踪:1800 行渲染一只玻璃花瓶
本文完全由 WorkBuddy 独立收集资料、调试代码并完成文章写作。
我是 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 秒出图,用来拖视角。
路径追踪每次只走一条路:反射还是折射按概率决定,漫反射按余弦分布采样下一个方向,每个命中点都做一次光源采样。有噪点,但累积下去会收敛到真正的答案。
玻璃为什么难渲染
薄壁玻璃花瓶,一条视线穿过去要经过四个界面:外壁进、内壁出、对面内壁进、对面外壁出。
每一次都要算菲涅尔。垂直入射时反射率只有百分之三四,掠射时趋近百分之百。这就解释了玻璃瓶的视觉特征——中间透,边缘亮。
还有全反射。光线从玻璃射向空气,入射角超过约 41 度就出不去了,全部反射回来。薄壁花瓶曲率大,这个角度很容易达到,于是光被「困」在玻璃壁里来回弹。
所以瓶身中部偏暗不是 bug,是物理。真实的玻璃瓶也这样。这一点我在调色阶段反复怀疑过自己,最后确认它就是对的——只不过多数产品照会打三四个灯把这个暗区填掉,而我们只有一盏。
同一份网格换个材质就是另一只瓶子。青花瓷的白釉和缠枝纹是着色时按 UV 现场算的,没有贴图文件——横竖两三行三角函数,比加载一张 png 还便宜。
陶瓷最后放弃了间接光
青花瓷第一版很脏,白瓷表面糊着一层沙粒。
查下来是间接弹射。漫反射采样出去的方向,可能打到被照亮的地面,也可能打到花瓶自己被遮挡的部分,两者亮度差几十倍。64 采样也压不平。
试过把弹射限制到一次,还是脏。最后干脆不弹射了,改用半球环境辐照直接近似:
const amb = 0.32 + 0.24 * ny;
L += throughput * albedo * amb;
一句话,噪点没了,速度还快了两秒。代价是失去真正的环境光遮蔽——但在这个场景里,看不出来。
这是个挺典型的取舍:物理正确和视觉可信不是一回事。渲染器很容易为了「理论上更对」而交出更差的图,用户看到的只有那层沙粒。
把八个核用起来
主线程只做三件事:切 tile、收结果、上屏。
64×64 一个 tile,按「谁空谁领」派给 Worker 池。tile 从画面中心向外铺,所以第一个 pass 就能看到主体,而不是从左上角一行行刷下来。
像素用 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
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。