光照和反射是我们看到颜色的基础,一切效果从反射开始,这里我们整理了UnityShader入门精要光照方面一些个人认为比较重要的知识点。
完整的工程会上传到个人代码仓库(链接),与书籍代码类似,但是包含了大量的个人中文注释(不是照搬书上的解释)和一些理解,看起来会比书上更友好。
目录
基础光照效果
标准光照模型
标准漫反射
兰伯特定律与半兰伯特定律
漫反射实际代码与效果
标准高光反射
Phong模型
Blinn模型
高光反射效果与关键代码
使用片元还是使用顶点来实现基础光照?
高级光照效果
前向渲染流程与原理
前向渲染如何选择逐像素与逐顶点
前向渲染的Pass通道
延迟渲染流程与原理
延迟渲染存在的问题
光照衰减
光照阴影
阴影是如何实现的?
屏幕空间的阴影映射技术
不透明物体的阴影
透明物体的阴影
基础光照效果
标准光照模型
首先标准光照模型会把进入摄像机内的光线分为四个部分。
- 自发光:书中使用Cmissive来表示。Cmissive用于描述,当给定一个方向时,一个表面本身会向该方向发射多少“辐射量”。需要注意的是,如果没有使用全局光照技术,这些自发光的表面并不会真的照亮周围的物体,而是它本身看起来更亮了而已。PS:要注意自发光是物体本身发出的光颜色,一般直接与反射结果颜色相乘以混合颜色。
PS:辐射量是光的量化单位,即,单位时间内穿过单位面积的光的能量多少。如果不好理解,可以暂时简单理解为,光的强度+颜色,更契合后续的学习内容。
- 高光反射:书中使用Cspecular来表示,Cspecular用于描述当光线从光源照射到模型表面时,该表面会在“完全镜面反射”方向,散射多少“辐射量”。
- 漫反射:书中使用Cdiffuse来表示。Cdiffuse用于描述,当光线从光源照射到模型表面时,该表面会向“每个方向散射”多少辐射量。
- 环境光:书中使用Cambient来表示。它用于描述其他所有的间接光照。PS:要注意是间接光照,该颜色一般直接与光照结果颜色相加。
标准漫反射
首先提醒,漫反射中,视角的位置是不重要的,因为反射是完全随机的,因此可以认为在任何反射方向上的分布是随机的。但是入射光线的角度很重要。
兰伯特定律与半兰伯特定律
首先是兰伯特定律,漫反射符合兰伯特定律:反射光线的强度与表面法线和光源方向之间的夹角的余弦成正比。
然后Valve公司后续改进了该公式,被称为半兰伯特定律(实际上半兰伯特定律更常用),该公式没有使用max操作来方式n*l点积为负值,而是对结果进行了先缩放α倍,然后再加上β的偏移(PS:一般情况下,α和β都是0.5)。虽然半兰伯特定律没有任何物理依据,但是大部分情况下往往实现的效果更理想。
半兰伯特定律的一般公式表示
;更常用的0.5常用系数公式
。α和β都是0.5,刚好可以将n*l的范围[-1,1]映射到[0,1],这样背面就不会有纯黑的阴影,而是会有明显的明暗变化。
漫反射实际代码与效果
PS:由于片元着色器的实现的效果更好,这里尽量只贴出片元着色器中编写的部分关键反射代码。TIP:完整代码看文章头部的链接。
效果展示
兰伯特定律-片元
//顶点着色器代码
v2f vert(a2v v){
v2f o;
//将顶点坐标转换到裁剪空间
o.pos = UnityObjectToClipPos(v.vertex);
//获取顶点的法线方向,变换到世界坐标
// Tip 使用顶点变换的逆转置矩阵来对法线进行变换,逆矩阵 _World2Object,转置 mul中调换参数位置
// Tip 截取了unity_WorldToObject前3*3矩阵,因为法线只有方向
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 = ambient + diffuse;
return fixed4(color,1.0);
}
半兰伯特定律-顶点
//顶点着色器代码
v2f vert(a2v v){
v2f o;
//将顶点坐标转换到裁剪空间
o.pos = UnityObjectToClipPos(v.vertex);
//获取环境光设置
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
//获取顶点的法线方向,变换到世界坐标后,再归一化
// Tip 使用顶点变换的逆转置矩阵来对法线进行变换,逆矩阵 _World2Object,转置 mul中调换参数位置
// Tip 截取了unity_WorldToObject前3*3矩阵,因为法线只有方向
fixed3 worldNormal = normalize(mul(v.normal,(float3x3)unity_WorldToObject));
// 获取光源的方向 //假设场景中只有单个平行光
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
//半兰伯特光照公式
fixed3 halfLambert = dot(worldNormal,worldLightDir) * 0.5 + 0.5;
// 通过漫反射公式计算出,漫反射光颜色
fixed3 diffuse = _LightColor0.rgb *_Diffuse.rgb * halfLambert;
//环境光+漫反射光 得最终颜色
o.color = ambient + diffuse;
return o;
}
//最后直接使用片元输出颜色
fixed4 frag(v2f i):SV_Target{
return fixed4(i.color,1.0);
}
标准高光反射
首先明确一点,高光反射模型是一个经验模型。而我们常用的有Phong模型,和Blinn模型。 (书上更倾向于使用Blinn模型)
经验模型:通过经验积累试验出来的问题比较优秀的解,但是该解无理论依据。也许前辈们试验了很多公式,最终觉得某个效果比较好,慢慢推广,裨益整个行业。渲染原则之一:看起来是对的,那他就是对的!
Phong模型
首先上示意图,图中n是法线方向,v是视角方向,i是光源方向,r是反射方向。而v方向看到的颜色,就是我们要通过高光模型得到的解。
由于是经验公式,没有什么物理依据,所以直接放出各个方向对应的关系公式和公式对应的解析。
Blinn模型
Blinn模型是对Phong模型的一个简单且有效的优化方案。它的基本思想是,避免计算反射方向r。为此它引入了一个新的矢量h,h相较于r计算成本更低,而且摄像机和光源距离足够远的情况下,硬件会认为v和I是恒定值,此时h将会是个常量,不用再反复计算。
PS:实际编写后看到的效果,Blinn比Phong生成的光斑更大一些。书上有提及,这不意味着Blinn的效果比Phong模型效果更差,甚至有些情况下,Blinn模型效果更符合试验结果。所以建议使用Blinn模型。
接下来是公式,公式与Phong模型的公式十分类似,只是将r替换了h。
高光反射效果与关键代码
phong高光-片元
v2f vert (a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
//这里要再次注意法向量的转换矩阵,是逆矩阵的结果的逆
o.worldNormal = mul(v.normal,(float3x3)unity_WorldToObject);
o.worldPos = normalize(mul(unity_ObjectToWorld,v.vertex));
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));
//获取反射方向,用于计算高光反射 //反射方向等于 l - 2(n*l)n 而reflect 填入的是入射方向也就是l的反方向,可以直接返回反射方向
fixed3 reflectDir = normalize(reflect(-worldLightDir,worldNormal));
//获取相机观察方向 //相机的位置 - 顶点的世界坐标位置 = 顶点位置指向相机的方向,即观察方向
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
//计算高光反射公式 //(Clight*Mspaceular)*pow(max(0,v*r),Mgloss) ;Mgloss越大,高光点越小
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate( dot(reflectDir,viewDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
blinn高光-片元
v2f vert (a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
//这里要再次注意法向量的转换矩阵,是逆矩阵的结果的逆
o.worldNormal = mul(v.normal,(float3x3)unity_WorldToObject);
o.worldPos = normalize(mul(unity_ObjectToWorld,v.vertex));
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 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
//blinm算法引入的h向量
fixed3 halfDir = normalize(worldLightDir+viewDir);
//计算高光反射公式 //(Clight*Mspaceular)*pow(max(0,v*r),Mgloss) ;Mgloss越大,高光点越小
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate( dot(worldNormal,halfDir)),_Gloss);
return fixed4(ambient + diffuse + specular,1.0);
}
使用片元还是使用顶点来实现基础光照?
这里会导向另外一篇文章。TODO
高级光照效果
常用的有前向,延迟,顶点照明(逐渐淘汰),三种光照渲染流程。
前向渲染流程与原理
每进行一次完整的前向渲染,我们需要渲染该对象的渲染图元,并计算两个缓冲区的信息:一个是颜色缓冲区,一个是深度缓冲区。我们利用深度缓冲来决定一个片元是否可见,如果可见就更新颜色缓冲区中的颜色值。对于每个逐像素光源,我们都需要进行上面一次完整的渲染流程。如果一个物体在多个逐像素光源的影响区域内,那么该物体就需要执行多个Pass, 每个Pass计算一个逐像素光源的光照结果,然后在帧缓冲中把这些光照结果混合起来得到最终的颜色值。这也就是意味着,渲染物体越多,或者每个物体受影响的逐像素光源越多,性能消耗越多。所以引擎通常会限制每个物体的逐像素光照数目。
前向渲染如何选择逐像素与逐顶点
Unity前向渲染路径中,有3种处理光照。逐顶点处理,逐像素处理,球谐函数(SH)处理。而决定一个光源使用哪种处理模式取决于它的类型和渲染模式。光源类型指的是该光源是平行光还是其他类型的光源,而光源的渲染模式指的是该光源是否是重要的(Important)。
然后Unity会对场景中的光源做一定的自动选择:
先排序:当我们渲染一个物体时,Unity 会根据场景中各个光源的设置以及这些光源对物体的影响程度(例如,距离该物体的远近、光源强度等)对这些光源进行个重要度排序。其中,一定数目的光源会按逐像素的方式处理,然后最多有4个光源按逐顶点的方式处理,剩下的光源可以按SH方式处理。Unity 使用的判断规则如下。
场景中最亮的平行光,一定是逐像素的。
- 场景中最亮的平行光总是按逐像素处理的。
- 渲染模式被设置成Not Important的光源,会按逐顶点或者SH处理。
- 渲染模式被设置成Important的光源,会按逐像素处理。
- 如果根据以上规则得到的逐像素光源数量小于Quality Setting 中的逐像素光源数量(PixelLight Count),会有更多的光源以逐像素的方式进行渲染。
前向渲染的Pass通道
前向渲染有两种Pass: BasePass和Additional Pass。通常来说,这两种Pass进行的标签和渲染设置以及常规光照计算如图。
简单的来说,渲染一个物体时每有一个逐像素光源将会执行一次Additional Pass。而BasePass只会在统一渲染逐像素平行光和其他光源时执行一次。
一些其他要注意的点:
- 要正确的设置#pragma multi_ compile_ fwdbase 和#pragmamulti_ compile_ fwdadd编译指令,否则将可能造成编译异常。(比如丢失某些默认的光照渲染变量)
- Base Pass旁边的注释给出了Base Pass中支持光照纹理,环境光,自发光,阴影(平行光的阴影)
- 环境光和自发光也是在Base Pass中计算的,因为我们希望环境光和自发光只计算一次。
- Additional Pass默认是没有阴影的。但我们可以使用#pragma multi_ compile_fwdadd_ fullshadows开启。
- 一般情况下,在Additional Pass的渲染设置中,将开启和设置了Blend One One的混合模式,因为我们希望多次个“不重要光源”的渲染效果叠加。
- 一般情况下,只会存在一个BasePass和一个Additional Pass。
延迟渲染流程与原理
延迟渲染主要包含了两个Pass。在第一个Pass中,我们不进行任何光照计算,而是仅仅计算哪些片元是可见的,这主要是通过深度缓冲技术来实现,当发现一个片元是可见的,我们就把它的相关信息存储到G缓冲区中。然后,在第二个Pass中,我们利用G缓冲区的各个片元信息,例如表面法线、视角方向、漫反射系数等,进行真正的光照计算。
- 第一个Pass用于渲染G缓冲。在这个Pass中,我们会把物体的漫反射颜色、高光反射颜色、平滑度、法线、自发光和深度等信息渲染到屏幕空间的G缓冲区中。对于每个物体来说,这个Pass仅会执行一次。
- 第二个Pass用于计算真正的光照模型。这个Pass会使用上一个Pass中渲染的数据来计算最终的光照颜色,再存储到帧缓冲中。
因为这种机制,所以延迟渲染更适合多光源渲染和场景中物体较多时使用,更为节省性能。但是如果物体或光源较少,则不合适,因为延迟渲染毕竟至少有两个Pass。
延迟渲染存在的问题
- 不支持真正的抗锯齿(anti-aliasing) 功能。
- 不能处理 半透明物体。
- 对显卡有一定要求。2020年了这应该不再成为门槛了吧。
光照衰减
实际物理世界中,不太强的光照是会随着穿透介质的距离,不断地削弱。
Unity使用两种方式来模拟这种效果。
- 找到一个合适的渲染顶点的坐标与光源距离和光照衰减系数之间的公式,然后按照目标点的位置来判断光照强度。
- 提前生成衰减纹理作为查找表,然后在片元着色器中查表判断光照强度。这样做的好处是不用数学计算,节省计算量。但是缺点也比较明显
- 需要预处理得到采样纹理,而且纹理的大小也会影响衰减的精度。
- 不直观,同时也不方便,因此一旦把数据存储到查找表中,我们就无法使用其他数学公式来计算衰减。
光照阴影
阴影这一节,概念比较复杂,但是实现起来比较简单,因为代码基本都是使用内置的宏来实现的。
阴影是如何实现的?
在实时渲染中,我们最常使用的是一种名为Shadow Map的技术。这种技术理解起来非常简单,它会首先把摄像机的位置放在与光源重合的位置上,那么场景中该光源的阴影区域就是那些摄像机看不到的地方。
屏幕空间的阴影映射技术
太多了,主要是阴影映射纹理,阴影,阴影图,屏幕空间,深度值,这些词汇组合在一起,一下子不好接受,也不好简化。但是仔细读一下,弄清楚之后,还是蛮好理解的。
简单的来说,就是如果一个可以被相机看到,但是却大于在同一位置的阴影映射纹理中的深度值,就说明他能被看到,但是被其他物体阻挡了。就要在它上面生成阴影。
当使用了屏幂空间的阴影映射技术时,Unity 首先会通过调用LightMode为ShadowCaster的Pass来得到可投射阴影的光源的阴影映射纹理以及摄像机的深度纹理。然后,根据光源的阴影映射纹理和摄像机的深度纹理来得到屏幕空间的阴影图。如果摄像机的深度图中记录的表面深度大于转换到阴影映射纹理中的深度值,就说明该表面虽然是可见的,但是却处于该光源的阴影中。通过这样的方式,阴影图就包含了屏幕空间中所有有阴影的区域。如果我们想要一个物体接收来自其他物体的阴影,只需要在Shader中对阴影图进行采样。由于阴影图是屏幕空间下的,因此,我们首先需要把表面坐标从模型空间变换到屏幕空间中,然后使用这个坐标对阴影图进行采样即可。
- 如果我们想要一个物体接收来自其他物体的阴影,就必须在Shader中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后的光照结果相乘来产生阴影效果。
- 如果我们想要一个物体向其他物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其他物体在对阴影映射纹理采样时可以得到该物体的相关信息。在Unity中,这个过程是通过为该物体执行LightMode为ShadowCaster的Pass来实现的。如果使用了屏幕空间的投影映射技术,Unity 还会使用这个Pass产生张摄像机的深度纹理。
不透明物体的阴影
上面也说了,要出现阴影分为两个步骤,即计算遮挡物,和计算接受阴影物体。万幸的是,这些运算基本都Unity为我们做完了。大部分情况下,使用内置的宏即可。
- 计算遮挡物:一般情况下,只要物体包含Tag为ShowCaster的通道,或者FallBack中包含ShowCaster的通道既可以作为阴影的投射物。
- 计算接受阴影:一般情况下,我们使用三个内置的宏来完成该计算。SHADOW_ COORDS(声明阴影纹理的采样坐标)、TRANSFER SHADOW(计算上一步声明的采样坐标)和SHADOW_ ATTENUATION(计算具体的阴影值)。其实还有UNITY_LIGHT_ATTENUATION,它与SHADOW_ ATTENUATION类似,但是会额外生成一个阴影衰减变量。
- 需要注意的是,这三个宏对某些特定的变量有要求
接下来是代码
//省略代码........ 完整代码请看
/ /用于存入对阴影纹理采样的坐标
//注意 这里没有";"
SHADOW_COORDS(2)//如果上一个变量是TEXCOORD1,这里传入2,如果是TEXCOORD2,这里传入3
//省略代码........ 完整代码请看
//SHADOW_COORDS,TRANSFER_SHADOW,SHADOW_ATTENUATION三个宏组合,Unity帮我们做了所有阴影所需要的计算
//使用这些内置宏,需要a2v.vertex,v2f.pos,v等一些变量的命名必须要与现在保持统一,编写shader时最好遵从同样用途的变量使用一样的名字的习惯
fixed shadow = SHADOW_ATTENUATION(i);
//TODO 这里环境光计算一次之后,后面的AdditionalPass就不会在计算?
// 后续解释,不要在看书时有一些误解,AdditionalPass不要再写环境光的代码就好了
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
//计算漫反射公式 //注意这里Unity提供的_LightColor0已经是颜色和强度相乘后的结果
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal,worldLightDir));
//获取相机观察方向 //相机的位置 - 顶点的世界坐标位置 = 顶点位置指向相机的方向,即观察方向
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
//blinm算法引入的h向量
fixed3 halfDir = normalize(worldLightDir+viewDir);
//计算高光反射公式 //(Clight*Mspaceular)*pow(max(0,v*r),Mgloss) ;Mgloss越大,高光点越小
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate( dot(worldNormal,halfDir)),_Gloss);
//用内置的UNITY_LIGHT_ATTENUATION宏来帮我们统一完成衰减和阴影的计算
//PS:这个宏定义在AutoLight.cginc,包含了Unity帮我们处理的各种平台的各种光照条件的绝大部分情况
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return fixed4(ambient + (diffuse + specular)*atten*shadow,1.0);
//省略代码........ 完整代码请看
透明物体的阴影
透明物体的阴影与不透明的类似,主要是要裁减掉,透明部分的阴影,这里我们把Fallback替换成VertexLit,然后在透明度超过阈值的地方进行裁剪。
但需要注意的是,由于Transparent/Cutout/VertexLit中计算透明度测试时,使用了名为_ _Cutoff 的属性来进行透明度测试,因此,这要求我们的Shader 中也必须提供名为_ Cutoff 的属性。否则,同样无法得到正确的阴影结果。
AlphaTestShadow.shader
//省略代码........ 完整代码请看
fixed4 frag(v2f i):SV_Target{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed4 texColor = tex2D(_MainTex,i.uv);
// 如果不满足clip的条件,该片元的代码会在这里中断,或者说return
// 正是这里实现了,透明测试的效果,效果比较极端,要么不透明,要么完全透明
// clip(texColor.a - _Cutoff);
if (texColor.a - _Cutoff < 0.0){
discard;
}
fixed3 albedo = texColor.rgb *_Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0,dot(worldNormal,worldLightDir));
UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
return fixed4(ambient + diffuse,1.0);
}
ENDCG
}
}
//要注意,这里设置的Fallback与前面不同
Fallback "Transparent/Cutout/VertexLit"
//省略代码........ 完整代码请看