Unity中的实时图像捕捉

2016年12月06日 18:01 0 点赞 0 评论 更新于 2025-11-21 20:59

引言

在游戏或图形应用程序中,从引擎中截取视频或屏幕截图是一项非常实用的分享功能,同时对于错误报告、社交分享或跟踪开发进度也大有裨益。在Unity中,直接从游戏里截取视频帧并非难事,但对于开发VR内容并期望提供优质用户体验的开发者而言,维持良好的性能至关重要。本文将详细阐述如何仅借助C#和Unity API,在实时截取合适的《Tilt Brush》视频的同时,确保实现舒适VR体验所需的90Hz高刷新率。

实现思路与初步尝试

我们通过自制的原生插件达成了该功能,不过在着手实现原生插件之前,先对C# API中存在的问题进行了研究。在Unity里捕捉帧缓冲区需要运用RenderTexture和Texture2D,之后复制像素相对容易。以下是一种初级方法的代码示例:

// 设置相机、纹理及RenderTexture
Camera cam = ...;
Texture2D tex = ...;
RenderTexture rt = ...;
// 渲染到RenderTexture
cam.targetTexture = rt;
cam.Render();
// 读取像素到纹理
RenderTexture.active = rt;
tex.ReadPixels(rectReadPicture, 0, 0);
// 读取纹理到数组
Color[] framebuffer = tex.GetPixels();

这种方法每帧执行时性能表现不错,但在VR体验中却不可行。其运行缓慢的根本原因如下:

  1. GetPixels() 会阻塞,直至 ReadPixels() 完成。
  2. 刷新GPU时,ReadPixels() 会阻塞。
  3. 每次调用 GetPixels() 都会分配一个新数组,这会导致垃圾回收器引发卡顿。

问题分析与解决

解决 GetPixels()ReadPixels() 阻塞问题

ReadPixels()GetPixels() 之间设置一帧的延迟,可避免 GetPixels() 阻塞直到 ReadPixels() 完成的问题,因为任何类型的传输都会在需要访问这些值之前完成。

处理 ReadPixels() 导致的GPU刷新问题

当向GPU发出命令或绘制调用时,这些命令会被批处理到驱动程序的批量命令缓冲区中。“刷新GPU”意味着等待当前命令缓冲区中的所有命令执行完毕。CPU和GPU可以并行运行,但在GPU刷新时,CPU会处于空闲状态并等待GPU空闲,这就是所谓的“同步点”。

若使用NVIDIA的Nsight等性能分析工具跟踪Unity对DirectX的使用,会发现 ReadPixels() 是通过调用 CopySubresourceRegion 后紧接着调用 MapUnmap 来实现的。Map 读取 CopySubresourceRegion 结果较为高效。正如DirectX文档所述,GPU复制可以流式进行,并且能与CPU同时执行。但如果在复制完成之前请求数据,唯一能返回相同值的方法就是完成所有待处理命令,从而强制CPU - GPU同步。

从Nsight性能图中能明显观察到这种情况,Unity API强制同步的过程较为缓慢。不过,如果GPU已经处于空闲状态,同步时间应该仅局限于传输成本,这将远小于等待所有或部分帧渲染完成所需的时间。

在本例中,SteamVR也会强制同步,所以在某个时刻GPU会处于空闲状态。要了解这一点,需要对渲染引擎有深入的认识,类似Nsight的Frame Debugger或RenderDoc工具可助力探索其中的奥秘。

尝试在 OnPreRender() 方法中进行操作,看似可行,但实际仅轻微提升了性能,在开始传输之前,CPU仍会阻塞以等待某些任务完成。由于场景中并非只有一台相机,所以GPU在 OnPreRender() 期间不一定处于空闲状态。

我们尝试在SteamVR渲染循环协程中插入一个回调到自己的代码来复制像素,但测试后同步时间仍为2毫秒。深入框架底层跟踪发现,存在早期的深度通道、阴影通道等,额外的相机才是真正的问题所在。视频捕捉相机在SteamVR渲染循环之外渲染,由于渲染循环实现了运行启动算法,额外的相机既打乱了运行启动,也使得GPU完全没有空闲时间。

优化同步时间

最终,我们将额外的渲染和像素复制都移至SteamVR渲染循环中完成,此时同步时间已减少到仅与传输相关,从之前的2毫秒降至0.5毫秒。最终的事件序列如下:

  1. 渲染帧
  2. Blit转为Render Texture作为后期效果
  3. 帧结束
  4. 在SteamVR渲染循环中,将Render Texture复制到Texture2D
  5. 在SteamVR渲染循环中,渲染第二个相机
  6. 等待一帧
  7. 在SteamVR渲染循环中,复制纹理Bit到C#中

需要注意的是,实现该技术需要三帧(对于90Hz的显示屏,截屏限制为30FPS),不过若应用没有内存限制,这些步骤也可以流式进行。此时,同步时间仅与截取的像素数量以及PCle总线的速度有关,这种实现方式仅有0.5毫秒的消耗(每帧预算的5%),处于可接受范围。但将像素复制回C#还有额外的CPU消耗,总消耗约为3毫秒,达到了每帧预算的30%,不过我们已预留了用于运行截屏的成本,因为开始运行后CPU可能处于空闲状态。

垃圾回收问题及解决思路

即便同步时间得到了优化,但每隔20帧左右仍会出现卡顿。查看Unity Profiler发现,GC会出现一些峰值,每个大概12毫秒。这是因为每次调用 GetPixels() 都会分配内存并将其移交给调用者,这些内存无法在下次调用 GetPixels() 时重用,所以每次帧截取都会生成堆内存垃圾,然后根据帧缓冲区的大小,每隔20帧左右被回收。

如果每帧执行垃圾回收会产生重大消耗,这不仅与垃圾大小有关,还和分配的内存有关。虽然将消耗减少到了7毫秒(帧预算的70%),但依旧很慢。我们有一个大胆的想法:如果垃圾回收是线程安全的,或许能在后台线程运行,从而避免阻塞主渲染线程。实际上垃圾回收是线程安全的,但渲染线程分配任何内存都会再次阻塞。在本例中,Unity是唯一需要分配内存的一方,所以该方案理论上可行,目前的不足主要在于垃圾回收的消耗。

后处理特效与超采样

最后,我们应用了模糊和花絮的后处理特效,以匹配此前宣传片中的风格。此外,视频采用2倍超采样,制作高清内容进行分享时采用4倍超采样。不过,超采样会使截取视频时头显设备的分辨率下降。

作者信息

孟子菇凉

孟子菇凉

共发布了 3994 篇文章