In the last post, we created another, basic 3D viewer for the data from our Apollonian web-service – this time using HTML5 via Three.js. In this post, we’ll extend the code to listen for keyboard events and manipulate the model according to user-input, as well as enabling feature detection of WebGL (allowing the same implementation to call into the canvas rendering code when WebGL isn’t present).
We’re introducing keyboard-based commands to enable zoom, rotate (meaning spin, although not continuously) and the change of levels:
- +
- Zoom in
- -
- Zoom out
- , or %
- Rotate left
- . or /
- Rotate right
- & or a
- Rotate up
- ( or z
- Rotate down
- 0 – 9
- Change level (0 == 10)
You’ll see some strange choices for some of the rotate keys, above: on browsers other than Chrome, the keypress event gets called with symbols representing the arrow keys (left: ‘%’ right: ‘/’ up: ‘&’ down: ‘(‘). But because of an issue with Chrome that means its doesn’t receive the keypress event for arrow keys, we’re also checking for other characters, too (left: ‘,’ right ‘.’ up: ‘a’ down: ‘z”).
For changing levels: we might also have confirmed the change of level with the user via a message-box, but in the end I felt it wasn’t too disruptive to just do it.
Before we see the code, here’s a quick demo video and a link to the page:
[Errata: I mistakenly said “level 5” in the below video instead of “level 10”, and I also should have reinforced that the view was really using WebGL rather than basic HTML5 Canvas.]
Here’s the updated HTML code:
<!doctype html>
<html>
<head>
<title>Apollonian Viewer</title>
</head>
<body>
<script
type="text/javascript"
src="http://code.jquery.com/jquery-1.7.1.js">
</script>
<script type="text/javascript" src="js/xdr.js"></script>
<script type="text/javascript" src="js/Three.js"></script>
<script type="text/javascript">
var animateWithWebGL;
var container, root = null;
var camera, scene, renderer;
var zoomScale, xRotation, yRotation;
var changingLevel = false;
init();
animate();
// Feature test for WebGL
function hasWebGL()
{
try
{
var canvas = document.createElement('canvas');
var ret =
!!(window.WebGLRenderingContext &&
(canvas.getContext('webgl') ||
canvas.getContext('experimental-webgl'))
);
return ret;
}
catch(e)
{
return false;
};
}
function init()
{
zoomScale = 1;
xRotation = 0;
yRotation = 0;
var rotInc = 0.05;
animateWithWebGL = hasWebGL();
container = document.createElement('div');
container.style.background = "#000000";
document.body.appendChild(container);
// Set the scene size (slightly smaller than the
// inner screen size, to avoid scrollbars)
var WIDTH = window.innerWidth - 25,
HEIGHT = window.innerHeight - 25;
// Set some camera attributes
var VIEW_ANGLE = 45,
ASPECT = WIDTH / HEIGHT,
NEAR = 0.1,
FAR = 200;
// Create the renderer, camera and scene
renderer =
animateWithWebGL ?
new THREE.WebGLRenderer() :
new THREE.CanvasRenderer();
camera =
new THREE.PerspectiveCamera(
VIEW_ANGLE,
ASPECT,
NEAR,
FAR
);
scene = new THREE.Scene();
// The camera starts at 0,0,0 so pull it back
camera.position.z = 4;
// Create a point light
var pointLight = new THREE.PointLight(0xFFFFFF);
// Set its position
pointLight.position.x = 2;
pointLight.position.y = 10;
pointLight.position.z = 26;
// Add to the scene
scene.add(pointLight);
// And the camera
scene.add(camera);
// Start the renderer
renderer.setSize(WIDTH, HEIGHT);
// Attach the renderer-supplied DOM element
container.appendChild(renderer.domElement);
$(document).keypress(
function (event)
{
// On Firefox we need event.which rather than keyCode
var code = event.keyCode ? event.keyCode : event.which;
switch (String.fromCharCode(code))
{
case '-': // Zoom out
zoomScale -= 0.1;
break;
case '+': // Zoom in
zoomScale += 0.1;
break;
case '=': // Reset view
zoomScale = 1;
root.rotation.x = 0;
root.rotation.y = 0;
break;
case '\'': // Rotate right
case '.':
yRotation = rotInc;
break;
case '%': // Rotate left
case ',':
yRotation = -rotInc;
break;
case '&': // Rotate up
case 'a':
xRotation = -rotInc;
break;
case '(': // Rotate down
case 'z':
xRotation = rotInc;
break;
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
var level = code - '0'.charCodeAt();
populateWithLevel(level == 0 ? 10 : level);
break;
}
}
);
populateWithLevel(10);
}
function populateWithLevel(level)
{
// Make sure we're not already changing levels
if (changingLevel)
return;
changingLevel = true;
// If we already have a level loaded, remove the
// root from the scene and delete it
if (root != null)
{
scene.remove(root);
delete root;
}
// Make sure CORS is enabled
jQuery.support.cors = true;
// Call our web-service with the appropriate level
$.ajax(
{
url:
'http://apollonian.cloudapp.net/api/spheres/1/' +
level,
crossDomain: true,
data: {},
dataType: "json",
error: function(err)
{
alert(err.statusText);
},
success: function(data)
{
// Create the spheres' materials
var materials =
[
new THREE.MeshLambertMaterial({ color: 0x000000 }),
new THREE.MeshLambertMaterial({ color: 0xFF0000 }),
new THREE.MeshLambertMaterial({ color: 0xFFFF00 }),
new THREE.MeshLambertMaterial({ color: 0x00FF00 }),
new THREE.MeshLambertMaterial({ color: 0x00FFFF }),
new THREE.MeshLambertMaterial({ color: 0x0000FF }),
new THREE.MeshLambertMaterial({ color: 0xFF00FF }),
new THREE.MeshLambertMaterial({ color: 0xA9A9A9 }),
new THREE.MeshLambertMaterial({ color: 0x808080 }),
new THREE.MeshLambertMaterial({ color: 0xD3D3D3 }),
new THREE.MeshLambertMaterial({ color: 0xFFFFFF }),
new THREE.MeshLambertMaterial({ color: 0xFFFFFF })
];
// Set up the sphere vars
var rootRad = 0.01, segs = 9, rings = 9;
// Create our root object
var sphereGeom =
new THREE.SphereGeometry(rootRad, segs, rings);
// Create the mesh from the geometry
root =
new THREE.Mesh(sphereGeom, materials[0]);
scene.add(root);
// Process each sphere, adding it to the scene
$.each(
data,
function (i, item)
{
// Get shortcuts to our JSON data
var x = item.X, y = item.Y, z = item.Z,
rad = item.R, level = item.L;
var length = Math.sqrt(x * x + y * y + z * z);
// Only add spheres near the edge of the outer one
// (and only the front half if not animating)
if (
length + rad > 0.99 &&
(animateWithWebGL || z > 0)
)
{
// Create the mesh from the geometry
var sphere =
new THREE.Mesh(sphereGeom, materials[level]);
sphere.position.x = x;
sphere.position.y = y;
sphere.position.z = z;
var scaledRad = rad / rootRad;
sphere.scale.x = scaledRad;
sphere.scale.y = scaledRad;
sphere.scale.z = scaledRad;
root.add(sphere);
}
}
);
// Draw!
renderer.render(scene, camera);
changingLevel = false;
}
}
);
}
function animate()
{
requestAnimationFrame(animate);
render();
}
function render()
{
if (root != null)
{
// Apply rotations around X and/or Y
if (xRotation != 0)
{
root.rotation.x += xRotation;
xRotation = 0;
}
if (yRotation != 0)
{
root.rotation.y += yRotation;
yRotation = 0;
}
}
// Apply the zoom
camera.position.z = 4 / zoomScale;
camera.lookAt(scene.position);
renderer.render(scene, camera);
}
</script>
</body>
</html>
A few things to note:
- The hasWebGL() function returns true if the browser supports the use of WebGL. We use the result of this function to determine whether to create and use a WebGL or a Canvas renderer.
- We use jQuery to assign a handler to the document’s keypress() event.
- We need to check event.keyCode in most browsers but event.which on Firefox.
- When changing levels we set a flag: if we kick off multiple level changes – very easy to do with the longer-loading levels – then we run the risk of stomping on our geometry list… safest to protect it with a rudimentary critical section.
That’s about all there is to it. The application itself works surprisingly well on WebGL-enabled browsers. If I use it for long enough, I can usually crash the page in Chrome (as you’ll see from the demo video), but in Firefox the page is rock solid (which leads me to be believe it’s a browser issue rather than a problem with the code).
Since publishing the last post (and this one was already pretty well baked, too), Jeff Geer has pointed me at the trackball capability in Three.js, which allows you to more simply create a user-interface to control 3D orientation of your model. In the next post we’ll take a look at integrating that – probably alongside some of the existing keypress-enabled level changing – to see what that provides.