在 Day 22 中,我們剖析了離散網格的軸向分離滑牆演算法、V8 引擎的 16.6 毫秒垃圾回收防線,以及 WebGL 顯存生命週期的嚴格管理。
今天我們將探討遊戲開發中極度重要卻常被忽視的「手感隱形工程」——相機平滑追蹤(Camera Tracking)與運鏡數學。
在 HD-2D 的世界裡,相機不僅僅是一台冷冰冰的觀察儀器,更是引導玩家沉浸在微縮模型氛圍中的靈魂窗口。許多初學者在實作相機跟隨時,經常會遇到以下兩大體驗致命傷:
硬梆梆的鋼性鎖定:相機 1:1 硬釘在角色頭頂,角色移動一像素、相機就猛然震動一像素,畫面抖動劇烈且毫無電影呼吸感。
看似平滑卻致命的 lerp 陷阱:使用常見的 camera.position.lerp(target, 0.1),結果在 60Hz 螢幕上感覺拖泥帶水,到了高階 144Hz 電競螢幕上運鏡卻快得像神經質抽動;一旦遇到掉幀,相機還會產生難看的卡頓拉扯。
今天我們將徹底拆解傳統線性插值的數學陷阱,並實裝現代遊戲引擎標準的影格獨立指數阻尼衰減(Frame-rate Independent Exponential Decay)與解耦雙層相機架構!
在遊戲教學中,最常看到的相機跟隨代碼通常長這樣:
// 表面看似優雅,實則暗藏重大瑕疵的寫法
function animate() {
requestAnimationFrame(animate);
// 每一幀將相機往目標推進 10% 的距離
camera.position.lerp(player.position, 0.1);
renderer.render(scene, camera);
}
這段代碼的數學本質是:每一幀計算相機與主角之間的距離差,並移動該距離的 $10%$。在固定 60 FPS 的理想狀況下,它確實呈現出先快後慢的緩動效果。但它隱藏了兩個在真實生產環境中不可接受的問題。
lerp 的第二個參數($\alpha = 0.1$)代表的是「每一幀(Per-Frame)」推進的比例,而不是「每一秒(Per-Second)」。
假設玩家移動了相同的距離,我們在不同更新率的螢幕上看會發生什麼事:
這意味著:玩家的螢幕更新率不同,體驗到的遊戲鏡頭速度與手感完全是兩款不同的遊戲! 如果遇到瀏覽器短暫掉幀,原本平滑的曲線更會被瞬間拉扯打碎。
在數學上,每次前進剩餘距離的 $10%$,意味著相機理論上永遠無法抵達目的地(芝諾悖論)。當相機極度靠近角色時,計算出的位移量會小到只有 $0.00001$ 單位。在 2D 像素藝術中,這種微小的浮點數漂移會導致 NearestFilter 採樣的紋理邊緣產生極其煩人的「微像素爬行(Pixel Swimming)」,使靜態畫面產生毛躁微顫。
要消除硬體更新率的差異,核心原則只有一個:位移計算必須嚴格乘上時間增量 $\Delta t$(Delta Time)。
但我們不能粗暴地寫成 lerp(target, speed * dt)。因為線性乘上 $dt$ 在物理上是等速直線前進,完全失去了緩動阻尼的高級質感;而且當某個影格因為背景程式卡頓導致 $dt$ 異常放大時,speed * dt 可能直接大於 $1.0$,導致相機直接朝反方向「彈射飛出(Overshoot)」。
半衰期與物理衰減公式
在連續時間物理學中,一個物體以正比於距離的速度逼近目標時,其剩餘距離遵循指數衰減規律:
其中:
在離散的電腦渲染中,經過一幀的時間 $\Delta t$ 後,距離的衰減比例為 $e^{-\lambda \cdot \Delta t}$。因此,相機在這一幀應該前進的比例因子 $\text{Factor}$ 為:
程式碼實作:三行搞定穩如磐石的運鏡
在專案的 animate() 主迴圈中,我們捨棄傳統的 lerp(target, alpha),改採嚴謹的指數阻尼計算:
// 指數阻尼平滑相機追蹤
const camFollowSpeed = 12.0; // 追蹤剛性 Lambda:12.0 帶來緊湊平穩的微縮跟隨感
const camFactor = 1.0 - Math.exp(-camFollowSpeed * dt);
// 嚴格依據時間衰減推進焦點座標
cameraTarget.x += (playerGroup.position.x - cameraTarget.x) * camFactor;
cameraTarget.z += (playerGroup.position.z - cameraTarget.z) * camFactor;
cameraTarget.y = 2.0; // 鎖定垂直高度,不受角色跳躍或呼吸形變影響
搞懂了阻尼數學後,下一個關鍵是:千萬不要直接對 camera.position 進行插值。
為什麼不能直接插值相機本體?
在 HD-2D 的透視場景中,攝影機通常維持固定的傾角(例如低俯視 28 度),這也是營造玩具屋微縮模型感的視覺核心。
如果直接對 camera.position 的 X、Y、Z 進行三軸動態阻尼:
雙層相機解耦架構(Camera Target & Offset)
我們將相機系統拆解為兩層:
一個沒有幾何實體的純 3D 向量,專門平滑鎖定角色在地面上的重心位置。
相機相對於焦點的絕對空間向量(例如 Vector3(0, 10, 18)),代表攝影機的吊架臂長與俯角。
// 雙層相機架構實作
// 1. 平滑推進地面的虛擬焦點
cameraTarget.x += (playerGroup.position.x - cameraTarget.x) * camFactor;
cameraTarget.z += (playerGroup.position.z - cameraTarget.z) * camFactor;
cameraTarget.y = 2.0;
// 2. 實際相機位置 = 焦點 + 剛性視角向量 (確保俯視傾角永遠絕對恆定)
camera.position.copy(cameraTarget).add(cameraOffset);
// 3. 相機永遠注視著焦點
camera.lookAt(cameraTarget);
透過這種雙層解耦:
在 3D 透視相機下渲染 2D 像素遊戲,另一個常見的痛點是:當相機在非常緩慢的移動中,地磚的邊緣會在相鄰螢幕像素之間來回跳動,產生視覺撕裂(Pixel Shimmering)。
在我們的系統中,以下三道防線相互配合,消除了這種瑕疵:
鎖定焦點高度 cameraTarget.y = 2.0:
在 Day 17 與 Day 19 的動畫系統中,主角在待機與跑步時有細微的 Y 軸呼吸起伏。如果焦點跟隨角色的 Y 軸上下晃動,相機會隨之劇烈垂直微震。將 $Y$ 軸剛性鎖定在 2.0,能確保鏡頭永遠在水平面上平滑滑行。
適度提高追蹤剛性(camFollowSpeed = 12.0):
過小的速度值(如 2.0 ~ 4.0)會讓相機產生過長、過拖的浮點尾巴;將剛性設定在 12.0,能在玩家起步與剎車時提供約 0.15 秒的極致柔和過渡,隨後迅速咬合速度,大幅縮短低速微像素蠕動的停留時間。
後處理移軸景深的散景柔化:
在 Day 18 實裝的 TiltShiftVignetteShader 中,相機上下兩端的失焦區域已經被 9 採樣高斯模糊均勻柔化;配合 0.20 的中央寬廣清晰帶,即使相機在浮點空間移動,玩家視覺中心聚焦的主角與路徑始終純粹乾淨。
【本日結語】
今天我們徹底擊碎了「隨手寫 lerp」所隱藏的硬體幀率陷阱,從物理衰減公式推導出影格獨立的指數阻尼衰減,並以焦點與剛性偏移的雙層架構,打造出如同主機大作般緊湊、平穩且絲毫不失溫潤質感的電影級鏡頭。
在下一篇 Day 24 中,我們將解密場景動態空氣感的最後一塊拼圖——GPU 粒子發射器架構(Particle System)!我們將深入探討如何利用 THREE.Points 打造微型木屑火星(Embers)與懸浮微塵,以及如何運用加法混合(Additive Blending)自然觸發 UnrealBloom 漫射光暈!