快门式 3D · NVIDIA 3D Vision · Mac 适配实录

快门式 3D 播放器开发探索

NVIDIA 早在 2016 年就停产了 3D Vision,官方驱动只有 Windows、且只认自家显卡。 我们手上有发射器、眼镜和一台 120Hz 显示器,但用 Mac。这份记录讲清楚:为什么不能直接装驱动、 我们是怎么从 USB 协议一层层把它在 macOS 上重新跑起来的、中间最难的眼镜-屏幕同步问题怎么解决, 以及现在能稳定到什么程度。

✓ 发射器真机 120Hz 连续 60 秒压力测试通过 · 7,205 条定时命令零传输错误
120 Hz
外屏实测刷新率(每秒 120 次左右眼切换,每眼 60Hz 无闪烁)
23.1 µs
USB 切眼命令的平均相位误差(允许范围 ±2500µs,实测远优于)
0
7,205 条连续命令的 USB 传输错误数(并发构建压力下)
0x0955:0007
NVIDIA 3D Vision 发射器的 USB 识别码(Cypress FX2 芯片)
第一部分 · 快门式 3D 到底是什么

先说人话:眼镜一直在"眨眼",只是你感觉不到

所有 3D 显示的核心都只有一件事:让左眼只看到左眼该看的画面,右眼只看到右眼该看的, 大脑把两张略有差别的图合成一个有远近的立体画面。快门式 3D 用的是最"暴力"也最准确的一种办法—— 时间上轮流来。

时分复用
同一块屏幕、同一时刻只显示左眼或右眼其中一张,但切换得足够快(120Hz),大脑感觉是同时看到的。
快门眼镜
两片液晶镜片,可以单独变黑(挡光)或变透明。屏幕显示左眼画面时右镜片变黑,反之亦然。
page-flip(翻页)
让显示器在每一次垂直同步(vsync)时整幅切换内容,而不是滚动更新——这是时分复用要求的无撕裂切换方式。
相位 / 串扰
眼镜切换和屏幕切换之间的时间差。差得太多,左眼就会漏看到一点右眼画面,出现"鬼影"。

一秒钟里发生了 120 次的事

这块示意图把 1 秒钟里前 4 次 vsync 周期放大了看(实际是 120 次)。注意屏幕和眼镜 必须严格同步:屏幕显示哪只眼,眼镜就放行哪只眼、挡住另一只。发射器(那个插 USB 的小盒子) 就是用红外信号把"现在该哪只眼看"告诉眼镜的。

时间 屏幕:左眼画面 第 1 帧 · vsync 屏幕:右眼画面 第 2 帧 · vsync 屏幕:左眼画面 第 3 帧 · vsync 屏幕:右眼画面 第 4 帧 · vsync 眼镜状态: 左镜片 右镜片 ↑ 红外信号:告诉眼镜现在该哪只眼透光
图 1 · 120Hz 快门式 3D 的工作周期(图中放大了 4 个周期,实际每秒 120 个)。屏幕与眼镜通过发射器的红外信号严格同步。

为什么不用更省事的偏振 / 裸眼方案?

快门式(本项目)

优点:分辨率不折半(每只眼都看到完整 1080p),色彩无损失,立体感最准。
缺点:要戴眼镜、要发射器、要 120Hz 显示器,且对同步精度极其敏感。

偏振式(电影院那种)

优点:眼镜便宜轻便,无闪烁。
缺点:需要专门的偏振屏幕(普通显示器做不了),且垂直分辨率直接减半。

我们走快门式,是因为手头正好有 NVIDIA 3D Vision 的整套设备(发射器+眼镜+一台 3D Vision Ready 显示器), 它是 2010 年代 PC 上 3D 游戏的事实标准。问题只有一个——它早停产了,而且 NVIDIA 的驱动不支持 Mac

第二部分 · 我们怎么在 Mac 上把它跑起来

核心问题:NVIDIA 不给驱动,我们自己写一个

NVIDIA 3D Vision 于 2016 年停产。官方驱动只在 Windows 上工作,而且必须有 NVIDIA 显卡。 Mac 上既没有这个驱动,苹果芯片也根本不是 NVIDIA 的 GPU——这条路是堵死的。

好消息是:发射器硬件本身只是个普通的 USB 设备。它内部是一颗 Cypress EZ-USB FX2 单片机 (USB 识别码 0955:0007),开机时是空的,需要主机把固件上传进去、然后按一套固定协议发"切左眼/切右眼"命令。 这套协议早就被开源项目 libnvstusb(LGPL 许可)逆向出来了,在 Linux 上的 Psychtoolbox 软件已经稳定用了十几年。 我们要做的,就是把这个协议路线搬到 macOS 上。

关键判断:走 libusb 协议路线,不移植驱动

libnvstusb 是纯 libusb 实现的——libusb 是跨平台的 USB 访问库,macOS 原生支持。这意味着协议代码 本身是主机无关的,只要 Mac 上能装 libusb(本机已装 1.0.29),理论上就能直接跑。我们不碰 NVIDIA 的私有驱动、 不依赖 GPU 厂商,完全靠 USB 协议和发射器对话。

三模块架构:一条链,各管一摊

① 播放器 SBS 左右并排视频 拆成独立的左/右眼帧 SwiftUI + AVFoundation ② Metal 帧序列 每个 vsync 交替整帧 输出左/右眼 → 显示器 @120Hz · 无撕裂 画面 120Hz 显示器 3D Vision Ready 每个 vsync 的 呈现时刻 ③ libusb 发射器 swap(eye) → 红外 → 眼镜 红外 快门眼镜 左右镜片交替变黑
图 2 · 三模块架构。画面流(青→绿)负责"屏幕显示对的图",同步流(黄)负责"眼镜在对应时刻放行对的镜片"。两条流必须对齐。

每个模块具体做了什么

模块 ①
SBS 源解码
把左右并排(SBS)的视频,用 AVFoundation 解出独立的左/右眼帧序列。这一步和普通 3D 播放器一样。
模块 ②
Metal page-flip
用 MetalKit 在每次 vsync 整幅切换左/右眼画面,要求真 120Hz 输出。这是 Mac 端最难的部分——见第三部分。
模块 ③
libusb 发射器
独立的 C 程序通过 USB 给发射器发"现在切左/右眼"。固件不随 App 分发(属 NVIDIA IP),首次用时让用户选合法取得的固件文件。

为什么发射器要做成独立 C 程序,不是直接写在 Swift 里?

USB 传输是阻塞的——如果发射器在等待响应,会卡住整个 Metal 渲染线程,导致画面掉帧。 把 USB 逻辑放进独立的 helper 进程,通过 stdin/stdout 与播放器通信, USB 阻塞永远不会进入渲染线程。helper 用 mach_wait_until 精确守时, 优先级是 user-interactive(最高),避免被普通任务抢占。

四阶段任务分解(S1–S4)

整个工程拆成四个有依赖关系的阶段,每个阶段都有明确的"完成"标准,不靠主观判断。

S1 · 发射器探测 ✓

用 libusb 枚举 USB,匹配 0955:0007。这一步不需要真硬件——没插时会正确报"未发现", 插了能 open+claim 接口就算过。无需硬件即可完成

S2 · 固件上传 + 切眼协议 ✓

上传 FX2 固件(23 个数据块,严格校验 CPUCS 头尾和 SHA256),重枚举后发 100/110/120Hz 初始化包和逐帧切眼包。 真机 0955:0007 已通过:set_rate(120)、左右眼、反相全部成功。

S3 · Metal 帧序列输出 ✓

新增输出模式 .frameSequential,每个 vsync 交替输出 L/R 整幅画面。 留好接口 onEyePresented(eye, hostTime) 给 S4 用。不需要发射器就能开发和测试

S4 · 同步与相位调优 🟡

最难的一块:让眼镜切换和屏幕切换对得上。真机时序已通过 60 秒压力测试, 但戴眼镜的肉眼串扰验收还要人工做(见第四部分)。

第三部分 · 最难的工程问题:让眼镜和屏幕对上

掉一个 vsync 槽,整个立体感就崩了

这一段是整个项目里花时间最多、踩坑最深的部分。听起来简单——"屏幕显示左眼时, 让眼镜也放行左眼"——但实际做起来,有一类问题能让整个立体效果瞬间瓦解,叫眼别错位

问题是什么:漏一个槽,左右眼就反了

120Hz 意味着每秒有 120 个"显示槽"(vsync 周期)。理想情况下,屏幕和眼镜各数 120 下,严丝合缝。 但显示器偶尔会丢一个槽——可能是 GPU 太忙、可能是合成器延迟、可能是窗口系统抖动。一旦屏幕漏掉一个槽 而眼镜继续按部就班地数 120 下,两者就错开了一格:从此屏幕显示左眼时,眼镜放行的是右眼, 观众看到的立体感完全反转(凸的变凹、近的变远),而且很难自己纠正——会一直反下去。

错误做法:按回调次数翻转眼别(漏槽时立刻错位) 屏幕: 右(重复) ⚠ 漏槽 眼镜: 放行左 ✓ 放行右 ✓ 放行左 放行右 放行左 从此屏幕左 vs 眼镜右,持续反转
图 3 · 眼别错位的成因。屏幕漏掉一个 vsync 槽(画面重复),但眼镜按自己的计数继续翻眼,两者从此永远错开一格。

我们的解法:把眼别绑死到"实际显示的那张图"

关键洞察是:眼别不能靠"我发了多少次命令"来决定,必须靠"屏幕真的显示了哪张图"来决定。 我们引入了一个叫 DisplayEyeClock 的组件,做的是一件事——把左右眼标签绑定到每次成功提交的 drawable (Metal 的画面缓冲)。无论屏幕漏不漏槽,USB 和屏幕对同一张画面永远用同一只眼。

原来的脆弱做法

每次 vsync 回调被触发,就翻转一次眼别。问题:回调次数 ≠ 实际显示次数。显示器漏槽时回调照样触发, 于是 USB 多翻一次,屏幕和眼镜各数各的,一旦错开就一直错。

现在的稳健做法

眼别 = drawable 提交序号 & 1。屏幕显示哪张图,眼镜就对哪张图用对应的眼。 漏槽时屏幕重复上一张 drawable,眼别也跟着重复,永远不会错位

另一个隐藏陷阱:不要相信 Metal 给的"目标呈现时刻"

Metal 提交一帧时会附带一个"预计呈现时刻"(target presentation time),听上去正好可以拿来排程。 但实测发现这个时间戳并不可靠——它不一定反映真实的物理呈现,而且 macOS 的窗口合成器还会介入。 我们最终改成:只在 drawable 真正呈现后(用 drawable.presentedTime 回调)才确认这一帧落地, 并且连续 5 秒严格 120Hz、零漏槽,才允许发射器开始工作;一旦检测到漏槽,立即 fail-closed(停止发射)。

相位锁(ShutterPhaseLock):提前一帧把下一眼排好

最朴素的同步是"画面呈现完了再发切眼命令"——但 USB 命令有传输延迟,等发出去眼镜才切,已经晚了。 我们的相位锁做了两件事:① 用历史呈现时刻估计刷新周期(EMA 平滑),提前一帧算出下一个 vsync 时刻; ② 通过 EYE_AT eye timestamp 命令告诉 helper:"在 这个绝对时刻 执行切眼"。 helper 用 mach_wait_until 提前 0.75ms 唤醒 + 末尾短忙等,把执行误差压到接近 0。 相位还可在 -2.5…+2.5 ms 范围内 0.05ms 步进调节,适配不同显示器和眼镜的物理延迟。

解法的全貌:四道安全闸门

① 眼别绑定 drawable 序列

漏槽不会让眼别错位——屏幕重复哪张图,眼镜就跟着对哪张图用同一只眼。

② 真实呈现时刻校验

不信 target timestamp,只信 presentedTime。相邻呈现间隔异常 = 漏槽,立即处理。

③ 启动前 5 秒稳定门

连续 5 秒严格 120Hz、零漏槽才让发射器上岗。任一不稳定就不开始,避免观众看到错乱的画面。

④ 刷新率 95–105% 容差

实测刷新率必须落在目标的 95%–105% 内才允许输出。PLL(锁相环)不会把 2 倍漏帧或重复眼学成新周期。

第四部分 · 现在能跑到什么程度

真机 60 秒压力测试:7,205 条命令,零错误

2026-07-30,我们拿到了真发射器(0955:0007,USB 2.0 High-Speed), 在一台 SONY TV 3840×2160@120Hz 外屏上做了完整的压力测试。下面是实测数据,不是估算。

7,205
连续 60 秒发出的定时切眼命令数(约 120/秒 × 60)
0
其中 USB 传输错误数(全程稳定)
23.1 µs
窗口均值绝对值的平均相位误差(允许 ±2500µs)
~1.0 ms
USB 完成的自适应提前量(初始 1.8ms,收敛到约 1.0ms)

压力场景:不只是空载

测试不是在空闲机器上跑的。我们在同时运行 Xcode Debug 构建和 pytest 的负载下 依然保持 120fps。系统调度的长尾最坏到 13.53ms(约一个 vsync 周期),但这种长尾不参与提前量学习, 后续的过期命令会被丢弃策略恢复,链路不会断。

✓ 已通过的真机验证

固件上传:23 个 FX2 vendor-request 数据块,逐块校验 CPUCS 头尾 + SHA256,7,026 字节完整上传后正确重枚举
切眼协议:set_rate(120)、左/右眼、INVERT(反相)全部成功,无 USB transfer error
相位闭环:自适应提前量从 1.8ms 收敛到 ~1.0ms,稳态窗口平均误差接近 0
压力稳定性:60 秒、60 个统计窗口,无传输错误;并发构建负载下窗口均值误差 23.1µs
过期命令恢复:注入 20ms 旧的 EYE_AT 命令后正确报告 stale_drops=1,链路不断

App 里的实际体验

外屏全屏输出

独立的 borderless 窗口精确覆盖目标屏(如 SONY TV),不创建 macOS 全屏 Space。 内屏保留所有控制:打开文件、播放/暂停、左右眼互换、刷新率选择、相位微调、USB 状态。

实时校准工具

全屏期间保留半透明校准条,可用 [ ] 快捷键调相位 (步进 0.05ms),+[ 调 0.1ms。 实测刷新周期和锁相抖动实时显示。

实时 2D → 快门 3D:把第一份研究的成果接进来

这个快门链路不只放现成的 SBS 3D 片——它能配合我们另一项研究(实时 2D 转 3D) 工作。普通 2D 视频用 Core ML 模型实时生成立体画面,直接喂进 page-flip 链路。 四个 Core ML 模型在 M4 Max 上的单帧耗时:Compact 3:1 7.65ms、Compact 6:1 7.68ms、 Medium 15M 8.64ms、S6 Full 20M 9.52ms——四档都满足 24fps 内容实时。 在 SONY TV 上 Compact 3:1 实测连续 30 秒窗口均达 120fps,USB 抖动 0.00ms。
(2D 转 3D 的算法细节在另一份报告里, 本页只讲快门链路怎么把它变成可看的画面。)

为什么 120Hz 是硬骨头

2D 转 3D 模型推理只要 ~8ms(够 24fps 内容实时),但120Hz 外屏输出意味着 GPU 每 8.3ms 就要合成一帧。 推理和显示合成在抢同一块 GPU。我们为此写了快门专用 Metal 快路径:把多层立体数据打包成 RGBA16F 纹理数组,每层一次硬件双线性采样;离屏右眼缓存降到 960×540(普通播放仍是原分辨率)。 相比之前 720p 缓存的 114fps 低谷,Compact 3:1 在外屏稳到 120fps。

第五部分 · 诚实边界与待办

哪些算数,哪些还不算

我们坚持一个原则:没验证的不说成已验证。下面把已确认和未确认的明确分开, 方便你判断当前到底是什么状态。

✓ 已经在真机验证的

• 发射器 USB 协议全链路(固件上传、set_rate、左右眼、反相)
• 120Hz 真机连续 60 秒、7,205 条命令、零传输错误
• 相位锁自适应收敛、过期命令丢弃、热插拔自动重连
• USB 完成时刻的闭环(自适应提前量 1.8ms → ~1.0ms)
• 眼别绑定 drawable 序列,漏槽不会导致左右反转
• 实时 2D→3D + 快门外屏 120fps(Compact 3:1 实测)

✗ 还没人工验收的

戴眼镜的肉眼观感:左眼只见左、右眼只见右,无串扰
最佳相位值:不同显示器/眼镜的物理延迟不同,需要用高对比测试图现场调
连续 60 秒不失锁的肉眼确认(时序已通过,观感需人工)
物理冷启动拔插:断电重上电的固件上传流程(逻辑已实现,未做物理实验)

已知风险与限制

3D Vision 2 变体可能不兼容

libnvstusb 逆向的是初代 3D Vision(0955:0007)。如果你的发射器是 3D Vision 2 (0955:0008 或其他变体),协议可能不直接覆盖,需要追加逆向,工作量会上升。

系统调度长尾

并发负载下系统抢占的最坏长尾约 13.53ms(超过一个 vsync 周期)。这个长尾不参与提前量学习, 依赖过期命令丢弃机制恢复。极端高负载下可能短暂丢几帧,但不会持续错位。

固件属 NVIDIA IP,不能随 App 分发

发射器固件是 NVIDIA 的知识产权。仓库和 App 都不附带固件。首次使用时 App 会提示用户 选择合法取得的 nvstusb.fw,保存到 ~/Library/Application Support/。libusb 与 LGPL 文本随 App 携带,符合许可证要求。

Mac 内屏不是最优目标

MacBook 内置屏虽然能跑 120Hz,但实测在 2D→3D 实时路径上左右眼差异弱。 外接 120Hz 3D Vision Ready 显示器(或 DLP-Link 投影)才是设计目标。

用户侧体验步骤(硬件齐备时)

预检
运行 scripts/preflight_3dvision.sh,看到 PREFLIGHT_PASS hardware=connected
选固件
App 首次提示时选 nvstusb.fw(合法提取自 NVIDIA 驱动)。之后自动加载。
开 120Hz
在 macOS 显示器设置里把目标屏选 120Hz。144/165Hz 仅作实验档。
调相位
用左右眼测试图(左红右蓝),戴眼镜调到双边最少串扰。连续 60 秒确认无失锁。

下一步:戴上眼镜,亲眼确认

所有 USB 时序、协议、相位数学都已在真机闭环验证。剩下唯一待办是人工戴眼镜观感验收—— 确认左眼只见左、右眼只见右,调到双边最少串扰,连续观看 60 秒无失锁。这是物理层面的最终确认, 无法用程序替代。完成后本链路即可作为内部 3D 效果评测的标准工具使用。