概述
引入 C# 主要是为了降低开发难度,特别是在战斗服部分,相比 Lua 脚本具有高性能、双端开发等优势。引擎主体仍由 C/C++ 实现,这与 Unity 的架构非常相似——C# 主要负责游戏逻辑开发(以战斗部分为主)。
C# 与 C/C++ 混合运行机制的基础概述参见:Http服务器-第十步加入基于Mono平台的C#脚本支持。StickEngine 简介详见:http://dreamyouxi.com:7129/blog/1203。
设计的核心思路是:C/C++ 负责引擎部分和大部分 GamePlay 框架(包括网络同步等),C# 负责部分逻辑编写,从而降低实现难度并提供双端开发的可能性。API 设计理念向 Unity 靠拢,以便开发人员快速上手。在这套体系中,C# 扮演的角色更接近脚本语言。
在组织结构上,共有两个 C# 编写的 DLL 和一个 runtime:一个是引擎层的 StickEngine.dll,另一个是游戏逻辑层的 Scripting.dll。引擎层 DLL 与 C/C++ 版本紧密耦合,GamePlay 开发人员只需编写 Scripting.dll 即可。客户端同理,对 GamePlay 开发人员而言,StickEngine.dll 提供的 API 完全一致——区别仅在于 runtime:服务端运行在 C/C++ 编写的二进制之上,客户端则运行在 Unity 环境中。当然,也可以在 Unreal Engine 之上支持 StickEngine.dll。
下文将通过内存管理模型、C# 与 C/C++ 交互、序列化、物理引擎、并发模型、异步编程、性能差异分析、异常处理等方面,阐述 StickEngine 中的混合编程范式。
内存管理模型
C/C++ 有多种内存管理机制,例如引用计数和标准库提供的智能指针;C# 则是带 GC 的语言。因此,混合编程中的内存管理问题主要集中在 C/C++ 与 C# 之间的引用关系上。以下列出两种方案,二者可相辅相成,也可分别应用于不同场景。
方案 A:利用 Mono 提供的 GC Handle 机制。由 C/C++ 负责持有和释放引用,主动通知 C# GC 收集器可以执行的动作。
方案 B:在 C# 层以 Map 的形式维护 C# 代码与其域内引用的映射。C/C++ 析构时,从 Map 中移除对应项,从而删除 C# 对象。
C# 与 C/C++ 交互
在 Lua 中,与 C/C++ 的交互通过 Lua C-API 提供的"栈"进行;在 Mono 下,一般通过 P/Invoke 实现。
C/C++ 侧:
C# 侧:
物理引擎
引入物理引擎基本上有两种方式:一是在 C/C++ 层实现后提供 high-level API 给 C# 使用;二是将 Box2D、PhysX 等物理引擎通过 P/Invoke 直接暴露给 C#。在 StickEngine 体系下,采用的是方式一,即 C/C++ 提供 API 供 C# 调用。以下给出一个示例说明使用方法。
C/C++ 侧:
C# 侧:
序列化
这里实现了类似 Unity Prefab 的概念,用于序列化操作和对象存储(C/C++ 层的序列化不在本文讨论范围内)。具体方式是利用反射和深拷贝完成反序列化与对象快速生成:根据反射信息逐字段解析 C# class,并将结果缓存到内存中。以 Prefab 为原型生成对象时,直接进行深拷贝,流程得以简化。当然,客户端内存有限,可能不会进行全量缓存。
C# 使用形式:
并发模型
在客户端,逻辑线程目前只有一个,因此不涉及并发模型;这里所说的并发模型是指服务端场景。与 Lua 类似,每个线程甚至每个房间单独开启一个虚拟机,以实现环境隔离和沙箱机制。Mono 下有类似概念,称为 domain。这种方式存在一定代价,例如不同 domain 之间会有重复的内存占用。但既然是沙箱环境且部署在服务器,问题并不严峻。如有需要,可以考虑为 Mono 的 domain 添加 share 机制,以减少相关开销。
异步模型
在 C/C++ 中,最简单的异步编程模型是基于回调(callback)的,也可以使用协程等其他模型。C# 内置了 await、yield 等关键字,极大地增强了 GamePlay 层的异步编程能力,使 C/C++ 与 C# 之间、C# 与 C# 之间的异步编程(例如网络通信逻辑流程)成为可能。Unity 提供了一套简易的协程机制(StartCoroutine),而在 StickEngine 中则另起炉灶。以下示例说明基本用法和原理。
运行结果如下:
性能差异简要分析
C# 虽是编译型语言,但其基本运行方式与 Java 类似——生成中间语言 IL。Mono 提供两种执行模式:JIT 和 AOT。除此之外,还需考虑与纯 C/C++ 实现 GamePlay 代码相比的性能差异。引入 C# 后,C/C++ 与 C# 之间的交互会带来诸多额外代价,例如内存至少需要双重拷贝、box/unbox 开销,以及托管代码与非托管代码之间的转换。以下通过几个小测试来量化 C# 引入的代价。
下面是 C/C++ 实现移动逻辑,耗时:33 ms
下面是 C# 实现的位移逻辑,耗时:36 ms
C/C++:
C#:
上述两个对比测试使用 Mono 默认的 JIT 模式。在该典型 GamePlay 代码案例中,引入 C# 的代价相当小,损耗约为 10%。切换为 AOT 模式后,理论上代价将进一步降低。需要注意的是,上述案例属于典型 GamePlay 代码;在 box/unbox、P/Invoke 调用密集的场景下,损耗占比将会更高。因此,合理的结构设计对于降低性能开销至关重要。
异常处理
在 Mono 的 C-API 中没有异常的概念,成功与否通过 C/C++ 编程中常规的错误码来标识。在 C/C++ 调用 C# 代码时,异常处理其实相当简单,如下面的示例所示。
输出将包含异常信息。不过异常栈中没有显示源代码行数等详细信息,这可能是因为未加载 PDB 信息所致。
总结
基于上述基础操作,在 StickEngine 体系下实现了客户端与服务端统一编程范式的双端开发:高性能的 C/C++ 引擎底座,加上 C# 语言在编程表达上的赋能,让这一切成为可能。