本文作者 陳炫燁
2月,基於 Cocos Creator 研發的線上數字空間 TRUE SPACE 首次開放。本次,該項目負責人陳炫燁將和我們聊一聊 TRUE SPACE 的技術亮點與實現,分享如何用 Cocos 打造一個元宇宙虛擬展會。
樓宇科技 TRUE 大會是美的樓宇科技事業部舉辦的高規格的年度行業大會。上個月底,第二屆樓宇科技 TRUE 大會在上海舉行。與往年不同,今年大會首次開放了線上數字空間 TRUE SPACE,打破傳統單一的參會方式,為廣大用戶提供全新的線上虛擬逛展體驗。TRUE SPACE 由美的樓宇科技研究院 TEAM x.y.z. 和 IBUX 團隊共同打造,使用 Cocos Creator 研發,集雲逛展、娛樂、社交、直播等多種功能於一身,是目前市面上標杆級的類元宇宙在線應用。
在 TRUE SPACE 中,玩家可以生成自己的虛擬形象,暢遊本屆大會的八大主題場景,解鎖隱藏在場景中的遊戲互動和驚喜彩蛋;玩家之間可以自由對話、交換名片、連麥交流,更便捷、更趣味地進行社交活動;同時還可以觀看線下各大論壇的實時直播,線上線下同步互動。
此外我們還實現了不少有趣的效果,比如花朵生長、失重、穿梭、水體、地球導航、拍照分享等,有很多小心思。本文我將挑一些比較通用的功能分享給大家。
技術選型
為什麼選擇使用 Cocos 開發呢?
首先,我們希望 TRUE SPACE 能夠通過網頁直接訪問,並且和大會官網無縫銜接。Cocos 的網頁發佈能力和相對完善的編輯器能力,能夠幫助我們更好地實現這一需求,這也成為我們選擇 Cocos 的主要原因。此外,Cocos 的引擎源碼是開源的,也能方便我們做一些定製化和問題的定位。
技術要點
反射探針
反射是營造場景質感的一個重要手段。由於開發時我們使用的 v3.5.2 還不支持反射探針,為了能讓場景有更好的視覺表現,我寫了一個反射探針的插件。當然,v3.7 新增了反射探針功能,我們可以直接在編輯器裡創建與設置。
- 捕捉
反射探針的原理其實很簡單,就是將相機的 fov 設置成90度,對場景的6個方向進行拍攝,並存儲為圖片。為了讓圖片能更好的的預覽和節約存儲空間,我將圖片轉成了全景圖,並在保存的過程當中,對圖片做了基於粗糙度的預卷積計算。所以最終輸出的圖片如下。
- 加載
將圖片加載到 cubemap 的多級 mipmap 中,遇到了個坑,在一些低版本的 iOS 的瀏覽器中,不支持設置 framebuffer 的 mipmap 等級。最後我們是通過直接讀取紋理數據的方式繞過了使用 framebuffer,偽代碼如下:
for (let i = 0; i < 6; i++) { bakeMaterial.setProperty("face", i); blit(tempRT, bakeMaterial.pass[2]); cubemap.uploadData(tempRT.readPixels(), level, i); }
- 插值
反射探針的插值,是通過物體包圍盒與反射探針 box 相交的體積比例,作為權重實現的。計算包圍盒相交體積的代碼如下:
Vec3.subtract(__boxMin, worldBounds.center, worldBounds.halfExtents); Vec3.add(__boxMax, worldBounds.center, worldBounds.halfExtents); Vec3.min(__interMax, __boxMax, probe.boxMax); Vec3.max(__interMin, __boxMin, probe.boxMin); let volume = max(__interMax.x - __interMin.x, 0.001) * max(__interMax.y - __interMin.y, 0.001) * max(__interMax.z - __interMin.z, 0.001);
探針插值部分 Shader 代碼如下:
vec3 getIBLSpecularRadiance(vec3 nrdir, float roughness, vec3 worldPos) { vec3 env0 = SAMPLE_REFLECTION_PROBE(cc_reflectionProbe0, worldPos, nrdir, roughness); #if CC_REFLECTION_PROBE_BLENDING float t = cc_reflectionProbe0_boxMax.w; if (t < 0.999) { vec3 env1 = SAMPLE_REFLECTION_PROBE(cc_reflectionProbe1, worldPos, nrdir, roughness); env0 = mix(env1, env0, t); } #endif return env0; }
盒子投影(boxProjection)
如果簡單地對捕捉的環境做反射,你會發現空間上是有問題的。如下圖,地面反射的場景距離用戶非常遠,很不協調。
那麼如何解決呢?
方案是開啟 boxProjection。將貼圖的採樣投影到一個 box 內,讓地面可以在正確的範圍內反射場景,這樣會有相對正確的空間感(InteriorCubeMap 也是類似的原理,InteriorCubeMap 可以用於做假室內效果)。
但是這樣又出現了個新問題,超出 box 的地方,會出現嚴重的採樣錯誤,並且交界處會出現邊界線。
解決方案就是將 box 的最小尺寸設置成物體的包圍盒的大小,這樣可以減少很大一部分的視覺問題。
在寫這個插件的過程當中,其實也遇到不少問題,比如 gfx 不支持 cubemap 作為 framebuffer 的輸出,這裡我用了比較 hack 的手段,直接使用 webgl 的原生方法來繞過 gfx,但是這樣會造成平臺兼容性的問題。不過這個插件只是用於離線生成,所以問題也不大。代碼片段如下:
for (let i = 0; i < 6; i++) { gl.bindFramebuffer(gl.FRAMEBUFFER, glFramebuffer); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, glTexture, 0 ) gl.bindFramebuffer(gl.FRAMEBUFFER, cache.glFramebuffer); camera.node.worldRotation = CameraForwards[i]; camera.camera.update(true); renderPipeline.render(cameras); }
PBR 優化
常規的 PBR 渲染會包含兩張預計算的環境貼圖,分別對應了 specular 和 diffuse。移動端對貼圖帶寬是很敏感的,所以能省則省。
這裡我做了兩個優化策略,一個是用 SH 替代 diffuse 卷積圖,另一個是直接用粗糙度為1的 specuar 卷積圖。
第二個方案的效果看起來和 diffuse 卷積圖很接近,而且對資源的需求更少,只要一張卷積圖就夠了,所以採用了這個方案,插件裡我全做了支持。PBR 的計算都是在線性空間,最後輸出要做一個 tonemap(將 HDR 轉成 LDR)和 gamma 矯正,這裡我將兩個合在一起用了一個近似:
x = x/(x+0.187) * 1.035;
動態生成海報
TRUE SPACE 做了一個拍照分享功能,用戶可以將自己的遊玩畫面生成海報分享給微信好友。這背後有兩個問題:如何生成這張圖片,以及微信如何能識別這張圖片。
- 生成圖片
生成海報的主要原理是利用相機的 targetTexture 功能,用戶可以將相機拍到的內容輸出到一張 renderTxture 上,然後將這張圖給到 Spite 的 spriteFrame 即可。
所以處理流程是這樣的,先用場景相機渲染三維場景到一張 renderTxture 上,然後再用 UI 相機將 UI 渲染到這張 renderTxture 上,就能得到一張完整的海報。
那麼海報生成了後可以直接拿去分享嗎?答案是不行,微信沒法識別這張圖片,因為它本質上不是圖片。所以我們要把它轉變成一張真正的圖片。
- 微信識別圖片
經過測試,我們發現微信可以識別 img 標籤,並且還能用於好友之間的分享,所以上面的問題就變成了如何將 renderTexture 轉換成 img 標籤。
Cocos 的 renderTexture 內置了一個 readPixel 方法,可以直接讀取圖像數據。因此只需將圖像數據生成 dataURL 餵給 img 標籤即可。最終的偽代碼大致是這樣的:
function ToObjectURL(RT, x, y, width, height) { let pixels = RT.readPixels(x, y, width, height); if (pixels) { let canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; let context = canvas.height.getContext('2d')!; let imageData = context.createImageData(width, height); context.putImageData(imageData, 0, 0); return canvas.toDataURL("image/png"); } } let img = new Image(width, height); img.src = ToObjectURL(RT, x, y, width, height); game.container!.appendChild(img);
動態 UI 適配
默認情況下當瀏覽器的分辨率與設計分辨率不一致時,UI 會出現比例嚴重失調的情況。TRUE SPACE 做了全平臺適配,UI 比例會根據寬高比自適應,包括摺疊屏也都可以適配。
下面是設計分辨率的適配代碼:
function onVisibleSizeChanged() { let size = view.getVisibleSize(); let ratio = size.width / size.height; if (ratio > 1) { ratio = ratio / 1.8 * 1.5; view.setDesignResolutionSize(1920 * ratio, 1080 * ratio, ResolutionPolicy.FIXED_WIDTH); } else { ratio = lerp(1, ratio / 0.48, ratio >= 0.6 ? 1 : 0); view.setDesignResolutionSize(750 * ratio, 1334 * ratio, ResolutionPolicy.FIXED_WIDTH); } }
更換形象
TRUE SPACE 更換形象的操作挺有意思的:鏡頭會往角色推進,背景會被虛化,並且角色不會被任何物體遮擋。
這是如何實現的呢?其實這裡用了兩臺相機,當點擊個人頭像時,一臺相機負責渲染場景,另外一臺相機負責渲染角色,清除深度(clearFlags 設置為 DepthOnly),並將角色渲染到畫面的最前端。
場景切換動畫
我們做了一個比較有趣的場景切換效果,原理是把 UI 渲染到一張 renderTexture 上,然後將這個 renderTexture 賦值給 Sprite,最後通過自定義 SpriteMaterial 實現。
需要注意,當瀏覽器尺寸發生變化時,複用 renderTexture 會讓 Sprite 丟失畫面。經過大量嘗試,我最後通過 new RenderTextue() 解決了這個問題,代碼如下:
let size = view.getVisibleSize(); if (this._loadingTexture.width != size.width || this._loadingTexture.height != size.height) { this._loadingTexture.destroy(); this._loadingTexture = new RenderTexture(); this._loadingTexture.reset({ width: size.width, height: size.height }); let spriteFrame = new SpriteFrame(); spriteFrame.texture = this._loadingTexture; this.loadingSprite.spriteFrame = spriteFrame; this.camera.targetTexture = this._loadingTexture; }
其他
TRUE SPACE 的研發還用到了兩個插件。一個是由 King 開發的開源框架 TSRPC,用於多人狀態同步;另一個是我自己開發的可視化智能相機系統 Cinestation,它具備智能追蹤、優先級控制、軌道移動、噪聲控制、時間軸動畫等功能,支持配置任意數量的鏡頭,完成複雜的相機混合和運動效果,在該項目中被用於製作各種鏡頭動畫。
限於篇幅,本文沒有對更多具體功能點做更細節的分享。歡迎體驗 TRUE SPACE,大家有想要了解的技術點也可以在評論區留言,後續我將從中挑選出一些大家感興趣的話題,推出一些教程。