iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Modern Web

Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統系列 第 22 篇

Day 22:技術底層精講(三)!空間碰撞與滑牆數學、零 GC 垃圾回收與 60 FPS 極限效能優化

  • 分享至 

  • xImage
  •  

在 Day 21 中,我們解析了 PCFSoftShadowMap 的深度比對邏輯、黑體輻射光學與 EffectComposer 雙緩衝暗房管線。

然而,一款出色的 HD-2D 遊戲除了具備電影級畫面,更離不開扎實絲滑的操作手感(Game Feel)與穩如磐石的 60 FPS 幀率。許多 WebGL 開發者在專案後期常遇到兩大噩夢:

  1. 碰撞手感僵硬:角色斜向奔跑撞牆時像撞到無形鐵板,瞬間原地定格、無法動彈。
  2. 畫面偶發微卡頓(Jank / Stutter):明明 GPU 算力很足,但每跑幾秒就會出現一次微妙的跳幀或畫面頓挫。

今天我們將聚焦於「遊戲邏輯與瀏覽器效能底層」,拆解如何將離散網格轉化為連續滑牆物理,並深入 V8 引擎垃圾回收機制,落實嚴格的「零 GC 記憶體管理」!

一、離散網格遇上連續世界:軸向分離與流暢滑牆演算法(Wall Sliding)

在我們的地下城中,地圖是以二維陣列形式儲存的離散數據(例如 mapGrid[row][col] 記錄該格是石磚、牆壁還是深淵);但主角在 3D 世界中的座標(player.position.x, player.position.z)卻是隨時間連續變化的浮點數。

  1. 致命的「全阻斷碰撞」痛點
    最簡單直覺的碰撞判斷是「計算下一步要到達的位置,如果該位置落在牆壁格子裡,就直接將速度歸零」:
// 糟糕的碰撞處理:一撞牆就全停
const nextX = player.position.x + vx * dt;
const nextZ = player.position.z + vz * dt;

if (!isWall(nextX, nextZ)) {
  player.position.x = nextX;
  player.position.z = nextZ;
}

這種寫法會引發災難級的操作手感:當玩家按住 W + D 斜向 45 度撞向一面北方的牆壁時,雖然北方($Z$ 軸)被擋住了,但東方($X$ 軸)本來是暢通無阻的。直接把整段移動沒收,會讓角色瞬間像是「黏在牆上」,操作極度生硬。

  1. 軸向分離碰撞檢查(Axis-Decoupled Collision)
    在專案的 updatePlayer 中,我們採用了軸向獨立步進與滑牆演算法。它的直覺核心是:「將 3D 移動拆解為兩條獨立軌道,X 軸被擋住,不影響 Z 軸繼續滑行」。

image
3. 實作核心與角色半徑緩衝(Radius Margin)
角色並不是一個毫無體積的零維質點,而是一個具備寬度的圓柱體。如果只檢查角色中心點,角色的肩膀和手臂會穿進石牆裡。

我們在世界座標與網格座標的轉換中,引入了角色碰撞半徑(radius = 0.35):

// 軸向分離滑牆邏輯
const radius = 0.35; // 角色碰撞半徑

// --- Step 1: 獨立測試 X 軸移動 ---
const targetX = playerGroup.position.x + moveX * speed * dt;
// 依據移動方向,檢查角色外邊緣落在哪個網格
const checkX = moveX > 0 ? targetX + radius : targetX - radius;
const gridColX = Math.floor(checkX);
const gridRowCurrent = Math.floor(playerGroup.position.z);

// 只有在目標網格可通行時,才推進 X 軸
if (mapGrid[gridRowCurrent] && mapGrid[gridRowCurrent][gridColX] === 0) {
  playerGroup.position.x = targetX;
}

// --- Step 2: 獨立測試 Z 軸移動 ---
const targetZ = playerGroup.position.z + moveZ * speed * dt;
const checkZ = moveZ > 0 ? targetZ + radius : targetZ - radius;
const gridColCurrent = Math.floor(playerGroup.position.x);
const gridRowZ = Math.floor(checkZ);

// 只有在目標網格可通行時,才推進 Z 軸
if (mapGrid[gridRowZ] && mapGrid[gridRowZ][gridColCurrent] === 0) {
  playerGroup.position.z = targetZ;
}

透過將兩個軸向獨立判定,當玩家斜切撞擊牆面時,垂直於牆面的分速度被物理吸收,平行於牆面的分速度則完好保留,角色自然而然地貼著石牆飛速滑動,操作反饋流暢無比。

二、16.6 毫秒生死線:垃圾回收(GC)如何毀掉 60 FPS

在 60 FPS 的渲染標準下,瀏覽器繪製每一幀的時間上限只有極為嚴苛的 16.6 毫秒($1000\text{ ms} / 60\text{ fps} \approx 16.6\text{ ms}$)。在這 16.6 毫秒內,CPU 必須依序跑完:

  1. 讀取鍵盤輸入與狀態機更新。
  2. 碰撞檢測與玩家座標更新。
  3. 動態光源呼吸與著色器 Uniform 傳遞。
  4. Three.js 內部矩陣運算與繪製指令派發(Draw Calls)。
  5. GPU 執行多道後處理通道並輸出畫面。

隱形殺手:主迴圈裡的 new 物件

很多開發者喜歡在 animate() 函式或輔助計算中隨手寫下:

// 效能毒藥:每幀都在建立臨時物件
function animate() {
  const currentPos = new THREE.Vector3(player.x, player.y, player.z);
  const targetCam = new THREE.Vector3().copy(currentPos).add(new THREE.Vector3(0, 10, 18));
  // ...
}
  • 發生了什麼事?
    一秒鐘有 60 幀,每幀建立 3 個向量,一秒鐘就在 V8 引擎的記憶體堆積區(Heap)中拋棄了 180 個臨時物件;一分鐘就是上萬個物件。

  • 清潔工突襲(Garbage Collection Pause):
    這就像你一邊全速衝刺長跑,一邊隨手往地上扔免洗紙杯。當垃圾堆積到閾值時,瀏覽器的垃圾回收器(GC)會強行啟動「Stop-The-World」機制——暫停主執行緒 10 到 30 毫秒來清掃記憶體。
    這直接超出了 16.6 毫秒的極限,該幀被硬生生丟棄,玩家眼裡就成了「莫名其妙的瞬間卡頓(Jank)」。

零 GC 心法:物件池與常態複用(In-place Mutation)

在我們專案的每幀更新中,嚴禁在迴圈內動態使用 new 宣告任何 3D 物件。所有需要的計算容器,全部拉到模組頂部預先配置,並在每幀中使用純內部屬性覆寫(In-place Mutation):

// 頂部常駐的記憶體容器 (只在初始化時配置一次,永久複用)
const _tempMoveDir = new THREE.Vector3();
const _tempCameraTarget = new THREE.Vector3();

function animate() {
  requestAnimationFrame(animate);

  // 計算位移時,直接修改既有容器,零記憶體配置開銷
  _tempMoveDir.set(inputX, 0, inputZ).normalize();

  // 相機追蹤運算:以 copy, add, lerp 進行內部數值覆蓋
  _tempCameraTarget.copy(playerGroup.position).add(cameraOffset);
  camera.position.lerp(_tempCameraTarget, camFactor);

  composer.render();
}

透過杜絕臨時物件配置,JavaScript 記憶體佔用曲線將會是一條平穩的水準線,完全不觸發垃圾回收器的重型清掃,徹底消滅週期性卡頓。

三、顯示卡不喜歡碎紙片:Draw Call 瓶頸與幾何實例化思維

地下城由 50x50 共 2,500 個網格組成。如果每一面石牆、每一塊地磚都是一個獨立的 new THREE.Mesh(geo, mat),會發生什麼事?

  1. 什麼是繪製呼叫(Draw Call)?
    Draw Call 是 CPU 向 GPU 發出「畫出這個幾何體」的一道具體指令。每一次發出 Draw Call,CPU 都必須在底層切換 WebGL 狀態、綁定紋理貼圖、配置著色器 Uniform,然後敲下 GPU 的繪製板機。
  • 問題本質:
    GPU 算圖速度極快,能一瞬間處理數百萬個頂點;但 CPU 處理 Draw Call 的通訊切換開銷極大。
    如果畫面中有 2,000 個獨立的牆壁方塊,CPU 每一幀就必須向顯卡發出 2,000 次 Draw Call。CPU 會在狀態切換中徹底塞車,導致 FPS 直接雪崩至個位數。
  1. HD-2D 的合併與實例化對策
    在大型地宮的構建中,維持 60 FPS 的標準做法是將幾何體進行整併:
  • InstancedMesh(實例化網格):
    如果所有石牆共用同一套幾何形狀與材質,GPU 允許我們使用 InstancedMesh。CPU 只需要準備一個矩陣陣列(包含 2,000 個方塊各自的世界座標與縮放),只需要 1 次 Draw Call,GPU 就能一口氣在螢幕上將 2,000 個方塊全部畫完!

  • 幾何體合併(BufferGeometryUtils.mergeGeometries):
    對於靜態不動的地板石磚,可以在初始階段直接將數百個獨立 PlaneGeometry 的頂點資訊焊死融合成一張巨大的幾何體。

在我們的專案中,全場景的 Draw Call 被嚴格壓制在 30 次以內(包含後處理 Passes),CPU 有充裕的計算餘裕去處理狀態機、火光微動與平滑相機運鏡。

四、WebGL 資源的隱形黑洞:手動釋放(Dispose)生命週期

在傳統網頁開發中,當一個 DOM 元素或 JavaScript 變數不再被引用時,瀏覽器會自動把它回收掉。但在 WebGL 與 Three.js 的世界裡,這個規則完全失效。

  1. 記憶體 vs. 顯存的隔離
    Three.js 物件(如 Texture、BufferGeometry、Material)在瀏覽器記憶體中只是小小的封裝外殼,其底層承載的龐大數據(頂點緩衝區 VBO、紋理圖像、著色器編譯成果)是常駐在顯示卡的專屬顯存(VRAM)中的。

如果你在遊戲中隨手更換貼圖或銷毀地圖:

// 嚴重的顯存洩漏寫法!
mesh.material = new THREE.MeshBasicMaterial({ map: newTexture });

舊的材質和舊的紋理雖然在 JavaScript 裡失去了變數引用,但它們在顯卡的 VRAM 中依然完好無損地佔據著空間!如果切換幾次地圖,網頁會逐漸吃爆顯存,最終導致瀏覽器 WebGL 上下文崩潰(WebGL Context Lost)並直接閃退。

  1. 嚴格的手動銷毀協議
    要確保 WebGL 應用程式具備商業級的穩定度,任何不再使用的 GPU 資源都必須明確調用 .dispose() 方法通知驅動程式釋放顯存:
// 徹底銷毀一個 3D 物件的標準流程
function cleanUpMesh(mesh) {
  // 1. 釋放頂點幾何體緩衝區 (VBO/EBO)
  if (mesh.geometry) {
    mesh.geometry.dispose();
  }

  // 2. 釋放材質與相關紋理
  if (mesh.material) {
    if (mesh.material.map) mesh.material.map.dispose();
    if (mesh.material.normalMap) mesh.material.normalMap.dispose();
    mesh.material.dispose();
  }

  // 3. 從場景圖中拔除
  if (mesh.parent) {
    mesh.parent.remove(mesh);
  }
}

掌握了主動調度顯存生命週期的觀念,我們的 HD-2D 應用程式才能在長時間運行、頻繁切換場景的極限狀況下,始終維持輕盈健康的硬體資源佔用。

【本日結語】
今天我們從遊戲手感的微觀幾何(軸向分離滑牆),一路剖析至 V8 引擎的 16.6ms 影格時間、零 GC 複用原則,再到 GPU 的 Draw Call 批次化與顯存手動銷毀機制。這些看不見的「幕後水電工工程」,正是支撐起流暢 HD-2D 地宮體驗的最堅固基石。

在下一篇 Day 23 中,我們將解析攝影機平滑跟隨的數學奧秘——為什麼傳統的線性插值(lerp)在不同螢幕更新率(60Hz vs 144Hz)下會產生肉眼可見的拉扯感?我們是如何運用指數阻尼衰減(Exponential Decay Damping)徹底消除運鏡抖動,打造如同主機遊戲般緊湊平穩的電影鏡頭!


上一篇
Day 21:技術底層精講(二)!即時光影之謎:Shadow Map 深度比對、黑體火光與 EffectComposer 雙緩衝暗房管線
下一篇
Day 23:技術底層精講(四)!電影級運鏡手感:告別幀率綁定 lerp,指數阻尼衰減(Exponential Decay)與雙層相機架構
系列文
Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言