UnityShader精要笔记六 基础纹理

本文继续对《UnityShader入门精要》——冯乐乐 第七章 基础纹理 进行学习

一、前置知识

参考
主要参考闫令琪Games101课程,对应的笔记在图形学笔记七 纹理和贴图 AO

二、单张纹理示例

打开源码中的场景Scene_7_1对应的Single Texture.shader,常规步骤不说了。

1.Properties语义块多了一个_MainTex
    Properties {
        _Color ("Color Tint", Color) = (1, 1, 1, 1)
        _MainTex ("Main Tex", 2D) = "white" {}
        _Specular ("Specular", Color) = (1, 1, 1, 1)
        _Gloss ("Gloss", Range(8.0, 256)) = 20
    }

“white”是内置的纹理的名字,也就是一个全白的纹理。

2.变量部分,注意多出来一个末尾ST的
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Specular;
float _Gloss;

与其它属性类型不同的是,我们还需要为纹理类型的属性声明一个float4类型的变量_MainTex_ST。其中,_MainTex_ST的名字不是任意起的。在Unity中,我们需要使用纹理名_ST的方式来声明某个纹理的属性。其中ST是缩放(scale)和平移(translation)的缩写。_MainTex_ST可以让我们得到该纹理的缩放和平移(偏移)值,_MainTex_ST.XY存储的是缩放值,而_MainTex_ST.zw存储的是偏移值。这些值可以在材质面板的纹理属性中调节,如下图所示:


image.png
3.定义顶点着色器的输入和输出结构体
struct a2v {
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
};

struct v2f {
    float4 pos : SV_POSITION;
    float3 worldNormal : TEXCOORD0;
    float3 worldPos : TEXCOORD1;
    float2 uv : TEXCOORD2;
};

在上面代码中,我们首先在a2v结构体中使用TEXCOORD0语义声明了一个新的变量texcoord,这样Unity就会将模型的第一组纹理坐标存储到该变量中。然后,我们在v2f结构体中添加了用于存储纹理坐标的uv,以便在片元着色器中使用该坐标进行纹理采样。

4.建议先看frag再看vert

这里可以对照上一节的Chapter6-BlinnPhongUseBuildInFunctions.shader

fixed4 frag(v2f i) : SV_Target {
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
    
    fixed3 worldNormal = normalize(i.worldNormal);
    //  Use the build-in funtion to compute the light direction in world space
    // Remember to normalize the result
    //fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
    
    fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
    
    // Use the build-in funtion to compute the view direction in world space
    // Remember to normalize the result
    //fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
    fixed3 halfDir = normalize(worldLightDir + viewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
    
    return fixed4(ambient + diffuse + specular, 1.0);
}

然后是现在的:

fixed4 frag(v2f i) : SV_Target {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
    
    // Use the texture to sample the diffuse color
    fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
    
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
    
    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));
    
    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
    fixed3 halfDir = normalize(worldLightDir + viewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
    
    return fixed4(ambient + diffuse + specular, 1.0);
}

其中_Color就是之前的_Diffuse,没啥区别,多出来的是tex2D(_MainTex, i.uv).rgb这里使用Cg的tex2D函数对纹理进行采样。它的第一个参数是需要被采样的纹理,第二个参数是一个float2类型的纹理坐标,它将返回计算得到的纹素值。

5.vert

这个相比Chapter6-BlinnPhongUseBuildInFunctions.shader,就多了一点点:

o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
// Or just call the built-in function
// o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

在顶点着色器中,我们使用纹理属性值_MainTex_ST来对顶点纹理坐标进行变换,得到最终的纹理坐标。计算过程是,首先使用缩放属性_MainTex_ST.xy对顶点纹理坐标进行缩放,然后再使用偏移属性_MainTex_ST.zw对结果进行偏移。

Unity提供了一个内置宏TRANSFORM_TEX来帮我们计算上述过程。TRANSFORM_TEX是在UnityCG.cginc中定义的:

//Transform 2D UV by scale/bias property
#define TRANSFORM_TEX(tex,name) (tex.xy*name##__ST.xy+name##_ST.zw)

它接受两个参数,第一个参数是顶点纹理坐标,第二个参数是纹理名,在它的实现中,将利用纹理名_ST的方式来计算变换后的纹理坐标。

这里我做了一个测试,使用o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);的方式,发现这个函数的参数并没有用到float4 _MainTex_ST;,那删除掉会怎么样,测试结果是不能删,应该就是##__ST.xy这种方式在动态引用吧。

三、纹理的属性

可以对照官方文档:https://docs.unity.cn/cn/2019.4/Manual/class-RenderTexture.html

1.Wrap Mode决定了当纹理坐标超过[0, 1]范围后将会如何被平铺

参考源码Scene_7_1_2_a

Wrap Mode有两种模式:一种是Repeat,在这种模式下,如果纹理坐标超过了1,那么它的整数部分将会被舍弃,而直接使用小数部分进行采样,这样的结果是纹理将会不断重复;另一种是Clamp,在这种模式下,如果纹理坐标大于1,那么将会截取到1,如果小于0,那么将会截取到0。下图给出了两种模式下平铺一张纹理的效果。


图7.5 Wrap Mode决定了当纹理坐标超过[0, 1]范围后将会如何被平铺

上图展示了在纹理的平铺(Tiling)属性为(3,3)时分别使用两种Wrap Mode的结果。作图使用了Repeat模式,在这种模式下纹理将会不断重复;右图使用了Clamp模式,在这种模式下超过范围的部分将会截取到边界值,形成一个条形结构。

需要注意的是,想要让纹理得到这样的效果,我们必须使用纹理的属性(例如上面的_MainTex_ST变量)在Unity Shader中对顶点坐标进行相应的变换。也就是说,代码中需要包含类似下面的代码:

o.uv = v.texcoord.xy*_MainTex_ST.xy+_MainTex_ST.zw;
//Or just call the bulit-in function
o,uv = TRANSFORM_TEX(v.texcoord,_MainTex);

我们还可以在材质面板中调整纹理的偏移量,下图给出了两种模式下调整纹理偏移量的一个例子:


图7.6 偏移(Offset)属性决定了纹理坐标的偏移量

上图展示了在纹理的偏移属性为(0.2,0.6)时分别使用两种Wrap Mode的结果,左图使用了Repeat模式,右图使用了Clamp模式。

2.Fliter Mode

Fliter Mode属性,它决定了当纹理由于变换而产生拉伸时将会采用哪种滤波模式。Fliter Mode支持3种模式:Point,Bilinear以及Trilinear。它们得到的图片滤波效果依次提升,但需要耗费的性能也依次增大。纹理滤波会影响放大或缩小纹理时得到的图片质量。例如,当我们把一张64×64大小的纹理贴在一个512×512大小的平面上时,就需要放大纹理。下图给出了3种滤波模式下的放大结果。

图7.7 在放大纹理时,分别使用三种Filter Mode得到的结果

这三个模式可以参考纹理过滤模式中的Bilinear、Trilinear以及Anistropic Filtering (转)找到对应的概念:

  • Nearest Point Sampling(最近点采样)
  • Bilinear(双线性过滤)
  • Trilinear(三线性过滤)

然后,就能对应到图形学笔记七 贴图Texture AO里面的,其实就是名字不太一样。

image.png

3.Generate Mip Maps开启多级渐远纹理技术

纹理缩小的过程比放大更加复杂一些,此时原纹理中的多个像素会对应一个目标像素。纹理缩小更加复杂的原因在于我们往往需要处理抗锯齿问题,一个最常用的方法就是多级渐远纹理(mipmapping)技术,其中“mip”是拉丁文“multum in parvo”的缩写,它的意思是在一个小空间有许多东西。如同它的名字,多级渐远纹理技术将原纹理提前用滤波处理来得到很多更小的图像,形成了一个图像金字塔,每一层都是对上一层图像降采样的结果。这样在实时运行时,就可以快速得到结果像素,例如当物体远离摄像机时,就可以直接使用较小的纹理。但缺点是需要使用一定的空间用于存储这些多级渐远纹理,通常会多占用33%的内存空间,这是一种典型的空间换取时间的方法。

在Unity中,我们可以在纹理导入面板时,首先将纹理类型(Texture Type)选择成Advanced,再勾选Generate Mip Maps即可开启多级渐远纹理技术。同时,我们也可以选择生成多级渐远纹理时是否使用线性空间(用于伽玛校正)以及采用的滤波器等。


image.png

效果可以在Scene_7_1_2_c中看到:


图7.9 从上到下: Point滤波 + 多级渐远纹理技术,Bilinear滤波 + 多级渐远纹理技术,Trilinear滤波 + 多级渐远纹理技术

在内部实现上,Point模式使用了最近邻(nearest neighbor)滤波,再放大或缩小时,它的采样像素数目通常只有一个,一次图像看起来会有种像素风格的效果。

而Bilinear滤波则使用了线性滤波,对于每个目标像素,他都会找到4个临近像素,然后对它们进行线性插值混合后得到最终像素,因此图像看起来被模糊了。

而Trilinear滤波几乎是和Bilinear一样的,只是Trilinear还会在多级渐远纹理之间进行混合。如果一张纹理没有使用多级渐远纹理技术,那么Trilinear得到的结果是和Bilinear就是一样的。

通常我们会选择Bilinear滤波模式。需要注意的是,有时我们不希望纹理看起来是模糊的,例如对于一些类似棋盘的纹理,我们希望它就是像素风的,这时我们可能会选择Point模式。

4.纹理的最大尺寸和纹理模式

当我们在为不同平台发布游戏时,需要考虑目标平台的纹理尺寸和质量问题。Unity允许我们为不同目标平台选择不同的分辨率,如下图所示:


图7.10 选择纹理的最大尺寸和纹理模式

如果导入的纹理大小超过了Max Texture Size中的设置值,那么Unity将会把改纹理缩放为这个最大分辨率。理想情况下,导入的纹理可以是非正方形的,但长宽的大小应该是2的幂,例如2,4,8,16,32,64等。如果使用了非2的幂大小(Non Power ofTwo,NPOT)的纹理,那么这些纹理往往会占用更多的内存空间,而且GPU读取改纹理的速度也会有所下降。有些平台甚至不支持这种NPOT纹理,这时Unity在内部会把它缩放成最近的2的幂的大小。出于性能和空间考虑,我们应尽量使用2的幂大小的纹理。

而Format决定了Unity内部使用哪种格式来存储该纹理。如果我们将Texture Type设置为Advanced,那么会有更多的Format供我们选择。这里不再依次介绍每种纹理模式,但需要知道的是,使用的纹理格式精度越高(例如使用Turecolor),占用的内存空间越大,得到的效果也越好。我们可以从纹理导入面板的最下方看到存储该纹理需要占用的内存空间(如果开启了多级渐远纹理技术,也会增加纹理的内存占用)。当游戏中使用了大量Truecolor类型的纹理时,内存可能会迅速增加,因此对于一些不需要使用很高精度的纹理(例如用于漫反射颜色的纹理),我们应该尽量使用压缩格式。

四、在切线空间下计算法线贴图

在图形学笔记七 纹理和贴图 AO中,已经把冯乐乐关于凹凸映射的理论知识转载过去了,复习一下,就可以开始实践部分了。计算法线贴图,分成在切线空间和世界空间两种思路,然后冯乐乐总结的差别,把我看蒙了。其实不必深究,先看看代码是怎么工作的,最后再看差别,更容易明白。

源代码在Scene_7_2_3,Chapter7-NormalMapTangentSpace.shader,可以对比上一个例子看。

1.Properties语义块
    Properties {
        _Color ("Color Tint", Color) = (1, 1, 1, 1)
        _MainTex ("Main Tex", 2D) = "white" {}
        _BumpMap ("Normal Map", 2D) = "bump" {}
        _BumpScale ("Bump Scale", Float) = 1.0
        _Specular ("Specular", Color) = (1, 1, 1, 1)
        _Gloss ("Gloss", Range(8.0, 256)) = 20
    }

要指定法线贴图,多出了_BumpMap ,使用"bump"作为它的默认值。''bump''是Unity内置的法线纹理,当没有提供任何法线纹理时。''bump''就对应了模型自带的法线信息。_BumpScale则是用于控制凹凸程度的,当它为0时,意味着该法线纹理不会对光照产生任何影响。

2.和之前的例子一样,先看frag
fixed4 frag(v2f i) : SV_Target {                
    fixed3 tangentLightDir = normalize(i.lightDir);
    fixed3 tangentViewDir = normalize(i.viewDir);
    
    // Get the texel in the normal map
    fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
    fixed3 tangentNormal;
    // If the texture is not marked as "Normal map"
    //tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
    //tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
    
    // Or mark the texture as "Normal map", and use the built-in funciton
    tangentNormal = UnpackNormal(packedNormal);
    tangentNormal.xy *= _BumpScale;
    tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
    
    fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
    
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
    
    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(tangentNormal, tangentLightDir));

    fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss);
    
    return fixed4(ambient + diffuse + specular, 1.0);
}

对比一下上面的单张纹理:

fixed4 frag(v2f i) : SV_Target {
    fixed3 worldNormal = normalize(i.worldNormal);
    fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
    
    // Use the texture to sample the diffuse color
    fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
    
    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
    
    fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));
    
    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
    fixed3 halfDir = normalize(worldLightDir + viewDir);
    fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
    
    return fixed4(ambient + diffuse + specular, 1.0);
}

对比发现,之前的world开头的,现在都换成了tangent开头的。其中tangentLightDir和 tangentViewDir 取的是顶点着色器输出的内容,tangentNormal 则进行了一些运算。首先就是这个:

fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);

这里用了顶点着色器输出的uv中的zw分量,过会儿再看这个。拿到后,法线纹理中存储的是把法线经过映射后得到的像素值,因此我们需要把它们反映射回来。如果我们没有在Unity里把该法线纹理的类型设置成Normal map,就需要在代码中手动进行这个过程。

3.tangentNormal = UnpackNormal(packedNormal);

书中没有细讲UnpackNormal,不过冯乐乐的博客有讲,参考
【Unity Shaders】法线纹理(Normal Mapping)的实现细节

Unity为了某些原因把上述过程进行了封装,也就是说上述代码在Unity里可以这么做:把法线纹理的“Texture Type”设置成“Normal Map”,在代码中使用UnpackNormal函数得到法线方向。这其中的原因,我猜想一方面是为了方便它对不同平台做优化和调整,一方面是为了解析不同格式的法线纹理。

我们现在可以来回答第一个问题:为什么需要把法线纹理的“Texture Type”设置成“Normal Map”才能正确显示。这样的设置可以让Unity根据不同平台对纹理进行压缩,通过UnpackNormal函数对法线纹理进行正确的采样,即“将把颜色通道变成一个适合于实时法向映射的格式”。我们首先来看UnpackNormal函数的内部实现(在UnityCG.cginc里):

inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)
{
    fixed3 normal;
    normal.xy = packednormal.wy * 2 - 1;
#if defined(SHADER_API_FLASH)
    // Flash does not have efficient saturate(), and dot() seems to require an extra register.
    normal.z = sqrt(1 - normal.x*normal.x - normal.y * normal.y);
#else
    normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
#endif
    return normal;
}
 
inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if (defined(SHADER_API_GLES) || defined(SHADER_API_GLES3)) && defined(SHADER_API_MOBILE)
    return packednormal.xyz * 2 - 1;
#else
    return UnpackNormalDXT5nm(packednormal);
#endif
}

从代码我们可以推导出,对于移动平台上,Unity没有更改法线纹理的存储格式,仍然是RGB通道对应了XYZ方向。对于其他平台上,则使用了另一个函数UnpackNormalDXT5nm。为什么要这样差别对待呢?实际上是因为对法线纹理的压缩。按我们之前的处理方式,法线纹理被当成一个和普通纹理无异的图,但其实,它只有两个通道是真正必不可少的,因为第三个通道的值可以用另外两个推导出来(法线是单位向量)。显然,Unity采用的压缩方式是DXT5nm。这种压缩方式的原理我就不讲了(其实我也不是很懂。有兴趣的可以看这篇),但从通道的存储上,它的特点是,原先存储在R通道的值会被转移到A通道上,G通道保留,而RB通道会使用某种颜色填充(相当于被舍弃了)。因此UnpackNormalDXT5nm函数中,真正法线的xy值对应了压缩纹理的wy值,而z值是通过xy值推导出来的。

4.sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)))

冯乐乐在7.2节提到过:

可压缩,由于切线空间下的法线纹理中的法线z方向总是正方向,因此我们可以仅存储XY方向,从而推导得到Z方向。而模型空间下的法线纹理由于每个方向都是可能的,因此必须存储3个方向的值,不可压缩。

再参考Shader小常识之——法线纹理在切线空间下的存储
假设法线向量为(x,y,z),它可以由(x,y,0)和(0,0,z)两条边组成,既然法线向量是一条单位向量,这两条边构成的三角形又是直角三角形,那么其实只要知道一条边长,就可以用勾股定理计算出另一条边长。实际上法线纹理只存储了xy信息

可以明白这句代码正是勾股定理。

5.frag后面的代码和之前普通纹理的代码就一样了,这里不再分析
6.vert中的zw
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);

o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

这里的v2f其实有点不一样了:

            struct v2f {
                float4 pos : SV_POSITION;
                float4 uv : TEXCOORD0;
                float3 lightDir: TEXCOORD1;
                float3 viewDir : TEXCOORD2;
            };

上一个的是这样:

            struct v2f {
                float4 position : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

参考
float4数据类型

GPU是以四维向量为基本单位来计算的。4个浮点数所组成的float4向量是GPU内置的最基本类型。使用GPU对两个float4向量进行计算,与CPU对两个整数或两个浮点数进行计算一样简单,都是只需要一个指令就可以完成。

HLSH的基本数据类型定义了float、int和bool等非向量类型,但是它们实际上都会被Complier转换成float4的向量,只要把float4向量的其中3个数值忽略,就可以把float4类型作为标量使用。

使用贴图坐标时,只需要二维向量,HLSL定义了float2类型作为二维向量使用。

Shader经常会用到矩阵,HLSL有一个内置类型float4x4,它可以用来表示一个4*4矩阵。float4x4并不是GPU的内置类型,float4x4实际上是由4个float4所组成的数组。其他的还有float3x3、float2x2,分表代表3*3矩阵、2*2矩阵。

Shader也可以声明数组,4*4矩阵实际上就是一个float4 m[4]的数组。注意,Shader中的所有的变量都使用寄存器,没有其他内存空间可以使用,所以越大的数组会占用越多的寄存器,甚至会超出寄存器的数量限制。

在使用float4向量中的个别数值时,可以用xyzw或rgba,都可以用来表示四维向量中的数值。但不能把它们混用,例如不能用xyba,把它视为颜色时就用rgba,否则就是用xyzw,不能把这二者混合使用。
----摘自《3D绘图程序设计

由于我们使用了两张纹理,因此我们需要存储两个纹理坐标。为此,我们把v2f中的uv变量的类型定义为float4类型,其中xy分量存储了_MainTex的纹理坐标,而zw分量存储了_BumpMap的纹理坐标。

实际上,_MainTex和_BumpMap通常会使用同一组纹理坐标,出于减少插值寄存器的使用数目的目的,我们往往只计算和存储一个纹理坐标即可。

7.vert后面的代码书上和源代码不一样了……

书上是这样的:

v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
o.uv.xy=v.texcoord.xy*_MainTex_ST.xy+_MainTex_ST.zw;
o.uv.zw=v.texcoord.xy*_BumpMap_ST.xy+_BumpMap_ST.zw;
//Compute the binormal
// float3 binormal = cross(normalize(v.normal),normalize(v.tangent.xyz))*v.tangent.w;
// //Construct a matrix which transform vectors from object space to tangent space
// float3×3 rotation = float3×3(v.tangent.xyz,binormal,v.normal);
//Or just use the bulid-in macro
TANGENT_SPACE_ROTATION;
//Transform the light direction from object space to tangent space
o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
//Transform the view direction from object space to tangent space
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz;
return o;
}

然后源码是这样的:

///
/// Note that the code below can handle both uniform and non-uniform scales
///

// Construct a matrix that transforms a point/vector from tangent space to world space
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);  
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);  
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; 

/*
float4x4 tangentToWorld = float4x4(worldTangent.x, worldBinormal.x, worldNormal.x, 0.0,
                                   worldTangent.y, worldBinormal.y, worldNormal.y, 0.0,
                                   worldTangent.z, worldBinormal.z, worldNormal.z, 0.0,
                                   0.0, 0.0, 0.0, 1.0);
// The matrix that transforms from world space to tangent space is inverse of tangentToWorld
float3x3 worldToTangent = inverse(tangentToWorld);
*/

//wToT = the inverse of tToW = the transpose of tToW as long as tToW is an orthogonal matrix.
float3x3 worldToTangent = float3x3(worldTangent, worldBinormal, worldNormal);

// Transform the light and view dir from world space to tangent space
o.lightDir = mul(worldToTangent, WorldSpaceLightDir(v.vertex));
o.viewDir = mul(worldToTangent, WorldSpaceViewDir(v.vertex));

注释中的uniform and non-uniform scales不知道说的是啥,没关系,先参照书上说的,搞清之前在干什么。

//Compute the binormal
// float3 binormal = cross(normalize(v.normal),normalize(v.tangent.xyz))*v.tangent.w;
// //Construct a matrix which transform vectors from object space to tangent space
// float3×3 rotation = float3×3(v.tangent.xyz,binormal,v.normal);

这里要构造一个变换矩阵,先重建切线坐标系的三个基,其中副切线需要用叉乘计算一下。参考图形学笔记七 纹理和贴图 AO概念部分的示意图

image.png

这里就是一个叉乘求法线,但是这个法线有两个方向,具体用哪个由v.tangent.w来决定。

struct a2v{
float4 vertex:POSITION;
float3 normal:NORMAL;
float4 tangent:TANGENT;
float4 texcoord:TEXCOORD0;
};

我们使用TANGENT语义来描述float4类型的tangent变量,以告诉Unity把顶点的切线方向填充到tangent变量中。需要注意的是,和法线方向normal不同,tangent的类型是float4,而非float3,这是因为我们需要tangent.w分量来决定切线空间中的第三个坐标轴——副切线的方向性。

对于模型的每一个顶点,都有属于自己的切线空间,所以基变换那一套,要构造基变换矩阵,把切线空间的xyz用模型空间的坐标表示出来。

Unity也提供了一个内置宏TANGENT_SPACE_ROTATION(在UnityCG.cginc中被定义)来帮助我们直接计算得到rotation变换矩阵,它的实现和上述代码完全一样。

但是TANGENT_SPACE_ROTATION就很神奇,看代码注释,它连rotation都没定义,下面居然就能接着用了,大概是内置变量吧。

然后就是使用Unity的内置函数ObjSpaceLightDir和ObjSpaceViewDir来得到模型空间下的光照和视角方向,再利用变换矩阵rotation把它们从模型空间变换到切线空间中。

//Transform the light direction from object space to tangent space
o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
//Transform the view direction from object space to tangent space
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz;

哎,模型空间变到切线空间,传的应该是模型基到切线基啊,可是我们上面弄出来的rotation不是切线基到模型基么,怎么没看到求逆。这里冯乐乐在示例开头也解释了,如果在一个变换中仅存在平移和旋转变换,那么这个变换矩阵的逆矩阵就等于它的转置矩阵,而从切线空间到模型空间的变换正符合这个要求。所以上面构造rotation时已经偷偷地做了转置???

回头再翻翻4.9.2节:

Cg使用的是行优先的方法,既是一行一行的填充矩阵的。因此,如果读者需要自己定义一个矩阵时(例如,自己构建用于空间变换的矩阵),就要注意这里的初始化方式。
类似地,当我们在Cg中访问一个矩阵中的元素时,也是按行来索引的。例如:

// 按行优先的方式初始化矩阵M
float3×3 M = float3×3(1.0,2.0,3.0,
                    4.0,5.0,6.0,
                    7.0,8.0,9.0);
//得到M的第一行,即(1.0,2.0,3.0)
float3 row = M[0];
// 得到M的第二行第一列的元素,即4.0
float ele = M[1][0]

之所以Unity Shader中的矩阵满足上述规则,是因为使用的是Cg语言。换句话说,上面的特性都是Cg的规定。

所以,当我们构造float3×3 rotation = float3×3(v.tangent.xyz,binormal,v.normal);时,其实构造的是行矩阵,而我们说的基坐标变换矩阵其实是列矩阵,那么自然就被偷偷地转置掉了。

现在回头看看源码那部分不同的代码,是利用了世界空间中转。把lightDir和viewDir都转到世界空间,然后使用worldToTangent变换到切线空间。而构造worldToTangent和构造rotation 是类似的,只不过一个是世界空间,一个是模型空间。

至于为什么这样做,就解决了所谓的uniform and non-uniform scales,在https://github.com/candycat1992/Unity_Shaders_Book/issues/45找到了作者回复:

上面代码的问题在于,如果模型存在非统一缩放,即缩放尺度各分量值不相等,那么上述方法就会造成错误的法线变换问题。

关于这个非统一缩放,在第四章第7节 法线变换 有详述。

8.总结

这个示例比之前的要复杂很多,但是复盘一下,发现复杂的只是细节,实际上整体步骤并不多。vert拿到顶点对应的主纹理和法线纹理坐标存到一个float4类型的uv中,然后把lightDir和viewDir转化到切线空间,把这3个变量都甩给frag。

在三角形遍历后,frag拿到的是被插值过后的uv,然后用里面的uv.zw去计算相应法线。这里会涉及到一个解密的过程,即先把颜色值还原回矢量,再把矢量的z用勾股定理算出来。因为这个值存的时候,就是切线空间中的坐标,所以解密后,直接就得到了tangentNormal。

法线tangentNormal算出来后,就可以和tangentLightDir,tangentViewDir愉快地在切线空间下进行光照计算了。

那么我的问题是,tangentLightDir,tangentViewDir被插值了么?

fixed4 frag(v2f i) : SV_Target {                
    fixed3 tangentLightDir = normalize(i.lightDir);
    fixed3 tangentViewDir = normalize(i.viewDir);
...

很明显是直接用的,也可以说,作为一个矢量是不需要插值的,同一个坐标空间,它在哪里都一样。

五、在世界空间下计算法线贴图

我们需要在计算光照模型中统一各个方向矢量所在的坐标空间。由于法线纹理中存储的法线是切线空间下的方向,因此,我们通常有两种选择:

  • (1)在切线空间下进行光照计算,此时我们需要把光照方向、视角方向转换到切线空间下。
  • (2)在世界空间下进行光照计算,此时我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照方向和视角方向进行计算。

从效率上来说,第一种方法往往要优于第二种方法,因为我们在顶点着色器中就可以完成对光照方向和视角方向的变换,而第二种方法由于要先对法线纹理进行采样,所以变换过程必须在片元着色器中实现,这意味着我们需要在片元着色器中进行一次矩阵操作。

但从通用性角度来说,第二种方法要优于第一种方法,因为有时我们需要在世界空间下进行一些计算,例如用Cubemap进行环境映射时,我们需要使用世界空间下的反射方向对Cubemap进行采样。如果同时需要进行法线映射,我们就需要把法线方向变换到世界空间下。当然读者可以选择其他的坐标空间进行计算,例如模型空间等,但切线空间和世界空间是最为常用的两个空间。

上面这段话,是冯乐乐在7.2.3开头时写的,我是想了很久也没想明白。为什么在切线空间光照计算,在顶点着色器中就可以完成对光照方向和视角方向的变换,而在世界空间,必须在片元呢。在网上搜索了一下,其中关于在切线空间下与世界空间下计算法线贴图经过测试,说世界空间计算光照,如果在顶点处理,看起来不一样。可是我怎么看着效果图完全一样呢……,并且这个作者也没有给出原因。

现在我来自己做个试验,把Chapter7-NormalMapWorldSpace.shader的光照方向和视角方向改成Chapter7-NormalMapTangentSpace.shader一样的。

1.输出变量增加lightDir和viewDir
            struct v2f {
                float4 pos : SV_POSITION;
                float4 uv : TEXCOORD0;
                float4 TtoW0 : TEXCOORD1;  
                float4 TtoW1 : TEXCOORD2;  
                float4 TtoW2 : TEXCOORD3; 
                float3 lightDir: TEXCOORD4;
                float3 viewDir : TEXCOORD5;
            };
2.顶点着色器中进行处理
                o.lightDir = WorldSpaceLightDir(v.vertex);
                o.viewDir = WorldSpaceViewDir(v.vertex);
3.片元着色器中直接归一化
fixed4 frag(v2f i) : SV_Target {
    // Get the position in world space      
    //float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
    // Compute the light and view dir in world space
    //fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
    //fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
    fixed3 lightDir = normalize(i.lightDir);
    fixed3 viewDir = normalize(i.viewDir);

注意看上面注释掉的部分,是之前冯乐乐的版本。

4.运行效果完全一样啊,懵逼中
image.png
5.源码中的TtoW0 ,TtoW1,TtoW2理解

这个是基变换的思路,把切线空间的基坐标保存下来

image.png

如图,把竖着的红线看成矩阵的基坐标就容易理解了。然后在bump=normalize(half3(dot(i.TtoW0.xyz,bump),dot(i.TtoW1.xyz,bump),dot(i.TtoW2.xyz,bump)));这里,相当于一个3*3的矩阵TtoW,和3*1的矩阵bump相乘。然后就是TtoW的第一行和bump相乘,TtoW的第二行和bump相乘,,TtoW的第三行和bump相乘。

从这里的分析也可以看出,变换到世界坐标效率确实会变差。因为纹理采样只能在片元着色器进行,而存的法线纹理本身是切线空间的,如果在切线空间计算,解压缩后就不用进行坐标转换了。而在世界坐标下计算光照,还得把解压缩的法线,再做上面的矩阵运算才可以,每个片元都得变换一次,效率肯定差呀。

6.对上面疑问的猜想,不知道对不对

lightDir和viewDir在高光反射中,是为了计算半程向量的。在第六章第5节,实现高光反射有逐顶点和逐像素两种,那个时候,还没有涉及法线纹理,所以都是转到世界坐标空间来计算的,没有什么所谓的切线空间。

然后针对逐顶点计算高光反射,冯乐乐有总结:

使用逐顶点的方法得到的高光效果有比较大问题,我们可以在上图中看出高光部分明显不平滑。这主要是因为,高光反射部分的计算是非线性的,而在顶点着色器中计算光照再进行插值的过程是线性的,破坏了原计算的非线性关系,就会出现较大的视觉问题。因此,我们就需要使用逐像素的方法来计算高光反射。

可以相像一下,在世界坐标系中有一个很大的三角形,某个位置有一个手电筒打过来,我们算出了三个顶点的lightDir,也算出了三角形内每个片元的lightDir。现在三角形遍历后,我要直接拿三个顶点的lightDir插值去得到每个片元的lightDir,这肯定是不正确的,具体如何数学证明,我做不到。

而逐像素计算高光反射就是正确的,在片元着色器里自己算自己的lightDir,重新算,不要插值出来的。这也是上面冯乐乐说的,插值是线性的,而正确的高光反射是非线性的。

在上面的例子中,我就是把lightDir 在顶点着色器中算出来,然后在片元着色器中直接用插值重新计算了高光反射。这和逐顶点的高光反射还不太一样,因为逐顶点高光反射是直接在顶点着色器中就算完了。我这样做理论上确实是错误的,尽管实际效果看不出来太大差别。其实冯乐乐说的,使用逐顶点的方法得到的高光效果有比较大问题,我们可以在上图中看出高光部分明显不平滑。其实我也没看出来差别,或许是误差太小??

现在,另一个无法解释的事情出现了。为什么在切线空间下,就可以提前在顶点着色器中计算出lightDir和viewDir,然后在片元着色器中使用插值计算光照呢。这里我仍然做个猜测,是因为坐标空间的不同。切线空间本质上是属于顶点的,它已经脱离了世界空间的影响。

以上猜测不知道对不对,有知道答案的请在评论区告诉我。

另外,在这里我也遇到有网友同样的疑问:
https://zhuanlan.zhihu.com/p/90727584

六、渐变纹理

尽管在一开始,我们在渲染中使用纹理是为了定义一个物体的颜色,但后来人们发现,纹理其实可以用来存储任何表面属性。一种常见的用法就是使用渐变纹理来控制漫反射光照的结果。在之前计算漫反射光照结果的时候,我们都是使用表面法线和光照方向的点积结果与材质的反射率相乘来得到表面的漫反射光照。但有时,我们需要更加灵活的控制光照。使用这种技术,可以保证物体的轮廓线相比于之前使用的传统漫反射光照更加明显,而且能够提供多种色调变化。

在本节中,我们将学习如何用一张渐变纹理来控制漫反射光照。然后得到类似下图的效果。


图7.18 使用不同的渐变纹理控制漫反射光照,左下角给出了每张图使用的渐变纹理

可以看出,使用这种方式可以自由的控制物体的漫反射光照。不同的渐变纹理有不同的特性。例如在左边的图中,我们使用一张从紫色调到浅黄的渐变纹理;而中间的渐变纹理是从黑色向浅灰色靠拢,而中间的分界线略微微发红,这是因为画家在插画中往往会在阴影中使用这种色调;右边的渐变纹理则通常被用于卡通风格的渲染,这种渐变纹理中的渲染通常是突变的,既没有平滑过渡,以此来模拟卡通中的阴影色块。

示例参见Scene_7_3,Chapter7-RampTexture.shader

1.Lambert模型

参考图形学笔记六 Shading 渲染管线

image.png

2.半Lambert模型
image.png

可以看出,与原兰伯特模型相比,半兰伯特光照模型没有使用max操作来防止n和l的点积为负值,而是对其结果进行了一个α倍的缩放再加上一个β大小的偏移。绝大多数情况下,α和β的值均为0.5。

通过这样的方式,我们可以把n·l的结果范围从[-1,1]映射到[0,1]的范围内。也就是说,对于模型的背光面,在原版兰伯特光照模型中点积结果将映射到同一个值,即0值处;而在半兰伯特模型中,背光面也可以有明暗变化,不同的点积结果会映射到不同的值上。

这是Lambert模型,Chapter6-DiffusePixelLevel.shader:

fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLight));

这是半Lambert模型Chapter6-HalfLambert.shader:

// Compute diffuse term
fixed halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * halfLambert;
3.纹理采样

这是正常的采样,Chapter7-SingleTexture.shader:

fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));

这是渐变纹理采样Chapter7-RampTexture.shader:

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// Use the texture to sample the diffuse color
fixed halfLambert  = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
fixed3 diffuseColor = tex2D(_RampTex, fixed2(halfLambert, halfLambert)).rgb * _Color.rgb;
fixed3 diffuse = _LightColor0.rgb * diffuseColor;

从之前的知识可以知道,halfLambert 是反应光线照射量的,只不过经过一个系数的修改0.5(n.l)+0.5,从[-1,1]映射到[0,1]范围。比如Lambert模型的正面照射是1,现在是0.5 x 1+0.5,仍然是1。Lambert模型的-0.3,算作0。到了半Lambert模型,就是0.35,就能看到了。也就是说,halfLambert 仍然反应了光照与当前片元的夹角关系,越接近90度,值就越接近1。

现在,不使用uv做纹理采样,而是使用halfLambert 会发生什么呢?反正不会报错,因为uv也是[0,1]范围的。而且,光照越是接近90度,纹理采样就越接近1的位置。看一下左下角使用的纹理:


image.png

从左到右,是黑到白的渐变。所以结论就是,光照越是接近90度,就越白,可以理解为越亮。


image.png

这个就是正面很亮,背景是紫的。
4.Wrap Mode设置为Clamp模式

需要注意的是我们需要把渐变纹理的Wrap Mode设置为Clamp模式,以防止由于浮点数精度而造成的问题。下图给出了WrapMode分别为Repeat和Clamp模式的效果对比。


图7.19 Wrap Mode分别为Repeat和Clamp模式的效果对比

可以看出,左图(使用Repeat模式)在高光区域有一些黑点。这是由于浮点数精度造成的,当我们使用fixed2(halfLambert,halfLambert)对渐变纹理进行采样时,虽然理论上halfLambert的值在[0,1]之间,但可能会有1.00001这样的值出现。如果我们使用的是Repeat模式,此时就会舍弃整数部分,只保留小数部分,得到的值就是0.00001,对应了渐变图中最左边的值,即黑色。因此,就会出现图中这样在高光区域反而有黑点的情况。我们只需把纹理的WrapMode设为Clamp模式就可以解决这种问题。

七、遮罩纹理

什么是遮罩呢?简单来讲,遮罩可以允许我们保护某些区域,使它们免于 某些修改。例如,在之前的实现中,我们都是把高光反射应用到模型表面的所有地方,即所有的像素都使用同样大小的高光强度和高光指数。但有时我们希望模型表面的某些区域的反光强一些,而某些区域弱一些。为了得到更加细腻的效果,我们就可以使用一张遮罩纹理来控制光照。另一种是常见的应用是在制作地形材质时需要混合多张图片,例如表现草地的纹理、表现石子的纹理、表现裸露土地的纹理等,使用遮罩纹理可以控制如何混合这些纹理。

使用遮罩纹理的一般流程是:通过采样得到遮罩纹理的纹素值,然后使用其中某个(或几个)通道的值(texel.r)来与某种表面属性进行相乘,这样当该通道的值为0时,可以保护表面不受该属性影响。总而言之,使用遮罩纹理可以让美术人员更加精准(像素级别)地控制模型表面的各种性质。

示例参见Scene_7_4,Chapter7-MaskTexture.shader,大部分步骤和Chapter7-NormalMapTangentSpace.shader都是一样的。区别在:

//Get the value
fixed specularMask=tex2D(_SpecularMask,i.uv).r*_SpecularScale;
//Compute specular term with the specular mask
fixed3 specular=_LightColor0.rgb*_Specular.rgb*pow(
max(0,dot(tangentNormal,halfDir)),_Gloss)*specularMask;

嗯,就是高光反射多算一个遮罩的相关数值specularMask。

由于本书中使用的遮罩纹理的每个纹素的rgb分量其实都是一样的,表明了该点对应的高光反射强度,在这里我们选择使用r分量来计算掩码值。然后我们得到的掩码值和_SpecularScale相乘,一起来控制高光的反射强度。

需要说明的是,我们使用的这张遮罩纹理其实有很多空间被浪费了——它的rgb分量存储的都是同一个值。在实际的游戏制作中,我们往往会充分利用遮罩纹理中的每一个颜色通道来存储不同的表面属性。

1.Properties
    Properties {
        _Color ("Color Tint", Color) = (1, 1, 1, 1)
        _MainTex ("Main Tex", 2D) = "white" {}
        _BumpMap ("Normal Map", 2D) = "bump" {}
        _BumpScale("Bump Scale", Float) = 1.0
        _SpecularMask ("Specular Mask", 2D) = "white" {}
        _SpecularScale ("Specular Scale", Float) = 1.0
        _Specular ("Specular", Color) = (1, 1, 1, 1)
        _Gloss ("Gloss", Range(8.0, 256)) = 20
    }

我们为主纹理_MainTex、法线纹理_BumpMap和遮罩纹理_SpecularMask定义了它们共同使用的纹理属性_MainTex_ST。这意味着,在材质面板中修改主纹理的平铺系数和偏移系数会同时影响3个纹理采样。使用这种方式可以让我们节省需要存储的纹理坐标数目,如果我们为每一个纹理都使用一个单独的属性变量TextureName_ST,那么随着纹理数目的增加,我们会迅速占满顶点着色器中可以使用的插值寄存器。而很多时候,我们不需要对纹理进行平铺和位移操作,或者很多纹理可以使用同一种平铺和位移操作,此时我们就可以对这些纹理使用同一个变换后的纹理坐标进行采样。

2.其他遮罩纹理

在真实的游戏制作过程中,遮罩纹理已经不止限于保护某些区域使它们免于修改,而是存储任何我们希望逐像素控制的表面属性。通常,我们会充分利用一张纹理的RGBA四个通道,用于存储不同的属性。例如我们把高光反射强度存储在R通道,把边缘光照的强度存储在G通道,把高光发射的指数部分存储在B通道,最后把自发光强度存储在A通道。

在游戏《DOTA2》的开发中,开发人员为每个模型使用了4张纹理:一张用于定义模型颜色,一张用于定义表面法线,另外两张则都是遮罩纹理。这样,两张遮罩纹理提供了共8种额外的表面属性,这使得游戏中的人物材质自由度很强,可以支持很多高级的模型属性。读者可以在他们的官网上找到关于《DOTA2》的更加详细的制作资料,包括游戏中的人物模型、纹理以及制作手册等。这是非常好的学习资料。

你可能感兴趣的:(UnityShader精要笔记六 基础纹理)