Categories
程式開發

億級前端項目中的3D技術:支付寶2020年新春活動的背後


新春紅包項目,作為每年用戶基數最大的支付寶活動之一,對整個項目組的技術都是一個很大的考驗。而作為前端,我們的技術考驗就是如何在保證穩定性的同時,為用戶不斷帶來更好的創新體驗。

今年的新春紅包項目相比以前,多了不少互動圖形方面技術的運用,尤其是第一次對 3D(WebGL)技術的引進。對於新春這個億萬量級的活動而言,這無疑是個巨大的挑戰。但作為合格的工程師,效果和穩定性的平衡是我們的一貫的追求,經過了前期的積累,我們使用自研的Web3D 遊戲引擎以及特效編輯器,學習了許多在整個橫向前端領域、做的相對最好的遊戲領域的經驗,最終達到了比較複雜3D 場景下極低的異常率。

我們的成果

我們在此次新春活動的兩個場景中都達到了極好的效果和穩定性的平衡:

  • 首頁 3D 展示:5 個複雜模型的內存總開銷為峰值 30M,穩定 20M,對整體穩定性無影響。
  • 福滿全球:3D+UI 總內存開銷峰值 70M,穩定 40M,加 Webview 總開銷 100M。

億級前端項目中的3D技術:支付寶2020年新春活動的背後 1

為了最好的效果

技術的使命只有一個——為用戶帶去最好的體驗。所以在項目最初肯定是先按照最高視覺效果來,針對新春中的兩個使用到​​了 3D 的場景,我們首先進行了嘗試,然後發現了問題。

並非最佳體驗

在這種情況下,我發現雖然視覺效果達到了最優,但又出現了很多其他方面的問題,這使得最終的用戶體驗反而並不是很好:

  • 加載時間過長。
  • 用戶體感卡頓。
  • 相對高發的崩潰(主要是 OOM)。

而這某種程度上也符合我們一開始的預測,因為在移動端 Web 這種技術方案中,有些限制是不可避免的,而這些不安定要素會在億萬用戶的量級被無限放大。在經過數據的詳細的分析,我們找到了針對這兩個場景的瓶頸共性。

瓶頸在何處

為了找到瓶頸,讓我們先來看一些數據。

首先是首頁 3D 動畫,首頁總共五個場景模型,使用的資源包括:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 2

可見統計下來,3D 部分最終可以預計的傳輸大小為 15M,峰值內存為 65M,而穩定下來最好情況也有 30M 內存開銷(這種策略下一般達不到最好,預計 40M 左右)。同時由於單場景 5W 三角形,對於中低端機的幀率也有較大挑戰。

其次是福滿全球頁面,福滿全球的模型資源開銷基本可以忽略,但卻有其他方面的問題:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 3

可見,福滿全球 3D 部分的主要開銷是在峰值 70M 的紋理,以及高清屏大量透明物體的渲染開銷。

死磕解決方案

通過瓶頸分析可知,問題主要集中在內存、傳輸體積和運行性能三個方面,而這三者又互相關聯。那麼自然得,我想到了從相對容易解決同時收益又大的方面入手。

削減模型大小

首先就是削減模型大小了,注意這個大小指的是三角形 / 頂點數量。為什麼這個如此重要呢?很簡單——模型的大小可以直接影響到以上的三個要素:

  1. 內存:模型的大小和其占據的內存是線性正相關的。
  2. 傳輸體積:和內存一致。
  3. 運行性能:單幀三角形數量越多,頂點著色器的壓力越大,尤其是在首頁 3D 模型這種具有骨骼動畫的情況下。

所以削減模型大小顯然是必須要做的,那麼我們如何去做呢?一般來講,這件事應該交由設計同學,讓他們去降低模型精度來達到一個可以接受的程度,但是結果不容樂觀,經過研究,我們最終採用了以下兩個策略:

1. 使用工具減模

首先是尋求能否使用工具自己進行減模,我們自研的Web3D 遊戲引擎使用Unity 進行場景編輯,而Unity 作為身經百戰、擴展性極強的一個遊戲引擎,有沒有這麼一個工具來幫助我們呢?答案是有的—— UnityMeshSimplify 就提供了讓我們在 Unity 中自由調整模型精度并序列化的能力。而也正是使用了它,在視覺效果損失較低的情況下,平均降低了所有場景 30% 的圖元數據大小:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 4

2. 削減不必要的數據

在工具減模以後,圖元數據大小從 12M 降到了 7.5M,但這顯然還是不夠,那麼還有什麼辦法呢?在思考後發現了一個關鍵點——處於性能考量,此次模型的光影是烘焙到紋理的,也就是說整個場景沒有光照。

這裡就需要我們了解一些細節了,即頂點數據的構成。

圖元的最基本單元是頂點,一個頂點有包含著若干信息,在繪製時這些頂點數據將會被送入頂點著色器進行一系列處理,然後進入光柵化階段。而一個頂點的信息,最常見來看,包含:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 5

而其中的法線和切線在首頁 3D 展示中並沒有作用,所以可以將其刪除,我在 UnityToolkit 中添加了 Unlit(No Normals) 選項來讓導出時可以自動剔除這兩項:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 6

而最終效果也令人滿意,圖元數據大小進一步降低到了 5MB。

成果小結

模型裁剪主要是針對首頁 3D 展示的,經過優化,我們得到了成果:

  • 單場景最大三角形數量從 5.5W 降到 2.9W;
  • 所有場景圖元和動畫數據大小從 12MB 降到 5MB。

可見,我們成功將成功將圖元數據+ 動畫大小縮減了一半,還保證了最複雜的場景的三角形數量也縮減了將近一半,使得內存開銷低了不少,同時傳輸體積小了不少,還大幅優化了渲染性能開銷。

但顯然傳輸體積還是太大了,這裡我們還進行了進一步的優化。

使用壓縮紋理

解決了模型圖元大小,接下來就是紋理的開銷了。通過上面的瓶頸分析可知,福滿全球項目的開銷主要就是在紋理方面。

何為紋理

紋理讀者也可以理解為貼圖、圖片。一般來講,我們存儲的圖片都是以 JPG、PNG 等格式存儲的,而格式決定的是什麼呢?其實是壓縮和編碼算法。實際上,無論我們把一張 JPG 或者 PNG 圖片壓縮得再小,它最終被解碼後在內存中還是以 Bitmap 的形式存在的,而且在瀏覽器中,基本都是以 RGBA 的像素格式存在的。

億級前端項目中的3D技術:支付寶2020年新春活動的背後 7

無論使用那種方式編碼存儲,最終都會被解碼為 RGBA32 的 Bitmap,一個像素 4 字節。

這就意味著無論我們將圖片的存儲體積壓縮到多麼小,其內存開銷總是固定的,比如 512×512 的圖片內存開銷就是 1M,而 1024×1024 的就是 4M。那麼有沒有辦法解決這個問題呢?當然有——遊戲業界為了解決這個問題,提出了壓縮紋理技術。

壓縮紋理

壓縮紋理是一種遊戲領域常用的紋理壓縮技術,其依賴於特定硬件實現,本質上可以以固定速率交由 GPU 即時解壓,其有如下優勢:

  • 內存:大幅節省內存開銷。
  • 解碼:免去圖片解碼開銷,直接丟給 GPU,提升啟動性能。
  • 採樣:提升紋理隨機採樣性能。
  • 可控:由於其本身就是在 JSHeap 上申請的 buffer,所以在 Web 容器下,提供了一個可以精確控制內存的方式。

億級前端項目中的3D技術:支付寶2020年新春活動的背後 8

PVRTC 的 Block 說明

經過調研和一些測試,我們最終選擇了安卓下使用ASTC 和iOS 下使用PVRTC 的策略來進行紋理壓縮,其中更為細節的配置暫且不表(都是中等精度壓縮),最終在項目中得出的成果如下:

  1. 首頁 3D 展示:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 9

  1. 福滿全球:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 10

可見壓縮紋理對於內存的開銷有著極大的優化,基本完全解決了內存問題。

條件和代價

當然,這世界上並沒有免費的午餐,我們接受了壓縮紋理的優點,就要相對得付出代價以及接受它的約束:

  1. 壓縮紋理是有損壓縮,會對圖片的質量有一定減損,這個需要視項目而定。
  2. 壓縮紋理的傳輸體積可能比 JPG/PNG 方案要高 1~4 倍。
  3. 壓縮紋理要求 POT,即長寬都是二的冪次。
  4. 對於 iOS 的 PVRTC,還要求長寬相等。
  5. 由於壓縮紋理格式在不同平台不能通用,加上降級需要三份資源,對於離線加速技術不友好。

對於某些代價,比如視覺質量損失、傳輸體積我們是可以自行調整的,不屬於原則性難題,但這個POT 對於很對前端應用可真是個原則性問題了,比如福滿全球中的地標和紅包貼圖,就不是POT 的,那麼怎麼辦呢?有辦法——使用圖集。

紋理標準化 – 圖集

圖集是一種紋理標準化的方式,在遊戲領域常常用於處理UI、2D 精靈等,簡單來講,圖集就是將許多圖片拼到一張上,不錯就是我們常說的雪碧圖(精靈圖):

億級前端項目中的3D技術:支付寶2020年新春活動的背後 11

如圖,我們將四個 500×500 的地標圖片拼到了一張 1024×1024 的圖集中,來滿足壓縮紋理的需求。那麼我們又如何去使用這個圖集呢?很簡單,我們的引擎內置了AtlasManager,可以讓你非常簡單得使用它,並且在引擎標準的開發流程中,依賴於Unity+Webpack 工作流,這個能力能夠十分方便得引入——在Unity 中直接編輯圖集,後面會說到。

圖集還有別的優勢,就是減少內存碎片,減少數據提交次數,某些情況下還可以減少資源請求。

精確掌控內存

目前我們擁有了削減模型和壓縮紋理兩種策略,大幅降低了內存開銷,並降低了一部分傳輸體積,但通過上面的論述不難發現其實我們還可以更進一步——我們很容易發現,在整個過程中,同一份數據可能在CPU 和GPU 端同時存在,尤其是移動設備CPU 和GPU 是共享內存的。所以我們一定有辦法再更進一步去解決這個問題。

這也就是我選用壓縮紋理的另一個理由——壓縮紋理本質上是JSHEAP 上的ArrayBuffer,我們可以很好得通過控制引用來幫助GC,這也就是為何上面的數據分析中能保證穩定開銷是峰值的一半。

在我們引擎的設計中,這個功能是可選的,通過紋理的 isImageCanRelease 來開啟,而如果遵守標準工作流,這一切都是自動的,無需開發者操心。

當然這也有代價,就是在 GL 上下文丟失後無法恢復,請酌情使用。

進一步減少傳輸體積

到目前為止,內存已經被控制得很好了,但是在傳輸體積上還是有更大的優化空間,在這個方面我首先考慮的就是內部的 Hilo3d 團隊提供的模型壓縮方案。

模型壓縮

我們採用的模型壓縮方案原理很簡單,針對移動端使用的模型,並不需要每個頂點數據都是32bits 的float 型,一般來講13bits 或者14bits 就夠用了,所以這裡有很大的可壓縮空間。而事實上經過測試,發現確實如此,但當然這也是有代價的,通過模型壓縮:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 12

也就是說,模型壓縮後,首頁的所有資源大小達到了安卓 5.8M、iOS5.2M。但代價是增加了解壓時間和 1.5M 的峰值內存。相對於收益,開銷是可以接受的。

然而即便如此,5M 的資源大小對於億萬 UV 的量級還是有些大,我們還有更多的辦法嗎?有,這時候就要請出我們的老朋友 GZIP 了。

GZIP

大家都熟知的 GZIP 其實在很多時候都能發揮意想不到的作用,而在我們的工作鏈路下,模型壓縮會提升 GZIP 的效果,而壓縮紋理也能獲得收益,在 GZIP 後:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 13

可見,我們讓資源體積再減半,和一開始相比縮減了六倍。

一般圖片資源

當然除了3D 相關的資源,我們也提供了方法來對普通圖片進行了壓縮,主要是將PNG 圖片編碼壓縮成了索引色,這是一種有損壓縮,也就是大家常用的TinyPNG 的策略,當然這個並沒有什麼神奇的,我們已經將這種算法作為了一個插件融入了工具鏈中,可以通過Webpack 工作流直接無縫整合,最終普遍帶來了2~4 倍的體積壓縮。

減少資源請求

到了這裡我們解決了大部分主要問題,但還有一些邊角問題會對體驗的極致構成影響。這一點就是資源請求數量,我們不難發現,對於兩個場景而言,3D 場景的資源請求數量都接近 20 個,而這個問題並非不可解。

對 3D 領域有一定了解的讀者想必是知道 glTF 這個格式的,而我們自研引擎的場景序列化也是使用了這個格式。為了應對某些場合,glTF 有它的二進制形式GLB,其可以將索引、紋理、圖元數據等等都打包到一個二進製文件中,大幅降低請求數量,在兩個新春場景中,請求數量均被降到了1 次。

而打包 GLB 的功能也被我們整合進了 Webpack 鏈路中,開發者可以零成本將其引入。

剩餘的性能問題

以上問題解決完成後,基本就可以保證項目穩定了。對於福滿全球大量透明物體和高清屏的問題,經過業務層面的調優,最終發現在可控範圍內。這個是由於業務性質決定的,否則我們當然可以採用強制最大畫布尺寸來降低開銷。

除此之外,還有一點需要注意的是我們很可能忽略的一點——運行時的 GPU 資源提交。由於引擎的設計是用到了在提交的原則(當然這很符合規範),但對於這兩個項目,保證用戶操作時不卡頓的優先級是很高的,而同時經過了上面的內存優化我們也已經保證了即使所有資源都被提交也可控,所以就需要一個策略將所有資源先提交到GPU,並預編譯所有Shader。

為了做到這一點,我們採取了一個簡單的策略:在第一幀將所有物體渲染一遍,再結束 Loading,這增加了些許的加載時間,但保證了整個過程中不會卡頓。

而對於首頁 3D 展示,為了做到極致的效果,我們設計了漸進式展示的策略。

漸進式展示

做這個策略是考慮到項目用戶量級極大,網絡情況不一,所以不可能等待Loaing 結束才展示頁面,那樣首次性能會很差,所以我們敲定方案——總是先展示靜態圖片,3D 資源加載、解析、提交GPU 成功後,才無縫切換為3D 動畫。

首頁 3D 動畫的這種策略是值得很多展示型項目參考的,這裡還需要注意的的一點是:若模型比較複雜,首幀渲染會卡住用戶操作。所以針對本項目的場景,我們採用了時間分片的策略,將五個模型拆分為五次渲染,每次間隔 200ms,留給用戶操作的時間:

loadOne = (index: number, total: number) => {
  const { state, event } = this.getGame();

  const actor = this.actors[state.typeList[index]];
  actor.visible = true;

  event.addOnce('MainRendererIsFinished', () => {
    actor.visible = false;
    if (index === total - 1) {
      event.trigger('Ready');
    } else {
      setTimeout(() => this.loadOne(index + 1, total), 200);
    }
  });
}

並且我們還保證靜態圖片和 3D 場景的姿態完全一致,從而達到視覺上無縫切換的目的。

酷炫易用 – 粒子特效

我們在大促的時候都需要炫酷的頁面來吸引用戶,但是動畫通常都是開發的噩夢,通常我們在做動畫會遇到以下三個問題:

  • 動畫粗糙,不能打動用戶;
  • 還原度不高,和設計差距較大;
  • 性能優化不足,兼容性不好。

這次的新春紅包項目大量使用了 3D 場景,在 3D 中加入了很多粒子特效,那麼這些特效是如何產出並且解決以上三個問題的?

讓動畫設計更精美

我們在首頁切換的時候增加旋轉的粒子特效,效果如下:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 14

這個是設計同學的原稿,由於 Lottie 技術的普及,設計同學做動畫大多使用 After Effect 在 AE 中製作好的 transform 動畫(僅使用 translate、scale、rotate 變化)導出可使用 Lottie 播放,大大降低開發成本。而 AE 本身是一個視頻後期軟件,裡面除了可以製作簡單的 transform 動畫,還可以開啟 3D 渲染,進行圖像跟踪,加濾鏡等等。這個粒子特效就是用 AE 裡 Particular 插件製作的,所以 AE 的上限就是設計師設計的上限。

設計師的設計工具將直接決定設計產物的質量。如果沒有 particular 插件,那麼我們的設計產物永遠都只會是 transform 動畫,很多影視級別的特效就不會出現在產品頁面中,所以提高設計工具能力將直接決定動畫產出的質量。當然還有一個值得焦慮的問題,我們的產品開發並不知知道particular 的插件是怎麼實現的,那麼很大概率是無法還原的,所以既要提高設計工具的質量,也要限制設計隨意使用設計工具導致無法實現。

新春紅包項目的粒子特效設計全部在工具裡實現:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 15

如果是手寫代碼還原設計稿的話,恐怕最要命的就是函數曲線的還原,動畫為了更加順滑會加入很多曲線來控制,比如說剛才的旋轉上升的星星,會有一個先加速再減速的過程:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 16

一個複雜的粒子系統有 60 多個屬性,如果開發通過肉眼還原數據,哪怕是複制粘貼屬性值,都可能會出問題。

最好的還原方法是不寫代碼。編輯器直接導出動畫數據,在手機上進行播放,開發完全不用關心各種參數。而產物很容易使用,直接保存項目工程,通過 webpack 進行加載,像使用圖片一樣簡單。

import myAnimation from '../assets/my-ani.vfx'; // 网页编辑项目工程文件

 const player = new Player({
    container:document.getElementById('displayObject')
  });
 player.loadSceneAsync(myAnimation).then(scene=>player.play(scene));

新春紅包項目中 3D 場景由 引擎 搭建渲染,使用的時候也是類似的方式,將編輯器工程作為資源引入,直接播放就可以了。動畫播放起來之後就是開發最關心的問題了。

保證動畫性能

其實任務首頁的粒子效果還很少,談不上性能瓶頸,而福滿全球大量使用粒子,特別是煙花作為常駐特效,需要特別進行優化。這裡我們參考了遊戲領域粒子系統的許多優化策略,將其運用到了本次的優化中。

優化一:粒子運動完全 GPU 運算

對於粒子系統來說,因為粒子數量大,使用曲線控制後運動計算複雜,如果通過CPU 計算粒子的運動,那麼網頁將不堪重負,所以粒子的運動旋轉和顏色變化計算全部放在GPU 中,通過定制shader 完成,在shader 中計算曲線是比較複雜的事情(此處省略3 千字)。

優化二:優化粒子發射器

可以看到進度條的粒子持續產生,因為粒子有生命週期,所以會有老的粒子死亡,新的粒子出生,繁衍不息。首先我們在內存中開闢一塊固定的地址,有一個按照粒子的生命週期排序的雙向列表,每一幀需要產生新粒子的時候,檢查列表最先死亡的粒子,如果此粒子已經死亡,那麼會把這個粒子的地址寫入新粒子的數據,同時將此列表元素從後插入。大概類似如下過程:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 17

這樣的列表可以保證粒子插入的速度,假設一個粒子系統有 200 個粒子,每幀其實只有 3-4 個新粒子的插入,在 CPU 中的計算量很小。

優化三:合併發射器

一個煙花是由兩個發射器組成的,構成了雙層煙花的效果,同時每個煙花增加一個拖尾效果,煙花飛過的地方就有個小尾巴。編輯效果如下:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 18

可以看到煙花以相同的模式爆炸了 6 次,但是每次爆炸的位置不一樣,通常情況下我們在編輯器會做好一次的爆炸,然後復制 6 次,時間軸類似如下:

億級前端項目中的3D技術:支付寶2020年新春活動的背後 19

但這樣的話會導致頻繁創建銷毀繪製元素,性能消耗很大,所以編輯器提供了合併粒子爆炸的選項,並且每次可以修改爆炸的位置。對於習慣了複製粘貼的設計師來說,很容易複製很多相同元素導致性能開銷過大,將六個繪製元素合併成一個元素可以大大降低開銷,同時重複利用內存。

億級前端項目中的3D技術:支付寶2020年新春活動的背後 20

優化四:減少拖尾使用

拖尾就是飛線,在粒子運動過的地方生成一個頂點,繪製的時候連成一條線,這樣就有流星劃過的感覺。但是因為粒子的計算都是在 GPU 中的,所以每幀如果要生成新的頂點,必須在 CPU 中也重新計算粒子的位置,這樣的計算量是很大的。如果煙花只是進場爆炸一次,那麼開銷可以忽略,但是有一些煙花是常駐的,隔一段時間就會播放一次,那麼對於常駐的動畫,就要避免使用拖尾。這次我們選擇了用貼圖縮放的方法來替代拖尾。

如果不用貼圖的話,看起來就是一圈延展的小菊花,這是通過通過增加長方形長度實現的,換上我們尖角的貼圖就非常像一條尾巴,最後常駐煙花沒有使用拖尾,但是視覺效果仍然很像劃過的流星,這樣保證所有的計算都在GPU 中進行,提高動畫性能。

做到極致 – 工程自動化

當然,作為引擎開發者,除了將這些技術應用到新春項目中,使得更多開發者可以簡便使用這些功能也是很重要的,所以我將這一切都封入了引擎的標準工作流中:

引擎工作流

引擎的工作流集成了以上所論述的所有優化策略,其主要包括 UnityToolkit 和 Webpack 鏈路兩部分。

UnityToolkit

UnityToolkit 是Unity 的一個插件,用於將Unity 中的各種特性導出供引擎使用,整個流程集成度很高,目前已經支持了大量特性,包括但不限於GameObject、模型、材質、紋理、動畫、光源、攝像機、天空盒、圖集、精靈、物理、音頻、環境反射、環境照明、光照貼圖的導出和導入,支持自定義擴展組件,支持腳本邏輯綁定等等。

億級前端項目中的3D技術:支付寶2020年新春活動的背後 21

Webpack 鏈路

然後就是 Webpack 鏈路了,我對 Webpack 鏈路做了深度定制,用於滿足引擎的工作流的需求。上面提到的壓縮紋理、模型壓縮、資源預處理、資源自動化發布等都被集成到了其中,包括多平台適配也是通過 Webpack 插件實現的。

這裡先大概介紹一下此次項目用到的最主要的鏈路:gltf-loader

這個 Loader 是整個鏈路中非常核心的一貫,其提供了加載 gltf 文件並進行複雜預處理的能力。使用它,我們可以做到:

  1. 模型壓縮。
  2. 紋理壓縮。
  3. 打包 GLB。
  4. 資源預處理:對 gltf 文件引用的資源進行預處理,通過定制 Processor 接口你可以實現任何你想要的任何預處理。
  5. 資源發佈器:自定義發佈器,在 gltf 文件引用的資源(包括自身)被產出時,攔截並進行自動化處理。

而在這兩個項目中,這幾個功能都被用到了,也為最終項目的穩定可靠提供了重要的保障。