Skip to content

Commit 9fb49c2

Browse files
committed
[ts][pixi-v8] Allow to define a bounds providers for the Spine game object. See #2734.
1 parent 01d676f commit 9fb49c2

File tree

2 files changed

+292
-6
lines changed

2 files changed

+292
-6
lines changed
+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<html>
2+
<head>
3+
<meta charset="UTF-8" />
4+
<title>spine-pixi-v8</title>
5+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/pixi.min.js"></script>
6+
<script src="../dist/iife/spine-pixi-v8.js"></script>
7+
<link rel="stylesheet" href="../../index.css">
8+
</head>
9+
10+
<body>
11+
<script>
12+
(async function () {
13+
14+
var app = new PIXI.Application();
15+
await app.init({
16+
width: window.innerWidth,
17+
height: window.innerHeight,
18+
resolution: window.devicePixelRatio || 1,
19+
autoDensity: true,
20+
resizeTo: window,
21+
backgroundColor: 0x2c3e50,
22+
hello: true,
23+
})
24+
document.body.appendChild(app.view);
25+
26+
// Pre-load the skeleton data and atlas. You can also load .json skeleton data.
27+
PIXI.Assets.add({alias: "spineboyData", src: "./assets/spineboy-pro.skel"});
28+
PIXI.Assets.add({alias: "spineboyAtlas", src: "./assets/spineboy-pma.atlas"});
29+
await PIXI.Assets.load(["spineboyData", "spineboyAtlas"]);
30+
31+
// Create the spine display object
32+
const spineboy1 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2 });
33+
34+
const spineboy2 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2,
35+
boundsProvider: new spine.SetupPoseBoundsProvider(),
36+
});
37+
38+
const spineboy3 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2,
39+
boundsProvider: new spine.SkinsAndAnimationBoundsProvider("portal", undefined, undefined, false),
40+
});
41+
42+
const spineboy4 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2,
43+
boundsProvider: new spine.SkinsAndAnimationBoundsProvider("portal", undefined, undefined, true),
44+
});
45+
46+
const spineboy5 = spine.Spine.from({skeleton: "spineboyData", atlas: "spineboyAtlas", scale: .2,
47+
boundsProvider: new spine.AABBRectangleBoundsProvider(-100, -100, 100, 100),
48+
});
49+
50+
const maxHeight = spineboy3.getBounds().height;
51+
const scaleFactor = 1 / (maxHeight * 5 / window.innerHeight);
52+
const scaledMaxHeight = maxHeight * scaleFactor;
53+
54+
const texts = [
55+
"Default bounds: dynamic, recomputed when queried",
56+
"Set up pose bound: fixed, based on setup pose",
57+
"Skin and animations based bound: fixed, the max AABB rectangle containing the skeleton with the given skin and given animations (clipping is ignored)",
58+
"Skin and animations based bound: same as above, but with clipping true. The bounds is smaller because clipped attachments' parts are not considered",
59+
"AABB Rectangle bounds: fixed, manually provided bounds. The origin is in skeleton root and size are in skeleton space",
60+
]
61+
62+
const pointerOn = [];
63+
64+
const elements = [spineboy1, spineboy2, spineboy3, spineboy4, spineboy5].map((spineboy, i) => {
65+
66+
const x = 300 * scaleFactor;
67+
68+
// spineboy placement
69+
spineboy.scale.set(scaleFactor);
70+
spineboy.state.setAnimation(0, "portal", true);
71+
spineboy.x = x;
72+
spineboy.y = 70 * scaleFactor + (window.innerHeight / 10 * (1 + 2*i));
73+
app.stage.addChild(spineboy);
74+
75+
// yellow rectangle to show bounds
76+
const graphics = new PIXI.Graphics();
77+
app.stage.addChild(graphics);
78+
79+
// text
80+
const basicText = new PIXI.Text({
81+
text: texts[i],
82+
style: {
83+
fontSize: 20 * scaleFactor,
84+
fill: "white",
85+
wordWrap: true,
86+
wordWrapWidth: 400 * scaleFactor,
87+
}
88+
});
89+
basicText.x = x + scaledMaxHeight + 0 * scaleFactor;
90+
basicText.y = scaledMaxHeight * (i + .5);
91+
basicText.anchor.set(0, 0.5);
92+
app.stage.addChild(basicText);
93+
94+
// pointer events
95+
spineboy.eventMode = "static";
96+
spineboy.cursor = "pointer";
97+
spineboy.on("pointerenter", () => pointerOn[i] = true);
98+
spineboy.on("pointerleave", () => pointerOn[i] = false);
99+
100+
return [spineboy, graphics];
101+
})
102+
103+
app.ticker.add((delta) => {
104+
elements.forEach(([spineboy, graphic], i) => {
105+
const bound = spineboy.getBounds();
106+
graphic.clear().rect(bound.x, bound.y, bound.width, bound.height).stroke({ width: 2, color: 0xfeeb77 }).fill({ color: 0xff0000, alpha: pointerOn[i] ? .2 : 0 });
107+
})
108+
})
109+
110+
111+
112+
})();
113+
</script>
114+
</body>
115+
</html>

spine-ts/spine-pixi-v8/src/Spine.ts

+177-6
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
SkeletonClipping,
6262
SkeletonData,
6363
SkeletonJson,
64+
Skin,
6465
Slot,
6566
type TextureAtlas,
6667
TrackEntry,
@@ -89,6 +90,9 @@ export interface SpineFromOptions {
8990
* If `undefined`, use the dark tint renderer if at least one slot has tint black
9091
*/
9192
darkTint?: boolean;
93+
94+
/** The bounds provider to use. If undefined the bounds will be dynamic, calculated when requested and based on the current frame. */
95+
boundsProvider?: SpineBoundsProvider,
9296
};
9397

9498
const vectorAux = new Vector2();
@@ -97,6 +101,138 @@ Skeleton.yDown = true;
97101

98102
const clipper = new SkeletonClipping();
99103

104+
/** A bounds provider calculates the bounding box for a skeleton, which is then assigned as the size of the SpineGameObject. */
105+
export interface SpineBoundsProvider {
106+
/** Returns the bounding box for the skeleton, in skeleton space. */
107+
calculateBounds (gameObject: Spine): {
108+
x: number;
109+
y: number;
110+
width: number;
111+
height: number;
112+
};
113+
}
114+
115+
/** A bounds provider that provides a fixed size given by the user. */
116+
export class AABBRectangleBoundsProvider implements SpineBoundsProvider {
117+
constructor (
118+
private x: number,
119+
private y: number,
120+
private width: number,
121+
private height: number,
122+
) { }
123+
calculateBounds () {
124+
return { x: this.x, y: this.y, width: this.width, height: this.height };
125+
}
126+
}
127+
128+
/** A bounds provider that calculates the bounding box from the setup pose. */
129+
export class SetupPoseBoundsProvider implements SpineBoundsProvider {
130+
/**
131+
* @param clipping If true, clipping attachments are used to compute the bounds. False, by default.
132+
*/
133+
constructor (
134+
private clipping = false,
135+
) { }
136+
137+
calculateBounds (gameObject: Spine) {
138+
if (!gameObject.skeleton) return { x: 0, y: 0, width: 0, height: 0 };
139+
// Make a copy of animation state and skeleton as this might be called while
140+
// the skeleton in the GameObject has already been heavily modified. We can not
141+
// reconstruct that state.
142+
const skeleton = new Skeleton(gameObject.skeleton.data);
143+
skeleton.setToSetupPose();
144+
skeleton.updateWorldTransform(Physics.update);
145+
const bounds = skeleton.getBoundsRect(this.clipping ? new SkeletonClipping() : undefined);
146+
return bounds.width == Number.NEGATIVE_INFINITY
147+
? { x: 0, y: 0, width: 0, height: 0 }
148+
: bounds;
149+
}
150+
}
151+
152+
/** A bounds provider that calculates the bounding box by taking the maximumg bounding box for a combination of skins and specific animation. */
153+
export class SkinsAndAnimationBoundsProvider
154+
implements SpineBoundsProvider {
155+
/**
156+
* @param animation The animation to use for calculating the bounds. If null, the setup pose is used.
157+
* @param skins The skins to use for calculating the bounds. If empty, the default skin is used.
158+
* @param timeStep The time step to use for calculating the bounds. A smaller time step means more precision, but slower calculation.
159+
* @param clipping If true, clipping attachments are used to compute the bounds. False, by default.
160+
*/
161+
constructor (
162+
private animation: string | null,
163+
private skins: string[] = [],
164+
private timeStep: number = 0.05,
165+
private clipping = false,
166+
) { }
167+
168+
calculateBounds (gameObject: Spine): {
169+
x: number;
170+
y: number;
171+
width: number;
172+
height: number;
173+
} {
174+
if (!gameObject.skeleton || !gameObject.state)
175+
return { x: 0, y: 0, width: 0, height: 0 };
176+
// Make a copy of animation state and skeleton as this might be called while
177+
// the skeleton in the GameObject has already been heavily modified. We can not
178+
// reconstruct that state.
179+
const animationState = new AnimationState(gameObject.state.data);
180+
const skeleton = new Skeleton(gameObject.skeleton.data);
181+
const clipper = this.clipping ? new SkeletonClipping() : undefined;
182+
const data = skeleton.data;
183+
if (this.skins.length > 0) {
184+
let customSkin = new Skin("custom-skin");
185+
for (const skinName of this.skins) {
186+
const skin = data.findSkin(skinName);
187+
if (skin == null) continue;
188+
customSkin.addSkin(skin);
189+
}
190+
skeleton.setSkin(customSkin);
191+
}
192+
skeleton.setToSetupPose();
193+
194+
const animation = this.animation != null ? data.findAnimation(this.animation!) : null;
195+
196+
if (animation == null) {
197+
skeleton.updateWorldTransform(Physics.update);
198+
const bounds = skeleton.getBoundsRect(clipper);
199+
return bounds.width == Number.NEGATIVE_INFINITY
200+
? { x: 0, y: 0, width: 0, height: 0 }
201+
: bounds;
202+
} else {
203+
let minX = Number.POSITIVE_INFINITY,
204+
minY = Number.POSITIVE_INFINITY,
205+
maxX = Number.NEGATIVE_INFINITY,
206+
maxY = Number.NEGATIVE_INFINITY;
207+
animationState.clearTracks();
208+
animationState.setAnimationWith(0, animation, false);
209+
const steps = Math.max(animation.duration / this.timeStep, 1.0);
210+
for (let i = 0; i < steps; i++) {
211+
const delta = i > 0 ? this.timeStep : 0;
212+
animationState.update(delta);
213+
animationState.apply(skeleton);
214+
skeleton.update(delta);
215+
skeleton.updateWorldTransform(Physics.update);
216+
217+
const bounds = skeleton.getBoundsRect(clipper);
218+
minX = Math.min(minX, bounds.x);
219+
minY = Math.min(minY, bounds.y);
220+
maxX = Math.max(maxX, bounds.x + bounds.width);
221+
maxY = Math.max(maxY, bounds.y + bounds.height);
222+
}
223+
const bounds = {
224+
x: minX,
225+
y: minY,
226+
width: maxX - minX,
227+
height: maxY - minY,
228+
};
229+
return bounds.width == Number.NEGATIVE_INFINITY
230+
? { x: 0, y: 0, width: 0, height: 0 }
231+
: bounds;
232+
}
233+
}
234+
}
235+
100236
export interface SpineOptions extends ContainerOptions {
101237
/** the {@link SkeletonData} used to instantiate the skeleton */
102238
skeletonData: SkeletonData;
@@ -106,6 +242,9 @@ export interface SpineOptions extends ContainerOptions {
106242

107243
/** See {@link SpineFromOptions.darkTint}. */
108244
darkTint?: boolean;
245+
246+
/** See {@link SpineFromOptions.boundsProvider}. */
247+
boundsProvider?: SpineBoundsProvider,
109248
}
110249

111250
/**
@@ -229,6 +368,19 @@ export class Spine extends ViewContainer {
229368
this._autoUpdate = value;
230369
}
231370

371+
public _boundsProvider?: SpineBoundsProvider;
372+
/** The bounds provider to use. If undefined the bounds will be dynamic, calculated when requested and based on the current frame. */
373+
public get boundsProvider (): SpineBoundsProvider | undefined {
374+
return this._boundsProvider;
375+
}
376+
public set boundsProvider (value: SpineBoundsProvider | undefined) {
377+
this._boundsProvider = value;
378+
if (value) {
379+
this._boundsDirty = false;
380+
}
381+
this.updateBounds();
382+
}
383+
232384
private hasNeverUpdated = true;
233385
constructor (options: SpineOptions | SkeletonData) {
234386
if (options instanceof SkeletonData) {
@@ -255,6 +407,8 @@ export class Spine extends ViewContainer {
255407
for (let i = 0; i < slots.length; i++) {
256408
this.attachmentCacheData[i] = Object.create(null);
257409
}
410+
411+
this._boundsProvider = options.boundsProvider;
258412
}
259413

260414
/** If {@link Spine.autoUpdate} is `false`, this method allows to update the AnimationState and the Skeleton with the given delta. */
@@ -357,8 +511,6 @@ export class Spine extends ViewContainer {
357511

358512
this._stateChanged = true;
359513

360-
this._boundsDirty = true;
361-
362514
this.onViewUpdate();
363515
}
364516

@@ -692,7 +844,9 @@ export class Spine extends ViewContainer {
692844
protected onViewUpdate () {
693845
// increment from the 12th bit!
694846
this._didViewChangeTick++;
695-
this._boundsDirty = true;
847+
if (!this._boundsProvider) {
848+
this._boundsDirty = true;
849+
}
696850

697851
if (this.didViewUpdate) return;
698852
this.didViewUpdate = true;
@@ -806,7 +960,18 @@ export class Spine extends ViewContainer {
806960

807961
skeletonBounds.update(this.skeleton, true);
808962

809-
if (skeletonBounds.minX === Infinity) {
963+
if (this._boundsProvider) {
964+
const boundsSpine = this._boundsProvider.calculateBounds(this);
965+
966+
const bounds = this._bounds;
967+
bounds.clear();
968+
969+
bounds.x = boundsSpine.x;
970+
bounds.y = boundsSpine.y;
971+
bounds.width = boundsSpine.width;
972+
bounds.height = boundsSpine.height;
973+
974+
} else if (skeletonBounds.minX === Infinity) {
810975
if (this.hasNeverUpdated) {
811976
this._updateAndApplyState(0);
812977
this._boundsDirty = false;
@@ -898,11 +1063,16 @@ export class Spine extends ViewContainer {
8981063
* @param options - Options to configure the Spine game object. See {@link SpineFromOptions}
8991064
* @returns {Spine} The Spine game object instantiated
9001065
*/
901-
static from ({ skeleton, atlas, scale = 1, darkTint, autoUpdate = true }: SpineFromOptions) {
1066+
static from ({ skeleton, atlas, scale = 1, darkTint, autoUpdate = true, boundsProvider }: SpineFromOptions) {
9021067
const cacheKey = `${skeleton}-${atlas}-${scale}`;
9031068

9041069
if (Cache.has(cacheKey)) {
905-
return new Spine(Cache.get<SkeletonData>(cacheKey));
1070+
return new Spine({
1071+
skeletonData: Cache.get<SkeletonData>(cacheKey),
1072+
darkTint,
1073+
autoUpdate,
1074+
boundsProvider,
1075+
});
9061076
}
9071077

9081078
const skeletonAsset = Assets.get<any | Uint8Array>(skeleton);
@@ -922,6 +1092,7 @@ export class Spine extends ViewContainer {
9221092
skeletonData,
9231093
darkTint,
9241094
autoUpdate,
1095+
boundsProvider,
9251096
});
9261097
}
9271098
}

0 commit comments

Comments
 (0)