项目: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 在此前一次”卡顿优化”中被改成了这样:

  1. 每帧先把彩色、遮罩两路 <video> 用 drawImage 缩小画到 2D canvas;
  2. 再把 2D canvas 用 texImage2D 上传成 WebGL 纹理;
  3. 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 推送音视频

发现以下设计导致”一卡就音画全停”:

  1. 音画按段捆绑:每 0.2 秒的画面和对应音频打成一个 segment,下一段没准备好,音视频一起停。
  2. 几乎没有提前量:空闲填充的预备量 maxLead 取”一个 chunk 时长”(AVTR-1 为 0.2 秒);推送队列容量 1,编码队列容量 3。任何一环抖动超过约 0.2 秒就会断供。
  3. 说话期间不补空闲帧:shouldSendIdle() 在说话开始后返回 false(blockedBySpeech),句子之间若 TTS 或生成没跟上,就没有任何内容可发——对应实测中”说话中途断流”。
  4. 编码开销大:每 0.2 秒 exec 一次新的 ffmpeg 进程,且 -g 等于段内帧数,每 5 帧一个关键帧。透明模式两路 1024×1280,编码压力约为普通会话的 10 倍。
  5. 断流被写进时间轴:发送端做了 RTP 时间戳间隙校正,把停发的墙钟时间计入时间戳。接收端无法用更大的抖动缓冲把空洞补回来——前端在这一层无能为力。

3. 两条渲染链路详解

3.1 对话数字人 2.0:双轨 + 前端 WebGL 合成

graph LR
  S[&#34;服务端 AVTR-1 生成, 每段编码两路&#34;] -->|彩色轨 VP8| J1[&#34;抖动缓冲 1&#34;]
  S -->|遮罩轨 VP8| J2[&#34;抖动缓冲 2&#34;]
  S -->|Opus 音频| A[&#34;audio 元素&#34;]
  J1 --> D1[&#34;VP8 软解 1 (CPU)&#34;]
  J2 --> D2[&#34;VP8 软解 2 (CPU)&#34;]
  D1 --> V1[&#34;隐藏 video 1&#34;]
  D2 --> V2[&#34;隐藏 video 2&#34;]
  V1 -->|上传纹理| G[&#34;WebGL 着色器: 彩色乘遮罩&#34;]
  V2 -->|上传纹理| G
  G --> C[&#34;透明 canvas&#34;]
  C --> W[&#34;macOS 透明窗口合成&#34;]

每帧经历:

  1. 收:两路 RTP 各自进入抖动缓冲,帧到达时间互不保证对齐;
  2. 解:两个 VP8 解码器在 CPU 上软解(macOS 无 VP8 硬件解码);
  3. 交接:解出的帧放进两个 1 像素、不可见的 <video> 元素;
  4. 绘:任一路新帧触发 requestVideoFrameCallback → 下个 rAF 中两路各 texImage2D 一次 → 着色器计算 vec4(rgb * a, a) → 透明 canvas;
  5. 窗口合成:WebKit 合成页面,macOS WindowServer 再与桌面混合;
  6. 附带开销:点击穿透每 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[&#34;讯飞云端 H.264 编码&#34;] -->|单路视频, 上下拼接| J[&#34;抖动缓冲&#34;]
  J --> D[&#34;H.264 硬解 (GPU)&#34;]
  D --> V[&#34;SDK video&#34;]
  V -->|上传纹理一次| G[&#34;WebGL 着色器: 上半取遮罩, 下半取彩色&#34;]
  G --> C[&#34;透明 canvas&#34;]
  C --> W[&#34;macOS 透明窗口合成&#34;]

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)——而在于:

  1. 单轨 vs 双轨:单轨天然同步,双轨需要额外保证同步;
  2. H.264 硬解 vs VP8 软解:在 Mac 上这是 CPU 负担数量级的差别;
  3. 服务端供流稳定性:讯飞云端供流稳定;我们的服务端预备量只有约 0.2 秒,任何抖动都会直接表现为音画同时卡住。

这次 2.0 卡顿的主因是第 3 点(服务端断供),第 2 点会在本地进一步放大卡顿感,第 1 点决定了上限。


5. 改进建议

5.1 服务端(主因,优先级最高)

  1. 加大预备量:maxLead 从”一个 chunk(0.2 秒)”提到 1~1.5 秒,推送和编码队列相应放大。新的语音段本身会替换排队中的空闲帧(Supersedable),对回复延迟影响很小。
  2. 说话中途供不上时补空闲帧:去掉或放宽 blockedBySpeech,宁可插入呼吸帧也不要停发。
  3. 常驻编码器:每个会话保持一个长生命周期的编码器,不要每 0.2 秒 exec 一次 ffmpeg;关键帧间隔拉长到 1~2 秒。
  4. 改用 H.264:服务端可用 NVENC 等硬件编码,客户端 macOS / Windows 均可硬解。
  5. 降低透明模式分辨率:彩色 720×1280 级别即可满足 300×533 的桌宠窗口(2 倍屏下也只需 600×1066),遮罩轨可再减半。
  6. 确认方法:复现卡顿时在服务端日志中检索 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. 经验总结

  1. 不要只看平均 fps。卡顿往往是亚秒级的,必须看帧间隔或卡顿次数这类”尾部指标”。
  2. 先证明问题在哪一段,再优化。第一轮直接优化前端合成,方向不算错,但没有解决主因;加上”收 / 解 / 绘”分段指标后,问题边界一目了然。
  3. “音画同时卡住”是很强的信号:音频和视频走不同的解码与渲染路径,能让两者同时停住的,几乎只有它们共同的上游——服务端发送。
  4. 同一套透明合成,差别在流本身:单轨还是双轨、硬解还是软解、服务端有没有足够的缓冲,决定了最终体验。