《Unity Shader入门精要》学习笔记二
1、基础光照
(1)看世界的光
模拟真实的光照环境来生成一张图像,需要考虑3种物理现象。
- 光线从光源中被发射出来。
- 光线和场景中的一些物体相交:一些光线被物体吸收了,而另一些光线被散射到其他方向
- 摄像机吸收了一些光,产生了一张图像。
【量化光】
使用辐照度来量化光。
对于平行光来说,它的辐照度可通过计算在垂直于I(光照方向)的单位面积上单位时间内穿过的能量来得到的。
当物体表面和I不垂直时,可以使用光源方向I和表面法线n之间的夹角的余弦值来得到。
因为辐照度是和照射到物体表面时光线之间的距离成反比的,因此辐照度和
成正比。
可以使用光源方向I和表面法线n的点积来得到。
【吸收和散射】
散射只改变光线的方向,但不改变光线的密度和颜色。
吸收只改变光线的密度和颜色,但不改变光线的方向。
光线在物体表面经过散射后,有两种方向:一种将会散射到物体内部,这个叫折射或透射;另一种将会散射到外部,这种叫反射。
对于不透明物体,折射的光线会在物体内部继续传播,最终有一部分光线会重新从物体表面被发射出去。
为了区分两种不同的散射方向,在光照模型中使用了不同部分来计算它们:
1)高光发射(specular)部分表示物体表面是如何反射光线的
2)漫发射(diffuse)部分则表示有多少光线被折射、吸收和散射出表面。
根据入射光线的数量和方向,可以计算出射光线的数量和方向,通常使用出射度(exitance)来描述它。辐照度和出射度之间是满足线性关系的,而它们之间的比值就是材质的漫反射和高光反射属性。
【着色】
着色:根据材质属性(如漫反射属性等)、光源信息(如光源方向、辐照度等),使用一个等式去计算沿某个观察方向的出射度的过程。
这个等式称为光照模型。
不同的光照模型有不同的目的,例如一些用于描述粗糙的物体表面,一些用于描述金属表面等。
(2)标准光照模型
基本方法:把进入摄像机内的光线分为4个部分,每个部分使用一种方法来计算它的贡献度。
1)自发光(emissive):当给定一个方向时,一个表面本身会向该方向发射多少辐射量。
2)高光发射(specular):当光线从光源照射到模型表面时,该表面会在完全镜面反射方向散射多少辐射量
3)漫反射(diffuse):当光线从光源照射到模型表面时,该表面会向每个方向散射多少辐射量。
4)环境光(ambient):其他所有的间接光照。
【逐像素还是逐顶点】
在哪里计算光照模型?
在片元着色器中计算,被称为逐像素光照(per-pixel lighting)。
在顶点着色器中计算,被称为逐顶点光照(per-vertex lighting)。
【saturate(x)函数】
把x截取在[0,1]范围内,如果x是一个矢量,那么会对它的每一个分量进行这样的操作。
(3)漫反射光照模型
基本光照中漫反射部分的计算公式:
LightMode标签是Pass标签中的一种,用于定义该Pass在Unity的光照流水线中的角色。只有定义了正确的LightMode,才能得到一些Unity的内置光照变量。
内置变量:
_Diffuse:材质的漫反射颜色
_LightColor0:光源的颜色和强度信息(想要得到正确的值需要定义合适的LightMode标签)
_WorldSpaceLightPos0:光源方向(假设场景中只有一个光源且该光源的类型是平行光)
// Transform the normal from object space to world space
fixed3 worldNormal = normalize(mul(v.normal, (float3x3)_World2Object));
在3D图形中,顶点位置使用模型到世界矩阵进行变换即可,但是法线向量不能直接用这个矩阵来变换。法线是方向向量,代表的是垂直于表面的方向。当模型发生非均匀缩放时(比如在X轴缩放2倍,Y轴不变),直接用_Object2World变换法线会导致它不再垂直于表面。
正确的做法:使用模型到世界变换矩阵的逆转置矩阵来变换法线。
(float3x3)_World2Object:因为法线是方向向量(没有位置信息),我们只需要变换方向,不需要平移部分,所以取_World2Object的3*3子矩阵(去掉最后一行一列的平移项),只保留旋转和缩放部分。
逐顶点代码:
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/DiffuseVertexLevel"
{Properties{_Diffuse("Diffuse", Color) = (1, 1, 1, 1)}SubShader{Pass{Tags{ "LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert#pragma fragment frag#include "Lighting.cginc"fixed4 _Diffuse;struct a2v{float4 vertex: POSITION;float3 normal: NORMAL;};struct v2f{float4 pos: SV_POSITION;fixed3 color: COLOR;};v2f vert(a2v v){v2f o;// Transform the vertex from object space to projection spaceo.pos = UnityObjectToClipPos(v.vertex);// Get ambient termfixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;// Transform the normal from object space to world spacefixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));// Get the light direction in world spacefixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);// Compute diffuse termfixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLight));o.color = ambient + diffuse;return o;}fixed4 frag(v2f i): SV_Target{return fixed4(i.color, 1.0);}ENDCG}}Fallback "Diffuse"
}
逐像素代码:
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'Shader "Custom/DiffusePixelLevel"
{Properties{_Diffuse("Diffuse", Color) = (1, 1, 1, 1)}SubShader{Pass{Tags{ "LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert#pragma fragment frag#include "Lighting.cginc"fixed4 _Diffuse;struct a2v{float4 vertex: POSITION;float3 normal: NORMAL;};struct v2f{float4 pos: SV_POSITION;float3 worldNormal: TEXCOORD0;};v2f vert(a2v v){v2f o;// Transform the vertex from object space to projection spaceo.pos = UnityObjectToClipPos(v.vertex);// Transform the normal from object space to world spaceo.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);return o;}fixed4 frag(v2f i): SV_Target{// Get ambient termfixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;// Get the normal in world spacefixed3 worldNormal = normalize(i.worldNormal);// Get the light direction in world spacefixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);// Compute diffuse termfixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));fixed3 color = ambient + diffuse;return fixed4(color, 1.0);}ENDCG}}Fallback "Diffuse"
}
效果:
上面的漫反射光照模型也被称为兰伯特光照模型,因为它符合兰伯特定律:在平面某点漫反射光线的光强与该反射点的法向量和入射光角度的余弦值成正比。
存在一个问题:在光照无法到达的区域,模型的外观通常是全黑的,没有任何明暗变化。
广义的半兰伯特光照模型公式:
绝大多数情况下和
的值均为0.5。
我们可以把的结果范围从[-1,1]映射到[0,1]范围内。
对于模型的背光面,原兰伯特光照模型中点积结果将映射到同一个值,即0值处。而在半兰伯特模型中,背光面也可以有明暗变化,不同的点积结果会映射到不同的值上。
半兰伯特代码:
Shader "Custom/HalfLambertMat"
{Properties{_Diffuse("Diffuse", Color) = (1, 1, 1, 1)}SubShader{Pass{Tags{ "LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert#pragma fragment frag#include "Lighting.cginc"fixed4 _Diffuse;struct a2v{float4 vertex: POSITION;float3 normal: NORMAL;};struct v2f{float4 pos: SV_POSITION;float3 worldNormal: TEXCOORD0;};v2f vert(a2v v){v2f o;// Transform the vertex from object space to projection spaceo.pos = UnityObjectToClipPos(v.vertex);// Transform the normal from object space to world spaceo.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);return o;}fixed4 frag(v2f i): SV_Target{// Get ambient termfixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;// Get the normal in world spacefixed3 worldNormal = normalize(i.worldNormal);// Get the light direction in world spacefixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);// Compute diffuse termfixed halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * halfLambert;fixed3 color = ambient + diffuse;return fixed4(color, 1.0);}ENDCG}}Fallback "Diffuse"
}
(4)高光反射光照模型
高光反射的计算公式:
4个参数:
入射光线的颜色和强度,材质的高光反射系数
,视角方向
以及反射方向
。
反射方向可以由表面法线和光源方向计算而得。
CG提供了计算反射方向的函数reflect(i, n),i是入射方向,n是法线方向。
【逐顶点方法】
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/SpecularVertexLevel"
{Properties{// 材质的漫反射颜色_Diffuse("Diffuse", Color) = (1,1,1,1) // 材质的高光反射颜色_Specular("Specular", Color) = (1,1,1,1)// 高光区域的大小_Gloss("Gloss", Range(8.0, 256)) = 20}SubShader{Pass{Tags{"LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert#pragma fragment frag #include "Lighting.cginc"fixed4 _Diffuse;fixed4 _Specular;float _Gloss;struct a2v{float4 vertex: POSITION;float3 normal: NORMAL;};struct v2f{float4 pos: SV_POSITION;fixed3 color: COLOR;};v2f vert(a2v v){v2f o;// Transform the vertex from object space to projection spaceo.pos = UnityObjectToClipPos(v.vertex);// Get ambient termfixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;// Transform the normal from object space to world spacefixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));// Get the light direction in world spacefixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);// Compute diffuse termfixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));// Get the reflect direction in world spacefixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));// Get the view direction in world spacefixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld, v.vertex).xyz);// Compute Specular termfixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);o.color = ambient + diffuse + specular;return o;}fixed4 frag(v2f i): SV_Target{return fixed4(i.color, 1.0);}ENDCG}}FallBack "Diffuse"
}
_WorldSpaceLightPos0:表示从表面指向光源(常用光照方向),当光源在上方时,_WorldSpaceLightPos0=(0,1,0),表示光从上往下照。
由于CG的reflect函数的入射方向要求由光源指向交点处,因此我们需要对worldLightDir取反后再传给reflect函数。
效果:
问题:高光部分明显不平滑。因为高光反射部分的计算是非线性的,而在顶点着色器中计算光照再进行插值的过程是线性的,破坏了原计算的非线性关系,就会出现较大的视觉问题。因此需要使用逐像素的方法来计算高光反射。
【逐像素方法】
代码:
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'Shader "Custom/SpecularPixelLevel"
{Properties{// 材质的漫反射颜色_Diffuse("Diffuse", Color) = (1,1,1,1) // 材质的高光反射颜色_Specular("Specular", Color) = (1,1,1,1)// 高光区域的大小_Gloss("Gloss", Range(8.0, 256)) = 20}SubShader{Pass{Tags{"LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert#pragma fragment frag #include "Lighting.cginc"fixed4 _Diffuse;fixed4 _Specular;float _Gloss;struct a2v{float4 vertex: POSITION;float3 normal: NORMAL;};struct v2f{float4 pos: SV_POSITION;float3 worldNormal: TEXCOORD0;float3 worldPos : TEXCOORD1;};v2f vert(a2v v){v2f o;// Transform the vertex from object space to projection spaceo.pos = UnityObjectToClipPos(v.vertex);// Transform the normal from object space to world spaceo.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);// Transform the vertex from object space to world spaceo.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;return o;}fixed4 frag(v2f i): SV_Target{// Get ambient termfixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;fixed3 worldNormal = normalize(i.worldNormal);// Get the light direction in world spacefixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);// Compute diffuse termfixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));// Get the reflect direction in world spacefixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));// Get the view direction in world spacefixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);// Compute Specular termfixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);return fixed4(ambient + diffuse + specular, 1.0);}ENDCG}}FallBack "Diffuse"
}
效果:
(5)Unity内置的函数
之前计算光源方向、视角方向的方法只适用于平行光,如果需要处理更复杂的光照类型(如点光源或聚光灯),之前计算光源方向的方法就是错误的。
Unity提供了一些内置函数来帮助我们计算这些信息,再UnityCG.cginc文件中。
上面的帮助函数使得我们不需要跟各种变换矩阵、内置变量打交道,也不需要考虑各种不同的情况(例如使用了哪种光源),而仅仅调用一个函数就可以得到需要的信息。
注意:上面的函数都没有保证得到的方向矢量是单位矢量,因此,需要在使用前把它们归一化。
通过内置函数,上面的代码优化为:
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'Shader "Custom/SpecularPixelLevel"
{Properties{// 材质的漫反射颜色_Diffuse("Diffuse", Color) = (1,1,1,1) // 材质的高光反射颜色_Specular("Specular", Color) = (1,1,1,1)// 高光区域的大小_Gloss("Gloss", Range(8.0, 256)) = 20}SubShader{Pass{Tags{"LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert#pragma fragment frag #include "Lighting.cginc"#include "UnityCG.cginc"fixed4 _Diffuse;fixed4 _Specular;float _Gloss;struct a2v{float4 vertex: POSITION;float3 normal: NORMAL;};struct v2f{float4 pos: SV_POSITION;float3 worldNormal: TEXCOORD0;float3 worldPos : TEXCOORD1;};v2f vert(a2v v){v2f o;// Transform the vertex from object space to projection spaceo.pos = UnityObjectToClipPos(v.vertex);// Transform the normal from object space to world spaceo.worldNormal = UnityObjectToWorldNormal(v.normal);// Transform the vertex from object space to world spaceo.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;return o;}fixed4 frag(v2f i): SV_Target{// Get ambient termfixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;fixed3 worldNormal = normalize(i.worldNormal);// Get the light direction in world spacefixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));// Compute diffuse termfixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));// Get the reflect direction in world spacefixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));// Get the view direction in world spacefixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));// Compute Specular termfixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);return fixed4(ambient + diffuse + specular, 1.0);}ENDCG}}FallBack "Diffuse"
}
2、基础纹理
纹理最初的目的就是使用一张图片来控制模型的外观。使用纹理映射(texture mapping)技术,把一张图“粘”在模型表面,逐纹素(texel)地控制模型的颜色。
纹理映射坐标定义了该顶点在纹理对应的2D坐标。通常,这些坐标使用一个二维变量(u,v)来表示,其中u是横向坐标,而v是纵向坐标。因此,纹理映射坐标也被称为UV坐标。
(1)单张纹理
目标:使用一张纹理来代替物体的漫反射颜色。
代码如下:
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/SingleTexture"
{Properties{_Color("Color Tint", Color) = (1,1,1,1)_MainTex("Main Tex", 2D) = "whilte"{}_Specular("Specular", Color) = (1,1,1,1)_Gloss("Gloss", Range(8.0, 256)) = 20}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 = mul(unity_ObjectToWorld, v.vertex).xyz;o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;// Or just call the built-in function // o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);return o;}fixed4 frag(v2f i): SV_Target{fixed3 worldNormal = normalize(i.worldNormal);fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));// Use the texture to sample the diffuse colorfixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));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);}ENDCG}}FallBack "Specular"
}
_MainTex("Main Tex", 2D) = "white" {}
声明了一个名为_MainTex的纹理,2D是纹理属性的声明方式。
使用一个字符串后跟一个花括号作为它的初始值,“white”是内置纹理的名字,也就是一个全白的纹理。
在CG代码片中声明纹理类型相匹配的变量,以便和材质面板中的属性建立联系:
_MainTex对应两个:
- sampler2D _MainTex;
- float4 _MainTex_ST;
_MainTex_ST的名字不是任意起的,在Unity中,我们需要使用 纹理名_ST 的方式来声明某个纹理的属性。其中,ST是缩放(scale)和平移(translation)的缩写。_MainTex_ST可以让我们得到该纹理的缩放和平移(偏移)的值,_MainTex_ST.xy存储的是缩放值,而_MainTex_ST.zw存储的是偏移值。
在顶点着色器中:
o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
计算过程:首先使用缩放属性_MainTex_ST.xy对顶点纹理坐标进行缩放,然后再使用偏移属性_MainTex_ST.zw对结果进行偏移。也可以使用内置宏TRANSFORM_TEX来帮助计算上述过程,第一个参数是顶点纹理坐标,第二个参数是纹理名。
在片元着色器中:
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
使用CG的tex2D函数对纹理进行采样,它的第一个参数是需要被采样的纹理,第二个参数是一个float2类型的纹理坐标,它将返回计算得到的纹素值。使用采样结果和颜色属性_Color的乘积来作为材质的反射率albedo,并把它和环境光照相乘得到环境光部分。
效果:
(2)纹理的属性
纹理映射的简单描述:声明一个纹理变量,再使用tex2D函数采样。
纹理的类型有:Texture类型、Normalmap类型等。
(导入素材后的界面)
之所以要为导入的纹理选择合适的类型,是因为只有这样才能让Unity知道我们的意图,为Unity Shader传递正确的纹理,并在一些情况下可以让Unity对该纹理进行优化。
Wrap Mode:决定了当纹理坐标超过[0,1]范围后将会如何被平铺。
Filter Mode:决定了当纹理由于变换而产生拉伸时将会采用哪种滤波模式,支持Point、Bilinear、Trilinear模式。3种方式效果依次提升,但需要耗费的性能也依次增大。纹理滤波会影响放大或缩小纹理是得到的图片质量。例如,当把一张64*64大小的纹理贴在一个512*512大小的平面上时,就需要放大纹理。
纹理缩放更加复杂的原因在于我们往往需要处理抗锯齿的问题。
(3)凹凸映射
凹凸映射的目的:使用一张纹理来修改模型表面的法线,以便为模型提供更多的细节。这种方法不会真的改变模型的顶点位置,只是让模型看起来好像是"凹凸不平"的样子。
两种方法:
- 高度映射(height mapping):使用一张高度纹理(height map)来模拟表面位移(displacement),然后得到一个修改后的法线值。
- 法线映射(normal mapping):使用一张法线纹理(normal map)来直接存储表面法线。
1)高度映射
高度图存储的是强度值(intensity),用于表示模型表面局部的海拔高度。因此,颜色越浅表明该位置的表面越向外凸起,而颜色越深表明该位置越向里凹。
这种方法的好处是非常直观,缺点是计算更加复杂。
高度图通常会和法线映射一起使用,用于给出表面凹凸的额外信息。也就是说,我们通常会使用法线映射来修改光照。
2)法线纹理
法线纹理种存储的是表面的法线方向。
由于法线方向的分量范围在[-1,1],而像素分量范围为[0,1],因此我们需要做一个映射:
这就要求在shader中对法线纹理进行纹理采样后,还需要对结果进行一次反映射的过程,以得到原先的法线方向。
反映射的过程就是使用上面映射函数的逆函数:
3)白话说明
想象你有一个3D模型,比如一个石头。这个石头表面不是完全光滑的,有凹凸不平的细节,比如小坑、裂缝、凸起等。
在计算机图形里,为了让这个石头看起来有这些细节,我们有两种办法:
- 真的把模型做得更复杂:加很多小三角形来做出凹凸(但这样太费性能)。
- 假装它有凹凸:用一张“贴图”告诉计算机:“这里看起来应该是凸的,那里看起来是凹的”,但实际上模型还是平的——这就是**法线贴图(Normal Map)**的作用。
法线贴图怎么工作:
其实是一张记录了“每个点表面朝向”的图。每个像素的颜色代表了那个位置的“表面法线方向”(你可以理解为“表面是朝哪个方向凸出来的”)。
但方向是相对的,必须有个参考系——就像你说“前面”“左边”,得先知道你脸朝哪。
所以关键问题是:这个方向是相对于谁来说的?
这就引出了两种不同的“参考系”(也就是坐标空间):
1. 模型空间法线贴图(Object-Space Normal Map)
- 所有点都用同一个参考系,就是整个模型自己的坐标系。
- 比如:整个石头的“上”是Y轴,“右”是X轴,“前”是Z轴。
- 每个点的法线方向都是相对于这个统一坐标系来记录的。
- 结果就是:不同方向的颜色五颜六色(因为有的地方朝上,有的朝左,有的斜着……颜色各不相同)。
- 缺点:这张贴图只能给这一个特定模型用。换个模型或旋转一下,就不对了。
2.切线空间法线贴图(Tangent-Space Normal Map)
它的思路是:每个点都有自己的“局部坐标系”。
这个局部坐标系是怎么定的呢?三个方向:
- Z轴:垂直于表面 → 就是原来的法线方向(n)
- X轴:沿着表面的“切线”方向(t),比如纹理拉伸的方向
- Y轴:垂直于X和Z → 叫做副切线(b)
这三个轴合起来叫“TBN坐标系”,每个顶点都有自己的一套。
类比:你在地球上的某个点,你的“上”是头顶(Z),你的“前”是鼻子方向(Y),你的“右”是右手方向(X)。虽然地球上每个人的方向都不同,但在自己看来都很自然。
现在,法线贴图记录的是:在这个点自己的坐标系下,新的法线往哪儿偏了?
- 如果没变化(还是垂直于表面),那就是 (0, 0, 1) —— Z轴方向。
- 计算机把 (0,0,1) 转换成颜色就是 RGB(0.5, 0.5, 1) —— 浅蓝色(利用前面法线转像素的公式)
所以你看切线空间法线贴图,一大片都是浅蓝色,说明大多数地方没有凹凸变化。
只有真正凹下去或凸出来的地方,颜色才会偏红、偏绿、偏紫……
为什么切线空间更常用?
- 同一张法线贴图可以用在不同模型上(比如砖墙贴图用在墙上、地上、柱子上都行)
- 模型变形或动画时也能正确工作(比如角色手臂弯曲,法线还能跟着变)
- 贴图看起来“整齐”,蓝色为主,容易检查错误
而这一切的前提,是我们用了“每个点自己看自己”的方式——也就是切线空间,才让这种“蓝色为主”的贴图变得有意义又实用。
就像给每个小格子发了一个指南针,告诉它:“你是朝天的”,那就涂成蓝色;“你往左歪了”,那就加点红。
4)UV坐标
UV坐标就是给3D模型“贴地图”时用的“经纬度”。
举例:想象你有一个地球仪(3D模型),你想给它贴一张世界地图(这就是你的纹理,比如颜色、凹凸图等)。
问题是:地图是一张flat的纸,地球是一个round的球,怎么贴才不歪、不皱、不错位?
这时候就需要一个贴图指南告诉计算机贴的位置,这个贴图指南就是UV坐标。
UV怎么工作?
每个3D模型的顶点(就是模型上的一个点),都会有一个对应的(U,V)值。
比如:
- 一个顶点的 UV 是 (0.5, 0.5) → 就对应贴图正中间的像素
- UV 是 (0, 0) → 贴图左下角
- UV 是 (1, 1) → 贴图右上角
这样,计算机就知道:这个点该显示贴图哪个位置的颜色。
UV坐标用途:
1. 上颜色(漫反射贴图)
你想让一个角色脸上有痣、衣服有花纹,就得靠UV把“颜色图”准确贴上去。
2. 做凹凸(法线贴图)
前面说的法线贴图,也是靠UV找到每个点该用哪个法线方向。
3. 控制材质(金属度、粗糙度等)
哪些地方亮?哪些地方糙?也都靠UV来定位。
4. 动画效果(比如水流、发光移动)
移动UV坐标,就能让纹理“滑动”,看起来像水在流、光在跑。
5)在切线空间下计算
切线空间是由顶点法线和切线构建出的一个坐标空间。
在切线空间下计算光照模型。基本思路:在偏远着色器中通过纹理采样得到切线空间下的法线,然后再与切线空间下的视角方向、光照方向等进行计算,得到最终的光照结果。
代码如下:
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/NormalMapTangentSpace"
{Properties{_Color("Color Tint", Color) = (1,1,1,1)_MainTex("Main Tex", 2D) = "white"{}_BumpMap("Normal Map", 2D) = "bump"{}_BumpScale("Bump Scale", Float) = 1.0_Specular("Specular", Color) = (1,1,1,1)_Gloss("Gloss", Range(8.0, 256)) = 20}SubShader{Pass{Tags{"LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert#pragma fragment frag#include "Lighting.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;// Compute the binormalfloat3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;// construct a matrix which transform vectors from object space to rangent spacefloat3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);// transform the light direction from object space to teangent spaceo.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;// transform the view direction from object space to tangent spaceo.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);// Get the texel in the normal Mapfixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);fixed3 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(0, dot(tangentNormal, tangentLightDir));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);}ENDCG}}FallBack "Specular"
}
对于法线纹理_BumpMap,使用“bump”作为它的默认值。
“bump"是Unity内置的法线纹理,当没有提供任何法线纹理时,”bump“就对应了模型自带的法线信息。_BumpScale则是用于控制凹凸程度的,当它为0时,意味着该法线纹理不会对光照产生任何影响。
为了的该纹理的属性(平铺和偏移系数),我们为_MainTex和_BumpMap定义了_MainTex_ST和_BuumpMap_ST变量。
使用TANGENT语义来描述float4类型的tangent变量,和法线方向normal不同,tangent是float4而非float3,是因为我们需要使用rangent.w分量来决定切线空间中的第三个坐标轴-副切线的方向性。
白话解释:
每个顶点都有自己的“局部坐标系”,叫切线空间,由三个方向组成:
- T(Tangent):切线方向 → x轴(通常是纹理U方向)
- B(Bitangent):副切线方向 → y轴(通常是纹理V方向)
- N(Normal):法线方向 → z轴(垂直于表面)
这三个方向要构成一个“坐标系”,必须满足手性规则(就像左右手)。
问题来了:T 和 N 我都有了,B 怎么算?
你可能会说:“用叉积不就行了?B = N × T”
没错!但叉积只能告诉你“垂直方向是哪个”,不能告诉你“正负”!
tangent.w
的作用:副切线方向的“开关”:+1 表示正常,-1 表示反向。
由于使用了两张纹理,因此需要存储两个纹理坐标。为此把v2f中的uv变量的类型定义为float4类型,其中xy分量存储了_MainTex的纹理坐标,而zw分量存储了_BumpMap的纹理坐标。
tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
法线纹理中存储的是法线经过映射后得到的像素值,因此需要把它们反映射回来,首先把packedNormal的xy分量按之前提到的公式映射回法线方向,然后乘以_BumpScale(控制凹凸程度)来得到tangentNormal的xy分量。由于法线都是单位矢量,因此tangentNormal.z分量可以由tangentNormal.xy计算得到。
效果:
需要上传2张图片,一张是普通的纹理图(Texture Type为Default),另一张是纹理法线图(Texture Type设置为Normal map)。
(4)渐变纹理
例子:
使用这种方式可以自由地控制物体的漫反射光照。
代码:
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/RampTexture"
{Properties{_Color("Color Tint", Color) = (1,1,1,1)_RampTex("Ramp Tex", 2D) = "white"{}_Specular("Specular", Color) = (1,1,1,1)_Gloss("Gloss", Range(8.0, 256)) = 20}SubShader{Pass{Tags{"LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert #pragma fragment frag#include "Lighting.cginc"fixed4 _Color;sampler2D _RampTex;float4 _RampTex_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 = mul(unity_ObjectToWorld, v.vertex).xyz;o.uv = TRANSFORM_TEX(v.texcoord, _RampTex);return o;}fixed4 frag(v2f i): SV_Target{fixed3 worldNormal = normalize(i.worldNormal);fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;// Use the texture to sample the diffuse Colorfixed halfLambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;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);}ENDCG}}FallBack "Specular"
}
代码解读:
1)整体干啥:不用传统的光照公式算颜色,而是用一张“颜色条”贴图(即RampTex对应的贴图)来决定物体亮还是暗。
2)_RampTex("Ramp Tex", 2D) = "white"{}:一张2D贴图,默认是白色,这张图就是“明暗对照表”。
3)SubShader:子着色器,真正干活的地方
4)Pass{}:一次渲染通道
5)float4 _RampTex_ST; 贴图的平铺(Scale)和偏移(Translate),Unity自动生成
6)struct a2v中是每个顶点都会带来的信息,vertex:顶点在模型里的位置,normal:这个点的法线方向(表面朝哪),texcoord:UV坐标,用来查贴图(只有横竖坐标值,对应平铺的贴图)
7)float4 pos: SV_POSITION; 这个点最终在屏幕上的位置
8)TRANSFORM_TEX:是Unity的宏,等价于:
v.texcoord.xy * _RampTex_ST.xy + _RampTex_ST.zw
就是说:你可以缩放/移动贴图位置。
9)fixed halfLambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
这个不是普通光照,而是半兰伯特光照。
dot(worldNormal, worldLightDir):两个方向的夹角点积,结果在[-1,1]。面对光,接近1;背对光,接近-1。 0.5* ... + 0.5:把[-1,1]映射到[0,1]。半兰伯特就是按暗部也有点光,过渡更平滑。
10)fixed3 diffuseColor = tex2D(_RampTex, fixed2(halfLambert, halfLambert)).rgb * _Color.rgb;
tex2D(_RampTex, ...):在_RampTex这张图上取颜色
fixed2(halfLambert, halfLambert):用halfLambert当作UV坐标,比如halfLambert=0.8,就查贴图的(0.8,0.8)的位置。假设_RampTex是一条从黑到白的横条,亮的地方取白色,暗的地方取黑色,中间过渡去灰色或你设定的颜色,这样就控制了明暗的风格。
最后乘以_Color,整体润色。
11)fixed3 diffuse = _LightColor0.rgb * diffuseColor;
真正的漫反射=光源颜色 * 查表得到的颜色
12)fixed3 halfDir = normalize(worldLightDir + viewDir);
半角向量:光照方向和视线方向的中间方向,高光是否可见,要看法线是否接近这个方向
(5)遮罩纹理
作用:遮罩允许我们可以保护某些区域,使它们免于某些修改。
举例:在之前的实现中,我们都是把高光反射应用到模型表面的所有地方,即所有的像素都使用同样大小的高光强度和高光指数。但有时,我们希望模型表面某些区域的反光强烈一些,而某些区域弱一些。为了得到更加细腻的效果,我们就可以使用一张遮罩纹理来控制光照。
使用遮罩纹理的流程:通过采样得到遮罩纹理的纹素值,然后使用其中某个(或某几个)通道的值(例如texel.r)来与某种表面属性进行相乘,这样,当该通道的值为0时,可以保护表面不受该属性的影响。
代码:
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'Shader "Custom/MaskTexture"
{Properties{_Color("Color Tint", Color) = (1,1,1,1)_MainTex("Main Tex", 2D) = "white"{}_BumpMap("Normal Map", 2D) = "bump"{}_BumpScale("Bump Scale", Float) = 1.0_SpecularMask("Specular Mask", 2D) = "white"{}_SpecularScale("Specular Scale", Float) = 1.0_Specular("Specular", Color) = (1,1,1,1)_Gloss("Gloss", Range(8.0, 256)) = 20}SubShader{Pass{Tags{"LightMode" = "ForwardBase"}CGPROGRAM#pragma vertex vert #pragma fragment frag#include "Lighting.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.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);fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));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(0, dot(tangentNormal, tangentLightDir));fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);// Get the mask valuefixed specularMask = tex2D(_SpecularMask, i.uv).rgb * _SpecularScale;// Compute specular term with the specular maskfixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask;return fixed4(ambient + diffuse + specular, 1.0);}ENDCG}}FallBack "Specular"
}
_SpecularMask即是我们需要使用的高光反射遮罩纹理,_SpecularScale则是用于控制遮罩影响度的系数。
我们为主纹理_MainTex、法线纹理_BumpMap和遮罩纹理_SpecularMask定义了它们共同使用的纹理属性_MainTex_ST。这意味着,在材质面板中修改主纹理的平铺系数和偏移系数会同时影响3个纹理的采样。
在顶点着色器中,我们对光照方向和视角方向进行了坐标空间的变换,把它们从模型空间变换到了切线空间中,以便在片元着色器中和法线进行光照运算。
白话解释:
1)o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
计算贴图要用的UV坐标。
v.texcoord.xy:模型自带的UV(比如0~1)
_MainTex_ST.xy:你在材质面板上设的缩放(scale)
_MainTex_ST.zw:
2)TANGENT_SPACE_ROTATION;
宏函数,它自动完成2件事情:
- 计算出当前顶点的副切线(binormal)
- 和切线(tangent)、法线(normal)一起构造一个从模型空间到切线空间的旋转矩阵叫rotation
这个rotation是Unity自动生成的变量。
作用:让光照计算可以在每个顶点自己的小坐标系里进行,法线切图才有效。
3)normalize的好处:
情况 | 向量长度 | 光照计算是否准确 | 为什么 |
---|---|---|---|
没归一化 | 可能 >1 或 <1 | ❌ 不准 | 点积结果被放大/缩小 |
归一化后 | 一定是 1 | ✅ 准确 | 点积只反映角度关系 |
4)fixed3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));
tex2D():在_BumpMap这张图上查颜色
法线贴图的颜色不是普通颜色,而是把方向存成了RGB,
UnpackNormal:Unity提供的函数,把RGB颜色还原成方向向量,比如红色多-> 表面往右凸,绿色多->往上凸。
5)tangentNormal.xy *= _BumpScale;
控制凸凹程度
6)tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
重新计算z分量,保证法线长度为1
7)fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
获取这个像素的基础颜色。
albedo:反照率,表示这个材质本来是什么颜色。
8)fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
环境光受材质的影响。
9)fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask;
_LightColor0:光源颜色
_Specular:高光颜色
pow(..., _Gloss):指数越大,高光越小越亮
specularMask:乘上遮罩,控制哪里亮哪里不亮
没有遮罩时,全模型统一高光;有了遮罩,可以局部控制。
10) Tags{"LightMode"="ForwardBase"}
只有定义了正确的LightMode,我们才能正确得到一些Unity内置光照变量,例如:_LightColor0。
3、透明效果
透明效果的2种实现方法:
- 透明度测试(Alpha Test):只要一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会被舍弃。它的效果很极端,要么完全透明,即看不到,要么完全不透明,就像不透明物体那样。
- 透明度混合(Alpha Blending):使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。
(1)通用渲染顺序
对于不透明物体,不考虑它们的渲染顺序也能得到正确的排序效果,这是由于强大的深度缓冲(depth-buffer,也被称为z-buffer)的存在。
它的基本思想:根据深度缓存中的值来判断该片元距离摄像机的距离,当渲染一个片元时,需要把它的深度值和已经存在于深度缓冲中的值进行比较(如果开启了深度测试),如果它的值距离摄像机更远,那么说明这个片元不应该被渲染到屏幕上(有物体挡住了它);否则,这个片元应该覆盖掉此时颜色缓冲中的像素值,并把它的深度值更新到深度缓冲中(如果开启了深度写入)。
【深度缓冲技术】
它像一个小本本,记录每个像素距离摄像机有多远。
比如从近到远的3个物体:车、房子、树。
先画树:每个像素记下距离=10
再画房子:发现“我比树近(距离=5)”,于是覆盖树,更新距离为5
再画车:发现“我比房子还近(距离=1)”,覆盖房子,更新为1
结果:车在最前面,房子在中间,树在后面。
这个过程叫:
- 深度测试:比较“我要画的点”和“已经画的点”,谁更近
- 深度写入:把新的距离写进“小本本”
渲染引擎一般都会先对物体进行排序,再渲染。常用的方式是:
1)先渲染所有不透明物体,并开启它们的深度测试和深度写入
2)把半透明物体按它们距离摄像机的远近进行排序,然后按照从后往前的顺序渲染这些半透明物体,并开启它们的深度测试,但关闭深度写入。
(2)Shader的渲染顺序
Unity为了解决渲染顺序的问题提供了渲染 队列这一解决方案。
Unity在内部使用一系列整数索引来表示每个渲染队列,且索引号越小表示越早被渲染。
1)透明度测试代码
SubShader {Tags { "Queue"="AlphaTest" }Pass {...}}
2)透明度混合代码
SubShader {Tags { "Queue"="Transparent" }Pass {ZWrite Off...}}
ZWrite Off用于关闭深度写入,在这里我们选择把它写在Pass中,也可以把它写在SubShader中,这意味着该SubShader下的所有Pass都会关闭深度写入。
(3)透明度测试
在片元着色器中是使用clip函数进行透明度测试。
void clip(floatx x);
x: 裁剪时使用的标量或矢量条件
描述:如果给定参数的任何一个分量是负数,就会舍弃当前像素的输出颜色。
等同于下面的代码:
void clip(float4 x){if (any(x < 0))discard;}
透明度测试完整代码:
Shader "Custom/AlphaTest"
{Properties{_Color("Main Tint", Color) = (1,1,1,1)_MainTex("Main Tex", 2D) = "white"{}_Cutoff("Alpha Cutoff", Range(0,1)) = 0.5}SubShader{Tags{"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout"}Pass{Tags{"LightMode"="ForwardBase"}CGPROGRAM#pragma vertex vert #pragma fragment frag#include "Lighting.cginc"fixed4 _Color;sampler2D _MainTex;float4 _MainTex_ST;fixed _Cutoff;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 = mul(unity_ObjectToWorld, v.vertex).xyz;o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);return o;}fixed4 frag(v2f i): SV_Target{fixed3 worldNormal = normalize(i.worldNormal);fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));fixed4 texColor = tex2D(_MainTex, i.uv);// Alpha testclip(texColor.a - _Cutoff);fixed3 albedo = texColor.rgb * _Color.rgb;fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));return fixed4(ambient + diffuse, 1.0);}ENDCG}}FallBack "Transparent/Cutout/VertexLit"
}
代码解释:
1)面板参数
参数名 | 中文名 | 默认值 | 作用 |
---|---|---|---|
_Color | 主色调 | 白色 (1,1,1,1) | 整体调色,比如想变红就把这里调红 |
_MainTex | 主纹理 | 白色贴图 | 你的图片(比如树叶、铁丝网) |
_Cutoff | 透明度裁剪阈值 | 0.5 | 控制“多透明才算透明” |
如果贴图某个像素的透明度是0.3,而_Cutoff=0.5, 0.3<0.5,这个像素被舍弃了,看不见。
2)SubShader标签
标签 | 含义 |
---|---|
"Queue"="AlphaTest" | “我是透明裁剪物体,请在不透明物体之后、透明混合物体之前渲染我” |
"IgnoreProjector"="True" | “不要把我投影到其他物体上(比如不要让这个树叶影子打到墙上)” |
"RenderType"="TransparentCutout" | “我是‘有透明有不透明’的类型”,Unity 其他系统(比如光照)会特殊处理 |
3)Shader的工作流程:
1. 拿到模型的每个顶点(位置、法线、UV)↓
2. 顶点着色器:转到世界空间,计算光照方向,处理 UV↓
3. GPU 插值:把顶点之间的像素都算出对应的法线、位置、UV↓
4. 像素着色器:采样纹理 → 判断透明度 → 太透明就剪掉↓
5. 不剪的像素:计算光照(环境光 + 漫反射)→ 输出颜色
效果:
随着Alpha cutoff参数的增大,更多的像素由于不满足透明度测试条件而被剔除。
(4)透明度混合
这种方法可以得到真正的半透明效果。它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。但是,透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序。
为了进行混合,我们需要使用Unity提供的混合命令——Blend。Blend是Unity提供的设置混合模式的命令。想要实现半透明的效果就需要把当前自身的颜色和已经存在于颜色缓冲中的颜色值进行混合,混合时使用的函数就是由该指令决定的。Blend命令的语义:
这个命令在设置混合因子的同时也开启了混合模式,只有开启了混合之后,设置片元的透明通道才有意义。
代码:
Shader "Custom/AlphaBlend"
{Properties{_Color("Main Tint", Color) = (1,1,1,1)_MainTex("Main Tex", 2D) = "white"{}_AlphaScale("Alpha Scale", Range(0,1)) = 1}SubShader{Tags{"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}Pass{Tags{"LightMode"="ForwardBase"}ZWrite OffBlend SrcAlpha OneMinusSrcAlphaCGPROGRAM#pragma vertex vert #pragma fragment frag#include "Lighting.cginc"fixed4 _Color;sampler2D _MainTex;float4 _MainTex_ST;fixed _AlphaScale;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 = mul(unity_ObjectToWorld, v.vertex).xyz;o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);return o;}fixed4 frag(v2f i): SV_Target{fixed3 worldNormal = normalize(i.worldNormal);fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));fixed4 texColor = tex2D(_MainTex, i.uv);fixed3 albedo = texColor.rgb * _Color.rgb;fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));return fixed4(ambient + diffuse, texColor.a * _AlphaScale);}ENDCG}}FallBack "Transparent/VertexLit"
}
指令 | 作用 |
---|---|
ZWrite Off | 关掉“占位置”功能 → 让后面的物体还能画上来 |
Blend SrcAlpha OneMinusSrcAlpha | 开启“混合”模式 → 当前颜色和后面的颜色“叠在一起” |
Blend SrcAlpha OneMinusSrcAlpha
翻译成数据公式如下:
最终颜色 = 当前颜色 × 当前透明度 + 背后颜色 × (1 - 当前透明度)
术语 | 含义 |
---|---|
SrcAlpha | Source Alpha → 当前要画的颜色的透明度(texColor.a ) |
OneMinusSrcAlpha | 1 - SrcAlpha → 当前颜色不占的比例 |
效果:
存在问题:
当模型网格之间有互相交叉的结构时,往往会得到错误的半透明效果。
(5)开启深度写入的半透明效果
针对方法:使用两个Pass来渲染模型,第一个Pass开启深度写入,但不输出颜色,它的目的仅仅是为了把该模型的深度值写入深度缓冲中。第二个Pass进行正常的透明度混合,由于上一个Pass已经得到了逐像素的正确的深度信息,该Pass就可以按照像素级别的深度排序结果进行透明渲染。
示例:
这个新添加的Pass的目的仅仅是为了把模型的深度信息写入深度缓冲中,从而剔除模型中被自身遮挡的片元。
ColorMask设为0,意味着该Pass不写入任何颜色通道,即不会输出任何颜色。
(6)ShaderLab的混合命令
混合的实现流程:当片元着色器产生一个颜色的时候,可以选择与颜色缓存中的颜色进行混合。这样一来,混合就和两个操作有关:源颜色(source color)和目标颜色(destiination color)。源颜色,用S表示,指的是由片元着色器产生的颜色值;目标颜色,用D表示,指的是从颜色缓冲中读取到的颜色值。对它们进行混合后得到的输出颜色,用O表示,它会重新写入到颜色缓冲中。
混合中的源颜色、目标颜色和输出颜色,都包含RGBA四个通道的值,而并非仅仅是RGB通道。
1)混合等式和参数
混合是一个逐片元的操作,而且它不是可编程的,但却是高度可配置的。
我们可以设置混合时使用的运算操作、混合因子等影响混合。
现在,我们已知两个操作数:源颜色S和目标颜色D,想要得到输出颜色O就必须使用一个灯饰来计算。
我们把这个等式称为混合等式。当进行混合时,我们需要使用两个混合等式:一个用于混合RGB通道,一个用于混合A通道。
当设置混合状态时,实际上设置的就是混合等式中的操作和因子。
默认情况下,混合等式使用的操作都是加操作,我们只需要再设置一下混合因子即可。
由于需要两个等式(分别用于混合RGB通道和A通道),每个等式有两个因子(一个用于和源颜色相乘,一个用于和目标颜色相乘),因此一共需要4个因子。
ShaderLab中设置混合因子的命令:
可以发现,第一个命令只提供了两个因子,这意味着将使用同样的混合因子来混合RGB通道和A通道,即此时SrcFactorA将等于SrcFactor, DstFactorA将等于DstFactor。下面就是使用这些因子进行加法混合时使用的混合公式:
Orgb=SrcFactor×Srgb+DstFactor×Drgb
Oa=SrcFactorA×Sa+DstFactorA×Da
ShaderLab中的混合因子:
假如我们想要在混合后,输出颜色的透明度值就是源颜色的透明度,可以使用下面的命令:
Blend srcAlpha OneMinusSrcAlpha, One Zero
2)混合操作