从零开始开发3D游戏,助力B类场景互动创新尝试-阿里云开发者社区

开发者社区> AlibabaF2E> 正文
登录阅读全文

从零开始开发3D游戏,助力B类场景互动创新尝试

简介: B类场景里的互动创新。

作者 | 楺楺

image.png
近些年,越来越多的业务都引入了互动游戏,相信大家都玩过双十一盖楼、养猫、蚂蚁森林等小游戏。那么互动在B类场景中是否也能带来价值,我们发起了第一次尝试:一达通 0-1 破蛋结合互动游戏做客户激励,拉动客户从准入到上行,游戏部分的效果如下:

点击查看视频

👇 微信或钉钉扫码玩游戏,挑战最高分2500~
image.png
截止目前的数据,线上每人平均玩了10次,说明这种竞技类小游戏对提高用户粘性和活跃度发挥了较大的作用。当然只有挑战性和趣味性是不够的,再结合任务道具、组队PK、自选赛道、分享/传播等手段,将带来更好的业务效果。

场景分析

整个游戏界面分成了3层:
image.png

  • DOM UI层:这一层主要来放置2D相关的UI元素,比如分数面板、游戏说明面板、游戏结束面板等,然后通过事件与3D场景进行通信。

  • 3D场景层:包含了阿牛、赛道、能量块在内的整个3D场景,主要通过 Oasis Engine 和 Oasis 3D Editor 实现,最终表现为一个 canvas 组件,下文会重点介绍这一层的实现。

  • 背景层:即放置在最底层的蓝天大海背景。

⚠️ 值得注意的是,不要直接 css 设置 canvas 的背景,这样会导致微信或 Safari 压后台之后出现场景重叠的异常现象。推荐的做法是把 gl.clearColor 的背景色设置为透明,然后在 canvas 外部放置一个背景层,让 canvas 专注于3D渲染。

实现 3D 场景

对比我们平常的业务开发,3D场景开发包含了以下几个流程:
image.png

场景搭建

我们从最简单的3D场景搭建开始。得益于 Oasis Editor 的功能,我们可以直接编辑3D场景,所见即所得。

首先我们要理解好 ECS(Entity-Component-System)架构,在 Oasis 3D 中,万物皆组件,一个实体代表什么取决于它身上挂载了什么组件。比如我们创建了一个实体,上面挂载了相机组件,那么它就是一个相机实体。如果再给它添加飞行组件,那么它就是会飞的相机。

基于这样的思想,我搭建出一棵完整的场景树(左侧红色框内):
image.png
其中,角色实体上挂载了 GLTF 模型组件,最终渲染出阿牛的模型。一般设计师在3D软件中导出 FBX,Oasis Editor 会将它转换成适合 Web 环境的 GLTF,并解析出骨骼动画、材质、纹理等信息。另外角色实体上挂载了不少脚本组件,这些都是包含了游戏逻辑的自定义组件。

选择脚本开发方式

Oasis Editor 包含了云端代码编辑器,而且提供了事件测试面板。3D场景中监听的事件会出现在输入事件列表中,我们可以配置事件参数进行触发;3D场景中触发的事件则出现在输出事件面板中。
image.png
针对本游戏,我们希望能通过 git 管理项目,并且为了使接入方只需关心3D通信部分,把强制横屏、降级、赛道配置化等交给游戏组件本身,而这些并不属于 Oasis 3D 的脚本,所以选择了本地开发的形式。

脚本创建及绑定是在 Oasis 场景编辑器里面操作,然后将项目下载到本地进行开发。之后我们会经常需要调整3D场景或者添加一些新的脚本,这时候就需要用 @alipay/oasis-cli 把3D场景拉取到本地。⚠️ 需要注意的是,oasis pull -s 会覆盖掉本地的脚本,所以一般执行 oasis pull,只拉取 schema 配置。

逻辑开发

1 、控制角色运动轨迹

利用碰撞检测来获取3D空间中两个物体之间的相交情况,需要碰撞体包围盒和碰撞检测器。为了实现不同碰撞带来的不同动作,我把角色的碰撞体包围盒设置得比实际大一点,所以角色和蓝色方块的碰撞体默认是相交的。如图所示,深蓝色立方体是角色的碰撞体包围盒
image.png
用户第一次点击的时候做 x 轴直线运动,第二次点击时增加 y 轴分量,做上抛运动。
image.png
image.png
检测角色与方块碰撞的 end_overlop 事件,如果碰撞体对象为蓝色方块,则直接做自由下落运动。
image.png
检测角色与方块碰撞 begin_overlop 事件,如果撞到蓝色方块,则计算方块与角色的相对位置。

  • 如果角色在方块上方,只做x轴直线运动。

image.png

  • 如果角色在侧方或下方,则做自由下落运动。

image.png
image.png

  • 检测角色与终点站台的碰撞,如果角色悬空位于站台上方,则做自由落体运动,同时切换相机视角。起始状态和终点状态都是可以计算出来,所以这里利用 tween 动画实现平滑的自由落体运动。

image.png
2、 相机跟随

相机是3D场景中必不可少的元素,它相当于一个观察3D世界的眼睛,只有在相机锥形体内的区域才会被渲染到屏幕上。在角色奔跑的过程中,我们希望相机永远跟随在角色的右后方,所以在每一帧都把相机的位置设置为角色的相对位置。

在 Oasis 3D 脚本的生命周期中,onUpdate 和 onLateUpdate 都是在每帧更新中执行,区别在于 onLateUpdate 是晚于所有 onUpdate 之后更新的。假设角色运动由两个脚本的 onUpdate 同时控制,相机在 onUpdate 进行跟随,这时候就可能出现抖动的现象,所以相机跟随一般放在 onLateUpdate 之中。

3、 骨骼动画

我们的角色有多种骨骼动画,比如水平运动时的奔跑动画、达到终点时的招手动画等,这些动画信息都包含在 FBX 文件中。设计师从 C4D 导出的文件把所有骨骼动画都包含在同一个动画片段中,导致我们无法分段播放。所以还需要借助 Blender(一款开源的跨平台全能三维动画制作软件),在 Blender 的动画摄影表中有一个动作编辑器,在里面复制一个时间轴,删去多余的、只保留需要的动作。依次分好后,导出 FBX。

我们通过动画片段的名称来播放(playAnimationClip),那这些名称如何获取呢?可以打开 GLTF 源文件,找到 animation 属性。samplers 描述动画数据的来源;channels 建立输入(即从采样器计算的值)和输出(即动画节点属性)之间的连接;而 name 就是动画片段的名称了。
image.png
image.png
4、 粒子动画

640.gif

粒子系统重点要理解好它的发射参数,引擎会根据配置参数,自动生成几何体和材质。在 Oasis Editor 中,我们可以直接为实体绑定粒子系统组件,以能量块的爆炸粒子为例:爆炸是从一个点往四周发射的效果,所以把所有粒子的初始速度都置零,x 轴和 y 轴加上速度随机因子,给 y 轴负方向加了微小的加速度模拟重力和空气摩擦力。
image.png
image.png
所有粒子默认都不播放的,只有在检测到碰撞才会发射:

const cd = this.entity.addComponent(o3.CollisionDetection);
cd.on("begin_overlop", e => {
  const colliderNode = e.collider._entity;
  // 获取碰撞实体的类型
  const entityType = this.getEntityType(colliderNode);

  // 撞到能量块
  if (entityType === "reward") {
    // 获取粒子组件
    const particleComponent = colliderNode.getComponent(o3.GPUParticleSystem);
    // 发射粒子
    particleComponent && particleComponent.start();
    this.engine.dispatch("gotReward");
  }
});

5、 Shader 动画

640 (1).gif

角色碰到红色方块之后的消失动画采用 Shader 帧动画,具体实现参考我的另一篇文章

6、 赛道配置化

基于 3.1 搭建出来的场景树,我们可以通过克隆、拖拽的形式配置我们的赛道。

640 (2).gif

为了能够灵活调整游戏难度,我们需要把赛道配置抽象出来。赛道主要由两部分组成:方块和能量块。实体之间的区别主要是位置,针对方块来说,有危险方块和普通方块,各自比例也不同,我们通过实体名字进行区分,比如 normalBox11 代表 11:1:1的普通方块,dangerousBox2 代表 2:1:1 的危险方块。危险方块可能包含旋转、位移等动画,所以都做成单独的脚本组件。最终抽象出数据结构:


interface IGameConfig {
  // 方块配置
  boxsConfig: {
    name: string;
    position: {
      x: number;
      y: number;
      z: number;
    };
    action?: string[];
  }[];
  // 能量块配置
  rewardsConfig: {
    name: string;
    position: {
      x: number;
      y: number;
      z: number;
    };
  }[];
}

游戏内部设置了多个难度等级的配置,接入方可以传入对应的 level,也可以自定义赛道。
7、 游戏换肤
我们在 Oasis Editor 中编辑完场景之后,可以导出项目,其中 schema.json 是关键产物,里面包含了实体(nodes)、组件(abilities)、资源(assets)、动画(animator)等信息,它们之间的关系:
image.png
其中,asset 包含了GLTF、材质、纹理、脚本等多种类型的资源。runtime 会根据 schema 配置来构建实体树,并加载所有资源,资源与组件进行关联,再将组件挂载到对应的实体上。所以我们需要在场景初始化之前,把 schema 中的相关纹理替换掉,从而达到自定义换肤的功能。

8、 手机横屏方案

游戏是横屏显示的,用户可能会开启手机的自动旋转设置,我们可以通过监听屏幕旋转事件(onorientationchange)来设置我们的游戏。如果屏幕的宽度大于屏幕的高度,游戏容器的宽高直接等于屏幕宽高。如果屏幕的宽度小于屏幕高度,游戏容器的宽设置为屏幕的高,游戏容器的高设置为屏幕的宽,然后将游戏容器旋转90度,由于是围绕中心点进行旋转,所以还需要将游戏容器往左下角偏移,如下图所示:
image.png

const clientWidth = document.documentElement.clientWidth;
const clientHeight = document.documentElement.clientHeight;
gameContainer.style.top = (clientHeight - clientWidth) / 2 + "px";
gameContainer.style.left = 0 - (clientHeight - clientWidth) / 2 + "px";

onorientationchange 在低端机上会有兼容性问题,可以借助 resize 来触发。这时候发现转屏之后无法马上获取屏幕宽高,所以还需要延迟监听。

优化

1、 画布节能模式

在 Retina 屏中,一个逻辑像素等于多个物理像素,取决于设备像素比。如果不对画布做处理,就会出现下图这种模糊的效果。通常我们会选择高清模式,即画布像素 1:1 填充到屏幕物理像素。具体做法是将画布的宽高按设备像素比来放大:webcanvas.width = canvas.clientWidth * window.devicePixelRatio 。设置之后发现帧率下降,手机容易发烫,因为渲染的压力和屏幕物理像素高宽成正比,物理像素越大,渲染压力越大。权衡性能和效果,我们选择了节能模式,即在高清模式的基础上对画布按照某个比例进行缩放 webcanvas.width = canvas.clientWidth * window.devicePixelRatio / scale。一般 scale 设置为 3/2 即可。
image.png
2、对象池

由 3.3.6 分析得到了赛道的两个主要素:方块和能量块,而且他们在场景中存在大量类似的节点。为了减少主循环过程中创建对象带来的开销、避免因创建释放等操作带来的GC,我们要用对象池来进行优化。如下图中的能量块对象池,在大池子中有几个小池子,方便集中管理。我们在大池子中保留了几个原始实体,这些实体在 3.1 中被我们挂载到同一个父实体中。我们先在每个小池子中克隆三个实体,需要的时候往池子中取,如果池子已经被取空,则先克隆之后再取出。当不需要的时候归还实体,并恢复实体的初始状态。
image.png
这时候我们发现存在大量 Drawcall,因为场景中存在太多的实体。所以我们进一步优化,以角色的位置为中心设置一个可视区域,只有在可视区范围内才从对象池中获取,可视区外则归还实体。比如我把可视区的范围缩小,可以更直观的看出效果:

640 (3).gif

我们在每局游戏开始的时候得到一个实体配置队列,实体配置结构见 3.3.6,这些数据可以在 Oasis Editor 可视化编排之后通过脚本输出。在角色奔跑过程中,我们从队列中取出可视区的实体配置,然后从对象池中取出对应实体进行摆放,当超出可视区之外则归还实体。


/**
 * 获取可见范围内的实体配置
 * @param positionX - 角色在x轴的位置
 */
getVisibleRewardConfig(positionX: number) {
  return this.rewardsConfig.filter(item => {
    const { position, entity } = item;
    // 已经不可见的实体隐藏掉
    if (position.x < positionX + SHOW_RANGE[0] && entity) {
      // 对象池归还实体
      this.entityPool.putEntity(entity);
      item.entity = null;
    }
    return (
      position.x >= positionX + SHOW_RANGE[0] &&
      position.x <= positionX + SHOW_RANGE[1]
    );
  });
}

3、 垂直同步

游戏运行在移动端上会出现很明显的抖动,严重影响用户体验。抖动有多种原因,先拿比较容易复现的场景来讲,当能量块和方块离得近的时候会抖动(如下图)。因为触发了能量块的碰撞结束事件,导致角色做自由下落运动。但此时角色是在方块上方,又触发了水平奔跑。所以“水平跑-下落-水平跑-下落”,循环引起了抖动。

640 (4).gif

另一种是跳跃过程中存在的抖动现象,由于找不到复现规律,排查起来也比较费劲,一开始是往应用层的逻辑思考,后来发现当用户不进行任何操作也会出现场景抖动,所以怀疑是屏幕刷新率不同步导致。由于我们一开始设置了目标帧率,所以主动关闭了垂直同步。垂直同步即场同步(Vertical synchronization),我们看到的游戏动画,都是经过用户的交互、显卡的计算和渲染,再由屏幕刷新,最终才呈现出来。我们所说的帧率是由屏幕的刷新率决定的,而显卡的渲染速率取决于某一帧的复杂程度。如果显卡在 60/1 s 的时间里渲染了两张图片,但屏幕的刷新率只有 60Hz,这时候屏幕就只能把两帧图片拼接在一起,造成抖动或画面撕裂。为了解决这个问题,我们可以开启垂直同步,让显卡每次渲染完成后,必须挂机休息,等待屏幕刷新结束再渲染下一张图片。
4、 WebGL 版本切换

一开始游戏运行在安卓版的钉钉上会出现闪退现象。经排查,钉钉使用的是旧版本的U4内核,该版本运行 WebGL2.0 会导致闪退。在钉钉未升级最新版的U4内核的情况下,我们需要针对这部分用户使用 WebGL1.0 处理。引擎的 WebGLMode 默认为 Auto,即设备支持优先选择 WebGL2.0,不支持 WebGL2.0 会回滚至 WebGL1.0。当检测到游戏运行在安卓的钉钉上时,需要将 WebGLMode 切换到 WebGL1.0。

沉淀

目前游戏已经抽成一个通用组件,支持多种业务场景接入,支持配置游戏难度等级、换肤、自定义赛道。业务接入十分很简单,只需要准备一个 canvas,当用户点击时触发游戏开始或重置,同时监听得分、结束等事件。业务联动关键代码如下:


const gameContainer = canvas.parentElement;
// 游戏初始化
const oasis = await boot({
  canvas,
  gameContainer,
  level: 2,
  debug: false,
  handleDowngrade: () => {
    setDowngrade(true);
  }
});
const { engine } = oasis;

// 开始游戏
engine.dispatch("start");
// 再玩一次
engine.dispatch("reset");
// 获得能量块
engine.on("gotReward", () => {
  setScore(x => x + 1);
});
// 游戏结束:死亡
engine.on("gameDie", () => {
  // do something
});
// 游戏结束:到达终点
engine.on("gameOver", () => {
  // do something
});

3D 研发过程的一些思考

本次项目时间十分紧张,开发和联调只用了一周半,加上ICBU跨供第一次尝试 3D 互动游戏,整个研发流程暴露了不少问题,这里提下自己对研发流程的一些总结 。

设计阶段

在拿到3D美术资源后,我们需要针对性的做一些优化。事实证明,3D资源交接这个流程耗费了大量时间,很多资源来回修改,所以我们应该在设计之前就跟美术同学约定好规范。这里提一些资源优化的参考:

  • FBX 或 GLTF 不能包含相机,会导致重复渲染;

  • 纹理不要绑在模型里面,后期经常要替换纹理,避免重复加载了两张;

  • 优先使用非透明材质,较少性能消耗;

  • 光照信息已经合并在漫反射贴图,使用 Unlit 材质替代 PBR 材质;

  • 较少三角形面数,把不在用户视野内的面挖空,整体三角面控制在5w以内;

  • 尽量合并材质,减少渲染批次;

  • 删除模型文件里面包含的多余节点,较少transform计算;

  • 纹理都用 2 的 n 次方,避免使用太大的纹理,尽量用 jpg 或 webp 格式,并进行压缩。像能量块这种小的模型一般 256*256 就够了;

  • 粒子纹理用透明的,去掉四周多余空间。多粒子的情况下使纹理合并到一张 sprite 图。

开发阶段

复杂的3D场景不建议直接用API来开发,Oasis Editor 很好的帮我们解决了开发效率低、效果还原差的两大痛点。在开始开发之前,我们需要跟营销前端同学划分好场景,3D部分尽量不要包含业务逻辑,并约定好业务H5与3D场景之间的通信事件。3D开发过程中我们很难去定位问题,这里推荐一个 Chrome 插件----SpectorJS,我们可以方便地查看当前的渲染状态、Drawcall 等信息。

image.png
在做性能优化时,我们可以利用 Chrome 的 Performance 工具,把 CPU 设置成 6x slowdown 来模拟一些比较低端的机型,然后看每一帧的渲染时长,理想情况下每帧 16.6ms,我们可以展开看具体耗时是哪个接口然后对其优化。同时我们也可以设置 3G 网络来模拟弱网情况。
image.png

测试阶段

不同于前端页面的测试,3D场景测试的重点是性能和兼容性。正常来说,WebGL 支持率在99%以上,不支持 WebGL 我们也做了降级处理,所以更多的是关注上层应用的兼容性。对于用户一些可能的操作流,比如压后台、省电模式、竖排方向锁定状态、切换APP等,测试过程中也需要覆盖到。

性能方面,重点关注白屏时间、Crash率、FPS、内存。可以引入 Oasis 3D 提供的性能查看面板(@oasis-engine/stats),FPS 在低端手机上需要稳定在30以上,Memory 在 60M 以下,DrawCall 在 50 以下,总的三角面在 5w 以内。还可以利用 MPerf 采集更具体的数据,复杂的场景需要出性能报告,对于超标的情况需要优化后才能上线。
image.png

发布阶段

线上要做好降级,本次游戏针对安卓 6.0.0 以下、IOS 10 以下的设备做了降级,不支持webGL、资源加载异常也做了降级,最终降级率在 3% 左右。同时要监控脚本异常、白屏及 crash。我们这次游戏运行在微信、钉钉、阿里卖家 APP 上,通过 EMAS 可以看到 APP 整体 crash,不过不能区分是不是h5引起的。另外,观察线上的得分情况,可以动态调整我们的游戏难度。

总结

互动游戏在传播、促活上面起到了明显的效果,后续3D互动将作为用户成长体系的一个能力,结合内容营销、搭建能力、成长激励,支撑运营进行内容营销推广和客户成长体系建设。

⭐️ 最后预告一下,Oasis 3D 将在今年2月1日举行开源发布会,届时将在B站进行全网直播,欢迎加入钉钉群 31360432 了解更多开源信息~ 🥰


image.png
关注「Alibaba F2E」
把握阿里巴巴前端新动态

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
AlibabaF2E
使用钉钉扫一扫加入圈子
+ 订阅

阿里经济体前端技术最新内容汇聚在此,由阿里经济体前端委员会官方运营。我们的愿景是建立全球一流的前端团队,链接商业,让数字世界触手可及是我们的使命。阿里经济体前端委员会致力于加强技术前瞻性、推进集体成长、提升国际影响力。同时我们运营着阿里经济体前端的官方公众号:Alibaba F2E,欢迎关注。

官方博客