diff --git a/css/art/art.css b/css/art/art.css
index 2610af5ed5c82ca8c8a1a98820ad24740e1022f0..f5d56d578e6036481788736c3f6e76d3cff323e7 100644
--- a/css/art/art.css
+++ b/css/art/art.css
@@ -50,11 +50,10 @@ img {
 
 .lrgRender {
     height: 531px;
-    width: 531px;
+    width: 506px;
     margin-right: -50px;
-    margin-left: -50px;
+    margin-left: 0px;
     float: right;
-    z-index: -1;
 }
 
 .lrgVector {
@@ -67,7 +66,7 @@ img {
 }
 
 .lrgRender > div.mask {
-    width: 150px;
+    width: 180px;
     height: 100%;
     background: linear-gradient(90deg, rgba(17, 17, 17, 1), rgba(17, 17, 17, 0.8) 60%, rgba(17, 17, 17, 0));
     z-index: 1;
diff --git a/src/art/artJS.js b/src/art/artJS.js
index 3aaa07506a12874079ead5e3a4e910bc074194f9..b34793724f7d5fce012d1d9381459fec219d7dce 100644
--- a/src/art/artJS.js
+++ b/src/art/artJS.js
@@ -121,7 +121,9 @@ App.Art.SlaveArtElement = function(artSlave, artSize, UIDisplay) {
 		return App.Art.legacyVectorArtElement(artSlave, UIDisplay);
 	} else if (imageChoice === 3) { /* VECTOR ART REVAMP*/
 		return App.Art.revampedVectorArtElement(artSlave);
-	} else { /* RENDERED IMAGES BY SHOKUSHU */
+	} else if (imageChoice === 4) { /* Elohiem's Webgl */
+		return App.Art.webglArtElement(artSlave, artSize);
+	} else if (imageChoice === 0) { /* RENDERED IMAGES BY SHOKUSHU */
 		return App.Art.renderedArtElement(artSlave, artSize);
 	}
 };
@@ -137,6 +139,7 @@ App.Art.SlaveArtElement = function(artSlave, artSize, UIDisplay) {
  * @param {number} [UIDisplay] (optional, only used by legacy art): icon UI Display for vector art, 1 for on
  */
 App.Art.refreshSlaveArt = function(artSlave, artSize, elementID, UIDisplay = 0) {
+	console.log('refresh');
 	if ($('#' + elementID).length) {
 		if (V.seeImages === 1) {
 			let image = document.createElement('div');
@@ -161,6 +164,95 @@ App.Art.refreshSlaveArt = function(artSlave, artSize, elementID, UIDisplay = 0)
 	}
 };
 
+App.Art.webglInitialize = function() {
+	// asynchronously load webgl assets if present (this can briefly hang the browser)
+	let loadLockID = LoadScreen.lock();
+	let script = document.createElement("script");
+	script.onload = function() {
+		// load model/morphs/textures assets
+		let sceneData = App.Art.sceneGetData();
+		// load default dictionary containing camera/light/morph/material values
+		let scene = App.Art.sceneGetParams();
+		scene.lockView = false;
+		scene.resetView = false;
+		scene.faceView = true;
+		scene.camera.xr = -6;
+		scene.camera.z = -275;
+		scene.camera.y = 127;
+		scene.camera.fov = 40;
+
+		App.Art.scenes = {};
+		App.Art.defaultScene = JSON.parse(JSON.stringify(scene));
+
+		// start Webgl engine, textures are streamed asynchronously
+		App.Art.engine = new App.Art.Engine();
+		App.Art.engine.bind(sceneData);
+		App.Art.engineReady = true;
+		LoadScreen.unlock(loadLockID);
+
+		// when ready fires event to art elements to start rendering
+		let containers = document.getElementsByClassName("artContainer");
+		for (let i = 0; i < containers.length; i++) {
+			containers[i].dispatchEvent(new Event("engineLoaded"));
+		}
+	};
+	script.onerror = function() {
+		App.Art.engineReady = false;
+		LoadScreen.unlock(loadLockID);
+
+		let containers = document.getElementsByClassName("artContainer");
+		for (let i = 0; i < containers.length; i++) {
+			containers[i].dispatchEvent(new Event("engineFailed"));
+		}
+	};
+	script.src = "resources/webgl/scene1.js";
+	document.head.appendChild(script);
+}();
+
+/**
+ * @param {App.Entity.SlaveState} slave
+ * @param {number} artSize
+ * @returns {HTMLElement}
+ */
+App.Art.webglArtElement = function(slave, artSize) {
+	let container = document.createElement("div");
+	container.setAttribute("class", "artContainer");
+	container.innerText = "Loading...";
+
+	container.addEventListener("engineLoaded", function(e) {
+		// when engine is ready, attach default scene to new slaves
+		if (!(slave.ID in App.Art.scenes)) {
+			App.Art.scenes[slave.ID] = JSON.parse(JSON.stringify(App.Art.defaultScene));
+		}
+		let scene = App.Art.scenes[slave.ID];
+
+		// apply the model transforms
+		App.Art.applySurfaces(slave, scene);
+		App.Art.applyMaterials(slave, scene);
+		App.Art.applyMorphs(slave, scene);
+
+		// draw on canvas and create UI
+		container.innerText = "";
+		let canvas = App.Art.createWebglUI(container, slave, artSize, scene);
+		App.Art.engine.render(scene, canvas);
+	}, true);
+
+	container.addEventListener("engineFailed", function(e) {
+		container.style.color = "#BB2027";
+		container.innerText = "Failed to start WebGL engine.";
+	}, true);
+
+	// incase engine is loaded, trigger listeners manually
+	if (App.Art.engineReady === true) {
+		container.dispatchEvent(new Event("engineLoaded"));
+	}
+	if (App.Art.engineReady === false) {
+		container.dispatchEvent(new Event("engineFailed"));
+	}
+
+	return container;
+};
+
 /**
  * @param {App.Entity.SlaveState} slave
  * @param {number} artSize
@@ -324,7 +416,141 @@ globalThis.extractColor = (function() {
 		["strawberry-blonde", "#e5a88c"],
 		/* these are not actually FreeCities canon, but like to appear in custom descriptions */
 		["brunette", "#6d4936"],
-		["dark", "#463325"]
+		["dark", "#463325"],
+
+		/* these are HTML color names supported by most browsers */
+		["aliceblue", "#f0f8ff"],
+		["antiquewhite", "#faebd7"],
+		["aqua", "#00ffff"],
+		["aquamarine", "#7fffd4"],
+		["azure", "#f0ffff"],
+		["beige", "#f5f5dc"],
+		["bisque", "#ffe4c4"],
+		["blanchedalmond", "#ffebcd"],
+		["blueviolet", "#8a2be2"],
+		["burlywood", "#deb887"],
+		["cadetblue", "#5f9ea0"],
+		["chartreuse", "#7fff00"],
+		["coral", "#ff7f50"],
+		["cornflowerblue", "#6495ed"],
+		["cornsilk", "#fff8dc"],
+		["crimson", "#dc143c"],
+		["cyan", "#00ffff"],
+		["darkblue", "#00008b"],
+		["darkcyan", "#008b8b"],
+		["darkgoldenrod", "#b8860b"],
+		["darkgray", "#a9a9a9"],
+		["darkgreen", "#006400"],
+		["darkkhaki", "#bdb76b"],
+		["darkmagenta", "#8b008b"],
+		["darkolivegreen", "#556b2f"],
+		["darkorange", "#ff8c00"],
+		["darkorchid", "#9932cc"],
+		["darkred", "#8b0000"],
+		["darksalmon", "#e9967a"],
+		["darkseagreen", "#8fbc8f"],
+		["darkslateblue", "#483d8b"],
+		["darkslategray", "#2f4f4f"],
+		["darkturquoise", "#00ced1"],
+		["darkviolet", "#9400d3"],
+		["deeppink", "#ff1493"],
+		["deepskyblue", "#00bfff"],
+		["dimgray", "#696969"],
+		["dodgerblue", "#1e90ff"],
+		["firebrick", "#b22222"],
+		["floralwhite", "#fffaf0"],
+		["forestgreen", "#228b22"],
+		["fuchsia", "#ff00ff"],
+		["gainsboro", "#dcdcdc"],
+		["ghostwhite", "#f8f8ff"],
+		["gold", "#ffd700"],
+		["goldenrod", "#daa520"],
+		["gray", "#808080"],
+		["greenyellow", "#adff2f"],
+		["honeydew", "#f0fff0"],
+		["hotpink", "#ff69b4"],
+		["indianred ", "#cd5c5c"],
+		["indigo", "#4b0082"],
+		["ivory", "#fffff0"],
+		["khaki", "#f0e68c"],
+		["lavender", "#e6e6fa"],
+		["lavenderblush", "#fff0f5"],
+		["lawngreen", "#7cfc00"],
+		["lemonchiffon", "#fffacd"],
+		["lightblue", "#add8e6"],
+		["lightcoral", "#f08080"],
+		["lightcyan", "#e0ffff"],
+		["lightgoldenrodyellow", "#fafad2"],
+		["lightgrey", "#d3d3d3"],
+		["lightgreen", "#90ee90"],
+		["lightpink", "#ffb6c1"],
+		["lightsalmon", "#ffa07a"],
+		["lightseagreen", "#20b2aa"],
+		["lightskyblue", "#87cefa"],
+		["lightslategray", "#778899"],
+		["lightsteelblue", "#b0c4de"],
+		["lightyellow", "#ffffe0"],
+		["lime", "#00ff00"],
+		["limegreen", "#32cd32"],
+		["linen", "#faf0e6"],
+		["magenta", "#ff00ff"],
+		["maroon", "#800000"],
+		["mediumaquamarine", "#66cdaa"],
+		["mediumblue", "#0000cd"],
+		["mediumorchid", "#ba55d3"],
+		["mediumpurple", "#9370d8"],
+		["mediumseagreen", "#3cb371"],
+		["mediumslateblue", "#7b68ee"],
+		["mediumspringgreen", "#00fa9a"],
+		["mediumturquoise", "#48d1cc"],
+		["mediumvioletred", "#c71585"],
+		["midnightblue", "#191970"],
+		["mintcream", "#f5fffa"],
+		["mistyrose", "#ffe4e1"],
+		["moccasin", "#ffe4b5"],
+		["navajowhite", "#ffdead"],
+		["navy", "#000080"],
+		["oldlace", "#fdf5e6"],
+		["olive", "#808000"],
+		["olivedrab", "#6b8e23"],
+		["orange", "#ffa500"],
+		["orangered", "#ff4500"],
+		["orchid", "#da70d6"],
+		["palegoldenrod", "#eee8aa"],
+		["palegreen", "#98fb98"],
+		["paleturquoise", "#afeeee"],
+		["palevioletred", "#d87093"],
+		["papayawhip", "#ffefd5"],
+		["peachpuff", "#ffdab9"],
+		["peru", "#cd853f"],
+		["plum", "#dda0dd"],
+		["powderblue", "#b0e0e6"],
+		["rebeccapurple", "#663399"],
+		["rosybrown", "#bc8f8f"],
+		["royalblue", "#4169e1"],
+		["saddlebrown", "#8b4513"],
+		["salmon", "#fa8072"],
+		["sandybrown", "#f4a460"],
+		["seagreen", "#2e8b57"],
+		["seashell", "#fff5ee"],
+		["sienna", "#a0522d"],
+		["skyblue", "#87ceeb"],
+		["slateblue", "#6a5acd"],
+		["slategray", "#708090"],
+		["snow", "#fffafa"],
+		["springgreen", "#00ff7f"],
+		["steelblue", "#4682b4"],
+		["tan", "#d2b48c"],
+		["teal", "#008080"],
+		["thistle", "#d8bfd8"],
+		["tomato", "#ff6347"],
+		["turquoise", "#40e0d0"],
+		["violet", "#ee82ee"],
+		["wheat", "#f5deb3"],
+		["white", "#ffffff"],
+		["whitesmoke", "#f5f5f5"],
+		["yellow", "#ffff00"],
+		["yellowgreen", "#9acd32"]
 	]);
 
 	/* these are HTML color names supported by most browsers */
@@ -501,6 +727,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#C39696";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#E1B585";
 						colorSlave.areolaColor = "#C39696";
@@ -752,6 +980,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#7C594B";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#AC7C4A";
 						colorSlave.areolaColor = "#7C594B";
@@ -873,6 +1103,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#92684C";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#B27554";
 						colorSlave.areolaColor = "#92684C";
@@ -994,6 +1226,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#AC8074";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#CFB48D";
 						colorSlave.areolaColor = "#AC8074";
@@ -1111,6 +1345,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#A7624F";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#CC8D53";
 						colorSlave.areolaColor = "#A7624F";
@@ -1228,6 +1464,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#AC8074";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#CFB48D";
 						colorSlave.areolaColor = "#AC8074";
@@ -1345,6 +1583,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#BF7577";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#DCA972";
 						colorSlave.areolaColor = "#BF7577";
@@ -1462,6 +1702,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#A7624F";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#CC8D53";
 						colorSlave.areolaColor = "#A7624F";
@@ -1579,6 +1821,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#976051";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#BA855E";
 						colorSlave.areolaColor = "#976051";
@@ -1696,6 +1940,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#C36E45";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#C17848";
 						colorSlave.areolaColor = "#C36E45";
@@ -1813,6 +2059,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#976051";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#BA855E";
 						colorSlave.areolaColor = "#976051";
@@ -1930,6 +2178,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#92684C";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#B27554";
 						colorSlave.areolaColor = "#92684C";
@@ -2051,6 +2301,8 @@ globalThis.skinColorCatcher = function(artSlave) {
 						colorSlave.areolaColor = "#92684C";
 						colorSlave.labiaColor = "#F977A3";
 						break;
+					case "sun tanned":
+					case "spray tanned":
 					case "tan":
 						colorSlave.skinColor = "#B27554";
 						colorSlave.areolaColor = "#92684C";
diff --git a/src/art/webgl/art.js b/src/art/webgl/art.js
new file mode 100644
index 0000000000000000000000000000000000000000..ae624eb352489827bfe206054222a288b95732c9
--- /dev/null
+++ b/src/art/webgl/art.js
@@ -0,0 +1,629 @@
+App.Art.getMaterialById = function(scene, id) {
+	for (let i =0; i < scene.materials.length; i++) {
+		if(scene.materials[i].matId === id) {
+			return scene.materials[i];
+		}
+	}
+	return null;
+};
+
+App.Art.getMorphById = function(scene, id) {
+	for (let i =0; i < scene.model.morphs.length; i++) {
+		if(scene.model.morphs[i].morphId === id) {
+			return scene.model.morphs[i];
+		}
+	}
+	return null;
+};
+
+App.Art.getSurfaceById = function(scene, id) {
+	for (let i=0, count=0; i < scene.model.figures.length; i++) {
+		for (let j=0; j < scene.model.figures[i].surfaces.length; j++, count++) {
+			if(scene.model.figures[i].surfaces[j].surfaceId === id) {
+				return scene.model.figures[i].surfaces[j];
+			}
+		}
+	}
+	return null;
+};
+
+App.Art.resetMorphs = function(slave, scene) {
+	for (let i =0; i < scene.model.morphs.length; i++) {
+		scene.model.morphs[i].value = App.Art.defaultScene.model.morphs[i].value;
+	}
+};
+
+App.Art.applySurfaces = function(slave, scene) {
+	let surfaces = [];
+
+	if (slave.dick !== 0 || (!(slave.scrotum <= 0 || slave.balls <= 0))) {
+		surfaces.push(["Futalicious_Genitalia_G8F_Shaft_Futalicious_Shell", "visible", true]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Glans_Futalicious_Shell", "visible", true]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Testicles_Futalicious_Shell", "visible", true]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Torso_Front_Futalicious_Shell", "visible", true]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Torso_Middle_Futalicious_Shell", "visible", true]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Torso_Back_Futalicious_Shell", "visible", true]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Rectum_Futalicious_Shell", "visible", false]);
+		surfaces.push(["Torso_Front", "visible", true]);
+		surfaces.push(["Torso_Middle", "visible", true]);
+		surfaces.push(["Torso_Back", "visible", true]);
+
+		surfaces.push(["Genitalia", "visible", true]);
+		surfaces.push(["Anus", "visible", true]);
+		surfaces.push(["new_gens_V8_1840_Genitalia", "visible", true]);
+		surfaces.push(["new_gens_V8_1840_Anus", "visible", true]);
+	} else {
+		surfaces.push(["Futalicious_Genitalia_G8F_Shaft_Futalicious_Shell", "visible", false]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Glans_Futalicious_Shell", "visible", false]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Testicles_Futalicious_Shell", "visible", false]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Torso_Front_Futalicious_Shell", "visible", false]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Torso_Middle_Futalicious_Shell", "visible", false]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Torso_Back_Futalicious_Shell", "visible", false]);
+		surfaces.push(["Futalicious_Genitalia_G8F_Rectum_Futalicious_Shell", "visible", false]);
+		surfaces.push(["Torso_Front", "visible", false]);
+		surfaces.push(["Torso_Middle", "visible", false]);
+		surfaces.push(["Torso_Back", "visible", false]);
+
+		surfaces.push(["Genitalia", "visible", true]);
+		surfaces.push(["Anus", "visible", true]);
+		surfaces.push(["new_gens_V8_1840_Genitalia", "visible", true]);
+		surfaces.push(["new_gens_V8_1840_Anus", "visible", true]);
+	}
+
+	// surfaces.push(["Arms", "visible", hasBothArms(slave)]);
+	// surfaces.push(["Fingernails", "visible", hasBothArms(slave)]);
+	// surfaces.push(["Legs", "visible", hasBothLegs(slave)]);
+	// surfaces.push(["Toenails", "visible", hasBothLegs(slave)]);
+
+	let cockSkin;
+	let skin;
+
+	switch (slave.skin) {
+		case "pure white":
+		case "ivory":
+		case "white":
+			cockSkin = "White";
+			skin = "Ceridwen";
+			break;
+		case "extremely pale":
+		case "very pale":
+			cockSkin = "White";
+			skin = "Celinette";
+			break;
+		case "pale":
+		case "extremely fair":
+			cockSkin = "White";
+			skin = "Kimmy";
+			break;
+		case "very fair":
+		case "fair":
+			cockSkin = "Light";
+			skin = "Saffron";
+			break;
+		case "light":
+		case "light olive":
+			cockSkin = "Light";
+			skin = "FemaleBase";
+			break;
+		case "sun tanned":
+		case "spray tanned":
+		case "tan":
+			cockSkin = "Light";
+			skin = "Reagan";
+			break;
+		case "olive":
+			cockSkin = "Mid";
+			skin = "Kathy";
+			break;
+		case "bronze":
+			cockSkin = "Mid";
+			skin = "Mylou";
+			break;
+		case "dark olive":
+			cockSkin = "Mid";
+			skin = "Adaline";
+			break;
+		case "dark":
+			cockSkin = "Mid";
+			skin = "Daphne";
+			break;
+		case "light beige":
+			cockSkin = "Mid";
+			skin = "Minami";
+			break;
+		case "beige":
+			cockSkin = "Mid";
+			skin = "Tara";
+			break;
+		case "dark beige":
+		case "light brown":
+			cockSkin = "Dark";
+			skin = "Topmodel";
+			break;
+		case "brown":
+		case "dark brown":
+			cockSkin = "Dark";
+			skin = "Angelica";
+			break;
+		case "black":
+		case "ebony":
+		case "pure black":
+			cockSkin = "Dark";
+			skin = "DarkSkin";
+			break;
+	}
+
+	surfaces.push(["Futalicious_Genitalia_G8F_Glans_Futalicious_Shell", "matId", cockSkin + "Futalicious_Genitalia_G8F_Glans_Futalicious_Shell"]);
+	surfaces.push(["Futalicious_Genitalia_G8F_Shaft_Futalicious_Shell", "matId", cockSkin + "Futalicious_Genitalia_G8F_Glans_Futalicious_Shell"]);
+	surfaces.push(["Futalicious_Genitalia_G8F_Testicles_Futalicious_Shell", "matId", cockSkin + "Futalicious_Genitalia_G8F_Glans_Futalicious_Shell"]);
+	surfaces.push(["Futalicious_Genitalia_G8F_Torso_Front_Futalicious_Shell", "matId", cockSkin + "Futalicious_Genitalia_G8F_Glans_Futalicious_Shell"]);
+	surfaces.push(["Futalicious_Genitalia_G8F_Torso_Middle_Futalicious_Shell", "matId", cockSkin + "Futalicious_Genitalia_G8F_Glans_Futalicious_Shell"]);
+	surfaces.push(["Futalicious_Genitalia_G8F_Torso_Back_Futalicious_Shell", "matId", cockSkin + "Futalicious_Genitalia_G8F_Glans_Futalicious_Shell"]);
+	surfaces.push(["Futalicious_Genitalia_G8F_Rectum_Futalicious_Shell", "matId", cockSkin + "Futalicious_Genitalia_G8F_Glans_Futalicious_Shell"]);
+	surfaces.push(["Torso_Front", "matId", skin + "Torso"]);
+	surfaces.push(["Torso_Middle", "matId",	skin + "Torso"]);
+	surfaces.push(["Torso_Back", "matId", skin + "Torso"]);
+
+	surfaces.push(["Torso", "matId", skin + "Torso"]);
+	surfaces.push(["Face", "matId", skin + "Face"]);
+	surfaces.push(["Lips", "matId", skin + "Lips"]);
+	surfaces.push(["Ears", "matId", skin + "Ears"]);
+	surfaces.push(["Legs", "matId", skin + "Legs"]);
+	surfaces.push(["Arms", "matId", skin + "Arms"]);
+	surfaces.push(["EyeSocket", "matId", skin + "Face"]);
+	surfaces.push(["Toenails", "matId", skin + "Toenails"]);
+	surfaces.push(["Fingernails", "matId", skin + "Fingernails"]);
+	surfaces.push(["Genitalia",	"matId", skin + "Genitalia"]);
+	surfaces.push(["Anus", "matId", skin + "Anus"]);
+
+	for (let i=0, count=0; i < scene.model.figures.length; i++) {
+		for (let j=0; j < scene.model.figures[i].surfaces.length; j++, count++) {
+			for (let h =0; h < surfaces.length; h++) {
+				if (scene.model.figures[i].surfaces[j].surfaceId === surfaces[h][0]) {
+					scene.model.figures[i].surfaces[j][surfaces[h][1]] = surfaces[h][2];
+				}
+			}
+		}
+	}
+};
+
+App.Art.applyMaterials = function(slave, scene) {
+	let materials = [];
+
+	function hexToRgb(hex) {
+		hex = hex.replace('#', '');
+		let r = parseInt(hex.substring(0, 2), 16);
+		let g = parseInt(hex.substring(2, 4), 16);
+		let b = parseInt(hex.substring(4, 6), 16);
+		return [r/255, g/255, b/255];
+	}
+
+	let hairColor = hexToRgb(extractColor(slave.hColor));
+	let lipsColor = hexToRgb(skinColorCatcher(slave).lipsColor);
+	// let lipsColor = hexToRgb("#ffffff");
+
+	let makeupColor;
+	let makeupOpacity;
+	let lipsGloss;
+
+	switch (slave.makeup) {
+		case 1:
+			// Nice
+			makeupColor = "#ff69b4";
+			makeupOpacity = 0.5;
+			lipsGloss = 32;
+			break;
+		case 2:
+			// Gorgeous
+			makeupColor = "#8b008b";
+			makeupOpacity = 0.7;
+			lipsGloss = 10;
+			break;
+		case 3:
+			// Hair coordinated
+			makeupColor = extractColor(slave.hColor);
+			makeupOpacity = 0.3;
+			lipsGloss = 10;
+			break;
+		case 4:
+			// Slutty
+			makeupColor = "#B70000";
+			makeupOpacity = 0.8;
+			lipsGloss = 5;
+			break;
+		case 5:
+			// Neon
+			makeupColor = "#DC143C";
+			makeupOpacity = 1;
+			lipsGloss = 1;
+			break;
+		case 6:
+			// Neon hair coordinated
+			makeupColor = extractColor(slave.hColor);
+			makeupOpacity = 1;
+			lipsGloss = 1;
+			break;
+		case 7:
+			// Metallic
+			makeupColor = "#b22222";
+			makeupOpacity = 0.7;
+			lipsGloss = 1;
+			break;
+		case 8:
+			// Metallic hair coordinated
+			makeupColor = extractColor(slave.hColor);
+			makeupOpacity = 0.7;
+			lipsGloss = 1;
+			break;
+		default:
+			makeupColor = "#ffffff";
+			makeupOpacity = 0;
+			lipsGloss = 32;
+			break;
+	}
+
+	makeupColor = hexToRgb(makeupColor);
+	lipsColor[0] = makeupColor[0] * makeupOpacity + lipsColor[0] * (1 - makeupOpacity);
+	lipsColor[1] = makeupColor[1] * makeupOpacity + lipsColor[1] * (1 - makeupOpacity);
+	lipsColor[2] = makeupColor[2] * makeupOpacity + lipsColor[2] * (1 - makeupOpacity);
+
+	let nailColor;
+	switch (slave.nails) {
+		case 2:
+			// color-coordinated with hair
+			nailColor = extractColor(slave.hColor);
+			break;
+		case 4:
+			// bright and glittery
+			nailColor = "#ff0000";
+			break;
+		case 6:
+			// neon
+			nailColor = "#DC143C";
+			break;
+		case 7:
+			// color-coordinated neon
+			nailColor = extractColor(slave.hColor);
+			break;
+		case 8:
+			// metallic
+			nailColor = "#b22222";
+			break;
+		case 9:
+			// color-coordinated metallic
+			nailColor = extractColor(slave.hColor);
+			break;
+		default:
+			nailColor = "#ffffff";
+			break;
+	}
+
+	nailColor = hexToRgb(nailColor);
+
+
+	materials.push(["HeadThin", "Kd", hairColor]);
+	materials.push(["Head", "Kd", hairColor]);
+	materials.push(["TuckedThin", "Kd", hairColor]);
+	materials.push(["TuckedR", "Kd", hairColor]);
+	materials.push(["BangsThin", "Kd", hairColor]);
+	materials.push(["Bangs", "Kd", hairColor]);
+	materials.push(["Scalp", "Kd", hairColor]);
+
+	let irisColor;
+	let scleraColor;
+
+	if (hasAnyEyes(slave)) {
+		irisColor = hexToRgb(extractColor(hasLeftEye(slave) ? extractColor(slave.eye.left.iris) : extractColor(slave.eye.right.iris)));
+		scleraColor = hexToRgb(extractColor(hasLeftEye(slave) ? extractColor(slave.eye.left.sclera) : extractColor(slave.eye.right.sclera)));
+	} else {
+		irisColor = hexToRgb(extractColor("black"));
+		scleraColor = hexToRgb(extractColor("black"));
+	}
+
+	materials.push(["Irises", "Kd", [irisColor[0] * 0.8, irisColor[1] * 0.8, irisColor[2] * 0.8]]);
+	materials.push(["Sclera", "Kd", [scleraColor[0] * 1.2, scleraColor[1] * 1.2, scleraColor[2] * 1.2]]);
+	materials.push(["Irises", "Ns", 4]);
+
+
+	switch (slave.skin) {
+		case "pure white":
+		case "ivory":
+		case "white":
+			materials.push(["CeridwenFingernails", "Kd", nailColor]);
+			materials.push(["CeridwenLips", "Kd", lipsColor]);
+			materials.push(["CeridwenLips", "Ns", lipsGloss]);
+			materials.push(["WhiteFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [1.05, 1, 1]]);
+			break;
+		case "extremely pale":
+		case "very pale":
+			materials.push(["CelinetteFingernails", "Kd", nailColor]);
+			materials.push(["CelinetteLips", "Kd", lipsColor]);
+			materials.push(["CelinetteLips", "Ns", lipsGloss]);
+			materials.push(["WhiteFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [1.05, 1, 1]]);
+			break;
+		case "pale":
+		case "extremely fair":
+			materials.push(["KimmyFingernails", "Kd", nailColor]);
+			materials.push(["KimmyLips", "Kd", lipsColor]);
+			materials.push(["KimmyLips", "Ns", lipsGloss]);
+			materials.push(["WhiteFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [1, 0.95, 0.91]]);
+			break;
+		case "very fair":
+		case "fair":
+			materials.push(["SaffronFingernails", "Kd", nailColor]);
+			materials.push(["SaffronLips", "Kd", lipsColor]);
+			materials.push(["SaffronLips", "Ns", lipsGloss]);
+			materials.push(["LightFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [1.1, 1.1, 1.1]]);
+			break;
+		case "light":
+		case "light olive":
+			materials.push(["FemaleBaseFingernails", "Kd", nailColor]);
+			materials.push(["FemaleBaseLips", "Kd", lipsColor]);
+			materials.push(["FemaleBaseLips", "Ns", lipsGloss]);
+			materials.push(["LightFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [1.0, 1.0, 1.0]]);
+			break;
+		case "sun tanned":
+		case "spray tanned":
+		case "tan":
+			materials.push(["ReaganFingernails", "Kd", nailColor]);
+			materials.push(["ReaganLips", "Kd", lipsColor]);
+			materials.push(["ReaganLips", "Ns", lipsGloss]);
+			materials.push(["MidFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.97, 0.95, 0.95]]);
+			break;
+		case "olive":
+			materials.push(["KathyFingernails", "Kd", nailColor]);
+			materials.push(["KathyLips", "Kd", lipsColor]);
+			materials.push(["KathyLips", "Ns", lipsGloss]);
+			materials.push(["MidFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.95, 0.92, 0.92]]);
+			break;
+		case "bronze":
+			materials.push(["MylouFingernails", "Kd", nailColor]);
+			materials.push(["MylouLips", "Kd", lipsColor]);
+			materials.push(["MylouLips", "Ns", lipsGloss]);
+			materials.push(["MidFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.91, 0.95, 0.98]]);
+			break;
+		case "dark olive":
+			materials.push(["AdalineFingernails", "Kd", nailColor]);
+			materials.push(["AdalineLips", "Kd", lipsColor]);
+			materials.push(["AdalineLips", "Ns", lipsGloss]);
+			materials.push(["MidFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.90, 0.90, 0.90]]);
+			break;
+		case "dark":
+			materials.push(["DaphneFingernails", "Kd", nailColor]);
+			materials.push(["DaphneLips", "Kd", lipsColor]);
+			materials.push(["DaphneLips", "Ns", lipsGloss]);
+			materials.push(["MidFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.88, 0.93, 0.96]]);
+			break;
+		case "light beige":
+			materials.push(["MinamiFingernails", "Kd", nailColor]);
+			materials.push(["MinamiLips", "Kd", lipsColor]);
+			materials.push(["MinamiLips", "Ns", lipsGloss]);
+			materials.push(["MidFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.68, 0.74, 0.8]]);
+			break;
+		case "beige":
+			materials.push(["TaraFingernails", "Kd", nailColor]);
+			materials.push(["TaraLips", "Kd", lipsColor]);
+			materials.push(["TaraLips", "Ns", lipsGloss]);
+			materials.push(["MidFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.77, 0.77, 0.77]]);
+			break;
+		case "dark beige":
+		case "light brown":
+			materials.push(["TopmodelFingernails", "Kd", nailColor]);
+			materials.push(["TopmodelLips", "Kd", lipsColor]);
+			materials.push(["TopmodelLips", "Ns", lipsGloss]);
+			materials.push(["DarkFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [1.7, 1.75, 1.75]]);
+			break;
+		case "brown":
+		case "dark brown":
+			materials.push(["AngelicaFingernails", "Kd", nailColor]);
+			materials.push(["AngelicaLips", "Kd", lipsColor]);
+			materials.push(["AngelicaLips", "Ns", lipsGloss]);
+			materials.push(["DarkFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.85, 0.85, 0.85]]);
+			break;
+		case "black":
+		case "ebony":
+		case "pure black":
+			materials.push(["DarkSkinFingernails", "Kd", nailColor]);
+			materials.push(["DarkSkinLips", "Kd", lipsColor]);
+			materials.push(["DarkSkinLips", "Ns", lipsGloss]);
+			materials.push(["DarkFutalicious_Genitalia_G8F_Glans_Futalicious_Shell", "Kd", [0.7, 0.7, 0.77]]);
+			break;
+	}
+
+	for (let i =0; i < scene.materials.length; i++) {
+		for (let j =0; j < materials.length; j++) {
+			if (scene.materials[i].matId === materials[j][0]) {
+				scene.materials[i][materials[j][1]] = materials[j][2];
+			}
+		}
+	}
+};
+
+App.Art.applyMorphs = function(slave, scene) {
+	let morphs = [];
+
+	function convertRange(sourceMin, sourceMax, targetMin, targetMax, value) {
+		return (targetMax-targetMin)/(sourceMax-sourceMin)*(value-sourceMin)+targetMin;
+	}
+
+	if(hasBothArms(slave) && hasBothLegs(slave)) {
+		if (slave.devotion > 50) {
+			morphs.push(["posesHigh", 1]);
+		} else if (slave.trust >= -20) {
+			if (slave.devotion <= 20) {
+				morphs.push(["posesLow", 1]);
+			} else {
+				morphs.push(["posesMid", 1]);
+			}
+		} else {
+			morphs.push(["posesMid", 1]);
+		}
+	}
+
+	switch (slave.race) {
+		case "white":
+			morphs.push(["raceWhite", 1]); break;
+		case "asian":
+			morphs.push(["raceAsian", 1]); break;
+		case "latina":
+			morphs.push(["raceLatina", 1]); break;
+		case "black":
+			morphs.push(["raceBlack", 1]); break;
+		case "pacific islander":
+			morphs.push(["racePacific", 1]); break;
+		case "southern european":
+			morphs.push(["raceEuropean", 1]); break;
+		case "amerindian":
+			morphs.push(["raceAmerindian", 1]); break;
+		case "semitic":
+			morphs.push(["raceSemitic", 1]); break;
+		case "middle eastern":
+			morphs.push(["raceEastern", 1]); break;
+		case "indo-aryan":
+			morphs.push(["raceAryan", 1]); break;
+		case "mixed race":
+			/*
+			let races = ["raceWhite" , "raceAsian", "raceLatina", "raceBlack", "racePacific", "raceEuropean" ,"raceAmerindian", "raceSemitic", "raceEastern", "raceAryan", "raceLatina"];
+			let rand = Math.random();
+			let index1 = Math.floor(Math.random() * races.length);
+			let index2 = Math.floor(Math.random() * races.length-1);
+			morphs.push([races[index1], rand]);
+			races.splice(index1, index1);
+			morphs.push([races[index2], 1-rand]);*/
+			break;
+	}
+
+	switch (slave.faceShape) {
+		case "normal":
+			break;
+		case "masculine":
+			morphs.push(["faceShapeMasculine", 0.8]); break;
+		case "androgynous":
+			morphs.push(["faceShapeAndrogynous", 1]); break;
+		case "cute":
+			morphs.push(["faceShapeCute", 1]); break;
+		case "sensual":
+			morphs.push(["faceShapeSensual", 0.8]); break;
+		case "exotic":
+			morphs.push(["faceShapeExotic", 1]); break;
+	}
+
+	if (slave.boobs < 500) {
+		morphs.push(["boobsSmall", 1-slave.boobs/500]);
+	} else {
+		switch (slave.boobShape) {
+			case "normal":
+				morphs.push(["boobShapeNormal", slave.boobs/2800]); break;
+			case "perky":
+				morphs.push(["boobShapePerky", slave.boobs/3500]); break;
+			case "saggy":
+				morphs.push(["boobShapeSaggy", slave.boobs/3500]); break;
+			case "torpedo-shaped":
+				morphs.push(["boobShapeTorpedo", slave.boobs/2000]); break;
+			case "downward-facing":
+				morphs.push(["boobShapeDownward", slave.boobs/3000]); break;
+			case "wide-set":
+				morphs.push(["boobShapeWide", slave.boobs/2500]); break;
+		}
+	}
+
+	switch (slave.nipples) {
+		case "huge":
+			morphs.push(["nipplesHuge", 1]); break;
+		case "tiny":
+			morphs.push(["nipplesTiny", 1]); break;
+		case "cute":
+			morphs.push(["nipplesCute", 1]); break;
+		case "puffy":
+			morphs.push(["nipplesPuffy", 1]); break;
+		case "inverted":
+			morphs.push(["nipplesInverted", 1]); break;
+		case "partially inverted":
+			morphs.push(["nipplesPartiallyInverted", 1]); break;
+		case "fuckable":
+			morphs.push(["nipplesFuckable", 1]); break;
+	}
+
+	if (slave.foreskin !== 0) {
+		morphs.push(["foreskin", 1]);
+	}
+	if (slave.dick === 0 && !(slave.scrotum <= 0 || slave.balls <= 0)) {
+		morphs.push(["dickRemove", 1]);
+	} else if (slave.dick !== 0) {
+		morphs.push(["dick", (slave.dick / 8) -1]);
+	}
+	if (slave.vagina === -1) {
+		morphs.push(["vaginaRemove", 1]);
+	}
+	if (slave.scrotum <= 0 || slave.balls <= 0) {
+		morphs.push(["ballsRemove", 1]);
+	} else {
+		if (slave.balls <= 2) {
+			morphs.push(["balls", convertRange(0, 2, -1, 0, slave.balls)]);
+		} else {
+			morphs.push(["balls", convertRange(2, 10, 0, 1.5, slave.balls)]);
+		}
+		if (slave.scrotum > 2) {
+			morphs.push(["scrotum", convertRange(2, 10, 0, 0.75, slave.scrotum)]);
+		}
+	}
+
+	morphs.push(["areolae", convertRange(0, 4, 0, 5, slave.areolae)]);
+	morphs.push(["shoulders", convertRange(-2, 2, -1.5, 1.5, slave.shoulders)]);
+	morphs.push(["lips", convertRange(0, 100, -1, 3, slave.lips)]);
+	scene.transform.scale = slave.height/175; // height by object transform
+	if (slave.muscles > 0) {
+		morphs.push(["muscles", slave.muscles/50]);
+	}
+	if (slave.belly <= 15000) {
+		morphs.push(["belly", slave.belly/15000]);
+	} else if (slave.belly <= 150000) {
+		morphs.push(["belly", 1 + convertRange(15000, 150000, 0, 2, slave.belly)]);
+	} else {
+		morphs.push(["belly", 3 + (slave.belly-150000)/300000]);
+	}
+
+	morphs.push(["hips", slave.hips/2]);
+	if (slave.butt<1) {
+		morphs.push(["butt", slave.butt-1]);
+	} else {
+		morphs.push(["butt", convertRange(1, 20, 0, 3, slave.butt)]);
+	}
+
+	if (slave.waist > 75) {
+		morphs.push(["waist", -75/50]);
+	} else {
+		morphs.push(["waist", -slave.waist/50]);
+	}
+
+	morphs.push(["weight", slave.weight/50]);
+
+	if (slave.visualAge < 20) {
+		morphs.push(["physicalAgeYoung", -(slave.visualAge-20)/15]);
+	} else {
+		morphs.push(["physicalAgeOld", (slave.visualAge-20)/100]);
+	}
+
+	if (!hasLeftArm(slave)) {
+		morphs.push(["amputeeLeftArm", 1]);
+	}
+	if (!hasRightArm(slave)) {
+		morphs.push(["amputeeRightArm", 1]);
+	}
+	if (!hasLeftLeg(slave)) {
+		morphs.push(["amputeeLeftLeg", 1]);
+	}
+	if (!hasRightLeg(slave)) {
+		morphs.push(["amputeeRightLeg", 1]);
+	}
+
+	App.Art.resetMorphs(slave, scene);
+
+	for (let i =0; i < scene.model.morphs.length; i++) {
+		for (let j =0; j < morphs.length; j++) {
+			if (scene.model.morphs[i].morphId === morphs[j][0]) {
+				scene.model.morphs[i].value = morphs[j][1];
+			}
+		}
+	}
+};
diff --git a/src/art/webgl/contents.txt b/src/art/webgl/contents.txt
new file mode 100644
index 0000000000000000000000000000000000000000..2110aff9d4803faab3d50a0243884281561fdb2c
--- /dev/null
+++ b/src/art/webgl/contents.txt
@@ -0,0 +1,148 @@
+///////////////// MORPHS /////////////////
+General	
+	physicalAgeYoung
+	physicalAgeOld
+	weight
+	muscles
+	height                  - not an actual morph, scaled with model transform
+	waist
+	belly
+	hips
+	butt
+	lips
+	shoulders
+	boobsSmall
+	areolae
+	dick
+	balls
+	scrotum
+	foreskin
+	ballsRemove
+	dickRemove
+	vaginaRemove            - just closes the vagina
+
+amputee	
+	leftArm
+	rightArm
+	leftLeg
+	rightLeg
+
+boobShape	
+	normal
+	perky
+	saggy
+	torpedo-shaped          (torpedo)
+	downward-facing         (downward)
+	wide-set                (wide)
+
+nipples	
+	huge
+	tiny
+	cute
+	puffy
+	inverted
+	partially inverted      (partially)
+	fuckable
+
+faceShape	
+	normal
+	masculine
+	androgynous
+	cute
+	sensual
+	exotic
+
+areolaeShape	
+	heart
+	star
+	circle
+
+race	
+	white
+	asian
+	latina
+	black
+	pacific islander        (pacific)
+	southern european       (european)
+	amerindian
+	semitic
+	middle eastern          (eastern)
+	indo-aryan              (aryan)
+	mixed race
+
+poses	
+	high
+	mid
+	low
+
+///////////////// MATERIALS /////////////////
+
+general
+    eye color
+    hColor
+    makeup
+    fingernails
+
+skin color	
+    pure white		        (Ceridwen)
+    ivory		            (Ceridwen)
+    white		            (Ceridwen)
+    extremely pale		    (Celinette)
+    very pale		        (Celinette)
+    pale		            (Kimmy)
+    extremely fair		    (Kimmy)
+    very fair		        (Saffron)
+    fair		            (Saffron)
+    light		            (FemaleBase)
+    light olive		        (FemaleBase)
+    tan		                (Reagan)
+    olive		            (Kathy)
+    bronze		            (Mylou)
+    dark olive		        (Adaline)
+    dark		            (Daphne)
+    light beige		        (Minami)
+    beige		            (Tara)
+    dark beige		        (Topmodel)
+    light brown		        (Topmodel)
+    brown		            (Angelica)
+    dark brown		        (Angelica)
+    black		            (DarkSkin)
+    ebony		            (DarkSkin)
+    pure black		        (DarkSkin)
+
+dick color
+    pure white	            (WhiteFuta)
+    ivory	                (WhiteFuta)
+    white	                (WhiteFuta)
+    extremely pale	        (WhiteFuta)
+    very pale	            (WhiteFuta)
+    pale	                (WhiteFuta)
+    extremely fair      	(WhiteFuta)
+    very fair	            (LightFuta)
+    fair	                (LightFuta)
+    light	                (LightFuta)
+    light olive	            (LightFuta)
+    tan	                    (MidFuta)
+    olive	                (MidFuta)
+    bronze	                (MidFuta)
+    dark olive          	(MidFuta)
+    dark	                (MidFuta)
+    light beige	            (MidFuta)
+    beige	                (MidFuta)
+    dark beige	            (DarkFuta)
+    light brown	            (DarkFuta)
+    brown	                (DarkFuta)
+    dark brown	            (DarkFuta)
+    black	                (DarkFuta)
+    ebony	                (DarkFuta)
+    pure black	            (DarkFuta)
+
+///////////////// MESH /////////////////
+
+General
+    genesis8
+    vagina
+    dick
+
+Hairstyle
+    bobHair
diff --git a/src/art/webgl/engine.js b/src/art/webgl/engine.js
new file mode 100644
index 0000000000000000000000000000000000000000..d15de10c69f890e36df7df4bb315e71ce34d06b6
--- /dev/null
+++ b/src/art/webgl/engine.js
@@ -0,0 +1,701 @@
+'use strict';
+
+App.Art.Engine = class {
+	constructor() {
+		this.vsSourceBg = `#version 300 es
+                    precision highp float;
+
+                    in vec2 vertexPosition;
+                    out vec2 vertexPos;
+            
+                    void main() {
+                        vertexPos = vertexPosition;
+                        gl_Position = vec4(vertexPosition, 0.0, 1.0);
+                    }`;
+
+		this.fsSourceBg = `#version 300 es
+                    precision highp float;
+                    precision highp sampler2D;
+
+                    uniform sampler2D textSampler;
+                    uniform vec4 backgroundColor;
+
+                    in vec2 vertexPos;
+                    out vec4 outputColor;
+
+                    void main() {
+                        vec2 textureCoord = vec2(vertexPos.s, -vertexPos.t) * 0.5 + 0.5;
+                        vec3 c = backgroundColor.rgb * texture(textSampler, textureCoord.st).rgb;
+                        outputColor  = vec4(c.rgb * backgroundColor.a, backgroundColor.a);
+                    }`;
+
+		this.vsSource = `#version 300 es
+                    precision highp float;
+
+                    uniform mat4 matView;
+                    uniform mat4 matProj;
+                    uniform mat4 matRot;
+                    uniform mat4 matTrans;
+                    uniform mat4 matScale;
+
+                    in vec3 vertexNormal;
+                    in vec3 vertexPosition;
+                    in vec2 textureCoordinate;
+                    in vec3 vertexTangent;
+
+                    in vec3 vertexNormalMorph;
+                    in vec3 vertexPositionMorph;
+
+                    out vec2 textureCoord;
+                    out vec3 normal;
+                    out mat3 TBN;
+
+                    void main() {
+                        gl_Position = matProj * matView * matTrans * matScale * matRot * vec4(vertexPosition + vertexPositionMorph, 1.0) + 0.01;
+                        normal = normalize((matRot * vec4(vertexNormal + vertexNormalMorph, 1.0)).xyz);
+
+                        vec3 T = normalize(vec3(matTrans * matRot * vec4(vertexTangent, 0.0)));
+                        vec3 N = normalize(vec3(matTrans * matRot * vec4(vertexNormal + vertexNormalMorph, 0.0)));
+                        T = normalize(T - dot(T, N) * N);
+                        vec3 B = cross(N, T);
+                        TBN = mat3(T, B, N);
+
+                        textureCoord = textureCoordinate;
+                    }`;
+
+		this.fsSource = `#version 300 es
+                    precision highp float;
+                    precision highp sampler2D;
+                    
+                    uniform float lightInt;
+                    uniform float lightAmb;
+                    uniform vec3 lightColor;
+                    uniform float whiteM;
+                    uniform float gammaY;
+
+                    uniform vec3 Ka;
+                    uniform vec3 Kd;
+                    uniform vec3 Ks;
+                    uniform float d;
+                    uniform float Ns;
+
+                    uniform float sNormals;
+                    uniform float sAmbient;
+                    uniform float sDiffuse;
+                    uniform float sSpecular;
+                    uniform float sAlpha;
+                    uniform float sGamma;
+                    uniform float sReinhard;
+                    uniform float sNormal;
+                    
+                    uniform vec3 lightVect;
+                    uniform vec3 lookDir;
+
+                    uniform sampler2D textSampler[6];
+
+                    in vec2 textureCoord;
+                    in vec3 normal;
+                    in mat3 TBN;
+
+                    out vec4 outputColor;
+
+                    void main() {
+                        vec3 new_normal = normal;
+                        vec3 map_Ka = vec3(0.0,0.0,0.0);
+                        vec3 map_Kd = vec3(0.0,0.0,0.0);
+                        vec3 map_Ks = vec3(0.0,0.0,0.0);
+                        float map_Ns = 0.0;
+                        float map_d = 1.0;
+                        float specular = 1.0;
+
+                        if (sNormal == 1.0) {
+                            vec3 map_Kn = texture(textSampler[5], textureCoord.st).rgb *2.0-1.0;
+                            if (map_Kn != vec3(-1.0,-1.0,-1.0))
+                                new_normal = normalize(TBN * map_Kn);
+                        }
+
+                        float angle = max(dot(-lightVect, new_normal),0.0);
+                        vec3 reflection = reflect(-lightVect, new_normal);
+
+                        if (sAmbient == 1.0)
+                            map_Ka = Ka * texture(textSampler[0], textureCoord.st).rgb;
+
+                        if (sDiffuse == 1.0)
+                            map_Kd = Kd * texture(textSampler[1], textureCoord.st).rgb;
+
+                        if (sSpecular == 1.0) {
+                            map_Ks = Ks * texture(textSampler[2], textureCoord.st).rgb;
+                            map_Ns = Ns * texture(textSampler[3], textureCoord.st).r;
+                            specular = pow(max(dot(reflection, lookDir),0.0), (0.0001+map_Ns));
+                        }
+
+                        if (sAlpha == 1.0)
+                            map_d = d * texture(textSampler[4], textureCoord.st).r;
+
+                        vec3 Ld = map_Kd * lightInt * angle * lightColor;
+                        vec3 Ls = map_Ks * specular * lightColor;
+                        vec3 La = map_Ka * lightAmb * lightColor;
+
+                        vec3 vLighting = Ld + Ls + La;
+                        vec3 c = map_Kd * vLighting;
+
+                        if (sReinhard == 1.0) {
+                            float l_old = 0.2126*c.r+0.7152*c.g+0.0722*c.b;
+                            float numerator = l_old * (1.0 + (l_old / (whiteM*whiteM)));
+                            float l_new = numerator / (1.0 + l_old);
+                            c = c * (l_new / l_old);
+                        }
+
+                        if (sGamma == 1.0) {
+                            c.r = pow(c.r, (1.0/gammaY));
+                            c.g = pow(c.g, (1.0/gammaY));
+                            c.b = pow(c.b, (1.0/gammaY));
+                        }
+
+                        if (sNormals == 1.0) {
+                            c = new_normal;
+                        }
+
+                        outputColor = vec4(c*map_d, map_d);
+                    }`;
+	}
+
+	initBuffers() {
+		this.backgroundPositionBuffer = this.gl.createBuffer();
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.backgroundPositionBuffer);
+		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1]), this.gl.STATIC_DRAW);
+
+		this.backgroundIndexBuffer = this.gl.createBuffer();
+		this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.backgroundIndexBuffer);
+		this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), this.gl.STATIC_DRAW);
+
+		this.verticesPositionBuffer = this.gl.createBuffer();
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesPositionBuffer);
+		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(this.base64ToFloat(this.sceneData.model.verts)), this.gl.STATIC_DRAW);
+		this.vertexCount = this.gl.getBufferParameter(this.gl.ARRAY_BUFFER, this.gl.BUFFER_SIZE)/4;
+
+		this.verticesNormalBuffer = this.gl.createBuffer();
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesNormalBuffer);
+		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(this.base64ToFloat(this.sceneData.model.vertsn)), this.gl.STATIC_DRAW);
+
+		this.verticesTextureCoordBuffer = this.gl.createBuffer();
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesTextureCoordBuffer);
+		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(this.base64ToFloat(this.sceneData.model.texts)), this.gl.STATIC_DRAW);
+
+		this.verticesTangentBuffer = this.gl.createBuffer();
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesTangentBuffer);
+		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(this.base64ToFloat(this.sceneData.model.tans)), this.gl.STATIC_DRAW);
+
+		this.vertexPositionMorphs = [];
+		this.vertexNormalMorphs = [];
+		this.vertexIndexMorphs = [];
+		for(let i=0; i < this.sceneData.model.mverts.length; i++) {
+			this.vertexPositionMorphs[i] = new Float32Array(this.base64ToFloat(this.sceneData.model.mverts[i]));
+			this.vertexNormalMorphs[i] = new Float32Array(this.base64ToFloat(this.sceneData.model.mvertsn[i]));
+			let vertexIndexMorph = new Int32Array(this.base64ToInt(this.sceneData.model.mvertsi[i]));
+			this.vertexIndexMorphs[i] = vertexIndexMorph.map((sum => value => sum += value)(0));
+		}
+
+		this.verticesMorphBuffer = this.gl.createBuffer();
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesMorphBuffer);
+		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(0), this.gl.STATIC_DRAW);
+
+		this.verticesNormalMorphBuffer = this.gl.createBuffer();
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesNormalMorphBuffer);
+		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(0), this.gl.STATIC_DRAW);
+
+		this.verticesIndexBuffer = [];
+		this.indexSizes = [];
+		for (let i=0, count=0; i < this.sceneData.model.figures.length; i++) {
+			for (let j=0; j < this.sceneData.model.figures[i].surfaces.length; j++, count++) {
+				this.verticesIndexBuffer[count] = this.gl.createBuffer();
+				this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.verticesIndexBuffer[count]);
+				let intArray = this.base64ToInt(this.sceneData.model.figures[i].surfaces[j].vertsi);
+				this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(intArray), this.gl.STATIC_DRAW);
+				this.indexSizes[count] = intArray.length;
+			}
+		}
+	}
+
+	loadTexture(gl, url, engine) {
+		// return dummy texture
+		let texture = gl.createTexture();
+		gl.bindTexture(gl.TEXTURE_2D, texture);
+		gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]));
+
+		// stream real textures
+		let image = new Image();
+		image.onload = function() {
+			gl.bindTexture(gl.TEXTURE_2D, texture);
+			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+			gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+			gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+
+			// hack to update canvas again after streaming is done
+			engine.loadCount += 1;
+			if (engine.loadCount == engine.sceneData.textures.length) {
+				let containers = document.getElementsByClassName("artContainer");
+				for (let i = 0; i < containers.length; i++) {
+					containers[i].dispatchEvent(new Event("engineLoaded"));
+				}
+			}
+		};
+		image.src = url;
+
+		return texture;
+	}
+
+	initTextures() {
+		// load model textures
+		this.modelTextures = [];
+		this.loadCount = 0;
+		for (let i=0; i < this.sceneData.textures.length; i++) {
+			this.modelTextures[i] = this.loadTexture(this.gl, this.sceneData.textures[i], this);
+		}
+	}
+
+	initShaders() {
+		// compile shaders
+		let vertexShader = this.gl.createShader(this.gl.VERTEX_SHADER);
+		this.gl.shaderSource(vertexShader, this.vsSource);
+		this.gl.compileShader(vertexShader);
+
+		let fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER);
+		this.gl.shaderSource(fragmentShader, this.fsSource);
+		this.gl.compileShader(fragmentShader);
+
+		this.shaderProgram = this.gl.createProgram();
+		this.gl.attachShader(this.shaderProgram, vertexShader);
+		this.gl.attachShader(this.shaderProgram, fragmentShader);
+		this.gl.linkProgram(this.shaderProgram);
+
+		let vertexShaderBg = this.gl.createShader(this.gl.VERTEX_SHADER);
+		this.gl.shaderSource(vertexShaderBg, this.vsSourceBg);
+		this.gl.compileShader(vertexShaderBg);
+
+		let fragmentShaderBg = this.gl.createShader(this.gl.FRAGMENT_SHADER);
+		this.gl.shaderSource(fragmentShaderBg, this.fsSourceBg);
+		this.gl.compileShader(fragmentShaderBg);
+
+		this.shaderProgramBg = this.gl.createProgram();
+		this.gl.attachShader(this.shaderProgramBg, vertexShaderBg);
+		this.gl.attachShader(this.shaderProgramBg, fragmentShaderBg);
+		this.gl.linkProgram(this.shaderProgramBg);
+
+		this.gl.useProgram(this.shaderProgram);
+
+		// enable vertex attributes
+		this.backgroundPositionAttribute = this.gl.getAttribLocation(this.shaderProgramBg, "vertexPosition");
+		this.gl.enableVertexAttribArray(this.backgroundPositionAttribute);
+
+		this.vertexPositionAttribute = this.gl.getAttribLocation(this.shaderProgram, "vertexPosition");
+		this.gl.enableVertexAttribArray(this.vertexPositionAttribute);
+
+		this.textureCoordAttribute = this.gl.getAttribLocation(this.shaderProgram, "textureCoordinate");
+		this.gl.enableVertexAttribArray(this.textureCoordAttribute);
+
+		this.vertexNormalAttribute = this.gl.getAttribLocation(this.shaderProgram, "vertexNormal");
+		this.gl.enableVertexAttribArray(this.vertexNormalAttribute);
+
+		this.vertexTangentAttribute = this.gl.getAttribLocation(this.shaderProgram, "vertexTangent");
+		this.gl.enableVertexAttribArray(this.vertexTangentAttribute);
+
+		this.vertexNormalMorphAttribute = this.gl.getAttribLocation(this.shaderProgram, "vertexNormalMorph");
+		this.gl.enableVertexAttribArray(this.vertexNormalMorphAttribute);
+
+		this.vertexPositionMorphAttribute = this.gl.getAttribLocation(this.shaderProgram, "vertexPositionMorph");
+		this.gl.enableVertexAttribArray(this.vertexPositionMorphAttribute);
+	}
+
+	bind(sceneData) {
+		this.sceneData = sceneData;
+
+		this.offscreenCanvas = document.createElement("canvas");
+		this.gl = this.offscreenCanvas.getContext("webgl2", {alpha:true, premultipliedAlpha: true});
+
+		this.gl.enable(this.gl.CULL_FACE);
+		this.gl.cullFace(this.gl.BACK);
+		this.gl.enable(this.gl.DEPTH_TEST);
+		this.gl.depthFunc(this.gl.LEQUAL);
+		this.gl.enable(this.gl.BLEND);
+		this.gl.blendEquation( this.gl.FUNC_ADD );
+		this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
+
+		this.initBuffers();
+		this.initTextures();
+		this.initShaders();
+	}
+
+	render(sceneParams, canvas) {
+		// set render resolution
+		this.offscreenCanvas.width = sceneParams.settings.rwidth;
+		this.offscreenCanvas.height = sceneParams.settings.rheight;
+		this.gl.viewport(0, 0, this.gl.drawingBufferWidth, this.gl.drawingBufferHeight);
+
+		// draw background
+		this.gl.clearColor(0, 0, 0, 0);
+		this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
+		this.gl.useProgram(this.shaderProgramBg);
+		if (sceneParams.background.visible) {
+			this.drawBackground(sceneParams);
+		}
+
+		// draw model
+		this.gl.clear(this.gl.DEPTH_BUFFER_BIT);
+		this.gl.useProgram(this.shaderProgram);
+		this.drawModel(sceneParams);
+
+		// clone from offscreen to real canvas
+		let ctx = canvas.getContext('2d', {alpha:true});
+		ctx.clearRect(0, 0, canvas.width, canvas.height);
+		ctx.drawImage(this.gl.canvas, 0, 0, canvas.width, canvas.height);
+	}
+
+	drawBackground(sceneParams) {
+		this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramBg, "textSampler"), 0);
+		this.gl.uniform4fv(this.gl.getUniformLocation(this.shaderProgramBg, "backgroundColor"), sceneParams.background.color);
+
+		this.gl.activeTexture(this.gl.TEXTURE0);
+		this.gl.bindTexture(this.gl.TEXTURE_2D, this.modelTextures[sceneParams.background.filename]);
+
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.backgroundPositionBuffer);
+		this.gl.vertexAttribPointer(this.backgroundPositionAttribute, 2, this.gl.FLOAT, false, 0, 0);
+
+		this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.backgroundIndexBuffer);
+		this.gl.drawElements(this.gl.TRIANGLES, 6, this.gl.UNSIGNED_SHORT, 0);
+	}
+
+	drawModel(sceneParams) {
+		// create camera
+		let camRotX = this.degreeToRad(-sceneParams.camera.xr);
+		let camRotY = this.degreeToRad(-sceneParams.camera.yr);
+		let camRotZ = this.degreeToRad(sceneParams.camera.zr);
+
+		let up = [Math.sin(camRotZ), Math.cos(camRotZ), Math.sin(camRotZ)];
+		let camera = [sceneParams.camera.x, sceneParams.camera.y, sceneParams.camera.z];
+
+		let matCameraRot = this.matrixMulMatrix(this.matrixMakeRotationX(camRotX), this.matrixMakeRotationY(camRotY));
+		let lookDir = this.matrixMulVector(matCameraRot, [0, 0, 1]);
+		let target = this.vectorAdd(lookDir, camera);
+		let matCamera = this.matrixPointAt(camera, target, up);
+
+		// create transforms
+		this.applyMorphs(sceneParams);
+		let matProj = this.matrixMakeProjection(sceneParams.camera.fov, sceneParams.settings.rheight/sceneParams.settings.rwidth, sceneParams.camera.fnear, sceneParams.camera.ffar);
+		let matView = this.matrixInverse(matCamera);
+		let matRot = this.matrixMakeRotation(this.degreeToRad(sceneParams.transform.xr), this.degreeToRad(sceneParams.transform.yr), this.degreeToRad(sceneParams.transform.zr));
+		let matTrans = this.matrixMakeTranslation(sceneParams.transform.x, sceneParams.transform.y, sceneParams.transform.z);
+		let matScale = this.matrixMakeScaling( sceneParams.transform.scale);
+
+		let lightVect = this.polarToCart(this.degreeToRad(sceneParams.light.yr), this.degreeToRad(sceneParams.light.xr));
+		let lightAmb = sceneParams.light.ambient;
+		let lightInt = sceneParams.light.intensity;
+		let lightColor = sceneParams.light.color;
+		let whiteM = sceneParams.settings.whiteM;
+		let gammaY = sceneParams.settings.gammaY;
+
+		// set uniforms
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "sNormals"), sceneParams.settings.normals);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "sAmbient"), sceneParams.settings.ambient);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "sDiffuse"), sceneParams.settings.diffuse);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "sSpecular"), sceneParams.settings.specular);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "sNormal"), sceneParams.settings.normal);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "sAlpha"), sceneParams.settings.alpha);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "sReinhard"), sceneParams.settings.reinhard);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "sGamma"), sceneParams.settings.gamma);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "whiteM"), whiteM);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "gammaY"), gammaY);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "lightAmb"), lightAmb);
+		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "lightInt"), lightInt);
+		this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgram, "lightColor"), lightColor);
+		this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgram, "lightVect"), lightVect);
+		this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgram, "lookDir"), lookDir);
+
+		for (let i = 0; i < sceneParams.model.morphs.length; i++) {
+			this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, sceneParams.model.morphs[i].morphId), sceneParams.model.morphs[i].value);
+		}
+
+		this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgram, "matTrans"), false, new Float32Array(this.matrixFlatten(matTrans)));
+		this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgram, "matScale"), false, new Float32Array(this.matrixFlatten(matScale)));
+		this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgram, "matRot"), false, new Float32Array(this.matrixFlatten(matRot)));
+		this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgram, "matProj"), false, new Float32Array(this.matrixFlatten(matProj)));
+		this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgram, "matView"), false, new Float32Array(this.matrixFlatten(matView)));
+
+		// bind vertex buffers
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesPositionBuffer);
+		this.gl.vertexAttribPointer(this.vertexPositionAttribute, 3, this.gl.FLOAT, false, 0, 0);
+
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesTextureCoordBuffer);
+		this.gl.vertexAttribPointer(this.textureCoordAttribute, 2, this.gl.FLOAT, false, 0, 0);
+
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesNormalBuffer);
+		this.gl.vertexAttribPointer(this.vertexNormalAttribute, 3, this.gl.FLOAT, false, 0, 0);
+
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesTangentBuffer);
+		this.gl.vertexAttribPointer(this.vertexTangentAttribute, 3, this.gl.FLOAT, false, 0, 0);
+
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesMorphBuffer);
+		this.gl.vertexAttribPointer(this.vertexPositionMorphAttribute, 3, this.gl.FLOAT, false, 0, 0);
+
+		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesNormalMorphBuffer);
+		this.gl.vertexAttribPointer(this.vertexNormalMorphAttribute, 3, this.gl.FLOAT, false, 0, 0);
+
+		// bind materials per surface
+		for (let i=0, count=0; i < this.sceneData.model.figures.length; i++) {
+			for (let j=0; j < this.sceneData.model.figures[i].surfaces.length; j++, count++) {
+				if(!sceneParams.model.figures[i].visible) {
+					continue;
+				}
+
+				let visible = sceneParams.model.figures[i].surfaces[j].visible;
+				let matId = sceneParams.model.figures[i].surfaces[j].matId;
+				let matIdx = sceneParams.materials.map(e => e.matId).indexOf(matId);
+				if (matIdx === -1) {
+					continue;
+				}
+				let mat = sceneParams.materials[matIdx];
+
+				if (mat.d > 0 && visible) {
+					this.gl.activeTexture(this.gl.TEXTURE0);
+					this.gl.bindTexture(this.gl.TEXTURE_2D, this.modelTextures[parseInt(mat.map_Ka)]);
+					this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgram, "textSampler[0]"), 0);
+
+					this.gl.activeTexture(this.gl.TEXTURE1);
+					this.gl.bindTexture(this.gl.TEXTURE_2D, this.modelTextures[parseInt(mat.map_Kd)]);
+					this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgram, "textSampler[1]"), 1);
+
+					this.gl.activeTexture(this.gl.TEXTURE2);
+					this.gl.bindTexture(this.gl.TEXTURE_2D, this.modelTextures[parseInt(mat.map_Ks)]);
+					this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgram, "textSampler[2]"), 2);
+
+					this.gl.activeTexture(this.gl.TEXTURE3);
+					this.gl.bindTexture(this.gl.TEXTURE_2D, this.modelTextures[parseInt(mat.map_Ns)]);
+					this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgram, "textSampler[3]"), 3);
+
+					this.gl.activeTexture(this.gl.TEXTURE4);
+					this.gl.bindTexture(this.gl.TEXTURE_2D, this.modelTextures[parseInt(mat.map_D)]);
+					this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgram, "textSampler[4]"), 4);
+
+					this.gl.activeTexture(this.gl.TEXTURE5);
+					this.gl.bindTexture(this.gl.TEXTURE_2D, this.modelTextures[parseInt(mat.map_Kn)]);
+					this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgram, "textSampler[5]"), 5);
+
+					this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "d"), mat.d);
+					this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgram, "Ka"), mat.Ka);
+					this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgram, "Kd"), mat.Kd);
+					this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgram, "Ks"), mat.Ks);
+					this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, "Ns"), mat.Ns);
+
+					// draw materials
+					this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.verticesIndexBuffer[count]);
+					this.gl.drawElements(this.gl.TRIANGLES, this.indexSizes[count], this.gl.UNSIGNED_INT, 0);
+				}
+			}
+		}
+	}
+
+	applyMorphs(sceneParams) {
+		if(this.oldMorphValues !== JSON.stringify(sceneParams.model.morphs)) {
+			let vertexPositionMorph = new Float32Array(this.vertexCount);
+			let vertexNormalMorph = new Float32Array(this.vertexCount);
+
+			for(let i=0; i < this.vertexPositionMorphs.length; i++) {
+				let morphValue = sceneParams.model.morphs[i].value;
+
+				if (morphValue !== 0) {
+					let vp = this.vertexPositionMorphs[i];
+					let vn = this.vertexNormalMorphs[i];
+					let vi = this.vertexIndexMorphs[i];
+
+					if (morphValue === 1) {
+						for(let j = 0; j < vi.length; j++) {
+							vertexPositionMorph[vi[j]] += vp[j];
+							vertexNormalMorph[vi[j]] += vn[j];
+						}
+					} else {
+						for(let j=0; j < vi.length; j++) {
+							vertexPositionMorph[vi[j]] += vp[j] * morphValue;
+							vertexNormalMorph[vi[j]] += vn[j] * morphValue;
+						}
+					}
+				}
+			}
+
+			this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesMorphBuffer);
+			this.gl.bufferData(this.gl.ARRAY_BUFFER, vertexPositionMorph, this.gl.STATIC_DRAW);
+
+			this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.verticesNormalMorphBuffer);
+			this.gl.bufferData(this.gl.ARRAY_BUFFER, vertexNormalMorph, this.gl.STATIC_DRAW);
+
+			this.oldMorphValues = JSON.stringify(sceneParams.model.morphs);
+		}
+	}
+
+	base64ToFloat(array) {
+		let b	= window.atob(array),
+			fLen	= b.length / Float32Array.BYTES_PER_ELEMENT,
+			dView	= new DataView(new ArrayBuffer(Float32Array.BYTES_PER_ELEMENT)),
+			fAry	= new Float32Array(fLen),
+			p		= 0;
+
+		for(let j=0; j < fLen; j++){
+			p = j * 4;
+			dView.setUint8(0, b.charCodeAt(p));
+			dView.setUint8(1, b.charCodeAt(p+1));
+			dView.setUint8(2, b.charCodeAt(p+2));
+			dView.setUint8(3, b.charCodeAt(p+3));
+			fAry[j] = dView.getFloat32(0, true);
+		}
+		return fAry;
+	}
+
+	base64ToInt(array) {
+		let b	= window.atob(array),
+			fLen	= b.length / Int32Array.BYTES_PER_ELEMENT,
+			dView	= new DataView(new ArrayBuffer(Int32Array.BYTES_PER_ELEMENT)),
+			fAry	= new Int32Array(fLen),
+			p		= 0;
+
+		for(let j=0; j < fLen; j++){
+			p = j * 4;
+			dView.setUint8(0, b.charCodeAt(p));
+			dView.setUint8(1, b.charCodeAt(p+1));
+			dView.setUint8(2, b.charCodeAt(p+2));
+			dView.setUint8(3, b.charCodeAt(p+3));
+			fAry[j] = dView.getInt32(0, true);
+		}
+		return fAry;
+	}
+
+	base64ToByte(array) {
+		let b	= window.atob(array),
+			fLen	= b.length / Uint8Array.BYTES_PER_ELEMENT,
+			dView	= new DataView(new ArrayBuffer(Uint8Array.BYTES_PER_ELEMENT)),
+			fAry	= new Uint8Array(fLen);
+
+		for(let j=0; j < fLen; j++){
+			dView.setUint8(0, b.charCodeAt(j));
+			fAry[j] = dView.getUint8(0);
+		}
+		return fAry;
+	}
+
+	degreeToRad(d) { return d * (Math.PI / 180); }
+
+	polarToCart(y, p) { return [Math.sin(p) * Math.cos(y), Math.sin(p) * Math.sin(y), Math.cos(p)]; }
+
+	vectorAdd(v1, v2) { return [v1[0] + v2[0], v1[1] + v2[1], v1[2] + v2[2]]; }
+
+	vectorSub(v1, v2) { return [v1[0] - v2[0], v1[1] - v2[1], v1[2] - v2[2]]; }
+
+	vectorMul(v, k) { return [v[0] * k, v[1] * k, v[2] * k]; }
+
+	vectorDotProduct(v1, v2) { return [v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2]]; }
+
+	vectorCrossProduct(v1, v2) { return [v1[1] * v2[2] - v1[2] * v2[1], v1[2] * v2[0] - v1[0] * v2[2], v1[0] * v2[1] - v1[1] * v2[0]]; }
+
+	vectorNormalize(v) {
+		let l = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
+		return [v[0]/l, v[1]/l, v[2]/l];
+	}
+
+	matrixMakeProjection(fov, aspect, near, far) {
+		return [[aspect * (1/Math.tan(fov*0.5/180*Math.PI)), 0, 0, 0],
+			    [0, (1/Math.tan(fov*0.5/180*Math.PI)), 0, 0],
+			    [0, 0, far/(far-near), 1],
+			    [0, 0, (-far*near)/(far-near), 0]];
+	}
+
+	matrixPointAt(pos, target, up) {
+		let newForward = this.vectorNormalize(this.vectorSub(target, pos));
+		let a = this.vectorMul(newForward, this.vectorDotProduct(up, newForward));
+		let newUp = this.vectorNormalize(this.vectorSub(up, a));
+		let newRight = this.vectorCrossProduct(newUp, newForward);
+
+		return [[newRight[0], newRight[1], newRight[2], 0],
+			    [newUp[0], newUp[1], newUp[2], 0],
+			    [newForward[0], newForward[1], newForward[2], 0],
+			    [pos[0], pos[1], pos[2], 1]];
+	}
+
+	matrixMakeRotation(xr, yr, zr) {
+		let cosA = Math.cos(xr);
+		let cosB = Math.cos(yr);
+		let cosC = Math.cos(zr);
+		let sinA = Math.sin(xr);
+		let sinB = Math.sin(yr);
+		let sinC = Math.sin(zr);
+
+		return([[cosB*cosC, -cosB*sinC, sinB, 0],
+			[sinA*sinB*cosC+cosA*sinC, -sinA*sinB*sinC+cosA*cosC, -sinA*cosB, 0],
+			[-cosA*sinB*cosC+sinA*sinC, cosA*sinB*sinC+sinA*cosC, cosA*cosB, 0],
+			[0, 0, 0, 1]]);
+	}
+
+	matrixMakeRotationX(r) {
+		return [[1, 0, 0],
+			[0, Math.cos(r), Math.sin(r)],
+			[0, -Math.sin(r), Math.cos(r)]];
+	}
+
+	matrixMakeRotationY(r) {
+		return [[Math.cos(r), 0, Math.sin(r)],
+			    [0, 1, 0],
+			    [-Math.sin(r), 0, Math.cos(r)]];
+	}
+
+	matrixMakeRotationZ(r) {
+		return [[Math.cos(r), Math.sin(r), 0],
+			    [-Math.sin(r), Math.cos(r), 0],
+			    [0, 0, 1]];
+	}
+
+	matrixMakeTranslation(x, y, z) {
+		return [[1, 0, 0, 0],
+			    [0, 1, 0, 0],
+			    [0, 0, 1, 0],
+			    [x, y, z, 1]];
+	}
+
+	matrixMakeScaling(s) {
+		return [[s, 0, 0, 0],
+			    [0, s, 0, 0],
+			    [0, 0, s, 0],
+			    [0, 0, 0, 1]];
+	}
+
+	matrixMulMatrix(m1, m2) {
+		return [[m1[0][0] * m2[0][0] + m1[0][1] * m2[1][0] + m1[0][2] * m2[2][0],
+			    m1[0][0] * m2[0][1] + m1[0][1] * m2[1][1] + m1[0][2] * m2[2][1],
+			    m1[0][0] * m2[0][2] + m1[0][1] * m2[1][2] + m1[0][2] * m2[2][2]],
+		        [m1[1][0] * m2[0][0] + m1[1][1] * m2[1][0] + m1[1][2] * m2[2][0],
+			    m1[1][0] * m2[0][1] + m1[1][1] * m2[1][1] + m1[1][2] * m2[2][1],
+			    m1[1][0] * m2[0][2] + m1[1][1] * m2[1][2] + m1[1][2] * m2[2][2]],
+		        [m1[2][0] * m2[0][0] + m1[2][1] * m2[1][0] + m1[2][2] * m2[2][0],
+			    m1[2][0] * m2[0][1] + m1[2][1] * m2[1][1] + m1[2][2] * m2[2][1],
+			    m1[2][0] * m2[0][2] + m1[2][1] * m2[1][2] + m1[2][2] * m2[2][2]]];
+	}
+
+	matrixMulVector(m, v) {
+		return [v[0] * m[0][0] + v[1] * m[1][0] + v[2] * m[2][0],
+			    v[0] * m[0][1] + v[1] * m[1][1] + v[2] * m[2][1],
+			    v[0] * m[0][2] + v[1] * m[1][2] + v[2] * m[2][2]];
+	}
+
+	matrixInverse(m) {
+		return [[m[0][0], m[1][0], m[2][0], 0],
+			    [m[0][1], m[1][1], m[2][1], 0],
+			    [m[0][2], m[1][2], m[2][2], 0],
+			    [-(m[3][0]*m[0][0]+m[3][1]*m[0][1]+m[3][2]*m[0][2]), -(m[3][0]*m[1][0]+m[3][1]*m[1][1]+m[3][2]*m[1][2]), -(m[3][0]*m[2][0]+m[3][1]*m[2][1]+m[3][2]*m[2][2]), 1]];
+	}
+
+	matrixFlatten(m) {
+		return [m[0][0], m[0][1], m[0][2], m[0][3],
+			    m[1][0], m[1][1], m[1][2], m[1][3],
+			    m[2][0], m[2][1], m[2][2], m[2][3],
+			    m[3][0], m[3][1], m[3][2], m[3][3]];
+	}
+};
+
diff --git a/src/art/webgl/ui.js b/src/art/webgl/ui.js
new file mode 100644
index 0000000000000000000000000000000000000000..6b02540deab7cfef1fd8196e7de721638085f510
--- /dev/null
+++ b/src/art/webgl/ui.js
@@ -0,0 +1,159 @@
+App.Art.isDraggingCanvas = false;
+
+App.Art.createWebglUI = function(container, slave, artSize, scene) {
+	let lockViewDisabled = "resources/webgl/ui/lockViewDisabled.png";
+	let lockViewEnabled = "resources/webgl/ui/lockViewEnabled.png";
+	let faceViewDisabled = "resources/webgl/ui/faceViewDisabled.png";
+	let faceViewEnabled = "resources/webgl/ui/faceViewEnabled.png";
+	let resetViewDisabled = "resources/webgl/ui/resetViewDisabled.png";
+	let resetViewEnabled = "resources/webgl/ui/resetViewEnabled.png";
+
+	let uicontainer = document.createElement("div");
+	uicontainer.setAttribute("style", "left: 82.5%; top: 5%; position: absolute; width: 15%; border: 0px; padding: 0px;");
+
+	// canvas
+	let cvs = document.createElement("canvas");
+	cvs.setAttribute("style", "position: absolute;");
+
+	// btnLockView
+	let btnLockView = document.createElement("input");
+	btnLockView.setAttribute("style", "display: flex; width: 100%; position: relative; border: 0px; padding: 0px; background-color: transparent;");
+	btnLockView.setAttribute("type", "image");
+	btnLockView.setAttribute("src", scene.lockView ? lockViewDisabled : lockViewEnabled);
+
+	// btnFaceView
+	let btnFaceView = document.createElement("input");
+	btnFaceView.setAttribute("style", "display: flex; width: 100%; position: relative; border: 0px; padding: 0px; background-color: transparent;");
+	btnFaceView.setAttribute("type", "image");
+	btnFaceView.setAttribute("src", scene.faceView ? faceViewEnabled : faceViewDisabled);
+
+	// btnResetView
+	let btnResetView = document.createElement("input");
+	btnResetView.setAttribute("style", "display: flex; width: 100%; position: relative; border: 0px; padding: 0px; background-color: transparent;");
+	btnResetView.setAttribute("type", "image");
+	btnResetView.setAttribute("src", scene.resetView ? resetViewEnabled : resetViewDisabled);
+
+	// events
+	btnLockView.onclick = function(e){
+		scene.lockView = !scene.lockView;
+
+		btnLockView.src = scene.lockView ? lockViewDisabled : lockViewEnabled;
+	};
+
+	btnFaceView.onclick = function(e){
+		scene.resetView = true;
+		scene.faceView = false;
+		btnFaceView.src = faceViewDisabled;
+		btnResetView.src = resetViewEnabled;
+
+		scene.camera.y = slave.height-5;
+		scene.transform.yr = 0;
+		scene.camera.z = -40 - slave.height/20;
+		App.Art.engine.render(scene, cvs);
+	};
+
+	btnResetView.onclick = function(e){
+		scene.resetView = false;
+		scene.faceView = true;
+		btnResetView.src = resetViewDisabled;
+		btnFaceView.src = faceViewEnabled;
+
+		scene.camera.y = App.Art.defaultScene.camera.y;
+		scene.transform.yr = App.Art.defaultScene.transform.yr;
+		scene.camera.z = App.Art.defaultScene.camera.z;
+		App.Art.engine.render(scene, cvs);
+	};
+
+	cvs.onmousemove = function(e){
+		if(scene.lockView){ return; }
+		if(!App.Art.isDraggingCanvas){ return; }
+		e.preventDefault();
+		e.stopPropagation();
+
+		scene.resetView = true;
+		scene.faceView = true;
+		btnResetView.src = resetViewEnabled;
+		btnFaceView.src = faceViewEnabled;
+
+		scene.camera.y = scene.camera.y + e.movementY/10;
+		scene.transform.yr = scene.transform.yr + e.movementX*5;
+		App.Art.engine.render(scene, cvs);
+	};
+
+	cvs.onmousedown = function(e){
+		if(scene.lockView){ return; }
+		e.preventDefault();
+		e.stopPropagation();
+		App.Art.isDraggingCanvas=true;
+	};
+
+	cvs.onmouseup = function(e){
+		if(scene.lockView){ return; }
+		if(!App.Art.isDraggingCanvas){ return; }
+		e.preventDefault();
+		e.stopPropagation();
+		App.Art.isDraggingCanvas=false;
+	};
+
+	cvs.onmouseout = function(e){
+		if(scene.lockView){ return; }
+		if(!App.Art.isDraggingCanvas){ return; }
+		e.preventDefault();
+		e.stopPropagation();
+		App.Art.isDraggingCanvas=false;
+	};
+
+	cvs.onwheel = function(e){
+		if(scene.lockView){ return; }
+
+		scene.resetView = true;
+		scene.faceView = true;
+		btnResetView.src = resetViewEnabled;
+		btnFaceView.src = faceViewEnabled;
+
+		scene.camera.z = scene.camera.z - e.deltaY/7;
+
+		if (scene.camera.z < -900) {
+			scene.camera.z = -900;
+		}
+		if (scene.camera.z > -10) {
+			scene.camera.z = -10;
+		}
+
+		App.Art.engine.render(scene, cvs);
+		return false;
+	};
+
+	container.appendChild(cvs);
+	uicontainer.appendChild(btnLockView);
+	uicontainer.appendChild(btnFaceView);
+	uicontainer.appendChild(btnResetView);
+	container.appendChild(uicontainer);
+
+	if (artSize) {
+		let sz;
+		switch (artSize) {
+			case 3:
+				sz = [300, 530];
+				break;
+			case 2:
+				sz = [300, 300];
+				break;
+			case 1:
+				sz = [150, 150];
+				break;
+			default:
+				sz = [120, 120];
+				break;
+		}
+
+		cvs.width = sz[0];
+		cvs.height = sz[1];
+		container.setAttribute("style", "position: relative; width: " + sz[0] + "px; height: " + sz[1] + "px;");
+	}
+
+	scene.settings.rwidth = cvs.width*2;
+	scene.settings.rheight = cvs.height*2;
+
+	return cvs;
+};
diff --git a/src/gui/options/options.js b/src/gui/options/options.js
index 2ba22b920def06d9a4b6de422eaf07f391090009..8397da521b96ceaa578f425df8517e28dda756f6 100644
--- a/src/gui/options/options.js
+++ b/src/gui/options/options.js
@@ -1038,7 +1038,7 @@ App.UI.artOptions = function() {
 
 	if (V.seeImages > 0) {
 		options.addOption("Image style is", "imageChoice")
-			.addValueList([["Revamped embedded vector art", 3], ["Non-embedded vector art", 2], ["NoX/Deepmurk's vector art", 1], ["Shokushu's rendered imagepack", 0]]);
+			.addValueList([["Revamped embedded vector art", 3], ["Non-embedded vector art", 2], ["NoX/Deepmurk's vector art", 1], ["Shokushu's rendered imagepack", 0], ["Elohiem's interactive WebGL", 4]]);
 
 		if (V.imageChoice === 1) {
 			options.addComment('<span class="warning">Git compiled only, no exceptions.</span>');
@@ -1066,6 +1066,10 @@ App.UI.artOptions = function() {
 
 			options.addOption("Clothing erection bulges are", "showClothingErection")
 				.addValue("Enabled", true).on().addValue("Disabled", false).off();
+		} else if (V.imageChoice === 4) {
+			options.addComment("You need to" +
+				" <a href='https://mega.nz/folder/DogSxTiD#boO9kcbIhpXKCogjXMVwxQ'>download the WebGL art assets</a>" +
+				" and put the 'webgl' folder into the resources/ folder where this html file is. Then refresh the page.");
 		}
 
 		options.addOption("PA avatar art is", "seeAvatar")