概述
本文聚焦於 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
).