接上一期:
膜力鸭苏蛙可:引擎搭建记录(5) - 距离场 : 建场zhuanlan.zhihu.com
项目地址:
MrySwk/GravityEnginegithub.com
上一期,我们完成了距离场的构建,本文接下来分析并完整地实现一套类似于堡垒之夜中使用到的阴影系统,如下图。
近处两层级联,远处距离场
最远的大桌子是距离场阴影,近处的两个是级联/pcss(因为没有AO所以桌角还是有明显的漂浮感,不过这不是这篇文章的讨论范围)。
CSM与PCSS
因为不是在unity这类的引擎里面实现,要考虑的东西就多了很多,事实上我现在这个小轮子里CPU端只有一个线程负责update和发dc,再加上系统里还没有接入LOD的库,导致一旦场景变复杂,效率会变得非常低,后期可能需要重构一次把渲染线程和更新线程分出来,以及加上LOD,场景的复杂度带来的代价才能压得下去,因此这里就不花时间讨论这一块,后面着手进行这一块的优化的话可能再另作分享。
级联和PCSS网上也有很多解释了,在下就不拿来水文章凑数了,下面我们简单提一下CSM/PCSS,再重点介绍距离场阴影的实现。
级联
级联的初衷是增加shadowmap的分辨率,一张贴图覆盖特别大的范围会导致效果很糟糕,所以考虑离镜头越近的地方用分辨率越高的shadowmap,越远的地方则分辨率越低,下图是一个按视锥划分的四层级联。
view space alignment
而这样做带来的问题是,一旦镜头转动,就会出现很严重的闪烁(想象一张网格纸在另一张网格纸上面转动的效果),所以一般划分级联是在光源的空间中划分:
light space alignment
这样划分的话,每一层级联之间的重叠会更大,一般我们在重叠区域会取更精细的一层作为结果,在接近交缝处则考虑混合。
在渲染完shadowmap后,我们把阴影投回屏幕上,下图是把各层级联可视化的效果
R,G,B分别表示前两层级联和距离场的范围
PCSS
Percentage Closer Soft Shadow是3A大作中常见的软阴影解决方案,和卷积sm、esm、vsm这些技术相比,PCSS能更好的表现阴影的软硬变化,其通过距离比计算出半影大小,再进行PCF滤波(也可以不选择PCF而是和其他软阴影技术结合使用)。PCSS主要解决的是,离阴影接触点越近,阴影越硬的问题。
PCSS的半影大小(软硬程度)是由下图方式计算得到。
dBlocker的求法是,采样周围的像素,找有没有哪个点对当前这个点造成了遮蔽,如果有,那么根据这个blocker来算半影的大小,然后再用比较大的核去采样,如果不在半影范围内的,则不需要使用大的采样核,离blocker近的也使用小的采样核,这样就可以使得接触点近处的阴影硬,远的阴影软。
因为代码比较长所以就不列了,毕竟不是本文重点,此外,这一步可以考虑用比较低的采样数+temporal过滤,不过这种方法也不适合特别大的采样核(因为变化过大会导致被clip/clamp掉),带temporal的效果我也在代码中也实现了,但是效率似乎不如直接加大采样数,因此最后这个效果中没有开启prefilter也没有开启temporal,采样分布也非常naive地用了Hammersley,最终消耗加上后面的SDF March不会超过0.6ms。
实现距离场阴影
级联是非常贵的,不作任何处理的话,每层级联都意味着drawcall翻倍。其实,对于现代cpu来说,dc数量已经不是最大的问题了,相比之下,顶点、填充的开销可能会更严重。而距离场绝对不仅仅是为了阴影更软,还可以在保持动态的情况下降低级联的开销。
很多游戏里会用非常近的级联,然后远处用别的技术代替,比如far cry以前就用过高度阴影,虚幻里针对地形也做了个高度场,此外还有更直接的用static shadowmap的办法,用静态的坏处是,光源就不能动了,而tod在现在3A中是很常见的需求,此外,远处的阴影也就只有静态物体有了,其实对于大多数线性流程的游戏,烘焙的阴影也是完全够用的,但是对于沙盘之类要求比较高的游戏,静态可能会不太够用,对于这种情境,SDF不能算是唯一的解决方案,但绝对是非常decent的一种。
距离场阴影效率远远高于普通shadowmap阴影,全屏1spp的ray march,在pc平台上是完全可以接受的,实际上因为是远处,降半分辨率也不会有太大影响,顽皮狗在美末中甚至用过1/4分辨率的cone trace来做软阴影。
下图给出的是1080p全分辨率全屏trace(不是只trace远处),对1000个距离场进行march的结果,场景如下。
测试场景
首先需要提一下的一点是,用的是32x32x32分辨率的距离场(也是虚幻中默认的距离场大小),march的效率和网格多边形数量无关,只和分辨率有关,所以不要在意都是box,复杂物体的效率也是一样的。
结果剔除用了0.694ms,trace用到的耗时为0.669毫秒,因为没有随机,1spp画面也是稳定的,不需要做任何高斯、双边、temporal。
剔除耗时
trace耗时(不要在意名字)
看上去是1.3ms~1.4ms的效率,但是分tile剔除本身是一个非常偏vgpr consuming的pass,这意味着,在主机和pc的A卡上,如果async安排的好,那么这个pass基本上是免费的,代价基本上就是后面的这个0.67ms的ray march,而实际使用的时候只有远处,不是全屏,一般来讲级联还是会覆盖屏幕上大部分区域,所以march的开销会在0.3ms以内。
- 分Tile剔除
上一期我们烘好了任意网格体的距离场:
烘出来的距离场(色带是因为march步长较大)
对距离场进行march之前,我们要确定当前这个像素点会交到哪些距离场,因为march的方向是固定朝着光源方向进行,所以我们从光源的方向进行分块剔除是一个比较合适的思路。
这一步我取的是64x64的分块(瞎取的),没有用距离场的AABB去求交,为了效率故直接用了sphere bound去求交,求交没有投影到灯光的viewport里进行,而是直接在view space,用的最粗糙的剔除,代码也很简单:
for
分块的效果和剔除的结果可视化一下,为了看的清楚点,就用比较少的物体演示下,用灰色代表1个,白色代表2个,如图所示:
- Ray March
剔除完了之后,就可以开始cone trace了,trace的原理上一期也简单提过,这里再解释一次:
我们选定一个cone的角度,这个cone的角度越大,阴影越软,选定好后,沿着cone的中心轴步进,采样到的距离场值,就是其位置到最近点的距离,我们要找的是这跟中心轴上被遮蔽得最厉害的点,也就是(距离/圆锥半径)的最小值:
float dist = gSdfTextures[sdfInd].Sample(basicSampler, pos).r;
shadow = min(shadow, saturate(dist / (totalDis * CONE_TANGENT)));
march的距离我们知道,乘上tangent就是圆锥的半径,而距离就是距离场里采样的值,这样取到的最小值,就是阴影的值。
追踪的这一部分代码我大半都是嫖虚幻的。这里把我的代码贴一下,并解释一下大致原理:
#if !DEBUG_CASCADE_RANGE
[branch]
if (!cascaded)
{
float3 lightDir = -normalize(gMainDirectionalLightDir.xyz);
float3 opaqueWorldPos = worldPos;
float3 rayStart = opaqueWorldPos + lightDir * RAY_START_OFFSET;
float3 rayEnd = opaqueWorldPos + lightDir * MAX_DISTANCE;
float2 tilePos = mul(float4(opaqueWorldPos, 1.0f), gSdfTileTransform).xy;
int2 tileID = int2(floor(tilePos.x * SDF_GRID_NUM), floor((1.0f - tilePos.y) * SDF_GRID_NUM));
int tileIndex = tileID.y * SDF_GRID_NUM + tileID.x;
if (tileID.x < 0 || tileID.x >= SDF_GRID_NUM ||
tileID.y < 0 || tileID.y >= SDF_GRID_NUM
)
return 1.0f;
int objectNum = gSdfList[tileIndex].NumSdf;
float minConeVisibility = 1.0f;
[loop]
for (int i = 0; i < objectNum; i++)
{
uint objIndex = gSdfList[tileIndex].SdfObjIndices[i];
int sdfInd = gSceneObjectSdfDescriptors[objIndex].SdfIndex;
float3 volumeRayStart = mul(float4(rayStart, 1.0f), gSceneObjectSdfDescriptors[objIndex].objInvWorld).xyz;
float3 volumeRayEnd = mul(float4(rayEnd, 1.0f), gSceneObjectSdfDescriptors[objIndex].objInvWorld).xyz;
float3 volumeRayDirection = volumeRayEnd - volumeRayStart;
float volumeRayLength = length(volumeRayDirection);
volumeRayDirection /= volumeRayLength;
float halfExtent = gMeshSdfDescriptors[sdfInd].HalfExtent;
float rcpHalfExtent = rcp(halfExtent);
#if USE_FIXED_POINT_SDF_TEXTURE
float SdfScale = halfExtent * 2.0f * SDF_DISTANCE_RANGE_SCALE;
#endif
float3 localPositionExtent = float3(halfExtent, halfExtent, halfExtent);
float3 outOfBoxRange = float3(SDF_OUT_OF_BOX_RANGE, SDF_OUT_OF_BOX_RANGE, SDF_OUT_OF_BOX_RANGE);
float2 intersectionTimes = LineBoxIntersect(volumeRayStart, volumeRayEnd, -localPositionExtent - outOfBoxRange, localPositionExtent + outOfBoxRange);
[branch]
if (intersectionTimes.x < intersectionTimes.y)
{
float sampleRayTime = intersectionTimes.x * volumeRayLength;
uint stepIndex = 0;
[loop]
for (; stepIndex < MAX_STEP; stepIndex++)
{
float3 sampleVolumePosition = volumeRayStart + volumeRayDirection * sampleRayTime;
float3 clampedSamplePosition = clamp(sampleVolumePosition, -localPositionExtent, localPositionExtent);
float distanceToClamped = length(clampedSamplePosition - sampleVolumePosition);
float3 volumeUV = (clampedSamplePosition * rcpHalfExtent) * 0.5f + 0.5f;
float distanceField;
#if USE_FIXED_POINT_SDF_TEXTURE
distanceField = SampleMeshDistanceField(sdfInd, SdfScale, volumeUV);
#else
distanceField = SampleMeshDistanceField(sdfInd, volumeUV);
#endif
distanceField += distanceToClamped;
// Don't allow occlusion within an object's self shadow distance
//float selfShadowVisibility = 1 - saturate(sampleRayTime * selfShadowScale);
//float sphereRadius = clamp(TanLightAngle * sampleRayTime, VolumeMinSphereRadius, VolumeMaxSphereRadius);
//float stepVisibility = max(saturate(distanceField / sphereRadius), selfShadowVisibility);
float sphereRadius = CONE_TANGENT * sampleRayTime;
float stepVisibility = saturate(distanceField / sphereRadius);
minConeVisibility = min(minConeVisibility, stepVisibility);
float stepDistance = max(abs(distanceField), MIN_STEP_LENGTH);
sampleRayTime += stepDistance;
// Terminate the trace if we are fully occluded or went past the end of the ray
if (minConeVisibility < .01f ||
sampleRayTime > intersectionTimes.y * volumeRayLength)
{
break;
}
}
// Force to shadowed as we approach max steps
minConeVisibility = min(minConeVisibility, (1 - stepIndex / (float)MAX_STEP));
}
if (minConeVisibility < .01f)
{
minConeVisibility = 0.0f;
break;
}
}
shadow = minConeVisibility;
#if DEBUG_TILE_CULLING
shadow = gSdfList[tileIndex].NumSdf / 2.0f;
//shadow = (float)tileIndex / (SDF_GRID_NUM * SDF_GRID_NUM);
#endif
}
#endif
首先我们根据深度还原世界坐标,然后投到light space里找到对应的tile,查看tile里面存的距离场,找到之后开始march,先发送一条光线和距离场的AABB求交,确定march的起点和终点,这里这个AABB可以稍微扩大一圈,以免阴影特别软的时候参与march的范围不够。
接下来规定一个最大步长,我这里取了64步,然后对于距离场外的位置,我们直接clamp到距离场内,并且加上clamp的距离作为距离场采样值,在距离场内的就直接采样,然后就是按上面所说的步进、采样,找最小的遮蔽系数作为阴影的值。
遇到负数,说明是本影,就可以early out,遇到超出边界,也可以直接停止march,这样,我们就得到了软阴影的效果:
- 作为近处使用的主要阴影系统的可能性?
距离场也有一些非常麻烦的问题,降低了距离场的实用性,首先就是烘焙慢,一万面左右的模型,一个32分辨率的场大概要烘十来秒到一分钟不等,128分辨率的经常五分钟起步,对于比较大的项目而言,这会是一个比较大的开支,有的时候可以考虑只烘焙比较大的建筑物等。此外,显存占用也是一个问题,比如在UE4里开启距离场会吃掉300M的显存。
烘焙速度慢,也就注定了距离场不能支持顶点动画,只能烘死了不做动画,物体级别的移动、旋转、缩放等是支持的,我们只需要对每个距离场维护一个变换矩阵就可以了,但是如果不能做动画,使用就非常受限了,所以,我们可以考虑用胶囊体等简单形状来做动画。
胶囊体阴影
虚幻里很早就支持了胶囊体软阴影,胶囊体做阴影的效果会远远软于PCSS,发个文档链接:
胶囊体阴影docs.unrealengine.com
虚幻给的图里效果不是很好,实际上用起来效果是相当不错的,顽皮狗在美末里以前也有过这个思路,那就是用简单的胶囊等几何体代替人来做复杂的trace计算:
出来的效果非常软非常好看,简直吊打shadowmap:
胶囊体软阴影
胶囊体、box、圆柱、圆环等大多数简单的几何形体,其距离场都可以用公式表达,这个网页里收录了很多距离场公式:
fractals, computer graphics, mathematics, shaders, demoscene and morewww.iquilezles.org
胶囊体的距离场函数:
float SampleMeshDistanceField(int sdfInd, float3 uv)
{
float dist = gSdfTextures[sdfInd].SampleLevel(basicSampler, uv, 0).r;
return dist;
}
float SampleMeshDistanceField(int sdfInd, float SdfScale, float3 uv)
{
float dist = gSdfTextures[sdfInd].SampleLevel(basicSampler, uv, 0).r;
dist = (dist - 0.5f) * SdfScale;
return dist;
}
march的过程和上面一样,可以写个简单的看下效果:
这个trace是只沿光源方向,不包括环境光,所以可以看到阴影内还是非常flat,这种trace的方式也和上面图中的不同,是存在比较大的本影区域的。
虚幻里有个专门给胶囊体用的CapsuleShadow,是CapsuleShadowShaders.usf这个文件,感兴趣的话各位可以去看,实现思路也基本上是先分tile剔除,再march距离场。
总结
距离场阴影和shadowmap相比,没有drawcall,代价非常小,效果远远软于PCSS,可以在没有额外代价的情况下调整阴影的软硬程度(可以特别特别软),配合胶囊体可以做动画,代价是烘焙需要时间,吃显存,总的来说是一项非常实用的阴影技术。
再次附上代码实现地址:
MrySwk/GravityEnginegithub.com
Reference
[1] fractals, computer graphics, mathematics, shaders, demoscene and more
[2] http://miciwan.com/SIGGRAPH2013/Lighting%20Technology%20of%20The%20Last%20Of%20Us.pdf
[3] Cascaded Shadow Maps - Win32 apps
[4] https://www.realtimeshadows.com/sites/default/files/Playing%20with%20Real-Time%20Shadows_0.pdf
[5] https://developer.download.nvidia.cn/shaderlibrary/docs/shadow_PCSS.pdf
[6] https://github.com/chenjd/Unity-Signed-Distance-Field-Shadow
[7] https://github.com/EpicGames/UnrealEngine