iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Modern Web

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

Day 20:技術底層精講!用直覺看懂 Three.js Billboard、像素採樣與著色器偷天換日

  • 分享至 

  • xImage
  •  

在前十九天的旅程中,我們從幾塊地磚起步,打造出了多層次古堡、動態火把、四向移動狀態機、移軸微縮景深與角色動態受光。 然而,如果只是看著一行行程式碼跑出漂亮的畫面,很容易陷入「知其然而不知其所以然」的困境:

  • 為什麼 16x16 的小像素圖放進 3D 世界裡,預設會變得像糊掉的凡士林?
  • 為什麼 2D 紙片人能像「向日葵」一樣,永遠對著攝影機視線?
  • 為什麼在 Day 17 想讓角色轉身時,寫了 scale.x = -1 卻完全無效?
  • 為什麼原本完全不受光的 SpriteMaterial,我們能靠幾行字串偷換,讓它長出立體受光?

今天我們不堆砌繁複的線性代數矩陣,而是用最直觀的物理比喻與關鍵邏輯,揭開 Three.js 與 WebGL 著色器背後的底層秘密!

一、WebGL 紋理採樣:拿掉「凡士林抹布」,捍衛像素尊嚴

在常規 3D 大作中,引擎恨不得把所有貼圖都抹得平滑無比;但在 HD-2D 的世界裡,「顆粒分明、輪廓銳利的像素邊界」才是靈魂所在。

預設的「凡士林抹布」(LinearFilter)

當相機靠近角色或地面時,螢幕上的一格像素往往需要顯示原本貼圖裡的極小區塊。

  • GPU 預設的想法:它覺得一格一格的馬賽克是「鋸齒瑕疵」,所以很貼心地拿了一條沾滿凡士林的抹布,把相鄰像素的顏色揉在一起抹勻(雙線性插值)。
  • 後果:原本帥氣的復古像素圖,瞬間看起來像相機失焦、霧化模糊。

我們的解法:用放大鏡看十字繡(NearestFilter)

在專案中,我們封裝的 loadPixelTexture 做了兩件關鍵設定:

function loadPixelTexture(url) {
  const tex = textureLoader.load(url);
  tex.magFilter = THREE.NearestFilter; // 放大時:直接抓最靠近的顏色,嚴禁抹勻
  tex.minFilter = THREE.NearestFilter; // 縮小時:同樣保持純粹
  tex.generateMipmaps = false;         // 核心關鍵:關閉縮小金字塔
  return tex;
}

二、Billboard(廣告看板):永遠盯著鏡頭的向日葵

在前幾天中,我們使用 THREE.Sprite 建立了主角。為什麼它不需要在每一幀用 JavaScript 計算旋轉角度,就能在相機傾斜時永遠站得直挺挺的面對玩家呢?

一般 3D 物件 vs. 向日葵 Sprite

  • 一般的平面幾何體(PlaneGeometry):
    就像一張插在泥土裡的紙牌。相機從斜上方 28 度看下去,你看到的就是一張「快要躺平的薄紙邊緣」。
  • Sprite 的直覺原理:它就像一朵永遠盯著太陽轉的向日葵。

GPU 做了什麼手腳?

打開 Three.js 底層的著色器,它用了一種巧妙的拆解技巧:

  1. 先定位錨點:GPU 只把主角的「腳底位置(Position)」算進 3D 空間中。
  2. 在相機鏡頭玻璃上畫畫:接著,它不在 3D 空間裡旋轉這張紙,而是直接把角色的四邊形貼在相機前方的二維平面上畫出來。

因為繪製四邊形時根本不參與 3D 視角旋轉,所以不論攝影機如何俯視、仰視或平移,角色永遠垂直挺拔地正對玩家螢幕,而且完全不耗費 CPU 的旋轉運算效能!

三、為什麼角色不能轉身?——世界上沒有「負幾公分的尺」

在 Day 17 中,我們曾遇過一個嚴重的 Bug:想讓角色向左走,我們把 X 軸寬度設為負值(scale.x = -playerBaseScale),結果精靈圖完全不轉身,依然固執地向右看。

  1. Bug 的真相:GPU 的那把尺
    一般的 3D 模型把寬度乘上 -1 確實會左右翻轉,但 THREE.Sprite 內部為了防止看板在旋轉時縮放形變,在 GPU 著色器裡寫了這行邏輯:
    image
    關鍵就在 length()! 在數學上,向量長度就是拿尺去量距離。世界上沒有「長度是負 2 公分」的尺,量出來的數值永遠是非負的正數(絕對值)。因此,不論在 JavaScript 裡傳入 -2.8 還是 -100,GPU 量完長度後都硬生生把它轉回了正數,角色自然永遠朝右!

  2. 我們的解法:放映機底片反著裝(UV 逆向採樣)

既然動不了骨架尺寸,我們就直接動貼圖——把放映機裡的底片左右倒過來裝!

  • 正常向右跑:
    貼圖寬度為正(repeat.x = 1 / 幀數),起點在左邊。著色器「由左向右」讀取圖片,顯示正常面向。
  • 鏡像向左跑:
    我們把貼圖寬度設為負(repeat.x = -1 / 幀數),並把起點推到畫格的最右側:
if (facingLeft) {
  // 底片反著裝:起點移到右邊界,由右向左倒序讀取像素!
  playerMat.map.repeat.x = -1 / totalFrames;
  playerMat.map.offset.x = (currentFrame + 1) / totalFrames;
} else {
  // 正常正向讀取
  playerMat.map.repeat.x = 1 / totalFrames;
  playerMat.map.offset.x = currentFrame / totalFrames;
}

骨架尺寸依然維持正向(滿足 GPU 的測量尺),但像素著色器在貼圖時被我們騙了,改從右往左倒著貼,零成本達成了完美的水平轉身

四、onBeforeCompile:趁主廚端麵前,偷偷塞一顆溫泉蛋

在 Day 19 之前,我們的角色就像一張「發光的塑膠貼紙」:手中拿著大火把,四周石牆與地面都被打亮了,但角色自己的身體卻完全不受光,背光面也不會變暗。

這是因為 Three.js 的 SpriteMaterial 天生就是 Unlit(不受光材質)。

  1. 為什麼不自己重寫一個 ShaderMaterial?
    手寫一個全新的著色器材質,就像「為了吃一碗牛肉麵,得自己去養牛、種小麥、揉麵糰」。你必須自己親手處理霧氣計算、投影矩陣、色彩色調映射等上千行底層邏輯,既費時又容易出錯。

  2. onBeforeCompile 的妙用
    Three.js 已經幫我們把官方材質的頂級程式碼寫好了。onBeforeCompile 是它留下的外掛介面——就像大廚剛把牛肉麵煮好、正準備端上桌(編譯給顯示卡)的前一秒,我們把它攔下來,偷偷往湯裡加一顆我們自製的「動態光影溫泉蛋」!

playerMat.onBeforeCompile = (shader) => {
  // 攔截片段著色器,置換掉原本純貼圖輸出的邏輯:
  shader.fragmentShader = shader.fragmentShader.replace(
    '#include <map_fragment>',
    `
    #include <map_fragment>
    
    // 1. 判斷像素靠火把近,還是靠火把遠
    float torchFacing = (uTorchSide > 0.0) ? vLocalX : (1.0 - vLocalX);

    // 2. 迎火側給予暖金光、背火側給予冷月藍[cite: 7]
    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);

    // 3. 圓柱體漸變:中間平滑過渡,讓紙片人看起來像有身體厚度[cite: 7]
    float lightMix = smoothstep(0.12, 0.88, torchFacing);
    vec3 characterLighting = mix(coolShadow, warmLight, lightMix);

    // 4. 將原本的貼圖色彩乘上我們算好的光影![cite: 7]
    diffuseColor.rgb *= characterLighting;
    `
  );
};
  1. 光學效果解構
    身體局部座標(vLocalX):
    我們在頂點著色器中記住角色的左側(0.0)到右側(1.0),不管它切換到哪一個動作畫格,我們都能精準知道「身體的哪一邊朝向火把」。

    圓柱體弧形過渡(smoothstep):
    如果只是粗暴地讓一邊亮、一邊暗,角色看起來依然平扁。smoothstep 提供了如同球體與圓柱體表面的圓弧光影過渡,讓 2D 紙片人瞬間產生了「胸膛圓潤挺拔」的 3D 體積感!

    動態火焰共振(uFlamePulse):
    手中的火把跳動時,迎光側的金色光輝會跟著輕微呼吸,與整座地宮的光影節奏融為一體。

【本日結語】
回顧今天的內容,我們完全不需要死記硬背複雜的矩陣公式,也能掌握圖形學的核心思想:

  1. 像素保真:拿掉模糊抹布(NearestFilter),關掉縮小金字塔(Mipmap),換回刀鋒般的像素邊緣
  2. Billboard 看板:把向日葵的根釘在地上,花朵直接畫在鏡頭前。
  3. 鏡像轉身:既然世界上沒有負長度的尺,我們就反著裝放映機底片(UV 逆向)。
  4. 著色器注入:用 onBeforeCompile 攔截上菜管線,用圓柱體光影為不受光的紙片人注入立體靈魂!

搞懂了像素本體與著色器之後,在下一篇 Day 21 中,我們將把聚光燈投向整座地下城的空間光影:即時動態陰影(PCFSoftShadowMap)是怎麼在 GPU 裡比對深度的?黑體輻射火光與後處理合成管線(EffectComposer)又是如何像暗房洗照片一樣疊加出電影質感的? 我們明天見!


上一篇
Day 19:告別虛空黑洞與貼紙感!遠景多層次古牆、腳底動態接觸陰影與著色器雙向擬真受光
系列文
Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言