code:
'use strict';

App.Art.Engine = class {
	getVsSourceGeometry() {
		return `#version 300 es
				uniform mat4 matModelViewProjection;
				uniform mat4 matModelView;
				uniform mat4 matModel;
				uniform mat4 matNormal;

				in vec3 vertexNormal;
				in vec3 vertexPosition;
				in vec2 textureCoordinate;

				in vec3 vertexNormalMorph;
				in vec3 vertexPositionMorph;

				out vec2 textureCoord;
				out vec3 normal;
				out vec3 pos;

				void main() {
					gl_Position = matModelViewProjection * vec4(vertexPosition + vertexPositionMorph, 1.0);
					normal = normalize((matNormal * vec4(vertexNormal + vertexNormalMorph, 1.0)).xyz);

					textureCoord = textureCoordinate;
					pos = (matModelView * vec4(vertexPosition + vertexPositionMorph, 1.0)).xyz;
				}`;
	}

	getFsSourceGeometry() {
		return `#version 300 es
				precision highp float;

				layout(location = 0) out vec3 gPosition;
				layout(location = 1) out vec3 gNormal;
				layout(location = 2) out float gAlpha;

				uniform sampler2D alpha;

				in vec2 textureCoord;
				in vec3 pos;
				in vec3 normal;

				void main() {
					float map_d = texture(alpha, textureCoord).r;

					if (map_d < 0.85)
						discard;

					gPosition = pos;
					gNormal = normal;
					gAlpha = map_d;
				}`;
	}

	getVsSourceQuad() {
		return `#version 300 es
				in vec2 vertexPosition;
				in vec2 textureCoordinate;

				out vec2 textureCoord;

				void main() {
					textureCoord = textureCoordinate;
					gl_Position = vec4(vertexPosition, 0.0, 1.0);
				}`;
	}

	getFsSourceSSAO() {
		return `#version 300 es
				precision highp float;

				out float ao;

				in vec2 textureCoord;

				uniform sampler2D gPosition;
				uniform sampler2D gNormal;
				uniform sampler2D gAlpha;
				uniform sampler2D texNoise;

				uniform vec3 samples[32];

				uniform mat4 projection;
				uniform mat4 view;

				uniform float radius;
				uniform float bias;
				uniform float scale;

				void main() {
					vec2 resolution = vec2(textureSize(gPosition, 0));
					vec3 pos = texture(gPosition, textureCoord).xyz;
					vec3 normal = normalize(texture(gNormal, textureCoord).rgb);

					// tile noise texture over screen based on screen dimensions divided by noise size
					vec3 randomVec = normalize(texture(texNoise, textureCoord * resolution / vec2(3.0, 3.0)).xyz);

					// create TBN
					vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
					vec3 bitangent = cross(normal, tangent);
					mat3 TBN = mat3(tangent, bitangent, normal);

					// iterate over the sample kernel and calculate occlusion factor
					float occlusion = 0.0;
					for(int i = 0; i < 32; i++)
					{
						// get sample position
						vec3 samplePos = TBN * samples[i]; // from tangent to view-space
						samplePos = pos + samplePos * radius;

						// project sample position
						vec4 offset = vec4(samplePos, 1.0);
						offset = projection * offset; // from view to clip-space
						offset.xy /= offset.w; // perspective divide
						offset.xy = offset.xy * 0.5 + 0.5; // transform to range 0.0 - 1.0

						float sampleDepth = texture(gPosition, offset.xy).z;

						// range check & accumulate
						float rangeCheck = smoothstep(0.0, 1.0, radius / abs(pos.z - sampleDepth));
						occlusion += (sampleDepth <= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
					}
					occlusion = 1.0 - (occlusion / scale);

					ao = occlusion * texture(gAlpha, textureCoord).r;
				}`;
	}

	getFsSourceSSAOBlur() {
		return `#version 300 es
				precision highp float;

				out float ao;
				in vec2 textureCoord;

				uniform sampler2D ssaoInput;
				uniform int blur;

				void main()
				{
					vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0));
					float result = 0.0;
					for (int x = -blur; x <= blur; x++)
					{
						for (int y = -blur; y <= blur; y++)
						{
							vec2 offset = vec2(float(x), float(y)) * texelSize;
							result += texture(ssaoInput, textureCoord + offset).r;
						}
					}
					ao = result / (float(blur*2+1) * float(blur*2+1));
				}`;
	}

	getFsSourceForwardPass(dl, pl) {
		return `#version 300 es
				precision highp float;

				uniform float lightInt[${dl}];
				uniform vec3 lightColor[${dl}];
				uniform vec3 lightVect[${dl}];

				uniform float pointLightInt[${pl}];
				uniform vec3 pointLightColor[${pl}];
				uniform vec3 pointLightPos[${pl}];

				uniform float whiteM;
				uniform float gammaY;
				uniform vec3 irradiance;
				uniform float ssaoInt;

				uniform float d;
				uniform vec3 Ka;
				uniform vec3 Ks;
				uniform vec3 Ke;
				uniform float Ni;
				uniform float r;
				uniform float m;
				uniform float s;

				uniform float sNormals;
				uniform float sSSAO;
				uniform float sAO;
				uniform float sAmbient;
				uniform float sAlbedo;
				uniform float sSpecular;
				uniform float sEmission;
				uniform float sAlpha;
				uniform float sGamma;
				uniform float sReinhard;
				uniform float sNormal;
				uniform float sHDR;

				uniform float roughness;
				uniform float metallic;
				uniform vec3 fresnel;

				uniform vec3 cameraPos;

				uniform sampler2D textSampler[6];

				in vec2 textureCoord;
				in vec3 normal;
				in vec3 pos;

				out vec4 outputColor;

				const float PI = 3.14159265359;

				float distributionGGX(vec3 N, vec3 H, float roughness)
				{
					float a = roughness*roughness;
					float a2 = a*a;
					float NdotH = max(dot(N, H), 0.0);
					float NdotH2 = NdotH*NdotH;

					float num = a2;
					float denom = (NdotH2 * (a2 - 1.0) + 1.0);
					denom = PI * denom * denom;

					return num / denom;
				}

				float geometrySchlickGGX(float NdotV, float roughness)
				{
					float r = (roughness + 1.0);
					float k = (r*r) / 8.0;

					float num = NdotV;
					float denom = NdotV * (1.0 - k) + k;

					return num / denom;
				}

				float geometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
				{
					float NdotV = max(dot(N, V), 0.0);
					float NdotL = max(dot(N, L), 0.0);
					float ggx2 = geometrySchlickGGX(NdotV, roughness);
					float ggx1 = geometrySchlickGGX(NdotL, roughness);

					return ggx1 * ggx2;
				}

				vec3 fresnelSchlick(float cosTheta, vec3 F0)
				{
					return F0 + (1.0 - F0) * pow(max(1.0 - cosTheta, 0.0), 5.0);
				}

				vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness)
				{
					return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(max(1.0 - cosTheta, 0.0), 5.0);
				}

				mat3 createTBNMatrix(vec3 position, vec2 coord, vec3 normal)
				{
					vec3 tangent = normalize(dFdx(position)*dFdy(coord).t - dFdy(position)*dFdx(coord).t);
					vec3 bitangent = cross(normal, tangent);
					return mat3(tangent, bitangent, normal);
				}

				void main() {
					vec2 resolution = vec2(textureSize(textSampler[0], 0));
					mat3 TBN = createTBNMatrix(pos, textureCoord, normal);

					vec3 new_normal = normal;
					vec3 map_Ka = vec3(0.0,0.0,0.0);
					vec3 map_Ks = vec3(0.0,0.0,0.0);
					vec3 map_Ke = vec3(0.0,0.0,0.0);
					float map_d = 1.0;
					float ao = 1.0;

					if (sAlpha == 1.0)
						map_d = d * texture(textSampler[1], textureCoord).r;

					if (map_d < 0.05)
						discard;

					if (sNormal == 1.0) {
						vec3 map_Kn = texture(textSampler[5], textureCoord).rgb *2.0-1.0;
						if (map_Kn != vec3(-1.0,-1.0,-1.0))
							new_normal = normalize(TBN * map_Kn);
					}

					if (sSSAO == 1.0)
						ao = texture(textSampler[0], vec2(gl_FragCoord.x, gl_FragCoord.y) / resolution).r;

					if (sAlbedo == 1.0)
						map_Ka = Ka * texture(textSampler[2], textureCoord).rgb;

					if (sSpecular == 1.0)
						map_Ks = Ks * s * texture(textSampler[3], textureCoord).rgb;

					if (sEmission == 1.0)
						map_Ke = Ke * texture(textSampler[4], textureCoord).rgb;

					// material parameters
					vec3 albedo = pow(map_Ka, vec3(2.2));
					ao = pow(ao, ssaoInt);

					vec3 N = new_normal;
					vec3 V = normalize(cameraPos - pos);
					vec3 F0 = mix(vec3(Ni), albedo, m);

					// reflectance equation
					vec3 Lo = vec3(0.0);
					vec3 Le = map_Ke;
					for (int i = 0; i < ${dl}; i++) {
						if (lightInt[i] <= 0.0)
							continue;

						// calculate per-light radiance
						vec3 L = -lightVect[i];
						vec3 H = normalize(V + L);
						vec3 radiance = lightColor[i] * lightInt[i];

						// cook-torrance brdf
						float NDF = distributionGGX(N, H, r);
						float G = geometrySmith(N, V, L, r);
						vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

						vec3 numerator = NDF * G * F;
						float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.00001;
						vec3 specular = numerator / denominator;

						// kS is equal to Fresnel
						vec3 kD = vec3(1.0) - F;
						kD *= 1.0 - m;

						// add to outgoing radiance Lo
						float NdotL = max(dot(N, L), 0.0);
						Lo += (kD * albedo / PI + specular * map_Ks) * radiance * NdotL;
					}

					for (int i = 0; i < ${pl}; i++) {
						if (pointLightInt[i] <= 0.0)
							continue;

						// calculate per-light radiance
						vec3 L = normalize(pointLightPos[i] - pos);
						vec3 H = normalize(V + L);
						vec3 radiance = pointLightColor[i] * pointLightInt[i];

						// cook-torrance brdf
						float NDF = distributionGGX(N, H, r);
						float G = geometrySmith(N, V, L, r);
						vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

						vec3 numerator = NDF * G * F;
						float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.00001;
						vec3 specular = numerator / denominator;

						// kS is equal to Fresnel
						vec3 kD = vec3(1.0) - F;
						kD *= 1.0 - m;

						// add to outgoing radiance Lo
						float NdotL = max(dot(N, L), 0.0);
						Lo += (kD * albedo / PI + specular * map_Ks) * radiance * NdotL;
					}

					// ambient lighting
					vec3 kS = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, r);
					vec3 kD = vec3(1.0) - m;

					vec3 ambient = (kD * irradiance * albedo);
					vec3 c = (ambient + Lo + Le);

					if (map_d >= 0.85)
						c = c * ao;

					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 = pow(c, vec3(1.0/gammaY));

					if (sNormals == 1.0)
						c = new_normal;

					if (sAO == 1.0)
						c = vec3(ao);

					outputColor = vec4(c*map_d, map_d);
				}`;
	}

	initBuffers(sceneData, dir) {
		// init buffer containers
		this.buffers = new class {};
		this.buffers.models = [];
		for (let m=0; m < sceneData.models.length; m++) {
			this.buffers.models[m] = new class {};
		}

		// init quad buffers
		this.buffers.quadPositionBuffer = this.gl.createBuffer();
		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffers.quadPositionBuffer);
		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([-1, 1, -1, -1, 1, 1, 1, -1]), this.gl.STATIC_DRAW);

		this.buffers.quadTextureBuffer = this.gl.createBuffer();
		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffers.quadTextureBuffer);
		this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([0, 1, 0, 0, 1, 1, 1, 0]), this.gl.STATIC_DRAW);

		this.buffers.quadIndexBuffer = this.gl.createBuffer();
		this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.buffers.quadIndexBuffer);
		this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 3, 2, 1]), this.gl.STATIC_DRAW);

		this.initFrameBuffers(this.buffers, 1200, 1500);

		// init model buffers
		for (let m=0; m < this.buffers.models.length; m++) {
			let modelBuffers = this.buffers.models[m];
			let modelData = sceneData.models[m];

			modelBuffers.verticesPositionBuffer = [];
			modelBuffers.verticesNormalBuffer = [];
			modelBuffers.verticesTextureCoordBuffer = [];
			modelBuffers.vertexCount = [];
			modelBuffers.verticesMorphBuffer = [];
			modelBuffers.verticesNormalMorphBuffer = [];

			modelBuffers.verticesIndexBuffer = [];
			modelBuffers.indexSizes = [];
			for (let i=0, count=0; i < modelData.figures.length; i++) {
				modelBuffers.verticesPositionBuffer[i] = this.gl.createBuffer();
				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesPositionBuffer[i]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, this.base64ToFloat(modelData.figures[i].verts), this.gl.STATIC_DRAW);
				modelBuffers.vertexCount[i] = this.gl.getBufferParameter(this.gl.ARRAY_BUFFER, this.gl.BUFFER_SIZE)/4;

				modelBuffers.verticesNormalBuffer[i] = this.gl.createBuffer();
				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalBuffer[i]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, this.base64ToFloat(modelData.figures[i].vertsn), this.gl.STATIC_DRAW);

				modelBuffers.verticesTextureCoordBuffer[i] = this.gl.createBuffer();
				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesTextureCoordBuffer[i]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, this.base64ToFloat(modelData.figures[i].texts), this.gl.STATIC_DRAW);

				// return dummy morph
				modelBuffers.verticesMorphBuffer[i] = this.gl.createBuffer();
				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesMorphBuffer[i]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(0), this.gl.STATIC_DRAW);

				modelBuffers.verticesNormalMorphBuffer[i] = this.gl.createBuffer();
				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalMorphBuffer[i]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(0), this.gl.STATIC_DRAW);

				for (let j=0; j < modelData.figures[i].surfaces.length; j++, count++) {
					modelBuffers.verticesIndexBuffer[count] = this.gl.createBuffer();
					this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, modelBuffers.verticesIndexBuffer[count]);
					let intArray = this.base64ToInt(modelData.figures[i].surfaces[j].vertsi);
					this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, intArray, this.gl.STATIC_DRAW);
					modelBuffers.indexSizes[count] = intArray.length;
				}
			}

			this.initMorphs(modelBuffers, modelData, dir);
		}
	}

	updateResolution(screenWidth, screenHeight) {
		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.gPosition);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA32F, screenWidth, screenHeight, 0, this.gl.RGBA, this.gl.FLOAT, null);

		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.gNormal);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA16F, screenWidth, screenHeight, 0, this.gl.RGBA, this.gl.FLOAT, null);

		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.gAlpha);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.R16F, screenWidth, screenHeight, 0, this.gl.RED, this.gl.FLOAT, null);

		this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, this.buffers.rboDepth);
		this.gl.renderbufferStorage(this.gl.RENDERBUFFER, this.gl.DEPTH_COMPONENT16, screenWidth, screenHeight);

		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.ssaoColorBuffer);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.R16F, screenWidth, screenHeight, 0, this.gl.RED, this.gl.FLOAT, null);

		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.ssaoColorBufferBlur);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.R16F, screenWidth, screenHeight, 0, this.gl.RED, this.gl.FLOAT, null);

		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
	}

	initFrameBuffers(buffers, screenWidth, screenHeight) {
		// configure g-buffer framebuffer
		buffers.gBuffer = this.gl.createFramebuffer();
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, buffers.gBuffer);

		// position color buffer
		buffers.gPosition = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.gPosition);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA32F, screenWidth, screenHeight, 0, this.gl.RGBA, this.gl.FLOAT, null);
		this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, buffers.gPosition, 0);
		// normal color buffer
		buffers.gNormal = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.gNormal);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA16F, screenWidth, screenHeight, 0, this.gl.RGBA, this.gl.FLOAT, null);
		this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT1, this.gl.TEXTURE_2D, buffers.gNormal, 0);
		// alpha buffer
		buffers.gAlpha = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.gAlpha);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.R16F, screenWidth, screenHeight, 0, this.gl.RED, this.gl.FLOAT, null);
		this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT2, this.gl.TEXTURE_2D, buffers.gAlpha, 0);

		let attachments = [];
		attachments[0] = this.gl.COLOR_ATTACHMENT0;
		attachments[1] = this.gl.COLOR_ATTACHMENT1;
		attachments[2] = this.gl.COLOR_ATTACHMENT2;
		this.gl.drawBuffers(attachments);

		// create and attach depth buffer
		buffers.rboDepth = this.gl.createRenderbuffer();
		this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, buffers.rboDepth);
		this.gl.renderbufferStorage(this.gl.RENDERBUFFER, this.gl.DEPTH_COMPONENT16, screenWidth, screenHeight);
		this.gl.framebufferRenderbuffer(this.gl.FRAMEBUFFER, this.gl.DEPTH_ATTACHMENT, this.gl.RENDERBUFFER, buffers.rboDepth);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);

		// SSAO color buffer
		buffers.ssaoColorBuffer = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.ssaoColorBuffer);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.R16F, screenWidth, screenHeight, 0, this.gl.RED, this.gl.FLOAT, null);

		buffers.ssaoFBO = this.gl.createFramebuffer();
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, buffers.ssaoFBO);
		this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, buffers.ssaoColorBuffer, 0);

		// SSAO blur buffer
		buffers.ssaoColorBufferBlur = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.ssaoColorBufferBlur);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.R16F, screenWidth, screenHeight, 0, this.gl.RED, this.gl.FLOAT, null);

		buffers.ssaoBlurFBO = this.gl.createFramebuffer();
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, buffers.ssaoBlurFBO);
		this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, buffers.ssaoColorBufferBlur, 0);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);

		// generate sample kernel
		this.buffers.ssaoKernel = [];
		for (let i = 0; i < 32; i++) {
			let sample = [Math.random() * 2.0 - 1.0, Math.random() * 2.0 - 1.0, Math.random()];
			sample = this.vectorNormalize(sample);
			sample = this.vectorMul(sample, Math.random());
			let scale = i / 32.0;

			// scale samples s.t. they're more aligned to center of kernel
			scale = this.lerp(0.1, 1.0, scale * scale);
			sample = this.vectorMul(sample, scale);
			this.buffers.ssaoKernel.push(sample);
		}

		// generate noise texture
		let ssaoNoise = [];
		for (let i = 0; i < 9; i++) {
			let noise = [Math.random() * 2.0 - 1.0, Math.random() * 2.0 - 1.0, 0.0, 1.0];
			ssaoNoise = ssaoNoise.concat(noise);
		}

		buffers.noiseTexture = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.noiseTexture);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.REPEAT);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.REPEAT);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
		this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA16F, 3, 3, 0, this.gl.RGBA, this.gl.FLOAT, new Float32Array(ssaoNoise));
	}

	initMorphs(modelBuffers, modelData, dir) {
		window.sceneBlocks = {}; // automatically populated during loading of morphs

		let promisedMorphs = [];
		modelBuffers.vertexPositionMorphs = [];
		modelBuffers.vertexNormalMorphs = [];
		modelBuffers.vertexIndexMorphs = [];
		for (let f=0; f < modelData.figures.length; f++) {
			modelBuffers.vertexPositionMorphs[f] = [];
			modelBuffers.vertexNormalMorphs[f] = [];
			modelBuffers.vertexIndexMorphs[f] = [];
			for (let m=0; m < modelData.morphCount; m++) {
				modelBuffers.vertexPositionMorphs[f].push(new Float32Array(0));
				modelBuffers.vertexNormalMorphs[f].push(new Float32Array(0));
				modelBuffers.vertexIndexMorphs[f].push(new Int32Array(0));
			}

			// stream real morphs
			promisedMorphs[f] = this.loadMorph(modelBuffers, f, modelData.figures[f].morphs, dir);
		}

		Promise.all(promisedMorphs).then((values) => {
			if (values.length > 2) { // promise triggers twice (?)
				if (App.Art.engineReady) { // re-send loaded event after morphs finish streaming
					modelBuffers.oldMorphValues = null;
					let containers = document.getElementsByClassName("artContainer");
					for (let i = 0; i < containers.length; i++) {
						containers[i].dispatchEvent(new Event("engineLoaded"));
					}
				}
			}
		});
	}

	loadMorph(modelBuffers, m, path, dir) {
		let engine = this;
		return new Promise(function(resolve, reject) {
			let script = document.createElement("script");
			script.onload = function() {
				let morph = window.sceneBlocks[path.split("/").slice(-1)[0]];

				for (let i=0; i < morph.length; i+=3) {
					modelBuffers.vertexPositionMorphs[m][i/3] = engine.base64ToFloat(morph[i+0]);
					modelBuffers.vertexNormalMorphs[m][i/3] = engine.base64ToFloat(morph[i+1]);
					// reconstruct compressed indices
					modelBuffers.vertexIndexMorphs[m][i/3] = engine.base64ToInt(morph[i+2]).map((sum => value => sum += value)(0));
				}

				morph = null; // let garbage collector clean
				resolve();
			};
			script.onerror = function(e) {
				reject(e, script);
			};
			script.src = dir + path;
			document.head.appendChild(script);
		});
	}

	initTextures(sceneData, dir) {
		// load model textures
		this.textures = {};
		let promisedTextures = [];
		for (let key in sceneData.textures) {
			let texture = this.gl.createTexture();
			this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
			this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, 1, 1, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]));
			this.textures[key] = texture;

			promisedTextures.push(this.loadTexture(this.gl, texture, sceneData.textures[key], dir));
		}

		Promise.all(promisedTextures).then(() => {
			if (App.Art.engineReady) { // re-send loaded event after textures finish streaming
				let containers = document.getElementsByClassName("artContainer");
				for (let i = 0; i < containers.length; i++) {
					containers[i].dispatchEvent(new Event("engineLoaded"));
				}
			}
		});
	}

	loadTexture(gl, texture, path, dir) {
		return new Promise(function(resolve, reject) {
			let script = document.createElement("script");
			script.onload = function() {
				let url = window.sceneBlocks[path.split("/").slice(-1)[0]][0];

				let img = document.createElement("img");
				img.onload = function() {
					// resize
					let width = img.width;
					let height = img.height;
					let aspect = parseFloat(height / width);

					let textureSize = 1024;
					if(typeof V.setTextureResolution !== "undefined") {
						textureSize = V.setTextureResolution;
					}
					if (width > textureSize || height > textureSize) {
						if (width > textureSize) {
							width = textureSize;
							height = parseInt(width * aspect);
						}
						if (height > textureSize) {
							height = textureSize;
							width = parseInt(width / aspect);
						}

						let canvas = document.createElement("canvas");
						let ctx = canvas.getContext("2d");
						canvas.width = width;
						canvas.height = height;
						ctx.drawImage(img, 0, 0, width, height);
						img = canvas;
					}

					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.LINEAR);
					gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
					gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);

					url = null; // let garbage collector clean
					resolve();
				};
				img.src = url;
			};
			script.onerror = function(e) {
				reject(e, script);
			};
			script.src = dir + path;
			document.head.appendChild(script);
		});
	}

	initShaders(sceneParams) {
		// compile shaders
		let vertexShaderGeometry = this.gl.createShader(this.gl.VERTEX_SHADER);
		this.gl.shaderSource(vertexShaderGeometry, this.getVsSourceGeometry());
		this.gl.compileShader(vertexShaderGeometry);

		let fragmentShaderGeometry = this.gl.createShader(this.gl.FRAGMENT_SHADER);
		this.gl.shaderSource(fragmentShaderGeometry, this.getFsSourceGeometry());
		this.gl.compileShader(fragmentShaderGeometry);

		let vertexShaderQuad = this.gl.createShader(this.gl.VERTEX_SHADER);
		this.gl.shaderSource(vertexShaderQuad, this.getVsSourceQuad());
		this.gl.compileShader(vertexShaderQuad);

		let fragmentShaderSSAO = this.gl.createShader(this.gl.FRAGMENT_SHADER);
		this.gl.shaderSource(fragmentShaderSSAO, this.getFsSourceSSAO());
		this.gl.compileShader(fragmentShaderSSAO);

		let fragmentShaderSSAOBlur = this.gl.createShader(this.gl.FRAGMENT_SHADER);
		this.gl.shaderSource(fragmentShaderSSAOBlur, this.getFsSourceSSAOBlur());
		this.gl.compileShader(fragmentShaderSSAOBlur);

		let fragmentShaderForwardPass = this.gl.createShader(this.gl.FRAGMENT_SHADER);
		this.gl.shaderSource(fragmentShaderForwardPass, this.getFsSourceForwardPass(sceneParams.directionalLights.length, sceneParams.pointLights.length));
		this.gl.compileShader(fragmentShaderForwardPass);

		this.shaderProgramGeometry = this.gl.createProgram();
		this.gl.attachShader(this.shaderProgramGeometry, vertexShaderGeometry);
		this.gl.attachShader(this.shaderProgramGeometry, fragmentShaderGeometry);
		this.gl.linkProgram(this.shaderProgramGeometry);

		this.shaderProgramSSAO = this.gl.createProgram();
		this.gl.attachShader(this.shaderProgramSSAO, vertexShaderQuad);
		this.gl.attachShader(this.shaderProgramSSAO, fragmentShaderSSAO);
		this.gl.linkProgram(this.shaderProgramSSAO);

		this.shaderProgramSSAOBlur = this.gl.createProgram();
		this.gl.attachShader(this.shaderProgramSSAOBlur, vertexShaderQuad);
		this.gl.attachShader(this.shaderProgramSSAOBlur, fragmentShaderSSAOBlur);
		this.gl.linkProgram(this.shaderProgramSSAOBlur);

		this.shaderProgramForwardPass = this.gl.createProgram();
		this.gl.attachShader(this.shaderProgramForwardPass, vertexShaderGeometry);
		this.gl.attachShader(this.shaderProgramForwardPass, fragmentShaderForwardPass);
		this.gl.linkProgram(this.shaderProgramForwardPass);

		// enable vertex attributes

		this.vertexPositionAttribute = this.gl.getAttribLocation(this.shaderProgramGeometry, "vertexPosition");
		this.gl.enableVertexAttribArray(this.vertexPositionAttribute);

		this.textureCoordAttribute = this.gl.getAttribLocation(this.shaderProgramGeometry, "textureCoordinate");
		this.gl.enableVertexAttribArray(this.textureCoordAttribute);

		this.vertexNormalAttribute = this.gl.getAttribLocation(this.shaderProgramGeometry, "vertexNormal");
		this.gl.enableVertexAttribArray(this.vertexNormalAttribute);

		this.vertexNormalMorphAttribute = this.gl.getAttribLocation(this.shaderProgramGeometry, "vertexNormalMorph");
		this.gl.enableVertexAttribArray(this.vertexNormalMorphAttribute);

		this.vertexPositionMorphAttribute = this.gl.getAttribLocation(this.shaderProgramGeometry, "vertexPositionMorph");
		this.gl.enableVertexAttribArray(this.vertexPositionMorphAttribute);

		this.quadPositionAttribute = this.gl.getAttribLocation(this.shaderProgramSSAO, "vertexPosition");
		this.gl.enableVertexAttribArray(this.quadPositionAttribute);

		this.quadTextureAttribute = this.gl.getAttribLocation(this.shaderProgramSSAO, "textureCoordinate");
		this.gl.enableVertexAttribArray(this.quadTextureAttribute);
	}

	bind(sceneData, sceneParams, dir) {
		this.offscreenCanvas = document.createElement("canvas");
		this.gl = this.offscreenCanvas.getContext("webgl2", {alpha:true, premultipliedAlpha: true});
		this.gl.getExtension('EXT_color_buffer_float');

		this.gl.enable(this.gl.DEPTH_TEST);
		this.gl.depthMask(true);
		this.gl.depthFunc(this.gl.LEQUAL);
		this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);

		this.initBuffers(sceneData, dir);
		this.initTextures(sceneData, dir);
		this.initShaders(sceneParams);
	}

	render(sceneParams, canvas) {
		// update logic
		this.update(sceneParams);

		// set culling
		if (V.setFaceCulling) {
			this.gl.enable(this.gl.CULL_FACE);
			this.gl.cullFace(this.gl.BACK);
		} else {
			this.gl.disable(this.gl.CULL_FACE);
		}
		this.gl.clearColor(0, 0, 0, 0);

		// draw scene
		this.gl.disable(this.gl.BLEND);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.buffers.gBuffer);
		this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
		this.gl.useProgram(this.shaderProgramGeometry);
		this.drawGeometry(sceneParams);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
		this.gl.enable(this.gl.BLEND);

		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.buffers.ssaoFBO);
		this.gl.clear(this.gl.COLOR_BUFFER_BIT);
		this.gl.useProgram(this.shaderProgramSSAO);
		this.drawSSAO(sceneParams);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);

		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.buffers.ssaoBlurFBO);
		this.gl.clear(this.gl.COLOR_BUFFER_BIT);
		this.gl.useProgram(this.shaderProgramSSAOBlur);
		this.drawSSAOBlur(sceneParams);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);

		this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
		this.gl.useProgram(this.shaderProgramForwardPass);
		this.drawForwardPass(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);
	}

	update(sceneParams) {
		// 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);
		this.updateResolution(sceneParams.settings.rwidth, sceneParams.settings.rheight);

		// 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 scene transforms
		let matProj = this.matrixMakeProjection(sceneParams.camera.fov, sceneParams.settings.rheight/sceneParams.settings.rwidth, sceneParams.camera.fnear, sceneParams.camera.ffar);
		let matView = this.matrixInverse(matCamera);
		this.buffers.matProj = matProj;
		this.buffers.matView = matView;

		for (let m=0; m < this.buffers.models.length; m++) {
			let modelBuffers = this.buffers.models[m];
			let modelParams = sceneParams.models[m];

			if(!modelParams.visible) {
				continue;
			}

			// create model transforms
			this.applyMorphs(modelParams, modelBuffers);

			let matRot = this.matrixMakeRotation(this.degreeToRad(modelParams.transform.xr), this.degreeToRad(modelParams.transform.yr), this.degreeToRad(modelParams.transform.zr));
			let matTrans = this.matrixMakeTranslation(modelParams.transform.x, modelParams.transform.y, modelParams.transform.z);
			let matScale = this.matrixMakeScaling( modelParams.transform.scale);
			let matModel = this.matrixMulMatrix4(matScale, this.matrixMulMatrix4(matTrans, matRot));
			let matModelView = this.matrixMulMatrix4(matModel, matView);
			let matModelViewProjection = this.matrixMulMatrix4(matModelView, matProj);
			let matNormal = this.matrixTranspose(this.matrixInverse(matModelView));

			modelBuffers.matModel = matModel;
			modelBuffers.matModelView = matModelView;
			modelBuffers.matModelViewProjection = matModelViewProjection;
			modelBuffers.matNormal = matNormal;
		}
	}

	drawGeometry(sceneParams) {
		// process each model in the scene
		for (let m=0; m < this.buffers.models.length; m++) {
			let modelBuffers = this.buffers.models[m];
			let modelParams = sceneParams.models[m];

			if(!modelParams.visible) {
				continue;
			}

			// set model uniform
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramGeometry, "matModelViewProjection"), false, new Float32Array(this.matrixFlatten(modelBuffers.matModelViewProjection)));
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramGeometry, "matModelView"), false, new Float32Array(this.matrixFlatten(modelBuffers.matModelView)));
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramGeometry, "matModel"), false, new Float32Array(this.matrixFlatten(modelBuffers.matModel)));
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramGeometry, "matNormal"), false, new Float32Array(this.matrixFlatten(modelBuffers.matNormal)));

			for (let i=0, count=0; i < modelParams.figures.length; i++) {
				if(!modelParams.figures[i].visible) {
					count += modelParams.figures[i].surfaces.length;
					continue;
				}

				// bind vertex buffers per figure
				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesPositionBuffer[i]);
				this.gl.vertexAttribPointer(this.vertexPositionAttribute, 3, this.gl.FLOAT, false, 0, 0);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesTextureCoordBuffer[i]);
				this.gl.vertexAttribPointer(this.textureCoordAttribute, 2, this.gl.FLOAT, false, 0, 0);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalBuffer[i]);
				this.gl.vertexAttribPointer(this.vertexNormalAttribute, 3, this.gl.FLOAT, false, 0, 0);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesMorphBuffer[i]);
				this.gl.vertexAttribPointer(this.vertexPositionMorphAttribute, 3, this.gl.FLOAT, false, 0, 0);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalMorphBuffer[i]);
				this.gl.vertexAttribPointer(this.vertexNormalMorphAttribute, 3, this.gl.FLOAT, false, 0, 0);

				// bind materials per surface and set uniforms
				for (let j=0; j < modelParams.figures[i].surfaces.length; j++, count++) {
					let visible = modelParams.figures[i].surfaces[j].visible;

					for (let h=0; h < 1; h++) { // skip overlays
						let matId = modelParams.figures[i].surfaces[j].matIds[h];
						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.textures[mat.map_D]);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramGeometry, "alpha"), 0);

							this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, modelBuffers.verticesIndexBuffer[count]);
							this.gl.drawElements(this.gl.TRIANGLES, modelBuffers.indexSizes[count], this.gl.UNSIGNED_INT, 0);
						}
					}
				}
			}
		}
	}

	drawSSAO(sceneParams) {
		this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramSSAO, "projection"), false, new Float32Array(this.matrixFlatten(this.buffers.matProj)));
		this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramSSAO, "view"), false, new Float32Array(this.matrixFlatten(this.buffers.matView)));

		for (let i = 0; i < 32; ++i) {
			this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramSSAO, "samples[" + i + "]"), this.buffers.ssaoKernel[i]);
		}

		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramSSAO, "radius"), sceneParams.ssao.radius);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramSSAO, "bias"), sceneParams.ssao.bias);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramSSAO, "scale"), sceneParams.ssao.scale);

		this.gl.activeTexture(this.gl.TEXTURE0);
		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.gPosition);
		this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramSSAO, "gPosition"), 0);

		this.gl.activeTexture(this.gl.TEXTURE1);
		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.gNormal);
		this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramSSAO, "gNormal"), 1);

		this.gl.activeTexture(this.gl.TEXTURE2);
		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.gAlpha);
		this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramSSAO, "gAlpha"), 2);

		this.gl.activeTexture(this.gl.TEXTURE3);
		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.noiseTexture);
		this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramSSAO, "texNoise"), 3);

		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffers.quadPositionBuffer);
		this.gl.vertexAttribPointer(this.quadPositionAttribute, 2, this.gl.FLOAT, false, 0, 0);

		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffers.quadTextureBuffer);
		this.gl.vertexAttribPointer(this.quadTextureAttribute, 2, this.gl.FLOAT, false, 0, 0);

		this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.buffers.quadIndexBuffer);
		this.gl.drawElements(this.gl.TRIANGLES, 6, this.gl.UNSIGNED_SHORT, 0);
	}

	drawSSAOBlur(sceneParams) {
		this.gl.activeTexture(this.gl.TEXTURE0);
		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.ssaoColorBuffer);

		this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramSSAOBlur, "blur"), sceneParams.ssao.blur);

		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffers.quadPositionBuffer);
		this.gl.vertexAttribPointer(this.quadPositionAttribute, 2, this.gl.FLOAT, false, 0, 0);

		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffers.quadTextureBuffer);
		this.gl.vertexAttribPointer(this.quadTextureAttribute, 2, this.gl.FLOAT, false, 0, 0);

		this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.buffers.quadIndexBuffer);
		this.gl.drawElements(this.gl.TRIANGLES, 6, this.gl.UNSIGNED_SHORT, 0);
	}

	drawForwardPass(sceneParams) {
		// set scene uniforms
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sNormals"), sceneParams.settings.normals);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sAO"), sceneParams.settings.ao);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sSSAO"), sceneParams.settings.ssao);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sAlbedo"), sceneParams.settings.albedo);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sSpecular"), sceneParams.settings.specular);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sEmission"), sceneParams.settings.emission);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sNormal"), sceneParams.settings.normal);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sAlpha"), sceneParams.settings.alpha);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sReinhard"), sceneParams.settings.reinhard);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sGamma"), sceneParams.settings.gamma);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "whiteM"), sceneParams.settings.whiteM);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "gammaY"), sceneParams.settings.gammaY);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "roughness"), sceneParams.pbr.roughness);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "metallic"), sceneParams.pbr.metallic);
		this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "fresnel"), sceneParams.pbr.fresnel);
		this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "irradiance"), sceneParams.pbr.irradiance);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "ssaoInt"), sceneParams.ssao.intensity);

		for (let i = 0; i < sceneParams.directionalLights.length; i++) {
			let lightVect = this.polarToCart(this.degreeToRad(sceneParams.directionalLights[i].yr), this.degreeToRad(sceneParams.directionalLights[i].xr));
			this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "lightInt[" + i + "]"), sceneParams.directionalLights[i].intensity);
			this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "lightColor[" + i + "]"), sceneParams.directionalLights[i].color);
			this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "lightVect[" + i + "]"), lightVect);
		}

		for (let i = 0; i < sceneParams.pointLights.length; i++) {
			this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "pointLightInt[" + i + "]"), sceneParams.pointLights[i].intensity);
			this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "pointLightColor[" + i + "]"), sceneParams.pointLights[i].color);
			this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "pointLightPos[" + i + "]"), [sceneParams.pointLights[i].x, sceneParams.pointLights[i].y, sceneParams.pointLights[i].z]);
		}

		this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "cameraPos"), [sceneParams.camera.x, sceneParams.camera.y, sceneParams.camera.z]);

		// process each model in the scene
		for (let m=0; m < this.buffers.models.length; m++) {
			let modelBuffers = this.buffers.models[m];
			let modelParams = sceneParams.models[m];

			if(!modelParams.visible) {
				continue;
			}

			// set model uniform
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "matModelViewProjection"), false, new Float32Array(this.matrixFlatten(modelBuffers.matModelViewProjection)));
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "matModelView"), false, new Float32Array(this.matrixFlatten(modelBuffers.matModelView)));
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "matModel"), false, new Float32Array(this.matrixFlatten(modelBuffers.matModel)));
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "matNormal"), false, new Float32Array(this.matrixFlatten(modelBuffers.matNormal)));

			for (let i=0, count=0; i < modelParams.figures.length; i++) {
				if(!modelParams.figures[i].visible) {
					count += modelParams.figures[i].surfaces.length;
					continue;
				}

				// bind vertex buffers per figure
				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesPositionBuffer[i]);
				this.gl.vertexAttribPointer(this.vertexPositionAttribute, 3, this.gl.FLOAT, false, 0, 0);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesTextureCoordBuffer[i]);
				this.gl.vertexAttribPointer(this.textureCoordAttribute, 2, this.gl.FLOAT, false, 0, 0);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalBuffer[i]);
				this.gl.vertexAttribPointer(this.vertexNormalAttribute, 3, this.gl.FLOAT, false, 0, 0);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesMorphBuffer[i]);
				this.gl.vertexAttribPointer(this.vertexPositionMorphAttribute, 3, this.gl.FLOAT, false, 0, 0);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalMorphBuffer[i]);
				this.gl.vertexAttribPointer(this.vertexNormalMorphAttribute, 3, this.gl.FLOAT, false, 0, 0);

				// bind materials per surface and set uniforms
				for (let j=0; j < modelParams.figures[i].surfaces.length; j++, count++) {
					let visible = modelParams.figures[i].surfaces[j].visible;

					for (let h=0; h < modelParams.figures[i].surfaces[j].matIds.length; h++) {
						let matId = modelParams.figures[i].surfaces[j].matIds[h];
						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.buffers.ssaoColorBufferBlur);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[0]"), 0);

							this.gl.activeTexture(this.gl.TEXTURE1);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[mat.map_D]);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[1]"), 1);

							this.gl.activeTexture(this.gl.TEXTURE2);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[mat.map_Ka]);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[2]"), 2);

							this.gl.activeTexture(this.gl.TEXTURE3);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[mat.map_Ks]);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[3]"), 3);

							this.gl.activeTexture(this.gl.TEXTURE4);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[mat.map_Ke]);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[4]"), 4);

							this.gl.activeTexture(this.gl.TEXTURE5);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[mat.map_Kn]);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[5]"), 5);

							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "d"), mat.d);
							this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "Ka"), mat.Ka);
							this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "Ks"), mat.Ks);
							this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "Ke"), mat.Ke);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "Ni"), this.ior2f0(mat.Ni));
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "r"), mat.r);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "m"), mat.m);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "s"), mat.s);

							// draw materials
							this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, modelBuffers.verticesIndexBuffer[count]);
							this.gl.drawElements(this.gl.TRIANGLES, modelBuffers.indexSizes[count], this.gl.UNSIGNED_INT, 0);
						}
					}
				}
			}
		}
	}

	applyMorphs(modelParams, modelBuffers) {
		if(modelBuffers.oldMorphValues !== JSON.stringify(modelParams.morphs) + JSON.stringify(modelParams.figures)) {
			for (let f=0; f < modelParams.figures.length; f++) {
				if(!modelParams.visible || !modelParams.figures[f].visible) {
					continue;
				}

				let vertexPositionMorph = new Float32Array(modelBuffers.vertexCount[f]);
				let vertexNormalMorph = new Float32Array(modelBuffers.vertexCount[f]);

				for(let m=0; m < modelParams.morphs.length; m++) {
					let morphValue = modelParams.morphs[m].value;

					if (morphValue !== 0) {
						let vp = modelBuffers.vertexPositionMorphs[f][m];
						let vn = modelBuffers.vertexNormalMorphs[f][m];
						let vi = modelBuffers.vertexIndexMorphs[f][m];

						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, modelBuffers.verticesMorphBuffer[f]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, vertexPositionMorph, this.gl.STATIC_DRAW);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalMorphBuffer[f]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, vertexNormalMorph, this.gl.STATIC_DRAW);
			}

			modelBuffers.oldMorphValues = JSON.stringify(modelParams.morphs) + JSON.stringify(modelParams.figures);
		}
	}

	base64ToFloat(array) {
		let b = window.atob(array);
		let fLen = b.length / (Float32Array.BYTES_PER_ELEMENT-1);
		let dView = new DataView(new ArrayBuffer(Float32Array.BYTES_PER_ELEMENT));
		let fAry = new Float32Array(fLen);
		let p = 0;

		for(let j=0; j < fLen; j++){
			p = j * 3;
			dView.setUint8(0, 0); // skip 1 precision byte
			dView.setUint8(1, b.charCodeAt(p+0));
			dView.setUint8(2, b.charCodeAt(p+1));
			dView.setUint8(3, b.charCodeAt(p+2));
			fAry[j] = dView.getFloat32(0, true);
		}
		return fAry;
	}

	base64ToInt(array) {
		let b = window.atob(array);
		let fLen = b.length / Int32Array.BYTES_PER_ELEMENT;
		let dView = new DataView(new ArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
		let fAry = new Int32Array(fLen);
		let 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);
		let fLen = b.length / Uint8Array.BYTES_PER_ELEMENT;
		let dView = new DataView(new ArrayBuffer(Uint8Array.BYTES_PER_ELEMENT));
		let 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]]];
	}

	matrixMulMatrix4(m1, m2) {
		return [[m1[0][0] * m2[0][0] + m1[0][1] * m2[1][0] + m1[0][2] * m2[2][0] + m1[0][3] * m2[3][0],
			m1[0][0] * m2[0][1] + m1[0][1] * m2[1][1] + m1[0][2] * m2[2][1] + m1[0][3] * m2[3][1],
			m1[0][0] * m2[0][2] + m1[0][1] * m2[1][2] + m1[0][2] * m2[2][2] + m1[0][3] * m2[3][2],
			m1[0][0] * m2[0][3] + m1[0][1] * m2[1][3] + m1[0][2] * m2[2][3] + m1[0][3] * m2[3][3]],
			[m1[1][0] * m2[0][0] + m1[1][1] * m2[1][0] + m1[1][2] * m2[2][0] + m1[1][3] * m2[3][0],
			m1[1][0] * m2[0][1] + m1[1][1] * m2[1][1] + m1[1][2] * m2[2][1] + m1[1][3] * m2[3][1],
			m1[1][0] * m2[0][2] + m1[1][1] * m2[1][2] + m1[1][2] * m2[2][2] + m1[1][3] * m2[3][2],
			m1[1][0] * m2[0][3] + m1[1][1] * m2[1][3] + m1[1][2] * m2[2][3] + m1[1][3] * m2[3][3]],
			[m1[2][0] * m2[0][0] + m1[2][1] * m2[1][0] + m1[2][2] * m2[2][0] + m1[2][3] * m2[3][0],
			m1[2][0] * m2[0][1] + m1[2][1] * m2[1][1] + m1[2][2] * m2[2][1] + m1[2][3] * m2[3][1],
			m1[2][0] * m2[0][2] + m1[2][1] * m2[1][2] + m1[2][2] * m2[2][2] + m1[2][3] * m2[3][2],
			m1[2][0] * m2[0][3] + m1[2][1] * m2[1][3] + m1[2][2] * m2[2][3] + m1[2][3] * m2[3][3]],
			[m1[3][0] * m2[0][0] + m1[3][1] * m2[1][0] + m1[3][2] * m2[2][0] + m1[3][3] * m2[3][0],
			m1[3][0] * m2[0][1] + m1[3][1] * m2[1][1] + m1[3][2] * m2[2][1] + m1[3][3] * m2[3][1],
			m1[3][0] * m2[0][2] + m1[3][1] * m2[1][2] + m1[3][2] * m2[2][2] + m1[3][3] * m2[3][2],
			m1[3][0] * m2[0][3] + m1[3][1] * m2[1][3] + m1[3][2] * m2[2][3] + m1[3][3] * m2[3][3]]];
	}

	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]];
	}

	matrixTranspose(m) {
		return [[m[0][0], m[1][0], m[2][0], m[3][0]],
				[m[0][1], m[1][1], m[2][1], m[3][1]],
				[m[0][2], m[1][2], m[2][2], m[3][2]],
				[m[0][3], m[1][3], m[2][3], m[3][3]]];
	}

	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]];
	}

	lerp(a, b, f) {
		return a + f * (b - a);
	}

	ior2f0(f0) {
		return (f0 - 1)**2/(f0 + 1)**2;
	}
};


code:
App.Art.views = {};

App.Art.isDraggingCanvas = false;

App.Art.createWebglUI = function(container, slave, artSize, scene, p) {
	// persistent view
	if (!(slave.ID in App.Art.views)) {
		let persist = {};
		persist.lockView = false;
		persist.resetView = true;
		persist.faceView = false;
		persist.inspectView = false;
		persist.inspect = false;
		persist.yr = scene.models[0].transform.yr;
		persist.camera = JSON.parse(JSON.stringify(scene.camera));

		App.Art.views[slave.ID] = persist;
	}

	function applyView(scene, view) {
		scene.inspect = view.inspect;
		scene.models[0].transform.yr = view.yr;
		scene.camera = view.camera;
	}

	function render() {
		applyView(scene, view); // copy persistent view to scene before render
		App.Art.applyMorphs(slave, scene, p); // used for posing
		App.Art.engine.render(scene, cvs);
	}

	let view = App.Art.views[slave.ID];

	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 inspectViewDisabled = "resources/webgl/ui/inspectViewDisabled.png";
	let inspectViewEnabled = "resources/webgl/ui/inspectViewEnabled.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");

	// 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");

	// 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");

	// btnInspectView
	let btnInspectView = document.createElement("input");
	btnInspectView.setAttribute("style", "display: flex; width: 100%; position: relative; border: 0px; padding: 0px; background-color: transparent;");
	btnInspectView.setAttribute("type", "image");

	function updateLinkedButtons(button) {
		switch(button) {
			case "lock": view.lockView = !view.lockView; break;
			case "reset": view.resetView = true; view.faceView = false; view.inspectView = false; break;
			case "face": view.faceView = true; view.inspectView = false; view.resetView = false; break;
			case "inspect": view.inspectView = true; view.faceView = false; view.resetView = false; break;
			case "move": view.resetView = false; view.faceView = false; view.inspectView = false; break;
		}

		btnInspectView.setAttribute("src", view.inspectView ? inspectViewDisabled : inspectViewEnabled);
		btnResetView.setAttribute("src", view.resetView ? resetViewDisabled : resetViewEnabled);
		btnFaceView.setAttribute("src", view.faceView ? faceViewDisabled : faceViewEnabled);
		btnLockView.setAttribute("src", view.lockView ? lockViewDisabled : lockViewEnabled);
	}

	// events
	btnInspectView.onclick = function(e){
		updateLinkedButtons("inspect");

		view.yr = 180;
		view.inspect = true;
		App.Art.Frame(slave, scene, view);
		render();
	};

	btnLockView.onclick = function(e){
		updateLinkedButtons("lock");
		render();
	};

	btnFaceView.onclick = function(e){
		updateLinkedButtons("face");

		view.camera.y = slave.height-5;
		view.yr = 0;
		view.camera.xr = -6;
		view.camera.z = -slave.height/3.85;
		view.inspect = false;
		render();
	};

	btnResetView.onclick = function(e){
		updateLinkedButtons("reset");

		view.yr = App.Art.defaultScene.models[0].transform.yr;
		view.inspect = false;
		App.Art.Frame(slave, scene, view);
		render();
	};

	cvs.onmousemove = function(e){
		if(view.lockView || !App.Art.isDraggingCanvas){ return; }
		e.preventDefault();
		e.stopPropagation();

		updateLinkedButtons("move");

		view.camera.y = view.camera.y + e.movementY/7;
		view.yr = view.yr + e.movementX*5;
		render();
	};

	cvs.onmousedown = function(e){
		if(view.lockView){
			// update if shared view
			updateLinkedButtons();
			render();
			return;
		}
		e.preventDefault();
		e.stopPropagation();
		App.Art.isDraggingCanvas=true;
	};

	cvs.onmouseup = function(e){
		if(view.lockView || !App.Art.isDraggingCanvas){ return; }
		e.preventDefault();
		e.stopPropagation();
		App.Art.isDraggingCanvas=false;
	};

	cvs.onmouseout = function(e){
		if(view.lockView || !App.Art.isDraggingCanvas){ return; }
		e.preventDefault();
		e.stopPropagation();
		App.Art.isDraggingCanvas=false;
	};

	cvs.onwheel = function(e){
		if(view.lockView){ return; }

		updateLinkedButtons("move");

		// zoom speed based on distance from origin, and along direction of camera
		let zOld = view.camera.z;
		let magnitude = e.deltaY/(10/V.setZoomSpeed) * (-view.camera.z/50 + 0.2);
		let zDistance = Math.cos(-view.camera.xr * (Math.PI/180)) * magnitude;
		view.camera.z -= zDistance;
		view.camera.z = Math.clamp(view.camera.z, -900, -10);
		view.camera.y += Math.sin(-view.camera.xr * (Math.PI/180)) * magnitude * -(view.camera.z - zOld)/zDistance;

		render();
		return false;
	};

	container.appendChild(cvs);
	uicontainer.appendChild(btnLockView);
	uicontainer.appendChild(btnFaceView);
	uicontainer.appendChild(btnInspectView);
	uicontainer.appendChild(btnResetView);
	container.appendChild(uicontainer);

	// calculate canvas resolution
	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;
	}

	let zoom = Math.max(1, window.devicePixelRatio);
	cvs.width = sz[0] * zoom;
	cvs.height = sz[1] * zoom;
	container.setAttribute("style", "position: relative; width: " + sz[0] + "px; height: " + sz[1] + "px;");

	if(typeof V.setSuperSampling === "undefined") {
		V.setSuperSampling = 2;
	}

	scene.settings.rwidth = cvs.width * V.setSuperSampling;
	scene.settings.rheight = cvs.height * V.setSuperSampling;

	// render state
	if (view.faceView) {
		btnFaceView.click();
	} else if (view.inspectView) {
		btnInspectView.click();
	} else if (view.resetView) {
		btnResetView.click();
	} else {
		// default
		updateLinkedButtons();
		render();
	}

	/*
	if (artSize === 3) {
		let cvs2 = document.createElement("canvas");
		cvs2.setAttribute("style", "position: absolute; top: 530px; border: 4px; border-color: #151515; border-style: solid; margin-top: 15px;");

		let cvs3 = document.createElement("canvas");
		cvs3.setAttribute("style", "position: absolute; top: 530px; left: 156px; border: 4px; border-color: #151515; border-style: solid; margin-top: 15px;");

		let oldYr = view.transform.yr;
		// let oldCamera = view.camera;

		view.camera.y = slave.height * 8 / 10;
		view.camera.z = -65 - (Math.sqrt(slave.boobs)/10);

		cvs2.width = 150;
		cvs2.height = 150;
		view.rwidth = cvs2.width * V.setSuperSampling;
		view.rheight = cvs2.height * V.setSuperSampling;
		App.Art.engine.render(scene, cvs2);

		view.camera.y = slave.height * 5.5 / 10;
		view.camera.z = -65;
		view.transform.yr = -180;

		cvs3.width = 150;
		cvs3.height = 150;
		view.rwidth = cvs3.width * V.setSuperSampling;
		view.rheight = cvs3.height * V.setSuperSampling;
		App.Art.engine.render(scene, cvs3);

		container.appendChild(cvs2);
		container.appendChild(cvs3);

		App.Art.Frame(slave, scene, view);

		view.rwidth = cvs.width * V.setSuperSampling;
		view.rheight = cvs.height * V.setSuperSampling;
		view.transform.yr = oldYr;
		// view.camera = oldCamera;
	}*/
};

App.Art.Frame = function(slave, scene, view) {
	let offset = scene.models[0].transform.y;

	if ((slave.height + offset) > 185) {
		App.Art.AutoFrame(view, slave.height, 123, offset);
	} else {
		App.Art.FixedFrame(view);
	}
};

App.Art.AutoFrame = function(view, slaveHeight, cameraHeight, offset) {
	// auto-frame based on camera height and FoV
	let n = Math.max((slaveHeight + offset) * 1.06 - cameraHeight, 1);
	let m = cameraHeight * 1.12;
	let fov = view.camera.fov;

	let a = fov * (Math.PI/180);
	let r = m/n;
	let h = 0;

	// solve for distance
	if (a !== Math.PI/2) {
		if (a > Math.PI/2) {
			h = n/((-(r + 1) - ((r+1)**2 + 4*r*Math.tan(a)**2)**(1/2))/(2*Math.tan(a)*r)); // take negative discriminant
		} else {
			h = n/((-(r + 1) + ((r+1)**2 + 4*r*Math.tan(a)**2)**(1/2))/(2*Math.tan(a)*r)); // take positive discriminant
		}
	} else {
		h = (m+n)/2 * Math.sin(Math.acos(((m+n)/2-n)/((m+n)/2))); // edge case
	}

	// solve for rotation
	let rot = fov/2 - Math.atan(n/h) * (180/Math.PI);

	view.camera.z = -h;
	view.camera.y = cameraHeight;
	view.camera.xr = -rot;
};

App.Art.FixedFrame = function(view) {
	view.camera.z = -282;
	view.camera.y = 123;
	view.camera.xr = -6;
};

repo_name: CloudyCoffee/fc

license: mit


code:
/**
 *
 * @param {"cash"|"rep"} budgetType
 * @returns {HTMLTableElement}
 */
App.Budget.table = function(budgetType) {
	let coloredRow = true;

	// Set up object to track calculated displays
	const income = (budgetType === "cash") ? "lastWeeksCashIncome" : "lastWeeksRepIncome";
	const expenses = (budgetType === "cash") ? "lastWeeksCashExpenses" : "lastWeeksRepExpenses";

	const table = document.createElement("table");
	table.classList.add("budget");

	// HEADER
	generateHeader();

	// BODY
	table.createTBody();

	// HEADER: FACILITIES
	createSectionHeader("Facilities");

	const f = App.Entity.facilities; // shortcut

	if (budgetType === "cash") {
		// PENTHOUSE
		addToggle(generateRowGroup("Penthouse", "PENTHOUSE"), [
			generateRowCategory("Rest", "slaveAssignmentRest"),
			generateRowCategory("RestVign", "slaveAssignmentRestVign"),
			generateRowCategory("Fucktoy", "slaveAssignmentFucktoy"),
			generateRowCategory("Classes", "slaveAssignmentClasses"),
			generateRowCategory("House", "slaveAssignmentHouse"),
			generateRowCategory("HouseVign", "slaveAssignmentHouseVign"),
			generateRowCategory("Whore", "slaveAssignmentWhore"),
			generateRowCategory("WhoreVign", "slaveAssignmentWhoreVign"),
			generateRowCategory("Public", "slaveAssignmentPublic"),
			generateRowCategory("PublicVign", "slaveAssignmentPublicVign"),
			generateRowCategory("Subordinate", "slaveAssignmentSubordinate"),
			generateRowCategory("Milked", "slaveAssignmentMilked"),
			generateRowCategory("MilkedVign", "slaveAssignmentMilkedVign"),
			generateRowCategory("ExtraMilk", "slaveAssignmentExtraMilk"),
			generateRowCategory("ExtraMilkVign", "slaveAssignmentExtraMilkVign"),
			generateRowCategory("Gloryhole", "slaveAssignmentGloryhole"),
			generateRowCategory("Confinement", "slaveAssignmentConfinement")
		]);
		// Other
		generateRowCategory("Choosing Own Assignment", "slaveAssignmentChoice");

		// LEADERSHIP ROLES

		// HEAD GIRL
		// find passage name for HGSuite
		addToggle(generateRowGroup(f.headGirlSuite.nameCaps, "HEADGIRLSUITE"), [
			generateRowCategory("Head Girl", "slaveAssignmentHeadgirl"),
			generateRowCategory("Head Girl Fucktoys", "slaveAssignmentHeadgirlsuite")
		]);

		// RECRUITER
		addToggle(generateRowGroup("Recruiter", "RECRUITER"), [
			generateRowCategory("Recruiter", "slaveAssignmentRecruiter")
		]);

		// BODYGUARD
		// find passage name for Armory
		addToggle(generateRowGroup(f.armory.nameCaps, "DOJO"), [
			generateRowCategory("Bodyguard", "slaveAssignmentBodyguard")
		]);

		// CONCUBINE
		addToggle(generateRowGroup(f.masterSuite.nameCaps, "MASTERSUITE"), [
			generateRowCategory("Master Suite Operation", "masterSuite"),
			generateRowCategory("Master Suite Concubine", "slaveAssignmentConcubine"),
			generateRowCategory("Master Suite Fucktoys", "slaveAssignmentMastersuite")
		]);

		// AGENT
		addToggle(generateRowGroup("Agent", "AGENT"), [
			generateRowCategory("Agent", "slaveAssignmentAgent"),
			generateRowCategory("Agent's Partner", "slaveAssignmentAgentPartner")
		]);

		// ARCADE
		addToggle(generateRowGroup(f.arcade.nameCaps, "ARCADE"), [
			generateRowCategory("Arcade Operation", "arcade"),
			generateRowCategory("Arcade Fuckdolls", "slaveAssignmentArcade")
		]);

		// BROTHEL
		addToggle(generateRowGroup(f.brothel.nameCaps, "BROTHEL"), [
			generateRowCategory("Brothel Operation", "brothel"),
			generateRowCategory("Brothel Madam", "slaveAssignmentMadam"),
			generateRowCategory("Brothel MadamVign", "slaveAssignmentMadamVign"),
			generateRowCategory("Brothel Whore", "slaveAssignmentBrothel"),
			generateRowCategory("Brothel WhoreVign", "slaveAssignmentBrothelVign"),
			generateRowCategory("Brothel Ads", "brothelAds")
		]);

		// CELLBLOCK
		addToggle(generateRowGroup(f.cellblock.nameCaps, "CELLBLOCK"), [
			generateRowCategory("Cellblock Operation", "cellblock"),
			generateRowCategory("Cellblock Warden", "slaveAssignmentWarden"),
			generateRowCategory("Cellblock Slaves", "slaveAssignmentCellblock")
		]);

		// CLUB
		addToggle(generateRowGroup(f.club.nameCaps, "CLUB"), [
			generateRowCategory("Club Operation", "club"),
			generateRowCategory("Club DJ", "slaveAssignmentDj"),
			generateRowCategory("Club DJVign", "slaveAssignmentDjVign"),
			generateRowCategory("Club Public", "slaveAssignmentClub"),
			generateRowCategory("Club PublicVign", "slaveAssignmentClubVign"),
			generateRowCategory("Club Ads", "clubAds")
		]);

		// CLINIC
		addToggle(generateRowGroup(f.clinic.nameCaps, "CLINIC"), [
			generateRowCategory("Clinic Operation", "clinic"),
			generateRowCategory("Clinic Nurse", "slaveAssignmentNurse"),
			generateRowCategory("Clinic Slaves", "slaveAssignmentClinic")
		]);

		// DAIRY
		addToggle(generateRowGroup(f.dairy.nameCaps, "DAIRY"), [
			generateRowCategory("Dairy Operation", "dairy"),
			generateRowCategory("Dairy Milkmaid", "slaveAssignmentMilkmaid"),
			generateRowCategory("Dairy Cows", "slaveAssignmentDairy"),
			generateRowCategory("Dairy CowsVign", "slaveAssignmentDairyVign")
		]);

		// FARMYARD
		addToggle(generateRowGroup(f.farmyard.nameCaps, "FARMYARD"), [
			generateRowCategory("Farmyard Operation", "farmyard"),
			generateRowCategory("Farmyard Farmer", "slaveAssignmentFarmer"),
			generateRowCategory("Farmyard Farmhands", "slaveAssignmentFarmyard"),
			generateRowCategory("Farmyard FarmhandsVign", "slaveAssignmentFarmyardVign")
		]);

		// INCUBATOR
		addToggle(generateRowGroup(f.incubator.nameCaps, "INCUBATOR"), [
			generateRowCategory("Incubator Operation", "incubator"),
			generateRowCategory("Incubator Babies", "incubatorSlaves")
		]);

		// NURSERY
		addToggle(generateRowGroup(f.nursery.nameCaps, "NURSERY"), [
			generateRowCategory("Nursery Operation", "nursery"),
			generateRowCategory("Nursery Matron", "slaveAssignmentMatron"),
			generateRowCategory("Nursery Nannies", "slaveAssignmentNursery"),
			generateRowCategory("Nursery NanniesVign", "slaveAssignmentNurseryVign")
		]);

		// PIT
		addToggle(generateRowGroup(f.pit.nameCaps, "PIT"), [
			generateRowCategory("Pit Operation", "pit")
		]);

		// PROSTHETIC LAB
		addToggle(generateRowGroup("Prosthetic Lab", "PROSTHETICLAB"), [
			generateRowCategory("Prosthetic Lab Operation", "lab"),
			generateRowCategory("Prosthetic Lab Research", "labResearch"),
			generateRowCategory("Prosthetic Lab Scientists", "labScientists"),
			generateRowCategory("Prosthetic Lab Menials", "labMenials")
		]);

		// SCHOOLROOM
		addToggle(generateRowGroup(f.schoolroom.nameCaps, "SCHOOLROOM"), [
			generateRowCategory("Schoolroom Operation", "school"),
			generateRowCategory("Schoolroom Teacher", "slaveAssignmentTeacher"),
			generateRowCategory("Schoolroom Students", "slaveAssignmentSchool")
		]);

		// SERVANTS' QUARTERS
		addToggle(generateRowGroup(f.servantsQuarters.nameCaps, "SERVANTSQUARTERS"), [
			generateRowCategory("Servants' Quarters Operation", "servantsQuarters"),
			generateRowCategory("Servants' Quarters Steward", "slaveAssignmentSteward"),
			generateRowCategory("Servants' Quarters Servants", "slaveAssignmentQuarter"),
			generateRowCategory("Servants' Quarters ServantsVign", "slaveAssignmentQuarterVign")
		]);

		// SPA
		addToggle(generateRowGroup(f.spa.nameCaps, "SPA"), [
			generateRowCategory("Spa Operation", "spa"),
			generateRowCategory("Spa Attendant", "slaveAssignmentAttendant"),
			generateRowCategory("Spa Slaves", "slaveAssignmentSpa")
		]);

		// HEADER: ARCOLOGY
		createSectionHeader("Arcology");

		// SLAVES
		addToggle(generateRowGroup("Miscellaneous Slave Income and Expenses", "SLAVES"), [
			generateRowCategory("Slave Porn", "porn"),
			generateRowCategory("Slave Modifications", "slaveMod"),
			generateRowCategory("Slave Surgery", "slaveSurgery"),
			generateRowCategory("Slave Birthing", "birth")
		]);

		// MENIAL LABOR
		addToggle(generateRowGroup("Menial Labor", "LABOR"), [
			generateRowCategory("Menials: Slaves", "menialTrades"),
			generateRowCategory("Menials: Fuckdolls", "fuckdolls"),
			generateRowCategory("Menials: Bioreactors", "menialBioreactors")
		]);

		// FLIPPING
		addToggle(generateRowGroup("Flipping", "FLIPPING"), [
			generateRowCategory("Slave Transfer", "slaveTransfer"),
			generateRowCategory("Menials", "menialTransfer"),
			generateRowCategory("Fuckdolls", "fuckdollsTransfer"),
			generateRowCategory("Bioreactors", "menialBioreactorsTransfer"),
			generateRowCategory("Assistant: Menials", "menialTransferA"),
			generateRowCategory("Assistant: Fuckdolls", "fuckdollsTransferA"),
			generateRowCategory("Assistant: Bioreactors", "menialBioreactorsTransferA"),
			generateRowCategory("Menial Retirement", "menialRetirement"),
			generateRowCategory("Scientist Transfer", "labScientistsTransfer"),
			generateRowCategory("Slave Babies", "babyTransfer")
		]);

		// FINANCIALS
		addToggle(generateRowGroup("Financials", "FINANCIALS"), [
			generateRowCategory("Weather", "weather"),
			generateRowCategory("Rents", "rents"),
			generateRowCategory("Fines", "fines"),
			generateRowCategory("Events", "event"),
			generateRowCategory("Capital Expenses", "capEx"),
			generateRowCategory("Future Society Shaping", "futureSocieties"),
			generateRowCategory("School Subsidy", "schoolBacking"),
			generateRowCategory("Arcology conflict", "war"),
			generateRowCategory("Cheating", "cheating")
		]);

		// POLICIES
		addToggle(generateRowGroup("Policies", "POLICIES"), [
			generateRowCategory("Policies", "policies"),
			generateRowCategory("Subsidies and Barriers", "subsidiesAndBarriers")
		]);

		// EDICTS
		addToggle(generateRowGroup("Edicts", "EDICTS"), [
			generateRowCategory("Edicts", "edicts")
		]);

		// PERSONAL FINANCE
		addToggle(generateRowGroup("Personal Finance", "PERSONALFINANCE"), [
			generateRowCategory("Personal Business", "personalBusiness"),
			generateRowCategory("Personal Living Expenses", "personalLivingExpenses"),
			generateRowCategory("Your skills", "PCSkills"),
			generateRowCategory("Your training expenses", "PCtraining"),
			generateRowCategory("Your medical expenses", "PCmedical"),
			generateRowCategory("Citizen Orphanage", "citizenOrphanage"),
			generateRowCategory("Private Orphanage", "privateOrphanage"),
			generateRowCategory("Stock dividends", "stocks"),
			generateRowCategory("Stock trading", "stocksTraded")
		]);

		// SECURITY
		addToggle(generateRowGroup("Security", "SECURITY"), [
			generateRowCategory("Mercenaries", "mercenaries"),
			generateRowCategory("Security Expansion", "securityExpansion"),
			generateRowCategory("Special Forces", "specialForces"),
			generateRowCategory("Special Forces Capital Expenses", "specialForcesCap"),
			generateRowCategory("Peacekeepers", "peacekeepers")
		]);
	} else if (budgetType === "rep") {
		// PENTHOUSE
		addToggle(generateRowGroup("Penthouse", "PENTHOUSE"), [
			generateRowCategory("Fucktoys", "fucktoy"),
			generateRowCategory("Public servants", "publicServant"),
			generateRowCategory("Free glory holes", "gloryhole"),
			generateRowCategory("Concubine", "concubine"),
			generateRowCategory("Head girl", "headGirl"),
			generateRowCategory("Bodyguard", "bodyguard"),
			generateRowCategory("Recruiter", "recruiter"),
		]);

		// ARCADE
		addToggle(generateRowGroup(f.arcade.nameCaps, "ARCADE"), [
			generateRowCategory("Arcade Operation", "arcade"),
			generateRowCategory("Free arcade", "gloryholeArcade")
		]);

		// BROTHEL
		addToggle(generateRowGroup(f.brothel.nameCaps, "BROTHEL"), [
			generateRowCategory("Brothel Operation", "brothel"),
		]);

		// CLUB
		addToggle(generateRowGroup(f.club.nameCaps, "CLUB"), [
			generateRowCategory("Club Operation", "club"),
			generateRowCategory("Club servants", "publicServantClub"),
			generateRowCategory("Club ads", "clubAds"),
		]);

		// PIT
		addToggle(generateRowGroup(f.pit.nameCaps, "PIT"), [
			generateRowCategory("Pit Operation", "pit")
		]);

		// SERVANTS' QUARTERS
		addToggle(generateRowGroup(f.servantsQuarters.nameCaps, "SERVANTSQUARTERS"), [
			generateRowCategory("Servants' Quarters Operation", "servantsQuarters"),
		]);

		// SPA
		addToggle(generateRowGroup(f.spa.nameCaps, "SPA"), [
			generateRowCategory("Spa Operation", "spa"),
		]);

		// FARMYARD
		addToggle(generateRowGroup(f.farmyard.nameCaps, "FARMYARD"), [
			generateRowCategory("Shows", "shows"),
		]);

		// HEADER: ARCOLOGY
		createSectionHeader("Arcology");

		// SLAVES
		addToggle(generateRowGroup("Miscellaneous Slave Income and Expenses", "SLAVES"), [
			generateRowCategory("Slave trust and devotion", "slavesViewOfPC"),
			generateRowCategory("Prestigious slaves", "prestigiousSlave"),
			generateRowCategory("Porn", "porn"),
			generateRowCategory("Selling/buying major slaves", "slaveTransfer"),
			generateRowCategory("Slave surgery", "babyTransfer"),
			generateRowCategory("Birth", "birth"),
			generateRowCategory("Slave retirement", "retirement"),
			generateRowCategory("Vignettes", "vignette"),
			generateRowCategory("Free Fuckdolls", "fuckdolls"),
		]);

		// POLICIES
		addToggle(generateRowGroup("Policies", "POLICIES"), [
			generateRowCategory("Capital expenses", "capEx"),
			generateRowCategory("Subsidies and Barriers", "subsidiesAndBarriers"),
			generateRowCategory("Society shaping", "futureSocieties"),
			generateRowCategory("Food", "food"),
		]);

		// EDICTS
		addToggle(generateRowGroup("Edicts", "EDICTS"), [
			generateRowCategory("Edicts", "edicts")
		]);

		// PERSONAL FINANCE
		addToggle(generateRowGroup("Personal Finance", "PERSONALFINANCE"), [
			generateRowCategory("Personal Business", "personalBusiness"),
			generateRowCategory("Your appearance", "PCappearance"),
			generateRowCategory("Your actions", "PCactions"),
			generateRowCategory("Your skills", "PCRelationships"),
			generateRowCategory("Slave relationships", "SlaveRelationships"),
			generateRowCategory("Events", "event"),
		]);

		// SECURITY
		addToggle(generateRowGroup("Security", "SECURITY"), [
			generateRowCategory("Security Expansion", "securityExpansion"),
			generateRowCategory("Special Forces", "specialForces"),
			generateRowCategory("Conflict", "war"),
			generateRowCategory("Peacekeepers", "peacekeepers")
		]);

		addToggle(generateRowGroup("Waste", "WASTE"), [
			generateRowCategory("Reputation decay (your reputation cannot be higher than 20k)", "multiplier"),
		]);
	}

	// BUDGET REPORT
	generateSummary();

	return table;

	function generateHeader() {
		const header = table.createTHead();
		const row = header.insertRow();
		const cell = row.insertCell();
		let pent = document.createElement("h1");
		pent.textContent = (budgetType === "cash") ? "Budget Overview" : "Reputation Overview";
		cell.appendChild(pent);

		for (let column of ["Income", "Expense", "Totals"]) {
			let cell = document.createElement("th");
			cell.textContent = column;
			row.appendChild(cell);
		}
	}

	function generateSummary() {
		let row; let cell;
		createSectionHeader("Summary Report");

		row = table.insertRow();
		cell = row.insertCell();
		cell.append("Tracked totals");

		cell = row.insertCell();
		// Make the total 0 first, otherwise it gets counted as part of the new total
		V[income].Total = 0;
		V[income].Total = hashSum(V[income]);
		cell.append(formatColorDOM(Math.trunc(V[income].Total)));

		cell = row.insertCell();
		V[expenses].Total = 0;
		V[expenses].Total = hashSum(V[expenses]);
		cell.append(formatColorDOM(Math.trunc(V[expenses].Total)));

		cell = row.insertCell();
		cell.append(formatColorDOM(Math.trunc(V[income].Total + V[expenses].Total)));
		flipColors(row);

		if (budgetType === "cash") {
			row = table.insertRow();
			cell = row.insertCell();
			cell.append(`Expenses budget for week ${V.week + 1}`);
			row.insertCell();
			cell = row.insertCell();
			cell.append(formatColorDOM(-V.costs));
			flipColors(row);
		}

		row = table.insertRow();
		cell = row.insertCell();
		cell.append(`Last week actuals`);
		row.insertCell();
		row.insertCell();
		cell = row.insertCell();
		if (budgetType === "cash") {
			cell.append(formatColorDOM(V.cash - V.cashLastWeek));
		} else {
			cell.append(formatColorDOM(V.rep - V.repLastWeek));
		}
		flipColors(row);

		row = table.insertRow();
		if (
			(budgetType === "cash" && (V.cash - V.cashLastWeek) === (V.lastWeeksCashIncome.Total + V.lastWeeksCashExpenses.Total)) ||
			(budgetType === "rep" && (V.rep - V.repLastWeek) === (V.lastWeeksRepIncome.Total + V.lastWeeksRepExpenses.Total))
		) {
			cell = row.insertCell();
			const span = document.createElement('span');
			span.className = "green";
			span.textContent = `The books are balanced, ${properTitle()}!`;
			cell.append(span);
		} else {
			cell = row.insertCell();
			cell.append("Transaction tracking off by:");
			row.insertCell();
			row.insertCell();
			cell = row.insertCell();
			if (budgetType === "cash") {
				cell.append(formatColorDOM((V.cash - V.cashLastWeek) - (V.lastWeeksCashIncome.Total + V.lastWeeksCashExpenses.Total)));
			} else {
				cell.append(formatColorDOM((V.rep - V.repLastWeek) - (V.lastWeeksRepIncome.Total + V.lastWeeksRepExpenses.Total)));
			}
		}
		flipColors(row);
	}

	function createSectionHeader(text) {
		coloredRow = true; // make sure the following section begins with color.
		const row = table.insertRow();
		const cell = row.insertCell();
		const headline = document.createElement('h2');
		headline.textContent = text;
		cell.append(headline);
	}

	function generateRowCategory(node, category) {
		if (category === "") {
			const row = table.insertRow();
			row.append(document.createElement('br'));
			row.insertCell();
			row.insertCell();
			row.insertCell();
			flipColors(row);
			return row;
		}

		if (V[income][category] || V[expenses][category] || V.showAllEntries[budgetType === "cash" ? "costsBudget" : "repBudget"]) {
			const row = table.insertRow();
			let cell = row.insertCell();
			cell.append(node);
			cell = row.insertCell();
			cell.append(formatColorDOM(V[income][category]));
			cell = row.insertCell();
			cell.append(formatColorDOM(-Math.abs(V[expenses][category])));
			flipColors(row);
			cell = row.insertCell();
			cell.append(formatColorDOM(V[income][category] + V[expenses][category]));
			return row;
		}
	}

	function generateRowGroup(title, name) {
		/** @type {string[]} */
		const members = (budgetType === "cash" ? CategoryAssociatedGroup[name] : CategoryAssociatedGroupRep[name]);
		const groupIn = members.map((k) => V[income][k]).reduce((acc, cur) => acc + cur);
		const groupEx = members.map((k) => V[expenses][k]).reduce((acc, cur) => acc + cur);

		if (groupIn || groupEx || V.showAllEntries[budgetType === "cash" ? "costsBudget" : "repBudget"]) {
			const row = table.insertRow();
			let cell = row.insertCell();
			const headline = document.createElement('h3');
			headline.textContent = title;
			cell.append(headline);
			cell = row.insertCell();
			cell.append(formatColorDOM(groupIn));
			cell = row.insertCell();
			cell.append(formatColorDOM(groupEx));
			cell = row.insertCell();
			cell.append(formatColorDOM(groupIn + groupEx));
			return row;
		}
	}

	/**
	 * @param {HTMLTableRowElement} head
	 * @param {Array<HTMLTableRowElement>} content
	 */
	function addToggle(head, content) {
		if (!head) {
			return;
		}
		content = content.filter(e => !!e);
		if (content.length === 0) {
			return;
		}
		App.UI.DOM.elementToggle(head, content);
	}

	function formatColorDOM(num, invert = false) {
		if (invert) {
			num = -1 * num;
		}
		let span = document.createElement('span');
		span.textContent = (budgetType === "cash") ? cashFormat(num) : num;
		if (num === 0) {
			// num overwrites gray, so we don't use it here.
			span.classList.add("gray");
		} else {
			span.classList.add((budgetType === "cash") ? "cash" : "reputation");
			// Display red if the value is negative, unless invert is true
			if (num < 0) {
				span.classList.add("dec");
				// Yellow for positive
			} else if (num > 0) {
				span.classList.add("inc");
				// Gray for exactly zero
			}
		}
		return span;
	}

	function flipColors(row) {
		if (coloredRow) {
			row.classList.add("colored");
		}
		coloredRow = !coloredRow;
	}
};


code:
/**
 * Costs Budget Passage
 * @returns {DocumentFragment}
 */
App.Budget.costs = function() {
	const f = new DocumentFragment();

	f.append(
		intro(),
		netWorth(),
	);
	if (V.difficultySwitch === 1) {
		f.append(economy());
	}
	f.append(settings());

	// Table of Totals
	if (!V.lastWeeksCashIncome) {
		App.UI.DOM.appendNewElement("p", f, "Financial data currently unavailable.");
	} else {
		App.UI.DOM.appendNewElement("p", f, App.Budget.table("cash"));
	}

	errors(f);
	return f;

	/**
	 * @returns {HTMLParagraphElement}
	 */
	function intro() {
		return App.UI.DOM.makeElement("p", `Here you can view many of the financial details of your arcology, ${properTitle()}. Proper cash flow is critical to the success of your long term goals. Find expensive waste here and you can change the right policies or sell off slackers. Find your next profit center and invest in new equipment, advertising, or flesh to maximize your assets.`, "scene-intro");
	}

	/**
	 * @returns {HTMLParagraphElement}
	 */
	function netWorth() {
		const p = document.createElement("p");

		p.append(`You have a total net worth of ${cashFormat(Math.trunc(App.Utils.totalNetWorth()))}.`);

		if (V.debugMode) {
			App.UI.DOM.appendNewElement("div", p, App.UI.DOM.link(`Recalculate net worth`, () => {
				App.UI.reload();
			}), ['indent']);
		}

		return p;
	}

	/**
	 * @returns {HTMLParagraphElement}
	 */
	function economy() {
		const p = document.createElement("p");
		App.UI.DOM.appendNewElement("div", p, `The Local Economy score effects some prices in your ecology. The lower the score, the higher the prices. The base score is 100.`, "scene-intro");

		const grid = document.createElement("div");
		grid.className = "grid-2columns-auto";

		App.UI.DOM.appendNewElement("div", grid, "Global Economy", "cash");
		if (V.cheatMode && V.cheatModeM) {
			const div = document.createElement("div");
			div.append(App.UI.DOM.makeTextBox(V.economy, v => {
				V.economy = v;
				V.cheater = 1;
			}, true));
			grid.append(div);
		} else {
			App.UI.DOM.appendNewElement("div", grid, String(V.economy));
		}

		App.UI.DOM.appendNewElement("div", grid, "Local Economy", "cash");
		if (V.cheatMode && V.cheatModeM) {
			const div = document.createElement("div");
			div.append(App.UI.DOM.makeTextBox(V.localEcon, v => {
				V.localEcon = v;
				V.cheater = 1;
			}, true));
			grid.append(div);
		} else {
			App.UI.DOM.appendNewElement("div", grid, String(V.localEcon));
		}

		p.append(grid);

		const r = [];
		r.push("The current score is");
		if (V.localEcon > 100) {
			let econPercent = Math.trunc(1000 - 100000 / V.localEcon) / 10;
			r.push(`reducing prices by <span class="cash inc">${econPercent}%.</span>`);
		} else if (V.localEcon === 100) {
			r.push("equal to the base score. There are no price modifications.");
		} else {
			let econPercent = Math.trunc(100000 / V.localEcon - 1000) / 10;
			r.push(`increasing prices by <span class="cash dec">${econPercent}%.</span>`);
		}
		$(p).append(...App.Events.spaceSentences(r));

		return p;
	}

	/**
	 * @returns {HTMLParagraphElement}
	 */
	function settings() {
		const p = document.createElement("p");
		App.UI.DOM.appendNewElement("div", p, "Your weekly costs are as follows:", "detail");

		let options = new App.UI.OptionsGroup();
		options.addOption("", "costsBudget", V.showAllEntries)
			.addValue("Normal", 0).on().addValue("Show Empty Entries", 1);
		p.append(options.render());

		return p;
	}

	/**
	 * @param {DocumentFragment} container
	 */
	function errors(container) {
		if (V.lastWeeksCashErrors.length > 0) {
			const p = document.createElement("p");
			p.append(App.UI.DOM.passageLink("Reset", "Costs Budget",
				() => { V.lastWeeksCashErrors = []; }));
			App.UI.DOM.appendNewElement("div", p, "Errors:", "error");
			for (const error of V.lastWeeksCashErrors) {
				App.UI.DOM.appendNewElement("div", p, error, "error");
			}
			container.append(p);
		}
	}
};


code:
/**
 * Reputation budget passage
 * @returns {DocumentFragment}
 */
App.Budget.rep = function() {
	const f = new DocumentFragment();

	// App.UI.DOM.appendNewElement("h1", f, `Reputation Budget`);

	f.append(intro());
	f.append(settings());
	// Table of Totals
	if (!V.lastWeeksRepIncome) {
		App.UI.DOM.appendNewElement("p", f, "Reputation data currently unavailable.");
	} else {
		App.UI.DOM.appendNewElement("p", f, App.Budget.table("rep"));
	}

	errors(f);
	return f;

	/**
	 * @returns {HTMLParagraphElement}
	 */
	function intro() {
		return App.UI.DOM.makeElement("p", `Reputation is a difficult thing to quantify, ${properTitle()}. Here you see an overview of topics that interest people in the arcology, and in turn, reflect on your own reputation. Much like a finance report, you can see here how your choices last week moved you closer to or further from your goals, and head any issues off before they get worse.`, "scene-intro");
	}

	/**
	 * @returns {HTMLParagraphElement}
	 */
	function settings() {
		const p = document.createElement("p");
		App.UI.DOM.appendNewElement("div", p, "Your weekly reputation changes are as follows:", "detail");

		let options = new App.UI.OptionsGroup();
		options.addOption("", "repBudget", V.showAllEntries)
			.addValue("Normal", 0).on().addValue("Show Empty Entries", 1);
		p.append(options.render());

		return p;
	}

	/**
	 * @param {DocumentFragment} container
	 */
	function errors(container) {
		if (V.lastWeeksRepErrors.length > 0) {
			const p = document.createElement("p");
			p.append(App.UI.DOM.passageLink("Reset", "Rep Budget",
				() => { V.lastWeeksRepErrors = []; }));
			App.UI.DOM.appendNewElement("div", p, "Errors:", "error");
			for (const error of V.lastWeeksRepErrors) {
				App.UI.DOM.appendNewElement("div", p, error, "error");
			}
			container.append(p);
		}
	}
};


code:
App.UI.Cheat.PCCheatMenu = function() {
	const node = new DocumentFragment();

	App.UI.DOM.appendNewElement("h2", node, "Cheat Editing Player Character");

	App.UI.DOM.appendNewElement("div", node, App.UI.DOM.passageLink(
		"Cancel",
		"Manage Personal Affairs",
		() => {
			V.PC = clone(V.backupSlave);
		}
	));

	const tabBar = new App.UI.Tabs.TabBar("PCCheatMenu");
	tabBar.addTab("Player", "player", App.UI.Player.design());
	tabBar.addTab("Family", "family", App.Intro.editFamily(V.PC, true));
	node.append(tabBar.render());

	return node;
};


code:
App.UI.Cheat.PCCheatMenuCheatDatatypeCleanup = function() {
	delete V.backupSlave;

	App.Entity.Utils.PCCheatCleanup();

	return App.Events.makeNode([`You perform the dark rituals, pray to the dark gods, and sell your soul for the power to reshape your body and life at will. What a cheater!`]);
};


code:
App.UI.Cheat.arcologyCheatDatatypeCleanup = function() {
	const node = new DocumentFragment();

	App.Update.playerArcologyDatatypeCleanup();
	App.Update.playerFSDatatypeCleanup();

	App.UI.DOM.appendNewElement("p", node, "The chanting grows louder and louder, CHEAT! CHEAT! CHEAT!");
	App.UI.DOM.appendNewElement("p", node, "The Arcology morphs and changes shape, CHEAT! CHEAT! CHEAT!");
	App.UI.DOM.appendNewElement("p", node, "The Arcology has been changed forever and you forfeit your CHEATING CHEATY SOUL!");

	return node;
};


code:
/**
 * @param {number} num
 * @returns {DocumentFragment}
 */
App.UI.Cheat.arcology = function(num) {
	V.cheater = 1;
	const el = new DocumentFragment();
	const arc = V.arcologies[num];
	if (arc.rival) {
		el.append(`You cannot cheat edit your rival's arcology at the moment.`);
	} else {
		const tabBar = new App.UI.Tabs.TabBar("CheatArcology");
		tabBar.addTab("Cheat", "cheat", cheat());
		tabBar.addTab("Future societies", "fs", fs());
		el.append(tabBar.render());
	}

	return el;

	function cheat() {
		const el = new DocumentFragment();
		let option;
		const options = new App.UI.OptionsGroup();
		options.addOption("Name", "name", arc).showTextBox();
		option = options.addOption("Direction", "direction", arc);
		const compass = ["east", "north", "northeast", "northwest", "south", "southeast", "southwest", "west"];
		if (num > 0) {
			option.addValueList(compass).pulldown();
		}
		options.addOption("government", "government", arc);
		if (num > 0) {
			option.addValueList(["a committee", "a corporation", "an individual", "an oligarchy", "direct democracy", "elected officials"]);
		}
		options.addOption("Leader ID", "leaderID", arc);
		if (num > 0) {
			option.showTextBox();
		}
		options.addOption("Honeymoon", "honeymoon", arc).showTextBox();
		options.addOption("Prosperity", "prosperity", arc).showTextBox();
		options.addOption("Ownership", "ownership", arc).showTextBox();
		options.addOption("Minority", "minority", arc).showTextBox();
		options.addOption("PC minority", "PCminority", arc).showTextBox();
		options.addOption("Demand factor", "demandFactor", arc).showTextBox();
		options.addOption("Embargo", "embargo", arc).showTextBox();
		option = options.addOption("Embargo target", "embargoTarget", arc)
			.addValue("none", -1).off()
			.addValueList(compass).pulldown();
		if (num !== 0) {
			option.addValue("player", 0);
		}
		option = options.addOption("Influence target", "influenceTarget", arc)
			.addValue("none", -1).off()
			.addValueList(compass).pulldown();
		if (num !== 0) {
			option.addValue("player", 0);
		}
		options.addOption("Influence bonus", "influenceBonus", arc).showTextBox();
		options.addOption("Cyber economic", "CyberEconomic", arc).showTextBox();
		option = options.addOption("Cyber economic target", "CyberEconomicTarget", arc)
			.addValue("none", -1).off()
			.addValueList(compass).pulldown();
		if (num !== 0) {
			option.addValue("player", 0);
		}
		options.addOption("Cyber reputation", "CyberReputation", arc).showTextBox();
		option = options.addOption("Cyber reputation target", "CyberReputationTarget", arc)
			.addValue("none", -1).off()
			.addValueList(compass).pulldown();
		if (num !== 0) {
			option.addValue("player", 0);
		}

		const rivalArc = V.arcologies.find((a) => a.rival === 1);
		if (arc.rival === 0 && rivalArc) {
			options.addCustom(`Sorry, you already have a rival in ${rivalArc.name}`);
		} else {
			options.addOption("Rival", "rival", arc)
				.addValue("Yes", 1).on()
				.addValue("No", 0).off();
		}
		if (arc.hasOwnProperty("hackingEconomic")) { // Sadly, hacking seems to be sometimes undefined, and .addOption will break atm if it is.
			options.addOption("Hacking economic", "hackingEconomic", arc).showTextBox();
		}
		if (arc.hasOwnProperty("hackingEconomicTarget")) {
			option = options.addOption("Hacking economic target", "hackingEconomicTarget", arc)
				.addValue("none", -1).off()
				.addValueList(compass).pulldown();
			if (num !== 0) {
				option.addValue("player", 0);
			}
		}
		if (arc.hasOwnProperty("hackingReputation")) {
			options.addOption("Hacking reputation", "hackingReputation", arc).showTextBox();
		}
		if (arc.hasOwnProperty("childhoodFertilityInducedNCSResearch")) {
			options.addOption("Childhood fertility induced NCS research", "childhoodFertilityInducedNCSResearch", arc).showTextBox();
		}
		el.append(options.render());
		return el;
	}

	function fs() {
		const el = new DocumentFragment();
		let option;
		const options = new App.UI.OptionsGroup();
		for (const FS of Object.keys(App.Data.FutureSociety.records)) {
			if (arc.hasOwnProperty(FS)) {
				option = options.addOption(App.Data.FutureSociety.records[FS].noun, FS, arc)
					.addValue("Unset", "unset");
				if (typeof arc[FS] === "number") {
					option.showTextBox();
				} else {
					option.addValue("Set", 0);
				}

				if (arc.hasOwnProperty(`${FS}Research`)) {
					options.addOption(`${App.Data.FutureSociety.records[FS].noun} research`, `${FS}Research`, arc)
						.addValue("Yes", 1).on()
						.addValue("No", 0).off();
				}
				if (num === 0) {
					if (arc.hasOwnProperty(`${FS}Decoration`)) {
						options.addOption(`${App.Data.FutureSociety.records[FS].noun} decoration`, `${FS}Decoration`, arc).showTextBox();
					}
				}
				if (FS === "FSSupremacist") {
					option = options.addOption(`Supremacist race`, `FSSupremacistRace`, arc);
					for (const [race, capRace] of App.Data.misc.filterRaces) {
						option.addValue(capRace, race);
					}
				} else if (FS === "FSSubjugationist") {
					option = options.addOption(`Subjugationist race`, `FSSubjugationistRace`, arc);
					for (const [race, capRace] of App.Data.misc.filterRaces) {
						option.addValue(capRace, race);
					}
				}
			}
		}
		el.append(options.render());
		return el;
	}
};

App.UI.Cheat.arcologyPassage = function() {
	const node = new DocumentFragment();
	App.UI.DOM.appendNewElement("h2", node, "Cheating Edit Arcology");

	if (
		(V.economy !== 100) ||
		(V.seeDicks !== 25) ||
		(V.continent !== "North America") ||
		(V.internationalTrade !== 1) ||
		(V.internationalVariety !== 1) ||
		(V.seeRace !== 1) ||
		(V.seeNationality !== 1) ||
		(V.seeExtreme !== 0) ||
		(V.plot !== 1)
	) {
		App.UI.DOM.appendNewElement("div", node, App.UI.DOM.link(
			"Restore defaults",
			App.Intro.arcDefault
		));
	}
	node.append(App.UI.Cheat.arcology(0));

	return node;
};