1. 项目概述:一个AR证件照项目的诞生
最近在做一个挺有意思的AR证件照项目,核心需求就是让用户通过手机摄像头,实时预览自己的形象,然后框选一个特定区域(比如证件照的头部和肩部),最终生成一张符合规格的电子证件照。听起来简单,对吧?但真做起来,从Unity的WebCamTexture调用摄像头,到处理不同平台的权限申请,再到精准的区域截图和图像处理,每一步都藏着不少“坑”。这个项目不仅考验对Unity引擎的熟悉程度,更考验对移动端原生交互、图像处理流程的理解。如果你也正在或打算开发涉及实时摄像头捕捉和图像处理的Unity应用,比如AR试妆、虚拟背景、在线考试监考等,那这篇从实战中摸爬滚打出来的避坑实录,或许能帮你省下不少调试时间。
2. 核心需求与技术选型解析
2.1 需求拆解:不止是“拍个照”
这个AR证件照项目,远不止调用摄像头拍张照片那么简单。我们需要把它拆解成几个可执行、可测试的模块:
- 摄像头权限动态申请与处理:在Android和iOS上,摄像头权限不是默认就有的。我们需要在合适的时机(通常是用户点击“开始拍照”时)向系统申请,并且优雅地处理用户“允许”或“拒绝”的后续流程。这涉及到与原生平台的交互。
- 摄像头设备的枚举与选择:现在的手机大多有前后多个摄像头。我们需要能列出所有可用设备,并允许用户选择(例如,默认使用前置摄像头进行自拍),同时要能获取到摄像头的分辨率、帧率等参数。
- 实时视频流的渲染与性能优化:使用WebCamTexture将摄像头画面实时渲染到Unity的UI或3D物体上。这里的关键是性能,高分辨率视频流非常消耗资源,处理不当会导致应用卡顿、发热甚至崩溃。
- 交互式区域框选与视觉反馈:用户需要能手动调整一个矩形框,这个框代表了最终证件照的裁剪区域。我们需要在实时画面上叠加这个框,并让它能响应用户的拖拽、缩放操作,同时提供清晰的视觉引导。
- 高精度区域截图与图像后处理:当用户确认框选区域后,我们需要从实时视频流中“冻结”并截取该区域的图像。这里不能简单地对屏幕上的RawImage进行截图,因为涉及渲染缩放,必须直接从WebCamTexture的数据源入手,保证截图的原始精度。之后,可能还需要进行背景替换、人像分割、亮度调节、尺寸裁剪等后处理。
- 跨平台兼容性与异常处理:不同厂商的Android设备、不同版本的iOS系统,在摄像头调用上可能存在细微差异。网络热词中提到的“unity webgl初始化很久”、“unity程序打开黑屏无响应”等问题,很多都源于此。健壮的异常处理和降级方案是必须的。
2.2 为什么是WebCamTexture?
Unity中处理摄像头的主流方案有几种:WebCamTexture、AR Foundation(底层可能是ARKit/ARCore)、以及一些第三方插件(如NatCorder, AVPro Camera)。针对我们这个“AR证件照”场景,WebCamTexture是最直接、最轻量、跨平台支持最成熟的选择。
- 轻量与直接:WebCamTexture是Unity内置组件,无需导入庞大的AR Foundation框架或购买第三方插件。它直接将摄像头输出映射到一个Texture2D上,我们可以像使用普通贴图一样使用它,渲染到UI Image或材质球上,概念简单清晰。
- 控制粒度适中:我们可以控制摄像头的开启(Play)、停止(Stop)、暂停(Pause),可以获取设备列表和当前分辨率,基本满足了我们对摄像头流的控制需求。
- 广泛的平台支持:在PC(WebCam)、Android、iOS以及WebGL平台上,WebCamTexture都有官方支持。虽然WebGL上初始化可能较慢(这就是“unity webgl初始化很久”的一个原因),但功能是完整的。
当然,它也有局限性。例如,它不提供计算机视觉层面的功能(如人脸识别、平面检测),这些如果需要,就得结合其他库(如OpenCV for Unity)或切换到AR Foundation。但对于单纯的视频捕捉、显示和截图,WebCamTexture绰绰有余。
注意:网络热词中提到了“unity 打包android 无vpn”,这通常指的是Unity打包时可能需要从国外服务器下载一些依赖包(如Android SDK/NDK的特定组件)。这与WebCamTexture功能本身无关,而是Unity Editor环境配置或打包设置的问题,通常通过配置正确的代理或使用国内镜像源解决。
3. 从零开始:权限申请与设备初始化
这是项目启动的第一步,也是最容易让新手卡住的地方。权限问题处理不好,后面的所有功能都无从谈起。
3.1 Android与iOS的权限配置
在写任何代码之前,必须在项目设置中声明需要使用的权限。
对于Android(Unity Editor内操作):
- 打开
File -> Build Settings,选择Android平台,点击Player Settings。 - 在
Player Settings窗口中,找到Other Settings部分。 - 在
Configuration下,找到Write Permission,如果需要保存图片到相册,可以选择External (SDCard)。 - 更重要的是,在
Configuration下方找到Scripting Backend,如果使用IL2CPP,请确保Target Architectures中选择了合适的架构(如ARMv7, ARM64)。 - 然后,找到
Publishing Settings部分,勾选Custom Main Manifest和Custom Main Gradle Template。这允许我们自定义Android的清单文件和Gradle构建脚本。 - 在生成的
Assets/Plugins/Android/AndroidManifest.xml文件中,确保包含摄像头权限声明:
对于Android 6.0 (API 23) 以上,仅有清单声明还不够,必须在运行时动态申请。<?xml version="1.0" encoding="utf-8"?> <manifest ...> <application ...> ... </application> <!-- 声明摄像头权限 --> <uses-permission android:name="android.permission.CAMERA" /> <!-- 如果还需要写入外部存储(保存图片) --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <!-- Android 10 (API 29) 及以上,如果需要保存到共享存储,可能需要 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> </manifest>
对于iOS(Unity Editor内操作):
- 同样在
Player Settings中,切换到iOS平台。 - 找到
Other Settings部分。 - 在
Camera Usage Description和Microphone Usage Description(如果用到麦克风)字段中,填写向用户请求权限时显示的描述信息,例如“需要摄像头权限来拍摄证件照”。这个描述必须填写,否则提交App Store审核会被拒。
3.2 运行时动态权限申请(Unity 2019.3+ 推荐方案)
Unity自2019.3版本起,在UnityEngine.Android.Permission和UnityEngine.iOS.Device中提供了更统一的API,但处理起来仍有平台差异。这里分享一个我封装的管理类核心思路:
using UnityEngine; #if UNITY_ANDROID using UnityEngine.Android; // Android专用API #elif UNITY_IOS using UnityEngine.iOS; // iOS专用API,注意:Unity 2020后可能需要使用 `UnityEngine.Apple.ReplayKit` 等,但摄像头权限描述已在设置中配置。 #endif public class CameraPermissionManager : MonoBehaviour { public System.Action<bool> OnPermissionCompleted; // 权限申请结果回调 public void RequestCameraPermission() { #if UNITY_ANDROID if (!Permission.HasUserAuthorizedPermission(Permission.Camera)) { var callbacks = new PermissionCallbacks(); callbacks.PermissionGranted += (permissionName) => { Debug.Log($"{permissionName} 权限已授予"); OnPermissionCompleted?.Invoke(true); InitializeWebCam(); // 权限获取成功后初始化摄像头 }; callbacks.PermissionDenied += (permissionName) => { Debug.LogWarning($"{permissionName} 权限被拒绝"); OnPermissionCompleted?.Invoke(false); // 这里可以弹窗提示用户去设置中手动开启 ShowPermissionDeniedDialog(); }; callbacks.PermissionDeniedAndDontAskAgain += (permissionName) => { Debug.LogError($"{permissionName} 权限被永久拒绝"); OnPermissionCompleted?.Invoke(false); // 这种情况通常需要引导用户去系统设置页面手动开启 ShowGoToSettingsDialog(); }; Permission.RequestUserPermission(Permission.Camera, callbacks); } else { Debug.Log("摄像头权限已拥有"); OnPermissionCompleted?.Invoke(true); InitializeWebCam(); } #elif UNITY_IOS // iOS的权限在首次尝试访问摄像头时会由系统自动弹出提示框。 // 我们无法在代码中主动触发弹窗,但可以检测当前的授权状态。 // 更常见的做法是直接尝试初始化WebCamTexture,如果失败,再处理。 // 这里简化处理,假设直接初始化。 InitializeWebCam(); #else // PC或WebGL平台,通常不需要权限或处理方式不同 InitializeWebCam(); #endif } private void ShowPermissionDeniedDialog() { // 实现一个UI弹窗,告知用户权限被拒,并可能提供重试按钮 } private void ShowGoToSettingsDialog() { // 实现一个UI弹窗,引导用户跳转到系统应用设置页面去开启权限 // Android上可以使用 `UnityEngine.Android.Permission.OpenSettings()` (某些版本) // 或者通过原生插件调用Intent。 } private void InitializeWebCam() { // 这里是初始化WebCamTexture的逻辑,下一节详述 } }实操心得:
- Android的“不再询问”:
PermissionDeniedAndDontAskAgain状态非常关键。一旦用户勾选了这个选项,再次调用RequestUserPermission将不会弹出系统对话框,必须引导用户去系统设置中手动开启。你的应用必须处理好这个流程,否则功能将永远无法使用。 - iOS的“一次性”弹窗:在iOS上,权限申请对话框通常只会在应用首次尝试访问摄像头时自动弹出一次。如果用户拒绝,后续再访问,系统不会自动弹窗,而是直接返回失败。同样需要引导用户去“设置”中修改。
- 异步回调:权限申请是异步操作,所有后续逻辑(如初始化摄像头)必须放在授权成功的回调里执行,不能同步等待。
4. WebCamTexture的初始化与渲染优化
拿到权限后,我们就可以开始和摄像头打交道了。
4.1 枚举设备与创建WebCamTexture
首先,我们需要获取可用的摄像头设备列表,并让用户选择或由我们指定一个默认设备(如前置摄像头)。
using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; public class ARIDPhotoCapture : MonoBehaviour { public RawImage cameraPreview; // 用于显示摄像头画面的UI RawImage public Dropdown deviceDropdown; // (可选)用于选择摄像头的UI Dropdown private WebCamTexture _webCamTexture; private List<WebCamDevice> _availableDevices = new List<WebCamDevice>(); void Start() { // 假设权限已获取,这里开始初始化设备列表 InitializeCameraDevices(); } void InitializeCameraDevices() { _availableDevices.Clear(); WebCamDevice[] devices = WebCamTexture.devices; if (devices.Length == 0) { Debug.LogError("未找到任何摄像头设备!"); return; } _availableDevices.AddRange(devices); // 更新UI Dropdown(如果存在) if (deviceDropdown != null) { deviceDropdown.ClearOptions(); List<string> options = new List<string>(); int defaultIndex = 0; for (int i = 0; i < devices.Length; i++) { string deviceName = devices[i].name; options.Add(deviceName); // 通常前置摄像头名字包含"front",我们可以以此设置默认选项 if (deviceName.ToLower().Contains("front")) { defaultIndex = i; } } deviceDropdown.AddOptions(options); deviceDropdown.value = defaultIndex; deviceDropdown.onValueChanged.AddListener(OnDeviceSelected); } // 根据默认或选中的设备初始化WebCamTexture StartCamera(_availableDevices[defaultIndex].name); } void OnDeviceSelected(int index) { if (index >= 0 && index < _availableDevices.Count) { // 先停止当前的摄像头 if (_webCamTexture != null && _webCamTexture.isPlaying) { _webCamTexture.Stop(); } // 用新选中的设备重启 StartCamera(_availableDevices[index].name); } } void StartCamera(string deviceName) { // 关键点1:分辨率选择 // 不要盲目使用最高分辨率。对于预览,通常640x480或1280x720足以,性能更好。 // 如果需要高清截图,可以考虑在截图时临时切换高分辨率,但预览保持低分辨率。 int requestWidth = 1280; int requestHeight = 720; int requestedFPS = 30; // 创建WebCamTexture _webCamTexture = new WebCamTexture(deviceName, requestWidth, requestHeight, requestedFPS); // 关键点2:设置渲染目标 if (cameraPreview != null) { cameraPreview.texture = _webCamTexture; // 重要!调整RawImage的Aspect Ratio Fitter或手动计算比例,防止画面拉伸 // 因为摄像头分辨率可能不是16:9,我们需要适配显示区域。 } // 开始播放 _webCamTexture.Play(); // 关键点3:处理初始方向 // WebCamTexture的纹理方向可能和设备方向不一致,需要修正。 // 通常需要根据 _webCamTexture.videoRotationAngle 和 _webCamTexture.videoVerticallyMirrored 来旋转和镜像RawImage。 StartCoroutine(AdjustPreviewOrientation()); } System.Collections.IEnumerator AdjustPreviewOrientation() { // 等待几帧,让WebCamTexture初始化完成,属性值稳定 yield return new WaitForEndOfFrame(); yield return new WaitForEndOfFrame(); if (cameraPreview != null && _webCamTexture != null) { int rotationAngle = _webCamTexture.videoRotationAngle; bool isMirrored = _webCamTexture.videoVerticallyMirrored; RectTransform rectTransform = cameraPreview.rectTransform; Vector3 rotation = rectTransform.localEulerAngles; rotation.z = -rotationAngle; // 注意符号,可能需要根据平台调整 rectTransform.localEulerAngles = rotation; Vector3 scale = rectTransform.localScale; scale.y = isMirrored ? -1f : 1f; // 处理垂直镜像 rectTransform.localScale = scale; } } void OnDestroy() { // 务必在退出时释放摄像头资源 if (_webCamTexture != null) { _webCamTexture.Stop(); // WebCamTexture 不需要手动Destroy,但停止播放是必要的。 } } }4.2 性能优化与常见问题
分辨率与帧率:这是性能影响最大的因素。在
new WebCamTexture(deviceName, width, height, fps)时,传入的参数是“请求值”,设备不一定支持。实际支持的分辨率可以通过WebCamTexture.devices[i].availableResolutions查询(部分平台支持)。最佳实践是:预览用低分辨率(如720p),截图前临时暂停预览,切换到高分辨率模式抓取一帧,然后再切回低分辨率预览。这能极大降低持续的性能开销,避免手机发热和卡顿。画面方向与镜像:
videoRotationAngle和videoVerticallyMirrored这两个属性至关重要。前者表示纹理需要旋转多少度才能正立(通常是0, 90, 180, 270),后者表示纹理是否垂直镜像(前置摄像头通常为true)。处理不当会导致画面横置或镜像错误。上述代码中的AdjustPreviewOrientation协程是一个基本的处理方式。更复杂的场景可能需要结合Screen.orientation来综合判断。生命周期管理:务必在
OnDisable()或OnDestroy()中调用_webCamTexture.Stop()。否则,当场景切换或对象销毁时,摄像头可能不会被正确释放,导致其他应用无法使用摄像头,或者在重新进入时初始化失败。异步初始化与黑屏:网络热词中提到的“unity程序打开黑屏无响应”,有时就是因为WebCamTexture初始化或播放是阻塞的,或者在高分辨率下主线程卡死。一定要把
_webCamTexture.Play()放在合适的时机(如权限回调后),并考虑在协程中等待其width和height属性变为非零(表示已就绪)后再进行后续操作,可以避免黑屏。IEnumerator WaitForWebCamReady() { _webCamTexture.Play(); while (_webCamTexture != null && (_webCamTexture.width <= 16 || _webCamTexture.height <= 16)) { yield return null; } Debug.Log($"WebCamTexture 已就绪,尺寸: {_webCamTexture.width}x{_webCamTexture.height}"); // 此时再调整UI或进行其他依赖纹理尺寸的操作 AdjustPreviewOrientation(); }
5. 交互式区域框选与视觉反馈实现
有了稳定的预览画面,下一步就是让用户框选证件照的区域。我们通常会在RawImage上叠加一个可拖拽、缩放的矩形框UI。
5.1 框选UI的搭建
层级结构:创建一个Canvas,下面包含:
RawImage(命名为CameraPreview): 用于显示WebCamTexture,锚点拉伸至全屏。Image(命名为SelectionFrame): 作为选择框的边框,类型设为Sliced并使用一个边框精灵。将其置于CameraPreview之上。初始时隐藏或设置一个默认大小和位置。- 四个
Image(作为DragHandle): 分别放在选择框的四个角上,作为拖拽手柄。可以给它们添加EventTrigger组件来处理拖拽事件。 - 或者,更现代的做法是使用一个
RectTransform和UnityEngine.UI.Extensions中的ResizablePanel等组件,但为了理解原理,我们从基础实现。
框选逻辑(简化版):
- 拖拽移动:监听选择框整体或中心区域的拖拽事件,根据鼠标/触摸的Delta值更新
SelectionFrame.rectTransform.anchoredPosition。 - 缩放:监听四个角上手柄的拖拽事件。以左上角手柄为例,拖拽时,应同时改变选择框的
anchoredPosition(左上角点移动了) 和sizeDelta(宽高变化)。需要仔细计算,确保是对角线方向的缩放。 - 限制范围:选择框不能移出
CameraPreview的范围,其最小尺寸也应有限制。
- 拖拽移动:监听选择框整体或中心区域的拖拽事件,根据鼠标/触摸的Delta值更新
5.2 将屏幕坐标映射到纹理坐标
这是最关键的一步。用户在屏幕上框选的是一个矩形(SelectionFrame),其坐标是相对于UI Canvas的。而我们要截取的是WebCamTexture中的一块区域,其坐标是纹理像素坐标(0到width-1, 0到height-1)。因此,我们需要进行坐标转换。
using UnityEngine; using UnityEngine.UI; public class RegionSelector : MonoBehaviour { public RawImage sourceImage; // 显示WebCamTexture的RawImage public RectTransform selectionRect; // 框选区域的RectTransform private WebCamTexture _targetWebCamTexture; // 假设这个方法在用户点击“截图”时调用 public Rect GetSelectedRegionInTexturePixels() { if (sourceImage == null || selectionRect == null || _targetWebCamTexture == null) return new Rect(0, 0, 0, 0); // 1. 获取RawImage在屏幕上的实际矩形(考虑缩放和裁剪) Rect sourceImageWorldRect = GetWorldRect(sourceImage.rectTransform); // 2. 获取选择框在屏幕上的实际矩形 Rect selectionWorldRect = GetWorldRect(selectionRect); // 3. 计算选择框相对于RawImage的归一化坐标 (0-1) // 注意:这里假设RawImage的显示模式是“Simple”,且没有九宫格拉伸等复杂情况。 // 如果RawImage使用了Aspect Ratio Fitter,实际显示区域可能小于RectTransform,计算会更复杂。 float normalizedX = (selectionWorldRect.xMin - sourceImageWorldRect.xMin) / sourceImageWorldRect.width; float normalizedY = (selectionWorldRect.yMin - sourceImageWorldRect.yMin) / sourceImageWorldRect.height; float normalizedWidth = selectionWorldRect.width / sourceImageWorldRect.width; float normalizedHeight = selectionWorldRect.height / sourceImageWorldRect.height; // 4. 将归一化坐标转换为纹理像素坐标 // 重要:纹理的(0,0)点通常在左下角,而UI的(0,0)可能在左上角。需要根据UV设置调整Y轴。 // 假设WebCamTexture在RawImage上是以“Fit Height”或“Fill”方式显示,且没有垂直镜像(已在预览时纠正)。 // 最稳妥的方式是直接使用RawImage的uvRect。 Rect uvRect = sourceImage.uvRect; // 这表示了纹理在RawImage上的UV映射 int texX = Mathf.RoundToInt((uvRect.x + normalizedX * uvRect.width) * _targetWebCamTexture.width); int texY = Mathf.RoundToInt((uvRect.y + normalizedY * uvRect.height) * _targetWebCamTexture.height); int texWidth = Mathf.RoundToInt(normalizedWidth * uvRect.width * _targetWebCamTexture.width); int texHeight = Mathf.RoundToInt(normalizedHeight * uvRect.height * _targetWebCamTexture.height); // 5. 确保坐标在纹理范围内 texX = Mathf.Clamp(texX, 0, _targetWebCamTexture.width - 1); texY = Mathf.Clamp(texY, 0, _targetWebCamTexture.height - 1); texWidth = Mathf.Clamp(texWidth, 1, _targetWebCamTexture.width - texX); texHeight = Mathf.Clamp(texHeight, 1, _targetWebCamTexture.height - texY); return new Rect(texX, texY, texWidth, texHeight); } // 辅助方法:获取UI矩形在世界空间中的轴对齐包围矩形 private Rect GetWorldRect(RectTransform rectTransform) { Vector3[] corners = new Vector3[4]; rectTransform.GetWorldCorners(corners); Vector3 bottomLeft = corners[0]; Vector3 topRight = corners[2]; float width = topRight.x - bottomLeft.x; float height = topRight.y - bottomLeft.y; return new Rect(bottomLeft.x, bottomLeft.y, width, height); } }避坑指南:坐标映射是区域截图错误(如截取区域错位、拉伸)的最常见原因。务必理解
RawImage.uvRect的含义。如果预览画面经过旋转(videoRotationAngle),这里的计算会更复杂,可能需要先将选择框的坐标进行相同的旋转变换,再进行映射。一个简单的调试方法:在截图时,将计算出的texX, texY, texWidth, texHeight打印出来,并与预期值对比。也可以在纹理上临时画一个标记(比如修改几个像素的颜色)来验证坐标是否正确。
6. 高精度区域截图与图像处理
现在,我们有了纹理上的一个精确矩形区域Rect。接下来就是从WebCamTexture中提取这个区域的像素数据,并生成一张新的Texture2D。
6.1 从WebCamTexture中截取区域
重要:WebCamTexture的像素数据不是每帧都自动同步到CPU内存的。我们需要在想要截图的那一帧,主动调用WebCamTexture.GetPixels()或WebCamTexture.GetPixels32()来获取当前帧的数据。这个过程是同步的,并且对于大纹理(如4K)可能比较耗时,绝对不能放在每帧的Update中执行。
public Texture2D CaptureRegion(Rect regionInPixels) { if (_webCamTexture == null || !_webCamTexture.isPlaying) { Debug.LogError("摄像头纹理不可用或未在播放。"); return null; } // 方法一:使用GetPixels(返回Color[],每个Color包含float类型的RGBA) // Color[] allPixels = _webCamTexture.GetPixels(); // 方法二:使用GetPixels32(返回Color32[],每个Color32包含byte类型的RGBA,内存和性能通常更好) Color32[] allPixels = _webCamTexture.GetPixels32(); // 创建一个新的Texture2D来存放截取的区域 Texture2D croppedTexture = new Texture2D((int)regionInPixels.width, (int)regionInPixels.height, TextureFormat.RGBA32, false); croppedTexture.filterMode = FilterMode.Bilinear; // 设置过滤模式 // 手动复制像素区域 // 注意:GetPixels32()获取的数组是一维的,索引计算方式为:index = y * textureWidth + x // 且原点(0,0)在纹理的**左下角**。 Color32[] croppedPixels = new Color32[(int)regionInPixels.width * (int)regionInPixels.height]; int sourceWidth = _webCamTexture.width; for (int y = 0; y < regionInPixels.height; y++) { for (int x = 0; x < regionInPixels.width; x++) { int sourceX = (int)regionInPixels.x + x; int sourceY = (int)regionInPixels.y + y; // 注意Y轴方向 // 计算在一维数组中的索引 int sourceIndex = sourceY * sourceWidth + sourceX; // 确保索引在范围内(理论上经过Clamp后应该安全) if (sourceIndex >= 0 && sourceIndex < allPixels.Length) { int destIndex = y * (int)regionInPixels.width + x; croppedPixels[destIndex] = allPixels[sourceIndex]; } } } // 将像素数据设置到新纹理 croppedTexture.SetPixels32(croppedPixels); croppedTexture.Apply(); // 应用更改,使纹理可读 return croppedTexture; }性能优化点:
- 避免频繁调用GetPixels:只在需要截图时调用一次。
- 使用GetPixels32:
Color32比Color更节省内存,处理速度也更快。 - 考虑异步:如果纹理很大,复制像素的循环可能造成帧率下降。可以考虑使用
Job System或Parallel.For(需注意线程安全)来并行处理,或者将截图操作放在一个单独的帧中执行。
6.2 图像后处理与保存
拿到Texture2D后,我们就可以进行各种后处理了。
尺寸标准化:证件照有固定尺寸(如一寸:295x413像素)。我们需要将截取的纹理缩放到标准尺寸。
Texture2D ResizeTexture(Texture2D source, int targetWidth, int targetHeight) { RenderTexture rt = RenderTexture.GetTemporary(targetWidth, targetHeight, 0, RenderTextureFormat.ARGB32); RenderTexture.active = rt; Graphics.Blit(source, rt); // 使用Graphics.Blit进行高质量缩放 Texture2D result = new Texture2D(targetWidth, targetHeight, TextureFormat.RGBA32, false); result.ReadPixels(new Rect(0, 0, targetWidth, targetHeight), 0, 0); result.Apply(); RenderTexture.active = null; RenderTexture.ReleaseTemporary(rt); return result; }背景处理:这是AR证件照的核心“AR”部分。可以使用人像分割算法(如使用
MediaPipe或Unity Barracuda运行ONNX模型)将人像与背景分离,然后替换为纯色(白色、蓝色、红色)或自定义背景。- 方案一(离线/轻量):使用预制的着色器(Shader)进行色度键控(Chroma Key),但这要求背景是纯色(如绿幕),且光照均匀,效果有限。
- 方案二(AI):集成人像分割AI模型。这涉及模型导入、推理、生成人像遮罩,然后将遮罩应用于原图,与背景合成。这是一个独立的大课题,需要处理模型性能、精度和平台兼容性。
保存到本地:
- 保存为图片文件:将
Texture2D编码为PNG或JPG字节流,然后使用System.IO.File.WriteAllBytes写入到持久化数据路径。byte[] pngData = croppedTexture.EncodeToPNG(); string filePath = Path.Combine(Application.persistentDataPath, "id_photo.png"); File.WriteAllBytes(filePath, pngData); Debug.Log($"证件照已保存至: {filePath}"); - 保存到系统相册(移动端):这需要调用原生插件。Android上可以使用
AndroidJavaClass调用MediaStoreAPI;iOS上需要使用UnityEngine.iOS.Device(旧版) 或NativeGallery等第三方插件。网络热词中的“uniapp 开发中的相机相册权限申请同步告知目的”也涉及类似的多平台权限和保存问题。
- 保存为图片文件:将
7. 实战避坑与疑难杂症排查
结合网络热词和我的实战经验,这里汇总了开发过程中最容易遇到的“坑”及其解决方案。
7.1 常见问题速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 黑屏,无画面 | 1. 权限未授予。 2. WebCamTexture未成功Play。 3. RawImage的Texture未赋值或UV设置错误。 4. 摄像头被其他应用占用。 | 1. 检查权限回调,确保返回成功。 2. 检查 _webCamTexture.isPlaying是否为true。在Play后等待几帧,检查_webCamTexture.width是否大于16。3. 检查 cameraPreview.texture是否已赋值。检查RawImage的Material/Shader是否支持普通纹理。4. 确保之前打开的摄像头已正确Stop()。重启应用。 |
| 画面方向错误(横置/颠倒) | 未处理videoRotationAngle和videoVerticallyMirrored。 | 在WebCamTexture播放后,获取这两个属性,并相应旋转和镜像RawImage的Transform。参考第4.1节代码。 |
| 画面拉伸变形 | RawImage的尺寸比例与WebCamTexture分辨率比例不一致,且未正确设置适配模式。 | 1. 为RawImage添加Aspect Ratio Fitter组件,设置Aspect Mode为Fit In Parent或Envelope Parent。2. 或者,手动计算并设置RawImage的rectTransform尺寸,使其保持纹理比例。 |
| 区域截图位置不准 | 屏幕坐标到纹理坐标映射错误。 | 1.重点检查:RawImage.uvRect是否为默认值(0,0,1,1)。如果使用了Image Type为Tiled或Filled,计算会非常复杂,建议预览时使用Simple。2. 确认纹理原点(左下角)与UI原点(通常左上角)的Y轴转换。 3. 使用调试方法,在纹理上标记计算出的区域,看是否匹配。 |
| 截图模糊 | 1. 截图区域分辨率过低。 2. 从低分辨率预览纹理截图,而非原始高分辨率数据。 | 1. 确保请求的WebCamTexture分辨率足够高(如1920x1080)。 2.采用“高分辨率快照”模式:在截图前,临时创建一个高分辨率的WebCamTexture,获取一帧数据后立即销毁,预览仍用低分辨率流。 |
| 应用卡顿、发热严重 | 1. 使用过高分辨率/帧率进行持续预览。 2. 每帧调用 GetPixels()。3. 图像后处理(如AI分割)耗时过长。 | 1. 降低预览分辨率至720p或480p。 2.绝对禁止在Update中频繁调用GetPixels。只在需要时调用一次。 3. 将AI推理等重计算移到子线程或使用异步,避免阻塞主线程。考虑降低推理分辨率。 |
| Android打包后崩溃 | 1. 权限未在AndroidManifest.xml中声明。 2. 脚本编译错误(如使用了不支持的API)。 3. IL2CPP代码裁剪导致必要的类被移除。 | 1. 检查自定义的AndroidManifest.xml。 2. 检查Player Settings中的 Scripting Backend,如果是IL2CPP,检查Managed Stripping Level,尝试设置为Low或Disabled。在链接文件中添加保留规则。 |
| WebGL平台初始化慢或失败 | WebGL中摄像头访问需要通过浏览器API,初始化是异步且可能较慢。 | 1. 增加加载等待提示。 2. 使用 WebCamTexture.RequestedWidth/Height而不是构造函数直接传参,配合WebCamTexture.devices异步获取。3. 注意浏览器本身可能阻止非HTTPS站点的摄像头访问。 |
7.2 进阶优化技巧
双纹理策略:这是解决“高清截图”与“流畅预览”矛盾的最佳实践。创建两个WebCamTexture:一个低分辨率(如
previewTex, 640x480)用于持续预览,另一个高分辨率(如captureTex, 1920x1080)仅在用户点击拍照时临时创建、获取一帧数据后立即销毁。这样可以最大限度平衡性能和画质。使用RenderTexture进行后处理:如果需要进行复杂的图像处理链(如缩放、滤镜、合成),不要反复在Texture2D和CPU内存之间来回拷贝数据。使用
RenderTexture和Graphics.Blit在GPU上完成这些操作,效率极高。最后需要时,再用Texture2D.ReadPixels从RenderTexture读回CPU。人像分割的轻量化集成:如果必须做背景替换,可以考虑使用开源的、针对移动端优化的轻量级模型。例如,使用
MediaPipe Selfie Segmentation的TFLite模型,通过TensorFlow Lite for Unity插件在Unity中运行。关键在于选择模型输入分辨率不要太高(如256x256),并将推理放在工作线程,只把最终掩码纹理传回主线程进行合成。内存管理:
Texture2D、WebCamTexture、RenderTexture都是占用显存/内存的大户。及时使用Destroy()或ReleaseTemporary()释放不再需要的纹理。特别是那个临时的高分辨率WebCamTexture,用完后立刻Stop()并置为null。
这个AR证件照项目,从权限申请到最终生成图片,是一条完整的链路。每一个环节都需要仔细考量平台差异、性能开销和用户体验。最深的体会是,移动端开发,尤其是涉及硬件和实时数据的,永远要把“稳定”和“兼容”放在第一位。功能做出来只是第一步,让它在成千上万种不同型号、不同系统的设备上都能稳定流畅地运行,才是真正的挑战。多测试、勤日志、善用异步和降级方案,是趟过这些坑的不二法门。