├── .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 | ](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 |
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 |
63 |
64 |
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 |
--------------------------------------------------------------------------------