專案概覽及開發設計
這次嚐鮮的業務夥伴是食品部門,最終落地專案是“探味奇遇記”:使用者使用左邊“joystick”操作 IP 人物,前往自己感興趣的美食館、調整當前視角,以 3D 的形式虛擬線下場館購物體驗。食品的數字人形象的第一視角在“元宇宙”虛擬美食館中的沉浸式體驗片段如下:
想要體驗的同學可用 APP 掃碼直達(開啟 APP 首頁,訪問“美食館”,點選右下角浮層也可體驗):
“探味奇遇記“的 2 個業務指標分別是:頁面停留時長、頁面復訪率。因此在和設計討論完方案後,功能上以任務和商品兩個維度展開,加上任務反饋的獎品列表以及新手教程,整體專案開發拆分為如下:
3D 沉浸式體驗最終目的是保障業務目標的達成,原本 2D 場域中的購物流程仍然必須考慮進來。因此在前端架構上設計了過渡方案,將渲染分為了 2 部分。一部分是 3D 渲染的處理,一部分是普通的 DOM 節點渲染。其中 3D 渲染採用的技術庫是 Babylon ,實際的前端設計如下圖所示:
渲染實現
- 3D 場景渲染
場景包括街道氛圍、各個特色美食場館、IP 人物在 HTML 中的渲染,在 早期Demo中 對於 3D 基礎渲染已經實現,在這次專案中主要優化了以下兩點:
- 強制介面橫屏翻轉
相較於豎屏 90 度以下的視野範圍,橫屏更符合人類生理上的視覺範圍( 114 度夾角左右)。前期技術方案中前端採用了自適應手機橫豎屏展示。由於 APP 中不支援橫屏,需要人為將豎屏內容整體翻轉為橫屏展示,調整後不論橫屏或豎屏,其介面呈現如下:
涉及的主要程式碼如下:
// 全域性容器在豎屏情況下,寬為螢幕高度,高為螢幕寬度,旋轉90deg
.wrapper{
position: fixed;
width: 100vh;
height: 100vw;
top: 0;
left: 0;
transform-origin: left top;
transform: rotate(90deg) translateY(-100%);
transform-style: preserve-3d;
}
// 橫屏情況不做旋轉
@media only screen and (orientation: landscape) {
.wrapper{
width: 100vw;
height: 100vh;
transform: none;
}
}
- 封裝資源管理中心
3D 頁面需要渲染的檔案比較多,且質量不小,因此對所需資源封裝了資源管理中心來集中處理載入。包括:3D 模型、貼圖圖片、紋理等型別,以及相同資原始檔的去重處理。
以模型載入為例涉及的主要程式碼如下:
async appendMeshAsync (tasks: Tasks, withLoading = true) {
// loading處理
...
const promiseList: Promise<MeshAssetTask>[] = []
// 過濾同名模型
const unqiTasks = _.uniqWith(tasks, (a, b) => a.name === b.name)
for (const item of unqiTasks) {
const { name, rootUrl, fileName, modelRoot = '' } = item
// 避免重複載入
if (this.modelAssets.has(name)) {
console.log(`${name}模型已載入過`)
continue
}
const promise = new Promise<MeshAssetTask>((res, reject) => {
const task = this.assertManager.addMeshTask(
`${Tools.RandomId}_task`,
modelRoot,
rootUrl,
fileName)
task.onSuccess = result => {
this.savemodelAssets(name, result)
res(result)
}
task.onError = () => {
reject(null)
}
})
promiseList.push(promise)
}
// load
this.assertManager.loadAsync()
const ret = await Promise.all(promiseList)
return ret
}
資源管理中心本質仍然是使用 Babylon 的 addMeshTask 、addTextureTask 等來載入模型、紋理,在專案應用過程中,發現在 APP 中批量載入多個模型時,對記憶體會造成不小的壓力,因此實際模型的載入過程,採用的是單個載入模式(需要根據當前環境決策)。
- DOM 元件
DOM 元件主要覆蓋日常電商活動中業務相關流程介面,例如下圖中的商品列表展示、商品詳情內容、獎品發放彈窗等:
- 商品列表頁
列表頁實現拉取運營配置商品組素材,展示商品名、商品圖片、價格、促銷資訊等等。
其問題表現為:在 App 僅支援豎屏的前提下,Demo 的橫屏方案是樣式層面的 90 度旋轉,需要考慮橫屏翻轉對觸控操作的影響。橫屏模式下的橫滑操作,Webview 會識別為豎屏下的豎滑操作,造成滑動方向與預期不符。
解決方案:捨棄日常使用的 CSS 方案( overflow: scroll ),重寫橫滑元件。
具體實現:首先將展示區域外的部分隱藏;根據設計的滑動方向,獲取使用者在與該方向垂直的方向上的觸控距離,例如這次專案中商品列表為橫向滑動,則需要獲取使用者在 Y 軸的觸控距離,再根據這個觸控距離決定商品列表的橫向偏移以及虛擬滾動條的滑動距離,以此糾正因旋轉畫面造成的滑動方向不對的問題。
- 商詳頁
在列表頁操作後展示更多的商品細節資訊,以及覆蓋“加購”這一業務鏈路,是當前活動中關聯訂單成交的重要一環。這裡複用普通會場中的商品元件即可。
- 3D 模型展示彈窗
專案接入了京豆、優惠券等獎品的發放,同時為了達成復訪率指標加入了累積簽到的獎勵。因此也涉及大量的彈窗提示,普通彈窗複用會場中的 Toast 元件來實現,開發量不大。
其中較為特殊的是收集物展示介面,和商品列表與商詳關係類似,點選收集物後出現對應物品的 3D 模型。由於層級關係,收集物的 3D 模型不能在展示場景的畫板渲染,所以另起一個 WebGL 畫板作渲染,為減少渲染量,在顯示收集物模型畫板時,暫停場景畫板的渲染。其效果和關鍵程式碼如下所示:
componentDidUpdate (prevProps: TRootStore) {
const prevIsStampShow = prevProps.stampDetailModal.isShow
const isStampShow = this.props.stampDetailModal.isShow
if (prevIsStampShow !== isStampShow) {
this.engine.stopRenderLoop()
if (isStampShow) {
this.engineRunningStatus = false
} else if (!this.engineRunningStatus) {
this.engine.runRenderLoop(() => {
this.mainScene.render()
})
}
}
}
- 混合模式
3D 渲染以及 DOM 元件的遷移複用,覆蓋了大部分渲染的場景,但仍然存在部分例外:
左邊是在上一節出現過的商品列表頁( DOM 元件),右邊則是當前這類商品的 3D 模型。所以在渲染除了獨立的3D模型渲染、普通的 DOM 渲染外,還需要考慮混合在一起的情況,以及互相之間的通訊聯動。
Babylon 是一個優秀的渲染框架,除了 3D 模型的渲染,完成模型互動,也可以在 3D 場景中混合地渲染 2D 介面。但是對比原生的 DOM 渲染,使用 Babylon 呈現 2D 畫面的開發成本和成品效果都莫可企及。因此,在需要渲染 2D 的場景,我們都儘可能採用 DOM 的渲染方式。
在這樣的頁面結構下,3D 模型渲染邏輯由 Babylon 框架處理,普通的 DOM 渲染邏輯由 React 處理,兩者之間的狀態和行為由一個事件管理中心來處理。兩者之間的互動就可以像兩個元件之間一樣。
具體如品類商品列表頁,使用者觸發商品列表頁展示之後,Babylon 對當前使用者的場景幀進行截圖儲存,並對其進行暗化,模糊的處理之後設定成整個畫布的背景,然後相機只渲染右側模型部分,當右側模型觸發一些互動之後,由於事件管理通知左側的 DOM 層去請求相應的商品資訊並切換展示。
設定頁面背景:
Tools.CreateScreenshot(this.scene.getEngine(), activeCamera, { width, height }, data => {
...
setProductBg(data)
...
})
private setProductBg (pic: string) {
const productUI = this.UIList.get('productUI')
if (productUI && productUI.layer) {
const image = new Image('bg', pic)
image.width = '100%'
image.height = '100%'
productUI.addControl(image)
this.mask = new Rectangle('mask')
this.mask.width = '100%'
this.mask.height = '100%'
this.mask.thickness = 0
this.mask.background = 'rgba(0, 0, 0, 0.5)'
productUI.addControl(this.mask)
productUI.layer.layerMask = detailLaymask
}
}
Babylon 通過事件同步 DOM 層:
private showSlider = (slider: Slider):void => {
slider.saveActive()
...
const index = slider.getCurrentIndex()
const dataItem = this.currentDataList[index]
const groupId = dataItem?.comments?.[0] || ''
this.eventCenter.trigger(EVENT_TYPES.SHOWDETSILS, {
name: dataItem.name,
groupId,
index
})
if (actCamera && slider) {
(slider as Slider).expand()
slider.layerMask = detailLaymask
}
...
}
最終效果如下圖所示:
相機處理之空氣牆
IP 主角在美食街中的探索是需要尋路的,整個街景中哪些道路可以路過,哪些無法通行,由視覺同學在設計時各個建築的座標和空隙決定。但我們必須要處理當主角行走至比較狹窄的道路中視角的切換和位移可能導致的穿模、不應該出現的視角等情況。
人物和建築之間的穿模可以通過設定空氣牆(包括地面)和人物模型的碰撞屬性,使兩種模型不會發生穿插來解決。對應的主要程式碼如下:
// 設定場景全域性的碰撞屬性
this.scene.collisionsEnabled = true
...
// 遍歷模型中的空氣牆節點,設定為檢查碰撞
if (mesh.id.toLowerCase().indexOf('wall') === 0) {
mesh.visibility = 0
mesh.checkCollisions = true
obstacle.push(mesh as Mesh)
}
鏡頭避免進入到建築、地面下也可使用設定碰撞屬性來實現,但在障礙物位置在鏡頭和人物之間時,像拐角這種情況,會導致鏡頭被卡住,人物繼續走的現象。
所以思路是求人物位置往鏡頭方向的射線,與空氣牆相交的最近點,然後移動鏡頭到結果點的位置,實現鏡頭與障礙物不穿插。
實際開發中,在鏡頭(下圖中圓球示意)和人物中間插入 3 個六面體,當六面體與空氣牆發生穿插,則鏡頭位置移動到發生穿插的離人物最近的六面體靠近人物一端的端點,最近會移到人物頭上的點。結合專案實際畫面示例如下:
預設鏡頭處於預設鏡頭半徑的位置如下圖所示:
當其中一個六面體與空氣牆交錯時,移動鏡頭到碰撞六面體的端點時位置如圖所示:
在鏡頭移動過程中,加上位置過渡動畫,實現無級縮放的效果
模型之間精確判斷穿插參考的是 Babylonjs討論區裡的一個帖子,Babylon 內建的 intersecMesh 方法使用的是 AABB 盒子判斷。
地圖功能
“探味奇遇記”中設計的街景範圍比預期的稍微大一些,同時增加了“尋寶箱”的遊戲趣味性,對於第一次參加活動的使用者來說,對當前所在位置是沒有預期的。針對這一情況,設計增加了地圖功能,在左上角顯示入口來檢視當前所在位置,具體示意如下:
地圖功能的實現依賴於視覺設計,以及當前位置的計算。涉及的主要環節如下:
- 在展示地圖彈窗的時候,事件分發器觸發一個事件,使場景把人物當前位置和方向寫到地圖彈窗 store,把人物標識展示到地圖上對應位置;
處理地圖與場景的位置對應關係。先找到地圖對應場景 0 點位置,再根據場景尺寸與圖片大小計算大致縮放值,最後微調得到準確的對應縮放值。
export function toggleShow (isShow?: boolean) { // 開啟地圖彈窗時觸發獲取人物位置資訊事件 eventCenter.getInstance().trigger(EVENT_TYPES.GET_CHARACTER_POSITION) store.dispatch({ type: EActionTypes.TOGGLE_SHOW, payload: { isShow } }) } // 場景捕捉時間把人物位置和方向寫入store this.appEventCenter.on(EVENT_TYPES.GET_CHARACTER_POSITION, () => { const pos = this.character?.position ?? { x: 0, z: 0 } const rotation = this.character?.mesh.rotation.y ?? 0 if (pos) { updatePos({ x: -pos.x, y: pos.z, rotation: rotation - Math.PI }) } })
新手引導
新手指引除了日常的功能引導,增加了類似賽車遊戲中對具體建築地點的路線指引,其思路是通過使用者已經熟悉的遊戲引導功能,來降低新玩家的認知成本。
在開發實現上,則拆解為數學問題。在引導線的方向、結束點固定的情形下,引導線展示的長度為人物位置到結束點向量在引導線方向的投影長度,如下座標示例圖所示:
// 使用向量點乘計算投影長度
setProgress (val: number | Vector3) {
if (typeof val === 'number') {
this.progress = val
} else if (this.startPoint && this.endPoint) {
const startToEnd = this.endPoint.add(this.startPoint.negate())
const startToEndNormal = Vector3.Normalize(startToEnd)
this.progress = 1 - Vector3.Dot(startToEndNormal, (this.endPoint.add(val.negate()))) / startToEnd.length()
}
}
引導線為一個平面,兩端的漸變效果和動畫使用定製的著色器實現
# 頂點著色器
uniform mat4 worldViewProjection;
uniform vec2 uScale;# [1, 引導線長度/引導線寬度] 用於計算紋理的uv座標
attribute vec4 position;
attribute vec2 uv;
varying vec2 st;
void main(void) {
gl_Position = worldViewProjection * position;
st = vec2(uv.x * uScale.x, uv.y * uScale.y);
}
# 片元著色器
uniform sampler2D textureSampler;
uniform vec2 uScale;# 同頂點著色器
uniform float uOffset;# 紋理偏移量,改變這個值實現動畫
uniform float uAlphaTransStart;# 引導線開始端透明漸變長度/引導線寬度
uniform float uAlphaTransEnd;# 引導線結束端透明漸變長度/引導線寬度
varying vec2 st;
void main(void) {
vec2 rst = vec2(st.x, -st.y + uOffset);
float alphaEnd = smoothstep(0., uAlphaTransEnd, st.y);
float alphaStart = smoothstep(uScale.y, uScale.y - uAlphaTransStart, st.y);
float alpha = alphaStart * alphaEnd;
gl_FragColor = vec4(texture2D(textureSampler, rst).rgb, alpha);
}
效能優化
在 HTML 中,通過第三方庫實現對 3D 模型的渲染,並且搭建一個街景以及多個場館,在記憶體方面確實是一筆龐大的開支。在開發過程中,由於和視覺設計並行,早期並沒有發現異常,隨著視覺逐步提供模型檔案,慢慢呈現整體氛圍,遊戲過程中的卡頓、閃退現象也出現了。
通過 PC 端開發環境下對記憶體的監控,以及和 App Webview 大佬的溝通,提前進入了效能優化討論環節。目前整個專案中模型共有 30 個,單個檔案大小平均在 2M 左右(最大的街景是 7M)。因此在效能優化上,主要採取了以下方法:
- 控制貼圖精度
在優化過程中發現佔模型檔案大部分體積的是貼圖資料,在和視覺溝通和嘗試後,把絕大部分的貼圖控制在 1Kx1K 以下,有部分貼圖尺寸是 512x512。 - 使用壓縮紋理
使用傳統的 jpg/png 格式作為紋理檔案,會使圖片檔案在瀏覽器圖片快取和 GPU 儲存都佔一份空間,增大頁面記憶體佔用量。記憶體佔用到一定大小,會在 IOS 裝置下出現閃退、Android 裝置下出現卡頓、掉幀等現象。而使用壓縮紋理格式,可以使圖片快取只在 GPU 儲存保留一份,大大減少了記憶體佔用。具體操作實現:在專案中使用紋理壓縮工具,把原 jpg/png 圖片檔案轉換成 pvrtc/astc 等壓縮紋理格式檔案,並把紋理檔案和模型其他資訊進行分拆,在不同的裝置上按支援度載入不同格式的紋理檔案。 - 高模細節烘焙到低模:將高精度模型的細節烘焙到貼圖上,然後把貼圖應用到低精度模型中,保證貼圖精度的同時,儘量隱藏模型精度的缺陷。
其他踩過的坑
- “光”處理
在視覺提供的“白模”初稿時,由於色彩和整體街景是過渡版本,大家對渲染效果並沒有提出異議,但當整體色彩、貼圖同時匯出後,前端的渲染結果,和視覺的烘焙匯出預覽差異較大。通過仔細對比,以及我們自己開發的 3D 素材管理平臺上的預覽對比,發現“光”的影響非常大。下圖左右分別是增加“光”和去除“光”的情況:
在和視覺進行溝通,多次實驗嘗試後,最終解決方案如下:
- 環境光加入 HDR 貼圖,使場景獲得更明亮的表現和反射資訊;
- 在攝像機往前的方向加入一個方向光,提升整體亮度。
對應的延展優化:增加地面光反射,實現倒影從而提升街景氛圍。
- GUI 渲染清晰度
在專案中,使用 Babylon 內建的 GUI 層展示圖片,展示的效果清晰度太差,達不到還原設計稿的要求。例如下圖中的文字、返回箭頭的鋸齒:
因為在預設的配置下,3D 畫布的大小為螢幕的顯示解析度作為大小,如 iPhone13 為 390x844,Babylon 官方提供的方法是使用螢幕點物理解析度作為畫布的大小,不僅可以點對點渲染 GUI,並且場景的解析度也更加清晰;但這種方案增加了整體專案的 GPU 渲染壓力,對原來已經緊張的資源來說再增加計算量這個方案不好使用。
於是修改把明顯渲染質量不好的 GUI 元件,移出 3D 畫板,使用 DOM 元件來渲染,可以在使用同樣資源的情況下把按鈕圖片渲染達到設計稿要求的效果,處理後效果如下:
- 發光材質的處理
在視覺設計的過程中,會在一些模型上使用自發光的材質,讓這個模型影響周圍的模型,以呈現細膩的光影效果,如下圖視覺稿所示:
但是開發使用到的框架並沒有發光材質的應用級實現,當我們把模型放入頁面時,沒有設定燈光的時候,模型上只有發光材質的部分可以被渲染出來,其他部分都是黑色的(下圖左所示);而在設定了燈光之後,模型整個被最亮,沒有任何光影效果(下圖右所示):
解決方案:與設計師配合,將受區域性燈光影響產生的光影效果烘培到材質貼圖中,而頁面還原上只需要設定合理的燈光位置,就可以還原效果,如下圖所示:
結語
感謝休食水飲部營銷運營組、平臺營銷設計部創新營銷設計組大佬們的探索精神和支援,全力投入使得專案在 5 月吃貨節上線。《探味奇遇記》是對未來購物的一種嘗試與探索,滿足顧客對未來美好新奇的一個需求。將購物場景化、趣味化,給顧客帶來美好的購物感受。在覆盤專案資料時發現,點選率和轉化率資料都略高於同期會場。
在這次 3D 技術落地的過程中,雖然踩了不少坑,但也是收穫滿滿,作為開荒團確實是離“元宇宙”的目標更進一步了,相信我們的技術和產品會越來越成熟,也請大家期待團隊的視覺化 3D 編輯工具!