The problem with multiple WebGL contexts

In web-based data visualisation applications, a common UX design is to have multiple cards (each card is a html <div> element) each containing a graphical representation of a different aspect of the data. If the plot is a view of a 3D surface, or involves thousands of data points, it is natural to use WebGL. This is most easily achieved by adding a <canvas> element as a child of the card <div> and making this a WebGL context. Unfortunately, browsers limit the number of available WebGL contexts (a limit of 16 is common), so, if you want more cards than that, another strategy is required.

Drag these spinning cubes

Using only one WebGL context with scissor

A solution is to use just one WebGL context, with a fixed <canvas> that covers the whole window. Each card then renders to only a portion of that canvas using WebGL scissor. There is a nice threejs demo of this here.

The key threejs renderer methods that are used are setViewport and setScissor:

renderer.setViewport(left, bottom, width, height);
renderer.setScissor(left, bottom, width, height);

setViewport specifies how the WebGL clip-space maps to the screen, and setScissor prevents any rendering outside of a specified box. In our case, we obtain left, bottom, width and height using the getBoundingClientRect() method of the <div> element (remember that bottom is measured from the bottom of the WebGL <canvas> whereas the element bounding rect is measured from the top of the window). The arguments of setViewport and setScissor can be the same (as above) or different if you want to cut off a portion of the scene that is outside of an enclosing container <div> (but still inside the <canvas>) - this approach is used in the demo at the start of this post.

Use a stencil as well

If we only use the scissor approach, a remaining problem is that a WebGL render that should be obscured by an overlapping <div> card will still be visible (as long as it is not covered by another overlapping WebGL viewport). This can be addressed using WebGL stencil. There is a good tutorial on how to use stencil buffers in threejs here.

For our application, every time we want to render a card we need to:

  • find which <div> elements are overlapping the current one;
  • for each overlap, add a stencil rectangle to the current threejs scene that covers the overlapping area.

The stencil rectangles are not normally shown (colorWrite=false) but they can be made visible using the the dropdown menu in the above demo. The material used for the stencil rectangles needs to have the following settings:

const stenMat = new THREE.MeshBasicMaterial({color: "yellow"});
stenMat.colorWrite = false; // don't write to the colour buffer
stenMat.depthWrite = false; // don't write to the depth buffer
stenMat.stencilWrite = true; // write to the stencil buffer
stenMat.stencilRef = 1; // reference value for the stencil test
stenMat.stencilZPass = THREE.ReplaceStencilOp; // set stencil value to 1

These settings mean that there is a 1 in the stencil buffer for each pixel of the rectangle stencil.

We also need to make sure that the material used for the boxes performs the stencil test by setting the following:

const boxMat = new THREE.MeshPhongMaterial({color: cube.colour});
boxMat.stencilWrite = true; // Perform a stencil check
boxMat.stencilRef = 1; // set stencilRef
boxMat.stencilFunc = THREE.NotEqualStencilFunc; // render if stencil value not equal to 1

These settings mean that the boxes will not be rendered in any part of the screen covered by a stencil rectangle, which is what we want!

Best of both worlds!

Using <div> elements and WebGL (one fixed <canvas> element) works well because we can use the DOM to manage the <div> locations and their depth (position in the DOM tree), and still use WebGL for speedy rendering of the data!