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;

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

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

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

	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 vec2 textureCoordinate;

				out vec2 textureCoord;

				void main() {
					gl_Position = matModelViewProjectionShadow * vec4(vertexPosition, 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));
				}`;
	}

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

				out float iao;

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

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

				out float iao;
				in vec2 textureCoord;

				uniform sampler2D sssInput;
				uniform float blur;

				void main()
				{
					vec2 texelSize = 1.0 / vec2(textureSize(sssInput, 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(sssInput, textureCoord + offset).r;
						}
					}
					iao = 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 float sssAmbient;

                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 float sss;

				uniform vec2 offset;
				uniform float angle;
				uniform float scale;

				uniform float sNormals;
				uniform float sSSAO;
				uniform float sAO;
				uniform float sIAO;
				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[10];

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

				out vec4 outputColor;

				const float PI = 3.14159265359;

				vec2 poissonDisk[16] = vec2[](
					vec2( -0.94201624, -0.39906216 ),
					vec2( 0.94558609, -0.76890725 ),
					vec2( -0.094184101, -0.92938870 ),
					vec2( 0.34495938, 0.29387760 ),
					vec2( -0.91588581, 0.45771432 ),
					vec2( -0.81544232, -0.87912464 ),
					vec2( -0.38277543, 0.27676845 ),
					vec2( 0.97484398, 0.75648379 ),
					vec2( 0.44323325, -0.97511554 ),
					vec2( 0.53742981, -0.47373420 ),
					vec2( -0.26496911, -0.41893023 ),
					vec2( 0.79197514, 0.19090188 ),
					vec2( -0.24188840, 0.99706507 ),
					vec2( -0.81409955, 0.91437590 ),
					vec2( 0.19984126, 0.78641367 ),
					vec2( 0.14383161, -0.14100790 )
				 );

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

				float random(vec3 seed, int i){
					vec4 seed4 = vec4(seed,i);
					float dot_product = dot(seed4, vec4(12.9898,78.233,45.164,94.673));
					return fract(sin(dot_product) * 43758.5453);
				}

				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 iao = 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 (sSSS == 1.0)
						iao = texture(textSampler[9], 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 i=0;i<16;i++){
							int index = int(16.0*random(floor(pos.xyz*1000.0), i))%16;
							float pcfDepth = texture(textSampler[8], projCoords.xy + poissonDisk[index]*texelSize).r;
							shadow += currentDepth - bias < pcfDepth ? 1.0 : 0.0;
						}
						shadow /= 16.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    = fresnelSchlickRoughness(max(dot(H, V), 0.0), F0, roughness);

						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);
						if (map_d >= 0.85 || overlay == 1.0) {
							Lo += (kD * albedo / PI + specular * map_Ks) * radiance * NdotL * shadow;
						}
						else {
							Lo += (kD * albedo / PI + specular * map_Ks) * radiance * NdotL;
						}

						// ambient lighting
						vec3 La = kD * albedo * lightAmb[i];

						if (map_d >= 0.85 || overlay == 1.0) {
							// sss
							if (sSSS == 1.0 && sss == 1.0) {
								vec3 H_d = normalize(L + N * sssDistortion);
								float VdotH = pow(clamp(dot(V, -H_d), 0.0, 1.0), sssPower);
								Lo += (kD * albedo / PI + specular * map_Ks) * (VdotH+sssAmbient) * iao * sssIntensity * NdotL * shadow;
							}

							// ao
							Lo += La * ao;
						} 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    = fresnelSchlickRoughness(max(dot(H, V), 0.0), F0, roughness);

						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);
						if (map_d >= 0.85 || overlay == 1.0) {
							Lo += (kD * albedo / PI + specular * map_Ks) * radiance * NdotL * shadow;
						}
						else {
							Lo += (kD * albedo / PI + specular * map_Ks) * radiance * NdotL;
						}

						// ambient lighting
						vec3 La = kD * albedo * pointLightAmb[i];

						if (map_d >= 0.85 || overlay == 1.0) {
							// sss
							if (sSSS == 1.0 && sss == 1.0) {
								vec3 H_d = normalize(L + N * sssDistortion);
								float VdotH = pow(clamp(dot(V, -H_d), 0.0, 1.0), sssPower);
								Lo += (kD * albedo / PI + specular * map_Ks) * (VdotH+sssAmbient) * iao * sssIntensity * NdotL * shadow;
							}

							// ao
							Lo += La * ao;
						} 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*0.5+0.5;

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

					if (sIAO == 1.0)
						c = vec3(iao);

					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.vertexPositionBuffer = [];
			modelBuffers.verticesIndexBuffer = [];
			modelBuffers.indexSizes = [];
			modelBuffers.figureIndices = [];
			modelBuffers.figureSeemMaps = [];
			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.vertexPositionBuffer[i] = this.base64ToFloat(modelData.figures[i].verts);

				modelBuffers.verticesNormalBuffer[i] = this.gl.createBuffer();
				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalBuffer[i]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(modelBuffers.vertexPositionBuffer[i].length), 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);

				modelBuffers.figureIndices[i] = [];
				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);
					modelBuffers.figureIndices[i] = [...modelBuffers.figureIndices[i], ...intArray];
					this.gl.bufferData(this.gl.ELEMENT_ARRAY_BUFFER, intArray, this.gl.STATIC_DRAW);
					modelBuffers.indexSizes[count] = intArray.length;
				}

				let seems = this.base64ToInt(modelData.figures[i].seems);
				let seemMap = new Int32Array(seems.length*2);
				let indices = modelBuffers.figureIndices[i];
				for (let j=0, h=0; j <= seems.length-3; j+=3, h+=6) {
					let pos = seems[j+0];
					let oldInd = seems[j+1];
					let newInd = seems[j+2];
					indices[pos] = newInd/3;
					seemMap[h] = oldInd;
					seemMap[h+1] = newInd;
					seemMap[h+2] = oldInd+1;
					seemMap[h+3] = newInd+1;
					seemMap[h+4] = oldInd+2;
					seemMap[h+5] = newInd+2;
				}
				modelBuffers.figureSeemMaps[i] = seemMap;
			}

			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.bindTexture(this.gl.TEXTURE_2D, this.buffers.sssColorBuffer);
		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.sssColorBufferBlur);
		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);

		// SSS color buffer
		buffers.sssColorBuffer = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.sssColorBuffer);
		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.sssFBO = this.gl.createFramebuffer();
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, buffers.sssFBO);
		this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, buffers.sssColorBuffer, 0);
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);

		// SSS blur buffer
		buffers.sssColorBufferBlur = this.gl.createTexture();
		this.gl.bindTexture(this.gl.TEXTURE_2D, buffers.sssColorBufferBlur);
		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.sssBlurFBO = this.gl.createFramebuffer();
		this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, buffers.sssBlurFBO);
		this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, buffers.sssColorBufferBlur, 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.vertexIndexMorphs = [];
		for (let f=0; f < modelData.figures.length; f++) {
			modelBuffers.vertexPositionMorphs[f] = [];
			modelBuffers.vertexIndexMorphs[f] = [];
			for (let m=0; m < modelData.morphCount; m++) {
				modelBuffers.vertexPositionMorphs[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 name = path.split("/").slice(-1)[0];
				let morph = window.sceneBlocks[name];

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

				window.sceneBlocks[name] = 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 name = path.split("/").slice(-1)[0];
				let url = window.sceneBlocks[name][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);

					window.sceneBlocks[name] = 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 fragmentShaderSSS = this.gl.createShader(this.gl.FRAGMENT_SHADER);
		this.gl.shaderSource(fragmentShaderSSS, this.getFsSourceSSS());
		this.gl.compileShader(fragmentShaderSSS);

		let fragmentShaderSSSBlur = this.gl.createShader(this.gl.FRAGMENT_SHADER);
		this.gl.shaderSource(fragmentShaderSSSBlur, this.getFsSourceSSSBlur());
		this.gl.compileShader(fragmentShaderSSSBlur);

		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.shaderProgramSSS = this.gl.createProgram();
		this.gl.attachShader(this.shaderProgramSSS, vertexShaderQuad);
		this.gl.attachShader(this.shaderProgramSSS, fragmentShaderSSS);
		this.gl.linkProgram(this.shaderProgramSSS);

		this.shaderProgramSSSBlur = this.gl.createProgram();
		this.gl.attachShader(this.shaderProgramSSSBlur, vertexShaderQuad);
		this.gl.attachShader(this.shaderProgramSSSBlur, fragmentShaderSSSBlur);
		this.gl.linkProgram(this.shaderProgramSSSBlur);

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

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

		this.quadPositionAttribute3 = this.gl.getAttribLocation(this.shaderProgramSSS, "vertexPosition");
		this.gl.enableVertexAttribArray(this.quadPositionAttribute3);

		this.quadTextureAttribute3 = this.gl.getAttribLocation(this.shaderProgramSSS, "textureCoordinate");
		this.gl.enableVertexAttribArray(this.quadTextureAttribute3);
	}

	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.cullFace(this.gl.FRONT);
			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.cullFace(this.gl.BACK);
			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);
		}

		if (V.setSSS) {
			this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.buffers.sssFBO);
			this.gl.clear(this.gl.COLOR_BUFFER_BIT);
			this.gl.useProgram(this.shaderProgramSSS);
			this.drawSSS(sceneParams);
			this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);

			this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.buffers.sssBlurFBO);
			this.gl.clear(this.gl.COLOR_BUFFER_BIT);
			this.gl.useProgram(this.shaderProgramSSSBlur);
			this.drawSSSBlur(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);

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

	drawSSS(sceneParams) {
		this.gl.uniformMatrix4fv(this.gl.getUniformLocation(this.shaderProgramSSS, "projection"), false, new Float32Array(this.matrixFlatten(this.buffers.matProj)));

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

		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramSSS, "radius"), sceneParams.sss.radius);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramSSS, "bias"), sceneParams.sss.bias);
		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramSSS, "scale"), sceneParams.sss.scale);

		this.gl.activeTexture(this.gl.TEXTURE0);
		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.gPosition);
		this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramSSS, "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.shaderProgramSSS, "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.shaderProgramSSS, "texNoise"), 2);

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

		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffers.quadTextureBuffer);
		this.gl.vertexAttribPointer(this.quadTextureAttribute3, 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);
	}

	drawSSSBlur(sceneParams) {
		this.gl.activeTexture(this.gl.TEXTURE0);
		this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.sssColorBuffer);

		this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgramSSSBlur, "blur"), sceneParams.sss.blur);

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

		this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffers.quadTextureBuffer);
		this.gl.vertexAttribPointer(this.quadTextureAttribute3, 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, "sIAO"), sceneParams.settings.iao);
		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"), sceneParams.settings.uchimura);
		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);
		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.uniform1f(this.gl.getUniformLocation(this.shaderProgramForwardPass, "sssAmbient"), sceneParams.sss.ambient);

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

				// 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.activeTexture(this.gl.TEXTURE9);
							this.gl.bindTexture(this.gl.TEXTURE_2D, this.buffers.sssColorBufferBlur);
							this.gl.uniform1i(this.gl.getUniformLocation(this.shaderProgramForwardPass, "textSampler[9]"), 9);

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

							// 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 vertexPosition = new Float32Array(modelBuffers.vertexPositionBuffer[f]);
				let vertexNormal = new Float32Array(vertexPosition.length);

				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 vi = modelBuffers.vertexIndexMorphs[f][m];

						if (morphValue === 1) {
							for (let j = 0; j < vi.length; j++) {
								vertexPosition[vi[j]] += vp[j];
							}
						} else {
							for (let j=0; j < vi.length; j++) {
								vertexPosition[vi[j]] += vp[j] * morphValue;
							}
						}
					}
				}

				// recalculate normals
				let indices = modelBuffers.figureIndices[f];
				for (let j=0; j < indices.length; j+=3) {
					let idx1 = indices[j]*3;
					let idx2 = indices[j+1]*3;
					let idx3 = indices[j+2]*3;

					let v1 = [vertexPosition[idx1], vertexPosition[idx1+1], vertexPosition[idx1+2]];
					let v2 = [vertexPosition[idx2], vertexPosition[idx2+1], vertexPosition[idx2+2]];
					let v3 = [vertexPosition[idx3], vertexPosition[idx3+1], vertexPosition[idx3+2]];

					let n = this.vectorNormalize(this.vectorCrossProduct(this.vectorSub(v2, v1), this.vectorSub(v1, v3)));

					vertexNormal[idx1] += n[0];
					vertexNormal[idx1+1] += n[1];
					vertexNormal[idx1+2] += n[2];
					vertexNormal[idx2] += n[0];
					vertexNormal[idx2+1] += n[1];
					vertexNormal[idx2+2] += n[2];
					vertexNormal[idx3] += n[0];
					vertexNormal[idx3+1] += n[1];
					vertexNormal[idx3+2] += n[2];
				}

				// fix edge normals
				let seemMap = modelBuffers.figureSeemMaps[f];
				for (let j=0; j <= seemMap.length-2; j+=2) {
					vertexNormal[seemMap[j]] = vertexNormal[seemMap[j+1]];
				}

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesPositionBuffer[f]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, vertexPosition, this.gl.STATIC_DRAW);

				this.gl.bindBuffer(this.gl.ARRAY_BUFFER, modelBuffers.verticesNormalBuffer[f]);
				this.gl.bufferData(this.gl.ARRAY_BUFFER, vertexNormal, 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), 1]];
	}

	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.runningAnim = [];

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, false); // 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;");
	cvs.setAttribute("oncontextmenu", "return false;");

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

	let panY;
	let zoomZ;
	let zoomY;
	let oz;
	let oy;
	let oxr;

	// events
	function inspectView(e) {
		updateLinkedButtons("inspect");

		if (!V.seeAnimation) {
			if (V.set3QView) {
				view.yr = 30;
			} else {
				view.yr = 0;
			}
			view.inspect = true;
			App.Art.Frame(slave, scene, view, p);
			render();
		}
	}

	function lockView(e) {
		updateLinkedButtons("lock");
		render();
	}

	function faceView(e) {
		updateLinkedButtons("face");

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

	function resetView(e) {
		updateLinkedButtons("reset");

		if (V.set3QView) {
			view.yr = 30;
		} else {
			view.yr = App.Art.defaultScene.models[0].transform.yr;
		}
		view.inspect = false;
		App.Art.Frame(slave, scene, view, p);
		render();
	}

	btnInspectView.onclick = inspectView;
	btnLockView.onclick = lockView;
	btnFaceView.onclick = faceView;
	btnResetView.onclick = resetView;

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

		updateLinkedButtons("move");

		if (e.buttons === 1) { // left click
			panY = panY + e.movementY/7*V.setPanSpeed;
			view.camera.y = view.camera.y + e.movementY/7*V.setPanSpeed;
			view.yr = view.yr + e.movementX*5*V.setRotationSpeed;
		}
		if (e.buttons === 2) { // right click
			view.yr = view.yr + e.movementX*5*V.setRotationSpeed;
			view.camera.xr = view.camera.xr - e.movementY*1*V.setRotationSpeed;
			view.camera.xr = Math.clamp(view.camera.xr, -89, 89);

			let r = (-oz-zoomZ)/Math.cos(oxr*Math.PI/180);
			view.camera.y = oy + panY + zoomY - Math.sin((view.camera.xr)*Math.PI/180) * r + Math.sin(oxr*Math.PI/180)*r;
			view.camera.z = Math.cos((view.camera.xr)*Math.PI/180) * -r;
		}
		if (e.buttons === 4) { // wheel click
			panY = panY + e.movementY/7*V.setPanSpeed;
			view.camera.y = view.camera.y + e.movementY/7*V.setPanSpeed;
			view.camera.x = view.camera.x - e.movementX/7*V.setPanSpeed;
		}
		render();
	};

	cvs.onmousedown = function(e){
		if (view.lockView){
			// update if shared view
			updateLinkedButtons();
			if (artSize < 3) {
				render();
			}
			return;
		}
		oz = view.camera.z;
		oy = view.camera.y;
		oxr = view.camera.xr;
		panY = 0;
		zoomZ = 0;
		zoomY = 0;
		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);
		zoomZ -= zDistance;

		let yDistance = Math.sin(-view.camera.xr * (Math.PI/180)) * magnitude * -(view.camera.z - zOld)/zDistance;
		view.camera.y += yDistance;
		zoomY += yDistance;

		render();
		return false;
	};

	// animation call
	if (artSize !== App.Art.artSize && App.Art.runningAnim.length > 0) {
		for (let i = 0; i < App.Art.runningAnim.length; i++) {
			clearInterval(App.Art.runningAnim[i]); // always clear, prevent animation running in background
		}
		App.Art.runningAnim.length = 0;
	}

	if (V.seeAnimation) {
		App.Art.startAnim(slave, scene, view, p, cvs);
		App.Art.artSize = artSize;
	}

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

	let sz = App.Art.GetCanvasResolution(artSize);
	let zoom = Math.max(1, window.devicePixelRatio);
	cvs.width = sz[0] * zoom;
	cvs.height = sz[1] * zoom;

	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;

	if (slave.height > 450) {
		scene.shadows.fov = 60;
	} else {
		scene.shadows.fov = 25;
	}

	// render state
	if (view.faceView) {
		faceView();
	} else if (view.inspectView) {
		inspectView();
	} else if (view.resetView) {
		resetView();
	} else {
		// default
		updateLinkedButtons();
		if (artSize < 3) {
			render();
		}
	}
};

App.Art.startAnim = function(slave, scene, view, p, cvs) {
	function updateFrame() {
		scene.inspect = view.inspect;
		scene.models[0].transform.yr = view.yr;
		scene.camera = view.camera;
		App.Art.applyMorphs(slave, scene, p, true);
		App.Art.engine.render(scene, cvs);
	}
	App.Art.runningAnim.push(setInterval(updateFrame, 1000 / V.animFPS));
};

App.Art.GetCanvasResolution = function(artSize) {
	switch (artSize) {
		case 3:
			return [300 * V.setImageSize, 530 * V.setImageSize];
		case 2:
			return [300, 300];
		case 1:
			return [150, 150];
		default:
			return [120, 120];
	}
};

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.x = 0;
	view.camera.xr = -rot;
};

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

repo_name: Fanatey/fc

license: mit