Emit
Z# 提供了一种将托管程序集元数据转换为虚幻类型系统元数据(反射信息)的机制,这个机制被称为 Emit
。
Emit 机制让开发者能在托管代码中定义 UClass、UFunction、UProperty 等内容,是 Z# 主推的扩展引擎的方式。
正是这个能力,使得 Z# 不需要依托 U++ 或蓝图就能和虚幻引擎最核心的 UObject 系统进行深层次的交互,从而成为绝大多数场景下的首选。
由 Emit 机制生成的反射信息会在程序集绑定的 C++ 模块加载时动态添加到引擎中。 这个时间点恰好在该 C++ 模块的反射信息注册到引擎之后,中间没有其他流程。
Emit 这一名称来源于 System.Reflection.Emit,后者是动态生成 .NET 元数据的机制,而前者是动态生成虚幻元数据。
Emit 生成的元数据与 UHT 生成的元数据之间存在严格的对应关系,这意味着任何 Emit 代码都可以翻译为等价的 U++ 代码,两份代码生成的元数据在运行时无法通过任何引擎原生的机制区分。
ZeroGames.ZSharp.Emit
程序集定义了一套与虚幻反射标识符相匹配的特性,使用这些特性,你可以将一个托管类型标记为虚幻反射类型,并添加各种修饰性的描述。
当引擎加载你的 C++ 模块时,会自动扫描对应的托管程序集,将其中定义的虚幻反射类型加载进内存中。
为了方便,我们将 Emit 生成的虚幻反射类型称为动态类型,动态类型的 共轭类型 称为动态共轭类型。 在不严谨的情况下,我们可以将动态类型和其共轭类型都称为动态类型。 假设你在脚本中定义了一个 MyActor 类,那么:
- MyActor 对应一个脚本类,这个类严格上来说是动态共轭类型,但大多数时候我们会直接称他为动态 类型。
- MyActor 对应一个 UClass,这个类才是严格意义上的动态类型。
UClass
定义 UClass 需要使用 [UClass]
特性。
这个特性的作用等同于 UCLASS()
宏,将它添加到你的类型上,就定义了一个 UClass。
可以继续向其添加 [Blueprintable]
特性,来将其转变为一个蓝图可继承的类。
如果你只希望这个类型对蓝图可见,但不希望被继承,可以使用 [BlueprintType]
特性。
下面的示例代码定义了一个 Actor 子类:
[UClass, Blueprintable]
public partial class MyActor : Actor;
[UClass]
特性对目标类型有一些要求:
- 必须是 class。
- 必须是 partial,因为 Z# 的静态分析器需要为这个类型生成额外的模板代码(相当于 UHT)。
- 不能是 static,也不能是 abstract,因为 UClass 至少有一个实例 (CDO)。
- 必须直接或间接继承
ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealObject
。 - 命名空间必须是
<类型所在的程序集名>.<类型所在的 C++ 模块名>
,这决定了类型将会被加载到哪个 Package 中。 例如:你的程序集是ZeroGames.Game
,C++ 模块名是MyGame
,那么命名空间就必须是ZeroGames.Game.MyGame
,对应的 UClass 对象会被加载到/Script/MyGame
包中。
如果目标类型不满足以上条件,则无法通过编译。 对于最后一个条件,分析器只能判断 C++ 模块名的标识符是否有效,无法判断存在性,这一点需要开发者自己保证。
如果你没有为 UClass 显式指定基类,则目标类型会默认继承 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealObject 而不是 System.Object。
构造方法和终结器
你可以在 UClass 中定义两种类型的构造方法——Red Constructor
和 UClass Constructor
。
Red Constructor
Red Constructor 是只接受一个 IntPtr 参数的构造方法。 当 Z# 构建红色共轭时,会调用这个构造方法创建共轭对象。
对于动态的 UClass,C++ 的 Class Constructor
内部会显式构建红色共轭,因此 Red Constructor 会在对象实例化时立即调用。
在允许 Master ALC 热重载的环境下,每次热重载,都会遍历内存中所有已存在的动态 UClass 实例,构建红色共轭。 这意味着 Red Constructor 可能会在同一个 UClass 实例的生命周期中执行多次。
建议只在 Red Constructor 中对托管侧的状态进行初始化。 你当然也可以对非托管侧的状态进行初始化 ,但这在多数情况下可能与你预期的行为并不一致。
UClass Constructor
UClass Constructor 是一个满足以下所有条件的实例方法:
- 标记了
[UClassConstructor]
特性。 - 返回 void,且不接受参数。
在同一个类中,最多只能有一个 UClass Constructor。如果方法不满足上述条件,或者有多个满足条件的方法,则无法通过编译。
C++ 的 Class Constructor
会自动从父类到子类依次调用 UClass Constructor,因此你不需要手动调用父类,把方法声明为 private 比较合理。
UClass Constructor 在同一个 UClass 实例的生命周期中只执行一次,建议在 UClass Constructor 中对非托管侧的状态进行实例化。
PostInitProperties
UnrealObject
类中有一个 PostInitProperties()
虚方法,这个方法只会在动态类型实例化时调用,调用时机在 UObject::PostInitProperties()
之前。
由于 UProperty 的初始化在 C++ 构造函数之后,因此你在构造函数中初始化的 UProperty 会被 CDO 覆盖。
如果你要给 UProperty 指定一个不同于 CDO 的默认值,应该重写这个方法。
终结器
你可以在 UClass 中定义终结器,垃圾回收器会正确调用终结器。
但需要注意的是,终结器并不在 Game Thread
执行,因此不能在这里与引擎进行任何互操作。
字段和自动属性
UClass 可以在托管侧持有状态,也就是可以定义字段和自动属性。 这一点也决定了共轭对象不是单纯的代理对象,而是与原对象协同工作的“另一半”。
需要注意的是,在有热重载的环境下,每当 Master ALC 重启,所有已经存在的共轭对象都会被销毁。 当它们再次创建时,所有托管状态都会被重置。 对于托管状态不可变的对象而言,重新初始化这些状态就可以解决问题;对于那些可变的对象,热重载之后可能无法恢复到正确的状态。 开发者需要谨记这一点,使用托管状态时需要考虑热重载。
实际上,C++ 类型导出的共轭类型也是分部类型,也可以持有状态。开发者完全可以这样做,但这在 Z# 中被认为是一种反模式,因为它属于侵入式扩展。 对于导出的共轭类型,其 C++ 部分已经是一个逻辑完备的整体,不应该再以外挂的方式去强行扩展。
UStruct
定义 UStruct 需要使用 [UStruct]
特性。
这个特性的作用等同于 USTRUCT()
宏,将它添加到你的类型上,就定义了一个 UStruct。
可以继续向其添加 [BlueprintType]
特性,来将其转变为一个对蓝图可见的结构。
下面的示例代码定义了一个 UStruct:
[UStruct, BlueprintType]
public partial class MyStruct;
[UStruct]
特性对目标类型的要求和 [UClass]
特性一样,但直接或间接继承的基类变为了 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealScriptStructBase
。
如果你没有为 UStruct 显式指定基类,则目标类型会默认继承 ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealScriptStructBase 而不是 System.Object。
构造方法
静态分析器会为每个 UStruct 生成无参构造方法,你可以定义有参构造方法,但所有有参构造方法都必须调用无参构造方法,因为它确保共轭映射正确建立。 如果自定义的有参构造方法没有调用无参构造方法,则无法通过编译。
如果你希望给 UProperty 指定默认值,应该使用 [UProperty] 特性中的 Default 属性或者 [PropertyDefaultOverride] 特性。 如果你仅通过某个构造方法进行初始化,那么只有在你显式调用它的时候才会有默认值,而其他时候(比如在蓝图中构造)无法正确设置默认值。 这是因为构造 UStruct 实例的时候不会立即构建共轭,而是会等到第一次跨边界时才构建,这就导致托管构造方法不会立即执行。
NetSerialize
你可以在 UStruct 中定义 NetSerialize()
方法来实现自定义的网络序列化。
下面的示例代码定义了一个用于同步普通 .NET 对象的方法:
public class MyData
{
public uint64 Id;
public Dictionary<string, int32> Data;
}
[UStruct]
public partial class MyReplicationProxy
{
[NetSerialize]
private bool NetSerialize(IArchive ar, PackageMap map, out bool success)
{
if (Owner is not { IsExpired: false })
{
success = false;
return false;
}
if (ar.IsSaving)
{
ar.Serialize(JsonSerializer.Serialize(Owner.Data));
}
else if (ar.IsLoading)
{
ar.Serialize(out var data);
Owner.Data = JsonSerializer.Deserialize<MyData>(data);
}
else
{
checkNoEntry();
}
success = true;
return true;
}
[UProperty, NotReplicated]
public partial UnrealObject? Owner { get; set; }
}
[UClass]
public partial class MyObject
{
public MyData Data { get; set;}
protected override void PostInitProperties()
{
RepProxy.Owner = this;
}
[UProperty, Replicated]
private partial MyReplicationProxy RepProxy { get; set; }
}
实际上,支持一般的序列化乃至其他 StructOps
也不麻烦,但 NetSerialize 是最迫切需要的。
字段和自动属性
你可以在 UStruct 中定义字段和自动属性,但你一般不应该这样做,因为 UStruct 的引用非常不稳定,极有可能可能在你预期之外的地方被指向其他实例,从而丢失数据。
UEnum
定义 UEnum 需要使用 [UEnum]
特性。
这个特性的作用等同于 UENUM()
宏,将它添加到你的枚举类上,就定义了一个 UEnum。
可以继续向其添加 [BlueprintType]
特性,来将其转变为一个对蓝图可见的枚举。
可以继续向其添加 [Flags]
特性,来将其转变为一个位域。
下面的示例代码定义了一个 UEnum:
namespace Game.MyGameModule;
[UEnum, BlueprintType]
[UnrealFieldPath("/Script/MyGameModule.EMyEnum")]
public enum EMyEnum : uint8
{
[DisplayName("Value 1")] Value1,
[DisplayName("Value 2")] Value2,
[Hidden] Max,
}
[UEnum]
特性对目标类型有一些要求:
- 必须是 enum。
- 命名空间要求和 UClass 一样。
- 必须显式指定
[UnrealFieldPath]
特性,且参数是/Script/<类型所在的 C++ 模块 名>.<类名>
。可以直接使用 Codefix 自动补全。
理论上,可以通过 Rewrite 程序集来自动添加 [UnrealFieldPath] 特性,但这种方案的结果无法体现在源代码上,因此我们最终选择利用静态分析器来做这件事。
Underlying Type
UEnum 的 UnderlyingType
会被正确识别,该类型的 UProperty 会优先使用枚举的 UnderlyingType。
如果你的枚举要对蓝图可见,则 UnderlyingType 必须是 uint8;
如果一个枚举类型的 UProperty 要对蓝图可见,但枚举类型的 UnderlyingType 不是 uint8,则需要在属性上标记 [EnumAsByte]
特性来强行转换为 uint8。
如果不满足以上条件,则无法通过编译。
下面的示例代码将一个 ECollisionChannel : int32
类型的枚举属性转变为蓝图可见:
[UProperty, BlueprintReadWrite, EnumAsByte]
public partial ECollisionChannel CollisionChannel { get; set; }
UDelegate
定义 UDelegate 需要使用 [UDelegate]
特性。
这个特性的作用等同于 UDELEGATE()
宏,将它添加到你的类上,就定义了一个 UDelegate。
实际上,当你在 U++ 中定义 UDelegate 时,无论加不加 UDELEGATE()
宏,UHT 都能正确识别。
但对于 Z# 来说,需要区分导出的委托和定义的委托,因此必须标记 [UDelegate]
特性。
委托支持单播、多播、稀疏三种,根据基类的不同,Z# 会将其识别为对应类型的委托:
- 如果基类是
ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealDelegateBase
,则生成单播委托。 - 如果基类是
ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealMulticastInlineDelegateBase
,则生成多播委托。 - 如果基类是
ZeroGames.ZSharp.UnrealEngine.CoreUObject.UnrealMulticastSparseDelegateBase
,则生成稀疏委托。 - 如果基类不是以上三者中的任何一个,则无法通过编译。
定义 UDelegate 的方式与定义 .NET 委托的方式有区别,下面的示例代码定义了一个单播 UDelegate:
[UDelegate]
public partial class GetNameDelegate : UnrealDelegateBase
{
public delegate UnrealString Signature();
}
可以看到,UDelegate 并不直接对应委托类本身,而是它外层的 class。 这是因为 UDelegate 本身与 .NET 委托并不等价,实际上,我们是用一个 .NET 对象来表示 UDelegate 的共轭对象。 这个对象像其他共轭对象一样,负责与虚幻引擎的委托对象进行互操作。 定义一个 UDelegate,本质是在定义它的共轭类型,内部的 Signature 委托则是该委托的签名。
如果你定义的是一个多播委托或者稀疏委托,则返回值类型必须是 void,否则无法通过编译。
内部委托类
UDelegate 支持嵌套在 UClass 内部,以避免污染全局命名空间,Z# 同样支持这个特性。 下面的示例代码在 UClass 内部定义了一个多播 UDelegate(事件)。
[UClass]
public partial class MyActor : Actor
{
[UDelegate]
public partial class OnDamaged : UnrealMulticastInlineDelegateBase
{
public delegate void Signature(float damage);
}
}
UInterface
目前版本尚未支持在托管侧定义和实现 UInterface。
UFunction
可以在 UClass 中定义 UFunction,具体有以下几种情形。
普通函数
将 [UFunction]
特性添加到一个方法上,就定义了一个普通的 UFunction。
大多数情况下,普通的 UFunction 是作为暴露给蓝图的接口使用。
可以继续向其添加 [BlueprintCallable]
或 [BlueprintPure]
特性,来将其转变为一个对蓝图可见的函数。
以下示例代码定义了一个蓝图可以调用的函数:
[UFunction, BlueprintCallable]
public void Say(UnrealString? content)
{
UE_LOG(LogTemp, $"{GetName()} says: {content}");
}
蓝图事件函数 (Blueprint Event)
可以给 UFunction 添加 [BlueprintNativeEvent]
或 [BlueprintImplementableEvent]
特性来将其转变为一个蓝图事件函数(虚函数)。
大多数情况下,蓝图事件函数是作为暴露给蓝图的框架使用。蓝图为其提供实现,基类在某个流程中调用这个函数。
如果你使用 [BlueprintNativeEvent]
,就必须在托管代码中为其提供默认实现,同时托管代码中的子类也可以重写这个函数;
如果你使用 [BlueprintImplementableEvent]
,就只有蓝图子类可以为其提供实现,你和你的托管子类都不可以。
以下示例代码展示了 [BlueprintNativeEvent]
和 [BlueprintImplementableEvent]
的用法:
[UFunction, BlueprintNativeEvent]
public partial void PreAttributeChange(int32 attributeId, double oldValue, ref double newValue);
protected virtual partial void PreAttributeChange_Implementation(int32 attributeId, double oldValue, ref double newValue)
{
newValue = Math.Clamp(newValue, 0, 10000);
}
[UFunction, BlueprintImplementableEvent]
public partial void PostAttributeChange(int32 attributeId, double oldValue, double newValue);
蓝图事件函数必须是没有方法体的 partial 方法,因为 Z# 的静态分析器需要为其生成一些模板代码。
和 C++ 一样,你不应该为蓝图事件函数本体提供实现,因此它也不应该是 virtual 的。
如果你使用 [BlueprintNativeEvent]
,你应该定义一个与其签名完全一致并以 _Implementation
为后缀的 protected virtual 方法,并在这里提供默认实现。
和 C++ 一样,默认情况下,蓝图事件函数只能被蓝图重写,不能被蓝图调用。 你需要使用 [BlueprintCallable] 或 [BlueprintPure] 特性来将其转变为蓝图可调用。