Building a Rudimentary 3D Engine with SVG

Wrinklefree jQuery and HTML5

Wrinklefree JS for Hipsters

A confession

I am not really a 3D / graphics developer

this Rudimentary 3D Engine

forever

What's the story, anyway?

var testBuilding = {
	width: 800
	, length: 800
	, rpa: 1
	, rpc: 1
	, swaEaveHeight: 240
	, swcEaveHeight: 240
};

clicky.

How do I make it easier to order very detailed buildings

without requiring software keyboard interaction on the iPad?

This talk is now:

Matt didn't know about graphics programming

mildly interesting

JavaScript and metal buildings

Nuances

SVG fixes

didn't know existed

Thus,

I began my training.

Serious time

  • Widely supported and very portable
  • Kept me close to the math (and not too much else)
  • SVG makes DOM elements
  • I just finished working with Raphael.js
  • Lets you work with IE8 (VML)
  • Pretty quick to get up and running

Raphael seems simple at first

// Creates canvas 320 x 200 at 10, 50
var paper = Raphael(10, 50, 320, 200);

// Creates circle at x = 50, y = 40, with radius 10
var circle = paper.circle(50, 40, 10);

// Sets the fill attribute of the circle to red (#f00)
circle.attr("fill", "#f00");

// Sets the stroke attribute of the circle to white
circle.attr("stroke", "#fff");
  • IE9+
  • Just a bunch of paths
  • Can modify/persist/mess with the <polygon> elements

Es-Vee-Geeeee

	<div id="svg-div">
		<svg version="1.1">
			<polygon
				style="fill:rgb(43,244,113);fill-opacity:1"
				points="241.76134566328196,367 241.76134566328196,551.6185119801582 84.47296084318484,459.4990933923533 84.47296084318484,367 136.974255293421,243.752363252747 241.76134566328196,367"></polygon>
		</svg>
	</div>
  • (241.76134566328196,367)
  • (241.76134566328196,551.6185119801582)
  • (84.47296084318484,459.4990933923533)
  • (84.47296084318484,367)
  • (136.974255293421,243.752363252747)
  • (241.76134566328196,367) - close the <polygon>
var ewb = [
	[0,0,0],
	[0,0,swcZ],
	[0,swcEaveY, swcZ],
	[0, ridgeHeight, swcZ/2],
	[0,swaEaveY,0]
];
				
var testBuilding = {
	width: 800,
	length: 800,
	rpa: 1,
	rpc: 1,
	swaEaveHeight: 240,
	swcEaveHeight: 240
};
				

Make me some polygons

var shapes = createPolygonsFromBuilding(testBuilding);
var svgShapes = {};

Make me some polygons

function createPolygonsFromBuilding(building) {
	...
	var ridgeHeight = building.swaEaveHeight+building.swcEaveHeight;
	var maxDimension = building.width > building.length ? building.width : building.width;
	...

	var opacity = 1;
	return [
		new Polygon('swa', swa, randomColor(), opacity, 0),
		new Polygon('ewb', ewb, randomColor(), opacity, 0),
		new Polygon('swc', swc, randomColor(), opacity, 0),
		new Polygon('ewd', ewd, randomColor(), opacity, 0),
		new Polygon('rpa', rpa, randomColor(), opacity, 0),
		new Polygon('rpc', rpc, randomColor(), opacity, 0),
		createGround(-.5*building.length, 1.5*building.length, -1.5*building.width, .5*building.width)
	]
}

Erm.. Polygon?

function Polygon(id, coordinates, color, opacity, stroke, forceToBackground){
	this.id = id;
	this.coordinates = coordinates;
	this.color = color;
	this.opacity = opacity;
	this.stroke = stroke;
	this.forceToBackground = forceToBackground || false;
}

Color!!!

/**
 * Return a random RGB color
 * @return {[Number]} an RGB array e.g. [200, 30, 47] => [R, G, B]
 */
function randomColor() {
	var color = [];
	for (var j = 0; j < 3; j++) {
		color[j] = Math.floor(Math.random() * 255);
	}
	return color;
}

I have a camera

/**
 * Updates the camera's location
 */
function setCameraPosition(x, y, z) {
	camera = [x, y, z];
}

sets() baby!

sets() all over!

window.addEventListener("load", function() {
	...
	addSVGsFromShapes(shapes, svgShapes, svg); // populate the <svg> with <polygon>s
	...
}, false);
				
function addSVGsFromShapes(shapes, svgShapes, svg) {
	for (var i = 0; i < shapes.length; i++) {
		var shape = shapes[i];
		var poly = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
		var rgb = "rgb(" + shape.color[0] + "," + shape.color[1] + "," + shape.color[2] + ")";
		poly.setAttribute("style", "fill:" + rgb + ";fill-opacity:" + shape.opacity);
		svgShapes[shape.id] = poly;
		svg.appendChild(poly);
	}
}
				
window.addEventListener("load", function() {
	...
	rearrangePolygonsByCameraDistances(shapes, svgShapes, camera, svg);
	...
}, false);
				
function rearrangePolygonsByCameraDistances(shapes, svgShapes, camera, svg){
	var cameraDistances = {};
	for (var i = 0; i < shapes.length; i++) {
		cameraDistances[shapes[i].id] = distance(average(shapes[i].coordinates), camera);
	}

	shapes.sort(byCameraDistance);

	var length = shapes.length;
	for(var i = 0; i < length; i++){
		svg.appendChild(svgShapes[shapes[i].id]);
	}
}
				
window.addEventListener("load", function() {
	...
	requestAnimationFrame(function(){ paint(shapes, svgShapes, camera, width, height, fov); });
}, false);
function paint(shapes, svgShapes, camera, width, height, fov) {
	var len = shapes.length;

	for (var i = 0; i < len; i++) {
		var shape = shapes[i]
			, coords = shape.coordinates
			, num_coords = coords.length

			// Start at the end to close the polygon
			, point = project(coords[coords.length - 1], camera, width, height, fov)
			, svgPoints = point[0] + "," + point[1];

		for (var j = 0; j < num_coords; j++) {
			point = project(coords[j], camera, width, height, fov); // The magic!
			svgPoints += " " + point[0] + "," + point[1];
		}

		svgShapes[shape.id].setAttribute("points", svgPoints); // set the points on
	}
}
				

Projections (3D space onto the 2D screen)

function project(point, camera, width, height, fov) {
	// what's greater, the screen width or height?
	var minDimension = width - height < 0 ? width : height;

	/*
		xfov = 70;
		fov = xfov*0.0174532925; // 1 degree = 0.0174532925 radians;
	*/

	var scale = minDimension / fov
		, z_weighting = point[2] + camera[2]
		, x = (width/2 + scale * (point[0] + camera[0]) / z_weighting)
		, y = (height/2 + scale * (point[1] + camera[1]) / z_weighting);
	return [x,y];
}
				

Feeling powerful

mouse and touch events

Mousedown / touchstart

window.addEventListener("mousedown", inputStart);
window.addEventListener("touchstart", inputStart);
function inputStart(e){
	mousedown = 1;
	mouseDownX = e.pageX;
	mouseDownY = e.pageY;
}
				

Mouseup / touchend

window.addEventListener("mouseup", inputEnd);
window.addEventListener("touchend", inputEnd);
function inputEnd(e){
	mousedown = 0;
	mouseMoveX = 0;
}
				

Mousemove / touchmove

window.addEventListener("mousemove", inputMove);
window.addEventListener("touchmove", inputMove);
function inputMove(e){
	e.preventDefault();
	if(!mousedown) return;

	var _dX = e.pageX - (mouseMoveX || mouseDownX);
	mouseMoveX = e.pageX;
	if((dX > 0 && _dX < 0) || (dX < 0 && _dX > 0)){
		mouseDownX = mouseMoveX;
	}
	dX = _dX;

	rotateAllSurfaces("y", (Math.PI * dX * 2 / window_width), shapes, camera);
	rearrangePolygonsByCameraDistances(shapes, svgShapes, camera, svg);
	requestAnimationFrame(function(){ paint(shapes, svgShapes, camera, width, height, fov) });
}
				

Rotating.

rotate ALL TEH THINGS

function rotateAllSurfaces(dimension, degree, shapes, camera){
	for (var i = 0; i < shapes.length; i++) {
		rotate(degree, dimension, shapes[i].coordinates, camera[2]);
	}
}
				
function rotate(angle, axis, points) {
	if (angle == 0) return;
	var d1, d2;
	switch (axis) {
		case "x":
			d1 = 1;
			d2 = 2;
			break;
		...
	}
	var sin = Math.sin(angle),
		cos = Math.cos(angle);
	for (var i = 0; i < points.length; i++) {
		var c1 = points[i][d1],
			c2 = points[i][d2];

		// edit in-memory
		points[i][d1] = c1 * cos - c2 * sin; // new y = y*Math.cos(angle) - z*Math.sin(angle)
		points[i][d2] = c1 * sin + c2 * cos; // new z = y*Math.sin(angle) + z*Math.cos(angle)
	}
}
				

Mouse scrolling

//FF doesn't recognize mousewheel as of FF3.x
var mousewheelevt=(/Firefox/i.test(navigator.userAgent))? "DOMMouseScroll" : "mousewheel";

document.addEventListener(mousewheelevt, function(e){
	zoom += e.wheelDeltaY;
	setCameraPosition(0, testBuilding.swaEaveHeight * -1, zoom);
	requestAnimationFrame(function(){ paint(shapes, svgShapes, camera, width, height, fov) });
}, false);
				

Performance?

requestAnimationFrame()

(function() {
	var lastTime = 0, vendors = ['webkit', 'moz'];
	for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
		window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'];
		window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame'];
	}

	if (!window.requestAnimationFrame)
		window.requestAnimationFrame = function(callback, element) {
			... You must be using IE
		};

	if (!window.cancelAnimationFrame) window.cancelAnimationFrame = function(id) { clearTimeout(id); };
}());
				
  • Single reflow and repaint cycle
  • Background tabs don't run, which means less CPU, GPU, and memory usage and longer battery life.

Yeah, bro. Totes McGoats.

removes some lols

makes code faster

changes important stuff

Stop the lols!

  • Use the GPU
  • Add textures (some browsers may support this)
  • Caching / currying
  • Use HTML5 Web Workers to calculate the projections
  • Get contributors
  • Modularize the API
  • Maybe utilize CSS3D transforms for rotations? (support probably limited)

have some fun with it.

Thank You!

Questions?

https://github.com/matthiasak/3D-svg-model-viewer

@matthiasak