ECS(Entity Component System,实体组件系统)已经成为现代游戏开发中非常流行的架构模式。它的核心思想是用组合代替继承,把游戏对象拆分成一个个独立的数据组件,再用系统统一处理逻辑。而物理引擎(比如Box2D)通常有自己的世界和刚体概念,如果直接在游戏逻辑里到处调用Box2D的API,代码很快就会变成一团乱麻。这篇文章就来讲讲如何用EnTT这个C++的ECS库,把Box2D封装成一套干净的系统,让物理模拟与游戏逻辑彻底解耦。

先搞清楚ECS的三个核心概念
在动手写代码之前,有必要把ECS的三个基本概念理清楚,否则后面集成Box2D时很容易混淆。实体(Entity)本质上只是一个ID,它本身不携带任何数据,也不包含任何行为。组件(Component)是纯数据的集合,比如位置、速度、贴图引用,就是一个简单的struct。系统(System)则是真正干活的逻辑单元,它会遍历所有拥有特定组件组合的实体,然后执行更新。
这种拆分带来的最大好处是数据和逻辑分离。比如一个实体可以同时拥有TransformComponent(位置和旋转)、RigidBodyComponent(Box2D刚体指针)和SpriteComponent(渲染用的贴图),渲染系统只关心前两者中的位置数据和Sprite,物理系统只关心刚体,彼此互不干扰。相比传统的面向对象继承体系(比如Actor基类派生出Character、Character派生出Player这种深层结构),ECS想给实体加一个新能力,只需要挂一个新组件,不用改动任何已有类的代码。
用EnTT搭建基础架构
EnTT是一个只包含头文件的C++库,直接把头文件目录加入工程的include路径就能用。它的API设计得很直观:registry是核心的注册表,负责管理所有实体和组件。下面这段代码展示了基本用法:
#include <entt/entt.hpp>
// 组件:纯数据,不包含逻辑
struct Transform {
float x, y;
float rotation;
};
struct Velocity {
float vx, vy;
};
int main() {
entt::registry registry;
// 创建实体并挂载组件
auto entity = registry.create();
registry.emplace<Transform>(entity, 0.0f, 0.0f, 0.0f);
registry.emplace<Velocity>(entity, 1.5f, 0.0f);
// 系统逻辑:遍历所有同时拥有 Transform 和 Velocity 的实体
auto view = registry.view<Transform, Velocity>();
for (auto e : view) {
auto& tf = view.get<Transform>(e);
auto& vel = view.get<Velocity>(e);
tf.x += vel.vx;
tf.y += vel.vy;
}
return 0;
}可以看到,view会自动筛选出同时拥有指定组件的实体,这就是系统访问数据的方式。EnTT内部用了稀疏集合来做组件存储,查找效率非常高。接下来我们要为物理集成定义一个专门的组件,用来持有Box2D的刚体指针:
#include <box2d/box2d.h>
struct RigidBody {
b2Body* body = nullptr;
bool isDynamic = true;
};这里的关键点在于,Box2D的b2Body的生命周期由b2World管理,所以我们不能把整个body复制进组件,只存放指针。组件销毁时要记得通知物理世界销毁对应的刚体,否则会出现悬空指针,这是集成中最常见的崩溃来源。
物理系统:把Box2D封装成独立系统
有了前面的基础,现在写一个PhysicsSystem类,它内部持有b2World实例,负责三件事:为新增的RigidBody组件创建刚体、每帧步进物理模拟、把模拟结果同步回Transform组件。这样游戏的其他部分完全不需要知道Box2D的存在。
class PhysicsSystem {
public:
PhysicsSystem(entt::registry& registry)
: m_registry(registry), m_world(b2Vec2(0.0f, -9.8f)) {}
// 创建刚体并绑定到实体
b2Body* createBody(entt::entity e, float x, float y, bool dynamic) {
b2BodyDef def;
def.type = dynamic ? b2_dynamicBody : b2_staticBody;
def.position.Set(x, y);
def.userData.pointer = static_cast<uintptr_t>(e); // 把实体ID存进刚体
b2Body* body = m_world.CreateBody(&def);
m_registry.emplace<RigidBody>(e, body, dynamic);
return body;
}
// 每帧更新:步进物理并同步位置
void update(float dt) {
m_world.Step(dt, 8, 3);
auto view = m_registry.view<RigidBody, Transform>();
for (auto e : view) {
auto& rb = view.get<RigidBody>(e);
auto& tf = view.get<Transform>(e);
b2Vec2 pos = rb.body->GetPosition();
tf.x = pos.x;
tf.y = pos.y;
tf.rotation = rb.body->GetAngle();
}
}
// 组件销毁时清理刚体,避免悬空指针
void destroyBody(entt::entity e) {
if (auto* rb = m_registry.try_get<RigidBody>(e)) {
m_world.DestroyBody(rb->body);
rb->body = nullptr;
}
m_registry.remove<RigidBody>(e);
}
private:
entt::registry& m_registry;
b2World m_world;
};注意def.userData.pointer这一行,我们把entt的实体ID存进了Box2D的userData。这非常关键,因为物理引擎是反向通知碰撞的,它只知道两个b2Body撞上了,通过userData才能反查回ECS里的实体,进而触发游戏逻辑层面的碰撞响应。这是双向绑定的核心技巧。
关于单位也要提一句:Box2D默认按米和千克工作,官方建议单个物体尺寸在0.1到10米之间。如果直接用像素坐标(一个角色可能上百像素),模拟会出现抖动甚至崩溃。常见做法是定义一个缩放系数,比如32像素等于1米,在同步Transform时做换算。
碰撞事件的处理与分发
Box2D通过b2ContactListener回调来通知碰撞事件。我们的做法是实现一个监听器,把碰撞事件转换成ECS层面的事件,再由游戏逻辑消费。这样碰撞处理函数里拿到的依然是实体ID和组件,而不是裸的物理对象。
struct CollisionEvent {
entt::entity a;
entt::entity b;
};
class CollisionListener : public b2ContactListener {
public:
std::vector<CollisionEvent> events;
void BeginContact(b2Contact* contact) override {
auto* bodyA = contact->GetFixtureA()->GetBody();
auto* bodyB = contact->GetFixtureB()->GetBody();
auto ea = static_cast<entt::entity>(bodyA->GetUserData().pointer);
auto eb = static_cast<entt::entity>(bodyB->GetUserData().pointer);
events.push_back({ea, eb});
}
};有一个细节需要注意:Box2D的回调发生在Step执行过程中,此时物理世界的内部状态正在锁定,绝对不能在回调里直接销毁刚体或者修改游戏数据。正确的做法是先把事件收集到队列里,等Step返回后再统一处理。这也是上面代码用vector缓冲的原因。
处理事件时,如果游戏逻辑里有"子弹击中敌人就销毁双方"这类需求,一定要先从队列取出事件,再调用前面写的destroyBody,顺序上要先移除组件、销毁刚体,最后调用registry.destroy销毁实体本身。颠倒顺序可能导致其他系统访问已销毁的组件。
主循环与系统更新顺序
最后把所有部分串起来。主循环中系统的执行顺序非常重要,一般遵循:输入处理、逻辑更新(比如移动指令写入速度组件)、物理步进与同步、碰撞事件处理、渲染。写错顺序的话,画面上看到的物体位置会落后一帧,输入手感也会变差。
int main() {
entt::registry registry;
PhysicsSystem physics(registry);
CollisionListener listener;
// 创建一个静态地面和一个动态盒子
auto ground = registry.create();
registry.emplace<Transform>(ground, 0.0f, 0.0f, 0.0f);
physics.createBody(ground, 0.0f, 0.0f, false);
auto box = registry.create();
registry.emplace<Transform>(box, 0.0f, 5.0f, 0.0f);
auto* boxBody = physics.createBody(box, 0.0f, 5.0f, true);
b2PolygonShape shape;
shape.SetAsBox(0.5f, 0.5f);
b2FixtureDef fd;
fd.shape = &shape;
fd.density = 1.0f;
fd.friction = 0.3f;
boxBody->CreateFixture(&fd);
while (true) {
float dt = 1.0f / 60.0f;
physics.update(dt); // 物理步进 + 位置同步
// TODO: 处理碰撞事件队列
// TODO: 渲染系统读取 Transform 组件绘制
break;
}
return 0;
}这套结构跑通之后,你会发现扩展变得非常容易:想加动画就加个AnimationComponent,想加血量就加个HealthComponent,物理、渲染、AI各管各的组件,互不干扰。如果后续需要更复杂的场景,还可以引入固定时间步长累加器来保证物理模拟的稳定性,或者利用EnTT的on_construct信号在组件创建时自动生成刚体,进一步减少手工胶水代码。掌握这套集成思路后,再切换到Sol2写Lua脚本层、或者接入别的引擎模块,都会顺利很多。