Post

自定义管线:Shadow Masks (翻译六)

自定义管线:Shadow Masks (翻译六)
  • 烘焙静态阴影。
  • 将实时光照与烘焙阴影结合。
  • 混合实时阴影和烘焙阴影。
  • 支持最多四个阴影掩码光源。

烘焙阴影

使用光照贴图的一个优点是我们不受最大阴影距离的限制。烘焙阴影不会被剔除,但它们也无法改变。理想情况下,我们可以在最大阴影距离内使用实时阴影,而在那之后使用烘焙阴影。Unity 的阴影掩码(Shadowmask)混合光照模式使这成为可能。

距离阴影掩码

让我们考虑与前一个教程相同的场景,但减小最大阴影距离,使得结构内部的一部分没有阴影。这使得实时阴影结束的地方非常清晰。我们首先只有一个光源。

Baked indirect mixed lighting, max distance 11.
Baked indirect mixed lighting, max distance 11.

将混合光照模式切换为 Shadowmask。这将使光照数据失效,因此必须重新烘焙。

Shadowmask mixed lighting mode.
Shadowmask mixed lighting mode.

使用阴影掩码混合光照有两种方式,可以在 Quality 项目设置中进行配置。我们将使用 Distance Shadowmask 模式。另一种模式简称为 Shadowmask,我们稍后会介绍。

Shadow mask mode set to distance.
Shadow mask mode set to distance.

这两种阴影掩码模式使用相同的烘焙光照数据。在这两种情况下,光照贴图最终都包含间接光照,与 Baked Indirect 混合光照模式完全相同。不同之处在于现在还有一个烘焙的阴影掩码贴图,你可以通过烘焙光照贴图预览窗口查看。

在 Unity 2022 中,你可以通过禁用光照的 Auto Generate,手动生成它,然后检查生成的纹理资产来查看阴影掩码贴图。

Baked indirect light and shadow mask.
Baked indirect light and shadow mask.

阴影掩码贴图包含我们单个混合方向光的阴影衰减,代表由所有贡献全局光照的静态对象投射的阴影。数据存储在红色通道中,因此贴图是黑色和红色的。

就像烘焙间接光照一样,烘焙阴影在运行时无法改变。然而,无论光源的强度或颜色如何,阴影都将保持有效。但光源不应旋转,否则其阴影将没有意义。此外,如果光源的间接光照是烘焙的,你不应该过多地改变光源。例如,如果光源关闭后间接光照仍然存在,那显然是错误的。如果光源变化很大,那么你可以将其 Indirect Multiplier 设置为零,这样就不会为其烘焙间接光。

检测阴影掩码

要使用阴影掩码,我们的管线必须首先知道它的存在。由于这完全关乎阴影,这是我们的 Shadows 类的职责。我们将使用着色器关键字来控制是否使用阴影掩码。由于有两种模式,我们将引入另一个静态关键字数组,尽管目前它只包含一个关键字:_SHADOW_MASK_DISTANCE

1
2
3
static string[] shadowMaskKeywords = {
    "_SHADOW_MASK_DISTANCE"
};

添加一个布尔字段来跟踪我们是否正在使用阴影掩码。我们每帧都会重新评估,所以在 Setup 中将其初始化为 false

1
2
3
4
5
6
bool useShadowMask;

public void Setup (...) {
    ...
    useShadowMask = false;
}

Render 的末尾启用或禁用关键字。即使我们最终没有渲染任何实时阴影,我们也必须这样做,因为阴影掩码不是实时的。

1
2
3
4
5
6
7
public void Render () {
    ...
    buffer.BeginSample(bufferName);
    SetKeywords(shadowMaskKeywords, useShadowMask ? 0 : -1);
    buffer.EndSample(bufferName);
    ExecuteBuffer();
}

为了知道是否需要阴影掩码,我们必须检查是否有光源使用了它。我们将在 ReserveDirectionalShadows 中执行此操作,当我们最终得到一个有效的投射阴影的光源时。

每个光源都包含有关其烘焙数据的信息。它存储在 LightBakingOutput 结构中,可以通过 Light.bakingOutput 属性检索。如果我们遇到一个光照贴图烘焙类型设置为混合(Mixed),且其混合光照模式设置为阴影掩码(Shadowmask)的光源,那么我们就在使用阴影掩码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Vector3 ReserveDirectionalShadows (
    Light light, int visibleLightIndex
) {
    if (...) {
        LightBakingOutput lightBaking = light.bakingOutput;
        if (
            lightBaking.lightmapBakeType == LightmapBakeType.Mixed &&
            lightBaking.mixedLightingMode == MixedLightingMode.Shadowmask
        ) {
            useShadowMask = true;
        }
        ...
    }
    return Vector3.zero;
}

这会在需要时启用着色器关键字。在 Lit 着色器的 CustomLit 通道中为其添加相应的多重编译指令。

1
2
3
#pragma multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER
#pragma multi_compile _ _SHADOW_MASK_DISTANCE
#pragma multi_compile _ LIGHTMAP_ON

阴影掩码数据

在着色器端,我们必须知道阴影掩码是否正在使用,如果是,烘焙阴影是什么。让我们向 Shadows 添加一个 ShadowMask 结构来跟踪这两者,包含一个布尔值和一个浮点向量字段。将布尔值命名为 distance,以指示是否启用了距离阴影掩码模式。然后将此结构作为一个字段添加到全局 ShadowData 结构中。

1
2
3
4
5
6
7
8
9
10
11
struct ShadowMask {
	bool distance;
	float4 shadows;
};

struct ShadowData {
	int cascadeIndex;
	float cascadeBlend;
	float strength;
	ShadowMask shadowMask;
};

GetShadowData 中默认将阴影掩码初始化为未使用。

1
2
3
4
5
6
ShadowData GetShadowData (Surface surfaceWS) {
	ShadowData data;
	data.shadowMask.distance = false;
	data.shadowMask.shadows = 1.0;
	...
}

虽然阴影掩码用于阴影处理,但它是场景烘焙光照数据的一部分。因此,检索它是 GI 的责任。所以也向 GI 结构添加一个阴影掩码字段,并在 GetGI 中将其初始化为未使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct GI {
	float3 diffuse;
	ShadowMask shadowMask;
};

...

GI GetGI (float2 lightMapUV, Surface surfaceWS) {
	GI gi;
	gi.diffuse = SampleLightMap(lightMapUV) + SampleLightProbe(surfaceWS);
	gi.shadowMask.distance = false;
	gi.shadowMask.shadows = 1.0;
	return gi;
}

Unity 通过 unity_ShadowMask 纹理及附带的采样器状态使阴影掩码贴图到着色器可见。在 GI 中定义这些,以及其他光照贴图纹理和采样器状态。

1
2
3
4
5
TEXTURE2D(unity_Lightmap);
SAMPLER(samplerunity_Lightmap);

TEXTURE2D(unity_ShadowMask);
SAMPLER(samplerunity_ShadowMask);

然后添加一个 SampleBakedShadows 函数来采样贴图,使用光照贴图 UV 坐标。就像常规光照贴图一样,这只对使用了光照贴图的几何体有意义,即定义了 LIGHTMAP_ON 时。否则没有烘焙阴影,衰减始终为 1。

1
2
3
4
5
6
7
8
9
float4 SampleBakedShadows (float2 lightMapUV) {
	#if defined(LIGHTMAP_ON)
		return SAMPLE_TEXTURE2D(
			unity_ShadowMask, samplerunity_ShadowMask, lightMapUV
		);
	#else
		return 1.0;
	#endif
}

现在我们可以调整 GetGI,使其在定义了 _SHADOW_MASK_DISTANCE 时启用距离阴影掩码模式并采样烘焙阴影。请注意,这使得 distance 布尔值成为编译时常量,因此其使用不会导致动态分支。

1
2
3
4
5
6
7
8
9
10
11
GI GetGI (float2 lightMapUV, Surface surfaceWS) {
	GI gi;
	gi.diffuse = SampleLightMap(lightMapUV) + SampleLightProbe(surfaceWS);
	gi.shadowMask.distance = false;
	gi.shadowMask.shadows = 1.0;
	#if defined(_SHADOW_MASK_DISTANCE)
		gi.shadowMask.distance = true;
		gi.shadowMask.shadows = SampleBakedShadows(lightMapUV);
	#endif
	return gi;
}

Lighting 负责在 GetLighting 中循环光源之前将阴影掩码数据从 GI 复制到 ShadowData。此时,我们还可以通过直接返回阴影掩码数据作为最终光照颜色来调试它。

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

最初它似乎不起作用,因为所有内容最终都变成了白色。我们必须指示 Unity 将相关数据发送到 GPU,就像我们在上一个教程中为 CameraRenderer.DrawVisibleGeometry 中的光照贴图和探针所做的那样。在这种情况下,我们必须将 PerObjectData.ShadowMask 添加到每对象数据中。

1
2
3
4
perObjectData =
    PerObjectData.Lightmaps | PerObjectData.ShadowMask |
    PerObjectData.LightProbe |
    PerObjectData.LightProbeProxyVolume
Sampling shadow mask.
Sampling shadow mask.

遮挡探针 (Occlusion Probes)

我们可以看到阴影掩码正确地应用到了使用了光照贴图的对象。我们也看到动态对象没有阴影掩码数据,这是意料之中的。它们使用光照探针而不是光照贴图。然而,Unity 也会将阴影掩码数据烘焙到光照探针中,称之为遮挡探针。我们可以通过在 UnityInputUnityPerDraw 缓冲区中添加 unity_ProbesOcclusion 向量来访问此数据。将其放在世界转换参数和光照贴图 UV 转换向量之间。

1
2
3
real4 unity_WorldTransformParams;
float4 unity_ProbesOcclusion;
float4 unity_LightmapST;

现在我们可以简单地在 SampleBakedShadows 中为动态对象返回该向量。

1
2
3
4
5
6
7
float4 SampleBakedShadows (float2 lightMapUV) {
	#if defined(LIGHTMAP_ON)
		...
	#else
		return unity_ProbesOcclusion;
	#endif
}

同样,我们必须指示 Unity 将此数据发送到 GPU,这次是通过启用 PerObjectData.OcclusionProbe 标志。

1
2
3
4
perObjectData =
    PerObjectData.Lightmaps | PerObjectData.ShadowMask |
    PerObjectData.LightProbe | PerObjectData.OcclusionProbe |
    PerObjectData.LightProbeProxyVolume
Sampling occlusion probes.
Sampling occlusion probes.

阴影掩码中未使用的通道对于探针被设置为白色,因此动态对象在完全照明时最终为白色,在完全阴影时最终为青色(cyan),而不是红色和黑色。

虽然这足以通过探针使阴影掩码工作,但它破坏了 GPU 实例化。遮挡数据可以自动实例化,但 UnityInstancing 仅在定义了 SHADOWS_SHADOWMASK 时才执行此操作。因此,在包含 UnityInstancing 之前,在 Common 中根据需要定义它。这是我们必须显式检查是否定义了 _SHADOW_MASK_DISTANCE 的唯一其他地方。

1
2
3
4
5
#if defined(_SHADOW_MASK_DISTANCE)
	#define SHADOWS_SHADOWMASK
#endif

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"

LPPVs

光照探针代理体(LPPV)也可以与阴影掩码配合使用。同样,我们必须通过设置标志来启用此功能,这次是 PerObjectData.OcclusionProbeProxyVolume

1
2
3
4
5
perObjectData =
    PerObjectData.Lightmaps | PerObjectData.ShadowMask |
    PerObjectData.LightProbe | PerObjectData.OcclusionProbe |
    PerObjectData.LightProbeProxyVolume |
    PerObjectData.OcclusionProbeProxyVolume

检索 LPPV 遮挡数据的工作方式与检索其光照数据相同,只是我们必须调用 SampleProbeOcclusion 而不是 SampleProbeVolumeSH4。它存储在相同的纹理中,需要相同的参数,唯一的例外是不需要法线向量。为此向 SampleBakedShadows 添加一个分支,以及目前所需世界位置的 surface 参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float4 SampleBakedShadows (float2 lightMapUV, Surface surfaceWS) {
	#if defined(LIGHTMAP_ON)
		...
	#else
		if (unity_ProbeVolumeParams.x) {
			return SampleProbeOcclusion(
				TEXTURE3D_ARGS(unity_ProbeVolumeSH, samplerunity_ProbeVolumeSH),
				surfaceWS.position, unity_ProbeVolumeWorldToObject,
				unity_ProbeVolumeParams.y, unity_ProbeVolumeParams.z,
				unity_ProbeVolumeMin.xyz, unity_ProbeVolumeSizeInv.xyz
			);
		}
		else {
			return unity_ProbesOcclusion;
		}
	#endif
}

GetGI 中调用该函数时添加新的 surface 参数。

1
	gi.shadowMask.shadows = SampleBakedShadows(lightMapUV, surfaceWS);
Sampling LPPV occlusion.
Sampling LPPV occlusion.

Mesh Ball

如果我们的 mesh ball 使用了 LPPV,它就已经支持阴影掩码。但当它自己插值光照探针时,我们必须在 MeshBall.Update 中添加遮挡探针数据。这是通过为 CalculateInterpolatedLightAndOcclusionProbes 的最后一个参数使用一个临时 Vector4 数组,并通过 CopyProbeOcclusionArrayFrom 方法将其传递给属性块来完成的。

1
2
3
4
5
6
7
		var lightProbes = new SphericalHarmonicsL2[1023];
		var occlusionProbes = new Vector4[1023];
		LightProbes.CalculateInterpolatedLightAndOcclusionProbes(
			positions, lightProbes, occlusionProbes
		);
		block.CopySHCoefficientArraysFrom(lightProbes);
		block.CopyProbeOcclusionArrayFrom(occlusionProbes);

在验证阴影掩码数据已正确发送到着色器后,我们可以从 GetLighting 中删除其调试可视化。

混合阴影 (Mixing Shadows)

现在我们已经获得了阴影掩码,下一步是在实时阴影不存在时使用它,即当片元落在最大阴影距离之外时。

在可用时使用烘焙阴影

混合烘焙和实时阴影将使 GetDirectionalShadowAttenuation 的工作变得更加复杂。让我们首先隔离所有实时阴影采样代码,将其移动到 Shadows 中新的 GetCascadedShadow 函数中。

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
38
39
40
41
42
43
//return gi.shadowMask.shadows.rgb;

float GetCascadedShadow (
	DirectionalShadowData directional, ShadowData global, Surface surfaceWS
) {
	float3 normalBias = surfaceWS.normal *
		(directional.normalBias * _CascadeData[global.cascadeIndex].y);
	float3 positionSTS = mul(
		_DirectionalShadowMatrices[directional.tileIndex],
		float4(surfaceWS.position + normalBias, 1.0)
	).xyz;
	float shadow = FilterDirectionalShadow(positionSTS);
	if (global.cascadeBlend < 1.0) {
		normalBias = surfaceWS.normal *
			(directional.normalBias * _CascadeData[global.cascadeIndex + 1].y);
		positionSTS = mul(
			_DirectionalShadowMatrices[directional.tileIndex + 1],
			float4(surfaceWS.position + normalBias, 1.0)
		).xyz;
		shadow = lerp(
			FilterDirectionalShadow(positionSTS), shadow, global.cascadeBlend
		);
	}
	return shadow;
}

float GetDirectionalShadowAttenuation (
	DirectionalShadowData directional, ShadowData global, Surface surfaceWS
) {
	#if !defined(_RECEIVE_SHADOWS)
		return 1.0;
	#endif

	float shadow;
	if (directional.strength <= 0.0) {
		shadow = 1.0;
	}
	else {
		shadow = GetCascadedShadow(directional, global, surfaceWS);
		shadow = lerp(1.0, shadow, directional.strength);
	}
	return shadow;
}

然后添加一个返回给定阴影掩码的烘焙阴影衰减的新函数 GetBakedShadow。如果掩码的距离模式已启用,则我们需要其 shadows 向量的第一个分量,否则没有可用的衰减,结果为 1。

1
2
3
4
5
6
7
float GetBakedShadow (ShadowMask mask) {
	float shadow = 1.0;
	if (mask.distance) {
		shadow = mask.shadows.r;
	}
	return shadow;
}

接下来,创建一个带有 ShadowData、实时阴影和阴影强度参数的 MixBakedAndRealtimeShadows 函数。它简单地将强度应用到阴影上,除非存在距离阴影掩码。如果是这样,用烘焙的阴影替换实时阴影。

1
2
3
4
5
6
7
8
9
float MixBakedAndRealtimeShadows (
	ShadowData global, float shadow, float strength
) {
	float baked = GetBakedShadow(global.shadowMask);
	if (global.shadowMask.distance) {
		shadow = baked;
	}
	return lerp(1.0, shadow, strength);
}

GetDirectionalShadowAttenuation 使用该函数而不是自己应用强度。

1
2
	shadow = GetCascadedShadow(directional, global, surfaceWS);
	shadow = MixBakedAndRealtimeShadows(global, shadow, directional.strength);
Faded baked shadows.
Faded baked shadows.

结果是我们现在总是使用阴影掩码,所以我们可以看到它在起作用。然而,烘焙阴影会像实时阴影一样随距离淡出。

过渡到烘焙阴影

为了基于深度从实时阴影过渡到烘焙阴影,我们必须根据全局阴影强度在它们之间进行插值。然而,我们还必须应用光源的阴影强度,这必须在插值之后完成。因此,我们不能再在 GetDirectionalShadowData 中立即合并两个强度。

1
2
	data.strength =
		_DirectionalLightShadowData[lightIndex].x; // * shadowData.strength;

MixBakedAndRealtimeShadows 中根据全局强度执行烘焙阴影和实时阴影之间的插值,然后应用光源的阴影强度。但当没有阴影掩码时,仅将合并的强度应用到实时阴影,就像我们之前所做的那样。

1
2
3
4
5
6
7
8
9
10
float MixBakedAndRealtimeShadows (
	ShadowData global, float shadow, float strength
) {
	float baked = GetBakedShadow(global.shadowMask);
	if (global.shadowMask.distance) {
		shadow = lerp(baked, shadow, global.strength);
		return lerp(1.0, shadow, strength);
	}
	return lerp(1.0, shadow, strength * global.strength);
}
Mixed shadows.
Mixed shadows.

结果是动态对象投射的阴影照常淡出,而静态对象投射的阴影则过渡到阴影掩码。

仅烘焙阴影

目前,我们的方法仅在有实时阴影要渲染时才有效。如果没有,那么阴影掩码也会消失。这可以通过缩小场景视图直到所有内容都位于最大阴影距离之外来验证。

Neither realtime nor baked shadows.
Neither realtime nor baked shadows.

我们必须支持存在阴影掩码但没有实时阴影的情况。让我们首先创建一个也有强度参数的 GetBakedShadow 函数变体,以便我们可以方便地获得受强度调制的烘焙阴影。

1
2
3
4
5
6
float GetBakedShadow (ShadowMask mask, float strength) {
	if (mask.distance) {
		return lerp(1.0, GetBakedShadow(mask), strength);
	}
	return 1.0;
}

接下来,在 GetDirectionalShadowAttenuation 中检查合并的强度是否最终小于或等于零。如果是,与其总是返回 1,不如仅返回调制的烘焙阴影,仍然跳过实时阴影采样。

1
2
3
	if (directional.strength * global.strength <= 0.0) {
		shadow = GetBakedShadow(global.shadowMask, directional.strength);
	}

除此之外,我们还必须更改 Shadows.ReserveDirectionalShadows,使其不会立即跳过最终没有实时阴影投射器的光源。相反,首先确定光源是否使用阴影掩码。之后检查是否没有实时阴影投射器,在这种情况下,只有阴影强度是相关的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (
    shadowedDirLightCount < maxShadowedDirLightCount &&
    light.shadows != LightShadows.None && light.shadowStrength > 0f //&&
    //cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)
) {
    LightBakingOutput lightBaking = light.bakingOutput;
    if (
        lightBaking.lightmapBakeType == LightmapBakeType.Mixed &&
        lightBaking.mixedLightingMode == MixedLightingMode.Shadowmask
    ) {
        useShadowMask = true;
    }

    if (!cullingResults.GetShadowCasterBounds(
        visibleLightIndex, out Bounds b
    )) {
        return new Vector3(light.shadowStrength, 0f, 0f);
    }
    ...
}

但当阴影强度大于零时,着色器将采样阴影贴图,即使那是不正确的。我们可以通过在这种情况下否定阴影强度来使其工作。

1
return new Vector3(-light.shadowStrength, 0f, 0f);

然后在当我们跳过实时阴影时,将绝对强度传递给 GetDirectionalShadowAttenuation 中的 GetBakedShadow。这样,当没有实时阴影投射器时以及当我们超出最大阴影距离时,它都能工作。

1
shadow = GetBakedShadow(global.shadowMask, abs(directional.strength));
Only baked shadows.
Only baked shadows.

始终使用阴影掩码 (Always use the Shadow Mask)

还有另一种阴影掩码模式,简称为 Shadowmask。它的工作方式与距离模式完全相同,除了 Unity 会为使用阴影掩码的光源省略静态阴影投射器。

No realtime shadows cast by static geometry.
No realtime shadows cast by static geometry.

这个想法是,因为阴影掩码随处可用,我们也可以随处将其用于静态阴影。这意味着实时阴影更少,这使得渲染更快,代价是近处静态阴影的质量较低。

要支持此模式,请将 _SHADOW_MASK_ALWAYS 关键字添加为 Shadows 中阴影掩码关键字数组的第一个元素。我们可以通过在 Render 中检查 QualitySettings.shadowmaskMode 属性来确定应该启用哪个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static string[] shadowMaskKeywords = {
    "_SHADOW_MASK_ALWAYS",
    "_SHADOW_MASK_DISTANCE"
};

...

public void Render () {
    ...
    buffer.BeginSample(bufferName);
    SetKeywords(shadowMaskKeywords, useShadowMask ?
        QualitySettings.shadowmaskMode == ShadowmaskMode.Shadowmask ? 0 : 1 :
        -1
    );
    buffer.EndSample(bufferName);
    ExecuteBuffer();
}

对着色器中的多重编译指令也添加该关键字。

1
#pragma multi_compile _ _SHADOW_MASK_ALWAYS _SHADOW_MASK_DISTANCE

并且在 Common 中决定是否定义 SHADOWS_SHADOWMASK 时也检查它。

1
2
3
#if defined(_SHADOW_MASK_ALWAYS) || defined(_SHADOW_MASK_DISTANCE)
	#define SHADOWS_SHADOWMASK
#endif

ShadowMask 结构一个单独的布尔字段,以指示是否应始终使用阴影掩码。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ShadowMask {
	bool always;
	bool distance;
	float4 shadows;
};

...

ShadowData GetShadowData (Surface surfaceWS) {
	ShadowData data;
	data.shadowMask.always = false;
	...
}

然后在 GetGI 中连同其阴影数据一起适时地设置它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GI GetGI (float2 lightMapUV, Surface surfaceWS) {
	GI gi;
	gi.diffuse = SampleLightMap(lightMapUV) + SampleLightProbe(surfaceWS);
	gi.shadowMask.always = false;
	gi.shadowMask.distance = false;
	gi.shadowMask.shadows = 1.0;

	#if defined(_SHADOW_MASK_ALWAYS)
		gi.shadowMask.always = true;
		gi.shadowMask.shadows = SampleBakedShadows(lightMapUV, surfaceWS);
	#elif defined(_SHADOW_MASK_DISTANCE)
		gi.shadowMask.distance = true;
		gi.shadowMask.shadows = SampleBakedShadows(lightMapUV, surfaceWS);
	#endif
	return gi;
}

两个版本的 GetBakedShadow 都应在任一模式处于使用状态时选择掩码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float GetBakedShadow (ShadowMask mask) {
	float shadow = 1.0;
	if (mask.always || mask.distance) {
		shadow = mask.shadows.r;
	}
	return shadow;
}

float GetBakedShadow (ShadowMask mask, float strength) {
	if (mask.always || mask.distance) {
		return lerp(1.0, GetBakedShadow(mask), strength);
	}
	return 1.0;
}

最后,当阴影掩码始终激活时,MixBakedAndRealtimeShadows 现在必须使用不同的方法。首先,实时阴影必须受全局强度调制,以便基于深度使其淡出。然后通过取它们的最小值来合并烘焙阴影和实时阴影。之后,将光源的阴影强度应用到合并后的阴影上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float MixBakedAndRealtimeShadows (
	ShadowData global, float shadow, float strength
) {
	float baked = GetBakedShadow(global.shadowMask);
	if (global.shadowMask.always) {
		shadow = lerp(1.0, shadow, global.strength);
		shadow = min(baked, shadow);
		return lerp(1.0, shadow, strength);
	}
	if (global.shadowMask.distance) {
		shadow = lerp(baked, shadow, global.strength);
		return lerp(1.0, shadow, strength);
	}
	return lerp(1.0, shadow, strength * global.strength);
}
Baked static shadows mixed with realtime dynamic shadows.
Baked static shadows mixed with realtime dynamic shadows.

多光源 (Multiple Lights)

因为阴影掩码贴图有四个通道,所以它最多可以支持四个混合光源。烘焙时最重要的光源获得红色通道,第二个光源获得绿色通道,依此类推。让我们通过复制我们的单个方向光、稍微旋转它并降低其强度来尝试一下,以便新光源最终使用绿色通道。

Two lights sharing the same baked shadows.
Two lights sharing the same baked shadows.

第二个光源的实时阴影按预期工作,但它最终将第一个光源的掩码用于烘焙阴影,这显然是错误的。当使用 always-shadow-mask 模式时,这最容易看到。

阴影掩码通道

检查烘焙阴影掩码贴图可以发现阴影已正确烘焙。仅被第一个光源照射的区域是红色的,仅被第二个光源照射的区域是绿色的,被两者都照射的区域是黄色的。这适用于最多四个光源,尽管第四个在预览中不可见,因为不显示 Alpha 通道。

Baked shadows for two lights.
Baked shadows for two lights.

两个光源使用相同的烘焙阴影,因为我们总是使用红色通道。为了使其工作,我们必须将光源的通道索引发送到 GPU。我们不能依赖光源顺序,因为它在运行时可能会发生变化,因为光源可以被更改甚至禁用。

我们可以通过 LightBakingOutput.occlusionMaskChannel 字段在 Shadows.ReserveDirectionalShadows 中检索光源的掩码通道索引。由于我们将 4D 向量发送到 GPU,我们可以将其存储在返回向量的第四个通道中,将返回类型更改为 Vector4。当光源不使用阴影掩码时,我们通过将其索引设置为 -1 来指示。

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
public Vector4 ReserveDirectionalShadows (
    Light light, int visibleLightIndex
) {
    if (
        shadowedDirLightCount < maxShadowedDirLightCount &&
        light.shadows != LightShadows.None && light.shadowStrength > 0f
    ) {
        float maskChannel = -1;
        LightBakingOutput lightBaking = light.bakingOutput;
        if (
            lightBaking.lightmapBakeType == LightmapBakeType.Mixed &&
            lightBaking.mixedLightingMode == MixedLightingMode.Shadowmask
        ) {
            useShadowMask = true;
            maskChannel = lightBaking.occlusionMaskChannel;
        }

        if (!cullingResults.GetShadowCasterBounds(
            visibleLightIndex, out Bounds b
        )) {
            return new Vector4(-light.shadowStrength, 0f, 0f, maskChannel);
        }
        shadowedDirectionalLights[shadowedDirLightCount] =
            new ShadowedDirectionalLight {
                visibleLightIndex = visibleLightIndex,
                slopeScaleBias = light.shadowBias,
                nearPlaneOffset = light.shadowNearPlane
            };
        return new Vector4(
            light.shadowStrength,
            settings.directional.cascadeCount * shadowedDirLightCount++,
            light.shadowNormalBias, maskChannel
        );
    }
    return new Vector4(0f, 0f, 0f, -1f);
}

选择合适的通道

在着色器端,将阴影掩码通道作为额外的整数合并字段添加到 Shadows 中定义的 DirectionalShadowData 结构中。

1
2
3
4
5
6
struct DirectionalShadowData {
	float strength;
	int tileIndex;
	float normalBias;
	int shadowMaskChannel;
};

然后 GI 必须在 GetDirectionalShadowData 中设置该通道。

1
2
3
4
5
6
7
DirectionalShadowData GetDirectionalShadowData (
	int lightIndex, ShadowData shadowData
) {
	...
	data.shadowMaskChannel = _DirectionalLightShadowData[lightIndex].w;
	return data;
}

向两个版本的 GetBakedShadow 添加一个通道参数,并使用它返回合适的阴影掩码数据。但仅在光源使用阴影掩码时执行此操作,即通道至少为零时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float GetBakedShadow (ShadowMask mask, int channel) {
	float shadow = 1.0;
	if (mask.always || mask.distance) {
		if (channel >= 0) {
			shadow = mask.shadows[channel];
		}
	}
	return shadow;
}

float GetBakedShadow (ShadowMask mask, int channel, float strength) {
	if (mask.always || mask.distance) {
		return lerp(1.0, GetBakedShadow(mask, channel), strength);
	}
	return 1.0;
}

调整 MixBakedAndRealtimeShadows 使其传递所需的阴影掩码通道。

1
2
3
4
5
6
float MixBakedAndRealtimeShadows (
	ShadowData global, float shadow, int shadowMaskChannel, float strength
) {
	float baked = GetBakedShadow(global.shadowMask, shadowMaskChannel);
	...
}

最后,在 GetDirectionalShadowAttenuation 中添加所需的通道参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
float GetDirectionalShadowAttenuation (
	DirectionalShadowData directional, ShadowData global, Surface surfaceWS
) {
	#if !defined(_RECEIVE_SHADOWS)
		return 1.0;
	#endif

	float shadow;
	if (directional.strength * global.strength <= 0.0) {
		shadow = GetBakedShadow(
			global.shadowMask, directional.shadowMaskChannel,
			abs(directional.strength)
		);
	}
	else {
		shadow = GetCascadedShadow(directional, global, surfaceWS);
		shadow = MixBakedAndRealtimeShadows(
			global, shadow, directional.shadowMaskChannel, directional.strength
		);
	}
	return shadow;
}
Both lights using their own channel.
Both lights using their own channel.

下一个教程是LOD和反射

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