Simulcast与SVC
多人会议里,发布端只有一个上行码率,但订阅端有强有弱。给所有人都推 2.5Mbps 高清,弱网会卡;都推 300kbps,强端清晰度浪费。Simulcast 和 SVC 是两种解决"一推多订阅"的分层技术,它们各有适用边界。
本文先讲两者的根本差别和取舍,再讲浏览器/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 详细对比表
| 维度 | Simulcast | SVC |
|---|---|---|
| 编码次数 | 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/f或low/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) |
L1T2 | 1 空域 2 时域 |
L1T3 | 1 空域 3 时域(轻量 SVC,移动端常用) |
L2T3_KEY | 2 空域 3 时域,每空域有独立关键帧 |
L3T3 | 3 空域 3 时域 |
L3T3_KEY | 3 空域 3 时域 + KEY(更强切层能力) |
S2T3 | Simulcast 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 但只测 Chrome | Safari 可能完全不显示 | 退化方案要 Simulcast |
把 q 关了只留 h/f | 弱网订阅者断流 | 至少保留最低档 |
11. 决策流程图
12. 权威资料
- W3C WebRTC SVC:https://www.w3.org/TR/webrtc-svc/
- W3C WebRTC
sendEncodings/scalabilityMode:https://www.w3.org/TR/webrtc/ - RFC 8853 Simulcast in SDP:https://www.rfc-editor.org/rfc/rfc8853
- RFC 8851 RTP RID:https://www.rfc-editor.org/rfc/rfc8851
- VP9 SVC RTP payload:https://datatracker.ietf.org/doc/draft-ietf-payload-vp9/
- AV1 RTP payload + SVC:https://aomediacodec.github.io/av1-rtp-spec/
- mediasoup Simulcast/SVC:https://mediasoup.org/documentation/v3/mediasoup/api/#ConsumerLayers
- LiveKit AdaptiveStream:https://docs.livekit.io/realtime/concepts/adaptive-stream/
- 核对日期:2026-06-22