React 优化技巧在 Web 版光线追踪里的应用(中)
在上篇中,我们介绍了 JS 里的操作符重载方案,它就像 React 里使用 JSX 代替 React.createElement 那样。可以优化我们的代码,使之更加简洁和直观。
可以点击 《React 优化技巧在 Web 版光线追踪里的应用(上)》查看上篇。
然而,它仅仅是语法糖,并不能解决性能问题。光线追踪算法的巨大运算量,需要别的优化技巧。今天我们就来讲一下这些技巧。
解决方案:Time Slicing
时间分片,或叫异步渲染,或叫并发模式,不管叫什么名字,大概描述的都是将一个长时间执行的任务,分成一小块一小块,每执行一段时间,就停下来让主线程里积压的其它任务(比如渲染)得到释放。
React 和 Vue 都曾展示过这种效果。在我们的场景里,也能用这个思路,并且并不需要先实现一个 Concurrency 框架。用 async/await 和 generator function 就可以简单地满足需求。
我们先来看一下,如何把光线追踪的计算任务进行分块。
光线追踪算法的思路是,逐个像素地计算这个像素位置的颜色。因此是两个 for 循环,i = 0~width 加 j = 0~height,每组 i, j 对应该像素的位置。
对于每个像素,我们从眼睛(观察点)里发射 100 条光线,模拟光路从物体到眼睛的反向路径,去采样光源。每个像素的计算方式如下图所示:
采样用的光线,每撞击到一个物体,就会根据物体本身的材质特征,进行反射、折射、散射等。相当于以撞击点为光源,向其它地方再次发射光线。光线在碰撞中,逐渐丧失能量(完全丧失能量,就呈现黑色;比如阴影,阴影通常在物体的夹角处,光线在狭隘的地方高频碰撞,每撞击一次就被部分吸收,部分反射,部分折射,多次撞击,就被充分吸收了)。
考虑到本系列内容的主旨是,以光线追踪为例,讲渲染优化的策略。因此,更详尽的光线追踪算法的描述,超出了范畴;感兴趣的同学,可以搜索《Ray Tracing in a Weekend》等阅读材料。
我们实现了光线追踪算法后,想把它放到浏览器里运行,需要用到本系列文章里介绍的优化措施。
当下,假设我们已经实现了光线追踪算法。其 JS 代码表达起来,如下图所示:
前两层 for 循环,用以确定像素点的位置;第三层 for 循环是发射采样光线的次数,加一点 Math.random() 随机扰动,让采样点在一个小方格的不同点发射出去,能探测到更广一点的环境。
color(ray, world) 函数里面递归地去计算 ray 光线跟 world 里的各个物体的碰撞关系,得出一个颜色值。
最后我们把采样光线获取到的颜色值,累加起来,再取平均值。就是这个像素点的实际颜色。由于它在计算时都被归一化到 0~1 的值区间,因此最后要放大到 0~255 的 RGB 区间中。在此之前做了一次开根号,是对伽马校正的一个简单模拟,在这里并不重要,按下不表。
至此,我们知道了我们的光线追踪算法的任务处理方式。可以如何切块呢?
第一步,颜色值是累加后取平均值得出的。我们可以不在一次遍历里发射光线那么多次,改为只发射一次。就可以得到一个粗糙的图像。把这个图像的像素值储存起来。然后发起再次遍历,每个像素点再发射一次光线,得到另一个粗糙图像,两个图像一迭加取平均,就得到了细节更丰富的图像。
我们消掉了第三层 for 循环。转而以反复执行函数的方式,拿到多个 content,合并起来,我们最终能得到一样细致的光线追踪图像。
我们确实做到了。但令人惊讶的是,把一张细致的图像,分为多个粗糙的图像,并不能彻底解决问题。只是把十几分钟,变成十几秒钟。对于网页来说,卡十几秒钟,还是不可接受。
我们还能怎么分块任务?每计算 n 个像素就停下来一次吗?确实可以。不过我们的代码要怎么写,两层 for 循环可以很好地进行 xy 轴宽高定位。我们不想用很杂乱的代码,来强行满足分片的需求。
有个很好的方式是,把我们的函数改成 generator function,它能通过 yiled 关键字多次停下来。
现在,我们不在函数内部去收集 content 了,我们把它们 4 个为一组地 yield 出去,由外面去收集。
外部通过 data 数组去累计颜色值,用 innerCount 去累积渲染次数,方便取平均值;用 duration 去追踪执行时间,每隔 100 毫秒,就 await delay() 一次(内部用 setTimeout(f, 0)),腾出机会给 UI 主线程。
如此,仅仅靠 generator function 和 async/await,我们就实现了简易的 Time Slicing。尽管出图的时间还是 10 几秒(在性能不佳的电脑或手机里),但起码界面点得动了,起码能不断地渲染 dom 去读秒和计时了。
进阶方案:Streaming Rendering
至此,我们把高清图片分割成多个模糊图片的叠加。又把一个模糊图片的生成,按照 100ms 分成多段,让 UI 主线程里的其它渲染任务(比如DOM),有机会执行。界面变得不再卡顿。
我们的光线追踪在浏览器里可用了,但这不是我们能做到的极限。我们还可以更进一步,让图像更快地展示。
回顾一下,在用 React 做 SSR 时,如果渲染的 HTML 太复杂,要等待全部内容完成,再发送给浏览器。用户就会一直看到白屏。当时我们是怎么优化的呢?
我们会采用 renderToNodeStreaming,渲染成 Node.js Stream,让浏览器一份一份的接收 HTML 字符串,实现渐进式地渲染。在我们的光线追踪场景里,这个优化策略一样可行。
因为,即便没有收集到一张图片的所有像素,它依然是 renderable(可渲染的),让其它部分保持透明即可(我们甚至无需处理,cxt.createImageData 生成的数据结构,默认就是透明)。
如上所示,相比每隔 100ms 就无脑的 delay 一下,让 UI 线程里可能积压的任务得到释放;我们这次,直接发起一次 requestAnimationFrame 的渲染,把渲染 canvas 图片也加入到 UI 主线程里。等全部像素收集完毕,我们会额外进行一次整体渲染。
如此,就实现了 Streaming Rendering,我们不用再等待 10 秒钟,才第一次看见完整图像。我们可以在第一秒钟就看到部分图像。如下所示,1.6 秒时已经能看到部分图像。
我们成功地解决了首次渲染的问题,但我们还能做得更好。除了首次渲染,在更新阶段我们也可以添加一些优化措施。
比如,很多图像大部分内容都是简单的背景,只有少部分物体,我们一视同仁地去计算,是一种浪费。我们应当把宝贵的计算资源,放到更关键的物体,特别是视觉中心里的物体上。让它们优先得到清晰化。
下一回,我将介绍如何采用 React 即将发布的 Schedule 优先级策略的思路,完善我们的光线追踪。