Skip to content

Commit

Permalink
feat: Add arbitrary non-convex polygon support (#2239)
Browse files Browse the repository at this point in the history
This PR adds arbitrary non-convex polygon support using the `ex.PolygonCollider(...).triangulate()` method which produces a new `ex.CompositeCollider` composed of triangles.

![triangulation](https://user-images.githubusercontent.com/612071/153693435-932e6f86-c531-46c6-8b0c-0f89ec5b794c.gif)

This PR also adds a `tessellate()` which will produce a triangle fan `ex.CompositeCollider`
  • Loading branch information
eonarheim authored Feb 12, 2022
1 parent 1410248 commit 2441285
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 13 deletions.
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

0 comments on commit 2441285

Please sign in to comment.