GLSL 程式設計/Blender/凹凸表面的光照

本教程介紹了 **法線貼圖**。
這是關於紋理技術的兩篇教程中的第一篇,它超越了二維表面(或表面層)。在本教程中,我們從法線貼圖開始,這是一種非常成熟的技術,可以模擬小凸起和凹陷的光照,即使是在粗糙的多邊形網格上也是如此。本教程的程式碼基於 平滑鏡面高光教程 和 紋理球體教程。
左側描繪的卡拉瓦喬的畫作是關於聖多馬的不信,他直到將手指放在基督的肋旁才相信基督復活。使徒們的皺眉不僅象徵著這種不信,而且透過常見的面部表情清晰地傳達了這種不信。然而,我們怎麼會知道他們的額頭實際上是皺的,而不是用一些明暗線條繪製的?畢竟,這只是一幅平面的畫作。事實上,觀眾直覺地認為這些是皺眉而不是繪製的眉毛,即使這幅畫本身允許這兩種解釋。教訓是:光滑表面的凸起通常可以透過光照本身來令人信服地傳達,而無需任何其他線索(陰影、遮擋、視差效果、立體聲等)。
法線貼圖試圖透過根據一些虛擬凸起改變表面法線向量來傳達光滑表面的凸起(即具有插值法線的粗糙三角形網格)。當使用這些修改後的法線向量計算光照時,觀眾通常會感知到虛擬凸起,即使渲染的是一個完全平坦的三角形。這種幻覺當然會失效(特別是在輪廓處),但在許多情況下它非常令人信服。
更具體地說,表示虛擬凸起的法線向量首先 **編碼** 到紋理影像(即法線貼圖)中。然後,片段著色器在紋理影像中查詢這些向量,並根據它們計算光照。就是這樣。當然,問題是如何在紋理影像中編碼法線向量。存在不同的可能性,並且片段著色器必須適應用於生成法線貼圖的特定編碼。

Blender 支援法線貼圖;請參閱 Blender 3D:菜鳥到專業維基百科中的描述。但是,在這裡,我們將使用左側的法線貼圖並編寫一個 GLSL 著色器來使用它。
對於本教程,您應該使用立方體網格,而不是 紋理球體教程 中使用的 UV 球體。除此之外,您可以按照相同的步驟將材質和紋理影像分配給物件。請注意,您應該在 **屬性視窗 > 物件資料選項卡** 中指定一個預設的 **UV 貼圖**。此外,您應該在 **屬性視窗 > 紋理選項卡 > 對映** 中指定 **座標 > UV**。
在解碼法線資訊時,最好了解資料的編碼方式。但是,選擇並不多;因此,即使您不知道法線貼圖是如何編碼的,一些試驗通常也能得出足夠好的結果。首先,RGB 分量是 0 到 1 之間的數字;但是,它們通常表示區域性表面座標系中 -1 到 1 之間的座標(因為向量是歸一化的,因此所有座標都不可能大於 +1 或小於 -1)。因此,從 RGB 分量到法線向量 **n** 的對映可能是
, , 以及
但是, 座標通常為正(因為表面法線不允許指向內部)。這可以透過對 使用不同的對映來利用
, , 以及
如果有疑問,應該選擇後者解碼,因為它永遠不會生成指向內部的表面法線。此外,通常需要對所得向量進行歸一化。
在片段著色器中計算歸一化向量 n 在變數 localCoords 中的實現可能是
vec4 encodedNormal = texture2D(normalMap, vec2(texCoords));
vec3 localCoords =
normalize(vec3(2.0, 2.0, 1.0) * vec3(encodedNormal)
- vec3(1.0, 1.0, 0.0));

通常,對於表面的每個點,都會使用區域性表面座標系來指定法線貼圖中的法線向量。這個區域性座標系的 軸由光滑的、插值的法線向量 N 給出,而 平面是表面的切平面,如左圖所示。具體來說, 軸由 Blender 提供給頂點的切線屬性 T 指定(參見關於著色器除錯的 教程 中關於屬性的討論)。給定 和 軸, 軸可以透過頂點著色器中的叉積來計算,例如 B = T × N。(字母 B 指的是這個向量的傳統名稱“副法線”。)
請注意,法線向量 N 使用模型檢視矩陣的逆矩陣的轉置從物體空間變換到檢視空間(因為它與表面正交;參見 “應用矩陣變換”),而切線向量 T 指定了表面上兩點之間的方向,因此使用模型檢視矩陣進行變換。副法線向量 B 代表第三類向量,它們以不同的方式進行變換。(如果你真的想知道:與“B×”相對應的反對稱矩陣 B 的變換方式與二次型相同。)因此,最好的選擇是先將 N 和 T 變換到檢視空間,然後使用變換後的向量的叉積在檢視空間中計算 B。
還要注意,這些軸的配置取決於提供的切線資料、法線貼圖的編碼和紋理座標。但是,這些軸實際上總是正交的,法線貼圖的藍色色調錶示藍色分量位於插值的法線向量方向上。
有了檢視空間中的歸一化方向 T、B 和 N,我們可以很容易地形成一個矩陣,該矩陣將法線貼圖的任何法線向量 n 從區域性表面座標系對映到檢視空間,因為該矩陣的列只是軸的向量;因此,將 n 對映到檢視空間的 3×3 矩陣為
這些計算由頂點著色器執行,例如,透過以下方式
attribute vec4 tangent;
varying mat3 localSurface2View; // mapping from
// local surface coordinates to view coordinates
varying vec4 texCoords; // texture coordinates
varying vec4 position; // position in view coordinates
void main()
{
// the signs and whether tangent is in localSurface2View[1]
// or localSurface2View[0] depends on the tangent
// attribute, texture coordinates, and the encoding
// of the normal map
// gl_NormalMatrix is precalculated inverse transpose of
// the gl_ModelViewMatrix; using this preserves data
// during non-uniform scaling of the mesh
// localSurface2View[1] is multiplied by the cross sign of
// the tangent, in tangent.w; this allows mirrored UVs
// (tangent.w is 1 when normal, -1 when mirrored)
localSurface2View[0] = normalize(gl_NormalMatrix
* tangent.xyz);
localSurface2View[2] =
normalize(gl_NormalMatrix * gl_Normal);
localSurface2View[1] = normalize(
cross(localSurface2View[2], localSurface2View[0])
* tangent.w);
texCoords = gl_MultiTexCoord0;
position = gl_ModelViewMatrix * gl_Vertex;
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
在片段著色器中,我們將此矩陣與 n(即 localCoords)相乘。例如,使用以下程式碼行
vec3 normalDirection =
normalize(localSurface2View * localCoords);
有了檢視空間中的新法線向量,我們可以像在 平滑鏡面高光教程 中一樣計算光照。
完整的著色器程式碼
[edit | edit source]完整的片段著色器只是將所有程式碼段和來自 平滑鏡面高光教程 的逐畫素光照整合在一起。此外,我們必須請求切線屬性並設定紋理取樣器(確保法線貼圖位於紋理列表的第一個位置,或者調整對 setSampler 的呼叫的第二個引數)。然後 Python 指令碼為
import bge
cont = bge.logic.getCurrentController()
VertexShader = """
attribute vec4 tangent;
varying mat3 localSurface2View; // mapping from
// local surface coordinates to view coordinates
varying vec4 texCoords; // texture coordinates
varying vec4 position; // position in view coordinates
void main()
{
// the signs and whether tangent is in localSurface2View[1]
// or localSurface2View[0] depends on the tangent
// attribute, texture coordinates, and the encoding
// of the normal map
// gl_NormalMatrix is precalculated inverse transpose of
// the gl_ModelViewMatrix; using this preserves data
// during non-uniform scaling of the mesh
// localSurface2View[1] is multiplied by the cross sign of
// the tangent, in tangent.w; this allows mirrored UVs
// (tangent.w is 1 when normal, -1 when mirrored)
localSurface2View[0] = normalize(gl_NormalMatrix
* tangent.xyz);
localSurface2View[2] =
normalize(gl_NormalMatrix * gl_Normal);
localSurface2View[1] = normalize(
cross(localSurface2View[2], localSurface2View[0])
* tangent.w);
texCoords = gl_MultiTexCoord0;
position = gl_ModelViewMatrix * gl_Vertex;
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
"""
FragmentShader = """
varying mat3 localSurface2View; // mapping from
// local surface coordinates to view coordinates
varying vec4 texCoords; // texture coordinates
varying vec4 position; // position in view coordinates
uniform sampler2D normalMap;
void main()
{
// in principle we have to normalize the columns of
// "localSurface2View" again; however, the potential
// problems are small since we use this matrix only
// to compute "normalDirection", which we normalize anyways
vec4 encodedNormal = texture2D(normalMap, vec2(texCoords));
vec3 localCoords =
normalize(vec3(2.0, 2.0, 1.0) * vec3(encodedNormal)
- vec3(1.0, 1.0, 0.0));
// constants depend on encoding
vec3 normalDirection =
normalize(localSurface2View * localCoords);
// Compute per-pixel Phong lighting with normalDirection
vec3 viewDirection = -normalize(vec3(position));
vec3 lightDirection;
float attenuation;
if (0.0 == gl_LightSource[0].position.w)
// directional light?
{
attenuation = 1.0; // no attenuation
lightDirection =
normalize(vec3(gl_LightSource[0].position));
}
else // point light or spotlight (or other kind of light)
{
vec3 positionToLightSource =
vec3(gl_LightSource[0].position - position);
float distance = length(positionToLightSource);
attenuation = 1.0 / distance; // linear attenuation
lightDirection = normalize(positionToLightSource);
if (gl_LightSource[0].spotCutoff <= 90.0) // spotlight?
{
float clampedCosine = max(0.0, dot(-lightDirection,
gl_LightSource[0].spotDirection));
if (clampedCosine < gl_LightSource[0].spotCosCutoff)
// outside of spotlight cone?
{
attenuation = 0.0;
}
else
{
attenuation = attenuation * pow(clampedCosine,
gl_LightSource[0].spotExponent);
}
}
}
vec3 ambientLighting = vec3(gl_LightModel.ambient)
* vec3(gl_FrontMaterial.emission);
vec3 diffuseReflection = attenuation
* vec3(gl_LightSource[0].diffuse)
* vec3(gl_FrontMaterial.emission)
* max(0.0, dot(normalDirection, lightDirection));
vec3 specularReflection;
if (dot(normalDirection, lightDirection) < 0.0)
// light source on the wrong side?
{
specularReflection = vec3(0.0, 0.0, 0.0);
// no specular reflection
}
else // light source on the right side
{
specularReflection = attenuation
* vec3(gl_LightSource[0].specular)
* vec3(gl_FrontMaterial.specular)
* pow(max(0.0, dot(reflect(-lightDirection,
normalDirection), viewDirection)),
gl_FrontMaterial.shininess);
}
gl_FragColor = vec4(ambientLighting + diffuseReflection
+ specularReflection, 1.0);
}
"""
mesh = cont.owner.meshes[0]
for mat in mesh.materials:
shader = mat.getShader()
if shader != None:
if not shader.isValid():
shader.setSource(VertexShader, FragmentShader, 1)
shader.setAttrib(bge.logic.SHD_TANGENT)
shader.setSampler('normalMap', 0)
總結
[edit | edit source]恭喜!您完成了本教程!我們已經瞭解了
- 人類對形狀的感知通常依賴於光照。
- 什麼是法線貼圖。
- 如何解碼常見的法線貼圖。
- 片段著色器如何解碼法線貼圖並將其用於逐畫素光照。
進一步閱讀
[edit | edit source]如果您想了解更多
- 關於紋理對映(包括平鋪和偏移),您應該閱讀 關於紋理球體的教程。
- 關於每個畫素的燈光以及Phong反射模型,您可以閱讀關於平滑鏡面高光的教程。
- 關於變換法線向量,您可以閱讀“應用矩陣變換”。
- 關於法線貼圖,您可以閱讀Mark J. Kilgard: “A Practical and Robust Bump-mapping Technique for Today’s GPUs”, GDC 2000: Advanced OpenGL Game Development,該文章可在網上獲取。