- 美术人员建模时,通常会利用
纹理展开技术
把纹理映射坐标
存储在每个顶点上 纹理映射坐标
定义了该顶点在纹理中对应的 2D 坐标(UV 坐标)- Unity 中使用的 UV 坐标系只有一种------OpenGL

7.1 单张纹理
7.1.1 实践
步骤:
- 老 5 步(新建场景,去掉天空盒,新建材质,新建 Unity Shader并赋给材质,场景中新建胶囊体并将材质赋给它)
- Shader 代码:
-
声明属性
- 新的属性 _MainTex,类型为 2D,它是纹理属性的声明方式。后面使用一个字符串和花括号作为它的初始值,"white" 是内置纹理的名字(全白)
- 为了控制物体的整体色调,还声明了一个 _Color 属性
-
在 Pass 的首行指明光照模式
- "ForwardBase" 不行用 "UniversalForward"
-
Pass 中的其他"头"代码
- 与其他属性类型不同,需要为纹理类型的属性声明一个 float4 的变量 _MainTex_ST
- 在 Unity 中,需要使用 纹理名_ST 的方式来声明某个纹理的属性,ST 是缩放(scale)和平移(translation) 的缩写
- _MainTex_ST 可以让我们得到该纹理的缩放 和平移(偏移)值 ,_MainTex_ST.xy 存储的是缩放值 ,_MainTex_ST.zw 存储的是偏移值
-
定义顶点着色器的输入和输出结构体
-
定义顶点着色器
jsv2f vert (a2v v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.uv = TRANSFORM_TEX(v.texcoord, _MainTex); return o; }
- UnityObjectToClipPos:内置函数,将
顶点坐标
从模型空间
转换到裁剪空间
- UnityObjectToWorldNormal:将
顶点法线矢量
从模型空间
转换到世界空间
- TRANSFORM_TEX:内置宏,根据
顶点纹理坐标
和纹理名
,得到UV 纹理坐标
- UnityObjectToClipPos:内置函数,将
-
定义片元着色器
jsfixed4 frag (v2f i) : SV_Target { fixed3 worldNormal = normalize(i.worldNormal); fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos)); // -------- 计算漫反射 // 利用纹理采样得到漫反射颜色 fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb; fixed3 diffuse = _LightColor0.rgb * albedo * (dot(worldNormal, worldLightDir) * 0.5 + 0.5); // -------- 环境光 fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo; // -------- 计算高光 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); }
-
最后设置 Fallback
jsFallback "Specular"
-
给材质的
Main Tex属性
设置好纹理贴图(纹理资源目录)后的效果

7.1.2 纹理的属性
点击导入的纹理贴图,可以看到属性面板中看到它的属性
-
纹理类型(Texture Type):设置合适的类型,以让 Unity 知道我们的意图,为 Unity Shader 传递正确的纹理,一些情况下可以让 Unity 对纹理进行优化
-
包裹模式(Wrap Mode):决定了当纹理坐标超过 [0,1] 范围后将会如何被平铺
- Repeat:舍弃纹理坐标整数部分,直接使用小数部分来采样,效果就是不断重复
- Clamp:大于 1 使用 1,小于 0 使用 0 来采样
-
滤波模式(Filter Mode):决定了当纹理由于变换而产生拉伸时将采用哪种滤波模式,影响缩放纹理时得到的图片质量。有 3 种模式,滤波效果和性能消耗依次提升
- Point(no filter):点(无滤波)。取最近一个像素
- Bilinear:双线性。取 4 个邻近像素进行线性插值混合
- Trilinear:三线性。除了使用双线性技术,还会在
多级渐远纹理(mipmap)
之间进行混合
-
纹理缩小比放大更加复杂一点,此时原纹理中多个像素会对应到一个目标像素。一种常用的方法就是
多级渐远纹理(mipmap)
- 将原纹理提前用滤波处理得多级更小的图像,每一级都是对上一级图像降采样的结果
- 运行时就可快速得到结果像素(如物体远离摄像机时,直接使用较小的纹理)
- 缺点是需要消耗一定的空间来存储这些多级纹理,通常会多占 33% 空间
- 开启方法
效果对比(左图远处有波纹)
-
最大尺寸(Max Size):纹理的最大尺寸,若超过了,Unity 会把该纹理缩小为这个最大分辨率
- 导入的纹理长宽最好是 2 的幂,否则
- 会占用更多的内存空间
- GPU 读取该纹理的速度会下降
- 导入的纹理长宽最好是 2 的幂,否则
-
存储格式(Format):决定了 Unity 使用哪种格式来存储该纹理
- 精度越高,占用内存空间越大,但效果越好
7.2 凹凸映射(bump mapping)
纹理的另一种常见应用,目的是使用一张纹理来修改模型表面的法线,以便为模型提供更多细节
有两种方法来实现
7.2.1 高度纹理
使用高度纹理(height map)来模拟表面位移,然后得到一个修改后的法线值
- 高度图中存储的是强度值(intensity),用于表示模型表面局部的海拔高度
- 颜色越浅表示该位置的表面越凸出
- 优点是直观,缺点是计算复杂
- 高度图常会和法线映射一起使用
7.2.2 法线纹理
使用一张法线纹理(normal map)直接存储表面法线
-
实际制作中,法线的坐标空间往往不会 采用
模型空间
,而是采用顶点的切线空间
------每个顶点自身的坐标空间。所得纹理称为切线空间的法线纹理
- 原点是顶点自身
- z 轴是顶点的法线方向( <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n)
- x 轴是顶点的切线方向( <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t)
- y 轴可由法线和切线的叉积而得,被称为副切线( <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b)
-
两种空间法线纹理比较
- 模型空间的看起来是"五颜六色"的,因为所有法线方向是各异的,映射到纹理后颜色也各异
- 切线空间下的看起来几乎全部都是浅蓝色(法线(0,0,1)映射到纹理后为 RGB(0.5,0.5,1),即浅蓝色),表明大部分法线和模型本身的法线一样,不需要改变
-
美术人员往往更喜欢切线空间的法线纹理,因可节省工作量
-
模型空间法线纹理的优点:
- 实现简单,计算更少,更加直观
- 可提供平滑的边界(纹理坐标缝合处和尖锐的边角部分,可见的突变较少)
-
切线空间法线纹理优点更多:
- 自由度很高。可应用于另一个网格上
- 可进行 UV 动画。比如移动纹理的 UV 坐标来实现一个凹凸移动的效果(流体、布料)
- 可重用法线纹理。比如一个砖块,仅使用一张法线纹理就可应用到 6 个面上
- 可压缩。由于法线 Z 方向总是正方向,因此可以仅存储 XY 方向,而推导得到 Z 方向 (详解)
7.2.3 实践
我们需要在计算光照模型中统一各个方向矢量所在的坐标空间,通常有两种选择:
- 在切线空间下计算:要把光照方向、视角方向变换到切线空间下
- 在世界空间下计算:要把采样得到的法线方向变换到世界空间下
从效率上来说,第一种方法优于第二种方法
- 可在
顶点着色器
中就完成对光照方向
和视角方向
的变换 - 第二种方法要先对
法线纹理
进行采样
,所以变换过程必须在片元着色器
中实现,即需要在片元着色器中进行一次矩阵操作
从通用性角度看,第二种方法优于第一种
- 有时需要在世界空间下进行一些涉及其他信息的通用计算
1. 在切线空间下计算
在片元着色器中采样得到
法线
,然后再与切线空间下的视角方向
、光照方向
等进行计算,得到最终结果
- 先在顶点着色器中将视角方向和光照方向从模型空间变换到切线空间,即需要知道模型空间到切线空间的变换矩阵
- 在顶点着色器中按切线(x 轴)、副切线(y 轴)、法线(z 轴)的顺序按列 排列,即可得到切线空间到模型空间的变换矩阵
- 若一个变换仅存在平移和旋转变换,那么这个变换的逆矩阵就等于它的转置矩阵
- 切线空间到模型空间的变换矩阵 的转置矩阵,就是模型空间到切线空间的变换矩阵(x、y、z 按行排列)
步骤:
-
老 5 步(新建场景,去掉天空盒,新建材质,新建 Unity Shader并赋给材质,场景中新建胶囊体并将材质赋给它)
-
Shader 代码:源码
- Properties 语义块:
- _BumpMap:使用了"bump"作为默认值,它是 Unity 内置的法线纹理(对应模型自带的法线信息)
- _BumpScale 用于控制凹凸程度
- CG 代码中声明的变量:
- 为 _MainTex 和 _BumpMap 定义了对应的 XXX_ST 变量
- 顶点着色器输入结构体 a2v:
- 使用
TANGENT
语义来描述 float4 类型的 tangent 变量,以告诉 Unity 把顶点切线方向
填充进去。和 normal 不同,tangent 是 float4,tangent.w 分量决定切线空间中的第三个坐标轴------副切线
的方向性
- 使用
- 顶点着色器输出到片元着色器的结构体 v2f:
- 添加两个变量(lightDir, viewDir)来存储转换到切线空间下的视角方向和光照方向
- 顶点着色器代码
jsv2f vert (a2v v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); // -------- 存储相关纹理坐标 -------- // 由于使用了两张纹理,因此需要存储两个纹理坐标 // v.uv.xy 存储的是 _MainTex 的 // v.uv.zw 存储的是 _BumpTex 的 o.uv.xy = v.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw; o.uv.zw = v.uv.xy * _BumpMap_ST.xy + _BumpMap_ST.zw; // 下面代码只支持统一缩放 // TANGENT_SPACE_ROTATION; // o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz; // o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz; // -------- 计算切线空间下的光照方向和视角方向 -------- // 支持统一或非统一缩放 fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz); fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; // 世界空间到切线空间的变换矩阵 float3x3 worldToTangent = float3x3(worldTangent, worldBinormal, worldNormal); o.lightDir = mul(worldToTangent, WorldSpaceLightDir(v.vertex)); o.viewDir = mul(worldToTangent, WorldSpaceViewDir(v.vertex)); return o; }
-
片元着色器代码
jsfixed4 frag (v2f i) : SV_Target { fixed3 tangentLightDir = normalize(i.lightDir); fixed3 tangentViewDir = normalize(i.viewDir); // -------- 凹凸反射 -------- // 对法线纹理 _BumpMap 进行采样 fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw); // 使用 UnpackNormal 函数将 packedNormal.xy 反映射回法线方向 // 前提是将法线纹理的纹理类型设为 Normal map fixed3 tangentNormal = UnpackNormal(packedNormal); // 处理凹凸程度 _BumpScale tangentNormal.xy *= _BumpScale; // 计算出法线的 z 分量 // 1 = sqrt(x² + y² + z²) // z = sqrt(1 - (x² + y²)) // dot(tangentNormal.xy, tangentNormal.xy) 计算的是 xy 分量的平方和(x² + y²) tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); // 漫反射 fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb; fixed3 diffuse = _LightColor0.rgb * albedo * (dot(tangentNormal, tangentLightDir) * 0.5 + 0.5); // 环境光 fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo; // 高光 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); }
- 当把法线纹理的纹理类型设为
Normal map
时,可使用 Unity 的内置函数UnpackNormal
来得到正确的法线方向
- 当把法线纹理的纹理类型设为
- Properties 语义块:
效果图(值为 -1 的砖块凸起来,感觉这是想要的效果,):

2. 在世界空间下计算
在
片元着色器
中把法线方向
从切线空间
变换到世界空间
下,然后再与其他世界空间下的向量进行计算
- 在顶点着色器中计算从切线空间变换到世界空间的变换矩阵,并把它传给片元着色器
- 尽管这方法需要更多的计算,但在需要使用 Cubemap 进行环境映射等情况下需要用到
步骤:
-
老 5 步
-
Shader 代码:源码
-
顶点着色器输出结构体 v2f,使它包含从切线空间到世界空间的变换矩阵
- TtoW0, TtoW1, TtoW2 依次存储了变换矩阵的第一行
- 其实变换矩阵只需使用 3x3 的矩阵,为了充分利用插值寄存器的存储空间,我们把世界空间下的顶点位置存储在这些变换的 w 分量中
-
顶点着色器代码
jsv2f vert (a2v v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv.xy = v.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw; o.uv.zw = v.uv.xy * _BumpMap_ST.xy + _BumpMap_ST.zw; 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; // 上面已知切线坐标系各向量在世界空间下的表示 // 推导出变换矩阵(切线空间-世界空间),按列排列(x切线、y副切线、z法线) 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); return o; }
- 计算出顶点切线、副切线和法线的矢量,即是顶点切线坐标系的三个矢量 ,在世界空间下的表示,按列摆放就能得出切线空间到世界空间的变换矩阵
- 世界空间下顶点位置的 xyz 分量分别存储在 TtoWX 的 w 分量中
-
片元着色器代码
jsfixed4 frag (v2f i) : SV_Target { // 从 w 分量中得到世界空间下的顶点位置 float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w); // 使用内置函数得到光照方向与视角方向 fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos)); fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos)); // -------- 处理法线 -------- // 采样并计算法线 fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw)); bump.xy *= _BumpScale; bump.z = sqrt(1.0 - saturate(dot(bump.xy, bump.xy))); // 将法线从切线空间变换到世界空间中 bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump))); ... }
- 使用 TtoWX 存储的变换矩阵把法线变换到世界空间下,是通过使用
点乘
操作来实现矩阵的每一行和法线相乘 来得到的。为何用 half3?- half3 比 float3 占用更少内存(16位 vs 32位)
- 适合存储颜色、法线(向量范围在[-1,1]之间,half精度足够)等不需要全精度的数据
- 减少GPU寄存器占用
- 提高片段着色器的并行执行效率
- 在移动平台能显著提升性能
- 使用 TtoWX 存储的变换矩阵把法线变换到世界空间下,是通过使用
-
效果图对比(左边是在切线空间下计算,右边是在世界空间下计算。感觉右边的高光会亮点?):

7.2.4 Unity 中的法线纹理类型
当我们需要使用那些包含了法线映射的内置 Unity Shader 时,必须把法线纹理的纹理类型设为 Normal map

这样做的好处:
-
让 Unity 根据不同平台对纹理进行压缩
-
减少法线纹理占用的内存空间(只保存 xy,推导出 z)
-
Create from Grayscale 复选框:使用高度图生成法线纹理
- Bumpiness:控制凹凸程度
- Filtering:使用哪种方式来计算凹凸程度(Smooth / Sharp)
7.3 渐变纹理
- 纹理其实可以用于存储任何表面属性,一种常见的用法就是使用
渐变纹理
来控制漫反射光照
的结果
步骤:
- 老 4 步(新建场景,去掉天空盒,新建材质,新建 Unity Shader并赋给材质)
- 向场景中拖曳一个 Suzanne 模型 模型文件夹链接
- Shader 代码: 源码
-
Properties 语义块
- 声明一个纹理属性 _RampTex 来存储渐变纹理
-
定义和 Properties 中各属性类型相匹配的变量
- _RampTex 也需要对应的 _RampTex_ST 属性变量
-
顶点着色器
jsv2f vert (a2v v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 下面这句好像没啥用,因为纹理是渐变类型的,得到漫反射颜色不需要通过这个 uv 纹理坐标 // o.uv = TRANSFORM_TEX(v.uv, _RampTex); return o; }
-
片元着色器
jsfixed4 frag (v2f i) : SV_Target { fixed3 worldNormal = normalize(i.worldNormal); fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos)); fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT; // -------- 处理漫反射 -------- fixed halfLambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5; // 根据 halfLambert 的值从渐变纹理上采样得到颜色值,再与叠加颜色 _Color 相乘得到最终漫反射颜色 fixed3 diffuseColor = tex2D(_RampTex, fixed2(halfLambert, halfLambert)).rgb * _Color.rgb; fixed3 diffuse = _LightColor0.rgb * diffuseColor; // -------- 处理高光反射 -------- 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); }
-
注意:需把渐变纹理的 Wrap Mode 设为 Clamp 模式,防止对纹理采样时由于浮点数精度而造成的问题。虽然理论上 halfLambert 的值在 [0,1] 之间,但可能会有 1.0001 的情况。若使用 Repeat 模式,就会舍弃整数只保留小数部分,即用 0.0001 来采样,得到的数据会导致显示异常

效果对比

7.4 遮罩纹理
遮罩可以保护某些区域,使它们免于某些修改
- 有时,我们希望模型表面某些区域的反光强烈一些,而某些区域则弱一些,我们就可以使用一张遮罩纹理来控制光照
- 另一种常见的应用是在制作地形材质时需混合多张图片(草地、石子、泥土等),使用遮罩纹理可以控制如何混合这些纹理
7.4.1 实践
步骤:
- 老 5 步
- Shader 代码:源码
-
Properties 语义块
- _SpecularMask:高光反射遮罩纹理
- _SpecularScale:控制遮罩影响度系数
-
定义和 Properties 中各属性类型相匹配的变量
- _MainTex_ST:三个纹理共用的纹理属性变量。因很多纹理不需要平铺和位移,或者它们可使用同一种平铺和位移,所以使用同一个纹理属性变量即可
-
顶点着色器的输入和输出结构体
打算在切线空间进入光照计算
-
顶点着色器(不赘)
-
片无着色器
- 采样得到的遮罩掩码值和 _SpecularScale 相乘,来控制高光反射的强度
- 为什么遮罩(掩码)值只取 r 分量?因这里使用的遮罩纹理每个纹素的 rgb 分量是一样的,但实际制作游戏时,会充分利用这个纹理
-
效果图:

7.4.2 其他遮罩纹理
- 在游戏《DOTA2》的开发中,开发人员为每个模型使用了 4 张纹理:
- 模型颜色(漫反射)
- 表面法线
- 另外两张都是遮罩,共提供了 8 种额外表面属性,使得物体材质自由度更强