LOADING

加载过慢请开启缓存 浏览器默认开启

拆解城市天际线2代:Unity模拟经营游戏背后的 ECS 架构

2026/5/6 GameDev Unity ECS
本文总阅读量

通过反编译,探寻天际线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/>分配实际供电"]
    end

Job 依赖链

// 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 完成。仅用于技术学习。

本站不开放评论区,如有讨论内容请移步至本站Github讨论区