在前十九天的旅程中,我們從幾塊地磚起步,打造出了多層次古堡、動態火把、四向移動狀態機、移軸微縮景深與角色動態受光。 然而,如果只是看著一行行程式碼跑出漂亮的畫面,很容易陷入「知其然而不知其所以然」的困境:
今天我們不堆砌繁複的線性代數矩陣,而是用最直觀的物理比喻與關鍵邏輯,揭開 Three.js 與 WebGL 著色器背後的底層秘密!
在常規 3D 大作中,引擎恨不得把所有貼圖都抹得平滑無比;但在 HD-2D 的世界裡,「顆粒分明、輪廓銳利的像素邊界」才是靈魂所在。
當相機靠近角色或地面時,螢幕上的一格像素往往需要顯示原本貼圖裡的極小區塊。
在專案中,我們封裝的 loadPixelTexture 做了兩件關鍵設定:
function loadPixelTexture(url) {
const tex = textureLoader.load(url);
tex.magFilter = THREE.NearestFilter; // 放大時:直接抓最靠近的顏色,嚴禁抹勻
tex.minFilter = THREE.NearestFilter; // 縮小時:同樣保持純粹
tex.generateMipmaps = false; // 核心關鍵:關閉縮小金字塔
return tex;
}
在前幾天中,我們使用 THREE.Sprite 建立了主角。為什麼它不需要在每一幀用 JavaScript 計算旋轉角度,就能在相機傾斜時永遠站得直挺挺的面對玩家呢?
打開 Three.js 底層的著色器,它用了一種巧妙的拆解技巧:
因為繪製四邊形時根本不參與 3D 視角旋轉,所以不論攝影機如何俯視、仰視或平移,角色永遠垂直挺拔地正對玩家螢幕,而且完全不耗費 CPU 的旋轉運算效能!
在 Day 17 中,我們曾遇過一個嚴重的 Bug:想讓角色向左走,我們把 X 軸寬度設為負值(scale.x = -playerBaseScale),結果精靈圖完全不轉身,依然固執地向右看。
Bug 的真相:GPU 的那把尺
一般的 3D 模型把寬度乘上 -1 確實會左右翻轉,但 THREE.Sprite 內部為了防止看板在旋轉時縮放形變,在 GPU 著色器裡寫了這行邏輯:
關鍵就在 length()! 在數學上,向量長度就是拿尺去量距離。世界上沒有「長度是負 2 公分」的尺,量出來的數值永遠是非負的正數(絕對值)。因此,不論在 JavaScript 裡傳入 -2.8 還是 -100,GPU 量完長度後都硬生生把它轉回了正數,角色自然永遠朝右!
我們的解法:放映機底片反著裝(UV 逆向採樣)
既然動不了骨架尺寸,我們就直接動貼圖——把放映機裡的底片左右倒過來裝!
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 的測量尺),但像素著色器在貼圖時被我們騙了,改從右往左倒著貼,零成本達成了完美的水平轉身
在 Day 19 之前,我們的角色就像一張「發光的塑膠貼紙」:手中拿著大火把,四周石牆與地面都被打亮了,但角色自己的身體卻完全不受光,背光面也不會變暗。
這是因為 Three.js 的 SpriteMaterial 天生就是 Unlit(不受光材質)。
為什麼不自己重寫一個 ShaderMaterial?
手寫一個全新的著色器材質,就像「為了吃一碗牛肉麵,得自己去養牛、種小麥、揉麵糰」。你必須自己親手處理霧氣計算、投影矩陣、色彩色調映射等上千行底層邏輯,既費時又容易出錯。
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;
`
);
};
光學效果解構
身體局部座標(vLocalX):
我們在頂點著色器中記住角色的左側(0.0)到右側(1.0),不管它切換到哪一個動作畫格,我們都能精準知道「身體的哪一邊朝向火把」。
圓柱體弧形過渡(smoothstep):
如果只是粗暴地讓一邊亮、一邊暗,角色看起來依然平扁。smoothstep 提供了如同球體與圓柱體表面的圓弧光影過渡,讓 2D 紙片人瞬間產生了「胸膛圓潤挺拔」的 3D 體積感!
動態火焰共振(uFlamePulse):
手中的火把跳動時,迎光側的金色光輝會跟著輕微呼吸,與整座地宮的光影節奏融為一體。
【本日結語】
回顧今天的內容,我們完全不需要死記硬背複雜的矩陣公式,也能掌握圖形學的核心思想:
搞懂了像素本體與著色器之後,在下一篇 Day 21 中,我們將把聚光燈投向整座地下城的空間光影:即時動態陰影(PCFSoftShadowMap)是怎麼在 GPU 裡比對深度的?黑體輻射火光與後處理合成管線(EffectComposer)又是如何像暗房洗照片一樣疊加出電影質感的? 我們明天見!