iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Modern Web

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

Day 21:技術底層精講(二)!即時光影之謎:Shadow Map 深度比對、黑體火光與 EffectComposer 雙緩衝暗房管線

  • 分享至 

  • xImage
  •  

在 Day 20 中,我們用直覺的比喻拆解了 WebGL 紋理取樣、Billboard 廣告看板與 onBeforeCompile 著色器動態受光的底層機制。

搞懂了像素本體之後,今天我們將視角拉遠,聚焦於本系列主題的另一大靈魂——即時光影系統(Real-Time Lighting)與後處理合成管線(Post-Processing Pipeline)。

在傳統 2D 像素遊戲中,光影通常是美術手繪在貼圖上的死板色塊;但 HD-2D 的迷人魅力,在於將復古像素浸泡在具備現代物理規則的真實動態光照中。許多人會好奇:

  • 3D 世界裡的陰影到底是怎麼算出來的?為什麼它極度消耗顯卡效能?
  • 為什麼有些遊戲的火把像死板的人工電燈泡,而我們的火把卻能呈現逼真的暖金色呼吸?
  • 後處理合成器(EffectComposer)究竟是用什麼魔法,把輝光、移軸微縮景深與電影暗角像濾鏡一樣層層疊加的?

今天我們將掀開渲染管線的第二層帷幕,用清晰好懂的邏輯,帶大家看懂這座地宮背後的光學奧秘!

一、即時動態陰影的本質:Shadow Map 深度相機與 PCF 軟陰影

在真實世界中,光子從光源出發,照亮物體並在背後留下陰影。但在即時渲染中,以追蹤每條光線路徑為原理的光線追蹤(Ray Tracing)成本過高,一般使用者的顯示卡難以負荷每秒 60 幀的運算。

WebGL 採用了一種非常聰明且經典的替代方案——陰影貼圖(Shadow Mapping)。

  1. 偷天換日:顯卡其實「偷偷拍了兩次照片」
    當我們開啟 renderer.shadowMap.enabled = true 時,GPU 在繪製一幀畫面時其實執行了兩次攝影工作:

    [第一道工序:光源視角]
    在天頂月光或火把處架設一台隱形相機,朝場景拍攝一張「黑白深度照片 (Depth Map)」。
    每個像素記錄的不是顏色,而是「該物體離光源有多近」。

    [第二道工序:玩家視角]
    相機回到玩家視角開始正常繪製石磚與牆壁。
    在畫每一個像素時,計算該點到光源的距離,並拿去跟深度照片比對:
    • 當前距離 <= 深度照片記錄的值 → 光照得到,正常著色!
    • 當前距離 > 深度照片記錄的值 → 中間有東西擋住了,塗黑變成陰影!

  2. 鋸齒狗啃 vs. PCFSoft 柔和筆觸

如果只進行單純的一對一深度比對(BasicShadowMap),陰影邊緣會出現嚴重的馬賽克鋸齒。

在專案中,我們啟用了 THREE.PCFSoftShadowMap(百分比漸近過濾軟陰影):

  • 原理:
    當顯卡判斷一個像素是否在陰影中時,不再只比對單一點,而是同時採樣周圍的多個鄰近深度點。
  • 效果:
    如果周圍有 80% 的採樣點被遮蔽,該處就呈現 80% 深度暗部;如果只有 20% 被遮蔽,則呈現淡淡的半影過渡。這讓石牆與立柱在地面上拉出的影子邊界呈現柔和擴散的筆觸,模擬自然界中光線漫射產生的真實軟陰影。
  1. 平行光 vs. 點光源:一張照片 vs. 六面體骰子
    我們在地宮中使用了兩種產生陰影的光源,它們在 GPU 底層的開銷完全不同:
  • 天頂月光(DirectionalLight):月光是平行光,光源在無限遠處,因此 GPU 只需要架設一台正交相機(Orthographic Camera)拍一張深度貼圖就能涵蓋整個地圖。
  • 隨身手持火把(PointLight):火把是點光源,光線以球狀向四周 $360^\circ$ 照射。為了記錄全方位的陰影,GPU 必須在火把核心架設 6 台相機,分別朝向前後左右上下,拼成一個立方體深度貼圖(Cube Shadow Map)!這相當於火把一個光源就佔用了 6 次深度渲染,這也是為什麼點光源陰影解析度必須精簡調校(如設為 512x512),才能維持極致的 60 FPS。

二、點燃真實火焰:黑體輻射色溫與多諧波呼吸算法

很多開發者在製作火把時,習慣直接給一個固定的亮橘色(例如 #ff8800),但這樣渲染出來的光斑在 3D 石磚上看起來會極度僵硬,宛如一顆人造橘色塑膠 LED 燈。

在 Day 18 的重構中,我們引入了兩個關鍵的光學要素,讓這盞火把擁有了生命力:

  1. 1850K 黑體輻射真實色溫(Blackbody Radiation)
    在物理光學中,高溫物體燃燒時發出的光譜遵循黑體輻射定律:
  • 溫度越低,波長越長,光色越偏深紅琥珀。

  • 溫度升高,能量激增,光色會轉化為明亮的暖金色與亮黃白。

我們捨棄了單一的人工純橘色,改用真實樹脂木質燃燒的三組黑體光譜矩陣:

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 深暖琥珀)
  1. 近物理平方反比衰減(Decay = 1.85)
    在真實世界中,點光源的照度會隨著距離的平方呈反比衰減,
    Three.js 的 PointLight 預設衰減係數常被設為 1.0(線性衰減),這會導致光斑邊緣有一圈明顯生硬的界線。我們將衰減度調校至 decay = 1.85(接近物理定律的 2.0),光線在靠近火焰處溫暖柔亮,向外擴散時平滑沒入地宮的幽暗冷霧中,徹底告別了「圓圈塑膠光罩感」。
  2. 三諧波疊加:模擬不可預測的自然氣流
    如果只是在渲染迴圈中寫 Math.sin(time),火焰的明暗變化會像機械心跳一樣規律死板。
    真實的火焰燃燒是受微風擾動影響的混亂現象。我們採用了三諧波疊加算法,使用三組互不整除的互質頻率正弦波疊加微動:
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)

三、後處理 EffectComposer:GPU 的「暗房洗照片流水線」

如果在瀏覽器中只執行 renderer.render(scene, camera),畫面就像拍立得一樣,相機快門按下的瞬間就直接烙印在 HTML Canvas 畫布上,無法進行二次加工。

在 Day 18 中,我們引入了 EffectComposer。它在顯卡記憶體中建立了一條後處理流水線,運作原理如同專業攝影師在「暗房沖洗照片」。

  1. 離屏幀緩衝區(Render Target)與 Ping-Pong 雙緩衝機制:
    在後處理管線中,畫面不會直接輸出到螢幕,而是繪製到記憶體中的離屏渲染目標(WebGLRenderTarget)。

但問題來了:當畫面經過多道濾鏡處理(例如:基礎場景 $\to$ 輝光 $\to$ 移軸景深 $\to$ 色調映射)時,顯卡不能「一邊讀取同一張畫布,一邊把處理後的結果塗在同一張畫布上」,這會引發嚴重的 GPU 讀寫衝突(Race Condition)。

EffectComposer 的底層採用了經典的 Ping-Pong(乒乓)雙緩衝機制:

[暗房工作檯上的兩張畫布:Buffer A 與 Buffer B]

  • 步驟 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!

兩張緩衝區就像打乒乓球一樣來回倒手,以極低的記憶體切換開銷,完成了多重電影濾鏡的串接。

  1. 選擇性輝光(UnrealBloomPass)的「0.98 門檻防護罩:
    輝光(Bloom)能營造夢幻的空氣漫射感,但如果配置不當,整個畫面會過曝泛白。
    我們將 Bloom 的亮度觸發門檻(Threshold)設定在極限的 0.98。

為什麼是 0.98?

  • 2D 像素主角的衣物亮部、淺色皮膚與地面石磚的高光,其反光係數最高只會達到 0.95 ~ 0.96。

  • 高門檻形成了一面精準的「防護罩」,把所有常規物件死死擋在發光名單之外,保證 2D 像素細節刀鋒銳利、絕不泛白模糊[cite: 4]。

  • 只有鍛鐵壁燈與火把火焰核心(自發光強度高達 1.6~2.3)能夠突破 0.98 的門檻,激發出自然柔和的漫射金暈!

  1. 自研融合型移軸景深與暗角著色器(TiltShiftVignetteShader)
    為了在單一通道內以 60 FPS 同時完成微縮模型感與電影暗角,我們編寫了融合型 GLSL 著色器:
// 片段著色器內部核心
// 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 世界中動態光影與後處理管線的運作本質:

  1. Shadow Map 深度相機:顯卡在光源處架設隱形相機拍照,透過深度數值比對與 PCF 鄰域採樣,繪製出邊界柔和的動態軟陰影。
  2. 黑體物理火光:擺脫人工塑膠單一橘色,以 1850K 黑體光譜、平方反比衰減與三諧波正弦波,重現不可預測的自然火焰呼吸。
  3. EffectComposer 雙緩衝暗房:透過 Ping-Pong 雙緩衝畫布來回調度,配合 0.98 的輝光高門檻與 9 採樣移軸著色器,在保證 2D 像素銳利的同時,疊加出精緻的微縮模型電影質感。

在掌握了幾何著色與動態光影兩大基石之後,在下一篇 Day 22 中,我們將把目光轉向系統的骨幹架構——離散網格到連續 3D 空間的平滑移動與流暢滑牆演算法(Wall Sliding),以及如何在瀏覽器中管理記憶體、避免垃圾回收(GC)頓挫,穩固維持極限 60 FPS!


上一篇
Day 20:技術底層精講(一)!用直覺看懂 Three.js Billboard、像素採樣與著色器偷天換日
下一篇
Day 22:技術底層精講(三)!空間碰撞與滑牆數學、零 GC 垃圾回收與 60 FPS 極限效能優化
系列文
Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言