Skip to content

Commit 581bf25

Browse files
committed
Implement tight bounding box and "touching edge"
Unfortunately, handling this properly involves a lot of edge cases. We have to implement the bounding box correctly even for sprites with distortion effects, and the pixels' area must be accounted for.
1 parent 3e00808 commit 581bf25

File tree

8 files changed

+444
-30
lines changed

8 files changed

+444
-30
lines changed

src/Renderer.js

+4
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,10 @@ export default class Renderer {
439439
}
440440
}
441441

442+
getTightBoundingBox(sprite) {
443+
return this._getDrawable(sprite).getTightBoundingBox();
444+
}
445+
442446
getBoundingBox(sprite) {
443447
return Rectangle.fromMatrix(this._getDrawable(sprite).getMatrix());
444448
}

src/Sprite.js

+11
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,17 @@ export class Sprite extends SpriteBase {
527527
},
528528
fast
529529
);
530+
case "edge": {
531+
const bounds = this._project.renderer.getTightBoundingBox(this);
532+
const stageWidth = this.stage.width;
533+
const stageHeight = this.stage.height;
534+
return (
535+
bounds.left < -stageWidth / 2 ||
536+
bounds.right > stageWidth / 2 ||
537+
bounds.top > stageHeight / 2 ||
538+
bounds.bottom < -stageHeight / 2
539+
);
540+
}
530541
default:
531542
console.error(
532543
`Cannot find target "${target}" in "touching". Did you mean to pass a sprite class instead?`

src/renderer/BitmapSkin.js

+18
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,29 @@ export default class BitmapSkin extends Skin {
55
super(renderer);
66

77
this._image = image;
8+
this._imageData = null;
89
this._texture = null;
910

1011
this._setSizeFromImage(image);
1112
}
1213

14+
getImageData() {
15+
// Make sure to handle potentially non-loaded textures
16+
if (!this._image.complete) return null;
17+
18+
if (!this._imageData) {
19+
const canvas = document.createElement("canvas");
20+
canvas.width = this._image.naturalWidth || this._image.width;
21+
canvas.height = this._image.naturalHeight || this._image.height;
22+
const ctx = canvas.getContext("2d");
23+
ctx.drawImage(this._image, 0, 0);
24+
// Cache image data so we can reuse it
25+
this._imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
26+
}
27+
28+
return this._imageData;
29+
}
30+
1331
getTexture() {
1432
// Make sure to handle potentially non-loaded textures
1533
const image = this._image;

src/renderer/Drawable.js

+272-16
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,54 @@
11
import Matrix from "./Matrix.js";
22

3+
import Rectangle from "./Rectangle.js";
4+
import effectTransformPoint from "./effectTransformPoint.js";
5+
import { effectBitmasks } from "./effectInfo.js";
6+
37
import { Sprite, Stage } from "../Sprite.js";
48

9+
// Returns the determinant of two vectors, the vector from A to B and the vector
10+
// from A to C. If positive, it means AC is counterclockwise from AB.
11+
// If negative, AC is clockwise from AB.
12+
const determinant = (a, b, c) => {
13+
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
14+
};
15+
16+
// Used to track whether a sprite's transform has changed since we last looked
17+
// at it.
18+
// TODO: store renderer-specific data on the sprite and have *it* set a
19+
// "transform changed" flag.
20+
class SpriteTransformDiff {
21+
constructor(sprite) {
22+
this._sprite = sprite;
23+
this._unset = true;
24+
this.update();
25+
}
26+
27+
update() {
28+
this._lastX = this._sprite.x;
29+
this._lastY = this._sprite.y;
30+
this._lastRotation = this._sprite.direction;
31+
this._lastRotationStyle = this._sprite.rotationStyle;
32+
this._lastScale = this._sprite.scale;
33+
this._lastCostume = this._sprite.costume;
34+
this._lastCostumeLoaded = this._sprite.costume.img.complete;
35+
this._unset = false;
36+
}
37+
38+
get changed() {
39+
return (
40+
this._lastX !== this._sprite.x ||
41+
this._lastY !== this._sprite.y ||
42+
this._lastRotation !== this._sprite.direction ||
43+
this._lastRotationStyle !== this._sprite.rotationStyle ||
44+
this._lastScale !== this._sprite.scale ||
45+
this._lastCostume !== this._sprite.costume ||
46+
this._lastCostumeLoaded !== this._sprite.costume.img.complete ||
47+
this._unset
48+
);
49+
}
50+
}
51+
552
// Renderer-specific data for an instance (the original or a clone) of a Sprite
653
export default class Drawable {
754
constructor(renderer, sprite) {
@@ -10,7 +57,230 @@ export default class Drawable {
1057

1158
// Transformation matrix for the sprite.
1259
this._matrix = Matrix.create();
60+
// Track when the sprite's transform changes so we can recalculate the
61+
// transform matrix.
62+
this._matrixDiff = new SpriteTransformDiff(sprite);
1363
this._calculateSpriteMatrix();
64+
65+
// Track when the image data used to calculate the convex hull,
66+
// or distortion effects that affect how it's drawn, change.
67+
// We also need the image data to know how big the pixels are.
68+
this._convexHullImageData = null;
69+
this._convexHullMosaic = 0;
70+
this._convexHullPixelate = 0;
71+
this._convexHullWhirl = 0;
72+
this._convexHullFisheye = 0;
73+
this._convexHullPoints = null;
74+
75+
this._aabb = new Rectangle();
76+
this._tightBoundingBox = new Rectangle();
77+
// Track when the sprite's transform changes so we can recalculate the
78+
// tight bounding box.
79+
this._convexHullMatrixDiff = new SpriteTransformDiff(sprite);
80+
}
81+
82+
getCurrentSkin() {
83+
return this._renderer._getSkin(this._sprite.costume);
84+
}
85+
86+
// Get the rough axis-aligned bounding box for this sprite. Not as tight as
87+
// getTightBoundingBox, especially when rotated.
88+
getAABB() {
89+
return Rectangle.fromMatrix(this.getMatrix(), this._aabb);
90+
}
91+
92+
// Get the Scratch-space tight bounding box for this sprite.
93+
getTightBoundingBox() {
94+
if (!this._convexHullMatrixDiff.changed) return this._tightBoundingBox;
95+
96+
const matrix = this.getMatrix();
97+
const convexHullPoints = this._calculateConvexHull();
98+
// Maybe the costume isn't loaded yet. Return a 0x0 bounding box around the
99+
// center of the sprite.
100+
if (convexHullPoints === null) {
101+
return Rectangle.fromBounds(
102+
this._sprite.x,
103+
this._sprite.y,
104+
this._sprite.x,
105+
this._sprite.y,
106+
this._tightBoundingBox
107+
);
108+
}
109+
110+
let left = Infinity;
111+
let right = -Infinity;
112+
let top = -Infinity;
113+
let bottom = Infinity;
114+
const transformedPoint = [0, 0];
115+
116+
// Each convex hull point is the center of a pixel. However, said pixels
117+
// each have area. We must take into account the size of the pixels when
118+
// calculating the bounds. The pixel dimensions depend on the scale and
119+
// rotation (as we're treating pixels as squares, which change dimensions
120+
// when rotated).
121+
const xa = matrix[0] / 2;
122+
const xb = matrix[3] / 2;
123+
const halfPixelX =
124+
(Math.abs(xa) + Math.abs(xb)) / this._convexHullImageData.width;
125+
const ya = matrix[1] / 2;
126+
const yb = matrix[4] / 2;
127+
const halfPixelY =
128+
(Math.abs(ya) + Math.abs(yb)) / this._convexHullImageData.height;
129+
130+
// Transform every point in the convex hull using our transform matrix,
131+
// and expand the bounds to include that point.
132+
for (let i = 0; i < convexHullPoints.length; i++) {
133+
const point = convexHullPoints[i];
134+
transformedPoint[0] = point[0];
135+
transformedPoint[1] = 1 - point[1];
136+
Matrix.transformPoint(matrix, transformedPoint, transformedPoint);
137+
138+
left = Math.min(left, transformedPoint[0] - halfPixelX);
139+
right = Math.max(right, transformedPoint[0] + halfPixelX);
140+
top = Math.max(top, transformedPoint[1] + halfPixelY);
141+
bottom = Math.min(bottom, transformedPoint[1] - halfPixelY);
142+
}
143+
144+
Rectangle.fromBounds(left, right, bottom, top, this._tightBoundingBox);
145+
this._convexHullMatrixDiff.update();
146+
return this._tightBoundingBox;
147+
}
148+
149+
_calculateConvexHull() {
150+
const sprite = this._sprite;
151+
const skin = this.getCurrentSkin();
152+
const imageData = skin.getImageData(
153+
"size" in sprite ? sprite.size / 100 : 1
154+
);
155+
if (!imageData) return null;
156+
157+
// We only need to recalculate the convex hull points if the image data's
158+
// changed since we last calculated the convex hull, or if the sprite's
159+
// effects which distort its shape have changed.
160+
const { mosaic, pixelate, whirl, fisheye } = sprite.effects;
161+
if (
162+
this._convexHullImageData === imageData &&
163+
this._convexHullMosaic === mosaic &&
164+
this._convexHullPixelate === pixelate &&
165+
this._convexHullWhirl === whirl &&
166+
this._convexHullFisheye === fisheye
167+
) {
168+
return this._convexHullPoints;
169+
}
170+
171+
const effectBitmask =
172+
sprite.effects._bitmask &
173+
(effectBitmasks.mosaic |
174+
effectBitmasks.pixelate |
175+
effectBitmasks.whirl |
176+
effectBitmasks.fisheye);
177+
178+
const leftHull = [];
179+
const rightHull = [];
180+
181+
const { width, height, data } = imageData;
182+
183+
const pixelPos = [0, 0];
184+
const effectPos = [0, 0];
185+
let currentPoint;
186+
// Not Scratch-space: y increases as we go downwards
187+
// Loop over all rows of pixels in the costume, starting at the top
188+
for (let y = 0; y < height; y++) {
189+
pixelPos[1] = (y + 0.5) / height;
190+
191+
// We start at the leftmost point, then go rightwards until we hit an
192+
// opaque pixel
193+
let x = 0;
194+
for (; x < width; x++) {
195+
pixelPos[0] = (x + 0.5) / width;
196+
let pixelX = x;
197+
let pixelY = y;
198+
if (effectBitmask !== 0) {
199+
effectTransformPoint(this, pixelPos, effectPos);
200+
pixelX = Math.floor(effectPos[0] * width);
201+
pixelY = Math.floor(effectPos[1] * height);
202+
}
203+
// We hit an opaque pixel
204+
if (data[(pixelY * width + pixelX) * 4 + 3] > 0) {
205+
currentPoint = [pixelPos[0], pixelPos[1]];
206+
break;
207+
}
208+
}
209+
210+
// There are no opaque pixels on this row. Go to the next one.
211+
if (x >= width) continue;
212+
213+
// If appending the current point to the left hull makes a
214+
// counterclockwise turn, we want to append the current point to it.
215+
// Otherwise, we remove hull points until the current point makes a
216+
// counterclockwise turn with the last two points.
217+
while (leftHull.length >= 2) {
218+
if (
219+
determinant(
220+
leftHull[leftHull.length - 1],
221+
leftHull[leftHull.length - 2],
222+
currentPoint
223+
) > 0
224+
) {
225+
break;
226+
}
227+
228+
leftHull.pop();
229+
}
230+
231+
leftHull.push(currentPoint);
232+
233+
// Now we repeat the process for the right side, looking leftwards for an
234+
// opaque pixel.
235+
for (x = width - 1; x >= 0; x--) {
236+
pixelPos[0] = (x + 0.5) / width;
237+
effectTransformPoint(this, pixelPos, effectPos);
238+
let pixelX = x;
239+
let pixelY = y;
240+
if (effectBitmask !== 0) {
241+
effectTransformPoint(this, pixelPos, effectPos);
242+
pixelX = Math.floor(effectPos[0] * width);
243+
pixelY = Math.floor(effectPos[1] * height);
244+
}
245+
// We hit an opaque pixel
246+
if (data[(pixelY * width + pixelX) * 4 + 3] > 0) {
247+
currentPoint = [pixelPos[0], pixelPos[1]];
248+
break;
249+
}
250+
}
251+
252+
// Because we're coming at this from the right, it goes clockwise.
253+
while (rightHull.length >= 2) {
254+
if (
255+
determinant(
256+
rightHull[rightHull.length - 1],
257+
rightHull[rightHull.length - 2],
258+
currentPoint
259+
) < 0
260+
) {
261+
break;
262+
}
263+
264+
rightHull.pop();
265+
}
266+
267+
rightHull.push(currentPoint);
268+
}
269+
270+
// Add points from the right side in reverse order so all the points are
271+
// clockwise.
272+
for (let i = rightHull.length - 1; i >= 0; i--) {
273+
leftHull.push(rightHull[i]);
274+
}
275+
276+
this._convexHullPoints = leftHull;
277+
this._convexHullMosaic = mosaic;
278+
this._convexHullPixelate = pixelate;
279+
this._convexHullWhirl = whirl;
280+
this._convexHullFisheye = fisheye;
281+
this._convexHullImageData = imageData;
282+
283+
return this._convexHullPoints;
14284
}
15285

16286
_calculateSpriteMatrix() {
@@ -52,27 +322,13 @@ export default class Drawable {
52322

53323
// Store the values we used to compute the matrix so we only recalculate
54324
// the matrix when we really need to.
55-
this._matrixX = this._sprite.x;
56-
this._matrixY = this._sprite.y;
57-
this._matrixRotation = this._sprite.direction;
58-
this._matrixRotationStyle = this._sprite.rotationStyle;
59-
this._matrixScale = this._sprite.scale;
60-
this._matrixCostume = this._sprite.costume;
61-
this._matrixCostumeLoaded = this._sprite.costume.img.complete;
325+
this._matrixDiff.update();
62326
}
63327

64328
getMatrix() {
65329
// If all the values we used to calculate the matrix haven't changed since
66330
// we last calculated the matrix, we can just return the matrix as-is.
67-
if (
68-
this._matrixX !== this._sprite.x ||
69-
this._matrixY !== this._sprite.y ||
70-
this._matrixRotation !== this._sprite.direction ||
71-
this._matrixRotationStyle !== this._sprite.rotationStyle ||
72-
this._matrixScale !== this._sprite.scale ||
73-
this._matrixCostume !== this._sprite.costume ||
74-
this._matrixCostumeLoaded !== this._sprite.costume.img.complete
75-
) {
331+
if (this._matrixDiff.changed) {
76332
this._calculateSpriteMatrix();
77333
}
78334

src/renderer/Matrix.js

+9
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,13 @@ export default class Matrix {
9494
dst[8] = src[8];
9595
return dst;
9696
}
97+
98+
// Transform a 2D point by the given matrix
99+
static transformPoint(m, dst, src) {
100+
const x = src[0];
101+
const y = src[1];
102+
dst[0] = m[0] * x + m[3] * y + m[6];
103+
dst[1] = m[1] * x + m[4] * y + m[7];
104+
return dst;
105+
}
97106
}

0 commit comments

Comments
 (0)