游戏引擎架构设计学习
Dionysen

Hazel Engine 没有使用分层式架构设计,多模块间的耦合相当严重,类之间的相互依赖也比较常见,这对分块编译和后续扩展提出了较大的挑战。

持续思考一个尽可能简单又有效的架构设计,尽可能满足多Target分离,动态加载DLL,模块解耦。

Architecture

  • 纯抽象类 + Service Locator
  • 需要热更新则引入 C 函数字典

模块间接口调用最核心的目标是将 编译器耦合 降低到最小,再用运行时注册或数据驱动将系统拼接起来。

常见问题:

  • 不要在 Interface 头里暴露 STL 容器或自定义模板实例,因为一旦编译器版本不同 ABI 就崩溃。
  • 跨 DLL 抛异常,传递 std::string 都很危险,统一使用 POD 或 string_view
  • C API 虽丑但稳定,很多商业引擎(Unity NativePlugin、Godot GDNative)都依赖它实现长期兼容。
  • 如果模块间必须传复杂对象,可用 Serializer:: Write(Object*, Blob&) 把状态打包,用 blob 交换。

Service Locator

  1. 接口只在编译器出现,不让任何实现细节泄露;
  2. 运行时把实时对象注册到一个全局的注册表(或显式传入)中;
  3. 调用时通过接口指针完成功能调用(多态)。

但在实现时需要注意一些坑

  1. 接口应该统一放在 Core 中还是各个子模块?

    • 最小原则:只有“跨模块要被别人用”的 API 才放到最低层。

    • 常见做法是:

    • 在 Core 目录里只放通用基类和 Service Locator;

    • 每个子系统再有自己的 Public 头,例如 Render/Public/IRender.h、Physics/Public/IPhysics.h。

    这样 Render 的实现(Render.dll / libRender.a)位于更高层,但它的 接口头文件 可以像第三方 SDK 一样被任何人 include,而不会造成链接依赖。

  • 需要调用渲染的模块只依赖 IRender.h(接口),不会链接 Render 的实现;
  • Render 若想回调 Physics,就只能依赖 IPhysics.h 或通过事件/ECS 数据,避免形成编译环。

POD

POD 是 “Plain Old Data” 的缩写,意指“像 C 语言那样朴素的数据类型”。在 C++ 语境中它有一套非常具体的标准定义,用来保证以下两件事 ︰

  1. 内存布局可预测——字段按声明顺序顺排,不会插入 v-ptr、padding 不会被继承级别打乱;
  2. 拷贝 / 反序列化 / memcpy / 跨 DLL 或跨语言传递 时不会触发构造、析构、虚函数等隐式行为,因而 ABI(应用二进制接口)稳定、安全。

一、正式定义(C++11 之后)

C++11 起标准把 “POD” 拆成两类 type traits,再用“二者皆满足”来等价于旧称 POD:

• trivial type

  • 默认构造、拷贝构造、移动构造、拷贝赋值、移动赋值、析构函数全部是 trivial(编译器隐式生成,且只是 bit-wise 拷贝 / 释放)。
    • standard-layout type
  • 没有虚函数或虚继承;
  • 同一访问级别(public/private/protected)的非静态数据成员彼此在同一个类定义层次;
  • 第一个非静态数据成员与结构体本身地址相同;
  • 不是多继承等会导致额外 v-ptr 或调整指针的复杂继承结构。

若一个类型同时满足 “trivial” 与 “standard-layout”,标准就把它视作 POD。
std::is_pod<T>::value(已在 C++20 标准中弃用,推荐用 is_trivial && is_standard_layout 自组合)。

二、常见满足/不满足例子

满足(POD):

struct Vec3 { float x, y, z; };      // 仅含内建类型
struct RGBA { uint8_t r,g,b,a; }; // 简单像素
struct Id { uint32_t value; }; // 句柄/索引

不满足(非 POD):

struct Foo { std::string s; };       // 成员有非 POD
struct Bar { virtual void f(); }; // 有虚函数
struct Baz { Baz(){} }; // 自定义构造函数(非 trivial)

三、为什么跨模块 / 语言首选 POD

  1. 可直接 memcpy、读文件、网络反序列化——不触发构造/析构栈;
  2. ABI 在不同编译器版本、不同 DLL 间一致(只要基本对齐规则一致);
  3. 与 C API、GPU 着色器常量缓冲 (Uniform Buffer) 或 SIMD 批处理结构天然对齐;
  4. 避免把 std::stringstd::vector、虚函数表指针等 实现相关 信息暴露到接口层,降低升级和热更新风险。

四、在游戏引擎中的用法示例

// Public Interface 头文件(跨 DLL )
struct TransformHandle { uint32_t index, generation; }; // POD 句柄

struct TransformSOA { // SoA, hot-data
float x[256];
float y[256];
float z[256];
};
// Render、Physics 等子系统都可直接持有指针或 Offset 访问,
// 客户端可以 `Serialize(binarystream, &transform, sizeof(transform))`。
  1. 跨 DLL 不要抛/捕异常,也不要返回/接收 STL 容器;
  2. 若需要复杂对象,可在模块内部保留指针,向外部只暴露 uint32_t handle
  3. 若必须传非 POD(例如 std::string),可封装成 struct { const char* data; uint32_t size; } 这样的轻量 POD。

简而言之,POD 是“没有隐藏行为、内存布局稳定”的数据结构,在模块边界、网络、磁盘、GPU 乃至脚本绑定等场景都极其安全、易维护,是现代游戏引擎接口层的首选交换格式。

显示评论