作者 | 诚空
点击查看视频
五福是蚂蚁一年一度的春节大促,而今年的五福无论是在交互形式上还是在玩法上都有不少创新点。而作为整个五福的主会场,交互形式的创新自然不会缺席。
业务拆解
在主会场中,4 个子场景的展示我们决定使用滚动形式,这块决定放在 3D 来完成,3D 部分的实现我们使用了 Oasis 引擎,其余部分在 DOM 中完成。最终整个页面的分层为:
- DOM UI 1:显示在 3D 内容底下的或者和 3D 内容不相交的内容
- Canvas:3D 内容显示区域 (给我们一个 canvas,还你一个 3D 世界)
- DOM UI 2:会盖在 3D 内容上面的内容
3D 实现
整体设计
明确 3D 需要做什么之后,下一步就开始构思整个 3D 场景该怎么搭建了。需求本质是多个场景放在一起,通过上下滑动屏幕可以让所有场景整体一起滚动。很自然的会想到,所有场景放在一个父节点上,然后通过控制父节点即可达到整体控制的效果。
至于上下滑动屏幕的滚动效果,我们先假设不考虑滚动,我们先来想想在一个平面内,上下滑动来控制场景上下移动,这个很简单了,大致如下:
在上图中,我们只需要通过滑动距离来控制父节点的上下移动距离即可完成场景整体的移动,接下来就是进一步思考该怎么滚动呢,滚动很容易就联想到圆,我们把圆心放在 Canvas 中心,然后圆心往屏幕后面推一定距离 (圆的半径 R
),然后每个场景是半径为 R
的圆环中的一段弧 (所有场景弧度一致),这样所有场景连续无缝拼接起来就是一个大弧。然后通过滑动距离来控制圆环在 X 轴的旋转角度即可。我们以右手坐标系为参考,并从右侧面观察整个场景为例,示例图如下:
场景搭建
整体设计思路理清楚后,接下来就是场景的搭建,Oasis 主打的就是 Low Code
模式,所以我们直接在场景编辑器中编辑我们的场景。
下面是我们使用 Oasis 3D 编辑器来搭建整个互动场景,WF~主会场首页。
我们再来一张从右侧面观看场景的截图,这样就会更清晰了:
逻辑开发
Oasis 提供了一个脚本组件来编写逻辑,我们创建好脚本组件后挂在对应的节点上即可。
滑动控制
按之前讲过的思路,我们是通过滑动屏幕的竖直方向的距离来控制整个五福场景的滚动,所以我们新建一个脚本 TabController.js
,并将其挂在 tab 节点上,在这个脚本中,有 3 个 api 来负责处理滑动事件,如下:
import * as o3 from 'oasis-engine';
// Y 方向滑动距离和绕 X 轴旋转角度的比例
const D2R_RATIO = 1 / 35;
export class Script6340825 extends o3.Script {
// 触摸开始位置
private _startTouchPos: o3.Vector2 = new o3.Vector2();
// 当前触摸位置
private _curTouchPos: o3.Vector2 = new o3.Vector2();
// 开始点击屏幕
public touchBegin(pos: o3.Vector2) {
// 记录屏幕点击的位置
this._startTouchPos.setValue(pos.x, pos.y);
// TODO ...
}
// 滑动屏幕
public touchMove(pos: o3.Vector2) {
// 当前位置
this._curTouchPos.setValue(pos.x, pos.y);
// 在竖直方向上的滑动距离
const offsetY = this._curTouchPos.y - this._startTouchPos.y;
// TODO ...
// 当前父节点旋转角度
const { x, y, z } = this._curRotation;
// 计算新的旋转角度
const newX = Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, x + offsetY * D2R_RATIO));
// 设置新的旋转角度
this.entity.transform.setRotation(newX, y, z);
}
// 离开屏幕
public touchEnd(pos: o3.Vector2) {
// TODO ...
}
}
上面代码就是控制旋转的核心逻辑,当然实际业务中比这个复杂一些,为了更好的体验,我们添加了回弹和速度加成的效果。回弹的实现比较简单,在结束滑动的时候,根据当前实际旋转角度计算出我们需要停留的旋转角度即可。速度加成就是我们在滑动结束瞬间计算出滑动速度,大于某个阀值后,在当前实际选择角度上叠加一个角度。
private _calculateTargetX(curX: number): number {
// 滑动速度
const { _speed } = this;
// 添加旋转角度, SPEED_LIMIT 是和设计同学一起调出来的数值
if (Math.abs(_speed) > SPEED_LIMIT) {
curX += _speed > 0 ? 5.5 : -5.5;
}
let curTab = this._getTab(curX);
curTab = Math.max(TAB_START, curTab);
if (this._curIndex !== curTab) {
this._curIndex = curTab;
this.engine.dispatch('moveToTab', {
tabIndex: this._curIndex - TAB_START,
});
}
return TAB_FLAG[curTab];
}
最后我们来看看速度的计算,速度的计算也是在 touchMove 过程中不断更新的,如下:
public touchBegin(pos: o3.Vector2) {
this._speedStartY = pos.y;
this._speedDir = 0;
this._speedStartTime = this.engine.time.nowTime;
this._speed = 0;
}
public touchMove(pos: o3.Vector2) {
const curSpeedDir = pos.y > this._speedCurY ? 1 : -1;
if (this._speedDir === 0) {
this._speedCurY = pos.y;
} else {
if (this._speedDir === curSpeedDir) { // 同向
this._speedCurY = pos.y
if (this._moveFlag && Math.abs(this._speedCurY - this._speedStartY) > 10) {
this._moveFlag = false;
this.engine.dispatch('moveBegin', { reason: 0, speedDir: this._speedDir });
}
} else {
this._moveFlag = true;
this._speedStartY = this._speedCurY;
this._speedStartTime = this.engine.time.nowTime;
}
}
this._speedDir = curSpeedDir;
}
public touchEnd(pos: o3.Vector2) {
this._speedEndTime = this.engine.time.nowTime;
this._speed = (this._speedCurY - this._speedStartY) / (this._speedEndTime - this._speedStartTime);
}
特效
点击查看视频
如上,我们每个场景中其实都加了一些粒子效果 (随机一些小圆点往上飘)以及场景模型本身的动画,模型动画是通过骨骼动画实现的,通过代码直接控制播放即可。粒子效果这块我们是直接使用的 Oasis 自带的粒子系统。粒子飘动的效果制作起来也比较简单,直接在编辑器中添加一个粒子组件,设置一些随机位置、初速度、加速度、数量、大小、粒子贴图:
业务联动
完成 3D 场景滚动后,还需要和业务层进行联动,3D 和 UI 层的通信我们采用事件机制,结构如下:
从上图结构可以看出,我们提供了一个 GameController
来监听 UI 层的事件,然后调用 TabController 的相关 api 来完成对应的操作。当 3D 层的一些变更需要通知 UI 层时,我们是直接从 TabController
派发事件直接通知 UI 层的。
import * as o3 from 'oasis-engine';
import { Script6340825 } from './tabController';
export class Script5460766 extends o3.Script {
onAwake() {
const { engine, entity } = this;
const tabEntity = entity.findByName('tab');
const tabController = tabEntity.getComponent(Script6340825);
// 初始化 tab 数据
engine.on('initTab', (e) => {
tabController.initTab(e);
});
// UI 层点击 tab 切换场景
engine.on('selectTab', (e) => {
tabController.selectTab(e);
});
// 触摸相关
engine.on('touchstart', (e) => {
tabController.touchBegin(e);
});
engine.on('touchmove', (e) => {
tabController.touchMove(e);
});
engine.on('touchend', (e) => {
tabController.touchEnd(e);
});
}
}
优化
功能开发完成后,我们需要结合具体业务场景,从不同纬度进行优化从而得到一个最优解,这里我们主要从内存、加载速度、展示策略这三个方面来讲讲。
内存优化
内存是五福项目最大的瓶颈点之一,也是线上比较容易触发 OOM (out of memory) 而导致 crash 的因素,所以这部分的优化是重中之重。而内存主要开销有:上传给 GPU 的顶点数据、纹理、各种缓冲(颜色缓冲、深度缓冲等)。
顶点数据
顶点数据的多少会影响 GPU 的运算量以及内存,而在我们的业务场景中,顶点数据数量优化主要是为了优化内存,下面我们从两个方面来进行优化:
1、模型减面:
场景模型在滚动过程中,我们能看到的始终只有场景的地面和场景中内容的前面部分,所以其它永远不可见部分在导出模型的时候是可以直接去掉的,我们以 AR扫福 为例,来看看最终交付模型是什么样的:
上面的是正面看模型的效果,我们来看看各个角度观看的效果:
2、CPU 裁剪
上面我们从美术资产的角度对三角面进行了优化,我们单独跑 3D 工程,可以看下现在上传到 GPU 的三角面数量 (54474),如下:
我们单个场景的三角面数量在 1~2 万之间,虽然我们模型做了优化,但是实际渲染的时候,我们会把场景中所有子场景的数据都上传 GPU,这样明显是不太合理的,开发五福的时候,用的引擎版本还没做裁剪优化 (现在最新的已经有了哦~),所以我们在自己的实现中添加这块的处理,大体思路就是通过父节点的旋转,可以计算出每个子场景的旋转角度,通过设置可见旋转角度的范围,来决定每个子场景当前是否可见。实现代码如下:
public touchMove(pos: o3.Vector2) {
// TODO ...
// touchMove 中计算出父节点的旋转 newX,然后刷新所有子场景是否可见
this._updateChildsActive(newX);
// TODO ...
}
private _updateChildsActive(_curRotationX) {
const { _childs } = this;
for (let i = 0, l = _childs.length; i < l; ++i) {
const child = _childs[i];
const min = (_curRotationX - 33) + i * 11;
const max = min + 11;
if (max <= 0 || min >= 16.5) {
child.isActive = false;
} else {
child.isActive = true;
}
}
}
这里有一点需要说明,我们这里的关于子场景的可见判断其实是取巧了,因为在我们设定的 Canvas 上,最多同时也只能显示下 2 个子场景,所以可见判断简单通过一个范围来判断。而引擎最新的版本是通过视锥剔除算法来判断,详见透视投影和视锥剔除。下面是优化后的效果:
纹理压缩
上传给 GPU 的顶点数据这块,目前来看很难有优化空间,我们前面三角面减面其实也算是变相的减少了顶点数量,不过这块内存占比本身也不高,大头还是在纹理 (之前的认知)。所以首先就是对纹理进行优化,我们每个子场景都是一个单独模型,然后各自引用了一张单独的 1024 * 1024 的贴图 (内存 4 M),贴图看下来优化空间好像也没有,考虑到五福项目会提前预推所有的资源 (ccdn),那么这个时候使用纹理压缩就是非常合适的选择了,Oasis 已经提供了完整的纹理压缩的解决方案,只需要在编辑器中操作即可,如下:
完成上述操作后,就会自动生成多种格式的纹理压缩后的文件,如下:
关于纹理压缩的使用,详见纹理压缩的使用,优化后,通过 VisionTMPerf 工具测试内存发现,内存涨幅依然很大,而且远远超出预期,纹理就算不使用纹理压缩,也就 16 M,但是涨幅确到了100+,如下:
缓冲优化
既然纹理这块不可能这么高,那就剩下唯一的 各种缓冲 了,这些和 Canvas 大小是有关的,以颜色缓冲来说,它必然要能够存储 Canvas 上所有像素点的颜色,而像素点数量的多少和 Canvas 的大小是成正比的。有了猜测,接下来就是验证了,这里我们直接在本地打开 Chrome 来进行测试,优化前:
通过优化后,可以明显看到 GPU 内存从 101 M 降低到了 30.7 M。优化后:
为了方便对比,我们把上面两张图片的数据放在表格中看看,如下:
加载优化
为了减少页面加载时长,提升用户体验,我们需要对加载时长进行优化。最开始版本所有资源都是直接在编辑器中,这会带来一个问题,编辑器中所有资源都会存进 schema 中,这样初始化 3D 完成的时间会比较长,体验比较差,我们可以在本地 Chrome 中进行测试对比,优化前如下:
我们通过 Chrome 的 Network 来查看具体下载耗时如下:
通过上图可以知道,下载比较耗费时间的就是模型的.bin
文件以及图片资源,而对于主会场来说,用户进来其实只会看到一个子场景,只有滑动才能看到其它子场景,并且很多时候可能压根不会去滑动。基于这些条件,我们优化方向就是动态去加载,首先我们把模型和图片资源的 url 配置在文件中,然后在代码中动态加载,如下:
// 初始化 tab 数据
public initTab(e) {
// TODO ...
// 动态下载资源
this._download(e);
}
然后我们在代码中去动态加载需要的资源即可,现在我们用同样的流程来测试初始化时间,如下:
最终在手机上的效果是完全符合预期的,通过打点统计到的在线数据加载时长在 1S 以内。
特效展示策略优化
考虑到不同手机性能、内存方面的差异,我们需要对不同机型做不同的降级操作。以往项目的降级比较简单,通过判断是否支持 webgl,是否有异常,是否在祖传降级名单中,如果中了其中任何一条,直接降级,属于一刀切的做法。五福项目中,对降级进行了进一步细化,不支持 webgl 或者有异常还是直接降级为静态版本,否则通过将机器分为 高、中、低 等级进行不同的展示,主会场子场景是这样细分的:
总结
五福从 2 月 1 号正式对外放量以来,收到不少同学的反馈,表示体验很不错,也很流畅,也有一些同学过来问是怎么实现的。能收到这么多正向的反馈,内心还是有点小窃喜。
从 1 号放量截止到 7 号为止,五福主会场页面访问量已经达到数十亿,3D 占比 90%,整体 crash 率在万分之0.23。能有这样的结果离不开一群靠谱的伙伴,在这里特别感谢这些伙伴。
最后,如果您也对图形渲染感兴趣,欢迎加入我们的钉钉开源群交流:31360432,也欢迎通过邮件和我单独交流,邮箱地址:chengkong.zxx@antgroup.com 。
查看原文可访问 Oasis Engine 的相关文档。
关注「Alibaba F2E」
把握阿里巴巴前端新动态