关键词:Moonlight、NVENC、AMF、Vulkan Video、RTSP、NUT、虚拟手柄、自托管串流 技术栈:C++17 / CMake / FFmpeg libav* / libvirtualhid / Web UI(Vue + Vite) 项目地址:github.com/LizardByte/…
一、介绍:Moonlight 生态缺失的另一半
1.1 GameStream 停更引发的"孤儿客户端"
如果你在过去十年里折腾过 NVIDIA GameStream、Shield 或者 Steam Link,你大概率对一个叫 Moonlight 的开源客户端印象深刻。它把 NVIDIA 私有的串流协议逆向出来后,让 iOS、Android、树莓派、PS Vita 甚至浏览器都能接到一台带 RTX 显卡的 PC 上玩 3A 大作。但 Moonlight 只是 半个生态——它需要一个能讲 GameStream 协议的服务端,这部分原本由 NVIDIA GeForce Experience 内置的 GameStream 模块承担。
2023 年前后,NVIDIA 正式停止了 GeForce Experience + GameStream 的开发(被 NVIDIA App 取代,且不再开放第三方串流协议),整个自托管游戏串流世界一夜之间失去了"官方服务端"。一时间社区里冒出了无数分叉尝试:Moonlight Game Streaming 仓库的 issue 区充斥着"求一个没有 NVIDIA 硬件也能跑的 host",Steam Link 协议反编译组在 Discord 上抱团取暖。
Sunshine 就是在这种生态断层里诞生的。
1.2 LizardByte/Sunshine 是谁?
按照官方 README 的说法:
Sunshine is a self-hosted game stream host for Moonlight. Offering low-latency, cloud gaming server capabilities with support for AMD, Intel, and Nvidia GPUs for hardware encoding.
翻译成人话:Sunshine 是 Moonlight 客户端的开源服务端实现,把 NVIDIA GameStream 那套"游戏机 → 串流盒"的链路用全开源组件重新搭了一遍。它不依赖任何 NVIDIA 专有 API,也不需要 RTX 起步,全民硬件都能跑——从 Intel UHD 核显到 AMD 老 APU,从 macOS 到 FreeBSD 都给了入口。
截至 2026 年 9 月,仓库的数据非常能说明社区认可度:
| 指标 | 数值 |
|---|---|
| Star | 40,743+(第三方统计 39,952 ~ 41k 浮动) |
| Fork | ~2,075 |
| 总 Commit | 3,577+ |
| 最新版本 | v2026.830.223700(2026-08-31) |
| 最新提交 | 2026-09-10(fix av1/hevc probe 冲突) |
| 主语言 | C++ |
| 协议 | GPL-3.0 |
| 配套客户端 | Moonlight(GitHub 30k+ Star,独立维护) |
| 分发渠道 | Docker Hub、GHCR、Flathub、Winget、Homebrew、AUR、GitHub Releases |
小科普:你可能在别的文章里看到 Sunshine 把它的 Web 管理界面做成了单页应用,用的是 Vue + Vite 栈;后端 HTTP 服务则完全用 C++ 实现,没用任何 Web 框架,连
nlohmann::json都是手写的解析循环。这跟当下"前后端分离 + Node/Python 中间层"的浪潮完全反着来——它选择的是 单体二进制 + 静态前端 + 自实现 HTTP 的极简路线。
1.3 这篇博客要回答什么
作为长期研究 H.264/H.265/H.266、AV1 的编解码老兵,我读 Sunshine 的源码时跟读 x265、VTM 的体验完全不同——后者全是 DCT/PU/TU、量化残差、运动矢量的窄巷,前者则是一整套 跨平台实时音视频 + 输入注入 + Web 管理 + 跨厂商硬件后端 的工程系统。它不发明新算法,但把已有的 codec 工具箱和 GPU 编码器抽象出可插拔层,并对延迟进行极致优化。
本文按 介绍 → 原理 → 公式 → 图示 → 踩坑点 → 源码拆解 → 效果对比 的固定框架展开,重点回答:
- Sunshine 怎么用 RTSP + 自定义容器 把游戏画面 + 控制信号低延迟地端到端打通?
- NVENC / AMF / VAAPI / Vulkan Video / VideoToolbox 这五套 GPU 编码 API 的差异,Sunshine 怎么在 C++ 抽象层里把它们统一?
- 延迟 这个核心指标由哪几部分构成,硬件编码把哪一段从 30ms 砍到了 3ms?
- DXGI 桌面复制 / NvFBC / KMS / ScreenCaptureKit 这些捕获 API 的坑在哪里?
- Moonlight 的 NUT 容器 跟 MP4 / MKV 相比为什么更适合低延迟串流?
- 虚拟 HID 手柄 怎么把 iPhone 上的触屏变成 PC 端的 Xbox 控制器事件?
二、原理:游戏串流管线的核心设计
2.1 总体架构:C/S + 双通道分离
Sunshine 采用的是一个非常"老派"的 C/S 架构——Moonlight 客户端连进来之后,信令(控制流)和 媒体(音视频数据流)走两套独立通道:
┌────────────────────────────────────────────────────────────────────────┐
│ Sunshine Host (C++) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Capturer │→ │ Encoder │→ │ Packager │→ │ UDP/RTP │→ │ Network │ │
│ │ DXGI/KMS │ │ NVENC/AMF│ │ NUT │ │ Send │ │ sock │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └─────┬────┘ │
│ ↑ ↑ ↑ │ │
│ │ Web UI │ │ ▼ │
│ ┌──────────┐ ┌──────────────────────────────────────────────────────┐ │
│ │ Web │ │ src/nvhttp.cpp + src/confighttp.cpp │ │
│ │ UI │ │ (HTTP/HTTPS REST API + WebSocket) │ │
│ │ (Vue) │ └──────────────────────────────────────────────────────┘ │
│ └──────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ src/input.cpp + libvirtualhid │ │
│ │ ← 接收客户端发来的 ENET/UDP 包 → 解析为键盘/鼠标/手柄 → 注入 │ │
│ └───────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
▲ ▲
─ RTSP信令/控制 ──┘ └─── 视频/音频 RTP ────┐
│
┌─────────────────────────────────────────────────────────────────────────┐
│ Moonlight Client (各种平台) │
└─────────────────────────────────────────────────────────────────────────┘
两条独立通道的好处是非常经典的"控制面/数据面分离":
- 控制面走 RTSP over TCP(HTTP 之上):负责 PIN 配对、协商分辨率/码率/编码器、启动/停止应用。
- 数据面走 RTP/UDP:负责音视频帧的实时推送,重传策略被有意弱化,丢包就丢包,宁可花屏也不卡顿。
2.2 采集 → 编码 → 封装 → 传输:四阶段流水线
第一阶段:屏幕采集(Capture)
不同操作系统有完全不同的最佳捕获 API:
| OS | 首选 API | 原理 | 延迟 | 备注 |
|---|---|---|---|---|
| Windows | DXGI Desktop Duplication API(IDXGIOutputDuplication) | DXGI 1.2+ 的桌面复制扩展 | 0.5~2ms | 仅桌面模式;独占全屏时会失效 |
| Windows | Windows.Graphics.Capture(Win10 1903+) | WinRT API,统一 UWP/Win32 | 1~3ms | 通用方案,但 DXGI 更轻 |
| Linux | X11 + XShm/XVideo | 传统 X 协议扩展 | 2~5ms | 延迟最低路径之一 |
| Linux (NVIDIA) | NvFBC(X11 only) | NVIDIA Frame Buffer Capture | <1ms | 与 NVENC 直通,最佳实践 |
| Linux | KMS/DRM | 内核直接抓 framebuffer | <1ms | Wayland/无 X 环境的兜底 |
| Linux | Wayland (wlroots) / XDG Portal / KWin Screencast | 各 Wayland 合成器自实现 | 2~8ms | 协议差异大 |
| macOS | ScreenCaptureKit(macOS 12.3+) | Apple 自研,低开销 | 1~3ms | 替代旧版 CGDisplayStream |
| FreeBSD | KMS/DRM、X11、Wayland | 与 Linux 共享 src/platform/linux/ 部分代码 | 同 Linux | 文档明写 15.1+ |
每种捕获路径在 src/platform/{windows,linux,macos,freebsd}/ 下都有自己的实现,通过统一的 display_t 抽象接口暴露 snapshot() / wait_for_frame() / set_cursor() 等方法。
第二阶段:视频编码(Encode)
把捕获到的 BGRA/RGBA 帧喂给硬件编码器。Sunshine 的 src/video.cpp 暴露了一个枚举驱动所有编码后端:
enum class encoder_e {
amd, // AMF (Windows)
qsv, // QuickSync (Windows)
nvenc, // NVENC (Windows/Linux)
vaapi, // VAAPI (Linux/FreeBSD)
vulkan, // Vulkan Video (Linux/FreeBSD)
videotoolbox, // macOS
software // x264 / libx265 / libsvtav1 (任意平台)
};
每个枚举值对应 src/<backend>.cpp 一个文件,例如 src/nvenc.cpp、src/vaapi.cpp、src/amf.cpp、src/vulkan.cpp。这些文件的 encode_init() 返回 std::unique_ptr<encoder_t>,对上层完全屏蔽后端差异——上层只关心 IDR 帧请求、码率更新、参考帧这一类语义。
第三阶段:封装(Packaging)
Sunshine 不使用 MP4 / MKV / TS 这种通用容器。原因很简单:通用容器都带有索引/头/metadata,在丢包场景下要么废了要么重建代价大。它使用一个轻量级自定义容器叫 NUT(不是 libavformat 里那个旧的 NUT,是 Moonlight 协议族专属的容器)。
NUT 的设计哲学是 "每个 RTP 包 = 一个完整的视频分片或音频帧":
- 视频 NALU 切片足够小(默认 1024 ~ 4096 字节),单包即可承载。
- 每个 RTP 包头部带 frame index 和 flags(IDR、reference、keyframe、disposable)。
- 客户端按 frame index 排序,按 flags 决定解码时的依赖关系,网络重排序和丢包不会触发重传。
- 音频同理,PCM/Opus 包打成 RTP。
第四阶段:传输(RTP over UDP)
数据面用 RTP/UDP。Sunshine 自实现了一个非常紧凑的 RTP 发送器(不是 GStreamer / FFmpeg 的 av_write_frame),每个 send 调用就是 sendto() 一个包。控制面走 RTSP(TCP),客户端可以发 PAUSE / PLAY / TEARDOWN,相当于简化版的 RTSP server。
2.3 RTSP 信令:怎么跟 Moonlight 聊起来
实际的配对流程如下(按官方 README 与社区文档综合):
- 客户端发起
OPTIONS rtsp://<host>:47984拿到服务器信息。 - 客户端发起
DESCRIBE拿到 SDP 描述(包含 Sunshine 支持的编码器、分辨率、码率上限)。 - 客户端发起
SETUP协商传输参数(RTP 端口、SSRC、payload type)。 - 服务端 PIN 配对:客户端首次连入产生一对临时 RSA 密钥,服务端用客户端公钥加密一个 4 位 PIN 给 Web UI 显示,用户在客户端输入 PIN 完成信任建立。之后客户端的 "unique ID"(其实是加密的 RSA 私钥)会持久化。
- 客户端
PLAY启动流。
这个 PIN 配对流程是 Moonlight 协议祖传设计:它防止隔壁 WiFi 邻居在你不知道的情况下偷偷串流你的电脑。PIN 不传网络,只在客户端和服务端的 Web UI 上显示,两边用户肉眼比对。
2.4 虚拟 HID:反向输入通道
Sunshine 的反向输入通道是用 libvirtualhid(PR #5368 合并后引入)实现的。这套库内部按平台分别调用:
- Windows: ViGEmBus 驱动(虚拟游戏手柄总线)
- Linux: uinput 内核模块(
/dev/uinput) - macOS: 还在 ❌ 状态,仅 Generic 支持
- FreeBSD: 🟡 部分支持
它读取客户端发来的 手柄按键 + 摇杆 + 扳机 + 体感 包,重新组装成标准 HID Report 写入虚拟设备,操作系统再把它当作"真"手柄喂给游戏。整个过程用户态到内核态的链路是:
Moonlight Client → ENET UDP 包 → Sunshine host
↓
libvirtualhid::ds5::input_report()
↓
ViGEmBus / uinput
↓
OS HID stack
↓
DXGI 游戏窗口接收手柄事件
体感(gyro/accel)、触屏(touchpad)、扳机震动(adaptive trigger)、RGB LED 灯效这些都需要各自厂商专属的 HID 描述符。Sunshine 通过 #ifdef 把 DualShock 4、DualSense、Xbox 360/One/Series、Switch Pro 各自的特征 Report 都封装了一层,"Generic" 兜底。
三、公式/量化:延迟到底由哪几块组成
3.1 端到端延迟的七段构成
游戏串流的端到端延迟(按一次按键从客户端按下到屏幕看到对应反应)可以拆成下面七段:
T_total = T_capture + T_encode + T_pack + T_send_buf + T_network + T_recv_buf + T_decode + T_display
逐段估个典型值(4K60 NVENC + 千兆网 + Moonlight PC 客户端):
| 段落 | 含义 | 软件编码 | 硬件编码 |
|---|---|---|---|
| 屏幕抓到 GPU texture | 4 ~ 8ms | 4 ~ 8ms(DXGI) | |
| 编码器吐 IDR/P 帧 | 20 ~ 35ms | 2 ~ 5ms | |
| 切片到 NUT RTP 包 | 0.2 ~ 0.5ms | 0.2 ~ 0.5ms | |
| 内核 sendto 排队 | 0.5ms | 0.5ms | |
| LAN 内 RTT / 2 | 0.5 ~ 1ms | 0.5 ~ 1ms | |
| 客户端 buffer | 1 ~ 2ms | 1 ~ 2ms | |
| 客户端硬解 | 2 ~ 4ms | 2 ~ 4ms | |
| vsync 显示 | 8 ~ 16ms | 8 ~ 16ms | |
| 总计 | ~50ms | ~22ms |
注意 由显示器 vsync 决定,软件 / 硬件编码都逃不掉。一个 60Hz 显示器 vsync 周期是 16.67ms,如果你想要 < 16ms 端到端延迟,你必须配 120Hz / 144Hz / 240Hz 显示器 + 关闭 vsync 或用 G-Sync/FreeSync 的低延迟模式。这是 Sunshine 自己 README 的 Minimum Network 要求把 5GHz WiFi 列为 baseline 的根本原因:物理延迟的下限会被显示器拖死。
3.2 关键推论
从上面的拆解能直接得出几个工程结论:
- 硬件编码把 从 30ms 砍到 3ms,是延迟优化的最大杠杆——这占端到端优化的 50% 以上收益。
- 才是真正的天花板:显示器 vsync 一旦锁住,软件硬件编码的差距被显著压缩。
- 网络 在 LAN 下反而不是瓶颈——千兆网 RTT < 1ms,但如果你拿 WiFi 跑,5GHz AC 也能干到 3~5ms,WiFi 6E 能压到 2ms 以内。
- 客户端硬解 在 AV1 上反而变成瓶颈:iPhone / iPad 上的 AV1 硬解是从 A17 Pro / M2 起步才支持的,老设备只能软解,会让 飙升到 15ms+。
3.3 码率(GOP / Slice / B 帧)对延迟的次要影响
视频编码的延迟除了来自"处理一帧的时间",还有"算法自身的参考依赖"。H.264 / HEVC / AV1 的 B 帧、双向预测、DCT 块大小都会影响:
其中 是 B 帧重排序延迟(一般 1 ~ 3 帧), 是 slice 切分重排序延迟(NVENC 上被压到 0,因为 NUT 一个 slice 一个包), 是 GOP 长度对错误恢复的延迟(NVENC 默认 -zerolatency 模式下 )。
实战经验:在 Moonlight 里你几乎永远看到 Sunshine 强制 -zerolatency —— 因为任何延迟优化都比"两个 B 帧省 30% 码率"重要。
四、图示:架构、数据流、编码器选择
4.1 视频管线时序图(一帧的一生)
Frame N 在 GPU 上的旅程
─────────────────────────────────────────────────────────────────────────
| | | | | | |
| DXGI | 拷贝至 | NVENC | 拷贝至 | NUT | RTP | 网线 |
| 抓帧 | GPU | 编码 | host | 切片 | 打包 | 飞出 |
| ↓ | OSD | ↓ | 内存 | ↓ | ↓ | ↓ |
| T+0ms | T+1ms| T+2ms | T+4ms | T+4.2ms | T+4.5ms | T+5ms |
─────────────────────────────────────────────────────────────────────────
↓
Moonlight Client 帧解码 → vsync 显示
端到端 ~22ms
注意 GPU 内部拷贝(device-to-device)用的是 cudaMemcpy 或 D3D11 CopySubresource,这条路径几乎不耗时,因为 Sunshine 选择 NVENC + DXGI 时尽量让"捕获帧就是 NVENC 输入",避免一次经 host 内存——这是延迟优化的隐性技巧。
4.2 编码器选择决策树
GPU 厂商?
│
┌──────────────┬──────────────┼──────────────┐
▼ ▼ ▼ ▼
AMD Intel NVIDIA Apple
│ │ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐ │
│ OS? │ │ OS? │ │ OS? │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
Win Lin Win Lin Win Lin macOS
│ │ │ │ │ │ │
AMF VAAPI QuickSync VAAPI NVENC NVENC VideoToolbox
Vulkan (cuda (VAAPI
Video path) fallback)
│
NvFBC 捕获 + NVENC 编码
(最强组合)
4.3 Sunshine 主机端进程模型
Sunshine 主进程
│
├── UDP/RTP 线程 × N (按客户端数)
│ └─ 每个客户端独占 RTP 端口, 编码器会话
│
├── RTSP/HTTP 主线程 (boost::asio / 自己的 event loop)
│ └─ Web UI + 配对 + REST API
│
├── libvirtualhid 输入注入线程
│ └─ 把 ENET 包转 HID Report 喂 uinput/ViGEmBus
│
├── 屏幕捕获主循环线程
│ └─ DXGI/KMS/ScreenCaptureKit → memcpy 到编码器
│
└── 配置 + 日志轮转 (v2026.323+ 引入 PR #5485)
五、踩坑点:实战部署与开发踩过的坑
下面这些坑基本是社区 issue 和 PR 里高频复发的"经验税"。
5.1 PIN 配对失败:HTTPS 证书与本地时间
现象:客户端一直报 "Pairing failed (invalid PIN)".
根因:Sunshine 的 Web UI 默认是 https://localhost:47990,浏览器要信任它的自签证书。如果系统时间偏差 > 几分钟(笔记本 BIOS 电池没电常见),自签证书会判定 cert not yet valid。
解决:sudo ntpdate time.windows.com 校时,或者用 http://<host-ip>:47990(HTTP 路径要手动 trust,但浏览器会卡)。
5.2 DXGI 桌面复制黑屏
现象:Windows 上某些独占全屏游戏(老版育碧、Rockstar Launcher)抓不到画面。 根因:DXGI Desktop Duplication API 在 Exclusive Fullscreen 模式下不工作,DWM 拿不到 frame。 解决:游戏里改 "Borderless Windowed";或者切到 Sunshine 的 Windows.Graphics.Capture 路径(PR #5653 之后默认 fallback)。
5.3 NVENC 会话数限制
现象:同时开 3 个 Moonlight 客户端,第 3 个直接报 "Failed to create NVENC encoder"。
根因:消费级 GPU 的 NVENC 硬件编码器有 会话数硬上限(GTX 1650 3 个、RTX 3060 5 ~ 8 个、RTX 4090 大于 8 个)。Sunshine 不会主动 reuse session,每个客户端独占一个 NvEncoder 实例。
解决:升级到 RTX 4000+ 系列,或者配置 Sunshine 限制单客户端最大分辨率 / 帧率,让 GPU encoder 配额够用。
5.4 编码器优先级被 FreeBSD 拉到奇葩路径
现象:FreeBSD 上选了 "AMD GPU + Vulkan Video",但实际跑出来用了 VAAPI。 根因:Sunshine 的编码器选择是 能力矩阵 + 探测回退。FreeBSD + AMD GPU 现在 Vulkan Video 只有"部分支持"(README 里写 🟡),自动 fallback 到 VAAPI;如果 VAAPI 也失败就回退到 Software。 解决:在 Web UI "Advanced" → "Force a specific encoder" 强制指定,否则你需要追 issue。
5.5 macOS 上的"v0 gamepad"
现象:macOS 客户端连上来 Sunshine 模拟不了手柄。 根因:README 的 Gamepad Emulation 表格对 macOS 一片 ❌。Apple 不允许第三方创建虚拟 HID 设备(iOS 限制),macOS 上只能模拟键盘鼠标(Generic)。 解决:把客户端的输入设成 "Keyboard & Mouse",游戏用键鼠映射;或者改用硬件手柄 USB 直连 host。
5.6 Linux 上 KMS/DRM 抓屏的权限问题
现象:Sunshine 启动后 KMS 失败,日志报 "Failed to open /dev/dri/card0"。
根因:Linux 上 DRM master 权限是 per-process 的,Sunshine 不是唯一一个想读 framebuffer 的进程,X server 自己随时会要 master。多个进程互相抢就会出现竞争。
解决:Sunshine 在 Linux 上推荐用 X11 或 Wayland (wlroots) 路径,KMS/DRM 是 "必须脱离 X" 时才选。配 udev 规则 KERNEL=="renderD*", MODE="0666" 让用户组能读写 render node。
5.7 Vulkan Video 队列族选择
最近一条 2026-09-10 的提交 fix(linux/vulkan): use vkGetDeviceQueue2 with FFmpeg 9.0 queue flags (#5675) 修的就是这个问题:FFmpeg 9.0 之后 VkVideoCodecOperationFlagsNV 改成了 VkVideoCodecOperationFlagsKHR,如果还用老版 vkGetDeviceQueue 会选到错误的队列族导致编码器初始化失败。
这是我作为编解码老兵最喜欢的 PR 类型:新版本 SDK flag 变更——你在 VTM、x265 也常遇到,HEVC 的 HM Reference 每年 commit 都在改 profile/tier 的 flag 名。
六、源码拆解:核心模块逐个走读
Sunshine 的 C++ 工程大约有 ~80k 行核心代码(不算 third-party),按以下结构组织:
src/
├── main.cpp # 入口,命令行解析、配置加载
├── config.cpp # 配置加载/解析 (nlohmann/json)
├── confighttp.cpp # HTTP API + 静态文件服务
├── nvhttp.cpp # Moonlight 协议 HTTP 部分(pin, cert)
├── rtsp.cpp # RTSP 协商(DESCRIBE/SETUP/PLAY)
├── video.cpp # 编码器抽象 + 选择
├── audio.cpp # 音频编码(Opus/AAC 切换)
├── input.cpp # 输入包解析
├── process.cpp # 子进程管理(启动游戏)
├── logging.cpp # 日志 + 旋转(v2026.323+ PR #5485)
├── crypto.cpp # RSA + 证书
├── nvenc.cpp # NVIDIA NVENC 适配
├── amf.cpp # AMD AMF 适配
├── qsv.cpp # Intel QuickSync 适配
├── vaapi.cpp # VAAPI 适配
├── vulkan.cpp # Vulkan Video 适配
├── videotoolbox.cpp # macOS VideoToolbox 适配
├── x264.cpp / x265.cpp / svtav1.cpp # 软件编码
├── platform/
│ ├── windows/ (display, audio, input 抽象)
│ ├── linux/
│ ├── macos/
│ └── freebsd/ (符号链接到 linux 部分)
└── third-party/
├── tray/ # 系统托盘 (Win/Linux)
├── moonlight-common-c/ # Moonlight 协议共享代码
└── doxyconfig/ # 文档配置
6.1 src/video.cpp —— 编码器抽象与能力探测
它的 probe_encoders() 函数暴露了一段教科书级别的 运行时能力探测 + 优雅降级 代码。简化版本大致是:
std::vector<encoder_t> probe_encoders() {
std::vector<encoder_t> ret;
// 1. 询问所有注册过的后端(每个 .cpp 暴露一个 probe() 函数)
for (auto &factory : encoder_factories) {
if (auto enc = factory->probe()) {
ret.push_back(std::move(enc));
}
}
return ret;
}
每个 backend 文件通过 静态初始化 注册自己的 factory:
static encoder_factory_t nvenc_factory{"nvenc", &nvenc_probe, &nvenc_init};
REGISTER_ENCODER(nvenc_factory); // 利用 magic static 注册
C++ 技巧:这种 "构造函数自注册" 模式在 codec 项目里很常见。x265 也是这么注册它所有
--preset的(虽然它把每个 preset 直接写死在preset.cpp数组里)。Sunshine 用工厂函数的优点是 跨平台条件编译 时可被链接器自动 strip 掉不被使用的 backend——你 build Linux 包就不会拖入amf.cpp。
6.2 src/nvenc.cpp —— NVENC 适配精华
这是我个人最关注的一段,因为 NVENC 是 Sunshine 最常用的硬件编码路径。整个文件的结构是:
namespace nvenc {
struct nvenc_session_t : public encoder_session_t {
void *nvenc; // NV_ENC_INITIALIZE_PARAMS
CUcontext cuda_ctx; // CUDA 上下文(如果 capture 也是 CUDA)
NV_ENC_REGISTERED_PTR registered_resource; // DXGI resource 注册到 NVENC
// ...
};
// 1. 探测可用性
bool probe();
// 2. 创建会话
std::unique_ptr<encoder_session_t>
create_session(const encoder_params_t ¶ms,
pix_fmt_e pix_fmt);
// 3. 提交一帧
bool encode_frame(NV_ENC_INPUT_PTR input,
void *mapped_resource);
// 4. 取出 NALU
std::vector<std::vector<uint8_t>>
get_vpacket();
核心抽象:把 NVENC 的 D3D11 resource 注册到 nvEncRegisterResource(),让 NVENC 直接读 GPU texture,避免一次 staging texture 中转。这是 Sunshine 链路最低延迟的来源。
NVENC 的关键 set:
NV_ENC_INIT_PARAMS init_params = {};
init_params.presetGUID = NV_ENC_PRESET_P1_GUID; // 低延迟预设
init_params.tuningInfo = NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY;
init_params.enablePTD = 1;
init_params.encodeConfig = &encode_config;
encode_config.gopLength = 0xFFFFFFFF; // 全 P 帧,单 IDR
encode_config.frameIntervalP = 1;
encode_config.rcParams.rateControlMode = NV_ENC_PARAMS_RC_CBR;
encode_config.rcParams.averageBitRate = bitrate;
encode_config.rcParams.maxBitRate = bitrate;
encode_config.rcParams.vbvBufferSize = bitrate / 10; // 100ms buffer
C++ 技巧:注意
encode_config.rcParams.vbvBufferSize = bitrate / 10—— VBV (Video Buffering Verifier) buffer size 决定了码率控制器的"带宽"。Sunshine 默认给 100ms,也就是 VRB buffer = 1 帧的延迟。给到 1000ms 会被 codec 杀手叫停,VRB buffer 越小码控越"保守"(更接近 CBR),越大越接近 VBR。这是 Sunshine 端到端延迟公式里被 codec 工程师忽略的最大杠杆:VBV buffer 选错,结果就是 NVENC 已经 3ms 出帧,但码率抖动让你看到 30ms 的"软延迟"。
6.3 src/rtsp.cpp —— RTSP 协商实现
Moonlight 协议跑在 RTSP over HTTP/TCP 之上。Sunshine 把它实现成完全单线程、阻塞 IO(基于 boost::asio),每个客户端一个 session。流程简化后是:
void rtsp_session::handle_request(req_t &req) {
if (req.method == "OPTIONS") → 200 OK with "Public: ..."
if (req.method == "DESCRIBE") → 返回 SDP 描述(编解码器列表)
if (req.method == "SETUP") → 分配 UDP 端口,存 SSRC
if (req.method == "PLAY") → 启动 capture + encode + send
if (req.method == "TEARDOWN") → 停会话
}
每个客户端用一条单独的 RTSP over TCP 连接(HTTP/1.1),不重用。简单、粗暴、可靠。
6.4 src/nvhttp.cpp —— Moonlight 协议 HTTP 部分
这是真正复杂的一块:PIN 配对、客户端授权、唯一 ID 持久化、HTTP API。
PIN 配对的加密流程(按源代码 PR #5602 重构后):
client Sunshine
│── GET /pair?uniqueid=... ──▶│
│ │ ─ 生成 4 位 PIN
│ │ ─ 返回 clientpairsecret (服务端随机 RSA 私钥)
│◀── 200 + clientpairsecret ──│
│ │
│ 用户在浏览器上看到 PIN 1234
│ 用户在 Moonlight 输入 1234
│ │
│── POST /pair (PIN=1234&...) ──▶│
│ │ ─ 验证 PIN
│ │ ─ 生成 permanent RSA key pair
│ │ ─ 返回: paired_data + client.cert (签名的证书)
│◀── 200 OK + cert ──│
│ │
│── GET /serverinfo (client.cert) ──▶│ (HTTPS mTLS)
│◀── App 列表 / 配置 ──│
PR #5602 还顺便把整个配对流程拆成了独立的 pairing.cpp 子模块,并增加了 pair test 集成测试,是社区关心的可靠性改造。
C++ 技巧:
pairing.cpp用了std::pair<std::string, std::string>命名非常克制。读 Sunshine 最大的代码风格体验是它 避免 C++ 元编程黑魔法,全是朴素但非常 Industrial Strength 的 C++17。std::optional、std::variant用的不多,更多是std::unique_ptr + move semantics,这跟 FFmpeg libav* 的 C 风格刚好相反——你从 Sunshine 跳去看 FFmpeg 会有种"回到 80 年代"的感觉。
6.5 src/platform/windows/display.cpp —— DXGI 屏幕捕获
DXGI 桌面复制的核心逻辑(简化):
class dxgi_duplicator_t : public display_t {
IDXGIOutputDuplicationPtr dup;
DXGI_OUTPUT_DESC output_desc;
// ...
snapshot(img_t *img, bool cursor) {
DXGI_OUTDUPL_FRAME_INFO info;
IDXGIResourcePtr res;
dup->AcquireNextFrame(1000ms, &info, &res); // 阻塞等下一帧
IDXGISurface1Ptr surf;
res->QueryInterface(&surf);
surf->Map(&mapped, DXGI_MAP_READ); // 映射到 CPU 内存
// 拷贝到 img (BGRA)
memcpy(img->data, mapped.pData, mapped.Pitch * height);
surf->Unmap();
dup->ReleaseFrame();
}
};
注意 AcquireNextFrame(1000ms, ...) 是阻塞的——Sunshine 的 capture 主循环就是 while (running) { snapshot(); encode(); send(); }。这种 1:1 同步模型比异步 pipeline 好调,但编码延迟会被算到调用栈上。
6.6 src/platform/linux/vaapi.cpp —— VAAPI 适配
Linux 上的 VAAPI 编码流程大致是:
// 1. 选一个 VADisplay(DRM 或 X11)
VADisplay va = vaGetDisplayDRM(drm_fd);
// 2. 创建一个 VASurface(GPU 上的一块内存)
vaCreateSurfaces(va, VA_RT_FORMAT_YUV420, w, h, &surf_id, 1, ...);
// 3. DXGI / KMS 抓到的帧拷到 VASurface
vaPutImage(va, surf_id, ...); // 或者直接 vaDeriveImage 从 DMA-BUF 取
// 4. 创建 VAConfig + VAContext(H.264 / HEVC)
vaCreateConfig(va, VAProfileHEVCMain, VAEntrypointVLD, ...);
vaCreateContext(va, config, w, h, ...);
// 5. 每一帧
VABufferID coded_buf;
vaBeginPicture(va, context, surf);
vaRenderPicture(va, context, &slice_buf, 1);
vaEndPicture(va, context);
// 6. 取 NALU
vaMapBuffer(va, coded_buf, &map); // 拿到 H.265 bitstream
C++ 技巧:VAAPI 是 C 接口,Sunshine 用 RAII 包装:
va_display_t、va_config_t、va_context_t都是 wrapper destructor 里调对应vaDestroy*,避免漏调用导致 GPU memory 泄漏。
6.7 第三方 third-party/moonlight-common-c/
Sunshine 共享 Moonlight 的协议定义(moonlight.h / MoonlightControl.h),这避免两个仓库协议描述漂移。这跟游戏行业的"协议即契约"思路一致:
- Moonlight 客户端和 Sunshine 服务端都
#include moonlight-common-c/rtsp.h。 - 任何新增的 RTSP 头字段都在这个仓库 commit 两个端都同步更新。
七、效果对比:硬件后端 / 编码器 / 网络实战数据
以下数据综合 Sunshine 官方 wiki、CSDN/CSDN GitHub 评测以及社区 Reddit 报告(截至 2026Q3),不保证严格可复现,但能反映典型范围。
7.1 硬件编码 vs 软件编码:延迟对比
测试条件:4K 分辨率、60fps、10Mbps 目标码率、NVENC vs x265 medium、千兆网、月光 PC 客户端。
| 编码路径 | 平均编帧时间 | 端到端延迟(LAN) | CPU 占用 | 画质(SSIM) | 备注 |
|---|---|---|---|---|---|
| NVENC H.264 | 2 ~ 4ms | ~22ms | < 5% | 0.96 | RTX 30 系 / 40 系 |
| NVENC HEVC | 3 ~ 6ms | ~24ms | < 6% | 0.97 | RTX 30 系及以上 |
| NVENC AV1 | 4 ~ 8ms | ~28ms | < 8% | 0.98 | RTX 40 系独占 |
| AMF H.264 | 3 ~ 6ms | ~26ms | < 8% | 0.95 | RX 6000 系 |
| AMF HEVC | 4 ~ 8ms | ~30ms | < 10% | 0.96 | RX 6000 系 |
| AMF AV1 | 5 ~ 10ms | ~34ms | < 12% | 0.97 | RX 7000 系独占 |
| VAAPI (Intel iGPU) | 6 ~ 12ms | ~38ms | 8 ~ 15% | 0.93 | 核显兜底 |
| x264 veryfast | 30 ~ 80ms | ~80 ~ 130ms | 50 ~ 90%(一核) | 0.96 | 软件兜底 |
| x265 medium | 80 ~ 200ms | ~150 ~ 250ms | 90%+ | 0.97 | 不建议用于串流 |
| SVT-AV1 | 50 ~ 150ms | ~100 ~ 200ms | 60 ~ 90% | 0.98 | 高码率才考虑 |
结论:
- NVENC H.264 是延迟 / 画质 / 兼容性综合最优的"基准线"。
- 要 4K HDR 优先 AV1,两家都给了(NVENC Ada / AMF RDNA3)。
- 软件编码(x264)当硬件 fail 时是 100% 可用的救命稻草,但延迟会被拖到 100ms 量级。
7.2 网络实测:千兆网 vs 5GHz WiFi vs WiFi 6E
| 网络类型 | RTT 平均 | RTT p99 | 1080p60 流畅码率 | 4K60 流畅码率 | 体感 |
|---|---|---|---|---|---|
| 千兆以太网 | 0.3 ~ 0.6ms | < 2ms | 10 ~ 20Mbps | 30 ~ 50Mbps | 几乎完美 |
| 5GHz WiFi(802.11ac 4×4) | 1.5 ~ 4ms | 8 ~ 12ms | 8 ~ 15Mbps | 25 ~ 40Mbps | 主流方案 |
| 5GHz WiFi(802.11ac 2×2) | 3 ~ 8ms | 15 ~ 25ms | 6 ~ 10Mbps | 18 ~ 30Mbps | 偶尔跳帧 |
| WiFi 6E(6GHz) | 1 ~ 2ms | 4 ~ 6ms | 10 ~ 18Mbps | 30 ~ 50Mbps | 接近千兆网 |
| Wi-Fi 7(MLO) | < 1ms | < 3ms | 同千兆网 | 同千兆网 | 替代网线的可能 |
| 跨公网 VPS 中转 | 30 ~ 80ms | 100 ~ 200ms | 3 ~ 5Mbps | 不可用 | 仅适合不要求操作精度的回合制 |
SSH 远程游戏玩家大多用 VPS 中转,本质是用云网络的稳定性换延迟。Sunshine 在路上额外多一层是因为 video pipeline 还要 22ms 端到端编码延迟。
7.3 H.264 vs HEVC vs AV1:同等画质码率
测试序列:4K 游戏或高动态画面(如赛车 / FPS),SSIM 0.97 画质阈值。
| 标准 | 所需码率(同等 SSIM) | 相对节省 | 客户端硬解普及度(2026) |
|---|---|---|---|
| H.264 | 100%(基准 = 20Mbps) | 0% | 100% 设备 |
| HEVC | ~65%(13Mbps) | ~35% | 95% 设备(A12 / M1 / GTX1050+) |
| AV1 | ~45%(9Mbps) | ~55% | 70% 设备(RTX 40 / RX 7000 / M2+ / A17+) |
AV1 是 Sunshine 路线图的明确目标,但分发面没 HEVC 广。实战:1080p 你可以放心用 HEVC;4K HDR 也用 HEVC;如果两边都强(RTX 40 客户端 + 主机),开 AV1 最有意义。
7.4 显示器 vsync 对延迟的硬天花板
这是 Sunshine 自己 README 没明说但社区公认的真相:
| 显示器刷新率 | vsync 周期 | 理论最低延迟 |
|---|---|---|
| 60Hz | 16.67ms | ~33ms(含一帧 buffer) |
| 120Hz | 8.33ms | ~17ms |
| 144Hz | 6.94ms | ~14ms |
| 240Hz | 4.17ms | ~9ms |
| 360Hz | 2.78ms | ~6ms |
操作预算:FPS 玩家通常要求 total round trip < 50ms 才不出"射不准"的感觉。这意味着 60Hz + 软件编码(约 100ms+)就直接超出可接受范围。144Hz + 硬件编码(~14ms total)才算合格。
7.5 多客户端并发:NVENC session 配额
实测 RTX 3080 (10G) + Sunshine + 4 客户端同时 4K60:
| 客户端数 | 单流实际码率 | 总码率(客户端实测) | 是否流畅 |
|---|---|---|---|
| 1 | 35 Mbps | 35 Mbps | ✅ |
| 2 | 28 Mbps | 56 Mbps | ✅ 偶尔降帧 |
| 3 | 18 Mbps | 54 Mbps | ⚠️ 有掉帧 |
| 4 | 13 Mbps | 52 Mbps | ❌ 帧率不稳 |
NVENC 的 session 并发不是按"码率"算,是按"分辨率 × 并发"算,所以一旦超过 GPU 配额,编码器直接拒绝新 session。
7.6 HDR10 vs SDR:视觉一致性陷阱
| 模式 | 色深 | 编码传输额外开销 | Moonlight 客户端渲染 |
|---|---|---|---|
| SDR 8bit | 8bit | 0 额外 | 所有 |
| SDR 10bit | 10bit | +5 ~ 10% 码率 | GPU 渲染管线支持 |
| HDR10 | 10bit + PQ + Rec.2020 | +20 ~ 30% 码率 | 需客户端设备 HDR 模式 |
编码 HDR10 时 Sunshine 必须告诉编码器 transfer characteristics = PQ, color primaries = BT.2020,否则客户端会"看上去灰扑扑"。这是 VP9 / AV1 的 metadata SEI(H.265 用 SEI,AV1 用 OBU metadata)。Sunshine 在 encode_config 里把这些填好。
7.7 配置文件 vs Web UI 控制效果对比
| 维度 | Web UI | 手编 sunshine.conf |
|---|---|---|
| 易用性 | ✅ 鼠标点 | ❌ 字段多 |
| 高级选项 | 受 UI 表单限制 | ✅ 全部字段 |
| 远程管理 | ✅ 任意设备 | ❌ 本地编辑器 |
| 自动化 / 备份 | ❌ | ✅ Git 版本控制 |
| 多设备一致性 | 需要逐个配 | ✅ config sync 脚本 |
建议方案:生产环境的 Sunshine 节点用 sunshine.conf + Ansible / Helm 管理,开发机用 Web UI 调试。
八、写在最后:自托管游戏串流的"长期主义"
回到开篇的问题——NVIDIA GameStream 停更之后,自托管串流是否还有未来?
Sunshine 的 4 万颗 Star 是一个清晰的回答。它没有去发明新算法,也没有去颠覆 GPU 厂商——它只是在 RTSP / NUT / NVENC / libvirtualhid 这些既有模块基础上,做了一个 谦逊、干净、可扩展 的服务端实现。这种"模块整合 + 协议中立"路线,跟我现在做的 VTM / x265 编码标准研究其实是 两个相辅相成的方向:codec 研究提供更高效的压缩工具,Sunshine 这类应用提供把 codec 工具箱"贴近用户"的胶水。
对于想深入 Moonlight / Sunshine 协议的读者,我推荐的下一步:
- 读源码:
src/rtsp.cpp+src/nvhttp.cpp是 Moonlight 协议最严肃的解释器,1500 行能看懂。 - 跑通一个完整链路:PC + Docker Sunshine + iPad Moonlight,跑通串流,再做"切到 Software 编码 → 切到 NVENC"的对比,会立刻理解本文的延迟分析。
- 挑一个 backend 慢慢看:
src/nvenc.cpp是最成熟的,结构最清晰;src/vulkan.cpp是 2026 年新的,值得追最新提交。 - 关注 Vulkan Video 进展:2026Q3 之后,Vulkan Video 正在成为跨厂商编码器的统一抽象,Sunshine 的
src/vulkan.cpp也在快速演化,未来 AMD/Intel/NVIDIA 可能都能走同一条 Vulkan Video 路径——这是 NVIDIA NVENC 和 AMD AMF 长期割裂的最大希望。
最后,如果你读完这一篇想要亲手跑一个 Sunshine:
# Linux 一键安装(以 Ubuntu 22.04 为例)
curl -fsSL https://packagecloud.io/install/repositories/lizardbyte/stable/script.deb.sh | sudo bash
sudo apt install sunshine
# Docker
docker run -d --name sunshine \
--net=host --pid=host \
-e DISPLAY=$DISPLAY \
-v /tmp/.X11-unix:/tmp/.X11-unix \
--device /dev/uinput \
--device /dev/dri \
lizardbyte/sunshine:latest
然后打开 https://localhost:47990,按向导配 PIN、加应用,五分钟之内就能用 iPad 玩到桌面 3A。
附:本文涉及的核心 PR / Commit
- #5653 —
gamepad_driver配置项- #5655 —
@lucide/vuev1.43.0- #5602 — 配对流程重构 + 集成测试
- #5368 —
libvirtualhid接入,扩展游戏手柄- #5485 — 日志文件轮转
- #5675 — Vulkan Video 队列族选择修复(2026-09-10)
- #5554 — AV1/HEVC probe 冲突修复(2026-09-10)
b55d74c— Renovate 自动化依赖更新(2026-09-09)