When we updated the Phyramid website in August 2014, we created a header background to go with our identity, employing geometric shapes. The background, pictured below, was made using 3DS Max — we displaced a segmented plane's vertices on all axes, added a directional light then placed a camera above the scene.

This render served us well, but we thought it would be much cooler to render the whole thing inside the browser in the first place. Not only would this look nicer, but we could also take advantage of the medium's interactivity. So that's what we did.

A quick note on the coding style: we initially opted to use a functional programming style exclusively, which improved clarity. However, due to the nature of the web and the presence of state mutation everywhere, we ended up switching to OOP.

We opted to use the wonderful three.js and recreate the relatively simple scene instead of exporting it from 3DS Max. Here's how we did it, covering every aspect from the model to the interactivity and ensuring touch controls work without disrupting the user experience.

Diving into the code

Creating the terrain

The first step was to create the main part of the scene. In order to do this, we created a plane with 100x100 segments then displaced the vertices randomly as we did in 3DS Max (but in code this time). It was important to set geometry.dynamic = true and geometry.normalsNeedUpdate = true , so that three.js knows that the vertices will change and that it should recalculate the lighting based on that. If we wouldn't have added it, three.js wouldn't have caught up with the changes to the vertices and the terrain would have just been a plane.

var makePlaneGeometry = function(width, height, widthSegments, heightSegments) { var geometry = new THREE.PlaneGeometry(width, height, widthSegments, heightSegments); var X_OFFSET_DAMPEN = 0.5; var Y_OFFSET_DAMPEN = 0.1; var Z_OFFSET_DAMPEN = 0.1; var randSign = function() { return (Math.random() > 0.5) ? 1 : -1; }; for (var vertIndex = 0; vertIndex < geometry.vertices.length; vertIndex++) { geometry.vertices[vertIndex].x += Math.random() / X_OFFSET_DAMPEN * randSign(); geometry.vertices[vertIndex].y += Math.random() / Y_OFFSET_DAMPEN * randSign(); geometry.vertices[vertIndex].z += Math.random() / Z_OFFSET_DAMPEN * randSign(); } geometry.dynamic = true; geometry.computeFaceNormals(); geometry.computeVertexNormals(); geometry.normalsNeedUpdate = true; return geometry; }; var makePlane = function(geometry) { var material = new THREE.MeshBasicMaterial({color: 0x00576b, wireframe: true}); var plane = new THREE.Mesh(geometry, material); return plane; }; var init = function(container, viewWidth, viewHeight) { var scene = makeScene(); // (...) var plane = makePlane(makePlaneGeometry(400, 400, 100, 100)); scene.add(plane); // (...) };

Playing around with a wireframe

A simple wireframe material helped visualize the model:

var material = new THREE.MeshBasicMaterial({color: 0x00576b, wireframe: true});

TrackballControls.js was used to let us move around the scene (stick around to find out how we then coded the controls from scratch). And here's what we ended up with:

Pretty cool, but a little unpolished. Let's add a real material and some lighting.

Adding materials and lighting

To get the look we wanted, ambient occlusion was needed. Additionally, the model's edges needed to be visible, with no smoothing applied. A Lambert material with flat shading was thus perfect:

var material = new THREE.MeshLambertMaterial({color: 0xffffff, shading: THREE.FlatShading});

Two lights were used. The first one, an ambient light, was placed to light the scene uniformly. The second light, a directional light, created all the cool shadows needed to give the model a polygonal look.

var makeLights = function() { var ambientLight = new THREE.AmbientLight(0x1a1a1a); this.scene.add(ambientLight); var dirLight = new THREE.DirectionalLight(0xdfe8ef, 0.09); dirLight.position.set(5, 2, 1); this.scene.add(dirLight); };

Positioning the camera

We wanted to point the camera down at the plane at a sort of 45-degree angle, which is pretty simple. After playing around, an angle of 75 degrees seemed to go best with the whole "looking down from a mountaintop" sort of thing (albeit a very geometric mountain).

camera = new THREE.PerspectiveCamera(fov, aspectRatio, 0.1, 1000); camera.up = new THREE.Vector3(0, 1, 0); camera.rotation.x = 75 * Math.PI / 180; camera.position.z = zPos; v

However, the field of view proved a little problematic, as for very wide canvases such as the smaller navbar on the "About" page, things would start to look weird, as if you were adjusting your FOV to 180 in Quake or something. We wrote some code to (very crudely) calculate the field of view based on the aspect ratio.

Adding fog and alpha

Things were starting to look close to our original image, with one big problem. The ends of the plane were visible. Here is an exaggerated example, with the camera pointing down to illustrate the issue.

Our initial approach was to make the plane into a sphere, and place the camera at (0,0,0), inside the sphere. While this seemed to solve the problem at first, the terrain just didn't have the same look to it, and seemed to bunch up at the sphere's poles.

The solution to this was to add exponential fog, which I think is really kickass. After enabling alpha, the fog started to blend in with our background color, for a seriously cool effect.

var renderer = new THREE.WebGLRenderer({antialiasing: true, alpha: true}); scene.fog = new THREE.FogExp2(0x222228, 0.003);

Here is a picture with the fog effect accentuated:

Interactivity (part one - mouse events)

At this point the scene looked just right, but the controls weren't good enough. TrackballControls allows you to move around the scene at will, but we only wanted users to be able to rotate the plane around the Z axis, to prevent them from doing silly stuff like looking at the underside of the plane. We decided to write the controls from scratch, loosely based on the three.js spinning cube demo.

When the user moves the mouse, autorotation should be turned off, and we should remember the distance that the mouse has been moved so we can add it to the object's Z rotation on the next frame.

var registerMouseMove = function(event) { this.autorotation = false; var mouseXOnMouseMove = event.clientX - (this.width / 2); var MOUSE_MOVE_DAMPENING = 0.0075; this.targetRotation = this.targetRotationOnMouseDown + (mouseXOnMouseMove - this.mouseXOnMouseDown) * MOUSE_MOVE_DAMPENING; };

A click event listener is also required, so that movements are only registered if the user is holding down the mouse button (and obviously to remember the initial position of the mouse so that the distance can be calculated).

var registerMouseDown = function(event) { startMouseMovementDetection(); this.mouseXOnMouseDown = event.clientX - (this.width / 2); this.targetRotationOnMouseDown = this.targetRotation; };

All that was left was to actually rotate the object:

if (this.autorotation) { this.object.rotation.z += OBJECT_AUTOROTATION_AMOUNT; } else { this.object.rotation.z -= (this.targetRotation + this.object.rotation.z) * TARGET_ROTATION_DAMPENING; }

We also added a speed threshold — if the object is being "moved" slowly enough, we assume that it's just noise or leftover speed from the last drag, so that we can set the rotation method back to autorotation.

if (Math.abs(this.targetRotation + this.object.rotation.z) < OBJECT_ROTATION_THRESHOLD) { this.autorotation = true; }

Interactivity (part two - touch events)

Almost done! We need touch controls too. These work in pretty much the same way as the mouse controls.

var registerTouchDown = function(event) { if (event.touches.length === 1) { this.mouseXOnMouseDown = event.touches[0].pageX - (this.width / 2); this.mouseYOnMouseDown = event.touches[0].pageY - (this.height / 2); this.targetRotationOnMouseDown = this.targetRotation; } }

But we had a problem. On devices with touchscreens, the gesture to move around the scene was the same as the scroll gesture. This made for some really bad UX, as we were practically disabling the scroll event.

To fix this, we checked the drag's direction. If it was predominantly horizontal, we took control of the event to spin the plane. If it was mostly vertical, we did nothing and allowed the default scroll event to proceed.

function registerTouchMove(event) { if (event.touches.length === 1) { var MOUSE_MOVE_DAMPENING = 0.01; this.autorotation = false; var mouseXOnMouseMove = event.touches[0].pageX - (this.width / 2); var mouseYOnMouseMove = event.touches[0].pageY - (this.height / 2); var xDiff = mouseXOnMouseMove - this.mouseXOnMouseDown; var yDiff = mouseYOnMouseMove - this.mouseYOnMouseDown; if (Math.abs(xDiff) > Math.abs(yDiff)) { event.preventDefault(); this.targetRotation = this.targetRotationOnMouseDown + xDiff * MOUSE_MOVE_DAMPENING; } } }

Updating dimensions on resize

Last but not least, we need to dynamically update everything when the user's browser window is resized.