前端如何快速上手 Web 3D 游戏的开发
簡介:?本文以「余額寶3D跑酷游戲」為例,介紹了前端如何快速上手 Web 3D 游戲的開發。
作者 | RichLab楺楺 誠空
本文以「余額寶3D跑酷游戲」為例,介紹了前端如何快速上手 Web 3D 游戲的開發。跑酷游戲是余額寶七周年的主玩法,用戶通過做任務來獲取玩游戲的機會并且解鎖游戲道具,從而在游戲中獲得更多的金幣,最終可以利用金幣兌換一些權益,同時我們也在游戲中植入了一些禮包,先看看具體效果。
?
游戲設計
我們把游戲的3D場景分成了三大模塊,分別是賽道、金幣(道具)和人物。
賽道設計
賽道包含了樓房和地面,由于人物需要不停地往前跑,基于相對運動的原理,我們復制了兩段樓房(如圖1),并同時做逆時針旋轉,當旋轉至 -theta 角度的時候,把樓房的旋轉角度置為0(如圖2)。地面是一個靜止的圓弧模型,通過改變紋理的 UV 值來實現地面滾動的效果。
圖1 賽道結構圖
?
圖2 樓房運動軌跡
金幣布局
由以上圖1可知,我們以 theta 角度的圓弧為一個控制單元,我們希望能控制游戲的總時長、每段圓弧旋轉的時間,以及每段圓弧擺放的金幣行數,這些參數如何控制3D場景的運作呢?根據已知字段推導出以下幾條公式(藍色字段為可配參數):
- 需要生成金幣的總行數 = (游戲總時長?/圓弧旋轉theta角度的時間?)x?每段圓弧擺放的金幣行數
- 每兩行金幣之間的時間間隔 =?游戲總時長?/ 需要生成金幣的總行數
- 每行金幣出現的時間 = 每兩行金幣之間的時間間隔 x?金幣索引
這里主要得出?游戲總時長?和?每行金幣出現時間?之間的關系,而每行金幣該如何擺放以及道具出現的時機由具體的業務邏輯控制,這里不展開來講。最終我們得到了一個控制金幣擺放的隊列:
[{"index": 0, // 索引,代表每一行"item": {"position": "center", // 擺放位置"type": "coin" // 應該擺放的模型類型},"time": 0 // 每行金幣出現的時間},{"index": 1,"item": {"position": "left","type": "coin"},"time": 0.25},// more...... ]這個隊列如何與我們的3D場景關聯呢?
由以上圖2可知,一共有兩段圓弧在交替旋轉,假設每段圓弧擺放的金幣行數定義為 rowsPerPart,當前圓弧的索引定義為 index,那么每次旋轉至0度的時候,取?[index * rowsPerPart, (index + 1), rowsPerPart]?區間的數據進行擺放。數組中 position 表示擺放位置,一共有左中右三條道,也可能三條道都擺放,根據配置創建金幣節點,并設置好節點的 position。type 表示應該擺放的模型類型,除了金幣還可能是道具、禮包、終點線等。
開發流程
設計好游戲思路之后,可以正式開始制作我們的游戲啦~ 😊
跑酷游戲是通過 Oasis Editor 開發的,這是一個 web 3D 內容在線開發平臺,底層用的是 Oasis 3D(螞蟻自研的3D引擎)。這時候你可能會問,為什么要用 Oasis Editor 開發呢?👇
接下來分為「場景搭建」、「邏輯開發」、「業務聯動」來講解整個3D工作流。
場景搭建
上傳資產
在編排場景之前我們需要先上傳好游戲資產,一般美術提供的模型文件格式為 fbx 或 gltf,紋理推薦使用 webp 格式,我們在資源區右側點擊上傳。
在開發過程中,美術可能經常需要替換紋理,所以建議美術將紋理與模型解綁,通過手動上傳的形式將紋理綁定到模型上,避免同時加載兩個紋理。
如圖,我們已經在資源區上傳好樓房、道具、金幣等模型和相應紋理。
場景編排
有了資產之后我們需要綁定到節點上,然后進行場景編排,如下視頻以樓房和地面為例進行演示:
?
按照同樣的方法我們完成了整個場景的編排,某些節點需要通過腳本控制展示,可以點擊場景樹左邊的小眼睛進行隱藏,場景效果如下:
粒子系統
游戲開發的時候,經常會用到粒子系統來幫助我們實現一些比較酷炫的效果,在我們這個項目中,在人物節點(person)下面有2個子節點,分別來負責吃到金幣(coinParticle)和道具(toolParticle)時的粒子效果,游戲過程中效果如下:
當我們點擊選中一個粒子節點的時候,編輯器右側會出來對應的屬性面板,屬性面板中就能夠看到我們的粒子組件以及相關參數,通過設置參數可以調整我們的粒子效果:
接下來一步就是來設置參數來控制我們的粒子效果了,下面給大家介紹下幾個常用參數:
邏輯開發
以上場景可由前端協助美術同學進行搭建,接下來這一步就正式進入編程階段了。
腳本能力
1、cli
Oasis Cli 是連接業務和 Oasis 3D 編輯器的橋梁,在使用我們引擎的時候,建議提前安裝好 Cli 的環境:
安裝好 Cli 之后,我們就可以將場景導出到我們的本地項目,并且隨時將最新的場景編排同步至本地。首先,我們進入跑酷項目根目錄,并執行如下命令,將我們已經建好的3D場景和當前項目連接:
oasis pull sceneId上面的 pull 命令中,sceneId是我們的場景id,執行完該命令后,會在根目錄下自動添加了1個目錄和1個文件,如下:
當我們需要對場景進行編輯,并且將最新修改同步至本地,我們只需要執行如下命令即可:
2、金幣轉動
這里以金幣轉動為例演示如何添加腳本控制,首先在資源面板添加一個腳本,然后在將腳本掛在節點上:
完成這一步后,我們就可以在coinAni的腳本中實現對coin節點的控制了,金幣一直旋轉我們在腳本的onUpdate 中處理即可:
碰撞檢測
利用碰撞檢測來反應人物與金幣之間的碰撞,首先需要給人物和金幣都加上碰撞體包圍盒。Oasis Editor 提供了立方體碰撞體和球型碰撞體,引擎會在每幀更新時計算本節點的 collider 與其他 collider 的相交情況,球型碰撞體之間只需要比較球心距離與兩個半徑之間的大小關系,而立方體碰撞體需要計算八個頂點的位置關系,所以使用球型碰撞體性能上會好一些。
如下圖,我們給人物添加了一個球型碰撞體,可以調節它的球心和半徑。可視化包圍盒只是編輯器運行時的插件,因此不會出現在我們的 H5 場景中。
編輯完碰撞體包圍盒之后,我們需要在腳本中進行碰撞檢測,監聽 collision 事件:
Shader
😝 嘿嘿,看到 Shader 別急著劃走,掌握了 Shader 你就可以:
- 自定義光照、物理等模型,可以開發更多酷炫的效果
- 能夠優化渲染性能
- 能夠幫助我們排查渲染上的問題
列舉幾個 Shader 的效果,更多效果可以前往shadertoy:
1、 什么是 Shader
Shader(著色器)是運行在 GPU 上的小程序,這些小程序為圖形渲染管線的某個特定部分而運行,它用于告訴圖形硬件如何計算和輸出圖像。為了更深入了解 Shader 的原理,我們需要了解 OpenGL 的渲染流水線,這里以渲染跑酷游戲的地面模型為例:
CPU 應用階段
我們在3.1.1中上傳了地面的 fbx 模型文件,其中包含了頂點位置、UV、法線、切線等信息,CPU 將這些信息加載到顯存中,然后設置渲染狀態,告訴 GPU 如何進行渲染工作。最后 CPU 會發出渲染命令(Drawcall),由GPU 接收并進行渲染。
GPU 渲染管線
GPU 渲染管線包含了幾何階段和光柵化階段,頂點著色器(Vertex Shader)和片元著色器(Fragment Shader)分別位于這兩個階段中。
幾何階段:頂點著色器接收 CPU 傳過來的頂點數據,通常在這個階段做一些空間變換、頂點著色等操作。接著會經過裁剪,把不在相機視野中的頂點裁剪掉,并剔除某些圖元,然后將物體坐標系轉換到屏幕坐標系。
光柵化階段:兩個頂點之間有很多個像素,片元著色器會對像素進行處理,除了進行紋理采樣,還會將像素與燈光進行計算,產生反射、折射等效果。同一個屏幕像素點可能會有多個物體,這時候需要通過 alpha 測試、深度測試、模板測試、混合(blend)等處理,把同一位置的像素進行過濾或合并,最終渲染到屏幕上。
2、如何編寫Shader
Oasis Editor 中寫 Shader 需要經過這幾個步驟:
(1)、在資源區中添加“Shader 材質”,然后綁定到模型上
(2)、編輯 Shader 材質,屬性面板中提供了常見的渲染狀態配置,也可以直接編輯著色器定義(ShaderDefine)。
整個 ShaderDefine 結構如下,其中 vertexShader 和 fragmentShader 分別存放頂點著色器和片元著色器代碼,采用 GLSL ( OpenGL 著色語言,OpenGL Shading Language )編寫。states 用來定義渲染狀態控制對象,對應上文提到的合并階段。
(3)、如果要動態改變材質參數值,需要創建腳本,在節點每幀執行的回調函數中修改屬性值。
下面通過跑道滾動和光波兩個示例來講解。
3、 跑道滾動
如2.1中所述,跑道是一個靜止的圓弧模型,通過改變紋理的UV值來實現跑道滾動的效果。為了實現給人物打光的效果,我們在基礎顏色紋理上面疊加了一張漸變紋理,并給人物加上了一個靜態的陰影(實際上是一個面片)。
( 基礎顏色紋理)
(漸變紋理)
=
( 疊加效果)
相關的Shader代碼如下:
export const ShaderMaterial = {// Vertex Shader 代碼vertexShader: `uniform mat4 matModelViewProjection;uniform float utime;attribute vec3 a_position;attribute vec2 a_uv;varying vec2 v_uv;varying vec2 v_uv_run;void main() {gl_Position = matModelViewProjection * vec4(a_position, 1.0 );v_uv = a_uv;v_uv_run = vec2( v_uv.s, v_uv.t + utime );}`,// Fragment Shader 代碼fragmentShader: `varying vec2 v_uv;varying vec2 v_uv_run;uniform sampler2D texturePrimary;uniform sampler2D textureLight;void main() { vec4 texSample = texture2D( texturePrimary, v_uv_run ).rgba;vec4 texLightSample = texture2D( textureLight, v_uv ).rgba;gl_FragColor = vec4(texSample.rgb * texSample.a + texLightSample.rgb * texLightSample.a, texSample.a);}`,states: {}, }Vertex Shader 和 Fragment Shader 都包含了一個 mian 入口函數。
初次看 Shader 代碼會發現很多陌生的符號,其中 uniform、attribute 和 varying 都是變量限定符,attribute 只能存在于 Vertex Shader 中,一般用來放置程序傳過來的頂點、法線、顏色等數據;uniform 是程序傳入到 Shader 中的全局數據;varying 主要負責在Vertex Shader 和 Fragment Shader 之間傳遞變量。
mat4、vec3、sampler2D 都是基本變量類型,分別代表矩陣、向量和紋理,后面的數字代表n維,例如 mat4表示 4x4 矩陣。
本例的 Vertex Shader 中,頂點位置 a_position 與 matModelViewProjection 矩陣相乘,其實是把三維世界的物體投影到二維的屏幕上。a_uv 存放了 UV 信息,我們想要把一張貼圖貼到模型表面,需要紋理映射坐標,即UV坐標,分別代表橫縱兩個方向。為了使地面能滾動起來,我們需要每幀改變 UV 的縱坐標,并通過變量 v_uv_run 傳遞給 Fragment Shader。
在 Fragment Shader 中,texturePrimary 和 textureLight 都是從 CPU 程序傳過來的紋理。通過 texture2D 采樣基礎顏色紋理 texturePrimary,得到了紋理貼圖在模型上滾動的效果。接著拿采樣后的顏色值與透明漸變紋理 texLightSample 進行疊加,得到了近亮遠暗的效果。
最后,我們在 CPU 中每幀更新 utime 的值,并傳入 Shader。
onUpdate(deltaTime) {if (!this.running || !this._streetMaterial) return;// 賽道滾動this._time -= deltaTime * 0.0002;this._time %= 1.0;this._streetMaterial.setValue('utime', this._time); }4、光波特效
人物吃到吸吸卡之后會有一個光波特效,由于是不規則動畫,我們采取了幀動畫來實現。首先需要拿到這樣nn的幀序列。注意,瀏覽器會對紋理尺寸進行限制,可以通過 gl.MAX_TEXTURE_SIZE 拿到這個值,最好別超過20482048。
接著在 Shader 中進行紋理采樣。假設一個 100 * 100 的正方形,它的頂點著色器運行4次(因為有4個頂點),但片元著色器會運行 10000 次,所以盡量把 UV 等計算放在 Vertex Shader 中,再通過 varying 傳給 Fragment Shader。代碼如下:
CPU需要傳入幀序列紋理uDiffuseMap,還要每幀更新uFrame的值:
onUpdate(deltaTime) {// update per frameif (this.material) {this.frame++if (this.frame > 57) {this.frame = 0;}this.material && this.material.setValue('uFrame', this.frame)} }業務聯動
余額寶跑酷是一個跑在 h5 環境下的項目,其中就涉及到業務層(react)和游戲層(oasis),我們在業務層和游戲層之間加了一個膠水層(gameController)來進行兩者通信,結構如下:
從上面結構圖可以看出,作為膠水層的gameController,主要做了2件事情,一個是給業務層提供api調用,并且通知游戲層,另外一個是監聽游戲層的消息,并且通知業務層,下面來看看示例:
性能優化
調試工具
工欲善其事必先利其器,當我們需要對項目進行性能優化的時候,我們首先需要分析性能瓶頸點,然后對癥下藥,很幸運的是chrome本身就自帶性能分析工具(Performance:打開頁面進入開發者工具即可看到),如下:
除了性能調試工具外,有時候我們還會遇到一些渲染異常,大多是給到GPU的數據有問題,而這部分數據我們沒法console.log,chrome提供了一個非常好用的插件(Spector.js)幫助我們查看每一幀的數據,如下:
降低三角面
三角面越多,gpu的計算量也會越大,結合游戲實際的玩法,我們對三角面這塊的優化主要就是不同模型進行減面,最終三角面從20萬+降低到6萬+,具體如下:
1、人物這塊,因為在跑動過程中,我們始終只能看到背面,所以把人物前面的三角面全部去掉
2、金幣這塊,在保證視覺效果看起來比較圓的前提下盡可能的減少三角面
3、樓房和人物類似,把賽道外部的游戲過程中根本看不到的面去除
提升幀率
提升幀率本質上就是減少cpu的運算時間,通過前面提到的分析工具分析,我們發現節點數量過多是導致cpu運算量大的主要原因,所以我們的優化重點是在降低節點數量上,最終我們的 fps 在低端機上面從10優化到25,下面來具體說下:
1、金幣模型里面有很多沒有用的空節點,這個我們找美術同學幫忙重新簡化模型文件
2、金幣模型簡化后,其實模型里面還有2個節點(其中有一個rootnode其實沒啥用,和美術同學交流,反饋是目前沒有辦法去掉),加上掛載模型的節點,我們一個金幣對象其實就有3個節點,為了進一步優化,我們通過代碼動態去掉多余節點并進行節點合并。
3、使用對象池來避免反復創建金幣。在主循環中,對一些循環出現的元素,我們一種優化手段就是在初始化的時候事先創建一定數量的對象,然后用的時候來取,用完就還回來,而緩存創建好的對象的結構就是我們的對象池了。對象池帶來的好處:減少主循環過程中創建對象帶來的開銷、可以有效避免因創建釋放等操作帶來的GC。我們游戲中金幣數量很多,并且是高頻出現的,所以要用對象池來緩存,相應的設計如下:
class CoinPool {private _originNode = null;private _pool = [];constructor () {}init (originNode: o3.Node, capacity: number = 5) {this._originNode = originNode;this._genNode(capacity);}destroy () {this._originNode = null;this._pool.length = 0;}getNode () {if (this._pool.length === 0) {this._genNode();}return this._pool.shift();}putNode (node: o3.Node) {if (this._pool.indexOf(node) === -1) {this._pool.push(node);}}_genNode (num: number = 1) {const pool = this._pool;for (let i = 0; i < num; ++i) {let node = this._originNode.clone();// 對金幣模型節點的優化在這里統一處理changeParent(node);purifyNode(node);pool.push(node);}} }對象池使用方式:
// 創建并初始化 const originCoin = node.findChildByName('coinParent'); // 掛載金幣模型的節點 const coinPool = new CoinPool(); coinPool.init(originCoin, 24);// 從池子里面獲取金幣節點 const coinNode = coinPool.getNode();// 金幣節點不需要使用了,進行回收 coinPool.putNode(coinNode);// 整個節點池銷毀 coinPool.destroy();其他
上述兩項其實都是針對跑酷項目本身做的一些特定優化,其他項目未必能夠完全照搬,我們的塵沫大神針對業務方面的性能優化做了比較通用全面的總結,這里簡單列舉一下:
語言
- 使用枚舉:在標記判斷if或switch語句中盡量使用number型枚舉,避免使用字符串作為判斷標記,字符串作為判斷標記性能損耗較大
- 使用Number做Object的Key:Object作為Map使用時盡量不要使用string作為Key,而是傾向使用Number作為Key,其中Number的范圍越小性能越高,通常小于65535性能較優
- 使用“.”訪問對象屬性:避免使用["string"]訪問對象的屬性和方法,會導致JIT優化失效,應使用“.”訪問屬性
- 盡量使用for循環遍歷:幀級調用盡量使用for循環進行遍歷操作提升性能,相對于語法糖循環更純粹,需要提前緩存長度n進行循環判斷,減少紋理尋址性能損耗
邏輯
- 多用對象池機制:由于JS本身機制和原理,需要避免在幀循環中new對象,避免GC卡頓,在業務開發中的模型抽象強烈建議使用對象池機制做對象管理
- 善用實例或靜態全局變量:除了對象池機制避免GC外,還需要利用實例或靜態全局變量減少GC損耗,比如一些用于中轉數學計算的臨時變量可使用靜態全局變量緩存,另外一些可逐實例的類變量可緩存為實例全局變量,減少使用時的頻繁new操作帶來的開銷和GC。
- 慎用事件:在大型項目中慎用事件,事件本身的靈活性是一把雙刃劍,在解耦的同時也帶來了邏輯可讀性低等困難,尤其在多人協作開發的項目中,所以在業務系統中該解耦的模塊用事件,不需要的地方需要用明確的設計調用邏輯解決,切記不要因為設計的懶惰把項目搞亂
資源優化
- 模型合并優化:美術需將不可獨立移動的模型盡可能合并減少渲染批次,同時注意不要合并場景范圍跨度過大的模型導致模型無法裁剪的問題
-
材質優化:
- 盡可能合并材質,材質作為三維引擎的合并根基,一切引擎級渲染批次的合并前提都是使用相同材質,所以要保持材質對象盡可能的少
- 材質模型選擇需要根據美術風格盡量精簡,比如直接把光照合并在漫反射貼圖的的卡通風格模型可以直接選擇unlit材質,而無需使用復雜的PBR材質模型
- 貼圖優化:貼圖尺寸不可能盲目追求質量使用超大尺寸,需要評估實際項目貼圖光柵化后的實際顯示像素來使用接近的貼圖尺寸,否則使用過大尺寸不僅得不到效果手機還浪費顯存。除此之外還可使用紋理壓縮優化顯存
-
像素填充率優化:
- 盡量減少全屏渲染的繪制,比如UI或遮罩使用類似全屏但大部分透明的圖片繪制會帶來大幅的GPU渲染負擔
- 在移動端等高DPI的設備中可適當降低DPI配置,減少GPU負擔
玩法系統優化
-
碰撞系統優化:
- 善用主動碰撞和被動碰撞概念,減少主動碰撞器可以大幅減少碰撞檢測的循環遍歷次數
- 善用碰撞組概念,將物體劃分所屬碰撞組和可與之發生碰撞的組作為過濾器,根據業務規則劃分可以減少不必要的碰撞檢測循環
- 跑酷彎道優化:可嘗試利用頂點著色器模擬彎道跑酷效果,減少CPU端相關跑酷彎道邏輯的計算負擔,降低美術制作復雜度
Oasis 3D V2.x To V3.x
隨著 Oasis 3D 服務的業務數量越來越多、業務負責度越來越大,也暴露出不少問題,為此,我們對現有引擎進行了大重構,也就是V3.x版本,此版本主要目標是:更快、更方便、更高效。
這里先簡單介紹幾個重構模塊,希望讓大家有個初步體感。
資源管理模塊
資源管理模塊我們從底層實現進行了大重構,主要目的是簡化開發者的使用,下面是v2.x版本和v3.x版本加載一個帶有骨骼動畫的模型示例,對比可以看出v3.x版本的api是特別精簡的,除了api的簡化外,功能上我們還提供了下載重試、重試間隔、下載超時、下載進度、取消下載等。
V2.x版本加載資源:
let gltfRes = new Resource("skin_gltf", {type: "gltf",url: "xxx.gltf" }); let resourceLoader = new ResourceLoader(engine);resourceLoader.load(gltfRes, (err, gltf) => {if (err) return;const fairyPrefab = gltf.asset.rootScene.nodes[1];const fairy1 = fairyPrefab;rootNode.addChild(fairy1);const animator = fairy1.addComponent(Animation);const animations = gltf.asset.animations;animations.forEach((clip) => {animator.addAnimationClip(clip, clip.name);});animator.playAnimationClip("Take 001"); });V3.x版本加載資源:
const { defaultSceneRoot, animations } = await engine.resourceManager.load("xxx.gltf"); rootEntity.addChild(defaultSceneRoot); const animator = root.getComponent(Animation); animator.playAnimationClip("Take 001");數學庫
數學庫整個進行重構,主要有2方面改善:寫法更簡捷、性能更優。老的數學庫都是函數式的,并且向量、四元數等低層其實都是Array,而V3.x采用Class的方式來實現,底層數據結構改為object。
新的數學庫不僅支持更為豐富的寫法,性能上面,通過數學庫重構以及使用數據庫相關的優化,性能提升比較明細,下面是我們的測試結果:
在線 coding
目前我們編輯器實現了在線coding,意味著你只需要一臺電腦,并且安裝一個瀏覽器,即可完成3D項目的創建、開發、發布等,界面如下:
在上面的界面中,即可完成在線coding,然后保存,即可實時查看最新的效果。進一步的,我們還提供了事件面板,模擬和業務層的交互,這樣我們就可以在3D項目中自測完整個流程,然后發布給業務層使用,如下:
當我們開發完項目后,需要交付給業務方使用,在V3.x中,我們只需要點擊發布至對應平臺即可(這塊還在持續優化中),如下:
?
?
原文鏈接
本文為阿里云原創內容,未經允許不得轉載。
總結
以上是生活随笔為你收集整理的前端如何快速上手 Web 3D 游戏的开发的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: SpringCloud应用在Kubern
- 下一篇: 使用Git后10件你可能需要“反悔”的事