原文链接
正文
级联阴影原理:
CSM阴影技术讲解-腾讯游戏学院gameinstitute.qq.com/community/detail/117522
一、为什么使用级联阴影
Unity 内置的方向光实时阴影技术是 Cascaded Shadow Mapping(简称 CSM)。这是因为使用基本的 Shadow mapping 在大场景下存在阴影图 精度问题
精度问题
- 主相机整个视锥生成一张阴影图,会导致单个物体占用阴影图的比例太小,读取阴影图时出现采样精度不够(多个像素采样一个图素),产生锯齿。使用尺寸更大的阴影图可以改善这个问题,但会导致 内存使用增加。
- 相机近端和远端物体对阴影图采样精度一样,会导致近端物体采样 精度不够,远端物体采样 精度浪费。
解决方法
级联阴影贴图(Cascaded Shadow Maps)是 解决 此问题的方法。这个想法是阴影投射器被渲染了不止一次,因此每个光在图集中会得到多个图块,称为级联(Cascaded )。第一个级联仅覆盖靠近相机的一小部分区域,而连续的级联会 缩小 以覆盖越来越大的具有相同像素数量的区域。然后,着色器对每个片段可用的最佳级联进行采样。
Unity 的阴影代码每个定向光最多支持 四个 级联:
二、具体使用情况
FrameDebugger:
-
FrameDebugger 中的 MainShadowMap
-
主光源阴影
如果将 mainLightShadows
的代码注释掉:
则 FrameDebugger 结果如下:
- 没有 MainShadowMap
此时场景内也没有主光源阴影了:
- 物体没有阴影
在 ForwardRenderer 的 Data 面板可以配置级联阴影的层级,有 0
、2
、4
三个选项:
选择 4 级级联阴影时 FrameDebugger 结果如下:
- 四级级联阴影
选择二级级联阴影时,FrameDebugger 结果如下:
- 二级级联阴影
三、代码分析
变量
const int k_MaxCascades = 4; //最大阴影级联级数
const int k_ShadowmapBufferBits = 16; // DepthBuffer位数
int m_ShadowmapWidth; //ShadowMap宽
int m_ShadowmapHeight; //ShadowMap高
int m_ShadowCasterCascadesCount; //配置的阴影级联数
bool m_SupportsBoxFilterForShadows; //时候支持BoxFilter(Mobile和Switch支持)
RenderTargetHandle m_MainLightShadowmap; //只是用来存储"_MainLightShadowmapTexture"这个id
RenderTexture m_MainLightShadowmapTexture; //渲染的目标贴图
Matrix4x4[] m_MainLightShadowMatrices; //用来存储shadowTransform然后赋值给GPU中的"_MainLightWorldToShadow"
ShadowSliceData[] m_CascadeSlices; //存储各级阴影的ShadowSliceData数据
Vector4[] m_CascadeSplitDistances; //存储各级阴影的剔除球数据
ShadowSliceData
中存放的是各级阴影的 矩阵、偏移 和 分辨率 等数据:
public struct ShadowSliceData
{
public Matrix4x4 viewMatrix;
public Matrix4x4 projectionMatrix;
public Matrix4x4 shadowTransform;
public int offsetX;
public int offsetY;
public int resolution;
public void Clear()
{
viewMatrix = Matrix4x4.identity;
projectionMatrix = Matrix4x4.identity;
shadowTransform = Matrix4x4.identity;
offsetX = offsetY = 0;
resolution = 1024;
}
}
方法
1. MainLightShadowCasterPass.Setup 方法
当 MainLightShadowCasterPass.Setup
方法返回为 true
的时候就会进行 MainLightShadowCasterPass
的渲染:
- 当
Setup
方法返回true
,则会进行MainLightShadowCasterPass
渲染
如果配置中不支持主光源阴影或者没有主光源,或者可见的光中,设置的阴影类型是 None
,都不会进行主光源阴影的渲染:
除此之外,可见光最终可能不会影响任何投射阴影的对象,这可能是因为它们没有配置,或者是因为光线仅影响了超出最大阴影距离的对象。我们可以通过在剔除结果上调用 GetShadowCasterBounds
以获得可见光索引来进行检查。它具有边界的第二个输出参数(我们不需要),并返回边界是否有效。如果不是,则没有阴影可渲染,因此应将其忽略:
最后按级联 Index 进行了遍历,调用了ShadowUtils.ExtractDirectionalLightMatrix
方法:
这里面主要调用了 cullResults.ComputeDirectionalShadowMatricesAndCullingPrimitives
方法:
ExtractDirectionalLightMatrix
方法
Api 文档:
CullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives
阴影贴图的 原理 是,我们从灯光的角度渲染场景,只存储深度信息。用结果标记出,光线在击中某物之前会传播多远。但是,定向光被假定为无限远,没有真实位置。因此,我们要做的是找出与灯光方向匹配的 视图 和 投影矩阵,并为我们提供一个剪辑空间立方体,该立方体与包含可见光阴影的摄像机可见区域重叠。这个不用自己去实现,我们可以使用 culling results 的 ComputeDirectionalShadowMatricesAndCullingPrimitives
方法为我们完成此工作,并为其传递 9 个参数。
第一个 参数是可见光指数。接下来的 三个参数 是两个整数和一个Vector3
,它们控制阴影级联。稍后我们将处理级联,因此现在使用零,一和零向量。然后是纹理尺寸,我们需要使用平铺尺寸。第六个 参数是靠近平面的阴影,我们现在将其忽略并将其设置为零。
这些是输入参数,其余三个 是输出参数。首先是视图矩阵,然后是投影矩阵,最后一个参数是 ShadowSplitData
结构。
cullResults.ComputeDirectionalShadowMatricesAndCullingPrimitives
返回的 bool
值就是表示这项转换能否成功完成。
因为每个级联都需要其自己的变换矩阵,所以需要根据每个级联 Index 进行遍历计算。
2. MainLightShadowCasterPass.Configure
创建了一个临时的 Texture,并将其设置为 Target。
3. MainLightShadowCasterPass.Execute
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
RenderMainLightCascadeShadowmap(ref context, ref renderingData.cullResults, ref renderingData.lightData, ref renderingData.shadowData);
}
RenderMainLightCascadeShadowmap
用来完成MainLightCascadeShadowmap Texture 的绘制工作:
void RenderMainLightCascadeShadowmap(ref ScriptableRenderContext context, ref CullingResults cullResults, ref LightData lightData, ref ShadowData shadowData)
{
int shadowLightIndex = lightData.mainLightIndex;
if (shadowLightIndex == -1)
return;
VisibleLight shadowLight = lightData.visibleLights[shadowLightIndex];
CommandBuffer cmd = CommandBufferPool.Get(m_ProfilerTag);
using (new ProfilingScope(cmd, m_ProfilingSampler))
{
var settings = new ShadowDrawingSettings(cullResults, shadowLightIndex);
for (int cascadeIndex = 0; cascadeIndex < m_ShadowCasterCascadesCount; ++cascadeIndex)
{
var splitData = settings.splitData;
splitData.cullingSphere = m_CascadeSplitDistances[cascadeIndex];
settings.splitData = splitData;
Vector4 shadowBias = ShadowUtils.GetShadowBias(ref shadowLight, shadowLightIndex, ref shadowData, m_CascadeSlices[cascadeIndex].projectionMatrix, m_CascadeSlices[cascadeIndex].resolution);
ShadowUtils.SetupShadowCasterConstantBuffer(cmd, ref shadowLight, shadowBias);
ShadowUtils.RenderShadowSlice(cmd, ref context, ref m_CascadeSlices[cascadeIndex],
ref settings, m_CascadeSlices[cascadeIndex].projectionMatrix, m_CascadeSlices[cascadeIndex].viewMatrix);
}
bool softShadows = shadowLight.light.shadows == LightShadows.Soft && shadowData.supportsSoftShadows;
CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadows, true);
CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.MainLightShadowCascades, shadowData.mainLightShadowCascadesCount > 1);
CoreUtils.SetKeyword(cmd, ShaderKeywordStrings.SoftShadows, softShadows);
SetupMainLightShadowReceiverConstants(cmd, shadowLight, shadowData.supportsSoftShadows);
}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
RenderMainLightCascadeShadowmap
方法中,首先调用 ShadowUtils.GetShadowBias
方法获取 shadow 的 bias 值。之所以需要 bias,是为了减轻阴影痤疮的现象。具体的可以参考下面的文章:
LearnOpenGL-CN —— 高级光照 » 阴影 » 阴影映射
然后调用 ShadowUtils.SetupShadowCasterConstantBuffer
进行 shader 参数的配置。最后调用了 关键 方法 ShadowUtils.RenderShadowSlice
:
ShadowUtils.RenderShadowSlice
是真正进行 MainLightCascadeShadowmap 绘制的方法。
流程如下:
- 计算该级联阴影级别在 Atlas 对应的 Viewport
- 通过
CommandBuffer
设置好 ViewPort 和 View&Project 矩阵 - 绘制 ShadowMap 到 Atlas 上
4.球形剔除
Unity 通过为其创建一个选择球来 确定每个级联覆盖的区域。由于阴影投影是正交的且呈正方形,因此它们最终会紧密契合其剔除球,但还会覆盖周围的一些空间。这就是为什么可以在剔除区域之外看到一些阴影的原因。同样,光的方向与球无关,因此所有定向光最终都使用相同的剔除球。
- 使用透明的球体来让剔除球可视化
还需要这些球体来确定从哪个级联进行采样,因此我们需要将它们发送到GPU。为级联计数和级联的剔除球体数组添加一个标识符,并为球体数据添加一个数组。它们由四分量矢量定义,包含其 XYZ 位置及其在 W 分量中的半径。
MainLightShadowCasterPass 中使用 m_CascadeSplitDistances
来存储剔除球信息。
- 存储剔除球信息
跟踪代码可以发现,级联的剔除球是ComputeDirectionalShadowMatricesAndCullingPrimitives
输出的拆分数据的一部分。
ComputeDirectionalShadowMatricesAndCullingPrimitives
计算剔除球数据
m_CascadeSplitDistances
的数据计算好后,会在 RenderMainLightCascadeShadowmap
时将数据传递给 ShadowDrawingSettings
,用于 context.DrawShadows
时使用。
- 将剔除球信息传递给
ShadowDrawingSettings
,用于绘制 shadow
在 RenderMainLightCascadeShadowmap
方法的最后会调用 SetupMainLightShadowReceiverConstants
方法,用于将计算所得的各种数据传递给 GPU:
void SetupMainLightShadowReceiverConstants(CommandBuffer cmd, VisibleLight shadowLight, bool supportsSoftShadows)
{
Light light = shadowLight.light;
bool softShadows = shadowLight.light.shadows == LightShadows.Soft && supportsSoftShadows;
int cascadeCount = m_ShadowCasterCascadesCount;
for (int i = 0; i < cascadeCount; ++i)
m_MainLightShadowMatrices[i] = m_CascadeSlices[i].shadowTransform;
// We setup and additional a no-op WorldToShadow matrix in the last index
// because the ComputeCascadeIndex function in Shadows.hlsl can return an index
// out of bounds. (position not inside any cascade) and we want to avoid branching
Matrix4x4 noOpShadowMatrix = Matrix4x4.zero;
noOpShadowMatrix.m22 = (SystemInfo.usesReversedZBuffer) ? 1.0f : 0.0f;
for (int i = cascadeCount; i <= k_MaxCascades; ++i)
m_MainLightShadowMatrices[i] = noOpShadowMatrix;
float invShadowAtlasWidth = 1.0f / m_ShadowmapWidth;
float invShadowAtlasHeight = 1.0f / m_ShadowmapHeight;
float invHalfShadowAtlasWidth = 0.5f * invShadowAtlasWidth;
float invHalfShadowAtlasHeight = 0.5f * invShadowAtlasHeight;
float softShadowsProp = softShadows ? 1.0f : 0.0f;
cmd.SetGlobalTexture(m_MainLightShadowmap.id, m_MainLightShadowmapTexture);
cmd.SetGlobalMatrixArray(MainLightShadowConstantBuffer._WorldToShadow, m_MainLightShadowMatrices);
cmd.SetGlobalVector(MainLightShadowConstantBuffer._ShadowParams, new Vector4(light.shadowStrength, softShadowsProp, 0.0f, 0.0f));
if (m_ShadowCasterCascadesCount > 1)
{
cmd.SetGlobalVector(MainLightShadowConstantBuffer._CascadeShadowSplitSpheres0,
m_CascadeSplitDistances[0]);
cmd.SetGlobalVector(MainLightShadowConstantBuffer._CascadeShadowSplitSpheres1,
m_CascadeSplitDistances[1]);
cmd.SetGlobalVector(MainLightShadowConstantBuffer._CascadeShadowSplitSpheres2,
m_CascadeSplitDistances[2]);
cmd.SetGlobalVector(MainLightShadowConstantBuffer._CascadeShadowSplitSpheres3,
m_CascadeSplitDistances[3]);
cmd.SetGlobalVector(MainLightShadowConstantBuffer._CascadeShadowSplitSphereRadii, new Vector4(
m_CascadeSplitDistances[0].w * m_CascadeSplitDistances[0].w,
m_CascadeSplitDistances[1].w * m_CascadeSplitDistances[1].w,
m_CascadeSplitDistances[2].w * m_CascadeSplitDistances[2].w,
m_CascadeSplitDistances[3].w * m_CascadeSplitDistances[3].w));
}
// Inside shader soft shadows are controlled through global keyword.
// If any additional light has soft shadows it will force soft shadows on main light too.
// As it is not trivial finding out which additional light has soft shadows, we will pass main light properties if soft shadows are supported.
// This workaround will be removed once we will support soft shadows per light.
if (supportsSoftShadows)
{
if (m_SupportsBoxFilterForShadows)
{
cmd.SetGlobalVector(MainLightShadowConstantBuffer._ShadowOffset0,
new Vector4(-invHalfShadowAtlasWidth, -invHalfShadowAtlasHeight, 0.0f, 0.0f));
cmd.SetGlobalVector(MainLightShadowConstantBuffer._ShadowOffset1,
new Vector4(invHalfShadowAtlasWidth, -invHalfShadowAtlasHeight, 0.0f, 0.0f));
cmd.SetGlobalVector(MainLightShadowConstantBuffer._ShadowOffset2,
new Vector4(-invHalfShadowAtlasWidth, invHalfShadowAtlasHeight, 0.0f, 0.0f));
cmd.SetGlobalVector(MainLightShadowConstantBuffer._ShadowOffset3,
new Vector4(invHalfShadowAtlasWidth, invHalfShadowAtlasHeight, 0.0f, 0.0f));
}
// Currently only used when !SHADER_API_MOBILE but risky to not set them as it's generic
// enough so custom shaders might use it.
cmd.SetGlobalVector(MainLightShadowConstantBuffer._ShadowmapSize, new Vector4(invShadowAtlasWidth,
invShadowAtlasHeight,
m_ShadowmapWidth, m_ShadowmapHeight));
}
}
总结一下渲染流程:
Setup()
方法判断是否需要绘制阴影,以及调用ShadowUtils.ExtractDirectionalLightMatrix
进行各级阴影数据的计算。Configure()
方法配置渲染Target
和Clear
的参数。Execute()
方法调用RenderMainLightCascadeShadowmap()
方法,用于阴影贴图的绘制。其中 最核心 的绘制方法是ShadowUtils.RenderShadowSlice
。绘制结束后会调用SetupMainLightShadowReceiverConstants
方法将一些数据传递给 GPU,用于阴影贴图的采样。
四、MainLightShadow 的采样
-
先看一下 Shadow.hlsl 中定义的变量,他们存储了
MainLightShadowPass
中传递给 GPU 的数据: -
MainLightShadow 的采样从 LitForwardPass 的 LitPassFragment 阶段开始,其中会调用
InitializeInputData
方法:这个方法位于 Shadow.hlsl 中,里面主要是调用
ComputeCascadeIndex
方法来计算级联index
: -
ComputeCascadeIndex 的代码
InitializeInputData
方法计算除了InputData.shadowCoord
,然后调用UniversalFragmentPBR
方法进行光照计算: -
UniversalFragmentPBR 方法
UniversalFragmentPBR
中使用InputData.shadowCoord
的是GetMainLight
方法: -
GetMainLight 方法
GetMainLight
方法会调用MainLightRealtimeShadow
方法,来采样_MainLightShadowmapTexture
: -
MainLightRealtimeShadow 方法
-
SampleShadowmap 方法:
real SampleShadowmap(TEXTURE2D_SHADOW_PARAM(ShadowMap, sampler_ShadowMap), float4 shadowCoord, ShadowSamplingData samplingData, half4 shadowParams, bool isPerspectiveProjection = true)
{
// Compiler will optimize this branch away as long as isPerspectiveProjection is known at compile time
if (isPerspectiveProjection)
shadowCoord.xyz /= shadowCoord.w;
real attenuation;
real shadowStrength = shadowParams.x;
// TODO: We could branch on if this light has soft shadows (shadowParams.y) to save perf on some platforms.
#ifdef _SHADOWS_SOFT
attenuation = SampleShadowmapFiltered(TEXTURE2D_SHADOW_ARGS(ShadowMap, sampler_ShadowMap), shadowCoord, samplingData);
#else
// 1-tap hardware comparison
attenuation = SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, shadowCoord.xyz);
#endif
attenuation = LerpWhiteTo(attenuation, shadowStrength);
// Shadow coords that fall out of the light frustum volume must always return attenuation 1.0
// TODO: We could use branch here to save some perf on some platforms.
return BEYOND_SHADOW_FAR(shadowCoord) ? 1.0 : attenuation;
}
- SampleShadowmapFiltered 方法:
real SampleShadowmapFiltered(TEXTURE2D_SHADOW_PARAM(ShadowMap, sampler_ShadowMap), float4 shadowCoord, ShadowSamplingData samplingData)
{
real attenuation;
#if defined(SHADER_API_MOBILE) || defined(SHADER_API_SWITCH)
// 4-tap hardware comparison
real4 attenuation4;
attenuation4.x = SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, shadowCoord.xyz + samplingData.shadowOffset0.xyz);
attenuation4.y = SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, shadowCoord.xyz + samplingData.shadowOffset1.xyz);
attenuation4.z = SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, shadowCoord.xyz + samplingData.shadowOffset2.xyz);
attenuation4.w = SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, shadowCoord.xyz + samplingData.shadowOffset3.xyz);
attenuation = dot(attenuation4, 0.25);
#else
float fetchesWeights[9];
float2 fetchesUV[9];
SampleShadow_ComputeSamples_Tent_5x5(samplingData.shadowmapSize, shadowCoord.xy, fetchesWeights, fetchesUV);
attenuation = fetchesWeights[0] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[0].xy, shadowCoord.z));
attenuation += fetchesWeights[1] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[1].xy, shadowCoord.z));
attenuation += fetchesWeights[2] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[2].xy, shadowCoord.z));
attenuation += fetchesWeights[3] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[3].xy, shadowCoord.z));
attenuation += fetchesWeights[4] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[4].xy, shadowCoord.z));
attenuation += fetchesWeights[5] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[5].xy, shadowCoord.z));
attenuation += fetchesWeights[6] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[6].xy, shadowCoord.z));
attenuation += fetchesWeights[7] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[7].xy, shadowCoord.z));
attenuation += fetchesWeights[8] * SAMPLE_TEXTURE2D_SHADOW(ShadowMap, sampler_ShadowMap, float3(fetchesUV[8].xy, shadowCoord.z));
#endif
return attenuation;
}
- SAMPLE_TEXTURE2D_SHADOW 宏定义:
#define SAMPLE_TEXTURE2D_SHADOW(textureName, samplerName, coord3) textureName.SampleCmpLevelZero(samplerName, (coord3).xy, (coord3).z)