A common question is how to use THREE.js with multiple canvases. Let’s say you want to make an e-commerce site or you want to make a page with lots of 3D diagrams. At first glance it appears easy. Just make a canvas every where you want a diagram. For each canvas make a Renderer.
You’ll quickly find though that you run into problems.
- The browser limits how many WebGL contexts you can have.
- Typically that limit is around 8 of them. As soon as you create the 9th context the oldest one will be lost.
- WebGL resources can not be shared across contexts
- That means if you want to load a 10 meg model into 2 canvases and that model uses 20 meg of textures your 10 meg model will have to be loaded twice and your textures will also be loaded twice. Nothing can be shared across contexts. This also means things have to be initialized twice, shaders compiled twice, etc. It gets worse as there are more canvases.
So what’s the solution?
The solution is one canvas that fills the viewport in the background and some other element to represent each “virtual” canvas. We make a single Renderer and then one Scene for each virtual canvas. We’ll then check the positions of the virtual canvas elements and if they are on the screen we’ll tell THREE.js to draw their scene at the correct place.
With this solution there is only 1 canvas so we solve both problem 1 and 2 above. We won’t run into the WebGL context limit because we will only be using one context. We also won’t run into the sharing issues for the same reasons.
Let’s start with a simple example with just 2 scenes. First we’ll make the HTML
<canvas id="c"></canvas> <p> <span id="box" class="diagram left"></span> I love boxes. Presents come in boxes. When I find a new box I'm always excited to find out what's inside. </p> <p> <span id="pyramid" class="diagram right"></span> When I was a kid I dreamed of going on an expedition inside a pyramid and finding a undiscovered tomb full of mummies and treasure. </p>
Then we can setup the CSS maybe something like this
#c {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
z-index: -1;
}
.diagram {
display: inline-block;
width: 5em;
height: 3em;
border: 1px solid black;
}
.left {
float: left;
margin-right: .25em;
}
.right {
float: right;
margin-left: .25em;
}
We set the canvas to fill the screen and we set its z-index to -1 to make it appear behind other elements. We also need to specify some kind of width and height for our virtual canvas elements since there is nothing inside to give them any size.
Now we’ll make 2 scenes each with a light and a camera. To one scene we’ll add a cube and to another a diamond.
function makeScene(elem) {
const scene = new THREE.Scene();
const fov = 45;
const aspect = 2; // the canvas default
const near = 0.1;
const far = 5;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.z = 2;
camera.position.set(0, 1, 2);
camera.lookAt(0, 0, 0);
{
const color = 0xFFFFFF;
const intensity = 1;
const light = new THREE.DirectionalLight(color, intensity);
light.position.set(-1, 2, 4);
scene.add(light);
}
return {scene, camera, elem};
}
function setupScene1() {
const sceneInfo = makeScene(document.querySelector('#box'));
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({color: 'red'});
const mesh = new THREE.Mesh(geometry, material);
sceneInfo.scene.add(mesh);
sceneInfo.mesh = mesh;
return sceneInfo;
}
function setupScene2() {
const sceneInfo = makeScene(document.querySelector('#pyramid'));
const radius = .8;
const widthSegments = 4;
const heightSegments = 2;
const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
const material = new THREE.MeshPhongMaterial({
color: 'blue',
flatShading: true,
});
const mesh = new THREE.Mesh(geometry, material);
sceneInfo.scene.add(mesh);
sceneInfo.mesh = mesh;
return sceneInfo;
}
const sceneInfo1 = setupScene1();
const sceneInfo2 = setupScene2();
And then we’ll make a function to render each scene only if the element is on the screen. We can tell THREE.js to only render to part of the canvas by turning on the scissor test with Renderer.setScissorTest and then setting both the scissor and the viewport with Renderer.setViewport and Renderer.setScissor.
function renderSceneInfo(sceneInfo) {
const {scene, camera, elem} = sceneInfo;
// get the viewport relative position of this element
const {left, right, top, bottom, width, height} =
elem.getBoundingClientRect();
const isOffscreen =
bottom < 0 ||
top > renderer.domElement.clientHeight ||
right < 0 ||
left > renderer.domElement.clientWidth;
if (isOffscreen) {
return;
}
camera.aspect = width / height;
camera.updateProjectionMatrix();
const positiveYUpBottom = canvasRect.height - bottom;
renderer.setScissor(left, positiveYUpBottom, width, height);
renderer.setViewport(left, positiveYUpBottom, width, height);
renderer.render(scene, camera);
}