Unity Sentis + ONNX 实现移动端AR实时遮挡:M1 iPad Pro实战指南
1. 项目概述当AR遇见本地AI手机也能玩转实时遮挡最近在捣鼓AR项目一个老问题又冒出来了怎么让虚拟物体和现实世界“无缝”融合特别是“遮挡”效果——比如一个虚拟的茶杯放在真实的桌子后面它应该被桌子挡住一部分。传统方案要么依赖昂贵的硬件比如带LiDAR的iPhone要么就得把图像数据传到云端去算深度图延迟和隐私都是问题。直到我发现了Unity Sentis这个“神器”再结合ONNX模型格式事情变得有意思了我们能不能直接在手机里离线跑一个AI模型实时算出深度图从而实现精准的AR遮挡这个想法听起来有点疯狂毕竟在移动端跑神经网络尤其是需要实时处理的对算力和内存都是巨大挑战。但Unity Sentis的出现让这变成了可能。它本质上是一个神经网络推理引擎直接集成在Unity Runtime里。这意味着只要你有一个训练好的、并转换成ONNX格式的AI模型就能把它像普通资源一样拖进Unity项目然后在iOS、Android等平台上直接运行完全不需要网络连接。我这次实测的目标很明确在一台搭载M1芯片的iPad Pro上实现基于单目摄像头的实时深度估计并利用这个深度信息驱动AR场景中的遮挡效果。整个过程从模型选择、转换、集成到性能优化踩了不少坑也收获了很多一手经验。如果你也对“移动端离线AI”、“AR虚实融合”或者“Unity Sentis实战”感兴趣这篇长文或许能给你提供一个完整的、可复现的参考路径。2. 核心思路与技术选型为什么是SentisONNX在动手之前得先把路子想清楚。实现AR遮挡核心是获取当前摄像头画面的深度信息。传统方法有几条路硬件深度传感器如iPhone的LiDAR直接提供高质量的深度图效果最好但设备门槛高。云端AI推理手机拍张照上传到服务器服务器跑完深度估计模型再把结果下发给手机。延迟大、依赖网络、隐私数据外泄不适合实时交互。本地传统算法如基于运动恢复结构SFM或立体匹配但计算复杂对手机CPU压力大且在纹理缺失或光照变化大的场景容易失败。而Unity Sentis ONNX组合开辟了第四条路本地化AI推理。它的优势非常突出离线运行所有计算都在用户设备上完成零网络延迟数据不出设备隐私安全有保障。跨平台Unity支持的平台iOS, Android, Windows, macOS等都能跑一份模型多处部署。引擎原生集成模型作为Asset直接管理输入输出与Unity的Texture、ComputeBuffer等数据类型可以高效转换无需复杂的桥接代码。性能可控Sentis允许你指定在CPU还是GPU包括移动平台的GPU上执行推理并且支持“切片执行”Slicing将一次重计算拆到多帧完成以维持帧率稳定。为什么必须是ONNX格式ONNXOpen Neural Network Exchange是一个开放的模型表示标准。它就像AI模型的“中间语言”。无论你的模型是用PyTorch、TensorFlow还是其他框架训练的都可以转换成ONNX格式。Sentis目前主要支持ONNX格式的模型导入这极大地扩展了其模型生态。你可以在Hugging Face、Model Zoo等社区找到大量预训练的、任务各异的ONNX模型直接“拿来主义”。对于我们的AR遮挡场景核心任务就是“单目深度估计”Monocular Depth Estimation。我们需要选择一个在精度和速度之间取得良好平衡并且模型大小适合移动端部署的ONNX模型。经过一番调研和测试我最终选定了MiDaS v2.1 small的ONNX版本。原因如下轻量级模型文件较小约几十MB对移动应用体积影响可控。相对精度与速度平衡虽然不是最精确的但在移动设备上能以较快的速度理想情况下15FPS产出可用的相对深度图。社区支持好ONNX模型容易获取且有相关的预处理、后处理代码参考。注意模型选择没有银弹。MiDaS small是一个不错的起点但如果你对精度要求极高可能需要尝试更大的模型并承受更慢的速度或者探索专为移动端优化的模型如MobileDepth。Sentis的开放性允许你随时替换模型。3. 环境准备与模型获取从零开始的配置清单工欲善其事必先利其器。开始编码前需要把环境和素材准备好。3.1 Unity项目与Sentis包配置Unity版本确保使用Unity 2021.3 LTS或更高版本。Sentis公开测试版支持这些版本。我使用的是Unity 2022.3 LTS稳定性较好。创建项目新建一个3D项目URP或Built-in Render Pipeline均可但需要注意后续Shader兼容性URP更现代推荐使用。安装Sentis包通过Unity的Package Manager安装Sentis。打开Window - Package Manager。点击左上角“”号选择Add package from git URL...。输入Sentis的Git仓库地址https://github.com/Unity-Technologies/com.unity.sentis.git。你也可以在Package Manager的Unity Registry中搜索“Sentis”进行安装如果已上架。等待下载和导入完成。3.2 获取与准备ONNX深度估计模型我们选择MiDaS模型。可以直接从其官方GitHub仓库或ONNX Model Zoo寻找。这里提供一个实操路径下载模型访问Hugging Face Model Hub或直接搜索“MiDaS small ONNX”。找到一个可靠的源下载.onnx模型文件。例如一个常见的版本是model-small.onnx。了解模型输入输出这是关键一步用Netron一个可视化神经网络模型的工具打开下载的.onnx文件。我们需要确认输入节点(Input Name)通常是input。查看它的形状(Shape)例如[1, 3, 256, 256]。这表示模型期望的输入是批大小(Batch Size)为13个颜色通道RGB高度256像素宽度256像素。注意通道顺序通常是RGB但有些训练模型可能用BGR。输出节点(Output Name)通常是output。它的形状可能是[1, 1, 256, 256]表示输出一张单通道的、256x256的深度图。导入Unity将下载好的model-small.onnx文件直接拖拽到Unity项目的Assets文件夹下。Unity会自动识别并用Sentis导入器处理它。模型导入设置在Unity Inspector窗口中点击这个模型文件可以看到Sentis提供的导入设置。模型精度默认是FP32单精度浮点数。对于大多数手机尤其是支持半精度计算的GPU如M1可以尝试切换到FP16以提升性能、减少内存占用但可能会轻微影响精度。实测建议在M1芯片上FP16通常能稳定工作且速度提升明显可以作为首选。优化级别保持默认或选择Best performance。Sentis会在导入时对模型图进行优化。生成代码这是一个很棒的功能。勾选后Sentis会为这个模型生成一个强类型的C#包装类里面包含了输入输出张量名称、形状等定义能避免硬编码字符串提高代码安全性和开发效率。务必勾选。完成这些你的项目资产里应该就有一个可用的Sentis模型资源了。同时在相同的目录下Sentis会生成一个同名的.cs脚本文件如果勾选了生成代码例如ModelSmall.cs。我们稍后会用到它。4. 核心实现构建实时深度推理与AR遮挡管线接下来是代码部分。我们将创建一个主要的管理器脚本来协调整个流程捕获摄像头画面、预处理、运行AI模型、后处理深度图、最后应用遮挡。4.1 创建深度推理管理器DepthInferenceManager新建一个C#脚本命名为DepthInferenceManager.cs。using UnityEngine; using Unity.Sentis; // Sentis核心命名空间 using UnityEngine.Rendering; // 用于AsyncGPUReadback using System.Collections.Generic; public class DepthInferenceManager : MonoBehaviour { [Header(Model Settings)] public ModelAsset modelAsset; // 拖入Assets文件夹里的ONNX模型文件 private Model m_RuntimeModel; private IWorker m_Worker; // 使用Sentis生成的代码类例如 private ModelSmall m_ModelMeta; // 假设生成的类叫ModelSmall [Header(Camera Input)] public Camera arCamera; // 你的AR摄像头如ARCamera private RenderTexture m_CameraRT; private Texture2D m_ResizedInputTexture; [Header(Processing Settings)] public int inputWidth 256; public int inputHeight 256; private TensorFloat m_InputTensor; private TensorFloat m_OutputTensor; [Header(Output)] public RenderTexture depthOutputRT; // 用于存储和显示深度图的RenderTexture public Material occlusionMaterial; // 使用深度图进行遮挡的材质 private bool m_IsProcessing false; private QueueAsyncGPUReadbackRequest m_ReadbackRequests new QueueAsyncGPUReadbackRequest(); void Start() { // 1. 加载模型 if (modelAsset null) { Debug.LogError(Model Asset is not assigned!); return; } m_RuntimeModel ModelLoader.Load(modelAsset); // 使用生成的代码初始化模型元数据 m_ModelMeta new ModelSmall(); // 2. 创建推理Worker。选择后端GPUCompute最快但Fallback到CPU // M1芯片的GPU性能很强优先使用GPUCompute m_Worker WorkerFactory.CreateWorker(BackendType.GPUCompute, m_RuntimeModel); // 3. 初始化纹理 m_CameraRT new RenderTexture(Screen.width, Screen.height, 24); m_ResizedInputTexture new Texture2D(inputWidth, inputHeight, TextureFormat.RGB24, false); // 4. 创建输出RenderTexture if (depthOutputRT null) { depthOutputRT new RenderTexture(inputWidth, inputHeight, 0, RenderTextureFormat.RFloat); // 深度图通常用单通道浮点数 depthOutputRT.Create(); } // 5. 将深度图传递给遮挡材质 if (occlusionMaterial ! null) { occlusionMaterial.SetTexture(_DepthTex, depthOutputRT); // 通常还需要传递相机参数如近/远裁剪面用于深度值映射这里省略简化 } } void OnDestroy() { // 清理资源非常重要 m_Worker?.Dispose(); m_InputTensor?.Dispose(); m_OutputTensor?.Dispose(); if (m_CameraRT ! null) Destroy(m_CameraRT); if (m_ResizedInputTexture ! null) Destroy(m_ResizedInputTexture); if (depthOutputRT ! null) Destroy(depthOutputRT); } }4.2 实现每帧的捕获、推理与后处理我们在Update循环中安排异步处理避免阻塞主线程。由于从GPU读取纹理数据是异步操作我们需要妥善管理这个流程。void Update() { // 如果上一帧还在处理则跳过避免堆积 if (m_IsProcessing || m_Worker null || arCamera null) return; // 开始新的处理流程 StartCoroutine(ProcessFrameAsync()); } System.Collections.IEnumerator ProcessFrameAsync() { m_IsProcessing true; // 1. 捕获当前摄像头画面到RenderTexture RenderTexture currentRT RenderTexture.active; RenderTexture.active m_CameraRT; arCamera.Render(); RenderTexture.active currentRT; // 2. 异步将RenderTexture数据读取到CPU AsyncGPUReadbackRequest request AsyncGPUReadback.Request(m_CameraRT, 0, TextureFormat.RGB24); m_ReadbackRequests.Enqueue(request); // 等待最近的请求完成 while (m_ReadbackRequests.Count 0 m_ReadbackRequests.Peek().done) { request m_ReadbackRequests.Dequeue(); if (request.hasError) { Debug.LogWarning(GPU readback error!); continue; } // 3. 数据预处理复制到Texture2D并缩放到模型输入尺寸 var data request.GetDatabyte(); m_ResizedInputTexture.LoadRawTextureData(data); m_ResizedInputTexture.Apply(); // 使用Graphics.Blit进行高效的缩放和格式转换如果需要BGR等 RenderTexture tempRT RenderTexture.GetTemporary(inputWidth, inputHeight, 0); Graphics.Blit(m_ResizedInputTexture, tempRT); // 注意这里假设模型输入是RGB顺序正确。如果模型需要BGR可能需要一个特定的材质进行通道交换。 // 4. 从处理后的RT创建Sentis输入张量 // 关键Sentis的TensorFloat.FromTexture期望纹理数据在GPU上。 // 我们直接使用tempRT创建张量避免额外的CPU-GPU拷贝。 m_InputTensor?.Dispose(); m_InputTensor TextureConverter.ToTensor(tempRT, inputWidth, inputHeight, 3); // 使用Sentis提供的转换工具 RenderTexture.ReleaseTemporary(tempRT); // 5. 执行模型推理 m_Worker.Execute(m_InputTensor); // 获取输出张量 (使用生成代码中的输出名称) m_OutputTensor m_Worker.PeekOutput(m_ModelMeta.Outputs[0]) as TensorFloat; // 6. 后处理将输出张量写回RenderTexture用于着色器 // 这里假设输出是[1,1,H,W]的单通道深度图 TextureConverter.RenderToTexture(m_OutputTensor, depthOutputRT); // 7. 可以在这里对depthOutputRT进行额外的后处理如高斯模糊减少噪声、归一化等 PostProcessDepth(depthOutputRT); } m_IsProcessing false; yield return null; } void PostProcessDepth(RenderTexture depthRT) { // 示例一个简单的双边滤波可以在Shader中实现减少深度图的噪声同时保持边缘。 // 这里为了简化可以只是一个直通或者调用一个内置的Blur。 // 实际项目中你可能会写一个Compute Shader或使用Command Buffer进行更高效的后处理。 // 例如 // Graphics.Blit(depthRT, tempRT, bilateralFilterMaterial); // Graphics.Blit(tempRT, depthRT); }4.3 实现AR遮挡着色器深度图已经实时计算出来了接下来就是如何在AR场景中使用它。核心思想是在渲染虚拟物体时对比虚拟物体像素的深度相对于AR相机和从深度图采样得到的对应位置的真实场景深度。如果虚拟物体深度大于真实深度则丢弃或淡化该像素。创建一个Unlit Shader Graph如果使用URP或编写一个Surface Shader。关键节点逻辑以Shader Graph为例输入Texture2D_DepthTex我们实时计算的深度图。Float_CameraNear/_CameraFarAR相机的近/远裁剪面距离。Float_DepthScale/_DepthBias用于调整深度图值的缩放和偏移以匹配虚拟场景的深度范围。计算虚拟物体深度使用View Port Node获取屏幕空间位置使用Scene Depth Node在URP中或手动计算从相机到当前片元的世界空间距离并线性化到[0,1]范围。采样真实场景深度使用屏幕空间UV即虚拟物体在屏幕上的位置去采样_DepthTex。注意深度图的值通常也是[0,1]的归一化值但可能不是线性的且其0和1对应的实际物理距离需要与虚拟深度匹配。这通常需要一个映射过程。深度比较比较“虚拟物体深度”和“采样得到的真实深度经过映射后”。如果虚拟深度 真实深度 _Bias虚拟物体在真实物体后面则clip(-1)或将其Alpha值设为0完全遮挡。如果虚拟深度 真实深度 - _Bias虚拟物体在真实物体前面则正常渲染。可以设置一个平滑过渡区域实现半透明遮挡效果更自然。输出将比较结果输出到片元的Alpha或直接Clip。实操心得深度值的匹配是整个遮挡效果是否准确的关键。AR相机计算的深度通常是线性眼空间Z与AI模型输出的深度通常是相对或反深度在数值范围和分布上完全不同。你需要一个校准步骤。一个实用的方法是在场景中放置一个已知物理尺寸的标定板分别用AR相机和深度图测量其深度然后拟合出一个简单的线性或非线性如1/(depthMapValue epsilon)映射函数。在项目初期可以先用一个可调节的_DepthScale和_DepthBias参数在编辑器里手动调到一个视觉上可接受的效果。将这个着色器应用到你希望被遮挡的虚拟物体材质即脚本中的occlusionMaterial上。这样这些物体就会根据实时深度图“知道”自己应该被现实世界的哪些部分挡住了。5. M1芯片实测性能调优与避坑指南在搭载M1芯片的iPad Pro上进行部署和测试是验证方案可行性的关键一步。Unity Sentis对Apple SiliconM系列芯片的支持是通过其GPUCompute后端实现的它利用Metal Performance Shaders来加速神经网络计算。5.1 性能数据与瓶颈分析在我的实测中iPad Pro M1, 2021款使用MiDaS small (FP16)模型输入分辨率256x256得到了以下大致数据推理耗时单次模型执行m_Worker.Execute耗时约30-50毫秒。这占据了每帧的大部分时间。整体帧率在包含摄像头捕获、数据读取、推理、后处理和常规AR渲染的完整流程下帧率可以维持在15-25 FPS之间。内存占用模型加载后GPU内存会有显著增加。需要密切关注Profiler中的GPU Memory和Total Allocated项。发热与功耗持续运行5-10分钟后设备背部有可感知的温升但未出现降频导致的卡顿。对于长时间使用的应用需要考虑间歇运行或降低频率。主要性能瓶颈模型推理本身这是最重的部分。选择更小的模型或进一步量化如INT8是根本解决办法但Sentis对INT8的支持可能有限且精度损失需要评估。CPU-GPU数据搬运AsyncGPUReadback和TextureConverter.ToTensor涉及数据在CPU和GPU间的传输是另一个开销源。我们通过使用RenderTexture直接创建张量减少了一次拷贝。后处理深度图的后处理如滤波如果放在CPU上进行会非常慢。务必使用Shader或Compute Shader在GPU上完成。5.2 关键优化策略降低输入分辨率这是提升速度最有效的方法。尝试从256x256降到128x128速度可能提升3-4倍但深度图细节会损失。需要根据应用场景权衡。使用Sentis的模型切片Slicing如果单次推理耗时超过一帧的预算例如33ms30FPS可以将模型切成多个部分分在连续的多帧中执行。Sentis提供了StartManualSchedule和ExecuteSlice等API。这对于大型模型在低端设备上维持交互帧率至关重要。// 伪代码示例分帧执行 IEnumerator ExecuteModelOverFrames(TensorFloat inputTensor) { using var ops WorkerFactory.CreateOps(BackendType.GPUCompute, null); using var slicedWorker WorkerFactory.CreateWorker(BackendType.GPUCompute, m_RuntimeModel, new WorkerArgs { scheduleType ScheduleType.Manual }); slicedWorker.SetInput(m_ModelMeta.Inputs[0], inputTensor); slicedWorker.StartManualSchedule(); while (slicedWorker.MoveNext()) { yield return null; // 每执行一个“切片”等待一帧 } m_OutputTensor slicedWorker.PeekOutput() as TensorFloat; }精度选择如前所述在M1上使用FP16精度通常能获得比FP32更好的性能且精度损失对于深度估计任务往往可以接受。在模型导入设置或创建Worker时指定。避免每帧分配新张量在Update循环中频繁创建和销毁Tensor和RenderTexture会引发GC垃圾回收导致卡顿。应该在Start中预分配并在循环中复用。控制推理频率并非每一帧都需要最新的深度图。对于移动缓慢的场景可以每2帧甚至每5帧推理一次中间帧复用上一帧的深度图。这能大幅降低平均功耗和发热。5.3 M1/iOS平台特定问题与解决构建设置Graphics API确保在Player Settings中只保留Metal。移除OpenGL ES。Architecture选择ARM64。Enable Engine Code Stripping开启以减小包体。Managed Stripping Level可以尝试设为High但需充分测试以防Sentis运行时需要的代码被错误剥离。权限别忘了在Info.plist中添加相机使用权限描述NSCameraUsageDescription。内存警告iOS对内存使用非常敏感。除了优化纹理和张量大小还要确保在应用进入后台OnApplicationPause时释放Sentis的Worker和大的张量回到前台时再重新初始化。发热管理长时间高负载运行会触发系统温控。除了上述降低频率的优化还可以考虑在检测到设备温度过高可通过iOS原生API获取但需谨慎时自动降低模型输入分辨率或关闭深度推理功能。6. 常见问题与排查技巧实录在实际开发中你几乎一定会遇到下面这些问题。这里记录了我的排查过程和解决方案。6.1 模型推理结果全黑或全白现象深度图depthOutputRT输出一片纯黑或纯白没有有效的深度信息。排查检查输入数据在TextureConverter.ToTensor之前将tempRT保存为PNG图片到本地查看缩放和颜色通道是否正确。确认图片内容是你预期的摄像头画面。检查模型输入/输出名确保m_Worker.Execute和PeekOutput使用的输入输出名称与模型定义完全一致。强烈建议使用Sentis生成的代码类如m_ModelMeta.Inputs[0]避免手输字符串出错。检查数值范围AI模型输出的深度值范围可能不是[0,1]。可能是[0, 255]也可能是负值到正值。用Debug.Log打印m_OutputTensor的几个数据点看看。后处理阶段可能需要重新缩放Normalize到[0,1]才能正确显示为灰度图。验证模型在Python环境中用ONNX Runtime加载同一个ONNX模型输入一个测试图片看输出是否正常。这能排除模型文件本身或预处理的问题。6.2 在编辑器里正常打包到iOS后崩溃或无效果现象Unity Editor中运行良好但部署到真机后应用启动即崩溃或深度推理不工作。排查查看设备日志通过Xcode的Console或log命令查看崩溃堆栈。最常见的原因是模型文件未被打包。检查模型文件的平台设置在Unity中选中你的.onnx模型文件在Inspector查看其导入设置。确保在iOS平台下Include in Build是勾选的并且没有因为任何原因被排除。检查依赖库确保Sentis包及其所有Native插件如iOS的Metal库都正确包含在构建中。有时需要手动检查Plugins/iOS目录。初始化顺序确保所有Sentis相关对象Model,Worker的创建都在Start或Awake中完成避免在子线程或不确定的时机创建。6.3 深度图抖动严重遮挡效果闪烁现象计算出的深度图噪声大相邻帧之间变化剧烈导致虚拟物体的遮挡边缘不停闪烁。解决时间滤波最简单有效的方法。不要直接使用当前帧的深度图而是与上一帧的深度图进行混合。例如currentDepth 0.7 * lastDepth 0.3 * newDepth。这能极大平滑抖动但会引入少量延迟。空间滤波对深度图进行后处理滤波。双边滤波Bilateral Filter是首选因为它能在平滑噪声的同时保留物体边缘。在Shader中实现一个高效的双边滤波。降低输入分辨率噪声部分来源于模型对小细节的误判。降低输入分辨率本身也是一种强力的降噪方式虽然损失了细节。检查摄像头对焦与曝光如果AR摄像头处于自动对焦/曝光模式画面亮度和焦点变化会导致模型输入差异巨大从而输出不稳定。尝试将摄像头设置为固定对焦和曝光。6.4 性能不达标帧率过低现象在目标设备上无法达到可接受的帧率如30FPS。系统性排查使用Unity ProfilerDeep Profile连接真机进行性能分析。精确找到耗时最长的函数。到底是Worker.Execute还是AsyncGPUReadback.Request或是你自己的后处理Shader调整Worker后端在创建Worker时尝试不同的BackendType。对于M1芯片GPUCompute通常是最快的。但在某些Android设备上CPU后端可能更稳定。可以做一个运行时检测根据设备能力选择后端。模型切片如上文所述对于无法在一帧内完成的推理使用手动调度切片。审视整个管线是否每一帧都在进行高代价的操作比如每帧都new Texture2D是否可以将一些计算如UV映射提前到初始化阶段