Seascape
Liquid layers
Rain effect
Fluid simulation
jQuery ripples
Basic fluid
<div id="ripples3">
<span id="fps">-- --</span>
</div>
:root {
--anim: 0.37s;
--fast: 0.23s;
--ease: ease-in-out;
--back: rgba(255, 255, 255, 0.65);
--bcck: rgba(255, 255, 255, 0.75);
--blck: rgba(255, 255, 255, 0.85);
--col: rgba(0, 0, 0, 1);
--hil: rgba(0, 128, 255, 1);
--rad: 4px;
--pad: .2em;
--spc: .3em;
--icn: 36px;
}
@media (prefers-color-scheme: dark) {
:root {
--back: rgba(0, 0, 0, 0.6);
--bcck: rgba(0, 0, 0, 0.7);
--blck: rgba(0, 0, 0, 0.85);
--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;
padding: .1em var(--pad) 0em var(--pad);
font-size: .8em;
pointer-events: none;
background: var(--back);
}
/* MENU BTN */
#ripples3 .gear {
pointer-events: all;
position: absolute;
left: 100%;
/* top: 0; */
padding-top: var(--spc);
padding-left: var(--spc);
/* left: calc(100% + var(--spc)); */
/* top: var(--spc); */
width: calc(var(--spc) + var(--icn));
height: calc(var(--spc) + var(--icn));
z-index: 10;
cursor: pointer;
background-color: var(--bcck);
-webkit-mask-image: url("../assets/gear.png");
mask-image: url("../assets/gear.png");
mask-origin: content-box;
mask-repeat: no-repeat;
mask-size: contain;
/* box-shadow: 1px 1px 2px var(--col); */
transition: padding-left var(--anim) var(--ease);
}
.gear.up {
/* transform-origin: 0px 0px; */
/* animation: gearup ease-in-out 1.3s; */
/* transform: rotate(360deg); */
}
@keyframes gearup {
0% {
}
30% {
transform: scale(1.6);
background-color: #007bff;
}
85% {
transform: scale(1.6);
}
100% {
}
}
/* MENU PANE */
.params {
position: absolute;
z-index: 20;
/* left: 0px; */
top: 0px;
width: 256px;
height: 100%;
pointer-events: none;
padding: var(--pad);
box-sizing: border-box;
/* transform: translateX(-100%); */
/* transition: all var(--anim) var(--ease); */
transition: transform var(--anim) var(--ease), opacity var(--anim) var(--ease), visibility var(--anim) var(--ease);
will-change: transform;
}
.params.exp {
height: 100%;
}
.params.pop {
transform: translateX(0px) !important;
}
.params.pop .gear {
padding-left: 0px;
}
.pwrp {
pointer-events: all;
max-height: 100%;
/* opacity: 0; */
display: flex;
flex-direction: column;
overflow: scroll;
gap: var(--pad);
color: var(--col);
font-size: 1.15em;
/* transition: opacity var(--anim) var(--ease); */
}
.pwrp.nobar {
/* Internet Explorer 10+ */
-ms-overflow-style: none;
/* Firefox */
scrollbar-width: none;
}
.pwrp::-webkit-scrollbar {
/* Safari and Chrome */
display: none;
}
.params.pop .pwrp {
opacity: 1;
}
.params.msk {
opacity: 0;
visibility: hidden;
}
/* ALL CTRLS */
.preset,
.toggle,
.action,
.grp,
.prop {
cursor: pointer;
outline: none;
}
/* BTNS WRAP */
.switch {
display: grid;
grid-template-rows: 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
column-gap: var(--pad);
row-gap: var(--spc);
background-color: var(--blck);
border-radius: var(--rad);
padding: var(--pad);
font-size: 1.2em;
}
/* PRESETS */
.preset {
display: flex;
justify-content: space-between;
align-items: center;
height: 2em;
background-color: var(--blck);
border-radius: var(--rad);
padding: var(--pad) 0em;
}
.prename {
font-size: 1.2em;
}
.preprev,
.prenext {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-sizing: border-box;
height: 100%;
width: 32px;
}
/* ACTION */
.action {
text-align: center;
}
.action:focus {
animation: feed 0.8s var(--ease);
}
.action:active {
-webkit-animation: none;
animation: none;
}
@keyframes feed {
13% {
color: var(--hil);
}
}
/* TOGGLE */
.toggle {
text-align: center;
transition: all var(--fast) var(--ease);
}
.toggle.toggled {
color: var(--hil);
}
/* GROUPS */
.group {
border-radius: var(--rad);
}
.group.exp {
}
.group .grp {
background-color: var(--back);
padding: 0.22em 0.33em;
border-top-left-radius: var(--rad);
border-top-right-radius: var(--rad);
cursor: pointer;
user-select: none;
transition: all var(--fast) var(--ease);
}
.group:not(.exp) .grp {
border-bottom-left-radius: var(--rad);
border-bottom-right-radius: var(--rad);
}
.group.exp .grp {
background-color: var(--blck);
}
.group .wrp {
display: grid;
grid-template-rows: 0fr;
transition: all var(--anim) var(--ease);
}
.group.exp .wrp {
grid-template-rows: 1fr;
}
.group .cnt {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
opacity: 0;
background-color: var(--back);
border-bottom-left-radius: var(--rad);
border-bottom-right-radius: var(--rad);
transition: all var(--fast) var(--ease);
}
.group.exp .cnt {
opacity: 1;
background-color: var(--bcck);
}
/* PROPS */
.prop {
position: relative;
display: flex;
gap: var(--spc);
align-items: center;
justify-content: space-between;
padding: 0.22em 0.33em;
/* WTF MOBILE CHROME POINTER CAPTURE */
touch-action: none;
}
.prop .valt,
.prop .val {
z-index: 10;
/* transition: all var(--anim) var(--ease); */
pointer-events: none;
}
.prop.down .valt {
color: var(--hil);
}
.prop.down .val {
/* transform: scale(1.2, 1.2); */
}
.vbar {
position: absolute;
bottom: 2px;
left: 0px;
/* height: 82%; */
/* background: var(--back); */
pointer-events: none;
height: 2px;
background: #3498db;
}
/* GITHUB LOGO */
#github {
position: absolute;
right: var(--spc);
top: var(--spc);
z-index: 10;
}
#github div {
width: var(--icn);
height: var(--icn);
}
/**
* 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) {
if(DEBUG)
console.log(
"load preset",
preset
);
this.renderText("loading");
await this.fadeCanvas(0);
this.stopRender();
this.resetWater();
if(preset.img && preset.img != this.img) {
if(DEBUG)
console.log("change background");
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");
if(DEBUG)
console.log("flow");
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() {
if(DEBUG)
console.log(
"load background",
this.img
);
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() {
if(DEBUG)
console.log("init webgl");
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() {
if(DEBUG)
console.log("init shaders");
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);
}`;
if(DEBUG)
console.log("physics program");
this.physicsProgram = this.createProgram(
VERTEX_SHADER,
PHYSICS_SHADER
);
if(DEBUG)
console.log("render program");
this.renderProgram = this.createProgram(
VERTEX_SHADER,
RENDER_SHADER
);
}
initBuffers() {
if(DEBUG)
console.log("init buffers");
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;
if(DEBUG)
console.log(
"screen",
this.ww + "x" + this.wh,
"base",
this.sizeBase
);
}
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;
if(DEBUG)
console.log(
"canvas",
cw + "x" + 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() {
console.log("sync", this.params.propagationSpeed);
// 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() {
if(DEBUG)
console.log("init textures");
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() {
if(DEBUG)
console.log("sync textures");
this.updateTextures();
this.updateBackgroundTexture();
}
handleResize() {
console.log("handle resize");
this.fadeCanvas(0).then(() => this.doResize())
}
doResize() {
if(DEBUG)
console.log("window resize");
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() {
if(DEBUG)
console.log("update textures");
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() {
if(DEBUG)
console.log("init uniforms");
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;
if(DEBUG)
console.log("start loop");
let perfCheck = this.SCALE_TIME;
if(!this.lastFrameTime) {
if(DEBUG)
console.log("first loop");
perfCheck *= 2;
}
this.isRunning = true;
this.amplify.toggle("flow");
this.lastFrameTime = performance.now();
this.startPerfCheck(perfCheck);
this.nextFrame();
}
stopRender() {
if(!this.isRunning)
return;
if(DEBUG)
console.log("stop loop");
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;
if(DEBUG)
console.log(
"perf run",
chk
);
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;
if(DEBUG)
console.log("perf stop");
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;
if(DEBUG)
console.log("downscale");
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;
if(DEBUG)
console.log("upscale");
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";
if(DEBUG)
console.log(this.isVisible ? "visible" : "hidden");
this.activeChange();
}
handleBlur() {
this.focusChange(false);
}
handleFocus() {
this.focusChange(true);
}
focusChange(focused) {
if(DEBUG)
console.log(focused ? "focus" : "blur");
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() {
if(DEBUG)
console.log("reset water");
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() {
if(DEBUG)
console.log("setup events");
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(!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();
}
}
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);
}
// utils
randRange(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// effects
dropIt(x, y, o = 0) {
x ||= this.randRange(
this.w * 0.3,
this.w * 0.7
);
y ||= this.randRange(
this.h * 0.3,
this.h * 0.7
);
const dropId = "drop_" + this.genPopId();
const point = {
x,
y,
px: x,
py: y,
index: this.pops.size,
...this.pointOptions(),
touchRadius: 0.012,
initialImpact: 0.12
};
this.pops.set(
dropId,
point
);
this.updatePointerPosition(point);
setTimeout(
() => {
this.pops.delete(dropId);
this.touchesDirty = true;
},
o + Math.round(Math.random() * 100)
);
}
curveIt() {
const centerX = this.w * 0.5;
const centerY = this.h * 0.5;
const offCenterX = 0.3;
const offCenterY = 0.3;
const startX = centerX - this.w * offCenterX;
const startY = centerY + (Math.random() - 0.5) * this.h * offCenterY;
const endX = centerX + this.w * offCenterX;
const endY = centerY + (Math.random() - 0.5) * this.h * offCenterY;
const ctrlX = centerX + (Math.random() - 0.5) * this.w * 0.2;
const ctrlY = centerY + (Math.random() - 0.5) * this.h * 0.4;
const touchId = "curve_" + this.genPopId();
const point = {
x: startX,
y: startY,
px: startX,
py: startY,
index: this.pops.size,
...this.pointOptions()
};
this.pops.set(
touchId,
point
);
this.updatePointerPosition(point);
const startTime = performance.now();
const duration = 1234;
const animate = () => {
const elapsed = performance.now() - startTime;
const progress = elapsed / duration;
if(progress >= 1) {
this.pops.delete(touchId);
this.touchesDirty = true;
return;
}
point.px = point.x;
point.py = point.y;
const t = progress;
const invT = 1 - t;
point.x = invT * invT * startX + 2 * invT * t * ctrlX + t * t * endX;
point.y = invT * invT * startY + 2 * invT * t * ctrlY + t * t * endY;
this.updatePointerPosition(point);
window.requestAnimationFrame(animate);
};
animate();
}
circleIt() {
const centerX = this.w * 0.5;
const centerY = this.h * 0.5;
const radius = Math.min(
this.w,
this.h
) * 0.05;
const speed = 0.005;
const touchId = "circle_" + this.genPopId();
const startAngle = Math.random() * Math.PI * 2;
const point = {
x: centerX + Math.cos(startAngle) * radius,
y: centerY + Math.sin(startAngle) * radius,
px: centerX + Math.cos(startAngle) * radius,
py: centerY + Math.sin(startAngle) * radius,
index: this.pops.size,
...this.pointOptions(),
touchRadius: 0.02
};
this.pops.set(
touchId,
point
);
this.updatePointerPosition(point);
const startTime = performance.now();
const animate = () => {
if(!this.pops.has(touchId))
return;
const elapsed = performance.now() - startTime;
const angle = startAngle + elapsed * speed;
point.px = point.x;
point.py = point.y;
point.x = centerX + Math.cos(angle) * radius;
point.y = centerY + Math.sin(angle) * radius;
this.updatePointerPosition(point);
requestAnimationFrame(animate);
};
animate();
}
snailIt() {
const centerX = this.w * 0.5;
const centerY = this.h * 0.5;
const startX = centerX + (Math.random() - 0.5) * this.w * 0.3;
const startY = centerY + (Math.random() - 0.5) * this.h * 0.3;
const touchId = "snail_" + this.genPopId();
const point = {
x: startX,
y: startY,
px: startX,
py: startY,
index: this.pops.size,
...this.pointOptions(),
touchRadius: 0.02,
};
this.pops.set(
touchId,
point
);
this.updatePointerPosition(point);
const duration = 2500;
const startTime = performance.now();
const radiusX = this.w * 0.15;
const radiusY = this.h * 0.15;
const loops = 1.5 + Math.random();
const drift = {
x: (Math.random() - 0.5) * 0.3,
y: (Math.random() - 0.5) * 0.3
};
const animate = () => {
const elapsed = performance.now() - startTime;
const progress = elapsed / duration;
if(progress >= 1) {
this.pops.delete(touchId);
this.touchesDirty = true;
return;
}
point.px = point.x;
point.py = point.y;
const angle = progress * Math.PI * 2 * loops;
point.x = startX + Math.cos(angle) * radiusX + drift.x * elapsed;
point.y = startY + Math.sin(angle) * radiusY + drift.y * elapsed;
this.updatePointerPosition(point);
requestAnimationFrame(animate);
};
animate();
}
// rain
toggleRain() {
if(this.isRaining)
this.stopRain();
else
this.startRain();
}
startRain() {
this.isRaining = true;
this.lastRainDrop = 0;
this.activeRaindrops = new Set();
this.animateRain();
this.amplify.toggle("rain");
}
animateRain() {
if(!this.isRaining)
return;
const currentTime = performance.now();
if(
currentTime - this.lastRainDrop > this.rainDropDelay
&& this.activeRaindrops.size < this.maxRaindrops
&& this.pops.size < 8
) {
this.createRaindrop();
this.lastRainDrop = currentTime;
}
requestAnimationFrame(() =>
this.animateRain());
}
createRaindrop() {
const x = Math.random() * this.w;
const y = Math.random() * this.h;
const dropId = "rain_" + this.genPopId();
const impactPoint = {
x,
y,
px: x,
py: y,
index: this.pops.size,
...this.pointOptions(),
touchRadius: 0.02,
initialImpact: 0.24
};
this.pops.set(
dropId + "_impact",
impactPoint
);
this.updatePointerPosition(impactPoint);
this.activeRaindrops.add(dropId);
setTimeout(
() => {
this.pops.delete(dropId + "_impact");
this.touchesDirty = true;
this.activeRaindrops.delete(dropId);
},
80
);
}
stopRain() {
this.isRaining = false;
this.amplify.toggle(
"rain",
false
);
}
// swirls
createSwirl(x, y, radius, params, dur, startAngle, ease) {
const popId = this.genPopId();
const startTime = performance.now();
const initialX = x + Math.cos(startAngle) * radius;
const initialY = y + Math.sin(startAngle) * radius;
const popped = {
x: initialX,
y: initialY,
px: initialX,
py: initialY,
index: this.pops.size,
...this.pointOptions(),
...params
};
this.pops.set(
popId,
popped
);
this.updatePointerPosition(popped);
const updateSwirl = () => {
const elapsed = performance.now() - startTime;
const progress = elapsed / dur;
if(progress < 1) {
const angle = startAngle + elapsed * 0.001 * popped.speed;
const easedProgress = ease(
elapsed,
0,
1,
dur
);
const currentRadius = radius * (1 - easedProgress);
if(this.pops.has(popId)) {
const pop = this.pops.get(popId);
pop.px = pop.x;
pop.py = pop.y;
pop.x = x + Math.cos(angle) * currentRadius;
pop.y = y + Math.sin(angle) * currentRadius;
this.updatePointerPosition(pop);
requestAnimationFrame(updateSwirl);
}
}
else {
this.pops.delete(popId);
this.touchesDirty = true;
}
};
requestAnimationFrame(updateSwirl);
}
createSwirls() {
if(DEBUG)
console.log("swirls");
let numSwirls = 3,
radius = 0.3;
for(let i = 0; i < numSwirls; i++) {
this.createSwirl(
this.w / 2,
this.h / 2,
Math.min(
this.w,
this.h
) * radius,
{
// touchRadius: 0.02,
// touchDamping: 0.995,
// initialImpact: 0.32,
speed: 2.5
},
3333,
2 * Math.PI / numSwirls * i,
this.sineIn
);
}
}
sineIn(t, b, c, d) {
return -c * Math.cos((t / d) * (Math.PI / 2)) + c + b;
}
// auto
toggleAutoTouch() {
if(this.isAuto)
this.stopAutoTouch();
else
this.startAutoTouch();
}
startAutoTouch() {
if(this.isAuto)
return;
this.isAuto = true;
const touchId = "auto";
const config = {
freqX: 0.15,
freqY: 0.23,
phaseX: 0,
phaseY: Math.PI / 4,
speedMin: 3,
speedMax: 6,
speedChangeRate: 0.004,
speedChangeInterval: 3000,
centerX: this.w * 0.5,
centerY: this.h * 0.5,
radiusX: this.w * 0.35,
radiusY: this.h * 0.35
};
let time = 0;
let currentSpeed = 2.5;
let targetSpeed = 2.5;
let lastSpeedChange = 0;
const point = {
x: config.centerX,
y: config.centerY,
px: config.centerX,
py: config.centerY,
index: this.pops.size,
...this.pointOptions()
};
this.pops.set(
touchId,
point
);
this.updatePointerPosition(point);
const updateSpeed = timestamp => {
if(timestamp - lastSpeedChange > config.speedChangeInterval) {
targetSpeed = config.speedMin + Math.random() * (config.speedMax - config.speedMin);
lastSpeedChange = timestamp;
}
currentSpeed += (targetSpeed - currentSpeed) * config.speedChangeRate;
return currentSpeed;
};
const animate = () => {
if(!this.isAuto) {
this.pops.delete(touchId);
this.touchesDirty = true;
return;
}
point.px = point.x;
point.py = point.y;
const speed = updateSpeed(performance.now());
time += 0.016 * speed;
point.x = config.centerX + config.radiusX * Math.sin(config.freqX * time + config.phaseX);
point.y = config.centerY + config.radiusY * Math.sin(config.freqY * time + config.phaseY);
this.updatePointerPosition(point);
requestAnimationFrame(animate);
};
animate();
this.amplify.toggle("point");
}
stopAutoTouch() {
if(!this.isAuto)
return;
if(DEBUG)
console.log("stop auto touch");
this.isAuto = false;
this.amplify.toggle(
"point",
this.isAuto
);
}
// test
toggleTest() {
if(this.isTest)
this.stopTest();
else
this.startTest();
}
startTest() {
if(this.isTest)
return;
this.isTest = true;
this.testLoop = setInterval(
() => {
const x = this.w / 2;
const y = this.h / 2;
const dropId = "test_" + this.genPopId();
const point = {
x,
y,
px: x,
py: y,
index: this.pops.size,
...this.pointOptions()
};
this.pops.set(
dropId,
point
);
this.updatePointerPosition(point);
setTimeout(
() => {
this.pops.delete(dropId);
this.touchesDirty = true;
},
256
);
},
500
);
this.amplify.toggle("test");
}
stopTest() {
if(!this.isTest)
return;
if(DEBUG)
console.log("stop test");
clearInterval(this.testLoop);
this.isTest = false;
this.amplify.toggle(
"test",
this.isTest
);
}
// audio
toggleAudio() {
if(this.isAudio)
this.stopAudio();
else
this.startAudio();
}
async startAudio() {
if(this.isAudio)
return;
try {
// Capture system audio
const systemStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
});
const audioStream = new MediaStream();
for(const track of systemStream.getAudioTracks()) {
audioStream.addTrack(track);
}
// Stop video tracks
for(const track of systemStream.getVideoTracks()) {
track.stop();
}
this.audioStream = audioStream;
this.audioContext = new AudioContext();
this.audioSource = this.audioContext.createMediaStreamSource(this.audioStream);
this.audioAnalyser = this.audioContext.createAnalyser();
this.audioAnalyser.fftSize = 512;
this.audioAnalyser.smoothingTimeConstant = 0.1;
this.audioFreqData = new Uint8Array(this.audioAnalyser.frequencyBinCount);
this.audioSource.connect(this.audioAnalyser);
this.isAudio = true;
this.audioVisualize();
this.amplify.toggle("audio");
}
catch(error) {
console.error(
"Audio capture failed:",
error
);
this.isAudio = false;
}
}
stopAudio() {
if(!this.isAudio)
return;
if(DEBUG)
console.log("stop audio");
// Cancel animation
if(this.audioAnimFrame) {
cancelAnimationFrame(this.audioAnimFrame);
this.audioAnimFrame = null;
}
// Clean up audio resources
if(this.audioSource) {
this.audioSource.disconnect();
this.audioSource = null;
}
if(this.audioAnalyser) {
this.audioAnalyser.disconnect();
this.audioAnalyser = null;
}
if(this.audioStream) {
this.audioStream.getTracks()
.forEach(track =>
track.stop());
this.audioStream = null;
}
if(this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.audioFreqData = null;
this.isAudio = false;
this.amplify.toggle(
"audio",
false
);
}
audioVisualize() {
if(!this.isAudio || !this.audioAnalyser)
return;
this.audioAnalyser.getByteFrequencyData(this.audioFreqData);
this.audioAnimFrame = requestAnimationFrame(() => this.audioVisualize());
}
}
(
() => {
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()
)
.then(() => {
if(DEBUG)
console.log("flowing");
lazy(
() => {
// liquify.snailIt();
// liquify.circleIt();
// if(!liquify.isTouched) liquify.dropIt();
// liquify.dropIt();
/*lazy(
() => {
if(!liquify.isTouched) liquify.curveIt();
},
1000
);*/
},
500
);
})
.catch(err =>
handleError(err));
}
catch(err) {
handleError(err);
}
}
)();