Post

自定义管线:烘焙光照 (翻译五)

自定义管线:烘焙光照 (翻译五)
  • 烘焙静态全局光照
  • 创建Meta pass
  • 支持自发光

烘焙静态光照

到目前为止,我们都是在渲染时计算所有光照,但这并不是唯一的选择。光照也可以预先计算并存储在光照贴图和探针中。这样做有两个主要原因:减少实时计算量,以及添加在运行时无法计算间接光照。后者就是所谓全局光照(Global Illumination)的一部分:光照不是直接从光源发出,而是通过反射、环境或自发光表面间接到达。

烘焙光照的缺点是它是静态的,无法在运行时改变。它还需要存储,这会增加构建大小和内存使用。

场景光照设置

全局光照是通过场景的 Lighting 窗口的 Scene 选项卡进行配置的。烘焙光照通过 Mixed Lighting 下的 Baked Global Illumination 开关启用。也有一个 Lighting Mode 选项,我们将其设置为 Baked Indirect,这意味着我们烘焙所有静态间接光照。

Baked indirect lighting only
Baked indirect lighting only

如果你的项目是在 Unity 2019.2 或更早版本创建的,你还会看到一个启用实时光照的选项,应该禁用它。如果你的项目是在 Unity 2019.3 或更高版本创建的,该选项不会显示。

再往下是 Lightmapping Settings 部分,用于控制光照贴图的处理过程。我将使用默认设置,但 LightMap Resolution 降低到 20,Compress Lightmaps 禁用,Directional Mode 设置为 Non-Directional。我还使用 Progressive GPU 光照贴图器。

Lightmapping settings
Lightmapping settings

静态对象

为了演示烘焙光照,我创建了一个场景,地面是一个绿色平面,几个立方体和球体,以及中间的一个结构,只有一面开放,这样内部是完全阴影的。

Scene with dark interior
Scene with dark interior
Same scene without ceiling
Same scene without ceiling

场景有一个单一方向光,Mode 设置为 Mixed。这告诉 Unity 应该为这个光源烘焙间接光照。除此之外,这个光源仍然像常规实时光源一样工作。

Mixed-mode light
Mixed-mode light

我还把地面平面和所有立方体(包括构成结构的那些)包含在烘焙过程中。它们将是光线反弹的物体,从而成为间接光。这是通过启用它们 MeshRenderer 组件的 Contribute Global Illumination 开关来完成的。启用这还会自动将它们的 Receive Global Illumination 模式切换为 Lightmaps,这意味着到达它们表面的间接光会被烘焙到光照贴图中。你也可以通过从对象的 Static 下拉列表中启用 Contribute GI,或者使其完全静态来启用此模式。

Contribute global illumination enabled
Contribute global illumination enabled

一旦启用,场景的光照将被重新烘焙,假设 Lighting 窗口中启用了 Auto Generate,否则你必须按 Generate Lighting 按钮。Lightmapping 设置也会显示在 MeshRenderer 组件中,包括包含该对象的光照贴图的视图。

Map of baked received indirect light
Map of baked received indirect light

球体没有出现在光照贴图中,因为它们不对全局光照做出贡献,因此被视为动态的。它们将不得不依赖光照探针,我们稍后会介绍。静态对象也可以通过将 Receive Global Illumination 模式切换回 Light Probes 从贴图中排除。它们仍然会影响烘焙结果,但不会占用光照贴图中的空间。

全烘焙光照

烘焙光照主要是蓝色的,因为它由代表环境天空间接光照的天空盒主导。中心建筑周围较亮的区域是由光线从地面和墙壁反弹的间接光照引起的。

我们也可以将所有光照烘焙到贴图中,包括直接光和间接光。这是通过将光源的 Mode 设置为 Baked 来完成的。它不再提供实时光照。

No realtime lighting
No realtime lighting

实际上,烘焙光源的直接光也被视为间接光,因此最终进入贴图,使其变得更亮。

Map of fully baked light
Map of fully baked light

采样烘焙光照

目前所有东西都被渲染为纯黑色,因为没有实时光照,而且我们的着色器还不知道全局光照。我们必须对光照贴图进行采样才能使其工作。

全局光照

创建一个新的 ShaderLibrary/GI.hlsl 文件来包含所有与全局光照相关的代码。在其中定义一个 GI 结构和一个 GetGI 函数,给定一些光照贴图 UV 坐标来检索它。间接光来自所有方向,因此只能用于漫反射照明,不能用于高光。所以给 GI 结构一个漫反射颜色字段。最初用光照贴图 UV 填充它用于调试。

1
2
3
4
5
6
7
8
9
10
11
12
13
#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

在 GetLighting 中添加一个 GI 参数,并使用它在累积实时光照之前初始化颜色值。此时我们不将它乘以表面的漫反射率,这样我们可以看到未修改的接收光照。

1
2
3
4
5
6
float3 GetLighting (Surface surfaceWS, BRDF brdf, GI gi) {
    ShadowData shadowData = GetShadowData(surfaceWS);
    float3 color = gi.diffuse;
    ...
    return color;
}

在 LitPass 中,在 Lighting 之前 Include GI。

1
2
#include "../ShaderLibrary/GI.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"

在 LitPassFragment 中获取全局光照数据,最初使用零 UV 坐标,并将其传递给 GetLighting。

1
2
GI gi = GetGI(0.0);
float3 color = GetLighting(surface, brdf, gi);

光照贴图坐标

为了获取光照贴图坐标,Unity 必须将它们发送到着色器。我们必须指示管线为每个被光照贴图化的对象执行此操作。这是通过将绘图设置的 perObjectData 属性设置为 PerObjectData.Lightmaps 来完成的。

1
2
3
4
5
6
7
var drawingSettings = new DrawingSettings(
    unlitShaderTagId, sortingSettings
) {
    enableDynamicBatching = useDynamicBatching,
    enableInstancing = useGPUInstancing,
    perObjectData = PerObjectData.Lightmaps
};

Unity 现在将使用具有 LIGHTMAP_ON 关键字的着色器变体来渲染被光照贴图化的对象。在我们 Lit 着色器的 CustomLit pass 中添加一个 multi-compile 指令。

1
2
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile_instancing

光照贴图坐标是 Attributes 顶点数据的一部分。我们必须将它们传输到 Varyings 以便在 LitPassFragment 中使用。但我们只应在需要时执行此操作。我们可以使用类似于传输实例化标识符的方法,依赖 GI_ATTRIBUTE_DATAGI_VARYINGS_DATATRANSFER_GI_DATA 宏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 所需的参数。

1
GI gi = GetGI(GI_FRAGMENT_DATA(input));

我们必须自己定义这些宏,最初将它们定义为空,除了 GI_FRAGMENT_DATA 简单地返回零。宏的参数列表就像函数的一样,只是没有类型,并且宏名称和参数列表之间不允许有空格,否则该列表会被解释为宏定义的内容。

1
2
3
4
#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 语义。

1
2
3
4
5
6
7
8
9
10
11
#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
Light map coordinates
Light map coordinates

所有静态烘焙对象现在都显示它们的 UV,而所有动态对象保持黑色。

变换后的光照贴图坐标

光照贴图坐标通常由 Unity 自动为每个网格生成,或者是导入网格数据的一部分。它们定义了一个纹理展开,将网格展平以便映射到纹理坐标。展开在光照贴图中按对象进行缩放和定位,因此每个实例获得自己的空间。这就像应用于基础 UV 的缩放和变换一样。我们必须对光照贴图 UV 应用相同的变换。

光照贴图 UV 变换作为 UnityPerDraw buffer 的一部分传递给 GPU,所以在那里添加它。它被称为 unity_LightmapST。尽管它已被弃用,在它之后还要添加 unityDynamicLightmapST,否则 SRP batcher 兼容性可能会破坏。

1
2
3
4
5
6
7
8
CBUFFER_START(UnityPerDraw)
    float4x4 unity_ObjectToWorld;
    float4x4 unity_WorldToObject;
    float4 unity_LODFade;
    real4 unity_WorldTransformParams;
    float4 unity_LightmapST;
    float4 unityDynamicLightmapST;
CBUFFER_END

光照贴图与 GPU 实例化一起工作吗? 是的。所有 UnityPerDraw 数据在需要时都会被实例化。

然后调整 TRANSFER_GI_DATA 宏以应用变换。宏定义可以分成多行,只要每行最后一行除外都有反斜杠。

1
2
3
#define TRANSFER_GI_DATA(input, output) \
    output.lightMapUV = input.lightMapUV * \
    unity_LightmapST.xy + unity_LightmapST.zw;
Transformed light map coordinates
Transformed light map coordinates

采样光照贴图

采样光照贴图是 GI 的职责。光照贴图纹理被称为 unity_Lightmap,带有相应的采样器状态。同时引入 Core RP Library 的 EntityLighting.hlsl,因为我们将使用它来检索光照数据。

1
2
3
4
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/EntityLighting.hlsl"

TEXTURE2D(unity_Lightmap);
SAMPLER(samplerunity_Lightmap);

创建一个 SampleLightMap 函数,当有光照贴图时调用 SampleSingleLightmap,否则返回零。在 GetGI 中使用它来设置漫反射光。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 宏。

1
2
3
return SampleSingleLightmap(
    TEXTURE2D_ARGS(unity_Lightmap, samplerunity_Lightmap), lightMapUV
);

然后是要应用的缩放和变换。因为我们之前已经做了,我们将在这里使用恒等变换。

1
2
3
4
return SampleSingleLightmap(
    TEXTURE2D_ARGS(unity_Lightmap, samplerunity_Lightmap), lightMapUV,
    float4(1.0, 1.0, 0.0, 0.0)
);

然后是一个布尔值,指示光照贴图是否压缩,这在 UNITY_LIGHTMAP_FULL_HDR 未定义时为 true。最后一个参数是一个包含解码指令的 float4。使用 LIGHTMAP_HDR_MULTIPLIER 作为其第一个分量,LIGHTMAP_HDR_EXPONENT 作为第二个。其他分量不使用。

1
2
3
4
5
6
7
8
9
10
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)
);
Sampled baked light
Sampled baked light

禁用环境光照

烘焙光照相当明亮,因为它还包括来自天空的间接光照。我们可以通过将 Intensity Multiplier 设为零来禁用它。这允许我们聚焦于单个方向光。

Environment intensity set to zero
Environment intensity set to zero

注意,结构内部现在被间接照亮,主要是通过地面。

我们也可以烘焙其他类型的光源吗? 是的,尽管我们目前只关注方向光。其他光源类型会烘焙,但需要一些额外的工作才能正确烘焙。

光照探针

动态对象不影响烘焙的全局光照,但可以通过光照探针受到影响。光照探针是场景中的一个点,通过用三阶多项式(具体来说是 L2 球谐函数)近似,烘焙了所有传入的光照。光照探针放置在场景中,Unity 在每个对象上在它们之间进行插值,以到达其位置的最终光照近似值。

光照探针组

通过创建光照探针组(GameObject / Light / Light Probe Group)将光照探针添加到场景中。这会创建一个具有 LightProbeGroup 组件的游戏对象,默认情况下该组件包含六个探针,形成立方体形状。当启用 Edit Light Probes 时,你可以移动、复制和删除单个探针,就像它们是游戏对象一样。

Editing light probe group inside structure
Editing light probe group inside structure

场景中可以有多个探针组。Unity 组合所有探针,然后创建一个连接它们的四面体体积网格。每个动态对象最终位于一个四面体内。顶点处的四个探针被插值以到达应用于对象的最终光照。如果对象最终位于探针覆盖的区域之外,则使用最近的三角形,因此光照可能会显得奇怪。

Light probes used by selected objects
Light probes used by selected objects

你把光照探针放在哪里取决于场景。首先,它们只需要在动态对象会出现的地方。其次,把它们放在光照变化的地方。每个探针都是插值的端点,所以把它们放在光照过渡的地方。第三,不要把探针放在烘焙几何体内部,因为它们最终会变黑。最后,插值会穿过物体,所以如果墙的两侧光照不同,把探针放在墙的两侧附近。这样就没有物体会插值到两侧。除此之外,你必须自己实验。

Showing all light probes
Showing all light probes

采样探针

插值的光照探针数据必须通过每个对象传递给 GPU。我们必须告诉 Unity 这样做,这次通过 PerObjectData.LightProbe 而不是 PerObjectData.Lightmaps。我们需要启用两个特性标志,所以用布尔 OR 运算符组合它们。

1
perObjectData = PerObjectData.Lightmaps | PerObjectData.LightProbe

所需的 UnityPerDraw 数据由七个 float4 向量组成,代表红、绿、蓝光的多项式分量。它们名为 unity_SH*,其中 * 是 A、B 或 C。前两个有三个带 r、g 和 b 后缀的版本。

1
2
3
4
5
6
7
8
9
10
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

我们在 GI 中通过一个新的 SampleLightProbe 函数采样光照探针。我们需要一个方向,所以给它一个世界空间表面参数。

如果此对象使用了光照贴图,则返回零。否则返回 max(0.0, SampleSH9)。该函数需要探针数据和法线向量作为参数。探针数据必须作为系数数组提供。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 中添加一个表面参数,并让它将光照探针样本添加到漫反射光。

1
2
3
4
5
GI GetGI (float2 lightMapUV, Surface surfaceWS) {
    GI gi;
    gi.diffuse = SampleLightMap(lightMapUV) + SampleLightProbe(surfaceWS);
    return gi;
}

最后,在 LitPassFragment 中将表面传递给它。

1
GI gi = GetGI(GI_FRAGMENT_DATA(input), surface);
Sampling light probes
Sampling light probes

光照探针代理体

光照探针适用于相当小的动态对象,但由于光照基于单个点,它对较大的对象效果不佳。举例来说,我在场景中添加了两个拉伸的立方体。由于它们的位置在黑暗区域,立方体是均匀的黑暗,即使显然这与光照不匹配。

Large objects sampling from one position
Large objects sampling from one position

我们可以通过使用光照探针代理体(LPPV)来克服这个限制。最简单的方法是向每个立方体添加一个 LightProbeProxyVolume 组件,然后将它们的 Light Probes 模式设置为 Use Proxy Volume。

这些体积可以通过多种方式配置。在这种情况下,我使用自定义分辨率模式沿立方体的边缘放置子探针,这样它们是可见的。

Using LPPVs
Using LPPVs

为什么我在场景视图中看不到探针?

当 LPPV 的 Refresh Mode 设置为 Automatic 时,它们可能不会显示。在这种情况下,你可以暂时将其设置为 Every Frame。

采样 LPPV

LPPV 还需要通过每个对象将数据发送到 GPU。在这种情况下,我们必须启用 PerObjectData.LightProbeProxyVolume

1
2
3
perObjectData =
    PerObjectData.Lightmaps | PerObjectData.LightProbe |
    PerObjectData.LightProbeProxyVolume

必须向 UnityPerDraw 添加四个额外值:unity_ProbeVolumeParamsunity_ProbeVolumeWorldToObjectunity_ProbeVolumeSizeInvunity_ProbeVolumeMin。第二个是矩阵,其他是 4D 向量。

1
2
3
4
5
6
7
CBUFFER_START(UnityPerDraw)
    ...
    float4 unity_ProbeVolumeParams;
    float4x4 unity_ProbeVolumeWorldToObject;
    float4 unity_ProbeVolumeSizeInv;
    float4 unity_ProbeVolumeMin;
CBUFFER_END

体积数据存储在 3D 浮点纹理中,称为 unity_ProbeVolumeSH。通过 TEXTURE3D_FLOAT 宏将其添加到 GI,以及它的采样器状态。

1
2
TEXTURE3D_FLOAT(unity_ProbeVolumeSH);
SAMPLER(samplerunity_ProbeVolumeSH);

是使用 LPPV 还是插值光照探针通过 unity_ProbeVolumeParams 的第一个分量进行通信。如果已设置,我们必须通过 SampleProbeVolumeSH4 函数采样体积。我们必须传递纹理和采样器,然后是世界位置和法线。之后是矩阵,分别传递 unity_ProbeVolumeParams 的 Y 和 Z 分量,然后是 min 和 size-inv 数据的 XYZ 部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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));
}
Sampling LPPVs
Sampling LPPVs

采样 LPPV 需要变换到体积的空间,以及一些其他计算、体积纹理采样和球谐函数的应用。在这种情况下只应用 L1 球谐函数,所以结果不太精确,但可以在单个对象的表面上变化。

Sampling LPPVs
Sampling LPPVs

Meta Pass

由于间接漫反射光从表面反弹,它应该受到这些表面漫反射率的影响。目前这种情况没有发生。Unity 将我们的表面视为均匀白色。Unity 在烘焙时使用特殊的 meta pass 来确定反射光。由于我们没有定义这样的 pass,Unity 使用默认的,最终是白色的。

统一输入

添加另一个 pass 意味着我们必须再次定义着色器属性。让我们从 LitPass 中提取基础纹理和 UnityPerMaterial buffer,并将它们放在一个新的 Shaders/LitInput.hlsl 文件中。我们还通过引入 TransformBaseUVGetBaseGetCutoffGetMetallicGetSmoothness 函数来隐藏实例化代码。给它们全部一个基础 UV 参数,即使未使用。这样可以隐藏值是否从贴图中检索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#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 的所有 pass 中包含此文件,在其 SubShader 块的顶部添加一个 HLSLINCLUDE 块,在 pass 之前。在其中包含 Common,然后是 LitInput。这段代码将被插入到所有 pass 的开始处。

1
2
3
4
5
6
7
SubShader {
    HLSLINCLUDE
        #include "../ShaderLibrary/Common.hlsl"
        #include "LitInput.hlsl"
    ENDHLSL
    ...
}

从 LitPass 中删除现在重复的 include 语句和声明。

1
2
3
4
5
6
7
//#include "../ShaderLibrary/Common.hlsl"
...
//TEXTURE2D(_BaseMap);
//SAMPLER(sampler_BaseMap);
//UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
//...
//UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

在 LitPassVertex 中使用 TransformBaseUV。

1
2
//float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
output.baseUV = TransformBaseUV(input.baseUV);

并在 LitPassFragment 中使用相关函数检索着色器属性。

1
2
3
4
5
6
7
8
9
10
//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 同样的处理。

Unlit

我们也为 Unlit 着色器做同样的事情。复制 LitInput.hlsl 并重命名为 UnlitInput.hlsl。然后从其 UnityPerMaterial 版本中删除 _Metallic 和 _Smoothness。保留 GetMetallic 和 GetSmoothness 函数,让它们返回 0.0,代表一个非常暗淡的漫反射表面。之后,给着色器一个 HLSLINCLUDE 块。

1
2
3
4
HLSLINCLUDE
    #include "../ShaderLibrary/Common.hlsl"
    #include "UnlitInput.hlsl"
ENDHLSL

像对 LitPass 一样转换 UnlitPass。注意,ShadowCasterPass 对两个着色器都适用,即使它最终具有不同的输入定义。

Meta Light Mode

向 Lit 和 Unlit 着色器添加一个新 pass,将 LightMode 设置为 Meta。这个 pass 需要将剔除始终关闭,可以通过添加 Cull Off 选项来配置。它将使用在新的 MetaPass.hlsl 文件中定义的 MetaPassVertex 和 MetaPassFragment 函数。它不需要 multi-compile 指令。

1
2
3
4
5
6
7
8
9
10
11
12
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,最初将裁剪空间位置设置为零。表面可以通过 ZERO_INITIALIZE(Surface, surface) 初始化为 zero,之后我们只需要设置它的颜色、金属度和平滑度值。这足以获取 BRDF 数据,但我们将从返回零开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#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 使用我们自己的 meta pass 重新烘焙场景,所有间接光照都会消失,因为黑色表面不反射任何东西。

No more indirect light
No more indirect light

光照贴图坐标

就像采样光照贴图一样,我们需要使用光照贴图坐标。不同之处在于这次我们朝相反方向前进,使用它们作为 XY 对象空间位置。之后我们必须将其提供给 TransformWorldToHClip,尽管在这种情况下该函数执行与其名称建议的不同类型的变换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 自己的 meta pass 相同的虚拟赋值,即 input.positionOS.z > 0.0 ? FLT_MIN : 0.0

1
2
3
input.positionOS.xy =
    input.lightMapUV * unity_LightmapST.xy + unity_LightmapST.zw;
input.positionOS.z = input.positionOS.z > 0.0 ? FLT_MIN : 0.0;

漫反射率

meta pass 可用于生成不同的数据。请求的内容通过 bool4 向量 unity_MetaFragmentControl 标志传达。

1
bool4 unity_MetaFragmentControl;

如果设置了 X 标志,则请求漫反射率,所以使其成为 RGB 结果。A 分量应设为一。

1
2
3
4
5
float4 meta = 0.0;
if (unity_MetaFragmentControl.x) {
    meta = float4(brdf.diffuse, 1.0);
}
return meta;

这足以给反射光着色,但 Unity 的 meta pass 也会通过添加一半按粗糙度缩放的高光反射率来增强结果。高度高光但粗糙的材质也会传递一些间接光的想法。

1
2
meta = float4(brdf.diffuse, 1.0);
meta.rgb += brdf.specular * brdf.roughness * 0.5;

之后,通过使用 PositivePow 方法将结果提升到通过 unity_OneOverOutputBoost 提供的幂,然后将其限制为 unity_MaxOutputValue

1
2
3
4
meta.rgb += brdf.specular * brdf.roughness * 0.5;
meta.rgb = min(
    PositivePow(meta.rgb, unity_OneOverOutputBoost), unity_MaxOutputValue
);

这些值作为浮点数提供。

1
2
float unity_OneOverOutputBoost;
float unity_MaxOutputValue;
Colored indirect light, mostly green from the ground
Colored indirect light, mostly green from the ground

现在我们得到了正确颜色的间接光照,同时在 GetLighting 中也将其应用于接收表面的漫反射率。

1
float3 color = gi.diffuse * brdf.diffuse;
Properly colored baked lighting
Properly colored baked lighting

我们还通过将环境光照强度设置回一来重新打开环境光照。

With environment lighting
With environment lighting

最后,将光源的模式设置回 Mixed。它再次成为实时光源,所有间接漫反射光照都被烘焙。

Mixed lighting
Mixed lighting

自发光表面

有些表面本身发出光,因此在没有其他照明的情况下也能看到。这可以通过在 LitPassFragment 末尾简单添加一些颜色来实现。这不是一个真正的光源,所以它不会影响其他表面。但是,效果可以贡献给烘焙光照。

发出光

在 Lit 着色器中添加两个新属性:一个自发光贴图和颜色,就像基础贴图和颜色一样。然而,我们将对两者使用相同的坐标变换,所以我们不需要为自发光贴图显示单独的控制。可以通过给它 NoScaleOffset 属性来隐藏它。为了支持非常亮的自发光,为颜色添加 HDR 属性。这使得可以通过检查器配置亮度大于一的颜色,显示 HDR 颜色弹出窗口而不是常规颜色。

作为示例,我制作了一个使用 Default-Particle 纹理的不透明自发光材质,它包含一个圆形渐变,从而产生一个亮点。

1
2
[NoScaleOffset] _EmissionMap("Emission", 2D) = "white" {}
[HDR] _EmissionColor("Emission", Color) = (0.0, 0.0, 0.0, 0.0)
Material with emission set to white dots
Material with emission set to white dots

将贴图添加到 LitInput,将自发光颜色添加到 UnityPerMaterial。然后添加一个 GetEmission 函数,其工作方式与 GetBase 一样,只是使用另一个纹理和颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 末尾将自发光添加到最终颜色。

1
2
3
float3 color = GetLighting(surface, brdf, gi);
color += GetEmission(input.baseUV);
return float4(color, surface.alpha);

还要在 UnlitInput 中添加 GetEmission 函数。在这种情况下,我们只需让它成为 GetBase 的代理。因此,如果你烘焙一个无光照对象,它最终会发出其全部颜色。

1
2
3
float3 GetEmission (float2 baseUV) {
    return GetBase(baseUV).rgb;
}

为了使无光照材质能够发出非常亮的光,我们可以在 Unlit 的基础颜色属性中添加 HDR 属性。

1
[HDR] _BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)

最后,让我们将自发光颜色添加到 PerObjectMaterialProperties。在这种情况下,我们可以通过为配置字段提供 ColorUsage 属性来允许 HDR 输入。我们必须传递两个布尔值。第一个指示是否必须显示 alpha 通道,我们不需要。第二个指示是否允许 HDR 值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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<Renderer>().SetPropertyBlock(block);
}
Per-object emission set to HDR yellow
Per-object emission set to HDR yellow

我在场景中添加了几个小的自发光立方体。我让它们对全局光照做出贡献,并将它们在 Lightmap 中的 Scale 翻倍以避免关于重叠 UV 坐标的警告。当顶点最终在光照贴图中太靠近而不得不共享同一个纹理像素时,就会发生这种情况。

Emissive cubes; no environment lighting
Emissive cubes; no environment lighting

烘焙自发光

自发光通过单独的 pass 烘焙。当 unity_MetaFragmentControl 的 Y 标志设置时,MetaPassFragment 应该返回发出的光,同样将 A 分量设为一。

1
2
3
4
5
6
if (unity_MetaFragmentControl.x) {
    ...
}
else if (unity_MetaFragmentControl.y) {
    meta = float4(GetEmission(input.baseUV), 1.0);
}

但这不会自动发生。我们必须为每个材质启用自发光烘焙。我们可以通过在 PerObjectMaterialProperties.OnGUI 中调用编辑器上的 LightmapEmissionProperty 来显示此配置选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 告诉光照贴图器为发出的光运行一个单独的 pass。还有一个 Realtime 选项,但它已被弃用。

Emission set to baked
Emission set to baked

这仍然不工作,因为 Unity 在烘焙时会积极尝试避免单独的发出光 pass。如果材质的自发光设置为零,它会被忽略。但是,这不会考虑每个对象的材质属性。我们可以通过在更改自发光模式时禁用所有选定材质的 globalIlluminationFlags 属性的默认 MaterialGlobalIlluminationFlags.EmissiveIsBlack 标志来覆盖此行为。这意味着你只应在需要时启用 Baked 选项。

1
2
3
4
5
6
7
8
9
10
void BakedEmission () {
    EditorGUI.BeginChangeCheck();
    editor.LightmapEmissionProperty();
    if (EditorGUI.EndChangeCheck()) {
        foreach (Material m in editor.targets) {
            m.globalIlluminationFlags &=
                ~MaterialGlobalIlluminationFlags.EmissiveIsBlack;
        }
    }
}
Baked emission, with and without directional light
Baked emission, with and without directional light

烘焙透明度

也可以烘焙透明对象,但这需要一点额外的努力。

Semitransparent ceiling treated as opaque
Semitransparent ceiling treated as opaque

硬编码属性

不幸的是,Unity 的光照贴图器对透明度采用硬编码方法。它查看材质的队列以确定它是 opaque、clipped 还是透明的。然后通过将 _MainTex 和 _Color 属性的 alpha 分量相乘来确定透明度,使用 _Cuto! 属性进行 alpha 裁剪。我们的着色器有第三个但缺少前两个。目前使这工作的唯一方法是将预期属性添加到我们的着色器,给它们 HideInInspector 属性以使它们不会出现在检查器中。Unity 的 SRP 着色器必须处理同样的问题。

1
2
[HideInInspector] _MainTex("Texture for Lightmap", 2D) = "white" {}
[HideInInspector] _Color("Color for Lightmap", Color) = (0.5, 0.5, 0.5, 1.0)

复制属性

我们必须确保 _MainTex 属性指向与 _BaseMap 相同的纹理并使用相同的 UV 变换。两种颜色属性也必须相同。我们可以在新的 CopyLightMappingProperties 方法中执行此操作,如果进行了更改,我们会在 CustomShaderGUI.OnGUI 结束时调用它。如果相关属性存在,则复制它们的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
    }
}
Transparency correctly baked
Transparency correctly baked

这也对裁剪材质有效。虽然可能不需要在 MetaPassFragment 中裁剪片段,因为透明度是单独处理的。

Baked clipping
Baked clipping

不幸的是,这意味着烘焙透明度只能依赖于单个纹理、颜色和裁剪属性。此外,光照贴图器只考虑材质的属性。每个实例的属性会被忽略。

Mesh Ball

我们通过为 Mesh Ball 生成的实例添加全局光照支持来结束。由于它们的实例是在播放模式中生成的,它们不能被烘焙,但通过一点努力,它们可以通过光照探针接收烘焙的光照。

Mesh ball with fully-baked lighting
Mesh ball with fully-baked lighting

光照探针

我们通过调用需要五个额外参数的 DrawMeshInstanced 方法变体来指示应该使用光照探针。首先是阴影投射模式,我们希望开启。之后是实例是否应该投射阴影,我们希望。接下来是层,我们只使用默认的零。然后我们必须提供一个实例应该可见的相机。传递 null 意味着它们应该为所有相机呈现。最后,我们可以设置光照探针模式。我们必须使用 LightProbeUsage.CustomProvided,因为没有单一位置可以用于混合探针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
        );
    }
}

我们必须为所有实例手动生成插值光照探针并将它们添加到材质属性块中。这意味着我们在配置块时需要访问实例位置。我们可以通过获取它们的变换矩阵的最后一列来检索它们并将它们存储在临时数组中。

1
2
3
4
5
6
7
8
9
10
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。

1
2
3
4
5
6
7
for (int i = 0; i < matrices.Length; i++) {
    positions[i] = matrices[i].GetColumn(3);
}
var lightProbes = new SphericalHarmonicsL2[1023];
LightProbes.CalculateInterpolatedLightAndOcclusionProbes(
    positions, lightProbes, null
);

我们不能在这里使用列表吗?

是的,有一个用于此的 CalculateInterpolatedLightAndOcclusionProbes 变体。但我们只需要数据一次,所以列表在这种情况下对我们没有好处。

之后我们可以通过 CopySHCoefficientArraysFrom 将光照探针复制到块中。

1
2
3
4
LightProbes.CalculateInterpolatedLightAndOcclusionProbes(
    positions, lightProbes, null
);
block.CopySHCoefficientArraysFrom(lightProbes);
Using light probes
Using light probes

LPPV

另一种方法是使用 LPPV。这是合理的,因为所有实例都存在于一个紧凑的空间中。这使我们不必计算和存储插值的光照探针。此外,它使得可以动画化实例位置,而不必每帧提供新的光照探针数据,只要它们保持在体积内。

添加一个 LightProbeProxyVolume 配置字段。如果它正在使用中,则不要将光照探针数据添加到块中。然后将 LightProbeUsage.UseProxyVolume 传递给 DrawMeshInstanced 而不是 LightProbeUsage.CustomProvided。我们可以始终将体积作为额外参数传递,即使它是 null 且未使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[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 组件添加到 mesh ball 或将其放在其他地方。custom bounding mode 可用于定义体积占据的世界空间区域。

Using an LPPV
Using an LPPV

下一教程是 Shadow Masks

This post is licensed under CC BY 4.0 by the author.