Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add arbitrary non-convex polygon support #2239

Merged
merged 1 commit into from
Feb 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Added

-
Added arbitrary non-convex polygon support (only non-self intersecting) with `ex.PolygonCollider(...).triangulate()` which builds a new `ex.CompositeCollider` composed of triangles.

### Fixed

Expand Down
1 change: 1 addition & 0 deletions sandbox/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ul>
<li><a href="html/index.html">Sandbox Platformer</a></li>
<li><a href="tests/triangulation/index.html">Triangulation</a></li>
<li><a href="tests/drawcalls/index.html">Draw Calls</a></li>
<li><a href="tests/imageloading/index.html">Large Image Loading</a></li>
<li><a href="tests/gif/animatedGif.html">Animated Gif</a></li>
Expand Down
13 changes: 13 additions & 0 deletions sandbox/tests/triangulation/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Triangulation</title>
</head>
<body>
<script src="../../lib/excalibur.js"></script>
<script src="./index.js"></script>
</body>
</html>
45 changes: 45 additions & 0 deletions sandbox/tests/triangulation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@

var game = new ex.Engine({
width: 800,
height: 600
});
game.toggleDebug();
game.debug.transform.showPosition = true;
game.debug.transform.positionColor = ex.Color.Black;
ex.Physics.useRealisticPhysics();
ex.Physics.gravity = ex.vec(0, 100);

// star points
var star: ex.Vector[] = [];
var rotation = -Math.PI / 2;
for (var i = 0; i < 5; i++) {
// outer
star.push(ex.vec(100 * Math.cos((2 * Math.PI * i)/5 + rotation), 100 * Math.sin((2 * Math.PI * i)/5 + rotation) ));
// inner
star.push(ex.vec(40 * Math.cos((2 * Math.PI * i)/5 + rotation + 2 * Math.PI / 10), 40 * Math.sin((2 * Math.PI * i)/5 + rotation + 2 * Math.PI / 10) ));
}
star.reverse();
var starCollider = new ex.PolygonCollider({points: star});
console.log("Collider Bounds", starCollider.localBounds);
var starGraphic = new ex.Polygon({points: star, color: ex.Color.Yellow});
console.log("Graphic Bounds", starGraphic.localBounds);

var actor = new ex.Actor({x: 200, y: 200, collisionType: ex.CollisionType.Active});
// This is an odd quirk but because we center graphics by default, and the star is asymmetric
actor.graphics.use(starGraphic, { offset: ex.vec(0, -10)});
actor.collider.set(starCollider.triangulate());
actor.angularVelocity = 3;
game.add(actor);


var actor2 = new ex.Actor({x: 400, y: 200, collisionType: ex.CollisionType.Active});
actor2.collider.set(ex.Shape.Box(100, 100).tessellate());
actor2.graphics.use(new ex.Rectangle({ width: 100, height: 100, color: ex.Color.Black}));
game.add(actor2);


var ground = new ex.Actor({anchor: ex.Vector.Zero, x: 0, y: 500, width: 800, height: 10, color: ex.Color.Black, collisionType: ex.CollisionType.Fixed});
game.add(ground);


game.start();
2 changes: 1 addition & 1 deletion src/engine/Collision/ColliderComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export class ColliderComponent extends Component<'ex.collider'> {
* By default, the box is center is at (0, 0) which means it is centered around the actors anchor.
*/
usePolygonCollider(points: Vector[], center: Vector = Vector.Zero): PolygonCollider {
const poly = Shape.Polygon(points, false, center);
const poly = Shape.Polygon(points, center);
return (this.set(poly));
}

Expand Down
192 changes: 185 additions & 7 deletions src/engine/Collision/Colliders/PolygonCollider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { Ray } from '../../Math/ray';
import { ClosestLineJumpTable } from './ClosestLineJumpTable';
import { Transform, TransformComponent } from '../../EntityComponentSystem';
import { Collider } from './Collider';
import { ExcaliburGraphicsContext } from '../..';
import { ExcaliburGraphicsContext, Logger, range } from '../..';
import { CompositeCollider } from './CompositeCollider';
import { Shape } from './Shape';

export interface PolygonColliderOptions {
/**
Expand All @@ -22,23 +24,21 @@ export interface PolygonColliderOptions {
* Points in the polygon in order around the perimeter in local coordinates. These are relative from the body transform position.
*/
points: Vector[];
/**
* Whether points are specified in clockwise or counter clockwise order, default counter-clockwise
*/
clockwiseWinding?: boolean;
}

/**
* Polygon collider for detecting collisions
*/
export class PolygonCollider extends Collider {
private _logger = Logger.getInstance();
/**
* Pixel offset relative to a collider's body transform position.
*/
public offset: Vector;

/**
* Points in the polygon in order around the perimeter in local coordinates. These are relative from the body transform position.
* Excalibur stores these in clockwise order
*/
public points: Vector[];

Expand All @@ -52,13 +52,191 @@ export class PolygonCollider extends Collider {
constructor(options: PolygonColliderOptions) {
super();
this.offset = options.offset ?? Vector.Zero;
const winding = !!options.clockwiseWinding;
this.points = (winding ? options.points.reverse() : options.points) || [];
this.points = options.points ?? [];
const clockwise = this._isClockwiseWinding(this.points);
if (!clockwise) {
this.points.reverse();
}
if (!this.isConvex()) {
this._logger.warn(
'Excalibur only supports convex polygon colliders and will not behave properly.'+
'Call PolygonCollider.triangulate() to build a new collider composed of smaller convex triangles');
}

// calculate initial transformation
this._calculateTransformation();
}

private _isClockwiseWinding(points: Vector[]): boolean {
// https://stackoverflow.com/a/1165943
let sum = 0;
for (let i = 0; i < points.length; i++) {
sum += (points[(i + 1) % points.length].x - points[i].x) * (points[(i + 1) % points.length].y + points[i].y);
}
return sum < 0;
}

/**
* Returns if the polygon collider is convex, Excalibur does not handle non-convex collision shapes.
* Call [[Polygon.triangulate]] to generate a [[CompositeCollider]] from this non-convex shape
*/
public isConvex(): boolean {
// From SO: https://stackoverflow.com/a/45372025
if (this.points.length < 3) {
return false;
}
let oldPoint = this.points[this.points.length - 2];
let newPoint = this.points[this.points.length - 1];
let direction = Math.atan2(newPoint.y - oldPoint.y, newPoint.x - oldPoint.x);
let oldDirection = 0;
let orientation = 0;
let angleSum = 0;
for (const [i, point] of this.points.entries()) {
oldPoint = newPoint;
oldDirection = direction;
newPoint = point;
direction = Math.atan2(newPoint.y - oldPoint.y, newPoint.x - oldPoint.x);
if (oldPoint.equals(newPoint)) {
return false; // repeat point
}
let angle = direction - oldDirection;
if (angle <= -Math.PI){
angle += Math.PI * 2;
} else if (angle > Math.PI) {
angle -= Math.PI * 2;
}
if (i === 0) {
if (angle === 0.0) {
return false;
}
orientation = angle > 0 ? 1 : -1;
} else {
if (orientation * angle <= 0) {
return false;
}
}
angleSum += angle;
}
return Math.abs(Math.round(angleSum / (Math.PI * 2))) === 1;
}

/**
* Tessellates the polygon into a triangle fan as a [[CompositeCollider]] of triangle polygons
*/
public tessellate(): CompositeCollider {
const polygons: Vector[][] = [];
for (let i = 1; i < this.points.length - 2; i++) {
polygons.push([this.points[0], this.points[i + 1], this.points[i + 2]]);
}
polygons.push([this.points[0], this.points[1], this.points[2]]);

return new CompositeCollider(polygons.map(points => Shape.Polygon(points)));
}

/**
* Triangulate the polygon collider using the "Ear Clipping" algorithm.
* Returns a new [[CompositeCollider]] made up of smaller triangles.
*/
public triangulate(): CompositeCollider {
// https://www.youtube.com/watch?v=hTJFcHutls8
if (this.points.length < 3) {
throw Error('Invalid polygon');
}

// Helper to get a vertex in the list
/**
*
*/
function getItem<T>(index: number, list: T[]) {
if (index >= list.length) {
return list[index % list.length];
} else if (index < 0) {
return list[index % list.length + list.length];
} else {
return list[index];
}
}

// Quick test for point in triangle
/**
*
*/
function isPointInTriangle(point: Vector, a: Vector, b: Vector, c: Vector) {
const ab = b.sub(a);
const bc = c.sub(b);
const ca = a.sub(c);

const ap = point.sub(a);
const bp = point.sub(b);
const cp = point.sub(c);

const cross1 = ab.cross(ap);
const cross2 = bc.cross(bp);
const cross3 = ca.cross(cp);

if (cross1 > 0 || cross2 > 0 || cross3 > 0) {
return false;
}
return true;
}

const triangles: Vector[][] = [];
const vertices = [...this.points];
const indices = range(0, this.points.length - 1);

// 1. Loop through vertices clockwise
// if the vertex is convex (interior angle is < 180) (cross product positive)
// if the polygon formed by it's edges doesn't contain the points
// it's an ear add it to our list of triangles, and restart

while (indices.length > 3) {
for (let i = 0; i < indices.length; i++) {
const a = indices[i];
const b = getItem(i - 1, indices);
const c = getItem(i + 1, indices);

const va = vertices[a];
const vb = vertices[b];
const vc = vertices[c];

// Check convexity
const leftArm = vb.sub(va);
const rightArm = vc.sub(va);
const isConvex = rightArm.cross(leftArm) > 0; // positive cross means convex
if (!isConvex) {
continue;
}

let isEar = true;
// Check that if any vertices are in the triangle a, b, c
for (let j = 0; j < indices.length; j++) {
const vertIndex = indices[j];
// We can skip these
if (vertIndex === a || vertIndex === b || vertIndex === c) {
continue;
}

const point = vertices[vertIndex];
if (isPointInTriangle(point, vb, va, vc)) {
isEar = false;
break;
}
}

// Add ear to polygon list and remove from list
if (isEar) {
triangles.push([vb, va, vc]);
indices.splice(i, 1);
break;
}
}
}

triangles.push([vertices[indices[0]], vertices[indices[1]], vertices[indices[2]]]);

return new CompositeCollider(triangles.map(points => Shape.Polygon(points)));
}

/**
* Returns a clone of this ConvexPolygon, not associated with any collider
*/
Expand Down
6 changes: 2 additions & 4 deletions src/engine/Collision/Colliders/Shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,12 @@ export class Shape {
*
* PolygonColliders are useful for creating convex polygon shapes
* @param points Points specified in counter clockwise
* @param clockwiseWinding Optionally changed the winding of points, by default false meaning counter-clockwise winding.
* @param offset Optional offset relative to the collider in local coordinates
*/
static Polygon(points: Vector[], clockwiseWinding: boolean = false, offset: Vector = Vector.Zero): PolygonCollider {
static Polygon(points: Vector[], offset: Vector = Vector.Zero): PolygonCollider {
return new PolygonCollider({
points: points,
offset: offset,
clockwiseWinding: clockwiseWinding
offset: offset
});
}

Expand Down
47 changes: 47 additions & 0 deletions src/spec/CollisionShapeSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,53 @@ describe('Collision Shape', () => {
expect(poly).not.toBe(null);
});

it('will adjust winding to clockwise', () => {
const points = [ex.vec(0, 0), ex.vec(10, 10), ex.vec(10, 0)];
const winding = new ex.PolygonCollider({ points: [...points] });

expect(winding.points).toEqual([ex.vec(0, 0), ex.vec(10, 10), ex.vec(10, 0)].reverse());
});

it('can be checked for convexity', () => {
const convex = new ex.PolygonCollider({
points: [ex.vec(0, 0), ex.vec(10, 10), ex.vec(10, 0)]
});
expect(convex.isConvex()).withContext('Triangles are always convex').toBe(true);

const concave = new ex.PolygonCollider({
points: [ex.vec(0, 0), ex.vec(5, 5), ex.vec(0, 10), ex.vec(10, 10), ex.vec(10, 0)]
});

expect(concave.isConvex()).withContext('Should be concave').toBe(false);
});

it('can triangulate', () => {
const concave = new ex.PolygonCollider({
points: [ex.vec(0, 0), ex.vec(5, 5), ex.vec(0, 10), ex.vec(10, 10), ex.vec(10, 0)]
});

const composite = concave.triangulate();

const colliders = composite.getColliders() as ex.PolygonCollider[];
expect(colliders.length).toBe(3);
expect(colliders[0].points).toEqual([ex.vec(0, 0), ex.vec(10, 0), ex.vec(10, 10)]);
expect(colliders[1].points).toEqual([ex.vec(0, 0), ex.vec(10, 10), ex.vec(0, 10)]);
expect(colliders[2].points).toEqual([ex.vec(0, 0), ex.vec(5, 5), ex.vec(0, 10)]);

expect(concave.isConvex()).withContext('Should be concave').toBe(false);
});

it('can tesselate', () => {
const box = ex.Shape.Box(10, 10);

const composite = box.tessellate();

const colliders = composite.getColliders() as ex.PolygonCollider[];
expect(colliders.length).toBe(2);
expect(colliders[0].points).toEqual([ex.vec(-5, -5), ex.vec(5, 5), ex.vec(-5, 5)]);
expect(colliders[1].points).toEqual([ex.vec(-5, -5), ex.vec(5, -5), ex.vec(5, 5)]);
});

it('can have be constructed with position', () => {
const poly = new ex.PolygonCollider({
offset: new ex.Vector(10, 0),
Expand Down