This article is a collection of small issues you might run into using WebGL that seemed too small to have their own article.
In the browser there are effectively 2 functions that will take a screenshot.
The old one
canvas.toDataURL
and the new better one
canvas.toBlob
So you'd think it would be easy to take a screenshot by just adding some code like
<canvas id="c"></canvas>
+<button id="screenshot" type="button">Save...</button>
const elem = document.querySelector('#screenshot');
elem.addEventListener('click', () => {
canvas.toBlob((blob) => {
saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
});
});
const saveBlob = (function() {
const a = document.createElement('a');
document.body.appendChild(a);
a.style.display = 'none';
return function saveData(blob, fileName) {
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = fileName;
a.click();
};
}());
Here's the example from the article on animation with the code above added and some CSS to place the button
When I tried it I got this screenshot
Yes, it's just a blank image.
It's possible it worked for you depending on your browser/OS but in general it's not likely to work.
The issue is that for performance and compatibility reasons, by default the browser will clear a WebGL canvas's drawing buffer after you've drawn to it.
There are 3 solutions.
call your rendering code just before capturing
The code we used as a drawScene
function. It would be best to make that
code not change any state and then we could call it to render for capturing.
elem.addEventListener('click', () => {
+ drawScene();
canvas.toBlob((blob) => {
saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
});
});
call the capturing code in our render loop
In this case we'd just set a flag that we want to capture and then in the rendering loop actually do the capture
let needCapture = false;
elem.addEventListener('click', () => {
needCapture = true;
});
and then in our render loop, which is current implemented in drawScene
,
somewhere after everything has been drawn
function drawScene(time) {
...
+ if (needCapture) {
+ needCapture = false;
+ canvas.toBlob((blob) => {
+ saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
+ });
+ }
...
}
Set preserveDrawingBuffer: true
when creating the WebGL context
const gl = someCanvas.getContext('webgl2', {preserveDrawingBuffer: true});
This makes webgl not clear the canvas after compositing the canvas with the rest of the page but prevents certain possible optimizations.
I'd pick #1 above. For this particular example first I'd separate the parts of the code that update state from the parts that draw.
var then = 0;
- requestAnimationFrame(drawScene);
+ requestAnimationFrame(renderLoop);
+ function renderLoop(now) {
+ // Convert to seconds
+ now *= 0.001;
+ // Subtract the previous time from the current time
+ var deltaTime = now - then;
+ // Remember the current time for the next frame.
+ then = now;
+
+ // Every frame increase the rotation a little.
+ rotation[1] += rotationSpeed * deltaTime;
+
+ drawScene();
+
+ // Call renderLoop again next frame
+ requestAnimationFrame(renderLoop);
+ }
// Draw the scene.
+ function drawScene() {
- function drawScene(now) {
- // Convert to seconds
- now *= 0.001;
- // Subtract the previous time from the current time
- var deltaTime = now - then;
- // Remember the current time for the next frame.
- then = now;
-
- // Every frame increase the rotation a little.
- rotation[1] += rotationSpeed * deltaTime;
webglUtils.resizeCanvasToDisplaySize(gl.canvas);
...
- // Call drawScene again next frame
- requestAnimationFrame(drawScene);
}
and now we can just call drawScene
before capturing
elem.addEventListener('click', () => {
+ drawScene();
canvas.toBlob((blob) => {
saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`);
});
});
And now it should work.
If you actually check the captured image you'll see the background is transparent. See this article for a few details.
Let's say you wanted to let the user paint with an animated
object. You need to pass in preserveDrawingBuffer: true
when
you create the webgl context. This prevents the browser from
clearing the canvas.
Taking the last example from the article on animation
var canvas = document.querySelector("#canvas");
-var gl = canvas.getContext("webgl2");
+var gl = canvas.getContext("webgl2", {preserveDrawingBuffer: true});
and change the call to gl.clear
so it only clears the depth buffer
-// Clear the canvas.
-gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+// Clear the depth buffer.
+gl.clear(gl.DEPTH_BUFFER_BIT);
Note that if you were serious about making a drawing program this would not be a solution as the browser will still clear the canvas anytime we change its resolution. We're changing is resolution based on its display size. Its display size changes when the window changes size. That can include when the user downloads a file, even in another tab, and the browser adds a status bar. It also includes when the user turns their phone and the browser switches from portrait to landscape.
If you really wanted to make a drawing program you'd render to a texture.
If you're making a full page / full screen webgl app then you can do whatever
you want but often you'd like some canvas to just be a part of a larger page and
you'd like it so if the user clicks on the canvas the canvas gets keyboard input.
A canvas can't normally get keyboard input though. To fix that set the
tabindex
of the canvas to 0 or more. Eg.
<canvas tabindex="0"></canvas>
This ends up causing a new issue though. Anything that has a tabindex
set
will get highlighted when it has the focus. To fix that set its focus CSS outline
to none
canvas:focus {
outline:none;
}
To demonstrate here are 3 canvases
<canvas id="c1"></canvas>
<canvas id="c2" tabindex="0"></canvas>
<canvas id="c3" tabindex="1"></canvas>
and some css just for the last canvas
#c3:focus {
outline: none;
}
Let's attach the same event listeners to all of them
document.querySelectorAll('canvas').forEach((canvas) => {
const ctx = canvas.getContext('2d');
function draw(str) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(str, canvas.width / 2, canvas.height / 2);
}
draw(canvas.id);
canvas.addEventListener('focus', () => {
draw('has focus press a key');
});
canvas.addEventListener('blur', () => {
draw('lost focus');
});
canvas.addEventListener('keydown', (e) => {
draw(`keyCode: ${e.keyCode}`);
});
});
Notice you can't get the first canvas to accept keyboard input. The second canvas you can but it gets highlighted. The 3rd canvas has both solutions applied.
A common question is how to make a WebGL animation be the background of a webpage.
There are 2 obvious ways.
position
to fixed
as in#canvas {
position: fixed;
left: 0;
top: 0;
z-index: -1;
...
}
and set z-index
to -1.
A small disadvantage to this solution is your JavaScript must integrate with the page and if you have a complex page then you need to make sure none of the JavaScript in your webgl code conflicts with the JavaScript doing other things in the page.
iframe
This is the solution used on the front page of this site.
In your webpage just insert an iframe, for example
<iframe id="background" src="background.html"></iframe>
<div>
Your content goes here.
</div>
Then style the iframe to fill the window and be in the background
which is basically the same code as we used above for the canvas
except we also need to set border
to none
since iframes have
a border by default.
#background {
position: fixed;
width: 100vw;
height: 100vh;
left: 0;
top: 0;
z-index: -1;
border: none;
pointer-events: none;
}