This article assumes you've read the article on
less code more fun
as it uses the library mentioned there so as to
unclutter the example. If you don't understand
what buffers, vertex arrays, and attributes are or when
a function named twgl.setUniforms
what it means
to set uniforms, etc... then you should probably to go further back and
read the fundamentals.
Let's say you wanted to draw multiple views of the same scene, how could we do this? One way would be to render to textures and then draw those textures to the canvas. That is certainly a valid way to do it and there are times it might be the right thing to do. But, it requires that we allocate textures, render stuff to them, then render those textures on the canvas. That means we're effectively double rendering. That might be appropriate, for example in a racing game when we want to render the view in a rear view mirror we'd render what's behind the car to a texture then use that texture to draw the rear view mirror.
Another way is to set the viewport and turn on the scissor test. This is great for situation where our views don't overlap. Even better there is no double rendering like the solution above.
In the very first article it's mentioned that we set how WebGL converts from clip space to pixel space by calling
gl.viewport(left, bottom, width, height);
The most common thing is to set to those to 0
, 0
, gl.canvas.width
, and gl.canvas.height
respectively to cover the entire canvas.
Instead we can set them to a portion of the canvas and they'll make it
so we only draw to that portion of the canvas.
WebGL clips vertices in clip space.
As we mentioned before we set gl_Position
in our vertex shader to values that go from -1 to +1 in x, y, z.
WebGL clips the triangles and lines we pass to that range. After the clipping happens then
the gl.viewport
settings are applied so for example if we used
gl.viewport(
10, // left
20, // bottom
30, // width
40, // height
);
Then a clip space value of x = -1 corresponds to pixel x = 10 and a clip space value of +1 corresponds to a pixel x = 40 (a left of 10 plus a width of 30) (Actually that's a slight over simplification, see below)
So, after clipping if we draw a triangle it would appear to fit inside the viewport.
Let's draw our 'F' from previous articles.
The vertex and fragment shaders are the same as the ones used in the articles on orthographic and perspective projection.
#version 300 es
// vertex shader
in vec4 a_position;
in vec4 a_color;
uniform mat4 u_matrix;
out vec4 v_color;
void main() {
// Multiply the position by the matrix.
gl_Position = u_matrix * a_position;
// Pass the vertex color to the fragment shader.
v_color = a_color;
}
#version 300 es
// fragment shader
precision highp float;
// Passed in from the vertex shader.
in vec4 v_color;
out vec4 outColor;
void main() {
outColor = v_color;
}
Then at init time we need to create the program and the buffers and vertex array for the 'F'
// setup GLSL programs
// compiles shaders, links program, looks up locations
const programInfo = twgl.createProgramInfo(gl, [vs, fs]);
// Tell the twgl to match position with a_position,
// normal with a_normal etc..
twgl.setAttributePrefix("a_");
// create buffers and fill with data for a 3D 'F'
const bufferInfo = twgl.primitives.create3DFBufferInfo(gl);
const vao = twgl.createVAOFromBufferInfo(gl, programInfo, bufferInfo);
And to draw let's make a function we can pass a projection matrix, a camera matrix, and a world matrix
function drawScene(projectionMatrix, cameraMatrix, worldMatrix) {
// Make a view matrix from the camera matrix.
const viewMatrix = m4.inverse(cameraMatrix);
let mat = m4.multiply(projectionMatrix, viewMatrix);
mat = m4.multiply(mat, worldMatrix);
gl.useProgram(programInfo.program);
// ------ Draw the F --------
// Setup all the needed attributes.
gl.bindVertexArray(vao);
// Set the uniforms
twgl.setUniforms(programInfo, {
u_matrix: mat,
});
// calls gl.drawArrays or gl.drawElements
twgl.drawBufferInfo(gl, bufferInfo);
}
and then let's call that function to draw the F.
function degToRad(d) {
return d * Math.PI / 180;
}
const settings = {
rotation: 150, // in degrees
};
const fieldOfViewRadians = degToRad(120);
function render() {
twgl.resizeCanvasToDisplaySize(gl.canvas);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.enable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
const near = 1;
const far = 2000;
// Compute a perspective projection matrix
const perspectiveProjectionMatrix =
m4.perspective(fieldOfViewRadians, aspect, near, far);
// Compute the camera's matrix using look at.
const cameraPosition = [0, 0, -75];
const target = [0, 0, 0];
const up = [0, 1, 0];
const cameraMatrix = m4.lookAt(cameraPosition, target, up);
// rotate the F in world space
let worldMatrix = m4.yRotation(degToRad(settings.rotation));
worldMatrix = m4.xRotate(worldMatrix, degToRad(settings.rotation));
// center the 'F' around its origin
worldMatrix = m4.translate(worldMatrix, -35, -75, -5);
drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);
}
render();
This is basically the same as the final example from the article on perspective except we're using our library to keep the code simpler.
Now let's make it draw 2 views of the 'F' side by side
by using gl.viewport
function render() {
twgl.resizeCanvasToDisplaySize(gl.canvas);
- gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.enable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
// we're going to split the view in 2
- const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
+ const effectiveWidth = gl.canvas.clientWidth / 2;
+ const aspect = effectiveWidth / gl.canvas.clientHeight;
const near = 1;
const far = 2000;
// Compute a perspective projection matrix
const perspectiveProjectionMatrix =
m4.perspective(fieldOfViewRadians, aspect, near, far);
+ // Compute an orthographic projection matrix
+ const halfHeightUnits = 120;
+ const orthographicProjectionMatrix = m4.orthographic(
+ -halfHeightUnits * aspect, // left
+ halfHeightUnits * aspect, // right
+ -halfHeightUnits, // bottom
+ halfHeightUnits, // top
+ -75, // near
+ 2000); // far
// Compute the camera's matrix using look at.
const cameraPosition = [0, 0, -75];
const target = [0, 0, 0];
const up = [0, 1, 0];
const cameraMatrix = m4.lookAt(cameraPosition, target, up);
let worldMatrix = m4.yRotation(degToRad(settings.rotation));
worldMatrix = m4.xRotate(worldMatrix, degToRad(settings.rotation));
// center the 'F' around its origin
worldMatrix = m4.translate(worldMatrix, -35, -75, -5);
+ const {width, height} = gl.canvas;
+ const leftWidth = width / 2 | 0;
+
+ // draw on the left with orthographic camera
+ gl.viewport(0, 0, leftWidth, height);
+
+ drawScene(orthographicProjectionMatrix, cameraMatrix, worldMatrix);
+ // draw on the right with perspective camera
+ const rightWidth = width - leftWidth;
+ gl.viewport(leftWidth, 0, rightWidth, height);
drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);
}
You can see above first we set the viewport to cover the left half of the canvas, draw, then we set it to cover the right half and draw. Otherwise we're drawing the same thing on both sides except we change the projection matrix.
Let's clear both sides to different colors
First, in drawScene
lets call gl.clear
function drawScene(projectionMatrix, cameraMatrix, worldMatrix) {
+ // Clear the canvas AND the depth buffer.
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
...
Then let's set clear colors before calling drawScene
const {width, height} = gl.canvas;
const leftWidth = width / 2 | 0;
// draw on left with orthographic camera
gl.viewport(0, 0, leftWidth, height);
+ gl.clearColor(1, 0, 0, 1); // red
drawScene(orthographicProjectionMatrix, cameraMatrix, worldMatrix);
// draw on left with orthographic camera
const rightWidth = width - leftWidth;
gl.viewport(leftWidth, 0, rightWidth, height);
gl.clearColor(0, 0, 1, 1); // blue
+ drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);
Oops, what happened? Why is there nothing on the left?
It turns out gl.clear
does not look at the viewport
settings. To fix this we can use the scissor test.
The scissor test lets use define a rectangle. Anything
outside that rectangle will not be affected if the scissor
test is enabled.
The scissor test is off by default. We can enable it by calling
gl.enable(gl.SCISSOR_TEST);
Like the viewport it defaults to the initial size of the canvas
but we can set with same parameters as the viewport by calling
gl.scissor
as in
gl.scissor(
10, // left
20, // bottom
30, // width
40, // height
);
So let's add those in
function render() {
twgl.resizeCanvasToDisplaySize(gl.canvas);
gl.enable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
+ gl.enable(gl.SCISSOR_TEST);
...
const {width, height} = gl.canvas;
const leftWidth = width / 2 | 0;
// draw on left with orthographic camera
gl.viewport(0, 0, leftWidth, height);
+ gl.scissor(0, 0, leftWidth, height);
gl.clearColor(1, 0, 0, 1); // red
drawScene(orthographicProjectionMatrix, cameraMatrix, worldMatrix);
// draw on left with orthographic camera
const rightWidth = width - leftWidth;
gl.viewport(leftWidth, 0, rightWidth, height);
+ gl.scissor(leftWidth, 0, rightWidth, height);
gl.clearColor(0, 0, 1, 1); // blue
drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);
}
and now it should work.
Of course you're not limited to drawing the same scene You can draw whatever you want in each view.
This is a good solution for simulating multiple canvases. Let's say you wanted to make character selection screen for a game an you want to show 3D models of each head in a list so the user can select one. Or let's say you wanted to make an e-commerce site and show 3d models of each product down the page at the same time.
The most obvious way to do this would be to put a <canvas>
each place you want to show an item. Unfortunately you'd run into a
bunch of issues.
First, Each canvas would require a different WebGL context WebGL contexts can not share resources so you'd have to compile shaders for each canvas, load textures for each canvas, upload geometry for each canvas.
Another issue is most browsers have a limit on how many simultaneous canvases they support. For many it's as low as 8. That means as soon as you create a webgl context on the 9th canvas the first canvas will lose its context.
We can work around these issues by making just 1 large canvas
that covers the entire window. We'll then put a placeholder
<div>
each place we want to draw an item. We can use
element.getBoundingClientRect
to find out where to set the viewport and scissor to
draw in that area.
This will solve both problems mentioned above. We'll only have one webgl context so we can share resources and we won't run into the context limit.
Let's make an example.
First let's make a canvas that goes in the background with some content that goes in front. First the HTML
<body>
<canvas id="canvas"></canvas>
<div id="content"></div>
</body>
Then the CSS
body {
margin: 0;
}
#content {
margin: 10px;
}
#canvas {
position: absolute;
top: 0;
width: 100vw;
height: 100vh;
z-index: -1;
display: block;
}
Now let's make a few things to draw.
// create buffers and fill with data for various things.
const bufferInfosAndVAOs = [
twgl.primitives.createCubeBufferInfo(
gl,
1, // width
1, // height
1, // depth
),
twgl.primitives.createSphereBufferInfo(
gl,
0.5, // radius
8, // subdivisions around
6, // subdivisions down
),
twgl.primitives.createTruncatedConeBufferInfo(
gl,
0.5, // bottom radius
0, // top radius
1, // height
6, // subdivisions around
1, // subdivisions down
),
].map((bufferInfo) => {
return {
bufferInfo,
vao: twgl.createVAOFromBufferInfo(gl, programInfo, bufferInfo),
};
});
Now let's make 100 html items. For each one we'll create a container div and inside will be a view and a label. The view is just an empty div where we want to draw the item.
function createElem(type, parent, className) {
const elem = document.createElement(type);
parent.appendChild(elem);
if (className) {
elem.className = className;
}
return elem;
}
function randArrayElement(array) {
return array[Math.random() * array.length | 0];
}
function rand(min, max) {
if (max === undefined) {
max = min;
min = 0;
}
return Math.random() * (max - min) + min;
}
const contentElem = document.querySelector('#content');
const items = [];
const numItems = 100;
for (let i = 0; i < numItems; ++i) {
const outerElem = createElem('div', contentElem, 'item');
const viewElem = createElem('div', outerElem, 'view');
const labelElem = createElem('div', outerElem, 'label');
labelElem.textContent = `Item ${i + 1}`;
const {bufferInfo, vao} = randArrayElement(bufferInfosAndVAOs);
const color = [rand(1), rand(1), rand(1), 1];
items.push({
bufferInfo,
vao,
color,
element: viewElem,
});
}
Let's style these items as follows
.item {
display: inline-block;
margin: 1em;
padding: 1em;
}
.label {
margin-top: 0.5em;
}
.view {
width: 250px;
height: 250px;
border: 1px solid black;
}
The items
array has a bufferInfo
, a vao
, a color
and an element
for each item. We loop over all the items one at a time and call
element.getBoundingClientRect
and use the rectangle returned to see if that element intersects
with the canvas. If it does we set the viewport and scissor to
match and then draw that object.
function render(time) {
time *= 0.001; // convert to seconds
twgl.resizeCanvasToDisplaySize(gl.canvas);
gl.enable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.SCISSOR_TEST);
// move the canvas to top of the current scroll position
gl.canvas.style.transform = `translateY(${window.scrollY}px)`;
for (const {bufferInfo, vao, element, color} of items) {
const rect = element.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > gl.canvas.clientHeight ||
rect.right < 0 || rect.left > gl.canvas.clientWidth) {
continue; // it's off screen
}
const width = rect.right - rect.left;
const height = rect.bottom - rect.top;
const left = rect.left;
const bottom = gl.canvas.clientHeight - rect.bottom - 1;
gl.viewport(left, bottom, width, height);
gl.scissor(left, bottom, width, height);
gl.clearColor(...color);
const aspect = width / height;
const near = 1;
const far = 2000;
// Compute a perspective projection matrix
const perspectiveProjectionMatrix =
m4.perspective(fieldOfViewRadians, aspect, near, far);
// Compute the camera's matrix using look at.
const cameraPosition = [0, 0, -2];
const target = [0, 0, 0];
const up = [0, 1, 0];
const cameraMatrix = m4.lookAt(cameraPosition, target, up);
// rotate the item
const rTime = time * 0.2;
const worldMatrix = m4.xRotate(m4.yRotation(rTime), rTime);
drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix, bufferInfo, vao);
}
requestAnimationFrame(render);
}
requestAnimationFrame(render);
I made the code above use a requestAnimationFrame loop
so I could animate the objects. I also passed which bufferInfo to draw
to drawScene
. The shader is just using the normals as colors to keep
the shaders simple. If I added lighting
the code would get much more complicated.
Of course you could draw whole 3D scenes or whatever for each item. As long as you set the viewport and scissor correctly and then set up your projection matrix to match the aspect of the area it should work.
One other notable thing about the code is we're moving the canvas with this line
gl.canvas.style.transform = `translateY(${window.scrollY}px)`;
Why? We could instead set the canvas to position: fixed;
in which case
it would not scroll with the page. The difference would be subtle.
The browser tries to scroll the page as smooth as possible. That might be
faster than we can draw our objects. Because of this we have 2 options.
Use a fixed position canvas
In this case if we can't update fast enough the HTML in front of the canvas will scroll but the canvas itself won't so for a few moments they will be out of sync
Move the canvas under the content
In this case if we can't update fast enough the canvas will scroll in sync with the HTML but new areas where we want stuff drawn will be blank until we get a chance to draw.
This is the solution used above
Hopefully this article gave you some ideas how to draw multiple views. We'll use these techniques in a few future articles where being able to see multiple views is useful for understanding.