目录
[TOC]
写在前面
本文将在上一篇单一光源光照中的PBS光照模型的基础上增加对多光源的支持(使用前向渲染)。全篇基于Unity2018.3.2。
在PC前的各位读者,建议下载MD文件阅读,观感更好。若有图片无法显示,请开启网络。欢迎转载,只需注明转载来源地址即可。MD文件及工程Package下载地址(提取码:4c1s)
创建自己的Shader包含文件
为了给我们的着色器增加多光源支持,我们需要增加多个Pass通道。新增的Pass通道包含了几乎相同的代码。为了减少重复代码,我们将上一篇的代码做成自己的Shader包含文件(Shader Include File)。
首先,复制上一篇的基于PBS的光照Shader;然后变更文件后缀名为”.cginc“;然后打开变更好后缀的cginc文件,将#include "UnityPBSLighting.cginc"
之前的代码全部删除,将从”ENDCG”开始的代码全部删除;最后在开头和结尾加上:
#if !defined(MY_LIGHTING_INCLUDED)
#define MY_LIGHTING_INCLUDED
......
#endif
开头加上的宏可以保证我们的自定义包含文件不会被其他Shader重复Include。避免重复引入造成的变量重复命名的编译错误。
多光源
我们将会使用前向渲染来实现多光源支持。
1.创建文件夹结构:
使用HTUtility工具(Shift+Alt+G)创建合适的文件夹结构,重命名Root文件夹为”2_多光源”。在场景中创建”Plane”,Reset到原点位置并旋转使其正对摄像机。创建Material,命名为”MultipleLightingMat”;创建Unlit Shader,命名为”MultipleLighting”。选择”MultipleLightingMat”的Shader为”MultipleLighting”,并将材质赋值给场景中的”Plane”。
2.光源类型:
平行光、点光、聚光灯,面光源等可见Unity手册介绍。
3.前向渲染路径:
(1)概述
前向渲染一般包含有两个Pass通道,一个是”ForwardBase”Pass通道,一个是”ForwardAdd”Pass通道。
(2)相关设置
在Unity中对于光照的计算方式有三种,分别为逐像素[^逐像素是?]、逐顶点[^逐顶点是?]、球谐函数(SH)。
逐像素:
逐像素计算光照是效果最好的,但性能消耗也最高,所以Unity限制了可使用逐像素计算光照的光源最大数量。
逐像素计算光照效果:
具体设置在”Edit–Project Settings–Quality–Rendering“中的”Pixel Light Count“。
可设置范围:[0,4]。
即使设置为0,也不会影响逐像素计算主光(最重要的平行光[^最重要的平行光是?]),主光的计算默认为逐像素,不会占用PiexlLightCount个数。
逐顶点:
逐顶点计算出来的效果一般,不够平滑,因为其计算是在各个顶点之间插值计算得到的结果,而顶点数较少,所以插值结果不够平滑,但也因此拥有较好的性能。
逐顶点计算光照效果:
当场景中的光源数量大于PiexlLightCount个数时,会采用逐顶点方式计算光照。同样的逐顶点计算也有限制个数。Unity默认的限制个数为4个,而且无法设置变更。
逐顶点相对于逐像素的缺点在于:不支持光照剪影和法线贴图。
球面谐波函数(SH):
简称球谐函数(SH),球谐函数光源的渲染速度很快。当场景中的光源数量超过逐像素个逐顶点的最大个数之和时,超出的光源部分会使用球谐函数计算。
这些光源的CPU成本很低,并且使用GPU成本几乎为零。球谐函数会在”ForwardBase”中常驻计算,且成本不变。
球谐函数也是逐顶点计算的,所以同样拥有逐顶点的缺点:不支持光照剪影和法线贴图。除此之外,球谐函数光照只影响漫反射光照(diffuse),而且球谐函数光照频率很低,无法实现快速光照过渡。最后,球谐函数光照不属于局部光照,所以当点光源和聚光灯以SH方式计算的时候,如果靠近物体表面,是不会有正确的光照显示的,看起来就像没有光照一样,因为SH光照被计算到了间接光照中。
(3)前向渲染的Pass通道
“ForwardBase”基础通道:
在”ForwardBase”中会以逐像素的方式计算主光,除此之外还会以逐顶点的方式计算最多4个次级光,以及会以逐顶点方式使用球谐函数(SH)计算额外的光照。
“ForwardAdd”额外通道:
在”ForwardAdd”中只会计算逐像素光照,而且每个逐像素光照的计算都会使用一个”ForwardAdd”通道。
也就是说,当场景中存在n个物体,以及m个逐像素光源时(m<=4,且不包含主光),此时光源计算的Batches=n+n*m;其中n为”ForwardBase”通道,n * m为”ForwardAdd”通道。
增加逐像素光数量会成倍增加Batches,而Batches过多会在CPU处产生性能瓶颈。所以在移动端逐像素光要慎用。
更多关于前向渲染的信息可以查看Unity官方手册。
4.Shader编写:
(1)准备工作
由于我们自定义了自己的光照包含文件,所以大部分光照代码在光照包含文件中写即可,在Shader中只需要对Pass通道进行相关设置即可。
打开Shader文件”MultipleLighting”:
先删除所有和雾相关的代码,在这里我们不需要雾。
将之前的属性块中的代码完全迁移过来:
Properties
{
_Tint("Tint",COLOR) = (1,1,1,1)
_MainTex ("Albedo", 2D) = "white" {}
//告诉Unity,金属值也需要进行Gamma校正
[Gamma]_Metallic("Metallic",Range(0,1)) = 0//金属般的
_Smoothness("Smoothness",Range(0,1)) = 0.1//物体表面平滑程度,越平滑越容易聚焦光线、光点越小
}
将之前的”SingleLighting_MetallicWorkflow”Shader中的Pass通道的代码完全迁移过来,然后删除Pass通道中从#include开始直到片元函数结束的部分。
Pass
{
Tags{"LightMode"="ForwardBase"}//前向渲染
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//使用PBS
#pragma target 3.0
ENDCG
}
增加一个”ForwardAdd”Pass通道:
由于新增Pass通道是用于计算逐像素的次级光,次级光颜色应与基础通道中光照颜色叠加显示,所以需要加上混合指令:Blend One One
,此后该Pass通道计算出的颜色就会添加到帧缓冲区与原帧缓冲区颜色混合。
在基础通道中GPU会将片元的深度信息写入深度缓冲区(Z Buffer)中,只有距离摄像机近的片元会渲染出来。这个工作我们没有必要在”ForwardAdd”通道中再做一次。通过添加指令”ZWrite Off“可以告诉GPU该Pass通道不需要进行深度写入。
Pass
{
Tags{"LightMode"="ForwardAdd"}//前向渲染
Blend One One//次级光和主光混合
ZWrite Off//计算次级光时无需重复计算深度缓冲
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
//使用PBS
#pragma target 3.0
ENDCG
}
(2)点光源
创建一个PointLight,设置为红色,此时并不能正确渲染出点光源光照:
原因在于,在第一个Pass通道,也就是”ForwardBase”通道中,以逐像素方式计算了平行光。在第二个Pass通道,也就是”ForwardAdd”Pass通道中,本应该以逐像素方式计算点光源光照,但我们没有处理点光源,而是将点光源当做平行光在处理。也就是:float3 lightDir = _WorldSpaceLightPos0.xyz;
光源方向不应该是”_WorldSpaceLightPos0.xyz”,这个是平行光的光源方向,点光源的光源方向是:normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
。要同时处理两种光源,甚至是处理后面更多的光源,所以我们需要增加Shader变体。
Shader变体在Shader的检视面板查看:
点击”Show“:当前只有一个Shader变体。
// Total snippets: 2
// -----------------------------------------
// Snippet #0 platforms ffffffff:
1 keyword variants used in scene:
<no keywords defined>
// -----------------------------------------
// Snippet #1 platforms ffffffff:
1 keyword variants used in scene:
<no keywords defined>
增加Shader变体的方式是在第二个Pass通道加上:#pragma multi_compile_fwdadd
。
Pass
{
......
CGPROGRAM
//使用PBS
#pragma target 3.0
//多个光类型定义:
//#pragma multi_compile DIRECTIONAL DIRECTIONAL_COOKIE POINT POINT_COOKIE SPOT
//用下面这个可以代替上面定义的五种光类型
#pragma multi_compile_fwdadd
......
ENDCG
}
之后再次查看该Shader的变体:
// Total snippets: 2
// -----------------------------------------
// Snippet #0 platforms ffffffff:
1 keyword variants used in scene:
<no keywords defined>
// -----------------------------------------
// Snippet #1 platforms ffffffff:
Builtin keywords used: DIRECTIONAL DIRECTIONAL_COOKIE POINT POINT_COOKIE SPOT
5 keyword variants used in scene:
POINT
DIRECTIONAL
SPOT
POINT_COOKIE
DIRECTIONAL_COOKIE
此时共有5个Shader变体,分别支持了点光源、平行光、聚光灯、点光源剪影、平行光剪影。
在处理不同光源之前,我们将PBS需要的光源信息提取出来,成为一个单独的函数:
//计算PBS需要使用的光数据
UnityLight CreateLight(v2f i)
{
UnityLight light;
light.color = lightColor;
light.dir = lightDir;
light.ndotl = DotClamped(i.normal , lightDir);
return light;
}
//计算PBS需要使用的间接光数据
UnityIndirect CreateIndirectLight(v2f i)
{
UnityIndirect indirectLight;
indirectLight.diffuse = 0;//漫反射代表环境光
indirectLight.specular = 0;//镜面反射代表环境反射
return indirectLight;
}
在片元函数最后的PBS函数变为:
return UNITY_BRDF_PBS
(
albedo , specColor ,
oneMinusReflectivity ,_Smoothness ,
i.normal ,viewDir ,
CreateLight(i) , CreateIndirectLight(i)
);
在处理点光源的时候,光源方向应为:light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
。
//计算PBS需要使用的光数据
UnityLight CreateLight(v2f i)
{
UnityLight light;
//定义为点光的时候:
#if defined(POINT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
//定义为平行光的时候:
light.dir = _WorldSpaceLightPos0.xyz;
#endif
light.color = lightColor;
light.ndotl = DotClamped(i.normal , lightDir);
return light;
}
此时我们需要处理光照衰减,一般情况下光照衰减使用的公式为:”光照衰减 = 1/距离的平方”;为了在光源靠近物体时光照达到最大,但不超过1,所以光照衰减公式变更为:”光照衰减 = 1/(1+距离的平方)”。
使用上述方法计算光照衰减会有一个问题,就是点光在光照范围交界处会产生光照由无到比较亮的突变。解决之法,就是使用宏UNITY_LIGHT_ATTENUATION来计算光照衰减,让光的边缘渐渐减小为0,而不是突变。
此宏会计算不同类型的光的衰减,但在计算前,需要引入库文件#include "AutoLight.cginc"
、并定义光的类型:比如点光:#define POINT。之后还是会出现新问题:光源类型不匹配,所以要定义多重光类型,分别处理不同光类型,让shader生成不同pass变体。由于之前我们已经定义了,所以此处可以直接使用。
在UnityShader库中的CGIncludes文件夹中可以搜索宏UNITY_LIGHT_ATTENUATION:
#ifdef POINT
uniform sampler2D _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
#define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
unityShadowCoord3 lightCoord = \
mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz; \
fixed destName = \
(tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr). \
UNITY_ATTEN_CHANNEL * SHADOW_ATTENUATION(input));
#endif
Unity在计算Point光源衰减的时候使用的是一张名为”_LightTexture0”的纹理作为查找表来在片元着色器中计算逐像素光照的衰减。优点在于节省性能;缺点在于需要预处理获得采样纹理,而且纹理的大小也会影响衰减精度。
//计算PBS需要使用的光数据
UnityLight CreateLight(v2f i)
{
UnityLight light;
//定义为点光的时候:
#if defined(POINT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
//定义为平行光的时候:
light.dir = _WorldSpaceLightPos0.xyz;
#endif
//新的计算光照衰减宏:UNITY_LIGHT_ATTENUATION(光照衰减,阴影相关,世界位置)
UNITY_LIGHT_ATTENUATION(attenuation , 0 , i.worldPos);
light.color = _LightColor0.rgb * attenuation;
light.ndotl = DotClamped(i.normal , light.dir);
light.color = lightColor;
light.ndotl = DotClamped(i.normal , lightDir);
return light;
}
现在点光源也通过逐像素的方式在第二个Pass通道中计算光照了。
(3)聚光灯
使用聚光灯的原理同点光源相似:
//计算PBS需要使用的光数据
UnityLight CreateLight(v2f i)
{
UnityLight light;
//定义为点光的时候:
#if defined(POINT) || defined(SPOT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
//定义为平行光的时候:
light.dir = _WorldSpaceLightPos0.xyz;
#endif
//新的计算光照衰减宏:UNITY_LIGHT_ATTENUATION(光照衰减,阴影相关,世界位置)
UNITY_LIGHT_ATTENUATION(attenuation , 0 , i.worldPos);
light.color = _LightColor0.rgb * attenuation;
light.ndotl = DotClamped(i.normal , light.dir);
light.color = lightColor;
light.ndotl = DotClamped(i.normal , lightDir);
return light;
}
在UnityShader库中的CGIncludes文件夹中可以搜索宏UNITY_LIGHT_ATTENUATION:
#ifdef SPOT
uniform sampler2D _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
uniform sampler2D _LightTextureB0;
inline fixed UnitySpotCookie(unityShadowCoord4 LightCoord) {
return tex2D(_LightTexture0, LightCoord.xy / LightCoord.w + 0.5).w;
}
inline fixed UnitySpotAttenuate(unityShadowCoord3 LightCoord) {
return tex2D(_LightTextureB0, dot(LightCoord, LightCoord).xx). \
UNITY_ATTEN_CHANNEL;
}
#define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
unityShadowCoord4 lightCoord = \
mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)); \
fixed destName = (lightCoord.z > 0) * UnitySpotCookie(lightCoord) * \
UnitySpotAttenuate(lightCoord.xyz) * SHADOW_ATTENUATION(input);
#endif
同Point光源一样,Unity在计算Spot光源衰减的时候使用的也是一张名为”_LightTexture0”的纹理作为查找表来在片元着色器中计算逐像素光照的衰减。
现在聚光灯也可以通过逐像素的方式在第二个Pass通道中计算光照了。
(4)剪影(Cookies)
Unity中可以使用一张遮罩纹理作为剪影,为平行光/点光源/聚光灯光源遮挡光线。剪影的A通道被用来遮挡光线,其他通道没有作用。如下图剪影(Cookies)的RGBA的值均相同:
将纹理导入Unity后,在纹理设置中设置纹理类型为Cookies:
设置光源类型:
将Cookies设置到Spot光源中,效果如下:
剪影(Cookies)还可以设置到平行光和点光源中,本文就不再赘述,读者可自行实验。
注意,为了支持点光源剪影,需要更改:
//计算PBS需要使用的光数据
UnityLight CreateLight(v2f i)
{
UnityLight light;
//定义为点光的时候:
#if defined(POINT) || defined(POINT_COOKIE) || defined(SPOT)
......
}
在UnityShader库中的CGIncludes文件夹中可以搜索宏UNITY_LIGHT_ATTENUATION:
#ifdef POINT_COOKIE
uniform samplerCUBE _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
uniform sampler2D _LightTextureB0;
#define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
unityShadowCoord3 lightCoord = \
mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz; \
fixed destName = \
tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr). \
UNITY_ATTEN_CHANNEL * texCUBE(_LightTexture0, lightCoord).w *
SHADOW_ATTENUATION(input);
#endif
同点光源和平行光不一样的是:Unity在计算Cookies光源衰减的时候使用的是一张名为”_LightTextureB0”的纹理作为查找表来在片元着色器中计算逐像素光照的衰减。
(5)逐顶点光照
接下来我们需要给基础通道添加逐顶点光照计算支持。逐顶点光照即在顶点着色器中计算光照信息,然后通过插值寄存器将光照颜色传递给片元着色器。逐顶点光照计算出来的结果没有逐片元好,但是可以提升性能。
在顶点着色器的输出结构体中添加顶点光照颜色:
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
//传递顶点光照到片元函数(PointLight)
#if defined(VERTEXLIGHT_ON)
float3 vertexLightColor : TEXCOORD3;
#endif
};
如果要使用此顶点光照颜色,还需在基础通道定义”VERTEXLIGHT_ON“:
Pass
{
Tags{"LightMode"="ForwardBase"}//前向渲染
CGPROGRAM
//使用PBS
#pragma target 3.0
//点光和聚光灯 支持顶点光照(前向渲染中优化光照计算的,减少drawcall)
#pragma multi_compile _ VERTEXLIGHT_ON
#pragma vertex vert
#pragma fragment frag
#include "HTLighting_MetallicWorkflow.cginc"
ENDCG
}
此时,顶点光照颜色就可以存储在插值寄存器中传递给片元着色器了。
然后,我们新建一个函数,单独计算顶点光照颜色。使用Unity自带函数Shade4PointLights(...)
计算四个顶点光照颜色。注意,即使场景中不足四个逐顶点光源,此处也会计算四次,不足的光源被当做黑色来计算。
在UnityShader库中的CGIncludes文件夹中可以搜索函数”Shade4PointLights“:
// Used in ForwardBase pass: Calculates diffuse lighting
// from 4 point lights, with data packed in a special way.
float3 Shade4PointLights (
float4 lightPosX, float4 lightPosY, float4 lightPosZ,
float3 lightColor0, float3 lightColor1,
float3 lightColor2, float3 lightColor3,
float4 lightAttenSq, float3 pos, float3 normal) {
// to light vectors
float4 toLightX = lightPosX - pos.x;
float4 toLightY = lightPosY - pos.y;
float4 toLightZ = lightPosZ - pos.z;
// squared lengths
float4 lengthSq = 0;
lengthSq += toLightX * toLightX;
lengthSq += toLightY * toLightY;
lengthSq += toLightZ * toLightZ;
// NdotL
float4 ndotl = 0;
ndotl += toLightX * normal.x;
ndotl += toLightY * normal.y;
ndotl += toLightZ * normal.z;
// correct NdotL
float4 corr = rsqrt(lengthSq);
ndotl = max(float4(0,0,0,0), ndotl * corr);
// attenuation
float4 atten = 1.0 / (1.0 + lengthSq * lightAttenSq);
float4 diff = ndotl * atten;
// final color
float3 col = 0;
col += lightColor0 * diff.x;
col += lightColor1 * diff.y;
col += lightColor2 * diff.z;
col += lightColor3 * diff.w;
return col;
}
我们的计算顶点光照的函数:
//计算顶点光照
void ComputeVertexLightColor(inout v2f o)
{
//传递顶点光照到片元函数(PointLight)
#if defined(VERTEXLIGHT_ON)
//四个顶点光照计算:只要开启了顶点光照计算,无论是否有4个顶点光照,都会付出4个顶点光照计算的代价;没有颜色的顶点光照会用黑色代替计算。
o.vertexLightColor = Shade4PointLights
(
unity_4LightPosX0 , unity_4LightPosY0 , unity_4LightPosZ0 ,
unity_LightColor[0].rgb , unity_LightColor[1].rgb ,
unity_LightColor[2].rgb , unity_LightColor[3].rgb ,
unity_4LightAtten0 , o.worldPos , o.normal
);
#endif
}
在顶点函数中添加计算顶点光照的这一步:
v2f vert (appdata v)
{
......
ComputeVertexLightColor(o);//计算顶点光照
return o;
}
将顶点光照颜色传递给片元着色器中的PBS所需的间接光数据,顶点光照被添加到间接光照中的漫反射颜色中:
//计算PBS需要使用的间接光数据
UnityIndirect CreateIndirectLight(v2f i)
{
UnityIndirect indirectLight;
indirectLight.diffuse = 0;//漫反射代表环境光
indirectLight.specular = 0;//镜面反射代表环境反射
//传递顶点光照到片元函数(PointLight)
#if defined(VERTEXLIGHT_ON)
indirectLight.diffuse = i.vertexLightColor;
#endif
return indirectLight;
}
逐顶点光照效果:
(6)球谐函数(SH)
在UnityShader库中的CGIncludes文件夹中可以搜索函数”ShadeSH9“:
// normal should be normalized, w=1.0
half3 SHEvalLinearL0L1 (half4 normal) {
half3 x;
// Linear (L1) + constant (L0) polynomial terms
x.r = dot(unity_SHAr,normal);
x.g = dot(unity_SHAg,normal);
x.b = dot(unity_SHAb,normal);
return x;
}
// normal should be normalized, w=1.0
half3 SHEvalLinearL2 (half4 normal) {
half3 x1, x2;
// 4 of the quadratic (L2) polynomials
half4 vB = normal.xyzz * normal.yzzx;
x1.r = dot(unity_SHBr,vB);
x1.g = dot(unity_SHBg,vB);
x1.b = dot(unity_SHBb,vB);
// Final (5th) quadratic (L2) polynomial
half vC = normal.x * normal.x - normal.y * normal.y;
x2 = unity_SHC.rgb * vC;
return x1 + x2;
}
// normal should be normalized, w=1.0
// output in active color space
half3 ShadeSH9 (half4 normal) {
// Linear + constant polynomial terms
half3 res = SHEvalLinearL0L1(normal);
// Quadratic polynomials
res += SHEvalLinearL2(normal);
if (IsGammaSpace())
res = LinearToGammaSpace(res);
return res;
}
将球面谐波SH光照数据添加到漫反射间接光中,并保证其不会产生负数:
//计算PBS需要使用的间接光数据
UnityIndirect CreateIndirectLight(v2f i)
{
......
#if defined(FORWARD_BASE_PASS)
//球面谐波(作为间接光中的diffuse)
indirectLight.diffuse += max(0 , ShadeSH9(float4(i.normal , 1)));
#endif
return indirectLight;
}
在基础通道定义”FORWARD_BASE_PASS“,以保证在”ForwardBase”Pass通道中会计算球谐光照。
Pass
{
......
//计算球面谐波
#define FORWARD_BASE_PASS
#include "HTLighting_MetallicWorkflow.cginc"
ENDCG
}
关闭场景中所有的灯光,不计算SH和计算SH的效果分别如下:
可见,计算SH时,场景中多了环境灯光颜色。
5.多光源的显示
若某个物体受多光源影响,如下图
我们假设光源A到H具有相同的颜色和强度,并且所有光源都具有自动渲染模式,因此它们将严格按照以下顺序排序。最亮的光源将以逐像素光照模式进行渲染(A到D),然后最多4个光源将以逐顶点光照模式渲染(D到G),最后其余光源以SH进行渲染(G到H)。
请注意,光源组会重叠;例如,最后一个逐像素光源混合到逐顶点光照模式。
写在最后
本文在前向渲染中支持了多光源光照,但是还未处理阴影部分。对于阴影部分的处理可以见:…。
Reference
UnityShader入门精要