在 Day 17 中,我們徹底攻克了 Sprite 著色器翻轉的技術盲區,完成了四向狀態機、火把左右換手、3.8 倍身形與平穩緊湊的指數阻尼運鏡。
然而,如果只依賴 WebGL 的原生單一渲染,畫面邊緣依然過於生硬,空氣中缺乏地下城特有的濕潤與深邃氛圍。觀察《歧路旅人》(Octopath Traveler)或《三角戰略》等經典 HD-2D 作品,其視覺精髓並非純粹堆疊 3D 模型面數,而在於電影級後期處理(Post-Processing)所帶來的「微縮模型感(Miniature Diorama)」與「夢幻光暈(Dreamy Glow)」。
今天我們將正式引入 Three.js 的後處理管線 EffectComposer,實裝高規格的畫面特效,並重構隨身火把的物理光學,徹底告別傳統 WebGL 的生硬塑膠感:
在原生渲染流程中,畫面會直接繪製至 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);
若直接套用大範圍泛光,地面的石磚高光與 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);
如果將景深(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);
在 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;
真實的火把受氣流吹拂時,除了明暗呼吸,還會產生微小的垂直抖動與色溫偏移。我們在 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風格,大家敬請期待!