A simple 2D Physics Engine. Based on work by @MaximeEuziere:
* github.com/xem/mini2…/gh-pages/index.html
* github.com/xem/js1k1…emojysics/index.html
#physics
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]];
}