Shadow Masks Baking Direct Occlusion
烘焙静态阴影。
将实时光照与烘焙阴影结合。
混合实时和烘焙阴影。
支持最多四个阴影遮罩灯光。
这是关于创建自定义可编程渲染管线的教程系列的第六部分。它使用阴影遮罩技术来烘焙阴影,同时仍计算实时光照。
本教程基于 Unity 2019.2.21f1 版本制作,并已升级至 2022.3.5f1 版本
Realtime shadows nearby, baked shadows farther away. 近处使用实时阴影,远处使用烘焙阴影
1. Baking Shadows 烘焙阴影
使用光照贴图的优势在于我们不受最大阴影距离的限制。烘焙阴影不会被剔除,但它们也无法变化。理想情况下,我们可以在最大阴影距离内使用实时阴影,超出该范围则使用烘焙阴影。Unity的阴影遮罩混合光照模式使这一设想成为可能
1.1 Distance Shadow Mask 距离阴影遮罩
让我们沿用上一教程中的场景,但将最大阴影距离缩短,使得建筑内部部分区域无法获得实时阴影。这样可以清晰展现实时阴影的截止边界。我们首先仅使用单一光源。
Baked indirect mixed lighting, max distance 11
将混合光照模式切换为阴影遮罩模式。这将使当前光照数据失效,需要重新进行烘焙
Shadowmask mixed lighting mode 阴影遮罩混合光照模式
可通过质量项目设置中的两种方式来配置阴影遮罩混合光照。我们将使用"距离阴影遮罩"模式,另一种称为"阴影遮罩"的模式将在后续讨论
Shadow mask mode set to distance
两种阴影遮罩模式使用相同的烘焙光照数据。在这两种情况下,光照贴图最终都会包含间接光照,与烘焙间接混合光照模式完全相同。不同之处在于现在还会生成烘焙阴影遮罩贴图,您可以通过烘焙光照贴图预览窗口进行查看。
在Unity 2022中,您可以通过禁用光照的自动生成功能,手动生成光照数据,然后检查生成的纹理资源来查看阴影遮罩贴图
Baked indirect light and shadow mask 烘焙间接光照与阴影遮罩
阴影遮罩贴图包含了我们单一混合方向光的阴影衰减数据,代表了所有参与全局光照的静态物体投射的阴影。这些数据存储在红色通道中,因此贴图呈现黑红两色。
与烘焙间接光照相同,烘焙阴影在运行时无法改变。但无论光照的强度或颜色如何变化,这些阴影都将保持有效。不过需要注意的是,光源不应旋转,否则其阴影将失去合理性。此外,如果光源的间接光照已被烘焙,则不应大幅调整该光源。例如,当灯光关闭后若间接光照仍然存在,就会出现明显错误。若某个光源变化频繁,可将其间接光照乘数设置为零,这样就不会为其烘焙间接光照数据
1.2 Detecting a Shadow Mask 检测阴影遮罩
要使用阴影遮罩,我们的渲染管线首先需要感知其存在。由于这完全关乎阴影处理,自然成为我们Shadows类的职责范围。我们将使用着色器关键字来控制是否启用阴影遮罩。鉴于存在两种模式,我们将引入另一个静态关键字数组——尽管目前仅包含_SHADOW_MASK_DISTANCE这一个关键字
static string[] shadowMaskKeywords = {"_SHADOW_MASK_DISTANCE"};
添加一个布尔字段来跟踪是否使用阴影遮罩。我们每帧都会重新评估此状态,因此在Setup中将其初始化为false。
bool useShadowMask;public void Setup (…) {…useShadowMask = false;}
在Render的末尾启用或禁用该关键字。即使最终没有渲染任何实时阴影,我们也必须这样做,因为阴影遮罩不是实时的
public void Render () {…buffer.BeginSample(bufferName);SetKeywords(shadowMaskKeywords, useShadowMask ? 0 : -1);buffer.EndSample(bufferName);ExecuteBuffer();}
要确定是否需要阴影遮罩,我们必须检查是否存在使用它的光源。当遇到有效的阴影投射光源时,我们会在ReserveDirectionalShadows中进行此检查。
每个光源都包含其烘焙数据的信息,这些信息存储在可通过Light.bakingOutput属性获取的LightBakingOutput结构体中。如果我们遇到光源的lightmapBakeType设置为Mixed且mixedLightingMode设置为Shadowmask,则表示我们正在使用阴影遮罩
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通道中添加对应的multi-compile指令
#pragma multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER#pragma multi_compile _ _SHADOW_MASK_DISTANCE#pragma multi_compile _ LIGHTMAP_ON
1.3 Shadow Mask Data 阴影遮罩数据
在着色器端,我们需要知道是否正在使用阴影遮罩,以及烘焙阴影的具体数据。让我们在Shadows中添加一个ShadowMask结构体来跟踪这两类信息,包含一个布尔值和一个浮点向量字段。将布尔值命名为distance以指示是否启用了距离阴影遮罩模式。然后将此结构体作为字段添加到全局ShadowData结构体中。
struct ShadowMask {bool distance;float4 shadows;
};struct ShadowData {int cascadeIndex;float cascadeBlend;float strength;ShadowMask shadowMask;
};
在GetShadowData中默认将阴影遮罩初始化为未使用状态
ShadowData GetShadowData (Surface surfaceWS) {ShadowData data;data.shadowMask.distance = false;data.shadowMask.shadows = 1.0;…
}
虽然阴影遮罩用于阴影处理,但它属于场景烘焙光照数据的一部分。因此,获取阴影遮罩数据是GI模块的职责。所以也在GI结构体中添加一个阴影遮罩字段,并在GetGI中同样将其初始化为未使用状态。
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中定义。
TEXTURE2D(unity_Lightmap);
SAMPLER(samplerunity_Lightmap);TEXTURE2D(unity_ShadowMask);
SAMPLER(samplerunity_ShadowMask);
然后添加一个SampleBakedShadows函数,该函数使用光照贴图UV坐标对阴影贴图进行采样。与常规光照贴图一样,这仅对具有光照贴图的几何体有意义,因此仅在定义了LIGHTMAP_ON时有效。否则表示没有烘焙阴影,衰减值始终为1
float4 SampleBakedShadows (float2 lightMapUV) {#if defined(LIGHTMAP_ON)return SAMPLE_TEXTURE2D(unity_ShadowMask, samplerunity_ShadowMask, lightMapUV);#elsereturn 1.0;#endif
}
现在我们可以调整GetGI,使其在定义了_SHADOW_MASK_DISTANCE时启用距离阴影遮罩模式并采样烘焙阴影。请注意,这会使距离布尔值成为编译时常量,因此其使用不会导致动态分支。
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);#endifreturn gi;
}
Lighting模块负责在GetLighting中、遍历光源循环之前将阴影遮罩数据从GI复制到ShadowData。此时,我们还可以通过直接将阴影遮罩数据作为最终光照颜色返回来进行调试
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添加到每物体数据中。
perObjectData =PerObjectData.Lightmaps | PerObjectData.ShadowMask |PerObjectData.LightProbe |PerObjectData.LightProbeProxyVolume
Sampling shadow mask 采样阴影遮罩
Why does Unity bake lighting each time we change shader code? 为什么每次修改着色器代码时Unity都会重新烘焙光照?
这是因为我们修改了被元通道包含的HLSL文件。你可以通过暂时禁用"Auto Generate"来避免不必要的烘焙
1.4 Occlusion Probes 遮蔽探针
我们可以看到阴影遮罩正确应用到了光照贴图对象上。同时如预期那样,动态物体没有阴影遮罩数据——它们使用光照探针而非光照贴图。不过,Unity也会将阴影遮罩数据烘焙到光照探针中(称为遮蔽探针)。我们可以在UnityInput的UnityPerDraw缓冲区中添加unity_ProbesOcclusion向量来访问这些数据,请将其放置在世界变换参数和光照贴图UV变换向量之间。
real4 unity_WorldTransformParams;float4 unity_ProbesOcclusion;float4 unity_LightmapST;
现在我们可以直接将该向量作为动态对象的SampleBakedShadows返回值。
float4 SampleBakedShadows (float2 lightMapUV) {#if defined(LIGHTMAP_ON)…#elsereturn unity_ProbesOcclusion;#endif
}
同样地,我们必须指示Unity将此数据发送到GPU,这次需要启用PerObjectData.OcclusionProbe标志。
perObjectData =PerObjectData.Lightmaps | PerObjectData.ShadowMask |PerObjectData.LightProbe | PerObjectData.OcclusionProbe |PerObjectData.LightProbeProxyVolume
Sampling occlusion probes 采样遮蔽探针
阴影遮罩中未使用的通道在探针中设为白色,因此动态物体在完全受光时显示白色,完全阴影时显示青色,而不是红色和黑色。
虽然这足以通过探针实现阴影遮罩功能,但会破坏GPU实例化。遮蔽数据可以自动实例化,但UnityInstancing仅在定义了SHADOWS_SHADOWMASK时才会执行此操作。因此需要在包含UnityInstancing之前,在Common中根据情况定义该关键字。这是唯一需要显式检查是否定义了_SHADOW_MASK_DISTANCE的其他地方。
#if defined(_SHADOW_MASK_DISTANCE)#define SHADOWS_SHADOWMASK
#endif#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
1.5 LPPVs
光照探针代理体积也可以与阴影遮罩协同工作。我们同样需要通过设置标志来启用此功能,这次使用的是PerObjectData.OcclusionProbeProxyVolume标志
perObjectData =PerObjectData.Lightmaps | PerObjectData.ShadowMask |PerObjectData.LightProbe | PerObjectData.OcclusionProbe |PerObjectData.LightProbeProxyVolume |PerObjectData.OcclusionProbeProxyVolume
获取LPPV遮蔽数据的方式与获取其光照数据类似,区别在于我们需要调用SampleProbeOcclusion而非SampleProbeVolumeSH4。这些数据存储在同一纹理中且需要相同的参数,唯一的例外是不需要法线向量。在SampleBakedShadows中添加对应的分支处理,同时为现在必需的世界坐标位置添加surface参数
float4 SampleBakedShadows (float2 lightMapUV, Surface surfaceWS) {#if defined(LIGHTMAP_ON)…#elseif (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参数
gi.shadowMask.shadows = SampleBakedShadows(lightMapUV, surfaceWS);
Sampling LPPV occlusion 采样LPPV遮蔽数据
1.6 Mesh Ball 网格球体
如果我们的网格球体使用了LPPV,它已经支持阴影遮罩了。但当它自行插值光照探针时,我们必须在MeshBall.Update中添加遮蔽探针数据。这需要为CalculateInterpolatedLightAndOcclusionProbes的最后一个参数使用临时的Vector4数组,并通过CopyProbeOcclusionArrayFrom方法将其传递到属性块中
var lightProbes = new SphericalHarmonicsL2[1023];var occlusionProbes = new Vector4[1023];LightProbes.CalculateInterpolatedLightAndOcclusionProbes(positions, lightProbes, occlusionProbes);block.CopySHCoefficientArraysFrom(lightProbes);block.CopyProbeOcclusionArrayFrom(occlusionProbes);
确认阴影遮罩数据正确发送到着色器后,我们可以移除GetLighting中的调试可视化代码
//return gi.shadowMask.shadows.rgb;
2. Mixing Shadows 混合阴影
现在我们有了可用的阴影遮罩,下一步是在没有实时光照的情况下(即当片段超出最大阴影距离时)使用它
2.1 Use Baked when Available 使用可用的烘焙阴影
混合烘焙阴影和实时阴影会使GetDirectionalShadowAttenuation的工作更加复杂。让我们首先将所有实时阴影采样代码分离出来,将其移至Shadows中新的GetCascadedShadow函数
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;#endiffloat 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函数,该函数返回给定阴影遮罩的烘焙阴影衰减值。如果遮罩启用了距离模式,我们需要其阴影向量的第一个分量,否则没有可用的衰减值,结果为1。
float GetBakedShadow (ShadowMask mask) {float shadow = 1.0;if (mask.distance) {shadow = mask.shadows.r;}return shadow;
}
接下来创建一个MixBakedAndRealtimeShadows函数,参数包括ShadowData、实时阴影和阴影强度。该函数通常仅对实时阴影应用强度系数,但当存在距离阴影遮罩时,会用烘焙阴影替换实时阴影
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使用该函数,而不是自行应用强度系数。
shadow = GetCascadedShadow(directional, global, surfaceWS);shadow = MixBakedAndRealtimeShadows(global, shadow, directional.strength);
Faded baked shadows 渐隐的烘焙阴影
结果是我们现在始终使用阴影遮罩,可见其运行正常。但烘焙阴影会像实时阴影一样随距离逐渐淡出
2.2 Transitioning to Baked 过渡到烘焙阴影
要根据深度从实时阴影过渡到烘焙阴影,我们必须根据全局阴影强度在两者之间进行插值。然而,我们还必须应用光源的阴影强度——这需要在插值之后进行。因此我们不能再在GetDirectionalShadowData中立即合并这两个强度值
data.strength =_DirectionalLightShadowData[lightIndex].x; // * shadowData.strength;
在MixBakedAndRealtimeShadows中,根据全局强度在烘焙阴影和实时阴影之间进行插值,然后应用光源的阴影强度。但当不存在阴影遮罩时,仅对实时阴影应用合并后的强度值,就像我们之前做的那样
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 混合阴影
结果是动态物体投射的阴影正常淡出,而静态物体投射的阴影则过渡到阴影遮罩
2.3 Only Baked Shadows 仅烘焙阴影
目前我们的方法仅在需要渲染实时阴影时有效。如果没有实时阴影,阴影遮罩也会消失。可以通过拉远场景视图直到所有物体都超出最大阴影距离来验证这一点
Neither realtime nor baked shadows 既无实时阴影也无烘焙阴影
我们需要支持存在阴影遮罩但没有实时阴影的情况。首先创建一个带强度参数的GetBakedShadow函数变体,这样就能方便地获取经过强度调制的烘焙阴影
float GetBakedShadow (ShadowMask mask, float strength) {if (mask.distance) {return lerp(1.0, GetBakedShadow(mask), strength);}return 1.0;
}
接下来,在GetDirectionalShadowAttenuation中检查合并后的强度是否为零或更小。如果是,则不再始终返回1,而是仅返回经过调制的烘焙阴影,同时跳过实时阴影采样
if (directional.strength * global.strength <= 0.0) {shadow = GetBakedShadow(global.shadowMask, directional.strength);}
此外,我们需要修改Shadows.ReserveDirectionalShadows,使其不会立即跳过没有实时阴影投射物的光源。而是先判断该光源是否使用阴影遮罩,再检查是否存在实时阴影投射物——如果不存在,则只有阴影强度是相关参数
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);}…}
但当阴影强度大于零时,着色器仍会采样阴影贴图,即使这样做并不正确。我们可以通过在这种情况下将阴影强度取负值来解决这个问题
return new Vector3(-light.shadowStrength, 0f, 0f);
然后在GetDirectionalShadowAttenuation中跳过实时阴影时,将强度绝对值传递给GetBakedShadow。这样无论是不存在实时阴影投射物还是超出最大阴影距离的情况都能正常工作
shadow = GetBakedShadow(global.shadowMask, abs(directional.strength));
Only baked shadows 仅烘焙阴影
2.4 Always use the Shadow Mask 始终使用阴影遮罩
还有另一种阴影遮罩模式,简称为Shadowmask。它的工作方式与距离模式完全相同,只是Unity会为使用阴影遮罩的光源省略静态阴影投射物
No realtime shadows cast by static geometry 静态几何体不投射实时阴影
这样设计的理念是:由于阴影遮罩随处可用,我们也可以在任何地方将其用于静态阴影。这意味着实时阴影更少,渲染速度更快,但代价是近距离静态阴影的质量较低。
要支持此模式,请在Shadows的阴影遮罩关键字数组中将_SHADOW_MASK_ALWAYS作为第一个元素添加。我们可以通过检查QualitySettings.shadowmaskMode属性来确定应启用哪个关键字。
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();}
将关键字添加到我们着色器的multi-compile指令中。
#pragma multi_compile _ _SHADOW_MASK_ALWAYS _SHADOW_MASK_DISTANCE
同时在Common中检查该关键字以决定是否定义SHADOWS_SHADOWMASK
#if defined(_SHADOW_MASK_ALWAYS) || defined(_SHADOW_MASK_DISTANCE)#define SHADOWS_SHADOWMASK
#endif
为ShadowMask结构体添加一个独立的布尔字段,用于指示是否应始终使用阴影遮罩
struct ShadowMask {bool always;bool distance;float4 shadows;
};…ShadowData GetShadowData (Surface surfaceWS) {ShadowData data;data.shadowMask.always = false;…
}
然后在GetGI中适时设置该字段及其阴影数据
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);#endifreturn gi;
}
两个版本的GetBakedShadow都应在任一模式启用时选择遮罩。
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现在必须采用不同的方法:首先,实时阴影必须通过全局强度调制以基于深度淡出;然后通过取最小值来合并烘焙阴影和实时阴影;最后将光源的阴影强度应用于合并后的阴影。
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
烘焙静态阴影与实时动态阴影混合
3. Multiple Lights 多光源
由于阴影遮罩贴图包含四个通道,它最多可支持四个混合光源。烘焙时最重要的光源使用红色通道,第二个光源使用绿色通道,依此类推。让我们复制现有的定向光源,稍作旋转并降低其强度,使新光源最终使用绿色通道来测试这个功能
What happens when there are more than four mixed-mode lights? 当混合模式光源超过四个时会出现什么情况?
Unity会将前四个之后的混合模式光源全部转换为完全烘焙光源。这是假设所有光源都是定向光(目前我们唯一支持的光源类型)。其他光源类型的影响范围有限,这可能使得同一通道能够用于多个光源
Two lights sharing the same baked shadows 两个光源共享相同的烘焙阴影
第二个光源的实时阴影工作正常,但其烘焙阴影错误地使用了第一个光源的遮罩数据(在始终启用阴影遮罩模式下最容易观察到这个问题
3.1 Shadow Mask Channels 阴影遮罩通道
检查烘焙的阴影遮罩贴图可以发现阴影已正确烘焙:仅受第一个光源照亮的区域显示红色,仅受第二个光源照亮的区域显示绿色,同时受两个光源照亮的区域显示黄色。这最多支持四个光源,不过第四个光源在预览中不可见(因为不显示Alpha通道)
Baked shadows for two lights 两个光源的烘焙阴影
由于两个光源都使用红色通道,导致它们共享相同的烘焙阴影。要解决这个问题,我们需要将光源的通道索引发送到GPU。不能依赖光源顺序,因为运行时顺序可能变化(光源可能被修改甚至禁用)。
我们可以在Shadows.ReserveDirectionalShadows中通过LightBakingOutput.occlusionMaskChannel字段获取光源的遮罩通道索引。由于我们向GPU发送的是4D向量,可以将其存储在返回向量的第四个通道中(需要将返回类型改为Vector4)。当光源不使用阴影遮罩时,我们将其索引设为-1来标识。
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);}
3.2 Selecting the Appropriate Channel 选择合适的通道
在着色器端,将阴影遮罩通道作为额外整数字段添加到Shadows中定义的DirectionalShadowData结构体中
struct DirectionalShadowData {float strength;int tileIndex;float normalBias;int shadowMaskChannel;
};
然后GI需要在GetDirectionalShadowData中设置该通道。
DirectionalShadowData GetDirectionalShadowData (int lightIndex, ShadowData shadowData
) {…data.shadowMaskChannel = _DirectionalLightShadowData[lightIndex].w;return data;
}
为两个版本的GetBakedShadow添加通道参数,并使用它返回相应的阴影遮罩数据。但仅当光源使用阴影遮罩时(即通道值大于等于零时)才执行此操作
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;
}
点积运算确实比通道索引更高效吗?
是的,但着色器编译器会为我们处理这个问题。它会使用通道索引一个静态向量缓冲区(这些向量的对应分量已设为1),然后通过点积运算来过滤遮罩数据。我们也可以将点积结果发送到GPU来跳过查找步骤,但这需要发送额外的向量数组且仍需进行索引访问
调整MixBakedAndRealtimeShadows,使其传递所需的阴影遮罩通道参数
float MixBakedAndRealtimeShadows (ShadowData global, float shadow, int shadowMaskChannel, float strength
) {float baked = GetBakedShadow(global.shadowMask, shadowMaskChannel);…
}
最后,在GetDirectionalShadowAttenuation中添加所需的通道参数
float GetDirectionalShadowAttenuation (DirectionalShadowData directional, ShadowData global, Surface surfaceWS
) {#if !defined(_RECEIVE_SHADOWS)return 1.0;#endiffloat 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 两个光源各自使用其对应的通道
What about the Subtractive mixed lighting mode?
关于"减色混合光照模式"(Subtractive mixed lighting mode)?
减色光照是结合烘焙光照与阴影的另一种方式,仅使用单张光照贴图。其原理是:完全烘焙一个光源,但同时将其用于实时光照。然后计算该光源的实时漫反射光照,采样实时阴影,并用其确定被遮蔽的漫反射光量,最后从漫反射全局光照中减去这部分。
因此,静态物体最终使用烘焙光照(尽管为它们计算了实时漫反射光照)并能接收实时阴影。动态物体则需依赖遮蔽探针来接收静态阴影。
这是一种预算有限的方案,具有严重局限性:仅适用于不可更改的单个定向光。所有间接光照或其他烘焙光源都会产生错误结果,可通过可配置的阴影颜色(应与场景平均间接全局光照颜色匹配)来约束暗化程度以缓解问题。
本系列教程不会包含对减色模式的支持。如果有空间存储阴影遮罩贴图,那么"始终使用阴影遮罩模式"(always-shadow-mask mode)优于减色模式。如果没有,则考虑完全烘焙方案,这允许更复杂的光照设置。
The next tutorial is LOD and Reflections. 下一章教程是LOD与反射