桌面透明数字人卡顿排查:自研双轨方案 vs 讯飞单轨拼接方案
项目:SRD 桌面数字人(Tauri 2 + React,macOS 上运行在 WKWebView / WebKit 内核)
对象:对话数字人 2.0(自研透明双轨)与讯飞数字人(讯飞 SDK 透明流)
1. 背景
桌面端有两种”透明桌宠”形态的数字人:
- 讯飞数字人:接入讯飞云端数字人 SDK,开启
alpha: 1后服务端下发带透明通道的视频,人物直接悬浮在桌面上。 - 对话数字人 2.0:自研后端(VerseAvatar)生成数字人画面,
transparent_avatar: true时下发 彩色轨 + 遮罩轨 两路视频,前端自己合成透明人物。
用户反馈:2.0 非常卡顿,画面和声音时不时同时卡住,而讯飞版体感流畅。本文记录完整的排查过程,并从架构层面对比两种透明渲染方案的优劣。
2. 排查过程
2.1 第一轮:怀疑前端合成太重
先读代码。2.0 的合成器 AlphaCompositor.tsx 在此前一次”卡顿优化”中被改成了这样:
- 每帧先把彩色、遮罩两路
<video>用drawImage缩小画到 2D canvas; - 再把 2D canvas 用
texImage2D上传成 WebGL 纹理; - WebGL 上下文开启了
powerPreference: "high-performance"。
这在 Chromium 里问题不大,但在 macOS WebKit 中:
- 把 WebRTC 视频
drawImage到 2D canvas,会走 CPU 的 YUV→RGB 转换和像素回读;两路 × 20 fps,相当于每秒 40 次整帧 CPU 处理; - 双显卡 Mac 上
high-performance会把 WebGL 放到独显,而视频在集显解码,每帧产生跨 GPU 拷贝。
修复:
- 视频直接
texImage2D(video)上传(WebKit 有 GPU 侧快速路径),缩放交给 GPU 采样完成; - 去掉
high-performance,关闭不需要的 depth / stencil / blend; - 两路都挂
requestVideoFrameCallback,任意一路有新帧才重画,统一在 rAF 中绘制;rvfc 不可用时退化为按解码帧计数判断; - 顺手修复
setColorVideoElement每次调用都追加事件监听、从不移除的泄漏; - 音频从彩色
<video>拆到独立<audio>元素,不再受轨道交换和视频状态影响。
修完后用户反馈:还是卡。说明前端合成不是主因(或不是唯一原因)。
2.2 第二轮:加观测指标,用数据说话
在调试面板中逐步加入以下指标(每秒刷新):
| 指标 | 含义 | 来源 |
|---|---|---|
| 收 | 每秒从网络收到的完整帧数(解码前) | inbound-rtp.framesReceived 差分 |
| 解 | 每秒解码出的帧数 | inbound-rtp.framesDecoded 差分 |
| 绘 | 合成器每秒重画次数 | 合成器 onFrame 计数 |
| 最大帧间隔 | 过去 1 秒内相邻两帧画面的最长间隔(含正在进行的停顿) | requestVideoFrameCallback 时间戳 |
| 卡顿次数 | 帧间隔 ≥ 150 ms 的累计次数 | 同上 |
为什么要加”最大帧间隔”:fps 是每秒平均值,会掩盖亚秒级卡顿。例如前 0.6 秒收到 12 帧、卡 0.4 秒后一次补来 7 帧,平均仍是 19 fps,但肉眼明显一顿。
一次典型的线上截图数据(彩色轨 1024×1280):
- 收 19.9 / 解 19.9 fps —— 平均帧率看起来正常;
- 最大帧间隔 367 ms、卡顿 37 次 —— 实际画面频繁停顿三四百毫秒;
- 弃帧持续上涨(彩色 67、遮罩 57)—— 帧扎堆到达,WebKit 丢弃了来不及播放的帧;
- 当时 RTT 1135 ms、丢包 0.1%~0.5%(平时 RTT 约 11 ms、丢包 0)—— 网络波动会放大卡顿。
结论:卡顿真实存在,且表现为”帧没按时到”,而不是本地掉帧。
2.3 第三轮:对照实测,确认问题在服务端供流
在浏览器里写了一个测试脚本,按 App 完全相同的信令流程(webrtc_ready → offer/answer → client_media_ready),分别创建普通会话和透明会话,每秒采样 getStats():空闲 12 秒,然后发一句话,再采样 20~25 秒。
说明:测试在 Chromium 内核中进行(App 实际是 WebKit),每种会话只测了一轮,结果用于定性判断。
| 普通会话(单轨) | 2.0 透明会话(双轨) | |
|---|---|---|
| 视频轨 | 1 路 512×512,VP8 | 2 路各 1024×1280,VP8 |
| 正常帧率 | ≈ 20 fps | 两路均 ≈ 20 fps |
| 回复过程中 | 思考阶段多次断流 | 思考阶段断流约 5 秒,说话中途又断 2 秒 |
| 断流时 | 视频、音频码率同时归零 | 视频、音频码率同时归零 |
| 丢包 | 0 | 0 |
| 弃帧 | 0 | 说话阶段彩色 27、遮罩 35 |
断流时丢包为 0、音视频同时归零,说明服务端这段时间根本没有发包——这正是用户感受到的”声音和画面同时卡住”。
2.4 第四轮:读服务端推流代码,找停发原因
在本地的 VerseAvatar 服务端代码(2026-09-18 快照,未包含透明模式实现,线上版本更新,但推流框架一致)中定位到 direct 模式推流链路:
数字人模型(AVTR-1) ──每次 5 帧 / 0.2s──▶ RawAVSegment(RGB + PCM)
──▶ encodeCh(容量 3)──▶ 每段启动一次 ffmpeg 编码 VP8
──▶ publishCh(容量 1)──▶ 按实时节奏 WriteSample 推送音视频
发现以下设计导致”一卡就音画全停”:
- 音画按段捆绑:每 0.2 秒的画面和对应音频打成一个 segment,下一段没准备好,音视频一起停。
- 几乎没有提前量:空闲填充的预备量
maxLead取”一个 chunk 时长”(AVTR-1 为 0.2 秒);推送队列容量 1,编码队列容量 3。任何一环抖动超过约 0.2 秒就会断供。 - 说话期间不补空闲帧:
shouldSendIdle()在说话开始后返回 false(blockedBySpeech),句子之间若 TTS 或生成没跟上,就没有任何内容可发——对应实测中”说话中途断流”。 - 编码开销大:每 0.2 秒
exec一次新的ffmpeg进程,且-g等于段内帧数,每 5 帧一个关键帧。透明模式两路 1024×1280,编码压力约为普通会话的 10 倍。 - 断流被写进时间轴:发送端做了 RTP 时间戳间隙校正,把停发的墙钟时间计入时间戳。接收端无法用更大的抖动缓冲把空洞补回来——前端在这一层无能为力。
3. 两条渲染链路详解
3.1 对话数字人 2.0:双轨 + 前端 WebGL 合成
graph LR
S["服务端 AVTR-1 生成, 每段编码两路"] -->|彩色轨 VP8| J1["抖动缓冲 1"]
S -->|遮罩轨 VP8| J2["抖动缓冲 2"]
S -->|Opus 音频| A["audio 元素"]
J1 --> D1["VP8 软解 1 (CPU)"]
J2 --> D2["VP8 软解 2 (CPU)"]
D1 --> V1["隐藏 video 1"]
D2 --> V2["隐藏 video 2"]
V1 -->|上传纹理| G["WebGL 着色器: 彩色乘遮罩"]
V2 -->|上传纹理| G
G --> C["透明 canvas"]
C --> W["macOS 透明窗口合成"]
每帧经历:
- 收:两路 RTP 各自进入抖动缓冲,帧到达时间互不保证对齐;
- 解:两个 VP8 解码器在 CPU 上软解(macOS 无 VP8 硬件解码);
- 交接:解出的帧放进两个 1 像素、不可见的
<video>元素; - 绘:任一路新帧触发
requestVideoFrameCallback→ 下个 rAF 中两路各texImage2D一次 → 着色器计算vec4(rgb * a, a)→ 透明 canvas; - 窗口合成:WebKit 合成页面,macOS WindowServer 再与桌面混合;
- 附带开销:点击穿透每 450 ms 把遮罩帧读回 CPU 做命中测试,每 50 ms 两次 IPC 查询鼠标位置。
3.2 讯飞数字人:单轨上下拼接 + SDK WebGL 合成
从讯飞 Web SDK(xrtc-player)源码中可以确认:
- 编码:H.264(SDK 内置错误码
700002 当前设备不支持 H.264); - 流参数:我们传入
stream: { protocol: "xrtc", alpha: 1, bitrate: 1_000_000, fps: 25 },形象 720×1280; - 透明方式:一帧画面里上下拼接——遮罩与彩色放在同一帧的两半,着色器里这样取样:
vec2 true_pixel_coord = vec2(v_TexCoord.x, 0.5 + v_TexCoord.y / 2.); // 下半:彩色
vec2 mask_pexel_coord = vec2(v_TexCoord.x, v_TexCoord.y / 2.); // 上半:遮罩
float alpha = texture2D(u_Sampler0, mask_pexel_coord).r;
vec3 rgb = texture2D(u_Sampler0, true_pixel_coord).rgb * alpha;
gl_FragColor = vec4(rgb, alpha);
- 渲染循环:rAF 驱动,按轨道
frameRate节流,每次texImage2D上传整帧,单纹理、单次绘制;WebGL 上下文开启preserveDrawingBuffer: true、antialias: true。
graph LR
S["讯飞云端 H.264 编码"] -->|单路视频, 上下拼接| J["抖动缓冲"]
J --> D["H.264 硬解 (GPU)"]
D --> V["SDK video"]
V -->|上传纹理一次| G["WebGL 着色器: 上半取遮罩, 下半取彩色"]
G --> C["透明 canvas"]
C --> W["macOS 透明窗口合成"]
4. 两种方案对比
4.1 链路与开销对比
| 维度 | 讯飞(单轨拼接) | 对话数字人 2.0(双轨) |
|---|---|---|
| 视频路数 | 1 路 | 2 路 |
| 编码格式 | H.264 | VP8 |
| macOS 解码 | VideoToolbox 硬件解码,几乎不占 CPU | libvpx 软件解码,全在 CPU |
| 每帧解码像素 | 720×1280 的形象拼成一帧(遮罩与彩色各占一半) | 2 × 1024×1280 ≈ 2.6 百万像素 |
| 码率 | 约 1 Mbps(我们配置的值) | 约 3.3 Mbps(彩色 2.9 + 遮罩 0.4,实测) |
| 帧率 | 25 fps(配置值) | 20 fps(实测) |
| 关键帧 | 由讯飞服务端控制 | 每 5 帧一个(每段重启编码器) |
| 彩色/遮罩同步 | 天然逐帧同步(同一帧) | 两路独立缓冲、独立解码,可能错位 |
| 每帧纹理上传 | 1 次 | 2 次 |
| 帧到达驱动 | rAF 轮询 + 帧率节流 | rvfc 新帧通知 |
| 服务端供流 | 讯飞云端(黑盒),体感稳定 | 自研,预备量仅 ≈ 0.2 秒,实测频繁断流 |
讯飞一栏的帧尺寸、码率、帧率来自 SDK 配置与源码,未做与 2.0 同等条件的实测;2.0 一栏的码率、帧率、断流来自实测。
4.2 讯飞单轨拼接方案
优点
- 彩色与遮罩永远同帧:不存在两路到达时间不同导致的错位、边缘闪烁或一路先卡的问题。
- 只需一个解码器:解码、纹理上传、渲染调度都只有一份,链路短。
- H.264 硬件解码:macOS / Windows / 移动端几乎都有硬解,CPU 占用低、功耗低,是体感流畅的关键。
- 复用标准视频基础设施:任何支持单路 H.264 的 CDN、录制、转推工具都能直接处理,遮罩不会丢。
- 码率低:1 Mbps 就能撑起透明形象。
缺点
- 分辨率被遮罩占掉一半:同样的编码尺寸下,彩色的有效分辨率只有一半,或者要编码两倍高的帧。
- 遮罩不能单独调参:遮罩本来只是黑白图,在拼接方案里却要跟彩色用同样的分辨率、码率和色彩采样;H.264 的 4:2:0 色度采样和块效应也会影响遮罩边缘精度,拼接边界处还可能互相串色。
- 只能用”取 R 通道”这类约定:解码端必须知道拼接布局,换个播放器不认就会看到上下两张图。
- 黑盒:讯飞 SDK 闭源、服务端不可控,按量计费;在 WKWebView 中还需要 UA 补丁才能播放;SDK 开了
preserveDrawingBuffer: true,每帧会多一次缓冲拷贝。
4.3 自研双轨方案
优点
- 彩色保持完整分辨率:遮罩不占彩色的像素。
- 遮罩可独立优化:实测遮罩轨只有约 0.4 Mbps,还可以进一步降到一半分辨率、降低帧率,甚至用灰度编码,彩色不受影响。
- 服务端完全可控:生成、编码、推流的每个参数都能调;不依赖第三方,没有按量费用。
- 调试友好:两路可以分别查看、交换、统计,排查问题直观。
- 前端合成可定制:着色器可以做描边、阴影、羽化等效果。
缺点
- 两路同步没有保证:两路各自抖动缓冲,任何一路晚到就会出现错位或局部停顿;WebRTC 只对同一个 stream 内的音视频做唇音同步,不保证两路视频逐帧对齐。
- 解码开销翻倍:两个解码器同时工作;加上 VP8 在 macOS 只能软解,2.0 的 CPU 解码负担是这次对比中最重的。
- 链路更长:隐藏 video → rvfc 回调 → 两次纹理上传 → 合成,主线程繁忙时更容易被推迟。
- 服务端成本更高:每段要编码两路高分辨率视频;在当前”每段重启 ffmpeg、每 5 帧一个关键帧”的实现下,更容易编码跟不上。
- 兼容性:需要客户端理解”第二路是遮罩”,标准播放器、录制、转推工具都会把两路当成两个独立画面。
4.4 小结
两种方案本质区别不在透明合成本身——两边的 WebGL 着色器几乎一样(rgb × alpha)——而在于:
- 单轨 vs 双轨:单轨天然同步,双轨需要额外保证同步;
- H.264 硬解 vs VP8 软解:在 Mac 上这是 CPU 负担数量级的差别;
- 服务端供流稳定性:讯飞云端供流稳定;我们的服务端预备量只有约 0.2 秒,任何抖动都会直接表现为音画同时卡住。
这次 2.0 卡顿的主因是第 3 点(服务端断供),第 2 点会在本地进一步放大卡顿感,第 1 点决定了上限。
5. 改进建议
5.1 服务端(主因,优先级最高)
- 加大预备量:
maxLead从”一个 chunk(0.2 秒)”提到 1~1.5 秒,推送和编码队列相应放大。新的语音段本身会替换排队中的空闲帧(Supersedable),对回复延迟影响很小。 - 说话中途供不上时补空闲帧:去掉或放宽
blockedBySpeech,宁可插入呼吸帧也不要停发。 - 常驻编码器:每个会话保持一个长生命周期的编码器,不要每 0.2 秒
exec一次 ffmpeg;关键帧间隔拉长到 1~2 秒。 - 改用 H.264:服务端可用 NVENC 等硬件编码,客户端 macOS / Windows 均可硬解。
- 降低透明模式分辨率:彩色 720×1280 级别即可满足 300×533 的桌宠窗口(2 倍屏下也只需 600×1066),遮罩轨可再减半。
- 确认方法:复现卡顿时在服务端日志中检索
RTP timestamp gap correction(每条即一次断流,wall=为时长)、publish pacing slip(推送落后于实时),以及direct_vp8_encode_done的encode_ms(若接近或超过 200 ms,即编码跟不上)。
5.2 架构层面:考虑借鉴讯飞的单轨拼接
如果服务端改用 H.264,可以进一步考虑把彩色和遮罩拼进同一帧(或 alpha 编进同一路流):
- 一路流、一个解码器、一次纹理上传,彩色与遮罩逐帧同步;
- 前端只需把着色器改成按上下半区取样(与讯飞一致),其余合成逻辑不变;
- 代价是需要权衡”拼接后的总分辨率”与”彩色有效分辨率”。
5.3 前端(已完成)
- 合成器改为视频直传纹理、按新帧驱动绘制、去掉独显偏好;
- 修复事件监听泄漏;音频改用独立
<audio>元素; - 调试面板新增 收 / 解 / 绘 三段帧率(按数值着色)、最大帧间隔与卡顿次数,可直接区分:
- 三项都低 → 服务端停发;
- 收正常、解低 → 本地解码跟不上;
- 收解正常、绘低 → 前端渲染跟不上。
6. 经验总结
- 不要只看平均 fps。卡顿往往是亚秒级的,必须看帧间隔或卡顿次数这类”尾部指标”。
- 先证明问题在哪一段,再优化。第一轮直接优化前端合成,方向不算错,但没有解决主因;加上”收 / 解 / 绘”分段指标后,问题边界一目了然。
- “音画同时卡住”是很强的信号:音频和视频走不同的解码与渲染路径,能让两者同时停住的,几乎只有它们共同的上游——服务端发送。
- 同一套透明合成,差别在流本身:单轨还是双轨、硬解还是软解、服务端有没有足够的缓冲,决定了最终体验。
