在 Day 20 中,我們用直覺的比喻拆解了 WebGL 紋理取樣、Billboard 廣告看板與 onBeforeCompile 著色器動態受光的底層機制。
搞懂了像素本體之後,今天我們將視角拉遠,聚焦於本系列主題的另一大靈魂——即時光影系統(Real-Time Lighting)與後處理合成管線(Post-Processing Pipeline)。
在傳統 2D 像素遊戲中,光影通常是美術手繪在貼圖上的死板色塊;但 HD-2D 的迷人魅力,在於將復古像素浸泡在具備現代物理規則的真實動態光照中。許多人會好奇:
今天我們將掀開渲染管線的第二層帷幕,用清晰好懂的邏輯,帶大家看懂這座地宮背後的光學奧秘!
在真實世界中,光子從光源出發,照亮物體並在背後留下陰影。但在即時渲染中,以追蹤每條光線路徑為原理的光線追蹤(Ray Tracing)成本過高,一般使用者的顯示卡難以負荷每秒 60 幀的運算。
WebGL 採用了一種非常聰明且經典的替代方案——陰影貼圖(Shadow Mapping)。
偷天換日:顯卡其實「偷偷拍了兩次照片」
當我們開啟 renderer.shadowMap.enabled = true 時,GPU 在繪製一幀畫面時其實執行了兩次攝影工作:
[第一道工序:光源視角]
在天頂月光或火把處架設一台隱形相機,朝場景拍攝一張「黑白深度照片 (Depth Map)」。
每個像素記錄的不是顏色,而是「該物體離光源有多近」。
[第二道工序:玩家視角]
相機回到玩家視角開始正常繪製石磚與牆壁。
在畫每一個像素時,計算該點到光源的距離,並拿去跟深度照片比對:
• 當前距離 <= 深度照片記錄的值 → 光照得到,正常著色!
• 當前距離 > 深度照片記錄的值 → 中間有東西擋住了,塗黑變成陰影!
鋸齒狗啃 vs. PCFSoft 柔和筆觸
如果只進行單純的一對一深度比對(BasicShadowMap),陰影邊緣會出現嚴重的馬賽克鋸齒。
在專案中,我們啟用了 THREE.PCFSoftShadowMap(百分比漸近過濾軟陰影):
很多開發者在製作火把時,習慣直接給一個固定的亮橘色(例如 #ff8800),但這樣渲染出來的光斑在 3D 石磚上看起來會極度僵硬,宛如一顆人造橘色塑膠 LED 燈。
在 Day 18 的重構中,我們引入了兩個關鍵的光學要素,讓這盞火把擁有了生命力:
溫度越低,波長越長,光色越偏深紅琥珀。
溫度升高,能量激增,光色會轉化為明亮的暖金色與亮黃白。
我們捨棄了單一的人工純橘色,改用真實樹脂木質燃燒的三組黑體光譜矩陣:
const TORCH_COLOR_BASE = new THREE.Color('#ffa048'); // 基準火光色 (1850K 溫潤琥珀金)
const TORCH_COLOR_FLARE = new THREE.Color('#ffb85c'); // 旺盛燃燒色 (2150K 躍動明金)
const TORCH_COLOR_EMBER = new THREE.Color('#ff7e28'); // 微風餘燼色 (1700K 深暖琥珀)
const f1 = Math.sin(elapsedTime * 8.5) * 0.45; // 主頻率:火焰整體的大幅呼吸
const f2 = Math.cos(elapsedTime * 17.1) * 0.25; // 次頻率:氣流吹拂的中頻波動
const f3 = Math.sin(elapsedTime * 31.3) * 0.15; // 高頻率:火尖微細顫抖的毛刺噪訊
const flamePulse = (f1 + f2 + f3) / 0.85; // 混合成無規律的動態波動率 (-1.0 ~ +1.0)
如果在瀏覽器中只執行 renderer.render(scene, camera),畫面就像拍立得一樣,相機快門按下的瞬間就直接烙印在 HTML Canvas 畫布上,無法進行二次加工。
在 Day 18 中,我們引入了 EffectComposer。它在顯卡記憶體中建立了一條後處理流水線,運作原理如同專業攝影師在「暗房沖洗照片」。
但問題來了:當畫面經過多道濾鏡處理(例如:基礎場景 $\to$ 輝光 $\to$ 移軸景深 $\to$ 色調映射)時,顯卡不能「一邊讀取同一張畫布,一邊把處理後的結果塗在同一張畫布上」,這會引發嚴重的 GPU 讀寫衝突(Race Condition)。
EffectComposer 的底層採用了經典的 Ping-Pong(乒乓)雙緩衝機制:
步驟 1 (RenderPass):
相機將 3D 場景繪製在【Buffer A】。
步驟 2 (UnrealBloomPass 選擇性輝光):
GPU 讀取【Buffer A】的像素,計算發光擴散後,繪製並輸出到【Buffer B】。
步驟 3 (TiltShiftVignettePass 移軸與暗角):
GPU 讀取【Buffer B】,計算 9 採樣上下景深模糊與暗角,輸出覆蓋回【Buffer A】。
步驟 4 (OutputPass 色彩校準):
GPU 讀取【Buffer A】,完成 ACESFilmic 色調映射,正式投映至玩家的螢幕 Canvas!
兩張緩衝區就像打乒乓球一樣來回倒手,以極低的記憶體切換開銷,完成了多重電影濾鏡的串接。
2D 像素主角的衣物亮部、淺色皮膚與地面石磚的高光,其反光係數最高只會達到 0.95 ~ 0.96。
高門檻形成了一面精準的「防護罩」,把所有常規物件死死擋在發光名單之外,保證 2D 像素細節刀鋒銳利、絕不泛白模糊[cite: 4]。
只有鍛鐵壁燈與火把火焰核心(自發光強度高達 1.6~2.3)能夠突破 0.98 的門檻,激發出自然柔和的漫射金暈!
// 片段著色器內部核心
// 1. 計算當前像素離垂直中心焦點 (focusPos = 0.46) 的距離
float dist = abs( vUv.y - focusPos );
// 透過 smoothstep 確保中央聚焦帶保持絕對清晰無損,往上下邊緣平滑失焦
float blurFactor = smoothstep( focusRange, focusRange + 0.35, dist );
float vv = v * blurAmount * blurFactor;
// 2. 9 採樣垂直高斯權重卷積:在上下方向進行柔和的微縮模型散景模糊
vec4 sum = vec4(0.0);
sum += texture2D(tDiffuse, vec2(vUv.x, vUv.y - 4.0 * vv)) * 0.0510;
sum += texture2D(tDiffuse, vec2(vUv.x, vUv.y - 3.0 * vv)) * 0.0918;
// 中間權重取樣 (中心權重 0.1633)
sum += texture2D(tDiffuse, vec2(vUv.x, vUv.y + 4.0 * vv)) * 0.0510;
// 3. 電影級暗角:以屏幕中心為圓心平滑壓暗四邊角落
vec2 uv = (vUv - vec2(0.5)) * vec2(offset);
float vignetteDist = dot(uv, uv);
float vignetteFactor = clamp(1.0 - vignetteDist * darkness, 0.0, 1.0);
【本日結語】
回顧今天的內容,我們徹底拆解了 HD-2D 世界中動態光影與後處理管線的運作本質:
在掌握了幾何著色與動態光影兩大基石之後,在下一篇 Day 22 中,我們將把目光轉向系統的骨幹架構——離散網格到連續 3D 空間的平滑移動與流暢滑牆演算法(Wall Sliding),以及如何在瀏覽器中管理記憶體、避免垃圾回收(GC)頓挫,穩固維持極限 60 FPS!