Making Our Scenes Responsive (and also Dealing with Jaggies)
Welcome back! The last chapter was a long one, stuffed full of mathematical and computer graphics theory. Here, we’ll slow things down a little and look at the current state of our app.
Since we created the World app a couple of chapters ago, our code is well structured and ready to be expanded as we add features over the coming chapters. Next, we switched to physically correct lighting and rendering and explained how we’ll (nearly always) build our scenes using meters for measurement. Our brains are tuned to appreciate physically correct lighting and colors, so when we set up our scenes this way a lot of the hard work of making them look great is done for us. This applies even to scenes with a cartoony or abstract look.
In the last chapter, we explored the coordinate systems and mathematical operations called transformations that are used to move objects around in 3D space. Over the next couple of chapters, we’ll use everything we have learned so far and start to create scenes that are more interesting than a single cube.
But first, take a closer look at the cube:
Closer…
Even closer…
Look closely at the cube’s edges. Can you see that they are not straight, but rather look jagged and unclean? Technically, this is called aliasing, but informally we refer to these as jaggies. Ugh…
There’s another problem. Try resizing the preview window in the editor and you’ll see that the scene does not adapt to fit the new size (the preview might refresh too fast to see this easily, in which case, try popping it out into a new window using the button). In the language of web design, our scene is not responsive. In this chapter, we’ll fix both of these issues.
Anti-Aliasing
It turns out that drawing straight lines using square pixels is hard unless the straight lines are exactly horizontal or vertical. We’ll use a technique called anti-aliasing (AA) to counter this.
Enable Anti-Aliasing
We can turn on anti-aliasing by passing a single new parameter into the WebGLRenderer
constructor. As with
the MeshStandardMaterial
, the WebGLRenderer
constructor takes a specification object with named parameters. Here, we will set the antialias
parameter to true
:
function createRenderer() {
const renderer = new WebGLRenderer({ antialias: true });
renderer.physicallyCorrectLights = true;
return renderer;
}
Note that you can’t change this setting once you have created the renderer. To change it, you need to create an entirely new renderer. That’s rarely a problem though since you’ll want this on for most scenes.
Multisample Anti-Aliasing (MSAA)
Anti-aliasing is performed using the built-in WebGL method, which is multisample anti-aliasing (MSAA). Depending on your browser and graphics card, there’s a chance this will be unavailable or disabled, although on modern hardware that’s unlikely. If your app does end up running on a device without MSAA, this setting will be ignored, but your scene will be otherwise unaffected.
MSAA is not a perfect solution, and there will be scenes that still display aliasing, even with AA enabled. In particular, scenes with many long, thin straight lines (such as wire fences, or telephone lines) are notoriously hard to remove aliasing from. If possible, avoid creating such scenes. On the other hand, some scenes look fine without AA, in which case you might choose to leave it switched off. On the powerful GPU in a laptop, you are unlikely to notice any difference in performance. However, mobile devices are a different story and you might be able to gain a few precious frames per second by disabling AA.
Other anti-aliasing techniques such as SMAA and FXAA are available as post-processing passes, as we’ll see later in the book. However, these passes are performed on the CPU, while MSAA is done on the GPU (for most devices), so you may see a drop in performance if you use another technique, again, especially on mobile devices.
Seamlessly Handling Browser Window Size Changes
Currently, our app cannot handle a user doing something as simple as rotating their phone or resizing their browser. We need to handle resizing gracefully, in an automatic manner that’s invisible to our users, and which involves a minimum of effort on our part. Unlike anti-aliasing, there’s no magic setting to fix this. However, we already have a Resizer
class, so here, we’ll extend this to reset the size whenever the window changes size. After all, that’s why we called this class a
Re-sizer in the first place.
Listen for resize
Events on the Browser Window
First, we need some way of listening to the browser and then taking action when the window’s size changes. In web-dev terminology, we want to listen for resize events. A built-in browser method called element.addEventListener
makes our work easy here. We can use this method to listen for all kinds of events, such as click
, scroll
, keypress
, and many more, on any HTML element. Whenever an event occurs, we say the event has fired. When a user clicks their mouse, the click
event will fire, when they spin their scroll wheel, the scroll
event will fire, when they resize the browser window, the resize
event will fire, and so on.
Later, we’ll use event listeners to add interactivity to our scenes. Here, we want to listen for the
resize
event, which fires whenever the browser’s window size changes. Rotating a mobile device from landscape to portrait, dragging a window between monitors on a multi-monitor setup, and resizing the browser by dragging the edges of the window with a mouse all cause the resize
event to fire, which means the code we add here will handle all of these scenarios.
If you are unfamiliar with event listeners, check out the DOM API reference in the appendices for more info.
We can listen for most events, like click
, or scroll
, on any HTML element. However, the resize
event listener must be attached to
the global window
object. There is another way of listening for resize events which works with any element: the
ResizeObserver
. However, it’s quite new and at the time of writing this chapter isn’t yet widely supported. Besides, it’s a little more work to set up, so we’ll stick with the tried and trusted resize
event for now.
Test addEventListener
in the Browser Console
Before we set up automatic resizing in our app, we’ll use the browser console to test addEventListener
and the resize
event. Open your browser console by pressing the F12 key, paste in the following code, then press Enter:
This will call the onResize
function every time the window resizes. Once you’ve entered the code, try resizing your browser while keeping an eye on the console. You should see something like the following image.
When we resize the window, the onResize
callback might get called many times. You might think you have performed a single resize, but find the resize
event has fired ten times or more. As a result, doing too much work in onResize
can cause stuttering. It’s important to keep this function simple.
Don’t do heavy calculations in the resize function.
If you find this function growing in size, you might consider using a throttling function such as
lodash’s _.throttle
to prevent it from being called too often.
Extend the Resizer Class
Now that we’ve confirmed everything works as expected, we’ll go ahead and extend the Resizer
class to automatically handle resizing. That means we need to call the sizing code in two situations: first, on load, to set the initial size, and then again whenever the size changes. So, let’s move that code into a separate function, and then call it once when our scene loads:
const setSize = (container, camera, renderer) => {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
};
class Resizer {
constructor(container, camera, renderer) {
// set initial size on load
setSize(container, camera, renderer);
}
}
export { Resizer };
Great. Now, let’s add an event listener and call setSize
again whenever the event fires.
class Resizer {
constructor(container, camera, renderer) {
// set initial size
setSize(container, camera, renderer);
window.addEventListener("resize", () => {
// set the size again if a resize occurs
setSize(container, camera, renderer);
});
}
}
Now, setSize
is called whenever the resize
event fires. However, we’re not quite done yet. If you try resizing the window now, you’ll see that the scene does expand or contract to fit the new window size. However, weird things are happening to the cube. It seems to be getting squashed and stretched instead of resizing with the window. What’s going on?
 
The camera, renderer, and <canvas>
element are all being resized correctly. However, we’re only calling .render
a single time, which draws a single frame into the canvas. When the canvas is resized, this frame is stretched to fit the new size.
Create an onResize
Hook
This means we need to generate a new frame every time the resize event fires. To do this, we need to call World.render
right after setSize
, inside the event listener in the Resizer
class. However, we’d rather not pass the entire World class into Resizer. Instead, we’ll create a Resizer.onResize
hook. This enables us to perform some custom behavior whenever a resize happens.
class Resizer {
constructor(container, camera, renderer) {
// set initial size on load
setSize(container, camera, renderer);
window.addEventListener('resize', () => {
// set the size again if a resize occurs
setSize(container, camera, renderer);
// perform any custom actions
this.onResize();
});
}
onResize() {}
}
.onResize
is an
empty method that we can customize from outside the Resizer
class.
Customize Resizer.onResize
in World
Over in World, replace the empty .onResize
with a new one that calls World.render
.
constructor(container) {
camera = createCamera();
scene = createScene();
renderer = createRenderer();
container.append(renderer.domElement);
const cube = createCube();
const light = createLights();
scene.add(cube, light);
const resizer = new Resizer(container, camera, renderer);
resizer.onResize = () => {
this.render();
};
}
With that, automatic resizing is complete.
Now that automatic resizing and antialiasing are working, our app looks much more professional. In the next chapter, we’ll set up an animation loop, which will spit out a steady stream of frames at a rate of sixty per second. Once we do that, we’ll no longer need to worry about re-rendering the frame after resizing.