Rope physics with p5.js

Sunday, July 17 at 10 AM (Edited Tuesday, April 4 at 7 PM)

Wow this sounds complicated

The general idea of this simulation is to use constraints thorugh Verlet integration to achieve rope physics. There are a few initial constants that I want to include:

  • Gravity: The gravity in the simulation
  • Tension: How much the segments of rope are allowed to change in length
  • Friction: I need air friction to prevent chaotic movement
  • Iteration count: How many times should I apply the physics to each rope segment each frame? (Note: This does not include gravity). More times will make the simulation both look faster and be more stable. Increasing this requries a decrease in gravity to look similar.

Starting out - Creating point and vector classes

Vectors

To start out I need to have a way to add, subtract, normalize and divide vectors.

💡 A vector is an object with an x and y component, it can represent a point, or a speed in two dimensions, or even a direction

Operations on vectors:

  • Add: Add two vectos together, this is as simple as adding the x and y components, e.g. (3, 5) + (1, 2) would be equal to (3 + 1, 5 + 2) = (4, 7).
  • Subtract: This is just subtracting each component of a vector (a, b) + (e, f) = (a - e, b - f)
  • Multiplication: You're not in kindergarten, you understand how this works now
  • Division: etc etc etc
  • Normalization: This means making the magnitude of a vector 1, the magnitude of a vector is calculated like this: |(x, y)| = sqrt(x^2 + y^2).
    • To make this 1 we do the following: (x, y) = (x / magnitude, y / magnitude) (where magnitude is calculated as above)

To achieve all of this I created the following JavaScript class object:

See code
class Vector {
  constructor(x, y){
    Object.assign(this, {x, y});
  }
  // Clone this vector
  clone(){
    return new Vector(this.x, this.y);
  }
  // Distance to another vector
  distance(vector){
    vector = this._v(vector);
    return Math.sqrt((vector.x - this.x)**2 + (vector.y - this.y) **2);
  }
  // Add
  add(vector){
    vector = this._v(vector);
    const a = this.clone();
    a.x += vector.x;
    a.y += vector.y;
    return a;
  }
  // Subtract
  subtract(vector){
    vector = this._v(vector);
    const a = this.clone();
    a.x -= vector.x;
    a.y -= vector.y;
    return a;
  }
  // Multiply
  multiply(vector){
    vector = this._v(vector);
    const a = this.clone();
    a.x *= vector.x;
    a.y *= vector.y;
    return a;
  }
  // Divide by another vector
  divide(vector){
    vector = this._v(vector);
    const a = this.clone();
    a.x /= vector.x;
    a.y /= vector.y;
    return a;
  }
  // Make the magnitude 1
  normalize(){
    return new Vector(this.x / this.length, this.y / this.length);
  }
  // Magnitude of the vector (pythagorean theorum)
  get length(){
    return Math.sqrt(this.x**2 + this.y**2);
  }
  /*
  This method creates a vector based on something non-vector, so you could do this:
  let a = new Vector(0, 1);
  a.add(2);// Add 2 to both the x and y components
  a.divide(2); // Divide by 2
  */
  _v(vector){
    if (!(vector instanceof Vector)){
      if (typeof vector === "number"){
        return new Vector(vector, vector);
      }
      console.log(vector)
      throw new Error("Vector must be number or vector");
    } else if (vector.x && vector.y){
      return new Vector(vector.x, vector.y);
    }else {
      return vector;
    }
  }
}

Now I could do things like this!

let a = new Vector(3, 5);
let b = new Vector(9, 10);

let average = a.add(b).divide(2);

Points

Now that I had vectors out of the way I needed to create points, these will have a few properties:

  • Dragging: Whether the user is dragging the current point, if so, then during the update loop we'll set its position to the mouse position
  • Position: A vector representing its current position on the screen (x, y)
  • Previous position: The previous position of the current vector (can be used to infer velocity and direction)
  • Locked: Should this point get physics or not? If it's locked it won't be influenced by gravity or physics and instead serve as a point for other points to attach to.

I also wanted a few methods:

  • connect(point): This method should connect the current point to another point. This will add a Connection object to both this point and the point connected to. When rendered this will simply appear as a line.
  • addBetween(number, point): Add a certain number of points between the current point and another point. Useful for creating lines of points.
  • applyFriction(multiplier): This method will alter the previous position of the point to change its velocity by a multiplier.
See code
class Point {
  constructor({position, prevPosition, locked = false}){
    if (!prevPosition){prevPosition = position.clone()}
    Object.assign(this, {
      position, 
      prevPosition, 
      locked,
      dragging: false,
      connections: [],
    });
  }
  connect(point){
    this.connections = [...this.connections, new Connection(this, point)];
    connections = [...connections, ...this.connections.slice(-1)]
  }
  addBetween(p2, {count, ...opts}){
    let newpoints = [];
    let weight = 0;
    let inc = 1 / (count + 1);
    for (let i = 0; i < count; i++){
      weight += inc;
      let p = new Point({
        position: new Vector(mix(this.position.x, p2.position.x, weight), mix(this.position.y, p2.position.y, weight)),
        prevPosition: new Vector(mix(this.prevPosition.x, p2.prevPosition.x, weight), mix(this.prevPosition.y, p2.prevPosition.y, weight)),
        ...opts,
      })
      newpoints.push(p)
    }
    return newpoints;
  }
  get x(){return this.position.x}
  get y(){return this.position.y}
  get index(){return points.indexOf(this) < 0 ? null : points.indexOf(this)}
  applyFriction(friction){
    this.prevPosition = new Vector(
      mix(this.position.x, this.prevPosition.x, friction),
      mix(this.position.y, this.prevPosition.y, friction),
    )
  }
  draw(){
    if (this.dragging){
      strokeWeight(DRAG_DISTANCE);
      stroke("#fff2");
      point(this.x, this.y);
    }
    strokeWeight(POINT_SIZE);
    if (this.locked){
      stroke("#f55");
    } else {
      stroke("#fff");
    }
    point(this.x, this.y);
  }
}

Connections

This wasn't fancy, a Connection object should just have a draw method and be linked to two Point objects. The only important part here was that the connection have a set length that ideally doesn't change over time. This means that while simulating we can push points away until they match the length of the connection between each other. This is simple inverse kinematics (if you're a math nerd look that up, lots of really cool uses + examples).

See code
class Connection {
  // p1 and p2 should be instanceof Point
  constructor(p1, p2){
    Object.assign(this, {
      p1,
      p2,
      //Give is how stretchy it is (0-1).
      give: GIVE,
      // If give is 0 it acts like a steel bar.
      length: Math.sqrt((p2.x - p1.x)**2 + (p2.y - p1.y)**2),
    })
  }
  draw(){
    strokeWeight(LINE_WEIGHT);
    stroke("#999");
    line(this.p1.x, this.p1.y, this.p2.x, this.p2.y)
  }
}

PHYSICS!!!

Now for the fun part! Creating physics! First I created some basic boilerplate to set up the canvas, allow dragging of points, show connections between them, render the points, etc (see full code at the end), but now I wanted to create the actual physics.

Steps

  1. For each point:

a. Apply gravity, change the point's position by its velocity, bounce off the walls of the simulation, don't apply this to locked points. b. Apply physics to point c. Done

Now we'll look at the important part, "Apply physics to point". For this I wanted to run through the points, then for each point after altering it's position with gravity and velocity change its position by moving it so that the distance between it and the previous point was equal to the length assigned to the connection between said points.

The psuedo code for this is as follows:

// The more iterations the less jittery the simulation will be. I find a good iteration count is 3. 
// **Note that because gravity is not applied here they won't fall or move in a direction faster.**
for each iteration {
  for each connection between points {
    // Center of the connection, calculated by p1 and p2 positions
    center = average of p1 and p2 positions
    direction = normalize(p1 - p2) // Calculate the direction by subtracting the points to get the direction between them then normalize the vector
    magnitude = connection.length // Remember that **this is set and will never changed. It is calculated on connection initialization**
    if p1 isn't locked:
      // Center is halfway between the points
      // Direction is the direction between p1 and p2
      // Connection.length never changes and represents how far away the points should be.
      // By scaling direction by half of the length
      //    of the connection, we end up with a position
      //    that represents the ideal end of the connection where p1 should be
      // Distance from center and either point is half of the magnitude. So simply adding half of the magnitude will result in a point that's at the end of the connection.
      p1 position = center + (direction * (connection.length / 2))
    if p2 isn't locked:
      // Here we're moving the starting point to the ideal start position of the connection
      p2 position = center - (direction * (connection.length / 2))
  }
}

The real code for this is as follows:

for (let i = 0; i < ITERATION_COUNT; i++){
  for (let connection of connections){
    //Find the center
    let center = connection.p1.position.add(connection.p2.position).divide(2);
    // Make it's magnitude 1
    let dir = connection.p1.position.subtract(connection.p2.position).normalize();
    if (!connection.p1.locked){
      let newpos = center.add(dir.multiply(connection.length / 2));
      connection.p1.position = connection.p1.position.multiply(connection.give).add(newpos.multiply(1 - connection.give));
    }
    if (!connection.p2.locked){
      let newpos = center.subtract(dir.multiply(connection.length / 2));
      connection.p2.position = connection.p2.position.multiply(connection.give).add(newpos.multiply(1 - connection.give));
    }
  }
}

You can view the full code here, or play with it below:

Recent posts

Cryptography using JavaScript

JavaScript Web API Crypto API

How to guide for hashing, signing, encryption, debunking VPNs and more!

Creating an end to end encrypted authenticator and password manager

Auth Security Encryption Hashing

The process and data flow of implementing a super-secure 0 knowledge storage system, where all data is securely encrypted

Explaining code non-coders

Explaining Code How to

How to explain code and generate interest in your projects if the person you're explaining it to doesn't code

How I made working code examples in my blog

Code editors iframes interactive

How I created a working code editor that I can now embed in my blog to demonstrate and run code!