翻譯:非常詳細易懂的法線貼圖(Normal Mapping)

自由布魯斯發表於2016-08-05

翻譯:非常詳細易懂的法線貼圖(Normal Mapping)

這一系列依賴於最小規模的用於著色器和渲染工具的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.01.0 之間. 我們可以通過把法線範圍轉換為 0.01.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.01.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. 注意, 我們機智地保留了 DiffuseColoralpha 值:

vec3 Intensity = Ambient + Diffuse * Attenuation;
vec3 FinalColor = DiffuseColor.rgb * Intensity;
gl_FragColor = vColor * vec4(FinalColor, DiffuseColor.a);

抓住你了(Gotchas)

  • 在我們的實現中, LightDirattenuation 依賴於解析度. 這意味著更改解析度會影響我們的光的衰減. 根據你的遊戲,不同的實現上解析度無關可能是必需的.
  • 一個必須處理的常見問題, 關於你遊戲的 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影像為法線圖的常用程式和濾鏡包括如下:

注意, 很多程式都會產生鋸齒和錯誤, 閱讀這篇文章How NOT To Make Normal Maps From Photos Or Images來獲得更多細節.

你也能使用 3D 建模軟體, 如 BlenderZBrush 來精心雕琢出高質量的法線圖.

Blender工具

一個工作流的想法是, 生成一個低面數,非常粗糙的 3D 物件在你的藝術資源中. 然後你可以使用這個 Blender Template: Normal Map Pass 把你的物件渲染為一個 2D 正切空間內的法線圖. 然後你就能在 PhotoShop 中開啟這個法線圖並且處理這個漫射(diffuse)顏色圖了.

下面是一個 Blender 模板的樣子:

進階閱讀

附錄:畫素藝術

在建立我的 WebGL法線影像素藝術演示時, 有一堆我不得不考慮的事項. 你可以從這裡檢視原始碼和細節.

效果如下圖: 截圖: enter image description here

在這個示例中, 我想讓衰減作為一個風格元素變得可見. 典型的做法帶來非常平滑的衰減, 它和塊狀畫素藝術風格衝突. 相反, 我使用 cel shading 的光線, 給它一個階梯狀的衰減. 通過片段著色器中的 if-else 語句實現了簡單的卡通著色.

下一步的考慮是, 我們希望光線的邊緣畫素的比例隨著精靈(sprites)的畫素變化. 實現這個目標的一個方法是通過光照著色器把我們的場景繪製到一個 FBO 中, 然後用一個預設的著色器以一個較大的尺寸把它渲染到螢幕上. 在我們的塊狀畫素藝術中這種照明方式影響整個"紋素"(texels).

其他 APIs

相關文章