简介:面向需要在 Winform 桌面界面中嵌入 Unity 独立程序的开发者,资源包围绕 Unity 构建产物与 Winform 窗体的融合实现展开,适合有一定 C# 基础、希望将三维能力接入现有桌面系统的中高级开发者。典型场景包括三维预览、可视化工具、游戏编辑器外壳集成。包内共 181 个文件,压缩后 18.32MB;主体为 112 个动态库、C# 源码与工程文件、可执行程序、配置文件,以及 Unity 构建生成的资源、关卡数据和全局管理文件。目录中已区分打包输出目录与 Winform 容器项目,便于对照工程理解进程启动、子窗口句柄挂接、消息交互和跨线程处理等关键实现。已有 170 人学习下载。借助其中代码、配置与构建产物,可快速搭建自己的嵌入原型,并对嵌入过程中常见的跨线程调用、句柄挂接失败等问题形成排查思路。
1. 为什么非要用“Unity exe嵌入到 Winform”:这不仅是套壳,是两种 UI 体系的缝合
做过数字孪生或者工业上位机的人,应该都见过这种尴尬:C# 团队把 Winform 的界面搭得整整齐齐,报表、曲线、设备状态全在一个窗体里,结果 3D 场景只能在旁边单独开一个 Unity 窗口。用户要来回切换,窗口一多任务栏就乱成一团,项目验收时甲方一句“能不能把那个 3D 场景放进去”就能让你加两周班。所谓“Unity exe 嵌入到 Winform”,就是把 Unity Build 出来的独立 exe 进程,通过 Windows 窗口句柄机制,塞进 Winform 某个 Panel 的区域内渲染。这套做法适合两类人:一类是上位机项目里需要 3D 展示但不想重写渲染引擎的 C# 开发者,另一类是 Unity 团队做好的演示程序,需要被现有 Winform 系统“收编”的集成工程师。它解决的不只是“画面嵌进去”,还有焦点、消息传递和退出时机这些窗口级交互问题。下面我把这个方案的原理、代码和坑一次讲完。
2. 嵌入方案选型:-parentHWND 还是 SetParent,决定你少踩一半坑
2.1 两种方案的底层差异:从进程、句柄到消息循环
Windows 上的窗口本质是个 HWND(窗口句柄),每个窗口都有自己独立的消息循环。Winform 窗体是一个进程里的窗口,Unity exe 是另一个进程里的窗口。要把两个窗口“合体”,底层思路只有一条:把 Unity 窗口的父窗口改成 Winform 的某个容器控件,让它成为 Winform 的一个子窗口。这里最关键的两个 Win32 API 是 SetParent 和 MoveWindow,它们决定了子窗口的位置和大小跟随父窗口。
SetParent 的调用很简单,但行为有几个反直觉的点。第一,跨进程设置父子关系后,Unity 窗口的消息循环依然独立,它不会收到 Winform 的 WM_CTLCOLOR 之类的自绘消息,也不受 Enabled 属性影响。第二,子窗口默认会继承父窗口的可见性,父窗体最小化时 Unity 窗口跟着隐藏,但父窗体有模态对话框时,Unity 窗口不一定自动置底,容易出现“模态框在 Unity 上面”的层级错乱。第三,SetParent 之后,Unity 窗口的边框和标题栏还在,只是被“囚禁”在 Panel 里,要好看还得用 GWL_STYLE 去掉标题栏,或者让 Unity 在启动时自己关掉边框。
另一种方案是 Unity 官方提供的 -parentHWND 启动参数。Unity 的 Windows Standalone 播放器支持在命令行传入父窗口句柄,启动后直接把渲染画面附着到指定窗口,相当于 Unity 引擎在初始化阶段就完成了“寄宿”。这个方案不需要你在 Winform 里手动调 SetParent,窗口层级由 Unity 自己管理,焦点行为也更接近原生嵌入。但要注意,-parentHWND 只对 Unity 5.x 之后的 64 位播放器支持良好,老版本或者某些自研引擎不一定认。
选型时我一般这样判断:如果你的 Winform 是主程序,Unity 是偶尔启动的可选模块,用 -parentHWND 最省心;如果你的 Unity exe 还得保留独立运行的场景,比如调试时想单独跑,就别 hack 得太死,SetParent 反而灵活。另外,如果你需要动态切换宿主 Panel(比如多个标签页共用一个 Unity 实例),SetParent 可以随时改父,而 -parentHWND 启动后就不太好变。
2.2 为什么优先选 Unity 官方的 -parentHWND
团队里有 Unity 背景的同事应该都知道,Unity 的 Standalone 播放器启动时会读命令行参数,其中 -parentHWND 是官方文档明确支持的。用法是在 Process.Start 时把参数拼到 exe 后面:-parentHWND 123456。这个 123456 是 Winform 某个 Panel 控件的句柄值,也就是 panel1.Handle.ToInt32()。
用它的第一个好处是省掉了自己写 SetParent 的时序问题。你用 SetParent 时,必须等 Unity 的主窗口创建出来再操作,而 Unity 启动有 C++ 引擎初始化、mono 运行时加载、场景资源加载,这段耗时从几百毫秒到几秒不等,你得轮询 FindWindow 或 WaitForInputIdle,很容易踩“窗口还没创建”或者“创建的不是主窗口”的坑。用 -parentHWND,Unity 引擎内部会在消息循环建立后主动把自己的窗口 attach 过来,你不用管中间态。
第二个好处是焦点和输入处理更顺。嵌入后,Unity 窗口接收鼠标键盘事件是靠 Win32 的焦点机制,手动 SetParent 的窗口在单击进入时经常需要额外的 SetFocus 调用,否则你先点一下 Unity 区域,第一次点击只“激活”了窗口,第二次点击才真正触发场景里的按钮。而 -parentHWND 模式下,Unity 会把自己注册成父容器的子窗口,焦点切换更接近原生控件,虽然偶尔也有需要点击两次的问题,但频率低很多。
第三个好处和尺寸同步有关。Unity 拿到父窗口句柄后,会监听父窗口的 WM_SIZE 消息,自动调整渲染分辨率。如果你用 SetParent,必须自己在 Winform 的 Resize 事件里调 MoveWindow,而且 Unity 内部渲染分辨率不一定跟着变,容易留黑边或者拉伸变形。所以做项目我会把 -parentHWND 作为默认方案,SetParent 只留给极老版本的兼容。
2.3 什么时候才需要自己写 SetParent 备胎逻辑
不是所有环境都有现成的 -parentHWND。比如你拿到的是第三方用 Unity 打的 exe,没留命令行参数解析的余地,或者 exe 是用 IL2CPP 但没开“允许传入命令行参数”的选项(有时构建配置里忽略了),你只能手动找窗口句柄。还有一种典型场景:Unity 启动时有启动画面(Splash Screen),它会单独创建一个无边框窗口,而真正的游戏窗口要等 splash 结束才出现。用 SetParent 时你把 splash 窗口塞进去,结果 splash 消失后你的 Panel 里就空了。
手动 SetParent 的备胎流程一般是:Start 进程 → 循环等待 MainWindowHandle 非零 → 如果 handle 对应的是 splash 窗口,再等第二个窗口出现(或者干脆关掉 splash,用 Unity 的 -nolog 或禁用 Splash 配置)→ 拿到真正的目标窗口句柄 → SetParent(handle, panel.Handle) → MoveWindow 铺满 panel。
写这段逻辑时最要注意超时。Unity 首次启动可能要 5 秒甚至更久,如果一个 while 循环里直接 Thread.Sleep(100) 去轮询,Winform 的 UI 线程会卡死。正确做法是把等待逻辑放到 Task 里,回调里再切换回 UI 线程操作句柄。下面第 3 章给的就是一份完整可用的最小实现。
3. 最小实现:把 Unity 打包好的 exe 塞进 Winform 的 Panel
3.1 启动 Unity 子进程并等待主窗口句柄
先准备一个 Winform 窗体,上面放一个 panel 控件,取名叫 unityContainer,Dock = Fill。然后写一个 UnityRunner 类,负责启动、等待和销毁进程。这里最核心的一点是启动参数要拼对,尤其是 -parentHWND 的值,必须是 panel.Handle,不是窗体的 Handle,也不是控件的 Handle 属性转字符串前的值。
我用 C# 给你一份能直接跑的代码,注释标清楚了关键点。
using System; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading.Tasks; using System.Windows.Forms; public class UnityRunner { [DllImport("user32.dll", SetLastError = true)] private static extern bool SetParent(IntPtr hWndChild, IntPtr hWndNewParent); [DllImport("user32.dll", SetLastError = true)] private static extern bool MoveWindow(IntPtr hWnd, int x, int y, int width, int height, bool repaint); private Process _process; private IntPtr _targetHandle = IntPtr.Zero; // 启动 Unity 并嵌入到指定 panel public async Task StartAsync(Control parentControl, string unityExePath, string[] extraArgs) { // 关键:把控件句柄转成 32 位整数,作为 -parentHWND 的值 int parentHwnd = parentControl.Handle.ToInt32(); var psi = new ProcessStartInfo { FileName = unityExePath, UseShellExecute = false, CreateNoWindow = false, // true 会隐藏掉 Unity 的控制台输出,但有时也把窗口隐藏了 Arguments = $"-parentHWND {parentHwnd}" }; // 如果有额外参数,比如 -screen-width -screen-height,拼进去 if (extraArgs != null && extraArgs.Length > 0) psi.Arguments += " " + string.Join(" ", extraArgs); _process = Process.Start(psi); // 等待主窗口出现,超时 15 秒 await Task.Run(() => { var watch = Stopwatch.StartNew(); while (watch.ElapsedMilliseconds < 15000) { _process.Refresh(); if (_process.MainWindowHandle != IntPtr.Zero) { _targetHandle = _process.MainWindowHandle; break; } System.Threading.Thread.Sleep(100); } }); if (_targetHandle == IntPtr.Zero) throw new TimeoutException("Unity 主窗口在 15 秒内没有出现,请检查 exe 是否正确启动"); } }逻辑说明:这里先取 panel 的 Handle,这个句柄在窗体创建后一定有效。Arguments 直接拼 -parentHWND,让 Unity 自己认领父窗口。等待窗口用 Process.MainWindowHandle,这个属性返回的是进程主线程创建的顶层窗口句柄,在 Unity 初始化完成前是零。注意不能用 Process.WaitForInputIdle 代替,因为 Unity 的消息循环可能一直在处理渲染消息,WaitForInputIdle 会一直等到永远。15 秒超时是血泪经验,Unity 冷启动加载大场景时超过 10 秒是常有的事,但超过 15 秒基本就是参数传错或者 exe 崩了。
参数说明:-parentHWND 后面的数字必须是十进制整数,不能带 0x 前缀。CreateNoWindow 设为 false 是为了在调试时能看到 Unity 的日志输出窗口,生产环境你可以设为 true,但那样 Unity 崩溃时你看不到任何提示。如果设为 true 后发现画面没进来,先确认窗口句柄是否取到了,而不是怀疑这个参数。
3.2 用 -parentHWND 传窗体的句柄给 Unity
上面代码里,-parentHWND 传的是 panel 的 Handle。为什么要传 panel 而不是窗体本身?因为 Winform 窗体有边框、标题栏和非客户区,Unity 窗口直接嵌入窗体的话,位置会包括标题栏区域,造成偏移。传 panel 后,Unity 窗口的坐标原点和 panel 的客户区左上角对齐,Dock 布局也自然响应。
但这里有个坑:Unity 的 -parentHWND 在启动时只读取一次,如果你在启动后修改了 panel 的 Handle(比如 panel 被重建),Unity 不会自动跟随。所以设计上应该让 panel 在整个生命周期里保持固定,不要用 TableLayoutPanel 或者需要动态创建销毁的容器。我一般建议放一个专用的 Panel,名字起成 unityHost,属性里设 DoubleBuffered = true 减少闪烁。
还有一种情况:你的 Unity 版本比较老,-parentHWND 不支持,或者 exe 是别人打好的没有传参,你只能等窗口出现后再 SetParent。此时改一下逻辑:把 -parentHWND 去掉,等 MainWindowHandle 出来后调用 SetParent。
// 等待窗口出现后手动 SetParent IntPtr unityHwnd = _process.MainWindowHandle; SetParent(unityHwnd, parentControl.Handle); MoveWindow(unityHwnd, 0, 0, parentControl.Width, parentControl.Height, true);这段逻辑说明:SetParent 把 Unity 窗口变成 panel 的子窗口,MoveWindow 把它铺满 panel 客户区。窗口出现到 SetParent 之间有一小段时间,Unity 的画面会先闪一下在任务栏里,注意把 panel 的 BackgroundImage 设成和 Unity 背景色接近,视觉上会好一点。参数说明:MoveWindow 第五个参数 repaint 传 true,告诉系统立即重绘;x 和 y 传 0,因为 panel 的客户区原点就是 (0,0)。如果你 panel 里有别的控件,比如一个工具栏,要预留空间,就传对应偏移。
3.3 嵌入后的尺寸同步与自适应布局
嵌入后最经常被问到的问题:Winform 窗口大小变了,Unity 画面怎么跟着变?-parentHWND 模式下,Unity 内部会监听父窗口的 WM_SIZE,自动调整渲染尺寸。但实际测试中发现,如果父窗口是 Winform 的 panel,窗体 Resize 时 panel 的尺寸变化会被 Winform 处理成先改 panel 尺寸,再重绘,Unity 收到的 WM_SIZE 可能晚一拍,于是你看到 Unity 画面有短暂拉伸,然后缓过来。这种顿挫感在窗口拖拽时尤其明显。
解决方法是手动“帮” Unity 同步。常见做法是重写窗体的 OnResize,在 base.OnResize 之后调用 MoveWindow 强制更新子窗口位置:
protected override void OnResize(EventArgs e) { base.OnResize(e); if (_runner != null && _runner.NativeWindowHandle != IntPtr.Zero) { MoveWindow( _runner.NativeWindowHandle, 0, 0, unityHost.Width, unityHost.Height, true); } }说明:MoveWindow 强制让 Unity 窗口位置大小和 panel 一致,即使 Unity 自己的消息循环还没处理 WM_SIZE。这样能消除大部分延迟。但注意,MoveWindow 不可以调得过勤,在窗体拖动过程中会触发大量 Resize 事件,如果每次都同步,CPU 占用会飙升。我一般加一个节流:记录上一次同步的时间,50 毫秒内不重复调。
还有分辨率参数。Unity 打包时默认的分辨率是在 Player Settings 里设置的,嵌入后用 -screen-width 和 -screen-height 可以覆盖。比如你想固定渲染 1920x1080 的输出,缩放铺满 panel,那么启动参数可以写:
UnityApp.exe -parentHWND 123456 -screen-width 1920 -screen-height 1080但要注意,这种固定分辨率不会等比缩放,如果 panel 宽高比和 1920x1080 不一样,多出来的部分要么拉伸要么留边。更稳的做法是让 Unity 的 Canvas Scaler 或者摄像机对准 panel 的实际大小,实时分辨率模式。这个在 Unity 侧脚本里去拿 Screen.width 和 Screen.height 即可,Winform 这边只管把 panel 尺寸传过去就行。
4. 交互打通:键盘焦点、消息路由与 Unity 侧参数
4.1 焦点管理:单击进游戏,切换窗体后要“点一下”的玄学
嵌入后第一个让你抓狂的就是焦点。用户从 Winform 输入框切到 Unity 画面,鼠标直接点一个按钮,结果第一次点击没反应,按钮只是“被聚焦”了,要再点一次才触发。原因在于两个进程的窗口各自管理键盘焦点,Winform 窗口失焦后,Unity 窗口虽然看得见,但没有获得 SetFocus。解决办法是在 panel 的 MouseEnter 事件里主动给 Unity 窗口发焦点。
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] private static extern bool SetFocus(IntPtr hWnd); private void unityHost_MouseEnter(object sender, EventArgs e) { if (_runner != null && _runner.NativeWindowHandle != IntPtr.Zero) { SetForegroundWindow(_runner.NativeWindowHandle); SetFocus(_runner.NativeWindowHandle); } }这段逻辑说明:鼠标进入 panel 时,把 Unity 窗口提到前台并赋键盘焦点。这能解决 90% 的“要点两次”问题。但副作用是,用户鼠标从 Winform 控件移入 Unity 区域时,Winform 的 TextBox 会立刻失焦,如果用户想在 Unity 和文本框之间连续操作(比如在文本框输入参数,然后去点 Unity 里的确认),MouseEnter 会打断输入。所以更稳妥的做法是在 panel.MouseDown 事件里设焦点,而不是 MouseEnter:
private void unityHost_MouseDown(object sender, MouseEventArgs e) { SetForegroundWindow(_runner.NativeWindowHandle); }解释:鼠标按下时 Unity 已经接收到这次点击,但由于没有焦点,Unity 可能会把第一次点击当作激活窗口,第二次才传给场景。SetForegroundWindow 必须在 MouseDown 里调用,让 Unity 在下一步处理鼠标消息前获得焦点。用了这个方案后基本没有“点两次”问题,代价是 Winform 里的任何输入框在点击 Unity 时都会失焦,这本来就是预期行为。
4.2 用自定义消息把 Winform 的数据传给 Unity
嵌入后不只是“看”,还得“管”。Winform 端要通知 Unity 切换场景、设置物体速度、更新状态栏与进度条,最简单的方式是发 Windows 自定义消息。定义消息 ID 用 RegisterWindowMessage 可以避免和别人冲突,比如:
private static readonly int WM_UNITY_COMMAND = RegisterWindowMessage("UnityCommand_" + Application.ProductName); [DllImport("user32.dll")] private static extern int RegisterWindowMessage(string lpString); [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);发送的时候,wParam 传命令 ID(比如 1 表示更新速度,2 表示暂停),lParam 可以传字符串指针(用 AllocHGlobal 封送)。Unity 侧怎么监听呢?Unity 的 Standalone 播放器用 Win32 消息循环,你需要在 Unity 的脚本里用SetWindowsHookEx或者重写WndProc,但 Unity 不开放主窗口的 WndProc。常见做法是用一个后台线程调用PeekMessage,把投递到 Unity 主窗口的自定义消息捞出来,或者用 Unity 官方提供的UnityPlayer.dll接口。如果你不想这么底层,可以用文件监听或命名管道,但消息方式的延迟最低,适合实时控制。
我建议把命令封装成字符串协议:lParam 传一个 JSON 字符串的指针,Unity 端解析。Winform 端这样发:
public void SendCommand(string json) { IntPtr ptr = Marshal.StringToHGlobalAnsi(json); SendMessage(_runner.NativeWindowHandle, WM_UNITY_COMMAND, new IntPtr(1), ptr); Marshal.FreeHGlobal(ptr); }说明:用 GlobalAlloc 分配的指针会在跨进程消息中被读取,但发送后立即 Free 有风险——如果消息是异步的( PostMessage ),Unity 还没读完你就释放了内存。所以这里必须用 SendMessage,它是同步的,Unity 的消息循环处理完才返回,这时释放是安全的。如果你用 PostMessage,务必用 GlobalAddAtom 做内存管理,这个坑我踩过,后面避坑章会写。
4.3 Unity 侧的分辨率、Timescale 和包体参数怎么配合嵌入
嵌入场景和普通单机运行不一样,Unity 侧有些参数要提前调好。第一个是分辨率自适应:Unity PlayerSettings 里勾选 Allow Fullscreen,嵌入后用户按 Alt+Enter 有可能会弹出全屏,把宿主 Winform 挡住。要禁掉,在 Unity 启动时调用Screen.fullScreen = false,并且把Resolution Dialog关掉。
第二个是Time.timeScale。很多上位机项目想用 Unity 做实时渲染,但不需要游戏的时间流动,比如你想让 3D 场景静止,只响应鼠标旋转。此时在 Unity Start 里设Time.timeScale = 0f。但注意,timeScale=0 不会停住 Update 里的渲染,只会让Time.deltaTime变成 0,所以如果场景里有Physics需要固定更新,可能会怪。更优雅的方式是 Winform 控制暂停时,发送一个消息触发 Unity 侧调Time.timeScale = 0,恢复时调回 1,这样 Unity 逻辑还活着,只是游戏时间冻结。
第三个是包体优化。嵌入方案中 Unity exe 的体积会影响启动速度,你在 Player Settings 里做如下设置:开启 Strip Engine Code、Managed Stripping Level 设为 High、IL2CPP 编译,这样包体能从 200MB 降到 100MB 以下。启动速度改善明显,因为 -parentHWND 模式下,Unity 是在 Winform 进程里被拉起来的,整体耗时等于“Unity 初始化 + 场景加载”,包体越小内存占用越低,对 Winform 主程序影响越小。
还有一个小参数:Unity 启动时默认会显示 Splash Screen,嵌入后很丑。你可以在构建时关掉,或者用启动参数-nolog -nographics(仅服务器模式不能用)。最靠谱的是在 Unity 编辑器的 Player Settings 里把 Splash Screen 的 Show Unity Splash Screen 取消勾选,需要专业版 License,没有的话就忍受一秒加载画面,或者自己用一个 Winform 遮罩。
5. 避坑手册:嵌入后白屏、焦点丢失、退出残留的 5 个真实案例
5.1 白屏:Process.Start 后立刻 SetParent 太早
现象:Unity 窗口正常出现在任务栏,但 panel 区域一片白,偶尔能看到 Unity 的 Splash 闪一下就没了。原因:Unity 主窗口还没正式初始化渲染,你就调了 SetParent,父窗口的样式/消息传递导致 Unity 的渲染表面没挂上。解决:等待MainWindowHandle非零后,再延迟 500 毫秒或等待 Unity 发出第一个 WM_PAINT。我一般加一个条件:_process.WaitForInputIdle()然后Thread.Sleep(300),对大多数版本有效。更稳的是循环里检查窗口标题,如果 Unity 窗口标题从空变成默认的"UnityPlayer"再 SetParent。
5.2 黑边:Unity 分辨率设置和 Panel 尺寸不一致
现象:Unity 画面嵌入后,上下或左右出现黑边,Panel 填满了但内容没有占满。原因:Unity 渲染分辨率在启动时被 Player Settings 固定,比如 800x600,MoveWindow 只能拉伸窗口框架,不能改变渲染缓冲。解决:给 Unity 传-screen-width和-screen-height为 Panel 当前尺寸。可是 Panel 大小是运行时才知道,所以你先启动窗口,然后用 MoveWindow 设置窗口大小,Unity 收到 WM_SIZE 后会自动改分辨率。如果还不行,在 Unity 脚本里写一个OnRectTransformDimensionsChange去适配,或者用Camera.aspect = (float)Screen.width / Screen.height强制。
5.3 焦点问题:嵌入后键盘输入失效
现象:鼠标能点 Unity 里的按钮,但键盘输入没反应,比如 WASD 控制角色没动。原因:键盘消息发送到拥有焦点的窗口,而 Unity 窗口虽然可视,但不是焦点窗口。解决:参考 4.1 节那样在 MouseDown 时 SetForegroundWindow,同时注意 Winform 窗体自身的 ShowInTaskbar 是否会影响焦点。另一个坑是:如果你在 Winform 上使用了 TopMost 属性,Unity 窗口可能会被覆盖并且永远无法接收焦点。把嵌入的 panel 所在窗体设为普通层级,或者用SetWindowPos把 Unity 窗口 z 序提到 panel 上面。
5.4 退出残留:Unity 进程没随主窗体关闭
现象:关闭 Winform 后,Unity 进程还留在任务管理器里,占用几百兆内存。原因:SetParent 建立的父子关系不会在父窗口销毁时自动杀死子进程。解决:在主窗体的 FormClosing 事件里调用_process.CloseMainWindow()然后等待 5 秒的退出,如果超时直接Kill()。
private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { if (_runner != null) { _runner.CloseAsync().GetAwaiter().GetResult(); } } public async Task CloseAsync() { if (_process != null && !_process.HasExited) { _process.CloseMainWindow(); if (!_process.WaitForExit(5000)) { _process.Kill(); } _process.Dispose(); } }注意不能因为 CloseMainWindow 没反应就马上 Kill,Unity 可能在保存配置或回写日志,给 5 秒是合理的。另外,如果 Unity 窗口被 SetParent 后,CloseMainWindow 会向子窗口发送 WM_CLOSE,它可能不响应,所以先用SetParent(IntPtr.Zero)把子窗口摘下,再 CloseMainWindow,成功率更高。
5.5 卡顿:主线程 WaitForIdle 导致的假死
现象:Winform 窗体启动后整个界面卡住,鼠标转圈,直到 Unity 完全加载完才恢复。原因:有人在 Form_Load 里用了process.WaitForInputIdle(),或者在Invoke里同步等待窗口句柄。解决:所有等待逻辑必须放到async方法里,用await Task.Run轮询,避免阻塞 UI 线程。另外,Unity 在加载大场景时会占满 CPU,Winform 窗口如果 Direct3D 绘制线程和 UI 线程争用,也会卡。可以按 4.3 节优化包体,或者把 Unity 的 TimeFixedTimestep 调低,减少物理运算开销。
6. 进阶:把嵌入玩成通信 —— 命令总线 + 隐藏窗口 + 多实例隔离
嵌入做到能跑只是第一步,真正好用靠这几个细节。
6.1 隐藏 Unity 自带窗口标题栏的两种手法
-ParentHWND 嵌入后 Unity 窗口还是带着标题栏,盖在面板上方很丑。第一种手法:启动后改窗口样式,去掉标题栏和边框:
[DllImport("user32.dll")] private static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll")] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); private const int GWL_STYLE = -16; private const int WS_CAPTION = 0x00C00000; private const int WS_THICKFRAME = 0x00040000; public static void RemoveBorder(IntPtr hwnd) { int style = GetWindowLong(hwnd, GWL_STYLE); style &= ~WS_CAPTION; style &= ~WS_THICKFRAME; SetWindowLong(hwnd, GWL_STYLE, style); }说明:改样式后必须调用SetWindowPos强制重绘,否则边框残留。这个方法只影响显示,不影响消息传递。第二种手法是让 Unity 侧自己在 Player Settings 里选择 Windowed 并去掉 Resizable,然后用-parentHWND时 Unity 不会显示边框,这种方式更干净,但要改构建配置。
6.2 双向通信:Winform 按钮控制 Unity 场景里的物体速度
用自定义消息包 JSON,Winform 端发指令,Unity 端要有一个持续监听消息的后台线程。Unity 的 C# 脚本中,可以用DllImport("user32.dll")调用RegisterWindowMessage和PeekMessage,在Update里拉取消息队列。伪代码:
// Unity 侧 [DllImport("user32.dll")] static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg); void Update() { MSG msg; while (PeekMessage(out msg, IntPtr.Zero, 0, 0, 1)) { if (msg.message == WM_UNITY_COMMAND) { string json = Marshal.PtrToStringAnsi(msg.lParam); HandleCommand(json); } } }注意 PeekMessage 需要传入 Unity 主窗口句柄,Unity 里可以通过GetActiveWindow()或硬编码拿。如果用-parentHWND嵌入,主窗口句柄就是父窗口传入的那个,你在脚本里用Process.GetCurrentProcess().MainWindowHandle取也行。Winform 端发送 JSON 格式如{"cmd":"SetSpeed","value":2.5},Unity 解析后修改 Rigidbody 的 velocity,这就能实现“Winform 界面控制 Unity 物体速度”的典型演示。
6.3 同时跑多个 Unity 实例:句柄隔离与资源占用
有些项目要在 Winform 里放多个 3D 视口,比如对比两台设备的数字孪生状态。你不能只启动一个 exe 然后分屏,那样的输出窗口永远只有一个。正确做法是启动多个 Unity 进程,每个用不同的-parentHWND指向不同的 Panel。但要注意:Unity 编辑器构建默认使用相同的 player 身份,多个实例会争抢文件锁(比如日志文件、偏好设置)。启动参数加-logFile指向不同的日志文件,并在 Player Settings 里开启 “Allow Fullscreen”? 不,要禁用。然后每个实例的-screen-width不同,内存开销大是正常的,建议只开两个上限。
我踩过最深的坑是忘记释放消息里用 GlobalAlloc 分配的内存。同步 SendMessage 还好,一旦改成 PostMessage 异步,Unity 还没读你就 Free 了,直接导致崩溃。后来规定:所有跨进程命令统一走同步 SendMessage 或者命名管道,不会再用裸指针异步。这套方案上线半年没出过问题,希望对你有帮助。
本文还有配套的精品资源,点击获取