Unigine整合Myra UI Library全纪录(1)

什么是Myra?

这个是Myra:https://github.com/rds1983/Myra

熟悉WPF/Avalonia/Silverlight/UWP开发的朋友肯定一眼就能看出来这个UI库用的是什么佐料了。它当然不是完整的WPF实现,对于游戏开发而言也没必要用完整的WPF,太重了。

Myra有着和WPF非常类似的Layout系统,运行效率也不错,同时还支持XML声明(被称为MML,Myra Markup Language),既可以实时加载XML也可以用MyraPad将这些XML转换成C#代码。整合进引擎里也不麻烦。这就够了。

这玩意儿甚至FileDialog和ColorPickerDialog都给你做了一份,省了大事了🤣

第一步:先学再用

本文写于Myra 1.5.9版本。

首先建议把Github上Myra的Wiki都看一遍,大概了解一下Myra是怎么玩的。之后我建议找个空文件夹git clone https://github.com/rds1983/Myra.git,然后打开Myra\build\Myra.PlatformAgnostic.sln,你会看到几个项目,其中在Samples文件夹下面有这三个示例项目:

  • Samples/Myra.Samples.PlatformAgnostic:用MonoGame手动整合,不使用自带的整合方式,而是手动从接口继承并实现所有接口功能。如果你要整合的目标引擎里面有Xna的SpriteBatch类似物,那么直接照着这个例子去做就够了。
  • Samples/Myra.Samples.Silk.NET用Silk.NET + OpenGL整合,是最底层的整合方式。虽然窗口框架还是Silk.NET提供的,不过其负责渲染的QuadBatch.cs完全是手写。如果你要整合的目标引擎里面只提供了最基础的Mesh渲染方式(比如Unigine),那么就要参考这个例子来做。
  • Samples/Myra.Samples.Silk.NET.TrippyGL仍然是Silk.NET + OpenGL整合,但使用TrippyGL简化了代码。TrippyGL提供了一个SpriteBatch类似物,叫TextureBatcher,因此这个整合过程和手动MonoGame整合很类似。其实可以跳过不看。

最后是Myra.PlatformAgnostic,Myra主项目,代码虽然很多但整理的很有序,想看可以钻进去看,但目前先不走那么深。

接下来建议去这里:https://github.com/unigine-engine/unigine-imgui-csharp-integration-sample

这个例子是Unigine整合ImGui.NET的例子,其中ImGuiImpl.cs是整个实现过程。别被这看上去乱七八糟的文件吓到,其实它内容还是挺简单的,只是把几个不同的模块全写在了一个类里面。好孩子不要这么做哦!

向Unigine整合新的GUI系统,绝大部分内容都可以参考这个ImGui.NET的实现方式。后文我也会多次提到这个东西。

第二步:准备工作

Myra自带有MonoGame、FNA和Stride的整合,同时还有PlatformAgnostic包用来应付其他的情况。我们当然要用这个包,给项目加上Myra支持很简单,毕竟这是Unigine😁:

复制代码
dotnet add package Myra.PlatformAgnostic

然后在source文件夹下建个新的文件夹,就叫MyraIntegration好了。

整合Myra到Unigine,包括整合到其他所有引擎,大概有这么几步工作:

  1. 实现接口ITexture2DManager,实现对纹理的创建和属性获取。
  2. 实现接口IMyraRenderer,实现窗口Scissor的设置和纹理的绘制
  3. 实现接口IMyraPlatform,向Myra提供窗口、键盘、鼠标和触屏的信息。目前我们暂时不管触屏。
  4. 最后,将上述实现提供给MyraEnvironment.Platform,再创建一个Myra.Graphics2D.UI.Desktop对象,将Desktop.Root设置成UI控件的实现,最后通过Desktop.Render()渲染出结果。

那么,接下来一个一个的处理:

ITexture2DManager

Unigine创建纹理要分两步走,注意创建为RGBA8格式,Usage要加上Dynamic,并且设置为Point Filter:

csharp 复制代码
object ITexture2DManager.CreateTexture(int width, int height)
{
	var texture = new Texture();
	texture.Create2D(width, height, Texture.FORMAT_RGBA8, Texture.FORMAT_USAGE_DYNAMIC | Texture.SAMPLER_FILTER_POINT);
	return texture;
}

Myra有个功能是Smooth Font,需要将纹理Filter设置为Bilinear。这个功能并不是指定渲染的字体是否有抗锯齿(抗锯齿是一直启用的),而是在UI发生缩放的时候是否对渲染出来的文字做平滑化。目前我们先不管这个。

之后要告诉Myra纹理的尺寸,毕竟传给Myra的是一个object而没有其他的信息:

csharp 复制代码
Point ITexture2DManager.GetTextureSize(object obj)
{
	var texture = (Texture)obj;
	return new Point(texture.GetWidth(), texture.GetHeight());
}

接下来要将图像数据传递给纹理:

csharp 复制代码
void ITexture2DManager.SetTextureData(object obj, Rectangle bounds, byte[] data)
{
	using var image = new Image();
	image.Create2D(bounds.Width, bounds.Height, Image.FORMAT_RGBA8, 1, false);
	image.SetPixels(data);

	var texture = (Texture)obj;
	texture.SetImage2D(image, bounds.X, bounds.Y);

	image.SetPixels((byte[])null!);
}

Unigine没有类似OpenGL的glTexSubImage2D,不能直接往纹理上写数据,需要创建一个Image对象然后拷贝过去。

创建的Image对象也得是RGBA8格式,和纹理保持一致。不需要Mipmap,并将clear参数设置成false,毕竟马上就要用数据写满整个Image。

后面就很好理解了,将Image传递给Texture进行数据上传。接下来这一行image.SetPixels((byte[])null!)不是C#里常见的操作:将Image的缓冲区设置为null。这一点和Unigine的C++底层实现有关,它的C++底层会直接拿data的指针去用,而不进行数据拷贝。因此在最后Image.Dispose的时候会报错。因此这里要设置为空。

这个古怪的设计卡了我好一段时间,直到我仔细翻阅了ImGui.NET的实现才搞明白。你可以在ImGuiImpl.cs的create_font_texture()函数里找到类似的东西。示例里使用了一个Blob进行中转,因为示例从ImGui获取的数据是RGBA32格式的,需要多一个步骤转换成RGBA8。Myra这边数据格式是相同的因此可以省略这一步。

IMyraPlatform

Renderer牵扯的东西多一些,先来搞Platform。

Myra需要知道渲染窗口的大小,也就是Unigine的ClientRenderSize:

csharp 复制代码
Point IMyraPlatform.ViewSize
{
	get {
		var clientRenderSize = WindowManager.MainWindow.ClientRenderSize;
		return new Point(clientRenderSize.x, clientRenderSize.y);
	}
}

之后实现向Myra提供鼠标信息的接口:

csharp 复制代码
int mouseWheelValue;

MouseInfo IMyraPlatform.GetMouseInfo()
{
	var position = Input.MousePosition - WindowManager.MainWindow.ClientPosition;
	mouseWheelValue += Input.MouseWheel;
	return new MouseInfo {
		IsLeftButtonDown = Input.IsMouseButtonPressed(Input.MOUSE_BUTTON.LEFT),
		IsRightButtonDown = Input.IsMouseButtonPressed(Input.MOUSE_BUTTON.RIGHT),
		IsMiddleButtonDown = Input.IsMouseButtonPressed(Input.MOUSE_BUTTON.MIDDLE),
		Position = new Point(position.x, position.y),
		Wheel = mouseWheelValue,
	};
}

有两点要注意。第一点是这里要使用Input.IsMouseButtonPressed而不是Input.IsMouseButtonDown,后者返回的是当前帧内鼠标按键是否有被按下过。另一点是鼠标滚轮数据,Myra需要的是累计后的绝对值(Xna的处理方式)而不是常见的相对值,因此这里定义了一个mouseWheelValue变量将历史数据累加起来再传递给Myra。

接下来需要向Myra提供键盘信息。由于Myra的Keys值和Unigine的不一样(Myra用的是Xna的值,也就是Windows平台的值,Unigine使用了一套自己的东西),因此需要创建一个映射表:

csharp 复制代码
readonly Keys[] UnigineToMyraKeyMap = new Keys[(int)Input.KEY.NUM_KEYS];

void GenerateMyraKeyMap()
{
	UnigineToMyraKeyMap[(int)Input.KEY.ESC] = Keys.Escape;
	UnigineToMyraKeyMap[(int)Input.KEY.F1] = Keys.F1;
	UnigineToMyraKeyMap[(int)Input.KEY.F2] = Keys.F2;
	UnigineToMyraKeyMap[(int)Input.KEY.F3] = Keys.F3;
	UnigineToMyraKeyMap[(int)Input.KEY.F4] = Keys.F4;
	UnigineToMyraKeyMap[(int)Input.KEY.F5] = Keys.F5;
	UnigineToMyraKeyMap[(int)Input.KEY.F6] = Keys.F6;
	UnigineToMyraKeyMap[(int)Input.KEY.F7] = Keys.F7;
	UnigineToMyraKeyMap[(int)Input.KEY.F8] = Keys.F8;
	UnigineToMyraKeyMap[(int)Input.KEY.F9] = Keys.F9;
	UnigineToMyraKeyMap[(int)Input.KEY.F10] = Keys.F10;
	UnigineToMyraKeyMap[(int)Input.KEY.F11] = Keys.F11;
	UnigineToMyraKeyMap[(int)Input.KEY.F12] = Keys.F12;
	UnigineToMyraKeyMap[(int)Input.KEY.PRINTSCREEN] = Keys.PrintScreen;
	UnigineToMyraKeyMap[(int)Input.KEY.SCROLL_LOCK] = Keys.Scroll;
	UnigineToMyraKeyMap[(int)Input.KEY.PAUSE] = Keys.Pause;
	UnigineToMyraKeyMap[(int)Input.KEY.BACK_QUOTE] = Keys.OemTilde;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_1] = Keys.D1;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_2] = Keys.D2;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_3] = Keys.D3;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_4] = Keys.D4;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_5] = Keys.D5;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_6] = Keys.D6;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_7] = Keys.D7;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_8] = Keys.D8;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_9] = Keys.D9;
	UnigineToMyraKeyMap[(int)Input.KEY.DIGIT_0] = Keys.D0;
	UnigineToMyraKeyMap[(int)Input.KEY.MINUS] = Keys.OemMinus;
	UnigineToMyraKeyMap[(int)Input.KEY.EQUALS] = Keys.OemPlus;
	UnigineToMyraKeyMap[(int)Input.KEY.BACKSPACE] = Keys.Back;
	UnigineToMyraKeyMap[(int)Input.KEY.TAB] = Keys.Tab;
	UnigineToMyraKeyMap[(int)Input.KEY.Q] = Keys.Q;
	UnigineToMyraKeyMap[(int)Input.KEY.W] = Keys.W;
	UnigineToMyraKeyMap[(int)Input.KEY.E] = Keys.E;
	UnigineToMyraKeyMap[(int)Input.KEY.R] = Keys.R;
	UnigineToMyraKeyMap[(int)Input.KEY.T] = Keys.T;
	UnigineToMyraKeyMap[(int)Input.KEY.Y] = Keys.Y;
	UnigineToMyraKeyMap[(int)Input.KEY.U] = Keys.U;
	UnigineToMyraKeyMap[(int)Input.KEY.I] = Keys.I;
	UnigineToMyraKeyMap[(int)Input.KEY.O] = Keys.O;
	UnigineToMyraKeyMap[(int)Input.KEY.P] = Keys.P;
	UnigineToMyraKeyMap[(int)Input.KEY.LEFT_BRACKET] = Keys.OemOpenBrackets;
	UnigineToMyraKeyMap[(int)Input.KEY.RIGHT_BRACKET] = Keys.OemCloseBrackets;
	UnigineToMyraKeyMap[(int)Input.KEY.ENTER] = Keys.Enter;
	UnigineToMyraKeyMap[(int)Input.KEY.CAPS_LOCK] = Keys.CapsLock;
	UnigineToMyraKeyMap[(int)Input.KEY.A] = Keys.A;
	UnigineToMyraKeyMap[(int)Input.KEY.S] = Keys.S;
	UnigineToMyraKeyMap[(int)Input.KEY.D] = Keys.D;
	UnigineToMyraKeyMap[(int)Input.KEY.F] = Keys.F;
	UnigineToMyraKeyMap[(int)Input.KEY.G] = Keys.G;
	UnigineToMyraKeyMap[(int)Input.KEY.H] = Keys.H;
	UnigineToMyraKeyMap[(int)Input.KEY.J] = Keys.J;
	UnigineToMyraKeyMap[(int)Input.KEY.K] = Keys.K;
	UnigineToMyraKeyMap[(int)Input.KEY.L] = Keys.L;
	UnigineToMyraKeyMap[(int)Input.KEY.SEMICOLON] = Keys.OemSemicolon;
	UnigineToMyraKeyMap[(int)Input.KEY.QUOTE] = Keys.OemQuotes;
	UnigineToMyraKeyMap[(int)Input.KEY.BACK_SLASH] = Keys.OemBackslash;
	UnigineToMyraKeyMap[(int)Input.KEY.LEFT_SHIFT] = Keys.LeftShift;
	UnigineToMyraKeyMap[(int)Input.KEY.LESS] = Keys.Apps;
	UnigineToMyraKeyMap[(int)Input.KEY.Z] = Keys.Z;
	UnigineToMyraKeyMap[(int)Input.KEY.X] = Keys.X;
	UnigineToMyraKeyMap[(int)Input.KEY.C] = Keys.C;
	UnigineToMyraKeyMap[(int)Input.KEY.V] = Keys.V;
	UnigineToMyraKeyMap[(int)Input.KEY.B] = Keys.B;
	UnigineToMyraKeyMap[(int)Input.KEY.N] = Keys.N;
	UnigineToMyraKeyMap[(int)Input.KEY.M] = Keys.M;
	UnigineToMyraKeyMap[(int)Input.KEY.COMMA] = Keys.OemComma;
	UnigineToMyraKeyMap[(int)Input.KEY.DOT] = Keys.OemPeriod;
	UnigineToMyraKeyMap[(int)Input.KEY.SLASH] = Keys.OemQuestion;
	UnigineToMyraKeyMap[(int)Input.KEY.RIGHT_SHIFT] = Keys.RightShift;
	UnigineToMyraKeyMap[(int)Input.KEY.LEFT_CTRL] = Keys.LeftControl;
	UnigineToMyraKeyMap[(int)Input.KEY.LEFT_CMD] = Keys.LeftWindows;
	UnigineToMyraKeyMap[(int)Input.KEY.LEFT_ALT] = Keys.LeftAlt;
	UnigineToMyraKeyMap[(int)Input.KEY.SPACE] = Keys.Space;
	UnigineToMyraKeyMap[(int)Input.KEY.RIGHT_ALT] = Keys.RightAlt;
	UnigineToMyraKeyMap[(int)Input.KEY.RIGHT_CMD] = Keys.RightWindows;
	UnigineToMyraKeyMap[(int)Input.KEY.MENU] = Keys.None;
	UnigineToMyraKeyMap[(int)Input.KEY.RIGHT_CTRL] = Keys.RightControl;
	UnigineToMyraKeyMap[(int)Input.KEY.INSERT] = Keys.Insert;
	UnigineToMyraKeyMap[(int)Input.KEY.DELETE] = Keys.Delete;
	UnigineToMyraKeyMap[(int)Input.KEY.HOME] = Keys.Home;
	UnigineToMyraKeyMap[(int)Input.KEY.END] = Keys.End;
	UnigineToMyraKeyMap[(int)Input.KEY.PGUP] = Keys.PageUp;
	UnigineToMyraKeyMap[(int)Input.KEY.PGDOWN] = Keys.PageDown;
	UnigineToMyraKeyMap[(int)Input.KEY.UP] = Keys.Up;
	UnigineToMyraKeyMap[(int)Input.KEY.LEFT] = Keys.Left;
	UnigineToMyraKeyMap[(int)Input.KEY.DOWN] = Keys.Down;
	UnigineToMyraKeyMap[(int)Input.KEY.RIGHT] = Keys.Right;
	UnigineToMyraKeyMap[(int)Input.KEY.NUM_LOCK] = Keys.NumLock;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIVIDE] = Keys.Divide;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_MULTIPLY] = Keys.Multiply;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_MINUS] = Keys.Subtract;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_7] = Keys.NumPad7;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_8] = Keys.NumPad8;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_9] = Keys.NumPad9;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_PLUS] = Keys.Add;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_4] = Keys.NumPad4;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_5] = Keys.NumPad5;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_6] = Keys.NumPad6;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_1] = Keys.NumPad1;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_2] = Keys.NumPad2;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_3] = Keys.NumPad3;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_ENTER] = Keys.Enter;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DIGIT_0] = Keys.NumPad0;
	UnigineToMyraKeyMap[(int)Input.KEY.NUMPAD_DOT] = Keys.Decimal;
}

有了这个映射表之后,向Myra提供键盘信息就很简单了:

csharp 复制代码
void IMyraPlatform.SetKeysDown(bool[] keys)
{
	for (int key = 0; key < (int)Input.KEY.NUM_KEYS; ++key) {
		var myraKey = UnigineToMyraKeyMap[key];
		keys[(int)myraKey] = Input.IsKeyDown((Input.KEY)key);
	}
}

和鼠标那边不同,长按一个键盘按键的时候,Input.IsKeyDown会多次触发,因此可以实现长按按键连续输入的效果。

剩下的两个:

csharp 复制代码
void IMyraPlatform.SetMouseCursorType(MouseCursorType mouseCursorType)
{
	//TODO: Use game's custom cursor with Input.SetMouseCursorCustom()
}

TouchCollection IMyraPlatform.GetTouchState()
{
	return TouchCollection.Empty;
}

SetMouseCursorType这里,根据传进来的MouseCursorType,用Input.SetMouseCursorCustom()设置成游戏自定义的光标即可。目前先略过。

GetTouchState这里,可以先忽略。Unigine是有触控处理的API的,就在Input里面,想实现也可以实现,不过目前Unigine不支持移动平台,忽略掉也不会有太大的问题。