THREE.js渲染顺序

本文将会讲述THREE.js渲染顺序,这里的渲染顺序指的是处于前后位置的物体,是如何渲染出来物体之间的遮挡关系的。
主要的讲述内容包括:

  1. 不透明物体的默认渲染顺序是怎样的;
  2. 透明物体的默认渲染顺序是怎样的;
  3. 不透明物体和透明物体一起渲染的时候,默认渲染顺序是怎样的;
  4. 如何改变物体的默认渲染顺序。
不透明物体的默认渲染顺序是怎样的 首先,我们通过一个简单的例子介绍下我们要实现的效果:
一个基本场景,场景中放置两个4X4正方形,正方形与XOY平面平行,红色正方形放在(0, 0, 0)的位置,绿色正方形放在(2, 0, -2)的位置。
相机使用THREE.PerspectiveCamera,这种相机会有近大远小的效果,相机放置在(0, 0, 6)的位置,此时,相机看向Z轴的负方向。
相机和两个平面的空间位置如下图1、图2所示:
THREE.js渲染顺序
文章图片

图1
THREE.js渲染顺序
文章图片

图2
通过相机和两个平面的空间位置关系,我们知道
  1. 红色平面在绿色平面的前面显示;
  2. 红色平面会遮挡绿色平面的一部分;
最后呈现效果如下图3所示:
THREE.js渲染顺序
文章图片

图3
此时,你可以先自己思考一下,如果让你自己实现,你会如何实现?
我当时自己想了一下,首先我们是知道这两个物体和相机之间的距离的,我们可以按照物体距离相机的远近给物体排个序,然后按照由远及近的顺序渲染物体。也就是先渲染绿色的平面,然后渲染红色的平面。上面这个简单的例子没有问题,那对其他复杂的场景是否适用呢?
比如,这两个平面距离相机的位置是一样的?如下图4所示:
THREE.js渲染顺序
文章图片

图4
按照我们刚才的设想,对于上述情景,我们渲染出来的是要么绿色完全在上面,要么红色完全在上面,如下图5或图6所示:
THREE.js渲染顺序
文章图片

图5
THREE.js渲染顺序
文章图片

图6
但是实际的渲染结果应该如下图7所示:
THREE.js渲染顺序
文章图片

图7
所以上述按照由远及近的顺序渲染的方案只能满足部分使用场景。那么,THREE.js是如何实现的呢?
THREE.js主要是使用WebGL构建3D场景的开源库,是对WebGL提供的能力的一个易用性封装。所以在探讨THREE.js中物体的渲染顺序之前就得先看下WebGL是如何渲染不同位置的物体的。而WebGL是基于OpenGL的,所以这个问题就变成了OpenGL是如何渲染不同位置的物体的。
OpenGL是使用深度测试(depth testing)来保证物体渲染出来正确的遮挡关系的。深度测试使用了深度缓冲区(depth buffer)。和颜色缓冲区(color buffer)类似,只不过颜色缓冲区中存储的是每个像素点的色值,而深度缓冲区存储的是颜色缓冲区当前像素点的色值所在的深度。深度缓冲区和颜色缓冲区具有相同的宽度和高度。
【THREE.js渲染顺序】那么,在渲染过程中,对于每一个像素点,我们存储的数据包括:
  1. 当前像素的色值(通过颜色缓冲区获取);
  2. 当前像素对应的物体片元在空间中的深度(通过深度缓冲区获取)。
下面,我们通过上述图4的例子说明一下,OpenGL是如何通过深度测试(depth testing)实现物体正确的遮挡关系的。
假设物体先绘制绿色的平面,物体在绘制的时候是逐像素绘制的,此时我们已知的信息包括像素的色值和深度信息。对于绿色平面投影到的每一个像素,我们在颜色缓冲区中该像素的位置写入色值,在深度缓冲区中该像素的位置写入深度信息。
然后,我们开始绘制红色的物体。当绘制红色物体的每一个像素时,我们知道了该像素的色值和深度信息D2。然后,我们根据该像素的坐标,获取深度缓冲区中已绘制像素对应的深度值D1,然后比较D1和D2的值。有以下三种情况:
  1. 如果D2小于D1,那就是该物体的当前像素在前面,也就是该像素应该取该物体的当前像素的色值。此时,更新颜色缓冲区当前像素的色值为红色,更新深度缓冲区当前像素的深度为D2;
  2. 如果D2大于D1,那就是该物体的当前像素在后面,也就是该像素不会显示出来,所以当前像素的颜色缓冲区和深度缓冲区的值都不用改变;
  3. 如果D2等于D1,行为和D2小于D1一致。
总结来说,就是我们有一个判断该像素点是否渲染的函数。该函数的输入是
  1. 当前等待渲染的像素点的深度值;
  2. 深度缓冲区中当前像素点的深度值。
输出是一个布尔值表明当前像素是否使用新的色值渲染。
THREE.js中该函数的默认值是LessEqualDepth,也就是上述D2和D1比较的三种情况。该函数的所有取值可以参考THREE.js官网Depth Mode。
所以,还是上面那个例子:
  1. 对于红色物体左边的每一个像素,该深度值D2小于深度缓冲区中的深度值D1,所以颜色缓冲区更新为红色。
  2. 对于红色物体右边的每一个像素,该深度值D2大于深度缓冲区中的深度值D1,所以保持原来的颜色。
最终的结果就是左半边是红色,右半边是绿色。
我们上面分析了先绘制绿色平面,再绘制红色平面的情况。你可以自己尝试分析先绘制红色平面,再渲染绿色平面的情况。
最后的结果就是渲染结果的遮挡关系基本和绘制的先后顺序无关。
透明物体的默认渲染顺序是怎样的 前面讲述了不透明物体的渲染顺序,那么,如果场景中的物体都是透明物体的时候,又是如何渲染的呢?
还是拿前面的例子举例,此时我们把两个平面都设置成半透明的。如下图8所示:
THREE.js渲染顺序
文章图片

图8
如果还是用前面那个逻辑,每个像素点的颜色要么不变,要么使用新物体的颜色,加上深度测试的逻辑之后,渲染出来的效果如下图9所示:
THREE.js渲染顺序
文章图片

图9
在现实生活中,透过透明的物体我们应该是可以看到该透明物体后面的物体的。显然,图9并没有实现这样的效果。那么,问题出在什么地方呢?
我们前面在深度测试的时候,在往颜色缓冲区中写入色值的时候,要么写入当前物体的色值,要么丢弃当前物体的色值。而对于透明物体来说,最终显示的色值并不是单个物体的颜色,而是多个可见物体颜色的一个混合(blend)。
那么,在前面步骤中,当我们判断当前物体是在前面时,可以从简单粗暴的直接使用该色值变为根据当前物体的色值和在颜色缓冲区中的色值按照透明度进行一个混合,然后使用混合后的色值更新颜色缓冲区。
THREE.js提供了多种blend方法,默认是NormalBlending。NormalBlending的计算公式如下:
color(RGB) = (sourceColor * sourceAlpha) + (destinationColor * (1 - sourceAlpha))
color(A) = (sourceAlpha * 1) + (destinationAlpha * (1 - sourceAlpha))
添加上混合逻辑,最后实现出来的效果如下图10所示,这个效果也是符合我们心理预期的一个效果:
THREE.js渲染顺序
文章图片

图10
在渲染不透明物体的时候,我们发现最终的实现效果和物体绘制的前后顺序没有关系。那么,对于透明物体呢?我们实验一下:
先渲染红色平面,再渲染绿色平面 渲染效果如下图11所示:
THREE.js渲染顺序
文章图片

图11
先渲染绿色平面,再渲染红色平面 渲染效果如下图12所示:
THREE.js渲染顺序
文章图片

图12
可以看到,对于透明物体的渲染来说,绘制的先后顺序会影响渲染结果的遮挡关系。那么这是什么原因导致的呢?
我们分析下先渲染红色平面,然后渲染绿色平面的情况。首先,绘制完红色平面之后,颜色缓冲区和深度缓冲区中存储的是红色平面相关的数据。此时,对于被红色平面遮挡的每一个绿色像素来说,先进行深度测试,深度测试失败了,所以该像素直接丢弃了。
所以这里的问题就是,当深度测试成功的时候,我们可以选择是否混合以及混合的函数;但是当深度测试失败的时候,是直接丢弃该像素,而不是也给你提供一个函数,让你自定义这个像素的色值。
综上,透明物体的最终渲染结果和物体的绘制顺序相关。当透明物体按照由远及近的顺序绘制时,结果会在更大程度上符合我们的预期;当透明物体按照由近及远的顺序绘制时,结果基本上不会符合我们的预期,除非你是有意为之。
上面之所以说是在更大程度上而不是一定的原因是存在一些特殊的情况。从上述结论中,我们也可以知道渲染结果和绘制顺序相关。我们可以在绘制物体之前先给物体排个序。但是需要注意的是,我们排序使用的是一个表示物体整体位置的坐标信息,而不是根据物体的每个像素进行排序。所以对于两个交叉的物体,无论绘制顺序是什么样的,最后的渲染结果都是不正确的。如下图13、14所示:
THREE.js渲染顺序
文章图片

图13
THREE.js渲染顺序
文章图片

图14
就上述这种情况,目前我还没有找到解决方案。
不透明物体和透明物体一起渲染的时候,默认渲染顺序是怎样的 如果我们的场景中既有不透明物体又有透明物体,那么,在前面的基础上试想一下,我们应该如何实现呢?
首先,
  1. 对于不透明物体来说,不要求绘制顺序;
  2. 对于透明物体来说,需要按照由远及近的顺序绘制;
那总结起来,是不是可以把所有的物体按照由远及近的顺序进行排序,然后按照这个顺序进行绘制呢?
我自己想了下觉得没有问题,但是发现THREE.js并不是按照这个逻辑实现的。我们先说下THREE.js的默认渲染顺序:
  1. 首先,把场景中的物体根据是否透明划分为两个数组;
  2. 对于不透明物体所在的数组,按照由近及远的顺序排序;
  3. 对于透明物体所在的数组,按照由远及近的顺序排序;
  4. 绘制不透明物体所在的数组;
  5. 绘制透明物体所在的数组。
我想了下,THREE.js之所以这样实现的原因应该是从性能方面考虑。
首先,对于不透明物体来说,虽然绘制顺序对渲染结果没有影响,但是对渲染性能还是有影响的。举例来说,比如两个平行的平面AB,平面A比平面B的距离近,此时:
  1. 先绘制A,再绘制B:
    1. 平面A的所有像素执行深度测试,测试成功,重写颜色和深度缓冲区;
    2. 平面B的所有像素执行深度测试,对于不被平面A遮挡的部分,重写颜色和深度缓冲区;对于被遮挡的部分,深度测试失败,直接返回;
  2. 先绘制B,再绘制A:
    1. 平面B的所有像素执行深度测试,测试成功,重写颜色和深度缓冲区;
    2. 平面A的所有像素执行深度测试,测试成功,重写颜色和深度缓冲区。
通过上述对比可以发现,当对不透明物体按照由近及远的顺序绘制的时候,是可以省掉后面遮挡部分重写颜色和深度缓冲区的操作的,所以在一定程度上提高了性能。
其次,当我们按照由近及远的顺序绘制完不透明物体,开始绘制透明物体的时候,在不透明物体后面的透明物体深度测试失败,所以不会执行下面的颜色和深度缓冲区的更新操作,所以也能在一定程度上提高渲染透明物体的性能。
所以,如果不做区分,一起绘制不透明物体和透明物体的时候,那么所有的物体都得按照由远及近的顺序绘制。那么,大部分情况下的深度测试都会成功,也就是会有更多的颜色和深度缓冲区的更新操作,在一定程度上影响了性能。
如何改变物体的默认渲染效果 前面我们提到的大部分都是默认绘制顺序的效果,但是如果你想改变默认渲染效果,那有没有什么方法呢?
答案是有的。
控制深度测试 前面我们有说到深度测试,深度测试的三个步骤都是可以控制的:
  1. 是否进行深度测试;
  2. 深度测试函数的行为;
  3. 是否更新深度缓冲区。
这三个步骤分别是通过Material的下面三个属性控制的:
  1. depthTest:是否进行深度测试;
  2. depthFunc:深度测试函数的行为;
  3. depthWrite:是否更新深度缓冲区。
此外,当你需要开启深度测试的时候,需要在初始化WebGLRenderer的时候开启depth参数,这个参数会创建一个深度缓冲区。这个参数的默认值是true,也就是一般情况下你不用关注这个属性。当然,如果你的需求明确不需要深度测试,并且性能要求比较高的话,你可以手动关闭这个值,减少一定的存储成本。
控制绘制顺序 前面我们有提到,THREE.js绘制不透明物体和透明物体分别是按照由近及远和由远及近的顺序绘制的,那么这个排序是THREE.js给我们实现的吗?还是需要我们自己控制绘制顺序?
THREE.js默认是开启排序的,这个是通过WebGLRenderer的sortObjects属性实现的。如果不开启自动排序,绘制顺序就是物体的添加顺序(注意,此时透明物体和非透明物体仍然是分开渲染的)。
不透明物体和透明物体的默认排序顺序可以参考源码的painterSortStable和reversePainterSortStable方法。
那么,我们如何干预上述排序过程呢?主要有如下两种方式:
  1. 上述两个方法,我们可以注意到其中有renderOrder属性,这个属性就是我们需要的,具体说明可以参见renderOrder文档;
  2. 通过setOpaqueSort和setTransparentSort完全自定义排序逻辑。
自定义混合(blend)函数 前面我们讲透明物体渲染的时候有提到透明物体的默认混合函数是NormalBlending。这个混合函数的行为也是可选的,具体可支持的行为可以参考Material.blending。
总结 本文主要讲述了THREE.js中的不透明物体和透明物体的渲染顺序,主要涉及THREE.js的以下内容:
  1. Material
    1. depthWrite(default is true)
    2. depthTest(default is true)
    3. depthFunc(default is LessEqualDepth)
    4. blending及blending相关的一系列属性
  2. Object3D
    1. renderOrder(default is 0)
  3. WebGLRenderer
    1. depth
    2. sortObjects(default is true)
    3. setOpaqueSort
    4. setTransparentSort
上述观点是基于目前对THREE.js的研究结果,可能会有认知错误。如有,欢迎留言评论。

    推荐阅读