Log in to post a comment.

// Mini 2D Physics Engine. Created by Reinder Nijhoff 2021 - @reindernijhoff
//
// https://turtletoy.net/turtle/7f067f9322#
//
// A simple 2D Physics Engine. Based on work by @MaximeEuziere: 
// https://github.com/xem/mini2Dphysics/blob/gh-pages/index.html
// https://github.com/xem/js1k19/blob/gh-pages/emojysics/index.html
//

const rectAngle = -0.5; // min=-1.6, max=1.6, step=0.01
const drawLastFrames = 1; // min=0, max=1, step=1 (No, Yes)
const GRAVITY = [0, 100];
const CIRCLE = 0;
const RECTANGLE = 1;
const turtle = new Turtle();

Canvas.setpenopacity(drawLastFrames ? .2 : .15);

//
// CollisionInfo
//

class CollisionInfo {
    constructor(depth, normal, start) {
    	this.depth  = depth;
    	this.normal = normal;
    	this.start  = start;
    	this.end = add(start, scale(normal, depth));
    }
    changeDirection() {
        this.normal = scale(this.normal, -1);
        [this.start, this.end] = [this.end, this.start]; 
    }
}

//
// RigidBody
//

class RigidBody {
	constructor(center, radius, mass, friction, restitution) {
	    this.type = CIRCLE;
	    
		this.center = [...center];
		this.velocity = [0, 0];
		this.acceleration = mass ? [...GRAVITY] : [0, 0];
		
		this.angle = 0; 
		this.angleVelocity = 0; 
		this.angleAcceleration = 0; 
		
		this.inverseMass = mass ? 1 / mass : 0; // (0 if immobile) 
		this.inertia = mass > 0 ? (mass * radius ** 2) / 12 : 0; // inertia of circle
		
		this.friction = friction;
		this.restitution = restitution;
		
		this.boundingRadius = radius;
	}

    update(dt) {
    	this.velocity = add(this.velocity, scale(this.acceleration, dt));
    	this.angleVelocity += this.angleAcceleration * dt;
    	
    	this.move(scale(this.velocity, dt));
    	this.rotate(this.angleVelocity * dt);
    }

	move(v) {
		this.center = add(this.center, v);
		return this;
	}

	rotate(angle) {
		this.angle += angle;
		return this;
	}
	
	draw() {
	}
}

//
// Circle
//

class Circle extends RigidBody {
    draw(turtle) {
        turtle.jump(this.center[0], this.center[1]-this.boundingRadius);
        turtle.circle(this.boundingRadius)
    }
}

//
// Rectangle
//

class Rectangle extends RigidBody {
	constructor(center, width, height, mass, friction, restitution) {
		super(center, Math.hypot(width, height), mass, friction, restitution);

        this.type = RECTANGLE;
		this.width = width;
		this.height = height;
		this.inertia = mass > 0 ? 1 / (mass * (width ** 2 + height ** 2) / 12) : 0;
		
		this.normals = []; // face normals
		this.vertices = [  // Vertex: 0: TopLeft, 1: TopRight, 2: BottomRight, 3: BottomLeft
		    add(this.center, [-width/2, -height/2]),
		    add(this.center, [ width/2, -height/2]),
		    add(this.center, [ width/2,  height/2]),
		    add(this.center, [-width/2,  height/2])
		];

		this.computeRectNormals();
	}

	move(v) {
		super.move(v);

		for (let i = 4; i--;) {
			this.vertices[i] = add(this.vertices[i], v);
		}
		return this;
	}

	rotate(angle) {
		super.rotate(angle);

		for (let i = 4; i--;) {
			this.vertices[i] = rotate(this.vertices[i], this.center, angle);
		}
		this.computeRectNormals();
		return this;
	}

	computeRectNormals() {
		for (let i = 4; i--;) {
			this.normals[i] = normalize(sub(this.vertices[(i + 1) % 4], this.vertices[(i + 2) % 4]));
		}
	}
	
	draw(turtle) {
        turtle.jump(this.vertices[this.vertices.length-1]);
        this.vertices.forEach(vertex => turtle.goto(vertex));
    }
}

//
// Physics2D
//

class Physics2D {
    constructor() {
        this.objects = [];
    }
    
    add(s) {
        this.objects.push(s);
    }

    testCollision(c1, c2) {
    	// Circle vs circle
    	if (c1.type == CIRCLE && c2.type == CIRCLE) {
            return testCollisionCircleCircle(c1, c2);
    	}
    	// Rect vs Rect
    	if (c1.type == RECTANGLE && c2.type == RECTANGLE) {
    		return testCollisionRectRect(c1, c2);
    	}
    	// Rectangle vs Circle
    	if ((c1.type == RECTANGLE && c2.type == CIRCLE) || (c1.type == CIRCLE && c2.type == RECTANGLE)) {
            return testCollisionRectSphere(c1, c2);
    	}
    }
    
    resolveCollision(s1, s2, collisionInfo) {
    	if (!s1.inverseMass && !s2.inverseMass) {
    		return;
    	}
    
    	// Make sure the normal is always from s1 to s2
		if (dot(collisionInfo.normal, sub(s2.center, s1.center)) < 0) {
			collisionInfo.changeDirection();
		}
    
        // First, correct the two shapes positions
        // The two shapes  are moved away from each other along N (the collision's normal) and proportionally to their masses.
        // The move distance is not equal to their interpenetration, but scaled down using a position correction rate (here, 0.8).
        // This rate makes the resolution of multiple collisions on the same shape smoother.
        
    	const num = 1 / (s1.inverseMass + s2.inverseMass);

    	s1.move(scale(collisionInfo.normal, -s1.inverseMass * num * (collisionInfo.depth * 0.8)));
    	s2.move(scale(collisionInfo.normal,  s2.inverseMass * num * (collisionInfo.depth * 0.8)));

    	// the direction of collisionInfo is always from s1 to s2
    	// but the Mass is inversed, so start scale with s2 and end scale with s1
    	const start = scale(collisionInfo.start, s2.inverseMass * num);
    	const end   = scale(collisionInfo.end,   s1.inverseMass * num);
    	const p     = add(start, end);
    	// r is vector from center of object to collision point
    	const r1 = sub(p, s1.center);
    	const r2 = sub(p, s2.center);
        // newV = velocity + angleVelocity cross r
    	const v1 = add(s1.velocity, [-s1.angleVelocity * r1[1], s1.angleVelocity * r1[0]]);
    	const v2 = add(s2.velocity, [-s2.angleVelocity * r2[1], s2.angleVelocity * r2[0]]);

    	const relativeVelocity  = sub(v2, v1);

    	// if objects moving apart ignore
    	if (dot(relativeVelocity, collisionInfo.normal) > 0) {
    		return;
    	}

        //
        // Restitution
        //
    
    	const newRestitution = Math.min(s1.restitution, s2.restitution);
    	
		// Compute jN
    	const jN = -(1 + newRestitution) * dot(relativeVelocity, collisionInfo.normal) / 
    	            (s1.inverseMass + s2.inverseMass + 
    	                cross(r1, collisionInfo.normal)**2 * s1.inertia + cross(r2, collisionInfo.normal)**2 * s2.inertia);

		// Compute impulse
    	let impulse = scale(collisionInfo.normal, jN);
    
    	// Update the velocity and angular velocity
    	s1.velocity = sub(s1.velocity, scale(impulse, s1.inverseMass));
    	s2.velocity = add(s2.velocity, scale(impulse, s2.inverseMass));
    	s1.angleVelocity -= cross(r1, collisionInfo.normal) * jN * s1.inertia;
    	s2.angleVelocity += cross(r2, collisionInfo.normal) * jN * s2.inertia;

        //
        // Friction
        //

    	const newFriction    = Math.min(s1.friction, s2.friction);
    	
    	let tangent = sub(relativeVelocity, scale(collisionInfo.normal, dot(relativeVelocity, collisionInfo.normal)));
    	if (dot(tangent, tangent) == 0) {
    	    return;
    	}
        tangent = normalize(tangent);

        // Compute jT
    	const jT = -(1 + newRestitution) * dot(relativeVelocity, tangent) * newFriction / 
    	            (s1.inverseMass + s2.inverseMass + 
    	                cross(r1, tangent)**2 * s1.inertia + cross(r2, tangent)**2 * s2.inertia);

        // compute angular impulse
    	impulse = scale(tangent, jT);
    	
    	// Update the velocity and angular velocity
    	s1.velocity = sub(s1.velocity, scale(impulse, s1.inverseMass));
    	s2.velocity = add(s2.velocity, scale(impulse, s2.inverseMass));
    	s1.angleVelocity -= cross(r1, tangent) * jT * s1.inertia;
    	s2.angleVelocity += cross(r2, tangent) * jT * s2.inertia;
    }
    
    step(dt, steps = 5) {
        for (let k = 0; k<steps; k++) { 
        	for (let i = 0; i < this.objects.length; i++) {
        		for (let j =i + 1; j < this.objects.length; j++) {
        		    const s1 = this.objects[i], s2 = this.objects[j]
        		    
        			if (dist(s1.center, s2.center) <= s1.boundingRadius + s2.boundingRadius) {
                        const collisionInfo = this.testCollision(s1, s2);
        				if (collisionInfo) {
        					this.resolveCollision(s1, s2, collisionInfo);
        				}
        			}
        		}
        	}
            // Update scene
            this.objects.forEach( object => object.update(dt/steps) );
        }
    }
    
    draw(turtle) {
        this.objects.forEach( object => object.draw(turtle) );
    }
}

//
// Test scene
//

function ran(min, max) {
    return Math.random()*(max-min)+min;
}

const world = new Physics2D();

world.add(new Rectangle([0,   0], 100, 20, 0, 1, .5).rotate(rectAngle + Math.PI));
world.add(new Rectangle([0, 100], 200, 10, 0, 1, .5));

for (let i = 0; i < 50; i++) {
 	world.add(new Circle   ([ran(-100,100), ran(-150,-50)], ran(1, 4),              ran(0.1, 2), ran(0, 0.5), ran(0, 0.5)).rotate(ran(0, 7)));
 	world.add(new Rectangle([ran(-100,100), ran(-150,-50)], ran(5, 15), ran(1, 10), ran(0.1, 2), ran(0, 0.5), ran(0, 0.5)).rotate(ran(0, 7)));
}

const steps = 200;
    
function walk(i) {
    world.step(1/60, 20);
    
    if (!drawLastFrames || i >= steps - 10) {
        world.draw(turtle);
    }
    
	return i < steps;
}

//
// Collision tests
//

function testCollisionCircleCircle(c1, c2) {
    const diff = sub(c2.center, c1.center);
	const rSum = c1.boundingRadius + c2.boundingRadius;
	const dst2 = dot(diff, diff);

	if (dst2 < rSum**2 && dst2 > 0) {
		// overlapping but not same position
		const n = normalize(diff);
		return new CollisionInfo(rSum - Math.sqrt(dst2), n, sub(c2.center, scale(n, c2.boundingRadius)));
	}
}

function testCollisionRectRect(c1, c2) {
    const collisionInfoR1 = findAxisLeastPenetrationRectRect(c1, c2); 
    if (collisionInfoR1) {
        
        const collisionInfoR2 = findAxisLeastPenetrationRectRect(c2, c1);
		if (collisionInfoR2) {
			// if both of rectangles are overlapping, choose the shorter normal as the normal
			if (collisionInfoR1.depth < collisionInfoR2.depth) {
				return new CollisionInfo(collisionInfoR1.depth, collisionInfoR1.normal, 
				                         sub(collisionInfoR1.start, scale(collisionInfoR1.normal, collisionInfoR1.depth)));
			} else {
				return new CollisionInfo(collisionInfoR2.depth, scale(collisionInfoR2.normal, -1), collisionInfoR2.start);
			}
		}
	}
}

function testCollisionRectSphere(c1, c2) {
	// (c1 is the rectangle and c2 is the circle, invert the two if needed)
	if (c1.type == CIRCLE && c2.type == RECTANGLE) {
		[c1, c2] = [c2, c1];
	}

	let	inside = true, bestDistance = -1e9, nearestEdge = 0;
	
	for (let i = 0; i < 4; i++) {
		// find the nearest face for center of circle    
		const v = sub(c2.center, c1.vertices[i]);
		const projection = dot(v, c1.normals[i]);

		if (projection > 0) {
			// if the center of circle is outside of c1angle
			bestDistance = projection;
			nearestEdge = i;
			inside = false;
			break;
		}
		if (projection > bestDistance) {
			bestDistance = projection;
			nearestEdge = i;
		}
	}
	if (inside) {
		// the center of circle is inside of c1angle
		return new CollisionInfo(c2.boundingRadius - bestDistance, c1.normals[nearestEdge], 
		                         sub(c2.center, scale(c1.normals[nearestEdge], c2.boundingRadius)));
	} else {
		// the center of circle is outside of c1angle
		// v1 is from left vertex of face to center of circle 
		// v2 is from left vertex of face to right vertex of face
		let v1 = sub(c2.center, c1.vertices[nearestEdge]);
		let v2 = sub(c1.vertices[(nearestEdge + 1) % 4], c1.vertices[nearestEdge]);
		
		if (dot(v1, v2) < 0) {
			// the center of circle is in corner region of vertices[nearestEdge]
			const dis = length(v1);
			// compare the distance with radius to decide collision
			if (dis > c2.boundingRadius) {
				return false;
			}
			const normal = normalize(v1);
			return new CollisionInfo(c2.boundingRadius - dis, normal, add(c2.center, scale(normal, -c2.boundingRadius)));
		} else {
			// the center of circle is in corner region of vertices[nearestEdge+1]
			// v1 is from right vertex of face to center of circle 
			// v2 is from right vertex of face to left vertex of face
			v1 = sub(c2.center, c1.vertices[(nearestEdge + 1) % 4]);
			v2 = scale(v2, -1);
			
			if (dot(v1, v2) < 0) {
				const  dis = length(v1);
				// compare the distance with radium to decide collision
				if (dis > c2.boundingRadius) {
					return false;
				}
				const  normal = normalize(v1);
				return new CollisionInfo(c2.boundingRadius - dis, normal, add(c2.center, scale(normal, -c2.boundingRadius)));
			} else {
				// the center of circle is in face region of face[nearestEdge]
				if (bestDistance < c2.boundingRadius) {
					return new CollisionInfo(c2.boundingRadius - bestDistance, c1.normals[nearestEdge], 
					                         sub(c2.center, scale(c1.normals[nearestEdge], c2.boundingRadius)));
				}
			}
		}
	}
}

// Find the axis of least penetration between two rects
function findAxisLeastPenetrationRectRect(r1, r2) {
	let	supportPoint, bestDistance = 1e9, bestIndex = -1, hasSupport = true;

	for (let i=0; hasSupport && i < 4; i++) {
		// find the support on B
		let tmpSupportPointDist = -1e9;
		let tmpSupportPoint = -1;

		// check each vector of other object
		for (let j = 0; j < 4; j++) {
			const vToEdge = sub(r2.vertices[j], r1.vertices[i]);
			const projection = -dot(vToEdge, r1.normals[i]);

			// find the longest distance with certain edge
			if (projection > 0 && projection > tmpSupportPointDist) {
				tmpSupportPoint = r2.vertices[j];
				tmpSupportPointDist = projection;
			}
		}
		hasSupport = (tmpSupportPoint !== -1);

		// get the shortest support point depth
		if (hasSupport && tmpSupportPointDist < bestDistance) {
			bestDistance = tmpSupportPointDist;
			bestIndex = i;
			supportPoint = tmpSupportPoint;
		}
	}

	if (hasSupport) {
		return new CollisionInfo(bestDistance, r1.normals[bestIndex], add(supportPoint, scale(r1.normals[bestIndex], bestDistance)));
	}
}

// 
// 2D Vector math
//

function cross(a, b) { return a[0]*b[1]-a[1]*b[0]; }
function equal(a,b) { return .001>dist_sqr(a,b); }
function scale(a,b) { return [a[0]*b,a[1]*b]; }
function add(a,b) { return [a[0]+b[0],a[1]+b[1]]; }
function sub(a,b) { return [a[0]-b[0],a[1]-b[1]]; }
function dot(a,b) { return a[0]*b[0]+a[1]*b[1]; }
function dist_sqr(a,b) { return (a[0]-b[0])**2+(a[1]-b[1])**2; }
function dist(a,b) { return Math.sqrt(dist_sqr(a,b)); }
function length(a) { return Math.sqrt(dot(a,a)); }
function normalize(a) { return scale(a, 1/length(a)); }
function lerp(a,b,t) { return [a[0]*(1-t)+b[0]*t,a[1]*(1-t)+b[1]*t]; }
function rotate(v, center, angle) {
    const x = v[0] - center[0], y = v[1] - center[1];
    return [x * Math.cos(angle) - y * Math.sin(angle) + center[0], x * Math.sin(angle) + y * Math.cos(angle) + center[1]];
}