本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
- 书本中句子照抄 + 个人批注
- 项目源码
- 一堆新手会犯的错误
- 潜在的太监断更,有始无终
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
文章目录
Unity中的光源类型
在之前的关于光源的学习笔记中,我曾简单介绍过光源的一些性质。如今在UnityShader中再提
Unity一共支持4种光源:平行光、点光源、聚光灯和面光源(Area light)。面光源只有在烘焙时才发生作用,此处暂且不表。
光源类型
由于每种光源的几何定义都不同,因此对应的光源属性也就不同。不过Unity为我们提供了很多的内置函数来处理这些光源。
一般而言,光源最常用的属性有:光源的位置、方向、颜色、强度以及衰减这五个属性,这些个属性和他们的几何定义息息相关
平行光
平行光的几何定义是最简单的,平行光的光照距离是无限的。通常作为太阳这类角色出现。平行光放在任何位置都可以,唯一的几何属性是方向,我们可以通过调整平行光的Transfrom中的Rotation属性来改变它的光源方向。且平行光到场景中所有点的方向都是一样的,这也是平行光名称的由来。平行光没有位置,没有衰减。
点光源
点光源的照亮空间是有限的,它由空间中的一个球体来定义。以该点为球心向一个球形范围发散光照。由于发散的特性,因此点光源的方向属性在场景中无法调整,如果需要控制点光源的照射方向可以在代码中获取。
点光源的范围,强度,颜色,位置都可以在面板中设置。衰减则是越圆心则光源越弱,衰减函数可以自定义。方向则是圆心方向到目标点的方向。
聚光灯
聚光灯的形状是以光源为顶点发散的一个锥形体,离光源越近接收光照越强,并且光照范围是物体与圆锥的切面重叠处。
我们可以在面板中设置聚光灯的开角属性,照明范围则由锥体的高所决定。
光照强度在顶点处最大,边界处则强度为0。强度距离顶点越远越衰减。衰减值可以由一个函数定义,但比较复杂,因为我们需要判断点与锥体的位置关系。
前向渲染中处理不同的光源类型
我们将学习如何在UnityShader中访问光源的五种属性。(前向渲染路径)
在前向渲染路径中,我们需要定义两个Pass——Base Pass和Additional Pass来处理多个光源。并使用Blinn-Phong光照模型,代码如下:
// Upgrade NOTE: replaced '_LightMatrix0' with 'unity_WorldToLight'
Shader "Custom/ForwardRendering_Shader_Copy"
{
Properties
{
_Diffuse("Diffuse",Color) = (1,1,1,1)
_Specular("Specular",Color) = (1,1,1,1)
_Gloss("Gloss",Range(8.0,256)) = 20
}
SubShader
{
// Base Pass
Pass
{
// Base Tag
Tags{"LightMode" = "ForwardBase"}
CGPROGRAM
// 预定义为forwardBase
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = normalize(i.worldNormal);
// _WorldSpaceLightPos0.xyz是该Pass处理的逐像素光源的位置,如果该光源是平行光,那么w分量是0,其他光源则w分量为1
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormalDir,worldLightDir));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfLambert = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(worldNormalDir,halfLambert)),_Gloss);
// Base Pass处理平行光,平行光不衰减
fixed atten = 1.0;
// 环境光在Base Pass中计算了一次,这样Additional Pass就不会再计算环境光
// 类似的只计算一次的光照还有自发光
return fixed4(ambient + (diffuse + specular) * atten ,1.0);
}
ENDCG
}
// Additional Pass
Pass
{
//Additional Tag
Tags{"LightMode" = "ForwardAdd"}
Blend One One
CGPROGRAM
#pragma multi_compile_fwdadd
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
return o;
}
fixed4 frag(v2f i):SV_Target
{
fixed3 worldNormalDir = normalize(i.worldNormal);
// 分支判断处理语句对不同类型光照进行不同分支的Shader计算处理
#ifdef USING_DIRECTIONAL_LIGHT
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
#else
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
#endif
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfLambert = normalize(worldLightDir + viewDir);
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormalDir,worldLightDir));
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(worldNormalDir,halfLambert)),_Gloss);
// 处理不同光源的衰减
// 平行光不衰减
#ifdef USING_DIRECTIONAL_LIGHT
fixed atten = 1.0;
#else
#if defined (POINT)
// 把点坐标转换到点光源的坐标空间中,_LightMatrix0由引擎代码计算后传递到shader中,这里包含了对点光源范围的计算,具体可参考Unity引擎源码。经过_LightMatrix0变换后,在点光源中心处lightCoord为(0, 0, 0),在点光源的范围边缘处lightCoord模为1
float3 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1)).xyz;
// 使用点到光源中心距离的平方dot(lightCoord, lightCoord)构成二维采样坐标,对衰减纹理_LightTexture0采样。_LightTexture0纹理具体长什么样可以看后面的内容
// UNITY_ATTEN_CHANNEL是衰减值所在的纹理通道,可以在内置的HLSLSupport.cginc文件中查看。一般PC和主机平台的话UNITY_ATTEN_CHANNEL是r通道,移动平台的话是a通道
// 简单来说就是根据光源到顶点间的欧氏距离进行衰减纹理采样
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
#elif defined (SPOT)
// 把点坐标转换到聚光灯的坐标空间中,_LightMatrix0由引擎代码计算后传递到shader中,这里面包含了对聚光灯的范围、角度的计算,具体可参考Unity引擎源码。经过_LightMatrix0变换后,在聚光灯光源中心处或聚光灯范围外的lightCoord为(0, 0, 0),在点光源的范围边缘处lightCoord模为1
float4 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1));
// 与点光源不同,由于聚光灯有更多的角度等要求,因此为了得到衰减值,除了需要对衰减纹理采样外,还需要对聚光灯的范围、张角和方向进行判断
// 此时衰减纹理存储到了_LightTextureB0中,这张纹理和点光源中的_LightTexture0是等价的
// 聚光灯的_LightTexture0存储的不再是基于距离的衰减纹理,而是一张基于张角范围的衰减纹理
// (lightCoord.z > 0)指的是顶点方向在光锥正方向的点(负方向不被照亮)
// lightCoord.xy / lightCoord.w + 0.5通过除以w分量进行齐次投影变换(齐次除法),值域为[-0.5,0.5],+0.5方便对基于距离的衰减纹理进行归一化采样
// 再根据光源到顶点间的欧氏距离进行基于张角纹理的衰减纹理采样
fixed atten = (lightCoord.z > 0) * tex2D(_LightTexture0, lightCoord.xy / lightCoord.w + 0.5).w * tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
#else
fixed atten = 1.0;
#endif
#endif
return fixed4((diffuse + specular) * atten, 1.0);
}
ENDCG
}
}
FallBack "Specular"
}
最终实现效果如图所示,对平行光在Base Pass中进行逐像素光追,对两个点光源在Additional Pass中进行逐像素光照。
对Base Pass和Additional Pass 的调用
当我们创建一个光源时,默认情况下光源的Render Mode是Auto(可以在面板中设置)。意味着Unity会在背后为我们判断哪些光源会按逐像素处理,而哪些按逐顶点或SH的方式处理。
默认情况下,一个物体可以接受除最亮的平行光以外的4个逐像素光照。我们可以在Quality设置中设置Pixel Light Count的数量
如图所示,该场景中包含了5个光源,其中1个是平行光,4个是点光源。平行光会根据Base Pass中按照逐像素的方式处理,而最多4个光源会在Additional Pass 中按照逐像素方式处理,每个光源调用一次Additional Pass。
由于场景中的光源类型都是Auto,因此自动使用前向渲染的Additional Pass。
我们可以使用帧调试器(Frame Debugger) 来查看场景的绘制过程:
通过帧调试器可以看出,该物体的光照渲染经过了6个渲染事件。
- 第一个渲染事件叫做Clear,只有一步,它会清理颜色、深度、模板缓冲
- 第二个渲染事件叫RenderForward.RenderLoopJob。也就是前向渲染,重复渲染工作。在这一步中重复了五次绘制胶囊体网格的渲染事件。
第一次是平行光绘制是在Base Pass中进行的。而其余的点光源(4个)绘制都是在Additional Pass中进行的。所有的颜色都会渲染到帧缓存中,我们依次将4个点光源的光照应用到这些物体上就能得到最后的渲染结果。
Unity处理这些光源点的顺序是按照他们的重要度排序的。由于所有点光源的颜色和强度都相同,因此他们的重要度取决于它们距离胶囊体的远近。(总之颜色、光源强度和距离物体的远近都会影响光源的重要度)
如果物体并不在光源的光照范围内,那么Unity是不会为物体调用Shader来处理光源的:
如图所示,将左侧光源向着远离物体的方向运动,使得物体不在其照明范围内,我们发现光照渲染绘制事件少了一个,渲染的点光源变为了3个。
当然我们也不希望逐像素光源的数量太多,该物体的Additional Pass会被光源多次调用,影响性能。我们可以通过设置光源的Render Mode 设为Not Important来告诉Unity,我们不希望把该光源当作逐像素处理:
如图所示是将四个点光源都设置为不重要,那么自然不会进行Additional Pass的处理,由于我们的Shader中也没有对逐顶点或者SH的其他处理,因此就不渲染了
如果连平行光都不重要,那么最后得到的渲染结果就是环境光了。
Unity的光照衰减
Unity使用一张纹理作为查找表来在片元着色器中计算逐像素光照的衰减。好处在于:不需要数学计算,直接对衰减贴图采样即可。但衰减纹理也存在一些弊端:
- 需要预处理得到采样纹理,而且纹理的大小会影响衰减的精度
- 不直观,也不方便,因此一旦把数据存储到查找表中,我们就无法使用其他数学公式来计算衰减。
由于该方法可以提升性能,且大部分情况下的效果都比较良好,因此Unity默认是使用这种纹理查找的方式来计算逐像素的点光源和聚光灯的衰减的。
用于光照衰减的纹理
Unity在内部使用了一张名为_LightTexture0 的纹理来计算光照衰减。需要注意的是,如果我们对光源使用了cookie(相当于一张阴影贴纸),那么衰减查找纹理就成了_LightTextureB0
(通过假彩色显示的_LightTexture0)
我们通常只对衰减纹理的对角uv(x,x)进行采样,其中(0,0)是关照最强点为1,反之(1,1)为0是最弱点。
(为什么我们只对对角采样,其实答案在之前的渐变纹理渲染中已经提到过了,这张衰减纹理表面上看是2D的,实际上纵轴上的颜色值是相同的,因此要改变采样值只改变横轴坐标,所以本质上是一张一维的图像)
衰减纹理的UV坐标是相对于以光源为中心的光源空间定义的。因此为了对衰减纹理采样,我们需要知道该点在光源空间上的位置。这是通过_LightTexture0变换矩阵得到的,该矩阵用于将顶点从世界空间变换到光源空间,因此我们可以得到光源空间中的位置:
float3 lightCoord = mul(_LightMatrix0,float4(i.worldPosition,1)).xyz;
然后我们对这个坐标计算相对于光源点(也就是坐标原点)的欧氏距离,并据此进行采样得到衰减值:
fixed atten= mul(_LightTexture0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
直接用向量点乘可以避免开方操作,最终我们用宏UNITY_ATTEN_CHANNEL
来得到衰减纹理中衰减值所在的分量(宏UNITY_ATTEN_CHANNEL
代替了xyzw中的其中一个分量)。
使用数学公式计算衰减
有时我们希望使用数学公式来计算光的衰减,例如使用下列代码来计算光源的线性衰减:
float distance = length(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
atten = 1.0 / distance;
如此也是可以的,毕竟是我们自定义的。