iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

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

Day 18:注入靈魂!EffectComposer 後處理管線、UnrealBloom 輝光、移軸微縮景深與真實火光重構

  • 分享至 

  • xImage
  •  

在 Day 17 中,我們徹底攻克了 Sprite 著色器翻轉的技術盲區,完成了四向狀態機、火把左右換手、3.8 倍身形與平穩緊湊的指數阻尼運鏡。

然而,如果只依賴 WebGL 的原生單一渲染,畫面邊緣依然過於生硬,空氣中缺乏地下城特有的濕潤與深邃氛圍。觀察《歧路旅人》(Octopath Traveler)或《三角戰略》等經典 HD-2D 作品,其視覺精髓並非純粹堆疊 3D 模型面數,而在於電影級後期處理(Post-Processing)所帶來的「微縮模型感(Miniature Diorama)」與「夢幻光暈(Dreamy Glow)」。

今天我們將正式引入 Three.js 的後處理管線 EffectComposer,實裝高規格的畫面特效,並重構隨身火把的物理光學,徹底告別傳統 WebGL 的生硬塑膠感:

  • Unreal Engine 級別的選擇性輝光(UnrealBloomPass):以極限高門檻(0.98)精準過濾 2D 像素角色,只讓鍛鐵壁燈與火把火焰漫射出溫暖柔焦。
  • 自研融合型移軸景深與電影暗角複合著色器(TiltShiftVignetteShader):單一 Pass 同時完成縱向 9 採樣微縮散景與邊緣壓暗,維持極致 60 FPS。
  • 終端色彩空間與色調校正(OutputPass):確保 ACESFilmic 色調映射在濾鏡疊加後完美呈現。
  • 火把火焰真實物理定位與 1850K 黑體輻射光學重構:將光源由手腕調升至頭頂火焰核心($y=2.82$),導入近物理平方反比衰減與動態色溫微移。

本日核心開發

  • 目標組裝四階 EffectComposer 後處理管線:以 RenderPass $\to$ UnrealBloomPass $\to$ TiltShiftVignetteShader $\to$ OutputPass 建立完整的後處理流水線。
  • 調校選擇性輝光防護門檻:將 threshold 設為 0.98,在激發鍛鐵壁燈光暈的同時,徹底避免 2D 像素精靈圖高光被誤曝白化。
  • 編寫融合型移軸景深與暗角著色器:自訂 GLSL 著色器,以單次全螢幕繪製取代兩次 Pass,大幅減少 GPU 頻寬消耗。
  • 火把火焰空間重定位與黑體色溫矩陣:修正手持火把至頭部上方火焰位置,實作 1700K ~ 2150K 的動態燃燒色溫微移與火舌垂直微幅震顫。
  • 環境光能見度平衡與管線交接:微調夜色環境底光與迷霧,將視窗 resize 與渲染迴圈全面移交給 composer.render()。

【Step 1:導入後處理模組與組裝四階流水線】

在原生渲染流程中,畫面會直接繪製至 HTML Canvas 上;引入後處理後,畫面會先輸出到記憶體中的離屏幀緩衝區(Render Target),依序通過多重濾鏡通道(Pass)運算後才呈現在螢幕上。

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

接著在建立 renderer 之後,初始化 EffectComposer 實例:

// 1) 建立後處理合成器 (EffectComposer)
const composer = new EffectComposer(renderer);

// 通道 1:基礎場景渲染通道 (RenderPass)
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

【Step 2:UnrealBloomPass 選擇性輝光與像素防護門檻】

若直接套用大範圍泛光,地面的石磚高光與 2D 主角淺色衣服會一同發光,導致畫面死白一片。HD-2D 的核心在於「選擇性輝光(Selective Bloom)」——僅讓自發光係數極高的光源本體漫射光芒。

// 參數依序為:解析度向量、輝光強度 (strength)、漫射半徑 (radius)、發光閾值 (threshold)
const bloomPass = new UnrealBloomPass(
  new THREE.Vector2(sizes.width, sizes.height),
  0.75, // strength:柔和擴散強度
  0.50, // radius:漫射半徑
  0.98  // threshold:關鍵高門檻
);
composer.addPass(bloomPass);

【Step 3:自研融合型移軸景深與暗角著色器(TiltShiftVignetteShader)】

如果將景深(Depth of Field)與電影暗角(Vignette)拆成兩個獨立的 ShaderPass,GPU 必須執行兩次全螢幕 Blit 與紋理讀寫切換。

我們將 縱向 9 採樣高斯移軸散景 與 電影級柔和暗角 融合為單一著色器通道,在確保 60 FPS 流暢運行的同時,打造微縮模型般的景深聚焦感:



// 通道 3:自研移軸微縮景深 + 電影暗角複合著色器
const TiltShiftVignetteShader = {
  name: 'TiltShiftVignetteShader',
  uniforms: {
    'tDiffuse': { value: null },
    'focusPos': { value: 0.47 },        // 垂直焦點中心 (Y 軸比例 0~1,主角上半身與火焰約在 0.47)
    'focusRange': { value: 0.22 },      // 完全清晰的中心聚焦頻寬 (在此範圍內維持 100% 銳利像素無模糊)
    'blurAmount': { value: 1.25 },      // 移軸模糊擴散強度 (細膩散景半徑,消除生硬模糊)
    'v': { value: 1.0 / sizes.height }, // 像素垂直尺寸 (resize 時動態更新)
    'offset': { value: 1.05 },          // 暗角擴散半徑
    'darkness': { value: 1.1 }          // 暗角深度強度
  },
  vertexShader: /* glsl */`
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    }
  `,
  fragmentShader: /* glsl */`
    uniform sampler2D tDiffuse;
    uniform float focusPos;
    uniform float focusRange;
    uniform float blurAmount;
    uniform float v;
    uniform float offset;
    uniform float darkness;

    varying vec2 vUv;

    void main() {
      // 1. 計算離聚焦點垂直距離,並透過 smoothstep 建立寬廣清晰聚焦帶與柔和邊緣失焦
      float dist = abs( vUv.y - focusPos );
      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;
      sum += texture2D( tDiffuse, vec2( vUv.x, vUv.y - 2.0 * vv ) ) * 0.12245;
      sum += texture2D( tDiffuse, vec2( vUv.x, vUv.y - 1.0 * vv ) ) * 0.1531;
      sum += texture2D( tDiffuse, vec2( vUv.x, vUv.y ) ) * 0.1633;
      sum += texture2D( tDiffuse, vec2( vUv.x, vUv.y + 1.0 * vv ) ) * 0.1531;
      sum += texture2D( tDiffuse, vec2( vUv.x, vUv.y + 2.0 * vv ) ) * 0.12245;
      sum += texture2D( tDiffuse, vec2( vUv.x, vUv.y + 3.0 * vv ) ) * 0.0918;
      sum += texture2D( tDiffuse, vec2( vUv.x, vUv.y + 4.0 * vv ) ) * 0.0510;

      // 3. 電影級柔和暗角 (Vignette):聚焦玩家目光於中央探索動線
      vec2 uv = ( vUv - vec2( 0.5 ) ) * vec2( offset );
      float vignetteDist = dot( uv, uv );
      float vignetteFactor = clamp( 1.0 - vignetteDist * darkness, 0.0, 1.0 );
      vignetteFactor = smoothstep( 0.0, 1.0, vignetteFactor );

      vec3 finalColor = sum.rgb * vignetteFactor;
      gl_FragColor = vec4( finalColor, sum.a );
    }
  `
};

const tiltShiftVignettePass = new ShaderPass(TiltShiftVignetteShader);
composer.addPass(tiltShiftVignettePass);

// 通道 4:色彩空間與色調映射輸出通道 (OutputPass)
// 確保 ACESFilmic 色調映射與 sRGB 色彩轉換在所有濾鏡疊加後正確執行
const outputPass = new OutputPass();
composer.addPass(outputPass);

【Step 4:火把空間重定位與黑體輻射光學重構】

在 Day 17 中,隨身火把光源位於手腕位置($y=1.8$),且使用單一人工橘色與線性衰減。在 3.8 倍比例的角色模型中,真正的火焰燃燒點位於頭部上方($y=2.82$)。我們將光源精準重定位,並採用符合物理光學的 1850K 黑體輻射真實暖金火燭色 與 近平方反比衰減(decay = 1.85):

// 火把光源由手部高度 (y=1.8) 精確調升至火把頂端的火焰核心位置 (y=2.82, x=±0.40)
const torchOffsetX = 0.40; // 左右手持火把 X 軸偏移量
const torchOffsetY = 2.82; // 火把火焰所在高度 (頭頂火焰核心)
const torchOffsetZ = 0.18; // 向前微推,使光暈自然灑在身前與地面

// 真實木質樹脂火把燃燒色溫 (約 1800K ~ 2100K 黑體輻射真實暖金火光)
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 深暖琥珀)

// 採用近物理平方反比衰減 (decay = 1.85),近處柔和溫暖、遠處平滑過渡至地城幽暗
const playerLight = new THREE.PointLight(TORCH_COLOR_BASE.clone(), 6.2, 11.5, 1.85);
playerLight.position.set(torchOffsetX, torchOffsetY, torchOffsetZ);
playerLight.castShadow = true;
playerLight.shadow.bias = -0.002;
playerLight.shadow.mapSize.width = 512;
playerLight.shadow.mapSize.height = 512;
playerGroup.add(playerLight);

同時,在 updatePlayer(dt) 內更新火把換手邏輯:

// 隨身火把動態換手:保持火焰頂端高度 y=torchOffsetY
    playerLight.position.x = facingLeft ? -torchOffsetX : torchOffsetX;
    playerLight.position.y = torchOffsetY;
    playerLight.position.z = torchOffsetZ;

【Step 5:三諧波火焰呼吸、動態色溫微移與迴圈交接】

真實的火把受氣流吹拂時,除了明暗呼吸,還會產生微小的垂直抖動與色溫偏移。我們在 animate() 中使用三組非諧波頻率(8.5, 17.1, 31.3)模擬自然火焰:

function animate() {
  requestAnimationFrame(animate);

  const dt = Math.min(clock.getDelta(), 0.1);
  const elapsedTime = clock.getElapsedTime();

  // 壁燈燭火微動搖曳
  animatedLights.forEach(item => {
    const flicker = Math.sin(elapsedTime * item.speed + item.phase) * 0.35 +
                    Math.cos(elapsedTime * item.speed * 2.1 + item.phase) * 0.2;
    item.light.intensity = item.baseIntensity + flicker;
  });

  // 主角手持火把真實複合頻率火焰呼吸與微動 (Realistic Multi-harmonic Torch Flame Flicker)
  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)

  // 1) 動態火光強度:基準強度 6.2,隨脈動自然起伏
  playerLight.intensity = 6.2 + (f1 + f2 + f3);

  // 2) 真實火焰色溫動態微移 (旺盛竄升時偏向明亮暖金 2150K,受風吹曳時偏向深暖琥珀 1700K)
  if (flamePulse >= 0) {
    playerLight.color.copy(TORCH_COLOR_BASE).lerp(TORCH_COLOR_FLARE, flamePulse * 0.75);
  } else {
    playerLight.color.copy(TORCH_COLOR_BASE).lerp(TORCH_COLOR_EMBER, -flamePulse * 0.75);
  }

  // 3) 火焰頂端隨氣流微幅垂直跳動 (Subtle Flame Tip Jitter)
  playerLight.position.y = torchOffsetY + Math.sin(elapsedTime * 14.0) * 0.02;

  // 更新主角物理移動、狀態機與動畫
  updatePlayer(dt);

  // 指數阻尼相機追蹤
  const camFollowSpeed = 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.copy(cameraTarget).add(cameraOffset);
  camera.lookAt(cameraTarget);

  // 由 EffectComposer 後處理管線接管畫面渲染輸出
  composer.render();
}

animate();

最後,在視窗縮放監聽器中同步後處理緩衝區與著色器尺寸:

window.addEventListener('resize', () => {
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();

  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

  // 同步更新 EffectComposer 尺寸、像素比與著色器像素垂直跨度
  composer.setSize(sizes.width, sizes.height);
  composer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  tiltShiftVignettePass.uniforms['v'].value = 1.0 / sizes.height;
});

【本日結語】
至此,整座地宮在視覺表現上迎來了質的飛躍。壁燈與隨身火把散發出溫潤柔和的微光漫射,0.98 的防護閾值徹底保住了 2D 像素主角的純粹與銳利,移軸微縮景深與電影暗角為畫面注入了如夢似幻的空氣立體感。
明天會繼續增加畫面的細節使其更加擁有HD-2D風格,大家敬請期待!
image


上一篇
Day 17:修復方向盲區!Three.js Sprite 著色器翻轉真相、四向朝向狀態機、火把動態換手與指數平滑運鏡
下一篇
Day 19:告別虛空黑洞與貼紙感!遠景多層次古牆、腳底動態接觸陰影與著色器雙向擬真受光
系列文
Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言