Unity协程WaitForEndOfFrame:核心机制、5大应用技巧与性能优化全解析
1. 项目概述在Unity开发中协程Coroutine是处理异步逻辑和延时任务的利器而WaitForEndOfFrame作为YieldInstruction家族中一个特殊且关键的成员其重要性常常被低估。很多开发者尤其是刚接触Unity不久的朋友可能会把它简单地理解为“等一帧”但实际上它的触发时机、执行逻辑以及与渲染管线的深度耦合决定了它在特定场景下无可替代的地位同时也潜藏着性能陷阱。简单来说WaitForEndOfFrame的作用是让协程挂起直到当前帧的所有渲染工作——包括所有摄像机的渲染、UI元素的绘制以及后处理等——全部完成后再继续执行后续代码。这听起来很直接但“渲染完成”这个时间点背后涉及到Unity引擎主循环、GPU命令提交、缓冲区交换等一系列复杂流程。理解它不仅能帮你精准地实现如截图、UI布局同步等高级功能更能让你在性能优化时避免因滥用而导致的协程堆积、帧率波动甚至GC垃圾回收压力激增。这篇文章我将结合自己十多年在Unity项目中的实战经验为你深度拆解WaitForEndOfFrame。我会从它的核心机制讲起剖析它在Unity帧生命周期中的确切位置并通过5个核心技巧展示如何高效、安全地使用它。更重要的是我会分享一系列性能优化策略告诉你什么时候该用它什么时候有更好的替代方案以及如何规避那些教科书里不会写的“坑”。无论你是想实现一个稳定的截图系统还是优化UI刷新逻辑或是单纯想深入理解Unity的协程调度这篇文章都将为你提供可直接落地的参考。2. WaitForEndOfFrame 的核心机制与执行时机要真正用好WaitForEndOfFrame绝不能停留在“yield一下”的层面。我们必须深入到Unity引擎的帧循环内部看清它究竟在哪个环节被唤醒。这就像外科医生做手术必须清楚人体的解剖结构才能下刀精准避免伤及无辜。2.1 Unity 帧生命周期与协程调度Unity的主循环可以简化为一个按固定顺序执行的事件流。每一帧引擎大致会经历以下阶段FixedUpdate物理更新 -Update逻辑更新 -LateUpdate后期逻辑更新 -渲染包括摄像机渲染、UI渲染等 -渲染后处理-缓冲区交换Present将图像显示到屏幕。协程的调度就巧妙地穿插在这个流水线中。当你执行yield return null或yield return 0时协程会在当前帧所有Update和LateUpdate调用完毕后但在渲染开始前恢复。而yield return new WaitForFixedUpdate()则会在下一帧的FixedUpdate之后、Update之前恢复。那么WaitForEndOfFrame呢它的名字已经揭示了答案它在“帧的结束”时恢复。更精确地说它是在所有摄像机的渲染命令包括后处理都已提交给GPU并且当前帧的渲染工作被认为“完成”之后但在最终图像被提交到屏幕缓冲区交换之前的那一刻被触发。这是一个非常靠后的时间点确保了你能获取到当前帧最完整的视觉结果。这里有一个常见的误解认为WaitForEndOfFrame是在屏幕刷新比如VSync之后。其实不然它发生在GPU完成本帧绘制之后屏幕显示之前。理解这一点对于处理与屏幕显示相关的操作至关重要。2.2 多相机渲染场景下的行为在单相机项目中WaitForEndOfFrame的行为相对直观。但在复杂的项目中我们经常使用多个相机进行分层渲染比如一个主相机渲染3D场景一个UI相机渲染UI。此时WaitForEndOfFrame会等待所有启用的、且渲染目标为屏幕或渲染纹理的相机都完成了它们的OnPostRender回调之后才会触发。这意味着如果你在一个协程中yield return new WaitForEndOfFrame()那么恢复执行时所有相机的渲染结果都已经就绪。这对于需要合并多个相机渲染结果的截图功能来说是唯一可靠的方法。如果你在某个相机的OnPostRender方法里直接截图你只能得到该相机的画面可能会遗漏其他相机的内容导致最终图像不完整。2.3 通过实验验证执行时机理论需要实践验证。我们可以写一个简单的脚本来观察WaitForEndOfFrame的执行顺序using UnityEngine; using System.Collections; public class FrameTimingTest : MonoBehaviour { void Update() { Debug.Log($[Update] Frame: {Time.frameCount}); } void LateUpdate() { Debug.Log($[LateUpdate] Frame: {Time.frameCount}); } void OnPostRender() { // 注意只有附加在相机上的脚本才会调用此方法 Debug.Log($[OnPostRender] Frame: {Time.frameCount}); } IEnumerator Start() { while (true) { yield return new WaitForEndOfFrame(); Debug.Log($[WaitForEndOfFrame] Frame: {Time.frameCount} - Time: {Time.time}); } } }将这段脚本挂载到场景中的主相机上运行观察控制台输出。你会发现日志顺序永远是[Update]-[LateUpdate]-[OnPostRender]-[WaitForEndOfFrame]。 这清晰地证明了WaitForEndOfFrame是在OnPostRender之后执行的即一帧渲染工作的最末尾。实操心得调试渲染时序问题时不要只依赖Debug.Log。在复杂场景下日志输出本身有微小延迟。更精确的方法是使用System.Diagnostics.Stopwatch记录时间戳或者利用Unity Profiler的“Timeline”视图可以直观地看到每一帧中各个事件包括协程恢复的具体耗时和先后关系。3. 五大核心应用技巧与代码实践理解了原理我们来看看WaitForEndOfFrame在哪些场景下能大显身手。我将这些场景归纳为五大核心技巧并附上经过实战检验的代码。3.1 技巧一实现稳定可靠的屏幕截图这是WaitForEndOfFrame最经典的应用。截图必须在所有渲染完成后进行否则你可能会截到半成品——比如UI还没画上去或者后处理效果还没应用。using UnityEngine; using System.Collections; using System.IO; public class AdvancedScreenshot : MonoBehaviour { public KeyCode screenshotKey KeyCode.P; public string directoryName Screenshots; public bool includeUI true; // 是否包含UI public int superSize 1; // 超采样倍数用于高清截图 private string _savePath; void Start() { _savePath Path.Combine(Application.persistentDataPath, directoryName); if (!Directory.Exists(_savePath)) { Directory.CreateDirectory(_savePath); } Debug.Log($截图将保存至: {_savePath}); } void Update() { if (Input.GetKeyDown(screenshotKey)) { StartCoroutine(CaptureScreenshotCoroutine()); } } IEnumerator CaptureScreenshotCoroutine() { // 关键等待帧渲染完全结束 yield return new WaitForEndOfFrame(); // 1. 创建纹理来存储屏幕像素 // 使用RGB24格式节省内存如果需要透明度则用ARGB32 Texture2D screenTex new Texture2D(Screen.width * superSize, Screen.height * superSize, TextureFormat.RGB24, false); // 2. 读取像素 // ReadPixels的源矩形是基于屏幕左下角为(0,0)的 screenTex.ReadPixels(new Rect(0, 0, Screen.width * superSize, Screen.height * superSize), 0, 0); screenTex.Apply(); // 应用像素更改使纹理可读 // 3. 编码为PNG或JPG byte[] bytes screenTex.EncodeToPNG(); // 无损文件大 // byte[] bytes screenTex.EncodeToJPG(95); // 有损文件小 // 4. 保存文件 string filename $Screenshot_{System.DateTime.Now:yyyyMMdd_HHmmssfff}.png; string fullPath Path.Combine(_savePath, filename); File.WriteAllBytes(fullPath, bytes); Debug.Log($截图已保存: {fullPath} (大小: {bytes.Length / 1024} KB)); // 5. 清理纹理避免内存泄漏 Destroy(screenTex); // 可选在移动端上保存后刷新相册仅Android/iOS #if UNITY_ANDROID || UNITY_IOS // 这里可以调用原生插件刷新媒体库 #endif } }为什么这里必须用WaitForEndOfFrame因为ReadPixels读取的是当前帧缓冲区的数据。如果在Update或LateUpdate中调用渲染可能还未开始或未完成读取到的将是上一帧或残缺的图像。WaitForEndOfFrame保证了我们读取的是“最终成品”。注意事项ReadPixels是一个同步的CPU阻塞调用它会等待GPU完成所有绘制命令将帧缓冲区数据回读到CPU内存。对于大分辨率或高超采样的截图这个过程可能耗时几十甚至上百毫秒造成明显的卡顿。在移动端或性能敏感的场景需谨慎使用或考虑异步截图方案。3.2 技巧二确保UI布局更新后获取准确尺寸在Unity的UGUI或UI Toolkit中改变UI元素的属性如位置、大小、文本内容后其实际的布局计算和网格重建可能不是立即完成的。如果你在修改后立刻去读取rectTransform.rect或Text.preferredWidth很可能得到的是旧值。using UnityEngine; using UnityEngine.UI; using System.Collections; public class UILayoutSync : MonoBehaviour { public RectTransform dynamicPanel; public Text dynamicText; public RawImage previewImage; // 用于显示布局后的效果 public void ChangeContentAndMeasure() { // 错误做法立即读取 // dynamicText.text 这是一个非常非常非常长的文本用来测试自动换行后的宽度。; // float wrongWidth dynamicText.preferredWidth; // 这里得到的可能是上一帧的宽度 // 正确做法使用协程等待布局刷新 StartCoroutine(ChangeContentAndMeasureCoroutine()); } IEnumerator ChangeContentAndMeasureCoroutine() { // 第一步修改UI内容 dynamicText.text 这是一个非常非常非常长的文本用来测试自动换行后的宽度。; dynamicPanel.sizeDelta new Vector2(300, 200); // 第二步等待一帧让Unity完成布局和网格重建 // yield return null; // 通常足够但极端复杂的UI可能仍需 WaitForEndOfFrame yield return new WaitForEndOfFrame(); // 更保险的选择确保所有CanvasRenderer都已完成 // 第三步此时可以安全读取准确的布局信息 float correctWidth dynamicText.preferredWidth; float correctHeight dynamicText.preferredHeight; Vector2[] corners new Vector2[4]; dynamicPanel.GetWorldCorners(corners); // 获取世界坐标下的四个角 Debug.Log($文本实际需求尺寸: {correctWidth}x{correctHeight}); Debug.Log($面板世界坐标左下角: {corners[0]}); // 第四步基于准确尺寸进行后续操作例如调整其他元素或截图 if (previewImage ! null) { StartCoroutine(CaptureUISectionCoroutine(dynamicPanel)); } } IEnumerator CaptureUISectionCoroutine(RectTransform targetRect) { yield return new WaitForEndOfFrame(); // ... 截图逻辑可以只截取targetRect对应的屏幕区域 } }核心原理UI的网格重建Canvas.BuildBatch和布局计算LayoutRebuilder通常发生在渲染阶段之前但具体时机由Canvas的渲染模式决定。WaitForEndOfFrame提供了一个绝对安全的屏障确保所有Canvas的更新批次都已提交。3.3 技巧三渲染到纹理RenderTexture的同步读取当你使用Camera.targetTexture或将渲染结果输出到RenderTexture时你可能需要在渲染完成后立即读取纹理中的像素数据进行处理如OCR识别、图像分析。同样读取操作必须等待该相机的渲染命令在GPU上执行完毕。using UnityEngine; using System.Collections; public class RenderTextureReader : MonoBehaviour { public Camera renderCamera; private RenderTexture _rt; private Texture2D _readTex; void Start() { // 创建一个渲染纹理 _rt new RenderTexture(512, 512, 24, RenderTextureFormat.ARGB32); _rt.Create(); renderCamera.targetTexture _rt; _readTex new Texture2D(512, 512, TextureFormat.RGBA32, false); // 开始每帧读取的协程 StartCoroutine(ReadRenderTextureEachFrame()); } IEnumerator ReadRenderTextureEachFrame() { while (true) { // 等待该相机以及其他所有相机渲染完成 yield return new WaitForEndOfFrame(); // 将当前激活的渲染目标切换回我们的RT // 这是关键步骤因为WaitForEndOfFrame之后渲染目标可能已是屏幕 RenderTexture.active _rt; // 从激活的RT中读取像素 _readTex.ReadPixels(new Rect(0, 0, _rt.width, _rt.height), 0, 0); _readTex.Apply(); // 恢复渲染目标可选但是个好习惯 RenderTexture.active null; // 此时_readTex包含了renderCamera这一帧渲染的结果 // 可以进行图像处理、保存或上传到服务器等操作 // ProcessTextureData(_readTex); yield return null; // 下一帧继续 } } void OnDestroy() { if (_rt ! null) _rt.Release(); if (_readTex ! null) Destroy(_readTex); } }重要提示RenderTexture.active是一个全局状态。在读取RenderTexture之前必须正确设置它并在读取完成后及时置空否则会影响后续的渲染逻辑可能导致屏幕一片漆黑或其他渲染错误。这是一个非常容易踩坑的地方。3.4 技巧四与后处理Post-processing效果的配合如果你使用了后处理堆栈如Unity的Post-processing V2或URP/HDRP的后处理一些效果如运动模糊、TAA可能需要多帧数据才能完成。在效果完全应用后再进行截图或数据提取才能得到预期效果。// 假设我们想在应用了Bloom和Color Grading后截图 IEnumerator CaptureWithPostProcessing() { // 简单地等待一帧可能不够因为某些后处理有延迟 // yield return null; // 等待帧结束确保所有Image Effect在OnRenderImage中或Renderer Features在SRP中都已执行完毕 yield return new WaitForEndOfFrame(); // 此时截图能确保包含完整的后处理效果 Texture2D tex new Texture2D(Screen.width, Screen.height); tex.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0); tex.Apply(); // ... 保存tex }3.5 技巧五资源清理与状态重置的合适时机有些资源清理或全局状态重置操作如果放在Update中可能会干扰同一帧内后续的渲染逻辑。将它们放在帧末执行可以确保本帧所有依赖该状态的渲染都已结束。private ListGameObject _objectsToDestroyAtFrameEnd new ListGameObject(); private bool _resetRenderStateFlag false; void Update() { // 在Update中标记需要销毁的对象或重置的状态 if (someCondition) { _objectsToDestroyAtFrameEnd.Add(someObject); _resetRenderStateFlag true; } } IEnumerator CleanupCoroutine() { while (true) { yield return new WaitForEndOfFrame(); // 安全地清理本帧标记的对象 foreach (var obj in _objectsToDestroyAtFrameEnd) { if (obj ! null) Destroy(obj); } _objectsToDestroyAtFrameEnd.Clear(); // 安全地重置渲染状态 if (_resetRenderStateFlag) { GL.Clear(true, true, Color.black); // 例如清屏 _resetRenderStateFlag false; } } }4. 性能陷阱与深度优化策略WaitForEndOfFrame虽好但绝不能滥用。不当的使用是性能问题的温床。下面我们来剖析常见的陷阱和优化策略。4.1 陷阱一协程堆积与主线程阻塞这是最常见的问题。如果在Update中每帧都启动一个包含WaitForEndOfFrame的协程你会迅速创建大量活跃的协程实例。每个协程都是一个IEnumerator对象由Unity引擎每帧进行调度检查。虽然单个协程开销不大但成百上千个协程的调度开销会累积严重消耗CPU时间。反面案例void Update() { // 每帧都启动一个新的协程灾难 if (Input.GetMouseButton(0)) { StartCoroutine(HeavyTaskAtFrameEnd()); } } IEnumerator HeavyTaskAtFrameEnd() { yield return new WaitForEndOfFrame(); // 执行一些耗时操作如读取像素、复杂计算 PerformHeavyOperation(); }优化策略1单例协程与标志位对于每帧都需要在帧末执行的任务应该用一个常驻的协程来处理通过标志位通信。private bool _needsFrameEndProcessing false; private HeavyData _dataToProcess; void Update() { if (Input.GetMouseButton(0)) { _needsFrameEndProcessing true; _dataToProcess GatherData(); } } IEnumerator PersistentFrameEndCoroutine() { while (true) { yield return new WaitForEndOfFrame(); if (_needsFrameEndProcessing) { PerformHeavyOperation(_dataToProcess); _needsFrameEndProcessing false; } } }优化策略2使用对象池管理协程任务如果确实需要处理多个独立的帧末任务可以实现一个简单的任务队列由一个主协程消费。private QueueAction _frameEndActions new QueueAction(); public void ScheduleFrameEndTask(Action task) { lock (_frameEndActions) // 注意线程安全 { _frameEndActions.Enqueue(task); } } IEnumerator FrameEndTaskProcessor() { while (true) { yield return new WaitForEndOfFrame(); lock (_frameEndActions) { while (_frameEndActions.Count 0) { var action _frameEndActions.Dequeue(); action?.Invoke(); } } } }4.2 陷阱二GPU-CPU同步与帧率波动如前所述ReadPixels、从RenderTexture读取数据等操作会强制CPU等待GPU完成当前帧的所有绘制命令。这个等待点被称为“GPU-CPU同步点”。如果GPU负载很重比如在渲染复杂场景这个等待时间会很长直接导致当前帧的CPU部分被拉长整体帧时间Frame Time增加表现为帧率下降或卡顿。监控方法在Unity Profiler的GPU模块中如果你看到WaitForPresent或类似的GPU等待时间很长并且在对应帧的CPU主线程上有一个明显的“空白”等待期很可能就是由这类同步读取操作引起的。优化策略异步读取与多帧延迟异步GPU回读 (Async GPU Readback)现代图形API如Vulkan, DX12, Metal和较新版本的Unity支持异步回读。在Unity中你可以使用Graphics.ConvertTexture配合AsyncGPUReadback类来非阻塞地获取纹理数据。这能极大缓解同步等待。AsyncGPUReadback.Request(renderTexture, 0, TextureFormat.RGBA32, OnCompleteReadback); // ... 在回调函数OnCompleteReadback中处理数据双缓冲/三缓冲对于连续截图或视频录制不要每帧都读。可以先将渲染结果复制到一个备用的RenderTexture中然后在后续的某一帧不一定是下一帧再读取这个备用纹理。这样可以将读取压力分摊到多个帧中避免单帧卡顿。降低频率非必要不每帧读取。例如截图功能可以限制为每秒最多一次。4.3 陷阱三内存分配与GC压力仔细观察之前的截图代码Texture2D screenTex new Texture2D(...); // 每帧分配 byte[] bytes screenTex.EncodeToPNG(); // 每帧分配在频繁操作比如录制视频时这会导致海量的临时Texture2D和byte[]对象被分配在托管堆上很快触发垃圾回收GC。GC会“Stop-the-World”导致游戏卡顿。优化策略对象复用与池化复用Texture2D如果截图分辨率固定可以预先创建一个Texture2D在每帧的WaitForEndOfFrame后复用它。private Texture2D _cachedScreenTex; IEnumerator CaptureScreenshotCoroutine() { yield return new WaitForEndOfFrame(); if (_cachedScreenTex null || _cachedScreenTex.width ! Screen.width || _cachedScreenTex.height ! Screen.height) { if (_cachedScreenTex ! null) Destroy(_cachedScreenTex); _cachedScreenTex new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false); } _cachedScreenTex.ReadPixels(...); _cachedScreenTex.Apply(); // ... 使用_cachedScreenTex // 注意EncodeToPNG仍然会分配byte[]这是另一个GC来源 }使用Unity.Collections和NativeArray对于需要将像素数据传递给C# Job System进行处理的场景可以使用NativeArraybyte来存储数据它分配在非托管堆不受GC管理。避免EncodeToPNG/JPG这两个方法在内部会分配新的字节数组。如果可能直接将Texture2D的原始像素数据通过GetRawTextureData发送到原生插件进行处理或压缩可以避免托管堆分配。4.4 替代方案评估何时不该用 WaitForEndOfFrameWaitForEndOfFrame不是万能的在很多场景下有更好、更高效的替代品。简单的延时一帧如果只是想等下一帧再执行逻辑用yield return null就够了。它的开销远小于WaitForEndOfFrame因为它不需要挂钩到渲染管线末端的事件。与物理同步如果逻辑需要和物理更新保持同步使用yield return new WaitForFixedUpdate()。自定义的、更早的时机如果你需要在所有Update之后、但在渲染开始之前做一些事情可以监听Application.onBeforeRender事件注意每帧可能触发多次对应多个相机。CommandBuffer对于纯粹的渲染效果插入如绘制一个全屏四边形、执行一个自定义的BlitCommandBuffer是更专业的选择。它允许你将一系列渲染命令注入到相机渲染流程的特定点如CameraEvent.AfterForwardOpaque完全在渲染线程内完成效率极高。C# Job System Burst Compiler对于需要在帧末进行的大量数据并行计算比如对截图像素进行大规模分析应该考虑将数据通过AsyncGPUReadback获取到NativeArray然后提交给一个C# Job在多个CPU核心上并行处理这能最大化利用硬件资源避免阻塞主线程。方案选择决策表需求场景推荐方案理由截图包含所有相机和UIWaitForEndOfFrame唯一能保证所有渲染完成的时机。UI布局更新后获取尺寸yield return null(通常足够) 或WaitForEndOfFrame(最保险)null在大多数UI更新后生效复杂动画或Canvas多批次渲染时用后者。渲染到纹理后立即读取像素WaitForEndOfFrameRenderTexture.active设置确保特定相机渲染完成。等待后处理效果生效WaitForEndOfFrame确保所有Image Effects和Renderer Features执行完毕。简单的下一帧延迟yield return null开销最小目的纯粹。在渲染流程中插入绘制命令CommandBuffer原生渲染管线集成性能最优控制精确。对渲染结果进行重型CPU分析AsyncGPUReadbackC# Job System异步无阻塞并行计算最大化性能。5. 高级技巧与疑难问题排查掌握了基础和优化后我们来看一些高级用法和那些让人头疼的疑难杂症。5.1 在编辑器模式下的特殊行为在Unity编辑器的Play模式下WaitForEndOfFrame的行为与独立运行时基本一致。但需要注意当游戏视图Game View未聚焦或最小化时渲染可能会停止或降频这会导致依赖WaitForEndOfFrame的协程暂停执行或执行间隔变长。如果你的逻辑依赖于稳定的帧末调用在编辑器下测试时请确保游戏视图是激活状态。此外在编辑器中进行屏幕截图时Screen.width/height获取的是游戏视图的分辨率而非整个编辑器窗口。如果你需要截取包含编辑器UI的整个窗口需要使用UnityEditor命名空间下的ScreenCapture.CaptureScreenshot重载方法或处理EditorWindow的渲染。5.2 与Time.scale的关系WaitForEndOfFrame不受Time.timeScale影响。即使你将时间缩放设置为0游戏暂停只要游戏仍在渲染比如UI动画可能还在播放WaitForEndOfFrame仍然会在每一渲染帧结束时触发。这与WaitForSeconds和WaitForSecondsRealtime有本质区别。如果你需要在游戏逻辑暂停时也暂停帧末任务需要手动在协程中检查一个暂停标志。private bool _gamePaused false; IEnumerator MyFrameEndTask() { while (true) { yield return new WaitForEndOfFrame(); if (_gamePaused) continue; // 如果暂停跳过本次处理 // ... 正常任务逻辑 } }5.3 常见问题排查清单当你发现WaitForEndOfFrame不工作或行为异常时可以按照以下清单排查协程启动了吗最基础的问题。确保StartCoroutine被调用且MonoBehaviour脚本的GameObject是激活的。对象被销毁了吗在WaitForEndOfFrame恢复执行前启动协程的GameObject或脚本可能已被销毁Destroy。这会导致协程静默停止。可以在协程开始时缓存this引用并检查this ! null但更推荐使用取消令牌模式或确保生命周期管理。渲染真的发生了吗如果相机被禁用、Culling Mask导致没有内容渲染、或者游戏视图被遮挡某些渲染路径可能被跳过但WaitForEndOfFrame可能依然会触发取决于Unity版本和设置。使用Frame Debugger工具查看当前帧的实际渲染过程。多相机顺序问题WaitForEndOfFrame等待所有相机。如果某个相机的渲染因为深度纹理、命令缓冲区等原因异常耗时会延迟所有帧末协程的执行。使用Profiler分析每个相机的渲染耗时。GC卡顿导致“错过”时机极端情况下如果在WaitForEndOfFrame即将恢复的那一刹那发生了一个长时间的GC可能会导致逻辑执行被大幅推迟感觉像“跳过”了一帧。优化内存分配是根本。与平台相关的渲染线程差异在某些平台如部分移动平台或图形API如Vulkan下渲染线程与主线程的同步方式可能有细微差别。如果遇到跨平台问题需要针对该平台进行详细测试和 profiling。5.4 一个综合案例安全的每帧截图录制系统最后我们整合所有技巧设计一个相对健壮、考虑性能的每帧截图录制系统用于制作游戏视频等。using UnityEngine; using System.Collections; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; public class FrameRecorder : MonoBehaviour { public bool isRecording false; public int frameRate 30; public string folderName RecordedFrames; public int superSize 1; private string _outputPath; private Queuebyte[] _frameDataQueue new Queuebyte[](); private Texture2D _cachedTexture; private System.Threading.CancellationTokenSource _cancellationTokenSource; void Start() { _outputPath Path.Combine(Application.persistentDataPath, folderName); Time.captureFramerate frameRate; // 锁定帧率确保时间间隔均匀 } public void StartRecording() { if (isRecording) return; isRecording true; // 清理旧目录 if (Directory.Exists(_outputPath)) Directory.Delete(_outputPath, true); Directory.CreateDirectory(_outputPath); _cancellationTokenSource new System.Threading.CancellationTokenSource(); StartCoroutine(CaptureFramesCoroutine()); Task.Run(() SaveFramesWorker(_cancellationTokenSource.Token)); Debug.Log(开始录制...); } public void StopRecording() { if (!isRecording) return; isRecording false; _cancellationTokenSource?.Cancel(); Debug.Log(录制停止。); } IEnumerator CaptureFramesCoroutine() { int frameIndex 0; // 预创建纹理避免每帧分配 if (_cachedTexture null || _cachedTexture.width ! Screen.width * superSize || _cachedTexture.height ! Screen.height * superSize) { if (_cachedTexture ! null) Destroy(_cachedTexture); _cachedTexture new Texture2D(Screen.width * superSize, Screen.height * superSize, TextureFormat.RGB24, false); } while (isRecording) { yield return new WaitForEndOfFrame(); // 捕获帧 _cachedTexture.ReadPixels(new Rect(0, 0, Screen.width * superSize, Screen.height * superSize), 0, 0); _cachedTexture.Apply(); // 编码为JPG比PNG快文件小 byte[] frameData _cachedTexture.EncodeToJPG(85); // 注意这里仍有GC分配 // 放入队列由后台线程保存 lock (_frameDataQueue) { _frameDataQueue.Enqueue(frameData); } frameIndex; // 每100帧输出一次进度避免频繁的Debug.Log开销 if (frameIndex % 100 0) Debug.Log($已捕获 {frameIndex} 帧); } } private async Task SaveFramesWorker(System.Threading.CancellationToken token) { int saveIndex 0; while (!token.IsCancellationRequested || (isRecording _frameDataQueue.Count 0)) { byte[] dataToSave null; lock (_frameDataQueue) { if (_frameDataQueue.Count 0) { dataToSave _frameDataQueue.Dequeue(); } } if (dataToSave ! null) { string filePath Path.Combine(_outputPath, $frame_{saveIndex:D08}.jpg); // 使用File.WriteAllBytesAsync进行异步文件写入减少主线程等待 await File.WriteAllBytesAsync(filePath, dataToSave, token); saveIndex; } else { // 队列为空短暂等待避免空转 await Task.Delay(10, token); } } Debug.Log($后台保存线程退出共保存 {saveIndex} 帧。); } void OnDestroy() { StopRecording(); if (_cachedTexture ! null) Destroy(_cachedTexture); } }这个案例体现了多个优化点使用Time.captureFramerate稳定帧间隔、复用Texture2D对象、将耗时的文件写入操作移到后台线程、使用JPG格式减少数据量。但它仍然存在EncodeToJPG带来的GC压力在生产环境中对于极高帧率的录制可能需要探索使用原生插件进行图像编码以彻底摆脱托管堆分配。