背景
云游戏
云游戏是一种基于云计算的游戏形式,其本质上是交互式的在线视频流。在云游戏的运行模式下,游戏首先在云端服务器上运行,然后将渲染完成的游戏画面或指令压缩后通过网络传输给用户。由于游戏服务部署在云端,用户只需通过网络接入云端游戏服务器,就能够流畅地体验游戏。相比传统游戏,云游戏具有更低的硬件门槛、更高的游戏画质、更流畅的游戏体验等优势,受到越来越多玩家的欢迎。

音视频引擎
云游戏的本质是多媒体技术的应用,主要依赖的是流媒体(Streaming media)的传输与编解码技术。云游戏SDK中音视频引擎模块是非常核心的一环,主要负责媒体数据的传输与解码。
云游戏目前应用于主流的音视频传输方案有以下几种:
WebSocket + WebAssembly软解
WebSocket是一种现代的网络通信协议,它提供了双向的、实时的、基于事件的通信方式,可以用于实现实时数据传输、在线游戏、在线聊天等应用。WebSocket协议建立在TCP协议之上,通过TCP的握手协议来建立连接,然后在双方之间建立起长连接,从而实现双向通信。
该方案使用WebSocket传输音视频流,客户端再通过WebAssembly解码音视频流,将解出的视频帧绘制到Canvas上,将音频帧通过AudioContext播放。

WebSocket 方案最大的优点是基本可以兼容所有场景。缺点也是显而易见的,首先由于建立在TCP协议之上,传输音视频流的效率会比较低,延迟相较会较大。
其次由于通过软解解码音视频,对CPU占用较高,可能导致耗电过快、发热严重、性能损失较大。
当然,音视频解码渲染部分我们也可以使用WebCodecs。但是这样会损失该方案极佳的兼容性。云游戏厂商一般都是采用WebSocket+软解来进行兼容兜底,该方案并不是Web云游戏的主流方案。
WebRTC
WebRTC(Web Real-Time Communication)是一种用于浏览器之间实时通信的开放标准和API集合。由于采用了P2P技术,可以实现超低延迟的实时音视频通信。由于浏览器采用硬解,解码方面效率也很高,可以满足云游戏的使用场景。
WebRTC相较于WebSocket方案的优点显而易见,更低的延迟、更高的解码效率,除此之外,还不需要引入额外的解码包,可以减少包体积。
缺点就是目前移动端设备较混杂,除了有些设备不支持的情况以外,由于不同系统的实现不统一,还会遇到各种各样的兼容性问题,如花屏、黑屏、卡顿、自动播放等问题。而且由于WebRTC基本音视频组帧与解码都由浏览器内核处理,对于开发者来说是一个黑盒,兼容问题的定位和解决很困难。
WebTransport + WebCodecs
WebTransport 以 QUIC 协议为基础,它提供了一种在浏览器中进行低延迟、可靠和安全的网络数据传输的方法。WebCodecs是Web平台上的一组媒体编解码器API,支持对音频和视频数据进行编码和解码。
这两个技术都是较新的提案,需要比较高的浏览器版本才能兼容,就目前可支持的浏览器版本占比而言,不适合在生产环境中大规模使用。但是针对展会、商业演示的一些特殊场景,由于可以指定用户使用的浏览器,WebTransport可以不受兼容性约束,发挥出比WebTransport更佳的体验效果。
WebTransport兼容性

WebCodecs兼容性

从CanIUse给出的兼容性结果可以看出,WebTransport和WebCodecs的兼容性都不够良好,在Safari、FireFox和一些移动端浏览器上基本无法使用。
虽然目前还不能全量使用,但是可以通过兼容性检测,在支持的设备优先使用WebTransport来做音视频传输,对于不支持的设备,使用WebRTC做降级处理,从而达到最优的体验。
其次,WebTransport作为新一代的http标准,将来的兼容性情况肯定会越来越好,提前布局该方案,可以在将来http3在浏览器中大规模普及的过程中逐步获得越来越大的收益。
对于以上三种音频引擎方案,可以通过设计完备的兼容性检测和降级策略,以达到最优的支持度覆盖和用户体验。
本文将重点介绍 WebTransport + WebCodecs 组合的音视频传输和解码方案,下文中简称为 webts引擎。
客户端技术栈
Rust WebAssembly
Rust 是一门系统级编程语言,由 Mozilla 公司的开发者团队设计和开发。它的目标是提供安全性、并发性和性能的平衡,同时保持开发者的生产力。Rust 最初发布于2010年,经过持续的迭代和社区贡献,已经成为一门备受关注和广泛应用的编程语言。
WebAssembly(缩写为WASM)是一种可移植、高性能的二进制格式,旨在在现代Web浏览器中执行高性能的计算密集型任务。它是一种低级别的虚拟机,可以在多种编程语言中编写代码,并在Web浏览器中运行。
Rust 对 WebAssembly(WASM)有很好的支持,开发者可以使用 Rust 编写高性能的 WebAssembly 模块,同时Rust 提供了强大的外部函数接口(FFI)功能,支持 Rust 代码与 JavaScript 之间相互调用。因此在WebAssembly 模块中使用 Rust 编写的函数可以与 JavaScript 代码进行交互,实现跨语言的功能扩展。
内存安全与运行高效是Rust最大的两个特点。在云游戏场景中,音视频组帧和QoS方案具有较高的复杂度和高频且密集的CPU计算,因此Rust这两大特点非常契合webts引擎的开发,可以保证不会因为密集计算引入过高的额外延迟和内存开销。
以下是几种较为主流的编译Wasm的语言的运行效率对比,以JavaScript作为对比基线。
测试方式为初始化一个包含 100,000 个随机值的数组,将该数组复制 500 次,并且每次都会进行稳定排序。每个测试将重复执行 5 次,并取5次执行结果的平均值,具体数据对比如下:
语言 | 编译wasm文件大小(kb) | 运行时耗时(ms) | 内存(mb) |
---|---|---|---|
JavaScript(js) | 1.3 | 68720 | 55.7 |
JavaScript(js) TypedArray | 1.3 | 4904 | 30.5 |
AssemblyScript | 4.7 | 6405 | 21.5 |
Rust | 74.0 | 2982 | 21.1 |
Go | 37.0 | 9717 | 21.5 |
测试结果表明,Rust在运行耗时方面远优于原生Js、AssemblyScript Wasm和Go Wasm。内存占用方面,与其他的AssemblyScript和Go基本保持一致,在编译产物大小上,虽然Rust表现最差,但是不存在数量级上的差异,且在音视频引擎场景下,Wasm的编译产物并不会影响性能和使用体验。
运行耗时方面,Go与AssemblyScript由于带有GC,所以运行时性能相较于Rust有较大差距。
WebTransport
WebTransport 是一项新的 Web API,它提供了一种在浏览器中进行低延迟、高可靠和安全的网络数据传输的方案,其目标是使开发人员能够使用现代的网络协议和技术来实现更快、更可靠的网络应用程序。WebTransport API 以 QUIC 协议为基础,QUIC 协议是由 Google 提出的一种新的基于 UDP 的协议,该协议允许在传输层上进行多路复用和零往返时间的连接设置,从而减少网络延迟,相较于WebRTC具有更低的延迟。
WebTransport提供了Datagram和BidirectionalStream两种传输通道,分别用于不可靠和可靠的数据传输。在云游戏场景中,可以根据传输的内容进行灵活选择,如对于低延迟要求较高的音视频数据,适合使用不可靠传输通道,而对于准确性要求比较高的游戏操作,适合使用可靠传输通道来进行传输。
WebCodecs
WebCodecs是Web端的一组媒体编解码器API,支持对音频和视频数据进行编码和解码。这些API可以结合WebRTC、Web Audio和Web Video等API使用,以支持浏览器中的音视频通信、音视频编辑、音视频流媒体等应用场景。
WebCodecs API提供了一组标准化的编解码器,包括VP9、H.264、Opus、AAC等。这些编解码器可以通过JavaScript API进行配置来启用,以支持不同的编解码质量需求。这使得开发人员可以结合其他web端的媒体传输技术,开发出能够提供高质量的音视频传输和处理功能的Web应用程序。
WebWorker
Web Worker 是一种支持web内容在后台线程中运行脚本的一种方法,它允许在后台线程中执行耗时的计算任务,而不会阻塞主线程任务的执行。Web Worker 使得在 Web 应用中可以同时进行多个任务,并提高了应用的性能和响应能力。
由于 Web Worker 在独立的线程中执行,因此它们不能直接操作 DOM 元素或执行与界面相关的操作。如果需要更新界面或操作 DOM,可以通过消息传递机制通知主线程,在主线程中完成相应的操作。
WebTransport和WebAssembly都支持在WebWorker中使用,但是WebCodecs目前不支持在WebWorker中使用。
客户端通信方案
运行时环境
为了追求更高的性能,避免频繁的音视频通信和复杂的数据计算等操作阻塞主线程任务执行带来操作卡顿等问题,可以将WebTransport和WebAssembly部分的功能放到WebWorker中运行,通过消息跟主线程进行通信。由于WebCodecs不支持在WebWorker中使用,所以解码渲染部分需要在浏览器主线程中执行。
于是,webts引擎便存在三个运行时环境,浏览器主线程、WebWorker线程、Wasm。
主线程与WebWorker线程之间通过浏览器提供的postMessage API进行双向通信,Worker线程与Wasm之间则通过Rust FFI进行双向通信,整体通信流程和功能划分设计图如下:

浏览器主线程
受限于浏览器API的限制,部分功能只能在浏览器主线程中处理,主线程主要负责以下功能:
- 使用WebCodecs解码音视频帧,然后通过canvas绘制画面,AudioContext播放音频。以上功能单独封装成播放器模块。
- 基于MediaDevices相关接口采集客户端音视频,通过WebCodecs编码压缩。
- 处理用户事件和其他的用户交互逻辑,并暴露SDK对外的用户接口。
WebAssembly
WebAssembly部分使用Rust编写,追求极致的运行时性能。WASM部分主要负责以下功能:
- CGTP协议(参考文章4.2)的解封装,对媒体报的分组和排序
- QoS(参考文章5.0)的相关计算,如JitterBuffer实现、FEC算法实现等
- 媒体数据的统计和输出,如帧率、码率、丢包等信息
WebWorker
WebTransport和WebAssembly都支持WebWorker中使用,其中WebTransport应用部分会频繁地接收和处理大量的媒体数据包,且需要与WebAssembly模块进行频繁的双向通信。为了避免这些高频率、高复杂度的逻辑给主线程带来性能损耗,引起体验上的劣化,WebTransport和WebAssembly模块都放在WebWorker中运行是一个很好的选择。
通信方案
通信场景
因为涉及到媒体数据和控制消息的频繁传递,所以需要设计三个环境的高效的通信方案。该方案中的通信涉及一下两个场景。
- 浏览器主线程与WebWorker之间的通信,该场景可以通过 postMessage API来进行双向通信
- WebWorker与WebAssembly之间的通信,该场景可以通过Rust提供的外部函数接口来进行Rust和JavaScript之间的相互调用
内存共享
上文介绍的webwoker通过postMessage和主线程通信, 以及在webworker中调用Rust外部函数接口的方式都需要将传输数据的内存进行拷贝,这种方式效率较低,会带来额外的时延和内存消耗。为了解决这个问题,需要设计内存共享方案来规避掉内存拷贝带来的性能损耗。
SharedArrayBuffer
针对webwoker通信带来的内存拷贝问题,可以使用SharedArrayBuffer对象来进行二进制数据在主线程和Worker线程的共享。为了保证对共享内存的安全控制,该方案需要显示授权,即使用SharedArrayBuffer需要在HTTP响应头中设置Cross-Origin-Opener-Policy(COOP)和Cross-Origin-Embedder-Policy(COEP)标头来授权,以确保只有授权的源可以共享和访问SharedArrayBuffer。
所以在云游戏SDK接入策略中,优先建议业务方开启授权,并且针对未授权的环境,使用ArrayBuffer来降级处理以保证功能不受影响。
Rust与JavaScript
通过外部函数接口的方式,Rust与JavaScript之间可以很方便进行双向通信,但是每次传递数据都会进行隐式的内存拷贝,在音视频包的频繁传输场景下,会带来不可忽略的性能损耗。普通的双向通信示意图如下:

针对以上的内存拷贝问题,可以在Rust中通过 once_cell crate延迟初始化一个u8数组类型的全局变量,用于共享内存。然后通过wasm-bindgen定义接口给到js调用,在方法中将使用 js-sys crate全局变量转换成 Uint8Array类型,js调用时传入一个回调函数,调用回调函数将转换之后的Uint8Array回调给js进行使用。
值得注意的是,如果是自动分配内存,js_sys::Uint8Array 类型在读写之后可能会失效,所以在初始化时需要使用标准库中的 alloc手动分配内存给全局变量使用,以保证在后续读写数据时Uint8Array能够一直生效。
最终Rust中的全局u8数组与js中的Uint8Array都指向了同一块固定的内存,进行双向通信时,只需要对这块内存进行读写即可。
整个流程示意图如下:

性能对比
对8千万个u8类型数字进行求和,对js、Rust内存拷贝、Rust内存共享三种方案进行性能对比。
对比实验数据如下,Rust内存拷贝方案传输数据的时间远大于内存共享的方案。
单位/ms | Js | Rust 内存拷贝 | Rust 内存共享 |
---|---|---|---|
赋值 | -- | -- | 1 |
内存拷贝 | -- | 17 | -- |
求和 | 131 | 29 | 27 |
总耗时 | 131 | 46 | 28 |
媒体传输协议
RTP/RTCP
业界实时音视频通话场景一般采用RTP/RTCP协议,RTP(Real-time Transport Protocol)是一种用于实时数据传输的协议,它通常用于音视频通信和流媒体传输。
以下是一个RTP包头的组成,最少占用12个字节。

RTCP(Real-time Transport Control Protocol)是用于实时数据传输控制的协议,它通常与RTP一起使用,用于支持音视频通信和流媒体传输中的控制和反馈机制。RTCP包由多个RTCP报文组成,每个报文有不同的类型和构成,占用4个字节,以下是一个RTCP报文包头的结构:

RTCP报文类型包含接收报告、发送者报告、SDES报文、BYE报文、APP报文等不同的内容。这些信息用于实时数据传输的控制、反馈和附加描述,以确保实时通信的质量和可靠性。
虽然RTP协议在实时音视频传输场景得到了广泛的应用,但是因为一些客观因素它在云游戏场景并不太适用。
- RTP/RTCP协议是一套大而全的方案,需要考虑到多方通信,支持多种音视频编码,RTCP报文种类繁多。这些特性会带来一定的报文包体积增长,在高频的音视频报文传输过程中会带来一定的带宽消耗。
- 同时协议复杂也意味着会增加一定的封装/解封装和计算时延,这在对低延迟要求极高的云游戏场景也会带来一定的体验劣化。
- 云游戏场景除了音视频数据之外,还会有一些用户指令和通信数据需要进行双向传输,需要在RTP协议基础上考虑额外的负载类型。其次,这些数据多数都是要求必须可靠传输,而RTP协议本身是不可靠的,需要借助RTCP增强传输的可靠性,增加了额外的复杂度和数据达到时延。
- 对于WebTransport的可靠传输通道,没有给予很好的支持,需要设计额外的机制来拼接成完整的包。
基于此上几点考虑,我们设计了适用于云游戏场景的实时通信协议 CGTP(CloudGame Transport Protocol)
CGTP
CGTP协议旨在满足云游戏场景的实时通信需求,尽可能减少协议的复杂度,剔除掉RTP协议在云游戏场景中的冗余部分,使得数据包体积更小、解封装效率更高。同时吸收RTCP中对于QoS的优秀设计,使得其能满足云游戏场景中对于音视频服务质量的高要求。
CGTP协议将媒体数据、传输控制数据、游戏数据、推流控制信息合并到一个协议中,共用一个公共的包头。
CGTP公共包头仅占用1个字节,主要包含了包类型信息,根据不同的包类型负载中定义不同的负载包头。针对WebTransport可靠传输通道,由于数据包是流式传输,所以增加两个字节额外的包头来协议数据包的拼接。这种设计方式可以给传输协议带来很高的可扩展性。
接下来主要介绍一下与客户端强相关的三个负载类型。
媒体数据
媒体数据的包头包含8个字节,CGTP移除了SSRC和CSRC,使用SID来取而代之表示不同的信号源。sequence number与timestamp的含义与RTP协议一致。
控制协议
控制协议是为了保证服务质量而制定的,相当于RTCP协议功能的在云游戏场景下的定制化精简。RTCP协议包头仅占用一个字节,用于指定负载类型的媒体流ID。其中payload类型目前有 NACK、FEC、PLI、JBA这几种。
游戏数据
云游戏场景中的游戏数据包含用户指令、键鼠事件、控制消息、用户自定义数据等,通过PB协议进行编码传输。一次可以传输多条游戏数据,因此游戏数据包的负载为长度加PB二进制数据的,其中长度占两个字节。
QoS
音视频传输的 QoS(Quality of Service)是指在网络传输过程中,为了保证音视频数据的实时性和质量,以达到给用户提供稳定、实时和高质量的音视频体验的目的而采取的一系列优化措施和策略。QoS的优化策略在弱网情况下尤其重要和具有挑战性。
相较于与传统的实时音视频应用,云游戏场景对画面的实时性,也就是低延迟具有很高的要求。以下从客户端的角度介绍一下该方案中涉及到的一些QoS优化策略。
NACK
NACK 是网络中的一种反馈机制,全称为 Negative Acknowledgment(否定确认)。它用于在数据传输过程中检测和处理丢失的数据包,并通知发送端重新发送这些丢失的数据包。当接收方检测到丢失的数据包时,它可以发送一个 NACK 给发送方,NACK 中包含丢失数据包的信息,通常是数据包的序号或标识符。发送方接收到 NACK 后,可以根据其中的信息重新发送对应的丢失数据包。
FEC
FEC 是网络传输中的一种前向纠错(Forward Error Correction)技术。它通过在发送端添加冗余数据,使接收端能够在存在少量数据丢失或错误的情况下进行纠错,而无需重新请求丢失的数据。FEC 技术主要用于提高数据传输的可靠性和容错性。
PLI
PLI 是实时通信中的一个重要概念,全称为 Picture Loss Indication(图片丢失指示)。它用于视频传输中,允许接收方向发送方发出信号,表明接收方已经丢失了一个或多个视频帧。 PLI协议用于请求发送端产生新的关键帧,用于在弱网情况下主动重传,或者在NACK都失效的情况下出现了视频帧的缺失导致无法解码,客户端主动发起请求关键帧来恢复画面播放,该类型没有payload。
JitterBuffer
JitterBuffer(抖动缓冲区)是在实时通信中用于处理网络抖动的一种缓冲机制。它主要用于音频和视频传输,旨在解决由于网络延迟和抖动引起的数据包到达时间不一致的问题。
该方案的JitterBuffer设计为以下几个主要部分
- PacketBuffer:负责接收媒体数据包并进行丢包检测、排序和组帧的工作。
- FrameBuffer:负责接收PacketBuffer组好的帧,对帧进行排序,是否可解码的判断,进行音视频抖动延迟(Jitter)的计算,通过jitter来进行缓存区大小的动态伸缩,保证在弱网情况下视频也能够流畅的播放,不过作为代价,会牺牲一定的低时延。
- MediaStats:负责在整个过程中收集和计算媒体包和音视频帧的信息,包含总包数、帧数、帧率、码率、丢包、卡顿等信息。并将数据返回给客户端进行数据上报或者展示给用户,也可以发送给服务端用来协助调整网络策略。
为了提高丢包检测、组帧的效率,尽可能降低计算带来的额外时延,PacketBuffer中设计了 FrameInfo 的数据结构,用于在每次插入媒体包时,选择性地更新视频帧的起始位置的等信息。在组帧检测完成的时候,利用FrameInfo中实时同步的信息,可以直接从媒体包队列中取出媒体包组成视频帧输出到FrameBuffer。除此之外,对于重传包插入位置的计算,也可以里利用 FrameInfo 来缩小遍历范围,以尽可能减少循环的次数。
三个主要部分的协作时序图如下:
