A while ago I developed a little demo app that generates an interactive 3D visualisation – a primitive landscape of sorts – from a number sequence generated by a Perlin Noise algorithm (don’t worry if that means nothing to you!). You can check out the app right here.
The 3D graphics are drawn in the browser DOM, using thousands of divs transformed via CSS. It turned out pretty well, but I found that the frame rate absolutely tanked when adding divs to increase the visualisation’s resolution. Browsers aren’t well equipped when it comes to transforming thousands of divs in 3D, so in pursuit of a better frame rate I decided to use this as an excuse to finally dip into WebGL, by rewriting the app using a WebGL-based renderer.
So, what is WebGL, and what’s Three.js? WebGL is a hardware-accelerated API for drawing graphics in a web browser, enabling 2D and 3D visuals to be drawn with a far higher level of performance than what you might get when using the DOM or a HTML5 canvas. In the right hands, the visual output can be more akin to a modern PC or console video game than the more simplistic animated graphics you might usually see around the web. However, WebGL can be somewhat impenetrable to newcomers, requiring an intimate knowledge of graphics programming and mathematics, so many developers add a library or framework on top to handle the heavy lifting.
And that’s where Three.js steps in, providing a relatively simple API for developing WebGL apps. There are other options of course, but I chose Three.js as there’s a wealth of demos and documentation for it out there, plus it seems like the one I see in the wild the most.
TL;DR: view the new Three.js version of my app in the embed below, or right here on Codepen.
Breaking it Down
Rather than diving straight into rebuilding the application in one go, I decided to break it down into various chunks of essential functionality required to complete the final product. Once I’d developed them all, I would then put the various components together and develop the final app. The items on my list were as follows:
Hello World: the objective was to get something simple onscreen. While researching this I discovered an amazing learning resource in the form of the tutorials at Three.js Fundamentals. I couldn’t have completed this project without it, it really was incredibly useful. I followed their Hello World tutorial and got a cube rotating onscreen pretty quickly.
Resizable canvas: by default, Three.js outputs to a 300x150px canvas element that does not resize. I needed the canvas to not only resize itself to fit the size of the browser window, but I also wanted the contents to always occupy a square area located in the centre of the canvas, regardless of window aspect ratio. This is so that the entire visualisation would be visible regardless of window aspect.
The “Responsive Design” article on Three.js Fundamentals got me most of the way there, but when viewed in a mobile-esque portrait aspect the sides of the demo spinning cube would be clipped, whereas I wanted the content to resize itself to always fit within that central square area so it would always be visible. After reading the following GitHub thread I realised I had to update the Three.js camera’s field of view (FOV) upon window resize, and after some mathematical trial and error I managed to get it working. Check out my example here, the red square represents any visible content that might be present in the scene.
Draggable rotating plane: I needed to replicate the ability to click and drag to change the viewing angle. Once I’d got something onscreen it was pretty easy to retrofit the code from the previous version of my application into a Three.js context- check it out here.
However, shortly afterwards I discovered that Three.js includes an extension called OrbitControls, which provides an orbiting camera with momentum, dollying (being able to move the camera closer or further to the target), auto rotation and more, with virtually no setup required beyond some simple configuration. As such it was a no-brainer to use it rather than my own code. You can view an OrbitControls example here.
Vertex colours: the original version uses a CSS gradient background on each transformed div as a texture. To achieve the same effect with WebGL I figured I could either use texture mapping or vertex colours, but given that there would potentially be thousands of child elements all needing a distinct texture I assumed that doing it with vertex colours would be easier and perhaps more performant (might be wrong about that though!).
BTW if you’re wondering what “vertex colours” are: a “vertex” is a point in a 3D model, such as a corner of a square. Each vertex can be given its own colour, and the renderer will interpolate a 3D model’s colour from one point to another- eg, a cylinder with blue vertices at one end and red ones at the other would have a blue-to-red gradient texture.
It was a bit tricky finding concrete info and working examples for this (a lot of the content about Three.js out there has been obsoleted by changes in its codebase), but I managed to get there after some trial and error: see my example here.
Parent and child Meshes: I needed to be able to parent the child objects that make up the landscape to the square base underneath, so that the children would rotate with the base. Three.js Fundamentals once again came to the rescue, with their Scene Graph tutorial explaining how to parent objects to other objects. The tutorial was easy to follow, plus there’s some bonus content at the end about making a tank that moves along a track.
Creating and deleting elements: in the original DOM-based version of the application, the quantity and positions of child objects would change when changing the landscape resolution setting in the controls. At the time it was easiest to simply delete all the child objects and recreate however many were required for the new resolution value. However with this new version I felt it would be more performant to implement a basic object pool.
Instead of repeatedly deleting and recreating objects, with an object pool you create an instance of every object that will ever be needed at startup. Then, you display and modify the ones that are required while deactivating the ones that aren’t. This approach improves performance, since regularly creating and deleting objects often has a heavier cost than modifying objects that are already present in the scene. The drawback is slightly longer startup time, but that isn’t a problem here. In this instance the max resolution of the visualisation is 100, so the total objects created at startup is 10,000.
You can find my basic Object Pool implementation here: enter the number of objects required into the field in the top-right. However, in the case of this application there are probably better ways to achieve this- rather than creating 10,000 individual meshes, it might be possible to use instancing or some sort of particle system to implement this more efficiently. Still got a lot of learning to do…
Modifying meshes in realtime: I needed to work out how to modify a mesh’s size and colours in realtime, since child objects would need to be modified when someone uses the app’s controls. You can see a simple example of scaling and changing vertex colours here. When changing vertex colours, don’t forget to set myGeometry.colorsNeedUpdate = true; whenever the colours need to change.
Putting it all together: I managed to complete all of the above items without too much effort. Once that was done, I assembled everything together and ended up with a version that looked more or less the same as the original DOM-based version, but with far better performance! Once I’d gotten used to the different syntax it wasn’t much more difficult than developing the original version.
Since building something like this with WebGL adds a lot more possibilities in terms of what you can do, I added some enhancements to the original version:
Fog: a basic fog effect can be added to a Three.js scene very easily- and of course, there’s a chapter about it on Three.js Fundamentals. I added a very subtle fog effect, more as a test than anything else. The only problem I encountered was that the OrbitControls “dolly” effect didn’t work well with fog, since the fog effect’s start and end points are relative to the camera position- so dollying the camera towards or away from the object would plunge it entirely into or out of the effect. To get around this I replaced camera dolly with zoom.
Removal of loading indicators: the original version was slow enough that changing a control would freeze the visualisation until the update had completed. To communicate to the user that something was happening I added loading indicators. This new WebGL-based version was so much faster that I felt I could simply remove these and have all updates happening in realtime! It still gets a bit slow when the “resolution” slider is at a high value, but not sure what I can do about that- more research needed. (Also, when you tweak the sliders it looks awesome).
Editable colours: you can change the land, sky and fog colours in this version. The colour picker provided by the library I used for the controls, dat.GUI, made this easy to implement.
3D Sky: the original version had a simple CSS gradient for its background- a flat image that doesn’t react to the rest of the visualisation. I decided that a 3D “sky” would be a good enhancement. Many videogames simulate a 3D horizon by surrounding the play area with a massive cube, textured with a cubemap (a special texture that will make the cube look like a realistic panoramic horizon) but in my case I wanted the sky’s colours to be a linear gradient, so felt a sphere would be a better fit for this.
This was a bit tricky to get right: at first I tried manipulating a sphere’s vertex colours to produce the gradient effect, by reverse-engineering this example code that more or less does exactly that. But for some reason there were slight visual artefacts at certain polygon edges that spoiled the effect slightly, so I tried a different approach.
This involved drawing a gradient to an unseen HTML5 canvas, then using that as a source texture for my sphere, and that worked more or less perfectly. Then with the sphere positioned at 0, 0, 0 in my scene I set it to resize itself on the window resize event in order to completely cover the background at any viewport size, set its material to not be affected by fog (or else it would be barely visible, if at all) and also set it to render the backs of its polygons only, so it wouldn’t obscure the landscape in front.
The procedural canvas texture gets redrawn when its colours are changed via the GUI, and any changes are applied to the sphere without needing to do anything so long as the sphere’s material.map.needsUpdate flag is set to true on each frame.
Post-processing effects: with WebGL you can add post-processing effects such as blur, noise or colour changes. This is somewhat similar to CSS filters except that unlike CSS filters you can write your own effects, so there are no limits on what you can do. I added some simple effects, using Three.js Fundamentals’ article on post processing – some bloom and visual noise – but in this instance I removed the effects from the final version as it made it look worse! Effects like these could be a good fit for a future project though.
Rendering on demand: more of a finishing touch than an enhancement, a frame will only be rendered if there is some movement in the scene, whether by user interaction or the camera moving on its own. If movement stops then rendering does too, using less power on the user’s device.
After all that, the Three.js version of my app was complete… and was it worth all the effort? Definitely, I’d say- the new version has a far higher level of performance, which means better fame rate, higher maximum landscape resolution and no “loading” indicators. Plus, it was a great way of dipping my toe into the Three.js world. The possibilities really are endless, and I’m looking forwards to learning more.
However, I’m also mindful of the drawbacks of using something a library like Three.js. The library is quite large, with a minified version weighing in at around 500k, plus GPU-accelerated graphics can be heavy on battery life. Maybe stick to using the DOM or HTML5 canvas when developing simple or frivolous animations.
Check out the final product on Codepen– give it some love if you like it or find the code useful!