前端上手 Web 3D 游戏开发

游戏设计

我们把游戏的3D场景分成了三大模块,分别是赛道、金币(道具)和人物。

赛道设计

赛道包含了楼房和地面,由于人物需要不停地往前跑,基于相对运动的原理,我们复制了两段楼房(如图1),并同时做逆时针旋转,当旋转至 -theta 角度的时候,把楼房的旋转角度置为0(如图2)。地面是一个静止的圆弧模型,通过改变纹理的 UV 值来实现地面滚动的效果。



图1 赛道结构图




图2 楼房运动轨迹

金币布局

由以上图1可知,我们以 theta 角度的圆弧为一个控制单元,我们希望能控制游戏的总时长、每段圆弧旋转的时间,以及每段圆弧摆放的金币行数,这些参数如何控制3D场景的运作呢?根据已知字段推导出以下几条公式(蓝色字段为可配参数):

  • 需要生成金币的总行数 = (游戏总时长 /圆弧旋转theta角度的时间 )x 每段圆弧摆放的金币行数
  • 每两行金币之间的时间间隔 = 游戏总时长 / 需要生成金币的总行数
  • 每行金币出现的时间 = 每两行金币之间的时间间隔 x 金币索引

这里主要得出 游戏总时长每行金币出现时间 之间的关系,而每行金币该如何摆放以及道具出现的时机由具体的业务逻辑控制,这里不展开来讲。最终我们得到了一个控制金币摆放的队列:

[
  {
    "index": 0, // 索引,代表每一行
    "item": {
      "position": "center", // 摆放位置
      "type": "coin" // 应该摆放的模型类型
    },
    "time": 0 // 每行金币出现的时间
  },
  {
    "index": 1,
    "item": {
      "position": "left",
      "type": "coin"
    },
    "time": 0.25
  },
  // more......
]

这个队列如何与我们的3D场景关联呢?

由以上图2可知,一共有两段圆弧在交替旋转,假设每段圆弧摆放的金币行数定义为 rowsPerPart,当前圆弧的索引定义为 index,那么每次旋转至0度的时候,取 [index * rowsPerPart, (index + 1), rowsPerPart] 区间的数据进行摆放。数组中 position 表示摆放位置,一共有左中右三条道,也可能三条道都摆放,根据配置创建金币节点,并设置好节点的 position。type 表示应该摆放的模型类型,除了金币还可能是道具、礼包、终点线等。

开发流程

设计好游戏思路之后,可以正式开始制作我们的游戏啦~

跑酷游戏是通过 Oasis Editor 开发的,这是一个 web 3D 内容在线开发平台,底层用的是 Oasis 3D(蚂蚁自研的3D引擎)。这时候你可能会问,为什么要用 Oasis Editor 开发呢?


接下来分为「场景搭建」、「逻辑开发」、「业务联动」来讲解整个3D工作流。

场景搭建

上传资产

在编排场景之前我们需要先上传好游戏资产,一般美术提供的模型文件格式为 fbx 或 gltf,纹理推荐使用 webp 格式,我们在资源区右侧点击上传。


在开发过程中,美术可能经常需要替换纹理,所以建议美术将纹理与模型解绑,通过手动上传的形式将纹理绑定到模型上,避免同时加载两个纹理。

如图,我们已经在资源区上传好楼房、道具、金币等模型和相应纹理。


场景编排

有了资产之后我们需要绑定到节点上,然后进行场景编排,如下视频以楼房和地面为例进行演示:

  1. 创建场景树
  2. 绑定GLTF模型
  3. 编辑器PBR材质,绑定纹理
  4. 调整编辑器相机,拷贝编辑视角
  5. 转换相机视角,微调相机参数


按照同样的方法我们完成了整个场景的编排,某些节点需要通过脚本控制展示,可以点击场景树左边的小眼睛进行隐藏,场景效果如下:


粒子系统

游戏开发的时候,经常会用到粒子系统来帮助我们实现一些比较酷炫的效果,在我们这个项目中,在人物节点(person)下面有2个子节点,分别来负责吃到金币(coinParticle)和道具(toolParticle)时的粒子效果,游戏过程中效果如下:



当我们点击选中一个粒子节点的时候,编辑器右侧会出来对应的属性面板,属性面板中就能够看到我们的粒子组件以及相关参数,通过设置参数可以调整我们的粒子效果:


接下来一步就是来设置参数来控制我们的粒子效果了,下面给大家介绍下几个常用参数:



逻辑开发

以上场景可由前端协助美术同学进行搭建,接下来这一步就正式进入编程阶段了。

脚本能力

1、cli
Oasis Cli 是连接业务和 Oasis 3D 编辑器的桥梁,在使用我们引擎的时候,建议提前安装好 Cli 的环境:

tnpm i @alipay/oasis-cli -g

安装好 Cli 之后,我们就可以将场景导出到我们的本地项目,并且随时将最新的场景编排同步至本地。首先,我们进入跑酷项目根目录,并执行如下命令,将我们已经建好的3D场景和当前项目连接:

oasis pull sceneId

上面的 pull 命令中,sceneId是我们的场景id,执行完该命令后,会在根目录下自动添加了1个目录和1个文件,如下:


当我们需要对场景进行编辑,并且将最新修改同步至本地,我们只需要执行如下命令即可:

oasis dev

2、金币转动
这里以金币转动为例演示如何添加脚本控制,首先在资源面板添加一个脚本,然后在将脚本挂在节点上:



完成这一步后,我们就可以在coinAni的脚本中实现对coin节点的控制了,金币一直旋转我们在脚本的onUpdate 中处理即可:

onUpdate() {
  const { node } = this;

  TWEEN.update();

  if (this._isRotate && node.parentNode.isActive) {
    node.setRotationAngles(0, globalVal.coinAngle % 360, 0);
  }
}

碰撞检测

利用碰撞检测来反应人物与金币之间的碰撞,首先需要给人物和金币都加上碰撞体包围盒。Oasis Editor 提供了立方体碰撞体和球型碰撞体,引擎会在每帧更新时计算本节点的 collider 与其他 collider 的相交情况,球型碰撞体之间只需要比较球心距离与两个半径之间的大小关系,而立方体碰撞体需要计算八个顶点的位置关系,所以使用球型碰撞体性能上会好一些。

如下图,我们给人物添加了一个球型碰撞体,可以调节它的球心和半径。可视化包围盒只是编辑器运行时的插件,因此不会出现在我们的 H5 场景中。


编辑完碰撞体包围盒之后,我们需要在脚本中进行碰撞检测,监听 collision 事件:

let cd = node.createAbility(o3.ACollisionDetection);
cd.addEventListener('collision', e => {
  const colliderNode = e.data.collider.node; // 拿到被碰撞的节点

  const name = colliderNode.name;
  // do something...
});

Shader

嘿嘿,看到 Shader 别急着划走,掌握了 Shader 你就可以:

  • 自定义光照、物理等模型,可以开发更多酷炫的效果
  • 能够优化渲染性能
  • 能够帮助我们排查渲染上的问题

列举几个 Shader 的效果,更多效果可以前往shadertoy:




1、 什么是 Shader
Shader(着色器)是运行在 GPU 上的小程序,这些小程序为图形渲染管线的某个特定部分而运行,它用于告诉图形硬件如何计算和输出图像。为了更深入了解 Shader 的原理,我们需要了解 OpenGL 的渲染流水线,这里以渲染跑酷游戏的地面模型为例:

CPU 应用阶段

我们在3.1.1中上传了地面的 fbx 模型文件,其中包含了顶点位置、UV、法线、切线等信息,CPU 将这些信息加载到显存中,然后设置渲染状态,告诉 GPU 如何进行渲染工作。最后 CPU 会发出渲染命令(Drawcall),由GPU 接收并进行渲染。

GPU 渲染管线


GPU 渲染管线包含了几何阶段和光栅化阶段,顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)分别位于这两个阶段中。

几何阶段:顶点着色器接收 CPU 传过来的顶点数据,通常在这个阶段做一些空间变换、顶点着色等操作。接着会经过裁剪,把不在相机视野中的顶点裁剪掉,并剔除某些图元,然后将物体坐标系转换到屏幕坐标系。

光栅化阶段:两个顶点之间有很多个像素,片元着色器会对像素进行处理,除了进行纹理采样,还会将像素与灯光进行计算,产生反射、折射等效果。同一个屏幕像素点可能会有多个物体,这时候需要通过 alpha 测试、深度测试、模板测试、混合(blend)等处理,把同一位置的像素进行过滤或合并,最终渲染到屏幕上。
2、如何编写Shader
Oasis Editor 中写 Shader 需要经过这几个步骤:
(1)、在资源区中添加“Shader 材质”,然后绑定到模型上



(2)、编辑 Shader 材质,属性面板中提供了常见的渲染状态配置,也可以直接编辑着色器定义(ShaderDefine)。


整个 ShaderDefine 结构如下,其中 vertexShader 和 fragmentShader 分别存放顶点着色器和片元着色器代码,采用 GLSL ( OpenGL 着色语言,OpenGL Shading Language )编写。states 用来定义渲染状态控制对象,对应上文提到的合并阶段。

export const ShaderMaterial = {
  vertexShader: ``,
  fragmentShader: ``,
  states: {},
  uniforms: {},
  attributes: {},
};

(3)、如果要动态改变材质参数值,需要创建脚本,在节点每帧执行的回调函数中修改属性值。

下面通过跑道滚动和光波两个示例来讲解。

3、 跑道滚动
如2.1中所述,跑道是一个静止的圆弧模型,通过改变纹理的UV值来实现跑道滚动的效果。为了实现给人物打光的效果,我们在基础颜色纹理上面叠加了一张渐变纹理,并给人物加上了一个静态的阴影(实际上是一个面片)。



( 基础颜色纹理)


(渐变纹理)
=


( 叠加效果)

相关的Shader代码如下:

export const ShaderMaterial = {
  // Vertex Shader 代码
  vertexShader: `
    uniform mat4 matModelViewProjection;
    uniform float utime;

    attribute vec3 a_position;
    attribute vec2 a_uv;

    varying vec2 v_uv;
    varying vec2 v_uv_run;

    void main() {
      gl_Position = matModelViewProjection * vec4(a_position, 1.0 );
      v_uv = a_uv;
      v_uv_run = vec2( v_uv.s, v_uv.t + utime );
    }
  `,
  // Fragment Shader 代码
  fragmentShader: `
    varying vec2 v_uv;
    varying vec2 v_uv_run;

    uniform sampler2D texturePrimary;
    uniform sampler2D textureLight;

    void main() {    
      vec4 texSample = texture2D( texturePrimary, v_uv_run ).rgba;
      vec4 texLightSample = texture2D( textureLight, v_uv ).rgba;
      gl_FragColor = vec4(texSample.rgb * texSample.a + texLightSample.rgb * texLightSample.a, texSample.a);
    }
  `,
  states: {},
}

Vertex Shader 和 Fragment Shader 都包含了一个 mian 入口函数。

初次看 Shader 代码会发现很多陌生的符号,其中 uniform、attribute 和 varying 都是变量限定符,attribute 只能存在于 Vertex Shader 中,一般用来放置程序传过来的顶点、法线、颜色等数据;uniform 是程序传入到 Shader 中的全局数据;varying 主要负责在Vertex Shader 和 Fragment Shader 之间传递变量。

mat4、vec3、sampler2D 都是基本变量类型,分别代表矩阵、向量和纹理,后面的数字代表n维,例如 mat4表示 4x4 矩阵。

本例的 Vertex Shader 中,顶点位置 a_position 与 matModelViewProjection 矩阵相乘,其实是把三维世界的物体投影到二维的屏幕上。a_uv 存放了 UV 信息,我们想要把一张贴图贴到模型表面,需要纹理映射坐标,即UV坐标,分别代表横纵两个方向。为了使地面能滚动起来,我们需要每帧改变 UV 的纵坐标,并通过变量 v_uv_run 传递给 Fragment Shader。

在 Fragment Shader 中,texturePrimary 和 textureLight 都是从 CPU 程序传过来的纹理。通过 texture2D 采样基础颜色纹理 texturePrimary,得到了纹理贴图在模型上滚动的效果。接着拿采样后的颜色值与透明渐变纹理 texLightSample 进行叠加,得到了近亮远暗的效果。

最后,我们在 CPU 中每帧更新 utime 的值,并传入 Shader。

onUpdate(deltaTime) {
  if (!this.running || !this._streetMaterial)  return;

  // 赛道滚动
  this._time -= deltaTime * 0.0002;
  this._time %= 1.0;
  this._streetMaterial.setValue('utime', this._time);
}

4、光波特效



人物吃到吸吸卡之后会有一个光波特效,由于是不规则动画,我们采取了帧动画来实现。首先需要拿到这样nn的帧序列。注意,浏览器会对纹理尺寸进行限制,可以通过 gl.MAX_TEXTURE_SIZE 拿到这个值,最好别超过20482048。


接着在 Shader 中进行纹理采样。假设一个 100 * 100 的正方形,它的顶点着色器运行4次(因为有4个顶点),但片元着色器会运行 10000 次,所以尽量把 UV 等计算放在 Vertex Shader 中,再通过 varying 传给 Fragment Shader。代码如下:

export const ShaderMaterial = {
  // Vertex Shader 代码
  vertexShader: `
    attribute vec3 a_position;
    attribute vec2 a_uv;

    uniform mat4 matModelViewProjection;
    uniform float uFrame;

    varying vec2 v_uv;

    void main(void)
    {
      gl_Position = matModelViewProjection * vec4(a_position, 1.0);
      float cellCount = 8.0;
      float row = floor(uFrame / cellCount); // 当前第几行
      float col = mod(uFrame, cellCount); // 当前第几列
      float cellSize = 1.0 / cellCount;
      v_uv = vec2(a_uv.s * cellSize + col * cellSize, a_uv.t * cellSize + row * cellSize);
    }
  `,
  // Fragment Shader 代码
  fragmentShader: `
    varying vec2 v_uv;

    uniform sampler2D uDiffuseMap;

    void main(void)
    {
      gl_FragColor = texture2D(uDiffuseMap, v_uv);
    }
  `,
  states: {},
  uniforms: {
    uDiffuseMap: {
      name: 'uDiffuseMap',
      type: o3.DataType.SAMPLER_2D
    },
    uFrame: {
      name: 'uFrame',
      type: o3.DataType.FLOAT
    }
  },
  attributes: {},
};

CPU需要传入帧序列纹理uDiffuseMap,还要每帧更新uFrame的值:

onUpdate(deltaTime) {
  // update per frame
  if (this.material) {
    this.frame++
    if (this.frame > 57) {
      this.frame = 0;
    }
    this.material && this.material.setValue('uFrame', this.frame)
  }
}

业务联动

余额宝跑酷是一个跑在 h5 环境下的项目,其中就涉及到业务层(react)和游戏层(oasis),我们在业务层和游戏层之间加了一个胶水层(gameController)来进行两者通信,结构如下:


从上面结构图可以看出,作为胶水层的gameController,主要做了2件事情,一个是给业务层提供api调用,并且通知游戏层,另外一个是监听游戏层的消息,并且通知业务层,下面来看看示例:

import * as o3 from '@alipay/o3';

export default class GameController extends o3.EventDispatcher {
    constructor (rootNode, dispatch) {
        super();
      
        this._dispatch = dispatch;
        this._oasis = this._rootNode.engine;
      
        // 获取需要监听的节点
        this._rootNode = rootNode;
        this._magnetCollidNode = rootNode && rootNode.findChildByName('magnetCollid');
        this._buildNNode1 = rootNode && rootNode.findChildByName('part1');
        this._buildNNode2 = rootNode && rootNode.findChildByName('part2');
        this._streetNode = rootNode && rootNode.findChildByName('street');
        
        // 注册监听
        this.getMessage(rootNode);
    }

    // 注册监听
    getMessage(rootNode) {
        // 注册监听游戏层消息
        this._magnetCollidNode.addEventListener('magnetCoinCollide', (event) => {
            // 反馈给业务层
            this._dispatch && this._dispatch({type: 'collideHappen', payload:{ type: 'coin' }});
        });
      
        // todo 其他节点注册监听
    }

    // 给业务层调用的api
    gameInit(iconList, gameData) {
        const gameInit = new o3.Event('gameInit');
        gameInit.data = {
            iconList,
            gameData,
        };

        this._oasis && this._oasis.resume();

        // 通知游戏层
        this._buildNNode1.trigger(gameInit);
        this._buildNNode2.trigger(gameInit);
        this._streetNode.trigger(gameInit);
    }
}

性能优化

调试工具

工欲善其事必先利其器,当我们需要对项目进行性能优化的时候,我们首先需要分析性能瓶颈点,然后对症下药,很幸运的是chrome本身就自带性能分析工具(Performance:打开页面进入开发者工具即可看到),如下:


除了性能调试工具外,有时候我们还会遇到一些渲染异常,大多是给到GPU的数据有问题,而这部分数据我们没法console.log,chrome提供了一个非常好用的插件(Spector.js)帮助我们查看每一帧的数据,如下:


降低三角面

三角面越多,gpu的计算量也会越大,结合游戏实际的玩法,我们对三角面这块的优化主要就是不同模型进行减面,最终三角面从20万+降低到6万+,具体如下:

1、人物这块,因为在跑动过程中,我们始终只能看到背面,所以把人物前面的三角面全部去掉

2、金币这块,在保证视觉效果看起来比较圆的前提下尽可能的减少三角面

3、楼房和人物类似,把赛道外部的游戏过程中根本看不到的面去除



提升帧率

提升帧率本质上就是减少cpu的运算时间,通过前面提到的分析工具分析,我们发现节点数量过多是导致cpu运算量大的主要原因,所以我们的优化重点是在降低节点数量上,最终我们的 fps 在低端机上面从10优化到25,下面来具体说下:

1、金币模型里面有很多没有用的空节点,这个我们找美术同学帮忙重新简化模型文件

2、金币模型简化后,其实模型里面还有2个节点(其中有一个rootnode其实没啥用,和美术同学交流,反馈是目前没有办法去掉),加上挂载模型的节点,我们一个金币对象其实就有3个节点,为了进一步优化,我们通过代码动态去掉多余节点并进行节点合并。

3、使用对象池来避免反复创建金币。在主循环中,对一些循环出现的元素,我们一种优化手段就是在初始化的时候事先创建一定数量的对象,然后用的时候来取,用完就还回来,而缓存创建好的对象的结构就是我们的对象池了。对象池带来的好处:减少主循环过程中创建对象带来的开销、可以有效避免因创建释放等操作带来的GC。我们游戏中金币数量很多,并且是高频出现的,所以要用对象池来缓存,相应的设计如下:

class CoinPool {
  private _originNode = null;
  private _pool = [];

  constructor () {
    
  }

  init (originNode: o3.Node, capacity: number = 5) {
    this._originNode = originNode;

    this._genNode(capacity);
  }

  destroy () {
    this._originNode = null;
    this._pool.length = 0;
  }

  getNode () {
    if (this._pool.length === 0) {
      this._genNode();
    }

    return this._pool.shift();
  }

  putNode (node: o3.Node) {
    if (this._pool.indexOf(node) === -1) {
      this._pool.push(node);
    }
  }

  _genNode (num: number = 1) {
    const pool = this._pool;

    for (let i = 0; i < num; ++i) {
      let node = this._originNode.clone();
      // 对金币模型节点的优化在这里统一处理
      changeParent(node);
      purifyNode(node);

      pool.push(node);
    }
  }
}

对象池使用方式:

// 创建并初始化
const originCoin = node.findChildByName('coinParent'); // 挂载金币模型的节点
const coinPool = new CoinPool();
coinPool.init(originCoin, 24);

// 从池子里面获取金币节点
const coinNode = coinPool.getNode();

// 金币节点不需要使用了,进行回收
coinPool.putNode(coinNode);

// 整个节点池销毁
coinPool.destroy();

其他

上述两项其实都是针对跑酷项目本身做的一些特定优化,其他项目未必能够完全照搬,我们的尘沫大神针对业务方面的性能优化做了比较通用全面的总结,这里简单列举一下:

语言

  • 使用枚举:在标记判断if或switch语句中尽量使用number型枚举,避免使用字符串作为判断标记,字符串作为判断标记性能损耗较大
  • 使用Number做Object的Key:Object作为Map使用时尽量不要使用string作为Key,而是倾向使用Number作为Key,其中Number的范围越小性能越高,通常小于65535性能较优
  • 使用“.”访问对象属性:避免使用["string"]访问对象的属性和方法,会导致JIT优化失效,应使用“.”访问属性
  • 尽量使用for循环遍历:帧级调用尽量使用for循环进行遍历操作提升性能,相对于语法糖循环更纯粹,需要提前缓存长度n进行循环判断,减少纹理寻址性能损耗

逻辑

展开阅读全文

本文系作者在时代Java发表,未经许可,不得转载。

如有侵权,请联系nowjava@qq.com删除。

编辑于

关注时代Java

关注时代Java