翻譯:非常詳細易懂的法線貼圖(Normal Mapping)
翻譯:非常詳細易懂的法線貼圖(Normal Mapping)
- 本文翻譯自: Shaders » Lesson 6: Normal Mapping
- 作者: Matt DesLauriers
- 譯者: FreeBlues
這一系列依賴於最小規模的用於著色器和渲染工具的lwjgl-basics API.
程式碼已經被移植到 LibGDX. 這些概念是足夠通用的, 它們能被應用於Love2D, GLSL Sandbox, iOS
, 或者其他支援 GLSL
的平臺.
概述
本文聚焦於 3D
光照和法線貼圖技術, 以及我們如何把它們應用到 2D
遊戲中, 示範下圖所示, 左邊是紋理貼圖, 右邊實時應用了光照:
一旦你理解了光照的概念, 把它應用於任何設定都是非常直截了當的. 這裡是一個 Java4K
示例中的法線貼圖的例子, 例如, 通過軟體渲染:
效果跟這個 YouTube流行視訊 和這個 Love2D示例 中展示的一樣, 你還可以在 [GLSL] Using Normal Maps to Illuminate a 2D Texture (LibGDX) 看到效果, 其中包括一個可執行的示例.
介紹向量和法線
正如我們在之前的教程中討論過的, 一個 GLSL
向量是一個浮點數的容器, 通常儲存諸如位置(x,y,z)
之類的值. 在數學中,向量意味著相當多的內容,以及用於表示長度(即大小)和方向. 如果你對向量很陌生並且想要學習關於它們更多一些的知識, 檢視下面這些連結:
為了計算光照, 我們需要使用網格的"法線
". 一個表面法線是一個垂直於切線平面的向量. 簡單來說, 它是一個向量, 垂直於給定頂點處的網格. 下面我們會看到一個網格, 每個頂點都有一條法線.
每個向量都指向外面, 遵循著網格的彎曲形狀. 下面是另一個例子, 這次是一個簡單的 2D
邊沿檢視:
法線貼圖
(Normal Mapping
)是一個遊戲程式設計技巧, 它允許我們渲染相同數目的多邊形(例如低解析度的網格模型), 但是在計算光照時使用高解析度網格模型的法線. 這為我們帶來更好的感受, 關於深度, 真實性和光滑度.
(影像來自於這個出色的部落格文章Making Worlds 3 - That's no Moon...)
高面數網格模型或者說精雕模型的法線被編碼到一個紋理貼圖(即法線圖)中, 當我們渲染低面數網格模型時會從片段著色器中對它進行取樣. 結果如下:
譯者注: 左側是4百萬個三角形的高模, 中間是500個三角形的低模, 右側是在500個三角形的低模上使用法線貼圖後的效果
對法線編碼和解碼
我們的表面法線是單位向量, 通常位於範圍 -1.0
到 1.0
之間. 我們可以通過把法線範圍轉換為 0.0
到 1.0
之間來把法線向量(x, y, z)
儲存到一個 RGB
紋理貼圖中. 下面是偽碼:
Color.rgb = Normal.xyz / 2.0 + 0.5;
例如, 一個法線 (-1, 0, 1)
會被作為 RGB
編碼為 (0, 0.5, 1)
. x
軸(左/右)被儲存到紅色通道, y
軸(上/下)被儲存到綠色通道, z
軸(前/後)被儲存到藍色通道. 最終的法線圖(normal map
)看起來就是下面這個樣子:
典型地, 我們使用程式來生成法線圖, 而不是手動繪製.
理解法線圖, 把每個通道獨立出來檢視會更清楚:
看著,綠色通道,我們看到更亮的部分(值更接近於 1.0
) 定義了法線指向上方的區域,而更暗的區域(值更接近為 0.0
) 定義了法線指向下方的區域. 大多數的法線圖會是藍色,因為Z
軸(藍色通道)通常指向我們(即值為 1.0
).
在我們遊戲的片段著色器中, 我們可以把法線解碼, 通過執行跟之前編碼時相反的操作, 把顏色值展開為範圍 -1.0
到 1.0
之間:
//sample the normal map
NormalMap = texture2D(NormalMapTex, TexCoord);
//convert to range -1.0 to 1.0
Normal.xyz = NormalMap.rgb * 2.0 - 1.0;
注意: 要記住不同的引擎和軟體會使用不同的座標系, 綠色通道可能需要翻轉.
Lambertian 光照模型
在計算機圖形學中, 我們有大量的演算法,可以結合起來打造 3D
物件的不同渲染效果. 在這篇文章我們將專注於 Lambert
著色,沒有任何反射(諸如"光澤"或"發光"). 其他的技術,像Phong
, Cook-Torrance
, 和 Oren–Nayar
, 可以用來產生不同的視覺效果(粗糙表面、 有光澤的表面等等)。
我們整個光照模型看起來像這樣:
N = normalize(Normal.xyz)
L = normalize(LightDir.xyz)
Diffuse = LightColor * max(dot(N, L), 0.0)
Ambient = AmbientColor * AmbientIntensity
Attenuation = 1.0 / (ConstantAtt + (LinearAtt * Distance) + (QuadraticAtt * Distance * Distance))
Intensity = Ambient + Diffuse * Attenuation
FinalColor = DiffuseColor.rgb * Intensity.rgb
說實話,你不需要從數學角度理解為什麼這個可以起作用,但如果你有興趣, 可以閱讀更多有關"N dot L
"的內容, 在這裡GLSL Tutorial – Directional Lights per Vertex I和這裡Lambertian reflectance.
一些關鍵的術語:
Normal-法線
: 從法線圖中解碼得到的法線向量 XYZ.LightDir-光線方向
: 從物體表面到光源位置的向量, 我們將會簡單解釋.Diffuse Color-漫射顏色
: 紋理貼圖的 RGB 顏色, 沒有光.Diffuse-漫射
: 跟Lambertian
反射相乘的光線顏色, 這是我們光照等式的主要部分.Ambient-環境光
: 處於陰影中的顏色和強度, 例如, 一個戶外場景會有一個更亮的環境光強度, 比起一個暗淡燈光下的戶內場景.Attenuation-衰減
: 這是光線的隨距離而降低
, 例如, 當我們遠離點光源時強度/亮度
的損失. 有多種方法來計算衰減--對於我們的目標而言, 我們將會使用常量-線性-二次方
衰減. 這裡用3
個係數來計算衰減, 我們可以改變它們來影響光線衰減的視覺效果.Intensity-強度
: 我們陰影演算法的強度--離1.0
越近意味著有光, 離0.0
越近意味著沒有光.
下面的圖有助於你對我們的光照模型有個直觀的理解:
正如你所見, 感覺它是相當模組化的, 我們可以拿走那些不需要的部分, 就像衰減(attenuation
) 或光線顏色(light colors
).
現在, 讓我們把它們應用到 GLSL
模型上. 注意我們只處理 2D
, 在 3D
中還有一些額外的考慮在這篇教程沒有覆蓋到(譯者注:就是空間變換, 在 3D
場景下, 法線圖中的法線所在的空間為正切空間, 光線所在的空間為世界空間, 需要統一到同一個空間計算才有意義). 我們將把模型分解為多個單獨部分, 每一個都建立在下面的基礎上.
Java 例程
你可以在這裡看到Java程式碼示例. 它是相對直截了當的, 並不會介紹過多的在在前面的課程中還沒有討論過的內容. 我們將使用以下兩種紋理貼圖︰
我們的示例根據滑鼠位置(歸一化到解析度)調整 LightPos.xy
, 根據滑鼠滾輪(點選則重置光線的 Z
值)調整 LightPos.z
(深度). 在特定的座標系中, 就像 LibGDX
, 你可能需要翻轉 Y
值.
注意, 我們的例子使用瞭如下這些常量, 你可以調整它們來獲得不同的視覺效果:
public static final float DEFAULT_LIGHT_Z = 0.075f;
...
//Light RGB and intensity (alpha)
public static final Vector4f LIGHT_COLOR = new Vector4f(1f, 0.8f, 0.6f, 1f);
//Ambient RGB and intensity (alpha)
public static final Vector4f AMBIENT_COLOR = new Vector4f(0.6f, 0.6f, 1f, 0.2f);
//Attenuation coefficients for light falloff
public static final Vector3f FALLOFF = new Vector3f(.4f, 3f, 20f);
下面是我們的渲染程式碼, 就像 教程4 一樣, 我們會在渲染時使用多重紋理:
...
//update light position, normalized to screen resolution
float x = Mouse.getX() / (float)Display.getWidth();
float y = Mouse.getY() / (float)Display.getHeight();
LIGHT_POS.x = x;
LIGHT_POS.y = y;
//send a Vector4f to GLSL
shader.setUniformf("LightPos", LIGHT_POS);
//bind normal map to texture unit 1
glActiveTexture(GL_TEXTURE1);
rockNormals.bind();
//bind diffuse color to texture unit 0
glActiveTexture(GL_TEXTURE0);
rock.bind();
//draw the texture unit 0 with our shader effect applied
batch.draw(rock, 50, 50);
陰影貼圖的結果:
下面對光線使用了更低的 Z
值:
片段著色器
這裡是我們完整的片段著色器
//attributes from vertex shader
varying vec4 vColor;
varying vec2 vTexCoord;
//our texture samplers
uniform sampler2D u_texture; //diffuse map
uniform sampler2D u_normals; //normal map
//values used for shading algorithm...
uniform vec2 Resolution; //resolution of screen
uniform vec3 LightPos; //light position, normalized
uniform vec4 LightColor; //light RGBA -- alpha is intensity
uniform vec4 AmbientColor; //ambient RGBA -- alpha is intensity
uniform vec3 Falloff; //attenuation coefficients
void main() {
//RGBA of our diffuse color
vec4 DiffuseColor = texture2D(u_texture, vTexCoord);
//RGB of our normal map
vec3 NormalMap = texture2D(u_normals, vTexCoord).rgb;
//The delta position of light
vec3 LightDir = vec3(LightPos.xy - (gl_FragCoord.xy / Resolution.xy), LightPos.z);
//Correct for aspect ratio
LightDir.x *= Resolution.x / Resolution.y;
//Determine distance (used for attenuation) BEFORE we normalize our LightDir
float D = length(LightDir);
//normalize our vectors
vec3 N = normalize(NormalMap * 2.0 - 1.0);
vec3 L = normalize(LightDir);
//Pre-multiply light color with intensity
//Then perform "N dot L" to determine our diffuse term
vec3 Diffuse = (LightColor.rgb * LightColor.a) * max(dot(N, L), 0.0);
//pre-multiply ambient color with intensity
vec3 Ambient = AmbientColor.rgb * AmbientColor.a;
//calculate attenuation
float Attenuation = 1.0 / ( Falloff.x + (Falloff.y*D) + (Falloff.z*D*D) );
//the calculation which brings it all together
vec3 Intensity = Ambient + Diffuse * Attenuation;
vec3 FinalColor = DiffuseColor.rgb * Intensity;
gl_FragColor = vColor * vec4(FinalColor, DiffuseColor.a);
}
GLSL 分解
現在, 把它分解. 首先, 我們從兩個紋理貼圖中取樣:
//RGBA of our diffuse color
vec4 DiffuseColor = texture2D(u_texture, vTexCoord);
//RGB of our normal map
vec3 NormalMap = texture2D(u_normals, vTexCoord).rgb;
接著, 我們需要從當前的片段(譯者注:即畫素)確定光線向量, 並且糾正它的縱橫比例(aspect ratio
). 然後在歸一化(normalize
)之前確定 LightDir
向量的值(長度):
//Delta pos
vec3 LightDir = vec3(LightPos.xy - (gl_FragCoord.xy / Resolution.xy), LightPos.z);
//Correct for aspect ratio
LightDir.x *= Resolution.x / Resolution.y;
//determine magnitude
float D = length(LightDir);
在我們的光照模型中, 我們需要從 NormalMap.rgb
中解碼 Normal.xyz
, 並且歸一化我們的向量:
vec3 N = normalize(NormalMap * 2.0 - 1.0);
vec3 L = normalize(LightDir);
下一步是計算 Diffuse
(漫射) 項. 為了這個, 我們需要使用 LightColor
. 在我們的例子中, 我們將會把光線顏色(RGB
)和強度(alpha
)相乘: LightColor.rgb * LightColor.a
. 因此, 所有這些看起來如下:
//Pre-multiply light color with intensity
//Then perform "N dot L" to determine our diffuse term
vec3 Diffuse = (LightColor.rgb * LightColor.a) * max(dot(N, L), 0.0);
接著, 我們預相乘(pre-multiply)環境顏色(ambient color
)和強度:
vec3 Ambient = AmbientColor.rgb * AmbientColor.a;
下一步是用我們的 LightDir
的值(前面計算好的)來確定衰減
(Attenuation
). 統一變數下降係數
(Falloff
) 定義了我們的常量, 線性和2次方的衰減係數:
float Attenuation = 1.0 / ( Falloff.x + (Falloff.y*D) + (Falloff.z*D*D) );
接著, 計算光強度
(Intensity
)和最終顏色
(FinalColor
), 並且把它們傳遞給 gl_FragColor
. 注意, 我們機智地保留了 DiffuseColor
的 alpha
值:
vec3 Intensity = Ambient + Diffuse * Attenuation;
vec3 FinalColor = DiffuseColor.rgb * Intensity;
gl_FragColor = vColor * vec4(FinalColor, DiffuseColor.a);
抓住你了(Gotchas)
- 在我們的實現中,
LightDir
和attenuation
依賴於解析度. 這意味著更改解析度會影響我們的光的衰減. 根據你的遊戲,不同的實現上解析度無關可能是必需的. - 一個必須處理的常見問題, 關於你遊戲的
Y
座標系和你所採用的法線圖生成程式(例如CrazyBump
)之間的差異. 一些程式允許你匯出一個翻轉了Y
軸的法線圖. 下面的圖片展示了這個問題:
多光源
實現多光源, 我們只要簡單地調整一下演算法, 如下:
vec3 Sum = vec3(0.0);
for (... each light ...) {
... calculate light using our illumination model ...
Sum += FinalColor;
}
gl_FragColor = vec4(Sum, DiffuseColor.a);
注意, 這樣會在你的著色器中引入更多分支(譯者注:也就是這個迴圈), 它會導致效能降低.
這有時被稱為"N 照明
"(N lighting
), 因為我們的系統僅支援一個固定數目 N
的光源. 如果你計劃包括大量的光源, 你可能想要調查多個繪製呼叫(例如 additive blending
), 或延遲渲染Deferred shading.
在某個時間點, 你可能會問自己:"為什麼我不直接做一個3D
遊戲?". 比起試著把這些概念應用到 2D
精靈來說, 這是個正當的問題並且可能會帶來更好的效能和更少的開發時間.
生成法線圖
這裡有各種從一張圖片生成法線圖的方法. 用於轉換2D
影像為法線圖的常用程式和濾鏡包括如下:
- SpriteLamp - specifically aimed at 2D normal-map art
- SMAK! - Super Model Army Knife
- CrazyBump
- NVIDIA Texture Tools for Photoshop
- gimp-normalmap
- SSBump Generator
- njob
- ShaderMap
注意, 很多程式都會產生鋸齒和錯誤, 閱讀這篇文章How NOT To Make Normal Maps From Photos Or Images來獲得更多細節.
你也能使用 3D
建模軟體, 如 Blender 或 ZBrush 來精心雕琢出高質量的法線圖.
Blender工具
一個工作流的想法是, 生成一個低面數,非常粗糙的 3D
物件在你的藝術資源中. 然後你可以使用這個 Blender Template: Normal Map Pass 把你的物件渲染為一個 2D
正切空間內的法線圖. 然後你就能在 PhotoShop
中開啟這個法線圖並且處理這個漫射(diffuse)顏色圖了.
下面是一個 Blender
模板的樣子:
進階閱讀
- UpVector - Intro to Shaders & Light
- Bump Mapping Using CG by Søren Dreijer
- Illumination Model Slides
- The Cg Tutorial
- oZone Bump Mapping Tutorial
- Bump Mapping in GLSL - Fabien Sanglard
附錄:畫素藝術
在建立我的 WebGL
的 法線影像素藝術演示時, 有一堆我不得不考慮的事項. 你可以從這裡檢視原始碼和細節.
效果如下圖: 截圖:
在這個示例中, 我想讓衰減作為一個風格元素變得可見. 典型的做法帶來非常平滑的衰減, 它和塊狀畫素藝術風格衝突. 相反, 我使用 cel shading
的光線, 給它一個階梯狀的衰減. 通過片段著色器中的 if-else
語句實現了簡單的卡通著色.
下一步的考慮是, 我們希望光線的邊緣畫素的比例隨著精靈(sprites)的畫素變化. 實現這個目標的一個方法是通過光照著色器把我們的場景繪製到一個 FBO
中, 然後用一個預設的著色器以一個較大的尺寸把它渲染到螢幕上. 在我們的塊狀畫素藝術中這種照明方式影響整個"紋素
"(texels
).
其他 APIs
相關文章
- 3D遊戲常用技巧Normal Mapping (法線貼圖)原理解析——基礎篇3D遊戲ORMAPP
- 3D遊戲常用技巧Normal Mapping (法線貼圖)原理解析——高階篇3D遊戲ORMAPP
- Jenkins安裝部署使用圖文詳解(非常詳細)Jenkins
- github的詳細使用,非常簡單!Github
- MongoDB分片群集的部署(用心描述,詳細易懂)!!MongoDB
- 非常詳細地Hive操作指南Hive
- 前端JavaScript規範 非常詳細前端JavaScript
- MySQL配置檔案my.cnf例子最詳細翻譯MySql
- MySQL配置檔案my.cnf 例子最詳細翻譯MySql
- iOS15上線圖片翻譯功能,能取代專業翻譯軟體嗎?iOS
- 網線水晶頭接法詳細圖解圖解
- Retrofit 2 0非常簡單的入門(翻譯官方文件)
- 《非常防護》專案詳細介紹
- Java List 用法程式碼分析 非常詳細Java
- ARM晶片詳解[翻譯]晶片
- 查發貼人IP詳細資訊!怎麼透過帖子找到發貼人個人詳細地址?
- Nginx 快取機制詳解!非常詳細實用Nginx快取
- OpenSSL專案路線圖【已翻譯100%】(2/2)
- Intellij IDEA 使用svn非常詳細的說明IntelliJIdea
- 『翻譯』一些JavaScript優化的細節JavaScript優化
- WebGL學習之法線貼圖Web
- openssl建立證書,非常詳細配置ssl+apacheApache
- 無線路由器設定詳細圖文教程路由器
- opencv的Mat類詳解以及mannal翻譯OpenCV
- google hack 的詳細圖解Go圖解
- JavaScript 事件迴圈詳解(翻譯)JavaScript事件
- SecureCRT的下載、安裝( 過程非常詳細!!值得檢視)Securecrt
- Linq語法詳細
- Yurii談翻譯(五)怎樣翻譯更地道:so…that…的翻譯
- 淺談計算機圖書的翻譯——“增值翻譯”的幾個參考例子 (轉)計算機
- 最簡單易懂的laravel事件,這個功能非常的有用Laravel事件
- [翻譯]圖書的未來 by Mike Hendrickson
- table細線表格詳解
- centos7 編譯安裝mysql 5.7.28圖文詳細教程CentOS編譯MySql
- 《Linux/Unix 設計思想》的翻譯細節討論Linux
- 網線水晶頭怎麼接?詳細教您網線水晶頭的接法圖解圖解
- Yurii談翻譯(九)怎樣翻譯更地道:冠詞a的翻譯
- Yurii談翻譯(十)怎樣翻譯更地道:最高階的翻譯