☰
Unigine集成Myra UI:离屏渲染与纹理合成实践
2026/9/28 8:10:58 网站建设 项目流程

先说清楚,这不是一篇介绍“怎么在 Unigine 里写 UI 控件”的文章,而是一篇“怎么把另一个 UI 框架的渲染结果,以纹理的形式塞进 Unigine 渲染管线”的文章。项目背景是一个训练模拟类应用,三维场景用 Unigine 负责,所有数据面板、参数列表、监控图表这类 HUD 走的是 Myra UI Library。选择 Myra 不是因为它比 Unigine 自带 UI 强,而是项目里的界面全部由策划用 JSON 定义,Unigine 原生的 widget 体系对这套数据驱动流程很不友好,改起来费劲,评估了一圈之后决定让 Myra 来管 2D 界面层。

这套方案的核心难题集中在渲染:Unigine 和 Myra 各有一套渲染上下文,资源互不共享,怎么让 Myra 的每一帧画面稳定地出现在 Unigine 屏幕上,还不能掉帧、不能变色、不能跟场景里的透明物体打架。这篇文章就把这条渲染链路完整拆开,从架构选型、纹理交换思路,到具体的代码落地和踩坑记录,适合正在做“引擎内嵌第二套 UI 框架”或者“跨引擎离屏渲染”的朋友参考。

1. 为什么偏要把 Myra 塞进 Unigine:渲染前端的一次被迫接线

先说选型这件事,因为后面所有渲染层的麻烦,本质上都是“两套 UI 系统硬凑在一起”造成的。如果项目从一开始就决定用 Unigine 自己做 HUD,那就没有这篇文章了。但实际需求摆在面前,你得先想清楚“为什么非它不可”,才有动力去解决后续一堆破事。

1.1 自带 UI 撑不起的数据驱动 HUD

Unigine 自带一套完整的 widget 体系,按钮、滚动条、列表、文本标签都有,应付工具型界面、调试面板、简单菜单完全够用。但我们的界面有个硬性要求:运行时允许策划通过修改 JSON 配置来调整布局、增删控件、换绑数据。这类场景在 Unigine 原生 UI 上做非常别扭,原生 UI 更适合手动代码创建控件树,而不是无中生有地把一份 JSON 解析成界面结构。

Myra 恰好是反向设计:它以 XML/JSON 描述控件树,布局系统也支持声明式配置,数据绑定虽然不是特别强,但配合事件回调足够支撑起复杂的监控面板。更关键的是,Myra 属于 MonoGame 生态,渲染目标是 RenderTarget2D,也就是它可以把整个 UI 画布离屏渲染到一张纹理上,而不是强占屏幕输出。这个特性让“把 UI 渲染结果交给另一个引擎”成为可能。

1.2 两套图形上下文,天然存在资源隔离

Unigine 是用自己的渲染器接管窗口的,MonoGame 也期望拿到窗口句柄创建一个 GraphicsDevice。如果谁都不让步,直接在同一个窗口上各自初始化,大概率会互相覆盖或者闪屏。这是所有跨 UI 集成里第一个要认清的问题:你不是在“加一个控件库”,你是在一个引擎进程里引入第二套图形系统。

Myra 的优势在于它不像普通 MonoGame 游戏那样必须走 Game 循环和 Draw 方法。你可以手动建立一个离屏 GraphicsDevice,让 Myra 在这上面完成所有绘制,再把结果纹理的内容搬出来。这样两个渲染上下文不是“同时往窗口上画”,而是“你画你离屏的,我再把你的画布贴到我的场景里”,互不抢窗口。后面所有方案都围绕这个思路展开。

1.3 渲染方案的几条候选路径

当时摆在我面前的选择大致有三条:

候选方案实现思路风险点
A. 像素回读上传Myra 渲染到 RenderTarget2D,CPU 读回像素数组,传给 Unigine 动态纹理实现简单,兼容性好,每帧有拷贝开销
B. D3D 共享纹理句柄利用 Windows D3D11 的 shared handle,让 Unigine 直接引用 Myra 的渲染目标零拷贝效率高,但绑定平台,且两个引擎对资源生命周期管理容易出问题
C. 完全放弃 Myra 渲染,只用它的逻辑层自己写一套 2D 图元生成逻辑,直接在 Unigine 里画工作量巨大,等于重写 UI 渲染器

最终我选了方案 A 作为主力路线,原因很直接:Unigine 的纹理接口对“外部传入内存数据再生成纹理”支持得比较干净,像素回读虽然听起来笨,但最不容易受驱动差异影响。方案 B 留到后期性能优化再考虑,后面会单独说。

2. 整条渲染链路的设计:从 Myra 画布到 Unigine 纹理的每一次搬运

确定了“离屏渲染 + 像素搬运”的大方向之后,剩下的就是数据流向和时序设计。这一步非常关键,因为两个渲染器各自有帧循环,你不能简单地说“每帧把 Myra 画出来贴上去”,还必须想清楚:谁先谁后、比率多少、尺寸怎么对齐、颜色空间怎么统一。

2.1 数据流:一个 2D 画布如何变成 Unigine 的材质贴图

从 Myra 到 Unigine,我的项目里链路是这样的:

  1. Myra 的 Desktop 通过控件树和布局器计算所有控件的位置、大小;
  2. Myra 调用 MonoGame 的 SpriteBatch 把所有控件绘制到一张 RenderTarget2D 上;
  3. 调用 RenderTarget2D.GetData(out Color[] pixels),将 GPU 纹理内容回读到 CPU 内存数组;
  4. 把像素数组拷贝到 Unigine 侧,通过 Texture.setImage 或者等价的“从内存创建纹理”接口,更新到一张动态纹理上;
  5. 这张动态纹理被应用到场景中的一个平面网格(或者作为全屏 Quad 的材质贴图)。

这个链路的关键在于第 3 步到第 4 步:数据从显存到内存,再从内存到显存,中间产生一次完整的 CPU 搬运。1080p 下 RGBA8 一帧大概 8MB 左右,听着不算大,但如果你每帧都这么干,又要保证毫秒级同步,问题就会放大。所以后面一定会引入“按需刷新”策略,这里先按下不表。

2.2 时序设计:让回读尽量不阻塞 Unigine 的帧

两个帧循环之间不能硬同步。Unigine 的渲染流程是它自己控制的,Myra 的 Desktop.Render() 如果你愿意,可以在任意时机调用。我采用的时序是:

  • Unigine 更新阶段:处理输入事件,转发给 Myra;
  • Unigine 渲染阶段前:调用 Myra 的 Update 和 Render,把 UI 画到 RenderTarget2D;
  • Unigine 渲染阶段中:把新像素更新到动态纹理,然后渲染承载 UI 的平面。

实际操作时,我把像素回读和纹理更新放在同一帧的尾部,也就是 Unigine 已经把承载 UI 的平面渲染完之后,再做下一次回读。这样避免 GPU 正在使用纹理时你强行改写数据,出现“一帧画面穿插上半帧新内容、下半帧旧内容”的割裂现象。

2.3 坐标映射:像素坐标系与屏幕坐标系的桥接

Myra 的世界本身是 2D 像素坐标,左上角是原点,向右向下为正。Unigine 的屏幕坐标处理习惯要看具体版本和坐标系设置,但最常用的方法不是去改 Unigine 的坐标规则,而是直接用一张平面网格匹配屏幕宽高比,让纹理的 UV 覆盖整个四边形。

这里有个容易忽略的点:Myra 的 RenderTarget2D 尺寸不一定要严格等于 Unigine 最终输出分辨率。如果你的 UI 设计稿是固定 1920×1080,而 Unigine 实际窗口是 2560×1440,你可以选择让 Myra 渲染一个更高分辨率的目标,也可以让 Myra 渲染 1920×1080 后在 Unigine 侧通过四边形大小拉伸。我建议让 Myra 的渲染分辨率等于 Unigine 主视口分辨率,并且通过监听视口变化事件来同步,这样文字边缘始终是最清晰的,不放大就基本没有模糊问题。

2.4 为什么不能直接让 Myra 覆盖到屏幕上

有人可能会问:既然只是想在 3D 场景上叠一层 2D UI,直接在 Unigine 窗口上用 GDI 或者覆盖窗口画不就行了?技术上确实有人这么干,但后果是两个渲染器各自绘制各自的,无法同步帧缓冲,全屏切换、DPI 变化、硬件加速合成时会出现各种诡异的时序问题。更重要的是,如果以后要在 VR 环境或者多输出环境里跑,这种覆盖方案基本作废。老老实实走纹理合成,才能让 UI 参与 Unigine 的场景管理和渲染队列。

3. 代码落地:初始化、按帧渲染、输入事件转发

链路清晰之后就是动手写代码。这里的代码不是“复制就能跑”的完整工程,而是我项目里沉淀下来的关键片段,你可以对照自己的 Unigine 版本和 Myra 版本做适配。

3.1 初始化:Myra 的离屏 GraphicsDevice 与 Desktop

Myra 需要 MonoGame 的 GraphicsDevice 才能完成所有绘制。你不能直接借助 Unigine 的设备,因为 Myra 内部访问了很多 MonoGame 资源类型,比如 RenderTarget2D、Texture2D、SpriteBatch。所以得创建一个不关联窗口的离屏设备。

我当时做的初始化顺序是这样:

// 创建离屏 GraphicsDevice,不绑定任何窗口 var graphicsDevice = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, GraphicsProfile.HiDef, new PresentationParameters() { BackBufferWidth = 1920, BackBufferHeight = 1080, BackBufferFormat = SurfaceFormat.Color, DepthStencilFormat = DepthFormat.Depth24Stencil8, DeviceWindowHandle = IntPtr.Zero, // 关键:不要绑定窗口 }); var desktop = new Desktop(); var myraRenderer = new MyraRenderer(graphicsDevice, desktop);

这里有两个注意点。第一,DeviceWindowHandle一定要保持空,否则它会尝试接管原生窗口,跟 Unigine 抢显示输出。第二,BackBufferWidth/Height实际上不会真正出现在屏幕上,它只影响 RenderTarget 的参考尺寸,所以后期要随 Unigine 主视口尺寸变化而修改,或者直接用 RenderTarget2D 的尺寸控制绘制区域。

Myra 的 Desktop 初始化完成后,就可以加载 UI 定义了。Myra 从 Project 对象加载,通常是一个 XML/JSON 文件,里面描述了一整棵控件树。

var project = Project.LoadFromXml("hud_main.xml"); desktop.Root = project.Root;

这一步不需要额外图形初始化,它只是构建控件树和布局。

3.2 Unigine 侧的动态纹理和 UI 平面

在 Unigine 里,我创建了一张动态纹理,专门用来接收 Myra 的像素数据。纹理格式选了 RGBA8,尺寸跟 Myra 的 RenderTarget 保持一致。

// 在 Unigine C# 绑定里创建动态纹理 Texture uiTexture = new Texture(); uiTexture.create(Texture.Format.RGBA8, width, height, Texture.UsageFlag.SAMPLE | Texture.UsageFlag.SCENE | Texture.UsageFlag.RENDER);

创建纹理之后,把它赋给一个材质。这个材质用在场景中的一个平面网格上。为了让 UI 平面不被场景光照影响,材质要选择 Unlit 类型的着色器,且关闭深度写入,让它始终在画面最上层。

Material uiMaterial = Materials.CreateMaterial("mesh_base"); uiMaterial.setTexture(0, uiTexture); // 或者按你的材质槽位设置 uiMaterial.setParameterFloat4("diffuse_color", new vec4(1, 1, 1, 1)); uiMaterial.setState(State.BLEND, true); uiMaterial.setState(State.DEPTH_TEST, false);

这里“关闭深度测试”不是让它永远最前,正确的做法是把 UI 平面单独放进一个视口或渲染队列中,用 Unigine 的排序机制保证它最后渲染。但实践中如果你的 UI 平面永远放在摄像机近裁剪面附近并且关掉深度写入,大部分情况就够用了。

3.3 按帧渲染:从 Myra 到 Unigine 的完整循环

每帧的处理流程我封装成了一个方法:

void UpdateUI(int mouseX, int mouseY, bool mouseLeftDown) { // 1. 把 Unigine 的鼠标事件转给 Myra myraRenderer.HandleMouse(mouseX, mouseY, mouseLeftDown); // 2. 让 Myra 更新布局和控件状态 desktop.Update(TimeSpan.FromSeconds(deltaTime)); // 3. 离屏渲染到 RenderTarget graphicsDevice.SetRenderTarget(uiRenderTarget); graphicsDevice.Clear(Color.Transparent); desktop.Render(); graphicsDevice.SetRenderTarget(null); // 4. 回读像素 Color[] pixels = new Color[width * height]; uiRenderTarget.GetData(pixels); // 5. 上传到 Unigine 动态纹理 byte[] rgba = ConvertColorToRgba(pixels); uiTexture.setImage(rgba, width, height); }

ConvertColorToRgba看起来多余,实际上非常必要。MonoGame 的Color结构内部是 RGBA 顺序,但在某些平台或者特定 SurfaceFormat 下回读的数据可能是 BGRA 或者其他顺序,你最好以实际回读结果为准写一次像素遍历。这一点在后面的踩坑记录里还会细说。

3.4 输入事件:Unigine 的鼠标键盘怎么转发给 Myra

Unigine 的输入系统有自己的抽象层,你要在 Update 回调里读取鼠标位置、滚轮、按键,然后转换成 Myra 能识别的输入参数。

Myra 的Desktop.HandleMouse接受的是 Myra 自己的 MouseInfo 对象,包含Position、LeftButton这些字段。从 Unigine 拿到的鼠标坐标通常是相对主视口的像素坐标,可以直接用。需要注意滚轮事件:Unigine 的滚轮值可能是整数步进或者浮动值,喂给 Myra 时通常要累加成连续值,否则滚动列表会一格一格跳得很生硬。

键盘事件则是通过控制焦点来处理的,一般只需要把按下和释放事件转发给 Desktop 的按键处理逻辑,让按钮快捷键和文本框输入能正常工作。

4. 排雷记录:颜色泛灰、通道顺序、透明渲染顺序

方案跑通是一回事,跑得正确是另一回事。这一节全部是实际调试过程中踩过的问题,每一个都对应着一段不愉快的排查经历,写出来帮大家少走弯路。

4.1 颜色空间不一致:UI 整体“泛灰”或者饱和度丢失

第一次把 Myra 的图像贴到 Unigine 场景里,我的第一反应是“界面怎么变灰了?”不是完全没颜色,而是颜色深度不对,红色不红,蓝色不蓝,整体像蒙了一层灰。

原因不复杂:Unigine 默认在线性色彩空间下做计算,而 Myra 生成的纹理内容是 sRGB 空间下的数值。你把 sRGB 数据直接当成线性数据采样,相当于所有中间调都被提亮或者压缩了,看起来就是发灰。而且 3D 场景在 Unigine 里经过正确的线性工作流后是正常的,唯独 UI 这块是外来数据,没有被 shader 里的 sRGB 转换处理。

解决思路有两种。第一种是在 Unigine 侧创建纹理时,选择支持 sRGB 通道的格式(如果引擎绑定支持),让采样器在硬件层面完成转换。第二种是手动在材质里给这张纹理加一个“幂律校正”,近似地把 sRGB 转回线性。我的项目里 Unigine 版本对 sRGB 纹理格式的支持比较稳定,所以采用了第一种,效果最干净。

4.2 红色和蓝色互换:回读通道顺序的坑

颜色泛灰修好之后,界面出现了更离谱的现象:红色按钮变成了蓝色,蓝色进度条变成了红色。这个 bug 属于经典的像素通道顺序问题。

MonoGame 的 RenderTarget2D 在不同 SurfaceFormat 下回读布局并不完全一致。我是按标准 RGBA 直接打包后传给 Unigine 的,但实际回读出来的字节流是 BGRA 顺序,导致整幅图像的 R 和 B 对调。解决办法有两个:要么在像素遍历时手动交换 R 和 B,要么干脆把回读数据按原始顺序塞给 Unigine,并调整纹理的 swizzle 或者材质贴图通道映射。我选了后者,因为少一次像素遍历,性能和代码量都更优。

4.3 UI 平面被场景物体遮挡或者半透明物体穿插

UI 平面设成不透明后,一开始是能正常叠在最顶层的。但一旦场景里出现半透明物体,比如玻璃、烟花粒子、云层之类的,就会发现 UI 被这些透明物体盖住了,或者跟它们产生混合,界面变得很脏。

原因在于 Unigine 的透明物体是后排序渲染的。你不关闭深度写入,透明物体在通过深度测试时会发现 UI 平面的深度值已经很近,按理说应该被挡住。可如果 UI 平面本身也带 alpha blend(为了处理 UI 阴影或者透明背景),它也会被纳入透明队列重新排序,顺序就可能跑到其他透明物体前面或后面。

我在项目里的做法是把 UI 平面从默认透明队列里拆出来,放进一个单独的渲染阶段,在场景的所有不透明和常规透明物体都画完后再绘制。相当于给 UI 开了“最后一棒”的特权。同时关闭深度写入,避免 UI 把场景里本应正常的透明遮挡关系打乱。

4.4 视口尺寸变化时 UI 被拉伸变形

窗口从 16:9 切到 4:3,或者从全屏切到无边框窗口,UI 会跟着拉伸变形。文字变扁,按钮变宽。如果固定让 Myra 的 RenderTarget 始终等于主视口分辨率,理论上不会有拉伸,但实际工程里视口变化时纹理重建会有延迟,中间几帧就会看到拉伸。

更稳的办法是固定 Myra 渲染一个基准分辨率,然后在 Unigine 侧控制 UI 平面的宽高比,而不是让平面强行填满主视口。比例不一致时,用黑边或者背景遮罩补齐。UI 作为一个容器,宁可留黑边也不许变形。基准分辨率从 1920×1080 改成 16:10 时,只需要改一处配置,不用动控件树。

5. 性能优化与画质打磨:别再每一帧都搬运 8MB 像素了

链路稳定后,接下来就是性能问题。Myra 本身渲染 2D 控件很快,Unigine 渲染一个全屏四边形也毫无压力,真正的瓶颈在于 CPU 与 GPU 之间的像素回读和上传。

5.1 按需刷新:动态 HUD 的帧率救星

如果你的 UI 是静态的,比如只显示固定菜单,那么完全可以只在内容变化时回读一次。但模拟类应用的 HUD 经常有数值滚动、进度条变化、告警闪烁,单纯“全都不更新”不现实。

我采用的策略是脏标记机制。Myra 的每个控件在值变化、布局变化、可见性变化时都有事件回调,我利用这些事件设置一个dirty标志。每帧只处理事件,不执行 Desktop.Render()。只有当dirty = true时才走一遍完整的“渲染—回读—上传”。

实测下来,大部分监控界面只有几百个像素区域在变化,整帧重绘的次数大幅下降。如果再配合局部纹理更新能力——只更新变化区域对应的纹理子矩形,CPU 拷贝量还能进一步缩小。局部更新对 Myra 这种控件式 UI 其实并不好做,因为 SpriteBatch 绘制时你不知道具体哪个控件占了哪块像素,整帧重绘虽然“笨”,但至少不会错。

5.2 异步回读:把同步点打掉

像素回读最怕的不是数据量大,而是回读时 GPU 必须等待之前的所有渲染命令完成,形成一个完全同步点。如果你刚好把回读放在 Unigine 渲染阶段中间,整帧都可能被拖住。

Unigine 的纹理更新如果支持异步上传,你可以把回读和上传错开一帧:读上一帧的数据,传这一帧的纹理。表现上有 1 帧延迟,但对 UI 交互来说感知不强。我用这个办法把回读造成的帧耗时从 3ms 降到了几乎为 0。

5.3 文本清晰度:mipmap 与采样方式的选择

UI 是静态贴图时,Unigine 会对纹理做标准双线性过滤,这没问题。但如果 UI 平面在场景里被旋转或者缩放(比如模拟舱内某个虚拟屏幕上显示了 UI 面板),纹理采样会出现闪烁和摩尔纹,文字密集区域尤其明显。

解决办法是给动态纹理生成 mipmap。注意,动态纹理一旦开了 mipmap,每次更新纹理内容后都要重新生成 mipmap,否则低 mip 层还是旧内容。生成 mipmap 有额外耗时,我用按需刷新策略,只在 dirty 的时候重新生成,把开销控制住。

5.4 可扩展优化:跨进程共享显存才是最省事的下一步

如果未来某一天按需刷新已经满足不了需求,方向也不是去优化 CPU 拷贝,而是跳到共享纹理。Windows 下 D3D11 支持共享句柄,可以让 MonoGame 的 RenderTarget2D 和 Unigine 的纹理引用同一块显存,省掉回读和上传两道工序。代价是平台绑定变强,代码里也需要处理资源生命周期,尤其是 Unigine 侧销毁纹理时,不能把共享资源直接释放掉。

这种“把外部引擎的 UI 渲染结果合成进主引擎帧缓冲”的思路,本质上是把 UI 当成纹理资产,而不是当成 3D 场景的一部分。理解了这一点,后续不管接的是 Myra,还是任何能输出 RenderTarget 的 2D 框架,你都能复制同样的套路。

最后再分享一个小技巧:所有坐标转换和通道顺序处理,尽量集中在一个文件里,千万别散落到各个回调函数里。我前期因为坐标反转和通道转换散乱写过,排查时一度以为 Myra 的布局系统出了问题,最后才发现是 Unigine 侧贴图 UV 方向反了。集中处理之后,运行时调试的效率提升明显,这套集成现在已经在项目里稳定跑了大半年,除了驱动升级时偶尔需要盯一眼纹理格式兼容性,基本没有闹过脾气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询