作者 | 誠空

點選檢視視訊
五福是螞蟻一年一度的春節大促,而今年的五福無論是在互動形式上還是在玩法上都有不少創新點。而作為整個五福的主會場,互動形式的創新自然不會缺席。
業務拆解
在主會場中,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
模式,是以我們直接在場景編輯器中編輯我們的場景,所見即所得 (編輯器文檔,
https://oasis-engine.github.io/#/editor/zh-cn/README)。
下面是我們使用 Oasis 3D 編輯器來搭建整個互動場景,WF~主會場首頁。
我們再來一張從右側面觀看場景的截圖,這樣就會更清晰了:
邏輯開發
Oasis 提供了一個腳本元件(
https://oasis-engine.github.io/#/editor/zh-cn/script)來編寫邏輯,我們建立好腳本元件後挂在對應的節點上即可。
滑動控制
按之前講過的思路,我們是通過滑動螢幕的豎直方向的距離來控制整個五福場景的滾動,是以我們建立一個腳本
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,也歡迎通過郵件和我單獨交流,郵箱位址:[email protected] 。
檢視原文可通路 Oasis Engine 的相關文檔。
關注「Alibaba F2E」
把握阿裡巴巴前端新動态