本文延续上篇内容:

three.js+WebGL踩坑经验合集(10.2):镜像问题又一坑——THREE.InstancedMesh的正反面向光问题-CSDN博客

上一篇我们经过多番排查,最终定位到的问题就是THREE.InstancedMesh里面的负矩阵没被读取并处理,导致面片的显隐出错。

在我们的项目里,业务层通过把material.side从Front改成Back让负定矩阵的丢失被抵消掉,但却产生了新的问题,面片的向光面和背光面也跟着反了(因为改side后shader会把法线取反),反倒在笔者写这篇博客的时候才真正处理到了问题的根源。

针对负定矩阵处理后,笔者发现,哪怕改成双面(DoubleSide),只要正负定矩阵不混在同一个THREE.InstancedMesh里面,不管是显隐还是向光背光的结果都是正确的。

var instanceCount = matrixes.length;
material.side = THREE.DoubleSide;
var instancedMesh = new THREE.InstancedMesh(geometry, material, instanceCount >> 1);
for(var i = 0; i < instanceCount >> 1; i ++)
{
  instancedMesh.setMatrixAt(i, matrixes[i]);
}
scene.add(instancedMesh);
var material_mirror = material.clone();
material_mirror.side = THREE.DoubleSide;
var instancedMesh_mirror = new THREE.InstancedMesh(geometry, material_mirror, instanceCount >> 1);
for(var i = instanceCount >> 1; i < instanceCount; i ++)
{
  let matrix = matrixes[i].clone();
  instancedMesh_mirror.setMatrixAt(i - (instanceCount >> 1), matrix);
}
scene.add(instancedMesh_mirror);

跟上篇的提取负定矩阵到InstancedMesh自身相比,这种解决方案要优雅得多,代价是多费了点性能,所以大家还是根据项目的实际情况进行取舍,哪怕只是把判断负定矩阵改成标记位,多个if判断,在场景东西多的时候还是会有一定的耗时。

这么看,好像双面问题就这样完结了,但因为双面能解决大多数的可见问题,所以很多的业务开发都会试着开启双面来规避一些错误,然后就很容易把双面给玩坏。

接下来笔者就和大家聊聊双面的使用问题。

双面这个玩意儿,按我们CTO的话讲,现实中是不存在的,一张再薄的纸也有厚度,并且几何对面的定义没有厚度,对线的定义没有粗细,对点的定义也没有大小。一个所谓的双面的平面,它更应该以两个平面的形式存在,有各自的法线。

从性能上说,3D场景的大多数物体是封闭的几何体,一个平面始终有一个方向会被遮挡而不可见,开启双面会额外产生不必要的性能损耗。

但是在实际开发中,双面在某些场合又会给我们带来便利,笔者在项目中遇到的有以下这些:

1 图形编辑器中的箭头,网格点,选中框,旋转控件,这些通常是纯色或者轻渐变色,且不受光照影响,无需定义法线,这种物体通常可以用线条或者平面来表达,并且不管场景如何旋转都始终可见,这时候用开启双面代替两个单面可以节省一半的内存占用。

2 遇到一些实体编辑的场景,比如自由建模,在一个立方体里挖个洞是非常常见的事情,这时候,如果想要透过洞看到立方体的内部(也就是背面),而不是穿透出去的话,开双面也是非常方便的,也很省内存(实际上这种情况更应该用两个单面代替)。

3 在我们的项目里,因为有些模型面数太多,渲染耗时长,我们的开发做了一个减面工具,但是这个工具有时会把面的绕序给改坏,为了赶上线,我们也是有过很长一段时间让模型已双面的方式进行呈现。

可以发现,除了第一种场景,其它的都是用双面来蒙混过关,业务开发们用爽了之后就会玩得越来越花,埋下的坑也越来越多。

下面我们先试着把上一篇的THREE.InstancedMesh案例改一下,把正负定矩阵合并回来,并且开启双面看看。

var instanceCount = matrixes.length;
material.side = THREE.DoubleSide;
var instancedMesh = new THREE.InstancedMesh(geometry, material, instanceCount >> 1);
for(var i = 0; i < instanceCount; i ++)
{
  instancedMesh.setMatrixAt(i, matrixes[i]);
}
scene.add(instancedMesh);

看到了没,正负定矩阵一合并,上面一排面片的向光和背光的显示就错了。

首先可以明确的一点是,这些面片的法线初始都是向着屏幕的(上面一排只有x方向的负缩放和小幅度的旋转),并不存在一半向外一半向里的情况,按道理算出来的光照结果一样或者接近才对。

下面我们把上篇最后贴出的,跟双面有关的颜色计算代码搬过来。

这里有个判断,根据gl_FrontFacing的值取不同的颜色,我们去掉判断,直接都用Front值看看。

好家伙,都显示成向光面了。

如前所述,双面是一个跟现实不存在的东西,用DoubleSide构造出来的双面图形,它跟两个单面的最大区别是,法线只有一条,所以单靠法线跟灯光向量做点乘,只能算出一个光照结果,无法把向光和背光面体现出来。所以在开启了双面的情况下,meshLambert(实际上其它的跟光线有关的材质都一样)的shader会多算一个back值,也就是背光面的颜色。

然后在最终算颜色的时候会根据gl_FrontFacing变量做出判断。

按这个名字来理解,它是个布尔值,指示当前是否为正面。把这个意思放到光照计算里面来理解的话,它的判断依据应该是法线方向是否向着屏幕。

笔者在处理这里的bug之前,是没见过gl_FrontFacing这个属性的,但是知道但凡是gl_前缀的变量名,都是webgl的内置变量,百度了下果然找得到。并且搜索出来的文章告诉笔者,gl_FrontFacing是根据点的绕序进行区分的。

艾玛,这不是坑人吗?但如果大家了解渲染管线的工作流程,就会知道,法线并不是绘制一次面片的必要组成部分,法线是归上一级的引擎(现在是three.js)管的,gl_前缀的变量自然拿不到。所以gl_FrontFacing跟背面剔除一样,都是拿着点绕序来做事。

这样的话,问题的原因就找到了,是gl_FrontFacing所指示的正面和法线向量所指示的正面不一致,所以上面一排面片的点绕序因为有x方向的负缩放而变反了,导致结果跟预期不一致。

但是这个问题在没有用THREE.InstancedMesh合批,或者是正负定矩阵不混在一起的情况下(这里指的是笔者上篇修正后的版本)又是不存在的。虽说双面无所谓FrontFace,但是渲染引擎在开启双面的情况下仅仅关闭了剔除的设置,FrontFace的绕序计算依然正常进行。

总的来说,就是绕序设置和背面剔除互不干扰。

言归正传,问题还是负定矩阵在THREE.InstancedMesh放在实例里导致的。

所以按道理,普通的Mesh是不会出这样子的事,但是笔者在标题里也加上了THREE.Mesh,那是因为我们的业务开发有玩得很花的时候。我们的项目除了用THREE.InstancedMesh对相同geometry和material的对象进行合批以外,还会对geometry不一致,material相同的物体做手动的顶点合批,至于material都不一样的,那就是大家非常熟悉的图集合批了。

顶点合批过程会把物体的顶点坐标都乘以全局矩阵,然后再合到同一个数组里显示到scene的根子节点,因此,如果全局矩阵有负定矩阵的话,那就会出现跟THREE.InstancedMesh一样的问题了。

为了节省篇幅,顶点合批的代码就不给出来了,这里直接建个简单的BufferGeometry,把点序取反来观察效果。

var geometry = new THREE.BufferGeometry();
var points = [new THREE.Vector3(-100, -100, 0), new THREE.Vector3(100, -100, 0), new THREE.Vector3(100, 100, 0)]
geometry.setFromPoints(points);
geometry.computeVertexNormals();
var material = new THREE.MeshLambertMaterial({color: 0xFF3300, side: THREE.DoubleSide});
scene.add(new THREE.Mesh(geometry, material));

//再加个法线查看器方便观察
var normalHelper = new THREE.VertexNormalsHelper(mesh, 100, 0xFFFFFF);
scene.add(normalHelper);

现在的结果是正确的,然而玩得花的,或者负责顶点合批优化的开发小盆友,它们会有意无意地做一件事情:在computeVertexNormals之后追加以下代码把顶点的绕序给改了。

geometry.setFromPoints(points.reverse());

这下就错了。笔者现在之所以这么容易把问题给构造出来,正是因为原理已经搞明白了。既然如此,那笔者就来给大家小结一下:

1 开启双面的情况下,跟光照有关的材质shader会给向光面和背光面都计算一个跟光照有关的颜色值,计算的依据为物体的法线向量和灯光的法线向量(这里只讨论平行光)

2 向光面和背光面的判断依据则为webgl的内置变量gl_FrontFacing

3 一个法线方向跟BufferGeometry.computeVertexNormals()计算结果一致的几何体,法线向量所指示的正面跟gl_FrontFacing所指示的正面相同,不会出bug

4 如果3的条件不满足,则向光面和背光面的判断就可能跟预期不符合,因为颜色值和正反面的判断机制不相同

找到这个问题之后,笔者把bug的原因告诉了业务开发人员。对方还问我,这地方是不是两个不同的人写的,怎么会用的方法不一样。然而笔者懒得去查three.js开发人员的提交记录,就结合了一下自己早前处理另一个双面问题的经验给到了他答复,说用法线判断正反面比用绕序要吃性能。

那个时候笔者还不知道有gl_FrontFacing这个属性,拿法线判断的话,就是用当前点的法线向量跟垂直于屏幕的射线向量的夹角。后者说的射线跟鼠标射线检测所用的射线一致,透视相机是camera.position跟物体世界坐标的连线,而正交相机则固定为(0,0,1)。

听起来也不是很耗性能的样子,但实战发现这玩意儿在低配机下表现不太好。首先camera的position要从cpu传给gpu,这相当于多了一道通讯(笔者突然有灵感这里可以优化,但会对three.js的工作流程造成较大的破坏),其次,笔者一开始想着在vertexShader做射线相关的计算来减少显卡的运算量(相对于fragmentShader),却发现这样生成出来的结果在比较精细的弧面下会有精度问题,搞得要逐像素计算才能解决。

传camera.position给gpu,three.js内部也有做,但这里并没包含MeshLambertMaterial,如果加上,那对于低配机而言也是一笔不小的开销。

还有一点就是,笔者在处理另一个双面问题的时候能力不如现在,刚去翻代码的时候发现有个地方写得性能非常糟糕,而且还是在fragmentShader上的,见笑了。

但无论如何,最后笔者还是奉劝大家,3D场景中的实体物品,能不用双面就不用双面吧,不然bug和性能问题都会永远处理不完。

更多推荐