Skip to content

Commit 09cdffb

Browse files
committed
improve performance of hit detection
1 parent e00cb7c commit 09cdffb

7 files changed

Lines changed: 382 additions & 5 deletions

File tree

‎.changeset/faster-many-sprites.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
type: patch
3+
---
4+
5+
Make stages with hundreds of sprites run at a usable frame rate.
6+
7+
Four things were costing more than they had to, all of them growing with the
8+
number of sprites:
9+
10+
- Every sprite's costume ended its draw with `resetShader()`, which flushes the
11+
renderer. That turned what could be a few batched draw calls into one per
12+
sprite. The shader is now only reset when one was actually set.
13+
- `getHitbox()` rebuilt the sprite's collision shape from scratch on every call,
14+
and collision checks call it once per pair - so a stage with 200 sprites built
15+
40,000 shapes a frame. A sprite that has not moved, turned, resized or changed
16+
costume now reuses the shape it had.
17+
- `Shape.intersects` compared two outlines by building a `java.awt.geom.Area` for
18+
each. Sprite hitboxes are convex, so they are now compared with separating
19+
axes instead, which gives the same answer about 30x faster.
20+
- Building a `Polygon` from a list of corners rebuilt its path once per corner.
21+
22+
A benchmark of 200 sprites moving, bouncing off the edges and checking collisions
23+
against each other every frame went from 96ms per frame to 25ms.

‎src/main/java/org/openpatch/scratch/Polygon.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ public Polygon(double[] xPoints, double[] yPoints) {
2929
if (xPoints.length != yPoints.length) {
3030
throw new IllegalArgumentException("xPoints and yPoints must have the same length");
3131
}
32-
this.awtShape = new Path2D.Double();
3332
for (int i = 0; i < xPoints.length; i++) {
34-
addPoint(xPoints[i], yPoints[i]);
33+
points.add(new Point2D.Double(xPoints[i], yPoints[i]));
3534
}
35+
rebuildPath();
3636
}
3737

3838
/**
@@ -61,5 +61,6 @@ private void rebuildPath() {
6161
path.closePath();
6262
}
6363
this.awtShape = path;
64+
this.invalidateCache();
6465
}
6566
}

‎src/main/java/org/openpatch/scratch/Shape.java‎

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,36 @@
1717
public class Shape {
1818
protected java.awt.Shape awtShape;
1919

20+
/**
21+
* The outline flattened to points, and whether it came out convex - worked out
22+
* once and reused, so that {@link #intersects(Shape)} can take the cheap route
23+
* without re-walking the path every call. Both are derived from
24+
* {@link #awtShape} and must be dropped whenever it is replaced.
25+
*/
26+
private double[] cachedPoints;
27+
private boolean cachedConvex;
28+
private Bounds cachedBounds;
29+
30+
/**
31+
* Above this many corners the separating-axis test stops being cheaper than
32+
* the general {@link Area} one, so shapes that flatten to a lot of segments
33+
* (circles, ellipses) keep using the general path.
34+
*/
35+
private static final int MAX_SAT_POINTS = 8;
36+
2037
protected Shape() {
2138
}
2239

2340
private Shape(java.awt.Shape awtShape) {
2441
this.awtShape = awtShape;
2542
}
2643

44+
/** Call after replacing {@link #awtShape} so the derived data is rebuilt. */
45+
protected void invalidateCache() {
46+
this.cachedPoints = null;
47+
this.cachedBounds = null;
48+
}
49+
2750
/**
2851
* Check if the shape contains a point defined by its x and y coordinates.
2952
*
@@ -44,11 +67,129 @@ public boolean contains(double x, double y) {
4467
* @return true if the shapes intersect, false otherwise
4568
*/
4669
public boolean intersects(Shape other) {
70+
// Sprite hitboxes are almost always convex quads, and building two Areas to
71+
// compare two quads costs more than the rest of a frame's collision work put
72+
// together. Separating axes give the same answer for the convex case.
73+
double[] a = this.points();
74+
double[] b = other.points();
75+
if (a != null && b != null && this.cachedConvex && other.cachedConvex) {
76+
return !separated(a, b) && !separated(b, a);
77+
}
78+
4779
Area a1 = new Area(awtShape);
4880
a1.intersect(new Area(other.awtShape));
4981
return !a1.isEmpty();
5082
}
5183

84+
/**
85+
* Checks whether some edge of {@code a} has all of {@code b} on its far side,
86+
* which for convex outlines means the two cannot be touching.
87+
*/
88+
private static boolean separated(double[] a, double[] b) {
89+
for (int i = 0; i < a.length; i += 2) {
90+
int j = (i + 2) % a.length;
91+
// Outward normal of the edge from point i to point j.
92+
double axisX = a[j + 1] - a[i + 1];
93+
double axisY = a[i] - a[j];
94+
95+
double aMin = Double.POSITIVE_INFINITY;
96+
double aMax = Double.NEGATIVE_INFINITY;
97+
for (int k = 0; k < a.length; k += 2) {
98+
double p = a[k] * axisX + a[k + 1] * axisY;
99+
if (p < aMin)
100+
aMin = p;
101+
if (p > aMax)
102+
aMax = p;
103+
}
104+
105+
double bMin = Double.POSITIVE_INFINITY;
106+
double bMax = Double.NEGATIVE_INFINITY;
107+
for (int k = 0; k < b.length; k += 2) {
108+
double p = b[k] * axisX + b[k + 1] * axisY;
109+
if (p < bMin)
110+
bMin = p;
111+
if (p > bMax)
112+
bMax = p;
113+
}
114+
115+
// Not "<", so that outlines meeting exactly along an edge count as apart,
116+
// the same way the Area path does - their overlap has no area.
117+
if (aMax <= bMin || bMax <= aMin) {
118+
return true;
119+
}
120+
}
121+
return false;
122+
}
123+
124+
/**
125+
* The outline as flat {@code x, y, x, y, ...} pairs, or null if the shape is
126+
* not a single small convex ring that {@link #separated} can handle.
127+
*/
128+
private double[] points() {
129+
if (this.cachedPoints != null) {
130+
return this.cachedPoints;
131+
}
132+
PathIterator it = this.awtShape.getPathIterator(null, 1);
133+
double[] coord = new double[6];
134+
double[] buffer = new double[2 * MAX_SAT_POINTS];
135+
int n = 0;
136+
boolean seenMoveTo = false;
137+
while (!it.isDone()) {
138+
int code = it.currentSegment(coord);
139+
if (code == PathIterator.SEG_CLOSE) {
140+
it.next();
141+
continue;
142+
}
143+
if (code == PathIterator.SEG_MOVETO) {
144+
if (seenMoveTo) {
145+
return null; // more than one ring
146+
}
147+
seenMoveTo = true;
148+
} else if (code != PathIterator.SEG_LINETO) {
149+
return null; // a curve survived flattening
150+
}
151+
if (n + 2 > buffer.length) {
152+
return null; // too many corners to be worth the separating-axis test
153+
}
154+
buffer[n++] = coord[0];
155+
buffer[n++] = coord[1];
156+
it.next();
157+
}
158+
if (n < 6) {
159+
return null; // fewer than three corners is not an area
160+
}
161+
// A flattened ring often repeats its first point at the end; drop it so the
162+
// edge walk does not produce a zero-length axis.
163+
if (n >= 8 && buffer[0] == buffer[n - 2] && buffer[1] == buffer[n - 1]) {
164+
n -= 2;
165+
}
166+
167+
double[] points = java.util.Arrays.copyOf(buffer, n);
168+
this.cachedConvex = isConvex(points);
169+
this.cachedPoints = points;
170+
return points;
171+
}
172+
173+
/** True if every turn along the ring goes the same way. */
174+
private static boolean isConvex(double[] p) {
175+
int sign = 0;
176+
for (int i = 0; i < p.length; i += 2) {
177+
int j = (i + 2) % p.length;
178+
int k = (i + 4) % p.length;
179+
double cross = (p[j] - p[i]) * (p[k + 1] - p[j + 1]) - (p[j + 1] - p[i + 1]) * (p[k] - p[j]);
180+
if (cross == 0) {
181+
continue;
182+
}
183+
int s = cross > 0 ? 1 : -1;
184+
if (sign == 0) {
185+
sign = s;
186+
} else if (sign != s) {
187+
return false;
188+
}
189+
}
190+
return true;
191+
}
192+
52193
/**
53194
* Check if the shape is empty.
54195
*
@@ -95,8 +236,11 @@ public Shape rotate(double theta, double anchorX, double anchorY) {
95236
* @return a Bounds object representing the bounding box of the shape
96237
*/
97238
public Bounds getBounds() {
98-
java.awt.geom.Rectangle2D bounds = awtShape.getBounds2D();
99-
return new Bounds(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight());
239+
if (this.cachedBounds == null) {
240+
java.awt.geom.Rectangle2D bounds = awtShape.getBounds2D();
241+
this.cachedBounds = new Bounds(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight());
242+
}
243+
return this.cachedBounds;
100244
}
101245

102246
public void draw(PGraphics buffer) {

‎src/main/java/org/openpatch/scratch/Sprite.java‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ public Shaders getShaders() {
124124
private final Pen pen;
125125
private Hitbox hitbox;
126126
private boolean hitboxDisabled = false;
127+
128+
// Last hitbox handed out by getHitbox(), plus the sprite state it was built
129+
// from. See hitboxStateChanged().
130+
private Hitbox cachedHitbox;
131+
private double hitboxCacheX;
132+
private double hitboxCacheY;
133+
private double hitboxCacheDirection;
134+
private double hitboxCacheSize;
135+
private double hitboxCachePenSize;
136+
private Image hitboxCacheCostume;
137+
private double hitboxCacheCostumeWidth;
138+
private double hitboxCacheCostumeHeight;
139+
private RotationStyle hitboxCacheRotationStyle;
140+
private boolean hitboxCacheShow;
141+
private Hitbox hitboxCacheCustom;
142+
127143
private final Text text;
128144
private boolean isUI;
129145

@@ -1481,6 +1497,14 @@ public void enableHitbox() {
14811497
* @return the hitbox of the sprite
14821498
*/
14831499
public Hitbox getHitbox() {
1500+
// Collision checks ask every sprite for its hitbox against every other one,
1501+
// so this runs O(n^2) times a frame. Rebuilding the shape each time made
1502+
// that the most expensive thing in a stage with a few hundred sprites, even
1503+
// though a sprite that has not moved has the same hitbox as last time.
1504+
if (!this.hitboxStateChanged() && this.cachedHitbox != null) {
1505+
return this.cachedHitbox;
1506+
}
1507+
14841508
Image currentCostume = null;
14851509
if (this.costumes.size() > this.getCurrentCostumeIndex()) {
14861510
currentCostume = this.costumes.get(this.getCurrentCostumeIndex());
@@ -1504,6 +1528,7 @@ public Hitbox getHitbox() {
15041528
this.x - spriteWidth / 2.0f,
15051529
-this.y - spriteHeight / 2.0f,
15061530
this.size);
1531+
this.cachedHitbox = this.hitbox;
15071532
return this.hitbox;
15081533
}
15091534

@@ -1548,9 +1573,54 @@ public Hitbox getHitbox() {
15481573
yPoints[3] = cornerBottomLeft[1];
15491574

15501575
Hitbox hitbox = new Hitbox(xPoints, yPoints);
1576+
this.cachedHitbox = hitbox;
15511577
return hitbox;
15521578
}
15531579

1580+
/**
1581+
* Records the state {@link #getHitbox()} derives its shape from, and reports
1582+
* whether any of it moved since the cached hitbox was built. Comparing the
1583+
* inputs rather than marking the cache dirty in every setter means a field
1584+
* that gets written directly, or a costume resized behind the sprite's back,
1585+
* cannot leave a stale hitbox behind.
1586+
*/
1587+
private boolean hitboxStateChanged() {
1588+
Image currentCostume = null;
1589+
if (this.costumes.size() > this.getCurrentCostumeIndex()) {
1590+
currentCostume = this.costumes.get(this.getCurrentCostumeIndex());
1591+
}
1592+
var costumeWidth = currentCostume != null ? currentCostume.getWidth() : this.pen.getSize();
1593+
var costumeHeight = currentCostume != null ? currentCostume.getHeight() : this.pen.getSize();
1594+
var penSize = this.pen.getSize();
1595+
1596+
if (this.hitboxCachePenSize == penSize
1597+
&& this.hitboxCacheX == this.x
1598+
&& this.hitboxCacheY == this.y
1599+
&& this.hitboxCacheDirection == this.direction
1600+
&& this.hitboxCacheSize == this.size
1601+
&& this.hitboxCacheCostume == currentCostume
1602+
&& this.hitboxCacheCostumeWidth == costumeWidth
1603+
&& this.hitboxCacheCostumeHeight == costumeHeight
1604+
&& this.hitboxCacheRotationStyle == this.rotationStyle
1605+
&& this.hitboxCacheShow == this.show
1606+
&& this.hitboxCacheCustom == this.hitbox) {
1607+
return false;
1608+
}
1609+
1610+
this.hitboxCachePenSize = penSize;
1611+
this.hitboxCacheX = this.x;
1612+
this.hitboxCacheY = this.y;
1613+
this.hitboxCacheDirection = this.direction;
1614+
this.hitboxCacheSize = this.size;
1615+
this.hitboxCacheCostume = currentCostume;
1616+
this.hitboxCacheCostumeWidth = costumeWidth;
1617+
this.hitboxCacheCostumeHeight = costumeHeight;
1618+
this.hitboxCacheRotationStyle = this.rotationStyle;
1619+
this.hitboxCacheShow = this.show;
1620+
this.hitboxCacheCustom = this.hitbox;
1621+
return true;
1622+
}
1623+
15541624
/**
15551625
* Checks if this sprite is touching another sprite.
15561626
*

‎src/main/java/org/openpatch/scratch/internal/Image.java‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,13 @@ public void draw(
464464
}
465465

466466
buffer.noTint();
467-
buffer.resetShader();
467+
// resetShader() flushes the renderer, which ends the current batch and costs
468+
// a draw call. Doing that for every sprite means a stage with 200 sprites
469+
// issues 200 draw calls instead of a handful of batched ones, so only reset
470+
// when a shader was actually set above.
471+
if (shader != null) {
472+
buffer.resetShader();
473+
}
468474
buffer.pop();
469475
}
470476

0 commit comments

Comments
 (0)