简介:这份Windows Capture插件面向需要在Unity工程中实时展现Windows桌面的开发者,适用于虚拟桌面、屏幕镜像、多窗口监控、会议共享等典型场景。资源共141个文件,压缩为zip格式,整体仅600KB:26个C#脚本构成核心控制逻辑,13个unity配置与3个prefab预制体负责场景搭建,4个mat材质配合shader、cginc文件处理显示效果,2个dll封装底层捕获能力,84个meta文件为Unity自动生成的导入标识。核心模块包括UwcManager、UwcWindowTexture、UwcDesktopLayouter等,覆盖窗口枚举、桌面纹理捕获与多窗口布局管理链路,接入后即可在场景中呈现实时桌面画面,也能根据项目需求做二次开发,省去从零研究桌面采集底层实现的成本。该包目前已有567人学习下载,特别适合具备一定Unity基础、希望快速集成桌面采集与同步能力的开发者参考使用。
1. Windows Capture插件:Unity里做桌面实时同步,关键不在抓屏而在“帧节奏”
做数字孪生驾驶舱和远程操作台时,经常遇到一个需求:把Windows桌面或某个业务窗口实时映射到Unity场景的屏幕上,画面要跟原桌面保持同步,不能越拉越远。Windows Capture插件做的就是这件事——通过系统级采集API在Unity里拿到桌面画面的逐帧纹理,再交给UGUI、RenderTexture或Shader做二次呈现。对做unity扩展和unity数字孪生项目的开发者来说,这几乎是进门第一课:选错捕获API,后面再怎么调延迟都补不回来。先给结论:桌面同步的观感不取决于抓屏API多新,而取决于帧池节奏和纹理上传路径是否匹配。
2. 抓屏方案三选一:GDI、DXGI 与 Windows Graphics Capture 的边界在哪
2.1 GDI BitBlt:适合做静态截图,不适合做帧级同步
最老的方案是GDI的BitBlt,从Windows XP时代一路用到今天。原理很简单:从桌面设备上下文(DC)里把像素复制到一张位图,再走CPU内存上传到GPU。一次全屏拷贝在1080p下大约要占用几十毫秒的CPU时间,4K屏会直接吃掉两个大核,帧率基本提不到30。
GDI方案最大的优点是兼容性强:远程桌面(RDP)会话里也能用,显卡驱动挂了也能用,老服务器上没有任何依赖。但它的缺点恰恰卡在实时同步的命门上——每一帧都是CPU到GPU的完整搬运,中间还要经过GDI的软件合成,延迟波动很厉害,画面撕裂也常见。我一般只在“按一下刷新一张桌面”的截图工具里用它,真正做连续同步时不会选这条路线。
2.2 DXGI Desktop Duplication:低延迟但“独占”特征限制很多场景
DXGI Desktop Duplication API(简称DD)是Windows 8引入的,走IDXGIOutputDuplication接口。它直接从显卡驱动层拿桌面图像,画面以GPU资源的形式返回,做些处理就能在Unity里显示,延迟可以压得很低。很多低延迟同屏工具和串流软件都基于它。
但这个API有个硬约束:对同一个输出(显示器)只能存在一个Duplication会话,只要你占住了,其他程序就再也拿不到桌面画面。想同时捕获主屏和副屏,得分别绑定到对应的IDXGIAdapter和IDXGIOutput上,多GPU机器尤其容易踩空。另外,RDP远程连接桌面时DD会直接失效,返回DXGI_ERROR_NOT_CURRENTLY_AVAILABLE,这在远程运维场景里很致命。
2.3 Windows Graphics Capture API:非独占、支持窗口级捕获,Unity 插件更倾向这条路
Windows 10 1809开始,系统提供了Windows.Graphics.Capture命名空间,也就是现在Windows Capture插件最常见的底层API。它有两个核心优势:第一是非独占,多个应用可以同时捕获同一桌面;第二是支持按窗口捕获,而不只是整块屏幕。这意味着你可以只同步某个业务软件的窗口,桌面其他内容不会穿帮。
这套API本质上是WinRT的,通过GraphicsCaptureItem描述捕获对象,配合Direct3D11CaptureFramePool管理帧的产出节奏。Unity里做数字孪生大屏时,控制台程序、监控画面、报表系统往往各自是独立窗口,用窗口级捕获可以做多路分屏,比截整块屏再裁剪省事得多。代价是它要求用户授权:必须通过GraphicsCapturePicker或者应用内主动声明捕获意图,系统隐私设置里的“图形捕获”权限也得打开。
2.4 方案选型对比
| 方案 | 延迟水平 | 纹理路径 | 捕获范围 | RDP会话 | 推荐场景 |
|---|---|---|---|---|---|
| GDI BitBlt | 高,波动明显 | CPU拷贝后上传GPU | 全屏或指定窗口矩形 | 可用但不流畅 | 低频截图、旧系统兜底 |
| DXGI Desktop Duplication | 低,稳定 | GPU到GPU,零拷贝潜力 | 只能整块显示器输出 | 不可用 | 低延迟直播、独占采集机 |
| Windows Graphics Capture | 中低,依赖帧池参数 | GPU到GPU | 窗口或显示器均可 | 不可用 | Unity桌面同步、多窗口分屏 |
选型结论很直接:Unity里做桌面实时展现,首选Windows Graphics Capture;DXGI更适合做成外部采集服务再转发数据;GDI就放弃吧。不过,选对API只是第一步,后面帧池和纹理上传的节奏才是真正拉开差距的地方。
3. 搭出可用的桌面同步链路:帧池、帧队列与 D3D11 纹理桥
3.1 创建捕获会话:GraphicsCaptureItem 与 Direct3D11CaptureFramePool
Windows Graphics Capture的调用链不长:先拿到GraphicsCaptureItem,再创建Direct3D11CaptureFramePool,然后通过CreateCaptureSession启动会话。Unity里的实现大概是这样的:
using System; using UnityEngine; using Windows.Graphics.Capture; using Windows.Graphics.DirectX; using Windows.Graphics.DirectX.Direct3D11; public class DesktopCaptureManager : MonoBehaviour { [Header("桌面捕获参数")] public int targetFps = 30; // 期望的同步帧率 public bool captureCursor = false; // 是否采集鼠标光标 private GraphicsCaptureItem _item; private Direct3D11CaptureFramePool _framePool; private GraphicsCaptureSession _session; public void BeginCapture(GraphicsCaptureItem item) { _item = item; // 用后台线程工作方式创建帧池 _framePool = Direct3D11CaptureFramePool.Create( Direct3D11CaptureFramePool.CreateFreeThreaded, DirectXPixelFormat.B8G8R8A8UIntNormalized, (uint)item.Size.Width, (uint)item.Size.Height, TimeSpan.FromMilliseconds(1000.0 / targetFps)); _framePool.FrameArrived += OnFrameArrived; _session = _framePool.CreateCaptureSession(item); _session.IsCursorCaptureEnabled = captureCursor; _session.StartCapture(); } public void StopCapture() { _session?.Dispose(); _framePool?.Dispose(); _session = null; _framePool = null; } }这里几个参数要说明一下。CreateFreeThreaded表示帧到达事件可能在后台线程触发,而不是被强制调度到Unity主线程,这是桌面捕获能保持低延迟的前提。像素格式用B8G8R8A8UIntNormalized,和Windows桌面合成器的原生格式一致,可以避免一次颜色转换。TimeSpan.FromMilliseconds(1000.0 / targetFps)控制帧池最快多久产出一帧,30fps就是33毫秒左右,这个值决定了“实时”的上限。
3.2 帧到达回调:队列有界、丢旧帧,延迟才不会跑飞
FrameArrived事件每来一帧,帧池里就有一个Direct3D11CaptureFrame可用。最常见的翻车做法是在回调里直接把帧内容拷进Unity纹理——回调线程不是主线程,Unity的多数API不允许这样调用。正确做法是入队,回到主线程的Update里消费。
using System.Collections.Concurrent; private readonly ConcurrentQueue<Direct3D11CaptureFrame> _pending = new ConcurrentQueue<Direct3D11CaptureFrame>(); private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args) { // 帧池里取最新一帧,取不到就跳过本轮 var frame = sender.TryGetNextFrame(); if (frame == null) return; // 队列积压超过3帧说明消费速度跟不上,直接丢帧 if (_pending.Count >= 3) { frame.Dispose(); return; } _pending.Enqueue(frame); } private void Update() { if (_pending.TryDequeue(out var frame)) { using (frame) { // frame.Surface 是 IDirect3DSurface,需要拷贝到 Unity 纹理 CopySurfaceToTexture(frame.Surface, _outputTexture); } } }队列的有界设计是最关键的一个小细节。如果无条件入队,帧生产速度略大于消费速度时,积压就会越来越严重,延迟从几百毫秒滚到几秒,最终彻底不同步。设3帧上限,消费不过来就直接丢新帧,画面卡一下但延迟不会失控。
3.3 D3D11纹理桥:把桌面表面搬进Unity纹理的最小实现
从frame.Surface拿到的是IDirect3DSurface,它不是Unity可以直接用的Texture2D。常见做法是做一个原生D3D11桥接插件:C#侧把Surface对象当IUnknown指针传过去,C++侧查询出ID3D11Texture2D,然后CopyResource到Unity提供的纹理上。
// DesktopTextureBridge.cpp #include "IUnityGraphicsD3D11.h" static ID3D11Device* g_device = nullptr; static ID3D11DeviceContext* g_context = nullptr; extern "C" void UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* unityInterfaces) { auto* d3d11 = unityInterfaces->Get<IUnityGraphicsD3D11>(); g_device = d3d11->GetDevice(); g_device->GetImmediateContext(&g_context); } extern "C" void UNITY_INTERFACE_API CopySurfaceToUnityTexture( IUnknown* surface, ID3D11Texture2D* unityTexture) { if (!surface || !unityTexture) return; Microsoft::WRL::ComPtr<ID3D11Texture2D> sourceTexture; if (FAILED(surface->QueryInterface(IID_PPV_ARGS(&sourceTexture)))) return; g_context->CopyResource(unityTexture, sourceTexture.Get()); }Unity侧的_outputTexture需要提前创建,而且必须保证和桌面纹理有相同的D3D11资源描述,尤其是格式。桌面是B8G8R8A8,Unity里创建RenderTexture时要把颜色格式对应到BGRA32,或者干脆在创建后通过RenderTexture.GetNativeDepthBufferPtr拿到D3D11纹理指针传给这个桥函数。
这个桥是整个方案里最容易在驱动层出幺蛾子的地方。CopyResource要求源和目标纹理尺寸、格式、MSAA采样数完全一致,任何一项不匹配都会返回错误或黑屏。所以我在项目里一般先读sourceTexture的GetDesc,再反向创建Unity纹理,宁可多花几毫秒做一次匹配检查,也不要让用户对着黑屏猜。
3.4 启动前的三个检查项
第一,GraphicsCaptureItem不能为空。用GraphicsCapturePicker让用户点选窗口时,用户取消就会返回null,此时直接启动会抛异常。第二,确认系统里“图形捕获”权限打开,路径在Windows设置→隐私和安全性→捕获,关着的话StartCapture之后永远等不到第一帧。第三,窗口最小化时Size.Width/Height可能变成0,创建帧池会直接报错,启动前要做一次尺寸合法性判断。
4. 调参让画面不卡不糊:帧率、纹理格式与 DPI 缩放的三个变量
4.1 帧率不是越高越好:30fps起步,先看消费端瓶颈
targetFps这个参数直观上就是同步的流畅度,但实际调的时候不能只看它。桌面捕获的帧率上限由帧池的刷新周期决定,下限由Unity主循环和纹理拷贝速度决定。Unity的Update本身依赖vsync,如果项目里开了垂直同步,那么主循环就被锁在显示器刷新率上,捕获帧来了也不一定赶得上当帧渲染,延迟无形中多半帧。
我一般会先把targetFps设为30,跑通后再看两个指标:一是_pending队列的积压情况,如果经常丢帧,说明拷贝链路是瓶颈,盲目调高targetFps只是让帧池白白产帧;二是看Profiler里CopySurfaceToTexture的耗时,超过了5毫秒就要考虑把拷贝挪到低优先级线程或者降低分辨率。
还有个小技巧:桌面画面长时间不变时,Windows Graphics Capture也会持续产帧,只是帧内容相同。完全可以加一个哈希或阀值判断,内容没变就跳过拷贝,能省下不少GPU带宽。这个优化对UI基本不变的监控大屏特别明显。
4.2 纹理格式:桌面为什么经常变成紫红色
很多人第一次把捕获画面显示出来,发现整个桌面都蒙了一层紫红色调,文字边缘还有奇怪的色边。这个现象在unity社区里常被描述成“unity材质变成紫红色”,其实根因十有八九是红色和蓝色通道颠倒了。
桌面合成器输出的原生格式是B8G8R8A8,即内存里第一个字节是蓝色。Unity的Texture2D常规格式是R8G8B8A8,第一个字节是红色。如果不做转换,直接把BGRA数据塞进RGBA纹理,显示时红蓝互换,整个画面就会呈现紫红偏色。解决的办法有三种,按推荐顺序排:
一是C#侧把ReadPixels出来的字节做一次通道重排,简单但CPU开销大。二是创建纹理时用TextureFormat.BGRA32,Unity的D3D11后端会直接对齐到B8G8R8A8_UIntNormalized,这是最省事的做法。三是保持RGBA32但材质上禁用sRGB标记,同时接受颜色轻微偏移,适合对色准要求不高的场景。
处理偏色时还要留意sRGB的坑。桌面纹理经过合成器后已经是sRGB空间的颜色了,Unity在Linear光照模式下会假设纹理是sRGB并做一次反Gamma校正,结果就是画面整体发亮或发暗。正确做法是在导入或创建纹理时把sRGB (Color Texture)关掉,让颜色数据原样通过,需要亮度校正时再交给后处理。
4.3 DPI缩放与多屏坐标:桌面尺寸不等于逻辑像素
Windows默认对高分屏做缩放,常见的150%、200%缩放会让“物理像素”和“逻辑像素”产生偏差。GraphicsCaptureItem.Size返回的是物理像素尺寸,但你在Unity里做UI布局时拿到的是若干缩放后的逻辑尺寸。直接拿逻辑尺寸去做RawImage的宽高比,画面会被裁掉一圈或者拉伸变形。
捕获多显示器时这个问题更明显。每台显示器的DPI可能是独立的,比如主屏150%缩放、副屏100%缩放,两路捕获的画面放到同一个UI里,如果不按各自的DPI换算,视觉上两台屏的桌面文字大小会不一致。我的做法是每路捕获单独记一个缩放系数:
// 从系统拿到窗口或显示器的DPI uint dpi = GetDpiForWindow(handle); float scaleFactor = dpi / 96.0f; // RawImage的实际显示宽高按物理尺寸除以缩放系数 float displayWidth = captureItem.Size.Width / scaleFactor; float displayHeight = captureItem.Size.Height / scaleFactor;这里GetDpiForWindow是Win32 API,Unity里通过[DllImport("user32.dll")]就能调用。多屏拼接时,把每一路的displayWidth/displayHeight作为UI尺寸,才能保证不同缩放率的屏幕在场景里看起来比例一致,这也是数字孪生大屏项目里最容易忽略的一步。
5. Windows Capture 常见问题排查:四个翻车现场与解决办法
5.1 会话启动成功但画面一直黑屏
现象:StartCapture()没有报错,帧到达事件也触发了,但Unity里显示全黑。
原因:最常见的是系统权限没开。Windows设置→隐私与安全→捕获→“允许桌面应用访问画面”这一项关闭时,捕获会话拉不到任何数据,但API本身不报错,表现就是黑屏。其次是GraphicsCaptureItem创建后又被系统回收,句柄失效。
解决:启动前检测权限,最简单的方式是主动调用一次GraphicsCapturePicker.PickSingleItemAsync()让用户重新授权;如果已授权,用户会看到系统的授权动画然后立刻返回。句柄失效的情况则要把GraphicsCaptureItem保存在类字段里防止被GC回收,不能只传空指针给原生层。
5.2 画面偏紫红,文字有彩色边缘
现象:桌面内容能看到,但整体色调偏紫,红蓝互换。
原因:前面讲过,桌面BGRA格式被Unity按RGBA解析,蓝色通道和红色通道对调了。文字边缘的彩边则是因为透明通道或子像素渲染被错误处理。
解决:新建纹理时明确TextureFormat.BGRA32,并确认帧池创建参数里像素格式是B8G8R8A8UIntNormalized。如果用的是RawImage显示,把材质球的纹理导入设置里sRGB (Color Texture)关掉。改了之后重启播放,偏色立刻消失。
5.3 延迟越跑越大,十分钟后桌面操作明显慢半拍
现象:刚启动时同步效果还行,过一段时间延迟肉眼可见地增长,操作鼠标后Unity画面要过一两秒才跟上。
原因:帧队列无界增长。生产线程持续产帧,消费线程一旦遇到Unity主循环卡顿,积压帧就会不断堆积,每一帧都含有完整的GPU资源,越堆越卡。
解决:队列设为有界,积压超过3帧直接丢新帧;Direct3D11CaptureFrame用完必须Dispose(),否则GPU资源一直占用。再把纹理拷贝从事件回调挪到主线程Update里,保证Unity API的调用都在主线程上。做完这三步,延迟曲线基本能压平。
5.4 窗口最小化或遮挡后帧停止更新
现象:捕获某个应用窗口时,窗口被最小化或完全遮挡,Unity画面定格在最后一帧,之后不再刷新。
原因:Windows Graphics Capture对窗口捕获的语义是“捕获窗口当前可见内容”。窗口最小化时没有内容可以合成,系统自然不产帧;完全遮挡时,被遮挡区域返回空白或旧帧,取决于合成器策略。
解决:这是窗口捕获的固有限制,不能靠调参数绕过。要么改成捕获显示器而不是窗口,桌面整体内容会一直有帧;要么给捕获链路加一个超时心跳,帧间隔超过500毫秒就触发一次重新创建会话。注意不要在最小化时反复重建帧池,那会造成短暂卡顿。
6. 验证同步延迟的秒表法,和顺带能用的三招进阶技巧
先聊一个没有门槛但很有效的验证方法。在Windows桌面放一个秒表窗口,比如系统自带的闹钟和时钟,Unity这边把捕获画面用RawImage显示出来,然后用另一台手机对着显示器和物理秒表同屏拍一张照片,读数相减就是端到端延迟。这个差值包含了系统采集、纹理上传、Unity渲染、屏幕输出全链路。测的时候要测三组取平均值,人眼反应速度大约在几十毫秒,读数差在80到120毫秒之间,说明链路是健康的;超过200毫秒就要回头检查队列积压和CopyResource耗时了。
进阶技巧第一条,多路窗口同步。项目里同时捕获多个窗口时,每路帧到达的时间点不一致,直接独立显示会导致操作端和呈现端错位。Direct3D11CaptureFrame自带一个SystemRelativeTime时间戳,是捕获帧的相对时间,记录每一路的最近帧时间戳,在挂载UI时用同一个参考时钟做对齐,能把多屏误差控制在几十毫秒内。第二条,捕获画面和UI混合。把RawImage放进Unity的Canvas里,就可以用unity UI框架的图文混排、数字滚轮这些组件给桌面画面上叠加标注和数据条,数字孪生大屏上常见的实时参数悬浮框就是这么做的,注意Canvas的渲染模式改为Screen Space - Camera,和RawImage保持同一相机,避免UI浮层与画面错位。第三条,低画质模式保帧率。当捕获内容只是做背景漫游或缩略图总览时,可以先把捕获分辨率降一半,比如只取item.Size的1/4创建帧池,视觉差异不大,GPU带宽占用下降一大截。
这套流程从选型、接入到调参和验证,已经跑过不只一次。说实话,最让我印象深刻的不是API的坑,而是“实时”这个词的魔幻——哪怕每帧都在正常产帧,只要某一环的节奏没对上,桌面就仿佛在延迟里漂流。做个有界队列,盯住纹理格式,再用秒表测一次真实延迟,这套基本功比任何高级技巧都值得先学会。希望帮到你。
本文还有配套的精品资源,点击获取