WebXR 图层

你渲染的一切都要经过同一张降采样过的 framebuffer —— 除非你把它作为 layer 直接交给合成器,那样它会在你这一帧画完之后,按原生分辨率被采样一次。

带着这个问题试一试

固定观察角度,降低主画面的分辨率比例。比较场景中的内容与独立图层示意,再改变倾斜角度。

每次只改变一个参数,对照变化阅读下方解释。这里的模拟不代表真实设备的性能或兼容性。

同一块面板,两条采样路径

同一张内容纹理,两条路。左边那块先渲进你设定的缩放倍数的 framebuffer 再放大;右边那块由合成器按原生分辨率直接采样。把缩放拖低,看哪一边先丢掉细线。

这个演示需要 WebGL,你的浏览器没有提供。下面的正文独立成篇,不看演示也能读完。

演示加载中…

屏幕上不是只有你那一帧

默认的安排是一个 projection layer:你把整个世界渲进会话的 framebuffer,合成器拿走这一张图,为镜片做畸变校正,然后显示出来。你产出的每一个像素都走这条路,包括菜单上拼出文字的那些。

而这张 framebuffer 几乎从来不是面板的原生分辨率。它先被 framebufferScaleFactor 缩放过 —— 为了保帧率常常低于 1.0 —— 然后又被镜片畸变那一趟重采样一次。边缘硬的内容(文字、一像素的细线、一帧视频)在到达眼睛之前经过了两次有损重采样,而且看得出来。

Layer 就是这条路的出口。你不再把视频贴到场景里的一个四边形上,而是把一张纹理和它在空间中的位置交给合成器,由合成器在合成时按显示器自己的分辨率采样一次。你的 framebuffer 从头到尾没碰过那些像素。

几种图层各自用在哪

它们都由 XRWebGLBinding 创建,再通过 updateRenderState 交给会话。它们跟 XR 里的其他东西一样,定位在某个参考空间里,单位是米。

XRProjectionLayer
场景层 —— 你本来就有的那个。它覆盖整个视野,是你的渲染器画进去的地方。下面这些都画在它之上或之下。
XRQuadLayer
空间里的一块平面矩形,尺寸以米给定。视频、菜单、文档、浏览器面板 —— 任何「清晰度比与场景光照融合更重要」的矩形内容,都该用它。
XRCylinderLayer
同一个想法弯成一段弧,由半径、圆心角和宽高比定义。用于那些平铺时边缘会退远的宽内容:长文本栏、影院银幕、环绕式仪表盘。
XREquirectLayer
包住观察者的球面等距柱状投影。想放 360° 照片或视频,靠它 —— 不用建球面网格,也不用为它付 framebuffer 的分辨率。
XRCubeLayer
包住观察者的立方体贴图,用于天空盒。比前几种用得少,支持面也最窄。

创建一个 quad layer

Layer 是可选特性,而且创建走的是 XRWebGLBinding,不是你平时用的那个渲染器。layers 数组里的顺序就是绘制顺序 —— 第一个在最后面。

const session = await navigator.xr.requestSession('immersive-vr', {
  // 用可选:相当多的设备和浏览器至今不支持 layers。
  optionalFeatures: ['layers'],
});

const refSpace = await session.requestReferenceSpace('local-floor');
const binding = new XRWebGLBinding(session, gl);

// 你本来就有的那个场景层。
const projection = binding.createProjectionLayer({ space: refSpace });

// 一块平面面板:尺寸以「米」计,位置在参考空间里。
const quad = binding.createQuadLayer({
  space: refSpace,
  viewPixelWidth: 1024,      // 你要画进去的那张纹理
  viewPixelHeight: 640,
  width: 0.6,                // 全宽,单位是米(默认值 1.0)
  height: 0.375,
});
quad.transform = new XRRigidTransform({ x: 0, y: 1.4, z: -1.2 });

// 顺序有意义:每个图层都画在前一个的上面。
// baseLayer 和 layers 同时设置会抛 NotSupportedError,二选一。
session.updateRenderState({ layers: [projection, quad] });

// 图层给你的是纹理,不是 framebuffer。自己建一个,反复用。
const fbo = gl.createFramebuffer();

function onFrame(time, frame) {
  // 内容没变、运行时也不需要新一帧,就什么都不做。
  if (!panelDirty && !quad.needsRedraw) return;

  // 子图像给的是 colorTexture,要挂到你自己的 framebuffer 上。
  const sub = binding.getSubImage(quad, frame);
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
                          gl.TEXTURE_2D, sub.colorTexture, 0);
  gl.viewport(sub.viewport.x, sub.viewport.y,
              sub.viewport.width, sub.viewport.height);
  drawPanelContents();
  panelDirty = false;
}

XRWebGLSubImage 没有 framebuffer 属性,只有 colorTexture 和一个可选的 depthStencilTexture,所以「挂到自己的 framebuffer 上」这一步省不掉。内容不变的 layer 不需要重画:needsRedraw 在运行时丢失了这个图层的资源时才变成 true;创建时传 isStatic: true,则是告诉合成器你只会在那些时刻去画。性能收益的大头就来自跳过绘制。

上面这个演示在做什么

内容纹理只画了一次 —— 一个标题、几行代码、一组一像素的细线、一张小表格。刻意做成高频内容,因为重采样毁掉的正是高频,而 layer 存在就是为了保护它们。换成一张渐变,两边会看起来完全一样,什么也教不了。

左边那块走 framebuffer 路线:纹理先渲进一张按缩放控件定尺寸的离屏图,再放大回面板大小。右边那块走 layer 路线:合成器直接采样原始纹理。两块面板几何相同、尺寸相同、角度相同,所以唯一的变量就是采样路径。

把缩放拖到 0.2 附近,左边的细线会整条消失,而右边纹丝不动。这个差距就是使用 layer 的全部理由 —— 而且你为了帧率把 framebufferScaleFactor 压得越狠,这个理由越硬。

把几何切成 cylinder,会看到用 layer 的另一个理由:宽内容绕着观察者弯过来之后,边缘仍然清楚可读,不会退远,而这段弧是合成器免费替你做的。

什么时候 layer 值回它的复杂度

Layer 意味着更多代码、更少灵活性。清晰度或上传开销占主导时它划算,需要与场景融合时它反而是负担。

内容用 layer 吗为什么
视频播放强烈建议满分辨率,而且帧直接进合成器,不经过你的渲染器。
文字密集的 UI 或文档建议细笔画能活下来。这是用户最容易一眼看出的差别。
360° 照片或视频建议用 equirect不用球面网格、不占 framebuffer 分辨率、两极采样还正确。
需要场景光照或阴影的物体不要layer 是在你这一帧之后合成的,接不到光,也不会被场景几何遮挡。
必须与世界做深度排序的东西不要默认情况下 layer 按列表顺序合成,后一个盖在前一个上面,所以它会直接穿过你的几何体。规范定义了一个需要主动申请的 depth-sorted-layers 特性,用于让图层之间按深度排序;在你要发行的设备上亲眼见到它被授予之前,当它不存在。

写于 2026-09。各浏览器与设备对 layer 的支持参差不齐 —— 把它当增强项,同时保留场景内贴图那条路可用。

最费时间的几个坑

前两个是让人放弃 layer、退回去用贴图四边形的主要原因 —— 而且通常没查明白为什么不对。

以为它会做深度排序
layer 按给定顺序合成,无视深度缓冲。一个本该在场景里桌子后面的 quad layer,除非你把它排在前面,否则照样画在桌子上面。
把 layers 写成必需特性
支持面并不整齐。用可选方式申请,检查创建是否成功,并保留普通贴图四边形那条路作为降级。
每帧重画静态 layer
一个没变过的菜单不需要新纹理。无视 needsRedraw,等于把性能收益的大部分扔了。
用像素给 layer 定尺寸
width / height 是参考空间里的米,viewPixelWidth / viewPixelHeight 才是纹理。搞混的结果是面板要么只有邮票大,要么有栋楼那么大。
指望它接受场景光照
layer 是在你这一帧画完之后合成的。它不会被照亮、投影、加雾,也不会被你画的任何东西遮挡。

延伸阅读

如果「会话的 framebuffer」这个说法对你还不够具体,先读立体渲染那篇 —— layer 的意义,只有在你能想象出它绕开了什么之后才成立。