跳转到内容

Cg 编程/Unity/凹凸表面的光照

来自维基教科书,开放的书籍,开放的世界
“多马的怀疑”由卡拉瓦乔创作,1601-1603 年。

本教程涵盖法线贴图

它是关于纹理技术的教程系列中的第一个,这些技术超越了二维表面(或表面层)。请注意,这些教程旨在教您这些技术的工作原理。如果您想在 Unity 中实际使用其中一项技术,您应该使用内置着色器或表面着色器

在本教程中,我们从法线贴图开始,这是一种非常成熟的技术,可以模拟小凹凸和凹陷的光照,即使在粗糙的多边形网格上也是如此。本教程的代码基于“平滑镜面高光”部分“纹理球体”部分.

基于光照感知形状

[编辑 | 编辑源代码]

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

法线贴图

[编辑 | 编辑源代码]

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

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

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

Unity 中的法线贴图

[编辑 | 编辑源代码]

好消息是,您可以使用 Unity 从灰度图像轻松创建法线贴图:在您喜欢的绘图程序中创建灰度图像,并使用特定灰色表示表面的常规高度,使用更浅的灰色表示凹凸,使用更深的灰色表示凹陷。确保不同灰度之间的过渡是平滑的,例如通过模糊图像。当您使用资产 > 导入新资产导入图像时,在检查器窗口中将纹理类型更改为法线贴图,并选中从灰度创建。单击应用后,预览应该显示一个带有红色和绿色边缘的蓝色图像。除了生成法线贴图外,还可以导入左侧的编码法线贴图。(在这种情况下,不要忘记取消选中从灰度创建框)。

不太好的消息是,片段着色器必须进行一些计算来解码法线。首先,纹理颜色存储在双组件纹理图像中,即只有一个 alpha 组件和一个可用的颜色组件。颜色组件可以作为红色、绿色或蓝色组件访问,所有情况下都会返回相同的值。这里,我们使用绿色组件,因为 Unity 也使用它。两个组件,存储为 0 到 1 之间的数字;但是,它们表示 -1 到 1 之间的坐标。映射是

   以及   

从这两个组件,三维法线向量 n的第三个组件可以计算出来,因为归一化到单位长度

  

如果我们选择 轴沿着光滑法向量(从顶点着色器中设置的法向量插值)的轴线,则只需要“+”解,因为我们无法渲染具有向内指向法向量的表面。片段着色器的代码片段可能如下所示

            float4 encodedNormal = tex2D(_BumpMap, 
               _BumpMap_ST.xy * input.tex.xy + _BumpMap_ST.zw);
            float3 localCoords = float3(2.0 * encodedNormal.a - 1.0, 
                2.0 * encodedNormal.g - 1.0, 0.0);
            localCoords.z = sqrt(1.0 - dot(localCoords, localCoords));
               // approximation without sqrt:  localCoords.z = 
               // 1.0 - 0.5 * dot(localCoords, localCoords);

对于使用 OpenGL ES 的设备,解码实际上更简单,因为 Unity 在这种情况下不使用双组件纹理。因此,对于移动平台,解码变为

            float4 encodedNormal = tex2D(_BumpMap, 
               _BumpMap_ST.xy * input.tex.xy + _BumpMap_ST.zw);
            float3 localCoords = 
               2.0 * encodedNormal.rgb - float3(1.0, 1.0, 1.0);

但是,本教程的其余部分(以及 “凹凸表面的投影”部分)将仅涵盖桌面平台。

球面上一点的切平面。

Unity 为表面的每个点使用局部表面坐标系来指定法线贴图中的法向量。此局部坐标系的 轴由世界空间中的光滑插值法向量 N 给出,并且 平面是表面的切平面,如左侧图像所示。具体来说, 轴由 Unity 提供给顶点的切线参数 T 指定(参见 “着色器的调试”部分 中关于顶点输入参数的讨论)。给定 轴, 轴可以通过顶点着色器中的叉积计算,例如 B = N × T。(字母 B 指的是此向量的传统名称“副法线”。)

请注意,法向量 N 使用模型矩阵的逆矩阵的转置从对象空间转换到世界空间(因为它与表面正交;参见 “应用矩阵变换”部分),而切向量 T 指定表面上点之间的方向,因此使用模型矩阵进行转换。副法线向量 B 代表第三类向量,它们以不同的方式进行变换。(如果你真的想知道:对应于“B×”的 反对称矩阵三元二次型 一样进行变换。)因此,最佳选择是首先将 NT 转换到世界空间,然后使用转换后的向量的叉积在世界空间中计算 B

这些计算由顶点着色器执行,例如以下方式

         struct vertexInput {
            float4 vertex : POSITION;
            float4 texcoord : TEXCOORD0;
            float3 normal : NORMAL;
            float4 tangent : TANGENT;
         };
         struct vertexOutput {
            float4 pos : SV_POSITION;
            float4 posWorld : TEXCOORD0;
               // position of the vertex (and fragment) in world space 
            float4 tex : TEXCOORD1;
            float3 tangentWorld : TEXCOORD2;  
            float3 normalWorld : TEXCOORD3;
            float3 binormalWorld : TEXCOORD4;
         };
 
         vertexOutput vert(vertexInput input) 
         {
            vertexOutput output;
            
            float4x4 modelMatrix = unity_ObjectToWorld;
            float4x4 modelMatrixInverse = unity_WorldToObject;
 
            output.tangentWorld = normalize(
               mul(modelMatrix, float4(input.tangent.xyz, 0.0)).xyz);
            output.normalWorld = normalize(
               mul(float4(input.normal, 0.0), modelMatrixInverse).xyz);
            output.binormalWorld = normalize(
               cross(output.normalWorld, output.tangentWorld) 
               * input.tangent.w); // tangent.w is specific to Unity

            output.posWorld = mul(modelMatrix, input.vertex);
            output.tex = input.texcoord;
            output.pos = UnityObjectToClipPos(input.vertex);
            return output;
         }

binormalWorld 计算中的因子 input.tangent.w 专门针对 Unity,即 Unity 提供切向量和法线贴图,因此我们必须进行此乘法。

使用世界空间中的归一化方向 TBN,我们可以轻松地形成一个矩阵,该矩阵将法线贴图中的任何法向量 n 从局部表面坐标系映射到世界空间,因为该矩阵的列只是轴向量的向量;因此,将 n 映射到世界空间的 3×3 矩阵为

在 Cg 中,构建转置矩阵实际上更容易,因为矩阵是逐行构建的

该构造在片段着色器中完成,例如:

            float3x3 local2WorldTranspose = float3x3(input.tangentWorld,
               input.binormalWorld, input.normalWorld);

我们希望使用 local2WorldTranspose 的转置(即未转置的原始矩阵)变换 **n**; 因此,我们从左侧用矩阵乘以 **n**。 例如,使用以下代码行

            float3 normalDirection = 
               normalize(mul(localCoords, local2WorldTranspose));

使用世界空间中的新法线向量,我们可以像在 “平滑镜面高光” 部分 中那样计算光照。

完整的着色器代码

[edit | edit source]

此着色器代码只是集成了所有代码片段,并使用我们的标准两遍方法来处理像素光。 它还演示了 CGINCLUDE ... ENDCG 块的使用,该块隐式地由所有子着色器的所有遍共享。

Shader "Cg normal mapping" {
   Properties {
      _BumpMap ("Normal Map", 2D) = "bump" {}
      _Color ("Diffuse Material Color", Color) = (1,1,1,1) 
      _SpecColor ("Specular Material Color", Color) = (1,1,1,1) 
      _Shininess ("Shininess", Float) = 10
   }

   CGINCLUDE // common code for all passes of all subshaders

      #include "UnityCG.cginc"
      uniform float4 _LightColor0; 
      // color of light source (from "Lighting.cginc")

      // User-specified properties
      uniform sampler2D _BumpMap;   
      uniform float4 _BumpMap_ST;
      uniform float4 _Color; 
      uniform float4 _SpecColor; 
      uniform float _Shininess;

      struct vertexInput {
         float4 vertex : POSITION;
         float4 texcoord : TEXCOORD0;
         float3 normal : NORMAL;
         float4 tangent : TANGENT;
      };
      struct vertexOutput {
         float4 pos : SV_POSITION;
         float4 posWorld : TEXCOORD0;
         // position of the vertex (and fragment) in world space 
         float4 tex : TEXCOORD1;
         float3 tangentWorld : TEXCOORD2;  
         float3 normalWorld : TEXCOORD3;
         float3 binormalWorld : TEXCOORD4;
      };

      vertexOutput vert(vertexInput input) 
      {
         vertexOutput output;

         float4x4 modelMatrix = unity_ObjectToWorld;
         float4x4 modelMatrixInverse = unity_WorldToObject;

         output.tangentWorld = normalize(
            mul(modelMatrix, float4(input.tangent.xyz, 0.0)).xyz);
         output.normalWorld = normalize(
            mul(float4(input.normal, 0.0), modelMatrixInverse).xyz);
         output.binormalWorld = normalize(
            cross(output.normalWorld, output.tangentWorld) 
            * input.tangent.w); // tangent.w is specific to Unity

         output.posWorld = mul(modelMatrix, input.vertex);
         output.tex = input.texcoord;
         output.pos = UnityObjectToClipPos(input.vertex);
         return output;
      }

      // fragment shader with ambient lighting
      float4 fragWithAmbient(vertexOutput input) : COLOR
      {
         // in principle we have to normalize tangentWorld,
         // binormalWorld, and normalWorld again; however, the 
         // potential problems are small since we use this 
         // matrix only to compute "normalDirection", 
         // which we normalize anyways

         float4 encodedNormal = tex2D(_BumpMap, 
            _BumpMap_ST.xy * input.tex.xy + _BumpMap_ST.zw);
         float3 localCoords = float3(2.0 * encodedNormal.a - 1.0, 
             2.0 * encodedNormal.g - 1.0, 0.0);
         localCoords.z = sqrt(1.0 - dot(localCoords, localCoords));
         // approximation without sqrt:  localCoords.z = 
         // 1.0 - 0.5 * dot(localCoords, localCoords);

         float3x3 local2WorldTranspose = float3x3(
            input.tangentWorld, 
            input.binormalWorld, 
            input.normalWorld);
         float3 normalDirection = 
            normalize(mul(localCoords, local2WorldTranspose));

         float3 viewDirection = normalize(
            _WorldSpaceCameraPos - input.posWorld.xyz);
         float3 lightDirection;
         float attenuation;

         if (0.0 == _WorldSpaceLightPos0.w) // directional light?
         { 
            attenuation = 1.0; // no attenuation
            lightDirection = normalize(_WorldSpaceLightPos0.xyz);
         } 
         else // point or spot light
         {
            float3 vertexToLightSource = 
               _WorldSpaceLightPos0.xyz - input.posWorld.xyz;
            float distance = length(vertexToLightSource);
            attenuation = 1.0 / distance; // linear attenuation 
            lightDirection = normalize(vertexToLightSource);
         }

         float3 ambientLighting = 
            UNITY_LIGHTMODEL_AMBIENT.rgb * _Color.rgb;

         float3 diffuseReflection = 
            attenuation * _LightColor0.rgb * _Color.rgb
            * max(0.0, dot(normalDirection, lightDirection));

         float3 specularReflection;
         if (dot(normalDirection, lightDirection) < 0.0) 
            // light source on the wrong side?
         {
            specularReflection = float3(0.0, 0.0, 0.0); 
            // no specular reflection
         }
         else // light source on the right side
         {
            specularReflection = attenuation * _LightColor0.rgb 
               * _SpecColor.rgb * pow(max(0.0, dot(
               reflect(-lightDirection, normalDirection), 
               viewDirection)), _Shininess);
         }
         return float4(ambientLighting + diffuseReflection 
            + specularReflection, 1.0);
      }
      
      // fragment shader for pass 2 without ambient lighting 
      float4 fragWithoutAmbient(vertexOutput input) : COLOR
      {
        // in principle we have to normalize tangentWorld,
        // binormalWorld, and normalWorld again; however, the  
        // potential problems are small since we use this 
        // matrix only to compute "normalDirection", 
        // which we normalize anyways

        float4 encodedNormal = tex2D(_BumpMap, 
           _BumpMap_ST.xy * input.tex.xy + _BumpMap_ST.zw);
        float3 localCoords = float3(2.0 * encodedNormal.a - 1.0, 
            2.0 * encodedNormal.g - 1.0, 0.0);
        localCoords.z = sqrt(1.0 - dot(localCoords, localCoords));
           // approximation without sqrt:  localCoords.z = 
           // 1.0 - 0.5 * dot(localCoords, localCoords);

        float3x3 local2WorldTranspose = float3x3(
           input.tangentWorld,
           input.binormalWorld, 
           input.normalWorld);
        float3 normalDirection = 
           normalize(mul(localCoords, local2WorldTranspose));

        float3 viewDirection = normalize(
           _WorldSpaceCameraPos - input.posWorld.xyz);
        float3 lightDirection;
        float attenuation;

        if (0.0 == _WorldSpaceLightPos0.w) // directional light?
        {
           attenuation = 1.0; // no attenuation
           lightDirection = normalize(_WorldSpaceLightPos0.xyz);
        } 
        else // point or spot light
        {
           float3 vertexToLightSource = 
              _WorldSpaceLightPos0.xyz - input.posWorld.xyz;
           float distance = length(vertexToLightSource);
           attenuation = 1.0 / distance; // linear attenuation 
           lightDirection = normalize(vertexToLightSource);
        }

        float3 diffuseReflection = 
           attenuation * _LightColor0.rgb * _Color.rgb
           * max(0.0, dot(normalDirection, lightDirection));

        float3 specularReflection;
        if (dot(normalDirection, lightDirection) < 0.0) 
           // light source on the wrong side?
        {
           specularReflection = float3(0.0, 0.0, 0.0); 
              // no specular reflection
        }
        else // light source on the right side
        {
           specularReflection = attenuation * _LightColor0.rgb 
              * _SpecColor.rgb * pow(max(0.0, dot(
              reflect(-lightDirection, normalDirection), 
              viewDirection)), _Shininess);
        }
        return float4(diffuseReflection + specularReflection, 1.0);
      }
   ENDCG

   SubShader {
      Pass {      
         Tags { "LightMode" = "ForwardBase" } 
            // pass for ambient light and first light source
 
         CGPROGRAM
            #pragma vertex vert  
            #pragma fragment fragWithAmbient  
            // the functions are defined in the CGINCLUDE part
         ENDCG
      }
 
      Pass {      
         Tags { "LightMode" = "ForwardAdd" } 
            // pass for additional light sources
         Blend One One // additive blending 
 
         CGPROGRAM
            #pragma vertex vert  
            #pragma fragment fragWithoutAmbient
            // the functions are defined in the CGINCLUDE part
         ENDCG
      }
   }
}

请注意,我们使用了如 “纹理球体” 部分 中所述的平铺和偏移一致性 _BumpMap_ST,因为此选项通常对凹凸贴图特别有用。

摘要

[edit | edit source]

恭喜你! 你完成了本教程! 我们已经了解了

  • 人类对形状的感知通常依赖于光照。
  • 什么是法线贴图。
  • Unity 如何编码法线贴图。
  • 片段着色器如何解码 Unity 的法线贴图并将其用于逐像素光照。

进一步阅读

[edit | edit source]

如果你还想了解更多

  • 关于纹理映射(包括平铺和偏移),你应该阅读 “纹理球体” 部分
  • 关于使用 Phong 反射模型进行逐像素光照,你应该阅读 “平滑镜面高光” 部分
  • 关于变换法线向量,你应该阅读 “应用矩阵变换” 部分
  • 关于法线贴图,你可以阅读 Mark J. Kilgard 的文章:“A Practical and Robust Bump-mapping Technique for Today’s GPUs”,GDC 2000:Advanced OpenGL Game Development,该文章可以在 网上 获取。

< Cg 编程/Unity

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