本文作者是蚂蚁集团前端工程师黑冰,本文主要内容是黑冰在使用 Oasis 开发 818 3D 跑酷的一个总结,也欢迎有兴趣的开发者一起来交流~

前言

跑酷游戏是余额宝七周年 (2020年) 的主玩法,用户通过做任务来获取玩游戏的机会,从而在游戏中获得更多的金币,最终可以利用金币兑换一些权益。在今年的余额宝 818 大促中,我们依然选择跑酷游戏作为主玩法,结合 Oasis 最新版本的引擎,给用户带来更好的游戏体验。

通过各项优化最终千万级用户的平均 fps 为 58.85

游戏设计

赛道设计

赛道分成2个部分分开建模:

  1. 中间道路
  2. 两旁花坛

中间道路是一个半径24.5米,圆心角为12度的圆弧桥面。这个模型是全程静止。通过找到其桥面圆弧面对应的mesh,修改其材质和贴图。如跑道效果图圈出来的部分纹理替换为跑道地面贴图(循环多次)。
3D 跑酷开发 - 图1跑道效果图
3D 跑酷开发 - 图2
跑道地面贴图
材质还是继承pbr材质,只是增加一段根据游戏时间修改uv的逻辑。这样就用了最低的成本模拟出来人往前跑的错觉。
两旁花坛根据桥面的半径和圆心角通过计算设置位置和旋转角度。通过编写圆弧运动组件,在游戏进行时将花坛往后移动,并按照特定的角速度绕着x轴旋转。当花坛移动到看不见的位置时候,将其位置设置到跑道最远处位置和角度。不断循环往复。其真实情况如下:
3D 跑酷开发 - 图3

7周年光照效果使用的烘焙。所有模型的贴图都是烘焙过的。好处是 shader 无需计算光照效果。缺点是效果不明显或者过于明显不一定适合游戏真实需要的效果。建模软件里面的光效渲染和 Oasis 里面是有区别的。不同的人建模用的光效也有区别,如果不统一就存在光效看起来不对劲。并且烘焙的方式游戏无法调节光效。光照变更需要重新对模型进行贴图烘焙。
3D 跑酷开发 - 图4
烘培效果

3D 跑酷开发 - 图5
未烘培效果
在游戏中,加速带,起点和终点实现则是构造一个圆弧面贴上图使其靠近地面。如果圆弧面离开地方较远,则在远处加速带出现时,会明显看到加速带是腾空的,效果不好。如果离地面太近,又会出现浮点精度问题产生z-fighting。通过各种尝试,最后让这些圆弧面离地面0.05米最合适,既看不出腾空也没有z-fighting。
3D 跑酷开发 - 图6

金币

金币布局会根据使用不同的道具,游戏难度控制参数有所变化。影响到金币的总数量。游戏确保每一行至少有一个金币。假设游戏总时间够用户跑20行金币的距离,那么游戏在初始化时生成20个金币放在对象池里备用。然后当游戏正式开始,按照生成的逻辑优先从对象池里面去金币摆放在对应位置。这个位置和之前花坛一样,也是根据路面半径和圆心角,已经生成频率根据数学公式算出来的。如果对象池里面金币不够用了,则会再动态创建新的金币。
3D 跑酷开发 - 图7
当金币被吃掉或者移动到看不见的地方,会从场景中移除并存放到对象池中。后续新增到金币再从对象池里取。
一个场景会有几十个金币,为了节约内存,所有金币的mesh都是引用的同一个mesh对象。

人物

人物是一个含有多段动画的gltf模型,放在场景的原点上。当人物撞到障碍会从跑步动作切换为炫晕动作,过了2秒再切换回跑步动作。

人物影子实现方案

人物有影子,影子的实现是通过planarshadow的方式,其原理是通过顶点着色器将人物模型的每个顶点坐标按照一个特定的光照角度映射到xoz平面上,伪造出了阴影效果,本质相当于clone了一份人物模型把它压成一个平面全涂成半透明黑色。核心逻辑在顶点着色器里计算,如下所示核心代码:
attribute vec4 POSITION;

vec3 ShadowProjectPos(vec4 vertPos) {
vec3 shadowPos;
//得到顶点的世界空间坐标
vec3 worldPos = (u_modelMat vertPos).xyz;
//灯光方向
float lightHeight = u_lightDirAndHeight.w;
vec3 lightDir = normalize(u_lightDirAndHeight.xyz);
//阴影的世界空间坐标(低于地面的部分不做改变)
shadowPos.y = min(worldPos.y , lightHeight);
shadowPos.xz = worldPos.xz - lightDir.xz
max(0.0, worldPos.y - lightHeight) / lightDir.y;
return shadowPos;
}

void main() {
vec4 position = vec4(POSITION.xyz, 1.0 );

//得到阴影的世界空间坐标
vec3 shadowPos = ShadowProjectPos(position);
//转换到裁切空间
gl_Position = u_VPMat vec4(shadowPos, 1.0);
//得到中心点世界坐标
vec3 center = vec3(u_modelMat[3].x , u_lightDirAndHeight.w , u_modelMat[3].z);
//计算阴影衰减
float falloff = 0.5 - clamp(distance(shadowPos , center)
u_planarShadowFalloff, 0.0, 1.0);
//阴影颜色
color = u_planarShadowColor;
color.a *= falloff;
}
由于路面是弧形的,但是一只是投影在平面上,如果人物正好是和路面接触,那么影子有一部分会穿透到路面下方。为了避免这个情况,路面是往下移了0.3米左右距离,人物是腾空的跑。但是由于跑步时候相机锁定在人物后上放,从视觉上这个角度看不出来人物是腾空的。
3D 跑酷开发 - 图8
相比使用shadowmap实现影子,通过顶点投影的方式性能更好。

人物影分身术实现

3D 跑酷开发 - 图9
通常设计师会给到我们一个视频来描述一个特效长啥样。但是复杂的动画导出来,只能是帧动画。但是帧动画占用庞大的体积(一个3秒钟的帧动画以30fps计算需要90张图,占用几十兆的大小),耗费巨大的加载时间。并且时间长度需要根据业务情况会调整持续时长,而帧动画是固定时间的。显然现实条件无法使用帧动画去实现。
由于这个影分身特效规则很简单,在出现和消失的时候有一些glitch抖动,并且渐隐渐显。其他时候就是半透明纯白。那思路就很清楚了,在顶点着色器里面通过noise函数将模型顶点x方向进行偏移。传入一个浮点数uniform变量用于控制透明度变化,就是实现影分身特效,核心shader代码如下:
// 顶点着色器
uniform float glitch; // 抖动幅度
uniform float iTime; // 游戏时间

// noise可以随便换,调出来一个效果和性能都不错的,此处是一种简单的noise函数实现示例
float noise(float value) {
return fract(sin(dot(vec2(value, 2), vec2(12.9898, 78.233))));
}

void main() {
// 先照抄一下PBR材质完整的shader

  1. // 以时间和坐标y咒值为因子,通过noise对x进行偏移。实际游戏里可以不断调整传入到noise函数的因子来达到合适的抖动效果<br /> gl_Position.x += noise(iTime + gl_Position.y) * glitch;<br />}`;

// 片元着色器,非常简单,就外部控制alpha值即可
uniform float alpha;
void main() {
gl_FragColor = vec4(1.,1.,1.,alpha);
}

游戏场景优化

低端机内存小,加上支付宝这类超级APP本身在运行时就占用很大内存。如果游戏没有经过优化,则容易造成oom崩溃。通过如下一些优化最终使得游戏的内存占用比react编写的业务页面的内存占用还少。

图片

画质和性能是不可兼得的,需要根据实际情况去取舍。设计师给到的原始资源都是体积非常大的,无法直接在项目里使用,需要减小体积。

纹理贴图

  1. 图片不宜过大和过小。评判方式可以按照如下:

比方说手机屏幕 750 1628 大小。一个模型占用面积为中间三分之一宽高。那么可以知道这个模型最终渲染出来尺寸为 250 509。那么这个模型需要的贴图最大占用面积为 250 509 2(正反两面)= 500 509。那么模型只需要 512 512 的贴图足够保证画面的清晰度,超过这个尺寸也是浪费。在实际优化中例如跑酷人物始终背对用户。因此建模时正面的脸,胸,腹都是可以不建模,一些部分也可以直接使用顶点颜色。从而实际贴图 256 * 512 就够用。

  1. 光影图片,重复性图片能小则小

重复性贴图例如跑道六边形贴图,理论上只需要重复 1.75 次六边形的图片即可。一个 128 * 128(甚至更小尺寸)即可满足一整个道路的贴图需求。只需在着色器里面放大 uv 倍数即可。如下贴图可以进一步优化只留圈出来的区域即可
3D 跑酷开发 - 图10
在实际游戏中,通过shader控制uv的变化即可模拟人物往前跑的效果(人在原地跑,路面纹理往后运动)
3D 跑酷开发 - 图11
光影例如背景建筑的发光,就是纯白图片外加一圈模糊的轮廓边。此时可以将光影的图面积缩小 1 到 16 倍不会影响整个效果。如下红圈中的图就是面积缩小 4 倍。当然其他一些元素还存在很多优化空间
3D 跑酷开发 - 图12

  1. 旋转图片

很多图片素材在 lottie 里面通常需要斜着放置。那么设计会为了图方便将原本一些需要斜放的元素在素材里面先倾斜了。这样做动效方便了一丢丢,但是资源消耗却大幅度提高。举个例子
3D 跑酷开发 - 图13
我们看到合图里面一些线条,文字都是斜着的。斜放使得图片占用更多面积,合图无法紧凑排布元素,造成空间浪费。正确的做的是素材都是方方正正的排布,动画里面去改旋转角度。

  1. 去掉 png 图片周围全透明区域

3D 跑酷开发 - 图14
例如这张图,560_562大小,会占用1.2M的显存。去掉透明区域,只剩89_131大小,只占用45K显存。尤其是很多小型素材的图(例如设计在做帧动画,通过留透明区域使得多张图都是同样尺寸就容易对齐位置,不需要每一帧都调节位置),周围留的透明区域较多。通过工具裁切并修改帧动画相应帧对应元素位置就能省下很多内存。

合图 atlas

合图在诸如渲染 lottie 动效方面起到非常大的优化作用。有如下几点:

  1. 大幅度减少 http 请求数量,加载速度快。(lottie 源文件几十张散图)
  2. 减少 drawcall 提升性能。如果是散图,那么一定会导致多次 drawcall,每次渲染动效不同部分,就肯定需要重新传递 buffer 和 texture。但是如果是 atlas,那么可以优化将很多元素合成一个元素一次性渲染

    模型

    https://oasisengine.cn/0.8/docs/editor-art-cn
    此处链接有个大概的关于面数、骨骼、贴图等要求。更细的如下

    面数

    首先我们需要做减面:

  3. 所有一定看不到的部分都减面,例如人物始终背对着用户,所以不要面部。(可以省 1 万多个面)。周围花坛是没有底部的,818 终点的拱门是没有背面的。

3D 跑酷开发 - 图15(例如从正面看面部都被切掉了)

  1. 头发进行 lowpoly,身体适当切除。因为带着帽子,穿着衣服,所有看不到的肉就可以去掉,或者lowpoly 化,又能少几万的面。如下视频所示:

3D 跑酷开发 - 图16
Aug-23-2022 16-45-20.gif

  1. 如果某块部分是纯色,那么不要用纹理,直接用顶点颜色。相比直接用纹理能占用更少显存
  2. 模型都是人物,但是换皮肤不代表不同皮肤纹理尺寸需要一样。比如默认纹理,就是衣服上有个五角星图案,其他都是纯色,那么这个皮肤的贴图就是 512 512 就可以了。其他复杂皮肤使用 1024 1024 。这样既能缩小某个皮肤模型体积,也不会出现摩尔纹

文档里建议 5 万面内。实际上这个面数通过减面之后依然大概率会超。
以跑酷为例,游戏中极端情况(用最复杂的皮肤,三条道全是金币,使用分身道具)总面数为 12000 4(本体、分身和影子)+ 8700(终点)+ 5800 4(花坛)+ 5300 4(8字草)+ 1080 18(金币)+ 8000(路面) = 12.2万面。
那么 12.2 万面到底合不合理?算正常可接受范围,但是可以做的更好。但是与之对应成本会高(主要是设计成本)。因为模型将很多看不见的部分都减面了,但是还有一些地方比如:

  1. 圆弧的 segment 太高(很多模型减少一半的 segment 就能减少一半的面。此处做到极致可再减一部分模型 50% 面)
  2. 设计没有将所有一定看不见的部分都减,只是减了一部分改动简单的地方(例如人的胸和腹部。花坛某一侧面部分。道路内部一些部分。这部分做到极致可以再减个 25% 的面)
  3. 人物一些没必要的细节(例如帽子的面数挺多,还有手指。各种不必要细节做到极致优化可以再减个 30%)

加起来大致总面数还能减大约 40%,从而理论上极端情况跑酷能 7.5 万面左右。
但是与之付出的代价是:程序员自己建模吧。实际与设计合作发现模型是外包公司做的,建模的人不清楚游戏建模和展示建模的本质区别。举个例子我们需要的是王者荣耀游戏里面开始游戏之后那种低精度模型,但是他们给的是选英雄环节的高精度模型。程序员只能拿到模型,截个图把需要减面的地方圈出来,标著怎么减。但是看不见的地方呢,或者外包建模的人没真正理解为啥要减,整个沟通过程就很复杂麻烦,反反复复好多次。(这就好比写一个 react 页面需要一些切图素材,但是设计不太会使用 ps 去做一些图片素材反过来问程序员怎么去 p 图)
但是对于专业的游戏建模的人,压根就不需要那么多沟通,很可能第一次给的模型就几乎符合要求了,只有少数细节微调。
因此如果在这个环节上花费成本过高,就退而求其次。必须减的部分一定要减,其他成本特别高的地方就算了。那么性能问题就需要直接用低端机测试(使用一定会降级或者直接兜底的手机测试性能,我红米 note8测试机 就是很可能直接兜底的手机可以稳定 55fps 以上。因此超了那么多面可以接受)

骨骼动画

模型可以包含多段动画,每个动画都是由若干关键帧组成。在实际游戏中通过对关键帧插值即可。因此在模型导出时直接导出关键帧,不要对骨骼动画进行烘焙导出。关键帧数量控制在50左右绝大部分情况足够使用了。

压缩纹理

压缩纹理可以有效减少显存使用。通常一个贴图可以减少 75% 的显存。但是有如下缺陷:

  1. 画质有损。画质可能会损失 15% 左右。这个可以实际测试。很多时候某个模型损失 15% 画质看不出来。那么完全可以使用压缩纹理去减小贴图显存消耗
  2. 加载时间延长。比如一个png图片通过雨燕智能优化减小到 100k 大小。转化为压缩纹理可能直接变成 2m。如果有很多图片都使用压缩纹理,那么加载时长大大增加,也消耗用户更多移动流量,也大幅提高了弱网加载失败概率。

因此压缩纹理只能权衡根据实际情况去决策而不是非得用或者不要去用。如果游戏内存始终稳定,占用较小。完全不用压缩纹理不会有问题。例如本次跑酷,内存消耗一直稳定且比业务部分还少,反而是觉得总的资源体积还比较大,如果图片转化为压缩纹理会导致出现更多加载时间。如果发现内存特别高很多机型容易因为游戏为主要原因出现 oom 了,那么压缩纹理就需要去使用。同时也需要考虑更久的加载时长和对应更多机型降级。

开发流程

资源加载

由于游戏中使用的资源众多,如果需要全部资源都加载完成则时间很长。因此将资源分成2部分。例如人物、道路、背景、金币,都是游戏必须的素材,缺少任何一个都会对体验有影响,因此这些素材放在一个列表里面加载。加载完成才能初始化场景。而一些特效元素可有可无,即使加载失败也不影响完整游戏流程,因此这部分资源在游戏初始化出现321倒计时的时候才开始加载,这样只有一半不到的素材必须加载成功,使得用户等待加载时间大幅缩短。
下图为模拟所有必须资源加载完成,其他资源全部加载失败时的情况
3D 跑酷开发 - 图17

场景搭建

好多元素确定位置不是通过数学计算才能确定位置,就是根据效果图去调试位置。如果完全通过写代码设置transform,无法直观知道位置是否合适。因此借助编辑器调整位置就方便很多。在编辑器调整好一些元素位置之后再将对应tranform的值写到代码里,效率就快很多
3D 跑酷开发 - 图18

逻辑开发

根据游戏流程分成如下几个模块;

  1. 游戏主流程控制:负责321预备,起跑,结束三个关键状态做逻辑处理
  2. 事件总线:用于对外部发游戏中的事件,例如吃到金币,碰到障碍等
  3. 道具模块;用户使用不同道具之后游戏需要做不同的逻辑
  4. 资源管理:资源加载,管理对象池,实体创建、销毁等
  5. 手势交互:左右滑动控制人物切换跑道

主流程模块伪代码如下:
const GameControl = {
fadeRate: 0, // 用于控制跑步速度,例如起跑会从0逐步变成1。遇到加速带变成2.撞到障碍变成0.5
gameTime: 0, // 游戏时间,每次循环时间会累加
wholeTime: 10000, // 游戏总时间
deltaTime: 0, // 渲染循环时间间隔
toolTime: 0,
initScene: () => {
// 初始化场景,元素布局,开始321倒计时
},
start: () => {
// 开始跑步,计时开始
},
stop: () => {
// 播放结束动画,然后调用资源管理模块销毁资源
},
onUpdate: (deltaTime) => {
this.deltaTime = deltaTime;
this.gameTime += deltaTime;
this.tooltime += deltaTime * this.fadeRate;
},
createObjOnRoad: () => {
// 根据toolTime每隔一个时间在路上创建金币,障碍或加速带
}
}

道具模块本质就是一个状态管理器,设置各种状态。在游戏运行是通过这个里面的状态做对应逻辑
class GameToolManager {
setShield(bool) {
// 是否开启护盾
}
setAccelerate(bool) {
// 是否使用加速道具
}
setCharacterClone(bool) {
// 是否使用影分身术
}
}

资源管理主要就是调用oasis引擎的resourceManager去加载资源,通过数组做对象池
const resources = {
coinPool: [], // 金币池
barricadePool: [], // 路障池
assets: {
character: mesh,
road: mesh2,
….
},
onLoad: () => {
// 初始化
GameControl.initScene();
GameEvent.dispatch(‘onLoad’);
},
loadImportantAssets: (list) => {
let assets = await engine.resourceManager.load(list);
this.onLoad();
}
}

手势交互很容易,给canvas添加事件监听。按下左右滑动距离超出一个阈值就修改人物模型左右移动距离。
let isDown = false;
let isSwipe = false;
let x = 0;
function onTouchStart(e) {
if (!GameState.interactive) {
// 使用影分身术会有几秒不可操作。通过游戏里面的状态做判断
return;
}
isDown = true;
isSwipe = false;
x = e.targetTouches[0].clientX;
}

function onTouchEnd() {
isDown = false;
}

function onTouchMove(e) {
if (!isDown) {
return;
}
let mouseX = e.targetTouches[0].clientX;
let deltaX = mouseX - x;
if (deltaX > 20) {
// 右滑,控制人物往右移动
isDown = false; // 触发了滑动之后终止判断,避免连续滑动
} else if (deltaX < -20) {
// 左滑,控制人物往左移动
isDown = false; // 触发了滑动之后终止判断,避免连续滑动
}
}

吃金币逻辑用到碰撞检测。由于人物和金币都是不规则物体,如果使用真实物理引擎做碰撞检测浪费太多性能。我们粗略认为在碰撞检测中,人物和金币都当成是一个个平面矩形。只需要判断矩形(不需要旋转)是否有重叠区域即可(即aabb碰撞检测),这样性能就好,效果也能接受,只需要给金币添加一个脚本组件
import {Script} from ‘oasis-engine’;

class EatCoinScript extends Script {
onUpdate(time) {
// 通过数学公式计算金币位置
// 如果金币position.z的绝对值在0.5以内(因为人物位置的z始终是0)且金币位置x减去人物位置x的绝对值在0.5以内则能够吃到金币
}
}

代码优化

shader优化

删掉用不到的代码。尽可能使用最简化的 shader。Oasis 里面可能直接使用 include 标记去复用现有 shader 代码(可以 include 多个 shader 代码段)。但是有些特效用不到所有的代码段,因此需要检查每个 include 对应的代码段到底做了什么,避免无意义的计算。
noise 使用简化版的(参见 shadertoy 上 iq 给出的简化版 noise 函数)。传统的 noise 函数(Perlin noise)计算量太大,影响性能。
// IQ大神的高性能三维noise
float hash(vec3 p)
{
p = fract( p0.3183099+.1 );
p
= 17.0;
return fract( p.xp.yp.z*(p.x+p.y+p.z) );
}

float noise( in vec3 x )
{
vec3 i = floor(x);
vec3 f = fract(x);
f = ff(3.0-2.0*f);

return mix(mix(mix( hash(i+vec3(0,0,0)),
hash(i+vec3(1,0,0)),f.x),
mix( hash(i+vec3(0,1,0)),
hash(i+vec3(1,1,0)),f.x),f.y),
mix(mix( hash(i+vec3(0,0,1)),
hash(i+vec3(1,0,1)),f.x),
mix( hash(i+vec3(0,1,1)),
hash(i+vec3(1,1,1)),f.x),f.y),f.z);
}
其他各种noise的实现参考如下链接
https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83
一个测试性能的办法就是把代码放到 shadertoy 上跑。全屏能始终 60fps,电脑发热不明显,风扇声音不大,那完全毛病。如果不到 60fps 就优化吧。

其他编码建议

能使用乘法计算代替除法的,就不使用除法
如果一个整数做乘以2(2的n次幂)或除以2,其结果也必须是整数的情况,则使用左移右移运算
弱引用关系使用 weakmap
清空数组使用 arr.length = 0
使用 for(let i = 0, j = arr.length; i < j; i++) 方式,不要使用 for in 或者 for of
销毁一个对象并且手动置空(让索引为 0)。例如 entity.destroy(); entity = null;
非必要就别使用解构符…

遇到的问题

内存泄漏、发烫问题
在游戏开发中遇到的内存泄漏问题只有一处:Oasis 的 lottie 插件 destroy 方法没有清干净资源 (最新版本已修复),可以 hack 或者更新版本解决。其他内存泄漏发生在react组件以及react自身上,通过优化组件,将react版本升级到18彻底解决内存泄漏问题。
发烫问题做了测试,把游戏单独开一个项目,手机运行个一个小时游戏还是温的。但是整合到 818 项目里面却玩几把明显热了好多。定位到游戏过程会频繁对外部react组件发送事件(例如吃到金币,告诉外部播放音效。撞到障碍调用一下手机震动)。而外部react组建没有优化,任何状态变更都进行了完整的重新render。通过前端对业务组件优化,发烫问题得以解决。

webgl crash 问题有无更好解法

上线首日下午2点统计数据,只有万分之 2.72 webgl crash 率,表现良好。
刷新页面的操作可以恢复奔溃问题。或者使用 restore context 方式。但是后者还不如直接刷新,原因在于 restore 必须手动将所有 webgl 资源先清理干净,然后手动重新加载资源,手动重新初始化引擎,重新执行页面逻辑。相当于原先刷新页面浏览器自动做的事情现在手动重新做一遍。如果这个逻辑当中出现问题,业务很容易出问题。
webgl crash 如何产生的?答案是运气差或者代码真的垃圾。
非运气 crash 如下(完全可解)

  1. 显存、内存爆了一定会 crash。这个就是代码问题,有内存泄漏,或者浪费一堆内存。这个需要写代码就需要各种注意,养成好的习惯。
  2. 图片超出硬件支持的尺寸一定会 crash。这个程序员在查看资源都不用写代码就知道哪些资源会引起 crash。所有不符合要求的资源都要设计去改。图片的要求就是 2048 2048 范围内。比如如果有一张图片是 1 2049 就会引起少数低端机直接 crash。这个 crash 是只要运行就瞬间 crash。

运气 crash 有如下情况:(不可解)

  1. 别人干了容易造成 crash 的事情造成 webgl crash,那么我们的也会 crash(有一个地方 webgl crash,所有 webgl 都 crash)
  2. 系统自身原因导致 crash。(比如我 mac 电脑随便打开一个 webgl 页面。然后点击电脑睡眠。然后再打开电脑。页面高概率 crash)
  3. 不明原因。玄学(每秒 60fps 流畅运行,内存消耗极低,cpu 和 gpu 消耗也极低,机子完全不发热。但是就突然 crash 了)

    总结

    做大促互动游戏,难点不是游戏本身是否复杂,而是如何保证稳定性避免各种崩溃发生,优化资源和加载避免在弱网情况花费太多时间。同时也要做好各种应急预案万一出现崩了情况得立即使用兜底方案不能造成业务无法正常进行。