NVIDIA 早在 2016 年就停产了 3D Vision,官方驱动只有 Windows、且只认自家显卡。 我们手上有发射器、眼镜和一台 120Hz 显示器,但用 Mac。这份记录讲清楚:为什么不能直接装驱动、 我们是怎么从 USB 协议一层层把它在 macOS 上重新跑起来的、中间最难的眼镜-屏幕同步问题怎么解决, 以及现在能稳定到什么程度。
所有 3D 显示的核心都只有一件事:让左眼只看到左眼该看的画面,右眼只看到右眼该看的, 大脑把两张略有差别的图合成一个有远近的立体画面。快门式 3D 用的是最"暴力"也最准确的一种办法—— 时间上轮流来。
这块示意图把 1 秒钟里前 4 次 vsync 周期放大了看(实际是 120 次)。注意屏幕和眼镜 必须严格同步:屏幕显示哪只眼,眼镜就放行哪只眼、挡住另一只。发射器(那个插 USB 的小盒子) 就是用红外信号把"现在该哪只眼看"告诉眼镜的。
优点:分辨率不折半(每只眼都看到完整 1080p),色彩无损失,立体感最准。
缺点:要戴眼镜、要发射器、要 120Hz 显示器,且对同步精度极其敏感。
优点:眼镜便宜轻便,无闪烁。
缺点:需要专门的偏振屏幕(普通显示器做不了),且垂直分辨率直接减半。
我们走快门式,是因为手头正好有 NVIDIA 3D Vision 的整套设备(发射器+眼镜+一台 3D Vision Ready 显示器), 它是 2010 年代 PC 上 3D 游戏的事实标准。问题只有一个——它早停产了,而且 NVIDIA 的驱动不支持 Mac。
NVIDIA 3D Vision 于 2016 年停产。官方驱动只在 Windows 上工作,而且必须有 NVIDIA 显卡。 Mac 上既没有这个驱动,苹果芯片也根本不是 NVIDIA 的 GPU——这条路是堵死的。
好消息是:发射器硬件本身只是个普通的 USB 设备。它内部是一颗 Cypress EZ-USB FX2 单片机
(USB 识别码 0955:0007),开机时是空的,需要主机把固件上传进去、然后按一套固定协议发"切左眼/切右眼"命令。
这套协议早就被开源项目 libnvstusb(LGPL 许可)逆向出来了,在 Linux 上的 Psychtoolbox 软件已经稳定用了十几年。
我们要做的,就是把这个协议路线搬到 macOS 上。
libnvstusb 是纯 libusb 实现的——libusb 是跨平台的 USB 访问库,macOS 原生支持。这意味着协议代码 本身是主机无关的,只要 Mac 上能装 libusb(本机已装 1.0.29),理论上就能直接跑。我们不碰 NVIDIA 的私有驱动、 不依赖 GPU 厂商,完全靠 USB 协议和发射器对话。
USB 传输是阻塞的——如果发射器在等待响应,会卡住整个 Metal 渲染线程,导致画面掉帧。
把 USB 逻辑放进独立的 helper 进程,通过 stdin/stdout 与播放器通信,
USB 阻塞永远不会进入渲染线程。helper 用 mach_wait_until 精确守时,
优先级是 user-interactive(最高),避免被普通任务抢占。
整个工程拆成四个有依赖关系的阶段,每个阶段都有明确的"完成"标准,不靠主观判断。
用 libusb 枚举 USB,匹配 0955:0007。这一步不需要真硬件——没插时会正确报"未发现",
插了能 open+claim 接口就算过。无需硬件即可完成。
上传 FX2 固件(23 个数据块,严格校验 CPUCS 头尾和 SHA256),重枚举后发 100/110/120Hz 初始化包和逐帧切眼包。
真机 0955:0007 已通过:set_rate(120)、左右眼、反相全部成功。
新增输出模式 .frameSequential,每个 vsync 交替输出 L/R 整幅画面。
留好接口 onEyePresented(eye, hostTime) 给 S4 用。不需要发射器就能开发和测试。
最难的一块:让眼镜切换和屏幕切换对得上。真机时序已通过 60 秒压力测试, 但戴眼镜的肉眼串扰验收还要人工做(见第四部分)。
这一段是整个项目里花时间最多、踩坑最深的部分。听起来简单——"屏幕显示左眼时, 让眼镜也放行左眼"——但实际做起来,有一类问题能让整个立体效果瞬间瓦解,叫眼别错位。
120Hz 意味着每秒有 120 个"显示槽"(vsync 周期)。理想情况下,屏幕和眼镜各数 120 下,严丝合缝。 但显示器偶尔会丢一个槽——可能是 GPU 太忙、可能是合成器延迟、可能是窗口系统抖动。一旦屏幕漏掉一个槽 而眼镜继续按部就班地数 120 下,两者就错开了一格:从此屏幕显示左眼时,眼镜放行的是右眼, 观众看到的立体感完全反转(凸的变凹、近的变远),而且很难自己纠正——会一直反下去。
关键洞察是:眼别不能靠"我发了多少次命令"来决定,必须靠"屏幕真的显示了哪张图"来决定。
我们引入了一个叫 DisplayEyeClock 的组件,做的是一件事——把左右眼标签绑定到每次成功提交的 drawable
(Metal 的画面缓冲)。无论屏幕漏不漏槽,USB 和屏幕对同一张画面永远用同一只眼。
每次 vsync 回调被触发,就翻转一次眼别。问题:回调次数 ≠ 实际显示次数。显示器漏槽时回调照样触发, 于是 USB 多翻一次,屏幕和眼镜各数各的,一旦错开就一直错。
眼别 = drawable 提交序号 & 1。屏幕显示哪张图,眼镜就对哪张图用对应的眼。 漏槽时屏幕重复上一张 drawable,眼别也跟着重复,永远不会错位。
Metal 提交一帧时会附带一个"预计呈现时刻"(target presentation time),听上去正好可以拿来排程。
但实测发现这个时间戳并不可靠——它不一定反映真实的物理呈现,而且 macOS 的窗口合成器还会介入。
我们最终改成:只在 drawable 真正呈现后(用 drawable.presentedTime 回调)才确认这一帧落地,
并且连续 5 秒严格 120Hz、零漏槽,才允许发射器开始工作;一旦检测到漏槽,立即 fail-closed(停止发射)。
最朴素的同步是"画面呈现完了再发切眼命令"——但 USB 命令有传输延迟,等发出去眼镜才切,已经晚了。
我们的相位锁做了两件事:① 用历史呈现时刻估计刷新周期(EMA 平滑),提前一帧算出下一个 vsync 时刻;
② 通过 EYE_AT eye timestamp 命令告诉 helper:"在 这个绝对时刻 执行切眼"。
helper 用 mach_wait_until 提前 0.75ms 唤醒 + 末尾短忙等,把执行误差压到接近 0。
相位还可在 -2.5…+2.5 ms 范围内 0.05ms 步进调节,适配不同显示器和眼镜的物理延迟。
漏槽不会让眼别错位——屏幕重复哪张图,眼镜就跟着对哪张图用同一只眼。
不信 target timestamp,只信 presentedTime。相邻呈现间隔异常 = 漏槽,立即处理。
连续 5 秒严格 120Hz、零漏槽才让发射器上岗。任一不稳定就不开始,避免观众看到错乱的画面。
实测刷新率必须落在目标的 95%–105% 内才允许输出。PLL(锁相环)不会把 2 倍漏帧或重复眼学成新周期。
2026-07-30,我们拿到了真发射器(0955:0007,USB 2.0 High-Speed),
在一台 SONY TV 3840×2160@120Hz 外屏上做了完整的压力测试。下面是实测数据,不是估算。
测试不是在空闲机器上跑的。我们在同时运行 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,链路不断
独立的 borderless 窗口精确覆盖目标屏(如 SONY TV),不创建 macOS 全屏 Space。 内屏保留所有控制:打开文件、播放/暂停、左右眼互换、刷新率选择、相位微调、USB 状态。
全屏期间保留半透明校准条,可用 [ ] 快捷键调相位 (步进 0.05ms),⌥+[ 调 0.1ms。 实测刷新周期和锁相抖动实时显示。
这个快门链路不只放现成的 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 的算法细节在另一份报告里,
本页只讲快门链路怎么把它变成可看的画面。)
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 秒不失锁的肉眼确认(时序已通过,观感需人工)
• 物理冷启动拔插:断电重上电的固件上传流程(逻辑已实现,未做物理实验)
libnvstusb 逆向的是初代 3D Vision(0955:0007)。如果你的发射器是 3D Vision 2
(0955:0008 或其他变体),协议可能不直接覆盖,需要追加逆向,工作量会上升。
并发负载下系统抢占的最坏长尾约 13.53ms(超过一个 vsync 周期)。这个长尾不参与提前量学习, 依赖过期命令丢弃机制恢复。极端高负载下可能短暂丢几帧,但不会持续错位。
发射器固件是 NVIDIA 的知识产权。仓库和 App 都不附带固件。首次使用时 App 会提示用户
选择合法取得的 nvstusb.fw,保存到 ~/Library/Application Support/。libusb 与 LGPL
文本随 App 携带,符合许可证要求。
MacBook 内置屏虽然能跑 120Hz,但实测在 2D→3D 实时路径上左右眼差异弱。 外接 120Hz 3D Vision Ready 显示器(或 DLP-Link 投影)才是设计目标。
scripts/preflight_3dvision.sh,看到 PREFLIGHT_PASS hardware=connected。nvstusb.fw(合法提取自 NVIDIA 驱动)。之后自动加载。所有 USB 时序、协议、相位数学都已在真机闭环验证。剩下唯一待办是人工戴眼镜观感验收—— 确认左眼只见左、右眼只见右,调到双边最少串扰,连续观看 60 秒无失锁。这是物理层面的最终确认, 无法用程序替代。完成后本链路即可作为内部 3D 效果评测的标准工具使用。