立体渲染
一个 XR 帧不是把两个场景各渲染一遍。它是同一个场景剔除一次、画进同一张 framebuffer 的几个视口里 —— 而视图的数量并不总是两个。
一张 framebuffer,两个视口
房间上方那块面板是一张真的 render target,画法跟运行时画 XR 帧完全一样:同一个场景渲染两次,进同一张纹理的两个视口。把瞳距拉到 0,两半会变得一模一样;把 framebuffer 缩放拉低,就能看清你为帧率付出的是什么。
这个演示需要 WebGL,你的浏览器没有提供。下面的正文独立成篇,不看演示也能读完。
帧循环是一个「遍历视图」的循环
每一个 XR 帧你都会拿到一个 XRViewerPose,其中有用的部分是 pose.views —— 一个 XRView 数组。每个视图带着自己的变换和自己的投影矩阵,并对应会话 framebuffer 上的一块矩形区域。你这一帧要做的,就是遍历这个数组,每一项把场景画一遍。
这个数组几乎总是长度 2,但写成「一定是 2」仍然是错的。手机上的 inline 会话只报告一个视图;有些平台会为旁观者输出或录制加一个次要视图,所以 3 在真机上是会出现的值。而正确地写这个循环,一行代码都不多花。
真正不能做的,是自己算两台相机的投影矩阵。矩阵由运行时给出,而且是**不对称的** —— 镜片中心并不在单眼视野的正中间,各家头显的光学也各不相同。你从一个视场角算出来的对称视锥,在每一台设备上都会有细微的偏差,而这种偏差表现出来不是一个看得见的 bug,是眼睛累。
手写一帧的样子
这些活 three.js、Babylon 以及任何引擎都替你做了。但值得读一遍,因为它的结构解释了后面大部分性能建议。
const glLayer = new XRWebGLLayer(session, gl, {
// 缩放的是像素数,不是视场角。1.0 是运行时认为的原生分辨率;
// 低于 1.0 用清晰度换帧率,高于 1.0 的开销按平方涨。
framebufferScaleFactor: 1.0,
});
session.updateRenderState({ baseLayer: glLayer });
function onFrame(time, frame) {
const pose = frame.getViewerPose(referenceSpace);
if (!pose) return; // 追踪丢失。什么都不画,而不是画一帧旧的。
gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// 所有与视图无关的工作都在循环**外面**做一次:
// 动画、物理、按合并视锥做的剔除、上传蒙皮矩阵。
// 放进循环里就等于白白做两遍。
updateScene(time);
for (const view of pose.views) {
// 每个视图是**同一张** framebuffer 上的一块矩形,不是一个新的渲染目标。
const viewport = glLayer.getViewport(view);
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
// 用运行时给的矩阵。不要从一个 FOV 数字自己搭:
// 真实头显的投影是不对称的,而且每台设备都不一样。
drawScene(view.projectionMatrix, view.transform.inverse.matrix);
}
}注意这里没有任何 swap 或 present 调用。回调返回时运行时就去合成了 —— 这也是为什么在回调里阻塞(一次 await、一次同步回读)不是让这一帧变慢,而是直接丢掉这一帧。
真正要紧的几个词
这个领域大部分困惑都出在这五个词上,而其中三个说的是分辨率,不是几何。
- XRView.projectionMatrix
- 运行时给出的这只眼睛的投影矩阵,其中已经包含光学要求的不对称性。照原样用。从一个视场角把它重新搭出来,是那个经典错误 —— 结果是头显里的画面「差不多舒服,但就是不太对」。
- XRWebGLLayer.getViewport(view)
- 这个视图在共享 framebuffer 上占的那块矩形。大多数硬件上两只眼睛并排在同一张宽纹理里,所以你要做的是设置 viewport,而不是绑定第二个渲染目标。
- framebufferScaleFactor
- 创建 layer 时设置的 framebuffer 像素尺寸倍数。它改的是分辨率,不是视场角。开销随它的平方变化 —— 这让它成为你手上最有效的那个性能旋钮。
- 固定注视点渲染(fixed foveated rendering)
- 把每个视口的周边区域按更低分辨率渲染 —— 反正那里被光学糊掉了。通过 XRWebGLLayer.fixedFoveation 暴露,取值 0 到 1。在移动级 GPU 上几乎是白拿的画质,在 PC 串流的头显上意义不大。
- 瞳距(IPD)
- 两个视图变换之间的距离,通常 58–72 毫米,来自用户自己的设置。你从视图变换里读它,永远不去选它。写死一个值,会让所有瞳距不同的人对世界的尺度感都是错的。
为什么立体渲染不是翻倍
「两只眼睛就是两倍工作量」这个直觉在两个方向上都不对,而准确的版本恰恰告诉你该往哪优化。
所有按帧付费而不是按视图付费的工作只做一次:动画、物理、蒙皮、剔除、上传那些不随眼睛变化的 uniform。所有按绘制调用付费的工作要付两次 —— 顶点处理、状态切换,以及提交这些命令的 CPU 开销。片元着色同样付两次,而在一体机上它压倒其他一切。所以一个填充率吃紧的场景确实接近翻倍,一个受限于绘制调用的场景要便宜一些,一个受限于物理的场景几乎感觉不到第二只眼睛的存在。
这个排序解释了为什么 XR 的优化建议跟桌面游戏不一样。分辨率是你最大的杠杆,因为它乘的是最贵的那一半;其次是绘制调用数量,因为它按视图付费;三者里面数最不重要的是多边形数量 —— 而砍它通常是大家最先去做的事。
也别只按帧率做预算。头显错过一帧的截止时间,结果不只是画面卡顿 —— 合成器会把上一帧重投影,而你看到的与内耳预期之间的错位,正是导致晕动症的同一种冲突。稳定的 72Hz 远比「平均 90 但会掉」值钱。
上面这个演示在做什么
悬在房间上方那块面板不是示意图。它是一张真的 render target,每一帧场景都往里画两次 —— 每只眼相机一次,各自进同一张纹理的一个视口。中间那条橙线是两个视口的分界,不是两张图之间的缝。
物体分布在三个深度上是有意的。视差随距离衰减,所以近处那个立方体在两半之间偏移明显,环面结要小一些,远处的柱子几乎不动 —— 而这正是整件事要产生的那个深度线索。把瞳距设成 0,两半会变得逐像素一致:一个已经没有立体可言的立体渲染器。
framebuffer 缩放那个控件调整 render target 的尺寸,方式跟 framebufferScaleFactor 调整会话 framebuffer 一样。拉到 0.4,面板明显发糊,而几何、视场角、帧率全都没变 —— 这就是那个真实设置做的交换,也是它该被第一个拿来拧的原因。
有一件事桌面上演不出来:这里两个投影矩阵是对称的。真实头显的投影不是 —— 而这个差别,正是「矩阵要从运行时拿、不要自己搭」的全部理由。
帧预算花在哪
一体机上开销的大致形状。要看的是排序,不是具体数字。
| 工作 | 按什么付费 | 随什么增长 | 先试哪一招 |
|---|---|---|---|
| 片元着色 | 视图 | 像素数 × overdraw | 调低 framebufferScaleFactor;打开固定注视点渲染。 |
| 绘制调用提交 | 视图 | 物体数量 | 合批与实例化;合并静态几何。 |
| 顶点处理 | 视图 | 三角形数 | 用 LOD,但通常排在上面两行之后。 |
| 剔除与场景更新 | 帧 | 场景规模 | 没有 XR 特有的招;第二只眼睛在这里是免费的。 |
| 物理与动画 | 帧 | 模拟开销 | 跟任何实时应用一样;这不是立体渲染的问题。 |
写于 2026-09,依据的是移动级 XR GPU 的一般形状。请对自己的内容做 profile —— 排序的可靠性远高于其中任何一个具体数字。
最费时间的几个坑
前两个在桌面预览里完全看不出来,在头显里则很难受 —— 对一个 bug 来说这是最糟的组合。
- 自己搭投影矩阵
- 真实头显的视锥是不对称的,而且因设备而异。从一个 FOV 数字算出来的对称矩阵在哪都不对,而且表现出来是眼睛累,不是一个看得见的错误。
- 写死瞳距
- IPD 来自用户自己的设置。覆盖它,会让所有瞳距不同的人对世界尺度的感知都是错的。
- 默认 pose.views.length === 2
- inline 会话只报告一个视图;为旁观者输出准备的次要视图会让它变成 3。遍历这个数组吧 —— 代码并不比按下标取更长。
- 把按帧的工作放进视图循环里
- 动画、物理、剔除都属于循环外面。放进去,它们就每只眼睛跑一遍,白付一倍。
- 自己绑 framebuffer
- 各视图共用会话的 framebuffer。把每只眼睛渲染到自己的目标再 blit,等于每帧每眼多一次全分辨率拷贝。
- 在帧回调里 await
- 回调返回时运行时就去合成了。一次 await、一次同步 readPixels,不是让这一帧变慢 —— 是把这一帧丢了。
延伸阅读
WebXR 规范里讲渲染的那一节写得意外地好读,即使你只打算用引擎,也值得扫一遍。
- MDN — Rendering and the WebXR frame animation callback — 帧循环、视图与视口,附一个完整示例。
- MDN — XRView — projectionMatrix、transform、eye 与次要视图的参考。
- MDN — XRWebGLLayer — getViewport、framebufferScaleFactor 与固定注视点渲染。
- WebXR 会话 — 观察者位姿从哪来,以及它相对哪个参考空间解析。
- WebXR 图层 — 怎么把文字和视频从这张 framebuffer 里拿出来,直接交给合成器。
- 光照估计 — 反射立方体贴图是三种估计里唯一贵到会出现在这里的那个。
- 移动与舒适度 — 舒适度的另一半:稳定帧率之所以重要,是同一套原因。