作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记11:高级光照A
原文发布时间:2020年3月14日
文章类型: 授权转载
求其上,得其中;求其中,得其下;求其下,必败。——《孙子兵法》
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新的版本:“2020.1.0.Alpha 25”
前面我们讲到了几种基础的光照模型,我们着重于光照的计算方式,也就是一旦我们知道了光照的算法是什么,剩下的可能就是去学习Unity是如何把参数给我们的。有了算法和参数,剩下的东西就好解决了。
前面的光照模型仅仅只能计算出平行光的光照,因为我们的设置了
Tags{"LightMode"="ForwardBase"}
如果把LightMode设为ForwardBase,那么Unity就会把平行光的光源信息填充到对应的变量中去。而使用点光源或者聚光灯照射物体时,是不会产生任何效果的。我们接下来要解决的两个问题是,理解ForwardBase和使用前向渲染路径来计算点光源的光照。
说的抽象一点,渲染路径决定了光照是如何应用到UnityShader中的,特别指明,重点是光照!只要是处理光照,我们就需要为某个pass块决定它的渲染路径,Unity有两种内置的渲染路径,一种是延迟渲染,一种是前向渲染。
简单的来说,它们的区别就类似于是画画一样
我们使用的,和要说的就是前向渲染,前向渲染的光照计算由一个Pass块来完成,我们在这个pass块中计算平行光或者其他光源,它的缺点是,每个光源都要被pass计算一遍才行。假设有一个物体,n个光源,就会计算n次,假设有m个物体,n个光源,就会计算m*n次,然而大部分时候,后一个光源可能会把前一个光源给覆盖了(由于光源强度问题),所以,很多时候如果场景中的光源过多,那么前向渲染就会做非常多根本不需要的工作,前向渲染路径里面不能使用过多的光源。否则性能就会极速下降。
参考下面的伪代码来认识什么是前向渲染
Pass{
for(each primitive in this model){
for(each fragment covered by this primitive){
if(failed in depth test){
//如果该片元没有通过深度测试,就舍弃
}else{
//如果该片元可见,就进行光照的计算
float4 color = Shading(materialInfo,pos,normal,lightDir,viewDir);
//写入帧缓存
writeFrameBuffer(fragment,color);
}
}
}
}
当我们渲染一个物体的时候,Unity会计算我们使用了什么样的光源照亮了它(在Shader中表现为定义了什么样的渲染路径和标签)以及这些光源照亮物体的方式(在Shader中表现为我们所使用的计算公式)
而Unity中所使用的前向渲染路径,则分为两个Pass,一个叫做BasePass,一个叫做AdditionalPass,其中BasePass主要负责计算平行光,AddPass则负责计算其他的实时光源。
BasePass会为所有的物体计算一次平行光,而AddPass则为所有的物体,迭代所有的其他光源(主要就是平行光和点光源)计算一次光照。
考虑到前向渲染的性能问题,就有人想到了延迟渲染,其实延迟渲染相对于前向渲染是一种更为古老的渲染方式,它只会经过两个pass,第一个pass计算哪些片元是可见的,如果一个片元是可见的,就把它储存到深度缓冲当中。然而在第二个pass当中,我们利用G缓冲区的各个片源信息来计算真正的光照。简而言之,在延迟渲染中,有m个物体和n个光源,计算复杂度可能也只有O(n+m)。
我们可以通过下面的伪代码来认识一下延迟渲染
Pass1{
//延迟渲染的第一个Pass不用于计算光照,而是收集所有的深度信息,法线信息等
//在第二个Pass中进行实际的光照计算,因而也被称为延迟渲染。
for(each primitive in this model){
for(each fragment covered by this primitve){
if(failed in depth test){
//如果没有通过深度测试,说明该片元是不可见的
discard;
}else{
writeGBuffer(materialInfo,pos,normal,lightDir,viewDir);
//如果该片元可见,就把需要的信息存储到G缓冲中去
}
}
}
}
Pass2{
for(each pixel in the screen){
if(the pixel is valid){
//如果该像素有效,那么就读取它对应的G缓冲中的信息
readGBuffer(pixel,materialInfo,pos,normal,lightDir,viewDir);
//在此处计算光照
float4 color = Shading(materialInfo,pos,normal,lightDir,viewDir);
writeFrameBuffer(); //更新帧缓存
}
}
}
但是延迟渲染也有自己的不可避免的缺点。有些缺点可能是致命的。这些缺点都是来自于延迟渲染路径处理光照的方式。
所以我们可以根据自己的需求来选择使用前向渲染还是延迟渲染。不过我们这里仅涉及到前向渲染如何使用。LightMode有很多可选的值,这里我们只需要知道ForwardBase和ForwardAdd就可以了。
关于前向渲染,有下面一些特别要补充的点
于是我在2019.3版本的文档中搜索了关于这个部分,找到了一些比较模糊的描述。
也就是下面这段文字
Often it is convenient to keep most of a piece of shader
code fixed but also allow slightly different shader “variants” to be produced. This is commonly called “mega shaders” or “uber shaders”, and is achieved by compiling the shader code multiple times with different preprocessor directives for each case.To achieve this in Unity, you can add a
#pragma multi_compile
or#pragma shader_feature
directive to a shader snippet. This also works in surface shaders
上面这段文字的意思概括来说就是,即使保持着色器代码的固定非常便利,但是有的时候我们也希望能够生成着色器的“变体”,也就是“超级着色器”,我们主要通过对同一段着色器代码使用不同的预处理器指令编译多次来实现这种Shader。
再次概括,说白了,它是一种控制Unity底层变量如何赋值的一种编译指令,它的作用和Tags标签类似,只不过暂时不明白Unity的底层是如何对它进行控制的,也不明白它和Tags的区别是什么。后面可能会专门针对这个部分学习一下再出一篇笔记。
我们之前,经常说到,设置LightMode为ForwardBase,才可以使得很多变量被正确的赋值,比如光照强度,颜色等。在下表中,我们可以看到更多的,可以访问的参数。
Unity一共支持4种类型的光源,分别是平行光,点光源,聚光灯,区域光,其中区域光仅仅是在烘焙的情况下有效。因而不是本节讨论的范围。
平行光是一种没有位置的光,因为它模拟的是太阳,所以它只有方向。(当然还有颜色和强度),平行光没有光照衰减,因为它模拟的是太阳!!(或者月亮)
用的最多的一种光源,它呈球体往四周散射,半径可以通过Light面板的Range来调整。它是有光照衰减的,当模型与光源的距离变远了,光照强度就会变低。
和点光源差不多,只不过它是一束投射出去的锥形光,如果不太形象的话,你直接当成手电筒来理解就完事了。
光源有五个属性,分别是位置,方向,颜色,强度以及衰减。我们接下来要实现一个,既能被平行光照亮,也可以被点光源照亮的Shader。
Shader "UShaderMagicBook/StdLight1"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
_Gloss("Gloss",Range(8.0,200.0)) = 20.0
}
SubShader{
Tags{"Queue"="Geometry"}
Pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#include "Lighting.cginc"
#include "UnityCG.cginc"
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdbase //一定要有这个编译指令
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};
sampler2D _MainTex;
fixed4 _BaseColor;
float _Gloss;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld,v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.uv = v.texcoord;
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
//BasePass和之前计算光照没有差别
fixed3 albedo = tex2D(_MainTex,i.uv).xyz * _BaseColor.xyz;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
//环境光只需要计算一次就行了
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(lightDir,worldNormal));
fixed3 halfDir = normalize(viewDir + lightDir);
fixed3 specular = _LightColor0.xyz * pow(saturate(dot(halfDir,worldNormal)),_Gloss);
fixed atten = 1.0;
//由于平行光没有光照衰减,所以光照衰减为1
return fixed4(ambient + (specular + diffuse) * atten,1.0);
//计算光照衰减
}
ENDCG
}
Pass{
Tags{"LightMode"="ForwardAdd"}
Blend One One
//一定要开启混合,设置线性混合,不然其他光源的光不能附加
CGPROGRAM
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
#pragma vertex Vertex1
#pragma fragment Pixel1
#pragma multi_compile_fwdadd //一定要有这个编译指令
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct vertexOutput{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};
sampler2D _MainTex;
fixed4 _BaseColor;
float _Gloss;
vertexOutput Vertex1(vertexInput v){
//顶点着色器依旧不变,因为这里和模型的外形没有啥关系
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld,v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.uv = v.texcoord;
return o;
}
fixed4 Pixel1(vertexOutput i):SV_TARGET{
fixed3 worldNormal = normalize(i.worldNormal);
//第一个不同的可能就是光照的方向,因为平行光的光照方向可以直接用位置代替,然而
//其他光源不行。
#ifdef USING_DIRECTIONAL_LIGHT
//这个宏指令的意思是如果照射该物体的光源是一个平行光
fixed3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
#else
//如果不是平行光,那么光照方向为光源位置减模型位置
fixed3 lightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#endif
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
//视角方向是不变的
//BasePass和之前计算光照没有差别
fixed3 albedo = tex2D(_MainTex,i.uv).xyz * _BaseColor.xyz;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(lightDir,worldNormal));
fixed3 halfDir = normalize(viewDir + lightDir);
fixed3 specular = _LightColor0.xyz * pow(saturate(dot(halfDir,worldNormal)),_Gloss);
#ifdef USING_DIRECTIONAL_LIGHT
//这个宏指令的意思是如果照射该物体的光源是一个平行光
fixed atten = 1.0; // 平行光没有光照衰减
#else
//如果不是平行光,光照衰减可以用下面这种方式计算出来。
#if defined(POINT)
//如果是点光源
float3 lightCoord = mul(unity_WorldToLight,float4(i.worldPos,1)).xyz;
fixed atten = tex2D(_LightTexture0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
#elif defined(SPOT)
//如果是聚光灯
float4 lightCoord = mul(unity_WorldToLight,float4(i.worldPos,1));
fixed atten=(lightCoord.z>0)*tex2D(_LightTexture0,lightCoord.xy/lightCoord.w+0.5).w*tex2D(_LightTextureB0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
#endif
#endif
return fixed4((specular + diffuse) * atten,1.0);
//计算光照衰减
}
ENDCG
}
}
}
上面的代码,在计算点光源和聚光灯的光照衰减的部分有点小复杂,不过正好大家熟悉一下代码,在笔记12中,我们来会讲到其中的原理。最终效果如下图所示。
可以看到,现在模型可以同时接受多种光照的照射。包括点光源,聚光灯,平行光。
这个章节说老实话,还是有点困难的,大家可以要在这个部分多磨一段时间。当然这和光照算法没有什么关系,而是和Unity的内置管线有关系。另外,本次使用到的所有代码都已经上传到了我的github,在下面的链接中可以找到。如果你喜欢这个系列的文章,欢迎关注该专栏,或者给我的github贡献一颗⭐,感谢阅读。