blaze-2d
Version:
A fast and simple WebGL 2 2D game engine written in TypeScript
396 lines • 17.1 kB
JavaScript
import { vec2 } from "gl-matrix";
import { cross2DWithScalar } from "../utils/vectors";
import CircleCollider from "./collider/circle";
import Physics from "./physics";
import { calculateRelativeVelocity } from "./solvers/collision/impulse";
/**
* Information about a collision which has occured between two objects.
*/
export default class Manifold {
/**
* Creates a {@link Manifold} describing a collision between **a** and **b** in detail.
*
* @param a First collision object
* @param b Second collision object
* @param collision {@link CollisionResult} from collision test
* @param gravity The collision world's gravity vector
* @param delta The time since the last update
*/
constructor(a, b, collision, gravity, delta) {
/**
* store used edges [inc, ref]
*/
this.edges = [];
this.positionImpulse = {
a: vec2.create(),
b: vec2.create(),
};
this.isDead = false;
this.a = a;
this.b = b;
this.depth = collision.depth;
this.normal = collision.normal;
this.penetration = vec2.scale(vec2.create(), this.normal, this.depth);
// calculate restitution
// smallest value from a and b is used
this.epsilon = Math.min(a.restitution, b.restitution);
// friction
this.sf = Math.sqrt(a.staticFriction * b.staticFriction);
this.df = Math.sqrt(a.dynamicFriction * b.dynamicFriction);
this.contactPoints = this.calculateContactPoints();
this.a.totalContacts += this.contactPoints.length;
this.b.totalContacts += this.contactPoints.length;
// add missing properties to contacts
for (const contact of this.contactPoints) {
contact.bias = 0;
contact.impulseNormal = 0;
contact.impulseNormalPosition = 0;
contact.impulseTangent = 0;
contact.massNormal = 0;
contact.massTangent = 0;
}
// for (const p of this.contactPoints) {
// const circle = new Circle(0.001, p);
// Renderer.renderCircle(circle);
// }
// console.log(this.contactPoints.length < 2);
const g = vec2.sqrLen(vec2.scale(vec2.create(), gravity, delta)) + 0.0001;
for (const contact of this.contactPoints) {
const contactA = vec2.sub(vec2.create(), contact.point, a.getPosition());
const contactB = vec2.sub(vec2.create(), contact.point, b.getPosition());
const relativeVelocity = calculateRelativeVelocity(this, contactA, contactB);
// Determine if we should perform a resting collision or not
// The idea is if the only thing moving this object is gravity,
// then the collision should be performed without any restitution
if (vec2.sqrLen(relativeVelocity) < g) {
this.epsilon = 0;
// console.log("epsilon 0");
}
}
}
update(m) {
const newContacts = m.contactPoints;
const oldContacts = this.contactPoints;
const mergedContacts = [];
// if we have different number of contacts drop manifold
if (newContacts.length !== oldContacts.length)
return this.kill();
// merge contacts
for (let i = 0; i < newContacts.length; i++) {
const nc = newContacts[i];
let match = -1;
for (let j = 0; j < oldContacts.length; j++) {
const oc = oldContacts[j];
if (this.compareContacts(nc, oc)) {
match = j;
break;
}
}
if (match === -1) {
continue;
}
const oc = oldContacts[match];
if (Physics.G_CONF.WARM_IMPULSE) {
nc.impulseNormal = oc.impulseNormal;
nc.impulseTangent = oc.impulseTangent;
nc.impulseNormalPosition = oc.impulseNormalPosition;
}
mergedContacts.push(nc);
}
if (mergedContacts.length !== oldContacts.length)
return this.kill();
this.mergeManifold(m, mergedContacts);
}
/**
* Merge this manifold with the given manifold and contact points.
*
* @param m The manifold to merge with
* @param contacts The manifold's new contact points
*/
mergeManifold(m, contacts) {
this.isDead = false;
this.a = m.a;
this.b = m.b;
this.edges = m.edges;
this.contactPoints = contacts;
this.normal = m.normal;
this.depth = m.depth;
this.penetration = m.penetration;
this.sf = m.sf;
this.df = m.df;
this.epsilon = m.epsilon;
this.positionImpulse = m.positionImpulse;
}
/**
* Compares two contact points using a distance heuristic to determine wether they should be considered the same contact.
*
* @param c1 A contact point
* @param c2 A contact point
* @returns If the 2 contact points match
*/
compareContacts(c1, c2) {
const d = vec2.sqrDist(c1.point, c2.point);
// console.log(d, Manifold.CACHED_CONTACTS_TOLERANCE, d < Manifold.CACHED_CONTACTS_TOLERANCE);
return d <= Physics.G_CONF.CACHED_CONTACTS_TOLERANCE;
}
/**
* Marks the manifold as dead and decrements the objects involved's `totalContacts` counts.
*/
kill() {
if (this.isDead)
return;
this.isDead = true;
this.a.totalContacts -= this.contactPoints.length;
this.b.totalContacts -= this.contactPoints.length;
}
/**
* Calculates the position impulse for objects `a` and `b` in the manifold.
*
* @see [MatterJS Position Solving](https://github.com/liabru/matter-js/blob/master/src/collision/Resolver.js)
*
* @param delta The time since the last update
*/
solvePositionImpulse(delta) {
// calculate position of a and b if current position impulse was applied
const aPos = vec2.add(vec2.create(), this.positionImpulse.a, vec2.add(vec2.create(), this.b.getPosition(), this.penetration));
const bPos = vec2.add(vec2.create(), this.b.getPosition(), this.positionImpulse.b);
// calculate separation distance of aPos and bPos along normal
const bToA = vec2.sub(vec2.create(), aPos, bPos);
const separation = vec2.dot(bToA, this.normal);
let positionImpulse = (separation - Physics.G_CONF.POSITION_SLOP) * Physics.G_CONF.POSITION_SCALE;
// if (positionImpulse > 0.1) console.log(positionImpulse, delta);
// console.log(this.a.totalContacts, this.b.totalContacts);
if (this.a.isStatic || this.b.isStatic)
positionImpulse *= 2;
// apply position impulse to a and b based on total contacts
if (!this.a.isStatic)
vec2.scaleAndAdd(this.positionImpulse.a, this.positionImpulse.a, this.normal, -positionImpulse * (Physics.G_CONF.POSITION_DAMPING / this.a.totalContacts));
if (!this.b.isStatic)
vec2.scaleAndAdd(this.positionImpulse.b, this.positionImpulse.b, this.normal, positionImpulse * (Physics.G_CONF.POSITION_DAMPING / this.b.totalContacts));
}
/**
* Precompute some additional information about the contact points for impulse resolution.
*
* Calculates and applies accumulative impulse.
*
* @param delta The time since the last udpate
*/
preStepImpulse(delta) {
for (const contact of this.contactPoints) {
const contactA = vec2.sub(vec2.create(), contact.point, this.a.getPosition());
const contactB = vec2.sub(vec2.create(), contact.point, this.b.getPosition());
contact.contactA = contactA;
contact.contactB = contactB;
// compute distances along normal for contacts
const distAlongNormalA = vec2.dot(contactA, contact.normal);
const distAlongNormalB = vec2.dot(contactB, contact.normal);
// normal mass
const invMass = this.a.getInverseMass() + this.b.getInverseMass();
const invInertiaA = this.a.getInverseInertia() * (vec2.dot(contactA, contactA) - distAlongNormalA * distAlongNormalA);
const invInertiaB = this.b.getInverseInertia() * (vec2.dot(contactB, contactB) - distAlongNormalB * distAlongNormalB);
const massNormal = invMass + invInertiaA + invInertiaB;
contact.massNormal = 1 / massNormal;
const tangent = cross2DWithScalar(vec2.create(), contact.normal, 1);
contact.tangent = tangent;
// compute distance along tangent for contacts
const distAlongTangentA = vec2.dot(contactA, tangent);
const distAlongTangentB = vec2.dot(contactB, tangent);
const invInertiaTangentA = this.a.getInverseInertia() * (vec2.dot(contactA, contactA) - distAlongTangentA * distAlongTangentA);
const invInertiaTangentB = this.b.getInverseInertia() * (vec2.dot(contactB, contactB) - distAlongTangentB * distAlongTangentB);
// tangent mass
const massTangent = invMass + invInertiaTangentA + invInertiaTangentB;
contact.massTangent = 1 / massTangent;
// calculate velocity bias for restitution
const contactVelocity = vec2.dot(contact.normal, calculateRelativeVelocity(this, contactA, contactB));
contact.bias = 0;
if (contactVelocity < -Physics.G_CONF.RESTITUTION_THRESHOLD) {
contact.bias = contactVelocity * -this.epsilon;
// console.log(contact.bias);
}
// contact.bias = (-biasFactor / delta) * Math.min(0, contact.depth + allowedPenetration);
// apply accumulate impulse
if (Physics.G_CONF.ACUMMULATE_IMPULSE) {
const impulseNormal = vec2.scale(vec2.create(), contact.normal, contact.impulseNormal);
const impulseTangent = vec2.scale(vec2.create(), tangent, contact.impulseTangent);
const impulse = vec2.add(vec2.create(), impulseNormal, impulseTangent);
this.a.applyImpulse(vec2.negate(vec2.create(), impulse), contactA);
this.b.applyImpulse(impulse, contactB);
}
}
}
/**
* Calculates the contact points of the collision.
*
* @see [Dyn4j Contact Points](https://dyn4j.org/2011/11/contact-points-using-clipping/)
*
* @returns The points of contact for the collision
*/
calculateContactPoints() {
if (this.a.collider instanceof CircleCollider) {
return [
{
point: this.a.collider.findFurthestPoint(this.normal),
depth: this.depth,
normal: this.normal,
},
];
}
else if (this.b.collider instanceof CircleCollider) {
return [
{
point: this.b.collider.findFurthestPoint(vec2.negate(vec2.create(), this.normal)),
depth: this.depth,
normal: this.normal,
},
];
}
const e1 = this.bestEdge(this.a, this.normal);
const e2 = this.bestEdge(this.b, vec2.negate(vec2.create(), this.normal));
// identify reference and incident edge for clipping
let ref;
let inc;
if (Math.abs(vec2.dot(e1.e, this.normal)) <= Math.abs(vec2.dot(e2.e, this.normal))) {
ref = e1;
inc = e2;
}
else {
ref = e2;
inc = e1;
}
this.edges = [inc, ref];
// perform clipping
const refv = vec2.clone(ref.e);
vec2.normalize(refv, refv);
const o1 = vec2.dot(refv, ref.p0);
// clip the incident edge by the first vertex of the reference edge
let cp = this.clipPoints({ point: inc.p0, depth: this.depth, normal: this.normal }, { point: inc.p1, depth: this.depth, normal: this.normal }, refv, o1);
if (cp.length < 2)
return [];
// clip whats left of the incident edge by the second vertex of
// the reference edge
// but we need to clip in opposite direction so we flip the
// direction and offset
const o2 = vec2.dot(refv, ref.p1);
cp = this.clipPoints(cp[0], cp[1], vec2.negate(vec2.create(), refv), -o2);
if (cp.length < 2)
return [];
// calculate 2d vector cross product with scalar
const refNorm = cross2DWithScalar(vec2.create(), refv, -1);
ref.normal = refNorm;
// if we had to flip the incident and reference edges
// then we need to flip the ref edge normal to clip properly
// * NOTE: No need to flip normal because of how GJK and EPA are implemented.
// * see comments of dyn4j post (search for comments between May 30, 2018 and June 15, 2019)
// if (flip) vec2.negate(refNorm, refNorm);
// get the largest depth
const max = vec2.dot(refNorm, ref.max);
// compute contact point depths
cp[0].depth = vec2.dot(refNorm, cp[0].point) - max;
cp[1].depth = vec2.dot(refNorm, cp[1].point) - max;
// make sure the final points are not past this maximum
let removed = false;
if (cp[0].depth < 0) {
cp.shift();
removed = true;
}
const i = removed ? 0 : 1;
if (cp[i].depth < 0) {
cp.splice(i, 1);
}
return cp;
}
/**
* Finds the best edge of a {@link CollisionObject} in a given direction.
*
* The best edge is defined as the edge most perpendicular to the given direction.
*
* @param obj The collision object to calculate the edge for
* @param direction The direction in which to calculate the edge
* @returns The best edge of `obj` for the direction given
*/
bestEdge(obj, direction) {
const points = obj.collider.findFurthestNeighbours(direction);
const l = vec2.sub(vec2.create(), points.furthest, points.left);
const r = vec2.sub(vec2.create(), points.furthest, points.right);
vec2.normalize(l, l);
vec2.normalize(r, r);
if (vec2.dot(r, direction) <= vec2.dot(l, direction)) {
// the right edge is better
// make sure to retain the winding direction
return {
max: points.furthest,
p0: points.right,
p1: points.furthest,
e: vec2.sub(vec2.create(), points.furthest, points.right),
};
}
else {
// the left edge is better
// make sure to retain the winding direction
return {
max: points.furthest,
p0: points.furthest,
p1: points.left,
e: vec2.sub(vec2.create(), points.left, points.furthest),
};
}
}
/**
* Clips the edge points (p0, p1) if they are past **o** along the direction.
*
* @param p0 The first point to clip
* @param p1 The second point to clip
* @param direction The direction to clip in
* @param o The vector to clip past
*/
clipPoints(p0, p1, direction, o) {
const clipped = [];
const dist0 = vec2.dot(direction, p0.point) - o;
const dist1 = vec2.dot(direction, p1.point) - o;
// if either point is past o along n then we can keep it
if (dist0 >= 0)
clipped.push(p0);
if (dist1 >= 0)
clipped.push(p1);
// finally we need to check if they are on opposing sides
// so that we can compute the correct point
if (dist0 * dist1 < 0) {
// if they are on different sides of the offset, d1 and d2
// will be (+) * (-) and will yield a negative result
// therefore be less than zero
//get the vector for the edge we are clipping;
const e = vec2.sub(vec2.create(), p1.point, p0.point);
// compute the location along e
const u = dist0 / (dist0 - dist1);
vec2.scale(e, e, u);
vec2.add(e, e, p0.point);
// add the point
clipped.push({
point: e,
depth: p0.depth,
normal: this.normal,
});
}
return clipped;
}
/**
* Translates the manifold's incident edge by the given vector.
*
* @param v The vector to translate by
*/
translateIncEdge(v) {
this.translateEdge(this.edges[0], v);
}
/**
* Translates the provided edge by the given vector.
*
* @param edge The edge to translate
* @param v The vector to translate by
*/
translateEdge(edge, v) {
vec2.add(edge.p0, edge.p0, v);
vec2.add(edge.p1, edge.p1, v);
vec2.add(edge.max, edge.max, v);
}
}
//# sourceMappingURL=manifold.js.map