iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Modern Web

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

Day 19:告別虛空黑洞與貼紙感!遠景多層次古牆、腳底動態接觸陰影與著色器雙向擬真受光

  • 分享至 

  • xImage
  •  

在 Day 18 實裝後處理管線與移軸景深著色器後,實機測試暴露出幾個破壞沉浸感的關鍵視覺缺陷:

  • 鏡頭邊緣模糊「完全看不出來」:地圖北方缺乏建築結構,鏡頭朝下俯視時上方是一片漆黑的虛空深淵。著色器在純黑像素上運算高斯模糊,結果依然是純黑,使得精心配置的移軸微縮景深無從顯現。
  • 角色如地窖幽靈般「飄浮懸空」:火把光源位於頭頂上方,地面缺乏直下的環境光遮蔽暗影(Ambient Occlusion),2D 像素角色看起來漂浮在石磚上方數公分。
  • SpriteMaterial 的「發光貼紙感」:原生 SpriteMaterial 屬於 Unlit(不受光)材質,角色在陰暗古堡中依然以 100% 原始色彩輸出。手握熊熊烈火,身體卻沒有受火光映照,背光面也沒有陰影壓暗,宛如一張黏在螢幕上的發光塑膠貼紙。
  • 全域光線過於死黑:原有的環境光偏暗且電影暗角過重,壓制了石磚縫隙與青苔紋理的細節呈現。

今天我們將針對這四大視覺痛點進行全方位重構,點亮清冷月光底色,築起 7 層高聳的遠景古牆與中景城垛,導入程序化高斯接觸陰影,並透過 onBeforeCompile 著色器注入技術,讓 2D 像素紙片人具備擬真的立體雙向受光!

【本日核心開發目標】

  • 重構全域冷調月光環境:提升環境底光與斜射月光照度,降低迷霧遮蔽率,營造「冷調月光底色 + 暖金火把」的經典電影光學對比。
  • 多層次遠景巨牆與背景壁燈:在地圖北側生成 7 層高聳主城堡古牆與城垛,為移軸著色器提供高解析度的散景物體,並藉由背景壁燈激發夢幻的暖光光斑(Bokeh)。
  • 優化移軸散景與暗角參數:提升模糊擴散強度至 2.1,柔化暗角深度至 0.82,防止畫面邊角死黑。
  • 程序化高斯接觸陰影(Blob Contact Shadow):使用 Canvas 2D 動態繪製柔和陰影貼片,配合角色奔跑實作踏步形變(Squash & Stretch)。
  • GPU 著色器雙向受光注入(onBeforeCompile):迎火側受暖金烘托、背光側沉入冷藍陰影,並隨火焰脈動共振,徹底消滅貼紙感。
【Step 1:重構全域清冷月光與地城夜色底光】

移軸景深與電影暗角需要足夠的環境信息才能展現層次。若大環境死黑一片,任何後期濾鏡都會失去作用。我們調升了深藍環境光與冷調月光強度,並將指數迷霧濃度減半(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);

【Step 2:鋪設多層次遠景巨牆、中景城垛與散景壁燈】

移軸微縮景深(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 }
);

【Step 3:優化移軸景深與電影暗角複合著色器參數】

為了讓虛化效果更顯著,同時避免過強的暗角將邊緣石牆切成死黑,微調 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 }      // 暗角深度強度 (大幅柔化,防止四角死黑)
  },
      

【Step 4:實裝角色腳底動態接觸陰影(Blob Contact Shadow)】

在真實物理環境中,物體與地面貼合時會產生局部環境光遮蔽(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);

【Step 5:消除 SpriteMaterial 貼紙感(GPU 著色器雙向受光注入)】

由於 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;
    `
  );
};

【Step 6:渲染迴圈(animate)動態聯動】

在主渲染迴圈 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 風格的精髓在這個主題中已經全部完整展現了出來。某種意義上,地宮原型與視覺機制的實裝在今天已經大功告成!從明天開始,我們將正式進入【核心技術精講篇】,第一站就帶大家深入拆解「像素幾何與著色器黑魔法」!
Adobe Express - day19


上一篇
Day 18:注入靈魂!EffectComposer 後處理管線、UnrealBloom 輝光、移軸微縮景深與真實火光重構
系列文
Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言