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.
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.
| Work | Paid per | Scales with | First thing to try |
|---|---|---|---|
| Fragment shading | View | Pixels × overdraw | Lower framebufferScaleFactor; enable fixed foveation. |
| Draw call submission | View | Number of objects | Batch and instance; merge static geometry. |
| Vertex processing | View | Triangles | LODs, but usually after the two rows above. |
| Culling and scene update | Frame | Scene size | Nothing XR-specific; the second eye is free here. |
| Physics and animation | Frame | Simulation cost | Same 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.
- MDN — Rendering and the WebXR frame animation callback — The frame loop, views, and viewports, with a worked example.
- MDN — XRView — Reference for projectionMatrix, transform, eye, and secondary views.
- MDN — XRWebGLLayer — getViewport, framebufferScaleFactor, and fixed foveation.
- WebXR sessions — Where the viewer pose and the reference space it resolves against come from.
- WebXR layers — How to get text and video out of this framebuffer and onto the compositor.
- Lighting estimation — A reflection cube map is the one estimate expensive enough to show up here.
- Locomotion and comfort — The other half of comfort: a stable frame rate matters for the same reasons.