Baked Light(烘焙光)
——光照映射和探针
本节内容
- 烘焙静态全局照明(GI)
- 采样光照贴图、探针、光照探针代理体(LPPVs)
- 创建一个元通道(meta pass)
- 支持自发光(Emissive)
这是关于如何创建Custom SRP的系列教程的第五个部分,它使得烘焙静态光照到贴图和探针中成为可能。
这个教程使用的是Unity版本是2019.2.6f1.
(ps:文章总被吞…最后偶然看到可能会被吞的一些词儿…尝试改了点但有些意思感觉不到位~)
1. 烘焙静态光(Rendering Shadows)
到目前为止,我们已经在渲染时计算了所有的光照,但这不是唯一的选择。光照也可以提前计算,并存储在光照贴图和探针中。这样做有两个主要原因:减少实时运算和添加不能在运行时计算的间接照明。后者是被称为全局光照的一部分:光不是来自直接光源,而是间接式的通过反射、环境或自发光表面。
烘焙光照的缺点是它是静态的,所以不能在运行时改变。它还需要进行存储,这增加了项目构建的大小和内存占用。
那么实时全局照明呢?
.
Unity使用了启发式照明系统(Enlighten System)进行实时全局照明(realtime-global-illumination),但这已经被弃用了,所以我们不会使用它。 此外,反射探针可以在运行时渲染,以创建高光环境反射,但我们不会在本教程中介绍它们。
1.1 场景照明设置(Scene Lighting Settings)
GI是通过照明窗口的场景选项卡逐场景配置的。烘焙光照通过Mixes Lighting
下的Baked Global Illumination
开关启用。还有一个Lighting Mode
光照模式选项,我们将选用Baked Indirect
,这意味着我们烘焙所有静态间接照明。
如果你的项目是在Unity 2019.2或更早的时候创建的,那么你也会看到一个启用实时照明的选项,这应该是禁用的。 如果你的项目是在Unity 2019.3或更高版本中创建的,那么该选项将不会显示。
再往下是一个Lightmapping Settings
部分,可以用来控制光照映射过程,这是由Unity编辑器完成的。 我将使用默认设置,除了LightMap Resolution
被降低到20,Compress Lightmaps
被禁用,Directional Mode
被设置为Non-Directional
。我还使用了Progressive CPU
光照贴图。
那么实时全局照明呢?
.
它还烘焙平行光数据,这使得法线贴图能够影响入射的烘烤光。因为我们目前还不支持法线映射,所以没有理由启用它。
1.2 静态对象(Static Objects)
为了演示烘焙光照,我创建了一个场景,用一个绿色的平面作为地面,放置了一些盒子和球体,中间放了一个只有一面没有墙壁的房间,所以它的内部都会产生阴影。
场景有一个简单的平行光,其模式设置为混合。这告诉Unity它应该烘焙这个光的间接照明。除此之外,这盏灯仍然像普通的实时光一样工作。
我还将地面和所以立方体,包括组成房间的立方体,都纳入到了烘焙过程。它们将是光线反射的对象,因此要变成间接的。这是通过启用MeshRenderer
组件的Contribute Global Illumination
开关来实现的。启用这一功能也会自动将它们的Receive Global Illumination
模式切换到Lightmaps
,这意味着到达它们表面的间接光会被烘焙到光照贴图中。 您也可以通过从对象的Static
下拉列表中启用Contribute GI
来启用这个模式,或者使它完全静态化。
一旦启用,场景的照明将再次烘焙,假设在Lighting
窗口中的Auto Generate
开启,否则你将不得不按下Generate Lighting
按钮。光照贴图设置也显示在MeshRenderer
组件中,包括一个包含对象的光照贴图视图。
不应该有很多绿色的间接光吗(因为绿色的地表)?
.
是的,我们稍后会讲到
球体不显示在光照贴图中,因为它们没有贡献全局照明,因此被认为是动态的。 它们必须依赖光照探针,我们稍后会讲到。静态对象也可以通过将它们的Receive Global Illumination
模式切换回Light Probes
来排除在贴图之外。 它们仍然会影响烘焙效果,但不会占用光照贴图的空间。
1.3 完全烘焙的光(Fully-Baked Light)
烘焙光照大部分是蓝色的,因为它由天空盒主导,代表来自环境中天空的间接照明。房间中心周围明亮的区域是由从地面和墙壁反射过来的间接照明造成的。
我们也可以将所有的照明烘焙到贴图中,无论是直接的还是间接的。这可以通过将灯的模式设置为“Baked”来实现。 然后它不再提供实时照明。
实际上,烘培光的直接照明也被视为间接照明,从而在使光照贴图更亮。
2. 采集烘焙光(Sampling Baked Light)
目前所有的东西都被渲染成纯黑色,因为没有实时光,我们的着色器还不知道全局照明。 我们必须对光照贴图进行采样,才能实现这个功能。
2.1 全局照明(Global Illumination)
创建一个新的ShaderLibrary/GI.hlsl
文件,包含所有与全局照明相关的代码。 在里面定义一个GI
结构体和一个GetGI
函数,给定光照贴图的UV坐标。间接光来自四面八方,因此只能用于漫反射,而不能用于镜面反射。所以给GI
一个漫反射字段。最初用光照贴图的UV填充它,用于调试目的。
#ifndef CUSTOM_GI_INCLUDED
#define CUSTOM_GI_INCLUDED
struct GI {
float3 diffuse;
};
GI GetGI (float2 lightMapUV) {
GI gi;
gi.diffuse = float3(lightMapUV, 0.0);
return gi;
}
#endif
那么镜面全局照明呢?
.
*镜面环境反射通常是通过反射探针提供的,我们将在以后的教程中介绍。屏幕空间反射是另一种选择。 *
添加一个GI
参数到GetLighting
,并在累计实时照明之前使用它初始化颜色。在现在我们不计算表面的漫反射,所以我们可以看到未修改的入射光。
float3 GetLighting (Surface surfaceWS, BRDF brdf, GI gi) {
ShadowData shadowData = GetShadowData(surfaceWS);
float3 color = gi.diffuse;
…
return color;
}
在LitPass
中Lighting
之前引用GI
#include "../ShaderLibrary/GI.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"
在LitPassFragment
获得GI,初始化为0,并将其传递给GetLighting
。
GI gi = GetGI(0.0);
float3 color = GetLighting(surface, brdf, gi);
2.2 光照贴图坐标(Light Map Coordinates)
为了获得光照贴图的UV坐标,Unity必须将它们发送到着色器。我们必须指示管线为每个参与光照映射的对象做这个。这是通过在CameraRenderer.DrawVisibleGeometry
中将DrawingSettings
的perObjectData
属性设置为PerObjectData.Lightmaps
来实现的。
var drawingSettings = new DrawingSettings(
unlitShaderTagId, sortingSettings
) {
enableDynamicBatching = useDynamicBatching,
enableInstancing = useGPUInstancing,
perObjectData = PerObjectData.Lightmaps
};
Unity现在将使用带有LIGHTMAP_ON
关键字的着色器变体来渲染光照映射的对象。为我们的光照着色器的CustomLit
通道添加一个multi-compile
指令。
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile_instancing
光照贴图的UV坐标是Attributes
顶点数据的一部分。 我们必须将它们转移到Varyings
,这样我们才能在LitPassFragment
中使用它们。但我们应该只在需要的时候这样做。我们可以使用类似于传输GPU-Instancing
标识符的方法,并依赖GI_ATTRIBUTE_DATA
、GI_VARYINGS_DATA
和TRANSFER_GI_DATA
宏。
struct Attributes {
…
GI_ATTRIBUTE_DATA
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings {
…
GI_VARYINGS_DATA
UNITY_VERTEX_INPUT_INSTANCE_ID
};
Varyings LitPassVertex (Attributes input) {
Varyings output;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
TRANSFER_GI_DATA(input, output);
…
}
加上另一个GI_FRAGMENT_DATA
宏来检索GetGI所需的参数。
GI gi = GetGI(GI_FRAGMENT_DATA(input));
我们需要在GI.hlsl
中自己定义这些宏。最初定义他们为空,除了GI_FRAGMENT_DATA
是0。一个宏的参数列表的工作方式类似于一个函数,除了没有类型,并且在宏名和参数列表之间不允许有空格,否则列表将被解释为宏定义的东西。
#define GI_ATTRIBUTE_DATA
#define GI_VARYINGS_DATA
#define TRANSFER_GI_DATA(input, output)
#define GI_FRAGMENT_DATA(input) 0.0
当定义LIGHTMAP_ON
时,宏应该将另一个UV集添加到结构体、复制它和检索它。光照贴图UV是通过第二个纹理的坐标通道提供的,所以我们需要在Attributes
中使用TEXCOORD1
语义。
#if defined(LIGHTMAP_ON)
#define GI_ATTRIBUTE_DATA float2 lightMapUV : TEXCOORD1;
#define GI_VARYINGS_DATA float2 lightMapUV : VAR_LIGHT_MAP_UV;
#define TRANSFER_GI_DATA(input, output) output.lightMapUV = input.lightMapUV;
#define GI_FRAGMENT_DATA(input) input.lightMapUV
#else
#define GI_ATTRIBUTE_DATA
#define GI_VARYINGS_DATA
#define TRANSFER_GI_DATA(input, output)
#define GI_FRAGMENT_DATA(input) 0.0
#endif
所有静态烘焙的对象现在显示他们的UV,而所有动态对象保持黑色。
2.3 转换的光照贴图坐标(Transformed Light Map Coordinates)
光照贴图坐标通常是由Unity或部分导入的网格数据自动生成的。 他们定义了一个展开的纹理,使网格变平,从而映射到纹理坐标。 展开是在光照贴图缩放和定位每个对象,所以每个实例都有自己的空间。这就像基础UV的缩放和平移一样。 我们必须把这个应用到光照贴图UV上。
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
float4 unity_LODFade;
real4 unity_WorldTransformParams;
float4 unity_LightmapST;
float4 unity_DynamicLightmapST;
CBUFFER_END
光照映射是否与GPU实例化一起工作?
.
*是的,所有的UnityPerDraw
数据在需要时得到实例化。 *
然后调整TRANSFER_GI_DATA
宏,使其应用转换。宏定义可以分成多行,每一行的末尾除了最后一行都需要用反斜杠标记。
#define TRANSFER_GI_DATA(input, output) \
output.lightMapUV = input.lightMapUV * \
unity_LightmapST.xy + unity_LightmapST.zw;
2.4 采集光照贴图(Sampling the Light Map)
光照贴图的采样是GI
的职责。 光照贴图纹理被称为unity_Lightmap
,带有相应的采样状态。引用EntityLighting.hlsl
,因为我们将使用它来检索光照数据。
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/EntityLighting.hlsl"
TEXTURE2D(unity_Lightmap);
SAMPLER(samplerunity_Lightmap);
创建一个SampleLightMap
函数,该函数在有光照贴图时调用SampleSingleLightmap
,否则返回0。 在GetGI
中使用它来设置漫射光。
float3 SampleLightMap (float2 lightMapUV) {
#if defined(LIGHTMAP_ON)
return SampleSingleLightmap(lightMapUV);
#else
return 0.0;
#endif
}
GI GetGI (float2 lightMapUV) {
GI gi;
gi.diffuse = SampleLightMap(lightMapUV);
return gi;
}
SampleSingleLightmap
函数需要更多的参数。首先,我们必须将纹理和采样状态作为前两个参数传递给它,为此我们可以使用TEXTURE2D_ARGS
宏。
return SampleSingleLightmap(
TEXTURE2D_ARGS(unity_Lightmap, samplerunity_Lightmap), lightMapUV
);
然后是要应用的缩放和转换。 因为我们之前已经做过了,我们将在这里使用恒等变换。
return SampleSingleLightmap(
TEXTURE2D_ARGS(unity_Lightmap, samplerunity_Lightmap), lightMapUV,
float4(1.0, 1.0, 0.0, 0.0)
);
然后是一个布尔值,用于指示光照映射是否被压缩,在UNITY_LIGHTMAP_FULL_HDR
没有定义的情况下就是这种情况。最后一个参数是包含解码指令的float4。 对第一个组件使用LIGHTMAP_HDR_MULTIPLIER
,对第二个组件使用LIGHTMAP_HDR_EXPONENT
,其他组件没有使用。
return SampleSingleLightmap(
TEXTURE2D_ARGS(unity_Lightmap, samplerunity_Lightmap), lightMapUV,
float4(1.0, 1.0, 0.0, 0.0),
#if defined(UNITY_LIGHTMAP_FULL_HDR)
false,
#else
true,
#endif
float4(LIGHTMAP_HDR_MULTIPLIER, LIGHTMAP_HDR_EXPONENT, 0.0, 0.0)
);
2.5 禁用环境照明(Disabling Environment Lighting)
烘焙光非常明亮,因为它还包括来自天空的间接照明。 我们可以通过将Intensity Multiplier
设置为零来禁用它。 这使得我们能够聚焦于单一方向的光。
注意,房间内部现在是间接照明,主要是通过地面。
我们也可以烘焙其他类型的光吗?
.
*是的,尽管我们目前只关注平行光,但其他类型的灯也可以烘焙,但需要一些额外的工作。 *
3. 光照探针(Light Probes)
动态对象不影响烘焙的全局照明,但它们可以通过光照探针受其影响。光照探针是场景中的一个点,它烘焙了所有入射光,通过一个三阶多项式来近似它,确切的说是L2球面谐波
。光照探针被放置在场景里,Unity在它们之间对每个物体进行插值,以达到近似它们位置的最终照明。
3.1 光照探针组组件(Light Probe Group)
通过GameObject/Light/Light Probe Group
创建一个光照探针组,光照探针被添加到场景中。这将创建一个带有LightProbeGroup
组件的游戏对象,该组件默认包含6个立方体形状的探针。当Edit Light Probes
启用时,你可以移动、复制和删除单个探针,就像它们是游戏对象一样。
在一个场景中可以有多个探针组。Unity合并所有的探针,然后创建一个四面体网格连接它们。 每个动态对象最终都在一个四面体中。 在其顶点处的四个探针被插值,以应用于物体的最终照明。 如果一个物体最终在探针覆盖的区域之外,则使用最近的三角形代替,这样光照可能会显得奇怪。
默认情况下,当一个动态对象被选择时,将绘制线框来显示影响该对象的探针,以及在其位置上的插值结果。可以通过在Lighting
窗口的Debug Settings
下调整Light Probe Visualization
来改变这个。
放置探针的位置取决于场景。首先,只有动态对象所在的地方才需要它们。 第二,把它们放在光线有变化的地方。 每个探针都是插值的终点,所以把它们放在照明过度的附近。 第三,不要把它们放在烘焙的几何体中,因为它们最终会变成黑色。 最后,差值是通过物体的,所以如果墙壁两侧的照明不同,就让探针靠近墙壁的两侧,这样就不会有物体在两边之间进行插值。 除此之外,你必须进行一些实验用来确定效果。
3.2 采集探针(Sampling Probes)
插入的光照探针数据必须传递给每个对象的GPU。我们必须告诉Unity这样做,这个需要PerObjectData.LightProbe
而不是PerObjectData.Lightmaps
。我们需要启用这两个标志,因此使用OR
运算符合并它们。
perObjectData = PerObjectData.Lightmaps | PerObjectData.LightProbe
所需的UnityPerDraw
数据由7个float4向量组成。
···
CBUFFER_START(UnityPerDraw)
…
float4 unity_SHAr;
float4 unity_SHAg;
float4 unity_SHAb;
float4 unity_SHBr;
float4 unity_SHBg;
float4 unity_SHBb;
float4 unity_SHC;
CBUFFER_END
···
我们通过一个新的SampleLightProbe
函数对GI
中的光照探针进行采样。我们需要一个方向,所以给它一个世界空间Surface
参数。
如果这个对象使用光照贴图,则返回0。 否则返回0和SampleSH9
之间的最大值。该函数需要探针数据和法向量作为参数。 探针数据必须以系数数组的形式提供。
float3 SampleLightProbe (Surface surfaceWS) {
#if defined(LIGHTMAP_ON)
return 0.0;
#else
float4 coefficients[7];
coefficients[0] = unity_SHAr;
coefficients[1] = unity_SHAg;
coefficients[2] = unity_SHAb;
coefficients[3] = unity_SHBr;
coefficients[4] = unity_SHBg;
coefficients[5] = unity_SHBb;
coefficients[6] = unity_SHC;
return max(0.0, SampleSH9(coefficients, surfaceWS.normal));
#endif
}
向GetGI
添加一个Surface
参数,并让它将光照探针采样添加到漫反射中。
GI GetGI (float2 lightMapUV, Surface surfaceWS) {
GI gi;
gi.diffuse = SampleLightMap(lightMapUV) + SampleLightProbe(surfaceWS);
return gi;
}
最后,在LitPassFragment
中将Surface
传递给它。
GI gi = GetGI(GI_FRAGMENT_DATA(input), surface);
3.3 光照探针代理体(Light Probe Proxy Volumes)
光照探针适用于比较小的动态物体,因为光照是基于单点的,所以不适用于较大的物体。 例如,我在场景中添加了两个拉伸的立方体。 因为它们的位置位于黑暗区域,立方体都是均匀的黑暗,尽管这显然与光照不匹配。
我们可以通过使用一个光照探针代理体(Light Probe Proxy Volume,简称LPPV)来解决这个限制。 最简单的方法是向每个立方体添加一个LightProbeProxyVolume
组件,然后将它们的Light Probes
模式设置为Use Proxy Volume
。
为什么在场景视图中看不到探针?
.
当LPPV的Refresh Mode
设置为Automatic
时,它们可能不会显示。 在这种情况下,你可以临时设置为Every Frame
。
3.4 采集光照探针代理体(Sampling LPPVs)
LPPV还需要将每个对象的数据发送给GPU,所以我们必须启用PerObjectData.LightProbeProxyVolume
。
perObjectData =
PerObjectData.Lightmaps | PerObjectData.LightProbe |
PerObjectData.LightProbeProxyVolume
UnityPerDraw
需要添加四个额外的值:unity_ProbeVolumeParams
, unity_ProbeVolumeWorldToObject
, unity_ProbeVolumeSizeInv
和unity_ProbeVolumeMin
。 第二个是一个矩阵,其他都是四维向量。
CBUFFER_START(UnityPerDraw)
…
float4 unity_ProbeVolumeParams;
float4x4 unity_ProbeVolumeWorldToObject;
float4 unity_ProbeVolumeSizeInv;
float4 unity_ProbeVolumeMin;
CBUFFER_END
数据存储在一个称为unity_ProbeVolumeSH
的3D浮点纹理中。通过TEXTURE3D_FLOAT
宏添加到GI
,包括它的采样状态。
TEXTURE3D_FLOAT(unity_ProbeVolumeSH);
SAMPLER(samplerunity_ProbeVolumeSH);
使用LPPV或插值光照探针是通过unity_ProbeVolumeParams
的第一个分量进行通信的。 如果被设置了,则必须通过SampleProbeVolumeSH4
函数对体积进行采样。 我们必须传递纹理和采样,然后是世界位置和法线。然后是矩阵、unity_ProbeVolumeParams
的Y和Z分量,然后是unity_ProbeVolumeMin
和unity_ProbeVolumeSizeInv
数据的XYZ分量。
if (unity_ProbeVolumeParams.x) {
return SampleProbeVolumeSH4(
TEXTURE3D_ARGS(unity_ProbeVolumeSH, samplerunity_ProbeVolumeSH),
surfaceWS.position, surfaceWS.normal,
unity_ProbeVolumeWorldToObject,
unity_ProbeVolumeParams.y, unity_ProbeVolumeParams.z,
unity_ProbeVolumeMin.xyz, unity_ProbeVolumeSizeInv.xyz
);
}
else {
float4 coefficients[7];
coefficients[0] = unity_SHAr;
coefficients[1] = unity_SHAg;
coefficients[2] = unity_SHAb;
coefficients[3] = unity_SHBr;
coefficients[4] = unity_SHBg;
coefficients[5] = unity_SHBb;
coefficients[6] = unity_SHC;
return max(0.0, SampleSH9(coefficients, surfaceWS.normal));
}
对LPPV进行采样需要对体积空间进行变换,以及一些其他计算,包括体积纹理采样和球谐函数的应用。 在这种情况下,只应用L1球谐函数,因此结果不太精确,但可以在单个物体的表面上变化。
4. 元通道(Meta Pass)
因为间接漫反射光从表面反射,它应该受到这些表面的漫反射率的影响。目前这种情况还没有发生。 Unity将我们的表面视为统一的白色。Unity使用一个特殊的Meta Pass
来确定在烘焙时的反射光。 因为我们还没有定义这样的Pass, Unity使用默认的Pass,它最终是白色的。
4.1 统一的输入(Unified Input)
添加另一个通道意味着我们必须再次定义着色器属性。 让我们从LitPass
中提取基本纹理和UnityPerMaterial
缓冲,并把它放在一个新的Shaders/LitInput.hlsl
文件中。我们还将通过引入TransformBaseUV
、GetBase
、GetCutoff
、GetMetallic
和GetSmoothness
函数来隐藏实例化代码。 给它们一个基本的UV参数,即使它没有被使用。 值是否从映射中检索也以这种方式隐藏。
#ifndef CUSTOM_LIT_INPUT_INCLUDED
#define CUSTOM_LIT_INPUT_INCLUDED
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
UNITY_DEFINE_INSTANCED_PROP(float, _Metallic)
UNITY_DEFINE_INSTANCED_PROP(float, _Smoothness)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
float2 TransformBaseUV (float2 baseUV) {
float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
return baseUV * baseST.xy + baseST.zw;
}
float4 GetBase (float2 baseUV) {
float4 map = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, baseUV);
float4 color = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
return map * color;
}
float GetCutoff (float2 baseUV) {
return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff);
}
float GetMetallic (float2 baseUV) {
return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Metallic);
}
float GetSmoothness (float2 baseUV) {
return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);
}
#endif
为了在Lit
的所有通道中引用这个文件,在Lit
的SubShader
块的顶部添加一个HLSLINCLUDE
块。 在这里包含Common
,然后是LitInput
。 这段代码将被插入到所有通道。
SubShader {
HLSLINCLUDE
#include "../ShaderLibrary/Common.hlsl"
#include "LitInput.hlsl"
ENDHLSL
…
}
从LitPass
中删除现在重复的引用语句和声明。
//#include "../ShaderLibrary/Common.hlsl"
…
//TEXTURE2D(_BaseMap);
//SAMPLER(sampler_BaseMap);
//UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
//…
//UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
在LitPassVertex
中使用TransformBaseUV
。
//float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
output.baseUV = TransformBaseUV(input.baseUV);
以及在LitPassFragment
中检索着色器属性的相关函数。
//float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV);
//float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
float4 base = GetBase(input.baseUV);
#if defined(_CLIPPING)
clip(base.a - GetCutoff(input.baseUV));
#endif
…
surface.metallic = GetMetallic(input.baseUV);
surface.smoothness = GetSmoothness(input.baseUV);
对ShadowCasterPass
同样的处理。
4.2 无光照(Unlit)
让我们对Unlit
着色器也这样做。 复制LitInput
。并将其重命名为UnlitInput.hlsl
。 然后从UnityPerMaterial
中移除_Metallic
和_Smoothness
。 保留GetMetallic
和GetSmoothness
函数,并使它们返回0.0,表示一个非常平淡的漫反射表面。之后也给着色器一个HLSLINCLUDE
块。
HLSLINCLUDE
#include "../ShaderLibrary/Common.hlsl"
#include "UnlitInput.hlsl"
ENDHLSL
转换UnlitPass
,就像我们对LitPass
做的那样。 请注意,ShadowCasterPass
对于两个着色器都工作得很好,即使它以不同的输入定义结束。
4.3 元通道模式(Meta Light Mode)
为Lit
和Unlit
着色器添加一个新的通道,将LightMode
设置为Meta
。 这个通道要求裁剪总是关闭的,可以通过添加Cull Off
来配置。 它将使用MetaPassVertex
和MetaPassFragment
函数,在一个新的MetaPass.hlsl
文件中定义。它不需要multi-compile
指令。
Pass {
Tags {
"LightMode" = "Meta"
}
Cull Off
HLSLPROGRAM
#pragma target 3.5
#pragma vertex MetaPassVertex
#pragma fragment MetaPassFragment
#include "MetaPass.hlsl"
ENDHLSL
}
我们需要知道表面的漫反射,所以我们必须在MetaPassFragment
中获得它的BRDF
数据。 因此,我们必须引用BRDF
,以及Surface
,Shadows
和Light
。我们只需要知道对象空间的位置和基础UV,最初将裁剪空间的位置设置为零。 Surface可以通过ZERO_INITIALIZE(Surface, surface)
初始化为0,之后我们只需要设置它的颜色、金属度和平滑度值。 这足以获得BRDF数据,但我们将从返回0开始。
#ifndef CUSTOM_META_PASS_INCLUDED
#define CUSTOM_META_PASS_INCLUDED
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Shadows.hlsl"
#include "../ShaderLibrary/Light.hlsl"
#include "../ShaderLibrary/BRDF.hlsl"
struct Attributes {
float3 positionOS : POSITION;
float2 baseUV : TEXCOORD0;
};
struct Varyings {
float4 positionCS : SV_POSITION;
float2 baseUV : VAR_BASE_UV;
};
Varyings MetaPassVertex (Attributes input) {
Varyings output;
output.positionCS = 0.0;
output.baseUV = TransformBaseUV(input.baseUV);
return output;
}
float4 MetaPassFragment (Varyings input) : SV_TARGET {
float4 base = GetBase(input.baseUV);
Surface surface;
ZERO_INITIALIZE(Surface, surface);
surface.color = base.rgb;
surface.metallic = GetMetallic(input.baseUV);
surface.smoothness = GetSmoothness(input.baseUV);
BRDF brdf = GetBRDF(surface);
float4 meta = 0.0;
return meta;
}
#endif
一旦Unity用我们自己的元通道再次烘焙场景,所有的间接照明都将消失,因为黑色表面不会反射任何东西。
4.4 光照贴图坐标(Light Map Coordinates)
就像对光照贴图进行采样一样,我们需要使用光照贴图UV坐标。 不同的是,这次我们在相反的方向上使用,将它们用于XY对象空间的位置。 在此之后,我们必须将其提供给TransformWorldToHClip
,尽管在本例中,该函数执行的转换与其名称不匹配。
struct Attributes {
float3 positionOS : POSITION;
float2 baseUV : TEXCOORD0;
float2 lightMapUV : TEXCOORD1;
};
…
Varyings MetaPassVertex (Attributes input) {
Varyings output;
input.positionOS.xy =
input.lightMapUV * unity_LightmapST.xy + unity_LightmapST.zw;
output.positionCS = TransformWorldToHClip(input.positionOS);
output.baseUV = TransformBaseUV(input.baseUV);
return output;
}
我们仍然需要对象空间顶点属性作为输入,因为着色器希望它存在。 事实上,除非OpenGL显式地使用Z坐标,否则它不会工作。 我们将使用与Unity自己的元通道使用的相同的虚拟赋值,即input.positionOS.z > 0.0 ? FLT_MIN: 0.0
。
input.positionOS.xy =
input.lightMapUV * unity_LightmapST.xy + unity_LightmapST.zw;
input.positionOS.z = input.positionOS.z > 0.0 ? FLT_MIN : 0.0;
4.5 漫反射率(Diffuse Reflectivity)
Meta Pass
可以用来生成不同的数据。 请求的内容通过bool4 unity_MetaFragmentControl
标记向量来传达。
bool4 unity_MetaFragmentControl;
如果设置了X标记,则请求漫反射,因此将其作为RGB分量的结果。 A分量应该设置为1。
float4 meta = 0.0;
if (unity_MetaFragmentControl.x) {
meta = float4(brdf.diffuse, 1.0);
}
return meta;
这足以给反射光上色,但Unity的元通道将结果提高了一些,通过增加一半的镜面反射率和粗糙度进行缩放。这背后的想法是强烈的高光但粗糙的材质也传递一些间接的光。
meta = float4(brdf.diffuse, 1.0);
meta.rgb += brdf.specular * brdf.roughness * 0.5;
在此之后,通过使用PositivePow
方法将结果提升到unity_OneOverOutputBoost
提供的功率,然后将其限制为unity_MaxOutputValue
,从而修改结果。
meta.rgb += brdf.specular * brdf.roughness * 0.5;
meta.rgb = min(
PositivePow(meta.rgb, unity_OneOverOutputBoost), unity_MaxOutputValue
);
这些值是作为浮点数提供的。
float unity_OneOverOutputBoost;
float unity_MaxOutputValue;
现在我们得到正确的间接照明颜色,也可以将其应用到GetLighting
。
float3 color = gi.diffuse * brdf.diffuse;
让我们再次打开环境照明将亮度调回到1。
最后,设置光源的模式为Mixed
。 这使它再次成为一个有这所有烘焙的间接漫反射光照的实时光。
5. 自发光表面(Emissive Surfaces)
有些表面会发出自己的光,因此即使在没有其他照明的情况下也能被看到。这可以通过简单地在LitPassFragment
的末尾添加一些颜色来完成。 但这不是一个真正的光源,所以它不会影响其他表面。然而,这种效果有助于烘焙光照。
5.1 自发光(Emitted Light)
给Lit着色器添加两个新属性:一个自发光贴图和自发光颜色,就像基础贴图和颜色一样。我们将对两者使用相同的坐标转换,因此我们不需要为自发光贴图显示单独的控件。 它们可以通过赋予NoScaleOffset
属性来隐藏。为了支持非常明亮的自发光,添加HDR属性到颜色中。这使得通过面板配置亮度大于1的颜色成为可能,显示一个HDR颜色窗口而不是常规颜色窗口。
[NoScaleOffset] _EmissionMap("Emission", 2D) = "white" {}
[HDR] _EmissionColor("Emission", Color) = (0.0, 0.0, 0.0, 0.0)
将贴图添加到LitInput
,并将自发光颜色添加到UnityPerMaterial
。 然后添加一个GetEmission
函数,它的工作原理与GetBase
类似,只是它使用了另一个纹理和颜色。
TEXTURE2D(_BaseMap);
TEXTURE2D(_EmissionMap);
SAMPLER(sampler_BaseMap);
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_DEFINE_INSTANCED_PROP(float4, _EmissionColor)
…
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
…
float3 GetEmission (float2 baseUV) {
float4 map = SAMPLE_TEXTURE2D(_EmissionMap, sampler_BaseMap, baseUV);
float4 color = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _EmissionColor);
return map.rgb * color.rgb;
}
在LitPassFragment
的结尾添加自发光。
float3 color = GetLighting(surface, brdf, gi);
color += GetEmission(input.baseUV);
return float4(color, surface.alpha);
另外,在UnlitInput
中添加一个GetEmission
函数。 在这个例子中,我们简单地使它成为GetBase
的代理函数。 因此,如果你烘焙一个无光照的物体,它最终会发射完整的颜色。
float3 GetEmission (float2 baseUV) {
return GetBase(baseUV).rgb;
}
为了让无光照的材质发出非常明亮的光,我们可以将HDR属性添加到无光照的基本颜色属性中。
[HDR] _BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
最后,让我们添加自发光颜色到PerObjectMaterialProperties
。在这种情况下,我们可以通过配置字段的ColorUsage
属性来允许HDR输入。 我们需要传递两个布尔值。 第一个指示是否必须显示alpha通道,这是我们不需要的。 第二个指示是否允许HDR值。
static int
baseColorId = Shader.PropertyToID("_BaseColor"),
cutoffId = Shader.PropertyToID("_Cutoff"),
metallicId = Shader.PropertyToID("_Metallic"),
smoothnessId = Shader.PropertyToID("_Smoothness"),
emissionColorId = Shader.PropertyToID("_EmissionColor");
…
[SerializeField, ColorUsage(false, true)]
Color emissionColor = Color.black;
…
void OnValidate () {
…
block.SetColor(emissionColorId, emissionColor);
GetComponent().SetPropertyBlock(block);
我在场景中添加了一些小的自发光立方体。 我让它们服务于全局照明,并在Lightmap
中将其Scale
加倍,以避免UV坐标重叠的警告。当顶点在光照贴图中靠得太近时,就会发生这种情况,因此它们必须共享相同的texel。
5.2 烘焙自发光(Baked Emission)
自发光通过单独的通道烘烤。 当unity_MetaFragmentControl
的Y
标志被设置时,MetaPassFragment
应该返回发出的光,同样将A组件设置为1。
if (unity_MetaFragmentControl.x) {
…
}
else if (unity_MetaFragmentControl.y) {
meta = float4(GetEmission(input.baseUV), 1.0);
}
但这并不是自动发生的。我们必须启用每一种材质的自发光烘焙。我们可以通过在PerObjectMaterialProperties.OnGUI
的编辑器上调用LightmapEmissionProperty
来显示配置选项。
public override void OnGUI (
MaterialEditor materialEditor, MaterialProperty[] properties
) {
EditorGUI.BeginChangeCheck();
base.OnGUI(materialEditor, properties);
editor = materialEditor;
materials = materialEditor.targets;
this.properties = properties;
BakedEmission();
…
}
void BakedEmission () {
editor.LightmapEmissionProperty();
}
这会显示一个Global Illumination
下拉菜单,它最初被设置为None
。 除了名字,它只影响烘焙自发光。将它更改为Baked
会告诉光照贴图为自发光使用一个单独的通道。还有一个Realtime
选项,但已弃用。
这仍然不能工作,因为Unity积极地试图避免在烘焙时使用单独的自发光通道。 如果材质的自发光设置为零,那么它将被忽略。 然而,这并没有考虑到每个对象的材质属性。当自发光模式改变时, 我们可以通过globalIlluminationFlags
禁用所有选定材质的MaterialGlobalIlluminationFlags.EmissiveIsBlack
标记。这意味着您应该只在需要时启用Baked选项。
void BakedEmission () {
EditorGUI.BeginChangeCheck();
editor.LightmapEmissionProperty();
if (EditorGUI.EndChangeCheck()) {
foreach (Material m in editor.targets) {
m.globalIlluminationFlags &=
~MaterialGlobalIlluminationFlags.EmissiveIsBlack;
}
}
}
6. 烘焙透明度(Baked Transparency)
也可以烘烤透明物体,但这需要一点额外的工作。
6.1 硬编码属性(Hard-Coded Properties)
不幸的是,Unity的光照贴图有一个硬编码的透明方式。它会查看材质的序列,以确定它是不透明的、剪切的还是透明的。然后,它通过将_MainTex
和_Color
属性的alpha
分量相乘来确定透明度,并使用_Cutoff
属性进行透明度剪切。 我们的着色器有第三个方式,但缺少前两个。 目前唯一的方法是将预期的属性添加到我们的着色器中,给它们HideInInspector
属性,这样它们就不会显示在面板中。 Unity的SRP着色器也必须处理同样的问题。
[HideInInspector] _MainTex("Texture for Lightmap", 2D) = "white" {}
[HideInInspector] _Color("Color for Lightmap", Color) = (0.5, 0.5, 0.5, 1.0)
6.2 拷贝属性(Copying Properties)
我们必须确保_MainTex
属性指向与_BaseMap
相同的纹理,并使用相同的UV转换。 这两个颜色属性也必须相同。 我们可以在CustomShaderGUI.OnGUIOnGUI
结束时调用一个新的CopyLightMappingProperties
方法来实现这一点。 如果相关属性存在,复制它们的值。
public override void OnGUI (
MaterialEditor materialEditor, MaterialProperty[] properties
) {
…
if (EditorGUI.EndChangeCheck()) {
SetShadowCasterPass();
CopyLightMappingProperties();
}
}
void CopyLightMappingProperties () {
MaterialProperty mainTex = FindProperty("_MainTex", properties, false);
MaterialProperty baseMap = FindProperty("_BaseMap", properties, false);
if (mainTex != null && baseMap != null) {
mainTex.textureValue = baseMap.textureValue;
mainTex.textureScaleAndOffset = baseMap.textureScaleAndOffset;
}
MaterialProperty color = FindProperty("_Color", properties, false);
MaterialProperty baseColor =
FindProperty("_BaseColor", properties, false);
if (color != null && baseColor != null) {
color.colorValue = baseColor.colorValue;
}
}
这也适用于裁剪材质。尽管有可能在MetaPassFragment
中裁剪片元是不需要的,因为透明度是单独处理的。
不幸的是,这意味着烘焙的透明度只能依赖于单一的纹理、颜色和裁剪属性。 此外,光照贴图只考虑材质的属性而忽略每个实例的属性。
7. 网格球体(Mesh Ball)
最后,我们为MeshBall
生成的实例添加了对全局照明的支持。 因为它的实例是在游戏模式下生成的,所以它们不能被烘焙,但如果多做一些工作,它们就可以通过光照探针接收烘焙的光照。
7.1 光照探针(Light Probes)
光照探针应该通过调用DrawMeshInstanced
方法来使用,该方法需要另外5个参数。
using UnityEngine;
using UnityEngine.Rendering;
public class MeshBall : MonoBehaviour {
…
void Update () {
if (block == null) {
block = new MaterialPropertyBlock();
block.SetVectorArray(baseColorId, baseColors);
block.SetFloatArray(metallicId, metallic);
block.SetFloatArray(smoothnessId, smoothness);
}
Graphics.DrawMeshInstanced(
mesh, 0, material, matrices, 1023, block,
ShadowCastingMode.On, true, 0, null, LightProbeUsage.CustomProvided
);
}
我们必须手动为所有实例生成插值的光照探针,并将它们添加到材质属性块。 这意味着我们在配置属性块时需要访问实例的位置。 我们可以通过获取它们的变换矩阵的最后一列来检索它们,并将它们存储在一个临时数组中。
if (block == null) {
block = new MaterialPropertyBlock();
block.SetVectorArray(baseColorId, baseColors);
block.SetFloatArray(metallicId, metallic);
block.SetFloatArray(smoothnessId, smoothness);
var positions = new Vector3[1023];
for (int i = 0; i < matrices.Length; i++) {
positions[i] = matrices[i].GetColumn(3);
}
}
光照探针必须通过SphericalHarmonicsL2
类型的数组提供。它通过调用LightProbes.CalculateInterpolatedLightAndOcclusionProbes
来填充,使用位置和光探头阵列作为参数。 还有第三个参数用于遮挡,我们将使用null
。
for (int i = 0; i < matrices.Length; i++) {
positions[i] = matrices[i].GetColumn(3);
}
var lightProbes = new SphericalHarmonicsL2[1023];
LightProbes.CalculateInterpolatedLightAndOcclusionProbes(
positions, lightProbes, null
);
我们不能在这里使用 List 吗?
·
可以,有一个CalculateInterpolatedLightAndOcclusionProbes
的变体。 但是我们只需要使用一次数据,所以在这种情况下,列表对我们没有益处。
之后,我们可以通过CopySHCoefficientArraysFrom
将光照探针复制到block
中。
LightProbes.CalculateInterpolatedLightAndOcclusionProbes(
positions, lightProbes, null
);
block.CopySHCoefficientArraysFrom(lightProbes);
7.2 光照探针代理体(LPPV)
另一种方法是使用LPPV。 这是有意义的,因为所有实例都存在于一个狭小的空间中。 这使我们不必计算和存储插值的光照探针。此外,它使实例位置动画化成为可能,而不必每帧提供新的光照探针数据,只要他们保持在探针体内。
添加一个LightProbeProxyVolume配置字段。 如果它在使用中,那么不要添加光照探针数据到块中。然后将LightProbeUsage.UseProxyVolume
传递给DrawMeshInstanced
,而不是LightProbeUsage.CustomProvided
。 我们总是可以将体积作为附加参数提供,即使它为null
且未被使用。
[SerializeField]
LightProbeProxyVolume lightProbeVolume = null;
…
void Update () {
if (block == null) {
…
if (!lightProbeVolume) {
var positions = new Vector3[1023];
…
block.CopySHCoefficientArraysFrom(lightProbes);
}
}
Graphics.DrawMeshInstanced(
mesh, 0, material, matrices, 1023, block,
ShadowCastingMode.On, true, 0, null,
lightProbeVolume ?
LightProbeUsage.UseProxyVolume : LightProbeUsage.CustomProvided,
lightProbeVolume
);
}
你可以添加一个LPPV组件到网格球或把它放在其他地方。 自定义边界模式可用于LPPV所占用的世界空间区域。