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]]; }