Multiview Rendering for VR

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:

Both eyes of the shelf from one set of draw calls with OVR_multiview2HTML
<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>
Browser output of Listing 3.153
Browser output of 153

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 .

Both eyes of the shelf from one set of draw calls with OVR_multiview2, each texture-array layer copied to half the canvasHTMLLive
<!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>