游戏引擎的骨架决定了玩法代码能走多远。很多从 游戏服务端 转客户端、或从 Lua 全栈 踏入引擎层的开发者,第一次面对"引擎是怎么把 60 帧跑起来的"这个问题时,往往被 ECS、场景图、资源系统这些名词淹没。本文剥开引擎外壳,聚焦四个最核心的骨架模块:实体组件系统(ECS)、场景图(Scene Graph)、资源加载与对象池、帧循环,并用 Unity DOTS、Godot 4 与一个自研迷你引擎三重视角对照,让你既能读得懂商业引擎源码,也能动手写出属于自己的引擎骨架。
建议先读 游戏模块划分和语言选型 建立全局视角,本文深入其"客户端/引擎"一侧。
1. ECS 实体组件系统:从对象到数据
1.1 为什么 OOP 会撞墙
传统面向对象做法是把游戏对象建模成继承树:Entity -> Unit -> Player,每个对象把自己的数据和行为捆在一起。
// 传统 OOP 的典型痛点
public class Enemy : MonoBehaviour {
public float hp = 100f;
public Vector3 position;
public void TakeDamage(float dmg) { hp -= dmg; }
public void Update() { /* 寻路、动画、AI 全塞在这里 */ }
}
当场景里有 1 万个敌人时,这种"行为跟随对象"的模型会遇到三个问题:
- 缓存不友好:每个
Enemy对象散落在堆上,遍历时 CPU 缓存命中率极低。 - 无法组合:想给某只怪加"中毒"效果,要么加字段、要么加继承层级,代码迅速腐化。
- Update 全部执行:AI、渲染、动画逻辑全部运行,即使大部分敌人此刻无事可做。
1.2 ECS 的三段式定义
ECS 把"对象"拆成三个独立维度:
| 概念 | 含义 | 类比 |
|---|---|---|
| Entity(实体) | 只是一个 ID,没有任何数据 | 数据库里的一行主键 |
| Component(组件) | 纯数据,不含方法 | 一行里的各列 |
| System(系统) | 处理某类组件集合的逻辑 | 一条 SQL 查询 + 处理逻辑 |
// 实体:裸 ID
public struct Entity { public int id; }
// 组件:纯数据
public struct Position { public float x, y, z; }
public struct Velocity { public float dx, dy, dz; }
public struct Health { public float hp; public float maxHp; }
// 系统:批量处理拥有 Position+Velocity 的实体
public struct MovementSystem {
public void Update(World world, float dt) {
foreach (ref var e in world.Query<Position, Velocity>()) {
e.Position.x += e.Velocity.dx * dt;
e.Position.y += e.Velocity.dy * dt;
e.Position.z += e.Velocity.dz * dt;
}
}
}
关键转变:数据与行为分离。想给实体加能力?加一个组件、加一个系统即可,零继承改动。
1.3 数据布局:AoS 与 SoA
ECS 真正的性能红利来自内存布局。两种存储方式:
AoS(Array of Structures)—— 传统写法,每个实体一个结构体连续排列:
[HP0][POS0][AI0] [HP1][POS1][AI1] [HP2][POS2][AI2] ...
只处理 HP 时仍需读入 POS/AI,浪费带宽
SoA(Structure of Arrays)—— ECS 推荐,每种组件单独一块数组:
[HP0][HP1][HP2] [POS0][POS1][POS2] [AI0][AI1][AI2] ...
只处理 HP 时,内存访问完全连续、命中率最高
// SoA 风格的 ECS 存储
public class PositionArray {
public float[] x, y, z; // 三块独立数组
public int Count;
}
// 系统遍历时逐数组处理,编译器还能自动向量化(SIMD)
for (int i = 0; i < Count; i++) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
}
实际测量中,在 10 万实体场景下,SoA 布局相比 AoS 常见 2-5 倍吞吐提升,因为现代 CPU 的瓶颈是内存带宽而不是算术。
1.4 三引擎对照
| 维度 | Unity DOTS | Godot 4 | 自研 |
|---|---|---|---|
| 组件载体 | struct + IComponentData | Component(仍带行为) | 任意 POD 结构体 |
| 实体存储 | Archetype(按组件组合分块) | 普通 Node 树 | Dictionary<int, ComponentStore> |
| 系统写法 | ISystem + Burst 编译 | _Process + _PhysicsProcess | 普通函数 |
| 优势 | 极致性能、并行 Job | 编辑器友好、生态成熟 | 完全可控、教学清晰 |
Godot 4 严格说不是纯 ECS:它的 Node 仍带行为,但有 GDExtension + 内存友好组件可做类 ECS 实践。多数中小项目直接用 Node 树就够,不必迷信 ECS。
1.5 自研 ECS 的关键设计决策
写一个迷你 ECS 需要回答五个问题:
- 实体 ID 复用:用
freeList + generation防悬挂引用(ID 被复用后旧引用误伤新实体)。 - 组件存储:按 Archetype 分块(快)还是稀疏数组(简单)?先做稀疏数组,复杂度低。
- 查询缓存:每次
Query<A,B>都全量扫描?缓存"哪些 archetype 同时含 A、B"。 - 系统调度:依赖关系(读写冲突)如何排序?用拓扑排序或简单串行。
- 是否并行:
System A 读 P、System B 写 V是否可并行?先串行,性能不够再加 Job。
// 防悬挂引用的 generation 技巧(伪代码)
struct Entity { public int index; public int generation; }
// 实体数组:generation++ 表示该槽位被回收并重新分配
2. 场景图(Scene Graph):Transform 层级
2.1 节点树与父子变换
场景图是一棵以 Transform 层级为骨架的树。子节点的世界坐标 = 父节点的世界变换 × 自身局部变换。
Root (0,0)
└── Player (10,0)
├── Camera (0,5) → 跟随玩家
├── Gun (0,1) → 相对玩家
└── MuzzleFlash (0,2) → 相对枪口
// 变换矩阵链:local → world
world = parent.world * local;
// 子节点只需要移动父节点,整棵子树自动跟随
player.localPosition += Vector3.right * speed * dt;
// Camera/Gun/Muzzle 的世界坐标自动更新
2.2 脏标记:避免每帧全量重算
朴素实现每帧对每个节点做一次矩阵乘法链。优化手段是脏标记(dirty flag):只有变换发生变化的子树才重算。
public class Transform {
public Matrix4x4 local, world;
public bool dirty = true;
public Transform parent;
public void SetLocalPosition(Vector3 p) {
local = Matrix4x4.Translate(p);
dirty = true; // 标记本节点
// 遍历子节点把 dirty 递归下发(或由系统统一传播)
}
public Matrix4x4 GetWorld() {
if (dirty) {
world = parent != null ? parent.GetWorld() * local : local;
dirty = false;
}
return world;
}
}
要点:世界矩阵在"需要时才计算"(lazy),渲染系统每帧只对有 dirty 标记的节点做矩阵链求值。
2.3 场景图与 ECS 的融合
纯 ECS 的世界没有层级关系,两个方案融合:
- 方案 A(hybrid):Transform 组件里保存 parent 实体 ID,系统按拓扑遍历求世界矩阵。
- 方案 B(Unity 做法):GameObject 仍是场景图节点,ECS 只负责高频逻辑数据,渲染走 GameObj 层。
实践中 Unity DOTS 的 Hybrid Renderer 与 Godot 的 MultiMesh + Node 都是"场景图负责结构、ECS/SoA 负责批量数据"的混合形态。对绝大多数项目,以场景图为骨架、以数据驱动填充性能热点,是最务实的架构。
3. 资源加载与对象池
3.1 资源生命周期:引用计数
引擎资源(Mesh、Texture、AudioClip)体积大、加载慢,必须有明确的生命周期管理。最常见的方案是引用计数 + 根场景持有。
public class Asset : IDisposable {
public int refCount = 1; // 资源系统持有 1
public string path;
public void AddRef() => refCount++;
public void Release() {
if (--refCount == 0) UnloadFromGpu(); // 释放 GPU 显存
}
}
Unity 的 Resources.UnloadUnusedAssets、Godot 的 Resource 都内置引用计数。陷阱是循环引用:A 引 B、B 引 A,双方 refCount 永不为零。解决:资源间只允许单向引用,或显式 unload。
3.2 同步 vs 异步加载
| 加载方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 同步 | 简单、确定性 | 卡主线程,可能掉帧数秒 | 启动场景、加载界面内 |
| 异步(多线程+主线程提交) | 不阻塞渲染 | 需要处理竞态、进度反馈 | 大地图流式加载、关卡切换 |
// Unity Addressables 异步加载
var handle = Addressables.LoadAssetAsync<GameObject>("Enemies/Goblin");
handle.Completed += op => {
if (op.Status == AsyncOperationStatus.Succeeded) {
var prefab = op.Result;
Instantiate(prefab);
}
};
# Godot:显式异步加载
var loader = ResourceLoader.load_threaded_request("res://Enemies/goblin.tscn")
var scene = await ResourceLoader.load_threaded_get("res://Enemies/goblin.tscn")
3.3 加载分层策略
生产级引擎普遍分三层:
常驻层(Resident) → UI、核心角色,启动即加载
按需层(Streaming) → 当前关卡、当前区块,进入前预加载
延迟层(Lazy) → 高光特效、隐藏 Boss,触发时加载
配合 Bundle/包体切分(Unity Addressables Group、Godot PCK),做到"首包小、边玩边下"。
3.4 对象池:消灭实例化尖峰
频繁 Instantiate/Destroy 会带来 GC 压力与内存碎片。对象池在初始化时预分配一批对象,用后放回。
public class ObjectPool<T> where T : class, new() {
private readonly Stack<T> _pool = new();
private readonly Func<T> _factory;
public T Get() {
if (_pool.Count > 0) return _pool.Pop();
return _factory();
}
public void Return(T item) {
// 重置状态、移出场景树
_pool.Push(item);
}
}
// 子弹射击:Get 而非 new
var bullet = pool.Get();
bullet.SetDirection(dir);
对象池三原则:预分配要量、归还必须重置、绝不跨场景持有(避免隐式大对象滞留)。Unity 官方
ObjectPool<T>(Unity 2021+)与 GodotPool<RefCounted>场景下均可直接用。
4. 帧循环(Game Loop)
4.1 三种帧循环模式
| 模式 | 逻辑 | 物理 | 代表 |
|---|---|---|---|
| 可变步长 | dt 随真实时间变化 | 用 dt 积分 | 老游戏、原型 |
| 固定步长 | 固定 dt,渲染插值 | 固定步长 | 现代推荐 |
| 半固定 | 逻辑固定、渲染插值 | 固定 | Unity/Godot 默认 |
固定步长的问题:物理结果确定性(帧同步游戏依赖它)。但固定步长与显示器刷新率(60/120Hz)不整除,所以要在逻辑步进与渲染输出之间插值。
// 经典固定步长循环(含累积器)
const float FIXED_DT = 1f / 60f;
double accumulator = 0;
double lastTime = TimeNow();
while (running) {
double now = TimeNow();
double frameTime = now - lastTime;
lastTime = now;
accumulator += frameTime; // 累积真实时间
accumulator = Math.Min(accumulator, 0.25); // 防螺旋死亡
while (accumulator >= FIXED_DT) {
UpdatePhysics(FIXED_DT); // 固定步长逻辑
accumulator -= FIXED_DT;
}
double alpha = accumulator / FIXED_DT; // 插值系数
Render(Interpolate(alpha)); // 渲染插值
}
4.2 逻辑与渲染的解耦
性能热点分层:
帧循环
├── FixedUpdate (固定步长):物理、网络输入、确定性逻辑
└── Update (每帧):
├── 输入采样
├── 逻辑更新(AI、动画状态机)
├── 渲染提交(Culling → 合批 → DrawCall)
└── 后处理 / UI
Unity 的 FixedUpdate/Update/LateUpdate 与 Godot 的 _PhysicsProcess/_Process 就是这个模型的具象化。法则:一切影响游戏性判定的逻辑必须放固定步长,渲染层只做"表现"。
4.3 帧率与预算
目标 60FPS 意味着每帧总预算 16.67ms,典型分配:
| 模块 | 预算(ms) | 占比 |
|---|---|---|
| 渲染(Draw/后处理) | 8-10 | 50-60% |
| 逻辑(AI/动画/物理) | 4-5 | 25-30% |
| 游戏代码与脚本 | 2-3 | 10-15% |
| 余量(GC/IO 尖峰) | 1-2 | 5-10% |
超过预算就是掉帧来源。用 Profiler 逐个模块测量,先解决最大的百分比,而不是凭感觉优化。详细方法论见 游戏性能剖析与优化。
5. 最佳实践与总结
架构决策清单:
- 别急着上 ECS:项目 < 5 万活跃实体、以编辑器工作流为主时,场景图 + 组件模式(Godot 默认 / Unity MonoBehavior)更省力。ECS 是为性能热点准备的。
- 数据驱动优先:把"行为差异"变成"数据差异"(配置表/组件组合),能显著减少分支与继承。
- 场景图负责结构,ECS 负责数据:二者不是二选一,商业引擎都已走向混合架构。
- 对象池 + 异步加载是标准答案:任何会产生尖峰的地方(子弹、粒子、怪物生成)都值得池化。
- 固定步长逻辑 + 渲染插值:这是保证网络确定性(帧同步)与画面平滑的基石。
自研引擎最小骨架推荐阅读顺序:帧循环 → 对象池 → 场景图 → ECS → 资源加载。每完成一层,就用一个"1000 个移动方块"的 demo 验证性能是否随预期变化。
架构没有银弹:Unity DOTS 的极致性能带来复杂度,Godot 的友好带来生态红利,自研带来完全掌控。选择取决于你的性能预算与团队能力,而不是技术潮流。
相关阅读:游戏渲染管线基础 讲解场景图产出后如何被 GPU 消费;游戏物理与碰撞 讲解固定步长里最常见的系统。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。