/** * Raindrop fragment shader, being used by PIXI.js in the EffectCanvas object * {{uniforms: {time: {type: string, value: number}, iResolution: {type: string, value: [*]}}, fragment: string}} */ const shaderData = { uniforms: { iResolution: { type: 'v2', value: [ window.innerWidth, window.innerHeight, ], }, vTextureSize: { type: 'v2', value: [ 0, 0, ], }, uTextureForeground: { type: 'sampler2D', value: null, }, uTextureBackground: { type: 'sampler2D', value: null, }, uTextureDropShine: { type: 'sampler2D', value: null, }, }, fragment: ` precision mediump float; //Textures uniform sampler2D uTextureForeground; uniform sampler2D uTextureBackground; uniform sampler2D uTextureDropShine; //Canvas image data uniform sampler2D uSampler; //The resolution and coordinates of the current pixel uniform vec2 iResolution; uniform vec2 vTextureSize; varying vec2 vTextureCoord; //Function to get the vec2 value of the current coordinate vec2 texCoord(){ return vec2(gl_FragCoord.x, iResolution.y - gl_FragCoord.y) / iResolution; } //Scales the bg up and proportionally to fill the container vec2 scaledTextureCoordinate(){ float ratioCanvas = iResolution.x / iResolution.y; float ratioImage = vTextureSize.x / vTextureSize.y; vec2 scale = vec2(1, 1); vec2 offset = vec2(0, 0); float ratioDelta = ratioCanvas - ratioImage; if(ratioDelta >= 0.0){ scale.y = (1.0 + ratioDelta); offset.y = ratioDelta / 2.0; }else{ scale.x = (1.0 - ratioDelta); offset.x = -(ratioDelta / 2.0); } return (texCoord() + offset) / scale; } //Alpha-blends two colors vec4 blend(vec4 bg, vec4 fg){ vec3 bgm = bg.rgb * bg.a; vec3 fgm = fg.rgb * fg.a; float ia = 1.0 - fg.a; float a = (fg.a + bg.a * ia); vec3 rgb; if(a != 0.0){ rgb = (fgm + bgm * ia) / a; }else{ rgb = vec3(0.0,0.0,0.0); } return vec4(rgb,a); } vec2 pixel(){ return vec2(1.0, 1.0) / iResolution; } //Get color from fg vec4 fgColor(){ return texture2D(uSampler, vTextureCoord); } void main(){ vec4 bg = texture2D(uTextureBackground, scaledTextureCoordinate()); vec4 cur = fgColor(); float d = cur.b; // "thickness" float x = cur.g; float y = cur.r; float a = smoothstep(0.65, 0.7, cur.a); vec4 smoothstepped = vec4(y, x, d, a); vec2 refraction = (vec2(x, y) - 0.5) * 2.0; vec2 refractionPos = scaledTextureCoordinate() + (pixel() * refraction * (256.0 + (d * 512.0))); vec4 tex = texture2D(uTextureForeground, refractionPos); float maxShine = 390.0; float minShine = maxShine * 0.18; vec2 shinePos = vec2(0.5, 0.5) + ((1.0 / 512.0) * refraction) * -(minShine + ((maxShine-minShine) * d)); vec4 shine = texture2D(uTextureDropShine, shinePos); tex = blend(tex,shine); vec4 fg = vec4(tex.rgb, a); gl_FragColor = blend(bg, fg); } `, }; /** * Application Class * Bootstraps the entire application and initializes all objects */ class Application { /** * Application constructor */ constructor() { this.width = window.innerWidth; this.height = window.innerHeight; // Define the assets that PIXI needs to preload to use later in the application this.loader = PIXI.loader .add('https://stefanweck.nl/codepen/alpha.png') .add('https://stefanweck.nl/codepen/shine.png') .add('https://stefanweck.nl/codepen/background.jpg') .add('https://stefanweck.nl/codepen/foreground.jpg') .load(() => this.initialize()); } /** * Initialize is ran when the image loader is done loading all resources * @return void */ initialize() { // Create the Stats object and append it to the DOM this.stats = new Stats(); this.stats.domElement.style.position = 'absolute'; this.stats.domElement.style.left = '0px'; this.stats.domElement.style.top = '0px'; this.stats.domElement.style.zIndex = '9000'; document.body.appendChild(this.stats.domElement); // Create a new instance of the EffectCanvas which is going to produce all of the visuals this.effectCanvas = new EffectCanvas(this.width, this.height, this.loader); // Resize listener for the canvas to fill browser window dynamically window.addEventListener('resize', () => this.resizeCanvas(), false); // Start the initial loop function for the first time this.loop(); } /** * Simple resize function. Reinitializing everything on the canvas while changing the width/height * @return {void} */ resizeCanvas() { this.width = window.innerWidth; this.height = window.innerHeight; this.effectCanvas.resize(this.width, this.height); } /** * Update and render the application at least 60 times a second * @return {void} */ loop() { window.requestAnimationFrame(() => this.loop()); this.stats.begin(); this.effectCanvas.update(this.width, this.height); this.effectCanvas.render(); this.stats.end(); } } /** * EffectCanvas Class */ class EffectCanvas { /** * EffectCanvas constructor */ constructor(width, height, loader) { // Create and configure the renderer this.renderer = new PIXI.autoDetectRenderer(width, height, { antialias: false, transparent: false, }); this.renderer.autoResize = true; document.body.appendChild(this.renderer.view); // Create a container object called the `stage` this.stage = new PIXI.Container(); // Create a graphics object that is as big as the scene of the users window // Else the shader won't fill the entire screen this.background = new PIXI.Graphics(); this.background.fillAlphanumber = 0; this.background.beginFill('0xffffff'); this.background.drawRect(0, 0, width, height); this.background.endFill(); this.background.alpha = 0; this.stage.addChild(this.background); // Create the DropletManager and pass it the stage so it can insert the droplet containers into it this.dropletManager = new DropletManager(this.stage, loader); // Send information about the textures and the size of the background texture through the uniforms to the shader shaderData.uniforms.uTextureDropShine.value = loader.resources['https://stefanweck.nl/codepen/shine.png'].texture; shaderData.uniforms.uTextureBackground.value = loader.resources['https://stefanweck.nl/codepen/background.jpg'].texture; shaderData.uniforms.uTextureForeground.value = loader.resources['https://stefanweck.nl/codepen/foreground.jpg'].texture; shaderData.uniforms.vTextureSize.value = [ loader.resources['https://stefanweck.nl/codepen/background.jpg'].texture.width, loader.resources['https://stefanweck.nl/codepen/background.jpg'].texture.height, ]; // Create our Pixi filter using our custom shader code this.dropletShader = new PIXI.Filter('', shaderData.fragment, shaderData.uniforms); // Apply it to our object this.stage.filters = [this.dropletShader]; } /** * Simple resize function which redraws our graphics object that should fill the entire screen * @return {void} */ resize(width, height) { this.renderer.resize(width, height); this.background.clear(); this.background.beginFill('0xffffff'); this.background.drawRect(0, 0, width, height); this.background.endFill(); } /** * Updates the application and every child of the application * @return {void} */ update(width, height) { this.updateShader(width, height); this.dropletManager.update(width, height); } /** * Updates the uniform values in the shader * @return {void} */ updateShader(width, height) { this.dropletShader.uniforms.iResolution = [ width, height, ]; } /** * Renders the application and every child of the application * @return {void} */ render() { this.renderer.render(this.stage); } } /** * DropletManager class */ class DropletManager { /** * EffectCanvas constructor */ constructor(stage, loader) { let smallDropletAmount = 9000; let largeDropletAmount = 200; //Quick implementation to make sure there aren't out of this world thunderstorms on mobile if(stage.width < 700){ smallDropletAmount = 3000; largeDropletAmount = 150; } this.options = { spawnRate: { small: 0.6, large: 0.05, }, spawnsPerFrame: { small: 200, large: 5, }, spawnMass: { small: { min: 1, max: 2, }, large: { min: 7, max: 10, }, }, poolDroplets: { small: { min: smallDropletAmount - 500, max: smallDropletAmount, }, large: { min: largeDropletAmount - 100, max: largeDropletAmount, }, }, maximumMassGravity: 17, maximumMass: 21, dropletGrowSpeed: 1, dropletShrinkSpeed: 2, dropletContainerSize: 100, }; // Define a position matrix so we can calculate all the edges of a droplet in a single loop this.positionMatrix = [ [-1, -1], [1, -1], [-1, 1], [1, 1], ]; this.smallDroplets = []; this.largeDroplets = []; this.dropletSmallTexture = loader.resources['https://stefanweck.nl/codepen/alpha.png'].texture; this.dropletLargeTexture = loader.resources['https://stefanweck.nl/codepen/alpha.png'].texture; // Create a container for all the droplets this.smallDropletContainer = new DropletPool(Droplet, this.dropletSmallTexture, this.options.poolDroplets.small.min, this.options.poolDroplets.small.max); this.largeDropletContainer = new DropletPool(LargeDroplet, this.dropletLargeTexture, this.options.poolDroplets.large.min, this.options.poolDroplets.large.max); stage.addChild(this.largeDropletContainer); stage.addChild(this.smallDropletContainer); } /** * Updates the application and every child of the application * @return {void} */ update(width, height) { DropletManager.removeLargeOffscreenDroplets(width, height, this.largeDroplets, this.largeDropletContainer); // Trigger the spawn function for a small droplet as much times as is configured in the options for (let i = 0; i < this.options.spawnsPerFrame.small; i++) { this.spawnNewSmallDroplet(width, height); } // Trigger the spawn function for a large droplet as much times as is configured in the options for (let i = 0; i < this.options.spawnsPerFrame.large; i++) { this.spawnNewLargeDroplet(width, height); } // Check if we need to do anything with a large Droplet // We don't process small droplets because they are 'dumb' objects that don't move after they've spawned this.checkLargeDropletLogic(); } /** * Checks whether a big droplet hits a smaller droplet, if so, it grows by half of the smaller droplets size * @return {void} */ checkLargeDropletLogic() { // Store the length of the array so the for loop doesn't have to do that every run const largeDropletsLength = this.largeDroplets.length; for (let i = largeDropletsLength - 1; i >= 0; i--) { this.updateLargeDropletSize(this.largeDroplets[i]); this.checkDropletMovement(this.largeDroplets[i]); this.checkLargeToSmallDropletCollision(this.largeDroplets[i]); this.checkLargeToLargeDropletCollision(this.largeDroplets[i]); this.removeLargeDroplets(i); } } /** * Function that checks if a single large Droplet should be removed * @param i - The current droplet that we are processing */ removeLargeDroplets(i) { if (this.largeDroplets[i].mass === 0 && this.largeDroplets[i].toBeRemoved === true) { this.largeDropletContainer.destroy(this.largeDroplets[i]); this.largeDroplets.splice(i, 1); } } /** * Function that updates the size of a single large Droplet * @param droplet */ updateLargeDropletSize(droplet) { // If a droplet needs to be removed, we have to shrink it down to 0 if (droplet.toBeRemoved === true) { this.shrinkDropletSize(droplet); } else { this.growDropletSize(droplet); } // Update the width and height of the droplet based on the new mass of the droplet droplet.width = droplet.mass * 6; droplet.height = droplet.mass * 7; } /** * Shrink a droplet based on the configured shrink speed. If it will be too small, we set the mass to 0 * @param {LargeDroplet} droplet */ shrinkDropletSize(droplet) { if (droplet.mass - this.options.dropletShrinkSpeed <= 0) { droplet.mass = 0; } else { droplet.mass -= this.options.dropletShrinkSpeed; } } /** * Grow a droplet based on the targetMass he has * @param {LargeDroplet} droplet */ growDropletSize(droplet) { // If a droplet has already reached its target mass, exit here if (droplet.mass === droplet.targetMass) { return; } // Check if we can grow the droplet based on the configured grow speed if (droplet.mass + this.options.dropletGrowSpeed >= droplet.targetMass) { droplet.mass = droplet.targetMass; } else { droplet.mass += this.options.dropletGrowSpeed; } } /** * Check whether a large droplet should be moving or not * @param {LargeDroplet} droplet * @return {void} */ checkDropletMovement(droplet) { // If the droplet is going to be removed at the end of this loop, don't bother checking it if (droplet.toBeRemoved === true) { return; } // Check if the droplets mass is high enough to be moving, and if the droplet is not moving yet if (droplet.mass < this.options.maximumMassGravity && droplet.dropletVelocity.y === 0 && droplet.dropletVelocity.x === 0) { // There's a slight chance that the droplet starts moving if (Math.random() < 0.01) { droplet.dropletVelocity.y = Utils.getRandomInt(0.5, 3); } } else if (droplet.mass < this.options.maximumMassGravity && droplet.dropletVelocity.y !== 0) { // There's a slight chance that the droplet shifts to the left or the right, just like real droplets attach to droplets near them if (Math.random() < 0.1) { droplet.x += Utils.getRandomInt(-10, 10) / 10; } // There's a slight chance that the droplet stops moving if (Math.random() < 0.1) { droplet.dropletVelocity.y = 0; } } else if (droplet.mass >= this.options.maximumMassGravity && droplet.dropletVelocity.y < 10) { // The droplet is falling because it is too heavy, its speed and direction are now set droplet.dropletVelocity.y = Utils.getRandomInt(10, 20); droplet.dropletVelocity.x = Utils.getRandomInt(-10, 10) / 10; } // Increase the x and y positions of the droplet based on its velocity droplet.y += droplet.dropletVelocity.y; droplet.x += droplet.dropletVelocity.x; } /** * Checks in which small droplet arrays the large droplet is positioned * @param {Droplet} droplet */ getDropletPresenceArray(droplet) { // Define a set of array indexes through which we hava to search for collision const arrayIndexes = []; const length = this.positionMatrix.length; // Loop through each positionMatrix to calculate the position of every edge of a droplet for (let i = 0; i < length; i++) { const edgePosition = { x: Math.floor((droplet.x + ((droplet.width / 7) * this.positionMatrix[i][0])) / this.options.dropletContainerSize), y: Math.floor((droplet.y + ((droplet.height / 7) * this.positionMatrix[i][1])) / this.options.dropletContainerSize), }; // Always push the first position in the arrayIndexes array, we use that value to compare the other edges to if (i === 0) { arrayIndexes.push(edgePosition); continue; } // If the current position differs from the first position, store the new value because that means that this is also an array we need to check for collision if (arrayIndexes[0].x !== edgePosition.x || arrayIndexes[0].y !== edgePosition.y) { arrayIndexes.push(edgePosition); } } return arrayIndexes; } /** * Check the collision between one large Droplet and all the other Droplets * @param droplet */ checkLargeToLargeDropletCollision(droplet) { if (droplet.toBeRemoved === true) { return; } // Store the length of the droplets array so we have that valua cached in the for loop const length = this.largeDroplets.length; for (let i = length - 1; i >= 0; i--) { // Don't bother checking this droplet against itself if (droplet.x === this.largeDroplets[i].x && droplet.y === this.largeDroplets[i].y) { continue; } // Calculate the difference in position for the horizontal and the vertical axis const dx = droplet.x - this.largeDroplets[i].x; const dy = droplet.y - this.largeDroplets[i].y; // Calculate the distance between the current droplet and the current other droplet const distance = Math.sqrt((dx * dx) + (dy * dy)); // If the distance between the droplets is close enough, the droplet colliding increases in size if (distance <= (droplet.width / 7) + (this.largeDroplets[i].width / 7)) { if (droplet.mass + this.largeDroplets[i].mass <= this.options.maximumMass) { droplet.targetMass = droplet.mass + this.largeDroplets[i].mass; } else { droplet.targetMass = this.options.maximumMass; } // The other droplet should be removed at the end of this loop this.largeDroplets[i].toBeRemoved = true; } } } /** * Checks whether a big droplet hits a smaller droplet, if so, it grows by half of the smaller droplets size * @param {LargeDroplet} droplet * @return {void} */ checkLargeToSmallDropletCollision(droplet) { if (droplet.toBeRemoved === true) { return; } // Define a set of array indexes through which we have to search for collision const arrayIndexes = this.getDropletPresenceArray(droplet); for (let i = 0; i < arrayIndexes.length; i++) { // If the small droplet doesn't exist anymore, we can continue to the next value in the loop if (typeof this.smallDroplets[arrayIndexes[i].x] === 'undefined' || typeof this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y] === 'undefined') { continue; } // Store the length of the array so the for loop doesn't have to do that every run const smallDropletsLength = this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y].length; for (let c = smallDropletsLength - 1; c >= 0; c--) { // Calculate the difference in position for the horizontal and the vertical axis const dx = droplet.x - this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y][c].x; const dy = droplet.y - this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y][c].y; // Calculate the distance between the current droplet and the current other droplet const distance = Math.sqrt((dx * dx) + (dy * dy)); // If the distance is small enough we can increase the size of the large droplet and remove the small droplet if (distance <= (droplet.width / 7) + (this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y][c].width / 7)) { if (droplet.mass + (this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y][c].mass / 3) <= this.options.maximumMass) { droplet.targetMass = droplet.mass + (this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y][c].mass / 3); } // Remove the small droplet and put it back in the object pool this.smallDropletContainer.destroy(this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y][c]); this.smallDroplets[arrayIndexes[i].x][arrayIndexes[i].y].splice(c, 1); } } } } /** * Spawns a new small droplet on the screen based on the spawn chance * @param {number} width * @param {number} height * @return {void} */ spawnNewSmallDroplet(width, height) { // If our random value doesn't match the given spawn rate, we don't spawn a droplet if (Math.random() > this.options.spawnRate.small) { return; } // Get a new droplet object from the pool const droplet = this.smallDropletContainer.get(); // If the pool decided that we can't add more droplets, exit here if (droplet === null) { return; } const position = { x: Utils.getRandomInt(0, width), y: Utils.getRandomInt(0, height), }; const mass = Utils.getRandomInt(this.options.spawnMass.small.min, this.options.spawnMass.small.max); const arrayIndex = { x: Math.floor(position.x / this.options.dropletContainerSize), y: Math.floor(position.y / this.options.dropletContainerSize), }; // Make sure the droplet updates with a new position and radius droplet.x = position.x; droplet.y = position.y; droplet.mass = mass; droplet.width = droplet.mass * 8; droplet.height = droplet.mass * 8; if (typeof this.smallDroplets[arrayIndex.x] === 'undefined') { this.smallDroplets[arrayIndex.x] = []; } if (typeof this.smallDroplets[arrayIndex.x][arrayIndex.y] === 'undefined') { this.smallDroplets[arrayIndex.x][arrayIndex.y] = []; } this.smallDroplets[arrayIndex.x][arrayIndex.y].push(droplet); } /** * Spawns a new large droplet on the screen based on the spawn chance * @param {number} width * @param {number} height * @return {void} */ spawnNewLargeDroplet(width, height) { // If our random value doesn't match the given spawn rate, we don't spawn a droplet if (Math.random() > this.options.spawnRate.large) { return; } // Get a new droplet object from the pool const droplet = this.largeDropletContainer.get(); // If the pool decided that we can't add more droplets, exit here if (droplet === null) { return; } // Make sure the droplet updates with a new position and radius const mass = Utils.getRandomInt(this.options.spawnMass.large.min, this.options.spawnMass.large.max); droplet.x = Utils.getRandomInt(0, width); droplet.y = Utils.getRandomInt(-100, height / 1.5); droplet.mass = mass / 2; droplet.targetMass = mass; droplet.width = droplet.mass * 6; droplet.height = droplet.mass * 7; droplet.dropletVelocity.x = 0; droplet.toBeRemoved = false; this.largeDroplets.push(droplet); } /** * Checks each droplet to see if it is positioned offscreen. If so, it's being pushed back into the object pool to be reused * @param {number} width * @param {number} height * @param {Array} dropletArray * @param {DropletPool} dropletContainer * @return {void} */ static removeLargeOffscreenDroplets(width, height, dropletArray, dropletContainer) { // Store the length of the array so the for loop doesn't have to do that every run const length = dropletArray.length; for (let i = length - 1; i >= 0; i--) { if (dropletArray[i].x > width + 10 || dropletArray[i].x < -10 || dropletArray[i].y > height + 10 || dropletArray[i].y < -100) { dropletContainer.destroy(dropletArray[i]); dropletArray.splice(i, 1); } } } } /** * DropletPool class * Functions as an object pool so we can re-use droplets over and over again */ class DropletPool extends PIXI.particles.ParticleContainer { /** * DropletPool constructor */ constructor(ObjectToCreate, objectTexture, startingSize, maximumSize) { super(maximumSize, { scale: true, position: true, rotation: false, uvs: false, alpha: false, }); this.ObjectToCreate = ObjectToCreate; this.objectTexture = objectTexture; this.pool = []; this.inUse = 0; this.startingSize = startingSize; this.maximumSize = maximumSize; this.initialize(); } /** * Initialize the initial batch of objects that we are going to use throughout the application * @return {void} */ initialize() { for (let i = 0; i < this.startingSize; i += 1) { const droplet = new this.ObjectToCreate(this.objectTexture); droplet.x = -100; droplet.y = -100; droplet.anchor.set(0.5); // Add the object to the PIXI Container and store it in the pool this.addChild(droplet); this.pool.push(droplet); } } /** * Get an object from the object pool, checks whether there is an object left or it if may create a new object otherwise * @returns {object} */ get() { // Check if we have reached the maximum number of objects, if so, return null if (this.inUse >= this.maximumSize) { return null; } // We haven't reached the maximum number of objects yet, so we are going to reuse an object this.inUse++; // If there are still objects in the pool return the last item from the pool if (this.pool.length > 0) { return this.pool.pop(); } // The pool was empty, but we are still allowed to create a new object and return that const droplet = new this.ObjectToCreate(this.objectTexture); droplet.x = -100; droplet.y = -100; droplet.anchor.set(0.5, 0.5); // Add the object to the PIXI Container and return it this.addChild(droplet); return droplet; } /** * Put an element back into the object pool and reset it for later use * @param element - The object that should be pushed back into the object pool to be reused later on * @return {void} */ destroy(element) { if (this.inUse - 1 < 0) { console.error('Something went wrong, you cant remove more elements than there are in the total pool'); return; } // Move the droplet offscreen, we cant't set visible or rendering to false because that doesn't matter in a PIXI.ParticleContainer // @see: https://github.com/pixijs/pixi.js/issues/1910 element.x = -100; element.y = -100; // Push the element back into the object pool so it can be reused again this.inUse -= 1; this.pool.push(element); } } /** * Droplet Class */ class Droplet extends PIXI.Sprite { /** * Droplet constructor */ constructor(texture) { super(texture); this.mass = 0; } } /** * LargeDroplet Class */ class LargeDroplet extends Droplet { /** * Droplet constructor */ constructor(texture) { super(texture); this.dropletVelocity = new PIXI.Point(0, 0); this.toBeRemoved = false; this.targetMass = 0; } } /** * Utilities Class has some functions that are needed throughout the entire application */ class Utils { /** * Returns a random integer between a given minimum and maximum value * @param {number} min - The minimum value, can be negative * @param {number} max - The maximum value, can be negative * @return {number} */ static getRandomInt(min, max) { return Math.floor(Math.random() * ((max - min) + 1)) + min; } } /** * Onload function is executed whenever the page is done loading, initializes the application */ window.onload = () => { // Create a new instance of the application const application = new Application(); };

!