Nico Pr

Nico Pr

Ripples3

Full demo

Code#

Github

Tests#

Bounce box
Ultra HD
Vortex fail
Flow fail
Smooth blur
Biohazard

ripples html
-
<div id="ripples3">
	<span id="fps">-- --</span>
</div>
ripples css
-
:root {
	--fast: 0.23s;
	--ease: ease-in-out;
	--back: rgba(255, 255, 255, 0.65);
	--col: rgba(0, 0, 0, 1);
}

@media (prefers-color-scheme: dark) {
	:root {
		--back: rgba(0, 0, 0, 0.6);
		--col: rgba(186, 186, 186, 1);
	}
}

#ripples3 {
	/* position: relative; */
	width: 100%;
	height: 50vh;
	overflow: hidden;
}

canvas {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	z-index: 1;
	touch-action: none;
	/* 1 = no transitionend evt = no init previous experiments */
	opacity: 0.99; 
	/* opacity: 0; */
	transition: all var(--fast) var(--ease);
}

.err {
	position: absolute;
	box-sizing: border-box;
	top: 50%;
	left: 50%;
	width: 90%;
	max-width: 460px;
	z-index: 1000;
	transform: translate(-50%, -50%);
	color: #bababa;
	background-color: rgba(0, 0, 0, 0.66);
	padding: 1em .5em;
	text-align: center;
}

.err div {
	color: #ff8400;
	font-size: 1.5em;
}

#fps {
	display: none;
	position: absolute;
	bottom: 0px;
	right: 0px;
	z-index: 100;
	color: var(--col);
	opacity: 0.5;
	font-size: .8em;
	pointer-events: none;
	background: var(--back);
}
Ripples3
-
/**
 * Ripples3
 * Nico Pr
 * https://nicopr.fr/ripples3
 * https://github.com/nicopowa/ripples3
 */

const DEBUG = true;

const handleError = err => {

	console.error(err);

	const errorDiv = document.createElement("div");

	errorDiv.classList.add("err");
	errorDiv.innerHTML = "<div>liquify fail</div>" + "<br/>" + err;
	document.body.appendChild(errorDiv);

};

const lazy = (cb, ms = 256) =>
	setTimeout(
		cb,
		ms + Math.round(Math.random() * ms)
	);

class NoParams {

	hashParams() {}

	toggle() {}

	updateSunLight() {}

	updateColors() {}

	liquified() {}

	mask() {}

	updateBounds() {}

}

class Liquid {

	/**
	 * @param {Element} wrap 
	 */
	constructor(wrap = document.body) {

		this.BASE_SCALE = 2.0;
		this.REF_SIZE = 1024;
		this.MAX_SCALE = 2.0;
		this.MIN_SCALE = 1.0;
		this.STEP = 1000 / 60;
		this.DISP_SCALE = this.MAX_SCALE;
		this.PAUSE_HIDE = true;
		this.PAUSE_BLUR = false;
		this.TARGET_FPS = 50;
		this.FRAME_SIZE = 32;
		this.SCALE_TIME = 1000;
		this.SCALE_STEP = 0.5;
		this.ENABLE_UPSCALE = true;
		this.LOOPS_TO_SCALE = 3;

		this.MAX_TOUCHES = 10;

		this.PERF_CHECK = false;
		this.LOOP_COUNT = 0;
		this.DOWN_FROM = this.MAX_SCALE;
		this.ww = 0;
		this.wh = 0;
		this.sizeBase = 0;
		this.wrap = wrap;

		this.syncSize();

		this.params = {
			img: "frame/ripples3/assets/img.webp",
			waveSpeed: 0.997,
			damping: 0.996,
			propagationSpeed: 10,

			refraction: 0.66,
			waterHue: 0.619,
			waterColor: [0, 0.286, 1],
			tintStrength: 0.1,
			specularStrength: 0.7,
			roughness: 0.16,
			fresnelEffect: 1.2,
			fresnelPower: 2.6,
			specularPower: 39,
			reflectionFresnel: 1,
			reflectionBlur: 0,
			reflectionDistortion: 0.5,

			skyHue: 0.583,
			skyColor: [0, 0.502, 1],
			depthFactor: 1.7,
			atmosphericScatter: 0.2,
			envMapIntensity: 0.7,

			touchRadius: 0.015,
			initialImpact: 0.32,
			trailStrength: 0,
			trailSpread: 0,

			causticStrength: 0.15,
			causticScale: 1.4,
			causticSpeed: 0.02,
			causticBrightness: 0.42,
			causticDetail: 2.5,

			sunDirection: [0.624, 0.332, 0.707],
			sunAngle: 28,
			sunHeight: 55,
			sunIntensity: 1.2,
			sunHue: 0.18,

			lightDirection: [0.659, -0.534, 0.53],
			lightAngle: 175,
			lightHeight: 50,
			lightIntensity: 0.2,
			lightHue: 0.52,

			waveReflectionStrength: 0.6,
			mirrorReflectionStrength: 0.3,
			velocityReflectionFactor: 0.15
		};

		this.amplify
		= this.hitting
		= this.sizing
		= this.img
		= this.image
		= this.cvs
		= this.gl
		= this.vertexBuffer
		= this.physicsProgram
		= this.physics
		= this.renderProgram
		= this.renders
		= this.currentTexture
		= this.previousTexture
		= this.backgroundTexture
		= this.waterTexture1
		= this.waterTexture2
		= this.currentFramebuffer
		= this.framebuffer1
		= this.framebuffer2
		= null;

		this.loop = -1;
		this.hits = false;

		this.vertices = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);

		this.scaledPropagation = this.params.propagationSpeed;

		this.currentFPS = 0;
		this.frameTimeHistory = [];
		this.frameTimeSum = 0;
		this.frameTimeIndex = 0;
		this.lastFrameTime = 0;
		this.lastScaleCheck = 0;
		this.notBefore = 0;

		this.isLiquid = false;
		this.isRunning = false;
		this.isVisible = true;
		this.isFocused = true;
		this.isTouched = false;
		this.isAuto = false;
		this.isTest = false;
		this.testLoop = null;
		this.isAudio = false;
		
		this.isRaining = false;
		this.lastRainDrop = 0;
		this.rainDropDelay = 384;
		this.maxRaindrops = 4;
		this.activeRaindrops = null;

		this.pops = new Map();
		this.deltaTime = 1;
		this.accumulatedTime = 0;
		this.fish = [];
		this.infos = null;

		this.dirtyPhysics = true;
		this.dirtyRenders = true;
		this.dirtyTouches = true;

		this.bck = document.createElement("canvas");
		this.btx = this.bck.getContext(
			"2d",
			{
				alpha: false
			}
		);

		this.cvs = document.createElement("canvas");
		this.cvs.style.opacity = "0";
		wrap.appendChild(this.cvs);
	
	}

	async load(preset) {
				
		this.renderText("loading");

		await this.fadeCanvas(0);

		this.stopRender();

		this.resetWater();

		if(preset.img && preset.img != this.img) {
			
			this.img = preset.img;
			await this.loadBackground();
			this.updateBackgroundTexture();
		
		}

		this.params = {
			...this.params,
			...preset
		};

		this.syncUniforms();

		this.amplify.updateSunLight();
		this.amplify.updateColors();

		this.startRender();
		
		await this.fadeCanvas(1);
	
	}

	flow(amplify = null, params = {}) {

		this.amplify = amplify;

		this.params = {
			...this.params,
			...params
		};

		this.img = this.params.img;

		this.infos = document.querySelector("#fps");

		return [
			this.loadBackground,
			this.initializeWebGL,
			this.initShaders,
			this.initUniforms,
			this.initBuffers,
			this.syncCanvas,
			this.syncViewport,
			this.initTextures,
			this.syncTextures,
			this.syncUniforms,
			this.setupEvents,
			this.startRender
		]
		.map(fn =>
			fn.bind(this))
		.reduce(
			(prv, cur) =>
				prv.then(() =>
					cur()),
			Promise.resolve()
		)
		.then(() => {

			return this.fadeCanvas(1)
			.then(() => {

				if(DEBUG)
					console.log("liquified");

				this.isLiquid = true;
				this.amplify.liquified();
			
			});
		
		})
		.catch(err => {

			throw err;
		
		});
	
	}

	async loadBackground() {

		const imgUrl = this.img;

		this.image = await this.loadImage(imgUrl);
	
	}

	loadImage(imgSrc) {

		return new Promise(imgLoaded => {

			const i = new Image();

			i.addEventListener(
				"load",
				() =>
					imgLoaded(i)
			);
			i.addEventListener(
				"error",
				() => {

					throw new Error("image load error " + imgSrc);
			
				}
			);
			i.crossOrigin = "anonymous";
			i.src = imgSrc;
		
		});
	
	}

	initializeWebGL() {

		const contextOptions = {
			alpha: false,
			antialias: false,
			depth: false,
			stencil: false,
			powerPreference: "high-performance",
			preserveDrawingBuffer: false
		};

		this.gl = this.cvs.getContext(
			"webgl2",
			contextOptions
		);

		if(!this.gl)
			throw new Error("webgl2 not supported");

		const ext = this.gl.getExtension("EXT_color_buffer_float");

		if(!ext)
			throw new Error("ext_color_buffer_float not supported");

		this.gl.getExtension("OES_texture_float_linear");

		const glInitErr = this.gl.getError();

		if(glInitErr !== this.gl.NO_ERROR) {

			throw new Error("webgl init error " + glInitErr);
		
		}
	
	}

	async initShaders() {

		const VERTEX_SHADER = `#version 300 es
in vec2 position;
out vec2 texCoord;

void main() {
	texCoord = position * 0.5 + 0.5;
	gl_Position = vec4(position, 0.0, 1.0);
}`;

		const PHYSICS_SHADER = `#version 300 es
precision highp float;
precision highp sampler2D;

uniform vec2 uResolution;
uniform float uDeltaTime;
uniform float uDisplayScale;
uniform float uSizeBase;
uniform float uWaveSpeed;
uniform float uDamping;
uniform float uPropagationSpeed;

uniform sampler2D uPreviousState;
uniform int uTouchCount;

struct Touch {
	vec2 position;
	float radius;
	float strength;
	float damping;
};

uniform Touch uTouchParams[10];
in vec2 texCoord;
out vec4 fragColor;

float getTouchDistance(vec2 texCoord, vec2 touchPos) {
	vec2 pixelCoord = texCoord * uResolution;
	float whatSize = max(uResolution.x, uResolution.y) / uSizeBase;
	vec2 delta = (pixelCoord - touchPos) / whatSize;
	return length(delta);
}

void main() {
	vec2 pixel = 1.0 / uResolution;
	vec4 state = texture(uPreviousState, texCoord);
	float height = state.r;
	float oldHeight = state.g;
	
	float sum = 0.0;
	
	for(int i = 0; i < 12; i++) {
		// precomputed vectors breaks on some android
		float angle = float(i) * 0.523598775;
		vec2 dir = vec2(cos(angle), sin(angle));
		vec2 sampleCoord = texCoord + dir * pixel * uPropagationSpeed;
		sampleCoord = clamp(sampleCoord, vec2(0.0), vec2(1.0));
		sum += texture(uPreviousState, sampleCoord).r;
	}
	
	sum /= 12.0;
	
	float newHeight = sum * 2.0 - oldHeight;
	newHeight = mix(height, newHeight, uWaveSpeed);
	newHeight *= uDamping;
	
	float whatSize = max(uResolution.x, uResolution.y);
	float totalEffect = 0.0;
	
	for(int i = 0; i < uTouchCount; i++) {
		float dist = getTouchDistance(texCoord, uTouchParams[i].position);
		float touchRadius = uTouchParams[i].radius;
		
		if(dist < touchRadius && touchRadius > 0.0) {
			float strength = smoothstep(touchRadius, 0.0, dist);
			float touchEffect = uTouchParams[i].strength * strength;
			totalEffect += touchEffect;
		}
	}
	
	newHeight += totalEffect;
	
	fragColor = vec4(newHeight, height, 0.0, 1.0);
}`;

		const RENDER_SHADER = `#version 300 es
precision highp float;
precision highp sampler2D;

uniform vec2 uResolution;
uniform float uTime;
uniform float uDisplayScale;
uniform sampler2D uWaterHeight;
uniform sampler2D uBackgroundTexture;
uniform sampler2D uPreviousState;
uniform vec3 uSunDirection;
uniform vec3 uLightDirection;
uniform float uSunIntensity;
uniform float uLightIntensity;
uniform vec3 uSunColor;
uniform vec3 uLightColor;
uniform vec3 uWaterColor;
uniform float uTintStrength;
uniform float uSpecularStrength;
uniform float uRoughness;
uniform float uFresnelEffect;
uniform float uFresnelPower;
uniform float uSpecularPower;
uniform float uReflectionFresnel;
uniform float uReflectionDistortion;
uniform vec3 uSkyColor;
uniform float uDepthFactor;
uniform float uAtmosphericScatter;
uniform float uEnvMapIntensity;
uniform float uCausticStrength;
uniform float uCausticScale;
uniform float uCausticSpeed;
uniform float uCausticBrightness;
uniform float uCausticDetail;
uniform float uWaveReflectionStrength;
uniform float uMirrorReflectionStrength;
uniform float uVelocityReflectionFactor;
uniform float uReflectionBlur;
uniform float uRefraction;

in vec2 texCoord;
out vec4 fragColor;

vec3 getNormal(vec2 uv, vec2 pixel, out float velocity) {
	vec4 data = texture(uWaterHeight, uv);
	float height = data.r;
	float oldHeight = data.g;
	velocity = (height - oldHeight) * uDisplayScale;
	
	vec2 offset = pixel * 2.0;

	// Use stable, centered gradient calculation
	float h_l = texture(uWaterHeight, uv - vec2(offset.x, 0.0)).r;
	float h_r = texture(uWaterHeight, uv + vec2(offset.x, 0.0)).r;
	float h_t = texture(uWaterHeight, uv - vec2(0.0, offset.y)).r;
	float h_b = texture(uWaterHeight, uv + vec2(0.0, offset.y)).r;
	
	// Improved gradient calculation to reduce linear artifacts
	vec2 grad = vec2(h_r - h_l, h_b - h_t) * 0.5;

	// Scale the gradient based on velocity for dynamic normals
	float normalStrength = 1.0 + abs(velocity) * uVelocityReflectionFactor;
	grad *= normalStrength * uDisplayScale;
	
	return normalize(vec3(grad, 1.0));
}

float getCaustics(vec2 uv, vec3 normal, float velocity) {
	if(uCausticStrength <= 0.0) return 0.0;
	
	float scaledTime = uTime * uCausticSpeed;
	
	float scale = uCausticScale;
	vec2 causticsUV = uv * scale + normal.xy * uReflectionDistortion;
	float timeOffset = scaledTime;
	
	vec2 warp = vec2(
		sin(causticsUV.y * 2.0 + timeOffset),
		sin(causticsUV.x * 2.0 + timeOffset)
	) * 0.1;
	
	causticsUV += warp;
	
	float detail = uCausticDetail * 8.0;
	float pattern = sin(causticsUV.x * detail + timeOffset) * sin(causticsUV.y * detail + timeOffset);
	
	return pattern * 0.5 + 0.5;
}

float getReflection(vec3 normal, vec3 lightDir, vec3 viewDir, float velocity) {
	vec3 halfDir = normalize(lightDir + viewDir);
	float NdotH = max(dot(normal, halfDir), 0.0);
	float NdotL = max(dot(normal, lightDir), 0.0);
	
	float alpha = uRoughness * uRoughness;
	float alpha2 = alpha * alpha;
	float NdotH2 = NdotH * NdotH;
	
	float denom = NdotH2 * (alpha2 - 1.0) + 1.0;
	float D = alpha2 / (3.14159265359 * denom * denom);
	
	float mirrorSpec = D * uMirrorReflectionStrength;
	float waveSpec = pow(NdotH, uSpecularPower) * (1.0 + abs(velocity) * 10.0) * uWaveReflectionStrength;
	
	float velocityFactor = smoothstep(0.0, 1.0, abs(velocity) * uVelocityReflectionFactor);
	return mix(mirrorSpec, waveSpec, velocityFactor) * uSpecularStrength * NdotL;
}

vec3 sampleBlurredBackground(vec2 uv, float radius, vec2 pixel) {
	if(radius <= 0.001) return texture(uBackgroundTexture, uv).rgb;
	
	const int samples = 5;
	vec2 offsets[samples] = vec2[](
		vec2(0.0, 0.0),
		vec2(1.0, 0.0), vec2(-1.0, 0.0), 
		vec2(0.0, 1.0), vec2(0.0, -1.0)
	);
	
	float weights[samples] = float[](
		0.5, 0.125, 0.125, 0.125, 0.125
	);
	
	vec3 color = vec3(0.0);
	for(int i = 0; i < samples; i++) {
		vec2 sampleUv = clamp(uv + offsets[i] * radius * pixel, 0.0, 1.0);
		color += texture(uBackgroundTexture, sampleUv).rgb * weights[i];
	}
	
	return color;
}

void main() {
	vec2 pixel = 1.0 / uResolution;
	vec3 viewDir = vec3(0.0, 0.0, 1.0);
	
	float velocity;
	vec3 normal = getNormal(texCoord, pixel, velocity);
	
	vec4 heightData = texture(uWaterHeight, texCoord);
	float height = heightData.r;
	float depth = clamp(height * uDepthFactor, 0.0, 1.0);
	
	float distortionFactor = min(abs(velocity) * 2.0, 1.0);
	vec2 refractOffset = normal.xy * uRefraction;
	vec2 bgCoord = clamp(texCoord + refractOffset, 0.0, 1.0);
	
	float viewAngle = 1.0 - max(dot(normal, viewDir), 0.0);
	
	float blurRadius = uReflectionBlur * (1.0 + distortionFactor);
	vec3 refractColor = sampleBlurredBackground(bgCoord, blurRadius, pixel);
	
	float waterDepth = smoothstep(0.0, 1.0, depth);
	float scatter = clamp(waterDepth * uAtmosphericScatter, 0.0, 1.0);
	
	vec3 deepWaterColor = mix(uWaterColor, uSkyColor * 0.3, scatter);
	vec3 waterColor = mix(refractColor, deepWaterColor, waterDepth * uTintStrength);
	
	float fresnel = pow(viewAngle, max(uFresnelPower, 1.0)) * uFresnelEffect;
	float reflectionStrength = smoothstep(0.0, 1.0, fresnel);
	
	vec3 sunDir = normalize(uSunDirection);
	vec3 lightDir = normalize(uLightDirection);
	
	float sunSpecular = getReflection(normal, sunDir, viewDir, velocity);
	float lightSpecular = getReflection(normal, lightDir, viewDir, velocity);
	
	vec3 subtleSunColor = mix(vec3(1.0), uSunColor, 0.32);
	vec3 subtleLightColor = mix(vec3(1.0), uLightColor, 0.24);
	
	vec3 sunRefl = subtleSunColor * sunSpecular * uSunIntensity * 3.0 * (1.0 + fresnel);
	vec3 secondaryRefl = subtleLightColor * lightSpecular * uLightIntensity * 2.0 * (1.0 + fresnel * 0.5);
	
	float caustic = getCaustics(texCoord, normal, velocity);
	float causticAttenuation = (1.0 - waterDepth) * (1.0 - scatter * 0.7);
	vec3 causticColor = vec3(1.0, 0.97, 0.9) * caustic * uCausticStrength * uCausticBrightness * causticAttenuation;
	
	vec3 envColor = mix(uSkyColor, vec3(1.0), reflectionStrength);
	vec3 reflectionColor = mix(refractColor, envColor, reflectionStrength) * uEnvMapIntensity * uReflectionFresnel;
	
	vec3 finalColor = mix(waterColor, reflectionColor, reflectionStrength);
	finalColor += sunRefl + secondaryRefl + causticColor;
	
	finalColor = mix(finalColor, uSkyColor, scatter * uAtmosphericScatter);
	
	fragColor = vec4(clamp(finalColor, 0.0, 1.0), 1.0);
}`;

		this.physicsProgram = this.createProgram(
			VERTEX_SHADER,
			PHYSICS_SHADER
		);

		this.renderProgram = this.createProgram(
			VERTEX_SHADER,
			RENDER_SHADER
		);

	}

	initBuffers() {

		this.vertexBuffer = this.gl.createBuffer();

		this.gl.bindBuffer(
			this.gl.ARRAY_BUFFER,
			this.vertexBuffer
		);

		this.gl.bufferData(
			this.gl.ARRAY_BUFFER,
			this.vertices,
			this.gl.STATIC_DRAW
		);
	
	}

	syncSize() {

		this.ww = this.wrap.offsetWidth;
		this.wh = this.wrap.offsetHeight;

		this.sizeBase = +Math.max(
			0.25,
			Math.min(
				1.0,
				// 0.4 + 0.6 * Math.log10(Math.max(
				Math.log10(Math.max(
					this.ww,
					this.wh
				) / 320)
			)
		);
		// .toFixed(3); // needs fixing ?

		this.deltaTime = this.BASE_SCALE / this.DISP_SCALE;
	
	}

	syncCanvas() {

		// round ?
		// const cw = Math.round(this.ww * this.DISP_SCALE);
		// const ch = Math.round(this.wh * this.DISP_SCALE);

		const cw = this.ww * this.DISP_SCALE;
		const ch = this.wh * this.DISP_SCALE;

		this.cvs.width = this.bck.width = cw;
		this.cvs.height = this.bck.height = ch;

	
	}

	fadeCanvas(op) {

		return new Promise(res => {

			this.cvs.addEventListener(
				"transitionend",
				() =>
					res(),
				{
					once: true
				}
			);

			this.cvs.style.opacity = op;
		
		});
	
	}

	syncViewport() {

		this.gl.viewport(
			0,
			0,
			this.w,
			this.h
		);
	
	}

	syncUniforms() {

		// force prev even value
		this.scaledPropagation = 2 * Math.floor((this.params.propagationSpeed * this.DISP_SCALE) / this.BASE_SCALE / 2);
		
		// this.scaledPropagation = 2 * Math.floor(this.params.propagationSpeed / this.DISP_SCALE * (Math.max(this.w, this.h) / this.REF_SIZE) / 2);
		
		this.uniformize();
	
	}

	uniformize() {

		this.dirtyPhysics = true;
		this.dirtyRenders = true;
	
	}

	initTextures() {

		const gl = this.gl;

		this.waterTexture1 = this.createWaterTexture();
		this.waterTexture2 = this.createWaterTexture();

		this.backgroundTexture = this.createBackgroundTexture();

		this.framebuffer1 = this.createFrameBuf(this.waterTexture1);
		this.framebuffer2 = this.createFrameBuf(this.waterTexture2);

		this.currentFramebuffer = this.framebuffer1;

		this.currentTexture = this.waterTexture1;
		this.previousTexture = this.waterTexture2;

		gl.bindFramebuffer(
			gl.FRAMEBUFFER,
			null
		);
		gl.bindTexture(
			gl.TEXTURE_2D,
			null
		);
	
	}

	createWaterTexture() {

		const gl = this.gl;
		const texture = gl.createTexture();

		gl.bindTexture(
			gl.TEXTURE_2D,
			texture
		);
		gl.texImage2D(
			gl.TEXTURE_2D,
			0,
			gl.RGBA32F,
			this.w,
			this.h,
			0,
			gl.RGBA,
			gl.FLOAT,
			null
		);
		gl.texParameteri(
			gl.TEXTURE_2D,
			gl.TEXTURE_MIN_FILTER,
			gl.NEAREST
		);
		gl.texParameteri(
			gl.TEXTURE_2D,
			gl.TEXTURE_MAG_FILTER,
			gl.NEAREST
		);
		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
		);

		const waterError = gl.getError();

		if(waterError !== gl.NO_ERROR) {

			throw new Error("create water texture fail");
		
		}

		return texture;
	
	}

	createBackgroundTexture() {

		const gl = this.gl;
		const texture = gl.createTexture();

		gl.bindTexture(
			gl.TEXTURE_2D,
			texture
		);
		gl.texImage2D(
			gl.TEXTURE_2D,
			0,
			gl.RGBA,
			this.w,
			this.h,
			0,
			gl.RGBA,
			gl.UNSIGNED_BYTE,
			null
		);
		gl.texParameteri(
			gl.TEXTURE_2D,
			gl.TEXTURE_MIN_FILTER,
			gl.LINEAR
		);
		gl.texParameteri(
			gl.TEXTURE_2D,
			gl.TEXTURE_MAG_FILTER,
			gl.LINEAR
		);
		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
		);

		return texture;
	
	}

	createFrameBuf(texture) {

		const gl = this.gl;
		const framebuffer = gl.createFramebuffer();

		gl.bindFramebuffer(
			gl.FRAMEBUFFER,
			framebuffer
		);
		gl.framebufferTexture2D(
			gl.FRAMEBUFFER,
			gl.COLOR_ATTACHMENT0,
			gl.TEXTURE_2D,
			texture,
			0
		);

		const frameBufferStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);

		if(frameBufferStatus !== gl.FRAMEBUFFER_COMPLETE) {

			throw new Error(`incomplete framebuffer ${frameBufferStatus}`);
		
		}

		return framebuffer;
	
	}

	syncTextures() {

		this.updateTextures();
		this.updateBackgroundTexture();
	
	}

	handleResize() {

		this.fadeCanvas(0).then(() => this.doResize())
		
	}

	doResize() {

		this.stopPerfCheck();
		this.amplify.mask(true);

		// Promise.resolve().then(() => {

		this.amplify.updateBounds();
		this.syncLiquid();
		this.fadeCanvas(1);
		this.amplify.mask(false);
		this.startPerfCheck(2000);
		
		// });
	
	}

	syncLiquid() {

		this.syncSize();
		this.syncCanvas();
		this.syncUniforms();
		this.syncViewport();
		this.syncTextures();
	
	}

	updateTextures() {

		const gl = this.gl;

		[this.waterTexture1, this.waterTexture2].forEach(texture => {

			gl.bindTexture(
				gl.TEXTURE_2D,
				texture
			);
			gl.texImage2D(
				gl.TEXTURE_2D,
				0,
				gl.RGBA32F,
				this.w,
				this.h,
				0,
				gl.RGBA,
				gl.FLOAT,
				null
			);
		
		});

		[this.framebuffer1, this.framebuffer2].forEach(framebuffer => {

			gl.bindFramebuffer(
				gl.FRAMEBUFFER,
				framebuffer
			);

			if(gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {

				throw new Error("incomplete resized framebuffer");
			
			}
		
		});

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

	initUniforms() {

		const getUniforms = (prgm, prop) =>
			Object.keys(prop)
			.reduce(
				(uniforms, uniform) =>
					({
						...uniforms,
						[uniform]: this.gl.getUniformLocation(
							prgm,
							`u${prop[uniform]}`
						)
					}),
				{}
			);

		this.physics = {
			...getUniforms(
				this.physicsProgram,
				{
					resolution: "Resolution",
					displayScale: "DisplayScale",
					sizeBase: "SizeBase",
					deltaTime: "DeltaTime",
					time: "Time",
					waveSpeed: "WaveSpeed",
					damping: "Damping",
					propagationSpeed: "PropagationSpeed",
					previousState: "PreviousState",
					touchCount: "TouchCount"
				}
			),
			touches: new Array(10)
			.fill({})
			.map((_, i) => {

				const uTouch = `TouchParams[${i}].`;

				return getUniforms(
					this.physicsProgram,
					{
						position: `${uTouch}position`,
						radius: `${uTouch}radius`,
						damping: `${uTouch}damping`,
						strength: `${uTouch}strength`,
					}
				);
			
			})
		};

		this.renders = getUniforms(
			this.renderProgram,
			{
				resolution: "Resolution",
				time: "Time",
				displayScale: "DisplayScale",
				previousState: "PreviousState",
				waterHeight: "WaterHeight",
				backgroundTexture: "BackgroundTexture",
				sunDirection: "SunDirection",
				lightDirection: "LightDirection",
				sunIntensity: "SunIntensity",
				lightIntensity: "LightIntensity",
				sunColor: "SunColor",
				lightColor: "LightColor",
				refraction: "Refraction",
				waterColor: "WaterColor",
				tintStrength: "TintStrength",
				specularStrength: "SpecularStrength",
				roughness: "Roughness",
				fresnelEffect: "FresnelEffect",
				fresnelPower: "FresnelPower",
				specularPower: "SpecularPower",
				skyColor: "SkyColor",
				depthFactor: "DepthFactor",
				atmosphericScatter: "AtmosphericScatter",
				envMapIntensity: "EnvMapIntensity",
				reflectionFresnel: "ReflectionFresnel",
				reflectionBlur: "ReflectionBlur",
				reflectionDistortion: "ReflectionDistortion",
				causticStrength: "CausticStrength",
				causticScale: "CausticScale",
				causticSpeed: "CausticSpeed",
				causticBrightness: "CausticBrightness",
				causticDetail: "CausticDetail",
				waveReflectionStrength: "WaveReflectionStrength",
				mirrorReflectionStrength: "MirrorReflectionStrength",
				velocityReflectionFactor: "VelocityReflectionFactor"
			}
		);
	
	}

	createProgram(vertexSource, fragmentSource) {

		const gl = this.gl;

		const program = gl.createProgram();

		const createShader = (type, source) => {

			const shader = gl.createShader(type);

			gl.shaderSource(
				shader,
				source
			);
			gl.compileShader(shader);

			if(!gl.getShaderParameter(
				shader,
				gl.COMPILE_STATUS
			)) {

				const shaderInfo = gl.getShaderInfoLog(shader);

				gl.deleteShader(shader);

				throw new Error(`compile shader fail ${shaderInfo}`);
			
			}

			return shader;
		
		};

		let vertShader, fragShader;

		try {

			vertShader = createShader(
				gl.VERTEX_SHADER,
				vertexSource
			);
			fragShader = createShader(
				gl.FRAGMENT_SHADER,
				fragmentSource
			);

			gl.attachShader(
				program,
				vertShader
			);
			gl.attachShader(
				program,
				fragShader
			);
			gl.linkProgram(program);

			if(!gl.getProgramParameter(
				program,
				gl.LINK_STATUS
			)) {

				const programInfo = gl.getProgramInfoLog(program);

				throw new Error(`link program fail ${programInfo}`);
			
			}
		
		}
		catch(programError) {

			throw programError;
		
		}
		finally {

			if(vertShader)
				gl.deleteShader(vertShader);

			if(fragShader)
				gl.deleteShader(fragShader);
		
		}

		return program;
	
	}

	physicsStep() {

		const gl = this.gl;

		gl.useProgram(this.physicsProgram);

		gl.bindFramebuffer(
			gl.FRAMEBUFFER,
			this.currentFramebuffer
		);

		if(this.dirtyPhysics) {

			gl.uniform2f(
				this.physics.resolution,
				this.w,
				this.h
			);

			gl.uniform1f(
				this.physics.displayScale,
				this.DISP_SCALE
			);

			gl.uniform1f(
				this.physics.sizeBase,
				this.sizeBase
			);

			gl.uniform1f(
				this.physics.waveSpeed,
				this.params.waveSpeed
			);

			gl.uniform1f(
				this.physics.damping,
				this.params.damping
			);

			gl.uniform1f(
				this.physics.propagationSpeed,
				this.scaledPropagation
			);

			this.dirtyPhysics = false;
		
		}

		gl.uniform1f(
			this.physics.deltaTime,
			this.deltaTime
		);
		gl.uniform1f(
			this.physics.time,
			this.accumulatedTime
		);

		gl.bindBuffer(
			gl.ARRAY_BUFFER,
			this.vertexBuffer
		);

		const positionLoc = gl.getAttribLocation(
			this.physicsProgram,
			"position"
		);

		gl.enableVertexAttribArray(positionLoc);

		gl.vertexAttribPointer(
			positionLoc,
			2,
			gl.FLOAT,
			false,
			0,
			0
		);

		if(this.touchesDirty)
			this.processTouches();

		gl.activeTexture(gl.TEXTURE0);

		gl.bindTexture(
			gl.TEXTURE_2D,
			this.previousTexture
		);

		gl.uniform1i(
			this.physics.previousState,
			0
		);

		gl.drawArrays(
			gl.TRIANGLE_STRIP,
			0,
			4
		);
	
	}

	processTouches() {

		const gl = this.gl;

		const touchCount = Math.min(
			this.pops.size,
			this.MAX_TOUCHES
		);

		gl.uniform1i(
			this.physics.touchCount,
			touchCount
		);

		for(const touch of this.pops.values()) {

			if(touch.index < this.MAX_TOUCHES) {

				const touchUniform = this.physics.touches[touch.index];

				gl.uniform2f(
					touchUniform.position,
					touch.x,
					touch.y
				);
				gl.uniform1f(
					touchUniform.radius,
					touch.touchRadius
				);
				gl.uniform1f(
					touchUniform.damping,
					touch.touchDamping
				);
				gl.uniform1f(
					touchUniform.strength,
					touch.initialImpact
				);
			
			}
		
		}

		this.touchesDirty = false;

	}

	renderStep() {

		const gl = this.gl;

		gl.useProgram(this.renderProgram);

		gl.bindFramebuffer(
			gl.FRAMEBUFFER,
			null
		);

		gl.clearColor(
			0,
			0,
			0,
			1
		);

		gl.clear(gl.COLOR_BUFFER_BIT);

		gl.bindBuffer(
			gl.ARRAY_BUFFER,
			this.vertexBuffer
		);

		const positionLoc = gl.getAttribLocation(
			this.renderProgram,
			"position"
		);

		gl.enableVertexAttribArray(positionLoc);

		gl.vertexAttribPointer(
			positionLoc,
			2,
			gl.FLOAT,
			false,
			0,
			0
		);

		if(this.dirtyRenders)
			this.updateRenderUniforms();

		gl.uniform1f(
			this.renders.time,
			this.accumulatedTime
		);

		// batch texture bindings
		gl.activeTexture(gl.TEXTURE0);

		gl.bindTexture(
			gl.TEXTURE_2D,
			this.currentTexture
		);

		gl.uniform1i(
			this.renders.waterHeight,
			0
		);

		gl.activeTexture(gl.TEXTURE1);

		gl.bindTexture(
			gl.TEXTURE_2D,
			this.backgroundTexture
		);

		gl.uniform1i(
			this.renders.backgroundTexture,
			1
		);

		gl.activeTexture(gl.TEXTURE2);

		gl.bindTexture(
			gl.TEXTURE_2D,
			this.previousTexture
		);

		gl.uniform1i(
			this.renders.previousState,
			2
		);

		gl.drawArrays(
			gl.TRIANGLE_STRIP,
			0,
			4
		);

		const renderError = gl.getError();

		if(renderError !== gl.NO_ERROR) {

			throw new Error("render step error " + renderError);
		
		}
	
	}

	updateRenderUniforms() {

		const gl = this.gl;

		gl.uniform2fv(
			this.renders.resolution,
			[this.w, this.h]
		);

		gl.uniform1f(
			this.renders.displayScale,
			this.DISP_SCALE
		);

		Object.keys(this.params)
		.forEach(prop => {

			const value = this.params[prop];
			const rendr = this.renders[prop];

			if(typeof value === "number")
				gl.uniform1f(
					rendr,
					value
				);
			else if(value instanceof Array)
				gl.uniform3fv(
					rendr,
					value
				);

		});

		this.dirtyRenders = false;
	
	}

	updateBackgroundTexture() {

		if(!this.image?.complete)
			return;
	
		this.btx.fillStyle = "#000000";
		this.btx.fillRect(
			0,
			0,
			this.w,
			this.h
		);

		// flip coordinate system
		this.btx.save();

		this.btx.translate(
			0,
			this.h
		);

		this.btx.scale(
			1,
			-1
		);

		const imgW = this.image.naturalWidth;
		const imgH = this.image.naturalHeight;
		const screenAspect = this.w / this.h;
		const imageAspect = imgW / imgH;

		let sx, sy, sw, sh;

		if(screenAspect > imageAspect) {

			sw = imgW;
			sh = Math.round(imgW / screenAspect);
			sx = 0;
			sy = Math.round((imgH - sh) / 2);
		
		}
		else {

			sh = imgH;
			sw = Math.round(imgH * screenAspect);
			sy = 0;
			sx = Math.round((imgW - sw) / 2);
		
		}

		this.btx.drawImage(
			this.image,
			sx,
			sy,
			sw,
			sh,
			0,
			0,
			this.w,
			this.h
		);

		this.fish.forEach(fish => {

			const {
				x, y, w, h
			} = fish.c();

			this.btx.globalAlpha = 0.55;
			this.btx.shadowColor = "#000000";
			this.btx.shadowBlur = 16;

			this.btx.drawImage(
				fish.i,
				x,
				y,
				w,
				h
			);
		
		});

		this.btx.restore();

		// update texture
		const gl = this.gl;

		gl.bindTexture(
			gl.TEXTURE_2D,
			this.backgroundTexture
		);

		gl.texImage2D(
			gl.TEXTURE_2D,
			0,
			gl.RGBA,
			gl.RGBA,
			gl.UNSIGNED_BYTE,
			this.bck
		);
	
	}

	toggleRender() {

		if(this.isRunning)
			this.stopRender();
		else
			this.startRender();
	
	}

	startRender() {

		if(this.isRunning)
			return;

		let perfCheck = this.SCALE_TIME;

		if(!this.lastFrameTime) {

			perfCheck *= 2;
		
		}

		this.isRunning = true;
		this.amplify.toggle("flow");
		this.lastFrameTime = performance.now();
		this.startPerfCheck(perfCheck);
		this.nextFrame();
	
	}

	stopRender() {

		if(!this.isRunning)
			return;

		this.isRunning = false;
		cancelAnimationFrame(this.loop);
		this.stopPerfCheck();
		this.amplify.toggle(
			"flow",
			false
		);
	
	}

	nextFrame() {

		this.loop = requestAnimationFrame(hrts =>
			this.renderLoop(hrts));
	
	}

	renderLoop(currentTime) {

		this.accumulatedTime += this.deltaTime;
		this.nextFrame();

		this.physicsStep();
		this.swapBuffers();
		this.renderStep();

		this.checkPerfs(currentTime);
	
	}

	startPerfCheck(chk = this.SCALE_TIME) {

		if(this.PERF_CHECK)
			return;
	
		this.PERF_CHECK = true;

		this.frameTimeHistory = new Array(this.FRAME_SIZE)
		.fill(16.67); // ~60fps default
		this.frameTimeSum = this.FRAME_SIZE * 16.67;
		this.frameTimeIndex = 0;
	
		this.notBefore = this.lastFrameTime + chk;
	
	}

	stopPerfCheck() {

		if(!this.PERF_CHECK)
			return;

		this.renderInfo("--");
		this.PERF_CHECK = false;
		this.LOOP_COUNT = 0;
		this.currentFPS = 0;
	
	}

	checkPerfs(currentTime) {

		const frameTime = currentTime - this.lastFrameTime;

		this.lastFrameTime = currentTime;
	
		this.frameTimeHistory[this.frameTimeIndex] = frameTime;
		this.frameTimeIndex = (this.frameTimeIndex + 1) & (this.frameTimeHistory.length - 1);
	
		this.frameTimeSum = this.frameTimeSum - this.frameTimeHistory[this.frameTimeIndex] + frameTime;
		const avgFrameTime = this.frameTimeSum / this.frameTimeHistory.length;
		
		const updatedFPS = Math.round(1000 / avgFrameTime);
	
		if(updatedFPS !== this.currentFPS) {

			this.currentFPS = updatedFPS;
			/*this.renderInfo(("" + updatedFPS).padStart(
				2,
				"0"
			));*/
		
		}
	
		if(!this.PERF_CHECK || currentTime < this.notBefore)
			return;
	
		if(currentTime - this.lastScaleCheck >= this.SCALE_TIME) {

			this.lastScaleCheck = currentTime;
			this.LOOP_COUNT++;
	
			if(this.LOOP_COUNT >= this.LOOPS_TO_SCALE) {

				this.LOOP_COUNT = 0;
	
				if(this.currentFPS < this.TARGET_FPS) {

					this.downScale();
				
				}
				else if(
					this.ENABLE_UPSCALE
					&& this.DISP_SCALE < this.DOWN_FROM
					&& this.DISP_SCALE < this.MAX_SCALE
				) {

					this.upScale();
				
				}
			
			}
		
		}
	
	}

	downScale() {

		if(this.DISP_SCALE === this.MIN_SCALE)
			return;

		this.DOWN_FROM = this.DISP_SCALE - this.SCALE_STEP;

		this.setDisplayScale(this.DISP_SCALE - this.SCALE_STEP);
	
	}

	upScale() {

		if(this.DISP_SCALE == this.MAX_SCALE)
			return;

		this.setDisplayScale(this.DISP_SCALE + this.SCALE_STEP);
	
	}

	setDisplayScale(scale) {

		if(scale === this.DISP_SCALE)
			return;

		this.stopPerfCheck();

		this.DISP_SCALE = Math.max(
			this.MIN_SCALE,
			Math.min(
				this.MAX_SCALE,
				scale
			)
		);
		
		this.syncLiquid();
		this.startPerfCheck();
	
	}

	renderText(str) {

		window.requestAnimationFrame(() =>
			(this.infos.textContent = str));
	
	}

	renderInfo(fps) {

		this.infos.textContent = (DEBUG ? this.ww + "x" + this.wh + "\n" : "") + "x" + this.DISP_SCALE.toString() + " " + fps;
	
	}

	visibleChange() {

		this.isVisible = document.visibilityState === "visible";

		this.activeChange();
	
	}

	handleBlur() {

		this.focusChange(false);
	
	}

	handleFocus() {

		this.focusChange(true);
	
	}

	focusChange(focused) {

		this.isFocused = focused;
		this.activeChange();
	
	}

	activeChange() {

		if(this.isActive)
			this.startRender();
		else
			this.stopRender();
	
	}

	swapBuffers() {

		[this.currentFramebuffer, this.currentTexture, this.previousTexture] = [
			this.currentFramebuffer === this.framebuffer1 ? this.framebuffer2 : this.framebuffer1,
			this.currentTexture === this.waterTexture1 ? this.waterTexture2 : this.waterTexture1,
			this.previousTexture === this.waterTexture1 ? this.waterTexture2 : this.waterTexture1
		];
	
	}

	resetWater() {

		const gl = this.gl;
		const flat = new Float32Array(this.w * this.h * 4);

		for(let i = 0; i < flat.length; i += 4) {

			flat[i] = 0.0; // height
			flat[i + 1] = 0.0; // prev height
			flat[i + 2] = 0.0; // velocity
			flat[i + 3] = 1.0; // alpha
		
		}

		[this.waterTexture1, this.waterTexture2].forEach(texture => {

			gl.bindTexture(
				gl.TEXTURE_2D,
				texture
			);

			gl.texImage2D(
				gl.TEXTURE_2D,
				0,
				gl.RGBA32F,
				this.w,
				this.h,
				0,
				gl.RGBA,
				gl.FLOAT,
				flat
			);
		
		});

		this.currentFramebuffer = this.framebuffer1;
		this.currentTexture = this.waterTexture1;
		this.previousTexture = this.waterTexture2;
	
	}

	setupEvents() {

		document.addEventListener(
			"visibilitychange",
			() =>
				this.visibleChange()
		);
		
		Object.entries({
			"blur": this.handleBlur,
			"focus": this.handleFocus,
			// "resize": this.handleResize
		})
		.forEach(([evtName, evtCall]) =>
			window.addEventListener(
				evtName,
				evtCall.bind(this)
			));

		Object.entries({
			"touchstart": this.doNotTouch,
			"pointerdown": this.handlePointerStart,
			"pointermove": this.handlePointerMove,
			"pointerup": this.handlePointerEnd,
			"pointerout": this.handlePointerEnd,
			"pointercancel": this.handlePointerEnd
		})
		.forEach(([evtName, evtCall]) =>
			this.cvs.addEventListener(
				evtName,
				evtCall.bind(this)
			));

		const obs = new ResizeObserver((entries) => {

			if(this.isLiquid) {

				this.handleResize();

			}
			
		});

		obs.observe(this.cvs);
	
	}

	// pointers handling

	genPopId() {

		return Math.random()
		.toString(36)
		.slice(-6);
	
	}

	getPointerPos(evt) {

		// const cx = evt.clientX - this.wrap.offsetLeft;
		// const cy = evt.clientY - this.wrap.offsetTop;

		const cx = evt.offsetX;
		const cy = evt.offsetY;

		return {
			x: cx * this.DISP_SCALE,
			y: this.h - cy * this.DISP_SCALE,
			rx: cx,
			ry: cy
		};
	
	}

	pointOptions() {

		return {
			touchRadius: this.params.touchRadius,
			touchDamping: this.params.touchDamping,
			initialImpact: this.params.initialImpact,
		};
	
	}

	doNotTouch(evt) {

		evt.preventDefault();
	
	}

	updatePointerPosition(pop) {

		this.touchesDirty = true;

	}

	handlePointerStart(evt) {

		const pId = evt.pointerId;

		if(evt.pointerType === "mouse" && this.pops.has(pId)) {
			return;
		}

		if(!this.isTouched) {

			this.isTouched = true;
		
		}

		this.cvs.setPointerCapture(pId);
		const pos = this.getPointerPos(evt);

		const touch = {
			x: pos.x,
			y: pos.y,
			px: pos.x,
			py: pos.y,
			rx: pos.rx,
			ry: pos.ry,
			index: this.pops.size,
			...this.pointOptions()
		};

		this.pops.set(
			pId,
			touch
		);

		this.updatePointerPosition(touch);

	}

	handlePointerMove(evt) {

		const pId = evt.pointerId;

		if(this.pops.has(pId)) {

			const pop = this.pops.get(pId);
			const pos = this.getPointerPos(evt);

			pop.px = pop.x;
			pop.py = pop.y;
			pop.x = pos.x;
			pop.y = pos.y;
			pop.rx = pos.rx;
			pop.ry = pos.ry;

			this.updatePointerPosition(pop);

			this.hitParams();
		
		}
		else if(evt.pointerType === "mouse") {
			this.handlePointerStart(evt);
		}

	}

	handlePointerEnd(evt) {

		const pId = evt.pointerId;

		if(this.pops.has(pId)) {

			const pop = this.pops.get(pId);

			this.cvs.releasePointerCapture(pId);
			this.updatePointerPosition(pop);
			this.pops.delete(pId);
			this.hitParams();
		
		}

	}

	hitParams() {

		if(this.amplify.pop) {

			const hit = Array.from(this.pops.values())
			.some(pop =>
				this.hitPoint(
					pop.rx,
					pop.ry,
					this.amplify.bnd
				));

			if(hit !== this.hits) {

				this.hits = hit;
				clearTimeout(this.hitting);
				this.hitting = setTimeout(
					() =>
						this.amplify.mask(this.hits),
					100
				);
			
			}
		
		}
	
	}

	hitPoint(px, py, bnd) {

		return (
			px >= bnd.x
			&& px <= bnd.x + bnd.w
			&& py >= bnd.y
			&& py <= bnd.y + bnd.h
		);
	
	}

	// getters

	get w() {

		return this.cvs.width;
	
	}

	get h() {

		return this.cvs.height;
	
	}

	get isActive() {

		return (this.isVisible || !this.PAUSE_HIDE) && (this.isFocused || !this.PAUSE_BLUR);
	
	}

}

(
	() => {

		console.log(atob("aHR0cHM6Ly9uaWNvcHIuZnIvcmlwcGxlczM"));

		window.addEventListener(
			"error",
			evt => {

				handleError(evt.error);

				return false;
	
			}
		);

		window.addEventListener(
			"unhandledrejection",
			evt => {

				handleError(evt.reason);

				return false;
	
			}
		);

		try {

			const rippler = document.querySelector("#ripples3");

			const liquify = new Liquid(rippler);
			const amplify = new NoParams(liquify);

			liquify
			.flow(
				amplify,
				amplify.hashParams()
			)
			.catch(err =>
				handleError(err));
	
		}
		catch(err) {

			handleError(err);
	
		}

	}
)();