Unity自定义截图功能实现:从屏幕捕获到区域选择与编辑

📅 2026/7/9 21:43:11
Unity自定义截图功能实现:从屏幕捕获到区域选择与编辑
1. 项目概述与核心价值最近在做一个Unity项目需要集成一个类似微信截图的功能。不是那种简单的全屏截图而是用户能自由框选、编辑、再分享的完整流程。市面上虽然有一些现成的插件但要么功能太重要么定制性不够要么就是授权费用不菲。所以我决定自己动手从零开始复刻一个“微信截图”的核心功能。这不仅仅是实现一个功能更是一个深入理解Unity图形处理、UI交互和跨平台适配的绝佳机会。这个系列我打算从最基础的“实现思路”开始讲起。为什么先讲思路因为截图功能看似简单点一下按钮就完事但背后涉及到屏幕坐标转换、纹理抓取、实时绘制、用户交互反馈等一系列环环相扣的环节。思路理不清代码写得再漂亮也容易变成一坨难以维护的“屎山”。通过这篇文章我希望无论是Unity新手还是有一定经验的开发者都能跟我一起把“微信截图”这个功能拆解得明明白白知道每一步为什么要这么做以及有哪些坑需要提前避开。2. 功能拆解与核心思路微信截图的核心交互流程大家应该都很熟悉触发快捷键或点击按钮 - 屏幕变暗并进入截图模式 - 鼠标按下开始拖拽选择区域 - 拖拽过程中实时显示选框和尺寸提示 - 松开鼠标完成区域选择 - 弹出编辑工具栏画框、箭头、文字、马赛克等 - 最终确认保存或分享。要在Unity里复刻这一套我们不能被表象迷惑得抓住几个最核心的技术点2.1 核心流程与技术栈选型整个功能可以抽象为三个核心阶段捕获阶段、交互阶段和后处理阶段。捕获阶段如何获取当前屏幕的图像数据这是所有操作的基石。交互阶段如何实现一个流畅、准确的区域选择交互这涉及到输入处理和UI渲染。后处理阶段如何对选定的区域图像进行编辑绘制和输出对于捕获阶段Unity提供了几种方案ScreenCapture.CaptureScreenshot最简单但它是异步的且只能保存为文件无法直接获取到内存中的纹理Texture2D进行实时处理不适合我们的交互式截图。Texture2D.ReadPixels这是我们的主力方案。它可以从当前的渲染结果比如Camera的TargetTexture或者直接是屏幕缓冲中读取像素数据生成一个Texture2D对象。这让我们拥有了对截图数据的完全控制权。渲染到纹理Render Texture更高级的方案可以将某个特定相机看到的内容渲染到一张纹理上适合截取UI或者特定视角。对于这个项目Texture2D.ReadPixels配合全屏覆盖的UI是最直接、性能也足够好的选择。它的原理是在一帧渲染结束后从帧缓冲Frame Buffer里把像素数据“读”出来。这里有个关键细节ReadPixels的调用必须放在yield return new WaitForEndOfFrame()协程中或者放在OnPostRender这类渲染事件之后以确保我们读取的是已经完全渲染好的最终画面。2.2 交互实现的难点分析交互阶段是体验的关键难点主要集中在两点第一坐标系的统一与转换。Unity世界里有至少三套重要的坐标系屏幕坐标系Screen Space以像素为单位原点在左下角。鼠标的Input.mousePosition就在这个坐标系里。UI坐标系Canvas Screen Space - Overlay当Canvas渲染模式为Screen Space - Overlay时它的坐标原点也在屏幕左下角单位是像素理论上和屏幕坐标系是对齐的。但这里有个巨坑Canvas的缩放Canvas Scaler。如果Canvas为了适配不同分辨率设置了缩放那么UI元素的位置和尺寸就需要进行相应的换算。世界坐标系World Space这个在截图功能里主要用于3D物体的选取我们本次复刻的2D区域截图暂时不涉及。我们的区域选择框一个半透明的矩形UI和屏幕遮罩一个全屏的半透明黑色UI都存在于一个Screen Space - Overlay的Canvas下。因此我们必须确保鼠标的屏幕坐标在经过Canvas缩放因子校正后能准确地映射到UI的局部坐标上用来设置选择框的位置和大小。第二实时绘制与性能。在鼠标拖拽过程中选择框需要每帧更新位置和大小屏幕遮罩需要每帧更新其“挖空”区域即选择框以外的部分保持半黑选择框内区域透明以显示原屏幕内容。如果处理不当频繁的UI网格重建Rebuild或材质属性更新会导致性能卡顿。解决方案是使用Raw Image来显示屏幕截图纹理并通过动态修改其UV Rect或使用一个遮罩Shader来实现“挖空”效果这比动态生成几何体或修改顶点高效得多。3. 详细实现步骤拆解理清了思路我们就可以开始搭建框架了。我会按照一个可运行的、模块化的结构来组织代码。3.1 创建UI结构与层级管理首先在场景中创建一个用于截图的专用Canvas渲染模式设为Screen Space - Overlay并添加一个Canvas Scaler组件缩放模式UI Scale Mode我推荐使用Scale With Screen Size并设定一个参考分辨率如1920x1080这样UI在不同分辨率下能保持相对一致的视觉比例。在这个Canvas下我们需要按顺序创建几个核心UI元素层级从下到上全屏遮罩FullScreenMask一个全屏大小的Panel或Image颜色为半透明黑色如RGBA(0,0,0,0.6)。它的作用是让屏幕变暗突出截图模式。截图显示层ScreenshotRawImage一个全屏大小的Raw Image组件。它最初是隐藏的。当我们按下截图键时会用ReadPixels捕获的屏幕纹理赋值给它然后显示出来并将其置于遮罩层之下。这样遮罩层的“挖空”区域就会露出这个Raw Image显示的截图实现“框内原图框外变暗”的效果。区域选择框SelectionRect一个空的RectTransform作为父节点内部包含一个用于显示边框的Image可以是简单的矩形Sprite也可以是九宫格图以实现更好的拉伸效果以及用于显示宽高尺寸的Text组件。这个RectTransform的锚点Anchor和轴心Pivot通常设置为左上角(0,1)或中心(0.5,0.5)具体取决于你的交互逻辑。我更喜欢将轴心设为(0, 1)即左上角这样计算拖拽矩形时比较直观。编辑工具栏EditToolbar一个包含确认、取消、画笔、箭头、文字、马赛克、保存等按钮的Panel。它在区域选择完成后才出现。注意这里的层级关系至关重要。遮罩在最底层截图显示在中间选择框和工具栏在最顶层。这样才能保证交互元素始终可见并且遮罩能正确覆盖在截图之上。3.2 核心脚本截图管理器ScreenshotManager.cs我们将创建一个单例模式的管理器脚本来统筹所有逻辑。它需要处理状态机空闲、选择区域、编辑、输入监听、纹理捕获和UI更新。using UnityEngine; using UnityEngine.UI; using System.Collections; public class ScreenshotManager : MonoBehaviour { public static ScreenshotManager Instance; [Header(UI References)] public Canvas screenshotCanvas; // 专用Canvas public RawImage screenshotDisplay; // 显示截图的RawImage public Image screenMask; // 全屏遮罩 public RectTransform selectionRect; // 区域选择框 public Text sizeText; // 尺寸显示文本 public GameObject editToolbar; // 编辑工具栏 [Header(Settings)] public KeyCode screenshotKey KeyCode.A; // 触发快捷键可配置 [Range(0.1f, 0.9f)] public float maskAlpha 0.6f; // 遮罩透明度 private Texture2D _currentScreenTexture; // 当前捕获的屏幕纹理 private Vector2 _selectionStartPos; // 区域选择的起点屏幕坐标 private bool _isSelecting false; // 是否正在选择区域 private ScreenshotState _currentState ScreenshotState.Idle; // 当前状态 private enum ScreenshotState { Idle, // 空闲 Selecting, // 选择区域中 Editing // 编辑中 } void Awake() { if (Instance null) { Instance this; DontDestroyOnLoad(gameObject); // 根据需要决定是否跨场景 } else { Destroy(gameObject); } InitializeUI(); } void InitializeUI() { // 初始化时隐藏所有截图相关UI screenshotCanvas.enabled false; screenshotDisplay.gameObject.SetActive(false); selectionRect.gameObject.SetActive(false); editToolbar.SetActive(false); // 设置遮罩颜色 Color maskColor screenMask.color; maskColor.a maskAlpha; screenMask.color maskColor; } void Update() { switch (_currentState) { case ScreenshotState.Idle: HandleIdleState(); break; case ScreenshotState.Selecting: HandleSelectingState(); break; case ScreenshotState.Editing: // 编辑状态下的输入处理如绘图可以在这里或由独立工具脚本处理 break; } } }3.3 状态一空闲状态与触发捕获在空闲状态下我们监听快捷键。一旦触发就需要捕获当前屏幕并进入选择状态。void HandleIdleState() { if (Input.GetKeyDown(screenshotKey)) { StartCoroutine(CaptureScreenAndEnterSelection()); } } IEnumerator CaptureScreenAndEnterSelection() { // 1. 先显示Canvas和遮罩给用户一个“进入截图模式”的视觉反馈 screenshotCanvas.enabled true; screenMask.gameObject.SetActive(true); // 隐藏其他UI确保截图干净根据项目需求调整 // ... // 2. 等待一帧结束确保UI包括刚显示的遮罩已经渲染 yield return new WaitForEndOfFrame(); // 3. 捕获屏幕 _currentScreenTexture new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false); _currentScreenTexture.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0); _currentScreenTexture.Apply(); // 应用像素数据使其可读 // 4. 将纹理赋值给RawImage并显示 screenshotDisplay.texture _currentScreenTexture; screenshotDisplay.gameObject.SetActive(true); // 设置RawImage的UV和尺寸为全屏 screenshotDisplay.uvRect new Rect(0, 0, 1, 1); screenshotDisplay.rectTransform.sizeDelta new Vector2(Screen.width, Screen.height); // 5. 进入选择状态 _currentState ScreenshotState.Selecting; selectionRect.gameObject.SetActive(true); sizeText.gameObject.SetActive(true); _isSelecting false; // 6. 锁定鼠标或改变光标样式可选 // Cursor.lockState CursorLockMode.Confined; // Cursor.SetCursor(crosshairCursor, Vector2.zero, CursorMode.Auto); }实操心得WaitForEndOfFrame是关键。如果在显示遮罩的同一帧立即调用ReadPixels你捕获到的画面可能不包含这个遮罩因为UI渲染顺序的问题。等待一帧能保证所有UI元素都已绘制完毕。另外TextureFormat.RGB24不包含Alpha通道节省内存。如果你需要捕获包含透明度的UI比如其他窗口可能需要使用ARGB32。3.4 状态二区域选择状态的交互实现这是最核心的交互部分。我们需要处理鼠标按下、拖拽、松开的事件并实时更新选择框的视觉表现。void HandleSelectingState() { Vector2 currentMousePos Input.mousePosition; // 鼠标按下开始选择 if (Input.GetMouseButtonDown(0)) { _selectionStartPos currentMousePos; _isSelecting true; UpdateSelectionRect(currentMousePos); } // 鼠标拖拽中更新选择框 if (_isSelecting Input.GetMouseButton(0)) { UpdateSelectionRect(currentMousePos); } // 鼠标松开结束选择 if (_isSelecting Input.GetMouseButtonUp(0)) { _isSelecting false; Vector2 endPos currentMousePos; // 检查选择区域是否有效例如长宽均大于某个阈值如10像素 if (IsSelectionValid(_selectionStartPos, endPos)) { // 进入编辑状态 EnterEditingState(); } else { // 区域太小视为取消重置状态 ResetToIdle(); } } // 按下ESC键取消截图 if (Input.GetKeyDown(KeyCode.Escape)) { ResetToIdle(); } } void UpdateSelectionRect(Vector2 currentMousePos) { // 计算选择框的屏幕坐标矩形 float x Mathf.Min(_selectionStartPos.x, currentMousePos.x); float y Mathf.Min(_selectionStartPos.y, currentMousePos.y); float width Mathf.Abs(currentMousePos.x - _selectionStartPos.x); float height Mathf.Abs(currentMousePos.y - _selectionStartPos.y); // **关键步骤将屏幕坐标转换为UI RectTransform的局部坐标** // 因为我们的Canvas是Screen Space - Overlay且选择框的锚点可能在左上角 // 我们需要将屏幕坐标转换为以Canvas左上角为原点的局部坐标。 Vector2 localPos; RectTransformUtility.ScreenPointToLocalPointInRectangle( (RectTransform)screenshotCanvas.transform, // 相对的目标RectTransform new Vector2(x, y), // 屏幕坐标 screenshotCanvas.worldCamera, // Overlay模式下为null out localPos ); // 设置选择框的位置和大小 selectionRect.anchoredPosition localPos; selectionRect.sizeDelta new Vector2(width, height); // 更新尺寸显示文本 sizeText.text ${(int)width} x {(int)height}; // 将尺寸文本放置在选框右下角 sizeText.rectTransform.anchoredPosition new Vector2(width, -height); } bool IsSelectionValid(Vector2 start, Vector2 end) { return Mathf.Abs(end.x - start.x) 10 Mathf.Abs(end.y - start.y) 10; }避坑指南坐标转换。RectTransformUtility.ScreenPointToLocalPointInRectangle是解决UI坐标转换的神器。在Screen Space - Overlay模式下第三个参数camera传null即可。它会把屏幕坐标点转换到指定RectTransform的局部坐标系中。这里我们传入了截图Canvas的Transform得到的localPos就是相对于Canvas左上角假设锚点也是左上角的位置。这样设置anchoredPosition就准确了。如果不做这个转换直接使用屏幕坐标设置UI位置在Canvas有缩放或者锚点设置不当时选框会严重错位。3.5 状态三进入编辑与最终输出当区域选择完成后我们隐藏选择框显示编辑工具栏并将状态切换到编辑。void EnterEditingState() { _currentState ScreenshotState.Editing; selectionRect.gameObject.SetActive(false); sizeText.gameObject.SetActive(false); editToolbar.SetActive(true); // 此时我们已经有了一个有效的选择区域selectionRect.rect。 // 可以在这里预先计算好最终截取区域的纹理或者等到用户点击“完成”时再计算。 // 为了性能通常先记录区域信息最终确认时再处理。 } // 编辑工具栏上“确认”按钮的调用方法 public void OnConfirmCrop() { // 1. 获取选择框在屏幕上的实际像素矩形 Rect screenRect GetSelectionScreenRect(); // 2. 从之前捕获的全屏纹理中裁剪出选择区域 Texture2D croppedTexture CropTexture(_currentScreenTexture, screenRect); // 3. 处理裁剪后的纹理保存、分享等 ProcessFinalTexture(croppedTexture); // 4. 重置状态准备下一次截图 ResetToIdle(); } Rect GetSelectionScreenRect() { // 将UI局部坐标的选择框转换回屏幕像素矩形 Vector2 globalPos selectionRect.TransformPoint(selectionRect.rect.position); // 注意TransformPoint得到的是世界坐标在Overlay Canvas下世界坐标的Z轴为0XY与屏幕像素坐标存在比例关系。 // 更稳健的方法是利用选择框的四个角的世界坐标反向映射到屏幕坐标。 // 这里提供一个简化但常用的方法直接使用anchoredPosition和sizeDelta结合Canvas的缩放因子进行计算。 Canvas canvas screenshotCanvas; float scaleFactor canvas.scaleFactor; // 这是关键Canvas的缩放因子 Vector2 scaledPos selectionRect.anchoredPosition * scaleFactor; Vector2 scaledSize selectionRect.sizeDelta * scaleFactor; // 因为屏幕坐标原点在左下角而我们的anchoredPosition可能是以左上角为参考。 // 假设anchoredPosition的(0,0)对应Canvas左上角那么屏幕Y坐标需要翻转。 float screenY Screen.height - (scaledPos.y scaledSize.y); // 计算左下角Y坐标 return new Rect(scaledPos.x, screenY, scaledSize.x, scaledSize.y); } Texture2D CropTexture(Texture2D sourceTexture, Rect cropRect) { // 确保裁剪区域在纹理范围内 cropRect.x Mathf.Clamp(cropRect.x, 0, sourceTexture.width); cropRect.y Mathf.Clamp(cropRect.y, 0, sourceTexture.height); cropRect.width Mathf.Clamp(cropRect.width, 0, sourceTexture.width - cropRect.x); cropRect.height Mathf.Clamp(cropRect.height, 0, sourceTexture.height - cropRect.y); Texture2D croppedTex new Texture2D((int)cropRect.width, (int)cropRect.height, sourceTexture.format, false); Color[] pixels sourceTexture.GetPixels((int)cropRect.x, (int)cropRect.y, (int)cropRect.width, (int)cropRect.height); croppedTex.SetPixels(pixels); croppedTex.Apply(); return croppedTex; } void ProcessFinalTexture(Texture2D finalTexture) { // 示例保存到本地 byte[] bytes finalTexture.EncodeToPNG(); // 或 EncodeToJPG string filePath Path.Combine(Application.persistentDataPath, $Screenshot_{DateTime.Now:yyyyMMdd_HHmmss}.png); System.IO.File.WriteAllBytes(filePath, bytes); Debug.Log($截图已保存至: {filePath}); // 示例分享功能平台相关此处为示意 // StartCoroutine(ShareScreenshot(bytes)); } void ResetToIdle() { _currentState ScreenshotState.Idle; screenshotCanvas.enabled false; screenshotDisplay.gameObject.SetActive(false); screenMask.gameObject.SetActive(false); selectionRect.gameObject.SetActive(false); editToolbar.SetActive(false); // 释放纹理内存避免泄漏 if (_currentScreenTexture ! null) { Destroy(_currentScreenTexture); _currentScreenTexture null; } // 恢复光标如果之前修改了 // Cursor.lockState CursorLockMode.None; // Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto); }核心技巧Canvas.scaleFactor。在GetSelectionScreenRect函数中canvas.scaleFactor是连接UI单位anchoredPosition, sizeDelta和屏幕像素单位的桥梁。当Canvas Scaler使用Scale With Screen Size时这个因子会根据当前屏幕分辨率与参考分辨率的比例动态计算。忘记乘以它是导致选框区域与实际截图区域对不齐的最常见原因。4. 性能优化与进阶思考基础功能跑通后我们还需要关注性能和扩展性。4.1 性能优化点纹理内存管理Texture2D是托管内存一定要及时销毁。在ResetToIdle中销毁全屏纹理在保存或分享完成后根据情况销毁裁剪后的纹理。避免每帧创建Rect在UpdateSelectionRect中虽然计算不复杂但可以将一些临时Vector2声明为成员变量复用减少GC分配。遮罩渲染优化全屏遮罩使用一个简单的Image即可。如果追求“挖空”效果即只有选框内是原图选框外是半透明黑色更高效的做法是使用一个自定义Shader的Image作为遮罩通过Shader参数传入选框的位置和大小在GPU端进行遮罩计算完全避免动态修改UI网格。这对于移动端尤其重要。编辑工具的对象池如果编辑工具画笔、箭头需要动态生成大量图形对象务必使用对象池来管理防止频繁的Instantiate和Destroy。4.2 功能扩展方向编辑工具实现编辑工具栏上的每个按钮都对应一个独立的工具脚本如PenTool,ArrowTool,TextTool。它们需要监听在编辑状态下的鼠标/触摸输入并在一个位于截图显示层之上的“绘制层”另一个RawImage或Graphics上实时绘制。绘制数据可以存储为一系列命令或顶点最终在确认时与底图合并。多平台适配PC上我们用鼠标和键盘。在移动端iOS/Android或微信小游戏平台需要将输入切换为触摸Input.touches。微信小游戏环境特殊其Canvas渲染和系统键盘调用都需要使用平台特定API不能直接用Unity原生的Application.CaptureScreenshot或某些系统对话框。撤销/重做为编辑操作实现一个简单的命令模式Command Pattern将每个绘制操作封装成一个可撤销的命令对象。更精确的坐标拾取当前方案对于普通UI截图足够。但如果需要截取包含3D场景的特定区域或者避开某些UI元素就需要使用相机渲染到纹理Render Texture并结合射线检测Raycast来精确确定范围。5. 常见问题与排查实录在实际开发中你几乎一定会遇到下面这些问题问题1选择框的位置和鼠标位置对不上或者选框大小不对。排查首先检查Canvas的渲染模式是否为Screen Space - Overlay。然后在UpdateSelectionRect函数中打印出currentMousePos、计算出的localPos以及selectionRect.anchoredPosition。确保RectTransformUtility.ScreenPointToLocalPointInRectangle的第一个参数是正确的父级RectTransform通常是截图Canvas。解决90%的情况是Canvas Scaler的缩放因子scaleFactor被忽略了。务必在将UI坐标转换为屏幕像素坐标时乘以canvas.scaleFactor反之亦然。问题2截图出来的画面是黑的或者缺少某些UI元素。排查确认ReadPixels的调用时机。它必须在所有渲染包括UI完成之后。确保你的捕获协程里有yield return new WaitForEndOfFrame()并且是在显示了遮罩等UI之后才调用。解决检查层级。确保你想要截取的UI元素所在的Canvas的Sorting Order低于或等于截图管理器的Canvas不实际上ReadPixels捕获的是整个屏幕帧缓冲。只要UI被渲染了就能捕获到。如果缺失可能是该UI在捕获的那一帧被意外禁用了或者其Shader有特殊的渲染设置。问题3在编辑器里运行正常打包后选框错位或截图不全。排查这通常是屏幕分辨率和Canvas缩放导致的差异。编辑器窗口的分辨率可能与真机不同。解决所有涉及屏幕像素的计算如Screen.width,Screen.height以及UI坐标到像素坐标的转换都必须使用运行时的实际值。在GetSelectionScreenRect函数中使用Screen.currentResolution或直接Screen.width/height并确保canvas.scaleFactor计算正确。问题4频繁截图导致内存占用越来越高内存泄漏。排查使用Unity Profiler的Memory模块查看Texture2D的数量是否只增不减。解决严格做到“用完即焚”。在ResetToIdle中销毁全屏纹理在完成保存/分享操作后销毁裁剪生成的纹理。对于编辑过程中生成的临时绘制纹理也要有相应的清理机制。问题5在移动端触摸拖拽不跟手有延迟。排查Unity的Input.touches在每帧更新。如果直接在Update中处理由于帧率波动可能会感觉不跟手。解决对于拖拽这类连续操作使用Touch.phase TouchPhase.Moved来更新位置。同时可以考虑使用Input.multiTouchEnabled来处理单点触摸并注意在Update中处理完触摸逻辑后及时更新UI减少不必要的布局计算。把这个思路和代码框架搭好一个可用的、高性能的Unity自定义截图功能就完成了一大半。剩下的编辑工具实现无非是在这个稳定的地基上添砖加瓦。记住理解坐标转换和渲染时机是攻克此类功能的关键。下次我们可以深入聊聊如何在这个框架上实现画笔、箭头这些编辑工具。