【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

时间:2024-01-13 08:19:50

笔者使用的是 Unity 2018.2.0f2 + VS2017,建议读者使用与 Unity 2018 相近的版本,避免一些因为版本不一致而出现的问题。

在游戏中,我们除了能看到游戏物体的形体轮廓,还能看到物体的一些具体外观,包括颜色,凹凸等。而实现这一步的就是使用 纹理。与纹理相对应的技术就是 纹理映射技术 ,相当于把一张图贴在物体表面,然后 逐纹素 地控制颜色

纹理映射坐标:纹理映射坐标定义了一个顶点在纹理中对应的2D坐标。由于常用 U 来表示横向坐标, V 来表示纵向坐标,所以纹理映射坐标也是我们常常见到的 UV坐标。 顶点 UV 坐标通常会被归一化至 【0,1】范围内。当然纹理采样时使用的坐标也不一定在这个范围内。

另外值得注意的是,OpenGL 与 DirectX 的二维坐标系是不一样的,OpenGL中原点位于左下角,DirectX原点位于左上角。当然Unity 会帮我们处理这个差异,同时一般情况下,Unity 采用的纹理空间是符合 OpenGL传统的。

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

需要注意的是:本文着重讲述纹理采样的原理,由于实现的shader中的光照模型计算如同上文中,并不完整。所以不能直接运用于项目

 

一. 单张纹理

先看一下我们要实现的效果

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

shader 的一些书写方式本文便不再赘述,同时本文的计算光照的方式都能够在上一篇文章中找到,如果忘了,可以先复习一下

【Unity Shader】(三) ------ 漫反射和高光反射的实现

1.1. 实现单张纹理

新建一个场景,去掉天空盒子;新建一个 Capsule 与 Material,命名为 SingleTexture;

I. 先定义 Properties 语义块

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

其中 _MainTex 的纹理用来表示纹理贴图,这里我们用这张纹理贴图来代替物体的漫反射颜色。

II. 为了控制 Properties 中的属性,我们在CG代码片中定义与之相匹配的变量

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

在Unity中,一般使用 纹理名_ST 来代表某个纹理的属性

_MainTex_ST 代表 _MainTex 这个纹理的属性:S(Scale)缩放,T(Translation)平移。

_MainTex_ST.xy 代表 缩放值;_MainTex_ST.zw 代表 偏移值

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

III. 定义输入输出结构体

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

uv 变量存储了纹理坐标,以便在片元着色器中进行采样

IV. 顶点着色器

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

黄色框中,我们使用了 _MainTex_ST 对顶点纹理坐标进行变换,得到最终的纹理坐标。先使用 _MainTex_ST.xy 对顶点纹理坐标进行缩放,然后使用 _MainTex_ST.zw 进行偏移。而 TRANSFORM_TEX 则是封装了这个计算方式的内置函数,我们可以在 UnityCG.cginc 中找到它的定义

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

很显然,参数一为顶点纹理坐标,参数二为纹理名

V. 片元着色器

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

此处光照模型使用的是 Blinn-Phong 模型,所以光照计算方面与之前并没有太大的差异, 如果读者对光照模型不太了解,可以翻看我的前一篇文章。

这个片元着色器主要使用 Cg 函数 tex2D(_MainTex,i.uv) 对纹理进行了采样,然后以采样结果与颜色属性相乘,乘积结果作为反射率。其余的光照计算基本无异。而关于tex2D 的解释如下

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

完整代码:

 Shader "Unity/Custom/01-SingleTexture"
{
Properties
{
_Color("Color Tint",Color) = (,,,)
_MainTex("Main Tex",2D) = "while"{}
_Specular("Specular",Color) = (,,,)
_Gloss("Gloss",Range(8.0,)) = }
SubShader
{
Pass
{
Tags { "LightMode"="ForwardBase" } CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc" fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Specular;
float _Gloss; 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;
}; v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldnormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = UnityObjectToClipPos(v.vertex).xyz; //o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw; return o;
} fixed4 frag(v2f i) : SV_Target
{ fixed3 worldnormal = normalize(i.worldnormal);
fixed3 worldlight = normalize(UnityWorldSpaceLightDir(i.worldPos)); fixed3 albedo = tex2D(_MainTex,i.uv).rgb * _Color.rgb; fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo; fixed3 diffuse = _LightColor0.rgb * albedo * max(,dot(worldnormal,worldlight)); fixed3 reflectDir = normalize(reflect(-worldlight,worldnormal));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
//计算得到矢量h
fixed3 halfDir = normalize(worldlight + viewDir); fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(,dot(worldnormal,halfDir)),_Gloss); return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG } } FallBack "Specular"
}

保存,进入Unity 查看效果。当然还有附上一张纹理。

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

二. 凹凸映射

另一种常见的纹理应用就是 凹凸映射 。凹凸映射就是为了使用一张纹理来修改模型表面法线,来为模型提供更多的细节。当然,这并不会真的改变模型的顶点位置,仅仅是使得模型看起来是 “不平滑的” ,更加的真实。

凹凸映射常用的两种方法:

  • 高度映射。使用一张高度纹理来模拟表面位移,然后得到修改后的法线值。
  • 法线映射。使用一张法线纹理直接存储表面法线。

2.1 高度纹理

高度纹理图存储是强度值,表示表面局部的海拔,颜色越浅表示越向外凸起,颜色越深表示越向内凹进去。这样就可以很形象地看出模型的凹凸,不过这样的计算会更加复杂,在实时计算时并不能直接得到表面法线,而是计算像素的灰度值得到。本文着重讲述的是法线纹理,所以高度映射技术便不再赘述了。

2.2 法线纹理

前文已经提到法线纹理存储的是表面法线方向,法线方向的分量范围在【-1.1】,而像素分量范围在【0,1】。所以为了让两者一致,我们需要做一个简单的映射。相必这个方式大家都有学过

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

那么可以预知的便是,当我们在shader中对法线纹理采样后,就必须对其进行反映射,得到原先的法线方向。

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

需要注意的是,这个方向是有着空间之异的。对于模型自带的顶点法线,则是定义在模型空间的,这个纹理称为 模型空间的法线纹理。不过,一般制作法线纹理时,我们一般会采样 切线空间(tangent space)

切线空间:对于每个顶点,它都有一个属于自己的切线空间,切线空间原点就是该顶点本身,Z 轴则是顶点法线方向。X 轴为切线方向。Y 轴可以由法线和切线叉积而得。也称为 副切线副法线。而存储在切线空间的纹理则称为 切线空间的纹理。

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

​上图是一张法线纹理。许多使用过法线纹理但不太了解其原理的朋友或许都有一个疑问:为什么普通的纹理都是红颜六色的,但是法线纹理大都像上图一样是一片蓝色的?

  • 模型空间的法线纹理,所有法线的坐标都是在模型空间,每个点存储的法线方向都是各异的,经过映射之后就变成了RGB(x,y,z) ,而x,y,z并不一致,所以对应着不同的颜色。所以模型空间的法线纹理看起来是五颜六色的。
  • 切线空间的法线纹理,所有法线的坐标都是在各自的切线空间,新的法线方向就是 Z 轴,即(0,0,1),经过映射就是(0.5,0.5,1)浅蓝色。所以切线空间的法线纹理看上去大部分都是蓝色的,这也说明了顶点的大部分法线是和模型本身法线一样的。

两种法线纹理的优劣·:

模型空间的法线纹理 :

① 直观,简单

② 可以提供平滑的边界部分。

切线空间的法线纹理 :

① *度很高:模型空间的法线纹理是 绝对法线信息 ,即只能用于创建它的那个模型,应用于它处就会出错。而切线空间的法线纹理 是 相对法线信息 ,应用于不同的网格都可以得到一个不错的效果

② 可以制作UV动画:可以通过移动UV来实现一个动画,而模型空间下的纹理则会完全错误。

③ 可以压缩。切线空间下的纹理,法线 Z 方向总是正方向,所以只存储XY方向就可以通过推导得到 Z 方向。而模型空间下的纹理则不行

④ 可以重复利用

由于法线方向存储于切线空间,所以在实际计算光照时会有两种计算方式:① 把光照方向,视角方向转换至切线空间,进行光照;② 把采样得到的法线方向转换至世界空间,计算光照;从效率角度,① 优于 ② ,从通用性来看,② 优于 ①。

本文会先给出第一种方法的实践,第二种以后我会补充回来,读者也可以自行实现。

2.3 切线空间下计算光照

新建一个材质和Capsule,命名为NormalTextureTangentSpace

I. 定义 Properties 语义块

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

其中 _BumpMap 表示法线纹理,_BumpScale 控制凹凸程度

II. 为了控制 Properties 中的属性,我们定义与之相匹配的变量

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

III. 修改输入输出结构体

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

因为切线空间是由顶点法线与切线构建的,所以在输入结构体添加一个切线变量,使用 TANGENT 语义。

因为我们是在切线空间下计算光照,所以在输出结构体中添加两个变量来存储转换空间后的光照方向和视角方向

IV. 定义顶点着色器

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

我们使用了两张纹理,所以 uv 变量修改为 float4 类型,其中,xy分量存储 _MainTex 的纹理坐标,zw分量存储 _BumpMap 的纹理坐标。然后为了对光照方向和视角方向转换至切线空间,我们需要一个变换矩阵 rotaion,而 TRANGENT_SPACE_ROTATION 则是Unity内帮我们实现了计算过程的内置宏,它会返回我们所需 rotation,我们可以在UnityCG.cginc 中找到它的定义。

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

V. 修改片元着色器

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

我们在顶点着色器中已经对光照方向和视角方向做了转换空间的工作,所以片元着色器中只需要对法线纹理进行采样,然后计算光照就可以了。tex2D 函数的定义在前文已经给出。然后使用Unity内置函数 UnpackNormal 得到正确的法线方向。然后对得到的法线向量的 xy 分量乘于 _BumpScale 就可以得到 法线的 xy 分量。再计算出 z 分量,就得到了正确的法线方向。

VI. 保存,查看效果

不同 _BumpScale 下的效果:

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

需要注意的是:

使用法线纹理时,注意其类型是否为 Normal map

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

如果不是,则要在 shader 里面进行以下的更改

把​

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

更改为

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

如果不进行修改,Unity 也提醒你

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

因为如果法线纹理类型不是 Normal map 时,我们需要手动对采样结果的 xy 分量进行反映射。而如果是 Normal map 类型,则使用 UnpackNormal 函数。因为,当法线纹理类型设置成 Normal map 时,Unity 会根据平台的不同而对该法线纹理进行压缩,此时 _BumpMap 的 rgb 分量已经不是切线空间下的 xyz 分量了。所以此时再进行以上的手动计算就会得到错误的结果。

而 UnpackNormal 函数则可以在 UnityCG.cginc 中找到其定义​

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

其中 DXT5nm 是一种压缩格式

那么,完整代码如下:

 Shader "Unity/Custom/01-NormalTexture-Tangent Space"
{
Properties
{
_Color("Color Tint",Color) = (,,,)
_MainTex("Main Tex",2D) = "while"{}
_BumpMap("Normal Map",2D) = "bump"{}
_BumpScale("Bump Scale",Float) = 1.0
_Specular("Specular",Color) = (,,,)
_Gloss("Gloss",Range(8.0,)) = }
SubShader
{
Pass
{
Tags { "LightMode"="ForwardBase" } CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc" fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss; struct a2v{ float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
}; struct v2f{ float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
}; v2f vert(a2v v)
{
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; TANGENT_SPACE_ROTATION; o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz; return o;
} fixed4 frag(v2f i) : SV_Target
{
//光源方向归一化
fixed3 tangentLightDir = normalize(i.lightDir);
//视角方向归一化
fixed3 tangentViewDir = normalize(i.viewDir); //对法线纹理取样
fixed4 packedNormal = tex2D(_BumpMap,i.uv.zw);
//切线空间下的法线
fixed3 tangentNormal; //手动反映射
//tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale; 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(,dot(tangentNormal,tangentLightDir)); //计算得到矢量h
fixed3 halfDir = normalize(tangentLightDir + tangentViewDir); fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(,dot(tangentNormal,halfDir)),_Gloss); return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG } } FallBack "Specular"
}

三. 遮罩纹理

法线纹理是十分常见且重要的纹理,讲完了法线纹理,我们现在讲另外一种非常有用的纹理:遮罩纹理

遮罩可以保护某些区域不受修改,比如我们上一篇光照原理中实现的高光反射则是对于所有像素而言的,现在我希望物体某部分更强烈一些,而另一部分则更弱一些,此时我们就可以用到遮罩纹理了。

遮罩纹理的使用流程:① 采样,得到纹素值  ② 使用其中一个或多个通道的值来与表面属性相乘  ③ 当通道的值为0时,可以保护表面不受该属性影响

现在我们来实现,对高光反射进行遮罩。计算在切线空间,代码与之前相差不多,就不赘述了

完整代码:

 Shader "Unity/Custom/01-MaskTexture"
{
Properties
{
_Color("Color Tint",Color) = (,,,)
_MainTex("Main Tex",2D) = "while"{}
_BumpMap("Normal Map",2D) = "bump"{}
_BumpScale("Bump Scale",Float) = 1.0
_SpecularMask("Specular Mask",2D) = "while"{}
_SpecularScale("Specular Scale",Float) = 1.0
_Specular("Specular",Color) = (,,,)
_Gloss("Gloss",Range(8.0,)) = }
SubShader
{
Pass
{
Tags { "LightMode"="ForwardBase" } CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc" fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float _BumpScale;
sampler2D _SpecularMask;
float _SpecularScale;
fixed4 _Specular;
float _Gloss; struct a2v{ float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
}; struct v2f{ float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
}; v2f vert(a2v v)
{
v2f o;
//o.pos = UnityObjectToClipPos(v.vertex);
o.pos = UnityObjectToClipPos(v.vertex); o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw; TANGENT_SPACE_ROTATION; o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex)).xyz; return o;
} fixed4 frag(v2f i) : SV_Target
{
//光源方向归一化
fixed3 tangentLightDir = normalize(i.lightDir);
//视角方向归一化
fixed3 tangentViewDir = normalize(i.viewDir); //对法线纹理贴图取样
fixed4 packedNormal = tex2D(_BumpMap,i.uv);
//切线空间下的法线
fixed3 tangentNormal; 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(,dot(tangentNormal,tangentLightDir)); //计算得到矢量h
fixed3 halfDir = normalize(tangentLightDir + tangentViewDir); //高光遮罩
fixed3 specularMask = tex2D(_SpecularMask,i.uv).r * _SpecularScale; fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(,dot(tangentNormal,halfDir)),_Gloss) * specularMask; return fixed4(ambient + diffuse + specular,1.0);
}
ENDCG } } FallBack "Specular"
}

这里需要注意的是:

① 这里三张纹理共用了 _MainTex_ST ,而不是一张纹理对应一个 _ST 变量。因为随着纹理越来越多,我们会迅速占满顶点着色器中可以使用的插值寄存器。而很多时候,我们并不需要对纹理进行平铺和位移,或者很多纹理使用同一种平铺,那么我们就可以对这些纹理使用同一个纹理坐标。

② 这张遮罩图我们只使用了 r 分量,那么有很多空间都是浪费了,因为一般遮罩纹理的 rgba 存储的是不同的表面属性,善用遮罩纹理,可以创作出高*度的材质,就可以实现更强的画面效果。

最后给出三种纹理的对比图

【Unity Shader】(四) ------ 纹理之法线纹理、单张纹理及遮罩纹理的实现

总结

纹理是十分重要的一环,它可以决定你看到的事物有多细腻逼真。

另外再强调一次,本文实现的 shader 仅供学习,因为光照计算并不完整,所以不能直接运用于项目之中

如果对光照不太了解的朋友,可以去翻看我的前一篇文章【Unity Shader】(三) ------ 漫反射和高光反射的实现

最后,希望本文能对你有所帮助!!!路漫漫其修远兮 !!!

本文实现的 Shader 和纹理