基于Unity3D的体素沙盒游戏设计与实现(中)

114 篇文章 37 订阅

2.6 网格概述

2.6.1 网格的定义

    网格(Mesh)是Unity中的一个组件,通常用于创建模型的网格。在三维空间中,Mesh是构成这些三角形点和边的集合[16]。由于在Unity3D中,每个3D模型均是由数个多边形组合拼接构成,而每个多边形实际上是由若干个三角形组合构成,因此,每个3D模型表面是由多个彼此相连的三角面拼接而成。

2.6.2 网格的组成

    (1) 顶点坐标数组vertices。Mesh的每个顶点的空间坐标都被此顶点坐标数组存储,比如,对于有n个顶点的Mesh,其对应verteices数组长度为n。

    (2) 纹理坐标数组uvs。它定义了图片上每个点的位置的信息。这些点与3D模型是相互联系的,共同确定表面纹理贴图的位置。uvs将图像上每一个点精确对应到物体网格的表面。uv[i]对应vertex[i]


    (3) 三角形顶点索引数组triangles,每个网格都由多个三角面组成,而三角面的三个点就是顶点坐标里的点,该数组长度 = 三角面个数 * 3。

    (4) 每个顶点的法向量数组normals。法线数组存放mesh每个顶点的法向量,大小与顶点坐标对应,normal[i]对应顶点vertex[i]的法向量。面朝外的方向即为法向量方向。通常情况下,只有从正面看时才会渲染面。

    (5) MeshFiler组件,一般用于添加mesh属性。

    (6) MeshRender组件,通常用于添加材质并渲染。

   一个方块面有4个顶点,由两个三角面(索引为0,1,2和0,2,3)组成。


2.7 区块与网格的创建

2.7.1 区块的划分

    在Unity中,单个网格绘制的顶点总数最多为65000,对于绘制顶点总数超过其上限的模型时,则需将模型进行拆分绘制[17]。故将星球以边长为64分割为若干个区块。如行星、卫星的可建造范围分别为边长128、64,则行星、卫星每边的区块数则为2、1,对应区块总数则为8、1。区块编号以x轴、z轴、y轴依次递增。区块整体偏移使星球自转中轴穿过其中心,用dChunk记录区块整体偏移量。

    每个区块共享星球的blocks数组,拥有独立的偏移量dx,dy,dz。记录区块左下角方块在blocks数组中的索引,即区块方块本地索引相对于blocks索引的偏移量。比如,行星中编号为3的区块的dx=64,dy=0,dz=64。该区块在构建Mesh时便是从blocks[64,0,64]开始遍历。

2.7.2 网格的构建

    在每个区块创建好后,便需要构建渲染网格和碰撞网格(碰撞网格仅在玩家所在星球开启)。每个区块使用_verts,_uvs,_tris列表存储最新的顶点坐标、纹理坐标、三角形顶点索引以便之后网格创建和高效更新。

    首先,遍历该区块对应的blocks数组区间,如果当前方块为空气则continue。反之计算当前该区块的所在区域(这将在第三章讨论)以便确定方块的朝向(方块朝向与该区域的法向量相同),blockPos为该方块所属Mesh的本地坐标。对于非透明方块而言,如果方块位于blocks顶端或者其上方不是非透明方块(如上方为空气或水方块),则调用AddFace函数添加上表面的数据。这将极大程度地减少每个区块需要绘制的面数。

代码清单 2.10  区块Mesh的构建

public void BuildMesh(){ // 新建Mesh网格
    for (x = dx; x < dx + L; x++) // x y z为方块在blocks数组中的索引
    for (z = dz; z < dz + L; z++)
    for (y = dy; y < dy + L; y++){
        if (blocks[x, y, z].type == BlockType.Air) continue;
        area = Star.AreaJudge(x + star.dChunk + .5f, y + star.dChunk + .5f, z + star.dChunk + .5f);
        // 输入方块中心坐标以判断所在区域
        blockPos = new Vector3(x - dx, y - dy, z - dz); // 此方块在所属区块mesh中的本地坐标
        if (blocks[x, y, z].state == 1) {// 对于不透明方块
            if (y == 2 * L - 1 || blocks[x, y + 1, z].state!=1)
 // 此方块位于blocks顶端/其上方(世界空间)不是非透明方块,则绘制
                AddFace(0); // i=0,1,2,3,4,5 分别表示世界空间里的上下右左前后方块面
            if (y == 0 || blocks[x, y - 1, z].state!=1) // 下
                AddFace(1);
…}     for (var i = 0; i < _numFaces; i++)  // 逐次加入两个三角形顶点索引
            _tris.AddRange(new[] {i * 4, i * 4 + 1, i * 4 + 2, i * 4, i * 4 + 2, i * 4 + 3});
…      mesh.RecalculateNormals(); // 计算法线
        meshFilter.mesh = mesh; // 赋予mesh
}}
    数组DVert{new Vector3(0, 0, 0), new Vector3(0, 0, 1), new Vector3(1, 0, 1), new Vector3(1, 0, 0),new Vector3(0, 1, 0), new Vector3(0, 1, 1), new Vector3(1, 1, 1), new Vector3(1, 1, 0)}存储8个相对顶点偏移量,Face数组存储DVert数组的24种构成面的顶点索引顺序,AddVerts数组表示该面使用Face第几组构成顺序。AddUvs数组表示该面使用uv顶/边/底纹理坐标。    AddFace函数将上表面的4个顶点坐标(方块相对区块的本地坐标+相对顶点偏移量)按所在区域的顺序添加至_verts列表中,再将上表面对应的4个纹理裁剪坐标添加至_uvs列表中,同时令_numFaces加1(注意,此处指的上表面是世界空间里的上表面,而不一定对应方块本地空间的上表面,故添加顶点的顺序和材质需要视所在区域而定)。对于其下、右、左、前、后方块面的判定也同理。

代码清单 2.11  AddFace函数

private void AddFace(int i){ // 添加方块面
    var f = AddVerts[area * 6 + i];
    _verts.Add(blockPos + DVert[Face[f * 4]]); // 区块本地坐标+相对顶点偏移量
    _verts.Add(blockPos + DVert[Face[f * 4 + 1]]);
    _verts.Add(blockPos + DVert[Face[f * 4 + 2]]);
    _verts.Add(blockPos + DVert[Face[f * 4 + 3]]);
    _uvs.AddRange(blocks[x, y, z].uvs[AddUvs[area*6+i]]);//获取方块指定面所对应四个纹理坐标
    _numFaces++;
}
    最后,根据已添加的面数_numFace,逐个给_tris列表添加两个三角形顶点索引,如{4,5,6,4,6,7},调用RecalculateNormals()计算每个面的法向量,最终更新渲染和碰撞网格。(这里仅展示区块中不透明方块的网格创建代码,对于透明方块的创建也同理,当区块中存在透明方块时,则在该区块对象下额外添加一个区块对象,用于绘制和渲染透明方块)。

2.8 本章小结

    本章对星球区块网格进行了创建。首先使用2D 柏林噪声对每个表面的基础地貌进行了创建,在此基础上加以适当的边界平滑衔接和地形翻转。并根据当前地势高低生成相应的草坪、树木或水源等自然资源方块。星球内部则根据3D噪声生成洞穴、矿物等资源。为减轻单个网格的绘制量,将星球分为若干的区块。

3 玩家与星球的更新

    在创建好星球每个区块的网格后,对于沙盒游戏而言仅仅只是完成了其中一步,玩家在星球上如何运动、如何与地形进行交互、如何对网格进行动态更新、如何更新粒子和背包数据则是本章需要进一步探讨的问题。

3.1 玩家运动

3.1.1 基础组件

    首先,创建玩家GameObject并附上脚本Player,操纵的方块人使用的是用Blender制作好并导入的骨骼模型,根据移速可在站立和奔跑两种动作状态之间切换。第一人称摄像机附于玩家头上,由主菜单进入游戏界面时激活,可随鼠标移动而进行本地空间里的水平和垂直旋转。玩家使用的碰撞箱为BoxCollider。


3.1.2 基本运动

    使用(transform.right * Input.Getaxis(Hor) + transform.forward * Input.Getaxis(Ver)) * speed以计算玩家本地空间的水平速度向量。其中transform.right为玩家本地空间X轴正方向变换到世界空间中的单位向量,Input.Gexaxis(Hor)获取键盘的左右输入并返回-1至1的单精度浮点值,speed为基础速率。

    对于垂直方向而言,如果玩家未触地,则施加tranform.down(即本地空间下方向)的重力加速度。若玩家着地(若collisionInfo包含的碰撞面法线向量存在本地法向量,即玩家底面发生碰撞),当按下空格时,则给予tranform.up方向一定的初速度,玩家起跳。重力的数值取决于所在的星球。当玩家速度过大时,则切换碰撞检测模式由离散的至连续动态的。

3.2 区域划分

3.2.1 区域判定

    以星球中心为原点的三维直角坐标系8个象限的4条角平分线将每个星球划分为6个引力区域,用于判断玩家朝向、星球引力方向以及方块朝向,如图3.2所示。编号为0-5的区域分别对应_normal数组存储的6个法向量{Vector3.up, Vector3.down, Vector3.right, Vector3.left, Vector3.forward, Vector3.back}。


3.2.2 玩家到达新区域后的更新

    在存档创建之初,玩家默认生成在星球的区域0中。当检测到玩家坐标已不满足区域0的范围条件时(通常情况下,拓展玩家当前所在区域的判定范围以防止玩家在两个区域之间反复转向),即玩家到达新区域,需要进行转向更新。

代码清单 3.1  区域转向更新

if(_updateFlag){ // 到新区域
        area =  Star.AreaJudge(_x, _y, _z);
        angle = Vector3.Angle(transform.up, _normal[area]); //求出两区域法向量之间的夹角
        axis = Vector3.Cross (transform.up,_normal[area]);} //叉乘求出法向量(旋转轴)
if (angle <= 0) return;
ang = angle > VMax ? VMax : angle;
transform.Rotate(axis,ang,Space.World); // 世界空间下以旋转轴axis旋转ang度
angle -= ang;


    首先使用AreaJudge函数代入玩家坐标求出新区域的编号,然后使用Vector3.Angle求出玩家当前法向量tranform.up和新区域法向量_norma[area]之间的夹角angle,使用Vector3.Cross叉乘求出两向量所在平面的法向量作为旋转轴axis。接着每帧都对仍大于0的angle进行更新:在世界空间下以axis为旋转轴对玩家旋转ang度。直至angle为0,表示转向完成,玩家已旋转至新区域。此时玩家所受重力方向tranform.down等于当前区域法向量的反向。

3.3 区块网格的更新

3.3.1 基于射线检测的方块放置拆除判定

    玩家通过使用准心获取方块位置信息并按左键进行建造、右键进行拆除。首先使用Pyhsics.Raycast向场景中的所有碰撞体投射一条射线,该射线起点为玩家位置 ,朝向 摄像机正前方,最大长度MaxDist=5,碰撞图层为Chunk。对于返回的碰撞信息hitInfo,如果是左键,则命中点顺玩家视角延伸0.1(拆除命中点前方的方块),反之则逆视角后退0.1(在命中点之后建造一个方块)。目标方块中心坐标为命中点向下取整并加上0.5f。使用Collision函数检测玩家与目标方块之间是否有重叠,依次对玩家和目标方块中心距离每轴距离与每轴边长一半进行比较,若返回True则不允许放置。

代码清单 3.2  Collsion函数

private static bool Collision(Vector3 a, Vector3 b, Vector3 al, Vector3 bl){
// 判断立方体a和立方体b是否有重合部分,ax代表中心x值,axl代表立方体x轴长一半
    if (Mathf.Abs(a.x - b.x) < al.x + bl.x)
        if (Mathf.Abs(a.y - b.y) < al.y + bl.y)
            return Mathf.Abs(a.z - b.z) < al.z + bl.z;
    return false;
}
    目标方块索引x,y,z=(向下取整)(目标方块中心坐标-区块整体偏移量),当目标方块超出blocks存储范围则中止。对于破坏方块,若blocks[x,y,z]为空气则中止。接着播放目标方块种类的删除音效,并使玩家背包相应物品数量加1,从对象池中获取相应的破碎粒子。再使blocks[x,y,z]设为空气。对于搭建方块也同理。若玩家手持方块为空,则中止,接着使blocks[x,y,z]设为玩家手持的方块种类,并使玩家手持物品数量-1。播放对应方块的建造音效。

    最后,根据索引值计算出所在区块ID并对该区块网格进行更新,并设置等待帧以防止高频拆建方块。


3.3.2 区块网格的更新优化

    许多基于Unity编写的沙盒游戏,对于区块更新仅是再次的调用区块网格创建函数,覆盖原有的网格。但如果仔细观察可以发现,实际上每次拆建都只是对网格中的1-6个方块面进行改动,如果每次都要重新遍历该区块在blocks数组的索引范围,重构整个网格,会造成运算量过大,效率过低。随着区块方块数的增多,甚至有可能造成游戏卡顿,故在此需要对网格更新进行简化。

    UpdateMesh函数分为两种模式。模式0用于拆除方块:删除方块面,添加邻块面;模式1用于放置方块:添加方块面,删除邻块面。以模式0为例,首先计算目标方块在所属区块网格中的本地坐标blockPos并计算所在星球的区域编号area。_numFaces记录方块面数增量,可正可负,用于更新_tris的长度。在前一章已经提到,每个区块实例保存了_verts,_uvs,_tris列表以存储最新的顶点坐标、纹理坐标、三角形顶点索引,所以可以很方便的对网格各项数据进行更新。

    首先,当目标方块位于blocks顶端或者其上方不为非透明方块时(如空气、水),则说明目标方块顶面是已经被绘制的,故调用UpdateFace函数删除此方块面。反之则说明其上方块非透明,使得目标方块顶面未被绘制。当其上方块仍位于本区块时,则直接添加其上方块的底面。反之则调用UpdateMesh重载函数请求其上方的区块实例来添加。这对于此方块的底、右、左、前、后方块面的更新判定也是同理的。

代码清单 3.3  UpdateMesh函数部分

public void UpdateMesh(int mode, int x0, int y0, int z0){ // 更新网格
    blockPos = new Vector3(x0 - dx, y0 - dy, z0 - dz); // 此方块在所属区块mesh中的本地坐标
area = Star.AreaJudge(x0 + star.dChunk + .5f, y0 + star.dChunk + .5f, z0 + star.dChunk + .5f);
// 此方块所在区域
    _numFaces = 0; // 面数总增量(+/-),用于更新_tris的长度
    // 此方块位于blocks顶端/其上方(世界空间)非透明,则删除/添加该方块面
    if (y0 == 2 * L - 1 || blocks[x0, y0 + 1, z0].state != 1)
        UpdateFace(mode, 0);
    else{ // 否则添加/删除其上方块的底面
    if (y0 + 1 < dy + L) // 其上方块仍位于本区块则直接操作
            UpdateFace(mode, 1, x0, y0 + 1, z0);
        else // 否则请求上方区块操作
            star.chunks[id + star.chunkPer * star.chunkPer].UpdateMesh(mode, 1, x0, y0 + 1, z0);
…}}
    UpdateFace函数会根据模式选择SubFace或AddFace函数,AddFace函数在第二章创建网格时已经提到过,故不再赘述。SubFace函数用于删除方块面。首先根据目标方块所在区域area判断顶点索引顺序,接着在_verts中以4为间距进行查找。当连续4个顶点都与求得的顶点顺序吻合时,则说明找到了该方块面,删除这四个顶点。因为_uvs与_verts存储的方块面一一对应,故也删除_uvs相同索引下的四个纹理坐标。并使_numFaces减一,结束查找。

代码清单 3.4  SubFace函数

private void SubFace(int i){ // 删除方块面
    var f = AddVerts[area * 6 + i];
    for (var j = 0; j < _verts.Count; j += 4){
        if (_verts[j] != blockPos + DVert[Face[f * 4]]) continue;
        if (_verts[j + 1] != blockPos + DVert[Face[f * 4 + 1]]) continue;
        if (_verts[j + 2] != blockPos + DVert[Face[f * 4 + 2]]) continue;
        if (_verts[j + 3] != blockPos + DVert[Face[f * 4 + 3]]) continue;
        _verts.RemoveRange(j,4); // 找到了该方块此面在verts中的4个顶点
        _uvs.RemoveRange(j,4);
        _numFaces--;break;
}}
    在对目标方块的六个面或其邻面都检测更新完后,若_numFaces大于0则说明此次网格更新后的总面数是增加的。故在_tris列表后面添加_numFaces*4个索引值。这些索引值对4取余后均为{0,1,2,0,2,3}。同理,若_numFaces小于0则删除_tris结尾_numFaces*4个索引值。若_numFaces=0则不对_tris做改动。调用RecalculateNormals()计算法向量,完成对区块网格的更新。

    UpdateMesh重载函数用于接收相邻区块实例发出的增加/删除一个方块面的请求并更新网格。其总体流程和上述类似,先确定请求的方块本地坐标和所在区域,之后不再对六个面或邻面进行检测更新,而是直接调用AddFace或SubFace对请求的方块面进行增删操作。之后根据增删情况修改_tris列表长度、计算法向量并完成网格更新。

    以上便是区块网格的更新算法,通过调用Stopwatch计时器计算耗时可以发现,用此算法比传统的区块创建算法来更新区块用时节约了大概50%的时间,显著提高了效率,减少了游戏卡顿的可能,改善了玩家快速拆建时的游戏体验。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值