本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
- 书本中句子照抄 + 个人批注
- 项目源码
- 一堆新手会犯的错误
- 潜在的太监断更,有始无终
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
文章目录
Unity中的环境光和自发光
在标准光照模型中国,环境光和自发光的计算是最简单的。而在Unity中,场景中的环境光可以在Lighitng面板中调整(不同版本Lighting面板路径不一样,百度一下即可)
(上述面板中,可见对一些光照的属性配置,环境光,实时光照,混合光照等)
在Shader中,我们只需要通过Unity的内置变量UNITY_LIGHTMODEL_AMBIENT 就可以得到环境光的颜色和强度信息。
由于大多数物体没有自发光特性,所以本书中绝大部分Shader没有计算自发光,想要计算自发光也很简单,只需在输出最终颜色结果前与材质自身的发光颜色进行加法即可。
Unity Shader中实现漫反射光照模型
在上节课学习了基本光照模型的理论后,我们看看如何在Unity中实现基本光照模型。例如标准光照模型中的漫反射光照部分:
公式: C d i f f u s e = ( C l i g h t ⋅ M d i f f u s e ) m a x ( 0 , n ^ ⋅ I ) C_{diffuse}=(C_{light} \cdot M_{diffuse})max(0,\hat n \cdot I) Cdiffuse=(Clight⋅Mdiffuse)max(0,n^⋅I)
(记住这个公式,发现绝大部分光照的计算都是这一公式的变形)
从上式可知,计算漫反射需要四个参数,入射光线的颜色和强度 C l i g h t C_{light} Clight,材质的漫反射系数 M d i f f u s e M_{diffuse} Mdiffuse,表面法线 n ^ \hat n n^以及光源方向 I I I
max函数是为了防止点积结果为负数(点积为负数说明法线反向了,若取0则光的最终计算值值为0,就变成了黑色!),在CG中也提供了一个Max函数,即saturate 函数:
saturate(x),其中x为一个用于操作的向量,可以是float,float2,float3等类型。其作用是将x截取在[0,1]的范围内,如果x是一个向量,那么则会对它的每一个分量进行这样的操作。
逐顶点光照
让我们实现一个逐顶点光照的效果,最终效果如上图所示:
(1)在Unity中新建一个场景,去除场景中内置的天空盒
(2)新建一个Shader和其对应的材质
(3)创建一个胶囊体并将材质赋值给它
接下来让我们编写这个Shader:
Shader "Chapter6/DiffuseVertexLevelCopy"
{
Properties{
_Diffuse ("Diffuse",Color) = (1,1,1,1)
}
SubShader{
Pass{
Tags{"LightMode" = "ForwardBase"}
}
}
}
我们定义这个Shader的属性面板,其中声明了Color类型的漫反射变量Diffuse,并将其颜色值设为白色。然后我们定义了一个SubShader块和Pass块,因为逐顶点光照是在顶点/片元着色器中实现的。
LightMode是Pass标签的一种,用于定义该Pass在光照流水线的角色,目前不需深入了解,只需知道只有定义了正确的LightMode我们才能得到一些Unity的内置光照变量,例如 _ L i g h t C o l o r 0 \_LightColor0 _LightColor0
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;
};
随后,我们在Pass块中定义CGPROGRAM,预定义使用的着色器函数名和包含的头文件。为了在Pass中使用Shader的_Diffuse变量,我们需要定义一个四维向量fixed4 _Diffuse;
最后我们定义着色器中需要使用的结构体a2v和v2f。
最后也是最关键的部分,为了实现逐顶点的漫反射光照,我们大部分代码将会在顶点着色器中实现:
v2f vert(a2v v) {
v2f o;
// 用MVP变换将顶点从模型空间变换到裁剪空间
o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
// 获取环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 将法线从模型空间变换到世界空间
// 解释一下该公式,mul(v.normal,(float3x3)_World2Object)的原因比较复杂
// 首先法向量是始终垂直于模型表面,因此无需平移,则其应用的是齐次变换矩阵中的左上方三阶矩阵
// 即包括了旋转和拉伸的部分
// 但是法向量的计算是通过顶点插值获取,所以并不等同与顶点计算
// 由于O2W的左上方三阶矩阵是旋转和缩放的复合矩阵,则[ow2]T = [rotate]T * [scale]T
// 由于旋转矩阵是正交矩阵,缩放矩阵是对角矩阵,因此上述结果为=[rotate]-1 * [scale]
// 正确的计算方法是应当对模型法向量应用[O2W]^T来进行反向缩放,再求逆矩阵
// 所以计算公式是 normal = [[[o2w]T]-1 * n]T = [[[o2w]-1]T * n]T = [[w2o]T * n]T = nT * w2o
// 反正记住这个公式就好了
fixed3 worldNormal = normalize(mul(v.normal,(float3x3)_World2Object));
// 获取世界空间中的光照方向
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
// 计算漫反射光照 ,对应上文公式
fixed3 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);
}
我们首先定义了最终的返回值o,顶点着色器的最基本任务就是把顶点位置从模型空间转换到裁剪空间中,因此基本任何顶点着色器的前面两行都是如此的固定开局(将POSITON语义赋值的变量进行MVP转换并赋值给SV_POSITION变量)
接着,我们通过Unity的内置变量UNITY_LIGHTMODEL_AMBIENT获取了环境光的值(环境光用于最后直接相加)。
而表面法线由于是在模型表面的,由于光照是基于引擎的世界坐标的,当然法线也是需要从模型空间转换到世界空间
最后,我们根据公式来计算漫反射光。我们已经获取了表面法线 n ^ \hat n n^,定义了材质的漫反射系数 M d i f f u s e M_{diffuse} Mdiffuse,就差入射光线的颜色和强度 C l i g h t C_{light} Clight以及光源方向 I I I
normalize(标准化)的目的是因为我们只需要对应向量方向即可,因此将其标准化为模长为1的单位向量方便计算。
入射光线的颜色和强度 C l i g h t C_{light} Clight 则可以通过Unity的内置变量 _ L i g h t C o l o r 0 \_LightColor0 _LightColor0来获取(前提是pass块中的Tag设置了正确的LightMode)。而光源方向 I I I则可以通过 _ W o r l d S p a c e L i g h t P o s 0 \_WorldSpaceLightPos0 _WorldSpaceLightPos0内置变量来得到(normalize(_WorldSpaceLightPos0.xyz)对其归一化),注意,在本节中,由于场景中只有一个光源且该光源类型为平行光,我们才直接使用归一化的_WorldSpaceLightPos0。若场景中存在多个光源且类型为点光源等其他类型,则直接使用_WorldSpaceLightPos0就不能得到正确的结果。
而法向量的求法就用上述式子,比较难理解的就是为什么法向量的缩放应当与模型缩放相反(应该是因为法向量与模型平面方向的乘积为-1)。
最后我们再根据上述公式对漫反射光照进行计算,最后加上环境光得到光照值。输出顶点颜色即可。
最终呈现的效果如图所示,对于细分程度较高的模型,逐顶点光照已经可以得到比较好的光照效果了。但对于一些细分程度低的模型来说,逐顶点光照会出现一些视觉问题,例如上图的胶囊体,我们可以看见在向光处与背光处的阴影交界,存在锯齿边缘。为了解决这一问题,我们可以使用更加精细的逐像素光照。
附上完整的Shader代码
Shader "Chapter6/DiffuseVertexLevelCopy"
{
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;
o.pos = UnityObjectToClipPos(v.vertex);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 worldNormal = normalize(mul(v.normal,(float3x3)unity_WorldToObject));
fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
fixed3 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"
}
逐像素光照
逐像素光照的代码其实与逐顶点光照类似,其实就是把计算漫反射光的过程从顶点着色器转移到片元着色器。我们之前说过,逐顶点光照是在顶点着色器内实现的,内部像素颜色是通过顶点插值进行计算(这与渲染流水线的原理相同,顶点着色器内部颜色就是通过顶点插值获得的)。而如果将相同功能的代码放于片元着色器内,就能实现逐像素计算。
Shader "Custom/DiffusePixelLevelCopy"
{
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 worldNormal : TEXCOORD0;
};
v2f vert(a2v v){
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = mul(v.normal,(float3x3)unity_WorldToObject);
return o;
}
fixed4 frag(v2f i):SV_TARGET{
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal,worldLightDir));
fixed3 color = diffuse + ambient;
return fixed4(color,1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
附代码,仔细观察不难发现,我们就将漫反射计算过程放在了片元着色器,顶点着色器的作用就是获取v2f,以及对法线进行从模型空间到世界空间的转换。
左为逐顶点光照,右为逐像素光照。不难发现逐像素光照的阴影交界去除了锯齿,更加柔和了。
使用逐像素光照获得了更好的光照效果,然而有个问题依然存在——在光照无法到达的区域,模型的外观是全黑的,没有任何明暗变化,整个背光面没有明暗过渡,看起来像是一个平面一样。这使得模型失去了一些细节,因此我们需要一个新技术,被称为半兰伯特光照模型(Half Lambert) 。
半兰伯特模型
半兰伯特如上所示,其实就是对 n ⋅ I n \cdot I n⋅I应用了一个一次函数 f ( x ) = a x + b f(x)=ax+b f(x)=ax+b(突然感觉我上我也行)
大多数情况下,半兰伯特模型公式如下:
其中
α
=
0.5
,
β
=
0.5
\alpha = 0.5,\beta = 0.5
α=0.5,β=0.5。上述公式代替了直接取max值的兰伯特模型,其实很好理解,之所以出现黑色部分,是因为法线和光照方向的点乘结果中存在小于0的值域,这些值被直接取为了0,也就是背光部分为0,一般来说正则化法线和光源光线后,其模长为1。所以
n
^
⋅
I
=
∣
n
^
∣
∣
I
∣
c
o
s
θ
=
c
o
s
θ
\hat n \cdot I = |\hat n||I|cos\theta =cos\theta
n^⋅I=∣n^∣∣I∣cosθ=cosθ,而
c
o
s
θ
cos\theta
cosθ的取值范围是
[
−
1
,
1
]
[-1,1]
[−1,1]。所以只需将
[
−
1
,
1
]
[-1,1]
[−1,1]通过函数映射到
[
0
,
1
]
[0,1]
[0,1]的值域即可,就能保证兰伯特模型最终值是非负的,同时应用了一次函数使得背光部分的光照存在线性的过渡效果。
因此半兰伯特模型只需使得 α = 0.5 , β = 0.5 \alpha = 0.5,\beta = 0.5 α=0.5,β=0.5即可。这一原理也引用到了NDC的坐标值域[-1,1]和颜色值域[0,1]的映射上(用颜色rgb值来表示坐标xyz)。
当然,这是没有任何物理依据的,只是通过引入线性过渡效果使得视觉上看起来更加自然。
只需修改公式代码:
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * (dot(worldNormal,worldLightDir) * 0.5 +0.5);
最右侧的shader使用了半兰伯特模型,阴影处的光照更加自然了
在UnityShader中实现高光反射光照模型
其中反射方向r可以由表面法线
n
^
\hat n
n^和光源方向
I
^
\hat I
I^计算而得:
r = I ^ − 2 ( n ^ ⋅ I ^ ) n ^ r = \hat I - 2(\hat n \cdot \hat I)\hat n r=I^−2(n^⋅I^)n^
更方便的是,Unity提供了反射方向函数reflect(i,n),只需给出入射光源方向I和法线方向n即可得到反射光,返回类型可以是float,float2,float3
逐顶点光照
我们已经写过漫反射逐顶点光照的shader了,那么高光反射逐顶点光照是相同的思路:主体着色在顶点着色器,对顶点从模型空间转换到齐次裁剪空间,获得Unity场景中的环境光,入射光方向I,法线方向n,从而计算出反射光方向r,最后根据公式得到反射光颜色。而最终呈现的光 = 环境光 + 高光反射光 + 漫反射光
Shader "Custom/SpecularVertexLevelCopy"
{
Properties
{
_Diffuse ("Diffuse",Color) = (1,1,1,1)
_Specular ("Specular",Color)=(1,1,1,1)
_Gloss("Gloss",Range(8.0,255)) = 20
}
SubShader
{
Pass
{
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
// _Gloss精度范围大,我们使用float存储
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;
o.pos = UnityObjectToClipPos(v.vertex);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 worldNormal = normalize(mul(v.normal,(float3x3)unity_WorldToObject));
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
// 计算漫反射光颜色
fixed3 diffuse = _Diffuse.rgb * _LightColor0.rgb * saturate(dot(worldNormal,worldLightDir));
// 获取反射光方向,实际公式定义和reflect函数正好入射方向是反的,所以注意对入射光反向取反
//注意千万别搞反了i和n的输入顺序,否则就会在背光面反射
fixed3 reflectDir = reflect(-worldLightDir,worldNormal);
// 视角方向即为摄像机点到顶点的方向,两点向量相减后标准化即可
// normalize(camera-vertex),注意二者统一到世界坐标下
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld,v.vertex).xyz);
fixed3 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 "Specular"
}
由于CG的reflect的i方向是光源指向交点处(光源与法线的交点)。所以需要对入射光方向取反。
最终成果如上图所示,向光处出现了高光区域。并且我们也可以在材质面板中对Gloss进行调整。由于点积结果由
c
o
s
θ
cos\theta
cosθ决定,最终取值范围saturate后为[0,1],Gloss面板值越大,幂指数越大,高光值会越小。
逐像素光照
既然已经学到了这里,原理已经解释的很清晰了,我想其实Shader部分代码已经可以自己编写了,因此就不提供代码部分了。
其实过程很简单,根据我们之前学习的内容,由于逐像素光照的计算主体在片元着色器内,所以我们在顶点着色器中只计算一些顶点相关的必要变量,例如从模型空间转到齐次裁剪空间的顶点向量(用于提供给SV_POSITION),从模型空间变换到世界空间的法线向量(用于在片元着色器中计算标准化的法线向量方向)和从模型空间转换到世界空间的顶点向量(用于在片元着色器中计算摄像机点到顶点的视角方向)。
最后只需要在片元着色器中计算即可
右侧即为逐像素光照渲染,我们法线高光反射处更加丝滑,锯齿消失了
Blinn-Phong光照模型
在之前将Phong模型的时候,我们提到了Blinn-Phong模型,其实就是引入了一个新的向量 h ^ \hat h h^,它是通过对视角方向 v ^ \hat v v^和光照方向 I ^ \hat I I^相加后再归一化得到的:
h
^
=
v
^
+
I
^
∣
v
^
+
I
^
∣
\hat h = \frac{\hat v + \hat I}{|\hat v + \hat I|}
h^=∣v^+I^∣v^+I^
最后我们再实现一个逐顶点的Blinn-Phong光照模型:
从左到右依次为逐顶点高光,逐像素高光,BlinnPhong逐像素高光
明显看出BlinnPhong模型的高光区域要更大
由于光照模型是经验模型,因此对于不同模型,我们应当在不同情况下做出选择。而不是盲目相信更为复杂的模型是更正确的。不过在基于物理的情况下BlinnPhong模型可能更符合真实的结果。
使用Unity 的内置函数
在上述代码中,我们使用例如 normalize(_WorldSpaceLightPos0.xyz)
来获得光源方向(这种方法实际上只适用于平行光),使用
normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
来得到视角方向。如果需要处理更加复杂的光源类型,例如点光源和聚光灯,那么我们计算光源方向的方法就是错误的,我们需要在代码中先判断光源类型,再计算其他的光源信息。具体方法会在第九章介绍
在Unity中提供了一些函数来帮助我们计算光源信息(在UnityCG.cginc中):
使用这些内置函数,可以替代我们之前Shader中的一些代码:
v2f vert(a2v v)
{
......
o.worldNormal = mul(v.normal,(float3x3)unity_WorldToObject);
替换为
o.worldNormal = UnityObjectToWorldNormal(v.normal);
......
}
fixed4 frag(v2f i): SV_Target
{
......
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
可替换为
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
......
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
可替换为
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
......
}
使用Unity的内置函数处理,某些较为复杂的情况下就无需我们自行去考虑了。但是内置函数的处理没有进行归一化,因此我们还需要对其进行归一化处理。
总结
本章中我们学习了光照模型的原理以及如何在Unity Shader中实现光照模型。
以标准光照模型为例,光照模型的计算包含了环境光,自发光,高光反射,漫反射四个部分。其中环境光,自发光都是全局变量,我们需要关注的是实现漫反射和高光反射的光照模型。
光照渲染有逐顶点渲染和逐像素渲染两种方式,前者计算主体在顶点着色器上, 后者计算主体在片元着色器上。
从上述代码中,我们熟悉了一个光照渲染的过程:
1.获取并转换模型的顶点信息,摄像机信息,光源信息等等,以计算公式中的各种向量
2.通过标准化计算公式中涉及的向量方向
3.计算漫反射,高光反射等,通过累加所有光的颜色值实现最终的渲染
fixed和float的选择
在Shader中,fixed和float看起来似乎很类似,其实只是精度有不同,如何在定义变量的时候选择,个人总结出了一点经验:
在计算数值较大,较为复杂的向量的时候,我们通常选用float类型,总之float总是使用在向量上,或者我们定义的面板一些数值上。
而fixed往往可以用在颜色值,或者标准化后的标量上,这些变量的特点是数值范围小,基本在【0,1】之间