通过反编译,探寻天际线2代的 CPU/GPU 协作的 ECS 架构
起因
在游戏里造水力发电站的时候,一直遇到问题,要么效率1%发电慢的出奇,要么效率始终跑不满,花了几个小时修也没修好,索性看源码看看他到底咋实现的逻辑。
于是我把游戏程序集 Game.dll 扔进了 dnSpy,借助 dnSpy MCP Server + Claude Code Opus 4.6(AI做这些确实快,两个小时完成一两天的工作量),从一座水力发电站追出了一整套 CPU/GPU 协作的 ECS 架构。本文就以这条线索为主线来串讲。
什么是 ECS?先从传统架构的痛点说起
传统 OOP 游戏架构的问题
传统 Unity 游戏用 MonoBehaviour 挂脚本,一个"水力发电站"可能长这样:
class HydroPowerPlant : MonoBehaviour {
float capacity;
float production;
Efficiency efficiency;
void Update() { /* 每帧算发电量 */ }
}看着挺直观,但规模一大就崩了:
- 内存碎片化:每个对象散落在堆上,遍历 1000 个发电站时 Cache 疯狂 miss
- 无法并行:
Update()里既读又写,没法安全地丢给多个线程 - 继承地狱:
HydroPowerPlant继承PowerPlant继承Building继承...改一个基类全爆炸
ECS 的思路:拆成数据和行为
ECS(Entity-Component-System)把游戏对象拆成三块:
graph LR
subgraph Entity["Entity (实体)"]
E["只是一个 ID<br/>比如: Entity #4396"]
end
subgraph Component["Component (组件 = 纯数据)"]
C1["WaterPowered<br/>{Length, Height, Estimate}"]
C2["ElectricityProducer<br/>{Capacity, LastProduction}"]
C3["Transform<br/>{Position, Rotation}"]
end
subgraph System["System (系统 = 纯行为)"]
S["PowerPlantAISystem<br/>遍历所有有 ElectricityProducer<br/>+ WaterPowered 的实体<br/>计算发电量"]
end
Entity --> Component
System -->|批量处理| Component- Entity:就是个 ID,没有逻辑
- Component:纯数据结构体,没有方法(
struct,不是class) - System:纯逻辑,不持有状态,批量操作符合条件的实体
为什么这样更快?
关键在内存布局。ECS 把同类型的 Component 紧密排列(Structure of Arrays):
传统 OOP(Array of Structures):
[对象A的全部字段] [对象B的全部字段] [对象C的全部字段] ...
↕ Cache line 可能只装得下一个对象
ECS(Structure of Arrays):
[A.Capacity][B.Capacity][C.Capacity][D.Capacity]... ← 连续!一条 Cache line 装几十个
[A.Position][B.Position][C.Position][D.Position]... ← 连续!遍历 1000 个发电站的 Capacity 时,数据全在相邻内存里,CPU 预取直接命中。实测性能差距可以是 10 倍以上。
Unity DOTS:ECS + Burst + Job System
天际线2 用的是 Unity 的 DOTS 技术栈,三件套配合使用:
graph LR
subgraph DOTS["Unity DOTS 三件套"]
ECS["ECS<br/>数据布局 + 实体查询<br/>决定'数据怎么摆'"]
Burst["Burst Compiler<br/>C# → 原生机器码<br/>决定'单核跑多快'"]
Jobs["Job System<br/>多线程调度<br/>决定'用几个核跑'"]
end
ECS -->|SoA 布局,Cache 友好| Burst
Burst -->|编译后的 Job| Jobs
Jobs -->|并行执行| Result["多核 + SIMD<br/>榨干 CPU"]Burst:把 C# 变成接近手写汇编的东西
普通 C# 跑在 Mono/IL2CPP 上,有 GC、有边界检查、有虚调用开销。Burst 是另一条编译路径——它把标记了 [BurstCompile] 的结构体直接编译成 LLVM IR,再生成针对当前 CPU 架构优化的原生代码。
实际效果:
[BurstCompile]
struct MyJob : IJobParallelFor {
[ReadOnly] public NativeArray<float> depths;
[ReadOnly] public NativeArray<float2> velocities;
public NativeArray<float> results;
public void Execute(int i) {
// 这段代码会被 Burst 编译成 SIMD 指令
// 比如 4 个 float 的乘法会变成一条 SSE/AVX 指令
results[i] = depths[i] * math.length(velocities[i]);
}
}编译出来的代码,循环里对 float/float4 的操作会变成 SSE4/AVX2 指令(一条指令处理 4 ~ 8 个数据),Job 里只允许值类型和 NativeArray 所以没有堆分配也没有 GC 暂停,编译器能证明安全的地方会去掉数组边界检查,LLVM 后端再做一轮寄存器分配和循环展开。最终跑出来的东西跟手写 C++ 没太大差别。
Job System:声明式的多线程
传统多线程要手动管线程、加锁、防死锁。Unity Job System 换了个思路——你不管线程,你只声明"我读什么、写什么":
[BurstCompile]
struct PowerPlantTickJob : IJobChunk {
// 声明:我要读这些
[ReadOnly] public ComponentTypeHandle<Transform> m_TransformType;
[ReadOnly] public ComponentTypeHandle<WaterPowered> m_WaterPoweredType;
// 声明:我要写这个
public ComponentTypeHandle<ElectricityProducer> m_ProducerType;
public void Execute(in ArchetypeChunk chunk, ...) {
// 处理一个 Chunk 里的所有实体
}
}运行时 Job System 拿到这些声明后就能自动做调度了——两个 Job 都只读 Transform?并行跑,没问题。都要写 ElectricityProducer?自动排队等前一个写完。一个 Job 还没跑完,另一个依赖它的结果?通过 JobHandle 链自动等。
开发者不写一行锁代码,不操心线程池大小。整个天际线2 的 478 个 System 就是这样在每帧里被自动调度到所有 CPU 核心上的。
三件套如何配合
sequenceDiagram
participant Main as 主线程
participant JS as Job System 调度器
participant W1 as Worker Thread 1
participant W2 as Worker Thread 2
participant W3 as Worker Thread 3
Main->>JS: Schedule(PowerPlantTickJob)<br/>[ReadOnly] Water, [RW] Producer
Main->>JS: Schedule(WindTurbineJob)<br/>[ReadOnly] Wind, [RW] Producer
Note over JS: 两个都写 Producer → 不能并行
JS->>W1: PowerPlantTickJob - Chunk 0
JS->>W2: PowerPlantTickJob - Chunk 1
JS->>W3: PowerPlantTickJob - Chunk 2
Note over W1,W3: 同一个 Job 的不同 Chunk 可以并行!
W1-->>JS: 完成
W2-->>JS: 完成
W3-->>JS: 完成
JS->>W1: WindTurbineJob - Chunk 0
JS->>W2: WindTurbineJob - Chunk 1
Note over W1,W2: 前一个 Job 全完成后才开始下一个看这个时序图,同一个 Job 的不同 Chunk 天然可以并行(每个 Chunk 是独立的内存块,不存在冲突),而不同 Job 之间由调度器根据读写声明自动排序。再加上 Burst 编译让每个线程跑得尽可能快(SIMD + 无 GC + 无边界检查),整条管线就是这么协作的。
这就是天际线2能在 478 个 System、十万级实体下保持可玩帧率的核心原因。后面会看到具体的水力发电系统是怎么利用这套机制的。
天际线2 的整体架构
自定义更新循环
天际线2没有用 Unity 自带的 SimulationSystemGroup,而是自己写了个 UpdateSystem,把一帧切成 34 个阶段:
enum SystemUpdatePhase {
MainLoop, // 主循环
Modification1~5, // 玩家操作(放建筑、修路等)
PreSimulation, // 模拟前准备
GameSimulation, // 核心模拟(CPU Jobs 跑这里)
PostSimulation, // 模拟后处理
Rendering, // GPU 渲染 + GPU 物理模拟
UIUpdate, // UI
UITooltip, // Tooltip
// ... 共34个
}阶段之间严格顺序,阶段内部的 System 通过 JobHandle 依赖链并行。这样就能精确控制"什么时候 GPU 在算水流,什么时候 CPU 在算发电量"。
ECS 在天际线2里的分层
天际线2把整个游戏拆成四层。先看通用架构,后面再用水力发电做具体案例:
graph TB
subgraph Prefab层["Prefab 层 — 只读的'蓝图'配置"]
direction LR
P1["建筑类型定义<br/>(最大产能、建造费用、占地...)"]
P2["道路类型定义<br/>(车道数、限速、宽度...)"]
P3["市民类型定义<br/>(消费习惯、教育需求...)"]
end
subgraph Runtime层["Runtime Component 层 — 每个实例身上挂的数据"]
direction LR
R1["建筑实例状态<br/>(当前产能、效率、员工数...)"]
R2["道路实例状态<br/>(车流量、拥堵度...)"]
R3["市民实例状态<br/>(年龄、幸福度、工作地...)"]
end
subgraph System层["System 层 — 478个系统,各管各的逻辑"]
subgraph CPU["CPU Systems — Burst编译,多核并行"]
S1[发电AI]
S2[交通AI]
S3[经济模拟]
S4[人口模拟]
end
subgraph GPU["GPU Systems — ComputeShader"]
G1[水流模拟]
G2[风场模拟]
end
end
subgraph Bridge["桥接层 — CPU和GPU之间搬运数据"]
B1[AsyncGPUReadback<br/>GPU结果回传CPU]
end
GPU --> Bridge --> CPU
CPU -->|读写| Runtime层
Prefab层 -.->|查询配置| CPU解释一下每层的职责:
- Prefab 层:游戏物体的"模板"。比如"小型水力发电站"的 Prefab 定义了它的 ProductionFactor 是多少、占多大地——所有同类型建筑共享这份配置,只读
- Runtime Component 层:每个实例各自的状态。你在地图上放了 3 座水力发电站,就有 3 份独立的运行时数据(各自的发电量、效率、坝体尺寸等)
- System 层:纯逻辑,不持有任何状态。
PowerPlantAISystem每次 tick 会遍历所有带有"发电站"相关 Component 的实体,批量计算它们的发电量。CPU 和 GPU 各有一批 System - 桥接层:GPU 上算出的水面数据要传回 CPU 给发电 AI 用,这中间需要一层异步搬运
整个 Game.Simulation 命名空间下有 478 个 System,涵盖交通、经济、电力、供水、垃圾、天气、犯罪...几乎城市中每个系统都是一个独立的 System。它们通过 ECS 的查询机制各取所需,互不干扰。
下面我们顺着水力发电这条线,看看这四层是怎么具体协作的。
GPU 层:水流模拟
浅水方程 ComputeShader
水流模拟全跑在 GPU 上,因为这活儿完美适合 GPU:规则网格、大规模并行、每帧都要算、几乎没有分支逻辑。
每帧的模拟流水线:
graph LR
A[SourceStep<br/>水源注入] --> B[VelocityStep<br/>速度场更新]
B --> C[DepthStep<br/>水深更新]
C --> D[EvaporateStep<br/>蒸发]
D --> E[MaxHeightStep<br/>记录最高水位]
E --> F[FlowPostProcess<br/>流场后处理]核心是 VelocityStep——浅水方程的 GPU 求解。反编译出来是这样的:
public void VelocityStep(CommandBuffer cmd) {
// 地形高度图
cmd.SetComputeTextureParam(..., m_Terrain, terrainSystem.GetCascadeTexture());
// 物理参数
cmd.SetComputeFloatParam(..., m_ID_Fluidness, this.Fluidness);
cmd.SetComputeFloatParam(..., m_ID_Damping, this.Damping);
cmd.SetComputeFloatParam(..., m_ID_WindVelocityScale, this.WindVelocityScale);
// 双缓冲:读上一帧,写新帧
cmd.SetComputeTextureParam(..., m_ID_Previous, WaterTexture);
cmd.SetComputeTextureParam(..., m_ID_Result, WaterRenderTexture);
// 只对有水的区块 dispatch(稀疏计算)
cmd.SetComputeBufferParam(..., m_ID_CurrentActiveIndices,
ActiveTilesHelper.GetActiveTilesIndices());
cmd.DispatchCompute(...);
}注意它用了双缓冲(Previous → Result,避免同一 texture 的读写竞争),并且只对有水的 Active Tiles dispatch 计算——典型地图水域占 10 ~ 20%,这一招直接省掉 80% 的 GPU 开销。另外 WindVelocityScale 参数让风力也能推动水面流动。
水源怎么工作
public void SourceStep(CommandBuffer cmd, NativeList<WaterSourceCache> sources) {
foreach (var source in sources) {
if (source.m_Polluted > 0) {
// 污水:直接添加水量
cmd.DispatchCompute(..., m_AddKernel, ...);
} else {
// 清洁水源:维持目标水面高度
cmd.DispatchCompute(..., m_AddConstantKernel, ...);
}
}
}河流源头不是"注入流速",而是持续维持一个目标水面高度。高度差产生势能,势能驱动流速——浅水方程自己会搞定后面的事。这个设计非常优雅:开发者只需要在地图上摆水源并指定高度,河流就自动形成了。
水面数据结构
GPU 端每个网格像素存一个 float4,CPU 侧有对应的镜像结构:
struct SurfaceWater {
float m_Depth; // 水深(水面到地形的距离)
float m_Polluted; // 污染浓度
float2 m_Velocity; // 水平流速向量 (vx, vz)
}CPU ↔︎ GPU 桥接
GPU 算完的水面数据要传回 CPU 给发电站用。这里用了 AsyncGPUReadback——异步回读,不阻塞渲染管线:
sequenceDiagram
participant GPU
participant Bridge as SurfaceDataReader
participant CPU as PowerPlantAISystem
GPU->>GPU: 每帧: VelocityStep + DepthStep
GPU->>Bridge: AsyncGPUReadback (降采样版)
Note over Bridge: 延迟 2~3 帧到达
Bridge->>CPU: NativeArray of SurfaceWater 就绪
CPU->>CPU: 每128帧: GetWaterProduction()
Note over CPU: 用"过时"几帧的数据完全够用GPU 模拟分辨率很高,但回传给 CPU 的是降采样版本(省 PCIe 带宽)。数据有 2 ~ 3 帧延迟,不过发电站 128 帧才 tick 一次,这点延迟完全无所谓。CPU 采样时用双线性插值避免锯齿:
public static float SampleDepth(ref WaterSurfaceData<SurfaceWater> data, float3 worldPos) {
float2 gridPos = ToSurfaceSpace(ref data, worldPos).xz;
// 取四个最近网格点的水深值,双线性插值
int4 corners = clamp(floor/ceil(gridPos), 0, resolution - 1);
float4 depths = sample_four_corners(data.depths, corners);
float2 t = frac(gridPos);
return bilinear_lerp(depths, t);
}CPU 层:水力发电的实时计算
PowerPlantAISystem 概览
这是个标准的 ECS System:用 Burst 编译的 IJobChunk,每 128 帧 tick 一次,跨多核并行处理所有发电站:
public override int GetUpdateInterval(SystemUpdatePhase phase) => 128;128 帧大约对应游戏内几秒。发电量变化本来就不快,没必要每帧都算。
核心算法:GetWaterProduction
这是从反编译代码中还原出的完整发电量计算。每次 tick 都从 GPU 回传的水面数据重新计算,不依赖放置时的缓存值:
private float GetWaterProduction(WaterPoweredData waterData, Curve curve,
PlaceableNetData placeableData, NetCompositionData compositionData,
TerrainHeightData terrainHeightData, WaterSurfaceData<SurfaceWater> waterSurfaceData)
{
// 采样密度:保证每个水面网格格子至少一个采样点
int numSamples = max(1, round(curve.m_Length * waterSurfaceData.scale.x));
bool flowLeft = (placeableData.m_PlacementFlags & FlowLeft) != 0;
float totalProduction = 0;
for (int i = 0; i < numSamples; i++) {
float t = (i + 0.5f) / numSamples;
// 沿坝体 Bezier 曲线取点,算法线方向
float3 pos = BezierPosition(curve, t);
float2 normal = perpendicular(BezierTangent(curve, t).xz);
// 坝体两侧各取一个采样点
float3 upstream = pos - normal * (width / 2);
float3 downstream = pos + normal * (width / 2);
// 从 GPU 回读的数据中采样水面信息
float h_up = SampleHeight(upstream, out float depth_up);
float h_down = SampleHeight(downstream, out float depth_down);
float2 v_up = SampleVelocity(upstream);
float2 v_down = SampleVelocity(downstream);
// 上游水面超过坝顶 → 截断(溢流坝模型)
if (h_up > dam_elevation) {
depth_up = max(0, depth_up - (h_up - dam_elevation));
h_up = dam_elevation;
}
// 核心公式:平均流量 × 水头差
totalProduction += (dot(v_up, normal) * depth_up
+ dot(v_down, normal) * depth_down)
* 0.5f
* max(0, h_up - h_down);
}
return totalProduction * waterData.m_ProductionFactor * curve.m_Length / numSamples;
}物理模型
本质上是离散化的水力发电功率公式。经典水力学中:
\[P = \rho g Q H\]
天际线2里的离散实现:
\[P = \sum_{i=1}^{N} \left[ \frac{(\vec{v}_{up} \cdot \hat{n}) \cdot d_{up} + (\vec{v}_{down} \cdot \hat{n}) \cdot d_{down}}{2} \right] \cdot \max(0,\ h_{up} - h_{down}) \cdot \frac{L}{N} \cdot k\]
其中:
- \(\vec{v} \cdot \hat{n}\) — 流速在坝体法线方向的分量(只有垂直穿过坝体的水流才发电)
- \(d\) — 水深(有效过流断面)
- \(h_{up} - h_{down}\) — 水头差(上游水面高度 vs 下游水面高度)
- \(L / N\) — 每个采样段的坝体长度
- \(k\) —
ProductionFactor,把物理量转换成游戏内的电力单位
graph LR
subgraph 每个采样点的计算
V["法向流速<br/>dot(v, n)"] -->|乘以| D["水深 depth"]
D -->|得到| Q["局部流量 Q"]
Q -->|乘以| H["水头差<br/>h_up - h_down"]
H -->|得到| P["局部发电贡献"]
end
P -->|沿坝体积分| SUM["总发电量"]
SUM -->|× ProductionFactor × L| FINAL["最终 Capacity"]CPU/GPU 完整协作流
flowchart LR
subgraph GPU["GPU — 每帧执行"]
WS[WaterSimulation<br/>ComputeShader<br/>浅水方程] --> RT[(WaterTexture<br/>RenderTexture<br/>float4 per pixel)]
end
RT -->|"AsyncGPUReadback<br/>降采样,延迟数帧"| NA[("NativeArray(SurfaceWater)<br/>CPU 可读")]
subgraph CPU["CPU — 每128帧执行"]
NA --> PP["PowerPlantAISystem<br/>GetWaterProduction()<br/>沿坝体采样,算 Q×H"]
PP --> EP["ElectricityProducer<br/>写入 .m_Capacity"]
EP --> EF["ElectricityFlowSystem<br/>电网图论求解<br/>分配实际供电"]
endJob 依赖链
// PowerPlantAISystem.OnUpdate()
JobHandle waterHandle;
var waterData = m_WaterSystem.GetVelocitiesSurfaceData(out waterHandle);
// 合并所有数据源的依赖
Dependency = tickJob.ScheduleParallel(m_PowerPlantQuery,
JobUtils.CombineDependencies(Dependency, windHandle, waterHandle, groundWaterHandle));
// 告诉 WaterSystem "我在读你的数据,别覆盖"
m_WaterSystem.AddVelocitySurfaceReader(Dependency);声明依赖、调度并行、注册为 Reader,Job System 就自动处理并发安全了,不用手写锁。
为什么发电量不放 GPU 算?
乍一看水面数据本来就在 GPU 上,何必回传再算?
| GPU 算 | CPU 算 (现行方案) | |
|---|---|---|
| 水面数据 | 天然就在 | 要 Readback |
| 坝体几何 | 要从 ECS 上传 Bezier 曲线 | 天然可用 |
| 结果去向 | 算完还得传回 CPU 给电网 | 直接写 ECS 组件 |
| 频率 | 每帧都跑,其实没必要 | 128帧一次 |
| 逻辑 | 循环+分支,GPU 不擅长 | CPU 的强项 |
规则网格的大规模并行丢 GPU,不规则几何+低频+多系统交互的逻辑留 CPU。各干各擅长的活。
性能优化手段
分频更新
不同系统用不同 tick 间隔,把 CPU 负载在时间上打散:
PowerPlantAISystem → 每128帧
// 交通AI → 大概每16帧
// 人口统计 → 大概每256帧不会出现"所有系统在同一帧同时算"的峰值卡顿。
Active Tiles 稀疏计算
if (ActiveTilesHelper.numThreadGroupsTotal > 0) {
cmd.DispatchCompute(...);
}地图上大部分是陆地,只有有水的区块才参与 GPU 计算。典型地图能省掉 80%+ GPU 开销。
Chunk 级并行 + 内存连续
graph TB
subgraph ECS内存布局["ECS Chunk 内存布局 (Structure of Arrays)"]
C0["Chunk 0: [电站A.Capacity][电站B.Capacity][电站C.Capacity]<br/>连续内存,一条 Cache Line 全命中"] --> T0[Worker Thread 0]
C1["Chunk 1: [电站D.Capacity][电站E.Capacity][电站F.Capacity]<br/>连续内存"] --> T1[Worker Thread 1]
C2["Chunk 2: [电站G.Capacity][电站H.Capacity]<br/>连续内存"] --> T2[Worker Thread 2]
end同 Chunk 的同类型数据在内存中紧密排列。遍历时 CPU 预取直接命中,再加上 Burst 自动向量化(SIMD),性能拉满。
读写声明 → 自动并行
// 大量只读 → 多个 Job 可同时读
[ReadOnly] ComponentTypeHandle<PrefabRef> m_PrefabType;
[ReadOnly] WaterSurfaceData<SurfaceWater> m_WaterSurfaceData;
// 少量读写 → 独占访问
ComponentTypeHandle<ElectricityProducer> m_ElectricityProducerType;
BufferTypeHandle<Efficiency> m_EfficiencyType;Job System 看到两个 Job 都只读同一份数据,自动判定可以并行;有一个要写,就串行等着。开发者不用管锁、不用管竞争。
效率因子系统
每个建筑身上挂着一串效率因子,相乘得到总效率:
enum EfficiencyFactor : byte {
Destroyed, Abandoned, Disabled, Fire, // 建筑状态
ServiceBudget, NotEnoughEmployees, // 管理因素
ElectricitySupply, WaterSupply, // 基础设施
WindSpeed, WaterDepth, SunIntensity, // 自然资源
// ... 共33个
}总效率的计算:
\[\eta = \prod_{i=0}^{32} \max(0,\ f_i)\]
某个因子为 0 就整体为 0(比如着火了),任何一个拉低都拖累整体。
不同 System 各管各的因子:
graph LR
PPS[PowerPlantAISystem] -->|"WindSpeed<br/>WaterDepth<br/>SunIntensity"| BUF[/"Efficiency Buffer<br/>(DynamicBuffer)"\]
BES[BuildingEfficiencySystem] -->|"ElectricitySupply<br/>WaterSupply"| BUF
CSES[CityServiceEfficiencySystem] -->|"ServiceBudget<br/>NotEnoughEmployees"| BUF
BSES[BuildingStateEfficiencySystem] -->|"Destroyed<br/>Fire, Abandoned"| BUF通过 Update Phase 的顺序保证不会有两个 System 同时写同一个 Buffer。这比加锁优雅得多——架构设计上就避免了竞争。
回到最初的问题
现在可以从代码层面解释开头那些游戏现象了。
水面必须齐平大坝
\[P \propto (\vec{v} \cdot \hat{n}) \times d \times (h_{up} - h_{down})\]
水面低于坝顶 → 水被坝体挡住 → GPU 浅水方程算出该处流速为零 → 整个乘积为零。不是程序写了个 if 判断,是物理模拟的自然结果。
下游挖深有用
\[\Delta h = h_{up} - h_{down}\]
下游地面低 → 水面跟着低 → \(\Delta h\) 大 → 发电量线性增加。
上游挖深(保持水面不变)有用
浅水方程里,流速由水面坡度驱动:
\[\frac{\partial v}{\partial t} \propto -g \frac{\partial h_{surface}}{\partial x}\]
挖深地形但水面不变:
- 坡度不变 → 流速基本不变
- \(d = h_{surface} - h_{terrain}\) → 水深变大了
- \(v \times d\) 增大 → 有效流量增大 → 发电增加
而且浅水方程中底部摩擦 \(\sim v / d\),水越深摩擦越小,流速甚至会略微增加。
挖浅加速没用
反过来,挖浅:
- 水面坡度不变 → 驱动力不变 → 流速不会等比增加
- \(d\) 减小
- \(v \times d\) 反而更小了
直觉上"水浅流急"是对的,但发电公式里要的是 \(v \times d\)(流量),而不单纯是 \(v\)(流速)。
写在最后
ECS 这套东西刚接触的时候会觉得很别扭——数据和逻辑硬拆开,写个简单功能都比 OOP 啰嗦。但从天际线2和其他的实际工程来看,这种"别扭"换来的东西是值得的:
传统 OOP 里一个对象的字段散落在堆上,原来遍历一万个对象其实是在内存里随机跳。而ECS 的 SoA 布局让同类型数据紧密排列,CPU的一条 Cache Line 可以装几十个实体的同一字段,CPU 预取直接命中。
传统多线程里你要操心锁、操心竞争、操心死锁。ECS + Job System 的做法是让你声明"我读什么写什么",剩下的调度器全包了。写代码的人脑子里不需要有"线程"这个概念,只需要想清楚数据流向。
Burst 证明只要你愿意接受约束(无 GC、无虚调用、值类型),C# 编译出来的东西也能逼近手写汇编。约束本身就是优化的前提——你不能分配堆内存,所以没有 GC 暂停;你只能用值类型,所以内存局部性天然好。
规则网格上的大规模并行(水流、风场)扔 GPU,不规则几何+复杂分支+多系统交互的逻辑留 CPU,中间用异步回读桥接,容忍几帧延迟。不是什么都要实时,不是什么都要精确——想清楚每个计算的频率需求和数据依赖,就能找到合理的 CPU/GPU 分工。
这套架构当然也有代价:开发门槛高、调试困难、不适合快速原型。但对于天际线2这种模拟游戏来说,这是目前能落地的最好方案之一。
基于 Cities: Skylines II Game.dll 逆向分析,使用 dnSpy MCP + Claude Code 完成。仅用于技术学习。
