React 优化技巧在 Web 版光线追踪里的应用(下)
在《React 优化技巧在 Web 版光线追踪里的应用(上)》中,我们介绍了 JS 中的操作符重载方案;在《React 优化技巧在 Web 版光线追踪里的应用(中)》中,我们介绍了 Time Slicing 和 Streaming Rendering 优化策略。
代码里的公式不再难看,UI 线程不再卡顿。用渐进的方式进行第一次渲染,用户更快的看见内容。这些都是不错的优化。
不过,我们还可以做更多。
进阶方案:Schedule
React 即将发布的 Concurrency Mode 特性,里面包含了 Suspense 和 Schedule 的功能。它们的作用是,让 UI 的渲染根据不同的触发来源和模块重要程度,划分出优先级。
一个页面,并非所有模块都一样重要。总有一些模块,如标题,头图,价格等,比其它模块(如侧边栏,广告)重要。
页面中触发更新的来源,也并非一样紧急。比如响应用户输入,就应该比其它任何渲染请求更重要。为此,Facebook 工程师还专门向 Chrome 团队,贡献了 isInputPending 这个 API,可以知道当前是否有用户输入。如果有,React 可以及时截断当前任务,处理用户请求。
对于我们的光线追踪场景来说,这种优先级关系也可以划分出来。比如相比背景,物体,特别是聚焦的物体,显然更重要。我们可以更多地计算物体所在的像素里的光线采样情况。
特别的,很多时候,背景不需要反复地去计算光线,它可能每次计算,都得到同一个值。我们可以探测到这种情况,直接跳过它们,把计算资源放到更重要的像素位置里。
优先级划分的策略,根据场景和需求的不同而变化。在此,我采用了一个相对通用的处理办法,就是对比前后两张图片的像素的均方误差(Mean Squared Error),按照误差大小进行排序,误差大的排前面,误差小的排后面。每次取前 20000 个来计算光线追踪。
如此,我们相当于为光线追踪添加了一个最优化算法。光线追踪里发射多条随机光线,本身就是使用蒙特卡罗方法去拟合渲染方程,它在用模拟出来的统计平均值,不断地逼近渲染方程计算出来的理论值(对蒙特卡罗方法感兴趣的同学,可以点击《40+行JS代码打造你的2048游戏AI》了解它在游戏 AI 场景里的应用)。
我们按照像素误差来排列我们的蒙特卡洛采样位置,可以更高效地逼近理论值。
首先,我们不能在一个 render 函数里把所有点位计算出来,我们需要细化出一个 renderByPosition 函数,如上所示。这样我们可以用这个函数,对像素点按照优先级进行光线追踪,而不必在 for 循环里无脑依次追踪。
然后我们新增 3 个数组,renderCount ,prevImageData 和 currImageData。
之前无脑依次渲染,一个数字变量 innerCount 对所有像素点通用,相除即可以得到平均值。如今,每个像素点被渲染的次数,因为优先级的关系,可能不一致了。所以需要专门的一一记录。
我们用 prevImageData 记住上一个颜色值,用 currImageData 记住当前的颜色值,方便用来计算误差。
误差计算很简单,就是实现一个获取均方误差的函数,然后根据上一张图片,当前的图片,计算出每个位置前后两个颜色的误差值(每个颜色值包含 RGBA 四个数字)。根据误差从大到小排列即可。
我们在数据消费端,也实现了一个 renderByPosition 函数,把光线追踪 ray.renderByPosition 的结果,跟 renderCount, prevImageData, currImageData 和 imageData.data 进行数据记录和同步。
我们在 render 函数里依然使用了 innerCount,去记录整体渲染的次数。当它大于 2 时,说明我们起码有两张图片,可以对比误差。因此,我们不再递归地去 render,而是切换到 scheduleRender 函数,根据优先级追踪光线。
scheduleRender 函数,先去获得误差列表,取前 20000 个误差最大的像素点,依次渲染。同时它也做了 Time Slicing 和 Streaming Rendering 处理,让 UI 保持流畅。
在 scheduleRender 函数的底部,我们通过 count 记录 scheduleRender 的次数,大于 5 次后,将渲染模式切换回整体渲染的 render 函数。
这是因为,蒙特卡罗模拟里带有随机性,只靠第一和第二张图片的误差来排优先级,会存在概率性的忽视,现象是图片变得不平整,仿佛有很多坏点。按照一定频次,进行整体渲染,可以让运气坏的像素点得到重新鉴定的机会。
通过切换 scheduleRender 和 render,我们尽可能消除了统计偏差。既实现了优先级划分,又保持了渲染的整体平滑效果。
花 1000 秒时间,渲染结果如上。上半部分是渲染的图片,下半部分是每个像素点的渲染次数,次数越多,颜色越白。我们可以很直观地看到,我们的计算资源主要分配在哪些地方。
从图中我们可以看到,交界和阴影里的光线情况相对复杂,因此我们着重去拟合了这些地方的光线情况(更白)。而背景跟天空,颜色比较单一,投入的光线计算资源就应该比较少(更黑)。
重复多次后,我们可以看到,误差列表里的 value 值,越来越接近 0。意味着对理论值的拟合变得更好了。
如上所示,通过 Schedule + MSE 最优化策略,我们用更短的时间,就得到了一个局部高清的图像。而不必等很长时间,去得到一个全局高清的图像。
进阶方案:心理加速
渲染性能上的优化,并非唯一的优化途径。
物理意义上的快,跟人类心理感受上的快,有时也不是一致的。
我们之所以在更新阶段进行 Schedule 渲染,是因为我们起码需要两张图片来计算误差梯度。
但是,仔细一想。第一次渲染,难道就分不出优先级吗?
根据我们对人类创造的图片的浏览经验,很容易可以总结出:图片中心的内容,比图片边缘的内容,往往更重要。而我们的 Streaming Rendering,仿照的是 React SSR 渲染 HTML 的模式,从上到下。
对于 HTML 文档来说,从上到下渲染,无可厚非。然而我们是图片,我们应该从中间开始,往上下两个方向展开。
如上所示,我们仅仅改变了一下渲染的起始位置和方向,用户就更大概率看到他们更感兴趣的东西。而不是首先看到空无一物的天空。
此外,人类视觉有自动捕捉模式的能力。我们不必完整渲染,靠人眼对物体的模式匹配,也能让用户知道图片里大概包含什么内容。因此,我们可以按照一定间隙,渲染部分更粗略的图片。
上图只渲染了一半像素,只花了一半的时间。我们确实能识别出图片里的大概内容。如此,我们可以快速地生成粗略图,让用户有视觉占位;结合前面一招,从中间展开像素,细化内容,实现更好的视觉体验。
如上所示,现在用户不仅更快看到视觉中心的物体,还对图片的整体有了一定的把握。我们的 Schedule 优先级策略,在首次渲染阶段也得到了成功的应用。
总结
回顾一下,我们可以看到,React/Vue 里的渲染优化策略,在其它地方同样适用。
+-*/编译成函数调用,跟 JSX 编译成 React.createElement 函数调用如出一辙。
长时间渲染卡住 UI 主线程,都能采用 Time Slicing 的做法。
长时间等待一次完整渲染,都可以采用 Streaming Rendering 的做法。
模块之间存在优先级划分,都可以采用 Schedule 的做法。
以上是我在学习光线追踪的过程中,顺手解决的问题。还没有把它们做成开源的 babel 插件和库的计划,故在此分享一下思路,希望能帮助到一些同学。
值得一提的是,我们仍未穷尽可用的优化方案;这里呈现的优化措施,仅仅是其中一小部分。比如,考虑到每个像素点的光线追踪都是独立的,把这个过程并行化(Parallelization),放到 Web Worker 或者 GPU 里计算,即能得到效率的显著改善。感兴趣的同学,可以自行探索。
点击原文,可以查看上图的在线 DEMO。手机端也能体验噢。