Unity 渲染过程中的纹理

在渲染的世界中,为图形增加更多的细节,有很多种方式。比如,可以为每个顶点添加颜色来达到多彩的效果,也可以使用纹理来添加细节。当使用顶点携带额外的颜色属性来增强细节时,为了达到更逼真的效果就必须增加顶点的数量,无疑会带来渲染性能上的开销。所以,大多数时候都会选择使用纹理贴图(纹理映射)的方式来增强细节。

对于一张纹理,除了普通存储颜色值之外,它还可以用来存储高度值(用于凹凸映射)或者法线值(用于法线映射)。

纹理映射

使用纹理映射技术可以达到多彩的效果。在 Unity 中,使用纹理坐标对纹理进行采样,纹理坐标范围在 [0,1] 之间,坐标系与 OpenGL 一致(原点在左下角,X 轴朝右,Y 轴朝上)。在前面的文章中说到过,网格顶点上通常包含了一组或多组可用的纹理坐标,我们可以使用这些纹理坐标对纹理进行采样。

texture_in_unity_rendering_1.png

纹理设置 - Wrap Mode

在 Unity 中,Wrap Mode 主要就是规定在纹理坐标超过 [0,1] 纹理该如何采样。

Wrap mode determines how texture is sampled when texture coordinates are outside of the typical 0..1 range.

Texture 有多种 Wrap Mode,不同的模式在不同的参数下采样情况会有些不同,这些参数主要体现在材质中对 Texture 的设置,如 TilingOffset,在《一次简单的网格渲染》一文中提到过,这两个设置参数会改变网格的纹理坐标(默认 Shader 中使用了 TRANSFORM_TEX),主要作用是缩放和位移。

下面就来简单看看各种 Wrap Mode 在不同 TilingOffset 出现的差异(Texture Type 默认为 Default,Filter Mode 默认为 Bilinear,默认对 U 方向进行了设置,V 同理)。

  • 当 Wrap Mode 为 RepeatClamp,且材质中 Texture 的设置为 Tiling(1,1)Offset(0,0),这时纹理会完全贴在模型上,一般情况下这都是我们需要的结果。
texture_in_unity_rendering_3.png
  • 接下来,将 Wrap Mode 设置为 Repeat,且材质中 Texture 的设置修改为 Tiling(2,1)Offset(0,0)
texture_in_unity_rendering_4.png

我们发现,纹理在 X 轴上就行了平铺。当 Wrap Mode 设置为 Repeat 模式下,Unity 会根据 Tiling 设置对网格纹理坐标进行缩放(Shader 中使用 TRANSFORM_TEX 宏实现)。

看一下修改网格顶点纹理坐标的 Shader 代码(TRANSFORM_TEX 宏的实现):

1
o.uv = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;

其中 _MainTex_ST.xy 就是 Tiling.xy,通过与原始顶点上的纹理坐标相乘就就行了缩放操作,在上面的 Tiling 为 (2,1) 的设置下,最终纹理坐标 U 的范围就是 [0,2]。在片元着色器中纹理采样时,由于设置了 Repeat 模式,所以会重复采样。 Repeat 模式下的纹理本身坐标大概如下图:

texture_in_unity_rendering_2.png
  • 现在,保持上面的 TilingOffset 的设置,将 Wrap Mode 设置为 Clamp,会得到如下的渲染效果。
texture_in_unity_rendering_5.png

Clamp 模式将纹理坐标限定在了 [0,1] 之间,当采样坐标小于 0 会返回 0 坐标处对应的颜色值,超过 1 时会返回 1 坐标处对应的颜色值。我们上面的渲染过程中,采样纹理坐标 U 的值在 [0,2] 之间,所以对 U 值在 [1,2] 内的采样值都是返回坐标 1 处的颜色。

  • 现在再将 Wrap Mode 设置为 MirrorTiling 为 (4,1) 以及 Offset 设置为 (-2,0)。
texture_in_unity_rendering_6.png

看模式名字也能知道,设置为 Mirror 纹理采样时就是镜像处理。为了更好的看镜像之后的结果,我们对 U 轴进行了 4 倍缩放,并向左移动了 2,此时的采样坐标范围就是 [-2,2]。可以看出, MirrorRepeat 有些类似,但是不同的是 Mirror 重复采样时是对纹理进行翻转再采样。

  • 看完了 Mirror,再看看与之类似的 Mirror Once。将 Wrap Mode 设置为 Mirror Once,其它设置不变,看看渲染效果。
texture_in_unity_rendering_7.png

在 [-1,1] 之间,同样进行了镜像采样操作。但是超过这个区间,采样就和 Clamp 模式一样了,超过 1 返回的是 1 坐标处的颜色值,小于 -1 返回 -1 坐标处的颜色值。Mirror Once 可以看作 MirrorClamp 相结合。

  • 最后看看 Per-axis 这种模式。选择这种模式时,会让你设置 U axixV axis 两个轴上的 Wrap Mode,这个模式就是让你能够区分 U 和 V 设置单独的 Wrap Mode。

纹理设置 - Filter Mode

讲这个属性设置之前,先说说我遇到的一个问题。

有一个平面模型,它由多个三角形网格组成。如下图。

texture_in_unity_rendering_8.png

这个平面模型有点特别,特别的是它每个网格顶点携带的 UV 坐标,可以看到除了最后一列顶点的 U 为 1 其余列的全部为 0。V 也类似。再看看将纹理渲染出的效果。

texture_in_unity_rendering_11.png

在 Wrap Mode 设置为 ClampMirrorMirror Once 时,看上去渲染得到的图像很正常,但当设置为 Repeat 模式时,问题来了,那些莫名的颜色是哪来的?带着这个问题,再来看看 Filter Mode。

在 Unity 中,Filter Mode 用于设置纹理被拉伸变换时,纹理将如何过滤插值。可以设置为 Point(no filter)BilinearTrilinear 几种模式。

  • Point(no filter) 模式

Point filtering - texture pixels become blocky up close.

使用这种模式时,纹理采样得到的颜色值是纹理坐标所对应的那一个纹理像素对应的颜色值。如同上面文档所说,纹理将会变得像素(颗粒状)。这种模式对应了 OpenGL 中的 GL_NEAREST

渲染效果如下图:

texture_in_unity_rendering_13.png

可以看出,在颜色交界处有较多的颗粒状颜色。有像素化的风格。

  • Bilinear (双线性插值)模式

Bilinear filtering - texture samples are averaged.

双线性插值,根据相邻的四个像素进行插值得到最终的颜色值。双线性插值的具体思想是在两个方向上进行插值。简单来说就是根据四个已知点,首先在 X 轴方向插值两次,对得到的两个插值结果再在 Y 轴方向上进行一次插值。

texture_in_unity_rendering_16.png

双线性插值简单流程: 已知 A1、A2、A3、A4 四个点(对应四个纹理像素的中心)的纹理坐标分别为 (x1,y1)、(x2,y1)、(x1,y2) 和 (x2,y2),四个点颜色值分别为 f(A1)、f(A2)、f(A3) 和 f(A4),现在要使用双线性插值计算得到点 F 处(x,y)的颜色值 f(F)。

首先在 X 轴方向上通过 A1、A2 对点 R2 进行插值得到 R2 处的颜色值。R2 处的纹理坐标为 (x,y1)。

\[ \frac{f(R2)-f(A1)}{x-x1}=\frac{f(A2)-f(A1)}{x2-x1} \]

\[ f(R2)=\frac{(f(A2)-f(A1))\times(x-x1)}{x2-x1}+f(A1) \]

\[ f(R2)=\frac{x-x1}{x2-x1}\times f(A2)+\frac{x2-x}{x2-x1}\times f(A1) \]

根据上面的公式可以看出,x 越靠近 x1,得到的值越接近 f(A1),也就是颜色值越接近 A1 处的颜色值。

同理再在 X 轴方向上使用 A3、A4 对点 R1 进行插值得到 R1 处的颜色值。

\[ f(R1)=\frac{(f(A4)-f(A3))\times(x-x1)}{x2-x1}+f(A3) \]

\[ f(R1)=\frac{x-x1}{x2-x1} \times f(A4)+\frac{x2-x}{x2-x1} \times f(A3) \]

然后再使用 R1 和 R2 对点 F 在 Y 轴上插值。

\[ \frac{f(F)-f(R1)}{y-y2}=\frac{f(R2)-f(R1)}{y1-y2} \]

\[ f(F)=\frac{f(R2)-f(R1)}{y1-y2}\times(y-y2)+f(R1) \]

\[ f(F)=\frac{y-y2}{y1-y2} \times f(R2)+\frac{y1-y}{y1-y2} \times f(R1) \]

根据 Y 轴插值公式可以看出最终 F 点出的颜色值,y 越靠近 y1,值越靠近 f(R2),即颜色越靠近 R2 处的颜色。所以纹理坐标越靠近某个纹理像素中心点,该纹理像素对最终颜色值的贡献越大。这种模式对应了 OpenGL 中的 GL_LINEAR

Bilinear 渲染效果如下图:

texture_in_unity_rendering_14.png

渲染途中对颜色交界处处理的比较平滑。看上去像是被模糊处理了。

  • Trilinear (三线性滤波插值)模式

Trilinear filtering - texture samples are averaged and also blended between mipmap levels.

三线性滤波插值,类似 Bilinear 三线性滤波插值。它还会在双线性滤波插值的基础上使用 Mipmap(多级渐远纹理)对颜色值进行混合。

现在再回到前面我们遇到的那个问题。当 Wrap Mode 设置为 Repeat,Filter Mode 设置为 Bilinear,且纹理渲染在了一组拥有特别纹理坐标的网格上。现在就看看问题是如何产生的吧!

我们知道,Wrap Mode 设置为 Repeat 时,纹理采样是采样平铺的模式,相当于使用同一张纹理无限的拼接成了一张大纹理,而采样就根据具体的纹理坐标采样。而此时当 Filter Mode 设置为 Bilinear,会根据纹理坐标所映射的纹理像素以及其周边三个纹理像素进行滤波插值。正是这个原因: 当在边界处进行纹理采样时,由于需要四个纹理像素进行滤波插值,所以 0 坐标处的颜色值就可能会和其左边纹理像素混合采样插值,而其左边的纹理像素又是当前纹理的纹理坐标为 1 处的颜色,我们使用的纹理图片中最左边和最右边纹理像素对应的颜色值是不一样的,所以经过双线性滤波插值就出现了混合色的效果。同理上下也有可能出现这样的问题。

那么如何解决了? 我们知道,Filter Mode 如果使用 Point 模式,则只会采样 [0,1] 内的纹理像素颜色值,超过 [0,1] 则返回边界处纹理像素的颜色,因此就不会出现混合色效果。

所以,将 Filter Mode 设置为 Point。修改后的渲染图如下:

texture_in_unity_rendering_12.png

可以看到,那些因 Bilinear 滤波插值得到的混合色被坐标 0 处的颜色给替代了。

凹凸映射

普通纹理映射可以达到增强表面效果的作用。但是对于想要渲染出一种类似凹凸的效果,纹理映射就无法满足需求。我们可以使用高度纹理或法线纹理实现凹凸映射。

高度纹理

凹凸映射使用高度纹理(图)(纹理贴图存储的是颜色值,高度图存储的是强度值),高度图中的强度值表示模型的局部海拔高度。凹凸映射的主要原理是通过计算高度图中相邻像素的高度差值来改变表面法向量的值,从而影响光照计算的结果,就能模拟出凹凸的效果。通常的计算方式如下:

计算每个纹理像素上 X 和 Y 的倾斜度:

\[ xGradient = pixel(x-1, y) - pixel(x+1, y) \]

\[ yGradient = pixel(x, y-1) - pixel(x, y+1) \]

根据计算得到的倾斜度,在顶点的切线空间中对法线进行干扰偏移(切线方向使用 yGradient 偏移,副切线方向使用 xGradient 偏移)。

\[ newNormal = originNormal + tangent \times yGradient + bitangent \times xGradient \]

最后使用新得到的法线向量计算光照。高度图通常也会和法线映射一起使用,用于给出表面凹凸的额外信息。

使用高度图计算简单过程如下:

  • 首先,在顶点着色器中计算邻域采样坐标。
1
2
3
4
5
6
7
8
9
sampler2D _BumpMap;
sampler2D _Bump;
half4 _Bump_TexelSize;
o.uv[0] = v.texcoord;
o.uv[1] = v.texcoord - float2(_Bump_TexelSize.x, 0.0);
o.uv[2] = v.texcoord + float2(_Bump_TexelSize.x, 0.0);
o.uv[3] = v.texcoord - float2(0.0, _Bump_TexelSize.y);
o.uv[4] = v.texcoord + float2(0.0, _Bump_TexelSize.y);
  • 其次,在片元着色器中分别计算倾斜度,根据倾斜度计算干扰后的法线。最后根据法线向量计算光照。
1
2
3
4
5
6
7
fixed3 xGradient = tex2D(_Bump, i.uv[1]).rgb - tex2D(_Bump, i.uv[2]).rgb;
fixed3 yGradient = tex2D(_Bump, i.uv[3]).rgb - tex2D(_Bump, i.uv[4]).rgb;
fixed3 normal = UnpackNormal(tex2D(_BumpMap, i.uv[0]));
normal = normal + i.tangent * yGradient + i.bitangent * xGradient;
// use normal ...

法线纹理

法线贴图也是一种特定的凹凸贴图的方法。在法线纹理中,我们将法线信息直接存储到了纹理中。像素值范围通常为 [0,1],而法线向量分量范围为 [-1,1],所以通常使用法线纹理的时候需要做一个映射得到像素值对应的真正的法线向量。

\[ normal = pixel \times 2 - 1 \]

法线到像素的映射为:

\[ piexl = (normal + 1) \times 2 \]

在 Unity 中使用法线纹理,导入的时候应当将纹理 Texture Type 设置为 Normal map,然后在 Shader 中使用 UnpackNormal 即可得到映射过后的正确的法线向量。当然你也可以自行在 Shader 中进行映射:

1
2
fixed4 normal = tex2D(_Bump, i.uv);
normal.xyz = norm.xyz * 2 - 1;

但是,使用 UnpackNormal 以及设置 Texture Type 为 Normal map 可以免去不同平台所需要进行的处理(比如压缩处理)。

表面法线在法线纹理中的法线向量可以属于不同的坐标空间中。比如最直观的,将模型空间中每个顶点的表面法线直接存储于一张纹理中,即模型空间中的法线纹理。但是在不同的模型使用模型空间中的法线纹理时,就有可能导致意想不到的效果,所以复用性很差。通常情况下,我们会使用切线空间下的法线纹理。这样把该纹理应用到不同的模型上,也可以得到一个合理的效果。

Shader 使用切线空间的法线纹理计算光照过程大致如下:

  • 首先,在顶点着色器中计算切线空间到世界空间的转换矩阵。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sampler2D _BumpMap;
v2f vert(a2v v) {
v2f o;
// ...
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
// ...
}
  • 其次,在片元着色器中采样法线纹理,映射后得到切线空间下的法线。
1
2
3
fixed4 frag(v2f i) : SV_Target {
fixed3 normal = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
}
  • 最后,将法线向量由切线空间转换到世界空间,再使用法线向量参与光照计算即可。
1
2
normal = half3(dot(i.TtoW0.xyz, normal), dot(i.TtoW1.xyz, normal), dot(i.TtoW2.xyz, normal));
// use normal ...

参考