从借呗双十一游戏看蚂蚁金服 Oasis 3D 工作流

avatar
花呗借呗前端团队 @蚂蚁集团
作者:RichLab 前端 楺楺
写在最前:欢迎来到「花呗借呗前端团队」技术专栏,我们将与大家分享前端各领域的高质量技术文章,包括但不限于移动端、小程序、互动技术/数据可视化、Node.js 全栈/中后台、基础架构、个人思考,不限于原创与翻译。我们也欢迎优质文章的投稿。
2019 年的双十一前夕,借呗上线了一款 3D 游戏,动动小手即可获得借呗免息借款优惠,大家有没有玩呢?我们办公室的小伙伴们可是玩得不亦乐乎!

具体玩法是这样的:画面中有一只可爱的小飞船和随机数量的金币,用户看准金币移动至中间位置、连成一条线的时候,点击“瞄准出发”按钮,将飞船发射出去,飞船触碰到金币,即可获取免息天数。看看演示👇~


这样的一款 3D 游戏,你觉得开发难度如何呢?


其实啊,这款飞船游戏是通过我们自研的 Oasis Editor 开发的,Oasis Editor 是一个 web 3D 内容在线开发平台,基于 Oasis Editor 的工作流,可视化编辑项目,再加上少量代码(JavaScript)便能完成整个 3D 游戏/动效。在 Oasis 的支持下,整个开发流程其实十分简单!


开发流程

整体结构

首先,我们来看下飞船游戏在 Oasis Editor 上的整体结构:
整个编辑器界面由 6 个窗口区域组成,项目开发的主要操作集中在窗口 2、5、6,窗口 4 可以实时反馈渲染的结果。
窗口 1:工具窗口,包括属性可视化操作工具(节点平移、旋转、缩放)、撤销、重做等功能。 窗口 2:场景节点树,可以可视化操作整个场景节点树,包括节点添加、删除、复制、拖拽移动等功能。 窗口 3:导航栏,展示一些项目级别的功能操作按钮,包括预览、发布功能按钮。 窗口 4:3D 场景操作视口,可视化 3D 场景操作界面,实时反馈节点以及其属性变化的渲染效果,也可通过鼠标和快捷键操作场景内容修改属性。 窗口 5:资源管理,可以添加上传资源、删除资源,供 3D 场景关联使用。 窗口 6:属性检查器,用于查看和修改节点属性、节点功能组件属性、资源属性。

场景树

我们把上图窗口 2 中的节点展开,会看到一颗完整的场景树。在 root 节点下,主要包含了飞船、金币、粒子、灯光和相机。游戏的逻辑则通过脚本创建 ability, 比如在 root 节点下进行金币的克隆和创建速度线,关于脚本下文会展开描述。




飞船

分析完了架构及场景,我们来看看如何利用 Oasis Editor 快速开发 3D 项目。

上传资产

  • 进入“我的项目”页面,创建一个新项目,然后点击"编辑"进入编辑页面。编辑器会默认创建好灯光、相机,以及一个立方体 model. 将默认创建的 model 删除,重新创建一个节点,重命名为 ship.
  • 飞船的模型是一个 GLTF 文件,点击”资源“右侧的”+“号,上传 GLTF 文件。编辑器会自动解析并生成对应的材质。
  • 为 ship 节点添加能力,选择 GLTF 模型。点击模型的 asset 属性,选择我们刚才上传的 GLTF 文件,这时候飞船模型就绑定到 ship 节点上了。
  • 为了模拟真实的效果,我们先上传一些贴图供下文使用:三张飞船纹理贴图及六张立方体纹理贴图
视频封面

上传视频封面

好的标题可以获得更多的推荐及关注者

目前我们已经有了以下资产,飞船整体呈现灰色,接下来将进行 PBR 材质的编辑。



编辑 PBR 材质

什么是 PBR?PBR 是指基于物理的渲染(Physically Based Rendering),它指的是一些在不同程度上都基于与现实世界的物理原理更相符的基本理论所构成的渲染技术的集合。
这种渲染方式与 Phong 或 Blinn-Phong 光照算法相比,总体上看起来要更真实一些。除此之外,由于它与物理性质非常接近,因此我们可以直接以物理参数为依据来编写表面材质,而不必依靠粗劣的修改与调整来让光照效果看上去正常。使用基于物理参数的方法来编写材质还有一个更大的好处,就是不论光照条件如何,这些材质看上去都会是正确的,而在非 PBR 的渲染管线当中有些东西就不会那么真实了。这里列举一些即将用到的参数:
  • metallicRoughnessTexture:金属粗糙度贴图
  • baseColorTexture:基础颜色贴图
  • normalTexture:法线贴图
  • srgb:是否为 SRGB 色彩空间
  • gamma:是否使用 Gamma 纠正
  • baseColorFactor:基础颜色因子
  • isMetallicWorkflow:是否使用金属粗糙度模式
  • specularFactor:高光度因子

鼠标选中飞船后,在右侧点击 asset 的链接按钮会跳到对应的 PBR 材质,飞船的 GLTF 一共包含了两个材质,其中 lambert2.1 是飞船顶部的圆盖,lambert2 是飞船的机身。点击 lambert2 右侧的链接按钮对机身的 PBR 材质进行编辑。


目前我们已经上传了 3 张贴图,分别绑定到 metallicRoughnessTexture、baseColorTexture 和 normalTexture.




为了得到更好的效果,需要进一步调整 lambert2 的属性。把 srgb 和 gamma 都勾选上,分别代表 SRGB 色彩空间和使用 Gamma 纠正。默认的基础颜色看起来偏白,所以把 baseColorFactor 的 rgb 设置为(200,200,200)




目前看到的模型缺少光泽感,显得十分呆板,接下来将用 cubeMap 实现玻璃反射效果。
什么是cubeMap?cubeMap 即立方体贴图,简单来说,立方体贴图就是将 6 个 2D 纹理组合起来映射到一张纹理上的一种纹理类型,每个 2D 纹理都组成了立方体的一个面。你可能会奇怪,这样一个立方体有什么用途呢?为什么要把 6 张纹理合并到一张纹理中,而不是直接使用 6 个单独的纹理呢?立方体贴图有一个非常有用的特性,它可以通过一个方向向量来进行索引/采样。假设我们有一个 1x1x1 的单位立方体,方向向量的原点位于它的中心。如下图,使用一个橘黄色的方向向量来从立方体贴图上采样一个纹理值:



通过使用环境的立方体贴图,我们可以给物体设置反射和折射的属性,这种使用环境立方体贴图的技术叫做环境映射(Environment Mapping)。
在左侧的节点树中创建一个新节点,然后添加 PBR 光的能力,这里的 PBR 光指的就是环境映射的光源。




点击 PBR 光的 map 属性,选择立方体贴图(只需点击其中一张图就会默认绑定好六张纹理)。




最后调整 PBR 材质的反射强度。选中”资源“区的 lambert2.1,默认是金属粗糙度模式,所以需要把 isMetallicWorkflow 取消勾选,变成高光光泽度模式。为了模拟蓝色的天空,specularFactor 的 hex 值设置为4A90E2,baseColorFactor 的 hex 值设置为 FFFFFF. 到此为止,一个具有光泽感的飞船就制作完成了~




添加脚本

浮动

接下来要给飞船添加脚本使它上下浮动。先在”资源“区创建一个脚本。




点击脚本进行编辑,把以下脚本复制进去之后保存。
class CustomAbility extends o3.NodeAbility {
 	time = 0;
  tscale = 0.012;

  constructor(node, props) {
    super(node);

    // init code
  }

  onUpdate(deltaTime) {
    // update per frame
    let pos = this.node.position;
    const y = Math.sin(this.time * 0.2) * 0.1;
    this.time += deltaTime * this.tscale;
    pos[1] = y;
    this.node.position = pos;
  }
}


回到编辑页面,鼠标选中飞船后点击添加能力,选择我们刚才创建的脚本




点击右上角的预览就能看到飞船上下浮动的效果了。



在预览页面,把右上角的相机轨道控制器打开之后就能可以拖动整个场景



最终效果如下


碰撞

利用碰撞检测来反应 3D 空间中两个物体的相交情况,碰撞检测的功能也是通过脚本添加。
首先给所有金币和飞船添加包围盒碰撞体,开发阶段可以创建一个立方体来调试碰撞体的大小。接着给飞船添加 ACollisionDetection 组件来检测碰撞。最后注册碰撞时的触发的事件,每碰撞一个金币,气泡和金币消失,并发射对应的粒子。



代码块:
// 设置包围盒碰撞体
setCollision() {
  const coinCollider = this.node.createAbility(o3.ABoxCollider);
  coinCollider.setBoxCenterSize([0, 0, 0], [1.4, 0.8, 1.2]);
  // 可视化碰撞体
  const mtl = new o3.LambertMaterial('cube_mtl', false);
  const renderer = this.node.createAbility(o3.AGeometryRenderer);
  renderer.geometry = new o3.CuboidGeometry(1.4, 1.0, 1.2);
  renderer.setMaterial(mtl);
}

// 监听碰撞事件
bindEvent() {
  let cd = this.node.createAbility(o3.ACollisionDetection);
  cd.addEventListener('begin_overlop', e => {
    const colliderNode = e.data.collider._ownerNode;
    const ev = new o3.Event('colliderCoin');
    colliderNode.trigger(ev);

		// ...
 
    // 撞击后,金币个数加一
    if(colliderNode.name === 'coin') {
      (this.node as any).coinNum++;
      this.engine.rootNode.trigger(ev);
    }
  });
}

辅助特效

尾焰

飞船的尾焰效果由两部分组成:上方有一个半透明的模型,下方是呈扇形发射的粒子系统,粒子系统可以点击右侧的“添加能力”添加。



如何设置扇形?首先确定粒子的发射方向是沿 z 轴的正方向发射,所以粒子的 x、y 方向的速度设置为 0,z 方向有初始的速度。为了使粒子由一个点进行发散,x 轴的速度随机因子设为 1.5。这时候粒子都是竖着做扇形发射,所以还需要让粒子随运动方向旋转。

光束赛道

飞船入场的时候会发出光束,光束赛道向前滚动,启动飞船之后光束逐渐收起。



利用纹理沿 y 轴平移来模拟赛道滚动效果。光束透明度渐变在 0.0 和 0.6 之间做线性插值,并不断改变插值系数。
发射光束时逐渐减小 utime 的值,收起光束时逐渐增大 utime 的值。

shader 代码:
varying vec2 v_uv;
uniform sampler2D texturePrimary;
uniform float utime;
uniform float time;

void main() {    
    vec2 uv2 = vec2( v_uv.x, v_uv.y +  time );
    vec4 texSample 	= texture2D( texturePrimary, uv2 ).rgba;

    vec4 showAlpha = mix(vec4(0.0, 0.94, 1.0, 0.0), vec4(0.0, 0.86, 1.0, 0.60), v_uv.y - utime);
    
    gl_FragColor = vec4(texSample.rgb, showAlpha.a);    
}
赛道的滚动效果利用纹理沿 y 轴平移来实现,代码中通过改变变量 time 来增加纹理的 UV 坐标,一开始效果很流畅,但运行久了之后就会变得明显卡顿和马赛克效果。
因为 time 在不断变大,猜测是取纹理的精度太低导致的,直接设置 precision highp float 可以解决这个问题,但是可能带来性能和兼容性问题,所以较优的方法是对递增后的 time 值取模。

onUpdate(deltaTime) {
  this._runtime += 0.005;
  this._runtime %= 1.0;
  this._trackMaterial.setValue('time', this._runtime);
}

金币

金币结构

粒子跟随着金币一起运动,默认情况下粒子不发射,在编辑器中需要把 defaultStart 去掉,当飞船碰撞金币时再发射粒子。这里一共包含黄色空心和蓝色实心两种粒子,所以创建了两个粒子节点。整个金币的结构如下




金币分为金币本身和泡泡两个模型,泡泡利用 Sprite 来显示 2D 图片,后面放置金币模型,并通过脚本控制金币自动旋转,同时两个模型随着父节点来回做横向运动。



金币运动

金币做 sin 曲线运动,初始的 x 轴位置随机,如下图中的红点


this._time 对应曲线图中的 x,x 对应曲线图中的 Y。
onUpdate(deltaTime) {
  if (!this._isMove) return;

  const turn = this._turn === 'left' ? -1 : 1;
  const x = Math.sin(this._time * 0.02) * 1.6 * turn;
  this._time += deltaTime * this._tscale;
  let pos = this.node.position;
  pos[0] = x;
  this.node.position = pos;
}

金币克隆

场景中默认只有一个金币,由业务传入个数 clone 多个金币,当飞船触碰金币时没有粒子发射的效果。看了下引擎对节点 clone 的实现,会对新创建的节点重新 createAbility,并把目标 ability 的 _props 作为新 ability 的参数,这会导致一些 ability 属性没有拷贝进去,所以需要在节点 clone 之后重新调用粒子的 initialize 方法。

金币的泡泡在某些安卓机上变黑



在解这个问题之前我们需要先了解什么是纹理过滤?什么是 mip map?我们的纹理要贴到三维图形表面,纹理与三维图形的大小不一定一致。当纹理大于三维图形表面时,导致一个像素被映射到许多纹理像素上。当纹理小于三维图形表面时,导致许多个像素都映射到相同纹理。当这些情况发生时,贴图就会变得模糊或发生错位。要解决此类问题,必须通过技术平滑 texel 和 pixel 之间的对应。这种技术就是纹理滤波。

Mipmap 是把纹理按照2的倍数进行缩放,直到图像为 1x1 的大小。比如一张 256x256 像素的纹理,它 mip map 就会有 8 个层级。每个层级是上一层级的四分之一的大小,依次层级大小就是:128x128;64x64;32x32;16x16;8x8;4x4;2x2;1x1(一个像素)。当加载纹理的时候,会加载这一系列从大到小的纹理,然后 OpenGl 会根据给定的几何图像的大小选择最合适的纹理。

Mipmap的纹理过滤模式如下:
  • GL_NEAREST:在 mip 基层上使用最邻近过滤
  • GL_LINEAR:在 mip 基层上使用线性过滤
  • GL_NEAREST_MIPMAP_NEAREST:选择最邻近的 mip 层,并使用最邻近过滤
  • GL_NEAREST_MIPMAP_LINEAR:在 mip 层之间使用线性插值和最邻近过滤
  • GL_LINEAR_MIPMAP_NEAREST:选择最邻近的 mip 层,使用线性过滤
  • GL_LINEAR_MIPMAP_LINEAR:在 mip 层之间使用线性插值和使用线性过滤,又称三线性 mip map

一开始泡泡用普通的加载方式得到图像之后,new Texture2D 得到的纹理资源通过Sprite渲染出来,虽然图片的大小是 116*116,但这时候泡泡纹理能正常显示。当 Texture2D 检测到图片为非2的n次幂,_canMipmap会被置为 false,对应的纹理过滤方式会采用 NEAREST 或 LINEAR。同一张图片用 ResourceLoader 加载之后,得到的纹理在大部分安卓机上会出现黑色的效果,因为引擎会生成标准的 2 的 n 次幂图像,此时图像的纹理过滤方式为 LINEAR_MIPMAP_LINEAR,在一些手机上生成指定纹理的 MipMap 会产生兼容问题,所以纹理都需要改成 2 的 n 次幂。

速度线

飞船启动后产生的白色线条即为我们要的速度线效果,如下图:



首先我们需要这样一张带有参差线条的图片,接着对它做径向旋转,产生穿梭的感觉,而且要使平面始终朝向相机。



shader 代码:
uniform float u_time;
uniform sampler2D u_texture;
varying vec2 v_uv;
const float PI = 3.1415926535897932384626433832795;

void main(void) {
  vec2 q = -1.0 + 2.0 * v_uv;
  q.y += 0.3;

  float len = length(q);
  float angle = atan(q.y, q.x);
  float u = fract((angle + PI) / (PI * 2.0) + u_time * 0.001);	
  float v = 0.3 / len + u_time * 0.5 - 0.5;

  vec4 tex = texture2D(u_texture, vec2(u, v));
  gl_FragColor = vec4(tex.xyz * len, 1.0);
}
这时候速度线图片会挡住后面的场景,所以需要修改混合函数,混合是指两种颜色的叠加方式。OpenGL 会把源颜色和目标颜色各自取出,并乘以一个系数(源颜色乘以的系数称为“源因子”,目标颜色乘以的系数称为“目标因子”),然后相加,这样就得到了新的颜色。源因子和目标因子是可以通过 glBlendFunc 函数来进行设置的。这里需要把源因子设置为 ONE_MINUS_SRC_COLOR(所有通道乘以 1 减去 Source Color),目标因子设置为 ONE(所有通道乘以1,实际上相当于完全的使用了这种颜色参与混合运算)。



陀螺仪

陀螺仪可以控制飞船的旋转和平移,主要利用 @alipay/orientation 库来实现。

bindOrientation() {
  this._shipOrientation = new Orientation({
    sensor: env.os.isIOS ? 'native' : 'web',
    interval: 20,
    frictionX: 0.1,
    frictionY: 0.1,
    limitX: 40,
    limitY: 10,
    tick: (data: any) => {
      let { x, y } = data;
      this.node.setRotationAngles( -y, 0, -x / 2);
    }
  })
}

业务联动

前面我们已经利用 Oasis Editor 完成了 3D 游戏场景的搭建,如何将游戏场景嵌入到项目的 HTML 中?业务逻辑与游戏场景如何通信?下图展示了游戏场景与业务代码之间的联系:



oasis 包含了场景 SceneGraph、资源和控制游戏逻辑的 scripts,可直接通过 VScode 插件从 Oasis Editor 同步下来。入口文件 main.ts 暴露了 loadScene 接口,用于加载整个 3D 场景。

// oasis/main.ts
import './scripts'
import { config } from './schema.json'
import * as o3 from '@alipay/o3-plus'

let scene

async function loadScene(sceneConfig:any) {
  scene = await o3.loadScene({
    local: true,
    config,
    ...sceneConfig
  })
}

export {config, o3, loadScene, scene}
gameController 是业务代码与 3D 场景之间的桥梁,获取页面中的 canvas 对上面的 loadScene 进一步包装,同时暴露了启动飞船、发射粒子等接口供 Oasis.vue 调用,业务开发无需关心 3D 场景的实现。
// gameController.ts
import { loadScene, scene } from '@oasis/main';
import * as o3 from '@alipay/o3-plus';
let rootNode;

// 游戏初始化
async function initGame(canvas) {
  await loadScene({
    canvas,
    rhiAttr: {
      enableCollect: false
    }
  });
  rootNode = scene.scenes[0].root.findChildByName('root');

  // 触发场景加载完的事件
  const event = new o3.Event('loadFinish');
  const coinNum = window.$config.coinNum;
  event.data = { coinNum };
  rootNode.trigger(event);
}

// 启动飞船
function startupShip() {
  if (!rootNode) return;

  const event = new o3.Event('startupShip');
  rootNode.trigger(event);
}

// ...

export {
  scene,
  rootNode,
  initGame,
  startupShip,
}

Oasis.vue 是页面中的组件,其中包含了一个 canvas 用来展示 3D 场景,可以直接调用 gameController 中的接口来操作游戏场景。
// Oasis.vue
<template>
	<canvas ref="canvas" class="canvas" :class="{ show: pageShow }" />
</template>

<script>
import { initGame } from '../gameController.ts'
import { detectWebGLContext } from '@common/utils/downGrade'

export default {
  methods: {
    async handle3D() {
      const canvas = this.$refs.canvas
      try {
        await initGame(canvas)
        if (!detectWebGLContext()) {
          throw new Error('不支持WebGL')
        }
      } catch (e) {
        this.change({
          mode3D: false
        })
        this.handle2D()
        return
      }
    },
  }
}
</script>
 

降级

3D 游戏的降级方案采用静态图 + css 动画实现,效果如下:

视频封面

上传视频封面

好的标题可以获得更多的推荐及关注者

降级的场景包括:
  • 机型黑名单
  • IOS 版本
  • 安卓版本
  • 不支持 WebGL 的机型降级
  • 初始化场景报错直接降级

总结

由线上数据分析可知, 3D 模式的曝光率占绝大部分,只有极少量用户看到 2D 降级版本。从整体上看, 3D 模式下各项转化率都高于 2D 模式,可见 3D 场景更能给业务带来价值。

通过这个项目,自己的 3D 技术得到了较大的提升,希望有更多前端同学跟我们一起来玩 3D. 经过 3D 互动小组的共同努力,对 Oasis Editor 进行了一轮升级战役,目前的功能已经能为我们的项目提高至少 35% 的开发效率,强烈推荐使用。


最后感谢蚂蚁 UED 团队、业务方、 3D 互动小组对本项目的支持。
🎉 我们团队还会陆续出品 3D 技术领域的系列文章,感兴趣的伙伴请多多关注哦~


蚂蚁 RichLab 前端团队」致力于与你共享高质量的技术文章

欢迎关注我们的专栏,将文章分享给你的好友,共同成长 :-)

我们团队正在急招内转:互动图形技术、前端/全栈开发、前端架构、大数据开发等方向任选,期望层级 P6+~P7,团队技术氛围好,上升空间大,简历可以直接砸给我哈 shudai.lyy@alibaba-inc.com