在 Day 18 實裝後處理管線與移軸景深著色器後,實機測試暴露出幾個破壞沉浸感的關鍵視覺缺陷:
今天我們將針對這四大視覺痛點進行全方位重構,點亮清冷月光底色,築起 7 層高聳的遠景古牆與中景城垛,導入程序化高斯接觸陰影,並透過 onBeforeCompile 著色器注入技術,讓 2D 像素紙片人具備擬真的立體雙向受光!
移軸景深與電影暗角需要足夠的環境信息才能展現層次。若大環境死黑一片,任何後期濾鏡都會失去作用。我們調升了深藍環境光與冷調月光強度,並將指數迷霧濃度減半(0.018 $\to$ 0.009),讓遠處的要塞城垛剪影能穿透薄霧呈現在鏡頭視野中:
// 1) 深藍地城夜色背景與清透指數迷霧
scene.background = new THREE.Color('#0e1526');
scene.fog = new THREE.FogExp2('#0e1526', 0.009); // 降低濃霧阻擋,透出遠景城垛夜色剪影
// 2) 冷調月色環境底光 (徹底消除畫面死黑,讓石磚細節與遠景古牆一覽無遺)
const ambientLight = new THREE.AmbientLight('#324364', 1.45);
scene.add(ambientLight);
// 3) 天頂清冷月光 / 斜射主光 (清晰勾勒遠景城垛、石磚立面與長條投影)
const dirLight = new THREE.DirectionalLight('#6882b4', 1.65);
dirLight.position.set(22, 36, 18);
dirLight.castShadow = true;
// ... (陰影相機參數維持不變)
scene.add(dirLight);
// 4) 天地半球微光 (大幅增強垂直牆面的立體過渡)
const hemiLight = new THREE.HemisphereLight('#354870', '#182030', 0.85);
scene.add(hemiLight);
移軸微縮景深(Tilt-Shift)的本質是模擬微距攝影的淺景深。相機焦點鎖定在中央主角時,畫面頂部必須有距離相機較遠的 3D 結構體,才能被著色器模糊成散景。
我們在地圖北端(Row 04)築起高達 7 層方塊的主城堡巨牆,在 Row 79 建立中景迴廊殘壁,並在牆頭點綴 4 盞壁燈:
// ==========================================
// 生成多層次遠景巨牆、中景拱門與微縮背景 (徹底啟動移軸景深)
// ==========================================
// 1) 北方 7 層高聳主城堡古牆 (Row 0~4),帶有城垛起伏
for (let c = 0; c < gridCols; c++) {
for (let r = 0; r <= 3; r++) {
mapGrid[r][c] = 5;
wallHeightMap[r][c] = 7; // 高達 7 層方塊的巨型要塞主牆
}
// Row 4 作為城垛邊沿 (凹凸交錯起伏)
mapGrid[4][c] = 5;
wallHeightMap[4][c] = (c % 2 === 0) ? 7 : 6;
}
// 2) 北側中景迴廊殘壁 (Row 7~9,位於大道兩側,形成景深多重遞進層次)
for (let r = 7; r <= 9; r++) {
for (let c = 8; c <= 20; c++) {
if (c === 14 && r === 8) continue; // 保留拱門通道
mapGrid[r][c] = 5;
wallHeightMap[r][c] = (c % 3 === 0) ? 5 : 4;
}
for (let c = 28; c <= 40; c++) {
if (c === 34 && r === 8) continue; // 保留拱門通道
mapGrid[r][c] = 5;
wallHeightMap[r][c] = (c % 3 === 0) ? 5 : 4;
}
}
// 3) 在遠景與中景高牆上增設壁燈 (在移軸散景中形成夢幻暖光光斑 Bokeh)
lanternPositions.push(
{ r: 4, c: 14 },
{ r: 4, c: 34 },
{ r: 9, c: 12 },
{ r: 9, c: 36 }
);
為了讓虛化效果更顯著,同時避免過強的暗角將邊緣石牆切成死黑,微調 TiltShiftVignetteShader 的 Uniform 參數:
uniforms: {
'tDiffuse': { value: null },
'focusPos': { value: 0.46 }, // 垂直焦點中心 (精準對位主角胸口視高)
'focusRange': { value: 0.20 }, // 完全清晰的中心聚焦頻寬 (保持角色與走廊路徑銳利)
'blurAmount': { value: 2.1 }, // 移軸模糊擴散強度 (提升至 2.1,散景顯著柔和)
'v': { value: 1.0 / sizes.height },
'offset': { value: 1.15 }, // 暗角擴散半徑 (向外擴張,避免遮蔽四周建築)
'darkness': { value: 0.82 } // 暗角深度強度 (大幅柔化,防止四角死黑)
},
在真實物理環境中,物體與地面貼合時會產生局部環境光遮蔽(Ambient Occlusion)。我們透過純 Canvas 2D 程序化生成一張高斯衰減的同心圓暗影貼圖,建構一個隨角色移動的貼地暗影面片:
// 角色腳底動態接觸陰影 (Blob Contact Shadow)
function createContactShadowTexture() {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
// 中心飽和柔和、外圍高斯擴散羽化的接觸暗影
const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
gradient.addColorStop(0, 'rgba(5, 7, 15, 0.80)');
gradient.addColorStop(0.35, 'rgba(5, 7, 15, 0.52)');
gradient.addColorStop(0.70, 'rgba(5, 7, 15, 0.18)');
gradient.addColorStop(1, 'rgba(5, 7, 15, 0.0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 128, 128);
const tex = new THREE.CanvasTexture(canvas);
return tex;
}
const shadowTex = createContactShadowTexture();
const shadowGeo = new THREE.PlaneGeometry(1.65, 0.95);
const shadowMat = new THREE.MeshBasicMaterial({
map: shadowTex,
transparent: true,
opacity: 0.65,
depthWrite: false // 杜絕深度排序穿透與 Z-fighting
});
const playerShadow = new THREE.Mesh(shadowGeo, shadowMat);
playerShadow.rotation.x = -Math.PI / 2; // 水平躺平在 X-Z 平面
// 貼地高度 y = 0.02 (相對於 playerGroup,世界座標 y = 1.02,略高於地板 1.0,完全消除閃爍)
playerShadow.position.set(0, 0.02, 0);
playerGroup.add(playerShadow);
由於 SpriteMaterial 無法像 MeshStandardMaterial 一樣接收 PBR 光照運算,角色在暗處會顯得格格不入。我們使用 Three.js 的 onBeforeCompile 機制,在底層 WebGL 片段著色器中注入光學受光梯度:
// 消除 SpriteMaterial 貼紙感:動態雙色受光著色器注入
// 讓面向火把側受暖金火光烘托、背向火把側沉入地城冷調深藍,大幅增強 3D 體積融合感
playerMat.customProgramCacheKey = () => 'HD2D_PlayerMat_Lit';
playerMat.onBeforeCompile = (shader) => {
shader.uniforms.uTorchSide = { value: 1.0 }; // +1.0: 火把在右側, -1.0: 火把在左側
shader.uniforms.uFlamePulse = { value: 0.0 }; // 火焰燃燒脈動
playerMat.userData.shader = shader;
// 1. 在頂點著色器中捕捉精靈圖四邊形未裁切前的原始局部橫向座標 (0.0 ~ 1.0)
shader.vertexShader = `
varying float vLocalX;
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
'#include <uv_vertex>',
`
#include <uv_vertex>
vLocalX = uv.x;
`
);
// 2. 在片段著色器中實裝雙向受光衰減與火光共振
shader.fragmentShader = `
uniform float uTorchSide;
uniform float uFlamePulse;
varying float vLocalX;
` + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(
'#include <map_fragment>',
`
#include <map_fragment>
// 計算水平受光比例:靠近火把端為 1.0,背向火把端為 0.0
float torchFacing = (uTorchSide > 0.0) ? vLocalX : (1.0 - vLocalX);
// 迎光側 (靠近火焰):暖金火光烘托 (最高值嚴格控管在 0.98 以下,杜絕觸發 Bloom 產生白芒)
vec3 warmLight = vec3(0.97, 0.90, 0.78) + vec3(0.04, 0.03, 0.01) * uFlamePulse;
// 背光側 (背離火焰):融入地城冷調深藍暗部
vec3 coolShadow = vec3(0.70, 0.74, 0.86);
// 依據角色身體圓柱曲面進行平滑過渡
float lightMix = smoothstep(0.12, 0.88, torchFacing);
vec3 characterLighting = mix(coolShadow, warmLight, lightMix);
diffuseColor.rgb *= characterLighting;
`
);
};
在主渲染迴圈 animate() 中,將火焰動態脈動、角色轉向旗標與腳底接觸陰影即時綁定:
// 更新角色材質雙向擬真受光 Uniform (面向火把暖金,背離火把深藍)
if (playerMat.userData.shader) {
playerMat.userData.shader.uniforms.uTorchSide.value = facingLeft ? -1.0 : 1.0;
playerMat.userData.shader.uniforms.uFlamePulse.value = flamePulse;
}
// 角色腳底動態接觸陰影呼吸與步伐形變 (Squash & Stretch)
if (currentAnim === 'run') {
// 跑步時隨步伐產生高頻踏步擠壓與拉伸
const shadowStep = Math.sin(elapsedTime * 18.0) * 0.08;
playerShadow.scale.set(1.0 + shadowStep, 1.0 - shadowStep * 0.5, 1.0);
playerShadow.material.opacity = 0.62 + shadowStep * 0.08;
} else {
// 待機時微弱呼吸起伏
const shadowBreath = Math.sin(elapsedTime * 3.5) * 0.03;
playerShadow.scale.set(1.0 + shadowBreath, 1.0 + shadowBreath, 1.0);
playerShadow.material.opacity = 0.65;
}
經過十九天的打磨,我們的 HD-2D 地宮在立體縱深、踏地陰影與雙向動態受光實裝後,視覺與操控體驗已臻於成熟。但比起單純拼湊功能,更重要的是理解背後的圖形學原理——像素在 3D 透視下為何能保持銳利?廣告看板(Billboard)如何解耦視圖矩陣?不受光的材質又是如何透過字串注入動態光影?為了真正切合《Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統》的主題,我們不僅要能做出畫面,更要看懂引擎蓋底下的底層機制。
雖然成品要達到真正的 HD-2D 風格遊戲還有一大步要邁進,但是 HD-2D 風格的精髓在這個主題中已經全部完整展現了出來。某種意義上,地宮原型與視覺機制的實裝在今天已經大功告成!從明天開始,我們將正式進入【核心技術精講篇】,第一站就帶大家深入拆解「像素幾何與著色器黑魔法」!