制作自己的引擎(二)脚本支持
开篇
之前文章中讲述了引擎的初始架构和基础渲染、物理模拟与并行化,本想告一段落专心论文春招的,但某天刷到了隔壁的Krystallos Engine。就是一种很奇妙的心态,觉也睡不下去了,肝了一周有了这次大改。
上一篇已经把引擎的基本框架搭起来了,也有了一个能跑的Editor界面。但到这个阶段其实还挺尴尬的,它看起来像个引擎,用起来却更像一个 C++ Demo 框架,所以脚本系统就绕不过去了。Unity 用 C#,Godot 有 GD Script,UE 虽然主力是 C++,但蓝图本质上也是把 gameplay 逻辑从底层代码里拎出来。个人小引擎当然没必要一上来整套蓝图虚拟机,能把程序跑起来、访问常见组件、在 Editor 里调字段,就已经能让项目开发体验跨一个台阶。这里最后还是选了 C# + Mono。原因倒也简单:个人之前主用Unity,C# 语法熟悉,Mono Embedded API 虽然有点老旧但资料多,自己折腾起来也不至于完全黑盒。
脚本系统
分层结构
脚本系统的结构大概分三层:
1 | 项目脚本 |
项目里的 .cs 不直接碰 C++。脚本只引用 DittoEngine.dll,里面提供 MonoBehaviour、GameObject、Transform、Input、Rigidbody2D、AudioSource 等包装。真正的数据还在 C++ 侧,C# 只是通过 internal call 过去读写。这个拆法主要是为了边界清楚。C++ 负责稳定的引擎能力,C# 负责变化快的玩法逻辑。否则所有东西都混在一起,短期能跑,后面一做 Demo 就会开始互相打搅。
Mono 运行时
Mono 这边没有选择静态链接,还是动态加载 mono-2.0-sgen.dll。静态链接当然也不是不行,但个人项目里维护一堆 lib 和版本路径实在过于繁琐,最后还是 LoadLibraryA 加 GetProcAddress 这套老办法最直接。启动流程大致如下:
1 | 1. 找到 Mono runtime |
写起来最繁琐的不是某一个 API,而是各种路径。Editor 里跑、Player 里跑、从项目目录启动、从构建目录启动,程序集和 runtime 的相对位置都不一样。脚本系统很多 bug 最后查下来不是 Mono 调错了,而是 dll 根本没在它以为的地方。
API 程序集
DittoEngine.dll 是脚本侧看到的引擎。当前项目里保留了 Ditto/3rdParty/Mono/DittoEngine.csproj,目标框架是 netstandard2.0;同时也留了一个直接用 csc 编译的脚本,方便在没有完整 .NET 工程流程时快速生成 dll。这样做的好处是项目脚本只面对一个比较干净的 C# API,不用知道 C++ 里到底怎么存 GameObject,也不用把原生头文件暴露出去。
脚本写起来大概就像这样,这里的 AddForce实际调用链会从 C# wrapper 走到 Mono internal call,再落到 C++ 的 Rigidbody2DComponent。
1 | using DittoEngine; |
编辑器工作流
编译与热重载
脚本编译由 CSharpScriptCompiler 处理。它需要找到 C# 编译器、DittoEngine.dll、mscorlib.dll、netstandard.dll,再把项目脚本编译成 dll。这里本质上和 Unity 的 asmdef 不是一个复杂度,但方向类似:Editor 负责把源代码变成可加载程序集,运行时只管加载和调用。比较实际的一点是错误信息必须回到 Editor。脚本编译失败时,如果只在控制台打几行字,体验基本等于没有。当前脚本组件会记录最近一次编译结果,Inspector 里能看到错误、警告和输出,至少能知道是代码没过还是运行时没绑上。
热重载在逻辑概念上十分简单:检测脚本文件变化,重新编译,成功后加载新程序集,再让组件重新找类型。这里需要承认的是当前实现不是那种完美保留托管实例的热替换:重载时旧的 C# 实例会先卸掉,如果后面编译或加载失败,场景里的组件和字段数据还在,但脚本实例要等下一次成功编译后才能恢复。个人感觉这比追求什么“完美无感热替换”更符合引擎现阶段,先保证别把项目结构弄坏。
字段序列化
脚本能跑只是第一步,能在 Inspector 里调字段才能算得上引擎。不然每次改速度、伤害、跳跃力度都要回代码,脚本系统还是半成品。目前支持公开字段,也支持类似 [SerializeField] 的标记。Editor 扫描脚本类型后,把可序列化字段画到 Inspector 里,场景保存时再把这些值写进去。这样一来,C# 脚本就不只是运行时代码,而是能真正接入编辑器工作流。
这块实现时也能理解 Unity 为什么有那么多序列化限制。字段类型一多,数组、引用、默认值、隐藏字段、组件生命周期全都会冒出来。个人这边当然没做到那么全,但至少常用数值和组件参数已经够用。
绑定范围
最开始只要 Transform 和 GameObject 就能糊一个例子,但一旦真的做玩法,绑定范围会飞速膨胀。目前 Ditto 已经把Transform / GameObject,Instantiate / Destroy,Input / Mouse,Camera,Rigidbody2D / Collider2D,AudioSource,UI,Animator,ParticleSystem这些都接到了 C#。
所以脚本系统并不是孤立模块,它更像一条通道。底层每加一个 runtime 功能,最终都要想想脚本侧怎么用、Inspector 怎么显示、构建时怎么带出去。做到这里,引擎才从“我自己能在 C++ 里写点逻辑”变成“项目可以在它上面继续写玩法”。
小结
脚本支持做完后,Ditto 的可用性明显上了一个台阶。虽然内部还是一堆 C++、Mono、dll、路径和绑定函数,但对用户来说理想状态就是挂一个脚本、改几个字段、点播放能跑。从某种程度上来说麻烦是守恒的,用户的麻烦少了,引擎层的麻烦就多了,我愿称之为某种守恒律。





