一文读懂「福气乐园 3D版」开发全流程

简介: 一文读懂「福气乐园 3D版」开发全流程

本文由 Oasis 团队诚空、阿霑、尘沫、慎思、桐伦、维勒联合编写

  背景和挑战

今年是五福的第八年,对于刚刚走过七年之痒的五福来说,无论从业务形态或者技术创新上来说,都做出了巨大的突破。业务形态上,希望能够更加开放,让更多的第三方商家能够参与进来,这点与元宇宙的开放概念非常契合,因此,邀请商家共同打造一个福气乐园的类元宇宙概念的诉求就呼之欲出了。正是因为今年业务形态的突破,开放力度加强、活动玩法丰富后,“五福”在 B 端的吸引力再度增强,今年有超三万商家小程序参与支付宝五福,是去年的30倍。意味着数字化服务商家全面开放的开始。


乐园是一个多人实时在线的第三视角漫游类项目,基本的玩法是在一个空间中,每个建筑代表一个商家,所有用户通过左下角的摇杆控制自身数字人的移动,当数字人靠近商家时可以触发和该商家的交互,不同用户的数字人之间也有粘福卡等交互。考虑到整个乐园的建筑数量以及数字人的数量,对于技术和美术侧来说都有非常大的挑战:

  • 资源很大,如何保证顺滑的加载体验;
  • 场景元素较多,加上各种特效,如何保证性能和内存;
  • 美术+开发工期紧张(约两个月),如何更高效的并行;
  • 首次对外部商家全面开放,商务部分带来不确定性如何兼顾。

  架构设计

整个项目上前端我们分为三层:

  • 游戏层:使用 Oasis Engine 和配套的物理库、动效库、自定义材质库完成游戏逻辑开发。
  • 通信层:(game 对象)提供业务层和游戏层的事件通信能力。
  • 业务层:处理通用的业务逻辑,比如福气值显示、设置、音乐、网络层等。

作为一个多人同时在线的项目,网络层的选择也是比较关键的。在整个项目中网络层我们使用了 RPC 和 RTS,RPC 有更完善的负载均衡等一系列逻辑,可靠性远高于 RTS,但是实时性方面略差,RTS 适用于对实时性要求比较高的场景,但不能有很复杂的逻辑。基于上述原则,乐园场景中对于实时性要求较高的数字人的位置信息的同步,人与人的交互等我们使用 RTS,其他则选择 RPC。

  场景搭建与渲染

考虑到工期问题,加上美术制作资源的紧张,为了更高效的推进项目进度,美术和开发并行。技美同学负责在 Oasis 编辑器中搭建场景,开发同学负责搭建和游戏逻辑相关的碰撞盒。

首先我们和设计、技美明确场景的布局和各个建筑的 ID,并且根据布局提前约定好了各个建筑的长宽,这样开发侧就可以在美术资源还在制作过程中提前进行逻辑的开发。

之后,在搭建时将场景碰撞盒单独设置到一个独立的节点且以 ID 命名,并且所有的代码逻辑都根据碰撞盒的位置进行配置,从而解耦业务逻辑和美术搭建。等所有美术资源到位后,技美同学就可以直接在我们包围盒位置根据 ID 摆放对应的建筑。另外,除了每个建筑一个包围盒,还额外增加了一些包围盒,这是因为乐园是一个封闭空间,保证数字人不会跑出去。

场景拆分策略

当我们拿到第一批模型并且在实际的机器上跑,加载耗时如预期般非常慢,很多安卓机都是20+秒以上。评估后我们决定对场景进行拆分,把一些通用的资源和用户进来后立马能看到的内容作为首屏,首屏加载完用户就能立马进入乐园,其余内容则进入乐园后动态加载。最终和产品沟通并且结合实际情况,我们将静态场景拆分成四个部分:

  1. 进入时朝向的福气商店,游戏入口以及祈愿树、中间的建筑和地板作为首屏场景;
  2. 整个场景的外圈建筑拆分为两个子场景;
  3. 氛围动画的优先级最低,作为最后一个子场景。

场景拆分后,用户进入乐园需要加载的资源进一步优化,相比拆分前节省了 20+MB,结合预推,最终大多数设备首次进入乐园时长不会超过 10秒,最终线上体感耗时在 3秒 左右。

当加载首屏资源后,其他建筑物由占位模型替代,占位模型三角面数很少,带一个很小的贴图,加载很快也不太占用内存。另外因为乐园是竖屏的应用,竖屏能够显示的建筑数量更少,所以大部分占位模型其实都在屏幕外部,用户几乎察觉不出这些占位模型的存在,当用户操作切视角看过去的时候,那些建筑基本已经加载好了,体感上来说会觉得显著提升了首屏的加载速度。

除了乐园中的地面建筑这些静态加载的资源,还有一些元素并非一定会出现,我们用脚本按需加载这些 Prefab 资产。脚本只需要读取编辑器场景中资产的相对路径,即使美术更新了这些资产,代码逻辑也不需要做任何的变更即可生效。以宝箱为例,在场景中相对路径为:

我们只需要在脚本中根据相对路径进行加载即可,如下:

const [box, animatorController] = await engine.resourceManager.load([
  {
    url: "/Assets/MS_BX/MS_BX.fbx",
    type: AssetType.Prefab
  },
  { url: "/Assets/MS_BX/ANI_BX", type: AssetType.AnimatorController },
]);

场景渲染

在福气乐园当中,整个场景的规模比较大,拥有三十个左右的场馆,考虑到场景中光源是固定不变的,出于性能的考虑,技美同学提出所有建筑使用一个特殊的材质来渲染,原理是将和光源的反射效果提前烘培出反射贴图,避免运行时的动态光照计算。为此我们定制了一个 BakePBRMaterial 自定义材质,最终每个建筑只需要提供两张贴图:基础纹理贴图+反射烘培贴图:

为了提供更丰富的环境光,乐园的环境光使用了 IBL 模式,技美同学直接在编辑器先上传烘培产物 *.env 文件,然后再编辑器中设置选择使用该环境光即可:

阴影是提升真实感的一个利器,考虑到需要兼顾很多低端机器以及端内的内存方面的限制,我们选择给用户自身数字人、NPC、宝箱添加阴影。

因为数字人的外形是非规则的,且位置随用户操作一直在变,并且是用户最关注的元素,所以数字人我们使用了真实的阴影:

对于一些非主角元素, 如 NPC、宝箱的阴影,考虑到固定位置,所以采用灰色面片充当阴影。以宝箱为例,我们给面片添加一个缓动动画,配合宝箱的浮动节奏,当宝箱往上浮动的时候面片变小,宝箱往下浮动的时候面片变大:

特效对于提升用户酷炫感非常有帮助,特效选择了 Lottie(由 @oasis-engine/lottie 包提供的 Lottie 组件来进行播放)动画组件,烟花、福气商店、两个游戏入口均添加了 Lottie 特效:

  数字人

场景开发完成后,接下来就是数字人的开发。当用户首次进入乐园的时候,会有弹窗提示送用户一套和该用户参与五福年限有关的皮肤。用户进入乐园后,将以这套皮肤展示,当然用户也可以通过数字人设置界面进行皮肤的切换。

动作与换装

乐园数字人男女各有一套基础模型,主要是包含了骨骼动画数据,便于复用。一共提供了男女各 6 套皮肤,一套默认皮肤(白色那套),五套和参加五福年限相关的皮肤。设计师在建模软件制作完数字人部件(身体、头发、衣服、眉毛等)后,导入编辑器,在编辑器里面进行材质修改,保证渲染效果和运行时是一致的,验证渲染无异常后,即可在运行时通过 ARK (https://www.npmjs.com/package/@oasis-engine/ark)组件提供的 SDK 进行动态加载、换装、动画等功能。

为了开发方便,我们一般会将角色的锚点放在脚底中间的位置,这样在调整角色尺寸时不会对坐标造成影响。为角色添加动作,只要在相应模型节点添加 Animator 组件,并且设置相应的动画状态:

换装的原理是蒙皮挂点,如头发模型如何保证在人物运动时也不会穿模呢?首先我们需要保证头发模型和身体的模型共用同一套骨架,然后在动态加载头发模型的时候,使用蒙皮技术将头发模型绑定到骨架上,之后,骨骼随着动画的运动就能够同时带动身体和头发、衣服等模型一起运动而不穿模。

另外,编辑器支持虚拟路径,如 /assets/hair01映射的是模型真实的 http 地址,

"data":{
  "hair":"/assets/hair01",
  "cloth":"assets/cloth02",
  ...
}

这极大地提升了素材管理的便利性,因为我们在后端存储的数据只需要录入一次/assets/hair01,以后即使模型更新了也不需要再次更新,也只需要根据后端返回的字段拼装不同的数字人即可。

摇杆控制

在乐园中用户通过摇杆控制角色移动,因此在实现上需要开发者结合摇杆的偏移位置计算角色此时的瞬间速度,在得到速度后,可以准确控制角色当前应当播放的动画,并且根据移动间隔更新角色的位置。

将上述逻辑绘制成流程图可得:

其中通过相机与摇杆计算瞬时速度较为复杂,思路是以相机的朝向(图中相机 forward)来决定角色移动的基准方向(图中映射在 XoZ 平面上的 forward'),再根据摇杆的角度来计算速度方向,下方是将相机的朝向和摇杆的角度(图中橙色的箭头)映射到地面(XoZ 平面)的示意图。

对应的代码如下:


// 1. 获取相机投影至 XoZ 平面的投影
// 获取相机朝向向量,并投影到 XoZ 平面上(将 y 置为 0)
GameCtrl.ins.camera.transform.getWorldForward(forawrdVec3).y = 0;
// 只求方向,所以归一化
forawrdVec3.normalize();
// 2. 获取摇杆角度获取旋转分量
const {rotateMatrix} = joyControl;
// 3. 对朝向进行旋转,得到最终方向向量
forawrdVec3.transformNormal(resultVec3, rotateMatrix);

此外,还有很多的细节优化,比如为了人物转向更加自然,逻辑上会对移动速度与朝向会做一个投影变化,又比如为了让人物的移动更加跟脚,移速降低或提高的时候会适当调整行走与跑步动画的播放速度。

多人联机

实现完一个单机数字人基本的逻辑后,我们已经可以控制他在场景中自由活动了,接下来面临的难题就是多人的情况下如何同步表现?有开发游戏经验的同学应该了解两种基本的前后端同步方式:状态同步和帧同步,两种方式在不同的适用情景下都可以达到很好的效果,它们最主要的区别是主逻辑由谁实现(服务端或客户端)。考虑到服务器计算压力等因素,我们最终采用帧同步与状态同步结合的形式:

  • 上行:主逻辑在客户端实现,客户端定期采样向服务端同步自己的位姿,每个用户自身的移动都是依照本地单机操作。
  • 下行:服务端定期向房内每位用户下发所有用户最新的位姿,屏幕中除自身外角色的移动行为都是依照服务端信息还原的。

众所周知,经过了采样与还原,失真是不可避免的,例如下图左侧是用户 1 的实际位移信息(连续且平滑),假设不考虑通信延时时,服务端同步的频率为 10 秒一次,那么在用户 2 的手机上还原的用户 1 的位移失真度就很高。

为了解决这个问题,我们采取以下策略:

  • 在考虑服务器的负载的前提下,上行频率(100ms左右)与下行频率(500ms左右)应该尽量较高,且上行频率应高于下行频率。
  • 在实现初期就考虑并支持了上行与下行频率可配置,为后期调整至较好的体验与较优的性能节约了不少时间。

为了避免网络波动引起的下行数据延迟,我们在同步逻辑中加入了预测移动逻辑,让整体的移动变得更加丝滑,人物每帧进入行为的轮询逻辑中时,都会先判断当前时刻与开始,结束与预测时间比较,判断当前移动的阶段,然后执行不同阶段相应的逻辑。

行为管理

至此,角色已经能在场景中自由移动,接下去可以增加传送、拜年等交互。为了防止每帧对交互的判断逻辑无限制扩张,我们增加了行为管理来处理这些复杂的交互逻辑。下方动图展示了以按钮作为交互的触发操作,点击拜年时角色就会执行拜年的行为。

得益于 Oasis 的组件化架构,参考脚本现有的生命周期(脚本生命周期),我们可以在 onUpdate 中添加每帧对所有行为的轮询,通过在不同的行为中修改角色的速度,动画属性来更新本帧角色的最终表现。

一个好的行为设计是一劳永逸的,其中最棘手的部分就是处理行为与行为之间的关系,比如他们的覆盖逻辑,顺序关系等,当这些捋清楚后,开发者只需要关心每个行为内部的逻辑即可。同时,基于这种实现,也可以很方便地增加更多的钩子回调给各个行为 (比如触摸按键时,被对方沾福卡时等),这样一来,无论多复杂的行为都可以拆解地较为简单,接下来我们可以参考一下乐园内的几种简单交互行为:

  1. 与他人的交互

结合业务逻辑,我们需要做的就是为用户提供触发交互行为表现

首先是触发交互,当主角和其他角色靠近到一定距离时,其他角色的头顶会出现交互按钮(拜年、粘福卡、换福卡)。我们先通过 registerWorldChangeFlag 给每个角色注册一个脏标记,只有当主角或者某个人动的时候才需要重新计算他们之间的距离,然后重新清空脏标记,提高性能。

其次是行为表现,以拜年行为 (WishAction.update) 为例,可以将拜年分为两个阶段:调整朝向和播放拜年动画。当行为更新时判断当前所处阶段,执行相应的逻辑。

  1. 与建筑的交互

以霸屏为例,用户进入建筑热区后,会触发业务层的交互按钮,点击交互按钮就会先进入霸屏视角。

如图所示,从效果上我们可以发现,他的逻辑就是两个点位之间的平滑切换,因此我们只需要:

  1. 在场景中找到一个合适的位置,记为霸屏观察点 P(targetMatrix);
  2. 在触发霸屏操作时,记录相机当前位置为起始点 S(startMatrix);
  3. 在缓动时间(tweenTime)内,插值 S 至 P 的位姿,实现相机从 S 到 P 点的缓动;
  4. 当退出霸屏时,插值 P 至 S 的位姿,实现相机从 P 到 S 点的缓动;

在场景中寻找位置时,我们可以使用编辑器快速定位,位姿的缓动我们可以直接调用矩阵的插值接口 Matrix.lerp 来实现,代码如下:

// 缓动至霸屏,subTime 是当前缓动时间,tempMatrix 是当前缓动时间对应的相机矩阵
Matrix.lerp(startMatrix, targetMatrix, subTime / tweenTime, tempMatrix);

  性能优化

乐园上线期间平均帧率 46(开启了半垂直同步),Crash 率万分之 0.24,T2 耗时平均 3000毫秒,可以在支付宝可兼容的最低端 iOS 设备 iphone5s 上 60 帧流畅运行:

我们是如何做到这样的性能成绩的呢?主要通过加载、帧率、内存三方面的优化来实现。

加载优化

初始化流程的复杂程度与项目规模成正比,乐园所有原始资源的加载大小达到了惊人的 100+ MB(磁盘展开大小), gzip 后仍然还有 70.5 MB(网络传输大小), 这也导致优化前很多同学体验福气乐园感受到加载的体感并不舒适,甚至很多时候直接超时降级到 2D。前面的场景拆分已经从体验侧解决了加载时长的问题,另外我们希望能够进一步减少资源总量的大小。

首先,对现有资源进行压缩,乐园的资源有模型+贴图,贴图本身都已经压缩过了,剩余的就是对模型进行压缩,对于模型的优化空间最大的就在于 glTF 几何模型数据。对于几何模型的压缩福气乐园决定使用 glTF 扩展 KHR_mesh_quantization ,该扩展不仅可以减少约 45% 的文件传输尺寸,还可以减少模型几何数据的显存,同样为 45% 左右。模型压缩前后整个乐园资源加载量对比:

另外,引擎侧通过重构和设计了 ResourceManager 的子资产概念和机制解决了资源循环引用导致的资源重复加载问题。最终优化结果为 Http 请求减少 56 个,Gzip 后资产文件请求减少 8MB,运行时内存减少 5MB,加载时长减少约 5%

帧率优化

同屏渲染面数 15万 左右,峰值甚至能到 60万+ 。30 多个建筑基本都有动画播放,部分运营位还带有特效。为了设备覆盖度,我们也在引擎侧和业务侧追加了一些优化措施。

乐园场景中包含大量的模型动画,不仅仅是角色,甚至每个建筑都包含酷炫的动画效果,整体对 CPU 侧的性能负担较大。如果能避免非可视范围的动画计算将会大幅提升性能,思路为提供一种动画裁剪模式达到该目的。对此我们在 Animator 组件中新增了cullingMode 属性。当该属性设置为 AnimatorCullingMode.Complete 时并且画组件控制的所有 Renderer均不可见时,动画会停止更新运算(注意该功能并非默认开启,因为在某些极端情况下,如动作位移超过整个屏幕时,该功能会出现非预期的裁剪)。下图展示了一个常规模型的节点结构,当蒙皮节点均不可见时,根节点的 Animator 会停止更新骨骼节点的 Transform,达到 CPU 侧的性能提升。

一个复杂场景往往很难一眼看出那个节点或组件正在浪费性能,因此在大型场景内,我们一般通过隐藏法来抓影响性能的臭虫,简单来说就是通过隐藏特定节点,观察整体 DrawCall 变化的幅度来确定可以优化的地方。

可以看到此处有 3 个 Lottie 动画(左侧福气兔入口,中间福字光效,右侧套圈圈入口),Lottie 动画自带图集合并,理论上它可以做到只占用一个 DrawCall ,但是使用隐藏法后可以发现,隐藏全部 Lottie 后 DrawCall 竟从 93 降低到 52 ,追溯到代码发现是因为不同的 Lottie 交叉渲染引发的,我们可以保证在单个 Lottie 中,渲染顺序是对的,但不能避免多个 Lottie 交叉不会出问题。

因此在项目初期就应该定义不同 Lottie 组件分属的层级,就好像大气层层级划分一般,不同 Renderer 的 priority 应当井水不犯河水,在调整完渲染优先级后,可以看到 DrawCall 数符合预期。


内存优化

内存风险直接影响 crash 率和业务覆盖率,而纹理又占了内存的大头。不同于传统前端业务,无论是 webp 还是 avif,都不是互动业务恰当的解决方案。因为两者只考虑了文件压缩,并未考虑内存压缩,而互动业务通常需要大量的贴图,如果福气乐园采用前两种方案,单贴图内存会高达 400+MB。我们采用了 GPU 纹理压缩 astc 和 pvrtc 两种 GPU 纹理压缩格式保障业务的内存占用,减少了至少约 75% 的 GPU 贴图内存占用。但是两者由于本身并未考虑文件压缩,所以传输尺寸会较大。按照今年 oasis engine 今年的规划会支持 ktx2 作为 GPU 纹理压缩格式的文件载体来减少文件传输尺寸并减少开发者使用 GPU 纹理压缩的难度。

除了技术测的优化手段,我们还和技美同学排查模型的贴图,重点就是建筑的贴图,最开始每个建筑 2 张贴图,尺寸都是 1024 的,这样一个建筑纹理压缩前的内存就是 8*1.33 MB(1.33 主要是 mipmap 带来的)。我们实验发现反射烘培贴图其实对清晰度影响很小,因此我们默认都将尺寸缩小至 256,基础纹理贴图我们默认改为 512,这样下来,每个建筑纹理压缩前的内存就是 1.25*1.33 MB。之后我们再根据产品的反馈,对一些希望非常高清的建筑进行高保(福气商店、祈愿树),基础纹理贴图改回 1024,反射烘培贴图改为 512。最终内存得到大幅度的优化。

  总结

这次乐园项目中,Oasis 众多的能力比如物理、阴影、数字人、2D、InputManager 等首次在一个这么复杂的业务场景中使用,引擎能力得到了一次很好的磨练和证明。同时也发现了一些工具链上的不足,比如纹理压缩的使用目前比较蛋疼,这块后续如何集成到编辑器中让开发者使用更便捷值得我们进一步探索。另外和技美的合作过程中,也发现了编辑器测一些使用体验的问题并且得以修复,编辑器的开发体验也得到进一步的提升。

  如何联系我们

Oasis 开源社区群 (钉钉):

Oasis 开源社区群 (微信):

如果微信二维码失效,可添加群管理员微信:zengxinxin2010

网站

官网地址

https://oasisengine.cn

Engine 源码地址

https://github.com/oasis-engine/engine

Engine Toolkit 源码地址

https://github.com/oasis-engine/engine-toolkit


相关文章
|
8月前
|
设计模式 SpringCloudAlibaba 负载均衡
每天打卡,跟冰河肝这些项目,技术能力嗖嗖往上提升
前几天,就有不少小伙伴问我,冰河,你星球有哪些项目呢?我想肝你星球的项目,可以吗?今天,我就给大家简单聊聊我星球里有哪些系统性的项目吧。其实,每一个项目的价值都会远超门票。
103 0
每天打卡,跟冰河肝这些项目,技术能力嗖嗖往上提升
|
7天前
|
负载均衡 Kubernetes 数据库
【鹅厂摸鱼日记(一)】(工作篇)认识八大技术架构
【鹅厂摸鱼日记(一)】(工作篇)认识八大技术架构
重拾梦想!语音交友源码平台搭建技术知识:在线KTV功能的实现
随着网络的快速发展,语音交友源码平台的新型功能将我们儿时的歌手梦托起,这个功能就是语音交友源码平台的在线KTV功能,对于开发语音交友平台的公司和个人来说,这个功能是非常重要的,下面我就详细为大家讲解语音交友源码平台搭建技术:在线KTV功能的实现!
重拾梦想!语音交友源码平台搭建技术知识:在线KTV功能的实现
|
存储 缓存 负载均衡
【小白晋级大师】如何设计一个支持10万人用的ChatGPT对接系统
之前给大家写了ChatGPT对接企业微信的教程,文章结尾说了教程只能适用于小规模使用,现在来写大规模使用的教程
228 0
【小白晋级大师】如何设计一个支持10万人用的ChatGPT对接系统
|
JSON 前端开发 JavaScript
解放双手!推荐一款阿里开源的低代码工具,YYDS
之前分享过一些低代码相关的文章,发现大家还是比较感兴趣的。之前在我印象中低代码就是通过图形化界面来生成代码而已,其实真正的低代码不仅要负责生成代码,还要负责代码的维护,把它当做一站式开发平台也不为过!最近体验了一把阿里开源的低代码工具LowCodeEngine,确实是一款面向企业级的低代码解决方案,推荐给大家! LowCodeEngine简介 LowCodeEngine是阿里开源的一套面向扩展设计的企业级低代码技术体系,目前在在Github上已有4.7K+Star。这个项目大概是今年2月中旬开源的,两个月不到收获这么多Star,确实非常厉害!
|
移动开发 人工智能 监控
热饭的测开成果盘点第十七期:web自动化智能平台
本期介绍的是打造的一款新的架构的selenium自动化平台。它可以实现的效果是,直接在用例平台爬下来用例,然后让浏览器去自动执行。就像一个活人一样去点点点。
热饭的测开成果盘点第十七期:web自动化智能平台
|
测试技术
热饭的测开成果盘点第二十一期:埋点自动化测试平台
本期介绍的是一款由旧移动测试平台改造的新测试平台。具体样式其实都和之前差不多,唯一不同的是增加了自动抓包和判断请求体。
热饭的测开成果盘点第二十一期:埋点自动化测试平台
|
人工智能 自然语言处理 测试技术
热饭的测开成果盘点第十九期:移动端自动化智能平台
本期介绍的是移动端app智能架构平台,效果和上期一样,也是直接根据用例 来直接执行,它的初衷是可以简单的对我们测试环境几千条用例全部自动执行的框架。在具体稳定和速度上可能不如原始写法,但是对付这种上千条的大需求,是有奇效的。
热饭的测开成果盘点第十九期:移动端自动化智能平台
|
消息中间件
热饭的测开成果盘点第三期:全端自动化平台翻版
本系列是回忆下 博主从事测试以来打造过的所有工具/框架等,算是大盘点。
热饭的测开成果盘点第三期:全端自动化平台翻版
|
人工智能 前端开发 JavaScript
热饭的测开成果盘点第十期:测试平台OneKey(一)
!! 本期要分享的是一款集成各种功能的测试平台,所以功能非常之多。本期也属于超重量级的一期,请仔细观看。因为展示的是脱敏的测试环境,所以数据统计等不要在意。
热饭的测开成果盘点第十期:测试平台OneKey(一)