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,                // 半宽,单位米
  height: 0.375,
});
quad.transform = new XRRigidTransform({ x: 0, y: 1.4, z: -1.2 });

// 顺序有意义:projection 放第一个,quad 才会画在它上面。
session.updateRenderState({ layers: [projection, quad] });

function onFrame(time, frame) {
  // 每个 layer 有自己的 framebuffer,逐帧取。
  const sub = binding.getSubImage(quad, frame);
  gl.bindFramebuffer(gl.FRAMEBUFFER, sub.framebuffer);
  gl.viewport(sub.viewport.x, sub.viewport.y,
              sub.viewport.width, sub.viewport.height);
  drawPanelContents();

  // 内容不变?画一次就够,之后跳过重绘。
  quad.needsRedraw;   // 只读:运行时需要新一帧时为 true
}

内容不变的 layer 不需要每帧重画。检查 needsRedraw 并跳过绘制,性能收益的很大一部分其实来自这里 —— 一个视频 layer 的开销是一次纹理上传,而不是一次场景渲染。

上面这个演示在做什么

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

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

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

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

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

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

内容用 layer 吗为什么
视频播放强烈建议满分辨率,而且帧直接进合成器,不经过你的渲染器。
文字密集的 UI 或文档建议细笔画能活下来。这是用户最容易一眼看出的差别。
360° 照片或视频建议用 equirect不用球面网格、不占 framebuffer 分辨率、两极采样还正确。
需要场景光照或阴影的物体不要layer 是在你这一帧之后合成的,接不到光,也不会被场景几何遮挡。
必须与世界做深度排序的东西不要layer 按列表顺序绘制,不按深度。它会直接穿过你的几何体。

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

最费时间的几个坑

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

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

延伸阅读

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