作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记12:高级光照B
原文发布时间:2020年3月16日
文章类型: 授权转载
生命,是自然付给人类去雕琢的宝石。——诺贝尔
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新的版本:“2020.1.0.Alpha 25”
在上一个章节中,我们讲了很多东西,主要是针对前向渲染路径的BasePass和AdditionalPass,那么在上个章节中,我们遗留了一个问题,也就是光照衰减是如何计算的。在本节中我们主要就是解决这个问题,另外,通过ShaderLab提供的一些机制来优化代码量。
简而言之,当光源离目标物体越远,它的对物体的光照影响就会越小,因而光照衰减是一个和距离有关的映射值,参数是距离,结果是衰减值。但是仔细观察我们在笔记11中的代码,在衰减计算这块,它似乎没有涉及到距离这个概念。而是一堆奇奇怪怪的代码。
既然光照衰减是和距离有关的,那么通过距离来计算肯定是可以的,只不过有什么理由导致了我们并没有采用这种计算方式。通过距离来计算的方式非常简单,就是一个反比例函数。
我们现在尝试使用这个计算方式来替换原来的代码。
float _distance = distance(_WorldSpaceLightPos0.xyz,i.worldPos); //先计算距离
fixed atten = 1.0/_distance; //通过反比例函数计算衰减
最终得到的结果和之前的光照无异
并且我们这次不用再使用编译指令来判断点光源还是聚光灯了。
说起来,这个代价还是挺严重的,尤其是对于聚光灯。这种距离的计算没有办法柔和的处理衰减,当物体离开照明范围的时候,它会产生一个突变,这是因为我们单纯的使用距离计算是无法得到聚光灯的朝向,张开角度等信息的。因此得到的效果往往不尽如人意。
下图指的就是所谓的“突变”
Unity为了更好的计算光照衰减,提供了一张纹理,用于查找光照衰减。这张纹理也可以称为衰减查找表
Look Up Table,简称为LUT,是一种和字典差不多的玩意,它把所有可能的结果都预先计算出来,后面直接从LUT中搜索我们要计算的结果,就可以了。举一个引擎开发中的案例,在引擎开发中肯定涉及到数学库,数学库中必然有三角学函数,其实三角学函数是很难计算的,只能通过泰勒公式去近似。计算难度比普通函数要大,为了方便的解决这个问题,我们可以事先计算好一张查找表,以提高三角学函数的计算性能。
#include <iostream>
using namespace std;
int main(int argc, char** argv) {
//entry point
float FastSin[360] = { 0 };
for (int i = 0; i < 360; i++) {
/* 计算正弦查找表,提高正弦计算速度 */
FastSin[i] = sin(i);
}
}
其实使用衰减查找表也有自身的缺陷,主要是下面两点
但是这种方法可以提升性能,并且大部分的时间工作都是良好的。因此这是默认的计算衰减的方式。
Unity在内部使用一张叫做_LightTexture0的纹理来计算光源的衰减,需要注意的是,如果我们对光源使用了cookie(相当于一张阴影贴纸),那么衰减查找纹理就成了_LightTextureB0,但这不是重点。在这张纹理上,(0,0)点表明了与光源位置重合点的衰减,(就是最近点),(1,1)表明了光源涉及到的范围的最远点的衰减。
其实比起使用纹理查找表,我其实更加好奇纹理查找表是什么样的一张纹理。为了知道_LightTexture0的庐山真面,我写了一个简单的Shader直接输出_LightTexture0的值,这样可以把他贴到一个四边面上。
代码我就不贴出来了,可以文章末尾处的github传送门里找到。下面就是_LightTexture0原始的样子,它其实和渐变纹理有点像,但是它的数值主要是用来控制衰减的。(ps:由于这个纹理仅仅当周围有点光源的时候才会定义,所以想要看见它,要在四边面附近定义一个点光源)
由于我们并没有输出UNITY_ATTEN_CHANNEL,如果输出的是衰减通道,那么它的边缘将会是白色而非红色的。我们可以看见,坐标越靠近(0,0)就越亮,而越靠近(1,1)就越暗,我们就是在(0,0)到(1,1)这条对角线上进行纹理采样。
有了上面的基础之后,我们需要解析的就是针对之前计算点光源和聚光灯的代码了。先贴出来。
#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
先来看比较简单的这个吧,聚光灯那个看起来有点不太好惹。
书上说,我们一般关注的是对角线的上的数据,但是呢,其实这个纹理的纵轴上是没有颜色变化的,只有横轴的变化,所以我们采样横轴数据和采样对角线数据其实都是差不多的,但是由于对角线比横轴要长,所以理论上,对对角线进行采样的话,我们会得到一个更加平滑的过渡。这是第一个要了解的。
然后呢,衰减是针对什么呢?肯定是针对模型距离光源的距离了,其实如果只有一个模型和一个光源的话,那么我们以模型为中心,计算到光源的距离本质上是可以的,但是问题就是,一般来说肯定不止一个物体,所以,我们要以光源为中心来计算距离。
以光源为中心?
这句话带出了一个新的空间,那就是光源空间,一个以光源的位置为原点的坐标空间,为了计算模型坐标在光源空间中的位置,我们需要把模型的世界坐标转到这个光源空间。通过一个叫做_LightMatrix0的矩阵,不过该矩阵在目前版本的Unity中被改成了unity_WorldToLight,是不是非常的直观。
所以我们的第一行代码,都是计算出模型的世界坐标在光源空间中的位置。
float3 lightCoord = mul(unity_WorldToLight,float4(i.worldPos,1)).xyz;
//计算出模型的坐标在世界坐标中的位置。
有了坐标之后,我们就需要计算模型到光源的距离,我们先观察一下点光源的采样代码
fixed atten = tex2D(_LightTexture0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
这里采样对象是_LightTexture0没得说,采样的坐标是rr(也就是对角线坐标x=y)没得说,UNITY_ATTEN_CHANNEL一般是r通道,但是这里用了这个宏是因为在不同的平台上,处理可能会有不同,所以写了一个宏针对不同的平台来编译。至于中间的这个dot(lightCoord,lightCoord),其实就是计算到光源中心的距离啦。它其实是一个平方,我们可以先看一下距离计算公式。
我们先设光源空间的坐标为pos,(就是上面的lightCoord)
但是这里没有开放哦,emmm,为什么不开放呢?理由很简单其实,因为我们要的这个距离其实就是一个反应强度的参数,因而这个强度的缩放是不产生影响的。
说白了,就像0-255可以表示颜色分量,0-1也可以表示颜色分量一样,这个缩放在这种情况下是不产生任何影响的。
ok~,这样,点光源的衰减就说明白啦,为什么点光源如此简单?因为它的光线呈球体散射,因而在这个球体范围内的衰减只和到球心的距离有关。
但是聚光灯就不一样了,聚光灯是一个椎体,椎体首先有一个方向,其次就是有一个张开角度。由于原书没有讲到这个部分,所以难度很大很大。
注意哦,理解聚光灯的光照衰减可能是目前这个系列的文章遇到的最难的问题了。
我们先来调整一下场景中的结构,删除所有的东西,只留下一盏聚光灯和一个平面。
这个平面所使用的代码还是笔记11中的着色器,这个时候是正常照明的。
我们先来看一下聚光灯的衰减的计算代码
fixed atten = (lightCoord.z > 0) * tex2D(_LightTexture0,lightCoord.xy/lightCoord.w + 0.5).w *
tex2D(_LightTextureB0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
大概是三部分,我们先看一下我们已经知道的部分,也就是第三部分。和点光源一模一样,唯一不一样的是我们不再是对_LightTexture0采样,而是对_LightTextureB0采样。
这个我们其实可以打开我们的帧调试器看一下
在我们绘制这个平面的时候,发现,_LightTextureB0变成了我们之前所看到的衰减纹理,而原本应该是衰减纹理的_LightTexture0被Unity填充了一个圆形的纹理,它是什么呢?
光照Cookie是光源的一个参数,它是一个可以替换的图片,用于表现出光照的投射的阴影,比如如果我们要实现树荫下的光照效果,就可以用一张全是洞洞的图来作为Cookie。
有光的地方设置为白色,值为1,光照是有效的,而没有光的地方设为黑色,值为0,光照是无效的。我们可以把聚光灯的cookie更换一下,
会发现帧调试器中的_LightTexture0也发生了变化。
而实际的灯光效果就成了下面这样。
似乎和想象的有点不太一样,难道不应该是只有一个光圈吗?不过我们暂时知道了一件事,那就是在聚光灯类型的光源下,_LightTexture0已经变成了光照Cookie,而_LightTextureB0才是真正的衰减纹理。
我们之前在点光源的衰减计算中,没有太大的难度,单纯就是计算衰减,而聚光灯和点光源最大的不同其实就是形状的不同,它是一个圆锥体光源,而不是一个球体。那我们接下来就要研究一下这个所谓的椎体光源。
在聚光灯的衰减计算公式中,还有两个部分我们没有涉及到,即下面的公式
lightCoord.z > 0
tex2D(_LightTexture0,lightCoord.xy/lightCoord.w+0.5).w
我们先来看第二个,因为我们已经知道了_LightTexture0是光照Cookie,它用lightCoord.xy除以分量w,如果要想要理解这个做法有什么含义,我们可以尝试修改w分量的值,来观察一下会产生什么结果,我们复制笔记11中的代码,(也就是标准光照着色器)
然后增加一个输入,该输入是一个Float,该输入会控制w分量的变化。这个分量应该是一个正实数。
然后我们把这个输入乘到w分量上去,这样就可以控制w分量的缩放了。
当我们调整w分量的值的时候,会发现这张图正在进行缩放。它很像什么呢?就非常类似于我们之前所用的对主纹理进行uv缩放的计算,“乘一个缩放,加一个偏移”,但是我们这里的偏移加的是一个常量0.5,所以为什么是0.5呢?
仔细想想就会明白,对于一个cookie来说,我们肯定希望它在中心开始采样,而不是左下角。
我们唯一遗留的问题应该是,为什么偏偏原始的Cookie除以了w分量之后,就正好是一个圆圈呢,而不是多个圈圈,至于这个问题,可以知道的是,lightCoord.w这个值肯定和内置的Cookie大小有关。还有,我们缩放主纹理的时候,有宽和高两个部分,为什么到这里就只有一个分量了呢?
第二个问题很好回答,因为我们设置聚光灯的Cookie的时候,要求必须是一张正方形的图,即宽和高相等。至于第一个问题,这个我暂时没有找到答案,不过我们可以通过增加一个输入来控制。
至于lightCoord.z>0,我们可以尝试把0改成0.05,然后观察调整高度观察结果。
可以发现,z代表的其实是目标对象相对于聚光灯照射面的距离,当lightCoord.z>1的时候,只有当目标对象到聚光灯的距离超过1,才会有光照。但是正常来说肯定是>0啦。
到此为止,我们就已经把聚光灯的衰减计算的秘密全部解开了。但是我们要说一句,实际呢,Unity封装好了一个宏,把这些代码全部包含进去了,我们甚至不需要用
#if define(SPOT)
这样的编译指令就可以获得所有的结果了。(不会有人想看完上面一大堆之后打我吧,狗头~)
但是我还是会去研究的和分享的,因为我们希望了解的不是API,而是怎么解决一个问题,Unity提供了工具就去使用,那么没有提供呢?就宕机吗?学习是一个多维度的活动,我们需要学习的不仅是知识,更是要学习如何学习。
最后呢,我们要讲到一个新的知识,就是CGINCLUDE,我们可以把我们的一些计算法则全部写到一个cginc文件中,然后用include指令来获取,不过这是后面要讲的,今天我们先来认识一下CGINCLUDE块。
CGINCLUDE是一个针对单个Shader文件的块,在CGINCLUDE中定义的函数可以在所有的pass中使用。正好我们可以用在这个地方,因为其实我们既然已经有了
USING_DIRECTIONAL_LIGHT
这个宏,为什么还要分开写呢?basePass和addPass中的代码几乎相差无几。所以我们可以重新调整Shader的结构。
Shader"Custom/MyShader"{
Properties{
//input here
}
CGINCLUDE
//计算平行光或者其他光源
ENDCG
pass{
//base pass
}
pass{
//add pass
}
}
我们所有的计算代码都可以塞到CGINCLUDE中,然后在下面的pass中,我们只需要引用一下函数就完事了。
Shader "UShaderMagicBook/StdLight_cginclude"{
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
}
CGINCLUDE
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
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;
float4 _WorldPos;
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);
//第一个不同的可能就是光照的方向,因为平行光的光照方向可以直接用位置代替,然而
//其他光源不行。
#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 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);
#ifdef USING_DIRECTIONAL_LIGHT
//这个宏指令的意思是如果照射该物体的光源是一个平行光
fixed atten = 1.0; // 平行光没有光照衰减
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
#else
//如果不是平行光,光照衰减可以用下面这种方式计算出来。
fixed3 ambient = 0;
#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,fixed4(i.worldPos,1));
float atten = (lightCoord.z > 0.05) * tex2D(_LightTexture0, lightCoord.xy/(lightCoord.w) + 0.5).w * tex2D(_LightTextureB0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
#endif
#endif
// UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
return fixed4(ambient + (specular + diffuse) * atten,1.0);
//计算光照衰减
}
ENDCG
SubShader{
Pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdbase
ENDCG
}
Pass{
Tags{"LightMode"="ForwardAdd"}
Cull Off
Blend One One
//一定要开启混合,设置线性混合,不然其他光源的光不能附加
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#pragma multi_compile_fwdadd
ENDCG
}
}
}
ok~这就是本节所有的内容了,本次使用到的所有代码都已经上传到了我的github,在下面的链接中可以找到。如果你喜欢这个系列的文章,欢迎关注该专栏,或者给我的github贡献一颗⭐,感谢阅读。