From 9fb49c2166b178d85d16ac67399c44a7dc3efe35 Mon Sep 17 00:00:00 2001 From: Davide Tantillo Date: Tue, 28 Jan 2025 16:13:28 +0100 Subject: [PATCH] [ts][pixi-v8] Allow to define a bounds providers for the Spine game object. See #2734. --- spine-ts/spine-pixi-v8/example/bounds.html | 115 +++++++++++++ spine-ts/spine-pixi-v8/src/Spine.ts | 183 ++++++++++++++++++++- 2 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 spine-ts/spine-pixi-v8/example/bounds.html diff --git a/spine-ts/spine-pixi-v8/example/bounds.html b/spine-ts/spine-pixi-v8/example/bounds.html new file mode 100644 index 000000000..2215a49fb --- /dev/null +++ b/spine-ts/spine-pixi-v8/example/bounds.html @@ -0,0 +1,115 @@ + + + + spine-pixi-v8 + + + + + + + + + \ No newline at end of file diff --git a/spine-ts/spine-pixi-v8/src/Spine.ts b/spine-ts/spine-pixi-v8/src/Spine.ts index 7cf4c4c5e..c25922b79 100644 --- a/spine-ts/spine-pixi-v8/src/Spine.ts +++ b/spine-ts/spine-pixi-v8/src/Spine.ts @@ -61,6 +61,7 @@ import { SkeletonClipping, SkeletonData, SkeletonJson, + Skin, Slot, type TextureAtlas, TrackEntry, @@ -89,6 +90,9 @@ export interface SpineFromOptions { * If `undefined`, use the dark tint renderer if at least one slot has tint black */ darkTint?: boolean; + + /** The bounds provider to use. If undefined the bounds will be dynamic, calculated when requested and based on the current frame. */ + boundsProvider?: SpineBoundsProvider, }; const vectorAux = new Vector2(); @@ -97,6 +101,138 @@ Skeleton.yDown = true; const clipper = new SkeletonClipping(); +/** A bounds provider calculates the bounding box for a skeleton, which is then assigned as the size of the SpineGameObject. */ +export interface SpineBoundsProvider { + /** Returns the bounding box for the skeleton, in skeleton space. */ + calculateBounds (gameObject: Spine): { + x: number; + y: number; + width: number; + height: number; + }; +} + +/** A bounds provider that provides a fixed size given by the user. */ +export class AABBRectangleBoundsProvider implements SpineBoundsProvider { + constructor ( + private x: number, + private y: number, + private width: number, + private height: number, + ) { } + calculateBounds () { + return { x: this.x, y: this.y, width: this.width, height: this.height }; + } +} + +/** A bounds provider that calculates the bounding box from the setup pose. */ +export class SetupPoseBoundsProvider implements SpineBoundsProvider { + /** + * @param clipping If true, clipping attachments are used to compute the bounds. False, by default. + */ + constructor ( + private clipping = false, + ) { } + + calculateBounds (gameObject: Spine) { + if (!gameObject.skeleton) return { x: 0, y: 0, width: 0, height: 0 }; + // Make a copy of animation state and skeleton as this might be called while + // the skeleton in the GameObject has already been heavily modified. We can not + // reconstruct that state. + const skeleton = new Skeleton(gameObject.skeleton.data); + skeleton.setToSetupPose(); + skeleton.updateWorldTransform(Physics.update); + const bounds = skeleton.getBoundsRect(this.clipping ? new SkeletonClipping() : undefined); + return bounds.width == Number.NEGATIVE_INFINITY + ? { x: 0, y: 0, width: 0, height: 0 } + : bounds; + } +} + +/** A bounds provider that calculates the bounding box by taking the maximumg bounding box for a combination of skins and specific animation. */ +export class SkinsAndAnimationBoundsProvider + implements SpineBoundsProvider { + /** + * @param animation The animation to use for calculating the bounds. If null, the setup pose is used. + * @param skins The skins to use for calculating the bounds. If empty, the default skin is used. + * @param timeStep The time step to use for calculating the bounds. A smaller time step means more precision, but slower calculation. + * @param clipping If true, clipping attachments are used to compute the bounds. False, by default. + */ + constructor ( + private animation: string | null, + private skins: string[] = [], + private timeStep: number = 0.05, + private clipping = false, + ) { } + + calculateBounds (gameObject: Spine): { + x: number; + y: number; + width: number; + height: number; + } { + if (!gameObject.skeleton || !gameObject.state) + return { x: 0, y: 0, width: 0, height: 0 }; + // Make a copy of animation state and skeleton as this might be called while + // the skeleton in the GameObject has already been heavily modified. We can not + // reconstruct that state. + const animationState = new AnimationState(gameObject.state.data); + const skeleton = new Skeleton(gameObject.skeleton.data); + const clipper = this.clipping ? new SkeletonClipping() : undefined; + const data = skeleton.data; + if (this.skins.length > 0) { + let customSkin = new Skin("custom-skin"); + for (const skinName of this.skins) { + const skin = data.findSkin(skinName); + if (skin == null) continue; + customSkin.addSkin(skin); + } + skeleton.setSkin(customSkin); + } + skeleton.setToSetupPose(); + + const animation = this.animation != null ? data.findAnimation(this.animation!) : null; + + if (animation == null) { + skeleton.updateWorldTransform(Physics.update); + const bounds = skeleton.getBoundsRect(clipper); + return bounds.width == Number.NEGATIVE_INFINITY + ? { x: 0, y: 0, width: 0, height: 0 } + : bounds; + } else { + let minX = Number.POSITIVE_INFINITY, + minY = Number.POSITIVE_INFINITY, + maxX = Number.NEGATIVE_INFINITY, + maxY = Number.NEGATIVE_INFINITY; + animationState.clearTracks(); + animationState.setAnimationWith(0, animation, false); + const steps = Math.max(animation.duration / this.timeStep, 1.0); + for (let i = 0; i < steps; i++) { + const delta = i > 0 ? this.timeStep : 0; + animationState.update(delta); + animationState.apply(skeleton); + skeleton.update(delta); + skeleton.updateWorldTransform(Physics.update); + + const bounds = skeleton.getBoundsRect(clipper); + minX = Math.min(minX, bounds.x); + minY = Math.min(minY, bounds.y); + maxX = Math.max(maxX, bounds.x + bounds.width); + maxY = Math.max(maxY, bounds.y + bounds.height); + } + const bounds = { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + return bounds.width == Number.NEGATIVE_INFINITY + ? { x: 0, y: 0, width: 0, height: 0 } + : bounds; + } + } +} + export interface SpineOptions extends ContainerOptions { /** the {@link SkeletonData} used to instantiate the skeleton */ skeletonData: SkeletonData; @@ -106,6 +242,9 @@ export interface SpineOptions extends ContainerOptions { /** See {@link SpineFromOptions.darkTint}. */ darkTint?: boolean; + + /** See {@link SpineFromOptions.boundsProvider}. */ + boundsProvider?: SpineBoundsProvider, } /** @@ -229,6 +368,19 @@ export class Spine extends ViewContainer { this._autoUpdate = value; } + public _boundsProvider?: SpineBoundsProvider; + /** The bounds provider to use. If undefined the bounds will be dynamic, calculated when requested and based on the current frame. */ + public get boundsProvider (): SpineBoundsProvider | undefined { + return this._boundsProvider; + } + public set boundsProvider (value: SpineBoundsProvider | undefined) { + this._boundsProvider = value; + if (value) { + this._boundsDirty = false; + } + this.updateBounds(); + } + private hasNeverUpdated = true; constructor (options: SpineOptions | SkeletonData) { if (options instanceof SkeletonData) { @@ -255,6 +407,8 @@ export class Spine extends ViewContainer { for (let i = 0; i < slots.length; i++) { this.attachmentCacheData[i] = Object.create(null); } + + this._boundsProvider = options.boundsProvider; } /** 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 { this._stateChanged = true; - this._boundsDirty = true; - this.onViewUpdate(); } @@ -692,7 +844,9 @@ export class Spine extends ViewContainer { protected onViewUpdate () { // increment from the 12th bit! this._didViewChangeTick++; - this._boundsDirty = true; + if (!this._boundsProvider) { + this._boundsDirty = true; + } if (this.didViewUpdate) return; this.didViewUpdate = true; @@ -806,7 +960,18 @@ export class Spine extends ViewContainer { skeletonBounds.update(this.skeleton, true); - if (skeletonBounds.minX === Infinity) { + if (this._boundsProvider) { + const boundsSpine = this._boundsProvider.calculateBounds(this); + + const bounds = this._bounds; + bounds.clear(); + + bounds.x = boundsSpine.x; + bounds.y = boundsSpine.y; + bounds.width = boundsSpine.width; + bounds.height = boundsSpine.height; + + } else if (skeletonBounds.minX === Infinity) { if (this.hasNeverUpdated) { this._updateAndApplyState(0); this._boundsDirty = false; @@ -898,11 +1063,16 @@ export class Spine extends ViewContainer { * @param options - Options to configure the Spine game object. See {@link SpineFromOptions} * @returns {Spine} The Spine game object instantiated */ - static from ({ skeleton, atlas, scale = 1, darkTint, autoUpdate = true }: SpineFromOptions) { + static from ({ skeleton, atlas, scale = 1, darkTint, autoUpdate = true, boundsProvider }: SpineFromOptions) { const cacheKey = `${skeleton}-${atlas}-${scale}`; if (Cache.has(cacheKey)) { - return new Spine(Cache.get(cacheKey)); + return new Spine({ + skeletonData: Cache.get(cacheKey), + darkTint, + autoUpdate, + boundsProvider, + }); } const skeletonAsset = Assets.get(skeleton); @@ -922,6 +1092,7 @@ export class Spine extends ViewContainer { skeletonData, darkTint, autoUpdate, + boundsProvider, }); } }