javascriptcanvasgeometrydistortion

Pinch/pucker an image in canvas


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)

enter image description here

enter image description here


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);

jsFiddle


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.


Solution

  • 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.

    enter image description here

    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>