diff --git a/README.md b/README.md
index d9a380d..4c82bf2 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,6 @@ Full documentation is in progress at the [wiki](https://github.com/brianchirls/S
 
 - Optimized rendering path and GPU accelerated up to 60 frames per second
 - Accept image input from varied sources: videos, images, canvases and arrays
-- Output to multiple canvases
 - Effect parameters accept multiple formats and can monitor HTML form inputs
 - Basic 2D transforms (translate, rotate, scale, skew) on effect nodes
 - Plugin architecture for adding new effects
@@ -68,15 +67,14 @@ Full documentation is in progress at the [wiki](https://github.com/brianchirls/S
 Seriously.js requires a browser that supports [WebGL](http://en.wikipedia.org/wiki/Webgl). 
 Development is targeted to and tested in Firefox (4.0+) and Google Chrome (9+). Safari
 and Opera are [expected to support WebGL](http://caniuse.com/#search=webgl)
-in the near future. There are no public plans for Internet Explorer to
-support WebGL.
+in the near future. Internet Explorer supports WebGL but does not support video textures.
 
 Even though a browser may support WebGL, the ability to run it depends
 on the system's graphics card. Seriously.js is heavily optimized, so most
 modern desktops and notebooks should be sufficient. Older systems may
 run slower, especially when using high-resolution videos.
 
-Mobile browser support for WebGL is limited. Mobile Firefox has some
+Mobile browser support for WebGL is limited. Mobile Firefox and Chrome have decent
 support, but the Android Browser and Mobile Safari do not.
 
 Seriously.js provides a method to detect browser support and offer
@@ -99,13 +97,7 @@ So for now, it is best to host your own video files.
 - Benchmarking utility to determine client capabilities
 - Automatic resolution tuning to maintain minimum frame rate
 - Handle lost WebGL context
-- Debug mode
 - Graphical interface
-- Effects:
-	- Perlin Noise
-	- Clouds
-	- Gaussian Blur
-	- Curves
 
 ## Contributing
 
@@ -118,6 +110,4 @@ Individual plugins may be licensed differently. Check source code comments.
 
 ## Credits
 
-Seriously.js is created and maintained by [Brian Chirls](http://chirls.com) with support from:
-
-<a href="http://mozillapopcorn.org"><img src="http://seriouslyjs.org/images/mozilla.png" alt="Mozilla"/></a>
+Seriously.js is created and maintained by [Brian Chirls](http://chirls.com)
\ No newline at end of file
diff --git a/effects/seriously.ascii.js b/effects/seriously.ascii.js
index d975eaf..abb95fa 100644
--- a/effects/seriously.ascii.js
+++ b/effects/seriously.ascii.js
@@ -26,7 +26,7 @@
 	var identity, letters;
 
 	letters = document.createElement('img');
-	letters.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAvAAAAAICAYAAACf+MsnAAAFY0lEQVR4Xu2Z644bOwyDN+//0NsOigEMQdRHyU6CFDnA+bHVWNaFojiTx8/Pz+/f/4/89/v7z9Xj8Tjib3XyTN9usFcMz8gt3h9zXf/O6nD/W1V7Vb9uXad+nHucZ9xenX7OqTHdSfmRXfmPsSn8xPMrllcfCkdVfHSe7Ned0/yp7jv2GPfqK+MCByc0zzvxKi5RPq8cuvE4+JrwpFM7N78K2yu+qb9kd3qV+ZjUx5n/+xnXP81ctW/UHQ5P3Gd360vxKf+n8dGpxXTeKu6h2ansFT6pvo5G2/FP99NsUf9d/xleInfetcj629m9cf9WOV5K+78R8ERGRLYO8VQiecd/1vwKEJV46JBJRzhRfXftVL/MTgM48UmL0l2OSmzs9kctAJfE4/1KkNFzbj8cjFHsJ/u460vhnPDfqddujJ27poLCWWBuHt0YKr/ki+yOKJnk5Z7pPLfLf4TZif+qvi7XuDWg+HbtNEe79ds9H7m1m2/3+YzLK5Hc9e/gYxdfNP+ZfdV9lT3usWn+9310/qiAdxa1O5gTEqVhoLudxVwVNPrvCqDp/ZX4d0Uk1Y7sbgyU4zooCk8nB3i9Y61V5wWpIjDlP+ZJsxPvmLxEOD2sntk5Pz1LBOb0L+sPfQGs6ksYpt7QAiHuUwtkgl+F3Qyf2YxTX53+Vdjfjc8VYIq7KT+abzof7ervZ8fX8d/Jyc3PmTcnRrrPEbyVTnD8T+Y38pH624mfNIr6muzO95S/sh1Gvog/XmW/a6N+scww43zgqLjcOX9cwFeESQK3Gpx32QggTlwk8Ei8OXfE4VMLeCLQiLBjfJM7VA069XefnZBGJz7Vr24dK3GwEoqLD7p/1+4IMWdRdxaMK9CmP4E62F7nm8S7s4B3BMCkBzQPVQ0IM06+2WLvzlDlI+NfF4d0ljiHuF/Zb/4m/4ojTgnA6f0qfiWA135P5l/NoFv/7txm+5ZyyOw0e1R/skd8ZKKwwnjXf9xLrkBV+2x3Pib9Vz3JOMaNL/KZ+oCkXhDUTLxEwLsC41OfI5DEYe9+mXfr0l2mJH5ISHTOUw2U8IjD5LyVUtxEmrvi4V5ejvijWNWicBbOyfsrYejkMMXmdIFEAZH19ASWnNyrPlBdKH+yU3y0gGjGKf4Mv51ft9zzKk83vul5qr9r7+CT9gHx2zvs0/yofpGX1AuC4svqhYJeJJydNZk/urcSxet91dfiUy94HX6oBHCHi5+F38svCeg1h+zZ6nyF5VUzVC8Q0X9LwE/IkMjmpJ3i27XvxuqQ0c4dp/JTfnb9T847AoNIW/nokIYrYKvnJvln/siPwtD0XAeTU+x0luEugWdLNeY4ecl260vxK8Efl3OnZi4uaZZIMBFeJ/hw6xrFvppvV1Q559d8MwwR50cskIBQ2KhE3y7/ZeddAUjxOr3diZ/8U3+I953z7uzR7Lj4rvjl9HxXvaHaOflSfSkf93y24xx94PpX89I5H2t9+fwK+KVzNOwdIeM+e905+ZqqRIj7pYHiU3FNFnBnkO+41EKige3cpX7GunwoARfjIwKrxNhEJFLfMrsbI+G/smfkojAa60vxPcNeCZCqhjSra6ydBaAWSFzaqnb01c4VEdVCWWPM7svstKDWuKrZpwUb7dVsOzPcxUeGdYdfdgV8Vr+Mv1R8Tn/iHcSNWR8jjjv9URzama9qbp0XlBP4y2Jw6u/E577AZTVz/BM/OfySzSjl79o73FRxaFdfuPG5/XE58PbXEvAT8UBn1HKuSIB8ThYwiZfJnd8z768Aib/3R/iN4J0VeMXcVwvynbl/735OBV6BKTfyT+e/T4/f7dP3uW8F3Aqs/PIHbWXeeeKjnSsAAAAASUVORK5CYII=";
+	letters.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAvAAAAAICAYAAACf+MsnAAAFY0lEQVR4Xu2Z644bOwyDN+//0NsOigEMQdRHyU6CFDnA+bHVWNaFojiTx8/Pz+/f/4/89/v7z9Xj8Tjib3XyTN9usFcMz8gt3h9zXf/O6nD/W1V7Vb9uXad+nHucZ9xenX7OqTHdSfmRXfmPsSn8xPMrllcfCkdVfHSe7Ned0/yp7jv2GPfqK+MCByc0zzvxKi5RPq8cuvE4+JrwpFM7N78K2yu+qb9kd3qV+ZjUx5n/+xnXP81ctW/UHQ5P3Gd360vxKf+n8dGpxXTeKu6h2ansFT6pvo5G2/FP99NsUf9d/xleInfetcj629m9cf9WOV5K+78R8ERGRLYO8VQiecd/1vwKEJV46JBJRzhRfXftVL/MTgM48UmL0l2OSmzs9kctAJfE4/1KkNFzbj8cjFHsJ/u460vhnPDfqddujJ27poLCWWBuHt0YKr/ki+yOKJnk5Z7pPLfLf4TZif+qvi7XuDWg+HbtNEe79ds9H7m1m2/3+YzLK5Hc9e/gYxdfNP+ZfdV9lT3usWn+9310/qiAdxa1O5gTEqVhoLudxVwVNPrvCqDp/ZX4d0Uk1Y7sbgyU4zooCk8nB3i9Y61V5wWpIjDlP+ZJsxPvmLxEOD2sntk5Pz1LBOb0L+sPfQGs6ksYpt7QAiHuUwtkgl+F3Qyf2YxTX53+Vdjfjc8VYIq7KT+abzof7ervZ8fX8d/Jyc3PmTcnRrrPEbyVTnD8T+Y38pH624mfNIr6muzO95S/sh1Gvog/XmW/a6N+scww43zgqLjcOX9cwFeESQK3Gpx32QggTlwk8Ei8OXfE4VMLeCLQiLBjfJM7VA069XefnZBGJz7Vr24dK3GwEoqLD7p/1+4IMWdRdxaMK9CmP4E62F7nm8S7s4B3BMCkBzQPVQ0IM06+2WLvzlDlI+NfF4d0ljiHuF/Zb/4m/4ojTgnA6f0qfiWA135P5l/NoFv/7txm+5ZyyOw0e1R/skd8ZKKwwnjXf9xLrkBV+2x3Pib9Vz3JOMaNL/KZ+oCkXhDUTLxEwLsC41OfI5DEYe9+mXfr0l2mJH5ISHTOUw2U8IjD5LyVUtxEmrvi4V5ejvijWNWicBbOyfsrYejkMMXmdIFEAZH19ASWnNyrPlBdKH+yU3y0gGjGKf4Mv51ft9zzKk83vul5qr9r7+CT9gHx2zvs0/yofpGX1AuC4svqhYJeJJydNZk/urcSxet91dfiUy94HX6oBHCHi5+F38svCeg1h+zZ6nyF5VUzVC8Q0X9LwE/IkMjmpJ3i27XvxuqQ0c4dp/JTfnb9T847AoNIW/nokIYrYKvnJvln/siPwtD0XAeTU+x0luEugWdLNeY4ecl260vxK8Efl3OnZi4uaZZIMBFeJ/hw6xrFvppvV1Q559d8MwwR50cskIBQ2KhE3y7/ZeddAUjxOr3diZ/8U3+I953z7uzR7Lj4rvjl9HxXvaHaOflSfSkf93y24xx94PpX89I5H2t9+fwK+KVzNOwdIeM+e905+ZqqRIj7pYHiU3FNFnBnkO+41EKige3cpX7GunwoARfjIwKrxNhEJFLfMrsbI+G/smfkojAa60vxPcNeCZCqhjSra6ydBaAWSFzaqnb01c4VEdVCWWPM7svstKDWuKrZpwUb7dVsOzPcxUeGdYdfdgV8Vr+Mv1R8Tn/iHcSNWR8jjjv9URzama9qbp0XlBP4y2Jw6u/E577AZTVz/BM/OfySzSjl79o73FRxaFdfuPG5/XE58PbXEvAT8UBn1HKuSIB8ThYwiZfJnd8z768Aib/3R/iN4J0VeMXcVwvynbl/735OBV6BKTfyT+e/T4/f7dP3uW8F3Aqs/PIHbWXeeeKjnSsAAAAASUVORK5CYII=';
 	identity = new Float32Array([
 		1, 0, 0, 0,
 		0, 1, 0, 0,
@@ -43,7 +43,24 @@
 			height,
 			scaledWidth,
 			scaledHeight,
-			unif;
+			unif = {},
+			me = this;
+
+		function resize() {
+			//set up scaledBuffer if (width or height have changed)
+			height = me.height;
+			width = me.width;
+			scaledWidth = Math.ceil(width / 8);
+			scaledHeight = Math.ceil(height / 8);
+
+			unif.resolution = me.uniforms.resolution;
+			unif.transform = identity;
+
+			if (scaledBuffer) {
+				scaledBuffer.resize(scaledWidth, scaledHeight);
+			}
+		}
+
 		return {
 			initialize: function (parent) {
 				function setLetters() {
@@ -74,22 +91,27 @@
 					});
 				}
 
-				unif = {
-					letters: lettersTexture
-				};
+				unif.letters = lettersTexture;
 
 				//when the output scales up, don't smooth it out
 				gl.bindTexture(gl.TEXTURE_2D, this.texture || this.frameBuffer && this.frameBuffer.texture);
 				gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
 				gl.bindTexture(gl.TEXTURE_2D, null);
 
-				scaledBuffer = new Seriously.util.FrameBuffer(gl, 1, 1);
+				resize();
+
+				scaledBuffer = new Seriously.util.FrameBuffer(gl, scaledWidth, scaledHeight);
+
+				//so it stays blocky
+				gl.bindTexture(gl.TEXTURE_2D, scaledBuffer.texture);
+				gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
 
 				this.uniforms.transform = identity;
+
+				baseShader = this.baseShader;
 			},
+			commonShader: true,
 			shader: function (inputs, shaderSource) {
-				baseShader = new Seriously.util.ShaderProgram(this.gl, shaderSource.vertex, shaderSource.fragment);
-
 				shaderSource.fragment = '#ifdef GL_ES\n\n' +
 					'precision mediump float;\n\n' +
 					'#endif\n\n' +
@@ -120,26 +142,9 @@
 
 				return shaderSource;
 			},
-			draw: function (shader, model, uniforms, frameBuffer, parent) {
-				//set up scaledBuffer if (width or height have changed)
-				if (height !== this.height || width !== this.width) {
-					height = this.height;
-					width = this.width;
-					scaledWidth = Math.ceil(width / 8);
-					scaledHeight = Math.ceil(height / 8);
-
-					unif.resolution = uniforms.resolution;
-					unif.transform = identity;
-
-					scaledBuffer.resize(scaledWidth, scaledHeight);
-
-					//so it stays blocky
-					gl.bindTexture(gl.TEXTURE_2D, scaledBuffer.texture);
-					gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
-				}
-
-
-				parent(baseShader, model, uniforms, scaledBuffer.frameBuffer, false, {
+			resize: resize,
+			draw: function (shader, model, uniforms, frameBuffer, draw) {
+				draw(baseShader, model, uniforms, scaledBuffer.frameBuffer, false, {
 					width: scaledWidth,
 					height: scaledHeight,
 					blend: false
@@ -148,7 +153,7 @@
 				unif.source = scaledBuffer.texture;
 				unif.background = uniforms.background;
 
-				parent(shader, model, unif, frameBuffer);
+				draw(shader, model, unif, frameBuffer);
 			},
 			destroy: function () {
 				if (scaledBuffer) {
diff --git a/effects/seriously.bleach-bypass.js b/effects/seriously.bleach-bypass.js
index 7fa9065..f771276 100644
--- a/effects/seriously.bleach-bypass.js
+++ b/effects/seriously.bleach-bypass.js
@@ -28,6 +28,7 @@
 	*/
 
 	Seriously.plugin('bleach-bypass', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.blend.js b/effects/seriously.blend.js
index dea487b..b81045d 100644
--- a/effects/seriously.blend.js
+++ b/effects/seriously.blend.js
@@ -123,6 +123,7 @@
 					this.frameBuffer.resize(width, height);
 				}
 
+				this.emit('resize');
 				this.setDirty();
 			}
 
diff --git a/effects/seriously.blur.js b/effects/seriously.blur.js
index 5b5eacd..17cfe0d 100644
--- a/effects/seriously.blur.js
+++ b/effects/seriously.blur.js
@@ -67,9 +67,12 @@ http://v002.info/plugins/v002-blurs/
 					return;
 				}
 
+				baseShader = this.baseShader;
+
 				fbHorizontal = new Seriously.util.FrameBuffer(gl, this.width, this.height);
 				fbVertical = new Seriously.util.FrameBuffer(gl, this.width, this.height);
 			},
+			commonShader: true,
 			shader: function (inputs, shaderSource) {
 				var gl = this.gl,
 					/*
@@ -79,8 +82,6 @@ http://v002.info/plugins/v002-blurs/
 					maxVaryings = gl.getParameter(gl.MAX_VARYING_VECTORS),
 					defineVaryings = (maxVaryings >= 10 ? '#define USE_VARYINGS' : '');
 
-				baseShader = new Seriously.util.ShaderProgram(gl, shaderSource.vertex, shaderSource.fragment);
-
 				shaderSource.vertex = [
 					defineVaryings,
 					'#define PI ' + Math.PI,
diff --git a/effects/seriously.brightness-contrast.js b/effects/seriously.brightness-contrast.js
index 6f60c73..d1f99b4 100644
--- a/effects/seriously.brightness-contrast.js
+++ b/effects/seriously.brightness-contrast.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('brightness-contrast', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = [
 				'#ifdef GL_ES\n',
diff --git a/effects/seriously.channels.js b/effects/seriously.channels.js
index 83b85f4..b454b6d 100644
--- a/effects/seriously.channels.js
+++ b/effects/seriously.channels.js
@@ -181,6 +181,7 @@
 					this.frameBuffer.resize(width, height);
 				}
 
+				this.emit('resize');
 				this.setDirty();
 			}
 
diff --git a/effects/seriously.checkerboard.js b/effects/seriously.checkerboard.js
new file mode 100644
index 0000000..258c1b7
--- /dev/null
+++ b/effects/seriously.checkerboard.js
@@ -0,0 +1,141 @@
+/* global define, require */
+(function (root, factory) {
+	'use strict';
+
+	if (typeof exports === 'object') {
+		// Node/CommonJS
+		factory(require('seriously'));
+	} else if (typeof define === 'function' && define.amd) {
+		// AMD. Register as an anonymous module.
+		define(['seriously'], factory);
+	} else {
+		if (!root.Seriously) {
+			root.Seriously = { plugin: function (name, opt) { this[name] = opt; } };
+		}
+		factory(root.Seriously);
+	}
+}(this, function (Seriously, undefined) {
+	'use strict';
+
+	Seriously.plugin('checkerboard', function () {
+		var me = this;
+
+		function resize() {
+			me.resize();
+		}
+
+		return {
+			initialize: function (initialize) {
+				initialize();
+				resize();
+			},
+			shader: function (inputs, shaderSource) {
+				shaderSource.vertex = [
+					'#ifdef GL_ES',
+					'precision mediump float;',
+					'#endif',
+
+					'attribute vec4 position;',
+					'attribute vec2 texCoord;',
+
+					'uniform vec2 resolution;',
+					'uniform mat4 transform;',
+
+					'uniform vec2 size;',
+					'uniform vec2 anchor;',
+
+					'vec2 pixelCoord;', //based in center
+					'varying vec2 vGridCoord;', //based in center
+
+					'void main(void) {',
+					// first convert to screen space
+					'	vec4 screenPosition = vec4(position.xy * resolution / 2.0, position.z, position.w);',
+					'	screenPosition = transform * screenPosition;',
+
+					// convert back to OpenGL coords
+					'	gl_Position.xy = screenPosition.xy * 2.0 / resolution;',
+					'	gl_Position.z = screenPosition.z * 2.0 / (resolution.x / resolution.y);',
+					'	gl_Position.w = screenPosition.w;',
+
+					'	pixelCoord = resolution * (texCoord - 0.5) / 2.0;',
+					'	vGridCoord = (pixelCoord - anchor) / size;',
+					'}\n'
+				].join('\n');
+				shaderSource.fragment = [
+					'#ifdef GL_ES',
+					'precision mediump float;',
+					'#endif',
+
+					'varying vec2 vTexCoord;',
+					'varying vec2 vPixelCoord;',
+					'varying vec2 vGridCoord;',
+
+					'uniform vec2 resolution;',
+					'uniform vec2 anchor;',
+					'uniform vec2 size;',
+					'uniform vec4 color1;',
+					'uniform vec4 color2;',
+
+
+					'void main(void) {',
+					'	vec2 modGridCoord = floor(mod(vGridCoord, 2.0));',
+					'	if (modGridCoord.x == modGridCoord.y) {',
+					'		gl_FragColor = color1;',
+					'	} else  {',
+					'		gl_FragColor = color2;',
+					'	}',
+					'}'
+				].join('\n');
+				return shaderSource;
+			},
+			inPlace: true,
+			inputs: {
+				source: {
+					type: 'image',
+					uniform: 'source',
+					shaderDirty: false
+				},
+				anchor: {
+					type: 'vector',
+					uniform: 'anchor',
+					dimensions: 2,
+					defaultValue: [0, 0]
+				},
+				size: {
+					type: 'vector',
+					uniform: 'size',
+					dimensions: 2,
+					defaultValue: [4, 4]
+				},
+				color1: {
+					type: 'color',
+					uniform: 'color1',
+					defaultValue: [1, 1, 1, 1]
+				},
+				color2: {
+					type: 'color',
+					uniform: 'color2',
+					defaultValue: [187 / 255, 187 / 255, 187 / 255, 1]
+				},
+				width: {
+					type: 'number',
+					min: 0,
+					step: 1,
+					update: resize,
+					defaultValue: 640
+				},
+				height: {
+					type: 'number',
+					min: 0,
+					step: 1,
+					update: resize,
+					defaultValue: 360
+				}
+			},
+		};
+	}, {
+		commonShader: true,
+		title: 'Checkerboard',
+		categories: ['generator']
+	});
+}));
diff --git a/effects/seriously.chroma.js b/effects/seriously.chroma.js
index 0c69502..d206a30 100644
--- a/effects/seriously.chroma.js
+++ b/effects/seriously.chroma.js
@@ -25,6 +25,7 @@
 		todo: rename parameters
 	*/
 	Seriously.plugin('chroma', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.vertex = [
 				'#ifdef GL_ES',
diff --git a/effects/seriously.color.js b/effects/seriously.color.js
index 0053fcf..c0396ed 100644
--- a/effects/seriously.color.js
+++ b/effects/seriously.color.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('color', {
+		commonShader: true,
 		shader: function(inputs, shaderSource, utilities) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
@@ -42,6 +43,7 @@
 			}
 		},
 		title: 'Color',
-		description: 'Generate color'
+		description: 'Generate color',
+		categories: ['generator']
 	});
 }));
diff --git a/effects/seriously.colorcomplements.js b/effects/seriously.colorcomplements.js
index b36b79c..7de300d 100644
--- a/effects/seriously.colorcomplements.js
+++ b/effects/seriously.colorcomplements.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('colorcomplements', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = [
 				'#ifdef GL_ES',
diff --git a/effects/seriously.colorcube.js b/effects/seriously.colorcube.js
index eb34cf0..82a3aff 100644
--- a/effects/seriously.colorcube.js
+++ b/effects/seriously.colorcube.js
@@ -22,6 +22,7 @@
 	//todo: find a way to not invert every single texture
 
 	Seriously.plugin('colorcube', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n' +
 				'precision mediump float;\n' +
diff --git a/effects/seriously.daltonize.js b/effects/seriously.daltonize.js
index db3c5c8..d6d3ced 100644
--- a/effects/seriously.daltonize.js
+++ b/effects/seriously.daltonize.js
@@ -53,6 +53,7 @@
 * 
 	*/
 	Seriously.plugin('daltonize', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			//Vertex shader
 			shaderSource.vertex = '#ifdef GL_ES\n' +
diff --git a/effects/seriously.directionblur.js b/effects/seriously.directionblur.js
index 37adaa9..d9f94be 100644
--- a/effects/seriously.directionblur.js
+++ b/effects/seriously.directionblur.js
@@ -68,6 +68,7 @@ http://v002.info/plugins/v002-blurs/
 					new Seriously.util.FrameBuffer(gl, this.width, this.height)
 				];
 			},
+			commonShader: true,
 			shader: function (inputs, shaderSource) {
 				var gl = this.gl,
 					/*
@@ -77,7 +78,7 @@ http://v002.info/plugins/v002-blurs/
 					maxVaryings = gl.getParameter(gl.MAX_VARYING_VECTORS),
 					defineVaryings = (maxVaryings >= 10 ? '#define USE_VARYINGS' : '');
 
-				baseShader = new Seriously.util.ShaderProgram(gl, shaderSource.vertex, shaderSource.fragment);
+				baseShader = this.baseShader;
 
 				shaderSource.vertex = [
 					defineVaryings,
diff --git a/effects/seriously.dither.js b/effects/seriously.dither.js
index be20da4..56e1cf3 100644
--- a/effects/seriously.dither.js
+++ b/effects/seriously.dither.js
@@ -24,6 +24,7 @@
 	*/
 
 	Seriously.plugin('dither', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = [
 				'#ifdef GL_ES\n',
diff --git a/effects/seriously.emboss.js b/effects/seriously.emboss.js
index 3ce94a4..36b5b1f 100644
--- a/effects/seriously.emboss.js
+++ b/effects/seriously.emboss.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('emboss', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.exposure.js b/effects/seriously.exposure.js
index 5b827d0..d189208 100644
--- a/effects/seriously.exposure.js
+++ b/effects/seriously.exposure.js
@@ -27,6 +27,7 @@
 	*/
 
 	Seriously.plugin('exposure', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.expression.js b/effects/seriously.expression.js
new file mode 100644
index 0000000..ebf6857
--- /dev/null
+++ b/effects/seriously.expression.js
@@ -0,0 +1,563 @@
+/* global define, require */
+(function (root, factory) {
+	'use strict';
+
+	if (typeof exports === 'object') {
+		// Node/CommonJS
+		factory(require('seriously'));
+	} else if (typeof define === 'function' && define.amd) {
+		// AMD. Register as an anonymous module.
+		define(['seriously'], factory);
+	} else {
+		if (!root.Seriously) {
+			root.Seriously = { plugin: function (name, opt) { this[name] = opt; } };
+		}
+		factory(root.Seriously);
+	}
+}(this, function (Seriously, undefined) {
+	'use strict';
+
+	function formatFloat(n) {
+		if (n - Math.floor(n) === 0) {
+			return n + '.0';
+		}
+		return n;
+	}
+
+	var symbols = {
+			red: 'rgba.r',
+			blue: 'rgba.b',
+			green: 'rgba.g',
+			alpha: 'rgba.a',
+			x: 'dim.x',
+			y: 'dim.y',
+			width: 'resolution.x',
+			height: 'resolution.y',
+			a: 'a',
+			b: 'b',
+			c: 'c',
+			d: 'd',
+			luma: ['luma', 'rgba']
+
+			/*
+			todo:
+			- time of day in seconds
+			- video time
+			- year, month, day
+			- datetime in milliseconds
+			- transform?
+			- transformed position?
+			*/
+		},
+		definitions = {
+			dim: 'vec2 dim = vTexCoord * resolution;',
+			rgba: 'vec4 rgba = texture2D(source, vTexCoord);',
+			luma: 'float luma = dot(rgba.rgb, vec3(0.2125,0.7154,0.0721));',
+			atan2: {
+				source: '#define atan2(x, y) atan(x / y)',
+				global: true
+			},
+			and: {
+				source: [
+					'float and(float a, float b) {',
+					'	if (a == 0.0) {',
+					'		return 0.0;',
+					'	}',
+					'	return b;',
+					'}'
+				].join('\n'),
+				global: true
+			},
+			or: {
+				source: [
+					'float or(float a, float b) {',
+					'	if (a != 0.0) {',
+					'		return a;',
+					'	}',
+					'	return b;',
+					'}'
+				].join('\n'),
+				global: true
+			},
+			luminance: {
+				source: [
+					'const vec3 lumaCoeffs = vec3(0.2125,0.7154,0.0721);',
+					'float luminance(float r, float g, float b) {',
+					'	return dot(vec3(r, g, b), lumaCoeffs);',
+					'}'
+				].join('\n'),
+				global: true
+			},
+			saturation: {
+				source: [
+					'float saturation(float r, float g, float b) {',
+					'	float lo = min(r, min(g, b));',
+					'	float hi = max(r, max(g, b));',
+					'	float l = (lo + hi) / 2.0;',
+					'	float d = hi - lo;',
+					'	return l > 0.5 ? d / (2.0 - hi - lo) : d / (hi + lo);',
+					'}'
+				].join('\n'),
+				global: true
+			},
+			lightness: {
+				source: [
+					'float lightness(float r, float g, float b) {',
+					'	float lo = min(r, min(g, b));',
+					'	float hi = max(r, max(g, b));',
+					'	return (lo + hi) / 2.0;',
+					'}'
+				].join('\n'),
+				global: true
+			},
+			hue: {
+				source: [
+					'float hue(float r, float g, float b) {',
+					'	float h;',
+					'	if (r > g && r > b) {', //red is max
+					'		h = (g - b) / d + (g < b ? 6.0 : 0.0);',
+					'	} else if (g > r && g > b) {', //green is max
+					'		h = (b - r) / d + 2.0;',
+					'	} else {', //blue is max
+					'		h = (r - g) / d + 4.0;',
+					'	}',
+					'	return h / 6.0;',
+					'}'
+				].join('\n'),
+				global: true
+			}
+		},
+		functions = {
+			//built-in shader functions
+			radians: 1,
+			degrees: 1,
+			sin: 1,
+			cos: 1,
+			tan: 1,
+			asin: 1,
+			acos: 1,
+			atan: 1,
+			pow: 2,
+			exp: 1,
+			log: 1,
+			exp2: 1,
+			log2: 1,
+			sqrt: 1,
+			inversesqrt: 1,
+			abs: 1,
+			sign: 1,
+			floor: 1,
+			ceil: 1,
+			fract: 1,
+			mod: 2,
+			min: 2,
+			max: 2,
+			clamp: 3,
+			mix: 3,
+			step: 2,
+			smoothstep: 3,
+
+			//custom logic functions
+			and: 2,
+			or: 2,
+
+			//custom functions
+			atan2: 2,
+			hue: 3,
+			saturation: 3,
+			lightness: 3,
+			luminance: 3
+
+			/*
+			todo:
+			noise, random, hslRed, hslGreen, hslBlue,
+			int, sinh, cosh, tanh, mantissa, hypot, lerp, step
+			noise with multiple octaves (See fBm)
+			*/
+		},
+		unaryOps = {
+			'-': true,
+			'!': true,
+			//'~': false, //todo: implement this or just get rid of it?
+			'+': true
+		},
+		binaryOps = {
+			//true means it's a comparison and needs to be converted to float
+			'+': false,
+			'-': false,
+			'*': false,
+			'/': false,
+			'%': 'mod',
+			'&&': 'and',
+			'||': 'or',
+			//'^^': false, //todo: implement xor?
+			//'&',
+			//'|',
+			//'<<',
+			//'>>',
+			'===': '==',
+			'==': true,
+			'!==': '!=',
+			'!=': true,
+			'>=': true,
+			'<=': true,
+			'<': true,
+			'>': true
+		},
+		pair,
+		key,
+		def,
+
+		jsep;
+
+	['E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'PI', 'SQRT1_2', 'SQRT2'].forEach(function (key) {
+		symbols[key] = key;
+		definitions[key] = {
+			source: 'const float ' + key + ' = ' + Math[key] + ';',
+			global: true
+		};
+	});
+
+	//clean up lookup tables
+	for (key in symbols) {
+		if (symbols.hasOwnProperty(key)) {
+			def = symbols[key];
+			if (typeof def === 'string') {
+				def = [def];
+			}
+
+			pair = def[0].split('.');
+			if (pair.length > 1) {
+				def.push(pair[0]);
+			}
+			symbols[key] = def;
+		}
+	}
+
+	for (key in definitions) {
+		if (definitions.hasOwnProperty(key)) {
+			def = definitions[key];
+			if (typeof def === 'string') {
+				definitions[key] = {
+					source: def,
+					global: false
+				};
+			}
+		}
+	}
+
+	Seriously.plugin('expression', function () {
+		var me = this;
+
+		function updateSingle() {
+			var inputs = me.inputs;
+
+			if (inputs.blue === inputs.red &&
+				inputs.green === inputs.blue) {
+				inputs.rgb = inputs.red;
+			} else {
+				inputs.rgb = '';
+			}
+		}
+
+		function resize() {
+			if (me.inputs.source) {
+				me.width = me.inputs.width = me.inputs.source.width;
+				me.height = me.inputs.height = me.inputs.source.height;
+			}
+		}
+
+		return {
+			shader: function (inputs, shaderSource) {
+				var expressions = {},
+					channels = {
+						red: '',
+						green: '',
+						blue: '',
+						alpha: ''
+					},
+					dependencies = {},
+					deps,
+					expr,
+					key,
+					statements,
+					globalDefinitions = [],
+					nonGlobalDefinitions = [],
+					cs = [],
+					tree;
+
+				function makeExpression(tree) {
+					var verb, x, i,
+						args;
+					/*
+					COMPOUND = 'Compound'
+					*/
+
+					//We do not have any objects to offer
+					if (tree.type === 'MemberExpression') {
+						throw new Error('Expression Error: Unknown object "' + (tree.object.name || 'this') + '"');
+					}
+
+					if (tree.type === 'BinaryExpression' || tree.type === 'LogicalExpression') {
+						if (!tree.right) {
+							throw new Error('Expression Error: Bad binary expression');
+						}
+
+						//jsep seems to parse some unary expressions as binary with missing left side
+						//todo: consider removing this if/when jsep fixes it. file a github issue
+						if (!tree.left) {
+							tree.type = 'UnaryExpression';
+							tree.argument = tree.right;
+							return makeExpression(tree);
+						}
+
+						verb = tree.operator;
+						x = binaryOps[verb];
+						if (x === undefined) {
+							throw new Error('Expression Error: Unknown operator "' + verb + '"');
+						}
+
+						if (typeof x === 'string') {
+							if (x in binaryOps) {
+								verb = binaryOps[x];
+								x = binaryOps[verb];
+							} else if (functions[x] === 2) {
+								deps[x] = true;
+								return x + '(' + makeExpression(tree.left) + ', ' + makeExpression(tree.right) + ')';
+							}
+						}
+
+						return (x ? 'float' : '') + '(' + makeExpression(tree.left) + ' ' + verb + ' ' + makeExpression(tree.right) + ')';
+					}
+
+					if (tree.type === 'CallExpression') {
+						if (tree.callee.type !== 'Identifier') {
+							throw new Error('Expression Error: Unknown function');
+						}
+
+						verb = tree.callee.name;
+						x = functions[verb];
+						if (x === undefined) {
+							throw new Error('Expression Error: Unknown function "' + verb + '"');
+						}
+
+						if (x > tree.arguments.length) {
+							throw new Error('Expression Error: Function "' + verb + '" requires at least ' + x + ' arguments');
+						}
+
+						args = [];
+						for (i = 0; i < x; i++) {
+							args.push(makeExpression(tree.arguments[i]));
+						}
+						deps[verb] = true;
+						return verb + '(' + args.join(', ') + ')';
+					}
+
+					if (tree.type === 'Identifier') {
+						args = symbols[tree.name];
+						if (!args) {
+							throw new Error('Expression Error: Unknown identifier "' + tree.name + '"');
+						}
+
+						for (i = args.length - 1; i >= 0; i--) {
+							x = args[i];
+							if (definitions[x]) {
+								deps[x] = true;
+							}
+						}
+						return args[0];
+					}
+
+					if (tree.type === 'Literal') {
+						if (tree.raw === 'true') {
+							return 1.0;
+						}
+
+						if (tree.raw === 'true') {
+							return 0.0;
+						}
+
+						if (typeof tree.value !== 'number' || isNaN(tree.value)) {
+							throw new Error('Expression Error: Invalid literal ' + tree.raw);
+						}
+
+						return formatFloat(tree.value);
+					}
+
+					if (tree.type === 'UnaryExpression') {
+						verb = tree.operator;
+						x = unaryOps[verb];
+						if (!x) {
+							throw new Error('Expression Error: Unknown operator "' + verb + '"');
+						}
+
+						//todo: are there any unary operators that could become functions?
+						return verb + '(' + makeExpression(tree.argument) + ')';
+					}
+				}
+
+				for (key in channels) {
+					if (channels.hasOwnProperty(key)) {
+						expr = inputs[key] || key;
+						channels[key] = expr;
+						expressions[expr] = '';
+					}
+				}
+
+				for (expr in expressions) {
+					if (expressions.hasOwnProperty(expr)) {
+						try {
+							deps = {};
+							tree = jsep(expr);
+							//todo: convert this to a function?
+							expressions[expr] = makeExpression(tree);
+
+							//flag any required declarations/precalculations
+							for (key in deps) {
+								if (deps.hasOwnProperty(key)) {
+									dependencies[key] = deps[key];
+								}
+							}
+
+							//special case for luma. todo: generalize if we need to
+							if (deps.luma) {
+								dependencies.rgba = true;
+							}
+						} catch (parseError) {
+							console.log(parseError.message);
+							expressions[expr] = '0.0';
+						}
+					}
+				}
+
+				for (key in dependencies) {
+					if (dependencies.hasOwnProperty(key)) {
+						deps = definitions[key];
+						if (deps) {
+							if (deps.global) {
+								globalDefinitions.push(deps.source);
+							} else {
+								nonGlobalDefinitions.push('\t' + deps.source);
+							}
+						}
+					}
+				}
+
+				/*
+				todo: assign duplicate expressions to temp variables
+				for (expr in expressions) {
+					if (expressions.hasOwnProperty(expr)) {
+						statements.push('float val' + index = )
+					}
+				}
+				*/
+
+				for (key in channels) {
+					if (channels.hasOwnProperty(key)) {
+						expr = channels[key];
+						cs.push(expressions[expr]);
+					}
+				}
+
+				statements = [
+					'precision mediump float;',
+					'varying vec2 vTexCoord;',
+					'varying vec4 vPosition;',
+
+					'uniform sampler2D source;',
+					'uniform float a, b, c, d;',
+					'uniform vec2 resolution;',
+					globalDefinitions.join('\n'),
+					'void main(void) {',
+					nonGlobalDefinitions.join('\n'),
+					'\tgl_FragColor = vec4(',
+					'\t\t' + cs.join(',\n\t\t'),
+					'\t);',
+					'}'
+				];
+
+				shaderSource.fragment = statements.join('\n');
+
+				return shaderSource;
+			},
+			inputs: {
+				source: {
+					type: 'image',
+					uniform: 'source',
+					update: resize,
+					shaderDirty: true
+				},
+				a: {
+					type: 'number',
+					uniform: 'a',
+					defaultValue: 0
+				},
+				b: {
+					type: 'number',
+					uniform: 'b',
+					defaultValue: 0
+				},
+				c: {
+					type: 'number',
+					uniform: 'c',
+					defaultValue: 0
+				},
+				d: {
+					type: 'number',
+					uniform: 'd',
+					defaultValue: 0
+				},
+				rgb: {
+					type: 'string',
+					update: function (val) {
+						var inputs = me.inputs;
+						inputs.red = inputs.green = inputs.blue = val;
+					},
+					shaderDirty: true
+				},
+				red: {
+					type: 'string',
+					update: updateSingle,
+					shaderDirty: true
+				},
+				green: {
+					type: 'string',
+					update: updateSingle,
+					shaderDirty: true
+				},
+				blue: {
+					type: 'string',
+					update: updateSingle,
+					shaderDirty: true
+				},
+				alpha: {
+					type: 'string',
+					shaderDirty: true
+				},
+				width: {
+					type: 'number',
+					min: 0,
+					step: 1,
+					update: resize,
+					defaultValue: 0
+				},
+				height: {
+					type: 'number',
+					min: 0,
+					step: 1,
+					update: resize,
+					defaultValue: 0
+				}
+			}
+		};
+	},
+	{
+		inPlace: false,
+		title: 'Expression'
+	});
+
+	/* jsep v0.2.9 (http://jsep.from.so/) */
+	!function(a){"use strict";var b="Compound",c="Identifier",d="MemberExpression",e="Literal",f="ThisExpression",g="CallExpression",h="UnaryExpression",i="BinaryExpression",j="LogicalExpression",k=!0,l={"-":k,"!":k,"~":k,"+":k},m={"||":1,"&&":2,"|":3,"^":4,"&":5,"==":6,"!=":6,"===":6,"!==":6,"<":7,">":7,"<=":7,">=":7,"<<":8,">>":8,">>>":8,"+":9,"-":9,"*":10,"/":10,"%":10},n=function(a){var b,c=0;for(var d in a)(b=d.length)>c&&a.hasOwnProperty(d)&&(c=b);return c},o=n(l),p=n(m),q={"true":!0,"false":!1,"null":null},r="this",s=function(a){return m[a]||0},t=function(a,b,c){var d="||"===a||"&&"===a?j:i;return{type:d,operator:a,left:b,right:c}},u=function(a){return a>=48&&57>=a},v=function(a){return 36===a||95===a||a>=65&&90>=a||a>=97&&122>=a},w=function(a){return 36===a||95===a||a>=65&&90>=a||a>=97&&122>=a||a>=48&&57>=a},x=function(a){for(var i,j,k=0,n=a.charAt,x=a.charCodeAt,y=function(b){return n.call(a,b)},z=function(b){return x.call(a,b)},A=a.length,B=function(){for(var a=z(k);32===a||9===a;)a=z(++k)},C=function(){B();for(var b=a.substr(k,p),c=b.length;c>0;){if(m.hasOwnProperty(b))return k+=c,b;b=b.substr(0,--c)}return!1},D=function(){var a,b,c,d,e,f,g,h;if(f=E(),b=C(),!b)return f;if(e={value:b,prec:s(b)},g=E(),!g)throw new Error("Expected expression after "+b+" at character "+k);for(d=[f,e,g];(b=C())&&(c=s(b),0!==c);){for(e={value:b,prec:c};d.length>2&&c<=d[d.length-2].prec;)g=d.pop(),b=d.pop().value,f=d.pop(),a=t(b,f,g),d.push(a);if(a=E(),!a)throw new Error("Expected expression after "+b+" at character "+k);d.push(e),d.push(a)}for(h=d.length-1,a=d[h];h>1;)a=t(d[h-1].value,d[h-2],a),h-=2;return a},E=function(){var b,c,d;if(B(),b=z(k),u(b)||46===b)return F();if(39===b||34===b)return G();if(v(b))return J();if(40===b)return K();for(c=a.substr(k,o),d=c.length;d>0;){if(l.hasOwnProperty(c))return k+=d,{type:h,operator:c,argument:E(),prefix:!0};c=c.substr(0,--d)}return!1},F=function(){for(var a="";u(z(k));)a+=y(k++);if("."===y(k))for(a+=y(k++);u(z(k));)a+=y(k++);if("e"===y(k)||"E"===y(k)){for(a+=y(k++),("+"===y(k)||"-"===y(k))&&(a+=y(k++));u(z(k));)a+=y(k++);if(!u(z(k-1)))throw new Error("Expected exponent ("+a+y(k)+") at character "+k)}if(v(z(k)))throw new Error("Variable names cannot start with a number ("+a+y(k)+") at character "+k);return{type:e,value:parseFloat(a),raw:a}},G=function(){for(var a,b="",c=y(k++),d=!1;A>k;){if(a=y(k++),a===c){d=!0;break}if("\\"===a)switch(a=y(k++)){case"n":b+="\n";break;case"r":b+="\r";break;case"t":b+="	";break;case"b":b+="\b";break;case"f":b+="\f";break;case"v":b+=""}else b+=a}if(!d)throw new Error('Unclosed quote after "'+b+'"');return{type:e,value:b,raw:c+b+c}},H=function(){var b,d=z(k),g=k;if(!v(d))throw new Error("Unexpected "+y(k)+"at character "+k);for(k++;A>k&&(d=z(k),w(d));)k++;return b=a.slice(g,k),q.hasOwnProperty(b)?{type:e,value:q[b],raw:b}:b===r?{type:f}:{type:c,name:b}},I=function(){for(var a,c,d=[];A>k;){if(B(),a=y(k),")"===a){k++;break}if(","===a)k++;else{if(c=D(),!c||c.type===b)throw new Error("Expected comma at character "+k);d.push(c)}}return d},J=function(){var a,b,c;for(b=H(),B(),a=y(k);"."===a||"["===a||"("===a;){if("."===a)k++,B(),b={type:d,computed:!1,object:b,property:H()};else if("["===a){if(c=k,k++,b={type:d,computed:!0,object:b,property:D()},B(),a=y(k),"]"!==a)throw new Error("Unclosed [ at character "+k);k++,B()}else"("===a&&(k++,b={type:g,arguments:I(),callee:b});B(),a=y(k)}return b},K=function(){k++;var a=D();if(B(),")"===y(k))return k++,a;throw new Error("Unclosed ( at character "+k)},L=[];A>k;)if(i=y(k),";"===i||","===i)k++;else if(j=D())L.push(j);else if(A>k)throw new Error("Unexpected '"+y(k)+"' at character "+k);return 1===L.length?L[0]:{type:b,body:L}};if(x.version="0.2.9",x.toString=function(){return"JavaScript Expression Parser (JSEP) v"+x.version},x.addUnaryOp=function(a){return l[a]=k,this},x.addBinaryOp=function(a,b){return p=Math.max(a.length,p),m[a]=b,this},x.removeUnaryOp=function(a){return delete l[a],a.length===o&&(o=n(l)),this},x.removeBinaryOp=function(a){return delete m[a],a.length===p&&(p=n(m)),this},"undefined"==typeof exports){var y=a.jsep;a.jsep=x,x.noConflict=function(){return a.jsep===x&&(a.jsep=y),x}}else"undefined"!=typeof module&&module.exports?exports=module.exports=x:exports.parse=x}(window);
+
+	jsep = window.jsep.noConflict();
+}));
diff --git a/effects/seriously.fader.js b/effects/seriously.fader.js
index 5cb2055..47ecd6f 100644
--- a/effects/seriously.fader.js
+++ b/effects/seriously.fader.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('fader', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.falsecolor.js b/effects/seriously.falsecolor.js
index a0d397e..a8a609f 100644
--- a/effects/seriously.falsecolor.js
+++ b/effects/seriously.falsecolor.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('falsecolor', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.filmgrain.js b/effects/seriously.filmgrain.js
index 58e8f25..1acaf83 100644
--- a/effects/seriously.filmgrain.js
+++ b/effects/seriously.filmgrain.js
@@ -29,6 +29,7 @@ Modified to preserve alpha
 	'use strict';
 
 	Seriously.plugin('filmgrain', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = [
 				'#ifdef GL_ES',
diff --git a/effects/seriously.hex.js b/effects/seriously.hex.js
index 373af4c..ff132e5 100644
--- a/effects/seriously.hex.js
+++ b/effects/seriously.hex.js
@@ -24,6 +24,7 @@
 	*/
 
 	Seriously.plugin('hex', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.highlights-shadows.js b/effects/seriously.highlights-shadows.js
index e48edee..5afa48c 100644
--- a/effects/seriously.highlights-shadows.js
+++ b/effects/seriously.highlights-shadows.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('highlights-shadows', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.hue-saturation.js b/effects/seriously.hue-saturation.js
index 377b7f6..d60c861 100644
--- a/effects/seriously.hue-saturation.js
+++ b/effects/seriously.hue-saturation.js
@@ -20,6 +20,7 @@
 	//inspired by Evan Wallace (https://github.com/evanw/glfx.js)
 
 	Seriously.plugin('hue-saturation', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.vertex = [
 				'#ifdef GL_ES',
diff --git a/effects/seriously.invert.js b/effects/seriously.invert.js
index 87b7efc..e49818d 100644
--- a/effects/seriously.invert.js
+++ b/effects/seriously.invert.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('invert', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.kaleidoscope.js b/effects/seriously.kaleidoscope.js
index 96e3a80..843fe1b 100644
--- a/effects/seriously.kaleidoscope.js
+++ b/effects/seriously.kaleidoscope.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('kaleidoscope', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = [
 				'#ifdef GL_ES',
diff --git a/effects/seriously.layers.js b/effects/seriously.layers.js
index ba19c7a..c1c7e77 100644
--- a/effects/seriously.layers.js
+++ b/effects/seriously.layers.js
@@ -140,6 +140,7 @@
 					this.frameBuffer.resize(width, height);
 				}
 
+				this.emit('resize');
 				this.setDirty();
 			}
 
@@ -149,6 +150,7 @@
 		};
 
 		return {
+			commonShader: true,
 			shader: function (inputs, shaderSource) {
 				shaderSource.vertex = [
 					'precision mediump float;',
@@ -189,7 +191,7 @@
 					'		gl_FragColor = vec4(0.0);',
 					'	} else {',
 					'		gl_FragColor = texture2D(source, vTexCoord);',
-					'		gl_FragColor.a *= opacity;',
+					'		gl_FragColor *= opacity;',
 					'	}',
 					'}'
 				].join('\n');
diff --git a/effects/seriously.linear-transfer.js b/effects/seriously.linear-transfer.js
index cf6b8fd..d036ac8 100644
--- a/effects/seriously.linear-transfer.js
+++ b/effects/seriously.linear-transfer.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('linear-transfer', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = [
 				'#ifdef GL_ES\n',
diff --git a/effects/seriously.lumakey.js b/effects/seriously.lumakey.js
index e123352..0e020be 100644
--- a/effects/seriously.lumakey.js
+++ b/effects/seriously.lumakey.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('lumakey', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.nightvision.js b/effects/seriously.nightvision.js
index c0043e8..1161c35 100644
--- a/effects/seriously.nightvision.js
+++ b/effects/seriously.nightvision.js
@@ -21,6 +21,7 @@
 	//todo: make noise better?
 
 	Seriously.plugin('nightvision', {
+		commonShader: true,
 		shader: function (inputs, shaderSource, utilities) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 					'precision mediump float;\n\n' +
diff --git a/effects/seriously.polar.js b/effects/seriously.polar.js
new file mode 100644
index 0000000..eb4d10a
--- /dev/null
+++ b/effects/seriously.polar.js
@@ -0,0 +1,58 @@
+/* global define, require */
+(function (root, factory) {
+	'use strict';
+
+	if (typeof exports === 'object') {
+		// Node/CommonJS
+		factory(require('seriously'));
+	} else if (typeof define === 'function' && define.amd) {
+		// AMD. Register as an anonymous module.
+		define(['seriously'], factory);
+	} else {
+		if (!root.Seriously) {
+			root.Seriously = { plugin: function (name, opt) { this[name] = opt; } };
+		}
+		factory(root.Seriously);
+	}
+}(this, function (Seriously, undefined) {
+	'use strict';
+
+	Seriously.plugin('polar', {
+		commonShader: true,
+		shader: function (inputs, shaderSource) {
+			shaderSource.fragment = '#ifdef GL_ES\n\n' +
+				'precision mediump float;\n\n' +
+				'#endif\n\n' +
+				'\n' +
+				'varying vec2 vTexCoord;\n' +
+				'varying vec4 vPosition;\n' +
+				'\n' +
+				'uniform sampler2D source;\n' +
+				'uniform float angle;\n' +
+				'const float PI = ' + Math.PI + ';\n' +
+				'\n' +
+				'void main(void) {\n' +
+				'	vec2 norm = (1.0 - vTexCoord) * 2.0 - 1.0;\n' +
+				'	float theta = mod(PI + atan(norm.x, norm.y) - angle * (PI / 180.0), PI * 2.0);\n' +
+				'	vec2 polar = vec2(theta / (2.0 * PI), length(norm));\n' +
+				'	gl_FragColor = texture2D(source, polar);\n' +
+				'}\n';
+			return shaderSource;
+		},
+		inPlace: false,
+		inputs: {
+			source: {
+				type: 'image',
+				uniform: 'source',
+				shaderDirty: false
+			},
+			angle: {
+				type: 'number',
+				uniform: 'angle',
+				defaultValue: 0
+			}
+		},
+		title: 'Polar Coordinates',
+		description: 'Convert cartesian to polar coordinates'
+	});
+}));
diff --git a/effects/seriously.repeat.js b/effects/seriously.repeat.js
index f53bf21..3796545 100644
--- a/effects/seriously.repeat.js
+++ b/effects/seriously.repeat.js
@@ -70,6 +70,9 @@
 				}
 			}
 
+			width = Math.floor(width);
+			height = Math.floor(height);
+
 			if (source) {
 				this.uniforms.resolution[0] = source.width;
 				this.uniforms.resolution[1] = source.height;
@@ -86,6 +89,7 @@
 					this.frameBuffer.resize(this.width, this.height);
 				}
 
+				this.emit('resize');
 				this.setDirty();
 			}
 
@@ -101,6 +105,7 @@
 				initialize();
 				this.uniforms.transform = transform;
 			},
+			commonShader: true,
 			shader: function (inputs, shaderSource) {
 				shaderSource.vertex = [
 					'precision mediump float;',
diff --git a/effects/seriously.ripple.js b/effects/seriously.ripple.js
index 8b3fff8..65bf4e7 100644
--- a/effects/seriously.ripple.js
+++ b/effects/seriously.ripple.js
@@ -19,6 +19,7 @@
 
 	//http://msdn.microsoft.com/en-us/library/bb313868(v=xnagamestudio.10).aspx
 	Seriously.plugin('ripple', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.scanlines.js b/effects/seriously.scanlines.js
index d0477fc..a02c984 100644
--- a/effects/seriously.scanlines.js
+++ b/effects/seriously.scanlines.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('scanlines', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 					'precision mediump float;\n\n' +
diff --git a/effects/seriously.select.js b/effects/seriously.select.js
new file mode 100644
index 0000000..847bb72
--- /dev/null
+++ b/effects/seriously.select.js
@@ -0,0 +1,169 @@
+/* global define, require */
+(function (root, factory) {
+	'use strict';
+
+	if (typeof exports === 'object') {
+		// Node/CommonJS
+		factory(require('seriously'));
+	} else if (typeof define === 'function' && define.amd) {
+		// AMD. Register as an anonymous module.
+		define(['seriously'], factory);
+	} else {
+		if (!root.Seriously) {
+			root.Seriously = { plugin: function (name, opt) { this[name] = opt; } };
+		}
+		factory(root.Seriously);
+	}
+}(this, function (Seriously, undefined) {
+	'use strict';
+
+	var intRegex = /\d+/;
+
+	Seriously.plugin('select', function (options) {
+		var count,
+			me = this,
+			i,
+			inputs;
+
+		function update() {
+			var i = me.inputs.active,
+				source;
+
+			source = me.inputs['source' + i];
+			me.texture = source && source.texture;
+		}
+
+		function resize() {
+			me.resize();
+		}
+
+		if (typeof options === 'number' && options >= 2) {
+			count = options;
+		} else {
+			count = options && options.count || 4;
+			count = Math.max(2, count);
+		}
+
+		inputs = {
+			active: {
+				type: 'number',
+				step: 1,
+				min: 0,
+				max: count - 1,
+				defaultValue: 0,
+				update: resize
+			},
+			sizeMode: {
+				type: 'enum',
+				defaultValue: '0',
+				options: [
+					'union',
+					'intersection',
+					'active'
+				],
+				update: resize
+			}
+		};
+
+		for (i = 0; i < count; i++) {
+			inputs.sizeMode.options.push(i.toString());
+			inputs.sizeMode.options.push('source' + i);
+
+			//source
+			inputs['source' + i] = {
+				type: 'image',
+				update: resize
+			};
+		}
+
+		this.uniforms.layerResolution = [1, 1];
+
+		// custom resize method
+		this.resize = function () {
+			var width,
+				height,
+				mode = this.inputs.sizeMode,
+				i,
+				n,
+				source,
+				a;
+
+			if (mode === 'union') {
+				width = 0;
+				height = 0;
+				for (i = 0; i < count; i++) {
+					source = this.inputs['source' + i];
+					if (source) {
+						width = Math.max(width, source.width);
+						height = Math.max(height, source.height);
+					}
+				}
+			} else if (mode === 'intersection') {
+				width = Infinity;
+				height = Infinity;
+				for (i = 0; i < count; i++) {
+					source = this.inputs['source' + i];
+					if (source) {
+						width = Math.min(width, source.width);
+						height = Math.min(height, source.height);
+					}
+				}
+			} else if (mode === 'active') {
+				i = this.inputs.active;
+				source = this.inputs['source' + i];
+				width = Math.max(1, source && source.width || 1);
+				height = Math.max(1, source && source.height || 1);
+			} else {
+				width = 1;
+				height = 1;
+				n = count - 1;
+				a = intRegex.exec(this.inputs.sizeMode);
+				if (a) {
+					n = Math.min(parseInt(a[0], 10), n);
+				}
+
+				for (i = 0; i <= n; i++) {
+					source = this.inputs['source' + i];
+					if (source) {
+						width = source.width;
+						height = source.height;
+						break;
+					}
+				}
+			}
+
+			if (this.width !== width || this.height !== height) {
+				this.width = width;
+				this.height = height;
+
+				this.emit('resize');
+				this.setDirty();
+			}
+
+			for (i = 0; i < this.targets.length; i++) {
+				this.targets[i].resize();
+			}
+		};
+
+		return {
+			initialize: function () {
+				this.initialized = true;
+				this.shaderDirty = false;
+			},
+			requires: function (sourceName) {
+				return !!(this.inputs[sourceName] && sourceName === 'source' + this.inputs.active);
+			},
+
+			//check the source texture on every draw just in case the source nodes pulls
+			//shenanigans with its texture.
+			draw: update,
+			inputs: inputs
+		};
+	},
+	{
+		title: 'Select',
+		description: 'Select a single source image from a list of source nodes.',
+		inPlace: false,
+		commonShader: true
+	});
+}));
diff --git a/effects/seriously.sepia.js b/effects/seriously.sepia.js
index eaf72f2..324057e 100644
--- a/effects/seriously.sepia.js
+++ b/effects/seriously.sepia.js
@@ -21,6 +21,7 @@
 	// http://www.techrepublic.com/blog/howdoi/how-do-i-convert-images-to-grayscale-and-sepia-tone-using-c/120
 
 	Seriously.plugin('sepia', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.simplex.js b/effects/seriously.simplex.js
index dd09e28..5ee4661 100644
--- a/effects/seriously.simplex.js
+++ b/effects/seriously.simplex.js
@@ -45,8 +45,6 @@
 					'varying vec2 vTexCoord;\n' +
 					'varying vec4 vPosition;\n' +
 					'\n' +
-					'uniform sampler2D source;\n' +
-					'\n' +
 					'uniform float amount;\n' +
 					'uniform vec2 noiseScale;\n' +
 					'uniform vec2 noiseOffset;\n' +
diff --git a/effects/seriously.sketch.js b/effects/seriously.sketch.js
index 3c15f91..223838f 100644
--- a/effects/seriously.sketch.js
+++ b/effects/seriously.sketch.js
@@ -20,6 +20,7 @@
 	/* inspired by http://lab.adjazent.com/2009/01/09/more-pixel-bender/ */
 
 	Seriously.plugin('sketch', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.split.js b/effects/seriously.split.js
index fc7567a..9e1d34d 100644
--- a/effects/seriously.split.js
+++ b/effects/seriously.split.js
@@ -72,6 +72,7 @@
 					this.frameBuffer.resize(width, height);
 				}
 
+				this.emit('resize');
 				this.setDirty();
 			}
 
@@ -94,10 +95,10 @@
 				initialize();
 				this.uniforms.resolutionA = resolutionA;
 				this.uniforms.resolutionB = resolutionB;
+				baseShader = this.baseShader;
 			},
+			commonShader: true,
 			shader: function (inputs, shaderSource) {
-				baseShader = new Seriously.util.ShaderProgram(this.gl, shaderSource.vertex, shaderSource.fragment);
-
 				shaderSource.vertex = [
 					'#ifdef GL_ES',
 					'precision mediump float;',
diff --git a/effects/seriously.tone.js b/effects/seriously.tone.js
index 94c4deb..6b4ad34 100644
--- a/effects/seriously.tone.js
+++ b/effects/seriously.tone.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('tone', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 				'precision mediump float;\n\n' +
diff --git a/effects/seriously.tvglitch.js b/effects/seriously.tvglitch.js
index 9a12ccd..f061dac 100644
--- a/effects/seriously.tvglitch.js
+++ b/effects/seriously.tvglitch.js
@@ -95,11 +95,12 @@
 
 				particleShader = new Seriously.util.ShaderProgram(gl, particleVertex, particleFragment);
 
-				particleFrameBuffer = new Seriously.util.FrameBuffer(gl, 1, this.height / 2);
+				particleFrameBuffer = new Seriously.util.FrameBuffer(gl, 1, Math.max(1, this.height / 2));
 				parent();
 			},
+			commonShader: true,
 			shader: function (inputs, shaderSource) {
-				//baseShader = new Seriously.util.ShaderProgram(this.gl, shaderSource.vertex, shaderSource.fragment);
+				//baseShader = this.baseShader;
 
 				shaderSource.fragment = '#ifdef GL_ES\n\n' +
 					'precision mediump float;\n\n' +
@@ -184,13 +185,15 @@
 
 				return shaderSource;
 			},
+			resize: function () {
+				particleFrameBuffer.resize(1, Math.max(1, this.height / 2));
+			},
 			draw: function (shader, model, uniforms, frameBuffer, parent) {
 				var doParticles = (lastTime !== this.inputs.time),
 					vsyncPeriod;
 
 				if (lastHeight !== this.height) {
 					lastHeight = this.height;
-					//todo: adjust framebuffer height?
 					doParticles = true;
 				}
 
diff --git a/effects/seriously.vignette.js b/effects/seriously.vignette.js
index 3ee6692..159d4fd 100644
--- a/effects/seriously.vignette.js
+++ b/effects/seriously.vignette.js
@@ -18,6 +18,7 @@
 	'use strict';
 
 	Seriously.plugin('vignette', {
+		commonShader: true,
 		shader: function (inputs, shaderSource) {
 			shaderSource.fragment = '#ifdef GL_ES\n\n' +
 					'precision mediump float;\n\n' +
diff --git a/effects/seriously.whitebalance.js b/effects/seriously.whitebalance.js
index a874d10..02c33e6 100644
--- a/effects/seriously.whitebalance.js
+++ b/effects/seriously.whitebalance.js
@@ -119,7 +119,7 @@
 			},
 			shader: function (inputs, shaderSource) {
 				var auto = inputs.auto;
-				//baseShader = new Seriously.util.ShaderProgram(this.gl, shaderSource.vertex, shaderSource.fragment);
+				//baseShader = this.baseShader;
 				//todo: gl.getExtension('OES_texture_float_linear')
 
 				if (auto && !pyramidShader) {
diff --git a/examples/camera/camera-source.html b/examples/camera/camera-source.html
new file mode 100644
index 0000000..9a13907
--- /dev/null
+++ b/examples/camera/camera-source.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<title>Seriously.js Camera Demo</title>
+</head>
+<body>
+	<!-- page content goes here -->
+
+	<canvas id="target" width="640" height="480"></canvas>
+
+	<script src="../../lib/html5slider.js"></script>
+	<script src="../../seriously.js"></script>
+	<script src="../../sources/seriously.camera.js"></script>
+	<script src="../../effects/seriously.edge.js"></script>
+	<script>
+	(function() {
+		//main code goes here
+
+		// declare our variables
+		var seriously, // the main object that holds the entire composition
+			source, // wrapper object for source video
+			edge, // edge detection effect
+			target; // a wrapper object for our target canvas
+
+		if (Seriously.incompatible('camera')) {
+			document.body.appendChild(document.createTextNode('Sorry, your browser does not support getUserMedia'));
+			document.querySelector('canvas').style.display = 'none';
+			return;
+		}
+
+		// construct our seriously object
+		seriously = new Seriously();
+
+		// time to get serious
+		source = seriously.source('camera');
+		target = seriously.target('#target');
+		edge = seriously.effect('edge');
+
+		// connect all our nodes in the right order
+		edge.source = source;
+		target.source = edge;
+
+		seriously.go();
+	}());
+	</script>
+</body>
+</html>
diff --git a/examples/camera/index.html b/examples/camera/index.html
index e29bf85..cba34be 100644
--- a/examples/camera/index.html
+++ b/examples/camera/index.html
@@ -48,13 +48,13 @@
 					if (video.mozCaptureStream) {
 						video.mozSrcObject = stream;
 					} else {
-						video.src = (URL && URL.createObjectURL( stream )) || stream;
+						video.src = (URL && URL.createObjectURL(stream)) || stream;
 					}
 					video.play();
 				}, 
 				// error callback
 				function(error){
-					console.log("An error occurred: " + (error.message || error.name) + "");
+					console.log('An error occurred: ' + (error.message || error.name));
 				}
 			);
 		}
diff --git a/examples/demo/select.html b/examples/demo/select.html
new file mode 100644
index 0000000..6247063
--- /dev/null
+++ b/examples/demo/select.html
@@ -0,0 +1,150 @@
+<!DOCTYPE html>
+<html>
+	<head>
+		<title>Seriously.js Switch Example</title>
+		<style type="text/css">
+			#canvas {
+				margin: auto;
+				display: block;
+				position: absolute;
+				top: 0;
+				left: 0;
+			}
+
+			#container {
+				position: relative;
+			}
+
+			#container > div {
+				position: absolute;
+				left: 0;
+				cursor: pointer;
+			}
+		</style>
+	</head>
+	<body>
+		<div id="container">
+			<canvas id="canvas" width="960" height="540"></canvas>
+		</div>
+		<img id="source" src="../images/robot.jpg" style="display: none"/>
+		<script src="../../lib/require.js"></script>
+		<script>
+		(function () {
+			require.config({
+				baseUrl: '../../'
+			});
+
+			var effects = [
+				'ascii',
+				'bleach-bypass',
+				'blur',
+				'daltonize',
+				'kaleidoscope'
+			];
+
+			require([
+				'seriously',
+				'effects/seriously.select',
+				'effects/seriously.layers'
+			], function (Seriously) {
+				// declare our variables
+				var seriously, // the main object that holds the entire composition
+					select,
+					target, // a wrapper object for our target canvas
+					main,
+					mainMove,
+					thumbnail,
+					thumbnailSize,
+					thumbnails = [],
+					transforms = [],
+					reformats = [],
+					container = document.getElementById('container');
+
+				seriously = new Seriously();
+				target = seriously.target('#canvas');
+				layers = seriously.effect('layers', {
+					count: effects.length + 1
+				});
+				select = seriously.effect('select', {
+					count: effects.length
+				});
+
+				main = seriously.transform('reformat');
+
+				thumbnailSize = target.height / effects.length;
+				thumbnail = seriously.transform('reformat');
+				thumbnail.mode = 'cover';
+				thumbnail.source = '#source';
+				thumbnail.width = thumbnailSize;
+				thumbnail.height = thumbnailSize;
+
+				/*
+				Connect all the fixed nodes
+				*/
+				main.source = select;
+				main.mode = 'cover';
+				main.height = target.height;
+				main.width = target.width;
+
+				layers.source0 = main;
+				target.source = layers;
+
+				//create and connect the images and thumbnails
+				effects.forEach(function (name, i) {
+					var layer = i + 1;
+					require(['effects/seriously.' + name], function () {
+						var thumbnailEffect,
+							effect,
+							transform,
+							reformat,
+							div;
+
+						// set up thumbnail
+						thumbnailEffect = seriously.effect(name);
+						thumbnails[i] = thumbnailEffect;
+						thumbnailEffect.source = thumbnail;
+
+						transform = seriously.transform('2d');
+						transforms[i] = transform;
+						transform.translateY = (target.height - thumbnailSize) / 2 - thumbnailSize * i;
+						transform.translateX = -(target.width - thumbnailSize) / 2;
+						transform.source = thumbnailEffect;
+
+						layers['source' + layer] = transform;
+						layers['opacity' + layer] = 0.6;
+
+						//set up main effect
+						reformat = seriously.transform('reformat');
+						reformat.mode = 'cover';
+						reformat.width = target.width;
+						reformat.height = target.height;
+						reformat.source = '#source';
+						effect = seriously.effect(name);
+						effect.source = reformat;
+						select['source' + i] = effect;
+
+						//use divs for mouse tracking 'cause it's easiest
+						div = document.createElement('div');
+						div.style.width = div.style.height = thumbnailSize + 'px';
+						div.style.top = (thumbnailSize * i) + 'px';
+						div.addEventListener('mouseover', function () {
+							layers['opacity' + layer] = 1;
+						});
+						div.addEventListener('mouseout', function () {
+							layers['opacity' + layer] = 0.6;
+						});
+						div.addEventListener('click', function () {
+							select.active = i;
+						});
+						container.appendChild(div);
+					});
+				}, function (err) {
+					console.log('unable to load effect: ' + name);
+				});
+
+				seriously.go();
+			});
+		}());
+		</script>
+	</body>
+</html>
diff --git a/examples/index.html b/examples/index.html
index 929449e..80db84f 100644
--- a/examples/index.html
+++ b/examples/index.html
@@ -14,9 +14,16 @@
 					<li><a href="blur/whip.html">Whip Pan</a></li>
 				</ul>
 			</li>
-			<li><a href="camera">Webcam (getUserMedia)</a></li>
+			<li>
+				Camera
+				<ul>
+					<li><a href="camera">getUserMedia</a></li>
+					<li><a href="camera/camera-source.html">Camera Source</a></li>
+				</ul>
+			</li>
 			<li><a href="channels">Channel Mapping</a></li>
 			<li><a href="color/linear-transfer.html">Linear Color Transfer</a></li>
+			<li><a href="demo/select.html">Select</a></li>
 			<li>Transforms
 				<ul>
 					<li><a href="transform/reformat.html">Reformat</a></li>
diff --git a/examples/transform/reformat.html b/examples/transform/reformat.html
index 1e65595..e94dee5 100644
--- a/examples/transform/reformat.html
+++ b/examples/transform/reformat.html
@@ -45,10 +45,7 @@ <h2>Target Canvas</h2>
 			reformat.width = 480;
 			reformat.height = 270;
 			//reformat.mode = '#mode';
-			reformat.mode = 'contain';
-			document.getElementById('mode').addEventListener('change', function () {
-				reformat.mode = this.value;
-			}, false);
+			reformat.mode = '#mode';
 
 			// connect all our nodes in the right order
 			reformat.source = '#source';
diff --git a/index.html b/index.html
index d0aa832..399dd45 100644
--- a/index.html
+++ b/index.html
@@ -10,6 +10,7 @@
 		<script type="text/javascript" src="effects/seriously.bleach-bypass.js"></script>
 		<script type="text/javascript" src="effects/seriously.blend.js"></script>
 		<script type="text/javascript" src="effects/seriously.brightness-contrast.js"></script>
+		<script type="text/javascript" src="effects/seriously.checkerboard.js"></script>
 		<script type="text/javascript" src="effects/seriously.chroma.js"></script>
 		<script type="text/javascript" src="effects/seriously.color.js"></script>
 		<script type="text/javascript" src="effects/seriously.colorcomplements.js"></script>
@@ -31,6 +32,7 @@
 		<script type="text/javascript" src="effects/seriously.directionblur.js"></script>
 		<script type="text/javascript" src="effects/seriously.nightvision.js"></script>
 		<script type="text/javascript" src="effects/seriously.noise.js"></script>
+		<script type="text/javascript" src="effects/seriously.polar.js"></script>
 		<script type="text/javascript" src="effects/seriously.ripple.js"></script>
 		<script type="text/javascript" src="effects/seriously.scanlines.js"></script>
 		<script type="text/javascript" src="effects/seriously.sepia.js"></script>
diff --git a/seriously.js b/seriously.js
index c0b57c4..0ec6712 100644
--- a/seriously.js
+++ b/seriously.js
@@ -16,7 +16,7 @@
 			}
 			return Seriously;
 		});
-	} else {
+	} else if (typeof root.Seriously !== 'function') {
 		// Browser globals
 		root.Seriously = factory(root);
 	}
@@ -35,10 +35,17 @@
 	incompatibility,
 	seriousEffects = {},
 	seriousTransforms = {},
+	seriousSources = {},
 	timeouts = [],
 	allEffectsByHook = {},
 	allTransformsByHook = {},
+	allSourcesByHook = {
+		canvas: [],
+		image: [],
+		video: []
+	},
 	identity,
+	maxSeriouslyId = 0,
 	nop = function () {},
 
 	/*
@@ -48,6 +55,7 @@
 	// http://www.w3.org/TR/css3-color/#svg-color
 	colorNames = {
 		transparent: [0, 0, 0, 0],
+		black: [0, 0, 0, 1],
 		red: [1, 0, 0, 1],
 		green: [0, 1, 0, 1],
 		blue: [0, 0, 1, 1],
@@ -377,7 +385,18 @@
 
 		canvas = document.createElement('canvas');
 		try {
-			testContext = canvas.getContext('experimental-webgl');
+			testContext = canvas.getContext('webgl');
+		} catch (webglError) {
+		}
+
+		if (!testContext) {
+			try {
+				testContext = canvas.getContext('experimental-webgl');
+			} catch (expWebglError) {
+			}
+		}
+
+		if (testContext) {
 			canvas.addEventListener('webglcontextlost', function (event) {
 				/*
 				If/When context is lost, just clear testContext and create
@@ -388,7 +407,7 @@
 					testContext = undefined;
 				}
 			}, false);
-		} catch (webglError) {
+		} else {
 			console.log('Unable to access WebGL.');
 		}
 
@@ -560,6 +579,7 @@
 		} else {
 			this.texture = gl.createTexture();
 			gl.bindTexture(gl.TEXTURE_2D, this.texture);
+			gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
 			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
 			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
 			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -871,7 +891,8 @@
 		}
 
 		//initialize object, private properties
-		var seriously = this,
+		var id = ++maxSeriouslyId,
+			seriously = this,
 			nodes = [],
 			nodesById = {},
 			nodeId = 0,
@@ -885,6 +906,7 @@
 			glCanvas,
 			gl,
 			rectangleModel,
+			commonShaders = {},
 			baseShader,
 			baseVertexShader, baseFragmentShader,
 			Node, SourceNode, EffectNode, TransformNode, TargetNode,
@@ -960,12 +982,9 @@
 
 			for (i = 0; i < effects.length; i++) {
 				node = effects[i];
-
 				node.gl = gl;
-
-				if (node.initialized) {
-					node.buildShader();
-				}
+				node.initialize();
+				node.buildShader();
 			}
 
 			for (i = 0; i < sources.length; i++) {
@@ -1086,13 +1105,13 @@
 			if (!options || options.blend === undefined || options.blend) {
 				gl.enable(gl.BLEND);
 				gl.blendFunc(
-					options && options.srcRGB || gl.SRC_ALPHA,
+					options && options.srcRGB || gl.ONE,
 					options && options.dstRGB || gl.ONE_MINUS_SRC_ALPHA
 				);
 
 				/*
 				gl.blendFuncSeparate(
-					options && options.srcRGB || gl.SRC_ALPHA,
+					options && options.srcRGB || gl.ONE,
 					options && options.dstRGB || gl.ONE_MINUS_SRC_ALPHA,
 					options && options.srcAlpha || gl.SRC_ALPHA,
 					options && options.dstAlpha || gl.DST_ALPHA
@@ -1143,9 +1162,20 @@
 			gl.enable(gl.DEPTH_TEST);
 		}
 
-		function findInputNode(source, options) {
+		function findInputNode(hook, source, options) {
 			var node, i;
 
+			if (typeof hook !== 'string' || !source && source !== 0) {
+				if (!options || typeof options !== 'object') {
+					options = source;
+				}
+				source = hook;
+			}
+
+			if (typeof hook !== 'string' || !seriousSources[hook]) {
+				hook = null;
+			}
+
 			if (source instanceof SourceNode ||
 					source instanceof EffectNode ||
 					source instanceof TransformNode) {
@@ -1164,12 +1194,13 @@
 				}
 
 				for (i = 0; i < sources.length; i++) {
-					if (sources[i].source === source) {
-						return sources[i];
+					node = sources[i];
+					if ((!hook || hook === node.hook) && node.compare && node.compare(source, options)) {
+						return node;
 					}
 				}
 
-				node = new SourceNode(source, options);
+				node = new SourceNode(hook, source, options);
 			}
 
 			return node;
@@ -1185,6 +1216,10 @@
 				return false;
 			}
 
+			if (node === original) {
+				return true;
+			}
+
 			sources = node.sources;
 
 			for (i in sources) {
@@ -1201,6 +1236,7 @@
 		}
 
 		Node = function () {
+			this.ready = false;
 			this.width = 1;
 			this.height = 1;
 
@@ -1216,17 +1252,48 @@
 
 			this.seriously = seriously;
 
+			this.listeners = {};
+
 			this.id = nodeId;
 			nodes.push(this);
 			nodesById[nodeId] = this;
 			nodeId++;
 		};
 
+		Node.prototype.setReady = function () {
+			var i;
+
+			if (!this.ready) {
+				this.emit('ready');
+				this.ready = true;
+				if (this.targets) {
+					for (i = 0; i < this.targets.length; i++) {
+						this.targets[i].setReady();
+					}
+				}
+			}
+		};
+
+		Node.prototype.setUnready = function () {
+			var i;
+
+			if (this.ready) {
+				this.emit('unready');
+				this.ready = false;
+				if (this.targets) {
+					for (i = 0; i < this.targets.length; i++) {
+						this.targets[i].setUnready();
+					}
+				}
+			}
+		};
+
 		Node.prototype.setDirty = function () {
 			//loop through all targets calling setDirty (depth-first)
 			var i;
 
 			if (!this.dirty) {
+				this.emit('dirty');
 				this.dirty = true;
 				if (this.targets) {
 					for (i = 0; i < this.targets.length; i++) {
@@ -1292,10 +1359,14 @@
 				height = 1;
 			}
 
+			width = Math.floor(width);
+			height = Math.floor(height);
+
 			if (this.width !== width || this.height !== height) {
 				this.width = width;
 				this.height = height;
 
+				this.emit('resize');
 				this.setDirty();
 			}
 
@@ -1309,12 +1380,68 @@
 			}
 		};
 
+		Node.prototype.on = function (eventName, callback) {
+			var listeners,
+				index = -1;
+
+			if (!eventName || typeof callback !== 'function') {
+				return;
+			}
+
+			listeners = this.listeners[eventName];
+			if (listeners) {
+				index = listeners.indexOf(callback);
+			} else {
+				listeners = this.listeners[eventName] = [];
+			}
+
+			if (index < 0) {
+				listeners.push(callback);
+			}
+		};
+
+		Node.prototype.off = function (eventName, callback) {
+			var listeners,
+				index = -1;
+
+			if (!eventName || typeof callback !== 'function') {
+				return;
+			}
+
+			listeners = this.listeners[eventName];
+			if (listeners) {
+				index = listeners.indexOf(callback);
+				if (index >= 0) {
+					listeners.splice(index, 1);
+				}
+			}
+		};
+
+		Node.prototype.emit = function (eventName) {
+			var i,
+				listeners = this.listeners[eventName];
+
+			if (listeners && listeners.length) {
+				for (i = 0; i < listeners.length; i++) {
+					setTimeoutZero(listeners[i]);
+				}
+			}
+		};
+
 		Node.prototype.destroy = function () {
-			var i;
+			var i,
+				key;
 
 			delete this.gl;
 			delete this.seriously;
 
+			//remove all listeners
+			for (key in this.listeners) {
+				if (this.listeners.hasOwnProperty(key)) {
+					delete this.listeners[key];
+				}
+			}
+
 			//clear out uniforms
 			for (i in this.uniforms) {
 				if (this.uniforms.hasOwnProperty(i)) {
@@ -1554,6 +1681,14 @@
 				return me.readPixels(x, y, width, height, dest);
 			};
 
+			this.on = function (eventName, callback) {
+				me.on(eventName, callback);
+			};
+
+			this.off = function (eventName, callback) {
+				me.off(eventName, callback);
+			};
+
 			this.alias = function (inputName, aliasName) {
 				me.alias(inputName, aliasName);
 				return this;
@@ -1585,10 +1720,15 @@
 			this.isDestroyed = function () {
 				return me.isDestroyed;
 			};
+
+			this.isReady = function () {
+				return me.ready;
+			};
 		};
 
 		EffectNode = function (hook, options) {
-			var key, name, input;
+			var key, name, input,
+				hasImage = false;
 
 			Node.call(this, options);
 
@@ -1632,13 +1772,22 @@
 					if (input.uniform) {
 						this.uniforms[input.uniform] = input.defaultValue;
 					}
+					if (input.type === 'image') {
+						hasImage = true;
+					}
 				}
 			}
 
 			if (gl) {
-				this.buildShader();
+				this.initialize();
+				if (this.effect.commonShader) {
+					//this effect is unlikely to need to be modified again
+					//by changing parameters
+					this.buildShader();
+				}
 			}
 
+			this.ready = !hasImage;
 			this.inPlace = this.effect.inPlace;
 
 			this.pub = new Effect(this);
@@ -1654,6 +1803,8 @@
 			if (!this.initialized) {
 				var that = this;
 
+				this.baseShader = baseShader;
+
 				if (this.shape) {
 					this.model = makeGlModel(this.shape, this.gl);
 				} else {
@@ -1690,6 +1841,61 @@
 			}
 		};
 
+		EffectNode.prototype.setReady = function () {
+			var i,
+				input,
+				key;
+
+			if (!this.ready) {
+				for (key in this.effect.inputs) {
+					if (this.effect.inputs.hasOwnProperty(key)) {
+						input = this.effect.inputs[key];
+						if (input.type === 'image' &&
+								(!this.sources[key] || !this.sources[key].ready)) {
+							return;
+						}
+					}
+				}
+
+				this.ready = true;
+				this.emit('ready');
+				if (this.targets) {
+					for (i = 0; i < this.targets.length; i++) {
+						this.targets[i].setReady();
+					}
+				}
+			}
+		};
+
+		EffectNode.prototype.setUnready = function () {
+			var i,
+				input,
+				key;
+
+			if (this.ready) {
+				for (key in this.effect.inputs) {
+					if (this.effect.inputs.hasOwnProperty(key)) {
+						input = this.effect.inputs[key];
+						if (input.type === 'image' &&
+								(!this.sources[key] || !this.sources[key].ready)) {
+							this.ready = false;
+							break;
+						}
+					}
+				}
+
+				if (!this.ready) {
+					this.emit('unready');
+					if (this.targets) {
+						for (i = 0; i < this.targets.length; i++) {
+							this.targets[i].setUnready();
+						}
+					}
+				}
+			}
+		};
+
+
 		EffectNode.prototype.setTarget = function (target) {
 			var i;
 			for (i = 0; i < this.targets.length; i++) {
@@ -1729,7 +1935,15 @@
 		EffectNode.prototype.buildShader = function () {
 			var shader, effect = this.effect;
 			if (this.shaderDirty) {
-				if (effect.shader) {
+				if (effect.commonShader && commonShaders[this.hook]) {
+					if (!this.shader) {
+						commonShaders[this.hook].count++;
+					}
+					this.shader = commonShaders[this.hook].shader;
+				} else if (effect.shader) {
+					if (this.shader && !effect.commonShader) {
+						this.shader.destroy();
+					}
 					shader = effect.shader.call(this, this.inputs, {
 						vertex: baseVertexShader,
 						fragment: baseFragmentShader
@@ -1742,6 +1956,13 @@
 					} else {
 						this.shader = baseShader;
 					}
+
+					if (effect.commonShader) {
+						commonShaders[this.hook] = {
+							count: 1,
+							shader: this.shader
+						};
+					}
 				} else {
 					this.shader = baseShader;
 				}
@@ -1788,8 +2009,10 @@
 
 				if (typeof effect.draw === 'function') {
 					effect.draw.call(this, this.shader, this.model, this.uniforms, frameBuffer, drawFn);
+					this.emit('render');
 				} else if (frameBuffer) {
 					draw(this.shader, this.model, this.uniforms, frameBuffer, this);
+					this.emit('render');
 				}
 
 				this.dirty = false;
@@ -1858,6 +2081,12 @@
 					this.shaderDirty = true;
 				}
 
+				if (value && value.ready) {
+					this.setReady();
+				} else {
+					this.setUnready();
+				}
+
 				this.setDirty();
 
 				if (input.update) {
@@ -2297,7 +2526,13 @@
 			delete this.effect;
 
 			//shader
-			if (this.shader && this.shader.destroy && this.shader !== baseShader) {
+			if (commonShaders[hook]) {
+				commonShaders[hook].count--;
+				if (!commonShaders[hook].count) {
+					delete commonShaders[hook];
+				}
+			}
+			if (this.shader && this.shader.destroy && this.shader !== baseShader && !commonShaders[hook]) {
 				this.shader.destroy();
 			}
 			delete this.shader;
@@ -2393,6 +2628,14 @@
 				return me.readPixels(x, y, width, height, dest);
 			};
 
+			this.on = function (eventName, callback) {
+				me.on(eventName, callback);
+			};
+
+			this.off = function (eventName, callback) {
+				me.off(eventName, callback);
+			};
+
 			this.destroy = function () {
 				var i,
 					descriptor;
@@ -2415,106 +2658,133 @@
 			this.isDestroyed = function () {
 				return me.isDestroyed;
 			};
+
+			this.isReady = function () {
+				return me.ready;
+			};
 		};
 
 		/*
 			possible sources: img, video, canvas (2d or 3d), texture, ImageData, array, typed array
 		*/
-		SourceNode = function (source, options) {
+		SourceNode = function (hook, source, options) {
 			var opts = options || {},
 				flip = opts.flip === undefined ? true : opts.flip,
 				width = opts.width,
 				height = opts.height,
 				deferTexture = false,
 				that = this,
-				matchedType = false;
+				matchedType = false,
+				key,
+				plugin;
+
+			function sourcePlugin(hook, source, options, force) {
+				var plugin = seriousSources[hook];
+				if (plugin.definition) {
+					plugin = plugin.definition.call(that, source, options, force);
+					if (plugin) {
+						plugin = extend(extend({}, seriousSources[hook]), plugin);
+					} else {
+						return null;
+					}
+				}
+				return plugin;
+			}
+
+			function compareSource(source) {
+				return that.source === source;
+			}
+
+			function initializeVideo() {
+				if (that.isDestroyed) {
+					return;
+				}
+
+				if (source.videoWidth) {
+					that.width = source.videoWidth;
+					that.height = source.videoHeight;
+					if (deferTexture) {
+						that.setReady();
+					}
+				} else {
+					//Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=926753
+					deferTexture = true;
+					setTimeout(initializeVideo, 50);
+				}
+			}
 
 			Node.call(this);
 
+			if (hook && typeof hook !== 'string' || !source && source !== 0) {
+				if (!options || typeof options !== 'object') {
+					options = source;
+				}
+				source = hook;
+			}
+
 			if (typeof source === 'string' && isNaN(source)) {
 				source = getElement(source, ['canvas', 'img', 'video']);
 			}
 
-			if (source instanceof HTMLElement) {
+			// forced source type?
+			if (typeof hook === 'string' && seriousSources[hook]) {
+				plugin = sourcePlugin(hook, source, options, true);
+				if (plugin) {
+					this.hook = hook;
+					matchedType = true;
+					deferTexture = plugin.deferTexture;
+					this.plugin = plugin;
+					this.compare = plugin.compare;
+					if (plugin.source) {
+						source = plugin.source;
+					}
+				}
+			}
+
+			//todo: could probably stand to re-work and re-indent this whole block now that we have plugins
+			if (!plugin && source instanceof HTMLElement) {
 				if (source.tagName === 'CANVAS') {
 					this.width = source.width;
 					this.height = source.height;
 
 					this.render = this.renderImageCanvas;
+					matchedType = true;
+					this.hook = 'canvas';
+					this.compare = compareSource;
 				} else if (source.tagName === 'IMG') {
 					this.width = source.naturalWidth || 1;
 					this.height = source.naturalHeight || 1;
 
-					if (!source.complete) {
+					if (!source.complete || !source.naturalWidth) {
 						deferTexture = true;
 
 						source.addEventListener('load', function () {
-							that.width = source.naturalWidth;
-							that.height = source.naturalHeight;
-							that.resize();
-							that.initialize();
+							if (!that.isDestroyed) {
+								that.width = source.naturalWidth;
+								that.height = source.naturalHeight;
+								that.setReady();
+							}
 						}, true);
 					}
 
 					this.render = this.renderImageCanvas;
+					matchedType = true;
+					this.hook = 'image';
+					this.compare = compareSource;
 				} else if (source.tagName === 'VIDEO') {
-					this.width = source.videoWidth || 1;
-					this.height = source.videoHeight || 1;
-
-					if (!source.readyState) {
+					if (source.readyState) {
+						initializeVideo();
+					} else {
 						deferTexture = true;
-
-						source.addEventListener('loadedmetadata', function () {
-							that.width = source.videoWidth;
-							that.height = source.videoHeight;
-							that.resize();
-							that.initialize();
-						}, true);
+						source.addEventListener('loadedmetadata', initializeVideo, true);
 					}
 
 					this.render = this.renderVideo;
-				} else {
-					throw 'Not a valid HTML element: ' + source.tagName + ' (must be img, video or canvas)';
-				}
-				matchedType = true;
-
-			} else if (source instanceof Object && source.data &&
-				source.width && source.height &&
-				source.width * source.height * 4 === source.data.length
-				) {
-
-				//Because of this bug, Firefox doesn't recognize ImageData, so we have to duck type
-				//https://bugzilla.mozilla.org/show_bug.cgi?id=637077
-
-				this.width = source.width;
-				this.height = source.height;
-				matchedType = true;
-
-				this.render = this.renderImageCanvas;
-			} else if (isArrayLike(source)) {
-				if (!width || !height) {
-					throw 'Height and width must be provided with an Array';
-				}
-
-				if (width * height * 4 !== source.length) {
-					throw 'Array length must be height x width x 4.';
-				}
-
-				this.width = width;
-				this.height = height;
-
-				matchedType = true;
-
-				//use opposite default for flip
-				if (opts.flip === undefined) {
-					flip = false;
+					matchedType = true;
+					this.hook = 'video';
+					this.compare = compareSource;
 				}
-
-				if (!(source instanceof Uint8Array)) {
-					source = new Uint8Array(source);
-				}
-				this.render = this.renderTypedArray;
-			} else if (source instanceof WebGLTexture) {
+			} else if (!plugin && source instanceof WebGLTexture) {
 				if (gl && !gl.isTexture(source)) {
 					throw 'Not a valid WebGL texture.';
 				}
@@ -2541,9 +2811,29 @@
 
 				this.texture = source;
 				this.initialized = true;
+				this.hook = 'texture';
+				this.compare = compareSource;
 
 				//todo: if WebGLTexture source is from a different context render it and copy it over
 				this.render = function () {};
+			} else if (!plugin) {
+				for (key in seriousSources) {
+					if (seriousSources.hasOwnProperty(key) && seriousSources[key]) {
+						plugin = sourcePlugin(key, source, options, false);
+						if (plugin) {
+							this.hook = key;
+							matchedType = true;
+							deferTexture = plugin.deferTexture;
+							this.plugin = plugin;
+							this.compare = plugin.compare;
+							if (plugin.source) {
+								source = plugin.source;
+							}
+
+							break;
+						}
+					}
+				}
 			}
 
 			if (!matchedType) {
@@ -2551,18 +2841,20 @@
 			}
 
 			this.source = source;
-			this.flip = flip;
+			if (this.flip === undefined) {
+				this.flip = flip;
+			}
 
 			this.targets = [];
 
 			if (!deferTexture) {
-				this.resize();
-				this.initialize();
+				that.setReady();
 			}
 
 			this.pub = new Source(this);
 
 			sources.push(this);
+			allSourcesByHook[this.hook].push(this);
 
 			if (sources.length && !rafId) {
 				renderDaemon();
@@ -2572,12 +2864,15 @@
 		extend(SourceNode, Node);
 
 		SourceNode.prototype.initialize = function () {
-			if (!gl || this.texture) {
+			var texture;
+
+			if (!gl || this.texture || !this.ready) {
 				return;
 			}
 
-			var texture = gl.createTexture();
+			texture = gl.createTexture();
 			gl.bindTexture(gl.TEXTURE_2D, texture);
+			gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
 			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
 			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
 			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -2628,21 +2923,64 @@
 				this.framebuffer.resize(this.width, this.height);
 			}
 
+			this.emit('resize');
 			this.setDirty();
 
-			for (i = 0; i < this.targets.length; i++) {
-				target = this.targets[i];
-				target.resize();
-				if (target.setTransformDirty) {
-					target.setTransformDirty();
+			if (this.targets) {
+				for (i = 0; i < this.targets.length; i++) {
+					target = this.targets[i];
+					target.resize();
+					if (target.setTransformDirty) {
+						target.setTransformDirty();
+					}
 				}
 			}
 		};
 
+		SourceNode.prototype.setReady = function () {
+			var i;
+			if (!this.ready) {
+				this.ready = true;
+				this.resize();
+				this.initialize();
+
+				this.emit('ready');
+				if (this.targets) {
+					for (i = 0; i < this.targets.length; i++) {
+						this.targets[i].setReady();
+					}
+				}
+
+			}
+		};
+
+		SourceNode.prototype.render = function () {
+			var media = this.source;
+
+			if (!gl || !media && media !== 0 || !this.ready) {
+				return;
+			}
+
+			if (!this.initialized) {
+				this.initialize();
+			}
+
+			if (!this.allowRefresh) {
+				return;
+			}
+
+			if (this.plugin && this.plugin.render &&
+					this.plugin.render.call(this, gl, draw, rectangleModel, baseShader)) {
+
+				this.dirty = false;
+				this.emit('render');
+			}
+		};
+
 		SourceNode.prototype.renderVideo = function () {
 			var video = this.source;
 
-			if (!gl || !video || !video.videoHeight || !video.videoWidth || video.readyState < 2) {
+			if (!gl || !video || !video.videoHeight || !video.videoWidth || video.readyState < 2 || !this.ready) {
 				return;
 			}
 
@@ -2660,10 +2998,11 @@
 
 				gl.bindTexture(gl.TEXTURE_2D, this.texture);
 				gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flip);
+				gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
 				try {
 					gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
 				} catch (securityError) {
-					if (securityError.name === 'SECURITY_ERR') {
+					if (securityError.code === window.DOMException.SECURITY_ERR) {
 						this.allowRefresh = false;
 						console.log('Unable to access cross-domain image');
 					}
@@ -2676,13 +3015,14 @@
 				this.lastRenderFrame = video.mozPresentedFrames;
 				this.lastRenderTimeStamp = Date.now();
 				this.dirty = false;
+				this.emit('render');
 			}
 		};
 
 		SourceNode.prototype.renderImageCanvas = function () {
 			var media = this.source;
 
-			if (!gl || !media || !media.height || !media.width) {
+			if (!gl || !media || !this.ready) {
 				return;
 			}
 
@@ -2697,10 +3037,11 @@
 			if (this.dirty) {
 				gl.bindTexture(gl.TEXTURE_2D, this.texture);
 				gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flip);
+				gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
 				try {
 					gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, media);
 				} catch (securityError) {
-					if (securityError.name === 'SECURITY_ERR') {
+					if (securityError.code === window.DOMException.SECURITY_ERR) {
 						this.allowRefresh = false;
 						console.log('Unable to access cross-domain image');
 					}
@@ -2708,41 +3049,19 @@
 
 				this.lastRenderTime = Date.now() / 1000;
 				this.dirty = false;
-			}
-		};
-
-		SourceNode.prototype.renderTypedArray = function () {
-			var media = this.source;
-
-			if (!gl || !media || !media.length) {
-				return;
-			}
-
-			if (!this.initialized) {
-				this.initialize();
-			}
-
-			//this.currentTime = media.currentTime || 0;
-
-			if (!this.allowRefresh) {
-				return;
-			}
-
-			if (this.dirty) {
-				gl.bindTexture(gl.TEXTURE_2D, this.texture);
-				gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flip);
-				gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.width, this.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, media);
-
-				this.lastRenderTime = Date.now() / 1000;
-				this.dirty = false;
+				this.emit('render');
 			}
 		};
 
 		SourceNode.prototype.destroy = function () {
 			var i, key, item;
 
-			if (this.gl && this.texture) {
-				this.gl.deleteTexture(this.texture);
+			if (this.plugin && this.plugin.destroy) {
+				this.plugin.destroy.call(this);
+			}
+
+			if (gl && this.texture) {
+				gl.deleteTexture(this.texture);
 			}
 
 			//targets
@@ -2759,6 +3078,11 @@
 				sources.splice(i, 1);
 			}
 
+			i = allSourcesByHook[this.hook].indexOf(this);
+			if (i >= 0) {
+				allSourcesByHook[this.hook].splice(i, 1);
+			}
+
 			for (key in this) {
 				if (this.hasOwnProperty(key) && key !== 'id') {
 					delete this[key];
@@ -2873,6 +3197,14 @@
 				return me.readPixels(x, y, width, height, dest);
 			};
 
+			this.on = function (eventName, callback) {
+				me.on(eventName, callback);
+			};
+
+			this.off = function (eventName, callback) {
+				me.off(eventName, callback);
+			};
+
 			this.go = function (options) {
 				me.go(options);
 			};
@@ -2907,6 +3239,10 @@
 			this.isDestroyed = function () {
 				return me.isDestroyed;
 			};
+
+			this.isReady = function () {
+				return me.ready;
+			};
 		};
 
 		/*
@@ -2954,7 +3290,7 @@
 					target = opts.context.canvas;
 				} else {
 					//todo: search all canvases for matching contexts?
-					throw "Must provide a canvas with WebGLFramebuffer target";
+					throw 'Must provide a canvas with WebGLFramebuffer target';
 				}
 			}
 
@@ -2965,14 +3301,14 @@
 				//todo: try to get a webgl context. if not, get a 2d context, and set up a different render function
 				try {
 					if (window.WebGLDebugUtils) {
-						context = window.WebGLDebugUtils.makeDebugContext(target.getContext('experimental-webgl', {
+						context = window.WebGLDebugUtils.makeDebugContext(target.getContext('webgl', {
 							alpha: true,
 							premultipliedAlpha: false,
 							preserveDrawingBuffer: true,
 							stencil: true
 						}));
 					} else {
-						context = target.getContext('experimental-webgl', {
+						context = target.getContext('webgl', {
 							alpha: true,
 							premultipliedAlpha: false,
 							preserveDrawingBuffer: true,
@@ -2984,14 +3320,13 @@
 
 				if (!context) {
 					try {
-						context = target.getContext('webgl', {
+						context = target.getContext('experimental-webgl', {
 							alpha: true,
 							premultipliedAlpha: false,
 							preserveDrawingBuffer: true,
 							stencil: true
 						});
 					} catch (error) {
-
 					}
 				}
 
@@ -3087,6 +3422,12 @@
 				this.source = newSource;
 				newSource.setTarget(this);
 
+				if (newSource && newSource.ready) {
+					this.setReady();
+				} else {
+					this.setUnready();
+				}
+
 				this.setDirty();
 			}
 		};
@@ -3107,6 +3448,7 @@
 				this.height = this.target.height;
 				this.uniforms.resolution[0] = this.width;
 				this.uniforms.resolution[1] = this.height;
+				this.emit('resize');
 				this.setTransformDirty();
 			}
 
@@ -3167,12 +3509,14 @@
 
 				draw(baseShader, rectangleModel, this.uniforms, this.frameBuffer.frameBuffer, this);
 
+				this.emit('render');
 				this.dirty = false;
 			}
 		};
 
 		TargetNode.prototype.renderSecondaryWebGL = function () {
 			if (this.dirty && this.source) {
+				this.emit('render');
 				this.source.render();
 
 				var width = this.source.width,
@@ -3223,6 +3567,8 @@
 				targets.splice(i, 1);
 			}
 
+			//todo: if this.gl === gl, clear out context so we can start over
+
 			Node.prototype.destroy.call(this);
 		};
 
@@ -3231,6 +3577,110 @@
 				self = this,
 				key;
 
+			function setInput(inputName, def, input) {
+				var key, lookup, value;
+
+				lookup = me.inputElements[inputName];
+
+				//todo: there is some duplicate code with Effect here. Consolidate.
+				if (typeof input === 'string' && isNaN(input)) {
+					if (def.type === 'enum') {
+						if (def.options && def.options.filter) {
+							key = String(input).toLowerCase();
+
+							//todo: possible memory leak on this function?
+							value = def.options.filter(function (e) {
+								return (typeof e === 'string' && e.toLowerCase() === key) ||
+									(e.length && typeof e[0] === 'string' && e[0].toLowerCase() === key);
+							});
+
+							value = value.length;
+						}
+
+						if (!value) {
+							input = getElement(input, ['select']);
+						}
+
+					} else if (def.type === 'number' || def.type === 'boolean') {
+						input = getElement(input, ['input', 'select']);
+					} else if (def.type === 'image') {
+						input = getElement(input, ['canvas', 'img', 'video']);
+					}
+				}
+
+				if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement) {
+					value = input.value;
+
+					if (lookup && lookup.element !== input) {
+						lookup.element.removeEventListener('change', lookup.listener, true);
+						lookup.element.removeEventListener('input', lookup.listener, true);
+						delete me.inputElements[inputName];
+						lookup = null;
+					}
+
+					if (!lookup) {
+						lookup = {
+							element: input,
+							listener: (function (name, element) {
+								return function () {
+									var oldValue, newValue;
+
+									if (input.type === 'checkbox') {
+										//special case for check box
+										oldValue = input.checked;
+									} else {
+										oldValue = element.value;
+									}
+
+									if (def.set.call(me, oldValue)) {
+										me.setTransformDirty();
+									}
+
+									newValue = def.get.call(me);
+
+									//special case for color type
+									/*
+									no colors on transform nodes just yet. maybe later
+									if (def.type === 'color') {
+										newValue = arrayToHex(newValue);
+									}
+									*/
+
+									//if input validator changes our value, update HTML Element
+									//todo: make this optional...somehow
+									if (newValue !== oldValue) {
+										element.value = newValue;
+									}
+								};
+							}(inputName, input))
+						};
+
+						me.inputElements[inputName] = lookup;
+						if (input.type === 'range') {
+							input.addEventListener('input', lookup.listener, true);
+							input.addEventListener('change', lookup.listener, true);
+						} else {
+							input.addEventListener('change', lookup.listener, true);
+						}
+					}
+
+					if (lookup && value.type === 'checkbox') {
+						value = value.checked;
+					}
+				} else {
+					if (lookup) {
+						lookup.element.removeEventListener('change', lookup.listener, true);
+						lookup.element.removeEventListener('input', lookup.listener, true);
+						delete me.inputElements[inputName];
+					}
+					value = input;
+				}
+
+				if (def.set.call(me, value)) {
+					me.setTransformDirty();
+				}
+			}
+
 			function setProperty(name, def) {
 				// todo: validate value passed to 'set'
 				Object.defineProperty(self, name, {
@@ -3240,9 +3690,7 @@
 						return def.get.call(me);
 					},
 					set: function (val) {
-						if (def.set.call(me, val)) {
-							me.setTransformDirty();
-						}
+						setInput(name, def, val);
 					}
 				});
 			}
@@ -3255,6 +3703,8 @@
 				};
 			}
 
+			this.inputElements = {};
+
 			//priveleged accessor methods
 			Object.defineProperties(this, {
 				id: {
@@ -3298,6 +3748,14 @@
 				return this;
 			};
 
+			this.on = function (eventName, callback) {
+				me.on(eventName, callback);
+			};
+
+			this.off = function (eventName, callback) {
+				me.off(eventName, callback);
+			};
+
 			this.destroy = function () {
 				var i,
 					descriptor;
@@ -3321,6 +3779,10 @@
 			this.isDestroyed = function () {
 				return me.isDestroyed;
 			};
+
+			this.isReady = function () {
+				return me.ready;
+			};
 		};
 
 		TransformNode = function (hook, options) {
@@ -3330,6 +3792,7 @@
 			this.matrix = new Float32Array(16);
 			this.cumulativeMatrix = new Float32Array(16);
 
+			this.ready = false;
 			this.width = 1;
 			this.height = 1;
 
@@ -3348,6 +3811,7 @@
 			this.inputElements = {};
 			this.inputs = {};
 			this.methods = {};
+			this.listeners = {};
 
 			this.texture = null;
 			this.frameBuffer = null;
@@ -3422,6 +3886,10 @@
 
 			Node.prototype.resize.call(this);
 
+			if (this.plugin.resize) {
+				this.plugin.resize.call(this);
+			}
+
 			for (i = 0; i < this.targets.length; i++) {
 				this.targets[i].resize();
 			}
@@ -3450,6 +3918,11 @@
 			this.source = newSource;
 			newSource.setTarget(this);
 
+			if (newSource && newSource.ready) {
+				this.setReady();
+			} else {
+				this.setUnready();
+			}
 			this.resize();
 		};
 
@@ -3647,6 +4120,12 @@
 			Node.prototype.destroy.call(this);
 		};
 
+		TransformNode.prototype.setReady = Node.prototype.setReady;
+		TransformNode.prototype.setUnready = Node.prototype.setUnready;
+		TransformNode.prototype.on = Node.prototype.on;
+		TransformNode.prototype.off = Node.prototype.off;
+		TransformNode.prototype.emit = Node.prototype.emit;
+
 		/*
 		Initialize Seriously object based on options
 		*/
@@ -3674,9 +4153,8 @@
 			return effectNode.pub;
 		};
 
-		this.source = function (source, options) {
-			var sourceNode = findInputNode(source, options);
-			//var sourceNode = new SourceNode(source, options);
+		this.source = function (hook, source, options) {
+			var sourceNode = findInputNode(hook, source, options);
 			return sourceNode.pub;
 		};
 
@@ -3827,25 +4305,34 @@
 			return isDestroyed;
 		};
 
-		this.incompatible = function (pluginHook) {
-			var i,
+		this.incompatible = function (hook) {
+			var key,
 				plugin,
 				failure = false;
 
-			failure = Seriously.incompatible(pluginHook);
+			failure = Seriously.incompatible(hook);
 
 			if (failure) {
 				return failure;
 			}
 
-			if (!pluginHook) {
-				for (i in allEffectsByHook) {
-					if (allEffectsByHook.hasOwnProperty(i) && allEffectsByHook[i].length) {
-						plugin = seriousEffects[i];
+			if (!hook) {
+				for (key in allEffectsByHook) {
+					if (allEffectsByHook.hasOwnProperty(key) && allEffectsByHook[key].length) {
+						plugin = seriousEffects[key];
 						if (plugin && typeof plugin.compatible === 'function' &&
-							!plugin.compatible.call(this)) {
+								!plugin.compatible.call(this)) {
+							return 'plugin-' + key;
+						}
+					}
+				}
 
-							return 'plugin-' + i;
+				for (key in allSourcesByHook) {
+					if (allSourcesByHook.hasOwnProperty(key) && allSourcesByHook[key].length) {
+						plugin = seriousSources[key];
+						if (plugin && typeof plugin.compatible === 'function' &&
+								!plugin.compatible.call(this)) {
+							return 'source-' + key;
 						}
 					}
 				}
@@ -3854,6 +4341,16 @@
 			return false;
 		};
 
+		Object.defineProperties(this, {
+			id: {
+				enumerable: true,
+				configurable: true,
+				get: function () {
+					return id;
+				}
+			}
+		});
+
 		//todo: load, save, find
 
 		baseVertexShader = [
@@ -3903,7 +4400,7 @@
 		].join('\n');
 	}
 
-	Seriously.incompatible = function (pluginHook) {
+	Seriously.incompatible = function (hook) {
 		var canvas, gl, plugin;
 
 		if (incompatibility === undefined) {
@@ -3924,12 +4421,19 @@
 			return incompatibility;
 		}
 
-		if (pluginHook) {
-			plugin = seriousEffects[pluginHook];
+		if (hook) {
+			plugin = seriousEffects[hook];
+			if (plugin && typeof plugin.compatible === 'function' &&
+				!plugin.compatible(gl)) {
+
+				return 'plugin-' + hook;
+			}
+
+			plugin = seriousSources[hook];
 			if (plugin && typeof plugin.compatible === 'function' &&
 				!plugin.compatible(gl)) {
 
-				return 'plugin-' + pluginHook;
+				return 'source-' + hook;
 			}
 		}
 
@@ -3994,7 +4498,7 @@
 		all = allEffectsByHook[hook];
 		if (all) {
 			while (all.length) {
-				effect = all[0];
+				effect = all.shift();
 				effect.destroy();
 			}
 			delete allEffectsByHook[hook];
@@ -4005,6 +4509,66 @@
 		return this;
 	};
 
+	Seriously.source = function (hook, definition, meta) {
+		var source;
+
+		if (seriousSources[hook]) {
+			console.log('Source [' + hook + '] already loaded');
+			return;
+		}
+
+		if (meta === undefined && typeof definition === 'object') {
+			meta = definition;
+		}
+
+		if (!meta && !definition) {
+			return;
+		}
+
+		source = extend({}, meta);
+
+		if (typeof definition === 'function') {
+			source.definition = definition;
+		}
+
+		if (!source.title) {
+			source.title = hook;
+		}
+
+
+		seriousSources[hook] = source;
+		allSourcesByHook[hook] = [];
+
+		return source;
+	};
+
+	Seriously.removeSource = function (hook) {
+		var all, source, plugin;
+
+		if (!hook) {
+			return this;
+		}
+
+		plugin = seriousSources[hook];
+
+		if (!plugin) {
+			return this;
+		}
+
+		all = allSourcesByHook[hook];
+		if (all) {
+			while (all.length) {
+				source = all.shift();
+				source.destroy();
+			}
+			delete allSourcesByHook[hook];
+		}
+
+		delete seriousSources[hook];
+
+		return this;
+	};
+
 	Seriously.transform = function (hook, definition, meta) {
 		var transform;
 
@@ -4061,7 +4625,7 @@
 		all = allTransformsByHook[hook];
 		if (all) {
 			while (all.length) {
-				transform = all[0];
+				transform = all.shift();
 				transform.destroy();
 			}
 			delete allTransformsByHook[hook];
@@ -4268,6 +4832,21 @@
 			}
 
 			return true;
+		},
+		'string': function (value) {
+			if (typeof value === 'string') {
+				return value;
+			}
+
+			if (value !== 0 && !value) {
+				return '';
+			}
+
+			if (value.toString) {
+				return value.toString();
+			}
+
+			return String(value);
 		}
 		//todo: date/time
 	};
@@ -4338,9 +4917,6 @@
 					}
 				}
 			}());
-		} else {
-			//seriously has already been loaded, so don't replace it
-			return;
 		}
 	}
 
@@ -4575,7 +5151,7 @@
 						}
 
 						//todo: fmod 360deg or Math.PI * 2 radians
-						rotation = angle;
+						rotation = parseFloat(angle);
 
 						recompute();
 						return true;
@@ -4947,6 +5523,7 @@
 				width: {
 					get: getWidth,
 					set: function (x) {
+						x = Math.floor(x);
 						if (x === forceWidth) {
 							return false;
 						}
@@ -4963,6 +5540,7 @@
 				height: {
 					get: getHeight,
 					set: function (y) {
+						y = Math.floor(y);
 						if (y === forceHeight) {
 							return false;
 						}
diff --git a/sources/seriously.array.js b/sources/seriously.array.js
new file mode 100644
index 0000000..13a670c
--- /dev/null
+++ b/sources/seriously.array.js
@@ -0,0 +1,87 @@
+/* global define, require */
+(function (root, factory) {
+	'use strict';
+
+	if (typeof exports === 'object') {
+		// Node/CommonJS
+		factory(require('seriously'));
+	} else if (typeof define === 'function' && define.amd) {
+		// AMD. Register as an anonymous module.
+		define(['seriously'], factory);
+	} else {
+		/*
+		todo: build out-of-order loading for sources and transforms or remove this
+		if (!root.Seriously) {
+			root.Seriously = { plugin: function (name, opt) { this[name] = opt; } };
+		}
+		*/
+		factory(root.Seriously);
+	}
+}(this, function (Seriously, undefined) {
+	'use strict';
+
+	Seriously.source('array', function (source, options, force) {
+		var width,
+			height,
+			typedArray;
+
+		if (options && (Array.isArray(source) ||
+				(source && source.BYTES_PER_ELEMENT && 'length' in source))) {
+
+			width = options.width;
+			height = options.height;
+
+			if (!width || !height) {
+				if (force) {
+					throw 'Height and width must be provided with an Array';
+				}
+				return;
+			}
+
+			if (width * height * 4 !== source.length) {
+				if (force) {
+					throw 'Array length must be height x width x 4.';
+				}
+				return;
+			}
+
+			this.width = width;
+			this.height = height;
+
+			//use opposite default for flip
+			if (options.flip === undefined) {
+				this.flip = false;
+			}
+
+			if (!(source instanceof Uint8Array)) {
+				typedArray = new Uint8Array(source);
+			}
+
+			return {
+				render: function (gl) {
+					var i;
+					if (this.dirty) {
+						//pixel array can be updated, but we need to load from the typed array
+						//todo: see if there's a faster copy method
+						if (typedArray) {
+							for (i = 0; i < typedArray.length; i++) {
+								typedArray[i] = source[i];
+							}
+						}
+
+						gl.bindTexture(gl.TEXTURE_2D, this.texture);
+						gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flip);
+						gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.width, this.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, typedArray || source);
+
+						this.lastRenderTime = Date.now() / 1000;
+
+						return true;
+					}
+				}
+			};
+		}
+	}, {
+		title: 'Array',
+		description: 'Array or Uint8Array'
+	});
+}));
\ No newline at end of file
diff --git a/sources/seriously.camera.js b/sources/seriously.camera.js
new file mode 100644
index 0000000..8c1b301
--- /dev/null
+++ b/sources/seriously.camera.js
@@ -0,0 +1,116 @@
+/* global define, require */
+(function (root, factory) {
+	'use strict';
+
+	if (typeof exports === 'object') {
+		// Node/CommonJS
+		factory(require('seriously'));
+	} else if (typeof define === 'function' && define.amd) {
+		// AMD. Register as an anonymous module.
+		define(['seriously'], factory);
+	} else {
+		/*
+		todo: build out-of-order loading for sources and transforms or remove this
+		if (!root.Seriously) {
+			root.Seriously = { plugin: function (name, opt) { this[name] = opt; } };
+		}
+		*/
+		factory(root.Seriously);
+	}
+}(this, function (Seriously, undefined) {
+	'use strict';
+
+	var getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia,
+
+	// detect browser-prefixed window.URL
+	URL = window.URL || window.webkitURL || window.mozURL || window.msURL;
+
+	Seriously.source('camera', function (source, options, force) {
+		var me = this,
+			video,
+			destroyed = false,
+			stream;
+
+		function cleanUp() {
+			if (video) {
+				video.pause();
+				video.src = '';
+				video.load();
+			}
+
+			if (stream && stream.stop) {
+				stream.stop();
+			}
+			stream = null;
+		}
+
+		function initialize() {
+			if (destroyed) {
+				return;
+			}
+
+			if (video.videoWidth) {
+				me.width = video.videoWidth;
+				me.height = video.videoHeight;
+				me.setReady();
+			} else {
+				//Workaround for Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=926753
+				setTimeout(initialize, 50);
+			}
+		}
+
+		//todo: support options for video resolution, etc.
+
+		if (force) {
+			if (!getUserMedia) {
+				throw 'Camera source type unavailable. Browser does not support getUserMedia';
+			}
+
+			video = document.createElement('video');
+
+			getUserMedia.call(navigator, {
+				video: true
+			}, function (s) {
+				stream = s;
+
+				if (destroyed) {
+					cleanUp();
+					return;
+				}
+
+				// check for firefox
+				if (video.mozCaptureStream) {
+					video.mozSrcObject = stream;
+				} else {
+					video.src = (URL && URL.createObjectURL(stream)) || stream;
+				}
+
+				if (video.readyState) {
+					initialize();
+				} else {
+					video.addEventListener('loadedmetadata', initialize, false);
+				}
+
+				video.play();
+			}, function (evt) {
+				//todo: emit error event
+				console.log('Unable to access video camera', evt);
+			});
+
+			return {
+				deferTexture: true,
+				source: video,
+				render: Object.getPrototypeOf(this).renderVideo,
+				destroy: function () {
+					destroyed = true;
+					cleanUp();
+				}
+			};
+		}
+	}, {
+		compatible: function () {
+			return !!getUserMedia;
+		},
+		title: 'Camera'
+	});
+}));
\ No newline at end of file
diff --git a/sources/seriously.imagedata.js b/sources/seriously.imagedata.js
new file mode 100644
index 0000000..2ffb5e6
--- /dev/null
+++ b/sources/seriously.imagedata.js
@@ -0,0 +1,51 @@
+/* global define, require */
+(function (root, factory) {
+	'use strict';
+
+	if (typeof exports === 'object') {
+		// Node/CommonJS
+		factory(require('seriously'));
+	} else if (typeof define === 'function' && define.amd) {
+		// AMD. Register as an anonymous module.
+		define(['seriously'], factory);
+	} else {
+		/*
+		todo: build out-of-order loading for sources and transforms or remove this
+		if (!root.Seriously) {
+			root.Seriously = { plugin: function (name, opt) { this[name] = opt; } };
+		}
+		*/
+		factory(root.Seriously);
+	}
+}(this, function (Seriously, undefined) {
+	'use strict';
+
+	Seriously.source('imagedata', function (source) {
+		if (source instanceof Object && source.data &&
+			source.width && source.height &&
+			source.width * source.height * 4 === source.data.length
+			) {
+
+			//Because of this bug, Firefox doesn't recognize ImageData, so we have to duck type
+			//https://bugzilla.mozilla.org/show_bug.cgi?id=637077
+
+			this.width = source.width;
+			this.height = source.height;
+
+			return {
+				render: function (gl) {
+					if (this.dirty) {
+						gl.bindTexture(gl.TEXTURE_2D, this.texture);
+						gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flip);
+						gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
+						this.lastRenderTime = Date.now() / 1000;
+						return true;
+					}
+				}
+			};
+		}
+	}, {
+		title: 'ImageData',
+		description: '2D Canvas ImageData'
+	});
+}));
\ No newline at end of file
diff --git a/test/index.html b/test/index.html
index 9258f1e..aca2b97 100644
--- a/test/index.html
+++ b/test/index.html
@@ -7,6 +7,14 @@
 
 	<script src="seriously.unit.setup.js"></script>
 	<script src="../seriously.js"></script>
+	<script src="../sources/seriously.imagedata.js"></script>
+	<script src="../sources/seriously.array.js"></script>
+	<script src="../lib/require.js"></script>
+	<script type="text/javascript">
+		require.config({
+			baseUrl: '../'
+		});
+	</script>
 	<script src="seriously.unit.js"></script>
 	<style>
 	
diff --git a/test/seriously.unit.js b/test/seriously.unit.js
index ec68da0..fbafa1e 100644
--- a/test/seriously.unit.js
+++ b/test/seriously.unit.js
@@ -1,7 +1,7 @@
 /*jslint devel: true, bitwise: true, browser: true, white: true, nomen: true, plusplus: true, maxerr: 50, indent: 4 */
-/* global module, test, asyncTest, expect, ok, equal, start, Seriously */
+/* global module, test, asyncTest, expect, ok, equal, start, Seriously, require */
 (function () {
-	"use strict";
+	'use strict';
 
 	function compare(a, b) {
 		var i;
@@ -41,6 +41,8 @@
 		}
 		document.body.removeChild(p);
 
+		window.globalProperties.push('requirejs', 'require', 'define');
+
 		for (p in window) {
 			if (!skipIds || document.getElementById(p) !== window[p] &&
 				document.getElementById('qunit-urlconfig-' + p) !== window[p]) {
@@ -62,24 +64,36 @@
 		ok(s instanceof Seriously, 'Create Seriously instance with new');
 		s.destroy();
 
+		/*jshint ignore:start*/
 		s = Seriously();
+		/*jshint ignore:end*/
 		ok(s instanceof Seriously, 'Create Seriously instance without new');
 		s.destroy();
 	});
 
-	test('Incompatible', function () {
-		var s, e, msg,
-			expected, gl, canvas;
-
-		expect(2);
+	test('Incompatible', 4, function () {
+		var seriously,
+			effect,
+			source,
+			msg,
+			expected,
+			gl,
+			canvas;
 
-		Seriously.plugin('removeme', {
+		Seriously.plugin('incompatibleeffect', {
 			compatible: function () {
 				return false;
 			}
 		});
 
-		s = new Seriously();
+		Seriously.source('incompatiblesource', {
+			compatible: function () {
+				return false;
+			},
+			title: 'delete me'
+		});
+
+		seriously = new Seriously();
 
 		canvas = document.createElement('canvas');
 		if (!canvas) {
@@ -88,31 +102,43 @@
 			expected = 'webgl';
 		} else {
 			try {
-				gl = canvas.getContext('experimental-webgl');
-			} catch (expError) {
+				gl = canvas.getContext('webgl');
+			} catch (webglError) {
+			}
+
+			if (!gl) {
 				try {
-					gl = canvas.getContext('webgl');
-				} catch (webglError) {
+					gl = canvas.getContext('experimental-webgl');
+				} catch (expError) {
 				}
 			}
 
 			if (!gl) {
 				expected = 'context';
-			} else {
-				expected = 'plugin-removeme';
 			}
 		}
 
-		msg = s.incompatible('removeme');
-		equal(msg, expected, 'Incompatibity test on plugin');
+		//test effect plugin
+		msg = seriously.incompatible('incompatibleeffect');
+		equal(msg, expected || 'plugin-incompatibleeffect', 'Incompatibity test on effect');
 
-		e = s.effect('removeme');
-		msg = s.incompatible();
-		equal(msg, expected, 'Incompatibity test on network with incompatible plugin');
+		effect = seriously.effect('incompatibleeffect');
+		msg = seriously.incompatible();
+		equal(msg, expected || 'plugin-incompatibleeffect', 'Incompatibity test on network with incompatible effect plugin');
+		effect.destroy();
+
+		//test source plugin
+		msg = seriously.incompatible('incompatiblesource');
+		equal(msg, expected || 'source-incompatiblesource', 'Incompatibity test on source');
+
+		source = seriously.source('incompatiblesource');
+		msg = seriously.incompatible();
+		equal(msg, expected || 'source-incompatiblesource', 'Incompatibity test on network with incompatible source plugin');
 
 		//clean up
-		s.destroy();
-		Seriously.removePlugin('removeme');
+		seriously.destroy();
+		Seriously.removePlugin('incompatibleeffect');
+		Seriously.removeSource('incompatiblesource');
 	});
 
 	module('Plugin');
@@ -359,6 +385,32 @@
 		Seriously.removePlugin('removeme');
 	});
 
+	test('Graph Loop', 1, function () {
+		var seriously,
+			effect,
+			error = false;
+
+		Seriously.plugin('removeme', {
+			inputs: {
+				source: {
+					type: 'image'
+				}
+			}
+		});
+		seriously = new Seriously();
+		effect = seriously.effect('removeme');
+
+		try {
+			effect.source = effect;
+		} catch (e) {
+			error = true;
+		}
+
+		ok(error, 'Setting effect source to itself throws an error');
+
+		seriously.destroy();
+		Seriously.removePlugin('removeme');
+	});
 
 	module('Source');
 	/*
@@ -505,6 +557,88 @@
 		Seriously.removePlugin('test');
 	});
 
+	test('Source Plugins', 7, function () {
+		var seriously,
+			funcSource,
+			objSource,
+			altSource1,
+			altSource2,
+			effect,
+			canvas,
+			target;
+
+		Seriously.plugin('temp', {
+			inputs: {
+				a: {
+					type: 'image'
+				},
+				b: {
+					type: 'image'
+				}
+			},
+			title: 'delete me'
+		});
+
+		Seriously.source('func', function (source) {
+			if (!source) {
+				ok(true, 'Source definition function runs');
+			}
+			return {
+				compare: function (source) {
+					return this.source === source;
+				},
+				render: function () {
+					if (!this.source) {
+						ok(true, 'Source render function runs (func)');
+					}
+				},
+				destroy: function () {
+					if (!this.source) {
+						ok(true, 'Source destroy function runs (func)');
+					}
+				}
+			};
+		}, {
+			title: 'delete me'
+		});
+
+		Seriously.source('obj', {
+			render: function () {
+				ok(true, 'Source render function runs (obj)');
+			},
+			destroy: function () {
+				ok(true, 'Source destroy function runs (obj)');
+			},
+			title: 'delete me'
+		});
+
+		seriously = new Seriously();
+
+		canvas = document.createElement('canvas');
+		target = seriously.target(canvas);
+
+		funcSource = seriously.source('func', 0);
+		objSource = seriously.source('obj');
+
+		altSource1 = seriously.source('func', 1);
+		altSource2 = seriously.source('func', 1);
+
+		equal(altSource1, altSource2, 'Matching source objects are the same');
+		ok(funcSource !== altSource1, 'Different source objects are not the same');
+
+		effect = seriously.effect('temp');
+		effect.a = funcSource;
+		effect.b = objSource;
+
+		target.source = effect;
+		target.render();
+
+		seriously.destroy();
+		Seriously.removePlugin('temp');
+		Seriously.removeSource('func');
+		Seriously.removeSource('obj');
+	});
+
 	module('Target');
 	/*
 	 * create target
@@ -570,7 +704,6 @@
 
 		s.destroy();
 		Seriously.removePlugin('testNumberInput');
-
 	});
 
 	test('Color', function () {
@@ -882,14 +1015,14 @@
 						},
 						set: function(x) {
 							prop = x;
-							equal(prop, id, "Transform setter runs successfully #" + id);
+							equal(prop, id, 'Transform setter runs successfully #' + id);
 							return true;
 						}
 					},
 					method: {
 						method: function(x) {
 							prop = x;
-							equal(prop, id, "Transform method runs successfully #" + id);
+							equal(prop, id, 'Transform method runs successfully #' + id);
 							return true;
 						}
 					}
@@ -911,7 +1044,7 @@
 
 		transform1.property = 1;
 		transform2.method(2);
-		equal(transform2.property, 2, "Transform getter runs successfully");
+		equal(transform2.property, 2, 'Transform getter runs successfully');
 
 		canvas = document.createElement('canvas');
 		target = seriously.target(canvas);
@@ -1028,6 +1161,119 @@
 		seriously.destroy();
 	});
 
+	module('Events');
+	asyncTest('ready/unready events', 9, function () {
+		var seriously,
+			effect,
+			canvas,
+			target,
+			immediate,
+			deferred,
+			proceeded = false;
+
+		function fail() {
+			ok(false, 'Removed callback should not run');
+		}
+
+		function finish() {
+			if (!effect.ready && !target.ready && !seriously.isDestroyed()) {
+				//clean up
+				seriously.destroy();
+				Seriously.removePlugin('testReady');
+				Seriously.removeSource('deferred');
+				Seriously.removeSource('immediate');
+				start();
+			}
+		}
+
+		function proceed() {
+			if (effect.isReady() && deferred.isReady() && target.isReady() && !proceeded) {
+				proceeded = true;
+				setTimeout(function () {
+					effect.compare = seriously.source('deferred', 1);
+				}, 10);
+			}
+		}
+
+		Seriously.source('deferred', function (source) {
+			var me = this;
+			if (!proceeded) {
+				setTimeout(function () {
+					me.setReady();
+				}, 0);
+			}
+
+			return {
+				deferTexture: true,
+				source: source,
+				render: function () {}
+			};
+		}, {
+			title: 'delete me'
+		});
+
+		Seriously.source('immediate', function () {
+			return {
+				render: function () {}
+			};
+		}, {
+			title: 'delete me'
+		});
+
+		Seriously.plugin('testReady', {
+			inputs: {
+				source: {
+					type: 'image'
+				},
+				compare: {
+					type: 'image',
+				}
+			},
+			title: 'testReady'
+		});
+
+		seriously = new Seriously();
+
+		immediate = seriously.source('immediate');
+		deferred = seriously.source('deferred', 0);
+
+		effect = seriously.effect('testReady');
+		effect.source = immediate;
+		effect.compare = deferred;
+
+		canvas = document.createElement('canvas');
+		target = seriously.target(canvas);
+		target.source = effect;
+
+		ok(immediate.isReady(), 'Immediately ready source is ready');
+		ok(!deferred.isReady(), 'Deferred source is not yet ready');
+		ok(!effect.isReady(), 'Connected effect is not yet ready');
+		ok(!target.isReady(), 'Connected target is not yet ready');
+
+		deferred.on('ready', fail);
+		deferred.off('ready', fail);
+		deferred.on('ready', function () {
+			ok(deferred.isReady(), 'Deferred source becomes ready');
+			proceed();
+		});
+		effect.on('ready', function () {
+			ok(effect.isReady(), 'Connected effect becomes ready');
+			proceed();
+		});
+		target.on('ready', function () {
+			ok(target.isReady(), 'Connected target becomes ready');
+			proceed();
+		});
+		effect.on('unready', function () {
+			ok(!effect.isReady(), 'Connected effect is no longer ready');
+			finish();
+		});
+		target.on('unready', function () {
+			ok(!target.isReady(), 'Connected target is no longer ready');
+			finish();
+		});
+	});
+
 	module('Alias');
 
 	module('Utilities');
@@ -1103,4 +1349,48 @@
 			checkImageFail(this);
 		}, false);
 	});
+
+	/*
+	use require for loading plugins
+	*/
+	module('Effect Plugins');
+	asyncTest('invert', 2, function () {
+		require([
+			'seriously',
+			'effects/seriously.invert',
+			'sources/seriously.array'
+		], function (Seriously) {
+			var seriously,
+				effect,
+				target,
+				canvas,
+				source,
+				pixels;
+
+			seriously = new Seriously();
+			source = seriously.source([255, 128, 100, 200], {
+				width: 1,
+				height: 1
+			});
+
+			canvas = document.createElement('canvas');
+			canvas.width = canvas.height = 1;
+			target = seriously.target(canvas);
+
+			effect = seriously.effect('invert');
+
+			ok(effect, 'Invert effect successfully created');
+
+			target.source = effect;
+			effect.source = source;
+
+			pixels = target.readPixels(0, 0, 1, 1);
+			ok(pixels && compare(pixels, [0, 127, 155, 200]), 'Invert effect rendered accurately.');
+
+			seriously.destroy();
+			Seriously.removePlugin('invert');
+
+			start();
+		});
+	});
 }());
diff --git a/transforms/seriously.transform3d.js b/transforms/seriously.transform3d.js
index 1ad22b9..6134130 100644
--- a/transforms/seriously.transform3d.js
+++ b/transforms/seriously.transform3d.js
@@ -9,9 +9,12 @@
 		// AMD. Register as an anonymous module.
 		define(['seriously'], factory);
 	} else {
+		/*
+		todo: build out-of-order loading for sources and transforms or remove this
 		if (!root.Seriously) {
 			root.Seriously = { plugin: function (name, opt) { this[name] = opt; } };
 		}
+		*/
 		factory(root.Seriously);
 	}
 }(this, function (Seriously, undefined) {