跳转到内容

GLSL 编程/Blender/凹凸表面的光照

来自 Wikibooks,开放世界中的开放书籍
卡拉瓦乔的“圣多马的不信”,1601-1603 年。

本教程介绍了 **法线贴图**。

这是关于纹理技术的两篇教程中的第一篇,它超越了二维表面(或表面层)。在本教程中,我们从法线贴图开始,这是一种非常成熟的技术,可以模拟小凸起和凹陷的光照,即使是在粗糙的多边形网格上也是如此。本教程的代码基于 平滑镜面高光教程纹理球体教程

基于光照感知形状

[编辑 | 编辑源代码]

左侧描绘的卡拉瓦乔的画作是关于圣多马的不信,他直到将手指放在基督的肋旁才相信基督复活。使徒们的皱眉不仅象征着这种不信,而且通过常见的面部表情清晰地传达了这种不信。然而,我们怎么会知道他们的额头实际上是皱的,而不是用一些明暗线条绘制的?毕竟,这只是一幅平面的画作。事实上,观众直觉地认为这些是皱眉而不是绘制的眉毛,即使这幅画本身允许这两种解释。教训是:光滑表面的凸起通常可以通过光照本身来令人信服地传达,而无需任何其他线索(阴影、遮挡、视差效果、立体声等)。

法线贴图

[编辑 | 编辑源代码]

法线贴图试图通过根据一些虚拟凸起改变表面法线向量来传达光滑表面的凸起(即具有插值法线的粗糙三角形网格)。当使用这些修改后的法线向量计算光照时,观众通常会感知到虚拟凸起,即使渲染的是一个完全平坦的三角形。这种幻觉当然会失效(特别是在轮廓处),但在许多情况下它非常令人信服。

更具体地说,表示虚拟凸起的法线向量首先 **编码** 到纹理图像(即法线贴图)中。然后,片段着色器在纹理图像中查找这些向量,并根据它们计算光照。就是这样。当然,问题是如何在纹理图像中编码法线向量。存在不同的可能性,并且片段着色器必须适应用于生成法线贴图的特定编码。

编码法线贴图外观的典型示例。

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 的变换方式与二次型相同。)因此,最好的选择是先将 NT 变换到视图空间,然后使用变换后的向量的叉积在视图空间中计算 B

还要注意,这些轴的配置取决于提供的切线数据、法线贴图的编码和纹理坐标。但是,这些轴实际上总是正交的,法线贴图的蓝色色调表示蓝色分量位于插值的法线向量方向上。

有了视图空间中的归一化方向 TBN,我们可以很容易地形成一个矩阵,该矩阵将法线贴图的任何法线向量 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,该文章可在网上获取。


< GLSL编程/Blender

除非另有说明,本页面上的所有示例源代码均为公有领域。
华夏公益教科书