在Unity角色渲染中,系统默认的级联阴影即使设置的较大,往往因为角色占阴影空间面积比例很小导致精度不够的情况;角色自己的阴影投射到自身上时,会出现糟糕的阴影表现。因此想将角色阴影单独渲染,使用一张高精度的阴影图来表现角色自阴影的效果。
默认阴影下,精度不够导致角色阴影表现不够精致
关于URP中的阴影实现以及相关的PCF实现方式已经有诸多教程和介绍,可以参考https://catlikecoding.com/unity/tutorials/custom-srp/directional-shadows/
角色阴影精度不够的原因主要在于角色在阴影相机的视角中占比太小导致的,那么我们的做法便是将阴影相机的视口对准角色,使角色尽可能的占满视口;阴影相机是正交投影方式且方向是与光入射的方向一致的,那么我们只要将阴影相机的视口涵盖角色的包围框即可。另外我们只需要单独渲染角色的阴影,场景阴影继续使用级联阴影,所以阴影相机需要获得角色上的所有Renderer。
这里分享一个我的实现方式,如有错误和不足的地方诚恳的欢迎指正和讨论。
1.初步实现
1.1阴影相机设置
GameObject shadowCameraGO = new GameObject("#Shadow Camera " + shadowCameraName);
shadowCameraGO.hideFlags = HideFlags.DontSave | HideFlags.NotEditable;
shadowMapCamera = shadowCameraGO.AddComponent<Camera>();
shadowMapCamera.renderingPath = RenderingPath.Forward;
shadowMapCamera.clearFlags = CameraClearFlags.Depth;
shadowMapCamera.depthTextureMode = DepthTextureMode.None;
shadowMapCamera.useOcclusionCulling = false;
shadowMapCamera.cullingMask = shadowCameraLayer;
shadowMapCamera.orthographic = true;
shadowMapCamera.depth = -100;
shadowMapCamera.aspect = 1f;
shadowMapCamera.SetReplacementShader(shadowDepthShader, "RenderType");
先设置一个阴影相机用于后续的阴影矩阵计算和渲染,设置正交投影并设置渲染ReplacementShader即后续用来渲染shadowmap的shader;
1.2创建ShadowMap的RT
shadowTexture = new RenderTexture(size, size, ShadowMapDepth, RenderTextureFormat.Shadowmap, RenderTextureReadWrite.Linear);
shadowTexture.filterMode = FilterMode.Bilinear;
shadowTexture.useMipMap = false;
shadowTexture.autoGenerateMips = false;
创建一张格式为ShadowMap类型的RT,关闭mip并设置过滤模式。如果想进一步提升深度精度可以提高depth的位数,默认为16位。
1.3计算相机的视口
首先要记录要渲染角色下的所有Renderer和Material以便后续的渲染和设置变量,这里注意记录Material时使用material 和sharedMaterial的区别。如果想要角色尽量占满相机视口那么可以创建一个包围盒让其包围角色下所有的renderer,这么做的话因为角色有动画所以需要每帧更新包围盒的大小;或者直接指定一个能够包围角色体型的大小。
var bounds = new Bounds(transform.position, Vector3.zero);
foreach (var re in shadowRenderers)
{
bounds.Encapsulate(re.bounds);
}
但是这里的包围盒是AABB包围盒,不能直接使用这个大小作为相机视口的大小,需要转换到光源空间下计算大小,考虑到每帧更新包围盒并计算视口的消耗,选择合适的固定视口大小更有性价比;
将相机位置设置到角色位置延光照反方向移动一段距离的位置,并将旋转保持与光源一致,那么阴影渲染的视角矩阵就得到了:
shadowViewMatrix = shadowMapCamera.worldToCameraMatrix;
通过计算后的视口大小来计算阴影渲染的投影矩阵,近平面和远平面位置覆盖角色即可:
shadowProjectionMatrix = Matrix4x4.Ortho(left, right, bottom, top, zNear, zFar);
1.4添加自定义的ShadowCaster Pass
与Urp中的shadow caster pass很接近,不过这里我们要加上自定义的shadow bias,然后在Fragment中处理一下alpha 和 cutoff就可以了;
float3 ApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection)
{
float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS));
float scale = invNdotL * _UniqueShadowBias.y;
// normal bias is negative since we want to apply an inset normal offset
positionWS = lightDirection * _ShadowBias.xxx + positionWS;
positionWS = normalWS * scale.xxx + positionWS;
return positionWS;
}
1.5渲染角色shadowmap
渲染角色阴影的时机应该要在Opaque之前,可以通过RenderFeature自定义一个pass去执行也可以通过RenderPipelineManager.beginCameraRendering事件实现:
RenderPipelineManager.beginCameraRendering += ExecuteShadowRendering;
之后就是遍历角色所有的renderer绘制:
CalculateShadowMatrix();
var commandBuffer = CommandBufferPool.Get(commandBufferName);
commandBuffer.SetRenderTarget(shadowTexture, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);
commandBuffer.ClearRenderTarget(true, false, Color.clear);
commandBuffer.SetViewMatrix(shadowViewMat);
commandBuffer.SetProjectionMatrix(shadowProjMat);
foreach(var r in roleShadowRenderers)
{
if (!cacheMaterial.TryGetValue(r, out Material[] sharedMaterials))
continue;
for(int i = 0; i < sharedMaterials.Length; i++)
{
var material = sharedMaterials[i];
var passId = material.FindPass("CustomShadowCaster");
if (-1 != passId)
commandBuffer.DrawRenderer(r, material, i, passId);
}
}
context.ExecuteCommandBuffer(commandBuffer);
CommandBufferPool.Release(commandBuffer);
这里其实并没有用之前创建的阴影相机来渲染,而是将通过阴影相机计算来的矩阵设置到主相机来渲染的;可以通过frame debugger查看自阴影绘制的RT:
1.6阴影采集
ShadowMap绘制完了,现在我们需要把它应用到场景和人物上,在shader中定义一个全局的texture变量,将前面绘制的Shadow Map赋值到这个变量,然后将绘制点的坐标转换到光源空间采样这张图就能得到阴影数据。这里我们使用pcf实现软阴影,其他方式当然也可以支持。如果我们人物使用了自定义的阴影来绘制,那么就可以关闭人物的默认级联阴影绘制;这样阴影逻辑就变成环境物体使用级联阴影绘制,人物使用自定义阴影绘制。人物和环境物体一样都要采样两次Shadow Map然后阴影值取min,这样保证人物和环境都能正常阴影投射。在绘制阶段我们计算了阴影的投影矩阵和视角矩阵,将世界坐标转换到光源空间使用的矩阵就是这两个,将计算出的VP矩阵传递到shader中。
MatrixVP = shadowProjectionMatrix * shadowViewMatrix
这里要注意投影矩阵在计算时需要判断渲染平台,因为DX和GL平台的深度差异:
if (SystemInfo.usesReversedZBuffer)
shadowProjectionMatrix.SetRow(2, -shadowProjectionMatrix.GetRoww(2));
地面的高精度人物阴影投射(这里的ShadowMap尺寸为1024):
2.多个角色的绘制
2.1 ShadowMap分块
前面的简单实现只是将单个角色的阴影数据绘制到了一张RT上,如果每个角色都需要创建一张RT的话这显然有些浪费;于是这里想到参考unity中的级联阴影的做法,将一张RT划分为多块,每一级绘制到RT上的一块区域。下图中显示的是4盏灯光+4级级联将Shadow Map划分成了16块区域。
那么我们参考这个做法,假如有12个角色,我们就将绘制角色阴影的ShadowMap划分成3行4列(角色往往包围盒是矩形,所以按列大于行的划分比较合理)。
2.2记录所有要绘制的角色
一个角色往往有多个renderer,按分块的做法需要将一个角色的所有renderer绘制到同一个区域内,这样就需要我们提前将所有要绘制的角色以及角色下的所有renderer记录。当区域内的角色有变化或者角色下的renderer有增减时就需要手动更新。可以创建一个单例manager用来记录要绘制的角色信息;
如果有需要,我们也可以记录每个人角色的包围盒大小,以适应不同体型的角色。
2.3依次计算矩阵并绘制
绘制阶段依次取出要绘制的角色,依角色的绘制和包围盒大小计算出当前的阴影投影矩阵和视角矩阵。不同的是这里需要设置出角色要绘制到的shadow map 格子相应offset:
Vector2 offset = new Vector2(index % split.y, index / split.y);
控制相机绘制到相应的区域可以使用cmdBuffer.SetViewport。这样就可以将角色按区域划分绘制到同一张RT上。在绘制时也可以按照当前绘制角色的数量来划分区域:4个角色以内-> 2x2,6个角色以内-> 2x3,12个角色以内 -> 3x4。
2.4多个角色的阴影投射
以地面举例,下图中的六个角色阴影都会投射到地面上,那么只采样一次阴影就不能满足了;但是如果粗暴的计算六次阴影显然不合理,比如后排的角色身上只接受了前面的一个角色阴影。因此我们先将世界坐标转换到角色的光源空间下,然后判断采样的坐标是否在该角色的阴影区域内:通过判断的即表示在当前角色的阴影投射范围内,将此前计算的阴影与当前角色的阴影叠加;
for (int i = 0; i < _ShadowRoleCount; i++)
{
float2 offset = float2(fmod(i, _ShadowTileCols), floor(i / _ShadowTileCols));
half4 shadowcoords = mul(_UniqueShadowMatrix[i], positionWS);
if (shadowcoords.x < offset.x * _ShadowTileSize.x ||
shadowcoords.x >(1.0 + offset.x) * _ShadowTileSize.x ||
shadowcoords.y < offset.y * _ShadowTileSize.y ||
shadowcoords.y >(1.0 + offset.y) * _ShadowTileSize.y)
continue;
else
{
shadow = min(shadow, SAMPLE_UNIQUE_SHADOW_PCF(shadowcoords));
}
}
3.支持Srp Batcher
3.1 使用Render Feature渲染阴影
之前我们逐个角色逐个renderer的绘制方式显然不支持Srp Batcher,在多个角色的情况下能够支持batcher更加友好。但是对于绘制角色时每个角色的投影矩阵和视角矩阵都不同,就不能再使用前面
commandBuffer.SetViewMatrix(shadowViewMat);
commandBuffer.SetProjectionMatrix(shadowProjMat);
的方式来设置视角矩阵和投影矩阵了;另外也不能使用commandBuffer.SetViewport的方式将角色绘制到rt的一块区域中了;
首先先写一个简单的render feature,将所有角色设置到一个layer中,来筛选要绘制的角色renderer,另外绘制时我们希望只绘制有我们自定义阴影pass shader的renderer,而且使用这个pass绘制。可以使用DrawingSetting来设置:
m_ShaderTagIdList.Add(new ShaderTagId("CustomShadowCaster"));
var drawSettings = CreateDrawingSettings(m_ShaderTagIdList, ref renderingData, sortFlags);
3.2计算每个角色的绘制矩阵
前面提到,我们不能在角色绘制前单独为每个角色指定view矩阵和projection矩阵了,那么我们就提前将VP矩阵计算好设置到material上然后修改shader中的vertex 计算positionCS的矩阵就可以了。同样因为不能使用commandBuffer.SetViewport来控制绘制区域,我们也提前将角色绘制的offset和scale也计算出来应用到VP矩阵上。
大体的计算过程如下:
//由于unity坐标系左手系的原因需要将第三行取反
Matrix4x4 viewMat = Matrix4x4.TRS(targetPos, lightOri, Vector3.one).inverse;
viewMat.SetRow(2, -viewMat.GetRow(2));
shadowViewMat[i] = viewMat;
shadowProjMat[i] = defaultProjMat;
var projMat = shadowProjMat[i];
if (SystemInfo.usesReversedZBuffer)
{
projMat.SetRow(2, -defaultProjMat.GetRow(2));
reversedY = 1.0f;
}
Vector2 offset = new Vector2(i % m_ShadowSettings.roleShadowTexTiles.y, i / m_ShadowSettings.roleShadowTexTiles.y);
Vector2 scale = new Vector2(1f / (float)m_ShadowSettings.roleShadowTexTiles.y, 1f / (float)m_ShadowSettings.roleShadowTexTiles.x);
ar db = SystemInfo.usesReversedZBuffer ? m_ShadowSettings.depthBias : -m_ShadowSettings.depthBias;
hadowCoordMat[i].SetRow(0, new Vector4(0.5f * scale.x, 0.0f, 0.0f, (0.5f + offset.x) * scale.x));
shadowCoordMat[i].SetRow(1, new Vector4(0.0f, 0.5f * scale.y, 0.0f, (0.5f + offset.y) * scale.y));
shadowCoordMat[i].SetRow(2, new Vector4(0.0f, 0.0f, 0.5f, 0.5f + db));
shadowCoordMat[i].SetRow(3, new Vector4(0.0f, 0.0f, 0.0f, 1.0f));
var matrixOffset = new Matrix4x4();
matrixOffset.SetRow(0, new Vector4(scale.x, 0.0f, 0.0f, -1.0f + (1.0f + offset.x * 2.0f) * scale.x));
matrixOffset.SetRow(1, new Vector4(0.0f, scale.y, 0.0f, reversedY * (1.0f - scale.y - offset.y * 2.0f * scale.y)));
matrixOffset.SetRow(2, new Vector4(0.0f, 0.0f, 1.0f, 0.0f));
matrixOffset.SetRow(3, new Vector4(0.0f, 0.0f, 0.0f, 1.0f));
shadowSampleMat[i] = shadowCoordMat[i] * projMat * shadowViewMat[i];
shadowVPMat[i] = matrixOffset * GL.GetGPUProjectionMatrix(shadowProjMat[i], true) * shadowViewMat[i];
这里还要注意一个地方,如果在ScriptableRenderPass的pass执行中去给每个材质单独赋值然后绘制会导致绘制合批后设置的值不生效,所以我使用了一个MatrixArray全局变量来存储所有角色的绘制矩阵,然后材质上记录一个id,绘制时根据这个id来取对应的矩阵计算。
最后绘制的结果还是和前面一致,不过可以支持srp batcher了。
这里可以看到六个相同的角色的阴影只使用一个SRP Batch就全部绘制了。
欢迎加入我们!
感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com