从零开始手撸WebGL3D引擎9:Scene & Transform

596 阅读8分钟

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

mini3djs8.png

引言

相比于实现很多花哨的图形效果,写引擎需要很多很扎实的东西。比如说场景和变换就是其中之一,基础中的基础。实现场景和变换的最初的动机,是我们需要在世界中移动旋转和缩放物体,物体之间需要有层级关系,可以将A放到B的上面随着B一起运动。而在这背后是矩阵和四元数的数学。我们使用四元数保存物体的方位朝向,并且可以让物体绕任意轴旋转,可以让物体的正面指向某个世界坐标,可以平滑的旋转,还可以使用欧拉角设置四元数所表示的旋转。然后我们可以操纵camera,并且从camera的视角渲染整个世界,这需要将所有的物体的位移旋转缩放合并成一个矩阵,这既需要将四元数表达的旋转转换成矩阵,又需要将物体自身的位移旋转和缩放合并成一个本地矩阵,还需要更新物体的世界矩阵,让物体随着上一层运动。当然最终我们需要一个视图矩阵,让所有的物体在camera的坐标系中表示,之后我们需要一个投影矩阵,让所有可见的物体变换到投影空间。计算出所有需要的矩阵后,我们将必要的矩阵和其他信息传入shader中,实现场景的绘制。当然场景的渲染是另外一个话题,本篇我们只谈谈变换。

将物体组织成场景

一个场景是一棵N叉树

场景是对游戏世界的抽象,而游戏世界是对现实世界的模拟。在现实世界中,物体总存在与某个物体之内,比如人坐在车中,随着车一起行动。世界中天然的存在一层层的层级关系。表示这样一个逻辑结构,很自然的采用树,这是因为任何一个物体只能有一个父物体,而可以有N个孩子物体。采用N叉树,我们通过parentchildren来组织场景中的物体。

场景不止是一棵树

N叉树用于组织场景的空间结构,可以很容易的处理物体的空间关系。然而,当物体数目很多时,我们需要高效的查找需要渲染的物体,需要高效的搜索和某个物体交互的其他物体。我们可以用四叉树或八叉树来管理物体的空间划分,使用层次包围体去优化快速的查询和剔除,以及还有遮挡剔除,LOD,门系统,BSP,区块系统等等。在场景中,我们可能还需要寻路,这需要导航网格。我们可能还要在场景中集成进物理引擎。这还没算上渲染相关的东西。我感觉这是游戏引擎最复杂的部分了,毕竟所有的一切都发生在场景中。

从简单开始

先忘掉所有复杂的优化和功能,实现一个最基础功能的场景,这样压力会小很多。但是这仍然不是一个简单的事情。我们需要面对数学,理解矩阵和四元数,理解本地变换,世界变换,视图变换和投影变换。并且我们需要采用合理的方式把这些组合起来。目前mini3d.js实现了一个简单的场景框架。

SceneNode

一个场景中包含很多节点,节点包含子节点。为了简单,我让SceneNode直接包含变换相关的成员:

class SceneNode {
    constructor(){
        this._isStatic = false;
        this._localPosition = new Vector3();
        this._localRotation = new Quaternion();
        this._localScale = new Vector3(1,1,1);

        this._worldPosition = new Vector3();
        this._worldRotation = new Quaternion();

        this.localMatrix = new Matrix4();
        this.worldMatrix = new Matrix4();

        this.parent = null;
        this.children = [];

        this.components = {};

        this._worldDirty = true;

        this._scene = null;
    }
 }

目前为止,非常简单。

  • 首先,我们使用向量和四元数保存节点的本地坐标,朝向和缩放。
  • 我们有世界坐标和世界朝向,这是和本地坐标系的值关联的。真正保存的是本地坐标系的值,比如我们设置世界坐标的方法:
	set worldPosition(v){
        if(this.parent==null){
            this.localPosition = v;
        } else {            
            _tempMat4.setInverseOf(this.parent.worldMatrix);
            Matrix4.transformPoint(_tempMat4, v, _tempVec3);
            this.localPosition = _tempVec3.clone();
        }        
    }

实际上修改的是本地坐标的位置。

  • 我们保存了本地矩阵和世界矩阵,本地矩阵对于动态物体是每帧更新的,世界矩阵采用Dirty机制,只在需要更新时更新。

本地矩阵计算

updateLocalMatrix(){        
        this.localMatrix.setTranslate(this._localPosition.x, this._localPosition.y, this._localPosition.z);           
        Quaternion.toMatrix4(this._localRotation, _tempMat4);
        this.localMatrix.multiply(_tempMat4);        
        this.localMatrix.scale(this._localScale.x, this._localScale.y, this._localScale.z);   
    }

常规操作,我们使用向量右乘,矩阵在左边。而本地变换需要先缩放,再旋转,最后平移。因此我们按照平移,旋转,缩放的顺序构造矩阵。其中旋转矩阵是从四元数转换过来的。

更新世界矩阵

	updateWorldMatrix(forceUpdate=false){        
        if(this._worldDirty || forceUpdate){
            if(!this._isStatic){
                this.updateLocalMatrix();
            }
    
            if(this.parent==null){
                this.worldMatrix.set(this.localMatrix);
            } else {
                Matrix4.multiply(this.parent.worldMatrix, this.localMatrix, this.worldMatrix);
            }
    
            //从world matrix中提取出worldPosition
            let worldMat = this.worldMatrix.elements;
            this._worldPosition.set(worldMat[12], worldMat[13], worldMat[14]);
    
            //计算world rotation (或许可以像three.js的decompose那样从矩阵解出来)
            if(this.parent==null){
                this._worldRotation.copyFrom(this._localRotation);
            } else {
                Quaternion.multiply(this.parent._worldRotation, this._localRotation, this._worldRotation);
            }

            this._worldDirty = false;
        }

        
        this.children.forEach(function(child){
            child.updateWorldMatrix(true);
        });        
    }

因为我们是从场景的根节点开始更新的,因此父节点的本地矩阵和世界矩阵先计算好,这样对于子节点,只要将父节点的世界矩阵乘以自己的本地矩阵,就得到了自己的世界矩阵。由于我们计算世界矩阵是根据是否dirty的,而当父节点需要重新计算时,子节点必须也要重新计算,因此对于子节点我们强制重新计算。

获取世界坐标和旋转

某些引擎会临时计算世界坐标,我觉得这有点浪费。我采用的是直接从世界矩阵中获取,因为世界矩阵是一直保持最新的,通过每帧的更新以及按需求更新(这很重要,因为游戏代码中很可能做了某些操作导致本帧更新出的世界矩阵已经失效,因此需要按需再次更新并缓存,否则就是下一帧才会生效,这容易产生bug,有的引擎不得不打补丁延后一帧操作) 而对于旋转,理论上也可以从矩阵中解出,比如three.js是这么做的,但是我有点拿不准这样是否有问题,three.js目前的代码也是经过一个社区PR修改后的。为了稳妥,我暂时在更新世界矩阵后使用父节点的世界旋转四元数乘以当前节点的本地旋转四元数,这和世界矩阵串接类似。

对象-组件和ECS

SceneNode中包含了component数组,显然我们使用了对象-组件机制。这是常规操作。作为一个实验性渲染引擎,我觉得已经足够了。不过我确实思考过ECS。从思考方式说,ECS是反面向对象的,它的优势是在大型项目中将逻辑分得很清晰,组件只包含数据没有操作,系统只关心对哪些组件采取操作,他们通过实体联系起来,看上去确实很美,而且有利于cache命中,提高代码运行效率。不过暂时我是没机会体验了,据说实际使用的项目也不多。

怀念一下C++

在写mini3d.js的过程中,我对javascript本身没什么不满意,必须我们搞的事情也是很轻量级的,但是直到我设计SceneNode的接口的时候。

get localPosition(){
        return this._localPosition;
    }

这里使用了ES6的类的get方法,返回了本地坐标。但是用户调用这个方法后可以直接去修改这个Vector3对象。。但是如果不这么写,比如我返回_localPositin的一个拷贝,这首先是浪费了性能,更重要的是,用户对这个拷贝进行修改是没有作用的,但是语言不能报错或报警,因此用户不知道bug在哪儿!!没办法我打了个补丁,允许用户这么做,但是必须要调用setTransformDirty。仍然很丑陋。 看看C++怎么做。如果是使用C++,很简单,返回一个const Vector&就可以啦,没有拷贝的性能消耗,并且用户去修改这个返回值是编译不过的。 我们在看一下Unity的c#怎么搞的,它的Vector是一个结构体,也就是说是一个值对象,这样返回的拷贝开销稍微低一些,而且值对象不允许修改成员,所以只能采用赋值的方法去调用setter。虽然可用,但是还是没有C++优美。10年前我用的最爽的语言就是C++,什么时候可以再用?

下一篇

本篇读起来没有什么意思,可能下一篇还是没什么意思。会讲一讲目前的材质系统,和前向渲染框架。