开篇

  之前文章中讲述了引擎的初始架构和基础渲染、物理模拟与并行化,本想告一段落专心论文春招的,但某天刷到了隔壁的Krystallos Engine。就是一种很奇妙的心态,觉也睡不下去了,肝了一周有了这次大改。

  上一篇已经把引擎的基本框架搭起来了,也有了一个能跑的Editor界面。但到这个阶段其实还挺尴尬的,它看起来像个引擎,用起来却更像一个 C++ Demo 框架,所以脚本系统就绕不过去了。Unity 用 C#,Godot 有 GD Script,UE 虽然主力是 C++,但蓝图本质上也是把 gameplay 逻辑从底层代码里拎出来。个人小引擎当然没必要一上来整套蓝图虚拟机,能把程序跑起来、访问常见组件、在 Editor 里调字段,就已经能让项目开发体验跨一个台阶。这里最后还是选了 C# + Mono。原因倒也简单:个人之前主用Unity,C# 语法熟悉,Mono Embedded API 虽然有点老旧但资料多,自己折腾起来也不至于完全黑盒。

脚本系统

分层结构

  脚本系统的结构大概分三层:

1
2
3
4
5
6
项目脚本
-> GameScripts.dll
引擎 C# API
-> DittoEngine.dll
原生引擎
-> C++ GameObject / Component / Physics / Audio / UI

  项目里的 .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
2
3
4
5
6
7
1. 找到 Mono runtime
2. 初始化 domain
3. 设置程序集搜索路径
4. 加载 DittoEngine.dll
5. 加载项目的 GameScripts.dll
6. 注册 native internal call
7. 给场景中的脚本组件创建托管对象

  写起来最繁琐的不是某一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using DittoEngine;

public class BirdController : MonoBehaviour
{
public float force = 10.0f;
private Rigidbody2D body;

public override void Start()
{
body = GetComponent<Rigidbody2D>();
}

public override void Update()
{
if (Input.GetMouseButtonDown(0))
{
body.AddForce(Vector2.right * force);
}
}
}

编辑器工作流

编译与热重载

  脚本编译由 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、路径和绑定函数,但对用户来说理想状态就是挂一个脚本、改几个字段、点播放能跑。从某种程度上来说麻烦是守恒的,用户的麻烦少了,引擎层的麻烦就多了,我愿称之为某种守恒律。