按屏幕占比裁剪局部光动态阴影:一个运行时 WorldSubsystem 的实现与实测
按屏幕占比裁剪局部光动态阴影:一个运行时 WorldSubsystem 的实现与实测
一个密集城市街道场景抓的 trace 里,我碰到一个不太常见的瓶颈形态:GameThread / RenderThread / RHIThread 三条线并列卡住,各约 88 ms,彼此在 ±9 ms 内;GPU 只忙 53 ms,有约 36 ms 余量。这篇记一下我针对其中一条杠杆——局部光动态阴影——做的运行时裁剪,包括为什么这么做、实现上几个绕不开的点,以及关掉/打开 CVar 的 A/B 数据。
同一个 trace 里针对另一条杠杆(30k 散摆静物的 draw-call)的处理,见把近场散摆静物合成 HISM 削 draw-call。两条线一起压,帧时才真正掉得下来。
背景:三线程并列的瓶颈没法单点优化
场景是一条密集城市街道,大量人群加海量街道静物。关键体量:
| 对象 | 数量 |
|---|---|
| StaticMeshComponent | 30,518 |
| SpotLightComponent | 1,765 |
| PointLightComponent | 661(局部光合计 2,426) |
平均帧时 88.99 ms(11.2 FPS),中位 83 ms,整段 trace 没有任何一帧进过 30 FPS 档——是稳态问题,不是偶发 hitch。
三线程并列这个形态最难受的地方在于:单削一条线不动帧时。比如你把 RenderThread 削掉 3 ms,它只会掉到 GameThread / RHIThread 之下,帧时仍由剩下两条更高的线决定。要提平均 FPS,必须找到能同时压三条线的动作。对这个场景,这类动作只有一个方向——减少可见工作体量。而局部光的动态阴影,恰好是能一次压三条线的那种开销。
为什么阴影是这场景性价比最高的一刀
ShadowDepths 是全 trace 第二大项:325,751 ms,占 11.5%,平均 9.1 ms/帧;ShadowProjection 另有 35,855 ms。而这些开销的来源是 1,765 盏投影 SpotLight 加 661 盏 PointLight,绝大多数是街道装饰光——街灯、招牌、室内固定灯。远处那些小屏占比的光,它投的动态阴影玩家根本分辨不出来。
一盏投影局部光同时吃三样东西:
- RenderThread:阴影 view setup、
FProjectedShadowInfo::GatherDynamicMeshElements - RHIThread:阴影深度 draw 的提交
- GPU:
ShadowDepths本身
前两样正好落在两条瓶颈线上。所以"关掉远处局部光的动态阴影"这一个动作,能同时压 RenderThread + RHIThread(GPU 那部分因为有余量不算进 FPS 收益)。这是我在这个 trace 里能找到的最直接的那一刀。
角色侧其实早有先例:AGameCharacter::ApplyShadowSignificance() 已经按距离桶给角色组件关过 CastShadow。但局部光是关卡摆放的 World Partition actor,没有统一的 C++ 基类,也从没进过任何显著性系统,阴影全程开着。这就是要补的洞。
思路:屏幕占比 + 迟滞,只在状态变化时动渲染态
核心逻辑很短:周期性发现已加载的 Movable 局部光,按它们的屏幕占比分类,占比掉到阈值以下就 SetCastShadows(false)——光本身照亮,只是不投动态阴影;走近了再恢复。
屏幕占比我用了一个便宜的近似:AttenuationRadius / 距相机距离。不精确,但对"远小光去阴影"这个决策够用,而且不用去查投影矩阵。默认阈值 MinScreenCoverage = 0.05,偏保守,只有很远或物理尺寸很小的光会被削掉,丢掉的接触阴影肉眼不可辨。
两个我一开始没想到、后来必须加的东西:
- 迟滞(hysteresis)。阈值卡在 0.05 的光,如果每次分类都硬比,它会在阈值两侧反复翻转 shadow 状态。而每次
SetCastShadows都会触发这盏光的渲染态重建——反复翻就是反复重建,得不偿失。所以开一档 ±10% 的迟滞带:占比掉到0.9×阈值才关,升到1.1×阈值才开。 - 只在状态变化时调
SetCastShadows。每帧无脑调用会让每盏受管光每帧重建渲染态,比不优化还慢。用一个bShadowsCurrentlyOn记住当前态,只有决策真的翻转时才下发。
为什么做成 WorldSubsystem,而不是 per-light 组件
第一直觉是给每盏光挂个 ULightSignificanceComponent,自己注册进现有的 SignificanceBudget 框架。我没这么做,原因有两个:
- 这些光是关卡摆放的 actor,没有统一 C++ 基类。要挂组件就得改一大批蓝图 / 关卡资产,或者写迁移脚本,成本高且脏。让子系统自己去发现已加载的光,一行关卡数据都不用动。
- 现有的 SignificanceBudget 插件本质是tick 时段的预算分配器,为"每帧要 tick 的对象"设计。而光组件根本不 tick——把不 tick 的东西塞进一个 tick 预算器,语义别扭。所以我让这套逻辑刻意独立于 SignificanceBudget,单独做一个世界子系统。
于是它是一个 UWorldSubsystem + FTickableGameObject,自己低频跑两个 pass:一个发现光,一个分类阴影。
实现细节
每帧节流两个 pass
Tick 本身不做重活,只按两个独立的时间间隔去触发发现和分类。CVar 默认:发现 1 秒一次(接 World Partition 流进流出的光),分类 0.2 秒一次(阴影开关不需要 60 Hz)。
void ULightSignificanceSubsystem::Tick(float DeltaTime)
{
const bool bEnabled = (GLightSigEnable != 0);
// 关闭沿:恢复一次全部灯的原始阴影态,然后空转到再次打开。
if (!bEnabled)
{
if (bWasEnabledLastFrame)
{
RestoreAllShadows();
ManagedLights.Empty();
}
bWasEnabledLastFrame = false;
return;
}
bWasEnabledLastFrame = true;
BindPlayerCamera();
if (!PlayerCamera.IsValid())
{
return;
}
RescanAccumulator += DeltaTime;
if (RescanAccumulator >= GLightSigRescanInterval)
{
RescanAccumulator = 0.f;
RediscoverLights();
}
UpdateAccumulator += DeltaTime;
if (UpdateAccumulator >= GLightSigUpdateInterval)
{
UpdateAccumulator = 0.f;
UpdateLightShadows(PlayerCamera->GetCameraLocation());
}
}
发现:靠 TActorIterator 天然作用域到近处
TActorIterator 只遍历当前已加载的 actor。在 World Partition 下,已加载集合正好是玩家周围流进来的那批,所以发现 pass 天然就框在近处的光上,不用自己做空间查询。发现时只收 EComponentMobility::Movable 的光——Static / Stationary 的阴影是烘焙或缓存的,本来就便宜,去动它们的 CastShadows 反而会让缓存失效。
if (LightComp->Mobility != EComponentMobility::Movable)
{
continue; // Static/Stationary 阴影已烘焙/已缓存,不碰
}
每次发现会先清掉被 GC / 流出的失效弱指针,再用一个 TSet 查重,避免重复加入或覆盖掉已记录的原始 CastShadows 值。
分类:迟滞带 + 只在翻转时下发
void ULightSignificanceSubsystem::UpdateLightShadows(const FVector& CameraLocation)
{
const float MinCoverage = GLightSigMinScreenCoverage;
const float OnCoverage = MinCoverage * 1.1f; // 迟滞:升到这个才重新开阴影
const float OffCoverage = MinCoverage * 0.9f; // 迟滞:掉到这个才关阴影
for (FManagedLight& Managed : ManagedLights)
{
ULocalLightComponent* LightComp = Managed.Light.Get();
if (!IsValid(LightComp)) continue;
if (!Managed.bOriginalCastShadows) continue; // 本来就不投影的光,没啥可省
const float Distance = FVector::Distance(LightComp->GetComponentLocation(), CameraLocation);
const float Coverage = (Distance > KINDA_SMALL_NUMBER)
? (LightComp->AttenuationRadius / Distance)
: TNumericLimits<float>::Max(); // 光贴在相机上时视为满显著性
bool bWantShadows = Managed.bShadowsCurrentlyOn;
if (Managed.bShadowsCurrentlyOn && Coverage < OffCoverage)
{
bWantShadows = false;
}
else if (!Managed.bShadowsCurrentlyOn && Coverage > OnCoverage)
{
bWantShadows = true;
}
// 只在真正翻转时动渲染态。
if (bWantShadows != Managed.bShadowsCurrentlyOn)
{
LightComp->SetCastShadows(bWantShadows);
Managed.bShadowsCurrentlyOn = bWantShadows;
}
}
}
整个每帧决策流:
可回退:CVar 关闭沿一键恢复
每盏光进管理时记下它作者态的 CastShadows(bOriginalCastShadows),所以任何时候都能精确还原。z.LightSig.Enable 0 会在关闭的那一帧走一次 RestoreAllShadows(),把所有灯恢复成优化前的样子——这也是做 A/B 时能干净对比的前提。世界 teardown 时 Deinitialize() 同样兜底恢复一次,不会把灯留在被改过的阴影态里。
还留了个白名单口子:光组件或它的 owner actor 带 LightSig_AlwaysShadow tag 就永不受管,给美术留招牌、关键剧情光这类不能丢阴影的对象。
CVar 一览:
| CVar | 默认 | 作用 |
|---|---|---|
z.LightSig.Enable | 1 | 总开关,关闭沿恢复所有灯 |
z.LightSig.MinScreenCoverage | 0.05 | 屏幕占比阈值,越大削得越狠 |
z.LightSig.RescanInterval | 1.0 | 发现 pass 间隔(秒) |
z.LightSig.UpdateInterval | 0.2 | 分类 pass 间隔(秒) |
z.LightSig.DrawDebug | 0 | 屏显受管 / 已关阴影灯数(非 shipping) |
A/B 实测
同一个 build、同一站位,只切 z.LightSig.Enable,稳态(剔除最差帧)对比:
| 指标 | OFF | ON | 变化 |
|---|---|---|---|
| 平均帧时 | 81.02 ms | 78.79 ms | −2.23 ms |
| 中位帧时 | 80.28 ms | 76.68 ms | −3.60 ms |
| GameThread busy | 80.81 | 78.58 | −2.23 ms |
| RenderThread busy | 79.70 | 78.02 | −1.68 ms |
| RHIThread busy | 79.86 | 78.15 | −1.71 ms |
| GPU1 busy | 48.43 | 44.34 | −4.09 ms |
| ShadowProjection 次/帧(局部光投影) | 5.18 | 3.12 | −39.7% |
| FRHICommandDrawIndexedPrimitive 次/帧 | 4,598 | 3,174 | −31.0% |
| Lights ms/帧 | 2.685 | 2.256 | −16.0% |
最想验证的一点在这张表里成立了:三条 CPU 瓶颈线同步下降(GT −2.2 / RT −1.7 / RHI −1.7 ms)。这正是前面判断"阴影能一次压三条线"的直接证据——如果它只压一条,帧时不会动。局部光投影 ShadowProjection 每帧削了 39.7%,纯质量无损,而且 CVar 可随时回退。
需要老实说的噪声:off / on 两段各只抓了 143 / 169 帧,站位没做到完全一致,精确值有 ±0.x ms 的抖动。但量级和方向是确定的。
和当初预估的差距:动手前我估的是 RenderThread ~2–3 ms + RHIThread ~2–3 ms,实测两条各约 −1.7 ms——预测偏乐观,实际落在预估区间的下沿。差在哪:一是默认阈值 0.05 保守,真正被去阴影的只是最远那一档,中距离一大批投影光还留着;二是估算时我按"削掉投影光 60–70%"倒推,实测 ShadowProjection 只掉了 39.7%。方向对、量级对,但别拿"预估上限"当承诺——这类估算我现在习惯往下压一档再报。
默认阈值 0.05 偏保守;我估计调到 0.08 还能再削一批中距离灯,但那批画质影响需要美术目视确认,不能纯看数字拍板。
如何自己验证
这套东西验证不难,但得对着瓶颈线看,不能只瞄一个总帧时。我的流程:
- 先确认它真在管灯:开
z.LightSig.DrawDebug 1(非 shipping),屏上会打受管灯数和已关阴影灯数。人物走动时,应能看到灯随距离进出"已关阴影"这个集合;数字一直是 0 说明发现 pass 没抓到光(多半是灯不是 Movable,或被 tag 白名单了)。 - 目视回归:走近一盏远处街灯,阴影应该恢复;走远,阴影消失。带白名单 tag 的光全程有阴影。这一步是画质无损的底线,数字再好看,pop 太明显也不能上。
- 看阴影本身有没有真的少画:
stat scenerendering对比Enable 0vs1下ShadowDepths/Shadows类别的时间,以及参与投影的局部光数;r.Shadow.Virtual.Cache.ShowStats 1看 VSM 页压力有没有跟着降。 - 确认压的是瓶颈线:
stat unit看 RenderThread 和 RHIThread 是不是同步下降。三线程并列的场景里,单看某一条掉了没意义——只有两条(乃至三条)一起掉,才会真转化成帧时。这也是我最后用 trace 抓 busy 时间做 A/B、而不是只信stat unit读数的原因。 - 回归到零改动:
z.LightSig.Enable 0应当和没装这套时逐帧一致(所有灯恢复作者态CastShadows),退出关卡不留残留状态。这条是"可回退"的验收线。
局限与后续
- 屏幕占比是近似。
AttenuationRadius / 距离忽略了 FOV 和光的实际投影形状。够用,但严格的"屏幕像素占比"要接投影矩阵——目前没这个必要。 - 远处光贴着可动物体会露馅。如果一盏被去阴影的远光正好紧贴一个动来动去的物体,少掉的阴影可能被看出来。当前靠阈值保守 + 白名单 tag 兜。更稳的做法是阈值取"屏幕占比 + 距离"双条件。
- 和光的 Mobility 审计是配套的。这套只处理 Movable 光的动态阴影;那些被误标成 Movable、其实不动的装饰光,更该在关卡里改回 Stationary / Static,从源头省掉每帧的变换重传和 VSM 静态缓存失效。两件事一起做收益才完整。
一点通用的体会:显著性 / 预算类系统最容易漏的就是"没有统一基类、也不 tick"的那类对象(这里是关卡摆放的光)。与其硬塞进为 tick 对象设计的框架,不如按对象的真实生命周期单开一个轻量子系统——发现、低频分类、可回退,三件事做干净就够了。