第 16 章

数据导向的逆流

把时钟拨回2014年秋天,Mike Acton在CppCon上讲了一个让整个房间陷入沉默的事实:你们过去二十年写的代码,都在谋杀性能。他不是在谈论算法复杂度。大O表示法没有问题,问题在别处。

Acton在屏幕上放了一张CPU缓存未命中的剖析图——处理器在执行单元停滞的数百个时钟周期里,什么都不做,只是等待数据从主存载入缓存线。然后他逐条拆解面向对象继承体系的代价:虚函数调用导致的间接跳转让分支预测器失效,对象分散在堆内存中使得缓存线加载了大量无用数据,多态设计迫使编译器放弃向量化机会。在SIMD宽度从128位扩展到256位、很快将到512位的硬件趋势下,这些代价已经从可接受的抽象税变成了系统性的性能灾难。Insomniac Games的这位引擎总监在游戏行业工作了超过十五年。他参与打造过《抵抗》系列、《瑞奇与叮当》系列——这些游戏在PlayStation平台上以稳定的帧率和流畅的动作闻名。

现在,他告诉台下这群将面向对象设计视为职业信仰的C++开发者,他们信奉了二十年的架构圣经,核心主张是错的。性能瓶颈不再取决于算法的时间复杂度,而取决于数据在内存中的布局与访问模式。这场演讲后来被社区称为“异端宣言”。Acton不是在提出一个优化建议。他在挑战整个游戏引擎行业赖以组织代码的基本范式。

要理解这场反叛的根源,需要回到更早的实践现场。数据导向设计并非从理论中推导出来的架构原则,而是从具体项目的性能绝境中被迫生长出来的生存策略。在索尼的第一方工作室体系内,这种实践已经默默积累了近十年。

Insomniac Games在PlayStation 3时代的《抵抗》系列开发中,最早撞上了这堵墙。PS3的Cell处理器是一个异构架构怪物——一个主控核心加上八个协处理单元,每个协处理单元只有256KB的本地存储。256KB。这个数字值得停下来想一想。

一个典型的C++对象,加上虚函数表指针、继承链上的成员变量、动态分配的子对象,很容易就膨胀到几百字节甚至上千字节。当你在256KB的空间里试图用面向对象的方式组织游戏逻辑时,你连对象本身都放不下,更不用说处理它们所需的数据。

Insomniac的程序员被迫做出了一个在当时看来离经叛道的决定:放弃对象,直接操作数据数组。他们将粒子系统的属性——位置、速度、生命值、颜色——拆分为独立的连续数组,每个数组在内存中紧密排列。当协处理单元需要更新所有粒子的位置时,它加载的不是一个个散布在内存中的对象,而是一整块连续的位置数据。缓存线里装满了有用的东西,预取器知道下一步该加载什么,SIMD指令可以一次处理四个或八个粒子。

性能提升是数量级的。但代价同样是真实的:代码不再“优雅”了。你不能再写对象调用链——读取武器ID、查找发射函数、执行调用——这一套基于继承和多态的建模方式。你必须遍历敌人数据数组,对每个元素执行批量操作。

这不是语法糖的损失,这是思维方式的断裂。程序员被训练了二十年去将世界建模为相互作用的对象,现在他们必须将世界建模为等待批量处理的数据流。

Naughty Dog在《最后生还者》的开发中经历了更极端的转化。PS3的生命周期晚期,这家工作室已经在Cell架构上榨出了令人震惊的视觉质量,但代价是彻底放弃了面向对象抽象。在SPU协处理器上运行的代码没有虚函数表,没有继承,没有封装——这些概念在256KB的存储空间和直接内存访问的编程模型下毫无意义。程序员必须手动管理数据流,精确控制哪些数据在何时被加载到本地存储,在何时被写回主存。

这种编程方式更接近DSP编程或嵌入式系统开发,而非游戏引擎的传统做法。这些实践在索尼内部形成了口耳相传的经验积累,但在很长一段时间里没有被系统化地表述为对面向对象范式的普遍性质疑。它们是特定硬件平台上的权宜之计,是工程师私下交流的技术诀窍,而非值得推广的架构哲学。

直到Acton站上CppCon的讲台,将这些分散的工程直觉提炼为明确的架构原则,并公开推向整个社区。

他的核心主张可以从三个层面来理解。在内存层面,数据导向设计要求将数据组织为连续、紧凑的数组,使得CPU预取器能够预测访问模式,使得每一条缓存线都装满有用的数据,使得SIMD指令能够一次处理多个元素。这与面向对象模型形成了根本对立——在面向对象模型中,一个游戏实体是一个对象,它包含或引用其他对象,这些对象散布在堆内存的各处,通过指针网络连接。遍历这个网络意味着在内存中随机跳跃,每一次跳跃都可能触发缓存未命中。

在计算层面,数据导向设计主张将处理逻辑与数据分离。不是让对象封装自己的更新行为,而是让系统遍历它所关心的数据并执行批量操作。一个运动系统处理所有包含运动组件的实体的位置和速度数组,执行物理积分。一个渲染系统处理所有包含渲染组件的实体的网格引用和变换矩阵数组,生成绘制调用。

系统与系统之间通过数据依赖关系耦合,而非通过对象间的消息传递。在架构层面,数据导向设计将数据流动性提升为与代码组织同等重要的一级设计对象。引擎架构师不再只关心类层次结构、设计模式和接口抽象,他们必须同样关心数据在内存中的流动路径——哪些数据在哪个核心上被访问,以什么模式被访问,在缓存层次中的哪个位置被命中或未命中。

这场思想反叛在2015至2018年间从边缘渗透至中心,其传播路径揭示了游戏引擎行业的知识流动机制。索尼第一方工作室的实践之所以能够影响更广泛的社区,并非通过公开发表——这些工作室的代码库是封闭的,技术细节受保密协议保护——而是通过人才流动。Insomniac和Naughty Dog的程序员在完成一个项目周期后,带着他们在数据导向实践中积累的经验,跳槽到其他工作室或引擎开发商。他们在新环境中成为数据导向方法的传教士,在代码审查中质疑继承层次过深的设计,在技术讨论中展示连续数组带来的缓存性能提升。

Acton的演讲在YouTube上积累了数十万次观看。它的影响力不在于提出了全新的概念——高性能计算社区和数据密集型应用领域早已实践类似的方法——而在于它在游戏引擎这个特定领域内,公开挑战了一个被视为理所当然的技术正统。在演讲后的数年里,游戏开发者大会的渲染和系统编程专题中,缓存友好设计、数据导向架构、SIMD友好布局成为反复出现的关键词。面向对象引擎架构的捍卫者与数据导向的倡导者在论坛、Twitter和技术博客上展开了持续争论。

反对者的论点集中在几个方面。Acton混淆了糟糕的实现与范式的本质缺陷——虚函数调用开销可以通过去虚拟化优化消除,对象分散问题可以通过自定义分配器和内存池缓解,这些是工程问题而非架构问题。数据导向设计在性能关键系统中有效,但在需要复杂状态管理和交互逻辑的系统中,面向对象提供的封装和抽象是不可替代的。放弃继承和多态意味着放弃代码复用,最终会导致大量重复代码和维护噩梦。这些反驳并非全无道理。

但Acton的支持者指出,问题恰恰在于:当“工程解决方案”需要自定义分配器、去虚拟化优化、精心设计的内存布局时,面向对象范式承诺的简单自然的建模已经名存实亡。你仍然在写类继承,但你必须时刻想着这些类在内存中是如何排列的——那么,为什么不直接按照内存布局来组织代码?这场争论在2017至2018年间到达了一个关键节点。不是因为某一方在理论上获胜,而是因为两大商业引擎做出了具体的技术选择,将这场哲学辩论转化为了可运行的代码和可发布的工具。

Unity的选择最为激进。在2017年,Unity Technologies开始向开发者社区展示一套全新的架构:实体组件系统。这个缩写ECS本身就是一个宣言——它明确指向了数据导向设计的核心理念。在Unity传统的GameObject系统中,一个游戏对象是一个包含多个组件的容器。每个组件是一个继承自MonoBehaviour的C#对象,拥有自己的数据字段和Update方法。

在运行时,引擎遍历所有活动对象,调用每个组件的Update。这是经典的面向对象模型:对象拥有数据,对象拥有行为,对象通过消息传递相互作用。

Unity ECS彻底翻转了这个模型。实体不再是对象,而仅仅是一个整数ID。组件不再是对象,而是存储在连续内存块中的纯数据。系统是处理特定组件组合的纯函数,它们遍历符合条件的实体,批量读取和写入组件数据。一个运动系统不关心“游戏对象”是什么,它只关心哪些实体同时拥有位置组件和速度组件,然后对所有这些实体的位置和速度数据执行批量操作。

这种布局的核心优势在于内存访问模式。当一个系统遍历所有位置和速度数据时,这些数据在内存中是连续排列的——位置数组的每个元素紧挨着下一个,速度数组同样如此。CPU预取器可以提前将即将访问的数据加载进缓存,SIMD指令可以一次处理多个实体的运动积分。缓存未命中率急剧下降,指令级并行度大幅提升。但Unity走的比数据布局更远。

他们意识到,要让数据导向设计真正发挥效力,需要解决另一个瓶颈:C#的托管代码执行模型。传统的MonoBehaviour.Update在Mono或IL2CPP运行时中执行,受制于垃圾回收、边界检查、虚方法分派等开销。2018年,Unity推出了Burst编译器,这是一个基于LLVM的编译后端,可以将C#的一个子集编译为高度优化的原生代码,直接利用SIMD指令集,消除托管开销。

Burst编译器的设计哲学与ECS一脉相承:限制语言的表达力,以换取可预测的高性能。在Burst兼容的代码中,你不能使用虚方法调用,不能分配托管对象,不能抛出异常,不能使用LINQ——所有这些特性都会引入不可预测的性能开销。你只能使用值类型、静态方法、简单循环和条件判断。这实际上是将C#的一个子集变成了类似C的数据导向编程语言。ECS加Burst的组合在Unity内部和早期试用者中产生了令人瞩目的性能数字。

在某些测试场景中,能够处理的实体数量从传统GameObject系统的数千个跃升到数十万个,帧时间大幅下降。这对于那些需要大量独立运动实体的游戏类型——弹幕射击、大规模策略游戏、物理模拟密集型应用——意味着全新的可能性。但代价同样巨大。ECS是一个与GameObject完全不同的编程模型,它要求开发者放弃他们已经熟悉的面向对象思维。在传统Unity中创建一个可移动的角色,你添加一个GameObject,挂上Transform、Rigidbody、脚本组件,在Update中写移动逻辑。在ECS中,你定义位置组件和速度组件的数据结构,创建一个移动系统,在系统的OnUpdate中遍历所有相关实体并批量更新位置。没有继承,没有多态,没有虚方法,没有MonoBehaviour生命周期回调。这种断裂不仅是技术上的,也是生态上的。

Unity Asset Store中积累的成千上万个资源包——根据公开数据,截至2018年商店下载量约为四千万次——几乎全部基于GameObject模型。Unity自己多年积累的编辑器工具链、UI系统、动画系统,都是围绕GameObject构建的。

ECS不是对现有系统的渐进改进,而是在同一个引擎内建立了一块飞地——一个数据导向的自治领,与GameObject帝国并存,使用不同的法律体系。Unity的工程师们清楚地意识到了这个问题。他们的解决方案不是强制迁移,而是共存与互操作。在2018年发布的预览版中,ECS世界和GameObject世界可以通过转换边界进行通信——GameObject可以被转换为实体,实体数据可以被提取回GameObject。但这层互操作是有性能代价的,每次跨越边界都意味着数据布局的重新组织和内存拷贝。两个法律体系之间的翻译成本,由开发者承担。在Unity论坛和社区中,ECS的发布引发了激烈的反应。

一些开发者欢呼这是引擎性能的质变,迫不及待地开始在新项目中全面采用。另一些开发者表达了深切的担忧:这是否意味着Unity正在分裂为两个引擎?学习ECS的投资是否会在未来得到回报,还是GameObject模型将继续作为主流支持?独立开发者和中小团队是否有资源去掌握这套更底层、更复杂的编程模型?

这些争论在论坛帖子和Twitter线程中蔓延,其深层焦虑可以追溯到引擎商业模式的根本矛盾。Unity的崛起依赖于降低游戏开发的门槛——拖拽导入、可视化编辑器、C#脚本的易用性,这些是Unity成为最广泛使用的游戏引擎的基础。Unity Asset Store于2010年推出,经过八年积累,已经形成了一个庞大的内容生态。ECS和Burst代表了相反的方向:为了性能,放弃易用性;为了数据流动性,放弃面向对象的直觉建模。Unity能否同时服务于这两类用户,还是会在这场分裂中失去自己的身份认同?Epic Games选择了另一条路径。

当Unity在2017至2018年间激进地推动ECS架构时,Epic的虚幻引擎4正在以一种更审慎、更渐进的方式吸收数据导向设计的思想。这种差异并非偶然。虚幻引擎的用户基础与Unity存在显著不同——更多的3A工作室、更高保真度的项目、更复杂的现有代码库。对Epic来说,彻底重写核心架构的风险远高于Unity,因为任何不兼容的变更都可能危及正在开发中的大型项目。

Niagara粒子系统是Epic在数据导向方向上迈出的标志性一步。传统的级联粒子系统遵循面向对象模型:每个粒子发射器是一个对象,包含一个粒子数组,每个粒子是一个包含位置、速度、颜色、生命周期等字段的结构体。在CPU上,更新逻辑遍历每个发射器的粒子数组,逐个更新粒子状态。

Niagara在2016至2018年间逐步成型,它的架构核心是数据导向的。粒子数据被组织为连续缓冲区,模拟阶段被实现为在GPU上执行的批量计算。

一个Niagara系统不是一组相互引用的对象,而是一个数据流图——数据从发射阶段流向模拟阶段,再流向渲染阶段,每个阶段对数据缓冲区执行并行操作。这种设计使得Niagara能够处理数百万个粒子,远超级联系统的能力上限。但Niagara的接口层保留了面向对象的外观。开发者通过蓝图或C++创建Niagara系统时,他们看到的是模块、参数和阶段,而非裸数据缓冲区。

Epic在高层保持了UE4用户熟悉的抽象,同时在底层将性能关键路径替换为数据导向的批量处理。这是一种妥协,也是一种策略——让数据导向的思想在引擎内部生根,而不强迫开发者直面范式转换的阵痛。

Chaos物理系统沿袭了类似的哲学。当Epic在2018年开始将Chaos作为PhysX的替代方案引入UE4时,它的内部架构是基于数据导向原则设计的。物理对象的状态被组织为连续数组,约束求解器在宽相位和窄相位阶段批量处理碰撞检测,充分利用SIMD和缓存局部性。

但对外暴露的接口仍然遵循UE4的面向对象约定——物理体是Actor上的组件,碰撞事件通过委托系统传递,物理约束通过蓝图可编辑的属性配置。

这种渐进路线的优势是显而易见的。使用UE4的开发者不需要学习一套全新的编程模型,他们现有的项目可以逐步受益于Niagara和Chaos的性能提升,而无需大规模重写。

但代价同样存在。数据导向的底层实现与面向对象的高层接口之间的翻译层,本身就是一种性能开销。在某些情况下,这种开销抵消了数据布局优化带来的部分收益。更重要的是,面向对象的接口约束限制了数据导向优化的深度——当外部代码仍然以对象为单位与物理系统交互时,批量处理的粒度就受到限制。

Epic的工程师们清楚地知道这种妥协的代价。在GDC演讲和引擎文档中,他们坦率地讨论了在保持向后兼容性与追求极致性能之间的权衡。

这不是一个技术问题,而是一个生态问题。虚幻引擎4已经被集成进了数百个正在开发的项目中,从独立游戏到3A大作。

任何破坏性的架构变更,无论性能收益多么诱人,都必须与迁移成本和项目风险进行权衡。

Unity和Epic的分化选择,折射出数据导向运动在2015至2018年间面临的深层张力。这场运动的核心主张——性能瓶颈在于数据布局而非算法复杂度——在技术层面已经获得了广泛认同。但将这个认知转化为引擎架构,却面临着不可回避的生态约束。

对于Unity来说,ECS是对引擎身份的一次豪赌。Unity作为民主化引擎的声誉建立在低门槛之上——2005年6月Unity 1.0.1发布时,Joachim Ante在苹果WWDC上的拖拽演示定义了这家公司的基因;2009年10月Unity 2.6独立版开始免费,进一步扩大了用户基础;2013年11月与Xbox One的合作、2014年5月在iOS上支持OpenGL ES 3.0,都是沿着降低门槛、扩大覆盖的方向前进。而ECS要求的编程思维转变,实际上将一部分门槛重新竖立了起来。

Unity的管理层似乎判断,随着游戏项目规模的增长和性能需求的提升,开发者愿意为性能付出学习成本。但这个判断是否正确,在当时还远未明朗。

对于Epic来说,渐进路线避免了生态分裂的风险,但也意味着数据导向思想在UE4中的渗透是不彻底的。面向对象的高层架构仍然是引擎的组织原则,数据导向优化被限制在性能关键路径的内部实现中。这种两栖状态能否持久,或者最终会向哪个方向演化,同样是一个悬而未决的问题。

在两大商业引擎之外,数据导向运动还在更广泛的生态中引发了连锁反应。独立引擎开发者和小型工作室开始尝试完全基于数据导向原则构建的轻量级引擎。游戏开发会议上出现了专门的数据导向设计专题讨论。大学游戏开发课程中,一些教师开始在教学大纲中加入缓存友好设计和SIMD编程的内容。这些变化缓慢而分散,但指向同一个方向:面向对象在游戏引擎中的垄断地位正在松动。

2018年底,一位在索尼第一方工作室工作了十年的引擎架构师,在调试器中看着新系统的内存访问模式可视化图。屏幕上,数据访问呈现出整齐的顺序流——CPU从连续内存中加载数据,缓存线满载,预取器提前工作,SIMD单元全速运转。他回想起自己十年前写的第一个面向对象引擎模块:对象散布在堆内存中,虚函数调用在调用图中跳跃,缓存未命中在剖析器中留下密集的红点。他当时认为那是正常的——游戏引擎就该是这样,性能损失是抽象的代价。

现在他知道那不是正常的。那是一个行业在长达二十年的时间里,将一种特定的代码组织方式误认为普遍真理。数据导向的逆流不是要废除面向对象——它在高层抽象和快速原型中仍然有其价值——而是要打破它的垄断,将内存访问模式提升为与代码架构同等重要的一级设计对象。

这场反叛的深层意义不在于面向对象与数据导向的二元对立,而在于它揭示了引擎架构中一个被长期忽视的维度:硬件契约不仅体现在GPU指令集与图形API的演进中,同样深刻地刻写在CPU缓存层次、内存带宽与SIMD宽度的物理约束中。引擎架构师开始意识到,他们设计的不仅是代码的组织方式,也是数据在硅片上的流动路径。这一认知转变,是引擎从管理代码复杂度走向管理数据流动性的分水岭。

而分水岭的两侧,是两个截然不同的世界。在GameObject的世界里,对象仍然是公民,继承树仍然是法律,虚函数调用仍然是日常语言。在ECS的世界里,实体只是整数,组件只是数据,系统只是批量操作的循环。这两个世界在同一个引擎进程中共存,通过小心翼翼的转换边界交换信息,每一次跨越都付出翻译的代价。Unity的开发者们正在学习一种新的编程语言——不是C#的语法,而是数据的语法。他们开始用剖析器观察缓存未命中,而不是函数调用次数。

他们开始将数组的长度视为性能的乘数,而不是内存的消耗。这场静默的反叛没有宣言式的胜利。Acton的演讲在YouTube上继续积累观看,但评论区已经从激烈争论变成了技术讨论。面向对象没有被推翻,数据导向没有被加冕。在引擎的源代码中,在内存的布局中,在缓存线的流动中,两种范式正在寻找它们的边界。边界的确切位置,取决于下一个项目的性能需求,下一个平台的硬件特性,下一个世代的物理约束。而硬件的演进不会等待。