前端如何快速上手 Web 3D 游戏的开发
时间:2022-09-17 04:30:00
本文以「余额宝3D跑酷游戏」例如,介绍了如何快速启动前端 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... ]
这个队列和我们的3怎么样?D场景关联呢?
从上图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 在资源区右侧点击上传格式。
在开发过程中,艺术往往需要更换纹理,因此建议艺术将纹理与模型解开,并以手动上传的形式将纹理绑定到模型上,以避免同时加载两种纹理。
如图所示,我们已将建筑、道具、金币等模型及相应纹理上传到资源区。
场景编排
拥有资产后,我们需要绑定到节点,然后安排场景。以下视频以建筑物和地面为例:
- 创建场景树
- 绑定GLTF模型
- 编辑器PBR材料,绑定纹理
- 调整编辑器相机,复制编辑视角
- 转换相机视角,微调相机参数
按照同样的方法,我们完成了整个场景的安排。有些节点需要通过脚本控制显示,可以点击场景树左侧的小眼睛隐藏。场景效果如下:
粒子系统
在游戏开发中,粒子系统经常被用来帮助我们实现一些酷的效果。在我们的项目中,在角色节点(person)下面有两个子节点负责吃金币(coinParticle)和道具(toolParticle)游戏过程中的粒子效果如下:
当我们点击选择粒子节点时,相应的属性面板将出现在编辑器的右侧。我们的粒子组件和相关参数可以在属性面板中看到。我们的粒子效果可以通过设置参数来调整:
下一步是设置参数来控制我们的粒子效应。以下是一些常用参数:
逻辑开发
以上场景可由前端协助美术学生搭建,下一步将正式进入编程阶段。
脚本能力
1、cli
Oasis Cli 连接业务和 Oasis 3D 在使用我们的引擎时,建议提前安装编辑器桥梁 Cli 的环境:
tnpm i @alipay/oasis-cli -g
安装好 Cli 之后,我们可以将场景导出到我们当地的项目中,并随时将最新的场景安排同步到当地。首先,我们进入跑酷项目的根目录,执行以下命令,我们已经建立了3个D连接场景和当前项目:
oasis pull sceneId
上面的 pull 命令中,sceneId是我们的场景id,执行命令后,根目录下自动添加一个目录和一个文件,如下:
当我们需要编辑场景并将最新修改同步到当地时,我们只需执行以下命令:
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进行循环判断,减少纹理寻址性能损耗
逻辑
- 多用对象池机制:由于JS本身机制和原理,需要避免在帧循环中new对象,避免GC卡顿,在业务开发中的模型抽象强烈建议使用对象池机制做对象管理
- 善用实例或静态全局变量:除了对象池机制避免GC外,还需要利用实例或静态全局变量减少GC损耗,比如一些用于中转数学计算的临时变量可使用静态全局变量缓存,另外一些可逐实例的类变量可缓存为实例全局变量,减少使用时的频繁new操作带来的开销和GC。
- 慎用事件:在大型项目中慎用事件,事件本身的灵活性是一把双刃剑,在解耦的同时也带来了逻辑可读性低等困难,尤其在多人协作开发的项目中,所以在业务系统中该解耦的模块用事件,不需要的地方需要用明确的设计调用逻辑解决,切记不要因为设计的懒惰把项目搞乱
资源优化
- 模型合并优化:美术需将不可独立移动的模型尽可能合并减少渲染批次,同时注意不要合并场景范围跨度过大的模型导致模型无法裁剪的问题
- 材质优化:
- 尽可能合并材质,材质作为三维引擎的合并根基,一切引擎级渲染批次的合并前提都是使用相同材质,所以要保持材质对象尽可能的少
- 材质模型选择需要根据美术风格尽量精简,比如直接把光照合并在漫反射贴图的的卡通风格模型可以直接选择unlit材质,而无需使用复杂的PBR材质模型
- 贴图优化:贴图尺寸不可能盲目追求质量使用超大尺寸,需要评估实际项目贴图光栅化后的实际显示像素来使用接近的贴图尺寸,否则使用过大尺寸不仅得不到效果手机还浪费显存。除此之外还可使用纹理压缩优化显存
- 像素填充率优化:
- 尽量减少全屏渲染的绘制,比如UI或遮罩使用类似全屏但大部分透明的图片绘制会带来大幅的GPU渲染负担
- 在移动端等高DPI的设备中可适当降低DPI配置,减少GPU负担
玩法系统优化
- 碰撞系统优化:
- 善用主动碰撞和被动碰撞概念,减少主动碰撞器可以大幅减少碰撞检测的循环遍历次数
- 善用碰撞组概念,将物体划分所属碰撞组和可与之发生碰撞的组作为过滤器,根据业务规则划分可以减少不必要的碰撞检测循环
- 跑酷弯道优化:可尝试利用顶点着色器模拟弯道跑酷效果,减少CPU端相关跑酷弯道逻辑的计算负担,降低美术制作复杂度
Oasis 3D V2.x To V3.x
随着 Oasis 3D 服务的业务数量越来越多、业务负责度越来越大,也暴露出不少问题,为此,我们对现有引擎进行了大重构,也就是V3.x版本,此版本主要目标是:更快、更方便、更高效。
这里先简单介绍几个重构模块,希望让大家有个初步体感。
资源管理模块
资源管理模块我们从底层实现进行了大重构,主要目的是简化开发者的使用,下面是v2.x版本和v3.x版本加载一个带有骨骼动画的模型示例,对比可以看出v3.x版本的api是特别精简的,除了api的简化外,功能上我们还提供了下载重试、重试间隔、下载超时、下载进度、取消下载等。
V2.x版本加载资源:
let gltfRes = new Resource("skin_gltf", {
type: "gltf",
url: "xxx.gltf"
});
let resourceLoader = new ResourceLoader(engine);
resourceLoader.load(gltfRes, (err, gltf) => {
if (err) return;
const fairyPrefab = gltf.asset.rootScene.nodes[1];
const fairy1 = fairyPrefab;
rootNode.addChild(fairy1);
const animator = fairy1.addComponent(Animation);
const animations = gltf.asset.animations;
animations.forEach((clip) => {
animator.addAnimationClip(clip, clip.name);
});
animator.playAnimationClip("Take 001");
});
V3.x版本加载资源:
const { defaultSceneRoot, animations } = await engine.resourceManager.load("xxx.gltf");
rootEntity.addChild(defaultSceneRoot);
const animator = root.getComponent(Animation);
animator.playAnimationClip("Take 001");
数学库
数学库整个进行重构,主要有2方面改善:写法更简捷、性能更优。老的数学库都是函数式的,并且向量、四元数等低层其实都是Array,而V3.x采用Class的方式来实现,底层数据结构改为object。
新的数学库不仅支持更为丰富的写法,性能上面,通过数学库重构以及使用数据库相关的优化,性能提升比较明细,下面是我们的测试结果:
在线 coding
目前我们编辑器实现了在线coding,意味着你只需要一台电脑,并且安装一个浏览器,即可完成3D项目的创建、开发、发布等
在上面的界面中,即可完成在线coding,然后保存,即可实时查看最新的效果。进一步的,我们还提供了事件面板,模拟和业务层的交互,这样我们就可以在3D项目中自测完整个流程,然后发布给业务层使用
当我们开发完项目后,需要交付给业务方使用,在V3.x中,我们只需要点击发布至对应平台即可(这块还在持续优化中)