Unity-Shader详解-其五

发布于:2025-05-09 ⋅ 阅读:(23) ⋅ 点赞:(0)

关于Unity的Shader部分的基础知识其实已经讲解得差不多了,今天我们来一些实例分享:

溶解

效果如下:

代码如下:

Shader "Chapter8/chapter8_1"
{
    Properties
    {
        // 定义属性
        [NoScaleOffset]_Albedo("Albedo", 2D) = "white" {} // 基础颜色纹理,默认白色
        _Noise("Dissolve Noise", 2D) = "white" {} // 溶解噪声纹理,默认白色
        _Dissolve("Dissolve", Range(0, 1)) = 0 // 溶解程度,范围0到1,默认0
        [NoScaleOffset]_Gradient("Edge Gradient", 2D) = "black" {} // 边缘渐变纹理,默认黑色
        _Range("Edge Range", Range(2, 100)) = 6 // 边缘范围,范围2到100,默认6
        _Brightness("Brightness", Range(0, 10)) = 1 // 亮度,范围0到10,默认1
    }
    SubShader
    {
        Tags
        { 
            "RenderType"="TransparentCutout" "Queue" = "AlphaTest" // 渲染类型为透明剪切,队列为Alpha测试
        }

        CGPROGRAM
        #pragma surface surf StandardSpecular addshadow fullforwardshadows // 使用StandardSpecular表面着色器,并添加阴影和全向阴影

        struct Input
        {
            float2 uv_Albedo; // Albedo纹理的UV坐标
            float2 uv_Noise; // 噪声纹理的UV坐标
        };

        sampler2D _Albedo; // Albedo纹理
        sampler2D _Noise; // 噪声纹理
        fixed _Dissolve; // 溶解程度
        sampler2D _Gradient; // 边缘渐变纹理
        float _Range; // 边缘范围
        float _Brightness; // 亮度

        void surf (Input IN, inout SurfaceOutputStandardSpecular o)
        {
            // 溶解遮罩
            fixed noise = tex2D(_Noise, IN.uv_Noise).r; // 从噪声纹理中获取红色通道值
            fixed dissolve = _Dissolve * 2 - 1; // 将溶解程度从0-1映射到-1到1
            fixed mask = saturate(noise - dissolve); // 计算遮罩值,限制在0到1之间
            clip(mask - 0.5); // 根据遮罩值进行剪切,小于0.5的部分将被剔除

            // 燃烧效果
            fixed texcoord = saturate(mask * _Range - 0.5 * _Range); // 计算纹理坐标,用于边缘渐变
            o.Emission = tex2D(_Gradient, fixed2(texcoord, 0.5)) * _Brightness; // 根据渐变纹理和亮度计算自发光

            fixed4 c = tex2D (_Albedo, IN.uv_Albedo); // 从Albedo纹理中获取颜色
            o.Albedo = c.rgb; // 设置表面颜色
        }
        ENDCG
    }
}

属性中有一个[NoScaleOffset]:

一言以蔽之, [NoScaleOffset]修饰的纹理的尺寸不可修改(至少在Inspector界面不可修改)。

        Tags
        { 
            "RenderType"="TransparentCutout" "Queue" = "AlphaTest" // 渲染类型为透明剪切,队列为Alpha测试
        }

Tags中设置渲染类型为TransparentCutout而渲染队列为AlphaTest。

        #pragma surface surf StandardSpecular addshadow fullforwardshadows // 使用StandardSpecular表面着色器,并添加阴影和全向阴影

正如注释所写,使用表面着色器,相关函数为surf,采用StandardSpecular光照模型,添加阴影以及全向阴影。

如果我们需要去查询Unity内置的光照模型和阴影模型可以:

        struct Input
        {
            float2 uv_Albedo; // Albedo纹理的UV坐标
            float2 uv_Noise; // 噪声纹理的UV坐标
        };

 这是作为输入的结构体,内部包含的是基础的纹理的UV坐标和噪声纹理的UV纹理。

        void surf (Input IN, inout SurfaceOutputStandardSpecular o)
        {
            // 溶解遮罩
            fixed noise = tex2D(_Noise, IN.uv_Noise).r; // 从噪声纹理中获取红色通道值
            fixed dissolve = _Dissolve * 2 - 1; // 将溶解程度从0-1映射到-1到1
            fixed mask = saturate(noise - dissolve); // 计算遮罩值,限制在0到1之间
            clip(mask - 0.5); // 根据遮罩值进行剪切,小于0.5的部分将被剔除

            // 燃烧效果
            fixed texcoord = saturate(mask * _Range - 0.5 * _Range); // 计算纹理坐标,用于边缘渐变
            o.Emission = tex2D(_Gradient, fixed2(texcoord, 0.5)) * _Brightness; // 根据渐变纹理和亮度计算自发光

            fixed4 c = tex2D (_Albedo, IN.uv_Albedo); // 从Albedo纹理中获取颜色
            o.Albedo = c.rgb; // 设置表面颜色
        }

我们实现了两个效果:溶解的遮罩,我们获取噪声纹理的红色通道值之后减去溶解的程度值来作为遮罩值,遮罩值小于0.5的部分将会被剔除。

然后是边缘的燃烧效果,我们利用之前生成的遮罩值来计算纹理坐标之后再根据纹理坐标来乘以自发光强度实现渐变纹理。最后我们从基础纹理中获取颜色后再添加到输出的模型中。

透视

效果如图:

能够看到我们可以透过岩石看到人物的轮廓。

代码如下:

Shader "Chapter8/chapter8_2"
{
    Properties
    {
        // 定义属性
        [Header(The Blocked Part)] // 标题,表示以下属性是用于被遮挡部分的设置
        [Space(10)] // 在Inspector中留出10像素的空白
        _Color ("X-Ray Color", Color) = (0,1,1,1) // X射线颜色,默认青色
        _Width ("X-Ray Width", Range(1, 2)) = 1 // X射线宽度,范围1到2,默认1
        _Brightness ("X-Ray Brightness",Range(0, 2)) = 1 // X射线亮度,范围0到2,默认1
    }

    SubShader
    {
        Tags{"RenderType" = "Opaque" "Queue" = "Geometry"} // 渲染类型为不透明,队列为几何体

        //---------- 被遮挡部分的效果 ----------
        Pass
        {
            ZTest Greater // 深度测试设置为大于当前深度值时才渲染(即渲染被遮挡的部分)
            ZWrite Off // 关闭深度写入,避免影响后续渲染

            Blend SrcAlpha OneMinusSrcAlpha // 设置混合模式,实现透明效果

            CGPROGRAM
            #pragma vertex vert // 顶点着色器
            #pragma fragment frag // 片段着色器
            #include "UnityCG.cginc" // 引入Unity的CG库

            // 定义顶点着色器的输出结构
            struct v2f
            {
                float4 vertexPos : SV_POSITION; // 顶点在裁剪空间中的位置
                float3 viewDir : TEXCOORD0; // 视线方向
                float3 worldNor : TEXCOORD1; // 世界空间中的法线方向
            };

            // 顶点着色器
            v2f vert(appdata_base v)
            {
                v2f o;
                o.vertexPos = UnityObjectToClipPos(v.vertex); // 将顶点从对象空间转换到裁剪空间
                o.viewDir = normalize(WorldSpaceViewDir(v.vertex)); // 计算视线方向
                o.worldNor = UnityObjectToWorldNormal(v.normal); // 将法线从对象空间转换到世界空间

                return o;
            }

            // 声明属性变量
            fixed4 _Color; // X射线颜色
            fixed _Width; // X射线宽度
            half _Brightness; // X射线亮度

            // 片段着色器
            float4 frag(v2f i) : SV_Target
            {
                //计算边缘光强度
                half NDotV = saturate(dot(i.worldNor, i.viewDir)); // 计算法线与视线方向的点积
                NDotV = pow(1 - NDotV, _Width) * _Brightness; // 根据宽度和亮度调整边缘光强度

                fixed4 color;
                color.rgb = _Color.rgb; // 设置颜色
                color.a = NDotV; // 设置透明度(基于Fresnel值)
                return color; // 返回最终颜色
            }
            ENDCG
        }
    }
}
        // 定义属性
        [Header(The Blocked Part)] // 标题,表示以下属性是用于被遮挡部分的设置
        [Space(10)] // 在Inspector中留出10像素的空白
        _Color ("X-Ray Color", Color) = (0,1,1,1) // X射线颜色,默认青色
        _Width ("X-Ray Width", Range(1, 2)) = 1 // X射线宽度,范围1到2,默认1
        _Brightness ("X-Ray Brightness",Range(0, 2)) = 1 // X射线亮度,范围0到2,默认1

首先是属性中,[Space(10)]在Inspector中留出10像素的空白,效果如图。

Tags{"RenderType" = "Opaque" "Queue" = "Geometry"}

Tags中渲染类型为不透明,队列为几何体。

            ZTest Greater // 深度测试设置为大于当前深度值时才渲染(即渲染被遮挡的部分)
            ZWrite Off // 关闭深度写入,避免影响后续渲染

开启深度测试的同时关闭深度写入。

            // 定义顶点着色器的输出结构
            struct v2f
            {
                float4 vertexPos : SV_POSITION; // 顶点在裁剪空间中的位置
                float3 viewDir : TEXCOORD0; // 视线方向
                float3 worldNor : TEXCOORD1; // 世界空间中的法线方向
            };

顶点着色器的输出中多了一个视线方向。

            // 顶点着色器
            v2f vert(appdata_base v)
            {
                v2f o;
                o.vertexPos = UnityObjectToClipPos(v.vertex); // 将顶点从对象空间转换到裁剪空间
                o.viewDir = normalize(WorldSpaceViewDir(v.vertex)); // 计算视线方向
                o.worldNor = UnityObjectToWorldNormal(v.normal); // 将法线从对象空间转换到世界空间

                return o;
            }

这里的视线方向计算方法:

最后是片元着色器的内容:

            // 片段着色器
            float4 frag(v2f i) : SV_Target
            {
                //计算边缘光强度
                half NDotV = saturate(dot(i.worldNor, i.viewDir)); // 计算法线与视线方向的点积
                NDotV = pow(1 - NDotV, _Width) * _Brightness; // 根据宽度和亮度调整边缘光强度

                fixed4 color;
                color.rgb = _Color.rgb; // 设置颜色
                color.a = NDotV; // 设置透明度(基于Fresnel值)
                return color; // 返回最终颜色
            }

 这里的边缘光强度计算的内容可能比较难以理解,我们先用法线和视线方向进行一个点积之后调整该值到[0,1]之间,然后根据宽度和亮度来调整光强,这里我们采用了幂次计算,宽度作为幂,那么宽度值越小则光强越小,同时注意我们的base是一减去点积,意思就是法线和视线的夹角越大则光强越强(夹角越大则越边缘),我们通过这些函数实现了边缘光越边缘强度越大的效果。

切割

效果如图:

非常直接的切割效果,代码如下:

Shader "Chapter8/chapter8_3"
{
    Properties
    {
        // 纹理部分
        [Header(Textures)] [Space(10)] // 标题和空白
        [NoScaleOffset] _Albedo ("Albedo", 2D) = "white" {} // 基础颜色纹理,默认白色,无缩放偏移

        // 切割部分
        [Header(Cutting)] [Space(10)] // 标题和空白
        [KeywordEnum(X, Y, Z)] _Direction ("Cutting Direction", Float) = 1 // 切割方向枚举(X、Y、Z),默认Y轴
        [Toggle] _Invert ("Invert Direction", Float) = 0 // 是否反转切割方向,默认关闭
    }
    SubShader
    {
        Tags { "RenderType"="TransparentCutout" "Queue"="AlphaTest" } // 渲染类型为透明剪切,队列为Alpha测试
        Cull Off // 关闭背面剔除,渲染双面

        CGPROGRAM
        #pragma surface surf StandardSpecular addshadow fullforwardshadows // 使用StandardSpecular表面着色器,添加阴影和全向阴影
        #pragma target 3.0 // 目标着色器模型3.0

        #pragma multi_compile _DIRECTION_X _DIRECTION_Y _DIRECTION_Z // 多编译选项,支持X、Y、Z三个方向的切割
        
        sampler2D _Albedo; // 基础颜色纹理

        float3 _Position; // 切割位置
        fixed _Invert; // 是否反转切割方向

        struct Input
        {
            float2 uv_Albedo; // 基础颜色纹理的UV坐标
            float3 worldPos; // 世界空间中的顶点位置
            fixed face : VFACE; // 判断当前渲染的是正面还是背面
        };

        void surf (Input i, inout SurfaceOutputStandardSpecular o)
        {
            // 获取基础颜色
            fixed4 col = tex2D(_Albedo, i.uv_Albedo);
            // 如果是正面,使用纹理颜色;如果是背面,使用黑色
            o.Albedo =  i.face > 0 ? col.rgb : fixed3(0,0,0);

            // 判断切割方向
            #if _DIRECTION_X
                // 如果选择X轴方向,根据世界坐标的X值与切割位置比较
                col.a = step(_Position.x, i.worldPos.x);
            #elif _DIRECTION_Y
                // 如果选择Y轴方向,根据世界坐标的Y值与切割位置比较
                col.a = step(_Position.y, i.worldPos.y);
            #else 
                // 如果选择Z轴方向,根据世界坐标的Z值与切割位置比较
                col.a = step(_Position.z, i.worldPos.z);
            #endif

            // 判断是否反转切割方向
            col.a = _Invert? 1 - col.a : col.a;

            // 根据透明度进行剪切,小于0.001的部分将被剔除
            clip(col.a - 0.001);
        }
        ENDCG
    }
}
    {
        // 纹理部分
        [Header(Textures)] [Space(10)] // 标题和空白
        [NoScaleOffset] _Albedo ("Albedo", 2D) = "white" {} // 基础颜色纹理,默认白色,无缩放偏移

        // 切割部分
        [Header(Cutting)] [Space(10)] // 标题和空白
        [KeywordEnum(X, Y, Z)] _Direction ("Cutting Direction", Float) = 1 // 切割方向枚举(X、Y、Z),默认Y轴
        [Toggle] _Invert ("Invert Direction", Float) = 0 // 是否反转切割方向,默认关闭
    }

 这里有两个新东西:KeywordEnum和一个Toggle。

效果如下:

        struct Input
        {
            float2 uv_Albedo; // 基础颜色纹理的UV坐标
            float3 worldPos; // 世界空间中的顶点位置
            fixed face : VFACE; // 判断当前渲染的是正面还是背面
        };

作为输入的结构体里除了基本的纹理UV坐标和顶点坐标以外还有一个声明为VFACE的face变量,用来表明具体渲染的是正面还是背面。

        void surf (Input i, inout SurfaceOutputStandardSpecular o)
        {
            // 获取基础颜色
            fixed4 col = tex2D(_Albedo, i.uv_Albedo);
            // 如果是正面,使用纹理颜色;如果是背面,使用黑色
            o.Albedo =  i.face > 0 ? col.rgb : fixed3(0,0,0);

            // 判断切割方向
            #if _DIRECTION_X
                // 如果选择X轴方向,根据世界坐标的X值与切割位置比较
                col.a = step(_Position.x, i.worldPos.x);
            #elif _DIRECTION_Y
                // 如果选择Y轴方向,根据世界坐标的Y值与切割位置比较
                col.a = step(_Position.y, i.worldPos.y);
            #else 
                // 如果选择Z轴方向,根据世界坐标的Z值与切割位置比较
                col.a = step(_Position.z, i.worldPos.z);
            #endif

            // 判断是否反转切割方向
            col.a = _Invert? 1 - col.a : col.a;

            // 根据透明度进行剪切,小于0.001的部分将被剔除
            clip(col.a - 0.001);
        }

表面着色器里,我们首先获取纹理的颜色,然后先判断是正面还是背面,接着从X,Y,Z轴选择切割的方向,根据选择的轴来确定世界坐标和切割位置的比较。这里我们使用了一个函数step来进行比较,step的具体用法如下:

最后把小于阈值的部分直接剔除掉即可。

切割轴为Y轴时效果如图:

广告

效果如下:

就是实现无论哪个位置看到的图片效果都一样。

代码如下:

Shader "Chapter8/chapter8_4"
{
    Properties
    {
        [NoScaleOffset] _Tex ("Texture", 2D) = "white" {}
        [KeywordEnum(Spherical, Cylindrical)] _Type ("Type", float) = 0
    }
    SubShader
    {
        Tags
        {
            "RenderType" = "Transparent"
            "Queue" = "Transparent"
            "DisableBatching" = "True"
        }

        //Blend OneMinusDstColor One
        ZWrite Off

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            // 声明枚举的关键词
            #pragma shader_feature _TYPE_SPHERICAL _TYPE_CYLINDRICAL
            
            struct appdata
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float2 texcoord : TEXCOORD0;
            };

            sampler2D _Tex;

            v2f vert (appdata v)
            {
                v2f o;

                // 计算面片朝向摄像机的前方向量
                float3 forward = mul(unity_WorldToObject,
                                     float4(_WorldSpaceCameraPos, 1)).xyz;

                // 判断Billboard的类型
                #if _TYPE_CYLINDRICAL
                forward.y = 0;
                #endif

                forward = normalize(forward);

                // 当摄像机完全在面片正上方或者正下方的时候,旋转临时的上方向量
                float3 up = abs(forward.y) > 0.999 ? float3(0, 0, 1) : float3(0, 1, 0);

                float3 right = normalize(cross(forward, up));
                up = normalize(cross(right, forward));

                // 将顶点在新的坐标系上移动位置
                float3 vertex = v.vertex.x * right + v.vertex.y * up;

                o.vertex = UnityObjectToClipPos(vertex);
                o.texcoord = v.texcoord;
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return tex2D(_Tex, i.texcoord);
            }
            ENDCG
        }
    }
}
        Tags
        {
            "RenderType" = "Transparent"
            "Queue" = "Transparent"
            "DisableBatching" = "True"
        }

这次的Tags里有新东西DisableBatching:

总结来说就是,批处理可以减少draw call但是会导致顶点坐标从模型空间转换为世界空间,所以如果在后续的代码中我们要使用模型空间的坐标的话就无法使用,所以我们需要显式地禁止使用批处理。

            // 声明枚举的关键词
            #pragma shader_feature _TYPE_SPHERICAL _TYPE_CYLINDRICAL

比起往常的shader多了一个shader_feature。

shader_feature本质上更像一个shader代码里的宏定义,我们可以根据不同的宏定义替换不同的shader变体。 

            v2f vert (appdata v)
            {
                v2f o;

                // 计算面片朝向摄像机的前方向量
                float3 forward = mul(unity_WorldToObject,
                                     float4(_WorldSpaceCameraPos, 1)).xyz;

                // 判断Billboard的类型
                #if _TYPE_CYLINDRICAL
                forward.y = 0;
                #endif

                forward = normalize(forward);

                // 当摄像机完全在面片正上方或者正下方的时候,旋转临时的上方向量
                float3 up = abs(forward.y) > 0.999 ? float3(0, 0, 1) : float3(0, 1, 0);

                float3 right = normalize(cross(forward, up));
                up = normalize(cross(right, forward));

                // 将顶点在新的坐标系上移动位置
                float3 vertex = v.vertex.x * right + v.vertex.y * up;

                o.vertex = UnityObjectToClipPos(vertex);
                o.texcoord = v.texcoord;
                return o;
            }

我们将摄像机的世界空间坐标从世界坐标系转换到模型空间坐标系,获得该面片朝向摄像机的方向向量。如果是圆柱形的广告牌,我们将这个方向向量的y轴修改为0。否则如果该方向向量的y轴分量大于0.999(基本就是在纯上方)我们把向上的向量直接设置为z轴方向向量(此时forward向量和向上向量高度重合,叉乘大概率为0,后续计算无法展开),否则就设置为y轴方向向量,然后用前向向量和上方向量叉乘得到右侧向量,之后再用右向向量和前方向量叉乘得到上方向量。

最后我们把v的顶点坐标和纹理坐标都根据这个右向向量和上方向量更新之后即可,这样我们就实现了一个面片永远朝向摄像机的效果。

扭曲

效果如下:

可以看到这些形形色色的颜色球在的位置视线被扭曲了。

代码如下:

Shader "Chapter8/chapter8_5"
{
	Properties
	{
		_StrengthColor("Color strength", Float) = 1 // 颜色强度,默认1
		_DistortionStrength ("Distortion strength", Range(-2,2)) = 0.1 // 扭曲强度,范围-2到2,默认0.1
		_DistortionCircle ("Distortion circle", Range(0,1)) = 0 // 扭曲圆形范围,范围0到1,默认0
		_NormalTexture("Normal", 2D) = "blue" { } // 法线纹理,默认蓝色
		_NormalTexStrength("Normal strength", Range(0,1)) = 0.5 // 法线强度,范围0到1,默认0.5
		_NormalTexFrameless("Normal circle", Range(0,1)) = 0.5 // 法线圆形范围,范围0到1,默认0.5
		_UVOffset("UVOffset XY, ignore ZW", Vector) = (0,0.01,0,0) // UV偏移,默认(0, 0.01, 0, 0)
	}
	Category
	{
		Tags { "Queue" = "Transparent" "RenderType" = "Transparent" "IgnoreProjector" = "True" } // 渲染队列为透明,渲染类型为透明,忽略投影器
		Blend SrcAlpha OneMinusSrcAlpha // 混合模式:SrcAlpha, OneMinusSrcAlpha
		ZWrite Off // 关闭深度写入
		SubShader
		{
			GrabPass // 抓取屏幕内容
			{
				Name "BASE"
				Tags { "LightMode" = "Always" }
			}
			Pass
			{
				Name "BASE"
				Tags { "LightMode" = "Always" }
				CGPROGRAM
				#pragma vertex vert // 顶点着色器
				#pragma fragment frag // 片段着色器
				#include "UnityCG.cginc" // 引入Unity的CG库
				
				sampler2D _GrabTexture; // 抓取的屏幕纹理
				float _DistortionStrength; // 扭曲强度
				float _DistortionCircle; // 扭曲圆形范围
				float _StrengthColor; // 颜色强度
				sampler2D _NormalTexture; // 法线纹理
				float4 _NormalTexture_ST; // 法线纹理的缩放和偏移
				float _NormalTexStrength; // 法线强度
				float _NormalTexFrameless; // 法线圆形范围
				float4 _UVOffset; // UV偏移
				
				// 顶点着色器输入结构
				struct VertexInput
				{
					float4 vertex : POSITION; // 顶点位置
					float2 texcoord0 : TEXCOORD0; // 纹理坐标
					float4 color : COLOR; // 顶点颜色
				};
				
				// 顶点着色器输出结构
				struct Vert2Frag
				{
					float4 position : SV_POSITION; // 裁剪空间中的顶点位置
					float4 uv_grab : TEXCOORD0; // 抓取纹理的UV坐标
					float2 uv : TEXCOORD1; // 纹理坐标
					float2 uv_normal : TEXCOORD2; // 法线纹理的UV坐标
					float2 movement: TEXCOORD3; // UV偏移运动
					float4 color : TEXCOORD4; // 顶点颜色
				};
				
				// 顶点着色器
				Vert2Frag vert (VertexInput vertIn)
				{
					Vert2Frag output;
					output.position = UnityObjectToClipPos(vertIn.vertex); // 将顶点从对象空间转换到裁剪空间
					output.uv_grab = ComputeGrabScreenPos(output.position); // 计算抓取纹理的UV坐标
					output.uv = vertIn.texcoord0; // 传递纹理坐标
					output.uv_normal = vertIn.texcoord0.xy * _NormalTexture_ST.xy + _NormalTexture_ST.zw; // 计算法线纹理的UV坐标
					output.movement = _UVOffset.xy*_Time.y; // 计算UV偏移运动
					output.color = vertIn.color; // 传递顶点颜色
					return output;
				}
				
				// 获取从中心到当前UV的向量
				float2 getVectorFromCenter(float2 uv)
				{
					float factor = _ScreenParams.y / _ScreenParams.x; // 计算屏幕宽高比
					float2 direction = float2((uv.x-0.5), (uv.y-0.5)) * factor; // 计算从中心到当前UV的向量
					return (direction);
				}
				
				// 获取扭曲强度
				float getDistortionStrength(float2 uv)
				{
					float2 diff = float2(distance(0.5, uv.x), distance(0.5, uv.y)) * 2.0; // 计算UV到中心的距离
					float dist = saturate(length(diff)); // 计算距离并限制在0到1之间
					return 1.0-dist; // 返回扭曲强度
				}
				
				// 获取法线
				float2 getNormal(sampler2D _NormalTexture, float2 normalUv, float2 uv, float2 uvOffset, float frameless, float strength)
				{
					float2 normal = tex2D( _NormalTexture, normalUv+uvOffset ).zy; // 从法线纹理中获取法线值
					float length = getDistortionStrength(uv); // 获取扭曲强度
					float normalTexStrength = ((1-frameless) + frameless*length) * strength; // 计算法线强度
					normal.x = ((normal.x-.5)*2) * normalTexStrength; // 调整法线X分量
					normal.y = ((normal.y-.5)*2) * normalTexStrength; // 调整法线Y分量
					return normal; // 返回法线
				}
				
				// 片段着色器
				half4 frag (Vert2Frag fragIn) : SV_Target
				{
					float4 uvScreen = UNITY_PROJ_COORD(fragIn.uv_grab); // 获取抓取纹理的UV坐标
					float2 direction = getVectorFromCenter(fragIn.uv); // 获取从中心到当前UV的向量
					float strength = getDistortionStrength(fragIn.uv); // 获取扭曲强度
					strength = (_DistortionCircle*strength + (1-_DistortionCircle)) * _DistortionStrength; // 计算最终扭曲强度
					direction *= strength; // 调整方向向量
					uvScreen += float4(direction.x, direction.y, 0, 0); // 调整抓取纹理的UV坐标
					float2 influence = normalize(direction) * strength; // 计算影响向量
					float2 offset = fragIn.movement; // 获取UV偏移
					float2 normal = getNormal(_NormalTexture, fragIn.uv_normal, fragIn.uv, offset, _NormalTexFrameless, _NormalTexStrength); // 获取法线
					uvScreen += float4(normal.x, normal.y, 0, 0); // 调整抓取纹理的UV坐标
					influence += normal.xy; // 调整影响向量
					float4 final = tex2Dproj(_GrabTexture, uvScreen); // 从抓取纹理中获取颜色
					float alpha = 1; // 设置透明度
					final = float4(final.xyz, alpha); // 设置最终颜色
					strength = saturate(sqrt(pow(abs(influence.x), 2.0) + pow(abs(influence.y), 2.0)) * _StrengthColor); // 计算最终强度
					final = final + (fragIn.color*strength); // 调整最终颜色
					final.w = saturate(final.w*fragIn.color.w); // 调整最终透明度
					return final; // 返回最终颜色
				}
				ENDCG
			}
		}
	}
}

我们应该首先能发现这一次的shader中没有Pass而是Category:

			GrabPass // 抓取屏幕内容
			{
				Name "BASE"
				Tags { "LightMode" = "Always" }
			}

如果还记得我们透明章节的话,我们在那里介绍过GrabPass:从屏幕中抓取缓冲来使用。

				// 顶点着色器输入结构
				struct VertexInput
				{
					float4 vertex : POSITION; // 顶点位置
					float2 texcoord0 : TEXCOORD0; // 纹理坐标
					float4 color : COLOR; // 顶点颜色
				};
				
				// 顶点着色器输出结构
				struct Vert2Frag
				{
					float4 position : SV_POSITION; // 裁剪空间中的顶点位置
					float4 uv_grab : TEXCOORD0; // 抓取纹理的UV坐标
					float2 uv : TEXCOORD1; // 纹理坐标
					float2 uv_normal : TEXCOORD2; // 法线纹理的UV坐标
					float2 movement: TEXCOORD3; // UV偏移运动
					float4 color : TEXCOORD4; // 顶点颜色
				};

 顶点着色器的输入和输出都有很多东西,把顶点位置、纹理坐标和顶点颜色作为输入而输出裁剪空间的顶点位置、抓取的纹理坐标、纹理坐标、法线纹理坐标、UV的偏移和颜色。

				// 顶点着色器
				Vert2Frag vert (VertexInput vertIn)
				{
					Vert2Frag output;
					output.position = UnityObjectToClipPos(vertIn.vertex); // 将顶点从对象空间转换到裁剪空间
					output.uv_grab = ComputeGrabScreenPos(output.position); // 计算抓取纹理的UV坐标
					output.uv = vertIn.texcoord0; // 传递纹理坐标
					output.uv_normal = vertIn.texcoord0.xy * _NormalTexture_ST.xy + _NormalTexture_ST.zw; // 计算法线纹理的UV坐标
					output.movement = _UVOffset.xy*_Time.y; // 计算UV偏移运动
					output.color = vertIn.color; // 传递顶点颜色
					return output;
				}

这是顶点着色器的函数代码内容。

				// 获取从中心到当前UV的向量
				float2 getVectorFromCenter(float2 uv)
				{
					float factor = _ScreenParams.y / _ScreenParams.x; // 计算屏幕宽高比
					float2 direction = float2((uv.x-0.5), (uv.y-0.5)) * factor; // 计算从中心到当前UV的向量
					return (direction);
				}
				
				// 获取扭曲强度
				float getDistortionStrength(float2 uv)
				{
					float2 diff = float2(distance(0.5, uv.x), distance(0.5, uv.y)) * 2.0; // 计算UV到中心的距离
					float dist = saturate(length(diff)); // 计算距离并限制在0到1之间
					return 1.0-dist; // 返回扭曲强度
				}
				
				// 获取法线
				float2 getNormal(sampler2D _NormalTexture, float2 normalUv, float2 uv, float2 uvOffset, float frameless, float strength)
				{
					float2 normal = tex2D( _NormalTexture, normalUv+uvOffset ).zy; // 从法线纹理中获取法线值
					float length = getDistortionStrength(uv); // 获取扭曲强度
					float normalTexStrength = ((1-frameless) + frameless*length) * strength; // 计算法线强度
					normal.x = ((normal.x-.5)*2) * normalTexStrength; // 调整法线X分量
					normal.y = ((normal.y-.5)*2) * normalTexStrength; // 调整法线Y分量
					return normal; // 返回法线
				}

这里有三个函数,分别用于计算从中心到当前UV的向量,计算扭曲强度以及获取法线。

第一个函数,我们首先计算一个屏幕的高宽比,然后把uv的x轴和y轴各减去0.5之后乘以这个比值,因为我们知道uv坐标是一个从0到1的坐标系,所以这样能得到正确的向量。

第二个函数中,我们先计算uv坐标的xy坐标到屏幕的中心的距离,然后把这个距离限制在[0,1]之间,最后返回一减去这个距离即可。

第三个函数中,我们首先从法线纹理中获取到法线,然后使用第二个函数获取扭曲强度,然后我们根据扭曲强度和参数中的边缘衰减(frameless)来动态调整法线强度。

				// 片段着色器
				half4 frag (Vert2Frag fragIn) : SV_Target
				{
					float4 uvScreen = UNITY_PROJ_COORD(fragIn.uv_grab); // 获取抓取纹理的UV坐标
					float2 direction = getVectorFromCenter(fragIn.uv); // 获取从中心到当前UV的向量
					float strength = getDistortionStrength(fragIn.uv); // 获取扭曲强度
					strength = (_DistortionCircle*strength + (1-_DistortionCircle)) * _DistortionStrength; // 计算最终扭曲强度
					direction *= strength; // 调整方向向量
					uvScreen += float4(direction.x, direction.y, 0, 0); // 调整抓取纹理的UV坐标
					float2 influence = normalize(direction) * strength; // 计算影响向量
					float2 offset = fragIn.movement; // 获取UV偏移
					float2 normal = getNormal(_NormalTexture, fragIn.uv_normal, fragIn.uv, offset, _NormalTexFrameless, _NormalTexStrength); // 获取法线
					uvScreen += float4(normal.x, normal.y, 0, 0); // 调整抓取纹理的UV坐标
					influence += normal.xy; // 调整影响向量
					float4 final = tex2Dproj(_GrabTexture, uvScreen); // 从抓取纹理中获取颜色
					float alpha = 1; // 设置透明度
					final = float4(final.xyz, alpha); // 设置最终颜色
					strength = saturate(sqrt(pow(abs(influence.x), 2.0) + pow(abs(influence.y), 2.0)) * _StrengthColor); // 计算最终强度
					final = final + (fragIn.color*strength); // 调整最终颜色
					final.w = saturate(final.w*fragIn.color.w); // 调整最终透明度
					return final; // 返回最终颜色
				}

我们的片元着色器就是重点了,可以看到很多内容啊,我们来看看AI怎么说吧:

扫描

这是一个更为复杂的项目,效果如下:

我们实现的效果是鼠标点击某个地点之后会发射这样一个扫描的波形。

这里就不只是一个shader可以实现的效果了,我们还需要一个C#脚本。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// 该脚本实现了一个扫描效果,当用户点击物体时,扫描效果会从该位置开始,并随时间扩展
// 这个脚本会在编辑模式下也生效([ExecuteInEditMode]属性)
[ExecuteInEditMode]
public class PosScanEffect : MonoBehaviour
{
    // 用于存储扫描效果的材质(shader),此材质会控制实际的视觉效果
    public Material ScanMat;

    // 控制扫描速度,扫描的范围会根据这个值逐渐增大
    public float ScanSpeed = 20;

    // 扫描计时器,决定扫描的进度,随着时间的推移,扫描的范围会增大
    public float scanTimer = 0;

    // 存储相机组件的引用
    private Camera scanCam;

    // 记录扫描的中心点,即鼠标点击时的物体位置
    private Vector3 ScanPoint = Vector3.zero;

    // 初始化方法,这里没有初始化操作
    void Awake()
    {
    }

    // 每一帧调用,主要用于计算扫描参数和更新扫描效果
    private void Update()
    {
        // 获取当前物体上的相机组件
        scanCam = GetComponent<Camera>();

        // 启用深度纹理(Depth)和法线深度纹理(DepthNormals),这些纹理对后续的渲染处理至关重要
        scanCam.depthTextureMode |= DepthTextureMode.Depth;
        scanCam.depthTextureMode |= DepthTextureMode.DepthNormals;

        // 获取相机的长宽比
        float aspect = scanCam.aspect;

        // 获取相机的远裁剪平面(远离相机的最大距离)
        float farPlaneDistance = scanCam.farClipPlane;

        // 根据相机的视野(field of view)计算出上方向量,用于定位视锥体的边界
        Vector3 midup = Mathf.Tan(scanCam.fieldOfView / 2 * Mathf.Deg2Rad) * farPlaneDistance * scanCam.transform.up;

        // 计算右方向量,同样用于确定视锥体的边界
        Vector3 midright = Mathf.Tan(scanCam.fieldOfView / 2 * Mathf.Deg2Rad) * farPlaneDistance * scanCam.transform.right * aspect;

        // 计算远裁剪平面的中心点位置
        Vector3 farPlaneMid = scanCam.transform.forward * farPlaneDistance;

        // 根据计算出的参数确定视锥体的四个角的世界坐标
        Vector3 bottomLeft = farPlaneMid - midup - midright;
        Vector3 bottomRight = farPlaneMid - midup + midright;
        Vector3 upLeft = farPlaneMid + midup - midright;
        Vector3 upRight = farPlaneMid + midup + midright;

        // 创建一个矩阵来表示视锥体的四个角
        Matrix4x4 frustumCorner = new Matrix4x4();
        frustumCorner.SetRow(0, bottomLeft); // 设置底左角
        frustumCorner.SetRow(1, bottomRight); // 设置底右角
        frustumCorner.SetRow(2, upRight); // 设置上右角
        frustumCorner.SetRow(3, upLeft); // 设置上左角

        // 将视锥体的矩阵传递给Shader
        ScanMat.SetMatrix("_FrustumCorner", frustumCorner);

        // 进行射线检测,获取鼠标点击的世界坐标
        RaycastHit hit;
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 将鼠标屏幕坐标转换为射线
        if (Input.GetMouseButton(0) && Physics.Raycast(ray, out hit)) // 如果按下左键并且射线与物体碰撞
        {
            scanTimer = 0; // 重置扫描计时器
            ScanPoint = hit.point; // 获取碰撞点的位置作为扫描的起始点
        }

        // 增加扫描计时器,控制扫描的进度
        scanTimer += Time.deltaTime;

        // 将扫描的起始位置和扫描进度传递给材质(Shader)
        ScanMat.SetVector("_ScanCenter", ScanPoint);
        ScanMat.SetFloat("_ScanRange", scanTimer * ScanSpeed);

        // 将相机的世界坐标变换矩阵传递给Shader
        ScanMat.SetMatrix("_CamToWorld", scanCam.cameraToWorldMatrix);
    }

    // 在渲染图像时调用,进行后处理操作,应用扫描效果
    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        // 将相机的远裁剪平面距离传递给材质(Shader),用于控制扫描的范围
        ScanMat.SetFloat("_CamFar", GetComponent<Camera>().farClipPlane);

        // 使用Graphics.Blit方法将源纹理渲染到目标纹理,并应用扫描效果的材质
        Graphics.Blit(source, destination, ScanMat);
    }
}

具体的代码作用注释里已经写明了。

我们重点还是来看看着色器的写法:

Shader "Chapter2/PointScanShader"
{
    Properties
    {
        // 主要纹理,用于物体表面的纹理
        _MainTex ("Texture", 2D) = "white" {}

        // 扫描纹理,用于显示扫描的图像或效果
        _ScanTex("ScanTexure", 2D) = "white" {}

        // 扫描的范围,控制扫描从中心开始的距离
        _ScanRange("ScanRange", float) = 0

        // 扫描宽度,控制扫描的宽度,影响扫描的可见区域
        _ScanWidth("ScanWidth", float) = 0

        // 扫描的背景颜色
        _ScanBgColor("ScanBgColor", color) = (1, 1, 1, 1)

        // 扫描时网格的颜色
        _ScanMeshColor("ScanMeshColor", color) = (1, 1, 1, 1)

        // 网格线的宽度
        _MeshLineWidth("MeshLineWidth", float) = 0.3

        // 网格的宽度,用于控制网格分割的尺寸
        _MeshWidth("MeshWidth", float) = 1

        // 缝隙的平滑度,控制缝隙的过渡效果
        _Smoothness("SeamBlending", Range(0, 0.5)) = 0.25
    }

    SubShader
    {
        // 不使用背面剔除(Cull Off),不写入深度缓存(ZWrite Off),且始终通过深度测试(ZTest Always)
        //Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            // 引用Unity的内置CG代码库
            #include "UnityCG.cginc"

            // 定义顶点着色器的数据结构
            struct appdata
            {
                float4 vertex : POSITION; // 顶点位置
                float2 uv : TEXCOORD0; // 顶点纹理坐标
            };

            // 定义片段着色器的数据结构
            struct v2f
            {
                float2 uv : TEXCOORD0; // 传递给片段着色器的纹理坐标
                float2 uv_depth : TEXCOORD1; // 用于传递深度信息的纹理坐标
                float4 interpolatedRay : TEXCOORD2; // 传递与扫描效果相关的视锥体数据
                float4 vertex : SV_POSITION; // 顶点最终位置
            };

            // 定义一个矩阵,用于描述视锥体四个角的坐标
            float4x4 _FrustumCorner;

            // 顶点着色器:计算顶点的最终位置,并计算视锥体四个角的插值数据
            v2f vert(appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex); // 计算顶点的最终位置
                o.uv = v.uv; // 将纹理坐标传递给片段着色器
                o.uv_depth = v.uv; // 将纹理坐标传递给深度计算

                // 根据UV坐标的不同区域选择不同的视锥体角
                int rayIndex;
                if (v.uv.x < 0.5 && v.uv.y < 0.5)
                {
                    rayIndex = 0;
                }
                else if (v.uv.x > 0.5 && v.uv.y < 0.5)
                {
                    rayIndex = 1;
                }
                else if (v.uv.x > 0.5 && v.uv.y > 0.5)
                {
                    rayIndex = 2;
                }
                else
                {
                    rayIndex = 3;
                }

                // 从视锥体四个角中选择一个,传递给片段着色器
                o.interpolatedRay = _FrustumCorner[rayIndex];

                return o;
            }

            // 声明材质参数,允许外部设置
            sampler2D _MainTex;
            sampler2D _ScanTex;
            float _ScanRange;
            float _ScanWidth;
            float3 _ScanCenter;
            fixed4 _ScanBgColor;
            fixed4 _ScanMeshColor;
            float _MeshLineWidth;
            float _MeshWidth;
            float4x4 _CamToWorld;
            fixed _Smoothness;

            // 声明用于获取深度信息的纹理
            sampler2D_float _CameraDepthTexture;
            sampler2D _CameraDepthNormalsTexture;

            // 片段着色器:计算像素的最终颜色
            fixed4 frag(v2f i) : SV_Target
            {
                float tempDepth;
                half3 normal;  
                
                // 获取该像素的法线和深度值
                DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), tempDepth, normal);
                
                // 将法线从相机空间转换到世界空间
                normal = mul((float3x3)_CamToWorld, normal);
                normal = normalize(max(0, (abs(normal) - _Smoothness)));  // 对法线进行平滑处理

                // 获取该像素的颜色
                fixed4 col = tex2D(_MainTex, i.uv);

                // 通过深度纹理获取该像素的深度值,并转换为线性深度
                float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
                float linearDepth = Linear01Depth(depth);

                // 计算该像素在世界空间中的位置
                float3 pixelWorldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay;

                // 计算像素到扫描中心的距离
                float pixelDistance = distance(pixelWorldPos, _ScanCenter);

                // 计算该像素的方向向量
                float3 pixelDir = pixelWorldPos - _ScanCenter;

                // 使用网格宽度将像素位置进行取模操作,用于创建网格效果
                float3 modulo = pixelWorldPos - _MeshWidth * floor(pixelWorldPos / _MeshWidth);
                modulo = modulo / _MeshWidth;

                // 使用平滑插值创建网格线效果
                float3 meshCol = smoothstep(_MeshLineWidth, 0, modulo) + smoothstep(1 - _MeshLineWidth, 1, modulo);

                // 将扫描背景颜色和网格颜色进行插值
                fixed4 scanMeshCol = lerp(_ScanBgColor, _ScanMeshColor, saturate(dot(meshCol, 1 - normal)));

                // 如果像素距离扫描中心在指定范围内,执行扫描效果
                if (_ScanRange - pixelDistance > 0 && _ScanRange - pixelDistance < _ScanWidth && linearDepth < 1)
                {
                    // 根据像素距离计算扫描百分比
                    fixed scanPercent = 1 - (_ScanRange - pixelDistance) / _ScanWidth;

                    // 通过插值将当前颜色与网格颜色混合,生成扫描效果
                    col = lerp(col, scanMeshCol, scanPercent);
                }

                // 返回最终颜色
                return col;
            }
            ENDCG
        }
    }
}
    Properties
    {
        // 主要纹理,用于物体表面的纹理
        _MainTex ("Texture", 2D) = "white" {}

        // 扫描纹理,用于显示扫描的图像或效果
        _ScanTex("ScanTexure", 2D) = "white" {}

        // 扫描的范围,控制扫描从中心开始的距离
        _ScanRange("ScanRange", float) = 0

        // 扫描宽度,控制扫描的宽度,影响扫描的可见区域
        _ScanWidth("ScanWidth", float) = 0

        // 扫描的背景颜色
        _ScanBgColor("ScanBgColor", color) = (1, 1, 1, 1)

        // 扫描时网格的颜色
        _ScanMeshColor("ScanMeshColor", color) = (1, 1, 1, 1)

        // 网格线的宽度
        _MeshLineWidth("MeshLineWidth", float) = 0.3

        // 网格的宽度,用于控制网格分割的尺寸
        _MeshWidth("MeshWidth", float) = 1

        // 缝隙的平滑度,控制缝隙的过渡效果
        _Smoothness("SeamBlending", Range(0, 0.5)) = 0.25

一些主要的属性,含义已在注释中写明。

            // 定义片段着色器的数据结构
            struct v2f
            {
                float2 uv : TEXCOORD0; // 传递给片段着色器的纹理坐标
                float2 uv_depth : TEXCOORD1; // 用于传递深度信息的纹理坐标
                float4 interpolatedRay : TEXCOORD2; // 传递与扫描效果相关的视锥体数据
                float4 vertex : SV_POSITION; // 顶点最终位置
            };

顶点着色器的输出以及片元着色器的输入中除了uv坐标和顶点坐标以外还有一个传递深度信息的uv坐标以及一个与扫描效果相关的变量。

            // 定义一个矩阵,用于描述视锥体四个角的坐标
            float4x4 _FrustumCorner;

注意这里定义矩阵的方式:float4*4,这个矩阵用来描述视锥体的四个角。

            // 顶点着色器:计算顶点的最终位置,并计算视锥体四个角的插值数据
            v2f vert(appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex); // 计算顶点的最终位置
                o.uv = v.uv; // 将纹理坐标传递给片段着色器
                o.uv_depth = v.uv; // 将纹理坐标传递给深度计算

                // 根据UV坐标的不同区域选择不同的视锥体角
                int rayIndex;
                if (v.uv.x < 0.5 && v.uv.y < 0.5)
                {
                    rayIndex = 0;
                }
                else if (v.uv.x > 0.5 && v.uv.y < 0.5)
                {
                    rayIndex = 1;
                }
                else if (v.uv.x > 0.5 && v.uv.y > 0.5)
                {
                    rayIndex = 2;
                }
                else
                {
                    rayIndex = 3;
                }

                // 从视锥体四个角中选择一个,传递给片段着色器
                o.interpolatedRay = _FrustumCorner[rayIndex];

                return o;
            }

我们根据不同的视锥体的位置来给定一个序号,从这四个序号中选择一个传给片元着色器。

// 片段着色器:计算像素的最终颜色
fixed4 frag(v2f i) : SV_Target
{
    float tempDepth;
    half3 normal;  
    
    // 获取该像素的法线和深度值
    DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), tempDepth, normal);
    
    // 将法线从相机空间转换到世界空间
    normal = mul((float3x3)_CamToWorld, normal);
    normal = normalize(max(0, (abs(normal) - _Smoothness)));  // 对法线进行平滑处理

    // 获取该像素的颜色
    fixed4 col = tex2D(_MainTex, i.uv);

    // 通过深度纹理获取该像素的深度值,并转换为线性深度
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
    float linearDepth = Linear01Depth(depth);

    // 计算该像素在世界空间中的位置
    float3 pixelWorldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay;

    // 计算像素到扫描中心的距离
    float pixelDistance = distance(pixelWorldPos, _ScanCenter);

    // 计算该像素的方向向量
    float3 pixelDir = pixelWorldPos - _ScanCenter;

    // 使用网格宽度将像素位置进行取模操作,用于创建网格效果
    float3 modulo = pixelWorldPos - _MeshWidth * floor(pixelWorldPos / _MeshWidth);
    modulo = modulo / _MeshWidth;

    // 使用平滑插值创建网格线效果
    float3 meshCol = smoothstep(_MeshLineWidth, 0, modulo) + smoothstep(1 - _MeshLineWidth, 1, modulo);

    // 将扫描背景颜色和网格颜色进行插值
    fixed4 scanMeshCol = lerp(_ScanBgColor, _ScanMeshColor, saturate(dot(meshCol, 1 - normal)));

    // 如果像素距离扫描中心在指定范围内,执行扫描效果
    if (_ScanRange - pixelDistance > 0 && _ScanRange - pixelDistance < _ScanWidth && linearDepth < 1)
    {
        // 根据像素距离计算扫描百分比
        fixed scanPercent = 1 - (_ScanRange - pixelDistance) / _ScanWidth;

        // 通过插值将当前颜色与网格颜色混合,生成扫描效果
        col = lerp(col, scanMeshCol, scanPercent);
    }

    // 返回最终颜色
    return col;
}

首先我们使用DecodeDepthNormal函数从_CameraDepthNormalsTexture中获取到法线和深度值,然后将法线经过一系列处理转换到世界空间。我们将深度值转换成线性深度之后用来计算该像素在世界空间的位置与扫描中心的距离,同时计算这个像素到扫描中心的向量。因为我们要实现网格效果,网格效果是一格一格实现的,所以我们需要将像素的位置取模来确定具体在第几个网格,然后将背景的颜色和网格颜色进行插值实现混合的效果,最终返回颜色。

关于线性深度:


网站公告

今日签到

点亮在社区的每一天
去签到