Discover three.js is now open source!
Word Count:2100, reading time: ~10minutes

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:

renderer.js: Enable antialiasing
    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:

Paste this code into your browser console then resize the page
    
function onResize() {
console.log('You resized the browser window!');
}

window.addEventListener('resize', onResize);

  

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:

Resizer.js: move the sizing code into a setSize function and call it on load
    


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.

Resizer.js: set up the event listener
    


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);
    });
  }
}



  
Cube abuse!

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?

Oh, the humanity!

 

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.

Resizer.js: an empty onResize method for custom resizing behavior
    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.

World.js: customise Resizer.onResize
      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.

Challenges

Import Style
Selected Texture