text: function draw(ctx, width, height, viewScale) {
  	PERF_START('draw');
  
  	const halfW = width / 2;
  	const halfH = height / 2;
  
  
  	// 3D Polys
  	// ---------------
  	ctx.lineJoin = 'bevel';
  
  	PERF_START('drawShadows');
  	ctx.fillStyle = shadowColor;
  	ctx.strokeStyle = shadowColor;
  	allShadowPolys.forEach(p => {
  		if (p.wireframe) {
  			ctx.lineWidth = 2;
  			ctx.beginPath();
  			const { vertices } = p;
  			const vCount = vertices.length;
  			const firstV = vertices[0];
  			ctx.moveTo(firstV.x, firstV.y);
  			for (let i=1; i<vCount; i++) {
  				const v = vertices[i];
  				ctx.lineTo(v.x, v.y);
  			}
  			ctx.closePath();
  			ctx.stroke();
  		} else {
  			ctx.beginPath();
  			const { vertices } = p;
  			const vCount = vertices.length;
  			const firstV = vertices[0];
  			ctx.moveTo(firstV.x, firstV.y);
  			for (let i=1; i<vCount; i++) {
  				const v = vertices[i];
  				ctx.lineTo(v.x, v.y);
  			}
  			ctx.closePath();
  			ctx.fill();
  		}
  	});
  	PERF_END('drawShadows');
  
  	PERF_START('drawPolys');
  
  	allPolys.forEach(p => {
  		if (!p.wireframe && p.normalCamera.z < 0) return;
  
  		if (p.strokeWidth !== 0) {
  			ctx.lineWidth = p.normalCamera.z < 0 ? p.strokeWidth * 0.5 : p.strokeWidth;
  			ctx.strokeStyle = p.normalCamera.z < 0 ? p.strokeColorDark : p.strokeColor;
  		}
  
  		const { vertices } = p;
  		const lastV = vertices[vertices.length - 1];
  		const fadeOut = p.middle.z > cameraFadeStartZ;
  
  		if (!p.wireframe) {
  			const normalLight = p.normalWorld.y * 0.5 + p.normalWorld.z * -0.5;
  			const lightness = normalLight > 0
  				? 0.1
  				: ((normalLight ** 32 - normalLight) / 2) * 0.9 + 0.1;
  			ctx.fillStyle = shadeColor(p.color, lightness);
  		}
  
  		// Fade out polys close to camera. `globalAlpha` must be reset later.
  		if (fadeOut) {
  			// If polygon gets really close to camera (outside `cameraFadeRange`) the alpha
  			// can go negative, which has the appearance of alpha = 1. So, we'll clamp it at 0.
  			ctx.globalAlpha = Math.max(0, 1 - (p.middle.z - cameraFadeStartZ) / cameraFadeRange);
  		}
  
  		ctx.beginPath();
  		ctx.moveTo(lastV.x, lastV.y);
  		for (let v of vertices) {
  			ctx.lineTo(v.x, v.y);
  		}
  
  		if (!p.wireframe) {
  			ctx.fill();
  		}
  		if (p.strokeWidth !== 0) {
  			ctx.stroke();
  		}
  
  		if (fadeOut) {
  			ctx.globalAlpha = 1;
  		}
  	});
  	PERF_END('drawPolys');
  
  
  	PERF_START('draw2D');
  
  	// 2D Sparks
  	// ---------------
  	ctx.strokeStyle = sparkColor;
  	ctx.lineWidth = sparkThickness;
  	ctx.beginPath();
  	sparks.forEach(spark => {
  		ctx.moveTo(spark.x, spark.y);
  		// Shrink sparks to zero length as they die.
  		// Speed up shrinking as life approaches 0 (root curve).
  		// Note that sparks already get smaller over time as their speed slows
  		// down from damping. So this is like a double scale down. To counter this
  		// a bit and keep the sparks larger for longer, we'll also increase the scale
  		// a bit after applying the root curve.
  		const scale = (spark.life / spark.maxLife) ** 0.5 * 1.5;
  		ctx.lineTo(spark.x - spark.xD*scale, spark.y - spark.yD*scale);
  
  	});
  	ctx.stroke();
  
  
  	// Touch Strokes
  	// ---------------
  
  	ctx.strokeStyle = touchTrailColor;
  	const touchPointCount = touchPoints.length;
  	for (let i=1; i<touchPointCount; i++) {
  		const current = touchPoints[i];
  		const prev = touchPoints[i-1];
  		if (current.touchBreak || prev.touchBreak) {
  			continue;
  		}
  		const scale = current.life / touchPointLife;
  		ctx.lineWidth = scale * touchTrailThickness;
  		ctx.beginPath();
  		ctx.moveTo(prev.x, prev.y);
  		ctx.lineTo(current.x, current.y);
  		ctx.stroke();
  	}
  
  	PERF_END('draw2D');
  
  	PERF_END('draw');
  	PERF_END('frame');
  
  	// Display performance updates.
  	PERF_UPDATE();
  }
  