
最近在做一个虚拟形象驱动项目需要在移动端实现实时人脸五官定位给虚拟形象提供表情驱动数据。我选择的方案是 Unity Sentis Compute Shader det_10g.onnx。先说结论这套组合在 Android 和 iOS 上都能跑到实时整帧延迟大概 30ms 左右Sentis 负责神经网络推理Compute Shader 负责后处理的并行加速det_10g.onnx 是 SCRFD 系列里精度和速度比较平衡的人脸检测模型。如果你正在做实时人脸追踪、表情映射、活体检测或者滤镜贴合类需求这篇文章应该能帮你少走不少弯路。这次我会把整个工程从选型、模型分析、Sentis 推理链路、Compute Shader 加速到最后性能调优和踩坑记录都捋一遍。代码部分尽量写完整方便直接抄到自己的项目里改。1. 为什么在 Unity 里做这件事方案怎么定下来的1.1 需求场景拆解这个项目的核心需求看起来很明确实时检测人脸、输出五官关键点。但真正往工程深处想其实是三个硬性约束在推着技术选型走第一个约束是数据不出设备。人脸数据属于高度敏感信息如果走云 API光网络传输延迟就够喝一壶合规方面还要考虑数据脱敏、用户授权、存储策略等等一大堆问题。所以只能本地推理。第二个约束是跨平台。项目要同时覆盖 Android、iOS、Windows 预览甚至后面还可能要出 WebGL 演示版。如果每换一个平台就重写一套推理逻辑开发和维护成本都承受不住。第三个约束是实时性。人脸五官定位本身是为表情驱动服务的它要喂给虚拟形象的 BlendShape 或者骨骼系统。一旦帧率掉到 15fps 以下虚拟形象的表情就会明显发飘、卡顿用户一眼就能看出来。所以方案选型的评价标准很明确本地运行、跨平台、实时、还得在 Unity 里能舒服地接入。1.2 技术选型Sentis 在几个方案里赢在哪我在确定 Sentis 之前其实把市面上能在 Unity 里跑的推理方案都过了一遍Barracuda这是 Unity 早年的神经网络推理方案历史包袱重ONNX 算子覆盖不完整。我拿 det_10g 试过一次模型导出过程中有几个层直接报不支持还得手动替换折腾半天最后放弃了。ONNX Runtime Unity 插件功能上没问题但需要自己处理 Android 和 iOS 的原生库每个平台的 so / framework 都要配置。打包体积、初始化速度也是自己管。如果项目里有 CI 打包流程光适配原生库就够呛。自写 native bridge 调 ncnn / TNN性能上限最高但要写 Android JNI iOS OC 桥接工程复杂度直接翻倍而且后续维护要同时懂 Unity 和原生开发。Sentis 是 Unity 官方现在主推的推理框架只要把 ONNX 模型拖进工程就能用内置 GPUCompute 后端底层自动分配显存、管理张量跨平台逻辑也处理好了。它还支持混合后端调用和量化模型对大多数做 Unity 内推理的人来说开发效率是最高的。当然 Sentis 也有自己的问题比如在 WebGL 平台对 GPUCompute 的支持受限这个后面我会专门讲。1.3 det_10g.onnx 这个模型到底适不适合det_10g 是 SCRFD 系列的人脸检测模型名字里的 10g 指的是模型计算量在 10 GFLOPs 左右在 InsightFace 开源项目里就能找到现成的 ONNX 文件。它输入 640x640 的 BGR 图像输出三个尺度stride 8、16、32的得分图、边界框偏移、以及 5 点关键点坐标。这 5 个点正好对应左眼、右眼、鼻尖、左嘴角、右嘴角直接覆盖了“人脸五官定位”的需求。选型时我拿几类模型做了对比超轻量模型比如 tiny 系列速度快但稍远距离、侧脸、遮挡情况下漏检很明显五官定位不稳定放弃。重模型比如 ResNet 系列精度高但移动端 GPU 推理经常超过 50ms达不到实时。det_10g处在中间位置移动端 GPU 推理大约 6-10ms精度也能接受。还有一点值得说det_10g 本质上是“先检人脸框再回归关键点”的模型这种设计比直接回归关键点的方式更稳。因为人脸框对光照突变、局部遮挡的敏感度更低先锁定框再做关键点后续 NMS 和尺度归一化也更有依据。2. det_10g.onnx 模型分析与预处理2.1 模型来源与输出结构如果你从 InsightFace 官方仓库下载 det_10g.onnx拿到手第一件事不是急着往 Unity 里拖而是先用 Netron 打开模型看一眼结构。这个习惯能帮你省下后面至少两小时的排错时间。det_10g 的输出大概是下面这样三组数据score_stride8 / score_stride16 / score_stride32形状分别是 [1, 2, 6400, 1]、[1, 2, 1600, 1]、[1, 2, 400, 1]bbox_stride8 / bbox_stride16 / bbox_stride32通道数是 8因为每个位置有 2 个 anchor每个 anchor 需要预测 4 个边界框偏移kps_stride8 / kps_stride16 / kps_stride32通道数是 20因为每个 anchor 需要预测 5 个关键点的 x、y 坐标那 6400、1600、400 怎么来的其实就是当前尺度下网格数量乘以 anchor 数量80×80×2640040×40×2160020×20×2400。需要特别提醒不同版本或不同脚本导出的模型输出层名称和维度布局可能有差异。我从社区里下载过一个别人转的版本输出名直接叫 outputs_0、outputs_1布局还和官方脚本不一样。遇到这种情况老老实实按你手上的模型为准。2.2 输入预处理从摄像头纹理到模型张量模型要求 640x640x3 的 BGR 输入像素值归一化到 [0,1]。但 Unity 里摄像头纹理通常是 BGRA32 或 RGBA32所以中间还得做几件事。第一件是缩放。把真实分辨率的摄像头画面缩放到 640x640这一步要注意比例问题。如果直接拉伸到正方形人脸会被横向或者纵向拉变形检测精度会明显下降。正确做法是做一个居中裁剪先按人脸区域在画面中央的假设把较长的边裁掉一部分再缩放到 640x640。第二件是通道转换。Unity 里拿到的像素是 RGBA 或 BGRA 顺序而模型训练时用的是 BGR。这个转换不能在遍历像素的时候漏掉否则模型的检测结果会非常怪异甚至直接什么都测不出来。第三件是归一化。把 0-255 的像素值除以 255映射到 [0,1] 区间。det_10g 训练时用的是 [0,1] 归一化不是 [0,255]也不是 ImageNet 的 mean/std 归一化别搞混了。我第一版实现里用了一个比较直接的方式先把摄像头纹理 Blit 到一个 640x640 的 RenderTexture然后用ReadPixels读回来再在 C# 循环里做通道转换和归一化。这个方案在 PC 上没问题但在移动端上ReadPixels和GetPixels32的开销会让人头疼后面我会专门讲怎么优化。2.3 测试模型输出形状的小技巧在我把解析逻辑写死之前先在工程里塞了一个临时的调试脚本核心逻辑就是加载模型后推理一次然后遍历所有输出层把名字和形状打出来var model ModelLoader.Load(modelBytes); var worker WorkerFactory.CreateWorker(BackendType.GPUCompute, model); // 构造一个全 0 的输入 var input new Tensorfloat(new TensorShape(1, 3, 640, 640)); worker.Execute(input); foreach (var outputName in model.outputs) { var tensor worker.PeekOutput(outputName) as Tensorfloat; Debug.Log(${outputName}: {tensor.shape}); }这一步的价值在于它能立刻告诉你手上的模型输出层叫什么、每一维是多少。我拿到手的 det_10g 模型输出层名称跟官方文档里的一模一样但我也见过别人导出的版本叫 score_8、bbox_16 这种带下划线的名称。所以这个调试脚本建议保留后面排查问题也能用上。3. Sentis 推理模块落地3.1 Worker 生命周期管理Sentis 里最核心的三个对象是Model、IWorker和Tensor。Model只是模型定义真正的推理执行者是IWorker。我写了一个FaceLandmarkDetector类核心字段就这几个public class FaceLandmarkDetector : IDisposable { private Model _model; private IWorker _worker; private Tensorfloat _inputTensor; public void Initialize(string modelPath) { byte[] modelBytes File.ReadAllBytes(modelPath); _model ModelLoader.Load(modelBytes); _worker WorkerFactory.CreateWorker(BackendType.GPUCompute, _model); } public void Dispose() { _inputTensor?.Dispose(); _worker?.Dispose(); } }这里有几个细节要注意模型文件路径如果放 StreamingAssets用File.ReadAllBytes读取如果放 Resources用TextAsset加载。别在移动平台上用File.ReadAllBytes去读 Resources 下的文件路径都对不上。IWorker一定要在OnDestroy或Dispose里释放。反复创建 Worker 而不释放短时间内显卡显存就会飙升。输入输出 Tensor 也需要注意释放。虽然 Sentis 有自动回收机制但在长时间运行的摄像头场景里依赖 GC 是不可靠的。3.2 执行推理与输出张量读取推理本身不复杂复杂的是把数据正确喂进去、正确拿出来。一个典型流程是这样public void Detect(Texture2D input, out Rect faceRect, out Vector2[] landmarks) { // 1. 预处理把 input 转成 BGR float 数组 float[] data Preprocess(input); // 2. 创建输入张量 using (var inputTensor new Tensorfloat(new TensorShape(1, 3, 640, 640), data, false)) { // 3. 执行推理 _worker.Execute(inputTensor); // 4. 读取三个尺度的输出 var score8 _worker.PeekOutput(score_8) as Tensorfloat; var bbox8 _worker.PeekOutput(bbox_8) as Tensorfloat; var kps8 _worker.PeekOutput(kps_8) as Tensorfloat; // 另外两个尺度同理... // 5. 转成托管数组 float[] scores8 score8.ToReadOnlyArray(); float[] boxes8 bbox8.ToReadOnlyArray(); float[] kps8Arr kps8.ToReadOnlyArray(); // 6. 后处理阈值过滤 NMS 坐标还原 } }有一个地方很容易踩坑PeekOutput返回的 Tensor 是执行引擎内部的引用不是拷贝。如果你在同一个 Worker 上第二次调用Execute这个 Tensor 的数据就会被覆盖。所以要马上调用ToReadOnlyArray()把数据复制出来要么复制 Tensor不要等着后续再去读。另外Sentis 不同版本之间Tensorfloat的构造参数有一些差异。我用的 Sentis 1.2.0 版本new Tensorfloat(shape, dataArray, false)最后一个参数表示是否直接把数组所有权转移给 Tensor。如果你用的版本 API 不一样以官方文档为准。3.3 坐标解码与坐标还原det_10g 的输出不是直接的像素坐标而是相对 anchor 的偏移值。要还原出真实坐标需要拿到每个尺度的 anchor 信息。以 SCRFD 官方脚本的逻辑为准anchor 是这样生成的stride8 的尺度网格大小是 80×80每个网格中心在 (grid_x 0.5) × 8, (grid_y 0.5) × 8每个网格点有 2 个预设框宽高比分别是 1.0 和 1.5输出偏移量是相对预设框中心和高宽的归一化值我在 C# 里写了一个解码函数流程是先按 score 阈值过滤掉低分 anchor再解码 bbox 和 kps这样能大幅减少后续 NMS 的候选数量private ListFaceCandidate DecodeCandidates(float[] scores, float[] boxes, float[] kps, int gridW, int gridH, int stride, float scoreThreshold) { var candidates new ListFaceCandidate(); int numAnchors gridW * gridH * 2; for (int i 0; i numAnchors; i) { float score scores[i]; if (score scoreThreshold) continue; int gridIndex i / 2; int anchorIndex i % 2; int gx gridIndex % gridW; int gy gridIndex / gridW; float cx (gx 0.5f) * stride; float cy (gy 0.5f) * stride; float ratio anchorIndex 0 ? 1.0f : 1.5f; float w stride * ratio; float h stride * ratio; // bbox 是 4 个偏移量对应 x, y, w, h float dx boxes[i * 4 0]; float dy boxes[i * 4 1]; float dw boxes[i * 4 2]; float dh boxes[i * 4 3]; float x1 cx dx * w * 0.5f; // 具体解码公式以模型为准 float y1 cy dy * h * 0.5f; float x2 cx dw * w * 0.5f; float y2 cy dh * h * 0.5f; // kps 是 10 个值对应 5 个点的 x, y var landmarks new Vector2[5]; for (int p 0; p 5; p) { landmarks[p] new Vector2(kps[i * 10 p * 2], kps[i * 10 p * 2 1]); } candidates.Add(new FaceCandidate(score, x1, y1, x2, y2, landmarks)); } return candidates; }这段代码里我写的解码公式是简化版因为 det_10g 不同导出脚本里 bbox 偏移的定义确实有差异。有些版本预测的是中心点偏移有些预测的是左上角坐标。如果发现还原出来的框位置不对先去查模型训练脚本里的解码方式再回头改公式。输入尺寸是 640x640 的话还原出来的坐标就直接对应 640x640 图像上的像素坐标。但如果摄像头实际分辨率和 640x640 不一样还要再做一步缩放。因为预处理时做了居中裁剪这一步的缩放需要按裁剪后的映射关系来算不能简单粗暴地按 x 除以 640 再乘以屏幕宽度。4. Compute Shader 加速后处理4.1 为什么后处理是性能瓶颈模型推理一次输出的候选框总数是 6400 1600 400 8400 个位置乘以 2 个 anchor也就是 16800 个候选。如果全部在 C# 的 for 循环里做解码和 NMS一帧 CPU 时间大概要 20ms 起步这还没算 GC 分配的开销。整帧时间直接爆炸。但这里有个特点绝大多数 candidate 的 score 都很低。真正能通过 score 0.5 阈值的人脸候选通常也就几十到几百个。所以我的策略是两步走在 GPU 上用 Compute Shader 并行做阈值过滤、bbox 解码和关键点还原把 16800 个候选快速缩小到几百个。把缩小后的候选列表拷贝回 CPU用经典贪心 NMS 做全局抑制。这个方案既不钻“GPU 上做严格全局 NMS”的牛角尖又能把最耗时的、可并行的那部分交给 GPU是工程效率和性能之间的一个良好平衡。实测后处理总耗时控制在 1ms 以内。4.2 阈值过滤与坐标解码的 GPU 实现要让 Compute Shader 读数据第一步是把张量数据搬进 ComputeBuffer。我采用的方式是先从 TensorToReadOnlyArray()拿到托管数组再直接塞进 ComputeBuffer// 创建 ComputeBuffer int totalAnchors 16800; ComputeBuffer scoreBuffer new ComputeBuffer(totalAnchors, sizeof(float)); ComputeBuffer bboxBuffer new ComputeBuffer(totalAnchors * 4, sizeof(float)); ComputeBuffer kpsBuffer new ComputeBuffer(totalAnchors * 10, sizeof(float)); scoreBuffer.SetData(scoresArray); bboxBuffer.SetData(boxesArray); kpsBuffer.SetData(kpsArray);这里不直接拿 Sentis 的 GPU Tensor 给 Compute Shader 用是因为 Sentis 在 GPU 侧会做内存布局优化它的 Tensor 内部 Layout 不保证和我们想象中的 NCHW 一致。如果直接当作普通 Buffer 去读取拿到的数据顺序大概率是错的。所以稳妥的路线是 GPU 推理完Tensor 转 CPU 数组再上传到 ComputeBuffer。Compute Shader 的核心 Kernel 长这样#pragma kernel DecodeAndFilter StructuredBufferfloat _Scores; StructuredBufferfloat _Boxes; StructuredBufferfloat _Kps; RWStructuredBufferfloat4 _CandidatesBox; RWStructuredBufferfloat4 _CandidatesKps0; RWStructuredBufferfloat4 _CandidatesKps1; RWStructuredBufferfloat _CandidatesScore; RWStructuredBufferuint _CandidateCount; float _ScoreThreshold; float _DecodeScale; [numthreads(256, 1, 1)] void DecodeAndFilter(uint3 id : SV_DispatchThreadID) { uint i id.x; if (i 16800) return; float score _Scores[i]; if (score _ScoreThreshold) return; // 从 index 反推 stride、网格坐标、anchor 序号 uint remainder i; uint strideIndex 0; uint offset 0; // 简化按实际三个尺度的排布计算 // ... // 解码 bbox float4 box float4(_Boxes[i * 4], _Boxes[i * 4 1], _Boxes[i * 4 2], _Boxes[i * 4 3]); float4 decodedBox DecodeBox(box, gx, gy, stride, anchorIndex); // 原子写入 candidate uint writeIndex; InterlockedAdd(_CandidateCount[0], 1u, writeIndex); if (writeIndex 2048) return; // 防止越界 _CandidatesBox[writeIndex] decodedBox; _CandidatesScore[writeIndex] score; _CandidatesKps0[writeIndex] float4(_Kps[i * 10 0], _Kps[i * 10 1], _Kps[i * 10 2], _Kps[i * 10 3]); _CandidatesKps1[writeIndex] float4(_Kps[i * 10 4], _Kps[i * 10 5], _Kps[i * 10 6], _Kps[i * 10 7]); }这里用了InterlockedAdd做原子计数让多个线程安全地往候选列表里追加数据。候选 Buffer 预分配 2048 个元素的容量实测在 score0.5 阈值下基本够用。需要特别注意的是三个尺度stride8、16、32的候选是分开放在三个输出张量里的我最后是把三组数据拼接成一个大的数组再上传让 Compute Shader 只用处理一个 index 空间。拼接时要把每组网格的起始偏移算清楚最好在 CPU 侧就调好。4.3 GPU NMS 的并行化实现如果你的候选框数量经常很多或者 CPU 侧 NMS 依然拖后腿可以考虑把 NMS 也搬到 GPU 上做。方法不复杂思路是假设候选框数量是 K那么 Dispatch K 个线程每个线程负责一个候选框 i。然后循环遍历所有候选框 j如果score[j] score[i]且 IoU(i, j) 大于阈值就把 i 标记为被抑制suppressed。#pragma kernel ParallelNMS StructuredBufferfloat4 _CandidatesBox; StructuredBufferfloat _CandidatesScore; RWStructuredBufferuint _Suppressed; float _NmsThreshold; [numthreads(64, 1, 1)] void ParallelNMS(uint3 id : SV_DispatchThreadID) { uint i id.x; uint count _CandidateCount[0]; if (i count) return; float scoreI _CandidatesScore[i]; float4 boxI _CandidatesBox[i]; for (uint j 0; j count; j) { if (i j) continue; float scoreJ _CandidatesScore[j]; // 只处理比 i 分数高的框 if (scoreJ scoreI || (scoreJ scoreI j i)) { float4 boxJ _CandidatesBox[j]; float iou ComputeIoU(boxI, boxJ); if (iou _NmsThreshold) { _Suppressed[i] 1; break; } } } }这个并行 NMS 实际上是“近似 NMS”因为它是并行判断每个框是否被任何更高分框抑制而不是严格按分数从高到低逐个贪心处理。但人脸检测场景里同一张脸上的多个候选框分数通常差异明显所以最终结果和严格 NMS 非常接近。K300 时总计算量是 9 万次 IoUGPU 上微秒级完成。用完之后CPU 侧只需要收集_Suppressed[i] 0的框数量通常只剩个位数到几十个后面做表情驱动完全够用。4.4 结果可视化与调试开发阶段我直接在 OnGUI 里画框和关键点这样最直观也能快速发现问题void OnGUI() { if (_lastFaceRect ! null) { GUI.color Color.green; GUI.DrawTexture(_lastFaceRect.Value, Texture2D.whiteTexture); } }正式项目里关键点数据要传给虚拟形象。最常见的方式是把 5 个关键点的坐标映射到 BlendShape 的权重或者驱动骨骼节点的旋转。如果想做更高质量的画面叠加可以把检测结果渲染到 RenderTexture 上再和摄像头画面混合。一个比较高效的方式是在 OnRenderImage 里用 Graphics.Blit 加一个后处理材质在后处理 Shader 里采样关键点 Buffer 并画线画点。不过这个方案要自己处理线段的栅格化代码量不小。我个人的经验是先用 OnGUI 把算法调通再考虑做正式的渲染管线。5. 性能数据与踩坑实录5.1 实测性能数据我在三台设备上做了简单测试统一输入 640x640后处理用的是 GPU 并行过滤 CPU 小规模 NMS设备Sentis 推理耗时后处理耗时整帧管线PCRTX 3060约 4ms小于 1ms约 20ms小米 13约 9ms约 2ms约 32msiPhone 13约 7ms约 1.5ms约 28ms整帧管线包括摄像头采集、渲染、缩放、预处理、推理、后处理和 UI 绘制。如果你需要更高的帧率第一件事是把输入分辨率从 640x640 降到 320x320。det_10g 在 320 输入下检测距离会缩水但对近距离的表情捕捉场景来说完全够用推理耗时能再降一半。另一个免费的性能优化点是预热。Sentis 在第一次执行时才编译 Kernel、分配显存所以首次推理通常特别慢。启动时用一个全黑纹理跑一次推理把预热时间提前到加载阶段。5.2 常见问题排查速查表我整理了自己踩过的一些坑按场景列出来问题出现场景排查思路模型加载失败首次接入检查 ONNX 文件完整性换官方脚本重新导出先打日志看失败算子输出张量形状和教程不一致不同来源模型用打印 shape 的调试脚本以实际模型为准修改解析代码推理结果全为 NaN预处理不匹配检查输入像素顺序BGR/RGB、归一化方式、Tensor 构造参数检测框位置偏到天边解码公式错误对照训练脚本里的解码公式确认偏移量定义WebGL 平台 GPUCompute 不可用发布测试改用 CPU 后端或限制 WebGL 下的功能长时间运行后卡顿内存泄漏用 Profiler 看 Tensor 分配检查 Worker 是否重复创建移动端发热严重持续运行降输入分辨率、控制目标帧率、考虑量化模型5.3 个人的一些优化心得最后分享几个我在实际项目中觉得价值比较大的优化点。第一个是摄像头纹理读取。Texture2D.ReadPixels和GetPixels32在移动端上非常消耗性能实测在部分 Android 机型上能吃掉 5ms 以上。优化方式是用AsyncGPUReadback异步读取 GPU 纹理或者干脆在 Compute Shader 里直接把摄像头纹理缩放、转换 BGR、归一化然后输出到一个和 Tensor 内存布局一致的 Buffer 里避免 CPU 和 GPU 之间反复搬运数据。这个优化做完整帧延迟能下降一截。第二个是张量复用。如果输入尺寸固定_inputTensor只需要创建一次每次推理前用SetData更新数据。输出 Tensor 同理在反复推理的场景里能减少大量 GC 开销。这个习惯在长时间运行的摄像头应用里格外重要。第三个是平滑滤波。直接使用单帧关键点会有抖动尤其是侧脸和遮挡的情况。最简单的做法是一阶低通滤波smooth lerp(smooth, target, 0.3f)但要注意延迟。更专业的做法是用 One Euro Filter 或者卡尔曼滤波。这一块如果做好了虚拟形象的表情自然度会提升非常明显比换性能更强的模型还管用。第四个是关于模型的扩展。det_10g 输出的是 5 个人脸关键点如果后续要做更细的表情捕捉比如眉毛、眼皮、瞳孔方向可以考虑再加一个轻量级的面部关键点模型比如在脸部区域再做一次小图回归。两个模型串联起来总延迟增加不大但表情维度会丰富很多。我在实际项目中把 det_10g 的 5 点和另一个 68 点模型串在一起跑整帧延迟仍在 35ms 以内但虚拟形象的表情已经能做得很生动了。如果哪天遇到检测不到人脸的情况先别急着调模型检查预处理里是不是把人脸区域裁掉了这个低级错误我在开发初期犯了不少次。