将组件从 C++ 公开到 C#

本章节将告诉您将组件从 C++ 公开到 C# 所需遵循的所有步骤。

以下是向 C# 公开组件所需完成的步骤概要:

  1. 添加 ETOD_REGISTER_MANAGED_COMPONENT(MyCustomComponent);ScriptGlue::RegisterComponentTypes
  2. 通过在 Components.cs 文件中定义一个类来实现 C# 组件,并使其从 Component 中继承。
  3. 添加必要的内部调用,让 C# 组件与 C++ 组件和引擎系统进行交互。

请务必保持 ETOD_REGISTER_MANAGED_COMPONENT 与组件内部调用的顺序相同,因此如果您的内部调用定义在例如 ScriptGlue.h 文件中的 RigidBodyComponent 下,您应该在 ScriptGlue::RegisterComponentTypesScriptGlue::RegisterInternalCalls 中的 RigidBodyComponent 下注册您的组件和内部调用,这有助于保持文件代码结构的井然有序。

编写 C# 组件的方式在很大程度上取决于我们引擎的组件系统,但基本的实现原理如下:

  1. 您需要向 Components.cs 文件中添加一个继承自 Component 的类, 例如:public class MyComponent : Component
  2. 您需要添加调用必要内部方法的方法或属性。(有关如何执行此操作的相关信息,请参阅上一章内容)
  3. 任何需要与您的组件交互的内部方法都必须接收实体的 ID,您可以通过访问 Entity.ID 来从组件内部访问该 ID。
  4. 实体 ID 是 64 位无符号整数,其 C# 类型称为ulong,C++ 类型称为 uint64_t

关于 C# 中堆分配的概要说明

从一开始 EToD Engine 的核心脚本中就存在一个问题,是关于调用内部方法时的堆分配问题。我们不会深入探讨堆分配这个概念, 但在 C# 中,当您通过调 new MyClass() 创建类的新实例时,将导致该对象会被分配到堆上。

在 EToD Engine 的核心脚本中,我们经常在调用内部方法后返回一个类的新实例,例如下面是 MeshColliderComponent 的一个简单示例:


// 下面的示例代码并不是最优,因为每次我们调用 `MeshColliderComponent.ColliderMesh` 时,
// 我们每次都分配一个全新的 `Mesh` 实例,即使 Mesh 内部并没有发生任何的改变。
public Mesh ColliderMesh
{
	get
	{
		InternalCalls.MeshColliderComponent_GetColliderMesh(Entity.ID, out AssetHandle outMeshHandle);
		return new Mesh(outMeshHandle);
	}
}

// 这是实现此属性的更优的解决方案:
// 首先我们添加一个名为 m_ColliderMesh 的私有成员变量。
private Mesh m_ColliderMesh = null;

// 其次我们添加一个名为 ColliderMeshHandle 的 AssetHandle 属性,
// 这实际上是 Mesh 的句柄
public AssetHandle ColliderMeshHandle
{
	get
	{
		if (!InternalCalls.MeshColliderComponent_GetColliderMesh(Entity.ID, out AssetHandle colliderHandle))
			return AssetHandle.Invalid;
		return colliderHandle;
	}
}

// 最后,我们添加一个返回 Mesh 的方法。
public Mesh GetColliderMesh()
{
	// 这里我们确保内部存储的 Mesh 句柄仍然有效,
	// 如果不是则返回 null
	if (!ColliderMeshHandle.IsValid())
		return null;

	// 在这里,我们检查是否尚未设置 m_ColliderMesh 或者内部 Mesh 是否已更改,
	// 如果是我们将创建一个新的 Mesh 实例
	if (m_ColliderMesh == null || m_ColliderMesh.Handle != ColliderMeshHandle)
		m_ColliderMesh = new Mesh(ColliderMeshHandle);

	// 否则我们只返回 Mesh 实例的缓存版本
	return m_ColliderMesh;
}

我们使用以上的解决方案可能不会去提高性能,它甚至可能还会 稍微 增加一些性能开销(但是性能开销并不明显), 但是使用以上解决方案这确实 减少了 堆分配的数量。

那么 为什么 我们要避免堆分配呢?因为如果你每一帧都在堆上分配对象,它则很有可能会导致引擎的性能出现问题,同时它也会导致 C# 的垃圾收集器运行得更频繁。我们在此不会深入的解释垃圾收集器是什么,或者它为什么有用,有什么用。而我们需要着重要了解的部分是,C# 的垃圾收集器会清理未使用的 C# 对象,这样做会导致严重的帧丢失问题的产生。 所以我们要尽可能的阻止垃圾收集器的运行,但是又因为 C# 的设计它最终还是会运行起来。因此我们不可能完全阻止它运行,但同时我们又不想让它完全运行起来,所以我们只能想办法来延迟它的运行。

在 C# 中缓存对象并不总是最好的解决方案,除非你真的需要在 C# 中缓存,否则你不需要将对象的原始类型缓存在 C# 中,并且结构 通常 是分配在堆栈上(尽管它们有时可以分配在堆上)。

该选择属性还是方法?

您应该使用属性还是方法来与需要实现的相关功能的 C++ 端进行交互? 我们的回答是:要看情况。
如果你正在做的缓存会存在与上面的示例中相似的代码,那么你应该使用一个方法,而不是一个属性。

我们不会对此进行过多的详细介绍,因为它更像是一个类似 C# 代码书写风格的相关问题,而不是在做 EToD Engine 拓展研发时会遇到的特定的代码问题。