WebXR layers
Everything you render goes through one downsampled framebuffer — unless you hand it to the compositor directly as a layer, in which case it is sampled once, at full resolution, after your frame is done.
The same panel, two sampling paths
One content texture, two routes. The left panel is rendered into a framebuffer at the scale you choose and then magnified; the right one is sampled by the compositor at its native resolution. Drag the scale down and watch which side loses its thin rules first.
This demo needs WebGL, which your browser did not provide. The explanation below covers the same material on its own.
Your frame is not the only thing on screen
The default arrangement is a single projection layer: you render the whole world into the session framebuffer, the compositor takes that one image, warps it for the lenses, and shows it. Every pixel you produce takes that path, including the ones that spell out text on a menu.
That framebuffer is almost never at native panel resolution. It is scaled by framebufferScaleFactor, often below 1.0 to hold frame rate, and it is then resampled again by the lens distortion pass. Content with hard edges — text, thin rules, a video frame — goes through two lossy resamples before it reaches the eye, and it looks it.
Layers are the escape hatch. Instead of drawing a video onto a quad inside your scene, you hand the compositor a texture and a position in space, and it samples that texture once, at the display's own resolution, at composite time. Your framebuffer never touches those pixels.
The layer types, and what each is for
All of them are created from an XRWebGLBinding and handed to the session through updateRenderState. They are positioned in a reference space, in metres, like anything else in XR.
- XRProjectionLayer
- The scene layer — the one you already have. It covers the full field of view and is what your renderer draws into. Everything below is drawn on top of, or behind, it.
- XRQuadLayer
- A flat rectangle in space, given a width and height in metres. The right choice for video, a menu, a document, a browser panel — anything rectangular whose sharpness matters more than its integration with scene lighting.
- XRCylinderLayer
- The same idea bent around an arc, defined by a radius, a central angle and an aspect ratio. For wide content that would otherwise recede at the edges: a long text column, a cinema screen, a wraparound dashboard.
- XREquirectLayer
- An equirectangular projection on a sphere around the viewer. This is how you show a 360° photo or video without building a sphere mesh and without paying framebuffer resolution for it.
- XRCubeLayer
- A cubemap around the viewer, for skyboxes. Less commonly used than the others, and the least widely supported.
Creating a quad layer
Layers are an optional feature and their creation goes through XRWebGLBinding rather than the renderer you normally use. The ordering in the layers array is the draw order — first is furthest back.
const session = await navigator.xr.requestSession('immersive-vr', {
// Optional: plenty of devices and browsers still do not support layers.
optionalFeatures: ['layers'],
});
const refSpace = await session.requestReferenceSpace('local-floor');
const binding = new XRWebGLBinding(session, gl);
// The scene layer you already had.
const projection = binding.createProjectionLayer({ space: refSpace });
// A flat panel, sized in METRES, positioned in the reference space.
const quad = binding.createQuadLayer({
space: refSpace,
viewPixelWidth: 1024, // the texture you will draw into
viewPixelHeight: 640,
width: 0.6, // half-width in metres
height: 0.375,
});
quad.transform = new XRRigidTransform({ x: 0, y: 1.4, z: -1.2 });
// Order matters: the projection layer goes first, so the quad draws over it.
session.updateRenderState({ layers: [projection, quad] });
function onFrame(time, frame) {
// Each layer has its own framebuffer, fetched per frame.
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();
// Static content? Draw it once and set this, then skip the redraw.
quad.needsRedraw; // read-only: true when the runtime needs a new frame
}A layer whose content does not change need not be redrawn every frame. Checking needsRedraw and skipping the draw is where a lot of the performance benefit actually comes from — a video layer costs one texture upload, not a scene render.
What the demo above is doing
One content texture is drawn once — a heading, some code lines, a set of one-pixel rules and a small table. Deliberately high-frequency content, because that is what resampling destroys and what layers exist to protect. A gradient would look identical on both sides and would teach nothing.
The left panel takes the framebuffer path: the texture is rendered into an offscreen buffer sized by the scale control, then magnified back up to panel size. The right panel takes the layer path: the compositor samples the original texture directly. Both panels are the same geometry at the same size and angle, so the only variable is the sampling route.
Drop the scale toward 0.2 and the thin rules on the left disappear entirely while the right side is untouched. That gap is the whole argument for layers, and it is also why the argument gets stronger the harder you push framebufferScaleFactor down for frame rate.
Switching the geometry to cylinder shows the other reason to reach for a layer: wide content bent around the viewer stays legible at its edges instead of receding, and the compositor does that curve for free.
When a layer earns its complexity
Layers are more code and less flexibility. They pay off where sharpness or upload cost dominates, and cost you where scene integration does.
| Content | Use a layer? | Why |
|---|---|---|
| Video playback | Yes, strongly | Full resolution, and the frame goes straight to the compositor instead of through your renderer. |
| Text-heavy UI or a document | Yes | Thin strokes survive. This is the single most visible difference a user will notice. |
| 360° photo or video | Yes, equirect | No sphere mesh, no framebuffer cost, correct sampling at the poles. |
| Objects that need scene lighting or shadows | No | A layer is composited after your frame; it cannot receive light or be occluded by scene geometry. |
| Anything that must be depth-sorted with the world | No | Layers draw in list order, not by depth. A layer will punch through your geometry. |
Written 2026-09. Layer support varies by browser and device — treat it as an enhancement and keep the in-scene path working.
Mistakes that cost the most time
The first two are the ones that make people give up on layers and go back to textured quads, usually without finding out why it looked wrong.
- Expecting depth sorting
- Layers composite in the order given, ignoring the depth buffer. A quad layer behind a table in your scene will still draw on top of it unless you order it first.
- Requiring the layers feature
- Support is uneven. Request it optionally, check whether creation succeeded, and keep the ordinary textured-quad path as the fallback.
- Redrawing static layers every frame
- A menu that has not changed does not need a new texture. Ignoring needsRedraw throws away most of the performance win.
- Sizing a layer in pixels
- width and height are metres in the reference space; viewPixelWidth and viewPixelHeight are the texture. Confusing them produces a panel either the size of a stamp or the size of a building.
- Expecting scene lighting
- A layer is composited after your frame is finished. It cannot be lit, shadowed, fogged, or occluded by anything you drew.
Further reading
Read the stereo rendering material first if the phrase "the session framebuffer" is not yet concrete — layers only make sense once you can picture what they are bypassing.
- W3C — WebXR Layers API Level 1 — Normative definitions of every layer type, XRWebGLBinding, and subimages.
- MDN — XRWebGLBinding — The factory for layers, and how to get a layer's framebuffer each frame.
- MDN — XRQuadLayer — Reference for the flat panel case, including transform and sizing.
- Stereo rendering — What the framebuffer is, and why framebufferScaleFactor is your biggest performance dial.
- Environment blend modes — The other compositor-side property that changes what you should draw.