基于插件的引擎设计

这篇小文是我读“Building an Engine Plugin System”(需翻墙)随手留下的一些笔记。

开始正文前,先无责任评论两句吧。bitsquid是一个强调动态(各种reload),极简设计(接口知识最小化),轻量级(核心不到20万行)的引擎,跟俺口味很贴近,所以俺一向比较关注他们的动向。这是个小团队,虽然网站上没啥信息,引擎也未公开放出,可是blog的水分很少,质量比较高,是俺的菜。

按照传统套路,先啰嗦一番“非插件化设计”的弊端:

  • 想扩展现有行为,就得修改代码,重编引擎。(隐含工作量太大——获取全部代码和依赖的库,架设所有build环境)
  • 一旦开始有了本地改动就得维护之,一旦pull了新版本就得merge,这个merge的工作量,随着本地改动的增加和时间的推移,不断上涨。
  • 本地的改动是基于当时那个snapshot的代码逻辑,有很多隐含的假设,随着时间的推移和引擎的重构,这些假设会被破坏,导致各种莫名的问题。(最常见的是本地行为依赖了一个内部数据结构,结果那个结构被重构甚至被去掉了)
  • 所有本地改动很难与其他人共享。实践上,最多就是打个patch发一下,难以整包发布。

插件的好处就不细说了,基本上因为双方依赖的是显式的API,这种紧密关系就被解耦了,各种好处。

先说下naive的做法——插件定义和导出一些标准的函数入口,引擎在特定时间调用。
看上去基本上是下面这样的:

__declspec(dllexport) void init();
__declspec(dllexport) void update(float dt);
__declspec(dllexport) void shutdown();

 

当插件要用引擎的功能时,通过SharedDLL来实现。

Image Title

一些传统引擎(比如Ogre)就是这么实现的,某种意义上讲,Windows本身也是如此。

这么设计的问题是引擎的设计者很被动——插件要啥功能,就得把啥放到SharedDLL里去。搞着搞着发现所有东西都进去了。暴露出去的接口变得臃肿,很多小秘密也被插件知道了。这样一来重走老路,修改的破坏性风险又急剧增加。

改良的办法是通过lua等脚本来作为天然的api屏障,这也是各种MMO的典型做法。但这种也有局限性,就是插件需要取得一些底层的东西时比较难办(给还是不给呢?)。对游戏引擎的设计来讲,大部分情况下写插件的人是程序,两边都是C++,中间用lua会很啰嗦和低效,有点儿没事找抽的感觉。

本文的方案是基于一种C的接口查询的方法(C++的ABI,唉,不提也罢)。
与让插件链接到一个提供引擎API的DLL,不如直接在插件初始化的时候把引擎API传过去,如下所示:
(小声说一句,这思路其实是抄Linux的)

"plugin_api.h"

typedef struct EngineApi
{
 void (*spawn_unit)(World *world, const char *name, float pos[3]);
 ...
} EngineApi;

一个头文件搞定,插件只要包含这个文件就行,不用链接任何dll。插件只需要在入口处获得(引擎提供的)这个结构的指针,然后想要什么功能就可以直接调进去了。

嗯,为了方便演化和兼容性,加个版本控制。为免牵一发而动全身,可把这些接口根据逻辑上的相关性,拆成多个结构,各自独立演化,如下所示:

"plugin_api.h"


#define WORLD_API_ID    0
#define LUA_API_ID      1

typedef struct World World;

typedef struct WorldApi_v0 {
 void (*spawn_unit)(World *world, const char *name, float pos[3]);
 ...
} WorldApi_v0;

typedef struct WorldApi_v1 {
 void (*spawn_unit)(World *world, const char *name, float pos[3], float rot[4]);
 ...
} WorldApi_v1;

typedef struct lua_State lua_State;
typedef int (*lua_CFunction) (lua_State *L);

typedef struct LuaApi_v0 {
 void (*add_module_function)(const char *module, const char *name, lua_CFunction f);
 ...
} LuaApi_v0;

typedef void *(*GetApiFunction)(unsigned api, unsigned version);

现在插件只要提供模块ID和版本号,就可以用 GetApiFunction 来获取对应的引擎功能了,具体代码可看原文,比较长就不贴了。

反过来插件暴露给引擎的API,也是如法炮制。各种小好处和细节,略去不提。

以上就是 基于C的API协议定义技术,在插件设计里的应用。

这个结构本质上很简单,A也不依赖B,B也不依赖A,咱俩都依赖协议,私下里就可以没负担地随便改了——只要别破坏协议就行。
这种方式比所谓的 DLL 显式链接(GetProcAddress + "FunctionName")依赖更少,因为显式链接除了签名一致以外,还依赖函数名一致。
这样也有动力保持协议最小化了——协议越简洁,双方的发展自由度也就越大,将来潜在的矛盾和冲突也就越小。(小一点,再小一点。各种这一类的梗可见 The old new thing,微软历史上各种被坑的经历,不要重演,呵呵)

(完)
Gu Lu
[2014-05-16]

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。