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 的意义,只有在你能想象出它绕开了什么之后才成立。
- W3C — WebXR Layers API Level 1 — 各类图层、XRWebGLBinding 与 subimage 的规范性定义。
- MDN — XRWebGLBinding — 创建图层的工厂,以及每帧怎么取到某个图层的 framebuffer。
- MDN — XRQuadLayer — 平面面板那一支的参考,含 transform 与尺寸。
- 立体渲染 — framebuffer 到底是什么,以及为什么 framebufferScaleFactor 是最大的性能旋钮。
- 环境混合模式 — 合成器那一侧的另一个属性,它同样改变你该画什么。