5. 对 Reddit 性能分析帖的回应
原文
A detailed performance analysis was posted to the WuWa community by @xLOCKnLOADx, based on hardware traces from a Snapdragon 8 Elite device (https://www.reddit.com/r/WutheringWaves/comments/1rtuzc8/wuthering_waves_performance_issues_analysis/). I want to be clear upfront: the data in that post is real and worth taking seriously. The measured numbers — 50% primitive rejection in open world, up to 99% in combat, L1 cache miss rates of 70–100%, FP32 shader dominance — come from actual hardware traces and should not be dismissed.
An important platform caveat: this section discusses mobile profiling data. Mobile and PC systems differ fundamentally — mobile uses tile-based rendering, unified memory, and aggressive thermal/power management, while PC uses discrete GPUs, higher bandwidth memory, and different rendering pipelines. Metrics such as primitive rejection rates, cache behavior, and GPU utilization do not translate directly between platforms. The causal arguments made here apply to the mobile context specifically; PC behavior should be evaluated separately using PC-native profiling. The coordination bottleneck argument for PC is supported by separate PC-native data presented in Section 1.2.
The issue is with the framing, not the data.
The post treats each GPU-side problem as an independent optimization failure: culling should be better, texture batching should be better, shaders should use lower precision. All of these are individually accurate observations. The conclusion — that none of them require a new engine — is also accurate.
What the post does not explain is why culling fails specifically under combat load.
The 99% trivial rejection rate in combat is not culling code that was written poorly. It is culling code that did not have time to run — because by the time the game reaches the GPU submission step, the GameThread has already consumed its entire frame budget on particle system updates, scripting ticks, and actor physics. There was no time left for culling.
Fixing the culling algorithm while the GameThread is still saturated does not fix the problem. It fixes a symptom. The cascade looks like this:
Root cause: GameThread saturation ← engine architecture
│
▼
Symptom 1: CPU-side culling runs out of time before GPU submission deadline
│
▼
Symptom 2: Unculled geometry submitted → GPU performs trivial rejection
(50% open world, up to 99% combat)
│
▼
Symptom 3: Draw calls jump across unrelated textures → L1/L2 cache thrashed
(L1 miss 70–100%, L2 miss ~45%)
│
▼
Symptom 4: GPU stalls waiting for memory, then waits again next frame
Treating Symptoms 2–4 as independent failures misses that they share a root. The GPU isn’t doing wasted work because someone forgot to write culling code. The GPU is doing wasted work because the code that would have prevented it didn’t get scheduled.
A note on causal attribution: the cascade above represents the most architecturally consistent explanation for the observed data, but it cannot be verified without full profiler access to the codebase. There are at least three plausible hypotheses for the culling failure: (1) GameThread has no time budget left for culling — the engine architecture explanation; (2) the culling algorithm itself is poorly implemented — an implementation quality issue; (3) the culling pipeline is structurally misdesigned for this workload — a hybrid of both. The honest position is that engine constraints and implementation quality are two separate variables whose individual contributions cannot be separated from the outside. Both are present. Their relative weight is unknown. This analysis treats hypothesis (1) as the primary explanation because it is most consistent with the broader data pattern, but readers should weigh that accordingly.
One additional data point worth noting: community engine configs confirm that r.ParallelFrustumCull=1 and r.ParallelOcclusionCull=1 are functional in WuWa’s shipping build — suggesting culling work can be partially offloaded from the GameThread to parallel threads. Whether this meaningfully shifts the bottleneck profile in dense city areas would require profiling to confirm, but their availability indicates the culling pipeline is not entirely GameThread-serialized.
— Flagged by @HtooMyatLin3
The Android Scheduling Dimension
The mobile profiling post also misses a platform-specific amplifier that matters for understanding the mobile experience specifically.
On Android, the Energy-Aware Scheduling (EAS) system manages which physical CPU core each thread runs on, dynamically migrating threads between the “big” (high-performance) and “LITTLE” (efficiency) clusters based on recent load history.
The problem: EAS uses a historical weighted average to decide where to place a thread. If the GameThread has been light for a few frames — say, during a loading transition — EAS may judge it as a light workload and assign it to an efficiency core. Then the scene becomes dense. The GameThread spikes. But the OS scheduler doesn’t react instantaneously: it takes time to recognize the new load level and migrate the thread to the prime core.
During that migration window, the GameThread is doing heavy work on a core not designed for it. The culling budget collapses further. The cascade gets worse.
The Android AOSP documentation acknowledges this problem directly:
“Without the scheduler change to make foreground apps more likely to move to the big CPU cluster, foreground apps may have insufficient CPU capacity to render until the scheduler decided to load balance the thread to a big CPU core.”
Source: https://source.android.com/docs/core/tests/debug/jank_capacity
This dimension does not exist on PC — x86 desktop CPUs are symmetric. Mobile stutter has this additional amplification layer on top of the same GameThread problem. The symptoms look similar; the mechanics are different.
On FP32: The Cross-Platform Constraint
The profiling post suggests transitioning to FP16 shaders as a quick optimization for mobile Adreno GPUs, where FP16 throughput is genuinely 2× faster.
This is accurate for Adreno. It ignores that WuWa runs on Adreno, Mali, Apple GPU, desktop AMD, desktop Nvidia, and Intel integrated graphics simultaneously. FP16 behavior and gain vary significantly across these GPU families. Maintaining separate shader variants per GPU family means larger pak files (the community has a separate ongoing conversation about WuWa’s storage footprint) and QA burden that multiplies with every device class.
The tradeoff is real. It is not as simple as the post implies. Community member @HtooMyatLin3 adds an important hardware-level nuance: FP16 throughput on GTX-series hardware (e.g. GTX 1060) runs at 1:64 ratio vs FP32 — meaning FP16 would be catastrophically slower on that GPU family. Dropping FP16 support for GTX would be required before any meaningful shader precision optimization could ship. By contrast, AMD RX 570 has identical throughput on both FP32 and FP16, so the optimization gap is highly architecture-dependent rather than a simple “switch FP16 on” solution.
The One Genuinely Easy Fix
The post documented r.Streaming.PoolSize = 400 from the Perfetto trace. On flagship mobile devices with 12–16 GB RAM, reserving only 400 MB for texture streaming is directly why L1 and L2 caches get thrashed — textures aren’t loaded into faster memory ahead of time because the pool is too small to hold them.
This is a configuration value. It has no architectural tradeoff. It is the one finding from that analysis that is genuinely addressable without any of the complications I’ve described elsewhere.
On r.Streaming.FullyLoadUsedTextures and r.Streaming.HLODStrategy: These are real UE4 engine cvars — FullyLoadUsedTextures forces all active textures to stream in immediately, HLODStrategy 2 disables HLOD-specific streaming entirely. Community member @HtooMyatLin3 reports that both cvars were usable in older patches (visible in earlier AlteriaX config commits) but are now being set by Kuro via code or console at a higher priority level — confirmed through log analysis — meaning Engine.ini entries are overridden and no longer effective at runtime. This represents a case where an easy fix was available, was being used by the community, and has since been locked down in the shipping build.
机翻
Reddit 用户 @xLOCKnLOADx 基于骁龙 8 Elite 设备的硬件追踪,在《鸣潮》社区发布了一份详细的性能分析报告(https://www.reddit.com/r/WutheringWaves/comments/1rtuzc8/wuthering_waves_performance_issues_analysis/)。我想先明确:那篇帖子中的数据是真实的,值得认真对待。其中测量的数据——开放世界中 50% 的图元剔除率、战斗中高达 99% 的剔除率、L1 缓存缺失率 70–100%、FP32 着色器占主导——都来自真实的硬件追踪,不应被忽视。
一个重要的平台说明: 本节讨论的是移动端性能剖析数据。移动端和 PC 系统有根本性差异——移动端使用基于块(tile-based)的渲染、统一内存架构以及激进的功耗/散热管理,而 PC 使用独立 GPU、更高带宽的内存以及不同的渲染管线。诸如图元剔除率、缓存行为、GPU 利用率等指标不能直接在不同平台间套用。本文中的因果论证特指移动端上下文;PC 行为应使用 PC 原生性能剖析工具单独评估。关于 PC 端协调瓶颈的论证,则由第 1.2 节中独立的 PC 原生数据支持。
问题在于解读框架,而非数据。
该帖将每一个 GPU 端的问题视为独立的优化失败:剔除应该做得更好、纹理批处理应该做得更好、着色器应该使用更低精度。这些单独来看都是准确的观察。结论——它们都不需要一个新引擎——也是准确的。
但该帖没有解释的是:为什么剔除在战斗负载下会特别失效?
战斗中 99% 的微不足道的剔除率,并不是因为剔除代码写得差。而是因为剔除代码没有时间运行——因为当游戏到达 GPU 提交步骤时,GameThread 已经将其整个帧预算消耗在了粒子系统更新、脚本 tick 和 Actor 物理上。已经没有剩余时间留给剔除了。
在 GameThread 仍然饱和的情况下修复剔除算法,并不能解决问题。它修复的是一个症状。其级联关系如下:
text
根本原因: GameThread 饱和 ← 引擎架构
│
▼
症状 1: CPU 端剔除在 GPU 提交截止时间前耗尽了时间
│
▼
症状 2: 未经剔除的几何体被提交 → GPU 执行微不足道的剔除
(开放世界 50%,战斗中高达 99%)
│
▼
症状 3: 绘制调用在无关纹理间跳跃 → L1/L2 缓存被扰乱
(L1 缺失 70–100%,L2 缺失约 45%)
│
▼
症状 4: GPU 等待内存而停滞,下一帧再次等待
将症状 2–4 视为独立的失效,忽略了它们共享同一个根本原因。GPU 执行浪费的工作,不是因为有人忘记写剔除代码。GPU 执行浪费的工作,是因为本可以阻止这些工作的代码没有被调度执行。
关于因果归因的说明: 上述级联关系是针对观察数据在架构上最一致的解释,但如果没有对代码库的性能剖析器完全访问权限,就无法证实。对于剔除失效,至少存在三种合理的假设:(1) GameThread 没有剩余的时间预算用于剔除——引擎架构解释;(2) 剔除算法本身实现不佳——实现质量问题;(3) 剔除管线在结构上针对该工作负载设计不当——两者兼有。诚实的态度是:引擎约束与实现质量是两个独立的变量,从外部无法区分它们的各自贡献。两者都存在。它们的相对权重未知。本分析将假设 (1) 作为主要解释,因为它与更广泛的数据模式最为一致,但读者应据此自行权衡。
一个值得注意的额外数据点: 社区引擎配置证实,r.ParallelFrustumCull=1 和 r.ParallelOcclusionCull=1 在《鸣浪》的发布版本中是功能性的——这表明剔除工作可以部分从 GameThread 卸载到并行线程上。这是否能在密集城区中有意义地转移瓶颈,需要性能剖析才能确认,但这些 CVar 的存在表明剔除管线并非完全串行在 GameThread 上。
—— 感谢 @HtooMyatLin3 指出
Android 调度维度
该移动性能帖还遗漏了一个平台特定的放大因素,这对于理解移动端体验非常重要。
在 Android 上,能效感知调度(EAS) 系统管理每个线程运行在哪个物理 CPU 核心上,它会根据最近的历史负载将线程动态地在“大核”(高性能)和“小核”(能效)集群之间迁移。
问题在于:EAS 使用历史的加权平均值来决定将线程放在哪里。如果 GameThread 在过去几帧中负载较低——比如在加载过渡期间——EAS 可能会判定它为轻负载,并将其分配给能效核心。然后场景变得密集。GameThread 出现尖峰。但操作系统调度器不会立即做出反应:它需要时间来识别新的负载水平,并将线程迁移到主核心。
在这个迁移窗口期间,GameThread 在一个并非为其设计的核心上执行繁重工作。剔除预算进一步崩溃。级联效应变得更糟。
Android AOSP 文档直接承认了这个问题:
“如果没有将调度程序更改为使前台应用更有可能移到大核集群,前台应用可能在调度程序决定将线程负载均衡到大核 CPU 之前,就没有足够的 CPU 能力进行渲染。”
来源:https://source.android.com/docs/core/tests/debug/jank_capacity
这个维度在 PC 上不存在——x86 桌面 CPU 是对称的。移动端卡顿在相同的 GameThread 问题之上,还有这一层额外的放大因素。症状看起来相似,但机制不同。
关于 FP32:跨平台的约束
该性能帖建议转向 FP16 着色器,作为移动端 Adreno GPU 的快速优化方案,因为 FP16 的吞吐量确实是 2 倍。
这对于 Adreno 来说是准确的。但它忽略了《鸣潮》同时运行在 Adreno、Mali、Apple GPU、桌面 AMD、桌面 Nvidia 以及 Intel 集成显卡上。FP16 的行为和收益在这些 GPU 家族之间差异巨大。为每个 GPU 家族维护独立的着色器变体,意味着更大的 pak 文件(社区还同时在讨论《鸣潮》的存储占用问题),以及 QA 负担随设备类别成倍增加。
这种权衡是真实存在的。它并不像该帖暗示的那样简单。社区成员 @HtooMyatLin3 补充了一个重要的硬件级细微差别:在 GTX 系列硬件(例如 GTX 1060)上,FP16 吞吐量与 FP32 的比例为 1:64——这意味着在该 GPU 家族上,FP16 会灾难性地慢。在发布任何有意义的着色器精度优化之前,必须先放弃对 GTX 的 FP16 支持。相比之下,AMD RX 570 在 FP32 和 FP16 上具有相同的吞吐量,因此优化空间高度依赖于架构,而不是简单的“打开 FP16”解决方案。
一个真正简单的修复
该帖记录了 Perfetto 追踪中的 r.Streaming.PoolSize = 400。在配备 12–16 GB RAM 的旗舰移动设备上,仅为纹理流式加载预留 400 MB,这直接导致了 L1 和 L2 缓存被搅乱——纹理没有提前加载到更快的内存中,因为池子太小装不下它们。
这是一个配置值。它没有任何架构上的权衡。这是该分析中发现的一个真正无需我在其他地方描述的那些复杂情况即可解决的问题。
关于 r.Streaming.FullyLoadUsedTextures 和 r.Streaming.HLODStrategy:这两个是真正的 UE4 引擎 CVar——FullyLoadUsedTextures 强制所有活动纹理立即流式加载,HLODStrategy 2 完全禁用 HLOD 特定的流式加载。社区成员 @HtooMyatLin3 报告说,这两个 CVar 在旧版补丁中是可用的(在早期的 AlteriaX 配置提交中可见),但现在被 Kuro 通过代码或控制台以更高优先级设置了——通过日志分析得到确认——这意味着 Engine.ini 中的条目会被覆盖,且运行时不再生效。这代表了一个案例:一个简单的修复原本存在、被社区使用,但此后在发布版本中被锁定了。
