Stereo rendering

An XR frame is not two renders of two scenes. It is one scene, culled once, drawn into several viewports of a single framebuffer — and the number of views is not always two.

One framebuffer, two viewports

The panel above the room is a real render target, drawn exactly the way a runtime draws an XR frame: the same scene rendered twice into two viewports of one texture. Drop the IPD to zero and the two halves become identical; drop the framebuffer scale and watch what you actually trade for frame rate.

This demo needs WebGL, which your browser did not provide. The explanation below covers the same material on its own.

Loading demo…

The frame loop is a loop over views

Every XR frame you get an XRViewerPose, and the useful part of it is pose.views — an array of XRView objects. Each view carries its own transform and its own projection matrix, and each maps to a rectangle of the session's framebuffer. Your job for the frame is to iterate that array and draw the scene once per entry.

The array is almost always length two, and writing code that assumes it is length two is still a mistake. An inline session on a phone reports one view. Some platforms add a secondary view for spectator or recording output, so three is a real value on real hardware. The loop costs nothing extra to write correctly.

What you must not do is build two cameras with your own maths. The projection matrices come from the runtime and they are asymmetric — the lens centre is not in the middle of the eye's field of view, and each headset's optics differ. A symmetric frustum you computed from a field-of-view number will be subtly wrong on every device, in a way that reads as eye strain rather than as a visible bug.

Drawing a frame by hand

This is what three.js, Babylon and every other engine do for you. It is worth reading once, because the shape of it explains most of the performance advice further down.

const glLayer = new XRWebGLLayer(session, gl, {
  // Pixels, not field of view. 1.0 is the runtime's idea of native;
  // below 1.0 trades sharpness for frame rate, above it costs quadratically.
  framebufferScaleFactor: 1.0,
});
session.updateRenderState({ baseLayer: glLayer });

function onFrame(time, frame) {
  const pose = frame.getViewerPose(referenceSpace);
  if (!pose) return;            // Tracking lost. Draw nothing, not a stale frame.

  gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

  // Everything that does not depend on the view happens ONCE, out here:
  // animation, physics, frustum culling against the combined view volume,
  // uploading skinning matrices. Doing it inside the loop doubles it.
  updateScene(time);

  for (const view of pose.views) {
    // Each view is a rectangle of the SAME framebuffer, not a new target.
    const viewport = glLayer.getViewport(view);
    gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);

    // The runtime's matrices. Do not build your own from an FOV number:
    // real headset projections are asymmetric and per-device.
    drawScene(view.projectionMatrix, view.transform.inverse.matrix);
  }
}

Note there is no swap or present call. The runtime composites when the callback returns, which is also why blocking inside it — an await, a synchronous readback — costs you the frame outright.

The vocabulary that matters

Five terms account for most of the confusion in this area, and three of them are about resolution rather than geometry.

XRView.projectionMatrix
The runtime's projection for this eye, including the asymmetry the optics require. Use it as given. Rebuilding it from a field-of-view value is the classic mistake that produces a headset image which is almost, but not quite, comfortable.
XRWebGLLayer.getViewport(view)
The rectangle of the shared framebuffer this view owns. On most hardware the two eyes are side by side in one wide texture, which is why you set a viewport rather than binding a second render target.
framebufferScaleFactor
A multiplier on the framebuffer's pixel dimensions, set when the layer is created. It changes resolution, not field of view. Cost scales with the square of it, which makes it the single most effective performance dial you have.
Fixed foveated rendering
Rendering the periphery of each viewport at lower resolution, where the optics blur it anyway. Exposed as XRWebGLLayer.fixedFoveation, from 0 to 1. Nearly free quality on mobile-class GPUs, and largely irrelevant on a PC-tethered one.
IPD / eye separation
The distance between the two view transforms, typically 58–72 mm and set from the user's own measurement. You read it from the view transforms; you never choose it. Hardcoding a value gives everyone else a subtly wrong sense of scale.

Why stereo does not cost double

The intuition that two eyes means twice the work is wrong in both directions, and the accurate version is what tells you where to optimise.

Everything that happens per frame rather than per view is paid once: animation, physics, skinning, culling, uploading uniforms that do not vary per eye. Everything that happens per draw call is paid twice — vertex processing, state changes, and the CPU cost of submitting the commands. Fragment shading is paid twice as well, and on a standalone headset it dominates everything else. So a fill-heavy scene really does cost close to double, while a draw-call-bound scene costs somewhat less, and a physics-bound one barely notices the second eye.

That ranking is why the advice for XR is not the same as the advice for a desktop game. Resolution is your biggest lever, because it multiplies the expensive half. Draw call count is next, because it is paid per view. Polygon count matters least of the three, and cutting it is usually the first thing people try.

Do not budget by frame rate alone, either. A headset that misses its frame deadline does not merely look choppy — the compositor reprojects the last frame, and the mismatch between what you see and what your inner ear expects is the same conflict that causes motion sickness. A stable 72 Hz is worth far more than an average 90 with drops.

What the demo above is doing

The panel floating over the room is not a diagram. It is a real render target, and every frame the scene is drawn into it twice — once from each eye camera, each into its own viewport of the same texture. The orange line down the middle is the boundary between the two viewports, not a gap between two images.

Objects sit at three depths on purpose. Parallax falls off with distance, so the near cube shifts noticeably between the two halves, the knot less, and the far pillars almost not at all — which is exactly the depth cue the whole exercise exists to produce. Set eye separation to zero and the two halves become pixel-identical: a stereo renderer with no stereo left in it.

The framebuffer scale control resizes the render target the way framebufferScaleFactor resizes the session's framebuffer. At 0.4 the panel is visibly soft while the geometry, the field of view and the frame rate are all unchanged — which is the trade the real setting makes, and the reason it is the first dial to reach for.

One thing the desktop cannot show: the two projection matrices here are symmetric. Real headset projections are not, and that difference is exactly why you take the matrices from the runtime instead of building them.

Where the frame budget goes

Approximate shape of the cost on a standalone headset. The point is the ranking, not the exact numbers.

WorkPaid perScales withFirst thing to try
Fragment shadingViewPixels × overdrawLower framebufferScaleFactor; enable fixed foveation.
Draw call submissionViewNumber of objectsBatch and instance; merge static geometry.
Vertex processingViewTrianglesLODs, but usually after the two rows above.
Culling and scene updateFrameScene sizeNothing XR-specific; the second eye is free here.
Physics and animationFrameSimulation costSame as any real-time app; not a stereo problem.

Written 2026-09 from the general shape of mobile-class XR GPUs. Profile your own content — the ranking holds far more reliably than any particular number.

Mistakes that cost the most time

The first two are invisible on a desktop preview and unpleasant in a headset, which is the worst combination for a bug.

Building your own projection matrices
Real headset frusta are asymmetric and device-specific. A symmetric matrix from an FOV number is wrong everywhere, and it reads as eye strain rather than as an error.
Hardcoding eye separation
IPD comes from the user's own setting. Overriding it gives everyone whose eyes are a different distance apart a wrong sense of the world's scale.
Assuming pose.views.length === 2
Inline sessions report one view; secondary views for spectator output make three real. Loop over the array — it is no more code than indexing into it.
Doing per-frame work inside the view loop
Animation, physics and culling belong outside. Inside, they run once per eye and you pay double for nothing.
Binding your own framebuffer
The views share the session's framebuffer. Rendering each eye to your own target and blitting adds a full-resolution copy per eye per frame.
Awaiting anything in the frame callback
The runtime composites when the callback returns. An await, or a synchronous readPixels, does not slow the frame down — it loses it.

Further reading

The rendering section of the WebXR spec is unusually readable, and worth skimming even if you will only ever use an engine.