在 Day 21 中,我們解析了 PCFSoftShadowMap 的深度比對邏輯、黑體輻射光學與 EffectComposer 雙緩衝暗房管線。
然而,一款出色的 HD-2D 遊戲除了具備電影級畫面,更離不開扎實絲滑的操作手感(Game Feel)與穩如磐石的 60 FPS 幀率。許多 WebGL 開發者在專案後期常遇到兩大噩夢:
今天我們將聚焦於「遊戲邏輯與瀏覽器效能底層」,拆解如何將離散網格轉化為連續滑牆物理,並深入 V8 引擎垃圾回收機制,落實嚴格的「零 GC 記憶體管理」!
在我們的地下城中,地圖是以二維陣列形式儲存的離散數據(例如 mapGrid[row][col] 記錄該格是石磚、牆壁還是深淵);但主角在 3D 世界中的座標(player.position.x, player.position.z)卻是隨時間連續變化的浮點數。
// 糟糕的碰撞處理:一撞牆就全停
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$ 軸)本來是暢通無阻的。直接把整段移動沒收,會讓角色瞬間像是「黏在牆上」,操作極度生硬。

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;
}
透過將兩個軸向獨立判定,當玩家斜切撞擊牆面時,垂直於牆面的分速度被物理吸收,平行於牆面的分速度則完好保留,角色自然而然地貼著石牆飛速滑動,操作反饋流暢無比。
在 60 FPS 的渲染標準下,瀏覽器繪製每一幀的時間上限只有極為嚴苛的 16.6 毫秒($1000\text{ ms} / 60\text{ fps} \approx 16.6\text{ ms}$)。在這 16.6 毫秒內,CPU 必須依序跑完:
很多開發者喜歡在 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)」。
在我們專案的每幀更新中,嚴禁在迴圈內動態使用 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 記憶體佔用曲線將會是一條平穩的水準線,完全不觸發垃圾回收器的重型清掃,徹底消滅週期性卡頓。
地下城由 50x50 共 2,500 個網格組成。如果每一面石牆、每一塊地磚都是一個獨立的 new THREE.Mesh(geo, mat),會發生什麼事?
InstancedMesh(實例化網格):
如果所有石牆共用同一套幾何形狀與材質,GPU 允許我們使用 InstancedMesh。CPU 只需要準備一個矩陣陣列(包含 2,000 個方塊各自的世界座標與縮放),只需要 1 次 Draw Call,GPU 就能一口氣在螢幕上將 2,000 個方塊全部畫完!
幾何體合併(BufferGeometryUtils.mergeGeometries):
對於靜態不動的地板石磚,可以在初始階段直接將數百個獨立 PlaneGeometry 的頂點資訊焊死融合成一張巨大的幾何體。
在我們的專案中,全場景的 Draw Call 被嚴格壓制在 30 次以內(包含後處理 Passes),CPU 有充裕的計算餘裕去處理狀態機、火光微動與平滑相機運鏡。
在傳統網頁開發中,當一個 DOM 元素或 JavaScript 變數不再被引用時,瀏覽器會自動把它回收掉。但在 WebGL 與 Three.js 的世界裡,這個規則完全失效。
如果你在遊戲中隨手更換貼圖或銷毀地圖:
// 嚴重的顯存洩漏寫法!
mesh.material = new THREE.MeshBasicMaterial({ map: newTexture });
舊的材質和舊的紋理雖然在 JavaScript 裡失去了變數引用,但它們在顯卡的 VRAM 中依然完好無損地佔據著空間!如果切換幾次地圖,網頁會逐漸吃爆顯存,最終導致瀏覽器 WebGL 上下文崩潰(WebGL Context Lost)並直接閃退。
// 徹底銷毀一個 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)徹底消除運鏡抖動,打造如同主機遊戲般緊湊平穩的電影鏡頭!