A headset needs the scene twice per frame, once per eye, from two cameras a few centimeters apart. Drawing everything twice doubles the draw calls of Bottlenecks. OVR_multiview2, community approved and present on both renderers here, runs each draw once for several views: the vertex shader declares num_views and picks its camera with gl_ViewID_OVR, and the draw lands in the layers of a texture array. WebXR's WebGL layers use this to render both eyes in one pass. No headset was available here, so this page renders BookNest's shelf for two eyes 0.4 units apart (exaggerated so the difference shows) and copies each layer to half of the canvas:
<canvas id="c" width="1240" height="300" style="width:620px;height:150px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<script src="webgl-helper.js"></script>
<script>
document.getElementById('c').getContext('webgl2', { antialias: false }); // blit needs this
const gl = GLH.context('c'), mv = gl.getExtension('OVR_multiview2'), W = 620, H = 300;
const { attributes, uniforms } = GLH.load(gl, `#version 300 es
#extension GL_OVR_multiview2 : require
layout(num_views = 2) in; // this shader runs once per eye
in vec3 aPosition, aNormal; uniform mat4 uEyes[2], uModel; out float vShade;
void main() { vShade = 0.75 + 0.25 * aNormal.z + 0.1 * aNormal.y;
gl_Position = uEyes[gl_ViewID_OVR] * uModel * vec4(aPosition, 1.0); }`,
`#version 300 es
precision mediump float; uniform vec3 uColor; in float vShade; out vec4 c;
void main() { c = vec4(uColor * vShade, 1.0); }`);
const layers = (format) => { // a 2-layer texture array, one per eye
gl.bindTexture(gl.TEXTURE_2D_ARRAY, gl.createTexture());
gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, format, W, H, 2);
return gl.getParameter(gl.TEXTURE_BINDING_2D_ARRAY);
};
const color = layers(gl.RGBA8), depth = layers(gl.DEPTH_COMPONENT24);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, gl.createFramebuffer());
mv.framebufferTextureMultiviewOVR(gl.DRAW_FRAMEBUFFER, gl.COLOR_ATTACHMENT0, color, 0, 0, 2);
mv.framebufferTextureMultiviewOVR(gl.DRAW_FRAMEBUFFER, gl.DEPTH_ATTACHMENT, depth, 0, 0, 2);
const shelf = GLH.shelf(gl, attributes);
uniforms.uEyes([...shelf.camera(W / H, [0.7, 0.9, 2.9]), // left and right eye cameras
...shelf.camera(W / H, [1.1, 0.9, 2.9])]);
gl.viewport(0, 0, W, H); gl.enable(gl.DEPTH_TEST);
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
shelf.draw(uniforms); // 7 draws render both eyes
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); // show each layer on half the canvas
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, gl.createFramebuffer());
for (const eye of [0, 1]) {
gl.framebufferTextureLayer(gl.READ_FRAMEBUFFER, gl.COLOR_ATTACHMENT0, color, 0, eye);
gl.blitFramebuffer(0, 0, W, H, eye * W, 0, eye * W + W, H, gl.COLOR_BUFFER_BIT, gl.NEAREST);
}
console.log('MAX_VIEWS_OVR', gl.getParameter(mv.MAX_VIEWS_OVR), '| error', gl.getError());
window.__done = true;
</script>
The seven drawElements() calls of GLH.shelf().draw() produced both views, which differ by the parallax between the eyes. MAX_VIEWS_OVR was 4 on both renderers. Two traps surfaced: blitFramebuffer() to the default, antialiased canvas failed with "Invalid operation on multisampled framebuffer", hence antialias: false; and the depth attachment must be a multiview texture array too. Headset rendering itself goes through WebXR's XRWebGLBinding; WebXR and WebXR use WebXR through Three.js 30,815 and Babylon.js 89,603 .
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
.names { display: flex; max-width: 600px; text-align: center; font: 11px monospace; }
.names div { flex: 1; padding: 4px 2px; }
p { max-width: 600px; margin: 2px 0; font: 11px monospace; }
</style>
<canvas id="c" width="1200" height="300"></canvas>
<div class="names"><div>layer 0: left eye (gl_ViewID_OVR 0)</div><div>layer 1: right eye (gl_ViewID_OVR 1)</div></div>
<p id="out"></p>
<script>
const W = 600, H = 300;
const gl = document.getElementById('c').getContext('webgl2', { antialias: false }); // blitting to the canvas needs this
const mv = gl.getExtension('OVR_multiview2');
function camera(eye, target, aspect) { // perspective x lookAt, column-major
const sub = (p, q) => p.map((c, i) => c - q[i]), unit = (v) => v.map((c) => c / Math.hypot(...v));
const cross = (p, q) => [p[1] * q[2] - p[2] * q[1], p[2] * q[0] - p[0] * q[2], p[0] * q[1] - p[1] * q[0]];
const dot = (p, q) => p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
const z = unit(sub(eye, target)), x = unit(cross([0, 1, 0], z)), y = cross(z, x);
const view = [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
const f = 1 / Math.tan(0.35), proj = [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, -1.01, -1, 0, 0, -0.2, 0];
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += proj[k * 4 + r] * view[c * 4 + k];
return out;
}
if (!mv) {
document.getElementById('out').textContent = 'OVR_multiview2 is not available here: a renderer would draw each eye separately.';
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
} else {
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
#extension GL_OVR_multiview2 : require
layout(num_views = 2) in; // this shader runs once per eye
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uEyes[2], uModel; out float vShade;
void main() { vShade = 0.75 + 0.25 * aNormal.z + 0.1 * aNormal.y;
gl_Position = uEyes[gl_ViewID_OVR] * uModel * vec4(aPosition, 1.0); }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; uniform vec3 uColor; in float vShade; out vec4 c; void main() { c = vec4(uColor * vShade, 1.0); }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
const cube = [];
for (const [n, a1, b1] of [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
[[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]])
for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]]) cube.push(...[0, 1, 2].map(i => (n[i] + a * a1[i] + b * b1[i]) / 2), ...n);
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(cube), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
// A 2-layer texture array per attachment: one layer per eye (depth must be multiview too).
function layers(format) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D_ARRAY, t);
gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, format, W, H, 2);
return t;
}
const color = layers(gl.RGBA8), depth = layers(gl.DEPTH_COMPONENT24);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, gl.createFramebuffer());
mv.framebufferTextureMultiviewOVR(gl.DRAW_FRAMEBUFFER, gl.COLOR_ATTACHMENT0, color, 0, 0, 2);
mv.framebufferTextureMultiviewOVR(gl.DRAW_FRAMEBUFFER, gl.DEPTH_ATTACHMENT, depth, 0, 0, 2);
// Two eyes 0.4 apart (exaggerated so the parallax shows).
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uEyes'), false,
[...camera([0.5, 1.2, 3.6], [0, 0.4, 0], W / H), ...camera([0.9, 1.2, 3.6], [0, 0.4, 0], W / H)]);
const colors = [[0.12, 0.37, 0.55], [0.36, 0.25, 0.6], [0.88, 0.6, 0.06], [0.25, 0.49, 0.23], [0.71, 0.27, 0.18], [0.16, 0.62, 0.56]];
const items = colors.map((c, i) => ({ c, m: [0.4, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0.6, 0, -1.25 + i * 0.5, 0.5, 0, 1] }));
items.push({ c: [0.54, 0.38, 0.25], m: [3.4, 0, 0, 0, 0, 0.1, 0, 0, 0, 0, 0.9, 0, 0, -0.05, 0, 1] });
gl.viewport(0, 0, W, H); gl.enable(gl.DEPTH_TEST);
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // clears both layers
for (const { c, m } of items) { // 7 draws render both eyes
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uModel'), false, m);
gl.uniform3fv(gl.getUniformLocation(program, 'uColor'), c);
gl.drawArrays(gl.TRIANGLES, 0, 36);
}
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null); // show each layer on half the canvas
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, gl.createFramebuffer());
for (const eye of [0, 1]) {
gl.framebufferTextureLayer(gl.READ_FRAMEBUFFER, gl.COLOR_ATTACHMENT0, color, 0, eye);
gl.blitFramebuffer(0, 0, W, H, eye * W, 0, eye * W + W, H, gl.COLOR_BUFFER_BIT, gl.NEAREST);
}
document.getElementById('out').textContent = `MAX_VIEWS_OVR ${gl.getParameter(mv.MAX_VIEWS_OVR)} | 7 draw calls for 2 views | error ${gl.getError()}`;
}
</script>