《Unity Shader 入门精要》学习总结03


纹理基础

纹理可以理解为是一个存储着信息的二维数组。我们需要使用纹理映射(texture mapping)来告诉 GPU,需要从哪个数组成员上读取信息。

这个纹理映射坐标便是"UV",纹理上的数组成员被我们称为纹素(texel)。

“纹素"也就是我们所说的图片中的"像素”,但是这里为了方便区分所以叫做"纹素"。

Unity中的纹理坐标

Unity 中的纹理和纹理采样

纹理采样模式

当我们在 Unity 中导入一张纹理资源之后,可以在其 Inspector 面板中调整其导入配置。
其中,有个非常重要的属性便是 Wrap Mode。这个选项指定了当纹理采样坐标超过 [0, 1] 范围时的采样规则。

  • repeat:重复采样,若是纹理坐标超过 1,其整数部分便会被舍弃,使用小数部分进行采样,纹理将会不断重复。
  • clamp:在这种模式下,会将大于 1 的纹理坐标分量截取到 1,小于 0 的截取到 0。

新版的 Unity 中可能会提供更多的模式选项。

纹理的最大分辨率

我们可以在 Inspector 面板中指定纹理的最大分辨率。
注意,在导入纹理时,建议纹理长宽大小皆为 2 的幂,如果不是(NPOT),则会占用更多的空间,GPU 读取这些纹理速度也会下降。

NPOT 纹理的三个代价:
Mipmap 问题:非 2 的幂无法完美逐级减半,累积误差大,或被迫内部填充。
压缩对齐问题:BCn / DXT / ETC / ASTC 等格式以 4×4 块为单位,NPOT 无法整除时需补白。
缓存效率问题:POT 行宽与缓存行完美对齐,NPOT 导致跨行读取,命中率下降。

纹理的缩放

缩小

纹理在比自身分辨率小的范围中接受采样时,一个屏幕上的像素会对应多个纹素,但是这样会产生严重走样,于是 Mipmap 便出现了。Mipmap 技术的全称为"多级渐远纹理(mipmapping)"。其采用提前滤波处理得到很多对应的小图像,组成一个图像金字塔,在运行时根据距离和采样大小选择对应的层级。

放大

再往下看会发现一个 Filter Mode 的选项。Filter Mode 指定了纹理由于变换而产生拉伸时的滤波模式。

  • Point:原始的纹理
  • Bilinear:双线性插值
  • Trilinear:三线性插值

其中,Point 会直接采样原始纹理数据而不做处理。
Bilinear 会在同一 Mipmap 层级中进行双线性插值。
Trilinear 会在同一 Mipmap 层级中插值后再对两个 Mipmap 层级的双线性插值结果进行一次线性插值,因此被叫做三线性插值。
GAMES101 中有着对该技术的细节介绍。

其从上往下得到的效果会依次提升,但是消耗的性能也依次增大。

在 Unity Shader 中进行纹理采样

要点 说明
_MainTex_ST Unity 自动为每个纹理生成,命名固定为 纹理名_ST.xy 控制平铺次数,.zw 控制 UV 偏移
TRANSFORM_TEX 宏,展开即为 uv * _MainTex_ST.xy + _MainTex_ST.zw,可在顶点或片元中使用
tex2D GPU 根据 UV 坐标对纹理采样的方法,内部自动处理滤波和 Mipmap
white Unity 内置纯白纹理,避免未赋值时采样到黑色/品红色

演示代码:

Shader "Unity入门精要/Chapter7/SingleTexture"
{
    Properties
    {
        // 2D 纹理,默认值 "white" 是 Unity 内置的 4×4 纯白纹理
        _MainTex      ("基础色贴图", 2D) = "white" {}
        _BaseColor    ("基础色", Color) = (1.0, 1.0, 1.0 ,1.0)
        _Gloss        ("高光系数", Range(8.0, 256)) = 20.0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }

        Pass
        {

            Tags { "LightMode"="ForwardBase" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            struct appdata
            {
                float4 vertex :    POSITION;
                float2 uv :        TEXCOORD0;
                float3 normal :    NORMAL;
            };

            struct v2f
            {
                float2 uv :           TEXCOORD0;
                float4 vertex_CS :    SV_POSITION;
                float4 vertex_WS :    TEXCOORD1;
                float3 normal_OS :    TEXCOORD2;
            };

            // 纹理采样器(GPU 用它读取纹素)
            sampler2D      _MainTex;
            // Unity 自动生成:.xy = Tiling, .zw = Offset
            // 命名规则:纹理名_ST(ST = Scale & Translation)
            float4         _MainTex_ST;
            float          _Gloss;
            float4         _BaseColor;

            v2f vert (appdata app)
            {
                v2f output;
                output.vertex_CS = UnityObjectToClipPos(app.vertex);
                output.vertex_WS = mul(unity_ObjectToWorld, app.vertex);
                output.normal_OS = app.normal;
                output.uv = app.uv;
                return output;
            }

            fixed4 frag (v2f input) : SV_Target
            {
                //--------------数据获取---------------------
                float3 normal_WS = UnityObjectToWorldNormal(input.normal_OS);
                float4 colorTexture = tex2D(_MainTex, input.uv * _MainTex_ST.xy + _MainTex_ST.zw);
                float4 surfaceColor = _BaseColor * colorTexture;
                float3 cameraDir_WS = normalize(_WorldSpaceCameraPos - input.vertex_WS);
                float3 lightDir_WS = normalize(_WorldSpaceLightPos0.xyz);
                float3 half_WS = normalize(cameraDir_WS + lightDir_WS);
                float NdotL = dot(normal_WS, lightDir_WS);
                float NdotH = dot(normal_WS, half_WS);

                //--------------光照计算---------------------
                fixed3 ambientColor = UNITY_LIGHTMODEL_AMBIENT.xyz * surfaceColor.xyz;
                fixed3 diffuseColor = (surfaceColor.xyz * _LightColor0.xyz) * saturate(NdotL);
                fixed3 specularColor = (surfaceColor.xyz * _LightColor0.xyz) * pow(max(0.0, NdotH), _Gloss);

                return fixed4(ambientColor + diffuseColor + specularColor, 1.0);
            }
            ENDCG
        }
    }
}

进阶纹理

凹凸映射

凹凸映射(bump mapping),可以使用纹理修改模型表面的法线,在较低的面数下为模型添加更多的细节。

高度纹理

其中一种方法就是使用高度纹理进行表面位移(又称置换,displacement)的模拟,然后根据模拟结果获得修改的法线值。当然也有别的方法进行表面置换,但是要么就是有着巨大的开销,要么就是过于复杂,这里不过多赘述。
高度纹理中存储的是强度值(intensity),用于表示模型表面的海拔高度,越浅越凸出。这样的图通常非常直观,但是不会像法线贴图那样方便使用。

法线纹理

法线纹理是储存表面法线方向信息的纹理。当我们直接在法线纹理中存储法线方向数据时,需要将其分量范围由 [-1, 1] 映射到 [0, 1]

pixel=normal+12

因此采样后我们需要将法线使用上述函数的逆函数进行反映射。

normal=pixel×21

但是我们需要知道,方向是相对于坐标空间来说的。所以法线贴图存储的法线应该统一存在于同一个坐标空间下。当下,我们一般会将法线存储在切线空间(tangent space)下。
每个顶点都会有一个切线空间,切线空间的原点是顶点本身,x 轴是顶点切线(tangent)方向,y 轴是副法线/副切线方向(bitangent),z 轴是法线本体的方向。
这种法线贴图正是切线空间法线纹理(tangent-space normal map),也正是我们平时接触到的"蓝紫色法线贴图"。

那为什么是蓝紫色呢?
因为实际上切线空间法线纹理相当于在存储法线在切线空间中的偏转方向。当法线与原模型表面相同时,不需要偏转,也就呈现出 (0.5, 0.5, 1),成为蓝紫色。

相比起将法线存储在模型空间中,切线空间中存储的是相对法线信息,具有可重用性(可在不同模型间共享),更加直观,还能进行 UV 动画的编写,并且可将其压缩至两个通道中(我们会看见一种黄色的法线贴图,实际上就是去掉 B 通道后的压缩切线空间法线贴图)。

在 Unity Shader 中使用法线纹理

注意,采样完成后需要保证光照和法线计算存在于同一坐标空间下。
笔者喜欢统一在世界空间下计算。

Shader "Unity入门精要/Chapter7/NormalTexture"
{
    Properties
    {
        _MainTex      ("基础色贴图", 2D) = "white" {}
        [Normal] _NormalTex    ("法线贴图", 2D) = "bump" {}   //bump为默认无扰动的法线
        _NormalScale  ("法线强度", float) = 1.0
        _BaseColor    ("基础色", Color) = (1.0, 1.0, 1.0 ,1.0)
        _Gloss        ("高光系数", Range(8.0, 256)) = 20.0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        Pass
        {

            Tags { "LightMode"="ForwardBase" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "Lighting.cginc"
            
            struct appdata
            {
                float4 vertex :    POSITION;
                float2 uv :        TEXCOORD0;
                float3 normal :    NORMAL;
                float4 tangent :   TANGENT;
            };

            struct v2f
            {
                float2 uv :           TEXCOORD0;
                float4 vertex_CS :    SV_POSITION;
                float4 vertex_WS :    TEXCOORD1;
                float3 normal_OS :    TEXCOORD2;
                float4 tangent_OS :   TEXCOORD3;
            };

            sampler2D      _MainTex;
            float4         _MainTex_ST;
            float          _Gloss;
            float4         _BaseColor;
            sampler2D      _NormalTex;
            float4         _NormalTex_ST;
            float          _NormalScale;
            
            v2f vert (appdata app)
            {
                v2f output;
                output.vertex_CS = UnityObjectToClipPos(app.vertex);
                output.vertex_WS = mul(unity_ObjectToWorld, app.vertex);
                output.normal_OS = app.normal;
                output.uv = app.uv;
                output.tangent_OS = app.tangent;
                return output;
            }

            fixed4 frag (v2f input) : SV_Target
            {
                //--------------数据获取---------------------
                float3 binormal = cross(normalize(input.normal_OS), normalize(input.tangent_OS.xyz)) * input.tangent_OS.w;
                float3x3 rotation = float3x3(input.tangent_OS.xyz, binormal, input.normal_OS);
                
                float3 cameraDir_WS = normalize(_WorldSpaceCameraPos - input.vertex_WS);
                float3 lightDir_WS = normalize(_WorldSpaceLightPos0.xyz);
                float3 half_WS = normalize(cameraDir_WS + lightDir_WS);
                
                float4 colorTexture = tex2D(_MainTex, input.uv * _MainTex_ST.xy + _MainTex_ST.zw);
                float4 surfaceColor = _BaseColor * colorTexture;
                float4 normalTexture = tex2D(_NormalTex, input.uv * _NormalTex_ST.xy + _NormalTex_ST.zw);

                //--------------法线计算---------------------
                float3 tangentNormal = UnpackNormal(normalTexture);
                tangentNormal.xy *= _NormalScale;
                tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

                // 切线空间 → 物体空间 → 世界空间
                float3 normal_OS = mul(tangentNormal, rotation);
                float3 normal_WS = UnityObjectToWorldNormal(normal_OS);

                float NdotL = dot(normal_WS, lightDir_WS);
                float NdotH = dot(normal_WS, half_WS);

                //--------------光照计算---------------------
                fixed3 ambientColor = UNITY_LIGHTMODEL_AMBIENT.xyz * surfaceColor.xyz;
                fixed3 diffuseColor = (surfaceColor.xyz * _LightColor0.xyz) * saturate(NdotL);
                fixed3 specularColor = (surfaceColor.xyz * _LightColor0.xyz) * pow(max(0.0, NdotH), _Gloss);

                return fixed4(ambientColor + diffuseColor + specularColor, 1.0);
            }
            ENDCG
        }
    }
}

法线贴图采样部分标注:

// ...
float3x3 rotation = float3x3(input.tangent_OS.xyz, binormal, input.normal_OS); // 构建 TNB 矩阵
// ...

//--------------法线计算---------------------
float3 tangentNormal = UnpackNormal(normalTexture); // 解包法线获得 xy 偏移量
tangentNormal.xy *= _NormalScale;                   // 应用偏移量缩放
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); // 根据 xy 偏移量计算出偏移后的法线方向

// 切线空间 → 物体空间 → 世界空间
float3 normal_OS = mul(tangentNormal, rotation); 
float3 normal_WS = UnityObjectToWorldNormal(normal_OS);
// ...

接下来便可以将偏转后的法线用于计算。
原始法线在这里只会用来构建 TBN 矩阵,不会参与到光照计算。

渐变纹理

在风格化渲染中我们经常遇到这类纹理。这也就是我们常说的 Ramp 纹理,用于进行风格化的着色色调映射。

遮罩纹理

使用纹理控制不同像素点的着色属性。
我们平时在 PBR 材质中看到的金属度贴图、粗糙度贴图一类的都属于遮罩纹理