渲染伸缩:画质档位不是一组 CVar,而是一份降级契约
自动关联目录:渲染伸缩:画质档位不是一组 CVar,而是一份降级契约
核心判断:设备档位只能给出初始预算,不能直接决定最终画面。真正可持续的渲染伸缩,必须把平台能力、用户选择、场景上下文、运行时压力和对象语义合成为多个独立预算,并用所有权、滞回、就绪切换和可读性下限约束每一次降级。
一、核心冲突:一个“低画质”同时改了八类不同问题
玩家创作内容的复杂度没有固定上限,同一份内容又要运行在移动端、轻量客户端和桌面端。业务要求并不是“所有平台画面一致”,而是三件事同时成立:能进入、能稳定运行、关键反馈仍然看得懂。
早期 UE 配置把画质档位映射到一组全局设置:可视距离和骨骼 LOD、阴影分辨率与级联、雾和环境遮蔽、后处理、植被密度、材质质量、纹理 LOD Bias、粒子质量。平台配置还会分别覆盖这些值。表面上只有“低、中、高”,实际上是在同时修改 CPU 提交、GPU 像素、几何量、显存、Shader 路径和视觉语义。
这会产生一个根本错误:把资源消耗的多个维度压成了一个可比较的整数。降低一级可能减少阴影,却没有降低 Slate 开销;也可能让远景更便宜,却仍被大量角色动画拖住 Game Thread;甚至可能去掉玩法提示,却没有解决真正的瓶颈。分辨率、目标帧率和综合画质后来被拆成独立选项,正说明“一档包办全部”并不成立。
因此,渲染伸缩的核心问题不是 CVar 取什么值,而是:当前瓶颈是什么、谁允许改这个值、哪些表示可以替换、哪些信息绝不能消失。
二、实际实现:已经形成六种机制,但还不是一个控制系统
工程实践不是一次完成的,而是逐步长出了六层能力:
设备基线 + 平台上限 + 用户选择
|
v
全局画质与场景覆盖
|
+----------+----------+
| |
对象重要度与组件降级 几何表示切换
| |
+----------+----------+
v
创作期检查 + 运行期采样设备基线。 移动端用设备表给出默认画质、分辨率和帧率,桌面端可用 CPU/GPU 基准测试;已知误判设备再做定向修正。轻量客户端和原生客户端使用不同帧率上限,并通过 UGameUserSettings、GEngine->SetMaxFPS 和平台 Frame Pacer 保持一致。这一层解决“从哪里起步”,不是证明设备长时间运行也能守住预算。
场景覆盖。 特定场景进入时,会按名字和平台覆盖屏幕比例、抗锯齿、后处理、动画预算、布料或刚体表现、LOD 与 UI 更新频率;离开时恢复。实现维护了“每个 CVar 当前由哪些覆盖者持有”的有序关系,基础画质变化后再重放活动覆盖。它把临时策略从散落的进入/退出代码提升成了可逆覆盖。
对象重要度。 客户端综合相机距离、投影面积和视锥结果计算对象等级,只在等级变化时通知 Actor 与 Component;本地角色可固定在最高等级。组件据此改变 Tick 间隔,装饰性粒子也能在低重要度停用。实现曾明确放弃 WasRecentlyRendered 作为当前可见性的唯一依据,因为它依赖渲染历史,在部分设备和时序下不是可靠的“此刻在屏幕内”判断,最终改为显式视锥测试。
几何表示切换。 后续远景方案没有销毁逻辑对象,而是由每个 World 的管理器在原始表示与简化代理之间切换。距离使用观察点到组包围盒的距离;多观察点取最近需求;档位边界带滞回;异步请求有每帧上限和 Generation 校验。代理无碰撞、无导航、无 Tick,不替代服务端逻辑;新表示未就绪时保留旧表示,加载失败退回已有代理或原始对象。这是“降级表示”而不是“降低数值”的实际落地。
创作期与运行期检查。 编辑器会检查移动对象、透明重叠、局部密度、三角形、灯光、组件 Tick 和 UI 控件等全局或区域热点,并能把相机定位到问题区域;实际游玩测试还会采样 FPS、Game Thread、Render Thread、内存、Draw Call 和三角形。静态检查负责提前拦截明显超量,真实运行负责发现组合成本。
运行时观察。 已有 FPS 采样会平滑帧时间、排除加载与 GC 等极端区间,并在持续低帧后评估更低档位;但真正修改画质的动作被关闭。也没有找到热状态或功耗输入。因此当前实现能观察部分压力,却没有形成“检测、决策、提交、验证、恢复”的自适应闭环。
三、应保留的设计:分层比统一成一个数字更重要
第一,设备默认、用户选择和场景策略必须分层。设备默认只是建议;场景降级可以在用户选择之上施加临时限制,但不应反向提高用户主动选择的低档。帧率、分辨率和视觉质量保持独立,才能针对 CPU、GPU 和功耗做不同决策。
第二,临时覆盖必须有所有者并可逆。进入场景写一个 CVar、退出时写“默认值”无法处理嵌套场景、多个系统竞争和运行中改画质。按变量记录覆盖者顺序,离开时恢复下一个有效所有者或基础值,是值得保留的方向;调试界面也应能回答“当前值是谁设置的”。
第三,对象降级应改变展示成本,不应篡改逻辑真相。本地角色的重要度下限、代理无碰撞与导航、服务端对象不因客户端 LOD 消失,都是正确边界。异步代理用 Generation 拒绝旧回调,并坚持新表示 Ready 后才隐藏旧表示,避免了空洞和迟到结果复活。
第四,静态容量检查与实际游玩采样缺一不可。Actor 数、三角形和透明重叠适合做快速提示,但无法代表材质复杂度、最差视角、动态生成和线程瓶颈;固定路径的真实运行采样能补足这些问题。两者应该共享预算口径,而不是互相替代。
四、结构性问题:局部优化正确,组合起来却不可证明
最明显的问题是策略分散:平台 ini 定义一套档位,设备表定义默认值,场景覆盖再改 CVar,对象重要度控制 Tick 和特效,远景系统切表示,编辑器性能检查又维护自己的阈值。它们分别合理,却没有共同的预算、优先级和原因码。于是“降低一级”无法回答究竟释放了多少 Game Thread、Render Thread、GPU 或显存成本。
覆盖机制也没有完全守住所有权。个别场景策略会直接修改基础值,再把旧值保存在文件级变量中。这在单实例顺序进入退出时可用,但遇到嵌套覆盖、多 World 或重复进入时不是栈安全的:后进入者可能保存的是已覆盖值,先离开者又可能恢复错误基线。正确边界应该是不可变基础值加 Owner Token,而不是共享的“上一个值”。
观测层仍然偏静态。创作期阈值大量依赖硬编码计数,甚至用对象数乘固定吞吐估计加载时间;实际游玩结果主要看平均值,缺少 P95/P99、卡顿分布和瓶颈归因。运行时 FPS 观察没有接通降级动作,也没有热状态、功耗和恢复冷却。短测能跑稳,不代表持续满载后仍能守住帧预算。
重要度也不应继续压成一个标量。距离很远的装饰粒子可以停,但同样远小的目标标记、攻击预警或交互反馈可能必须保留;动画、特效、音频、网络和模拟需要不同的语义下限。现有几何 LOD 有滞回,对象重要度边界却缺少同等明确的保持时间,镜头在边界附近时可能频繁切档。
两个真实问题进一步说明“画质”不能只看 3D 场景。一次阴影开关已经出现在属性 UI 和 Actor 应用代码中,却因为数据声明与初始化广播未接通而没有视觉效果;可调参数存在,不等于运行时链路完整。另一次 Slate 快路径把“一个 Widget”错误映射到“一个缓存 Batch 槽”,但一个 Widget 每帧可能产生多个 Batch,稳定界面反而持续扩张稀疏数组。最终修复为按 Widget 保存有序槽位列表,第 N 个 Batch 复用上次第 N 个槽位。降低世界分辨率,对这种 UI CPU 与内存问题没有任何帮助。
五、目标设计:从画质枚举升级为可提交的渲染计划
目标架构不再让业务代码直接“降一级”,而是由五个职责明确的组件合作:能力评估器给出平台硬上限;策略合成器合并用户选择和带 Owner Token 的场景覆盖;压力观察器维护线程、GPU、内存和可选热状态的滚动分位数;重要度评估器给每个领域提供语义下限;表示协调器负责异步准备与原子切换。
struct RenderPolicyInput
{
PlatformCaps platform;
DeviceBaseline baseline;
UserPreference user;
OwnerOverlaySet context;
RuntimePressure pressure; // GT/RT/GPU、内存,以及平台可提供的热状态
SemanticFloors floors; // 本地角色、玩法反馈、交互 UI 等最低可读表示
};
struct RenderPlan
{
FrameCap frame;
ResolutionPolicy resolution;
VisibilityBudget visibility;
LightingTier lighting;
GeometryTier geometry;
MaterialVariantTier material;
EffectBudget effects;
CharacterBudget characters;
UiBudget ui;
Generation generation;
ReasonCode reason;
};
RenderPlan Evaluate(const RenderPolicyInput& in, const RenderPlan& previous)
{
RenderPlan next = BuildBaseline(in.platform, in.baseline, in.user);
ApplyOwnedOverlays(next, in.context);
Bottleneck b = ClassifyByPercentiles(in.pressure);
if (IsSustainedOverBudget(b))
DegradeCheapestEligibleDomain(next, b, in.floors);
else if (IsSustainedRecoveredWithHeadroom(b))
RecoverOneDomainSlowly(next, in.floors);
return ApplyHysteresisAndHoldTime(previous, next);
}
void Commit(const RenderPlan& next)
{
RequestRepresentations(next.generation, next.geometry, next.material);
KeepCurrentRepresentationUntilReady();
if (AllRequiredRepresentationsReady(next.generation))
AtomicallySwapPresentationAndReleaseOld();
}各领域仍保留 UE 自己的实现方式:可见性使用视锥、距离与投影尺寸,但不把渲染历史当唯一真相;灯光降低阴影距离、级联或分辨率,不改创作者定义的昼夜和颜色语义;几何使用 LOD、实例或代理,但碰撞与逻辑身份独立;材质只能选择已 Cook 的能力变体;特效分别控制装饰密度和关键反馈;角色分别预算骨骼、动画更新和远端表现,本地角色保底;UI 单独预算失效、Batch、元素数和更新频率。
热状态属于目标设计而不是现状描述。接入平台信号后也不能一热就连续降档:降级要等待持续超预算,恢复要更慢,并预留 GPU 余量。否则系统会在满频高画质与降频低画质之间振荡,用户看到的不是稳定,而是周期性卡顿和画质跳变。
六、决策规则:先按瓶颈省成本,再用可读性否决
降级顺序不应写成所有场景共用的一条固定列表。GPU 像素受限时,优先评估屏幕比例、后处理、透明叠加和阴影;几何或提交受限时,评估视距、实例、LOD 与代理;Game Thread 受限时,评估 Tick、角色动画和 UI 失效;内存受限时,评估纹理、代理驻留和缓存。每个候选动作都要估算“节省的目标预算 / 造成的可读性损失”,被语义下限否决后再选下一个。
这也给出了明确禁区:客户端视觉代理不能获得权威碰撞;低特效不能删除关键反馈;低角色档不能降低本地输入反馈;低 UI 档不能隐藏交互状态;阴影和材质降级不能偷偷改变逻辑可见性。轻量平台若不具备某项原生能力,应暴露 Capability 并使用明确替代实现,而不是保留同名接口后静默做另一件事。
验证必须覆盖稳定态、切换态和长时间状态:
| 目标 | 指标与判定 |
|---|---|
| 瓶颈判断 | FrameBudget = 1000 / TargetFPS;分别记录 GT、RT、GPU 的 P50/P95/P99,降级后目标线程分位数应改善 |
| 持续稳定 | 记录长时间运行中的热状态、频率变化、功耗信号和帧时间趋势;不能只看冷启动短测 |
| 切换稳定 | 统计每分钟档位切换、边界往返、异步取消和旧 Generation 回调;持续压力下收敛,恢复不得振荡 |
| 表示正确 | 固定相机比较原始/代理、阴影、材质、特效、角色和 UI;新表示未就绪时可见空洞帧数必须为零 |
| 玩法可读 | 对本地角色、目标提示、危险反馈和交互 UI 做自动截图与人工检查;语义下限在所有档位成立 |
| 资源收敛 | 观察 Draw Call、三角形、Shader/PSO、GPU/系统内存、Slate Batch 与缓存槽;切档后资源应回收到新稳态 |
| 平台一致 | 原生、轻量和 DS 分别验证能力声明;DS 保持逻辑真相,不创建客户端视觉代理 |
我的反思是:早期把设备分档和 CVar 集合做起来是必要的,它快速建立了低端可运行基线;后续的所有者覆盖、对象重要度、创作期诊断和代理 LOD 也都解决了真实问题。没有做好的,是一直把它们当成独立优化项,没有把“为什么降、由谁降、降哪一维、何时恢复、什么不能降”建成共同契约。下一次面对画质问题,我不会先问“把哪项设为 Low”,而会先问:当前被哪种预算限制,最便宜的可逆降级是什么,它是否跨过了玩法可读性的底线。