原文
There is a deeper problem underneath the GameThread bottleneck. It is not just that everything runs on one thread — it is how that thread accesses data when it runs.
Unreal Engine 4 is built around an Object-Oriented Programming (OOP) model. This is not an informal observation — it is the documented design philosophy of the engine. Epic’s official programming documentation describes UE4 as built around a class hierarchy where UObject is the base for all engine objects and AActor is the base class for everything placeable in a game world, with components attached to actors to define their behavior.
Sources: UE4 Programming Basics · OOP Principles in Unreal Engine
In practice, this means every entity in the game world — every NPC, building facade, particle emitter, tree, physics prop — is an AActor object. Each AActor has a vtable pointer, owns a list of UComponent objects, and those components are heap-allocated individually, sitting wherever the memory allocator placed them at creation time.
In simple terms: every object lives in its own corner of RAM. To update it, the CPU has to go find it.
Imagine the GameThread needs to process logic ticks for a large number of active actors in a single frame — NPCs with behavior trees, buildings with interaction handlers, physics props, particle systems — which happens constantly in a scene as dense as Startorch Academy or Septimont City/Ragunna. UE4 does have systems like HISM, HLOD to reduce rendering load, but these don’t reduce the logic tick burden on the GameThread — behavior trees still need to execute, interaction logic still needs to evaluate, scripting ticks still run, regardless of whether the actor is being rendered at full detail or not. The CPU’s work on the GameThread looks like this:
Tick ActorA → follow pointer → find ComponentList → follow pointer → find data
[CPU fetches from RAM: cache miss]
Tick ActorB → follow pointer → find ComponentList → follow pointer → find data
[CPU fetches from RAM: different address, cache miss again]
Tick ActorC → [different address again]
Tick ActorD → [different address again]
...10,000 times
Every single actor tick involves pointer-chasing through memory. When the CPU looks for data not already in its L1 or L2 cache, it stalls waiting for main RAM to respond. On a modern CPU, an L1 cache hit costs ~4 cycles. A main memory fetch costs up to ~200 cycles in the worst case — though in practice many accesses hit L2 or L3 rather than going all the way to RAM, and CPUs use prefetching to mitigate some of this. The actual penalty depends on access patterns, data locality, and cache hit rates. Nevertheless, in a scene with thousands of actors updating per frame, the aggregate impact of cache-unfriendly access patterns is real and measurable — the CPU spends meaningful time waiting rather than computing, even if not every access is a worst-case miss.
A note on UE4’s Binned Allocator: UE4’s default memory allocator (Binned/Binned2) is sophisticated — it uses a tiered allocation system that pools objects by size class and is designed to maximize L1/L2 cache utilization for individual allocations. This meaningfully reduces heap fragmentation and improves per-allocation cache behavior compared to naive allocators. However, it does not connect cache lines between different actors — the pointer-chasing traversal pattern described above persists regardless of allocator strategy, because actors that need to be processed together are not guaranteed to be co-located in memory. The allocator mitigates the problem at the micro-allocation level; the OOP traversal pattern is the macro-level issue. — Clarification credit: @aizen76 (Indie-us Games, UE specialist)
A note on Actor Clustering: UE4 has an Actor Clustering feature, but its function is specific and worth clarifying. Actor Clustering helps skip random-access pointer searches during UE4’s garbage collection pass — reducing GC-induced cache miss overhead. It is not a Tick cache locality system and does not improve the per-frame logic tick access pattern described above. Its benefit is therefore more relevant to GC-related pauses (Pattern B territory) than to the per-frame logic processing bottleneck (Pattern A). — Clarification credit: @aizen76
A useful mental model: imagine a librarian who needs to look up 10,000 books. In a well-organized library (DOD/ECS), books on the same topic are shelved together — she grabs one section and works through it sequentially. In UE4’s model, each book was shelved wherever it happened to fit when it arrived. She walks across the entire building for every single book. The reading is the same amount of work. The walking is what kills performance.
The L3 cache is a small room next to her desk where she keeps recently-used books nearby. A bigger room (more L3 cache) means fewer trips across the building. This is exactly why X3D processors — with their stacked cache — help specifically with WuWa’s workload where other games don’t see the same benefit.
This is exactly why a large L3 cache matters more for WuWa than raw clock speed. The L3 cache acts as a buffer between the CPU cores and main RAM — the larger it is, the more actor data can sit nearby instead of requiring a full round-trip to RAM. A processor with a 96MB L3 cache (like AMD’s X3D lineup) can keep far more of WuWa’s scattered actor data resident than a processor with 20MB or 30MB, even if the smaller-cache CPU has higher clock speeds. The result in practice: lower average FPS on benchmarks, but meaningfully better 1% lows and frame consistency in city areas — which is exactly where the problem lives.
This is not a problem Kuro introduced. It is a structural consequence of how UE4’s object model works. The engine was designed this way, and there is no mechanism within UE4 to change it without rebuilding the entity system from scratch. A developer in the Unreal Engine community forum observed this limitation directly back in 2018:
“For large scale entity simulation, UE4 will likely not be a great tool choice here. As far as I can tell, none of the large engines are particularly well suited for that type of large scale simulation though.”
Source: https://www.reddit.com/r/unrealengine/comments/9uzkpk/
That last clause — none of the large engines — was accurate at the time. Since then, two studios have changed the equation — not by choosing a better engine, but by investing heavily enough to transform the one they had.
Why Genshin Impact and Arknights Endfield Feel Different
The Genshin comparison comes up constantly in this community, and it is worth addressing properly rather than dismissing.
Genshin Impact runs on Unity. But the reason Genshin handles entity density more gracefully is not “Unity is better than UE4” — it is the result of HoYoverse committing a decade of R&D investment into a heavily customized engine that bears the name Unity but operates very differently from the stock version any other studio would ship.
HoYoverse’s previous major title, Honkai Impact 3rd, was built on Unity and ran for years at scale. That experience gave them two things Kuro didn’t have when starting WuWa: a team with deep Unity expertise, and years to understand how to push Unity’s architecture toward data-oriented patterns before the hard limits became visible. By the time Genshin launched, they were not shipping Unity — they were shipping HoYoverse’s Unity, a heavily modified engine reflecting a decade of institutional investment in custom systems, tooling, and DOD-oriented architecture.
Similarly, Arknights Endfield is not a stock Unity game — Hypergryph has invested substantially in customizing their Unity pipeline. They benefit from being able to leverage official DOTS/ECS infrastructure (which Unity only shipped for production use from 2022 onward) while also building on their own engine expertise.
The key point: the credit belongs to Hoyo and Hypergryph’s engineering investment, not to Unity as a product. A studio shipping stock Unity today would not automatically achieve Genshin’s entity handling. Stock Unity is also OOP-heavy in its classic form. The performance characteristics we observe in these titles are the result of what those studios built on top of the engine — not the engine itself.
Unity’s component-based design is architecturally closer to DOD than UE4’s inheritance-heavy AActor model — it is a more favorable starting point. But “more favorable starting point” and “better engine” are not the same thing. The distance between stock Unity and what Genshin actually runs on is enormous, and that distance represents years of engineering work that no other studio can simply acquire by choosing Unity.
OOP approach (UE4 AActor):
Actor_A.Position → address 0x1A3F00 (somewhere in heap)
Actor_B.Position → address 0x7C2104 (completely different location)
Actor_C.Position → address 0x4E8830 (different again)
→ CPU cache: evicted and refilled on every single actor tick
DOD/ECS approach (Unity DOTS / custom):
PositionArray: [ A.pos | B.pos | C.pos | D.pos | E.pos | ... ]
↑ single contiguous memory block
→ CPU loads one cache line, processes several entities, moves to next
→ Cache stays warm for the entire batch
Note: The memory addresses above (0x1A3F00, etc.) are illustrative examples only, not actual values from any real process.
Source: https://unity.com/ecs
Unity’s C# Job System makes the thread-safety implications of this data layout explicit. From Unity’s own documentation:
“To make it easier to write multithreaded code, the Unity C# Job System detects all potential race conditions and protects you from the bugs they can cause.” “The C# Job System solves this by sending each job a copy of the data it needs to operate on, rather than a reference to the data in the main thread.”
Source: https://docs.unity3d.com/2020.1/Documentation/Manual/JobSystemSafetySystem.html
This is the architectural reason why Unity’s Job System can safely dispatch work to worker threads. UE4’s AActor graph has no equivalent — actors share references, making safe multi-threading on arbitrary game logic nearly impossible without architectural surgery. But accessing this capability in a game like Genshin required Hoyo to build much of the infrastructure themselves before official support existed.
An honest caveat: Genshin has a significantly lower visual ceiling than WuWa. The trade runs both ways. DOD-friendly architecture comes with real constraints on content authoring and system interaction. And Hoyo had years of prior work and institutional runway that Kuro simply did not have when starting WuWa on a new engine with a new team.
There is no ECS or DOD framework in UE4. Unreal Engine 5 does improve the threading situation somewhat — Mass Entity offers limited large-scale entity simulation support — but the fundamental OOP heritage remains deeply embedded. WuWa predates UE5 and is not built on it.
我是机翻
在 GameThread 瓶颈之下有一个更深层次的问题。不仅仅是所有事情都在一个线程上运行——而是该线程在运行时如何访问数据。
虚幻引擎 4 是围绕面向对象编程模型构建的。这并非非正式观察——这是引擎的设计哲学。Epic 的官方编程文档将 UE4 描述为构建在一个类层次结构上,其中 UObject 是所有引擎对象的基类,Actor 是游戏中所有可放置物件的基类,组件附加到 actor 上以定义其行为。
来源:UE4 Programming Basics · OOP Principles in Unreal Engine
在实践中,这意味着游戏世界中的每个实体——每个 NPC、建筑物立面、粒子发射器、树木、物理道具——都是一个 Actor 对象。每个 Actor 都有一个虚函数表指针,拥有一个 Component 对象列表,而这些组件是在堆上单独分配的,位于内存分配器在创建时放置它们的任何位置
简单来说:每个对象都住在自己那片 RAM 角落里。为了更新它,CPU 必须去找到它。
想象一下,GameThread 需要在一帧内为大量活动 Actor 处理逻辑 Tick——在 Startorch Academy 或 Septimont City/Ragunna 这样密集的场景中,这种情况经常发生。UE4 确实有 HISM、HLOD 等系统来减少渲染负载,但这些并不能减少 GameThread 上的逻辑 Tick 负担——行为树仍需执行,交互逻辑仍需评估,脚本 Tick 仍在运行,无论 Actor 是否以全细节渲染。CPU 在 GameThread 上的工作看起来像这样:
Tick ActorA - 跟随指针 - 找到 ComponentList - 跟随指针 - 找到数据 [CPU 从 RAM 读取:缓存未命中]
Tick ActorB - 跟随指针 - 找到 ComponentList - 跟随指针 - 找到数据 [CPU 从 RAM 读取:不同地址,再次缓存未命中]
Tick ActorC - [又是不同地址]
Tick ActorD - [又是不同地址]
… 重复 10,000 次
每个 Actor Tick 都是一次穿越分散内存的指针追逐。每次 CPU 查找不在其 L1 或 L2 缓存中的数据时,它就会停顿并等待主 RAM 响应。在现代 CPU 上,L1 缓存命中约需 4 个周期。主内存读取约需 200 个周期。在拥有数千个 Actor 的场景中,这种惩罚累积成真实且可测量的帧时间损失——不是因为游戏逻辑慢,而是因为 CPU 大部分时间在等待而非计算。
关于 UE4 的分箱分配器的说明:UE4 的默认内存分配器(Binned/Binned2)是复杂的——它使用分层的分配系统,按大小类别对对象进行池化,旨在最大化单个分配的 L1/L2 缓存利用率。这相比幼稚的分配器有用地减少了堆碎片并改善了每个分配的缓存行为。然而,它并不连接不同 Actor 之间的缓存行——上述指针追逐遍历模式无论分配器策略如何都持续存在,因为需要一起处理的 Actor 不能保证在内存中相邻。分配器在微观分配层面缓解了问题;OOP 遍历模式是宏观层面的问题。—— 澄清致谢:@aizen76(Indie-us Games,UE 专家)
关于 Actor 聚簇的说明:UE4 有一个 Actor Clustering 功能,但其功能值得明确。Actor Clustering 有助于在 UE4 的垃圾回收过程中跳过随机访问指针搜索——减少 GC 引起的缓存未命中开销。它不是一个 Tick 缓存局部性系统,也不改进上述每帧逻辑 Tick 访问模式。因此,它的好处更多地与 GC 相关的暂停(模式 B 领域)相关,而不是与每帧逻辑处理瓶颈(模式 A)相关。—— 澄清致谢:@aizen76
一个有用的心智模型:想象一个图书管理员需要查阅 10,000 本书。在一个组织良好的图书馆(DOD/ECS)中,主题相同的书放在一起——她拿过一个区域,按顺序工作。在 UE4 的模型中,每本书都是在到达时随便找了个有空的地方放下的。她为每一本书都要穿过整个图书馆。阅读量是相同的。走路才是杀死性能的东西。
L3 缓存是她桌子旁边的一个小房间,用来存放最近用过的书。更大的房间(更多的 L3 缓存)意味着减少穿越大楼的次数。这正是为什么 X3D 处理器——具有堆叠缓存——特别有助于解决《鸣潮》的工作负载,而其他游戏则没有同样的好处。
这正是为什么大容量 L3 缓存对《鸣潮》比原始时钟速度更重要。L3 缓存充当 CPU 核心和主 RAM 之间的缓冲区——它越大,就越多的 Actor 数据可以坐在附近,而不需要往返 RAM。一个拥有 96MB L3 缓存的处理器(如 AMD 的 X3D 系列)可以让《鸣潮》分散的 Actor 数据中更多部分保持驻留,而一个只有 20MB 或 30MB 缓存的处理器则做不到,即使后者有更高的时钟速度。实际结果是:基准测试中的平均 FPS 较低,但在城市地区——问题所在——的 1% 低帧率和帧一致性要好得多。
这不是库洛引入的问题。这是 UE4 对象模型的结构性后果。引擎就是这样设计的,UE4 内部没有任何机制可以改变这一点而不从头重建实体系统。一位开发者早在 2018 年就在虚幻引擎社区论坛中直接观察到了这个限制:
“对于大规模实体模拟,UE4 很可能不是一个好的工具选择。据我所知,没有一个大型引擎特别适合那种大规模模拟。”
来源:https://www.reddit.com/r/unrealengine/comments/9uzkpk/
最后那句——“没有大型引擎”——在当时是准确的。从那时起,其中一个已经发生了显著变化。
为什么《原神》、《明日方舟:终末地》感觉不同
《原神》的比较在这个社区中不断出现,值得妥善处理而不是置之不理。
《原神》运行在 Unity 上。但《原神》能更优雅地处理实体密度的原因并不仅仅是“Unity 更好”——而是米哈游对 Unity 架构进行了深入、长期的投资,这早在《原神》概念形成之前就开始了。
米哈游的前作《崩坏3》就是基于 Unity 构建的,并大规模运行了多年。这段经历给了库洛在开始制作《鸣潮》时所没有的两样东西:一个拥有深厚 Unity 专业知识的团队,以及在硬性限制显现之前了解如何将 Unity 的架构推向数据导向模式的时间。
Unity 的基本设计——一个基于组件的系统,实体拥有组件而非继承行为——在架构上比 UE4 继承重量级的 Actor 模型更接近 DOD。它不是 ECS,但它是垫脚石。米哈游有多年《崩坏3》的开发经验,可以构建自定义系统、工具和制度知识,将这种架构推向缓存友好、并行友好的模式。到《原神》发布时,他们已经在使用一个高度定制的 Unity,反映了近十年的这种投资。
Unity 直到 2022 年才开始正式将其 DOTS/ECS 框架用于生产——这个概念源于 2018 年的研究——那时《原神》早已发布。像《明日方舟:终末地》这样的新作仍然使用高度修改的 Unity,但它们开发的时代已经存在官方的 ECS 支持——这意味着它们可以直接利用 Unity 自己的数据导向基础设施,而不必像米哈游在《原神》中那样从头构建。
OOP 方法(UE4 Actor):
Actor_A.Position → 地址 0x1A3F00(堆中的某处)
Actor_B.Position → 地址 0x7C2104(完全不同的位置)
Actor_C.Position → 地址 0x4E8830(再次不同)
→ CPU 缓存:对每个 Actor Tick 都被驱逐和重新填充
DOD/ECS 方法(Unity DOTS / 自定义):
PositionArray: [A.pos | B.pos | C.pos | D.pos | E.pos | … ]
↑ 单个连续内存块
→ CPU 加载一条缓存行,处理多个实体,移动到下一行
→ 缓存在整个批次中保持温暖
注意:上面的内存地址(0x1A3F00 等)仅为说明性示例,并非来自任何真实进程的值。
来源:https://unity.com/ecs
Unity 的 C# Job System 使这种数据布局的线程安全性含义变得明确。根据 Unity 自己的文档:
“为了更容易编写多线程代码,Unity C# Job System 会检测所有潜在的竞争条件,并保护你免受它们可能引起的错误的影响。”
“C# Job System 通过向每个 Job 发送一份它需要操作的数据的副本(而不是主线程中数据的引用)来解决这个问题。
来源:https://docs.unity3d.com/2020.1/Documentation/Manual/JobSystemSafetySystem.html
这就是为什么 Unity 可以安全地将物理和模拟工作分派给工作线程的架构原因:每个 Job 接收一个隔离的数据副本,消除了共享状态的风险。UE4 的 AActor 图没有等效的机制——actors 共享引用,使得在没有架构手术的情况下对任意游戏逻辑进行安全多线程几乎不可能。
数据所有权模型也改变了并行性的可能性。连续的组件数组可以干净地交给工作线程。UE4 互联的对象图则不能。这就是为什么《原神》的系统可以跨核心分配工作,而《鸣潮》的工作仍然串行在一个线程上。
一个诚实的警告在这里:与原神相比,《鸣潮》的视觉上限要高得多。这种权衡是双向的。DOD 友好的架构对内容创作和系统交互带来了实际限制。米哈游有多年先期工作和有利的引擎基础可以依赖。而库洛正在构建他们的第一个开放世界 3D 项目,使用的是本质上是 OOP 的实体模型的引擎,没有那种制度性的缓冲。
UE4 中没有 ECS 或 DOD 框架。虚幻引擎 5 确实在一定程度上改善了线程状况——Mass Entity 提供了有限的大规模实体模拟支持,UE5 的渲染管线比 UE4 有更好的多线程利用率——但引擎的 OOP 传统仍然存在。AActor、UObject 和组件模型深深嵌入在 UE5 的工作方式中。使用 UE5 上限会适度提高,但不会消失。《鸣潮》早于 UE5 且并非基于 UE5 构建,所以无论如何这都是学术性的。