#Fabric

The goal here is to create an interactive fabric simulation. The link to this demo is found here.

The source code is found on the Github repo.

Setup

Before we get to writing any actual runnable code, let's create some helper functions that will make things much easier.

Drawing

We're going to define two simple drawing fucntions. The first draws a circle, the second draws a line. These both use the current stroke/fill color set by the canvas drawing context. This is all pretty basic, I'm just putting it here in case you see these functions in later code and don't recognize them. For more information, see my canvas pages.

// draw a circle at (x,y) of radius r
function drawCircle(x, y, r) {
  ctx.beginPath();
  ctx.arc(x, y, r, 0, 2*Math.PI);
  ctx.fill();
};
// draw a line from start = {x:_,y:_} to end = {x:_,y:_}
function drawLine(start, end) {
  ctx.beginPath();
  ctx.moveTo(start.x, start.y);
  ctx.lineTo(end.x, end.y);
  ctx.stroke();
};

Vectors

We're going to define four simple functions for vector math. For concision I used ES6 arrow notation, but I'll also explain them using traditional function notation.

The functions are: dist for euclidean distance, normVec for normalization, scale for scalar multiplication, and add for componentwise addition.

const dist = (point1,point2) => Math.pow(Math.pow(point1.x-point2.x,2)+Math.pow(point1.y-point2.y,2), 0.5);
const normVec = (vec) => {let mag = dist(vec, VEC_ZERO); return {x:vec.x/mag, y:vec.y/mag}};
const scale = (vec, scalar) => ({x: vec.x*scalar, y:vec.y*scalar});
const add = (vec1, vec2) => ({x:vec1.x+vec2.x, y:vec1.y+vec2.y});

Distance

The distance between two vectors $\vec{v_1} = (x_1, y_1)$ and $\vec{v_2} = (x_2, y_2)$ is defined using the Pythagorean Theorem:

$$d(\vec{v_1}-\vec{v_2}) = \sqrt{(x_1-x_2)^2+(y_1-y_2)^2}$$

The resulting function is a direct application of ES6 arrow notation.

const dist = (point1,point2) => Math.pow(Math.pow(point1.x-point2.x,2)+Math.pow(point1.y-point2.y,2), 0.5);

//equivalent syntax
function dist(point1, point2) {
    return Math.pow(Math.pow(point1.x-point2.x,2)+Math.pow(point1.y-point2.y,2), 0.5);
}

Normalization

In order to normalize a vector, we divide it by its magnitude. For some vector $\vec{v} = (x, y)$, where we have a magnitude $|\vec{v}|=d(\vec{v}-(0,0))$ we have:

$$\text{norm}(\vec{v})=(x/|\vec{v}|,y/|\vec{v}|)$$

Again, we use arrow notation. However, we use the return syntax so that we do not calculate the magnitude twice.

//earlier on
const VEC_ZERO = {x:0, y:0};
...

const normVec = (vec) => {let mag = dist(vec, VEC_ZERO); return {x:vec.x/mag, y:vec.y/mag}};

//equivalent syntax
function normVec(vec) {
    let mag = dist(vec, VEC_ZERO);
    return {x:vec.x/mag, y:vec.y/mag};
}

//functional, but inefficient syntax
const normVec = (vec) => {x:vec.x/dist(vec, VEC_ZERO), y:vec.y/dist(vec, VEC_ZERO)};

Scalar Multiplication

We use the standard definition of scalar multiplication on a vector.

$$c(x,y) = (cx, cy)$$

The resulting function:

const scale = (vec, scalar) => ({x: vec.x*scalar, y:vec.y*scalar});

// equivalent syntax
function scale(vec, scalar) {
    return {x: vec.x*scalar, y:vec.y*scalar};
}

Addition

We use the standard definition of addition on two vectors.

$$(x_1, y_1) + (x_2, y_2) = (x_1+x_2, y_1+y_2)$$

The resulting function:

const add = (vec1, vec2) => ({x:vec1.x+vec2.x, y:vec1.y+vec2.y});

// equivalent syntax
function add(vec1, vec2) {
    return {x:vec1.x+vec2.x, y:vec1.y+vec2.y};
}

Fundamental Physics

We need to start off by modeling our desired effect using basic physics. We're going to model the fabric as a matrix of point masses with springs between each mass. As a system, this collection of masses and springs should exhibit fabric-like behavior.

Calculating Movement (Calculus)

As with most physics simulations, the key here is in understanding movement. Since our system is really just a system of particles, all we need to do is calculate the net force on each particle for each frame. We use this force to calculate acceleration, velocity, and position.

In code, I add this method to each point object:

getForce: function() {
    return this.forces.reduce((acc, curr) => ({x:acc.x+curr.x, y:acc.y+curr.y}),{x:0, y:0});
}

forces is an array which keeps track of all the forces on each point. In our case, the only forces are gravity and spring forces.

Here, we use ES6 arrow notation to return a vector obtained by component-wise adding up the values of each force by accumulating values over the starting vector {x:0, y:0}.

To get realistic movement, we use a naive integration function on the time step. All this means is that we directly use the definitions of velocity and acceleration to calculate position. Velocity is defined as the instantaneous change in position (derivative of position with respect to time), which means we can calculate the position of the next frame by adding the velocity to the position. Similarly, acceleration is the instantaneous change in velocity (derivative of velocity with respect to time), we can just add the acceleration fo the velocity. (If you don't understand the derivative stuff, don't worry too much about it.)

In order to make the movement more natural, we also add fluid damping. This just means that we're going to account for fluid friction (like air resistance) by introducing a slowing factor proportional to the velocity. Think about trying to swim through water. The faster you move your hand, the more you feel the water push back.

for(let i=0; i<points.length; i++) {
    // calculate forces
    points[i].forces.push({x:0, y:0.2}); //apply gravity
    let netForce = points[i].getForce(); // calculate resulting force
    points[i].forces = []; // reset forces

    points[i].ax = netForce.x;
    points[i].ay = netForce.y;

    // simple integration
    points[i].vx+=points[i].ax;
    points[i].vy+=points[i].ay;
    points[i].x+=points[i].vx;
    points[i].y+=points[i].vy;
    ...
    // fluid damping
    points[i].ax -= 0.4 * points[i].vx;
    points[i].ay -= 0.4 * points[i].vy;

}

At the line let netForce = points[i].getForce(); we see the getForce() function that we defined earlier. We use a forces array to manage our forces: at the start of each frame, we clear the array. Then, (somewhere else) we populate it with each spring force. Then, we push a gravity vector. We use our getForce to merge this list before we clear it.

Springs

The force produced by each spring follows Hooke's law, where the force is proportional to the displacement. See this page for more information.

We're going to model our springs using Hooke's law. Let's take a look at our complete Constraint prototype, where a Constraint is a spring:

// create a constraint b/t two points
function Constraint(point1, point2) { 
    // create internal references (pointers) to points that are in the global
    // this is so that we can access them later internally
    this.start = point1; 
    this.end = point2;

    this.length = dist(point1,point2); // resting length
    this.k = 0.02; // spring constant
    this.applyForce = function() {
        let delx = dist(this.start,this.end)-this.length; // displacement
        const mypoints = [this.start, this.end];
        for(let i=0; i<mypoints.length; i++) { // do it for both points
            const thispoint = mypoints[i];
            const otherpoint = mypoints[(i+1)%2]; // this is a fun hack

            // find the direction of the Hooke's law force
            // normalize the difference vector
            let normalized = normVec({
                x:otherpoint.x-thispoint.x, 
                y:otherpoint.y-thispoint.y
            });
            // now scale the normalized vector by magnitude (k del x)
            let force = scale(normalized, this.k*delx);
            // finally, add the force to the forces array of each point
            thispoint.forces.push(force);
        }
    }
}

The structure of this prototype is pretty straightforward. We only have four attributes. start and end are just pointers that allow us to internally access the global array of vertices. k and length are attributes of the spring representing its strength and resting length, respectively.

Let's look a bit more in-depth at the applyForce function. First, we find the displacement in order to use it for the Hooke's law calculation.

this.applyForce = function() {
    let delx = dist(this.start,this.end)-this.length; // calculate displacement
    const mypoints = [this.start, this.end];
    for(let i=0; i<mypoints.length; i++) {
        const thispoint = mypoints[i];
        const otherpoint = mypoints[(i+1)%2];

        let normalized = normVec({
            x:otherpoint.x-thispoint.x, 
            y:otherpoint.y-thispoint.y
        });
        let force = scale(normalized, this.k*delx);
        thispoint.forces.push(force);
    }
}

Program Flow

With everything set up, we can finally go to creating the running executable code of the program. We can model this code in three component: initialization, animation loop, and action listeners. This is pretty standard architecture for any kind of interactive code.

Initialization

In the initialization (sometimes called init) phase, we run all the code that runs before the user is able to interact with the canvas. This includes basic canvas functions, such as setting up a drawing context, and populating the document with our objects (vertices and springs).

First, we set up our basic canvas stuff:

// basic canvas stuff
let canvas = document.getElementById("canvas"); 
let ctx = canvas.getContext('2d');

// this is exclusively so we can use jQuery to handle the resize event later on
let canv = $("canvas"); 
canv.attr('width',$('body').width());
canv.attr('height',$('body').height());

We use the canv object as a wrapper just so that we can use jQuery to handle dimension stuff. Technically, this is less efficient, as it requires loading the additional resource of jQuery, but it makes coding a lot easier so we'll just do it this way.

We have to run canv.attr(...) for both height and width so that we can use those dimensions in the following calculation:

let dist_between = 50; // dist b/t each vertex

// calculate maximum # of vertices across and down
let max_across = Math.floor(canvas.width/dist_between)-4;
let max_down = Math.floor(canvas.height/dist_between)-5;

// margins b/t fabric and sides of document
let offset_x = (canvas.width-dist_between*(max_across-1))/2; //left margin
let offset_y = dist_between; // top margin

dist_between is the number of pixels, vertically or horizontally, between each vertex. max_across and max_down are the number of rows and columns in our fabric. We calculate the largest number of vertices that can fit (canvas.width/dist_between), floor it to the closest integer, and then subtract a few so that we have a margin.

At the end, we calculate our margins. We just use a dist_between vertical margin. Our horizontal margin is the difference between the canvas width canvas.width and the fabric width dist_between*(max_across-1) (note that we subtract 1 because we create a vertex at both ends) divided by 2.

Now, we populate an array of vertices.

let points = []; // contains all the vertices

// add all the vertices to the points array
for(let i=0; i<max_across; i++) { // iterate across and down
  for(let j=0; j<max_down; j++) {
    points.push({
      x:offset_x+i*dist_between, // position
      y:offset_y+j*dist_between,
      vx: 0, // velocity
      vy: 0,
      ax: 0, // acceleration
      ay: 0,
      static: j==0, // if it's the top row, it can't move
      forces: [], // list of forces
      getForce: function() { // accumulator function for the forces
        return this.forces.reduce((acc, curr) => ({x:acc.x+curr.x, y:acc.y+curr.y}),{x:0, y:0});
      },
      clicking:false // is it being held down by the mouse?
    });
  }
}

We go across all max_across columns and max_down rows and populate the fabric vertex by vertex by filling a points array. The position parameter is calculated by takin the between-vertex spacing and multiplying it by the column/row number, offset by the margin. The velocity and acceleration are both initialized at 0. static is a boolean variable which signals if a point is on the top row or not - if so, it cannot move. (We'll deal with this in the draw function.) forces and getForce are used to handle movement, as mentioned in the Calculating Movement section. clicking is used to handle click events, as we'll see later.

Finally, we'll populate the document with constraints, using the Constraint prototype described earlier:

let constraints = []; // array of all springs

// make constraints across
for(var i=0; i<max_across; i++) {
  for(var j=0; j<max_down-1; j++) {
    constraints.push(new Constraint(points[i*max_down+j],points[i*max_down+j+1]));    
  }
}
//make constraints down
for(var i=0; i<max_down; i++) {
  for(var j=0; j<max_across-1; j++) {
    constraints.push(new Constraint(points[j*max_down+i],points[(j+1)*max_down+i]));    
  }
}
//make constraints diagonally
for(var i=0; i<max_across-1; i++) {
  for(var j=0; j<max_down-1; j++) {
    constraints.push(new Constraint(points[i*max_down+j],points[(i+1)*max_down+j+1]));    
  }
}

This is all fairly straightforward. First, we make all the horizontal springs, then all the vertical ones, then all the diagonal ones.

Animation Loop

The animation loop is a function that runs every frame. Here's what it looks like in full:

function reDraw() { // animation step
  //reset 
  canv.attr('width',$('body').width());
  canv.attr('height',$('body').height());

  ctx.fillStyle = "#222244";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  //update constraints
  ctx.strokeStyle="#FFFFFF";
  for(let i=0; i<constraints.length; i++) {
    constraints[i].applyForce();
    drawLine(constraints[i].start, constraints[i].end);
  }

  //redraw + update points
  for(let i=0; i<points.length; i++) {
    if(!points[i].static) {
      points[i].x+=points[i].vx;
      points[i].y+=points[i].vy;

      if(points[i].clicking) {
        points[i].x += click.vx;
        points[i].y += click.vy;
        points[i].vx = 0; // comment these 4 line out to make the points snap back
        points[i].vy = 0;
        points[i].ax = 0;
        points[i].ay = 0;
      } else {
        points[i].vx+=points[i].ax;
        points[i].vy+=points[i].ay;

        points[i].forces.push({x:0, y:0.2}); //apply gravity
        let netForce = points[i].getForce();
        points[i].forces = [];

        points[i].ax = netForce.x;
        points[i].ay = netForce.y;

        points[i].ax -= 0.4 * points[i].vx;
        points[i].ay -= 0.4 * points[i].vy;
      }
    }

    ctx.fillStyle="white";  
    drawCircle(points[i].x,points[i].y,5);
  }
  window.requestAnimationFrame(reDraw);
}
window.requestAnimationFrame(reDraw);

Let's go through this component by component. First of all, we use window.requestAnimationFrame to set up the animation loop.

At the start of every frame, we reset the canvas.

// set dimensions to fill the screen - this is just to make things look nice when the window is moved around
canv.attr('width',$('body').width());
canv.attr('height',$('body').height());
// redraw the background
ctx.fillStyle = "#222244";
ctx.fillRect(0, 0, canvas.width, canvas.height);

Next, we iterate through all the constraints and handle the forces.

ctx.strokeStyle="#FFFFFF"; // set the canvas draw color
for(let i=0; i<constraints.length; i++) {
    constraints[i].applyForce(); // apply the force to the vertices at either end
    drawLine(constraints[i].start, constraints[i].end); // draw the constraint
}

Finally, we iterate through the particles:

for(let i=0; i<points.length; i++) {
    if(!points[i].static) { // if it's static don't do anything
        points[i].x+=points[i].vx; // integrate velocity
        points[i].y+=points[i].vy;

        if(points[i].clicking) { // we'll get to this later, just ignore it for now
            points[i].x += click.vx;
            points[i].y += click.vy;
            points[i].vx = 0;
            points[i].vy = 0;
            points[i].ax = 0;
            points[i].ay = 0;
        } else {
            points[i].vx+=points[i].ax; // integrate acceleration
            points[i].vy+=points[i].ay;

            points[i].forces.push({x:0, y:0.2}); //apply gravity
            // merge all forces into a single value, and reset the forces array
            let netForce = points[i].getForce(); 
            points[i].forces = [];

            // newton's law: f = ma (where m = 1)
            points[i].ax = netForce.x;
            points[i].ay = netForce.y;

            // fluid damping
            points[i].ax -= 0.4 * points[i].vx;
            points[i].ay -= 0.4 * points[i].vy;
        }
    }
    // draw the new position of each vertex
    ctx.fillStyle="white";  
    drawCircle(points[i].x,points[i].y,5);
}

Action Listeners

We want to make it possible to play with and move around the fabric. The objective is to make it feel natural and like real fabric. When you click down, you take a portion of the fabric with you, and when you release the click, you release the fabric at that point.

Here's the click handler:

// this click stores mouse information
let click = {x: 0, y:0, vx:0, vy:0};

// update position of mouse on mousemove
$(canvas).mousemove(function(e) {
    // update the position of the mouse relative to the canvas
    let newx = e.pageX - $(canvas).offset().left;
    let newy = e.pageY - $(canvas).offset().top;
    click.vx = newx-click.x;
    click.vy = newy-click.y;
    click.x = newx;
    click.y = newy;
}).mousedown(function() { // grab all points in a 100 px radius on mousedown
    for(let i=0; i<points.length; i++) {
        if(dist(click, points[i]) < 100) {
            points[i].clicking = true;
        }
    }
}).mouseup(function() { // release all points on mouseup
    for(let i=0; i<points.length; i++) {
        points[i].clicking = false;
    }
});

First, we create an object click = {x: 0, y:0, vx:0, vy:0} which can be accessed from anywhere in the code for mouse data.

In the mousemove portion, the let newx = ..., let newy = ... lines update the stored position of the mouse relative to the canvas. This is so that we can use an <iframe> or something and our positioning won't be thrown off. Then, we calculate a mouse position click.x,click.y and a mouse velocity click.vx, click.vy.

On mousedown, we set point.clicking in each point within a 100 pixel radius to be true, so that we can handle the behavior in the animation loop.

On mouseup, we release all points by setting point.clicking to false.

Finally, let's look at the click handling of the animation loop:

reDraw() {
    ...
    if(points[i].clicking) { // we'll get to this later, just ignore it for now
        points[i].x += click.vx;
        points[i].y += click.vy;
        points[i].vx = 0;
        points[i].vy = 0;
        points[i].ax = 0;
        points[i].ay = 0;
    }
    ...
}

If a point is being held (point.clicking==true), we suspend normal movement calculations (vx, vy, ax, ay = 0) and handle them separately using the click. To do this, we simply make the points move with the mouse by integrating the mouse velocity onto the point position.

Final Thoughts

There are a still a lot of ways to improve this demo: