简介:面向需要在桌面应用中集成三维交互能力的开发者,这份资源围绕Unity3D嵌入WPF的实现流程,提供了从Unity场景设计、工程导出到WPF宿主集成的完整示例,覆盖了WindowsFormsHost控件承载渲染窗口、场景加载,以及Unity与WPF双向事件通信等关键设计。压缩包内共140个文件,大小约49.44MB,以C#源码、动态链接库、界面定义文件、可执行程序及调试符号为主,并附有配置、资源和Unity场景文件,可帮助读者快速理解项目引用关系和运行环境,目录结构清晰,便于按模块检索学习。目前已有940人学习下载。示例针对Unity场景与WPF桌面程序的结合给出了可运行代码与交互思路,并涉及性能优化和测试调试要点,适合有一定C#与Unity基础、希望快速搭建三维桌面应用原型的工程人员参考,能显著减少跨技术栈集成时的踩坑成本。
1. Unity3D嵌入WPF:为什么要在窗口程序里养一个游戏引擎
我最早碰到这个需求,是给产线做一套带3D数字孪生的MES看板。WPF做界面和图表没问题,但一到三维仿真就露怯——要么用第三方控件,要么求引擎组给个视频流。最后兜了一圈发现,与其在WPF里模拟3D,不如直接把Unity3D嵌入WPF进程,让它成为你窗口里的一个“子视图”。这不是黑科技,而是把两套成熟的UI体系放进同一个应用程序框架里:WPF负责2D交互和业务,Unity负责渲染和物理,两者用窗口句柄或纹理交换做边界。适合两种人:一种是WPF工程师,想在桌面程序里加一段可交互的3D场景;另一种是Unity开发者,不想单独做两个进程来回切换。读完你就能搞清楚路线怎么选、步骤怎么写、以及那些让我反复翻车的坑到底长什么样。
2. 三条嵌入路线:窗口句柄、纹理共享与视频流怎么选
把Unity3D塞进WPF,本质上就是解决一个问题:两个不同的渲染上下文怎么共存。WPF走DirectX 9时代的渲染路径,Unity默认用D3D11/12,谁也没法直接在对方的画布上画图。目前常见做法有三条路线:窗口句柄嵌入、D3DImage纹理共享、以及视频流方案。我先说结论,再做选择逻辑。
2.1 窗口句柄方案(HwndHost):最成熟,但被Airspace限制
窗口句柄方案是Windows下最朴素的思路——Unity程序跑起来后是一个独立窗口,这个窗口有自己唯一的HWND(窗口句柄)。WPF里的HwndHost控件正好是专门用来承载Win32子窗口的容器,我们把Unity窗口的句柄塞进HwndHost,Unity画面就“镶嵌”在WPF界面里了。
这里关键点是WPF的Airspace机制:WPF窗口上凡是托管了原生HWND的区域,WPF自身的渲染就会在那块区域上“挖洞”,让原生窗口直接落在屏幕上。你会遇到两类问题:一是Unity窗口会永远盖住WPF元素,你想在它上面放个悬浮按钮是办不到的;二是窗口拖动、缩放时可能会闪黑或撕裂。但这不影响它成为最稳定的方案,因为它不碰内核渲染,只是把两个进程的窗口做父子关系。
选它的理由是简单、可靠、对Unity版本和渲染管线没有任何要求。Unity导出Standalone Windows程序,或者直接用Unity Editor的Play模式窗口,都能拿句柄。缺点是所有交互都限定在Unity窗口矩形范围内,跨窗口的视觉层叠、圆角、动画过渡就别想了。
2.2 D3DImage纹理共享:绕开Airspace,但工程复杂度高
D3DImage是WPF提供的一个微软官方“后门”,它允许你把一块DirectX共享纹理直接当作WPF里的ImageSource来显示。Unity侧预先创建一张共享纹理,每次渲染完成后把纹理句柄传给WPF,WPF侧再用D3DImage包装这张纹理,渲染到任意位置,甚至可以叠加半透明控件。这条路从根本上消除了Airspace问题,因为Unity不再有独立窗口,它只是把像素数据“送”给了WPF。
代价是工程复杂度直线上升。Unity侧要写渲染插件或者用外部调用(例如通过RenderTexture结合共享API),WPF侧要处理D3D9与D3D11的兼容(D3DImage只能接收Direct3D 9Ex的共享纹理,Unity通常是D3D11,中间需要做适配)。这已经有点“玄学”了,因为不同显卡驱动对共享纹理的支持不一样,我在两台相同型号的GPU上遇到过完全不同的表现。
选它的时候要有心理准备:调试周期比窗口句柄方案长3到5倍。但如果你的需求是在3D画面上叠加圆角遮罩、悬浮动画、或者做不规则裁剪,D3DImage几乎是从底层上唯一的体面解法。
2.3 视频流方案:最省心但延迟换来的
第三条路是把Unity画面编码成视频流,用推流工具(比如Unity自带Camera的RenderTexture + 编码器)把画面传到本地或远程,WPF这边用MediaPlayer或VLC控件播放。这种方式我在异地大屏项目里用过,好处是彻底解耦,Unity崩溃不影响WPF主程序;坏处是延迟通常有100到300毫秒,并且交互反馈得通过额外通信通道传回Unity。
千万别以为视频流是个“听起来low”的方案。当你的Unity场景部署在云端服务器、WPF只是本地瘦客户端时,这是唯一可持续的架构。但如果你的Unity和WPF在同一个机器上,视频流的编码解码开销纯属浪费,直接用窗口句柄方案性能更好。
下表是三条路线的核心取舍,我一般按“能不能接受区域遮挡”来决定:
| 对比项 | HwndHost窗口句柄 | D3DImage纹理共享 | 视频流 |
|---|---|---|---|
| 实现复杂度 | 低 | 高 | 中 |
| Airspace问题 | 有 | 无 | 无 |
| 交互延迟 | <10ms | <10ms | 100ms以上 |
| 视觉叠加能力 | 不能叠加WPF元素 | 可以叠加 | 可以叠加 |
| 进程耦合度 | 父子进程 | 线程级共享 | 完全解耦 |
| 适用场景 | 本地工具/看板 | 高质量定制UI | 远程渲染/旁路监控 |
一句话总结:本地单机,要快要省事,选HwndHost;要视觉自由度,选D3DImage;要跨机器或者容灾,选视频流。
3. HwndHost落地:把Unity窗口钉进WPF的完整步骤
这一章以窗口句柄方案为例,从Unity导出开始,到WPF这边把句柄“接住”,再到生命周期控制。整个过程我在多个项目里复用过,步骤基本一致。
3.1 Unity侧准备:导出Standalone窗口程序
不需要在Unity工程里写特别复杂的代码,但有一个设置必须提前改:Unity默认窗口要带标题栏和边框,嵌入父窗口后这些要全部去掉。在Unity的Player Settings里找到Resolution and Presentation,将Fullscreen Mode设为Windowed,并把Resizable Window勾选掉。运行时分辨率可以通过Screen.SetResolution强制设定,例如固定为1280x720。
using UnityEngine; public class EmbeddingConfig : MonoBehaviour { void Start() { // 以窗口模式启动,无边框 Screen.SetResolution(1280, 720, false); // 把标题栏去掉,嵌入后只保留客户区 #if UNITY_STANDALONE_WIN // 通过Win32 API隐藏边框的脚本需要单独封装 // 这里标识一下,后续在WPF端处理即可 #endif } }其实边框不用在Unity侧去掉也能工作,只是嵌进来会看到一条难看的标题栏,还会带来额外的双击拖动冲突。我在实际项目中是把Unity窗口的样式改为“无边框”的,做法是在Unity导出前用下面这段Win32 API直接修改窗口样式。
using System; using System.Runtime.InteropServices; public static class Win32Borderless { [DllImport("user32.dll", EntryPoint = "SetWindowLong")] private static extern int SetWindowLong32(IntPtr hWnd, int nIndex, int dwNewLong); [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] private static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int nIndex, IntPtr dwNewLong); // 为兼容64位,这里统一走SetWindowLongPtr public static void RemoveBorder(IntPtr hwnd) { const int GWL_STYLE = -16; const int WS_BORDER = 0x00800000; const int WS_DLGFRAME = 0x00400000; IntPtr style = new IntPtr(SetWindowLongPtr64(hwnd, GWL_STYLE, IntPtr.Zero).ToInt64() | WS_BORDER | WS_DLGFRAME); // 实际使用时应先获取原有样式再移除,这里省略了GetWindowLong } }需要注意,上段代码只是为了说明思路,直接跑不一定会生效,因为SetWindowLongPtr的调用需要先拿到GetWindowLong的原有值,做按位取反后再SetWindowLong,我建议你把这两步封装成一个完整的静态类。这个脚本放在Unity工程里后,Unity窗口启动时调用一次即可。
3.2 写HwndHost子类绑定Unity窗口
WPF侧的核心是自定义一个HwndHost子类。它的生命周期非常明确:BuildWindowCore创建承载窗口的父句柄,然后你在这个窗口里去启动Unity进程,等待Unity进程主窗口句柄出现,再把它SetParent到承载窗口上。
using System; using System.Diagnostics; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; public class UnityHost : HwndHost { private Process _unityProcess; private IntPtr _unityHwnd; private IntPtr _containerHwnd; public string UnityExePath { get; set; } public string UnityArguments { get; set; } protected override HandleRef BuildWindowCore(HandleRef hwndParent) { _containerHwnd = hwndParent.Handle; // 启动Unity进程,--parent参数是自定义约定 _unityProcess = new Process { StartInfo = new ProcessStartInfo { FileName = UnityExePath, Arguments = UnityArguments, UseShellExecute = false, CreateNoWindow = true } }; _unityProcess.Start(); // 等待Unity主窗口出现,超时10秒 _unityHwnd = WaitForMainWindowHandle(_unityProcess, 10000); // 将Unity窗口设为容器窗口的子窗口 SetParent(_unityHwnd, _containerHwnd); return new HandleRef(this, _containerHwnd); } private IntPtr WaitForMainWindowHandle(Process p, int timeoutMs) { p.WaitForInputIdle(timeoutMs); int waited = 0; while (p.MainWindowHandle == IntPtr.Zero && waited < timeoutMs) { p.Refresh(); System.Threading.Thread.Sleep(50); waited += 50; } return p.MainWindowHandle; } [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent); protected override IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { // 这里可以拦截WM_SIZE等消息来做尺寸同步 return base.WndProc(hwnd, msg, wParam, lParam, ref handled); } protected override void DestroyWindowCore(HandleRef hwnd) { if (_unityProcess != null && !_unityProcess.HasExited) { _unityProcess.Kill(); _unityProcess.Dispose(); } _unityProcess = null; } }这个类有四个关键点:第一,WaitForMainWindowHandle必须放在BuildWindowCore里,因为HwndHost要求这个方法同步返回,你不能异步等待;第二,SetParent一定要在Unity主窗口创建完成之后调用,否则可能拿到的是启动画面的句柄;第三,Unity进程的UseShellExecute必须设为false,否则你拿不到MainWindowHandle的快照;第四,DestroyWindowCore里必须清理进程,否则Unity会变成孤儿进程。
3.3 尺寸同步与DPI修正
Unity窗口嵌入后,它的尺寸不会自动跟随WPF容器。你需要在HwndHost里重写OnChildSizeChanged,或者拦截WM_SIZE消息,然后调用MoveWindow来调整Unity窗口。同时,WPF可能有DPI缩放,比如你的系统是125%、150%缩放,而WPF单位与物理像素之间需要换算。我一般这样写:
protected override void OnChildSizeChanged(Size size) { base.OnChildSizeChanged(size); if (_unityHwnd == IntPtr.Zero) return; // 将WPF逻辑尺寸转换为物理像素 var presentationSource = PresentationSource.FromVisual(this); if (presentationSource?.CompositionTarget == null) return; double dpiX = presentationSource.CompositionTarget.TransformToDevice.M11; double dpiY = presentationSource.CompositionTarget.TransformToDevice.M22; int width = (int)(size.Width * dpiX); int height = (int)(size.Height * dpiY); MoveWindow(_unityHwnd, 0, 0, width, height, true); } [DllImport("user32.dll")] private static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint);这里有个容易翻车的地方:如果你在Window的Loaded事件里直接给UnityHost设置尺寸,此时PresentationSource可能还没有Ready。我通常会把尺寸同步逻辑放在LayoutUpdated事件里,并加一个bool标志防止重复调用。DPI换算的另一种方式是使用VisualTreeHelper.GetDpi,但在HwndHost内部我习惯直接用TransformToDevice矩阵,它有更稳定的行为。
3.4 进程启动与关闭的完整流程
整个嵌入流程不是只用前面那两个overwrite就能稳住的,还需要约定启动顺序和退出顺序。我的习惯是这样的:
public void StartUnity() { var host = new UnityHost { UnityExePath = @"D:\builds\myunity.exe", UnityArguments = "--parent-mode" }; host.BuildWindowCore(new HandleRef(this, IntPtr.Zero)); // 实际不应手动调用,这里是示意 }严格来说,HwndHost不允许你直接手动调用BuildWindowCore,它是由WPF框架在测量阶段自动调用的。所以你真正要做的是把UnityHost放入XAML布局,然后在宿主窗口的SourceInitialized事件里确保HwndHost已经创建完成。关闭时,先通知Unity侧退出(发送WM_CLOSE),再关闭WPF窗口。如果Unity侧挂了,才用Kill兜底。
我遇到过一种情况:Unity进程在主窗口启动前就抛异常退出了,WaitForMainWindowHandle会一直等到超时,然后SetParent拿到的是一个无效句柄。所以等待过程中要轮询p.HasExited:
while (!p.HasExited && p.MainWindowHandle == IntPtr.Zero && waited < timeoutMs) { p.Refresh(); System.Threading.Thread.Sleep(50); waited += 50; } if (p.HasExited) throw new InvalidOperationException("Unity进程启动失败");这个细节帮我省下了无数次排查时间,很多“嵌入后黑屏”实际上是因为进程压根没起来,而你还在傻等句柄。
4. 参数调优与数据互通:从“能显示”到“能交互”
窗口句柄方案能显示只是第一步,审图员要旋转视角,操作员要点选设备,数据要双向流动。这一章讲三件事:输入透传、进程间通信、渲染参数。
4.1 消息透传:把WPF的输入喂给Unity
Unity窗口成为子窗口后,理论上Windows会把鼠标键盘消息投递给有焦点的子窗口。但WPF的焦点管理有自己的逻辑,如果你点击WPF侧的其他区域,再回来点击Unity,会发现Unity收不到键盘输入。原因是焦点被WPF主窗口抢走了。解决办法是把UnityHost在TabIndex上设为可聚焦,并在点击Unity区域时强制设置Win32焦点。
[DllImport("user32.dll")] private static extern IntPtr SetFocus(IntPtr hWnd); // 在UnityHost的鼠标点击事件里,把Win32焦点给Unity protected override void OnMouseDown(MouseButtonEventArgs e) { base.OnMouseDown(e); if (_unityHwnd != IntPtr.Zero) SetFocus(_unityHwnd); }同时需要重写HwndHost的TabInto,让Tab键也能进入Unity窗口。大部分游戏需要鼠标捕获(比如FPS视角),你在Unity端要使用UnityEngine.Input并且确保在窗口激活状态下运行。一个常见做法是,Unity启动时申请独占鼠标锁定,但在嵌入场景下,锁定鼠标会让WPF侧的操作失灵,建议修改Unity侧的输入脚本,让鼠标锁定只发生在用户明确按下右键时。
4.2 IPC通信:从C#到Unity的数值通道
嵌入之后,业务数据从WPF传给Unity,常见做法是Socket、命名管道或文件映射。我优先选命名管道,因为不占端口、权限好控制,而且不需要额外库。Unity侧需要写一个线程来监听管道消息。
// WPF侧发送消息 using System.IO.Pipes; public void SendToUnity(string jsonMessage) { using (var pipeClient = new NamedPipeClientStream(".", "unity-embed-pipe", PipeDirection.Out)) { pipeClient.Connect(1000); using (var writer = new StreamWriter(pipeClient)) { writer.WriteLine(jsonMessage); writer.Flush(); } } }// Unity侧接收(放在非主线程) using System.IO.Pipes; using System.Threading; void StartListening() { var thread = new Thread(() => { while (true) { using (var pipeServer = new NamedPipeServerStream("unity-embed-pipe", PipeDirection.In)) { pipeServer.WaitForConnection(); using (var reader = new StreamReader(pipeServer)) { string line = reader.ReadLine(); MainThreadDispatcher(() => ParseMessage(line)); } } } }); thread.IsBackground = true; thread.Start(); }这段代码里的坑在于,Unity的API不能在后台线程调用,所以示例中的MainThreadDispatcher需要你自己封装,常见做法是向Unity主线程的Update里塞一个队列。另外命名管道一次连接只能传一条消息,WPF每次Send都会创建新连接,性能上限大约每秒几百条,如果业务消息频率高于这个量级,要改用MemoryMappedFile。
4.3 渲染参数与性能预算
嵌入后Unity的渲染性能受两个因素影响:窗口尺寸和垂直同步。Unity的QualitySettings默认跟显示模式走,如果你嵌入的小窗口分辨率是1280x720,但Unity实际渲染的是1920x1080,那浪费的像素就多了。我一般在Unity启动后强制设置分辨率等于Host窗口大小。
// Unity侧脚本,放在Update中定时校正分辨率 void Update() { if (Screen.width != targetWidth || Screen.height != targetHeight) { Screen.SetResolution(targetWidth, targetHeight, false); } }关闭垂直同步能降低输入延迟,但会让画面出现撕裂。我建议在Unity里通过Application.targetFrameRate限制帧率而不是依赖VSync:
// 嵌入模式下锁60帧 Application.targetFrameRate = 60; QualitySettings.vSyncCount = 0;对于看板类应用,30帧就够了,可以把targetFrameRate设为30,GPU占用率会明显下降。如果WPF主界面同时有大表格刷新,一旦数据重排导致布局计算占满主线程,Unity帧率也会抖动。这种事我只能在架构上做隔离:把WPF的复杂数据绑定尽量放到后台线程,UI上只做简单写值,或者用虚拟化表格。
5. Unity与WPF集成避坑:四个真实翻车现场
这一章全是血泪经验。每一个我都实际遇到过且修过不止一次,记录成“现象→原因→解决”格式,方便你排查时直接对照。
5.1 黑窗崩溃:句柄获取时机不对
现象:Unity进程启动后,WPF里只有一块灰色或黑色区域,过一阵子整个界面变成“未响应”。 原因:我在BuildWindowCore里通过WaitForInputIdle后立刻拿MainWindowHandle,但Unity的启动画面(Splash Screen)窗口和主场景窗口不是同一个。MainWindowHandle先返回了启动画面的句柄,场景加载完后主窗口替换,但我们已经把启动画面窗口SetParent进去了,Unity主窗口还飘在屏幕上或直接崩溃。 解决:放弃WaitForInputIdle,改为轮询进程主窗口标题。Unity窗口标题通常是你的项目名(Product Name),或者你自定义的窗口标题。找到标题匹配的窗口再SetParent。更稳妥的做法是先让Unity把启动画面关闭。
5.2 鼠标穿透与焦点丢失
现象:点击WPF按钮后,再点Unity画面,发现Unity画面里的按钮点了没反应,或者鼠标滚轮滚动的是WPF侧页面。 原因:Unity窗口虽然成了子窗口,但没有获得Win32焦点。WPF会周期性把焦点收回给自身,尤其是当有文本框获得焦点时。Unity的Input系统靠窗口激活状态来派发事件。 解决:在UnityHost的MouseDown事件中强制执行SetFocus;同时禁用WPF对UnityHost及其父容器的IsTabStop自动管理,把聚焦逻辑统一交给Win32。如果用了MVVM框架,还要留意命令绑定里的Focusable属性。
5.3 关闭时进程残留
现象:关闭WPF程序后,任务管理器里有一个Unity进程僵尸一样挂着,CPU占用10%左右,再次启动时Unity画面黑屏且无法连接。 原因:DestroyWindowCore不一定被调用。WPF在窗口关闭时,如果某个异常导致消息循环中断,HwndHost的清理流程会被跳过;如果直接结束WPF进程,Unity进程是独立的,不会自动跟着退出。 解决:在宿主窗口的Closed事件里主动调用host.Dispose(),并且在Dispose中不仅杀进程,还要先发送WM_CLOSE让Unity做资源清理。如果Unity里有未保存的Release操作,直接Kill会导致下次启动时PlayerPrefs损坏。
protected override void Dispose(bool disposing) { if (_unityProcess != null && !_unityProcess.HasExited) { // 先发WM_CLOSE SendMessage(_unityHwnd, 0x0010, IntPtr.Zero, IntPtr.Zero); // 等待3秒 if (_unityProcess.WaitForExit(3000)) return; _unityProcess.Kill(); } base.Dispose(disposing); }5.4 尺寸缩放撕裂与DPI错位
现象:窗口从放大到缩小后,Unity画面左侧/顶部出现黑边,或者Unity画面偏移,鼠标点击位置和Unity内部坐标总差20多个像素。 原因:DPI缩放时,WPF的ActualWidth是逻辑像素,MoveWindow用的物理像素,如果缩放比例不是100%,你就会看到尺寸偏差。另外,Unity窗口的客户区并不一定从(0,0)开始,因为Unity自身也可能对窗口调整了位置。 解决:MoveWindow前必须用PresentationSource.TransformToDevice做一次缩放换算。同时,在Unity侧通过Screen.SetResolution时也传入物理像素值,而Win32 MoveWindow的宽高填物理像素。黑边问题通常是Unity窗口的宽高比与Host区域不一致,建议Unity端固定分辨率而不是拉伸,实在要拉伸就设置Unity的RenderScale为0.5再拉大,可以勉强规避模糊。
6. 进阶技巧:用D3DImage把Unity画面变成WPF纹理
如果你不想被Airspace坑一辈子,最后的进阶方案就是用D3DImage。思路是Unity侧把渲染结果写入一张共享纹理,WPF侧用D3DImage引用它。
6.1 D3DImage接入步骤简写
Unity侧需要写一个原生插件(C++)暴露共享纹理句柄,常见做法是使用Unity的UnityRenderingExtEventType或直接在C#侧通过System.Drawing + DirectX。这里我不写C++,只列出最小流程:
- Unity创建一个RenderTexture,并设置成共享模式。
- 在C#侧调用native插件把共享纹理的handle传给WPF。
- WPF侧用D3DImage.SetBackBuffer把handle包装成画布。
// WPF侧D3DImage示例 var d3dImage = new D3DImage(); // sharedHandle来自Unity侧传递的IntPtr d3dImage.SetBackBuffer(D3DResourceType.IDirect3DSurface9, sharedHandle); imageControl.Source = d3dImage;这段代码能工作,但注意SetBackBuffer必须在Render线程或持有锁的情况下调用,否则会有几百毫秒的延迟。
6.2 验证渲染连续性的清单
接入D3DImage后,最容易出现“画面静止”或“刷新撕裂”。我建议按以下清单排查:
- 确认Unity侧有主动调用共享纹理的更新接口,而不是只在Start里写一次。
- 确认D3DImage的IsFrontBufferAvailableChanged事件有监听,这是WPF用来通知底层资源丢失的。
- 每帧在WPF侧调用d3dImage.Lock()和AddDirtyRect(),否则WPF不会重画。
compositionTarget.Rendering += (s, e) => { d3dImage.Lock(); d3dImage.AddDirtyRect(new Int32Rect(0, 0, d3dImage.PixelWidth, d3dImage.PixelHeight)); d3dImage.Unlock(); };6.3 收尾技巧
我最后用D3DImage做成功的一个项目,代价是花了三个晚上调显卡兼容性。后来我养成了一个习惯:在工程初期就先用一个空场景跑通共享纹理链路,再往里填业务代码。因为共享纹理失败时往往不是报错,而是“画面上只有一层半透明的花屏”,这种玄学问题越早暴露越好。从那以后我每次做嵌入方案,都会先画一张A4纸,写清楚哪个进程拥有渲染资源、哪个进程拥有窗口句柄,再动手敲代码。如果你也准备走D3DImage这条路,先确认显卡不低于GTX 1050级别,驱动更新到近一年内的版本,否则省下来的时间会加倍还回去。希望帮到你。
本文还有配套的精品资源,点击获取