跳到主要内容

Simulcast与SVC

多人会议里,发布端只有一个上行码率,但订阅端有强有弱。给所有人都推 2.5Mbps 高清,弱网会卡;都推 300kbps,强端清晰度浪费。SimulcastSVC 是两种解决"一推多订阅"的分层技术,它们各有适用边界。

本文先讲两者的根本差别和取舍,再讲浏览器/SFU 端集成,最后给完整切层代码。

1. 为什么需要分层

单档上行的失败

  • 推 2.5Mbps:移动端订阅者卡顿。
  • 推 500kbps:桌面端只能看模糊画面。
  • 让 SFU 转码:CPU 成本爆炸,全部转码会让 SFU 单机承载量从数千路掉到几十路。

目标:发布端一次编码出多档可选码流,SFU 按订阅者实时切换。

适用:3 人以上会议、SFU 架构、订阅端异构。

不适用:1v1 通话(直接 GCC 调码率即可);MCU 架构(已经在服务端转码)。

2. Simulcast 与 SVC 的根本差别

2.1 一句话对比

  • Simulcast:编码器跑 N 次,输出 N 份独立的码流(不同分辨率/码率),每份有自己的 SSRC。
  • SVC:编码器跑 1 次,输出 1 份"分层封装"的码流,可丢弃高层只解低层。

2.2 详细对比表

维度SimulcastSVC
编码次数N 次(每档一次)1 次
SSRC 数N 个(每档一个)1 个(含分层标记)
CPU 开销高(线性叠加)低(共享变换)
各档解耦强(任一档独立可解)弱(必须从基本层开始)
切层成本切档需新关键帧切层无需关键帧
浏览器支持Chrome/Firefox/Safari 全支持Chrome 有限(VP9 SVC 默认;AV1 SVC)
SFU 实现成本中(按 rid 转发)低(按层标记丢包)
弱网失败模式可降到最低档可丢 enhancement 层
编码器要求任何编码器都可编码器必须支持 SVC
上行码率各档码率之和单层全部码率

2.3 各自心智模型

Simulcast:
Camera ──┬─► Encoder1 (180p, 200kbps) ─► SSRC_A
├─► Encoder2 (360p, 600kbps) ─► SSRC_B
└─► Encoder3 (720p, 2Mbps ) ─► SSRC_C

总上行 = 2.8Mbps,CPU = 3 倍编码

SVC(VP9 L3T3 为例,3 空域 + 3 时域):
Camera ──► Encoder ─► SSRC_X (内含 9 层封装)

└─ 单流,含 spatial 0~2 / temporal 0~2 标记

总上行 ≈ 等同最高档码率 × 1.2,CPU ≈ 1.2 倍

2.4 选哪个

场景推荐
跨浏览器兼容(含 Safari)Simulcast
大会议,CPU 敏感(电池设备)SVC
快速切层(如说话人切大画面)SVC
直播 + 低延迟Simulcast(解耦稳定)
AV1 / VP9 编码器可用SVC
H.264 编码Simulcast(H.264 SVC 浏览器不支持)

实战建议:默认 Simulcast,确认所有目标平台都支持后再上 SVC。

3. Simulcast 详解

3.1 SDP 表现

m=video 9 UDP/TLS/RTP/SAVPF 96 97
a=rtpmap:96 VP8/90000
a=rid:q send
a=rid:h send
a=rid:f send
a=simulcast:send q;h;f
a=ssrc:1111 cname:abc msid:stream1 video
a=ssrc:2222 cname:abc msid:stream1 video
a=ssrc:3333 cname:abc msid:stream1 video

rid(RTP Stream ID, RFC 8851)是层标识。q/h/f 分别对应 quarter/half/full 三档。

3.2 浏览器端配置

const transceiver = pc.addTransceiver('video', {
direction: 'sendonly',
sendEncodings: [
{
rid: 'q',
scaleResolutionDownBy: 4, // 1/4 分辨率
maxBitrate: 250_000,
maxFramerate: 15,
},
{
rid: 'h',
scaleResolutionDownBy: 2, // 1/2 分辨率
maxBitrate: 700_000,
maxFramerate: 30,
},
{
rid: 'f',
scaleResolutionDownBy: 1, // 全分辨率
maxBitrate: 2_500_000,
maxFramerate: 30,
},
],
});
const sender = transceiver.sender;
sender.replaceTrack(videoTrack);

注意:

  • sendEncodings 必须在 addTransceiver 阶段传入,协商后不能新增 rid
  • scaleResolutionDownBy 是相对原始 track 分辨率的比例。
  • rid 顺序无关,但常用 q/h/flow/mid/high

3.3 动态控制各档

const params = sender.getParameters();
params.encodings.forEach((e, i) => {
if (e.rid === 'q') e.active = true;
if (e.rid === 'h') e.active = bandwidthOk;
if (e.rid === 'f') e.active = bandwidthOk && cpuOk;
});
await sender.setParameters(params);

active=false 让该档完全不发包;要重新打开时也要重新走一遍 setParameters

3.4 关键帧与切档

订阅者从 q 切到 f

切档总会经过一次 IDR,码率会短时尖峰。

3.5 SFU 转发逻辑(mediasoup 示例)

// 订阅者优先订阅 high 层
const consumer = await transport.consume({
producerId: producer.id,
rtpCapabilities,
preferredLayers: { spatialLayer: 2, temporalLayer: 2 },
});

// 切层
await consumer.setPreferredLayers({ spatialLayer: 0, temporalLayer: 1 });

consumer.on('layerschange', (layers) => {
console.log('订阅者实际拉到的层', layers);
});

mediasoup 会自动根据订阅者带宽估计选最高可承受的层,开发者只需设 preferredLayers 上限。

4. SVC 详解

4.1 分层维度

Spatial layer(空域,分辨率):S0=180p, S1=360p, S2=720p
Temporal layer(时域,帧率) :T0=7.5fps, T1=15fps, T2=30fps
Quality layer(质量,量化) :很少用
  • L1T3:1 个空域层 + 3 个时域层(最常见的"轻量 SVC")。
  • L3T3:3 个空域 + 3 个时域 = 9 层(功能完整,CPU 略高)。

4.2 时域分层示意

丢弃 T2 → 帧率减半但仍可解;丢弃 T1+T2 → 帧率为 1/4 但仍可解。T0 不能丢

4.3 浏览器端配置

const transceiver = pc.addTransceiver('video', {
direction: 'sendonly',
sendEncodings: [
{
scalabilityMode: 'L3T3_KEY', // 3 空域 + 3 时域
maxBitrate: 2_500_000,
},
],
});

scalabilityMode 取值(W3C webrtc-svc):

模式含义
L1T1单层(等同非 SVC)
L1T21 空域 2 时域
L1T31 空域 3 时域(轻量 SVC,移动端常用)
L2T3_KEY2 空域 3 时域,每空域有独立关键帧
L3T33 空域 3 时域
L3T3_KEY3 空域 3 时域 + KEY(更强切层能力)
S2T3Simulcast like 2 spatial(K-SVC)

4.4 编码器要求

编码器SVC 支持
VP8仅时域(L1T3)
VP9完整空域 + 时域
AV1完整 SVC(标准内置)
H.264浏览器不开放 SVC
H.265浏览器不开放

4.5 SFU 转发 SVC

SVC 的 RTP 包带分层标记(VP9 在 RTP payload header 里有 SID/TID 字段),SFU 只需按订阅者带宽决定丢哪些层

function forwardSvcPacket(packet, subscriberBwe) {
const sid = packet.spatialId;
const tid = packet.temporalId;
if (sid > subscriberMaxSpatial(subscriberBwe)) return; // 丢
if (tid > subscriberMaxTemporal(subscriberBwe)) return;
send(packet);
}

切层无需关键帧(除非 K-SVC 要切 spatial 基础层)。

5. 完整代码:自适应切层管理器

class LayerController {
constructor(pc) {
this.pc = pc;
this.timer = null;
this.lastDecision = null;
}

start() {
this.timer = setInterval(() => this.decide(), 2000);
}

stop() {
clearInterval(this.timer);
}

async decide() {
const stats = await this.pc.getStats();
let availableOut = 0;
let cpuLimited = false;

for (const r of stats.values()) {
if (r.type === 'candidate-pair' && r.nominated) {
availableOut = r.availableOutgoingBitrate ?? 0;
}
if (r.type === 'outbound-rtp' && r.kind === 'video') {
cpuLimited = r.qualityLimitationReason === 'cpu';
}
}

const next = this.classify(availableOut, cpuLimited);
if (JSON.stringify(next) !== JSON.stringify(this.lastDecision)) {
console.log('[Layer] 切换', this.lastDecision, '→', next);
await this.apply(next);
this.lastDecision = next;
}
}

classify(bw, cpu) {
// 返回每档的 active/maxBitrate
if (cpu) return { q: true, h: true, f: false };
if (bw < 500_000) return { q: true, h: false, f: false };
if (bw < 1_500_000) return { q: true, h: true, f: false };
return { q: true, h: true, f: true };
}

async apply(decision) {
const sender = this.pc.getSenders().find(s => s.track?.kind === 'video');
if (!sender) return;
const params = sender.getParameters();
params.encodings.forEach(e => {
e.active = decision[e.rid] ?? true;
});
await sender.setParameters(params);
}
}

const ctrl = new LayerController(pc);
ctrl.start();

6. 订阅端切层(SFU 协议层面)

mediasoup-client 端:

const consumer = await device.consume(...);

// 请求 SFU 给我转发更高的层
await sfuClient.request('setConsumerPreferredLayers', {
consumerId: consumer.id,
spatialLayer: 2,
temporalLayer: 2,
});

consumer.on('@layerschange', (layers) => {
// SFU 实际给到的层
console.log('SFU forwarding', layers);
});

LiveKit 类似:

participant.on('trackSubscriptionPermissionChanged', () => {});
trackPub.setSubscribed(true, { videoQuality: VideoQuality.HIGH });

7. 上行码率预算

Simulcast 三档典型:
q (180p, 15fps) ≈ 200kbps
h (360p, 30fps) ≈ 700kbps
f (720p, 30fps) ≈ 2.0Mbps
合计 ≈ 2.9Mbps

L3T3 SVC(VP9, 720p):
≈ 2.4Mbps (多 ~15% over 单档)

弱网移动端可只开 q + h 两档(约 900kbps)。

8. 排障

8.1 stats 关键字段

字段看什么
outbound-rtp.rid当前是哪一档
outbound-rtp.frameWidth/frameHeight该档实际编码分辨率
outbound-rtp.framesEncoded该档真实在跑
outbound-rtp.qualityLimitationReason是带宽还是 CPU 限制
outbound-rtp.qualityLimitationDurations各原因累计时长
media-source.width/height原始采集分辨率

8.2 chrome://webrtc-internals

如果只看到一个 outbound-rtp 而非三个,说明 Simulcast 没生效。常见原因:

  • 没在 addTransceiver 时传 sendEncodings
  • setParameters 的时机错了(必须先有 sender)。
  • 浏览器版本不支持指定 codec 的 Simulcast(如 Safari 早期 VP8 不支持)。

8.3 验证 SVC

const params = sender.getParameters();
console.log(params.encodings[0].scalabilityMode);
// "L3T3_KEY" ✅
// 如果浏览器不支持,会被忽略,retrieve 时不带这个字段

9. SFU 转发策略对比

策略实现复杂度切层延迟适用
固定订阅最高层小会议、固定网络
按订阅者 BWE 自动选层一次 IDR大会议默认
主讲人最高层 + 其它最低层几十人会议常用
屏幕共享独立通道屏幕共享分辨率高,单独流
接收端反馈选层极致体验

10. 反模式

反模式后果正确做法
单档上行 + 大会议弱网用户全部卡上 Simulcast 至少 2 档
三档全开但分辨率一样浪费 CPU 和码率scaleResolutionDownBy 必须不同
Simulcast 用 H.264(部分场景)浏览器实现差,丢档用 VP8/VP9
不设 maxBitrate编码器自由发挥,码率不稳显式设上限
SFU 给所有人转最高层弱网卡按 BWE 选层
切档时不等关键帧短暂花屏切档前缓存 IDR 或主动 PLI
用 SVC 但只测 ChromeSafari 可能完全不显示退化方案要 Simulcast
q 关了只留 h/f弱网订阅者断流至少保留最低档

11. 决策流程图

12. 权威资料