How can I pinch/pucker some area of an image in canvas?
I’ve made a solar system animation some time ago, and I started rewriting it. Now, I want to add gravity effect to masses. To make the effect visible, I turned the background into a grid and I’ll be modifying it.
Desired effect is something like this (made in PS)
context.background("rgb(120,130,145)"); context.grid(25, "rgba(255,255,255,.1)"); var sun = { fill : "rgb(220,210,120)", radius : 30, boundingBox : 30*2 + 3*2, position : { x : 200, y : 200, }, }; sun.img = saveToImage(sun); context.drawImage(sun.img, sun.position.x - sun.boundingBox/2, sun.position.y - sun.boundingBox/2);
Update: I’ve done some googling and found some resources, but since I’ve never done pixel manipulation before, I can’t put these together.
Pixel Distortions with Bilinear Filtration in HTML5 Canvas | Splashnology.com (functions only)
glfx.js (WebGL library with demos)
JSFiddle (spherize, zoom, twirl examples)
The spherize effect in inverted form would be good for the job, I guess.
Advertisement
Answer
I’ve had time to revisit this problem and came up with a solution. Instead of solving the problem directly, first, I needed to understand how the math behind the calculation and pixel manipulation works.
So, instead of using an image/pixels, I decided to use particles
. A JavaScript object is something I’m much more familiar with, so it was easy to manipulate.
I’ll not try to explain the method because I think it’s self-explanatory, and I tried to keep it as simple as it can get.
var canvas = document.getElementById("canvas"); var context = canvas.getContext("2d"); canvas.width = 400; canvas.height = 400; var particles = []; function Particle() { this.position = { actual : { x : 0, y : 0 }, affected : { x : 0, y : 0 }, }; } // space between particles var gridSize = 25; var columns = canvas.width / gridSize; var rows = canvas.height / gridSize; // create grid using particles for (var i = 0; i < rows+1; i++) { for (var j = 0; j < canvas.width; j += 2) { var p = new Particle(); p.position.actual.x = j; p.position.actual.y = i * gridSize; p.position.affected = Object.create(p.position.actual); particles.push(p); } } for (var i = 0; i < columns+1; i++) { for (var j = 0; j < canvas.height; j += 2) { var p = new Particle(); p.position.actual.x = i * gridSize; p.position.actual.y = j; p.position.affected = Object.create(p.position.actual); particles.push(p); } } // track mouse coordinates as it is the source of mass/gravity var mouse = { x : -100, y : -100, }; var effectRadius = 75; var effectStrength = 50; function draw() { context.clearRect(0, 0, canvas.width, canvas.height); particles.forEach(function (particle) { // move the particle to its original position particle.position.affected = Object.create(particle.position.actual); // calculate the effect area var a = mouse.y - particle.position.actual.y; var b = mouse.x - particle.position.actual.x; var dist = Math.sqrt(a*a + b*b); // check if the particle is in the affected area if (dist < effectRadius) { // angle of the mouse relative to the particle var a = angle(particle.position.actual.x, particle.position.actual.y, mouse.x, mouse.y); // pull is stronger on the closest particle var strength = dist.map(0, effectRadius, effectStrength, 0); if (strength > dist) { strength = dist; } // new position for the particle that's affected by gravity var p = pos(particle.position.actual.x, particle.position.actual.y, a, strength); particle.position.affected.x = p.x; particle.position.affected.y = p.y; } context.beginPath(); context.rect(particle.position.affected.x -1, particle.position.affected.y -1, 2, 2); context.fill(); }); } draw(); window.addEventListener("mousemove", function (e) { mouse.x = e.x - canvas.offsetLeft; mouse.y = e.y - canvas.offsetTop; requestAnimationFrame(draw); }); function angle(originX, originY, targetX, targetY) { var dx = targetX - originX; var dy = targetY - originY; var theta = Math.atan2(dy, dx) * (180 / Math.PI); if (theta < 0) theta = 360 + theta; return theta; } Number.prototype.map = function (in_min, in_max, out_min, out_max) { return (this - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; }; function pos(x, y, angle, length) { angle *= Math.PI / 180; return { x : Math.round(x + length * Math.cos(angle)), y : Math.round(y + length * Math.sin(angle)), }; }
* { margin: 0; padding: 0; box-sizing: inherit; line-height: inherit; font-size: inherit; font-family: inherit; } body { font-family: sans-serif; box-sizing: border-box; background-color: hsl(0, 0%, 90%); } canvas { display: block; background: white; box-shadow: 0 0 2px rgba(0, 0, 0, .2), 0 1px 1px rgba(0, 0, 0, .1); margin: 20px auto; } canvas:hover { cursor: none; }
<canvas id="canvas"></canvas>
I might try to create twirl effect some other time, and move these into WebGL for better performance.
Update:
Now, I’m working on the twirl effect, and I’ve made it work to some degree.
var canvas = document.getElementById("canvas"); var context = canvas.getContext("2d"); canvas.width = 400; canvas.height = 400; var particles = []; function Particle() { this.position = { actual : { x : 0, y : 0 }, affected : { x : 0, y : 0 }, }; } // space between particles var gridSize = 25; var columns = canvas.width / gridSize; var rows = canvas.height / gridSize; // create grid using particles for (var i = 0; i < rows+1; i++) { for (var j = 0; j < canvas.width; j += 2) { var p = new Particle(); p.position.actual.x = j; p.position.actual.y = i * gridSize; p.position.affected = Object.create(p.position.actual); particles.push(p); } } for (var i = 0; i < columns+1; i++) { for (var j = 0; j < canvas.height; j += 2) { var p = new Particle(); p.position.actual.x = i * gridSize; p.position.actual.y = j; p.position.affected = Object.create(p.position.actual); particles.push(p); } } // track mouse coordinates as it is the source of mass/gravity var mouse = { x : -100, y : -100, }; var effectRadius = 75; var twirlAngle = 90; function draw(e) { context.clearRect(0, 0, canvas.width, canvas.height); particles.forEach(function (particle) { // move the particle to its original position particle.position.affected = Object.create(particle.position.actual); // calculate the effect area var a = mouse.y - particle.position.actual.y; var b = mouse.x - particle.position.actual.x; var dist = Math.sqrt(a*a + b*b); // check if the particle is in the affected area if (dist < effectRadius) { // angle of the particle relative to the mouse var a = angle(mouse.x, mouse.y, particle.position.actual.x, particle.position.actual.y); var strength = dist.map(0, effectRadius, twirlAngle, 0); // twirl a += strength; // new position for the particle that's affected by gravity var p = rotate(a, dist, mouse.x, mouse.y); particle.position.affected.x = p.x; particle.position.affected.y = p.y; } context.beginPath(); context.rect(particle.position.affected.x -1, particle.position.affected.y -1, 2, 2); context.fillStyle = "black"; context.fill(); }); } draw(); window.addEventListener("mousemove", function (e) { mouse.x = e.x - canvas.offsetLeft; mouse.y = e.y - canvas.offsetTop; requestAnimationFrame(draw); }); function angle(originX, originY, targetX, targetY) { var dx = targetX - originX; var dy = targetY - originY; var theta = Math.atan2(dy, dx) * (180 / Math.PI); if (theta < 0) theta = 360 + theta; return theta; } Number.prototype.map = function (in_min, in_max, out_min, out_max) { return (this - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; }; function pos(x, y, angle, length) { angle *= Math.PI / 180; return { x : Math.round(x + length * Math.cos(angle)), y : Math.round(y + length * Math.sin(angle)), }; } function rotate(angle, distance, originX, originY) { return { x : originX + Math.cos(angle * Math.PI/180) * distance, y : originY + Math.sin(angle * Math.PI/180) * distance, } }
* { margin: 0; padding: 0; box-sizing: inherit; line-height: inherit; font-size: inherit; font-family: inherit; } body { font-family: sans-serif; box-sizing: border-box; background-color: hsl(0, 0%, 90%); } canvas { display: block; background: white; box-shadow: 0 0 2px rgba(0, 0, 0, .2), 0 1px 1px rgba(0, 0, 0, .1); margin: 20px auto; }
<canvas id="canvas"></canvas>
There is a slight issue with the mapping of strength of the twirl. I’ve used the same function map
that I’ve used with pinch effect, but I think twirl doesn’t use linear mapping, but eased mapping. Compare the JS version with the PS filter. PS filter is smoother. I need to rewrite the map
function.
Update 2:
I’ve managed to make it work the same way PS filter does. Using an ease function, i.e., easeOutQuad
solved the problem. Enjoy 🙂
var canvas = document.getElementById("canvas"); var context = canvas.getContext("2d"); canvas.width = 400; canvas.height = 400; var particles = []; function Particle() { this.position = { actual : { x : 0, y : 0 }, affected : { x : 0, y : 0 }, }; } // space between particles var gridSize = 25; var columns = canvas.width / gridSize; var rows = canvas.height / gridSize; // create grid using particles for (var i = 0; i < rows+1; i++) { for (var j = 0; j < canvas.width; j+=2) { var p = new Particle(); p.position.actual.x = j; p.position.actual.y = i * gridSize; p.position.affected = Object.create(p.position.actual); particles.push(p); } } for (var i = 0; i < columns+1; i++) { for (var j = 0; j < canvas.height; j+=2) { var p = new Particle(); p.position.actual.x = i * gridSize; p.position.actual.y = j; p.position.affected = Object.create(p.position.actual); particles.push(p); } } // track mouse coordinates as it is the source of mass/gravity var mouse = { x : -100, y : -100, }; var effectRadius = 75; var twirlAngle = 90; function draw(e) { context.clearRect(0, 0, canvas.width, canvas.height); particles.forEach(function (particle) { // move the particle to its original position particle.position.affected = Object.create(particle.position.actual); // calculate the effect area var a = mouse.y - particle.position.actual.y; var b = mouse.x - particle.position.actual.x; var dist = Math.sqrt(a*a + b*b); // check if the particle is in the affected area if (dist < effectRadius) { // angle of the particle relative to the mouse var a = angle(mouse.x, mouse.y, particle.position.actual.x, particle.position.actual.y); var strength = twirlAngle - easeOutQuad(dist, 0, twirlAngle, effectRadius); // twirl a += strength; // new position for the particle that's affected by gravity var p = rotate(a, dist, mouse.x, mouse.y); particle.position.affected.x = p.x; particle.position.affected.y = p.y; } context.beginPath(); context.rect(particle.position.affected.x-1, particle.position.affected.y-1, 2, 2); context.fillStyle = "black"; context.fill(); }); } draw(); window.addEventListener("mousemove", function (e) { mouse.x = e.x - canvas.offsetLeft; mouse.y = e.y - canvas.offsetTop; requestAnimationFrame(draw); }); function easeOutQuad(t, b, c, d) { t /= d; return -c * t*(t-2) + b; }; function angle(originX, originY, targetX, targetY) { var dx = targetX - originX; var dy = targetY - originY; var theta = Math.atan2(dy, dx) * (180 / Math.PI); if (theta < 0) theta = 360 + theta; return theta; } Number.prototype.map = function (in_min, in_max, out_min, out_max) { return (this - in_min) / (in_max - in_min) * (out_max - out_min) + out_min; }; function pos(x, y, angle, length) { angle *= Math.PI / 180; return { x : Math.round(x + length * Math.cos(angle)), y : Math.round(y + length * Math.sin(angle)), }; } function rotate(angle, distance, originX, originY) { return { x : originX + Math.cos(angle * Math.PI/180) * distance, y : originY + Math.sin(angle * Math.PI/180) * distance, } }
* { margin: 0; padding: 0; box-sizing: inherit; line-height: inherit; font-size: inherit; font-family: inherit; } body { font-family: sans-serif; box-sizing: border-box; background-color: hsl(0, 0%, 90%); } canvas { display: block; background: white; box-shadow: 0 0 2px rgba(0, 0, 0, .2), 0 1px 1px rgba(0, 0, 0, .1); margin: 20px auto; }
<canvas id="canvas"></canvas>