雲計算

Oasis 億級應用之五福 3D 主會場

作者 | 誠空

image.png

點擊查看視頻

五福是螞蟻一年一度的春節大促,而今年的五福無論是在交互形式上還是在玩法上都有不少創新點。而作為整個五福的主會場,交互形式的創新自然不會缺席。

業務拆解

在主會場中,4 個子場景的展示我們決定使用滾動形式,這塊決定放在 3D 來完成,3D 部分的實現我們使用了 Oasis 引擎,其餘部分在 DOM 中完成。最終整個頁面的分層為:
image.png

  • DOM UI 1:顯示在 3D 內容底下的或者和 3D 內容不相交的內容
  • Canvas:3D 內容顯示區域 (給我們一個 canvas,還你一個 3D 世界)
  • DOM UI 2:會蓋在 3D 內容上面的內容

3D 實現

整體設計

明確 3D 需要做什麼之後,下一步就開始構思整個 3D 場景該怎麼搭建了。需求本質是多個場景放在一起,通過上下滑動屏幕可以讓所有場景整體一起滾動。很自然的會想到,所有場景放在一個父節點上,然後通過控制父節點即可達到整體控制的效果。

至於上下滑動屏幕的滾動效果,我們先假設不考慮滾動,我們先來想想在一個平面內,上下滑動來控制場景上下移動,這個很簡單了,大致如下:
image.png
在上圖中,我們只需要通過滑動距離來控制父節點的上下移動距離即可完成場景整體的移動,接下來就是進一步思考該怎麼滾動呢,滾動很容易就聯想到圓,我們把圓心放在 Canvas 中心,然後圓心往屏幕後面推一定距離 (圓的半徑 R),然後每個場景是半徑為 R 的圓環中的一段弧 (所有場景弧度一致),這樣所有場景連續無縫拼接起來就是一個大弧。然後通過滑動距離來控制圓環在 X 軸的旋轉角度即可。我們以右手座標系為參考,並從右側面觀察整個場景為例,示例圖如下:
image.png

場景搭建

整體設計思路理清楚後,接下來就是場景的搭建,Oasis 主打的就是 Low Code 模式,所以我們直接在場景編輯器中編輯我們的場景,所見即所得 (編輯器文檔,https://oasis-engine.github.io/#/editor/zh-cn/README )。

下面是我們使用 Oasis 3D 編輯器來搭建整個互動場景,WF~主會場首頁。
image.png
我們再來一張從右側面觀看場景的截圖,這樣就會更清晰了:
image.png

邏輯開發

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 自帶的粒子系統。粒子飄動的效果製作起來也比較簡單,直接在編輯器中添加一個粒子組件,設置一些隨機位置、初速度、加速度、數量、大小、粒子貼圖:

image.png

image.png

image.png

業務聯動

完成 3D 場景滾動後,還需要和業務層進行聯動,3D 和 UI 層的通信我們採用事件機制,結構如下:
image.png
從上圖結構可以看出,我們提供了一個 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掃福 為例,來看看最終交付模型是什麼樣的:
image.png
上面的是正面看模型的效果,我們來看看各個角度觀看的效果:
image.png
2、CPU 裁剪

上面我們從美術資產的角度對三角面進行了優化,我們單獨跑 3D 工程,可以看下現在上傳到 GPU 的三角面數量 (54474),如下:
image.png
我們單個場景的三角面數量在 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 個子場景,所以可見判斷簡單通過一個範圍來判斷。而引擎最新的版本是通過視錐剔除算法來判斷,詳見透視投影和視錐剔除。下面是優化後的效果:
image.png

紋理壓縮

上傳給 GPU 的頂點數據這塊,目前來看很難有優化空間,我們前面三角面減面其實也算是變相的減少了頂點數量,不過這塊內存佔比本身也不高,大頭還是在紋理 (之前的認知)。所以首先就是對紋理進行優化,我們每個子場景都是一個單獨模型,然後各自引用了一張單獨的 1024 * 1024 的貼圖 (內存 4 M),貼圖看下來優化空間好像也沒有,考慮到五福項目會提前預推所有的資源 (ccdn),那麼這個時候使用紋理壓縮就是非常合適的選擇了,Oasis 已經提供了完整的紋理壓縮的解決方案,只需要在編輯器中操作即可,如下:
image.png
完成上述操作後,就會自動生成多種格式的紋理壓縮後的文件,如下:
image.png
關於紋理壓縮的使用,詳見紋理壓縮的使用,優化後,通過 VisionTMPerf 工具測試內存發現,內存漲幅依然很大,而且遠遠超出預期,紋理就算不使用紋理壓縮,也就 16 M,但是漲幅確到了100+,如下:
image.png

緩衝優化

既然紋理這塊不可能這麼高,那就剩下唯一的 各種緩衝 了,這些和 Canvas 大小是有關的,以顏色緩衝來說,它必然要能夠存儲 Canvas 上所有像素點的顏色,而像素點數量的多少和 Canvas 的大小是成正比的。有了猜測,接下來就是驗證了,這裡我們直接在本地打開 Chrome 來進行測試,優化前:
image.png
通過優化後,可以明顯看到 GPU 內存從 101 M 降低到了 30.7 M。優化後:
image.png
為了方便對比,我們把上面兩張圖片的數據放在表格中看看,如下:
image.png

加載優化

為了減少頁面加載時長,提升用戶體驗,我們需要對加載時長進行優化。最開始版本所有資源都是直接在編輯器中,這會帶來一個問題,編輯器中所有資源都會存進 schema 中,這樣初始化 3D 完成的時間會比較長,體驗比較差,我們可以在本地 Chrome 中進行測試對比,優化前如下:
image.png
我們通過 Chrome 的 Network 來查看具體下載耗時如下:
image.png
通過上圖可以知道,下載比較耗費時間的就是模型的.bin 文件以及圖片資源,而對於主會場來說,用戶進來其實只會看到一個子場景,只有滑動才能看到其它子場景,並且很多時候可能壓根不會去滑動。基於這些條件,我們優化方向就是動態去加載,首先我們把模型圖片資源的 url 配置在文件中,然後在代碼中動態加載,如下:

// 初始化 tab 數據
public initTab(e) {
  // TODO ...
  
  // 動態下載資源
  this._download(e);
}

然後我們在代碼中去動態加載需要的資源即可,現在我們用同樣的流程來測試初始化時間,如下:
image.png
最終在手機上的效果是完全符合預期的,通過打點統計到的在線數據加載時長在 1S 以內。

特效展示策略優化

考慮到不同手機性能、內存方面的差異,我們需要對不同機型做不同的降級操作。以往項目的降級比較簡單,通過判斷是否支持 webgl,是否有異常,是否在祖傳降級名單中,如果中了其中任何一條,直接降級,屬於一刀切的做法。五福項目中,對降級進行了進一步細化,不支持 webgl 或者有異常還是直接降級為靜態版本,否則通過將機器分為 高、中、低 等級進行不同的展示,主會場子場景是這樣細分的:
image.png

總結

五福從 2 月 1 號正式對外放量以來,收到不少同學的反饋,表示體驗很不錯,也很流暢,也有一些同學過來問是怎麼實現的。能收到這麼多正向的反饋,內心還是有點小竊喜。

從 1 號放量截止到 7 號為止,五福主會場頁面訪問量已經達到數十億,3D 佔比 90%,整體 crash 率在萬分之0.23。能有這樣的結果離不開一群靠譜的夥伴,在這裡特別感謝這些夥伴。

最後,如果您也對圖形渲染感興趣,歡迎加入我們的釘釘開源群交流:31360432,也歡迎通過郵件和我單獨交流,郵箱地址:[email protected]

查看原文可訪問 Oasis Engine 的相關文檔。


image.png
關注「Alibaba F2E」
把握阿里巴巴前端新動態

Leave a Reply

Your email address will not be published. Required fields are marked *