立体渲染

一个 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 规范里讲渲染的那一节写得意外地好读,即使你只打算用引擎,也值得扫一遍。