更加复杂的光照
前言
在之前学习中,我们场景中都只有一个光源且光源类型是平行光,但在实际开发中,我们往往需要处理数目更多、类型更复杂的光源。更重要的是我们需要得到阴影。在学习这些之前,我们是有必要知道Unity的底层渲染引擎如何让我们在Shader中访问到它们的。
本章节出示代码,都为解释原理而实现,不能使用到项目中,在文章结尾会提供能够使用的光照Shader!
Unity的渲染路径
Unity主要支持三种渲染路径:前向渲染路径、延迟渲染路径、顶点照明渲染路径,其中顶点渲染照明路径已经被淘汰,其次新的延迟渲染路径代替了原来的延迟u渲染路径。
设置项目中的渲染路径
大多数情况下,一个项目只能使用一种渲染路径,我们可以为整个项目设置渲染时的渲染路径。主菜单:Edit > Project Settings > Graphics > Tier Settings > Rendering Path。默认情况下该设置的选择是前向渲染路径。
但有时,我们希望使用多个渲染路径,这时,我们可以在每个摄像机的渲染路径设置中设置该摄像机使用的渲染路径,以覆盖 Graphics 中的设置。
设置Pass中的渲染路径
完成了上面的设置后,我们就可以在每个Pass中使用标签来指定该Pass使用的渲染路径。这是通过设置Pass的 LightMode 标签实现的。
Pass{
Tags { "LightMode" = "ForwardBase" }
上面代码告诉Unity,该Pass使用前向渲染路径中的ForwardBase路径。下图给出了Pass的 LightMode 标签支持的渲染路径设置选项。
指定渲染路径的作用
通俗来讲,指定渲染路径是我们和Unity的底层渲染引擎的一次重要沟通,可以告诉Unity,以什么渲染流程去准备光照属性与光照信息。如果我们没有指定任何渲染路径,那么一些光照变量很可能不会被正确的赋值,我们计算出的效果也就很有可能是错误的。
借用网上一个例子,不同的渲染路径差距,如同不同的绘画方式
不同渲染路径对比
功能 | 延迟 | 前向 | 旧版延迟 | 顶点光照 |
---|---|---|---|---|
每像素光照(法线贴图、光照剪影) | 是 | 是 | 是 | - |
实时阴影 | 是 | 带有警告 | 是 | - |
反射探针 | 是 | 是 | - | - |
深度和法线缓冲区 | 是 | 其他渲染pass | 是 | - |
软粒子 | 是 | - | 是 | - |
半透明对象 | - | 是 | - | 是 |
抗锯齿 | - | 是 | - | 是 |
光照剔除遮罩 | 受限 | 是 | 受限 | 是 |
光照保真度 | 全部每像素 | 部分每像素 | 全部每像素 | 全部每顶点 |
性能 | ||||
每像素光照的成本 | 照射像素数量 | 像素数量 * 照射对象数量 | 照射像素数量 | - |
正常渲染对象的次数 | 1 | 每像素光照的数量 | 2 | 1 |
简单场景的开销 | 高 | 无 | 中 | 无 |
平台支持 | ||||
PC (Windows/Mac) | Shader Model 3.0+ 和 MRT | 所有 | Shader Model 3.0+ | 所有 |
移动端 (iOS/Android) | OpenGL ES 3.0 和 MRT、Metal(在搭载 A8 或更高版本 SoC 的设备上) | 所有 | OpenGL ES 2.0 | 所有 |
游戏主机 | XB1、PS4 | 所有 | XB1、PS4、360 | - |
前向渲染路径
前向渲染路径的原理
前向渲染的光照计算由一个Pass块来完成,我们在这个pass块中计算平行光或者其他光源。它的缺点是,每个光源都要被pass计算一遍才行。
假设有一个物体,n个光源,就会计算n次pass,假设有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中的前向渲染
我们知道,计算光照不仅仅可以在片元着色器中计算,还可以在顶点着色器中计算。在Unity中,前向渲染有3种处理光照(照亮物体)的方式:逐顶点处理、逐像素处理、球谐函数处理(Spherical Harmonics,SH)。
使用哪种模式处理光照
决定一个光源使用哪种处理模式取决于它的 类型 和 渲染 模式。
- 光源的类型是指该光源是平行光还是其他类型的光源
- 光源的渲染模式是指该光源是否是重要的(Important)
在前向渲染中,Unity会根据场景中各个光源的设置以及这些光源对物体的影响程度(距离物体远近、光源强度等),对光源进行一个权重排序。然后Unity将一定数量的光源按照逐像素处理,最多4个光源按逐顶点处理,剩下光源按SH方式处理。
Unity使用的判断规则如下:
- 场景中最亮的平行光总是按逐像素处理的
- 渲染模式被设置成Not Important的光源,会按逐顶点或者SH处理
- 渲染模式被设置成Important的光源,会按逐像素处理。
- 如果根据以上规则得到的逐像素光源数量小于Quality Setting中的逐像素光源数量(Pixel Light Count),会有更多的光源以逐像素的方式进行渲染。
在哪进行光照计算
前面提到过,前向渲染有两种Pass:Base Pass 和 Additional Pass。通常这两种进行标签和渲染设置以及常规光照计算如图所示:
上图有几点需要说明的地方:
- 首先,可以发现在渲染设置中,我们除了设置Pass的标签外,还使用了#pragma multi_compile_fwdbase这样的编译指令。这些编译指令会保证Unity可以为相应类型的Pass生成所需的Shader变种,这些变种会处理不同条件下的渲染逻辑,例如是否使用了光照贴图,当前处理哪种光源类型,是否开启了阴影等,同时Unity也会在背后声明相关的内置变量并传递到Shader中。
- Base Pass旁边的注释给出了Base Pass中支持的一些光照特性。例如在Base Pass中我们可以访问光照纹理(lightmap)
- Base Pass中渲染的平行光默认是支持阴影的(如果开启了光源的阴影功能),而Additional Pass中渲染的光源在默认情况下是没有阴影效果的,即便我们在它的Light组件中设置了有阴影的Shadow Type。但我们可以在Additional Pass中使用#pragma multi_compile_fwdadd_fullshadows代替#pragma multi_compile_fwdadd编译指令,为点光源和聚光灯开启阴影效果,但这需要在Unity内部使用更多的Shader变种。
- 环境光和自发光也是在Base pass中计算的。这是因为对于一个物体来说,环境光和自发光我们只希望计算一次即可,而如果我们在Additional Pass中计算这两种光照,就会造成叠加多次环境光和自发光,这不是我们想要的。
- 在Additional Pass的渲染设置中,我们还开启和设置了混合模式。这是因为我们希望每个Additional Pass可以与上一次的光照结果在帧缓存中进行叠加,从而得到最终有多个光照的渲染效果。如果我们没有开启和设置混合模式,那么Additional Pass的渲染结果会覆盖掉之前的渲染结果,看起来好像该物体只受该光源的影响。通常情况下,我们选择的混合模式是Blend One One
- 对于前向渲染来说,一个UnityShader通常会定义一个Base Pass(Base Pass也可以被定义多次,例如需要双面渲染的情况)以及一个Additional Pass。一个Base Pass仅会执行一次(定义了多个Base Pass的情况除外),而一个Additional Pass会根据影响该物体的其他逐像素光源数目被多次调用,即每个逐像素光源会执行一次Additional Pass。
上图给出的光照计算是通常情况下我们在每种Pass中进行的计算。实际上,渲染路径的设置用于告诉Unity该Pass在前向渲染路径中的位置,然后底层的渲染引擎会进行相关计算并填充一些内置变量(如_LightColor0等),如何使用这些内置变量进行计算完全取决于开发者的选择。例如我们完全可以利用Unity提供的内置变量在Base Pass中只进行逐顶点光照;同样,我们也可以完全在Additional Pass中按逐顶点的方式进行光照计算,不进行任何逐像素计算。
内置的光照变量和函数
前面说过,根据我们使用的渲染路径(即Pass标签中LightMode的值),Unity会把不同的光照变量传递给Shader。
在Unity5中,对于前向渲染(即LightMode为ForwardBase或ForwardAdd)来说,下表给出了我们可以在Shader中访问到的光照变量。
我们在以前已经给出了一些可以用于前向渲染路径的函数,例如WorldSpaceLightDir、UnityWorldSpaceLightDir和ObjSpaceLightDir。为了完整性,我们在下面再次列出前向渲染中可以使用的内置光照函数。
顶点渲染路径
由于该路径已被Unity淘汰,在此不做介绍。
延迟渲染路径
延迟渲染是一种古老的渲染方法,由于解决前向渲染所带来的瓶颈问题,进而流行起来。
延迟渲染的原理
除了前向渲染中使用的颜色缓冲和深度缓冲外,延迟渲染还会利用额外的缓冲区,这些缓冲也被称为 G 缓冲(G-buffer),其中G是英文Geometry的缩写。G缓冲区存储了我们所关心的表面(通常指的是离摄像机最近的表面)的其他信息,例如该表面的法线、位置、用于光照计算的材质属性等。
延迟渲染主要包含量两个Pass:
- 在第一个Pass中,我们不进行任何光照计算,而仅仅计算哪片片元是可见的,这主要是通过深度缓冲区技术来实现,当发现一个片元是可见的,我们就把它的相关信息存储到 G 缓冲区中。
- 在第二个Pass中,我们利用 G 缓冲区的各个片元信息,例如表面法线、视角方向、漫反射系数等,进行真正的光照计算。
参考下面的伪代码来认识什么是延迟渲染
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(); //更新帧缓存
}
}
}
延迟渲染的效率不依赖于场景的复杂度,而是和我们使用的屏幕空间大小有关。这是因为,我们需要的信息都存储在缓冲区中,而这些缓冲区可以理解成是一张张2D图像,我们的计算实际上就是在这些图像空间中进行的。
Unity中的延迟渲染
对于延迟渲染路径来说,它最适合在场景中光源数目很多、如果使用前向渲染会造成性能瓶颈的情况下使用。而且,延迟渲染路径中的每个光源都可以按照逐像素的方式处理。但是,延迟渲染也有一些缺点。
- 不支持真正的抗锯齿(anti-aliasing)功能。
- 不能处理半透明物体
- 对显卡有一定要求。如果要使用延迟渲染的话,显卡必须支持MRT(Multiple Render Targets)、Shader Mode3.0及以上、深度渲染纹理以及双面的模板缓冲。
Unity要求提供的两个Pass
- 第一个Pass用于渲染G缓冲。在这个Pass中,我们会把物体的漫反射颜色、高光反射颜色、平滑度、法线、自发光和深度等信息渲染到屏幕空间的G缓冲区中。对于每个物体来说,这个Pass仅会执行一次。
- 第二个Pass用于计算真正的光照模型。这个Pass会使用上一个Pass中渲染的数据来计算最终的光照颜色,再存储到帧缓冲中。
注意:当在第二个Pass计算光照时,默认情况下仅可以使用Unity内置的Standard光照模型。如果我们想要使用其它的光照模型,就需要替换掉原有的Internal-DefferedShading.shader文件。
默认的G缓冲区
注意,不同Unity版本的渲染纹理存储内容会有所不同,缓冲区包含了以下几个渲染纹理(Render Texture,RT)
- RT0:格式是AGRB32,RGB通道用于存储漫反射颜色,A通道没有被使用】
- RT1:格式是AGRB32,RGB通道用于存储高光反射颜色,A通道用于存储高光反射的指数部分。
- RT2:格式是ARGB2101010,RGB通道用于存储法线,A通道没有使用
- RT3:格式是ARGB32(非HDR)或ARGBHalf(HDR),用于存储自发光+lightmap+反射探针(reflection probes)
可访问的内置变量和函数
下表给出了处理延迟渲染路径可以使用的光照变量。这些变量都可以在UnityDefferedLibrary.cginc
文件中找到它们的声明。
Unity的光源类型
Unity中提供了4种光源类型:平行光、点光源、聚光灯和面光源(area light)。面光源尽在烘焙时才可发挥作用,因此我们不讨它。
最常见的光源属性有:光源的位置、方向(更具体的说就是,到某点的方向)、颜色、强度以及衰减(更具体的说是,到某点的衰减,与该点到光源的距离有关)这5个属性。而这些属性和它们的几何定义息息相关。
三种光源在Unity中表现效果如下:
需要注意的是,我们需要在Scene视图中开启光照才能看到预览光源是如何影响场景中的物体的。下图给出了开启Scene视图光照的按钮。
在前向渲染中处理不同的光源类型
在了解3种光源的几何定义后,我们来看一下如何在Unity Shader中访问它们的5个属性:位置、方向、颜色、强度以及衰减。需要注意的是,本节均建立在使用前向渲染路径的基础上。
我们的代码使用了Blinn-Phong光照模型,并为前向渲染定义了 Base Pass 和 Additional Pass 来处理多个光源。原理代码实现如下:
Shader "Unity Shaders Book/Chapter 9/Forward Rendering"
{
Properties
{
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Tags { "RenderType" = "Opaque" }
// BasePass
Pass
{
// 环境光和第一像素光(方向光)Pass
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
// 显然需要添加此声明
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "Lighting.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 worldNormal = normalize(i.worldNormal);
// _WorldSpaceLightPos0平行光方向
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
// 计算场景中的环境光
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// _LightColor0平行光颜色和强度
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
// 平行光的衰减为1
fixed atten = 1.0;
return fixed4(ambient + (diffuse + specular) * atten, 1.0);
}
ENDCG
}
// Additional Pass
Pass
{
// 其他像素光源Pass
Tags { "LightMode" = "ForwardAdd" }
// 开启混合模式用于叠加光照
Blend One One
CGPROGRAM
// 显然需要添加此声明
#pragma multi_compile_fwdadd
#pragma vertex vert
#pragma fragment frag
#include "Lighting.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 worldNormal = normalize(i.worldNormal);
// 用于判断当前光源是否是平行光
#ifdef USING_DIRECTIONAL_LIGHT
// 获取平行光的方向
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
#else
// 通过点光源(或聚光灯)的位置减去世界空间下顶点位置,获得光的方向
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
#endif
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
// 判断当前光源是否是平行光
#ifdef USING_DIRECTIONAL_LIGHT
// 平行光衰减值为1
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;
#else
fixed atten = 1.0;
#endif
#endif
return fixed4((diffuse + specular) * atten, 1.0);
}
ENDCG
}
}
FallBack "Specular"
}
需要注意,该代码只为讲解处理其他类型光源的实现原理,代码并不会用于真正的项目中,下文会给出包含完整光照计算的Shader
Base Pass和Additional Pass的调用
在场景中创建四个点光源,把它们的颜色设为相同的红色;然后将平行光设置为绿色,我们可以得到类似下图的效果:
在这个例子中,场景中共包含了5个光源,其中一个是平行光,它会在上一节Shader的Base Pass中按逐像素的方式进行处理;其余4个都是点光源,由于它们的Render Mode为Auto且数目正好等于4,因此都会在Shader的Additional Pass中按逐像素的方式被处理,每个光源会调用一次Additional Pass。
使用帧调试器
在Unity5中,我们还可以使用帧调试器(Frame Debugger)工具来查看场景的绘制过程,使用方法是:在window > Analysis > Franme Debugger中打开帧调试器,如下图所示
从图中可以看出,Unity是如何一步步将不同光照渲染到物体上的:
- 在第一个渲染事件中,Unity首先清除颜色、深度和模板缓冲,为后面的渲染做准备;
- 在第二个渲染事件中,Unity利用Shader的第一个Pass,即Base Pass,将平行光的光照渲染到帧缓存中;
- 在后面的4个渲染事件中,Unity使用Shader的第二个Pass,即Additional Pass,一次将四个点光源应用到物体上,得到最后的渲染结果。
渲染顺序
可以注意到,Unity处理这些点光源的顺序是按照它们的重要度排序的。在这个例子中,由于所有的点光源颜色和强度都相同,因此它们的重要度取决于它们距离胶囊体的远近,因此上图中首先绘制的是距离胶囊体较近的光源。但是如果光源的强度和颜色互不相同,那么距离就不再是唯一的衡量标准。例如,如果我们把现在距离最近的点光源的强度设为0.2,那么从帧调试器中我们可以发现绘制顺序发生了变化。此时,首先绘制的是距离胶囊体第二近的光源,最近的点光源会在最后被渲染。
Not Important
我们知道,如果逐像素光源的数目很多的话,该物体的Additional Pass就会被调用多次,影响性能,我们可以通过把光源的Render Mode设为Not Important来告诉Unity,我们不希望把该光源当成逐像素处理。在本例中,我们可以把4个点光源的Render Mode都设为Not Important,可以得到下图的结果:
Unity的光照衰减
在Unity中,如果是平行光的话,衰减值为1.0。如果是其它的光源类型,那么处理更复杂一些。尽管我们可以使用数学表达式来计算给顶点相对于点光源和聚光灯的衰减,但这些计算往往涉及开根号、除法等计算量较大的操作,因此Unity选择了使用一张纹理作为查找表(Lookup Table,LUT),以在片元着色器中得到光源的衰减。我们首先得到光源空间下的坐标,然后利用该坐标对衰减纹理进行采样得到衰减值。
这样的好处在于,计算衰减不依赖于数学公式的复杂性,我们只要使用一个参数值去纹理中采样即可。但使用纹理查找来计算衰减也有一些弊端:
- 需要预处理得到采样纹理,而且纹理的大小也会影响衰减的精度
- 不直观,同时也不方便,因此一旦把数据存储到查找表中,我们就无法使用其它数学公式来计算衰减
但由于这种方法可以在一定程度上提升性能,而且得到的效果在大部分情况下都是良好的,因此Unity默认的就是使用这种纹理查找方式来计算逐像素的点光源和聚光灯的衰减的。
用于光照衰减的纹理
Unity在内部使用一张叫做_LightTexture0的纹理来计算光源的衰减,需要注意的是,如果我们对光源使用了cookie(相当于一张阴影贴纸),那么衰减查找纹理就成了_LightTextureB0,但这不是重点。在这张纹理上,(0,0)点表明了与光源位置重合点的衰减(就是最近点),(1,1)表明了光源涉及到的范围的最远点的衰减。
搭建一个简单聚光灯场景,在帧调试器中可以看到,两张纹理如下图所示。
为了对 _LightTexture0纹理采样得到给定点到该光源的衰减值,我们首先需要得到该点在光源空间中的位置,这是通过 _LightMatrix0变换矩阵得到的。在前面我们已经知道_LightMatrix0可以把顶点从世界空间变换到光源空间。因此,我们只需把_LightMatrix0和世界空间中的顶点坐标相乘即可得到光源空间中的相应位置。
float3 lightCoord=mul(_LightMatrix0,float4(i.worldPosition,1)).xyz;
然后,我们可以使用这个坐标模的平方对衰减纹理进行采样,得到衰减值:
fixed atten = tex2D(_LightTexture0,dot(lightCoord,lightCoord).rr).UNITY_ATTEN_CHANNEL;
可以发现,在上面的代码中,我们使用了光源空间中顶点距离的平方(通过dot函数来得到)来对纹理采样,之所以没有使用距离值来采样是因为这种方法可以避免开方操作。最后,我们使用宏UNITY_ATTEN_CHANNEL来得到衰减纹理中衰减值所在的分量,以得到最终的衰减值。
使用数学公式进行计算
尽管纹理衰减的方法可以减少计算衰减时的复杂度,但有时我们希望可以在代码中利用公式来计算光源的衰减。例如下面的代码可以计算光源的线性衰减。
float distance = length(_WorldSpaceLightPos.xyz-i.worldPosition.xyz);
atten=1.0/distance;//线性衰减
可惜的是,Unity没有在文档中给出内置衰减计算的说明。
Unity的阴影
阴影是如何实现的
Shadow Map技术
在实时渲染中,我们最常使用是一种名为Shadow Map的技术。这种技术理解起来非常简单,它会首先把摄像机的位置放在与光源重合的位置上,那么场景中该光源的阴影区域就是那些摄像机看不到的地方,而Unity使用的就是这样的技术。
在前向渲染路径中,如果场景中最重要的平行光开启了阴影,Unity就会为该光源计算它的阴影映射纹理(shadowmap)。这张阴影映射纹理本质上也是一张深度图,它记录了从该光源位置出发、能看到的场景中距离它最近的表面位置(深度信息)。
距离阴影映射纹理最近的表面位置
在计算阴影映射纹理时,我们如何判定距离它最近的表面位置呢?
-
一种方法是,先把摄像机放置到光源位置上,然后按正常的渲染流程,即调用Base Pass和Additional Pass来更新深度信息,得到阴影映射纹理。
但这种方法会对性能造成一定的浪费,因为实际上我们仅仅需要深度信息而已,而Base Pass和Additional Pass往往会涉及很多复杂的光照模型计算。
-
Unity选择使用一个额外的Pass来专门更新光源的阴影映射纹理,这个Pass就是LightMode标签被设置为ShadowCaster的Pass。这个Pass的渲染目标不是帧缓存,而是阴影映射纹理(或深度纹理)。
Unity首先把摄像机放置到光源位置上,然后调用该Pass,通过对顶点变换后得到光源空间下的位置,并据此来输出深度信息到阴影映射纹理中。
因此,当开启了光源的阴影效果后,底层渲染引擎首先会在当前渲染物体的UnityShader中找到LightMode为ShadowCaster的Pass,如果没有,它就会在Fallback指定的Unity Shader中继续寻找,如果仍然没有找到,该物体就无法向其它物体投射阴影(但它仍然可以接收来自其它物体的阴影)。当找到一个LightMode为ShadowCaster的Pass后,Unity会使用该Pass来更新光源的阴影映射纹理。
阴影映射纹理实现
阴影映射纹理实现是如何实现的呢?
在传统的阴影映射纹理的实现中,我们会在正常渲染的Pass中把顶点位置变换到光源空间下,以得到它在光源空间中的三维位置信息。然后我们使用xy分量对阴影映射纹理进行采样,得到阴影映射纹理中该位置的深度信息。如果该深度值小于该顶点的深度值(通常由z分量得到),那么说明该点位于阴影中。
-
在Unity5中,Unity使用了不同于这种传统的阴影采样技术,即屏幕空间的阴影映射技术(Screenspace Shadow Map)。屏幕空间的阴影映射原本是延迟渲染中产生阴影的方法。
需要注意的是,并不是所有平台的Unity都会使用这种技术。这是因为,屏幕空间的阴影映射需要显卡支持MRT,而有些移动平台不支持这种特性。
Unity中阴影映射技术具体实现
当使用了屏幕空间的阴影映射技术时,Unity首先会通过调用LightMode为ShadowCaster的Pass来得到可投射阴影的光源的阴影映射纹理以及摄像机的深度纹理。然后,根据光源的阴影映射纹理和摄像机的深度纹理来得到屏幕空间的阴影图。
如果摄像机的深度图中记录的表面深度大于转换到阴影映射纹理的深度值,就说明该表面虽然是可见的,但却处于该光源的阴影中。通过这样的方式,阴影图就包含了屏幕空间中所有有阴影的区域。
如果我们想要一个物体接收来自其它物体的阴影,只需要在Shader中对阴影图进行采样。由于阴影图是屏幕空间下的,因此我们首先需要把表面坐标从模型空间变换到屏幕空间中,然后使用这个坐标对阴影图进行采样即可。
总结物体接收阴影和投射阴影的两个过程
如果我们想要一个物体接收来自其它物体的阴影,就必须在Shader中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后光照结果相乘来产生阴影效果。
-
如果我们想要一个物体向其它物体投射阴影,就必须把该物体加入到光源的阴影映射纹理的计算中,从而让其它物体对阴影映射纹理采样时可以得到该物体的相关信息。
在Unity中,这个过程是通过为该物体执行LightMode为ShadowCaster的Pass来实现的。如果使用了屏幕空间的投影映射技术,Unity还会使用这个Pass产生一张摄像机的深度纹理。
不透明物体的阴影
为了让场景中可以产生阴影,我们首先需要让平行光可以收集阴影信息。这需要在光源的Light组件中开启阴影,如下图所示。在本例中,我们选择了软阴影(Soft Shadows)。
让物体投射阴影
在Unity中,我们可以选择是否让一个物体投射或接收阴影。这是通过设置Mesh Renderer组件中的Cast Shadows和Receive Shadows属性来实现的。如下图所示:
Cast Shadows可以被设置为开启(On)或关闭(Off)。如果开启了Cast Shadows属性,那么Unity就会把该物体加入光源的阴影映射纹理的计算中,从而让其它物体在对阴影映射纹理采样时可以得到该物体的相关信息。
Receive Shadows则可以选择是否让物体接收来自其它物体的阴影。如果没有开启Receive Shadows,那么当我们调用Unity的内置宏和变量计算阴影(在后面我们会看到如何实现)时,这些宏通过判断该物体没有开启接收阴影的功能,就不会在内部为我们计算阴影。
问题一、Shader中未实现Pass
场景中的方块使用我们模拟光照渲染时使用的Shader,注意在Shader中并没有一个为ShadowCaster的Pass来渲染阴影映射纹理和深度图,但依旧可以向下投影。其缘由是在我们为Fallback指定了一个用于回调的Unity Shader,即内置的Specular。虽然Specular本身也没有包含这样一个Pass,但是由于它的Fallback调用了VertexLit,它会继续回调,并最终回调到内置的VertexLit。
如果我们把Fallback注释掉,就会发现正方体不会再向平面投射阴影了。当然,我们可以不依赖Fallback,而自行在SubShader中定义自己的LightMode为ShadowCaster的Pass。这种自定义的Pass可以让我们更加灵活的控制阴影的产生。但由于这个Pass的功能是可以在多个Unity Shader间通用的,因此直接Fallback是一个更加方便的用法。
问题二、平面未投射阴影
上图中还有一个有意思的现象,就是右侧的平面并没有向最下面的平面投射阴影,尽管它的Cast Shadows已经被开启了。
在默认情况下,我们在计算光源的阴影映射纹理时会剔除掉物体的背面,但对于内置平面来说,它只有一个面,因此在本例中计算阴影映射纹理时,由于右侧的平面在光源下没有任何正面(front face),因此就不会添加到阴影映射纹理中。
我们可以将Cast Shadows设置为Two Sided来允许对物体的所有面都计算阴影信息。下图给出了当把右侧平面的Cast Shadows设置为Two Sided后的结果。
让物体接收阴影
为了让正方体能够接收阴影,我们在之前模拟光照渲染时使用的Shader的基础上进行修改。
-
我们在Base Pass中包含进一个新的内值文件,用于获取我们后续要使用到的宏:
#include "AutoLight.cginc"
-
SHADOW_COORDS、TRANSFER_SHADOW和SHADOW_ATTENUATION是计算阴影时的“三剑客”。我们可以在AutoLight.cginc中找到它们的声明。
在前向渲染中,宏SHADOW_COORDS实际上就是声明了一个名为_ShadowCoord的阴影纹理坐标变量。而TRANSFER_SHADOW的实现会根据平台不同而有所差异。如果当前平台可以使用屏幕空间的阴影映射技术(通过判断是否定义了UNITY_NO_SCREENSPACE_SHADOWS来得到),TRANSFER_SHADOW会调用内置的ComputePos函数来计算_ShadowCoord;如果该平台不支持屏幕空间的阴影映射技术,就会使用传统的阴影映射技术,TRANSFER_SHADOW会把顶点坐标从模型空间变换到光源空间后存储到_ShadowCoord中。然后SHADOW_ATTENUATION负责使用_ShadowCoord对相关纹理进行采样,得到阴影信息。
需要读者注意的是,由于这些宏会使用上下文变量来进行相关计算,例如TRANSFER_SHADOW会使用v.vertex或a.pos来计算坐标,因此为了能够让这些宏正确工作,我们需要保证自定义的变量名和这些宏中使用的变量名相匹配。我们需要保证:a2f结构体中顶点坐标变量名必须是vertex,顶点着色器的输出结构体v2f必须命名为v,且v2f中的顶点位置变量必须命名为pos。
注意,该代码仅是为了解释如何让物体接收阴影,但不可以直接应用到项目中,下文会给出包含了完整光照处理的Unity Shader
Shader "Unity Shaders Book/Chapter 9/Shadow"
{
Properties
{
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
// 环境光和第一像素光(方向光)Pass
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
// 显然需要添加此声明
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
// 需要这些文件获得内置宏
#include "Lighting.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;
// 这个宏用于声明一个作用于阴影纹理采样的坐标
SHADOW_COORDS(2)
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 这个宏用于将阴影坐标传递到像素着色器
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i): SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
fixed atten = 1.0;
// 使用宏坐标来采样阴影贴图
fixed shadow = SHADOW_ATTENUATION(i);
return fixed4(ambient + (diffuse + specular) * atten * shadow, 1.0);
}
ENDCG
}
Pass
{
// 其他像素光源Pass
Tags { "LightMode" = "ForwardAdd" }
Blend One One
CGPROGRAM
// 显然需要添加此声明
#pragma multi_compile_fwdadd
// 使用下面的line为点光源和聚光灯添加阴影
// #pragma multi_compile_fwdadd_fullshadows
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "AutoLight.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex: POSITION;
float3 normal: NORMAL;
};
struct v2f
{
float4 position: SV_POSITION;
float3 worldNormal: TEXCOORD0;
float3 worldPos: TEXCOORD1;
};
v2f vert(a2v v)
{
v2f o;
o.position = 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 worldNormal = normalize(i.worldNormal);
#ifdef USING_DIRECTIONAL_LIGHT
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
#else
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
#endif
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
#ifdef USING_DIRECTIONAL_LIGHT
fixed atten = 1.0;
#else
float3 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1)).xyz;
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
#endif
return fixed4((diffuse + specular) * atten, 1.0);
}
ENDCG
}
}
FallBack "Specular"
}
使用帧调试器查看阴影绘制过程
在window > Analysis > Franme Debugger中打开帧调试
在上图中可以看出,绘制该场景共需要花费20个渲染事件。这些渲染事件可以分为4个部分:
- UpdateDepthTexture,即更新摄像机的深度纹理;
- RenderShadowmap,即渲染的得到平行光的阴影映射纹理;
- CollectShadows,即根据深度纹理和阴影映射纹理得到屏幕空间的阴影图;
- 最后绘制渲染结果。
统一管理光照和衰减
在前面,我们已经讲过如何在UnityShader的前向渲染路径中计算光照衰减——在Base Pass中,平行光的衰减因子总是等于1,而在Additional Pass中,我们需要判断该Pass处理的光源类型,再使用内置变量和宏计算衰减因子。那么是不是有一个方法可以同时计算两个信息呢?好消息是,Unity在Shader里提供了这样的功能,这主要是通过内置的UNITY_LIGHT_ATTENUATION宏来实现的。
UNITY_LIGHT_ATTENUATION是Unity内置的用于计算光照衰减和阴影的宏,我们可以在内置的AutoLight.cginc里找到它们的相关声明。它接收3个参数:
- atten:光照衰减和阴影值相乘后的结果存储到第一个参数中。注意到,我们并没有在代码中声明第一个参数atten,这是因为UNITY_LIGHT_ATTENUATION会帮我们声明这个变量。
- 结构体v2f,这个参数会传递给SHADOW_ATTENUATION,用来计算阴影值。
- 世界空间的坐标,正如我们在前面讲的那样,这个参数会用于计算光源空间下的坐标,再对光照衰减纹理采样得到的光照衰减。
Shader "Unity Shaders Book/Chapter 9/Attenuation And Shadow Use Build-in Functions"
{
Properties
{
_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
// 环境光和第一像素光(方向光)Pass
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
// 显然需要添加此声明
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
// 需要这些文件获得内置宏
#include "Lighting.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;
// 这个宏用于声明一个作用于阴影纹理采样的坐标
SHADOW_COORDS(2)
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 这个宏用于将阴影坐标传递到像素着色器
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i): SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
// UNITY_LIGHT_ATTENUATION)不仅可以计算衰减,还可以计算阴影信息
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return fixed4(ambient + (diffuse + specular) * atten, 1.0);
}
ENDCG
}
Pass
{
// 其他像素光源Pass
Tags { "LightMode" = "ForwardAdd" }
Blend One One
CGPROGRAM
// 显然需要添加此声明
#pragma multi_compile_fwdadd
// 使用下面的line为点光源和聚光灯添加阴影
// #pragma multi_compile_fwdadd_fullshadows
#pragma vertex vert
#pragma fragment frag
#include "Lighting.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;
SHADOW_COORDS(2)
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
//将阴影坐标传递到像素着色器
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i): SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
// UNITY_LIGHT_ATTENUATION不仅计算衰减,还有阴影信息
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return fixed4((diffuse + specular) * atten, 1.0);
}
ENDCG
}
}
FallBack "Specular"
}
由于我们使用了UNITY_LIGHT_ATTENUATION,我们的Base Pass和Additional Pass的代码得以统一——我们不需要在Base Pass里单独处理阴影,也不需要在Additional Pass中判断光源类型来处理光照衰减,一切都只需要通过UNITY_LIGHT_ATTENUATION来完成即可。
如果我们希望可以在Additional Pass中添加阴影效果,就需要使用#pragma multi_compile_fwdadd_fullshadows
编译指令来代替Additional Pass中的#pragma multi_compile_fwdadd
指令。这样一来,Unity也会为这些额外的逐像素光源计算阴影,并传递给Shader。
透明度物体的阴影
对于大多数不透明物体来说,把Fallback设为VertexLit就可以得到正确的阴影。但对于透明物体来说,我们就需要小心处理它的阴影。透明物体的实现通常会使用透明度测试或透明度混合,我们要小心设置这些物体的Fallback。
透明度测试的阴影
透明度测试的处理比较简单,但如果我们仍然直接使用VertexLit、Diffuse、Specular等做为回调,往往无法得到正确的阴影。这是因为透明度测试需要在片元着色器中舍弃某些片元,而VertexLit中的阴影投射纹理并没有进行这样的操作。
我们采用和透明度测试时同样的Shader代码进行改造:
-
添加需要的头文件:
#include "Lighting.cginc" #include "AutoLight.cginc"
-
在v2f中使用内置宏SHADOW_COORDS声明阴影纹理坐标:
注意到,由于我们已经占用了3个插值寄存器(使用TEXCOORD0、TEXCOORD1和TEXCOORD2修饰的变量),因此SHADOW_COORDS传入的参数是3,这意味着阴影纹理坐标将占用第四个插值寄存器TEXCOORD3。
struct v2f{ float4 pos:SV_POSITION; float3 worldNormal:TEXCOORD0; float3 worldPos:TEXCOORD1; float2 uv:TEXCOORD2; SHADOW_COORDS(3) };
在顶点着色器中使用内值宏TRANSFER_SHADOW计算阴影纹理坐标后传递给片元着色器
在片元着色器中,使用内置宏UNITY_LIGHT_ATTENUATION计算阴影和光照衰减
这次,我们更改它的Fallback,使用VertexLit作为它的回调Shader
我们可以得到类似下图的效果:
问题一
可以发现,镂空区域出现了不正常的阴影,看起来就像这个正方体是一个普通的正方体一样。我们希望有些光应该是可以通过这些镂空区域透过来的,这些区域不应该有阴影。如果我们想要得到经过透明度测试后的阴影效果,就需要提供一个有透明度测试功能的ShadowCaster Pass。当然,我们可以自行编写一个这样的Pass,但这里我们仍然选择使用内置的UnityShader来减少代码量。
为了让使用透明度测试的物体得到正确的阴影效果,我们只需要在Unity Shader中更改一行代码,即把Fallback设置为Transparent/Cutout/VertexLit。
问题二
这样的结果仍然有一些问题,例如出现了一些不应该透过光的部分。出现这种情况的原因是,默认情况下把物体渲染到深度图和阴影映射纹理中仅考虑物体的正面。但对于本例的正方体来说,由于一些面完全背对光源,因此这些面的深度信息没有加入到阴影映射纹理的计算中。为了得到正确的结果,我们可以将正方体的Mesh Renderer组件中的Cast Shadows属性设置为Two Sided,强制Unity在计算阴影映射纹理时计算所有面的深度信息。下图给出了正确设置后的渲染结果。
最终的参考Shader
Shader "Unity Shaders Book/Chapter 9/Alpha Test With Shadow" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
}
SubShader {
Tags {"Queue"="AlphaTest" "IgnoreProjector"="True" "RenderType"="TransparentCutout"}
Pass {
Tags { "LightMode"="ForwardBase" }
Cull Off
CGPROGRAM
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "AutoLight.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _Cutoff;
struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
SHADOW_COORDS(3)
};
v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
// 传递阴影坐标到像素着色器
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed4 texColor = tex2D(_MainTex, i.uv);
clip (texColor.a - _Cutoff);
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)不仅可以计算衰减,还可以计算阴影信息
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return fixed4(ambient + diffuse * atten, 1.0);
}
ENDCG
}
}
FallBack "Transparent/Cutout/VertexLit"
}
透明度混合的阴影
阴影无法适用
事实上,所有内置的透明度混合的Unity Shader,如Transparent/VertexLit等,都没有包含阴影投射的Pass。
这意味着,这些半透明物体不会参与深度图和阴影映射纹理的计算,也就是说,它们不会向其它物体投射阴影,同样它们也不会接收来自其它物体的阴影。
我们使用之前学习的透明度混合+阴影的方法来渲染一个正方体,添加关于阴影的计算,并且它的Fallback是内置的Transparent/VertexLit,下图显示了渲染的结果:
缘由
Unity会这样处理半透明物体是有它的原因的。由于透明度混合需要关闭深度写入,由此带来的问题也影响了阴影的生成。总体来说,要想为这些透明半透明物体产生正确的阴影,需要在每个光源空间下仍然严格按照从后往前的顺序进行渲染,这会让阴影处理变得非常复杂,而且会影响性能。因此,在Unity中,所有内置的半透明Shader是不会产生任何阴影效果的。
强制阴影效果
我们可以使用一些dirty trick来强制为半透明物体生成阴影,这可以通过把它们的Fallback设置为VertexLit、Diffuse这些不透明物体使用的UnityShader,这样Unity就会在它的Fallback找到一个阴影投射的Pass,然后我们可以通过物体的Mesh Render组件上的Cast Shadows和Receive Shadows选项来控制是否需要向其他物体投射或接收阴影。下图显示了把Fallback设置为VertexLit并开启阴影投射和接收阴影后的半透明物体的渲染效果。
透明度混合+阴影的Shader参考实现
Shader "Unity Shaders Book/Chapter 9/Alpha Blend With Shadow"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" { }
_AlphaScale ("Alpha Scale", Range(0, 1)) = 1
}
SubShader
{
Tags { "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" }
Pass
{
Tags { "LightMode" = "ForwardBase" }
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "AutoLight.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale;
struct a2v
{
float4 vertex: POSITION;
float3 normal: NORMAL;
float4 texcoord: TEXCOORD0;
};
struct v2f
{
float4 pos: SV_POSITION;
float3 worldNormal: TEXCOORD0;
float3 worldPos: TEXCOORD1;
float2 uv: TEXCOORD2;
SHADOW_COORDS(3)
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
// 传递阴影坐标到像素着色器
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i): SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed4 texColor = tex2D(_MainTex, i.uv);
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)不仅可以计算衰减,还可以计算阴影信息
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return fixed4(ambient + diffuse * atten, texColor.a * _AlphaScale);
}
ENDCG
}
}
FallBack "Transparent/VertexLit"
// 或用force应用阴影
// FallBack "VertexLit"
}
可使用的标准光照着色器
前面说过,本章前面所有的代码仅仅是为了解释Unity中各种光照实现原理,由于缺少一些光照计算,因此不可以直接使用到项目中。现在提供两可以运用到项目中的标准光照着色器,它整合了我们之前所学过的技术。
这两个Shader都包含了对法线纹理、多光源、光照衰减和阴影的相关处理,唯一不同的是BumpedDiffuse是逐像素漫反射着色器,BumpedSpeculer是采用了Blinn-Phong光照模型的高光反射着色器。
BumpedDiffuse
Shader "Unity Shaders Book/Common/Bumped Diffuse"
{
// 逐像素漫反射的标准光照着色器
// 思路:采用两个Pass,分别计算不同光源的渲染,主要取决于不同的渲染路径与预编译指令
Properties
{
// 叠加颜色
_Color ("Color Tint", Color) = (1, 1, 1, 1)
// 主贴图
_MainTex ("Main Tex", 2D) = "white" { }
// 法线贴图
_BumpMap ("Normal Map", 2D) = "bump" { }
}
SubShader
{
// 渲染类型 = 不透明物体 渲染队列 = 几何
Tags { "RenderType" = "Opaque" "Queue" = "Geometry" }
// Base Pass
Pass
{
// 光照渲染路径 = 前向渲染Base 说明该Pass会计算环境光、最重要的平行光、逐顶点/SH光源和Lightmaps
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
// 编译指令会保证Unity可以为相应类型的光照Pass生成所需的Shader变种
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
// 用于提供光照参数的文件
#include "Lighting.cginc"
// 用于提供计算光照衰减宏的文件
#include "AutoLight.cginc"
// 属性
fixed4 _Color;
sampler2D _MainTex;
// 主纹理的缩放和平移属性
float4 _MainTex_ST;
sampler2D _BumpMap;
// 法线纹理的缩放和平移属性
float4 _BumpMap_ST;
struct a2v
{
// 该变量名是指定的,用于后续宏计算阴影
float4 vertex: POSITION;
float3 normal: NORMAL;
float4 tangent: TANGENT;
float4 texcoord: TEXCOORD0;
};
struct v2f
{
// 该变量名是指定的,用于后续宏计算阴影
float4 pos: SV_POSITION;
float4 uv: TEXCOORD0;
// 用于存储从切线空间到世界空间的变换矩阵
float4 TtoW0: TEXCOORD1;
float4 TtoW1: TEXCOORD2;
float4 TtoW2: TEXCOORD3;
// 使用宏声明一个名为_ShadowCoord的阴影纹理坐标变量
// 由于前面已经占用了三个寄存器,所以参数是4,代表使用第四个寄存器
SHADOW_COORDS(4)
};
v2f vert(a2v v)
{
v2f o;
// 转换顶点坐标到裁剪空间
o.pos = UnityObjectToClipPos(v.vertex);
// 计算纹理的缩放与偏移
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
// 或使用内置函数
// o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
// o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap_ST);
// 世界空间下坐标
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 或使用内置函数
// float3 worldPos = UnityObjectToWorldDir(v.vertex).xyz;
// 世界空间下法线方向
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
// 世界空间下切线方向
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
// 世界空间下副法线方向 cross:叉积
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
// 切线空间到世界空间的变换矩阵,按列摆放
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
// 使用宏计算阴影纹理变量
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i): SV_Target
{
// 世界空间下的坐标(从前面切线空间变换到时间空间的变换矩阵分量提取而来)
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
// 计算世界空间下光照方向和视角方向
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
// 切线空间的法线方向 = 法线贴图采样
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
// 凹凸映射 = 将切线空间的法线转换到世界空间
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
// 反射贴图 = 纹理采样 * 叠加颜色
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color.rgb;
// 环境光 = 环境光宏 * 反射贴图
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
// 漫反射 = 入射光线颜色 * 反射贴图 * 取值为正数(反射方向 · 视角方向)
// _LightColor0:该Pass处理的逐光源颜色,通过声明渲染路径后,由Unity传递给Shader的参数
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bump, lightDir));
// 使用宏对阴影纹理进行采样,输出到变量atten中
UNITY_LIGHT_ATTENUATION(atten, i, worldPos);
// 最终结果 = 环境光 + 漫反射 * 阴影 , 透明度
return fixed4(ambient + diffuse * atten, 1.0);
}
ENDCG
}
// Additional Pass
Pass
{
// 光照渲染路径 = 前向渲染Add 说明该Pass会计算额外的逐像素光源,每个Pass对应一个光源
Tags { "LightMode" = "ForwardAdd" }
// 开启混合模式,避免该Pass的渲染结果直接覆盖之前Pass的结果
Blend One One
CGPROGRAM
// 编译指令会保证Unity可以为相应类型的光照Pass生成所需的Shader变种
// 注意该指令与之前Pass中的"multi_compile_fwdbase"有所不同
#pragma multi_compile_fwdadd
// 使用下面的方式可以为点光源与聚光灯光源添加阴影
// #pragma multi_compile_fwdadd_fullshadows
#pragma vertex vert
#pragma fragment frag
// 用于提供光照参数的文件
#include "Lighting.cginc"
// 用于提供计算光照衰减宏的文件
#include "AutoLight.cginc"
// 属性
fixed4 _Color;
sampler2D _MainTex;
// 主纹理的缩放和平移属性
float4 _MainTex_ST;
sampler2D _BumpMap;
// 法线纹理的缩放和平移属性
float4 _BumpMap_ST;
struct a2v
{
// 该变量名是指定的,用于后续宏计算阴影
float4 vertex: POSITION;
float3 normal: NORMAL;
float4 tangent: TANGENT;
float4 texcoord: TEXCOORD0;
};
struct v2f
{
// 该变量名是指定的,用于后续宏计算阴影
float4 pos: SV_POSITION;
float4 uv: TEXCOORD0;
// 用于存储从切线空间到世界空间的变换矩阵
float4 TtoW0: TEXCOORD1;
float4 TtoW1: TEXCOORD2;
float4 TtoW2: TEXCOORD3;
// 使用宏声明一个名为_ShadowCoord的阴影纹理坐标变量
// 由于前面已经占用了三个寄存器,所以参数是4,代表使用第四个寄存器
SHADOW_COORDS(4)
};
v2f vert(a2v v)
{
v2f o;
// 转换顶点坐标到裁剪空间
o.pos = UnityObjectToClipPos(v.vertex);
// 计算纹理的缩放与偏移
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
// 或使用内置函数
// o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
// o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap_ST);
// 世界空间下坐标
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// 或使用内置函数
// float3 worldPos = UnityObjectToWorldDir(v.vertex).xyz;
// 世界空间下法线方向
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
// 世界空间下切线方向
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
// 世界空间下副法线方向 cross:叉积
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
// 切线空间到世界空间的变换矩阵,按列摆放
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
// 使用宏计算阴影纹理变量
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i): SV_Target
{
// 世界空间下的坐标(从前面切线空间变换到时间空间的变换矩阵分量提取而来)
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
// 计算世界空间下光照方向和视角方向
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
// 切线空间的法线方向 = 法线贴图采样
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
// 凹凸映射 = 将切线空间的法线转换到世界空间
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
// 反射贴图 = 纹理采样 * 叠加颜色
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color.rgb;
// 漫反射 = 入射光线颜色 * 反射贴图 * 取值为正数(反射方向 · 视角方向)
// _LightColor0:该Pass处理的逐光源颜色,通过声明渲染路径后,由Unity传递给Shader的参数
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bump, lightDir));
// 使用宏对阴影纹理进行采样,输出到变量atten中
UNITY_LIGHT_ATTENUATION(atten, i, worldPos);
// 最终结果 = 漫反射 * 阴影 , 透明度
return fixed4(diffuse * atten, 1.0);
}
ENDCG
}
}
FallBack "Diffuse"
}
BumpedSpeculer
Shader "Unity Shaders Book/Common/Bumped Specular"
{
// 采用了Blinn-Phong光照模型的高光反射着色器
// 思路:采用两个Pass,分别计算不同光源的渲染,主要取决于不同的渲染路径与预编译指令
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" { }
_BumpMap ("Normal Map", 2D) = "bump" { }
// 高光反射颜色
_Specular ("Specular Color", Color) = (1, 1, 1, 1)
// 光滑度
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Tags { "RenderType" = "Opaque" "Queue" = "Geometry" }
Pass
{
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma multi_compile_fwdbase
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex: POSITION;
float3 normal: NORMAL;
float4 tangent: TANGENT;
float4 texcoord: TEXCOORD0;
};
struct v2f
{
float4 pos: SV_POSITION;
float4 uv: TEXCOORD0;
float4 TtoW0: TEXCOORD1;
float4 TtoW1: TEXCOORD2;
float4 TtoW2: TEXCOORD3;
SHADOW_COORDS(4)
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
// o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
// o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap_ST);
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// float3 worldPos = UnityObjectToWorldDir(v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i): SV_Target
{
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
// 计算世界空间下光照方向和视角方向
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
// 切线空间的法线方向 = 法线贴图采样
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bump, lightDir));
// 半角方向
fixed3 halfDir = normalize(lightDir + viewDir);
// BlinnPhong高光反射 = 入射光线颜色 * 高光反射颜色 * n次平方(取值为正数(切线空间的法线方向 · 半角方向)); pow:次平方
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(bump, halfDir)), _Gloss);
UNITY_LIGHT_ATTENUATION(atten, i, worldPos);
// 最终结果 = 环境光 + (高光反射 + 漫反射) * 阴影 , 透明度
return fixed4(ambient + (diffuse + specular) * atten, 1.0);
}
ENDCG
}
Pass
{
Tags { "LightMode" = "ForwardAdd" }
Blend One One
CGPROGRAM
#pragma multi_compile_fwdadd
// Use the line below to add shadows for point and spot lights
// #pragma multi_compile_fwdadd_fullshadows
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "AutoLight.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss;
struct a2v
{
float4 vertex: POSITION;
float3 normal: NORMAL;
float4 tangent: TANGENT;
float4 texcoord: TEXCOORD0;
};
struct v2f
{
float4 pos: SV_POSITION;
float4 uv: TEXCOORD0;
float4 TtoW0: TEXCOORD1;
float4 TtoW1: TEXCOORD2;
float4 TtoW2: TEXCOORD3;
SHADOW_COORDS(4)
};
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
// o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
// o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap_ST);
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// float3 worldPos = UnityObjectToWorldDir(v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
TRANSFER_SHADOW(o);
return o;
}
fixed4 frag(v2f i): SV_Target
{
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw));
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color.rgb;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bump, lightDir));
// 半角方向
fixed3 halfDir = normalize(lightDir + viewDir);
// BlinnPhong高光反射 = 入射光线颜色 * 高光反射颜色 * n次平方(取值为正数(切线空间的法线方向 · 半角方向)); pow:次平方
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(bump, halfDir)), _Gloss);
UNITY_LIGHT_ATTENUATION(atten, i, worldPos);
// 最终结果 = (高光反射 + 漫反射) * 阴影 , 透明度
return fixed4((diffuse + specular) * atten, 1.0);
}
ENDCG
}
}
FallBack "Specular"
}