Unity-Shader详解-其三

今天我们继续Unity的Shader部分。

透明

我们先来看一张图:

这个流程展示了我们GPU的运作原理和流程:

对于我们的顶点着色器,我们要在顶点着色器中进行坐标系变换(Transform)、纹理生成(TexGen)以及光照(Lighting)的处理和计算,这一步可以统称为几何变换。然后我们进行Culling片面剔除工作(不可视的片面不用渲染,减少开销)和深度测试(这两步也被称为光栅化,输出像素)之后在片元着色器上进行纹理采样(Texturing),最后我们通过一个基于透明度的混合处理。

在说透明物体之前我们需要先知道不透明的物体如何渲染,而这其实就是深度测试的意义:

比较简单地说,我们会有一个深度缓冲区,每个片元的深度值如果小于缓冲区的深度值(离摄像机更近)则更新缓冲,否则舍弃。

简单地说,大的顺序上,我们会优先渲染不透明物体之后再渲染透明物体。对于不透明物体:我们的态度依然不变,依然开启深度测试和深度缓冲,但是对于半透明的物体,我们会采取进行深度测试但是不写入深度缓冲区的方法:假如写入深度缓冲区可能导致不透明物体不渲染,这不符合我们的要求,但是我们依然要计算深度,对于深度大于深度缓冲的半透明物体也没有渲染的必要,而假如有多个半透明物体,我们采取类似画家算法的思想,也就是默认渲染顺序:先渲染远的再渲染近的。总的来说就是深度的缓冲里一定只有不透明物体的深度值,而对于半透明物体,我们采取正常的默认从远到近的渲染顺序来做即可。

在Unity中,我们实现上述的正确渲染半透明物体的前提是:

其中提到了一个渲染队列标签:

现在让我们来一个实例来看看:

cs 复制代码
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 Front
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            CGPROGRAM
            // 声明顶点着色器和片段着色器
            #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 Back
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            CGPROGRAM
            // 声明顶点着色器和片段着色器
            #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
        }
    }
}

我们还是只说新东西:

cs 复制代码
        // 设置渲染类型为"不透明",并指定光照模式为"ForwardBase"。
        Tags {
            "Queue"="Transparent"
            "RenderType"="Transparent"
            "LightMode"="ForwardBase"
        }

我们声明渲染队列为Transparent:Unity内置的半透明队列------在不透明物体之后,同时声明渲染类型(RenderType)为Transparent:也为透明的。

cs 复制代码
            Cull Front
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha

Cull Front代表我们要剔除正面,也就是只渲染背面部分(关于面剔除的正反面判断可回顾以往笔记),ZWrite Off则是代表关闭深度写入,也就是我们之前提到的渲染半透明物体的办法,而关于Blend SrcAlpha OneMinusSrcAlpha则是一种透明度混合的方法,其中的SrcAlpha是源颜色(当前片元)的透明度,而另一个颜色的透明度则是1-SrcAlpha。

大体上就是这些新东西,我们这样分别剔除正面和背面之后就可以得到一个双面透明的物体。

效果如图。

模板测试

模板测试是在我们进行混合之前的一个步骤,主要的作用和执行流程包含如下:

模板最重要的两个功能:决定是否舍弃片元和动态修改缓冲区的值。

其中蕴含着非常多的模板操作,细节可以自行查阅,我们用一个实例来介绍。

cs 复制代码
Shader "Chapter4/chapter4_2_A"
{
    SubShader
    {
        // 设置渲染队列,优先于普通几何体进行渲染
        Tags {"Queue" = "Geometry-1"}

        Pass
        {
            // 设置模板测试的状态
            Stencil
            {
                Ref 1                 // 模板参考值为1
                Comp 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,这个值可以让你比不透明物体先一步渲染。

cs 复制代码
            // 设置模板测试的状态
            Stencil
            {
                Ref 1                 // 模板参考值为1
                Comp Always           // 始终通过模板测试
                Pass Replace          // 如果通过模板测试,则将模板缓冲值替换为参考值
            }

对于模板测试的内容我们在Stencil块中定义,主要包含的就是Ref(参考值),Comp(比较函数)以及Pass(通过后操作)。

不难看出上述代码的内容就是一个始终通过模板测试的着色器且不断更新缓冲值,然后输出一个透明的颜色。

cs 复制代码
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                 // 模板参考值为1
                Comp 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
        }
    }
}

这段代码则是:

cs 复制代码
            // 设置模板测试的状态
            Stencil
            {
                Ref 1                 // 模板参考值为1
                Comp NotEqual         // 如果模板缓冲值不等于参考值,则通过测试
                Pass Keep             // 如果通过测试,保持模板缓冲区的值不变
            }

回顾一下我们上一段的模板测试,我们设置参考值为1且始终通过且会把缓冲区更新为参考值,这意味着上一个着色器所在的地方的缓冲区参考值都是1,且输出都是透明的;而这个就是如果参考值不为1就能通过测试(为1就被舍弃)且不改动缓冲区值,这样就能得到一个被"镂空"的效果。

效果如图:

法线贴图

首先大致的说一下法线贴图的作用:就是一种模拟凹凸不平的视觉效果的技术,且不会改变实际的几何体形状。

我们在使用法线贴图之前,需要先明白什么是法线,怎么用法线。

是什么导致了二者的光照效果差异如此之大呢?

对于左边的图:

他的每一个面的法线方向是固定的,这些法线方向决定了光的反射方向,既然每一个面的法线方向都是完全相同的,那么在一个面上的光照效果当然就是一样的,而对于右边的图:

右边的图则不是这样,我们每个面上的法线方向会逐渐变化,这种效果通过只存储顶点法线方向数据,然后顶点与顶点之间法线方向通过插值实现。

在这基础上,我们的法线贴图正式登场:

法线贴图可以理解为就是一个记录模型表面法线位置的贴图,他不去修改模型真实的几何形状,而是只是去修改法线的位置和方向,这样就会导致光照的结果发生变化,从而出现凹凸不平的效果。

凹凸贴图、高度贴图和法线贴图:凹凸贴图、高度贴图和法线贴图的核心理念都是利用贴图修改光照效果来模拟出凹凸不平的视觉效果,不过高度贴图针对的是黑白(一维的颜色值)而凹凸贴图则是三色(三维的颜色值)。

TBN矩阵

切线空间定义于每一个顶点之中,是由切线(TangentTangentTangent),副切线(BiTangentBiTangentBiTangent),顶点法线(NormalNormalNormal)以模型顶点为中心的坐标空间。normalMap中的法向量在切空间中表示,其中法向量总是大致指向正z方向。切线空间是一个三角形表面的局部空间:法线相对于单个三角形的局部参考系。把它想象成法向量的局部空间;它们都是指向正z方向的不管最终变换的方向是什么。使用一个特定的矩阵,我们可以将这个局部切线空间的法向量转换为世界或视图坐标,并将它们沿最终映射曲面的方向定向。这个矩阵就是TBN矩阵。

一言以蔽之,TBN矩阵就是每个顶点法线方向为z轴,切线方向为x轴,副切线(垂直于切线和顶点法线方向)为y轴的空间。

写成数学形式则是:

T代表切线,B代表副切线,N代表法线。

那么问题来了,我们的TBN矩阵是干嘛用的呢?

是的,TBN矩阵就是专门针对法线贴图的法线信息转换的手段:法线贴图中我们的法线方向向量本质上是在切线空间中的------并非世界空间中,如果不进行转换的话显然会计算出错误的结果。那具体来说如何转换呢?

我们来看具体的代码:

cs 复制代码
float3 normalMap = UnpackScaleNormal(tex2D(_NormalMap, uv.xy), intensity);

其中的UnpackScaleNormal函数是关键,后续的两个参数分别是纹理和强度,这个函数的作用就是将法线贴图(_NormalMap)进行解包(将法线贴图的颜色值[0,1]转换为法线向量[-1,1])和缩放(根据intensity调整法线强度),最后得到一个切向空间中的法线向量。

cs 复制代码
float3 bitangent = cross(normal, tangent) * tangent.w;

这个是计算副切线的代码,我们将切线和法线叉乘得到,后续可以看到跟了一个tangent.w,这个分量的意思是根据不同图形学API调整UV坐标系(左手还是右手,Unity和OpenGL一样是左手系而像DirectX则是右手系)。

cs 复制代码
float3 newNormal = normalize(
    normalMap.x * tangent +        // 沿切线方向的分量
    normalMap.y * bitangent +  // 沿副切线方向的分量(Y轴反转判断)
    normalMap.z * normal           // 沿法线方向的分量
);

可以看到,我们对切线空间中的法线与TBN矩阵相乘就得到了世界空间的法线(每个轴的值对应相乘,TBN矩阵的每一列就是一个轴的值)。

GrabPass折射

什么是GrabPass折射?

可以看到GrabPass是一个只支持内置渲染管线的模拟折射效果的命令,本质上的原理就是去把帧缓冲区(准确地说,后台缓存)的内容抓取到纹理中,这样我们后续使用这个纹理就可以实现一些非常高级的效果。

代码如下:

cs 复制代码
Shader "Chapter4/chapter4_3"
{
    Properties
    {
        //这里的法线贴图用于计算折射产生的扭曲
        _BumpMap("Normal Map",2D)="bump"{}
        _Distortion("Distortion",range(0,100))=10
    }
    SubShader
    {
        //保证该物体渲染时,其他不透明物体都已经渲染完成
        Tags { "RenderType"="Opaque" "Queue"="Transparent"}
        //抓取当前屏幕的渲染图像并存入指定纹理
        GrabPass{"_RefractionTex"}

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal:NORMAL;
                float4 tangent:TANGENT;
            };

            struct v2f
            {
                float4 uv : TEXCOORD0;
                float4 pos : SV_POSITION;
                float4 scrPos : TEXCOORD4;
            };
            
            sampler2D _BumpMap;
            float4 _BumpMap_ST;
            float _Distortion;
            sampler2D _RefractionTex;
            float4 _RefractionTex_TexelSize;

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv.xy = TRANSFORM_TEX(v.uv, _BumpMap);
                //得到屏幕采样坐标
                o.scrPos = ComputeGrabScreenPos(o.pos);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed3 tanNormal = UnpackNormal(tex2D(_BumpMap, i.uv.xy));
                //对采集的屏幕图像进行关于法线方向上的扭曲和偏移,也就是模拟折射的效果
                //_RefractionTex_TexelSize表示纹理的 纹素大小(Texel Size),即纹理中每个像素在 UV 坐标中的大小
                float2 offset = tanNormal.xy*_Distortion*_RefractionTex_TexelSize.xy;
                i.scrPos.xy += offset;
                fixed3 color = tex2D(_RefractionTex, i.scrPos.xy / i.scrPos.w).xyz;
                return fixed4(color,1.0);
            }
            ENDCG
        }
    }
    fallback "Diffuse"
}

其中:

cs 复制代码
        //抓取当前屏幕的渲染图像并存入指定纹理
        GrabPass{"_RefractionTex"}

这个是GrabPass的具体用法:

cs 复制代码
float2 offset = tanNormal.xy * _Distortion * _RefractionTex_TexelSize.xy;
i.scrPos.xy += offset;

通过法线贴图的xy通道(切线空间法线方向)和_Distortion系数计算偏移量,结合_RefractionTex_TexelSize(纹理像素尺寸)适配不同分辨率,避免缩放失真。

效果如图:

相关推荐
狗狗显卡7 小时前
虚幻引擎入门笔记
笔记·游戏引擎·虚幻
Luna-player17 小时前
unity 导入图片后,可选择精灵表自动切片,并可以导出为png
unity·游戏引擎
大飞pkz17 小时前
【Unity】使用XLua进行热修复
unity·c#·游戏引擎·lua·游戏开发·xlua·lua热修复
Leoysq20 小时前
为 Unity 项目添加自定义 USB HID 设备支持 (适用于 PC 和 Android/VR)-任何手柄、无人机手柄、摇杆、方向盘
android·unity·vr
一个程序员(●—●)1 天前
Unity的HTTP请求类使用方法+Unity整合接口
网络·unity
XR技术研习社1 天前
“情况说明“以后,Unity XR 开发者如何选择?
unity·游戏引擎·xr
虾球xz1 天前
游戏引擎学习第257天:处理一些 Win32 相关的问题
c++·学习·游戏引擎
夜猫逐梦1 天前
【Unity】 组件库分类详解
unity·游戏引擎
Magnum Lehar1 天前
ApophisZerg的vulkan游戏引擎的实现
游戏引擎