HTML5 Canvas Drawing Library Exploration: Three.js

This is the sixth in an ongoing series of posts exploring various Javascript drawing libraries.

Three.js is a powerful 3d drawing library, and currently the one to beat when it comes to leveraging the latest browser capabilities. When available, it can tie in to the WebGL rendering engine (current browsers on fairly new computers) for an experience which is very close to what you would get from a native application. This demo is not that ambitious, but it should give you a basic idea of how to get started building interactive 3d applications in Javascript.

Click here to launch the demo. Give it a couple of moments to load; the Three.js library is over 300kb.

HTML

<!DOCTYPE html>
<html>
	<head>
		<title>Trigonometron built using Three.js</title>
		<style>
			* {margin:0;padding:0;}
			body {background:#ffffff;}
			#myCanvas {width:800px;height:480px;position:absolute;left:0;top:0;background:#ffffff;}
		</style>
		<script type="text/javascript" src="Three.js"></script>
	</head>
	<body>
		<div id="myCanvas"></div>
		<script type="text/javascript">
			//	Javascript goes here
		</script>
	</body>
</html>

Fairly basic stuff here. Note that the container for the app is a <div/> element, not a <canvas/> element. Now on to the good bits.

Javascript

// shim layer with setTimeout fallback set to 60 frames per second
window.requestAnimFrame = (function(){
	return  window.requestAnimationFrame	|| 
		window.webkitRequestAnimationFrame || 
		window.mozRequestAnimationFrame    || 
		window.oRequestAnimationFrame      || 
		window.msRequestAnimationFrame     || 
		function( callback ){
			window.setTimeout(callback, 1000 / 60);
		};
})();

var canvas,
	stage,
	container,
	scene,
	camera,
	renderer,
	projector,
	light,
	
	currentShape="circle",
	isPaused = true,
	isInitialized = false,
	currentHoverTarget = null;


	
var shapes = ["circle","tricuspoid","tetracuspoid","epicycloid","epicycloid 2","epicycloid 3","lissajous","lemniscate","butterfly"];
var w = 800;
var h = 480;
var VIEW_ANGLE = 45;
var ASPECT = w/h;
var NEAR = .1;
var FAR = 10000;
var centerX = -100;
var centerY = 0;
var centerZ = -150;
var radius_x = 175;
var radius_y = 175;
var radius_z = 175;
var theta = 0;
var objects = [];
var buttons = [];
var numObjects = 0;
var r2d = 180/Math.PI;
var d2r = Math.PI/180;
var orbitSteps = 240;
var orbitSpeed = Math.PI*2/orbitSteps;
var objectInterval;
var objectPosition;
var direction = 1;
var index = 0;
var xVar1 = 0;
var xVar2;
var xVar3;
var xVar4;
var startingObjects = 100;
var newX;
var newY;
var newZ = centerZ;


function init() {
	canvas = document.getElementById("myCanvas");
	scene = new THREE.Scene();
	camera = new THREE.PerspectiveCamera( VIEW_ANGLE, ASPECT, NEAR, FAR );
	camera.position.y = 0;
	camera.position.z = 500;
	scene.add( camera );
	projector = new THREE.Projector();
	renderer = new THREE.WebGLRenderer( { antialias: true } );
	renderer.setSize( w, h );
	light = new THREE.SpotLight();
	light.position.set( centerX, centerY, 160 );
	scene.add(light);
	canvas.appendChild( renderer.domElement );
	renderer.render(scene,camera);
	canvas.addEventListener( 'mousedown', onDocumentMouseDown, false );
	canvas.addEventListener( 'mousemove', onDocumentMouseMove, false );
	
	initInterface();
	initObjects();
	onEnterFrame();
	isInitialized = true;
}
onload = init;


function initInterface() {
	var xOff = 310;
	var yOff = 150;
	var zOff = -100;
	for(var i=0;i<shapes.length;i++) {
		buildTextButton(shapes[i],xOff,yOff,zOff,120,20,15);
		yOff -= 23;
	}
	yOff -= 23;
	buildTextButton("play/pause",xOff,yOff,zOff,120,20,15);

}
function buildTextButton(text,bX,bY,bZ,bW,bH,bD) {
	var materials = [];
	var co = 0xededed;
	for ( var j = 0; j < 6; j ++ ) {
		materials.push(new THREE.MeshLambertMaterial({color:co,opacity:1}));
	}
	var ctx = document.createElement('canvas');
	ctx.getContext('2d').font = '12px Courier,monospace';
	ctx.getContext('2d').fillStyle = '#000000';
	ctx.getContext('2d').textAlign="center";
	ctx.getContext('2d').fillText(text, 125,10);
	var tex = new THREE.Texture(ctx);
	tex.needsUpdate = true;
	var mat = new THREE.MeshBasicMaterial({map: tex});
	mat.transparent = true;
	var textRender = new THREE.Mesh(
		new THREE.PlaneGeometry(ctx.width, ctx.height),
		mat
	);
	textRender.doubleSided = true;
	textRender.position.x = bX+25;
	textRender.position.y = bY-70;
	textRender.position.z = bZ+8;
	scene.add(textRender);
	var c = new THREE.Mesh(
		new THREE.CubeGeometry(bW,bH,bD, 1, 1, 1,materials ),
		new THREE.MeshFaceMaterial()
	);

	c.position.x = bX;
	c.position.y = bY;
	c.position.z = bZ;
	c.overdraw = true;
	c.name = text;
	scene.add(c);
	buttons.push(c);
}
function initObjects() {
	for(var i=0;i<startingObjects;i++) {
		addObject();
	}
}
function addObject() {
	var sphereMaterial = new THREE.MeshLambertMaterial({
		color: Math.round(Math.random()*0xffffff)
	});	
	var obj = new THREE.Mesh(
	   new THREE.SphereGeometry(10,16,16),	//	radius,segments,rings
	   sphereMaterial);

	obj.position.x = centerX
	obj.position.y = centerY
	obj.position.z = centerZ;
	obj.overdraw = true;
	scene.add(obj);
	objects.push(obj);
	numObjects = objects.length;
	objectInterval = orbitSteps/numObjects;
}

function onEnterFrame() {
	requestAnimFrame(onEnterFrame);
	renderer.render(scene,camera);
	if(isPaused==true && isInitialized==true) return;
	for(var i = 0; i < numObjects; i++) {
		objectPosition = orbitSpeed*objectInterval*i;    //    each object is individually updated
		switch(currentShape) {
			case "circle":
				newX = centerX + radius_x * Math.cos(theta + objectPosition);
				newY = centerY + radius_y * Math.sin(theta + objectPosition);
				break;
			case "tricuspoid":
				newX = centerX + (radius_x*.5) * ((2 * Math.cos(theta + objectPosition)) + Math.cos(2 * (theta + objectPosition)));
				newY = centerY + (radius_y*.5) * ((2 * Math.sin(theta + objectPosition)) - Math.sin(2 * (theta + objectPosition)));
				break;
			case "tetracuspoid":
				newX = centerX + radius_x * Math.pow((Math.cos(theta + objectPosition)),3);
				newY = centerY + radius_y * Math.pow((Math.sin(theta + objectPosition)),3);
				break;
			case "epicycloid":
				newX = centerX + (radius_x*.4) * Math.cos(theta + objectPosition) - radius_x*1*(Math.cos((radius_x/radius_x + 1) * (theta + objectPosition)));
				newY = centerY + (radius_y*.4) * Math.sin(theta + objectPosition) - radius_y*1*(Math.sin((radius_y/radius_y + 1) * (theta + objectPosition)));
				break;
			case "epicycloid 2":
				newX = centerX + (radius_x*.4) * Math.cos(theta + objectPosition) - radius_x*1*(Math.cos((radius_x/radius_x + 2) * (theta + objectPosition)));
				newY = centerY + (radius_y*.4) * Math.sin(theta + objectPosition) - radius_y*1*(Math.sin((radius_y/radius_y + 2) * (theta + objectPosition)));
				break;
			case "epicycloid 3":
				newX = centerX + (radius_x*.4) * Math.cos(theta + objectPosition) - radius_x*1*(Math.cos((radius_x/radius_x + 3) * (theta + objectPosition)));
				newY = centerY + (radius_y*.4) * Math.sin(theta + objectPosition) - radius_y*1*(Math.sin((radius_y/radius_y + 3) * (theta + objectPosition)));
				break;
			case "lissajous":
				newX = centerX + radius_x * (Math.sin(3 * (theta + objectPosition) + xVar1));
				newY = centerY + radius_y * Math.sin(theta + objectPosition);
				xVar1 += .0005;
				break;
			case "lemniscate":
				newX = centerX + (radius_x*1.2) * ((Math.cos(theta + objectPosition)/(1 + Math.pow(Math.sin(theta + objectPosition),2))));
				newY = centerY + (radius_y*1.2) * (Math.sin(theta + objectPosition) * (Math.cos(theta + objectPosition)/(1 + Math.pow(Math.sin(theta + objectPosition),2))));
				break;
			case "butterfly":
				newX = centerX + (radius_x*.4) * (Math.cos(theta + objectPosition) * (Math.pow(5,Math.cos(theta+objectPosition)) - 2 * Math.cos(4 * (theta+objectPosition)) - Math.pow(Math.sin((theta+objectPosition)/12),4)));
				newY = centerY + (radius_y*.4) * (Math.sin(theta + objectPosition) * (Math.pow(5,Math.cos(theta+objectPosition)) - 2 * Math.cos(4 * (theta+objectPosition)) - Math.pow(Math.sin((theta+objectPosition)/12),4)));
				break;
			default:
				break;
	
		}
		objects[i].position.x = newX;
		objects[i].position.y = newY;
	}
	theta += (orbitSpeed*direction);
}

function onDocumentMouseMove( event ) {
	var targetObj = getIntersectedObject(event);
	if(targetObj == null) {
		document.getElementById("myCanvas").style.cursor="default";
		if(currentHoverTarget != null) {
			setObjectColor(currentHoverTarget,0xededed);
			currentHoverTarget = null;
		}
	} else {
		document.getElementById("myCanvas").style.cursor="pointer";
		if(targetObj != currentHoverTarget && currentHoverTarget != null) {
			setObjectColor(currentHoverTarget,0xededed);
		}
		setObjectColor(targetObj,0xffffff);
		currentHoverTarget = targetObj;
	}
}

function setObjectColor(thing,c) {
	for(var i=0;i<6;i++) {
		thing.geometry.materials[i].color.setHex(c);
	}
}

function onDocumentMouseDown( event ) {
	var targetObj = getIntersectedObject(event);
	if(targetObj != null) {
		if(targetObj.name=="play/pause") {
			isPaused = !isPaused;
		} else {
			currentShape = targetObj.name;
		}
	}
}
function getIntersectedObject(event) {
	event.preventDefault();
	var mouseX, mouseY;
	if(event.offsetX) {
		mouseX = event.offsetX;
		mouseY = event.offsetY;
	}
	else if(event.layerX) {
		mouseX = event.layerX;
		mouseY = event.layerY;
	}
	var vector = new THREE.Vector3(
		(mouseX / w) * 2 - 1,
		-(mouseY / h) * 2 + 1,
		0.5
	);
	projector.unprojectVector( vector, camera );
	var ray = new THREE.Ray( camera.position, vector.subSelf( camera.position ).normalize() );
	var intersects = ray.intersectObjects( buttons );
	if ( intersects.length > 0 ) {
		return intersects[0].object;
	} else {
		return null;
	}
}

Here is a line-by-line breakdown of the code.

  • 1-11 – requestAnimFrame – this is a custom function which tries to take advantage of the built-in (for new browsers) requestAnimationFrame functionality, which is meant to be used for frame-based animations (like Adobe Flash uses). Each browser uses its own variations, so this function tries each one, and if it is in a browser which does not support the functionality, it defaults to a setTimeout call.
  • 13-25 – initialize global variables. The first eight – “canvas” to “light”, are used by the Three.js to create, control, and render the 3d scene to the canvas.
  • 29-61 – initialize variables for the animation. These are not specific to Three.js. Note, however, that there are now “z” variables in addition to “x” and “y”
  • 64-86 – init() – set up 3d environment and add all of the graphic elements
    • 65 – associate the app with an HTML element
    • 66 – create a new scene for display
    • 67-70 – initialize and position a camera, relative to the scene. The camera is the “screen” through which you look at the scene
    • 71 – create a new projector
    • 72-73 – initialize the renderer, which controls how the app will be drawn to the screen. In tthe his instance, we will use the WebGL renderer
    • 74-76 – create a new light source for the scene
    • 77 – add the newly created renderer to the HTML DOM
    • 78 – draw the app to the screen
    • 79-80 – add mouse listeners
    • 82 – create the UI buttons
    • 83 – create the graphics for the animations
    • 84 – call the animation once to display everything to the screen
    • 85 – we are now initialized and ready to go
  • 87 – tell the browser to call the init() method as soon as the page finishes loading
  • 90-101 – initInterface() – create the UI elements for the app
    • 91-93 – set the initial x, y, and z positions for the UI buttons
    • 94-97 – create one text button for each element in the shapes[] array
    • 98 – add some space between the shape buttons and the play/pause button
    • 99 – create the play/pause button
  • 102-138 – buildTextButton() – create a 3d rectangle button, add text to one face, and position it on the screen
    • 103 – initialize the materials array
    • 104 – set the default color of the buttons
    • 105-107 – create a new Lambert Material for each face of the cube. Lambert materials can be lit by spotlights and cast shadows. Normal materials cannot.
    • 108-112 – create a new canvas element, add text to it, and style the text
    • 113-114 – copy the canvas element to a Texture
    • 115-116 – draw the texture to a Material
    • 117-125 – create a new display object, fill it with the Material, and position it in 3d space. In this case, it will have the same x and y coordinates as the gray box behind it, but will hover one “pixel” from the front face of the box. I am sure there are ways to draw the text directly to the face of a cube, but I have not yet discovered it. This is a hack.
    • 126-135 – create a cube display object, color it in with the materials array, and position it in 3d space
    • 136 – add the cube display object to the display list
    • 137 – add the cube to the array of buttons. This is for determining mouse interactions
  • 139-143 – initObjects() – create a number of graphic element for the animations, equal to the value of startingObjects
  • 144-160 – addObject() – create a new display object for the animations
    • 145-147 – create a new material and give it a random color
    • 148-150 – create a new Sphere display object
    • 152-154 – position the object in 3d space
    • 155 – set the material on top of the wireframe of the model, so the “edges” don’t show through
    • 156 – add the Sphere to the display list
    • 157 – add a reference to the Sphere to the array of spheres. Used for animations
    • 158-159 – update global variables to correctly set spacing of objects within the animations
  • 162-214 – onEnterFrame() – update the animation by one step
    • 163 – advance everything by one frame
    • 164 – draw everything to the screen
    • 165 – if the app is finished initializing, but is paused, do nothing
    • 166-212 – cycle through each Sphere display object in the animation and update its position on the screen. Note that this app only updates the Spheres in the x and y planes; z remains constant.
    • 213 – update the progress of the animation as a whole
  • 216-232 – onDocumentMouseMove() – called whenever the mouse is moved in the app; used to determine which button is currently being moused over
    • 217 – call getIntersectedObject() to determan which (if any) of the buttons is currently being moused over
    • 218-223 – if there is nothing being moused over…
      • 219 – set the cursor to the default
      • 220-223 – if there was an element being moused over in the last frame iterations…
        • 221 – call setObjectColor() to return the color of the rectangle to its default
        • 222 – set the currentHoverTarget to null
    • 224-231 – else if one of the buttons IS being moused over…
      • 225 – set the cursor to a pointer
      • 226-228 – if the current hover target is not the same as the one hovered over in the previous frame, yet SOMETHING was hovered over in the previous frame, set the previous target’s color to the default
      • 229 – set the color of the current target to the hover highlight color
      • 230 – set the global currentHoverTarget to equal the button which is currently being hovered over
  • 234-238 – setObjectColor() – cycle through the six faces of a button and set each face to the appropriate color
  • 240-249 – onDocumentMouseDown() – called whenever the mouse button is pressed within the app
    • 241 – find out which button, if any, was clicked
    • 242-248 – if a button was clicked on…
      • 243-244 – if it was the play/pause button, toggle the pause variable
      • 245-247 – if it was any other button, set the currentShape variable to the sppropriate value
  • 250-275 – getIntersectedObject() – determine if a mouse event happened where it might intersect a target display object
    • 251 – stop the mouse event from cascading through the rest of the DOM
    • 252 – declare coordinate variables
    • 253-260 – based on how the browser handles mouse events, determine where on the app the event occurred
    • 261-265 – create a new vector object based on the x, y, and z coordinates of the event within the app
    • 266-267 – calculate where the objects in 3d space intersect the screen (the camera) in 2d space
    • 268 – build an array of every object in the buttons[] array which intersects the mouse event
    • 269-270 – if there is more than 0 elements in this array, return the first (top) display object
    • 271-273 – if nothing is intersected, do nothing

So there you have it: The trigonometron in 3d. Well, 3d-ish. It exists in 3d space, but everything is on the same plane, so I suppose it is de-facto 2d. I have a couple of 3d demos which I will post in the next few days. All in all, figuring out the 3d stuff was less difficult than I thought it would be. My experiments last fall with Away3d and Flash certainly helped.

This will be the last library exploration for a while. Further posts will must likely be experiments using Three.js, or one of the other libraries I explored in this series of posts.

In case you missed them the previous posts in the series are here: