对阴影的理解
unity的阴影,实际做了两件事
- 一是计算出实时光的阴影,基于shadowmap。
- 二是将实时阴影和bake数据,根据不同策略做混合
URP12对阴影的支持情况
- 一个主光源(必须是平行光)的实时阴影。
- 移动平台最多32个附加光源(不支持平行光)。
- 附加光源都会写入一张rt,按图集的方式写入不同块。
- 点光源会写入图集的最多6个分区中(根据照到的区域计算),相比用cube会有性能提升,因为点光源不一定会照到6个面。
- 点光源和聚光灯的实时阴影。
- 屏幕空间阴影。
- 半透表面接收阴影,但不能投射阴影。
主光源shadowmap
通过MainLightShadowCasterPass驱动
先看构造函数和主要的属性
RenderPassEvent是BeforeRenderingShadows,也就是clear之后就渲染shadowmap。
m_MainLightShadowMatrices = new Matrix4x4[k_MaxCascades + 1];
m_CascadeSlices = new ShadowSliceData[k_MaxCascades];
m_CascadeSplitDistances = new Vector4[k_MaxCascades];
- 级联贴图相关的属性,用法在后面的代码细看,简单来说,级联阴影的作用类似mipmap,在不同距离采样不同精度的贴图,以提升性能。
private static class MainLightShadowConstantBuffer
- 这个类,声明了shader用到的各个变量,记录id。
Setup函数
有两个作用,一个是判断是否开启了阴影,一个是设置阴影的一些参数,分辨率,级联层级数量等。函数的结果是将设定好的阴影图按级联数量,分成几个小块,每个小块渲染一个精度的阴影。
renderingData.cullResults.GetShadowCasterBounds(shadowLightIndex, out bounds)
- 调用了一个底层接口,获取光源的包围盒,在光源范围内有shadowcast的物体时返回true。
int shadowResolution = ShadowUtils.GetMaxTileResolutionInAtlas(renderingData.shadowData.mainLightShadowmapWidth, renderingData.shadowData.mainLightShadowmapHeight, m_ShadowCasterCascadesCount);
- 这个函数的作用,是根据级联数量,获取每块贴图的分辨率。
ShadowUtils.ExtractDirectionalLightMatrix
- 这个函数用来设置级联贴图相关的属性,矩阵、距离、分辨率等。
- 调用底层ComputeDirectionalShadowMatricesAndCullingPrimitives
Configure函数
主要作用是获取shadowmap的rt,配置到pass的color缓冲区,标记为只渲染深度值。
m_MainLightShadowmapTexture = ShadowUtils.GetTemporaryShadowTexture(m_ShadowmapWidth,m_ShadowmapHeight, k_ShadowmapBufferBits);
ConfigureTarget(new RenderTargetIdentifier(m_MainLightShadowmapTexture), m_MainLightShadowmapTexture.depthStencilFormat, renderTargetWidth, renderTargetHeight, 1, true);
重点是最后一个参数,标记depthOnly为true。
Execute函数,实际执行的是RenderMainLightCascadeShadowmap
- 首先渲染shader需要一个ShadowDrawingSettings,设置好setting的cullingSphere。
Vector4 shadowBias = ShadowUtils.GetShadowBias(ref shadowLight, shadowLightIndex, ref shadowData, m_CascadeSlices[cascadeIndex].projectionMatrix, m_CascadeSlices[cascadeIndex].resolution);
- 计算shadow的偏移值,不是直接用配置的值,这样可以根据分辨率做更细致的调整。具体计算在GetShadowBias函数,对点光源软阴影还要乘上2.5,这大概是个经验值。
ShadowUtils.SetupShadowCasterConstantBuffer(cmd, ref shadowLight, shadowBias);
- 设置shader的_ShadowBias和_LightDirection变量。
ShadowUtils.RenderShadowSlice(cmd, ref context, ref m_CascadeSlices[cascadeIndex], ref settings, m_CascadeSlices[cascadeIndex].projectionMatrix, m_CascadeSlices[cascadeIndex].viewMatrix);
- 这个函数是渲染shadowmap的核心,每次调用,都将一个分辨率下的shadow渲染到shadowmap对应的一部分。
- 一个重要的函数是SetViewProjectionMatrices,不过这个函数没查到太详细的资料,我的理解是相当于在光源创建一个相机的作用,因为shadowmap需要在光源位置渲染,而在URP中并没有实际创建相机,当初看这的时候还迷惑了好久。
- 还有一个函数是EnableScissorRect,实际把shadowmap渲染的小了一点,避免边缘像素冲突。
SetupMainLightShadowReceiverConstants(cmd, shadowLight, softShadows);
- 最后一步,就是设置各个shader变量了。
附加光shadowmap
大体流程和主光源差不多,下面就对一些有区别的地方做下说明。
- 附加光没有级联阴影,因为不支持平行光,而点光源和聚光灯都是有范围的,不需要支持级联,也可以节省内存。
- 附加光会以图集的方式渲染到一个rt上,并且不管有多少光源,都只有一个rt。所以实际上为了效果,对同时可见的点光源数量还是要限制的,因为点光源最多会占6块,聚光灯固定一块,光源越多,每个光源的分辨率也会越低,毕竟rt大小也是有限制的。
- unity有个优化,为了避免图集空间不足时显示错误,会删除分辨率过小的shadow slices。
ShadowCaster pass写入shadowmap
- 通过ScriptableRenderContext的DrawShadows方法渲染ShadowCaster这个pass,写入shadowmap。
- https://docs.unity3d.com/ScriptReference/Rendering.ScriptableRenderContext.DrawShadows.html接口说明在这。
- 实现在ShadowCasterPass.hlsl
- 顶点shader,主要是做坐标变换,和一般坐标变换不同点,一个是要加上偏移值,偏移值通过_ShadowBias获取,在c#设置。另一个问题是要考虑UNITY_REVERSED_Z。
- 像素shader也比较简单,固定返回0。调用Alpha函数,只是为了做裁剪,配合物体的挖洞效果,如果没定义_ALPHATEST_ON,这个函数调用是没用的,返回的alpha并不会用到。
实时阴影和bake数据混合
计算上是分主光和附加光分别处理的,但算法相同。
bake有3种策略,在计算bakedShadow值和混合时处理。
核心代码是下面的两个分支。
- LIGHTMAP_SHADOW_MIXING在烘焙模式为Subtractive或Shadowmask时为true,表示对直接光和间接光都烘焙了阴影,取实时和烘焙的最小值。通过CalculateShadowMask函数处理,在Subtractive模式下,bakedShadow也是没有值的,如果不开启lightmap,则使用探针数据,unity提供unity_ProbesOcclusion获取。
- 否则只烘焙了间接光,bakedShadow是没有值的,阴影已经加在lightmap的颜色值上了。表现效果为根据fade距离,调整实时阴影强度。
#if defined(LIGHTMAP_SHADOW_MIXING)
return min(lerp(realtimeShadow, 1, shadowFade), bakedShadow);
#else
return lerp(realtimeShadow, bakedShadow, shadowFade);
#endif
还有一点是软阴影的计算,区分平台。
在移动平台,采样4次,通过C#获取4个偏移值,返回平均值。
其他平台,通过SampleShadow_ComputeSamples_Tent_5x5获取偏移和权重,然后采样原点和周围8个像素,加权取平均值。
可以发现采样次数很多,由于overdraw的影响,也会造成比较大的性能浪费。unity增加了一个屏幕空间阴影的feature,可以提升一些性能。
屏幕空间阴影
只对主光源生效,分两个pass。第二个pass只是将一些shader关键字还原。重点是第一个。
核心算法是取到深度图上的每一点,转换为对应的世界坐标,然后正常计算shadow(包括软阴影),也就是对屏幕上的每一点做了一次阴影计算,所以最后的阴影效果,和不开启时是一样的。区别在于这样每个像素点只会计算一次阴影,避免了overdraw带来的多次采样shadowmap的消耗。可以理解为是一种预计算,之后计算颜色时采样这张全屏texture就可以了。
但是对半透表面,是不能采样这个贴图的,因为SS用的是当前深度图的深度对应的世界坐标,而半透表面不在这个点,所以遮挡当前深度的物体不一定会遮挡半透表面,对于半透表面,还是要正常采样shadowmap,因为shadowmap是对光源可见,能用来判断半透的遮挡情况。
总的来说,屏幕空间阴影是为了优化overdraw带来的多次采样shadowmap的问题,和其他屏幕空间的技术有点差别,但是核心思路还是一样的,都是为了减少计算量。作为一个feature是因为有些情况不透明没有overdraw,比如URP开启depth priming,以及支持TBDR的显卡。未来可能有更多的硬件可以处理overdraw。
半透表面处理
半透的表面,不会写入shadowmap,所以不能产生阴影。
半透接收的阴影,只能通过光源的shadowmap计算。
接收阴影会产生阴影比较重的问题,因为半透后面的物体也接收了阴影,然后半透本身变暗,叠加后边的颜色,变更暗。模型越复杂,颜色越暗。
总结下来,URP对阴影的支持还是比较完整的,各个光源都可以支持,也有一定的性能优化。对于手游项目还是有必要升级的。