code:
'use strict';

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

				uniform mat4 matModelViewProjectionShadow;

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

				in vec3 vertexNormalMorph;
				in vec3 vertexPositionMorph;

				out vec2 textureCoord;
				out vec3 normal;
				out vec3 pos;
				out vec4 shadowMap;

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

					textureCoord = textureCoordinate;
					pos = (matModelView * posM).xyz;
					shadowMap = matModelViewProjectionShadow * posM;
				}`;
	}

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

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

				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 = normalize(normal);
				}`;
	}

	getVsSourceShadow() {
		return  `#version 300 es
				uniform mat4 matModelViewProjectionShadow;
				uniform mat4 matModel;

				in vec3 vertexPosition;
				in vec3 vertexPositionMorph;
				in vec2 textureCoordinate;

				out vec2 textureCoord;

				void main() {
					gl_Position = matModelViewProjectionShadow * vec4(vertexPosition + vertexPositionMorph, 1.0);
					textureCoord = textureCoordinate;
				}`;
	}

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

				uniform sampler2D alpha;
				in vec2 textureCoord;
				out float gShadowDepth;

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

					if (map_d < 0.85)
						discard;

					gShadowDepth = gl_FragCoord.z;
				}`;
	}

	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 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;
					}
					ao = 1.0 - (occlusion / scale);
				}`;
	}

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

				out float ao;
				in vec2 textureCoord;

				uniform sampler2D ssaoInput;
				uniform float blur;

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

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

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

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

				uniform vec3 shadowDir;
				uniform float shadowBiasMin;
				uniform float shadowBiasMax;
				uniform float shadowIntensity;

				uniform float sssPower;
				uniform float sssDistortion;
				uniform float sssIntensity;
				uniform vec3 sssColor;

                uniform float whiteM;
                uniform float gammaY;
				uniform float uchimuraP;
				uniform float uchimuraA;
				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 vec2 offset;
				uniform float angle;
				uniform float scale;

				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 sUchimura;
				uniform float sNormal;
				uniform float sHDR;
				uniform float sShadows;
				uniform float sSSS;

				uniform float overlay;

                uniform vec3 cameraPos;

                uniform sampler2D textSampler[9];

				in vec2 textureCoord;
				in vec3 normal;
				in vec3 pos;
				in vec4 shadowMap;

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

				vec3 tonemapUchimura(vec3 x, float P, float a) {
					// Uchimura 2017, "HDR theory and practice"
					// Math: https://www.desmos.com/calculator/gslcdxvipg
					// Source: https://www.slideshare.net/nikuque/hdr-theory-and-practicce-jp

					// const float P = 2.0;  // max display brightness
					// const float a = 1.0;  // contrast
					const float m = 0.22; // linear section start
					const float l = 0.4;  // linear section length
					const float c = 1.33; // black
					const float b = 0.0;  // pedestal

					float l0 = ((P - m) * l) / a;
					float S0 = m + l0;
					float S1 = m + a * l0;
					float C2 = (a * P) / (P - S1);
					float CP = -C2 / P;

					vec3 w0 = 1.0 - smoothstep(0.0, m, x);
					vec3 w2 = step(m + l0, x);
					vec3 w1 = 1.0 - w0 - w2;

					vec3 T = m * pow(x / m, vec3(c)) + b;
					vec3 S = P - (P - S1) * exp(CP * (x - S0));
					vec3 L = m + a * (x - m);

					return T * w0 + L * w1 + S * w2;
				}

				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;
					float shadow = 1.0;
					float roughness = 0.0;
					float metallic = 0.0;

					vec2 coord = textureCoord - vec2(0.5, 0.5) - offset;
					coord = vec2(coord.x * cos(angle) - coord.y * sin(angle), coord.x * sin(angle) + coord.y * cos(angle));
					coord = coord / scale;
					coord = coord + vec2(0.5, 0.5);

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

					if (map_d < 0.05)
						discard;

					if (sNormal == 1.0) {
                        vec3 map_Kn = texture(textSampler[5], coord).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], coord).rgb;

                    if (sSpecular == 1.0) {
                        map_Ks = Ks * texture(textSampler[3], coord).rgb;
						roughness = r * texture(textSampler[6], coord).r;
						metallic = m * texture(textSampler[7], coord).r;
					}

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

					if (sShadows == 1.0) {
						vec3 projCoords = shadowMap.xyz/shadowMap.w * 0.5 + 0.5;
						float currentDepth = projCoords.z;
						float bias = max(shadowBiasMax * (1.0 - dot(new_normal, shadowDir)), shadowBiasMin);

						vec2 texelSize = 1.0 / vec2(textureSize(textSampler[8], 0));
						for(int x = -1; x <= 1; ++x)
						{
							for(int y = -1; y <= 1; ++y)
							{
								float pcfDepth = texture(textSampler[8], projCoords.xy + vec2(x, y) * texelSize).r;
								shadow += currentDepth - bias < pcfDepth ? 1.0 : 0.0;
							}
						}
						shadow /= 9.0;
						shadow = min(shadow + (1.0-shadowIntensity), 1.0);
					}

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

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

					// 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, roughness);
						float G   = geometrySmith(N, V, L, roughness);
						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 - metallic;

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

						// ambient lighting
						vec3 La = kD * albedo * lightAmb[i] * (1.0-pow(NdotL, 0.5));

						if (map_d >= 0.85 || overlay == 1.0) {
							// ao
							Lo += La * ao;

							// sss
							if (sSSS == 1.0) {
								vec3 H_d = normalize(L + N * sssDistortion);
								float VdotH = pow(clamp(dot(V, -H_d), 0.0, 1.0), sssPower);
								Lo += sssColor * VdotH * kD * albedo * sssIntensity;
							}
						} else {
							Lo += La;
						}
					}

					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, roughness);
						float G   = geometrySmith(N, V, L, roughness);
						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 - metallic;

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

						// ambient lighting
						vec3 La = kD * albedo * pointLightAmb[i] * (1.0-pow(NdotL, 0.5));

						if (map_d >= 0.85 || overlay == 1.0) {
							// ao
							Lo += La * ao;

							// sss
							if (sSSS == 1.0) {
								vec3 H_d = normalize(L + N * sssDistortion);
								float VdotH = pow(clamp(dot(V, -H_d), 0.0, 1.0), sssPower);
								Lo += sssColor * VdotH * kD * albedo * sssIntensity;
							}
						} else {
							Lo += La;
						}
					}

					vec3 c = (Lo + Le);

					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 (sUchimura == 1.0) {
						c = tonemapUchimura(c, uchimuraP, uchimuraA);
					}

					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.gShadowDepth);
		this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.R32F, screenWidth*4, screenHeight*4, 0, this.gl.RED, this.gl.FLOAT, null);

		this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, this.buffers.shadowFBO);
		this.gl.renderbufferStorage(this.gl.RENDERBUFFER, this.gl.DEPTH_COMPONENT32F, screenWidth*4, screenHeight*4);

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

		let attachments = [];
		attachments[0] = this.gl.COLOR_ATTACHMENT0;
		attachments[1] = this.gl.COLOR_ATTACHMENT1;
		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);

		// configure shadow buffer
		buffers.shadowBuffer = this.gl.createFramebuffer();
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, buffers.shadowBuffer);

		buffers.gShadowDepth = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.gShadowDepth);
		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.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.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.R32F, screenWidth, screenHeight, 0, this.gl.RED, this.gl.FLOAT, null);
		this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, buffers.gShadowDepth, 0);

		this.gl.drawBuffers([this.gl.COLOR_ATTACHMENT0]);

		buffers.shadowFBO = this.gl.createRenderbuffer();
		this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, buffers.shadowFBO);
		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.shadowFBO);
		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);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);

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

		const random = function(seed) {
			let x = Math.sin(seed+1) * 10000;
			return x - Math.floor(x);
		};

		// generate sample kernel
		this.buffers.ssaoKernel = [];
		for (let i = 0; i < 32; i++) {
			let sample = [random(i+1) * 2.0 - 1.0, random(i+2) * 2.0 - 1.0, random(i+3)];
			sample = this.vectorNormalize(sample);
			sample = this.vectorMul(sample, random(4));
			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 = [random(i+1) * 2.0 - 1.0, random(i+2) * 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 vertexShaderShadow = this.gl.createShader(this.gl.VERTEX_SHADER);
		this.gl.shaderSource(vertexShaderShadow, this.getVsSourceShadow());
		this.gl.compileShader(vertexShaderShadow);

		let fragmentShaderShadow = this.gl.createShader(this.gl.FRAGMENT_SHADER);
		this.gl.shaderSource(fragmentShaderShadow, this.getFsSourceShadow());
		this.gl.compileShader(fragmentShaderShadow);

		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.shaderProgramShadow = this.gl.createProgram();
		this.gl.attachShader(this.shaderProgramShadow, vertexShaderShadow);
		this.gl.attachShader(this.shaderProgramShadow, fragmentShaderShadow);
		this.gl.linkProgram(this.shaderProgramShadow);

		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.vertexPositionAttribute2 = this.gl.getAttribLocation(this.shaderProgramShadow, "vertexPosition");
		this.gl.enableVertexAttribArray(this.vertexPositionAttribute2);

		this.vertexPositionMorphAttribute2 = this.gl.getAttribLocation(this.shaderProgramShadow, "vertexPositionMorph");
		this.gl.enableVertexAttribArray(this.vertexPositionMorphAttribute2);

		this.textureCoordAttribute2 = this.gl.getAttribLocation(this.shaderProgramShadow, "textureCoordinate");
		this.gl.enableVertexAttribArray(this.textureCoordAttribute2);

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

		if (V.setShadowMapping) {
			this.gl.disable(this.gl.BLEND);
			this.gl.viewport(0, 0, sceneParams.settings.rwidth*4, sceneParams.settings.rheight*4);
			this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.buffers.shadowBuffer);
			this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
			this.gl.useProgram(this.shaderProgramShadow);
			this.drawShadow(sceneParams);
			this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
			this.gl.viewport(0, 0, this.gl.drawingBufferWidth, this.gl.drawingBufferHeight);
			this.gl.enable(this.gl.BLEND);
		}

		if (V.setSSAO) {
			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);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);

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

		// shadows
		let shadow = [sceneParams.shadows.x, sceneParams.shadows.y, sceneParams.shadows.z];
		let shadowRotX = this.degreeToRad(sceneParams.shadows.xr);
		let shadowRotY = this.degreeToRad(sceneParams.shadows.yr);
		let matShadowRot = this.matrixMulMatrix(this.matrixMakeRotationX(shadowRotX), this.matrixMakeRotationY(shadowRotY));
		let shadowDir = this.matrixMulVector(matShadowRot, [0, 0, 1]);
		let shadowTarget = this.vectorAdd(shadowDir, shadow);
		let matShadow = this.matrixPointAt(shadow, shadowTarget, up);

		// create scene transforms
		let matProj = this.matrixMakeProjection(sceneParams.camera.fov, sceneParams.settings.rwidth/sceneParams.settings.rheight, sceneParams.camera.fnear, sceneParams.camera.ffar);
		let matView = this.matrixInverse(matCamera);

		let matShadowProj = this.matrixMakeProjection(sceneParams.shadows.fov, sceneParams.settings.rwidth/sceneParams.settings.rheight, sceneParams.shadows.fnear, sceneParams.shadows.ffar);
		let matShadowView = this.matrixInverse(matShadow);

		this.buffers.matProj = matProj;
		this.buffers.shadowDir = shadowDir;

		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));
			let matModelViewShadow = this.matrixMulMatrix4(matModel, matShadowView);
			let matModelViewProjectionShadow = this.matrixMulMatrix4(matModelViewShadow, matShadowProj);

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

	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, "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++) {
					if (!modelParams.figures[i].surfaces[j].visible) {
						continue;
					}

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

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

	drawShadow(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.shaderProgramShadow, "matModelViewProjectionShadow"), false, new Float32Array(this.matrixFlatten(modelBuffers.matModelViewProjectionShadow)));

			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.vertexPositionAttribute2, 3, this.gl.FLOAT, false, 0, 0);

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

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesTextureCoordBuffer[i]);
				this.gl.vertexAttribPointer(this.textureCoordAttribute2, 2, 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++) {
					if (!modelParams.figures[i].surfaces[j].visible) {
						continue;
					}

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

						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.shaderProgramShadow, "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)));

		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.noiseTexture);
		this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramSSAO, "texNoise"), 2);

		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.uniform1f(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"), V.setSSAO);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sSSS"), sceneParams.settings.sss);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sShadows"), V.setShadowMapping);
		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, "sUchimura"), V.setTonemapping);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "uchimuraP"), sceneParams.settings.uchimuraP);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "uchimuraA"), sceneParams.settings.uchimuraA);
		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.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "fresnel"), sceneParams.pbr.fresnel);
		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, "lightAmb[" + i + "]"), sceneParams.directionalLights[i].ambient);
			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, "pointLightAmb[" + i + "]"), sceneParams.pointLights[i].ambient);
			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, "shadowDir"), this.buffers.shadowDir);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "shadowBiasMin"), sceneParams.shadows.biasMin);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "shadowBiasMax"), sceneParams.shadows.biasMax);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "shadowIntensity"), sceneParams.shadows.intensity);

		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, "matNormal"), false, new Float32Array(this.matrixFlatten(modelBuffers.matNormal)));
			this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "matModelViewProjectionShadow"), false, new Float32Array(this.matrixFlatten(modelBuffers.matModelViewProjectionShadow)));

			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++) {
					if (!modelParams.figures[i].surfaces[j].visible) {
						continue;
					}

					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 (matId !== "ao_surface") {
							if (h > 0) {
								this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "overlay"), 1.0);
							} else {
								this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "overlay"), 0.0);
							}

							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.activeTexture(this.gl.TEXTURE6);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[mat.map_Kr]);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[6]"), 6);

							this.gl.activeTexture(this.gl.TEXTURE7);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.textures[mat.map_Km]);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[7]"), 7);

							this.gl.activeTexture(this.gl.TEXTURE8);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.gShadowDepth);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[8]"), 8);

							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) * sceneParams.pbr.fresnel);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "r"), mat.r * sceneParams.pbr.roughness);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "m"), mat.m * sceneParams.pbr.metallic);

							this.gl.uniform2fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "offset"), [mat.transform[0], mat.transform[1]]);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "angle"), mat.transform[2]);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "scale"), mat.transform[3]);

							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sssPower"), sceneParams.sss.power);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sssDistortion"), sceneParams.sss.distortion);
							this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sssIntensity"), sceneParams.sss.intensity);
							this.gl.uniform3fv(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sssColor"), mat.Ksss);

							// 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 [[1/(aspect*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]];
	}

	matrixMakeOrthoProjection(width, height, near, far) {
		return [[2/width, 0, 0, 0],
			[0, 2/height, 0, 0],
			[0, 0, 1/(far-near), 0],
			[0, 0, -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(ior) {
		return (ior - 1)**2/(ior + 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, p);
		render();
	};

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

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

		view.camera.y = p.height-5;
		view.yr = 0;
		view.camera.xr = -6;
		view.camera.z = -p.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, p);
		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*V.setPanSpeed;
		view.yr = view.yr + e.movementX*5*V.setRotationSpeed;
		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 * V.setImageSize, 530 * V.setImageSize];
			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 (sz[0] > 300 && artSize === 3) {
		uicontainer.setAttribute("style", "right: 20px; top: 5%; position: absolute; width: 40px; border: 0px; padding: 0px;");
	}

	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 = p.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 = p.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, p);

		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, p) {
	let offset = scene.models[0].transform.y;

	let window = Math.clamp(V.setDefaultView, 0.01, 1); // fraction of full view

	if (V.setAutoFrame === 1) {
		let cameraHeight = p.height * 0.7 + (1 - window) * (p.height * (1-0.7));
		App.Art.AutoFrame(view, p.height, cameraHeight, window, offset);
	} else if ((p.height + offset) > 185) {
		App.Art.AutoFrame(view, p.height, 123, 1, offset);
	} else {
		App.Art.FixedFrame(view);
	}
};

App.Art.AutoFrame = function(view, slaveHeight, cameraHeight, window, offset) {
	// auto-frame based on camera height and FoV
	let n = Math.max((slaveHeight + offset) * 1.06 - cameraHeight, 1);
	let m = cameraHeight * 1.12 * window;
	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: Banyanael/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("Resting Slaves", "slaveAssignmentRest"),
			generateRowCategory("Resting Vign", "slaveAssignmentRestVign"),
			generateRowCategory("Fucktoys", "slaveAssignmentFucktoy"),
			generateRowCategory("Taking Classes", "slaveAssignmentClasses"),
			generateRowCategory("House Servants", "slaveAssignmentHouse"),
			generateRowCategory("House Servant Vign", "slaveAssignmentHouseVign"),
			generateRowCategory("Whores", "slaveAssignmentWhore"),
			generateRowCategory("Whore Vign", "slaveAssignmentWhoreVign"),
			generateRowCategory("Public Sluts", "slaveAssignmentPublic"),
			generateRowCategory("Public Slut Vign", "slaveAssignmentPublicVign"),
			generateRowCategory("Subordinate Slaves", "slaveAssignmentSubordinate"),
			generateRowCategory("Milked", "slaveAssignmentMilked"),
			generateRowCategory("MilkedVign", "slaveAssignmentMilkedVign"),
			generateRowCategory("ExtraMilk", "slaveAssignmentExtraMilk"),
			generateRowCategory("ExtraMilkVign", "slaveAssignmentExtraMilkVign"),
			generateRowCategory("Gloryhole", "slaveAssignmentGloryhole"),
			generateRowCategory("Confined Slaves", "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 Sluts", "slaveAssignmentClub"),
			generateRowCategory("Club Slut Vign", "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", "multiplier"),
			generateRowCategory("Overflow (your reputation cannot be higher than 20k)", "overflow"),
			generateRowCategory("Income curve", "curve")
		]);
	}

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