├── .gitattributes ├── static └── liquid.gif ├── js ├── config.js ├── physics.js ├── controls.js ├── filters.js └── canvas.js ├── LICENSE ├── README.md ├── css └── index.css └── index.html /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /static/liquid.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3r4zzurr0/canvas-liquid-effect/HEAD/static/liquid.gif -------------------------------------------------------------------------------- /js/config.js: -------------------------------------------------------------------------------- 1 | const Config = { 2 | canvasSize: 1000, 3 | rectThickness: 15, 4 | slantRectDeltaX: 120, 5 | slantRectDeltaY: 150, 6 | slantRectAngle: 24, 7 | slantRectWidth: 600, 8 | verticalRectHeight: 120, 9 | smallCircleDistance: 78, 10 | smallCircleRadius: 10, 11 | largeCircleRadius: 30, 12 | particleRadius: 6, 13 | particleCount: 300, 14 | particlesStreamInitialWidth: 50 15 | } 16 | -------------------------------------------------------------------------------- /js/physics.js: -------------------------------------------------------------------------------- 1 | // A custom wrapper function for initializing the Matter.js Physics Engine 2 | const Physics = (bodies) => { 3 | const Runner = Matter.Runner 4 | const Engine = Matter.Engine 5 | const Bodies = Matter.Bodies 6 | const Composite = Matter.Composite 7 | const Constraint = Matter.Constraint 8 | 9 | const engine = Engine.create() 10 | const runner = Runner.create({ isFixed: true }) 11 | const initialBodies = bodies(Bodies, Constraint) 12 | 13 | Runner.start(runner, engine) 14 | Composite.add(engine.world, initialBodies) 15 | 16 | const addBodies = (bodies) => { 17 | const addedBodies = bodies(Bodies) 18 | Composite.add(engine.world, addedBodies) 19 | return addedBodies 20 | } 21 | 22 | const removeBody = body => { 23 | Composite.remove(engine.world, body) 24 | } 25 | 26 | const timeScale = (scale) => { 27 | engine.timing.timeScale = scale 28 | } 29 | 30 | return { 31 | addBodies, 32 | removeBody, 33 | timeScale, 34 | initialBodies 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Utkarsh Verma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Canvas Liquid Effect 2 | 3 | Demonstration of liquid (or gooey) effect on HTML Canvas using [Matter.js](https://github.com/liabru/matter-js) and SVG Filters ([`feGaussianBlur`](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feGaussianBlur) and [`feColorMatrix`](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feColorMatrix)). 4 | 5 | ### [DEMO](https://n3r4zzurr0.in/canvas-liquid-effect) 6 | 7 | [ 8 | ![Canvas Liquid Effect](https://raw.githubusercontent.com/n3r4zzurr0/canvas-liquid-effect/main/static/liquid.gif)](https://n3r4zzurr0.in/canvas-liquid-effect) 9 | 10 |
11 | 12 | ## Liquid / Gooey Effect 13 | 14 | This effect is obtained by first applying the blur filter and then by increasing the contrast of the alpha channel by applying the color matrix filter. I have created a [pen](https://codepen.io/n3r4zzurr0/pen/oNEMzOa) that demonstrates the same. 15 | 16 | **Example** 17 | 18 | ```svg 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ``` 28 | 29 | ## License 30 | 31 | MIT © [Utkarsh Verma](https://github.com/n3r4zzurr0) 32 | -------------------------------------------------------------------------------- /js/controls.js: -------------------------------------------------------------------------------- 1 | const Controls = { 2 | 3 | staticFilterToggle: true, 4 | particlesFilterToggle: true, 5 | timeScales: [0.2, 1, 1.5], 6 | timeScaleIndex: 1, 7 | 8 | set: function () { 9 | this.staticCanvas = document.querySelector('#static') 10 | this.particlesCanvas = document.querySelector('#particles') 11 | this.values = document.querySelectorAll('.value') 12 | this.action = document.querySelectorAll('.controls .action') 13 | 14 | Array.from(this.action).forEach((a, i) => { 15 | const button = a.querySelector('button') 16 | switch (i) { 17 | case 0: 18 | button.onclick = () => { 19 | this.staticFilterToggle = !this.staticFilterToggle 20 | this.staticCanvas.style.filter = this.staticFilterToggle ? '' : 'none' 21 | button.style.opacity = this.staticFilterToggle ? '' : 0.3 22 | this.values[0].innerText = this.staticFilterToggle ? 'enabled' : 'disabled' 23 | } 24 | break 25 | case 1: 26 | button.onclick = () => { 27 | this.particlesFilterToggle = !this.particlesFilterToggle 28 | this.particlesCanvas.style.filter = this.particlesFilterToggle ? '' : 'none' 29 | button.style.opacity = this.particlesFilterToggle ? '' : 0.3 30 | this.values[1].innerText = this.particlesFilterToggle ? 'enabled' : 'disabled' 31 | } 32 | break 33 | case 2: 34 | button.onclick = () => { 35 | this.timeScaleIndex++ 36 | if (this.timeScaleIndex >= this.timeScales.length) { this.timeScaleIndex = 0 } 37 | this.values[2].innerText = this.timeScales[this.timeScaleIndex] 38 | Canvas.physics.timeScale(this.timeScales[this.timeScaleIndex]) 39 | } 40 | break 41 | } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /js/filters.js: -------------------------------------------------------------------------------- 1 | const Filters = { 2 | // Here, the values for standard deviations and color matrices 3 | // are experimentally obtained for different canvas sizes 4 | staticStdDeviation: [2, 3, 4, 5, 6, 7, 7, 8, 9], 5 | staticMatrix: ['6 -1', '8 -2', '10 -3', '12 -4', '15 -6', '18 -7', '18 -7', '21 -9', '27 -10'], 6 | particlesStdDeviation: [2, 2, 3, 4, 5, 6, 6, 8, 11], 7 | particlesMatrix: ['7 -2', '7 -3', '9 -3', '12 -3', '15 -4', '18 -6', '18 -6', '22 -9', '25 -12'], 8 | 9 | set: function () { 10 | this.staticCanvas = document.querySelector('#static') 11 | this.staticFilterBlur = document.querySelector('#static-filter feGaussianBlur') 12 | this.staticFilterMatrix = document.querySelector('#static-filter feColorMatrix') 13 | this.particlesFilterBlur = document.querySelector('#particles-filter feGaussianBlur') 14 | this.particlesFilterMatrix = document.querySelector('#particles-filter feColorMatrix') 15 | 16 | this.update() 17 | 18 | window.onresize = () => { 19 | this.update() 20 | } 21 | }, 22 | update: function () { 23 | const canvasSize = this.staticCanvas.clientWidth 24 | let filterIndex = 0 25 | 26 | if (canvasSize < 450) { 27 | filterIndex = 0 28 | } else if (canvasSize < 540) { 29 | filterIndex = 1 30 | } else if (canvasSize < 600) { 31 | filterIndex = 2 32 | } else if (canvasSize < 750) { 33 | filterIndex = 3 34 | } else if (canvasSize < 900) { 35 | filterIndex = 4 36 | } else if (canvasSize < 1200) { 37 | filterIndex = 5 38 | } else if (canvasSize < 1500) { 39 | filterIndex = 6 40 | } else if (canvasSize < 2200) { 41 | filterIndex = 7 42 | } else { 43 | filterIndex = 8 44 | } 45 | 46 | this.staticFilterBlur.setAttribute('stdDeviation', this.staticStdDeviation[filterIndex]) 47 | this.staticFilterMatrix.setAttribute('values', '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ' + this.staticMatrix[filterIndex]) 48 | this.particlesFilterBlur.setAttribute('stdDeviation', this.particlesStdDeviation[filterIndex]) 49 | this.particlesFilterMatrix.setAttribute('values', '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ' + this.particlesMatrix[filterIndex]) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /css/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background: #000; 4 | user-select: none; 5 | -webkit-user-select: none; 6 | } 7 | canvas { 8 | position: fixed; 9 | top: 50%; 10 | left: calc(50% + 180px); 11 | height: 100vh; 12 | transform: translate(-50%, -50%); 13 | } 14 | svg { 15 | width: 0; 16 | height: 0; 17 | } 18 | .controls { 19 | position: fixed; 20 | z-index: 2; 21 | left: 24px; 22 | top: 24px; 23 | } 24 | .controls .action { 25 | position: relative; 26 | margin: 12px 0; 27 | } 28 | .controls button { 29 | cursor: pointer; 30 | display: block; 31 | width: 48px; 32 | height: 48px; 33 | padding: 0; 34 | border: 0; 35 | border-radius: 50%; 36 | background: #2a2b36; 37 | transition: opacity .3s, background .3s, transform .3s; 38 | } 39 | .controls button svg { 40 | position: absolute; 41 | top: 50%; 42 | left: 50%; 43 | width: 32px; 44 | height: 32px; 45 | fill: #fff; 46 | transform: translate(-50%, -50%); 47 | } 48 | .controls .tooltip { 49 | opacity: 0; 50 | pointer-events: none; 51 | position: absolute; 52 | left: 54px; 53 | top: 50%; 54 | font-size: 14px; 55 | padding: 6px 12px 7px; 56 | white-space: nowrap; 57 | color: #fff; 58 | font-family: Poppins; 59 | font-weight: 600; 60 | border-radius: 6px; 61 | background: #2a2b36; 62 | transform: translate(0, -50%); 63 | transition: opacity .3s; 64 | } 65 | .controls button:hover { 66 | background: #353745; 67 | transform: scale(1.14); 68 | } 69 | .controls button:hover + .tooltip { 70 | opacity: 1; 71 | } 72 | .status { 73 | position: fixed; 74 | left: 24px; 75 | bottom: 24px; 76 | } 77 | .status .property { 78 | display: table; 79 | border-radius: 6px; 80 | font-family: monospace; 81 | font-weight: bold; 82 | font-size: 14px; 83 | margin: 9px 0; 84 | background: #2a2b36; 85 | color: #fff; 86 | padding: 4px 9px; 87 | } 88 | .title { 89 | color: #fff; 90 | position: fixed; 91 | top: 50%; 92 | left: calc(50% - 180px); 93 | font-size: 30px; 94 | font-family: 'Rubik Mono One', sans-serif; 95 | letter-spacing: -1px; 96 | white-space: nowrap; 97 | transform: translate(-50%, -50%); 98 | } 99 | #static { 100 | filter: url("#static-filter"); 101 | z-index: 0; 102 | } 103 | #particles { 104 | filter: url("#particles-filter"); 105 | z-index: 1; 106 | } 107 | @media only screen and (max-width: 1024px) { 108 | .controls { 109 | top: 50%; 110 | transform: translate(0, -50%); 111 | } 112 | .status { 113 | left: auto; 114 | right: 24px; 115 | } 116 | .status .property { 117 | margin: 9px 0 9px auto; 118 | } 119 | .title { 120 | top: 30px; 121 | left: auto; 122 | right: 30px; 123 | font-size: 24px; 124 | text-align: right; 125 | transform: translate(0, 0); 126 | } 127 | canvas { 128 | left: 50%; 129 | width: 100vw; 130 | height: auto; 131 | } 132 | } 133 | @media only screen and (max-width: 660px) { 134 | .controls { 135 | top: auto; 136 | left: 18px; 137 | bottom: 18px; 138 | transform: translate(0, 0); 139 | } 140 | .title { 141 | font-size: 18px; 142 | } 143 | } 144 | @media only screen and (max-height: 600px) { 145 | .title { 146 | font-size: 24px; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Canvas Liquid Effect 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | Canvas Liquid Effect
21 | Matter.js + SVG Filters 22 |
23 | 24 | 25 | 26 | 27 |
28 |
29 | 32 |
Toggle Filter (Static Bodies)
33 |
34 |
35 | 38 |
Toggle Filter (Particles)
39 |
40 |
41 | 44 |
Change Time Scale
45 |
46 |
47 | 48 |
49 |
Filter (Static Bodies): enabled
50 |
Filter (Particles): enabled
51 |
Time Scale: 1
52 |
FPS: 0
53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /js/canvas.js: -------------------------------------------------------------------------------- 1 | const Canvas = { 2 | // Colors for the particles to be set in sequential order 3 | colors: ['#ffbf00', '#dc143c', '#8e2de2', '#2196f3', '#39ff14'], 4 | colorIndex: -1, 5 | 6 | // For calculating FPS 7 | times: [], 8 | fps: 0, 9 | 10 | init: function () { 11 | this.staticCanvas = document.querySelector('#static') 12 | this.particlesCanvas = document.querySelector('#particles') 13 | this.staticContext = this.staticCanvas.getContext('2d') 14 | this.particlesContext = this.particlesCanvas.getContext('2d') 15 | 16 | this.staticContext.canvas.width = Config.canvasSize 17 | this.staticContext.canvas.height = Config.canvasSize 18 | this.particlesContext.canvas.width = Config.canvasSize 19 | this.particlesContext.canvas.height = Config.canvasSize 20 | 21 | this.fpsCounter = document.querySelectorAll('.value')[3] 22 | 23 | // Initializing the Matter.js Physics Engine using a custom wrapper 24 | // Matter.Bodies: https://brm.io/matter-js/docs/classes/Bodies.html 25 | // Matter.Constraint: https://brm.io/matter-js/docs/classes/Constraint.html 26 | this.physics = Physics((Bodies, Constraint) => { 27 | const COS = Math.cos(Config.slantRectAngle * Math.PI / 180) 28 | const SIN = Math.sin(Config.slantRectAngle * Math.PI / 180) 29 | 30 | const leftVerticalRect = Bodies.rectangle( 31 | this.staticCanvas.width / 2 - Config.slantRectDeltaX - Config.slantRectWidth * COS / 2, 32 | this.staticCanvas.height / 2 - Config.slantRectDeltaY - Config.slantRectWidth * SIN / 2 - Config.verticalRectHeight / 2, 33 | Config.rectThickness, 34 | Config.verticalRectHeight, 35 | { isStatic: true } 36 | ) 37 | 38 | const rightVerticalRect = Bodies.rectangle( 39 | this.staticCanvas.width / 2 + Config.slantRectDeltaX + Config.slantRectWidth * COS / 2, 40 | this.staticCanvas.height / 2 + Config.slantRectDeltaY - Config.slantRectWidth * SIN / 2 - Config.verticalRectHeight / 2, 41 | Config.rectThickness, 42 | Config.verticalRectHeight, 43 | { isStatic: true } 44 | ) 45 | 46 | const topSlantRect = Bodies.rectangle( 47 | this.staticCanvas.width / 2 - Config.slantRectDeltaX, 48 | this.staticCanvas.height / 2 - Config.slantRectDeltaY, 49 | Config.slantRectWidth, 50 | Config.rectThickness, 51 | { isStatic: true, angle: Config.slantRectAngle * Math.PI / 180 } 52 | ) 53 | 54 | const bottomSlantRect = Bodies.rectangle( 55 | this.staticCanvas.width / 2 + Config.slantRectDeltaX, 56 | this.staticCanvas.height / 2 + Config.slantRectDeltaY, 57 | Config.slantRectWidth, 58 | Config.rectThickness, 59 | { isStatic: true, angle: -Config.slantRectAngle * Math.PI / 180 } 60 | ) 61 | 62 | const largeCircle = Bodies.circle( 63 | this.staticCanvas.width / 2 - Config.slantRectDeltaX + Config.slantRectWidth * COS / 2, 64 | this.staticCanvas.height / 2 - Config.slantRectDeltaY + Config.slantRectWidth * SIN / 2 - Config.largeCircleRadius + Config.rectThickness * SIN, 65 | Config.largeCircleRadius, 66 | { isStatic: true } 67 | ) 68 | 69 | const turbine = Bodies.rectangle( 70 | this.staticCanvas.width / 2 + Config.slantRectDeltaX - Config.slantRectWidth * COS / 4, 71 | this.staticCanvas.height / 2 + Config.slantRectDeltaY + Config.slantRectWidth * SIN / 4 - Config.verticalRectHeight / 2 + Config.rectThickness * SIN, 72 | Config.verticalRectHeight, 73 | Config.rectThickness, 74 | { friction: 0, restitution: 1, mass: 0.25, angle: 90 * Math.PI / 180 } 75 | ) 76 | 77 | const hinge = Constraint.create({ 78 | pointA: { 79 | x: this.staticCanvas.width / 2 + Config.slantRectDeltaX - Config.slantRectWidth * COS / 4, 80 | y: this.staticCanvas.height / 2 + Config.slantRectDeltaY + Config.slantRectWidth * SIN / 4 - Config.verticalRectHeight / 2 - Config.rectThickness * SIN * 2 81 | }, 82 | bodyB: turbine, 83 | length: 0 84 | }) 85 | 86 | const smallCircle = Bodies.circle( 87 | this.staticCanvas.width / 2 + Config.slantRectDeltaX - (Config.slantRectWidth / 2 + Config.smallCircleDistance) * COS, 88 | this.staticCanvas.height / 2 + Config.slantRectDeltaY + (Config.slantRectWidth / 2 + Config.smallCircleDistance) * SIN, 89 | Config.smallCircleRadius, 90 | { isStatic: true } 91 | ) 92 | 93 | return [ 94 | leftVerticalRect, 95 | topSlantRect, 96 | largeCircle, 97 | rightVerticalRect, 98 | bottomSlantRect, 99 | turbine, 100 | hinge, 101 | smallCircle 102 | ] 103 | }) 104 | 105 | this.staticBodies = this.physics.initialBodies 106 | this.particles = [] 107 | this.addParticles() 108 | }, 109 | 110 | addParticles: function () { 111 | this.colorIndex++ 112 | if (this.colorIndex >= this.colors.length) { this.colorIndex = 0 } 113 | const fillStyle = this.colors[this.colorIndex] 114 | 115 | for (let i = 0; i < Config.particleCount; i++) { 116 | this.physics.addBodies((Bodies) => { 117 | return [ 118 | Bodies.circle( 119 | this.particlesCanvas.width / 2 - Config.slantRectDeltaX - Config.slantRectWidth * Math.cos(Config.slantRectAngle * Math.PI / 180) / 2 + ~~(Math.random() * Config.particlesStreamInitialWidth) + Config.particlesStreamInitialWidth + ((i + 1) % 10) * Config.particleRadius, 120 | -~~((Config.particleCount + 1) / 10) * Config.particleRadius + ~~((i + 1) / 10) * Config.particleRadius, 121 | Config.particleRadius, 122 | { restitution: 0.2, friction: 0 } 123 | ) 124 | ] 125 | }).forEach(body => { 126 | this.particles.push({ body, fillStyle }) 127 | }) 128 | } 129 | }, 130 | 131 | draw: function () { 132 | this.staticContext.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height) 133 | this.particlesContext.clearRect(0, 0, this.particlesCanvas.width, this.particlesCanvas.height) 134 | 135 | this.staticBodies.forEach(s => { 136 | this.staticContext.beginPath() 137 | if (s.label === 'Rectangle Body') { 138 | this.staticContext.fillStyle = '#fff' 139 | this.staticContext.moveTo(s.vertices[0].x, s.vertices[0].y) 140 | this.staticContext.lineTo(s.vertices[1].x, s.vertices[1].y) 141 | this.staticContext.lineTo(s.vertices[2].x, s.vertices[2].y) 142 | this.staticContext.lineTo(s.vertices[3].x, s.vertices[3].y) 143 | } 144 | if (s.label === 'Circle Body') { 145 | this.staticContext.fillStyle = '#fff' 146 | this.staticContext.arc(s.position.x, s.position.y, s.circleRadius, 0, 2 * Math.PI) 147 | } 148 | if (s.label === 'Constraint') { 149 | this.staticContext.fillStyle = '#000' 150 | this.staticContext.arc(s.pointA.x, s.pointA.y, 4, 0, 2 * Math.PI) 151 | } 152 | this.staticContext.closePath() 153 | this.staticContext.fill() 154 | }) 155 | 156 | for (let i = 0; i < this.particles.length; i++) { 157 | const position = this.particles[i].body.position 158 | 159 | this.particlesContext.fillStyle = this.particles[i].fillStyle 160 | this.particlesContext.beginPath() 161 | this.particlesContext.arc(position.x, position.y, Config.particleRadius, 0, 2 * Math.PI) 162 | this.particlesContext.closePath() 163 | this.particlesContext.fill() 164 | 165 | // Remove the particle when it goes off the visual viewport 166 | if (position.y > this.particlesCanvas.height + Config.particleRadius) { 167 | this.physics.removeBody(this.particles[i].body) 168 | this.particles.splice(i, 1) 169 | i-- 170 | } 171 | // Initiate the next stream of particles when the current count is less than 75% of the initial count 172 | if (this.particles.length < Config.particleCount * 0.75) { this.addParticles() } 173 | } 174 | 175 | const now = performance.now() 176 | while (this.times.length > 0 && this.times[0] <= now - 1000) { this.times.shift() } 177 | this.times.push(now) 178 | this.fps = this.times.length 179 | 180 | this.fpsCounter.innerText = this.fps 181 | 182 | window.requestAnimationFrame(() => { this.draw() }) 183 | } 184 | } 185 | --------------------------------------------------------------------------------