26、带宽自适应:动态调整视频码率、Simulcast(联播)技术、SVC(可伸缩视频编码)
做音视频通话,最头疼的问题是什么?
我个人觉得,不是编码选什么,也不是信令怎么走。而是——网络突然变差了,画面卡成PPT,声音断断续续,对方问你“你刚才说什么?”
嗯,这种场景我遇到过太多次了。早期做一款在线教育产品时,学生家里WiFi不稳定,老师那边的视频码率还飙在2Mbps,结果就是学生端疯狂丢包,整节课体验极差。
所以,带宽自适应能力,是WebRTC实战中绕不开的核心话题。说白了,就是让视频流学会“看人下菜碟”——网络好就高清,网络差就降质,保证通话不中断。
一、为什么需要带宽自适应?
我们先想一个问题:视频通话的码率是固定的好,还是动态的好?
固定码率,实现简单。但网络波动时,要么卡顿,要么浪费带宽。我见过不少项目,上线后才发现,固定码率在移动网络下根本扛不住。
动态调整,才是正解。WebRTC内部有一套完整的带宽估计机制,叫做GCC(Google Congestion Control)。它会根据丢包率、RTT(往返时延)、接收端反馈等信息,实时估算当前可用带宽。
有了这个估算值,我们就可以做三件事:
- 动态调整视频码率——最直接的方式,带宽降了,码率跟着降。
- Simulcast(联播)——同时发送多路不同分辨率的流,接收端按需选择。
- SVC(可伸缩视频编码)——只发一路流,但编码时分层,接收端按能力解码。
这三种方案,各有各的适用场景。下面我一个个拆开讲。
二、动态调整视频码率
这是最基础的做法。WebRTC的RTCRtpSender对象上有一个setParameters()方法,我们可以用它来动态修改编码器的码率上限。
核心思路:监听带宽估计事件,拿到可用带宽后,按比例设置视频编码器的目标码率。
代码示例:
// 获取视频发送者
const sender = pc.getSenders().find(s => s.track.kind === 'video');
// 监听带宽估计(需要浏览器支持)
// 实际项目中,可以通过 stats 轮询或自定义反馈来获取带宽信息
function adjustBitrate(availableBitrate) {
if (!sender) return;
const params = sender.getParameters();
if (!params.encodings) return;
// 设置目标码率为可用带宽的 80%,留点余量
const targetBitrate = Math.floor(availableBitrate * 0.8);
params.encodings[0].maxBitrate = targetBitrate;
sender.setParameters(params)
.then(() => {
console.log(`码率已调整为: ${targetBitrate} bps`);
})
.catch(e => console.error('设置码率失败', e));
}
我在项目中遇到过一个问题:频繁调用setParameters()会导致编码器重置,反而造成短暂卡顿。后来我加了一个防抖逻辑——带宽变化超过20%才触发调整,效果好了很多。
小技巧:不要直接设成可用带宽的100%。留20%给音频和重传,视频占80%左右比较稳。
三、Simulcast(联播)技术
动态调整码率有个局限:它只能改变同一路流的清晰度。如果接收端网络差异很大(比如一个在WiFi下,一个在4G下),单路流很难同时满足所有人。
Simulcast的思路很简单:发送端同时编码多路不同分辨率的视频流,比如同时发720p、360p、180p三路。接收端根据自身网络状况,选择其中一路来接收。
WebRTC中启用Simulcast,需要在addTransceiver时指定编码参数:
const transceiver = pc.addTransceiver('video', {
direction: 'sendonly',
sendEncodings: [
{ rid: 'high', maxBitrate: 2_000_000, scaleResolutionDownBy: 1.0 },
{ rid: 'mid', maxBitrate: 500_000, scaleResolutionDownBy: 2.0 },
{ rid: 'low', maxBitrate: 100_000, scaleResolutionDownBy: 4.0 }
]
});
这里rid是流的标识,scaleResolutionDownBy表示分辨率缩放比例。1.0是原图,2.0是缩小到1/2,4.0是1/4。
接收端怎么选?可以通过RTCRtpReceiver的setParameters()来切换:
// 接收端选择 mid 流
const receiver = pc.getReceivers().find(r => r.track.kind === 'video');
const params = receiver.getParameters();
params.encodings.forEach(enc => {
enc.active = (enc.rid === 'mid');
});
receiver.setParameters(params);
注意:Simulcast会显著增加发送端的编码负载和带宽消耗。三路流同时编码,CPU和上行带宽都要翻倍。我建议只在服务端转发的场景(SFU)中使用,P2P场景慎用。
说实话,Simulcast的优点是切换非常平滑——接收端可以瞬间切到不同质量的流,没有解码延迟。缺点是浪费带宽,发送端要同时推三路。
四、SVC(可伸缩视频编码)
SVC是另一种思路。它只编码一路流,但这路流内部是分层的。比如:
- 时间层(Temporal):控制帧率。底层只有关键帧,高层加上插值帧。
- 空间层(Spatial):控制分辨率。底层是低分辨率,高层叠加高分辨率细节。
- 质量层(Quality):控制画质。底层是粗糙画质,高层提升信噪比。
接收端可以根据自己的带宽和解码能力,只解码部分层。比如网络差时只解码底层,得到低分辨率但流畅的画面;网络好时解码所有层,得到全高清画质。
WebRTC对SVC的支持,目前主要依赖VP9编码器。启用方式:
const transceiver = pc.addTransceiver('video', {
direction: 'sendonly',
sendEncodings: [
{
rid: 'svc',
scalabilityMode: 'L3T3', // 3层空间 + 3层时间
maxBitrate: 2_000_000
}
]
});
scalabilityMode的格式是L<空间层数>T<时间层数>。比如L3T3表示3层空间分辨率、3层帧率。
接收端解码时,浏览器会自动根据网络状况丢弃部分层数据。开发者不需要手动干预——这是SVC相比Simulcast最大的优势。
我的经验:SVC在Chrome上对VP9支持较好,但H264的SVC支持有限。如果你需要兼容Safari或老版本浏览器,Simulcast可能是更稳妥的选择。
五、Simulcast vs SVC:怎么选?
我整理了一个对比表,方便你决策:
| 维度 | Simulcast | SVC |
|---|---|---|
| 编码负载 | 高(多路同时编码) | 低(单路编码) |
| 带宽消耗 | 高(同时发送多路) | 低(只发一路,分层) |
| 切换延迟 | 极低(接收端直接选流) | 低(丢弃部分层即可) |
| 浏览器兼容 | 好(Chrome/Firefox/Safari均支持) | 一般(VP9支持较好,H264有限) |
| 实现复杂度 | 中等(需管理多路流切换) | 低(浏览器自动处理) |
| 适用场景 | SFU架构,接收端网络差异大 | P2P或小规模会议,希望节省带宽 |
我个人习惯是:新项目优先考虑SVC,因为它更节省带宽和CPU。但如果目标用户中有大量Safari用户,或者需要精细控制每路流的质量,Simulcast更靠谱。
六、知识体系总览
下面这张图,帮你把本章的核心逻辑串起来:
七、避坑指南
最后,分享几个我踩过的坑:
- 不要过度调整码率。我曾经写过一个逻辑,每100ms根据带宽调整一次码率。结果编码器频繁重置,画面反而更卡。后来改成每2秒评估一次,带宽变化超过15%才调整,稳多了。
- Simulcast的rid命名要规范。有些浏览器对rid大小写敏感。我建议统一用小写字母,比如
high、mid、low,别用High或HIGH。 - SVC的VP9编码器在移动端可能不支持。我在Android Chrome上遇到过VP9 SVC解码失败的情况。建议在移动端降级到Simulcast或单流动态码率。
- 带宽估计不准时,可以自己实现反馈。WebRTC内置的GCC在弱网下有时会过于激进。我习惯在应用层加一个基于接收端统计的带宽反馈通道,双保险。
嗯,带宽自适应这块,说白了就是让视频流学会“弹性伸缩”。没有银弹,只有根据场景选对方案。Simulcast适合多端差异大的SFU场景,SVC适合追求带宽效率的P2P场景,动态码率则是保底方案。
你在实际项目中,可以根据用户设备和网络环境,甚至做动态切换——网络好时用SVC全层解码,网络差时切到Simulcast的低流。灵活一点,总没错。
最后一个小建议:上线前一定要做弱网模拟测试。用Chrome的DevTools模拟3G、2G网络,看看你的自适应逻辑能不能扛住。我吃过这个亏,希望你不用再吃。