从零开始手撸WebGL3D引擎7:载入.obj模型以及顶点法线的计算

458 阅读7分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

载入.obj格式模型

.obj是文本格式的,相对比较好解析,而且可以查看,所以先暂时只支持这个格式。当然这个格式里面包含的东西不仅仅是模型数据,其实还有物体的概念,以及材质的关联。由于只是把它作为模型文件使用,所以忽略了材质的关联。材质会采用自己定义的材质,然后使用到模型上。其实这个地方没什么好说的,主要解决的问题是顶点法线的计算。

顶点法线计算的时机

载入.obj格式的模型时,发现有的模型没有提供法线数据,这就需要自己计算。在Unity里面,法线是在导入资源时计算好的,这需要有编辑器的支持,将外部模型格式在导入时转换为引擎内部表示,法线只是其中的一项数据,Unity提供了几个选项,比如是否使用光滑组,夹角阈值等。mini3d.js没有编辑器和工具链,所以暂时只能在载入时(运行时)计算顶点法线。

顶点法线和面法线

我们知道,三角面是有法线的,那么顶点法线是什么玩意?基于顶点计算光照和其他计算时,往往需要法线,这个法线是作为顶点属性传入到shader里面的,这样就需要每个顶点都有对应的法线。如果没有共享顶点,那么顶点法线直接使用面法线就行,一般所谓硬边效果,就是将本可共享的顶点分开,让每个顶点只使用所在面的法线,这样面和面之间就没有过渡的光照效果,例如立方体的六个面。其实光滑组也大概是这个意思,同属于同一个光滑组的面可共享顶点,否则就应该把顶点分开。对于不需要硬边效果的一组邻接面,通过共享顶点,且在共享的顶点上计算顶点法线,来达到过渡的光照效果,使得模型显得更平滑。那么顶点法线如何计算,最简单的就是将共享顶点的几个面的法线进行平均,作为这个共享顶点的顶点法线。但是这存在两个问题:

  • 问题一:如果多个面共享一个顶点,但是有几个面很小,一个面很大。最终顶点方向就偏向很小的面那边。解决方法是使用三角形面积作为权重。
  • 问题二:例如三个面共享一个顶点的情况,其中两个面是在同一个平面A上,另一个面B和这个平面有一个角度,如果采用平均策略,平面A的法线其实是使用了两次,面B使用了一次,这样法线就会偏向平面A,其实需要的效果是在面A和面B中间。解决方法是邻接面上从共享顶点出发的两条边的夹角作为权重。这样面A上的两个三角形虽然计算了两次,但是权重是夹角,而这两个三角形夹角加起来是90度,面B虽然只使用了一次,但夹角也是90度,这样平均下来法线就居中了。 以上参考了这个文章:Weighted Vertex Normals 文章里面有图可以看得更清楚些。

mini3d.js相关代码

objFileLoader.js中包含了.obj文件的载入代码,以及计算法线的代码。其中载入代码参考了《WebGL Programming Guide》,但是《WPG》书中是将所有的顶点复制了,虽然也使用了索引,但是索引就是0,1,2,3....顺序引用所有的顶点,实际上增加了顶点数量。这未免有些奇怪,所以我改成了正常的索引模式,并且不增加顶点。当然这也可能是和.obj文件的格式有点关系,.obj文件中不是从索引对应到一个顶点,然后顶点包含坐标法线这些属性;而是坐标有坐标的索引,法线有法线的索引,理论上来说,这些索引可以是不一致的,甚至数量都可以不同,当然我看到的模型里面都是一样的。暂时处理是只使用坐标索引,然后检查一下其他的索引是否和坐标索引一致。然后还有一个需要注意的是.obj的面不一定是三角形,也可能是多边形。这就需要将多边形拆开:

	// Devide to triangels if face contains over 3 points.
        // 即使用三角扇表示多边形。n个顶点需要三角形n-2。
        if(face.vIndices.length > 3){
            let n = face.vIndices.length - 2;
            let newVIndices = new Array(n * 3);
            let newNIndices = new Array(n * 3);
            for(let i=0; i<n; i++){
                newVIndices[i*3] = face.vIndices[0];
                newVIndices[i*3+1] = face.vIndices[i+1];
                newVIndices[i*3+2] = face.vIndices[i+2];
                if(face.nIndices.length>0){
                    newNIndices[i*3] = face.nIndices[0];
                    newNIndices[i*3+1] = face.nIndices[i+1];
                    newNIndices[i*3+2] = face.nIndices[i+2];
                }
            }
            face.vIndices = newVIndices;
            if(face.nIndices.length>0){
                face.nIndices = newNIndices;    
            }            
        }       

拆的方法是使用三角扇表示多变形,n个顶点需要n-2个三角形。

计算法线的相关方法
    _calcFaceNormal(p0, p1, p2){
        let v_10 = new Vector3(p0.x-p1.x, p0.y-p1.y, p0.z-p1.z);
        let v_12 = new Vector3(p2.x-p1.x, p2.y-p1.y, p2.z-p1.z);
        let normal = new Vector3();
        Vector3.cross(v_12, v_10, normal);
        normal.normalize();
        return normal;
    }

    _calcFaceArea(p0, p1, p2){
        let a = Vector3.distance(p0, p1);
        let b = Vector3.distance(p1, p2);
        let c = Vector3.distance(p0, p2);
        let p = (a+b+c)/2;
        return Math.sqrt(p*(p-a)*(p-b)*(p-c));
    }

    _calcAngle(v0, v1){
        v0.normalize();
        v1.normalize();
        return Math.acos(Vector3.dot(v0, v1));
    }

三个方法分别是计算面法线,计算三角形面积,以及计算向量夹角。计算面积使用了海伦公式。最终计算顶点法线的代码:

        if(normals.length===0){            
            let triangleCount = triangels.length/3;
            let vertexNormals = [];
            let t = 0;
            for(let i=0; i<triangleCount; ++i){                
                let idx0 = triangels[t];
                let idx1 = triangels[t+1];
                let idx2 = triangels[t+2];
                t+=3;

                let p0x = positions[idx0*3];
                let p0y = positions[idx0*3+1];
                let p0z = positions[idx0*3+2];

                let p1x = positions[idx1*3];
                let p1y = positions[idx1*3+1];
                let p1z = positions[idx1*3+2];

                let p2x = positions[idx2*3];
                let p2y = positions[idx2*3+1];
                let p2z = positions[idx2*3+2];

                let p0 = new Vector3(p0x, p0y, p0z);
                let p1 = new Vector3(p1x, p1y, p1z);
                let p2 = new Vector3(p2x, p2y, p2z);

                let faceN = this._calcFaceNormal(p0, p1, p2);          
                let faceArea = this._calcFaceArea(p0, p1, p2);      

                if(vertexNormals[idx0]==null){
                    vertexNormals[idx0] = new Vector3();
                }
                let angle = this._calcAngle(new Vector3(p1x-p0x, p1y-p0y, p1z-p0z), new Vector3(p2x-p0x, p2y-p0y, p2z-p0z));                
                vertexNormals[idx0].add(Vector3.scaleTo(faceN, angle, new Vector3().scale(faceArea)));

                if(vertexNormals[idx1]==null){
                    vertexNormals[idx1] = new Vector3();
                }
                angle = this._calcAngle(new Vector3(p2x-p1x, p2y-p1y, p2z-p1z), new Vector3(p0x-p1x, p0y-p1y, p0z-p1z));                
                vertexNormals[idx1].add(Vector3.scaleTo(faceN, angle, new Vector3().scale(faceArea)));

                if(vertexNormals[idx2]==null){
                    vertexNormals[idx2] = new Vector3();
                }
                angle = this._calcAngle(new Vector3(p0x-p2x, p0y-p2y, p0z-p2z), new Vector3(p1x-p2x, p1y-p2y, p1z-p2z));                
                vertexNormals[idx2].add(Vector3.scaleTo(faceN, angle, new Vector3().scale(faceArea)));
            }

            for(let i=0; i<vertexNormals.length; ++i){
                let n = vertexNormals[i];                                
                n.normalize();
                normals.push(n.x, n.y, n.z);
            }
        

尚未解决的问题

其实目前只解决了一个计算平均法线的问题。但是前提是所有的邻接面都参与到共享顶点的法线的计算中。但是这显然是不科学的。比如一个立方体,如果原始模型是8个顶点,12个三角形。就这8个顶点,你法线怎么算,都会在六面体的相邻面中产生过渡,唯一的方法是不再共享顶点。那么就需要复制出新的顶点。在mini3d.js的build\examples\models目录中有个cube.obj,是一个8顶点的立方体,你可以把它拖到Unity中,会发现Unity导入后变成了一个24顶点的立方体,因为只有两个三角形属于同一个平面时Unity才允许它们共享这个顶点,否则需要新复制出一个顶点。Unity复制的标准要么是光滑组,要么是三角面的夹角阈值。这个模型里面没有光滑组信息,因此应该是满足了阈值。当然另外一个可能是需要分开设置UV坐标。总之无论如何导入模型后是有可能要增加顶点的,这个暂时没有实现,估计到最后实在需要才会做吧。目前使用.obj模式是一个临时方案,可能我会定义自己的二进制模型格式,这样更小解析也更快,这需要工具链的支持。Laya引擎的解决方案是写了一个Unity插件,将模型从Unity导出。这是一个很取巧的方法了,我觉得无任何商业目的时这么做也可以,商业化的东西最好别这么搞,但是Unity被别人用来二次开发做商业化的太多了。