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:
To start out I need to have a way to add, subtract, normalize and divide vectors.
Operations on vectors:
(3, 5)
+ (1, 2)
would be equal to (3 + 1, 5 + 2)
= (4, 7)
.(a, b) + (e, f) = (a - e, b - f)
|(x, y)| = sqrt(x^2 + y^2)
.
(x, y) = (x / magnitude, y / magnitude)
(where magnitude is calculated as above)To achieve all of this I created the following JavaScript class object:
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);
Now that I had vectors out of the way I needed to create points, these will have a few properties:
I also wanted a few methods:
Connection
object to both this point and the point connected to. When rendered this will simply appear as a line.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);
}
}
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).
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)
}
}
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.
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: