Unity-Shader详解-其三
今天我们继续Unity的Shader部分。
透明
我们先来看一张图:
这个流程展示了我们GPU的运作原理和流程:
对于我们的顶点着色器,我们要在顶点着色器中进行坐标系变换(Transform)、纹理生成(TexGen)以及光照(Lighting)的处理和计算,这一步可以统称为几何变换。然后我们进行Culling片面剔除工作(不可视的片面不用渲染,减少开销)和深度测试(这两步也被称为光栅化,输出像素)之后在片元着色器上进行纹理采样(Texturing),最后我们通过一个基于透明度的混合处理。
在说透明物体之前我们需要先知道不透明的物体如何渲染,而这其实就是深度测试的意义:
比较简单地说,我们会有一个深度缓冲区,每个片元的深度值如果小于缓冲区的深度值(离摄像机更近)则更新缓冲,否则舍弃。
简单地说,大的顺序上,我们会优先渲染不透明物体之后再渲染透明物体。对于不透明物体:我们的态度依然不变,依然开启深度测试和深度缓冲,但是对于半透明的物体,我们会采取进行深度测试但是不写入深度缓冲区的方法:假如写入深度缓冲区可能导致不透明物体不渲染,这不符合我们的要求,但是我们依然要计算深度,对于深度大于深度缓冲的半透明物体也没有渲染的必要,而假如有多个半透明物体,我们采取类似画家算法的思想,也就是默认渲染顺序:先渲染远的再渲染近的。总的来说就是深度的缓冲里一定只有不透明物体的深度值,而对于半透明物体,我们采取正常的默认从远到近的渲染顺序来做即可。
在Unity中,我们实现上述的正确渲染半透明物体的前提是:
其中提到了一个渲染队列标签:
现在让我们来一个实例来看看:
Shader "Chapter4/chapter4_1"
{Properties{// _MainTex: 纹理贴图,默认值为白色纹理_MainTex ("Texture", 2D) = "white" {}_MainColor ("MainColor", Color) = (1,1,1,1)}SubShader{// 设置渲染类型为“不透明”,并指定光照模式为“ForwardBase”。Tags {"Queue"="Transparent""RenderType"="Transparent""LightMode"="ForwardBase"}LOD 100 // 设置最低的细节等级(LOD)// Pass阶段定义了渲染的一次完整过程。每个Pass包含顶点着色器和片段着色器的执行。Pass{Cull FrontZWrite OffBlend SrcAlpha OneMinusSrcAlphaCGPROGRAM// 声明顶点着色器和片段着色器#pragma vertex vert#pragma fragment frag// 引入Unity的常用着色器代码库#include "UnityCG.cginc"// 引入Unity的光照模型代码库#include "Lighting.cginc"// 顶点输入结构体,包含从模型传入的顶点数据struct appdata{// uv:纹理坐标float2 uv : TEXCOORD0;// vertex:物体空间中的顶点位置float4 vertex : POSITION;// normal:物体空间中的法线float3 normal : NORMAL;};// 顶点输出结构体,将数据传递到片段着色器struct v2f{// uv:纹理坐标float2 uv : TEXCOORD0;// vertex:裁剪空间中的顶点位置float4 vertex : SV_POSITION;// worldNormal:转换到世界空间的法线float3 worldNormal : TEXCOORD1;};// uniform变量:用于从外部传入的数据uniform sampler2D _MainTex; // 纹理采样器uniform float4 _MainTex_ST; // 纹理的平移和缩放参数fixed4 _MainColor;// 顶点着色器:将顶点从物体空间转换到裁剪空间,并计算法线的世界空间表示v2f vert(appdata v){v2f o;// 将物体空间的顶点转换为裁剪空间o.vertex = UnityObjectToClipPos(v.vertex);// 变换纹理坐标o.uv = TRANSFORM_TEX(v.uv, _MainTex);// 将法线从物体空间转换到世界空间o.worldNormal = UnityObjectToWorldNormal(v.normal);return o;}// 片段着色器:根据纹理和光照计算每个像素的最终颜色fixed4 frag(v2f i) : SV_Target{// 从纹理中采样颜色fixed4 color = tex2D(_MainTex, i.uv);// 规范化法线(确保长度为1)fixed3 worldNormal = normalize(i.worldNormal);// 获取世界空间的光源方向并规范化fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);// 计算漫反射光照强度:点积计算法线与光源方向的夹角fixed3 diffuse = _LightColor0.rgb * saturate(dot(worldNormal, worldLight));// 将纹理颜色与漫反射光照强度相乘,得到最终颜色color.rgb *= diffuse;color.a *= _MainColor.a;// 返回最终颜色return color;}ENDCG}Pass{Cull BackZWrite OffBlend SrcAlpha OneMinusSrcAlphaCGPROGRAM// 声明顶点着色器和片段着色器#pragma vertex vert#pragma fragment frag// 引入Unity的常用着色器代码库#include "UnityCG.cginc"// 引入Unity的光照模型代码库#include "Lighting.cginc"// 顶点输入结构体,包含从模型传入的顶点数据struct appdata{// uv:纹理坐标float2 uv : TEXCOORD0;// vertex:物体空间中的顶点位置float4 vertex : POSITION;// normal:物体空间中的法线float3 normal : NORMAL;};// 顶点输出结构体,将数据传递到片段着色器struct v2f{// uv:纹理坐标float2 uv : TEXCOORD0;// vertex:裁剪空间中的顶点位置float4 vertex : SV_POSITION;// worldNormal:转换到世界空间的法线float3 worldNormal : TEXCOORD1;};// uniform变量:用于从外部传入的数据uniform sampler2D _MainTex; // 纹理采样器uniform float4 _MainTex_ST; // 纹理的平移和缩放参数fixed4 _MainColor;// 顶点着色器:将顶点从物体空间转换到裁剪空间,并计算法线的世界空间表示v2f vert(appdata v){v2f o;// 将物体空间的顶点转换为裁剪空间o.vertex = UnityObjectToClipPos(v.vertex);// 变换纹理坐标o.uv = TRANSFORM_TEX(v.uv, _MainTex);// 将法线从物体空间转换到世界空间o.worldNormal = UnityObjectToWorldNormal(v.normal);return o;}// 片段着色器:根据纹理和光照计算每个像素的最终颜色fixed4 frag(v2f i) : SV_Target{// 从纹理中采样颜色fixed4 color = tex2D(_MainTex, i.uv);// 规范化法线(确保长度为1)fixed3 worldNormal = normalize(i.worldNormal);// 获取世界空间的光源方向并规范化fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);// 计算漫反射光照强度:点积计算法线与光源方向的夹角fixed3 diffuse = _LightColor0.rgb * saturate(dot(worldNormal, worldLight));// 将纹理颜色与漫反射光照强度相乘,得到最终颜色color.rgb *= diffuse;color.a *= _MainColor.a;// 返回最终颜色return color;}ENDCG}}
}
我们还是只说新东西:
// 设置渲染类型为“不透明”,并指定光照模式为“ForwardBase”。Tags {"Queue"="Transparent""RenderType"="Transparent""LightMode"="ForwardBase"}
我们声明渲染队列为Transparent:Unity内置的半透明队列——在不透明物体之后,同时声明渲染类型(RenderType)为Transparent:也为透明的。
Cull FrontZWrite OffBlend SrcAlpha OneMinusSrcAlpha
Cull Front代表我们要剔除正面,也就是只渲染背面部分(关于面剔除的正反面判断可回顾以往笔记),ZWrite Off则是代表关闭深度写入,也就是我们之前提到的渲染半透明物体的办法,而关于Blend SrcAlpha OneMinusSrcAlpha则是一种透明度混合的方法,其中的SrcAlpha是源颜色(当前片元)的透明度,而另一个颜色的透明度则是1-SrcAlpha。
大体上就是这些新东西,我们这样分别剔除正面和背面之后就可以得到一个双面透明的物体。
模板测试
模板测试是在我们进行混合之前的一个步骤,主要的作用和执行流程包含如下:
模板最重要的两个功能:决定是否舍弃片元和动态修改缓冲区的值。
其中蕴含着非常多的模板操作,细节可以自行查阅,我们用一个实例来介绍。
Shader "Chapter4/chapter4_2_A"
{SubShader{// 设置渲染队列,优先于普通几何体进行渲染Tags {"Queue" = "Geometry-1"}Pass{// 设置模板测试的状态Stencil{Ref 1 // 模板参考值为1Comp Always // 始终通过模板测试Pass Replace // 如果通过模板测试,则将模板缓冲值替换为参考值}// 禁止绘制任何色彩到渲染目标ColorMask 0 // 禁用颜色通道的写入ZWrite Off // 禁用深度写入// 开始编写CG代码CGPROGRAM#pragma vertex vert // 顶点着色器#pragma fragment frag // 片段着色器// 顶点着色器float4 vert (in float4 vertex : POSITION) : SV_POSITION{float4 pos = UnityObjectToClipPos(vertex); // 将模型空间的顶点位置转换到裁剪空间return pos; // 返回裁剪空间的顶点位置}// 片段着色器void frag (out fixed4 color : SV_Target){color = fixed4(0, 0, 0, 0); // 输出透明的颜色(完全不可见)}ENDCG}}
}
首先是Tags里的"Queue" = "Geometry-1",这是什么意思呢?
其实Unity的Queue,也就是渲染队列,是由值来组成的:
值约小则越优先渲染,Unity为你预定义了一系列名称的值但是你依然可以自定义,所以像这里的Geometry-1也就是1999,这个值可以让你比不透明物体先一步渲染。
// 设置模板测试的状态Stencil{Ref 1 // 模板参考值为1Comp Always // 始终通过模板测试Pass Replace // 如果通过模板测试,则将模板缓冲值替换为参考值}
对于模板测试的内容我们在Stencil块中定义,主要包含的就是Ref(参考值),Comp(比较函数)以及Pass(通过后操作)。
不难看出上述代码的内容就是一个始终通过模板测试的着色器且不断更新缓冲值,然后输出一个透明的颜色。
Shader "Chapter4/chapter4_2_B"
{Properties{// 定义一个主颜色属性,默认值为白色_MainColor ("Main Color", Color) = (1, 1, 1, 1)// 定义一个主纹理属性,默认值为白色纹理_MainTex ("Main Tex", 2D) = "white" {}}SubShader{// 设置渲染队列为普通几何体Tags {"Queue" = "Geometry"}Pass{// 设置光照模式为前向渲染的基础通道Tags {"LightMode" = "ForwardBase"}// 设置模板测试的状态Stencil{Ref 1 // 模板参考值为1Comp NotEqual // 如果模板缓冲值不等于参考值,则通过测试Pass Keep // 如果通过测试,保持模板缓冲区的值不变}// 开始编写CG代码CGPROGRAM#pragma vertex vert // 指定顶点着色器函数#pragma fragment frag // 指定片段着色器函数#include "UnityCG.cginc" // 引入Unity的通用着色器工具函数#include "UnityLightingCommon.cginc" // 引入Unity光照相关的工具函数// 自定义的结构体,用于在顶点和片段着色器之间传递数据struct v2f{float4 pos : SV_POSITION; // 裁剪空间位置float4 worldPos : TEXCOORD0; // 世界空间位置float3 worldNormal : TEXCOORD1; // 世界法线float2 texcoord : TEXCOORD2; // 纹理坐标};sampler2D _MainTex; // 主纹理采样器float4 _MainTex_ST; // 主纹理的平移缩放参数fixed4 _MainColor; // 主颜色属性// 顶点着色器v2f vert (appdata_base v){v2f o;// 将顶点位置从模型空间转换到裁剪空间o.pos = UnityObjectToClipPos(v.vertex);// 将顶点位置从模型空间转换到世界空间o.worldPos = mul(unity_ObjectToWorld, v.vertex);// 计算世界空间法线并归一化float3 worldNormal = UnityObjectToWorldNormal(v.normal);o.worldNormal = normalize(worldNormal);// 应用纹理的平移和缩放变换o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);return o;}// 片段着色器fixed4 frag (v2f i) : SV_Target{// 计算从当前像素到光源的世界空间光照方向float3 worldLight = UnityWorldSpaceLightDir(i.worldPos.xyz);worldLight = normalize(worldLight);// 计算法线和光照方向的点积,并将结果限制在0到1之间fixed NdotL = saturate(dot(i.worldNormal, worldLight));// 从主纹理采样颜色fixed4 color = tex2D(_MainTex, i.texcoord);// 计算光照效果,乘以主颜色、光照强度和光源颜色color.rgb *= _MainColor * NdotL * _LightColor0.rgb;// 添加环境光的影响color.rgb += unity_AmbientSky.rgb;return color; // 返回最终颜色}ENDCG}}
}
这段代码则是:
// 设置模板测试的状态Stencil{Ref 1 // 模板参考值为1Comp NotEqual // 如果模板缓冲值不等于参考值,则通过测试Pass Keep // 如果通过测试,保持模板缓冲区的值不变}
回顾一下我们上一段的模板测试,我们设置参考值为1且始终通过且会把缓冲区更新为参考值,这意味着上一个着色器所在的地方的缓冲区参考值都是1,且输出都是透明的;而这个就是如果参考值不为1就能通过测试(为1就被舍弃)且不改动缓冲区值,这样就能得到一个被“镂空”的效果。
效果如图:
法线贴图
首先大致的说一下法线贴图的作用:就是一种模拟凹凸不平的视觉效果的技术,且不会改变实际的几何体形状。
我们在使用法线贴图之前,需要先明白什么是法线,怎么用法线。
是什么导致了二者的光照效果差异如此之大呢?
对于左边的图:
他的每一个面的法线方向是固定的,这些法线方向决定了光的反射方向,既然每一个面的法线方向都是完全相同的,那么在一个面上的光照效果当然就是一样的,而对于右边的图:
右边的图则不是这样,我们每个面上的法线方向会逐渐变化,这种效果通过只存储顶点法线方向数据,然后顶点与顶点之间法线方向通过插值实现。
在这基础上,我们的法线贴图正式登场:
法线贴图可以理解为就是一个记录模型表面法线位置的贴图,他不去修改模型真实的几何形状,而是只是去修改法线的位置和方向,这样就会导致光照的结果发生变化,从而出现凹凸不平的效果。
凹凸贴图、高度贴图和法线贴图:凹凸贴图、高度贴图和法线贴图的核心理念都是利用贴图修改光照效果来模拟出凹凸不平的视觉效果,不过高度贴图针对的是黑白(一维的颜色值)而凹凸贴图则是三色(三维的颜色值)。
TBN矩阵
切线空间定义于每一个顶点之中,是由切线(TangentTangentTangent),副切线(BiTangentBiTangentBiTangent),顶点法线(NormalNormalNormal)以模型顶点为中心的坐标空间。normalMap中的法向量在切空间中表示,其中法向量总是大致指向正z方向。切线空间是一个三角形表面的局部空间:法线相对于单个三角形的局部参考系。把它想象成法向量的局部空间;它们都是指向正z方向的不管最终变换的方向是什么。使用一个特定的矩阵,我们可以将这个局部切线空间的法向量转换为世界或视图坐标,并将它们沿最终映射曲面的方向定向。这个矩阵就是TBN矩阵。
一言以蔽之,TBN矩阵就是每个顶点法线方向为z轴,切线方向为x轴,副切线(垂直于切线和顶点法线方向)为y轴的空间。
写成数学形式则是:
T代表切线,B代表副切线,N代表法线。
那么问题来了,我们的TBN矩阵是干嘛用的呢?
是的,TBN矩阵就是专门针对法线贴图的法线信息转换的手段:法线贴图中我们的法线方向向量本质上是在切线空间中的——并非世界空间中,如果不进行转换的话显然会计算出错误的结果。那具体来说如何转换呢?
我们来看具体的代码:
float3 normalMap = UnpackScaleNormal(tex2D(_NormalMap, uv.xy), intensity);
其中的UnpackScaleNormal函数是关键,后续的两个参数分别是纹理和强度,这个函数的作用就是将法线贴图(_NormalMap)进行解包(将法线贴图的颜色值[0,1]转换为法线向量[-1,1])和缩放(根据intensity调整法线强度),最后得到一个切向空间中的法线向量。
float3 bitangent = cross(normal, tangent) * tangent.w;
这个是计算副切线的代码,我们将切线和法线叉乘得到,后续可以看到跟了一个tangent.w,这个分量的意思是根据不同图形学API调整UV坐标系(左手还是右手,Unity和OpenGL一样是左手系而像DirectX则是右手系)。
float3 newNormal = normalize(normalMap.x * tangent + // 沿切线方向的分量normalMap.y * bitangent + // 沿副切线方向的分量(Y轴反转判断)normalMap.z * normal // 沿法线方向的分量
);
可以看到,我们对切线空间中的法线与TBN矩阵相乘就得到了世界空间的法线(每个轴的值对应相乘,TBN矩阵的每一列就是一个轴的值)。