diff --git a/examples/assets/spine_logo.png b/examples/assets/spine_logo.png new file mode 100644 index 0000000..40e65c5 Binary files /dev/null and b/examples/assets/spine_logo.png differ diff --git a/examples/events-example.html b/examples/events-example.html index 1eca2a5..6c37961 100644 --- a/examples/events-example.html +++ b/examples/events-example.html @@ -2,7 +2,7 @@ spine-pixi - + @@ -35,8 +35,8 @@ document.body.appendChild(app.view); // Pre-load the skeleton data and atlas. You can also load .json skeleton data. - PIXI.Assets.add("spineboyData", "./assets/spineboy-pro.skel"); - PIXI.Assets.add("spineboyAtlas", "./assets/spineboy-pma.atlas"); + PIXI.Assets.add({alias: "spineboyData", src: "./assets/spineboy-pro.skel" }); + PIXI.Assets.add({alias: "spineboyAtlas", src: "./assets/spineboy-pma.atlas" }); await PIXI.Assets.load(["spineboyData", "spineboyAtlas"]); // Create the Spine display object diff --git a/examples/index.html b/examples/index.html index da69aff..f1b9866 100644 --- a/examples/index.html +++ b/examples/index.html @@ -2,7 +2,7 @@ spine-pixi - + diff --git a/examples/manual-loading.html b/examples/manual-loading.html index 1792aa3..7acbd87 100644 --- a/examples/manual-loading.html +++ b/examples/manual-loading.html @@ -2,7 +2,7 @@ spine-pixi - + @@ -24,8 +24,8 @@ document.body.appendChild(app.view); // Pre-load the skeleton data and atlas. You can also load .json skeleton data. - PIXI.Assets.add("spineboyData", "./assets/spineboy-pro.skel"); - PIXI.Assets.add("spineboyAtlas", "./assets/spineboy-pma.atlas"); + PIXI.Assets.add({alias: "spineboyData", src: "./assets/spineboy-pro.skel" }); + PIXI.Assets.add({alias: "spineboyAtlas", src: "./assets/spineboy-pma.atlas" }); await PIXI.Assets.load(["spineboyData", "spineboyAtlas"]); // Manually load the data and create a Spine display object from it using diff --git a/examples/mix-and-match-example.html b/examples/mix-and-match-example.html index 30a636a..ac69f38 100644 --- a/examples/mix-and-match-example.html +++ b/examples/mix-and-match-example.html @@ -2,7 +2,7 @@ spine-pixi - + @@ -24,8 +24,8 @@ document.body.appendChild(app.view); // Pre-load the skeleton data and atlas. You can also load .json skeleton data. - PIXI.Assets.add("mixAndMatchData", "./assets/mix-and-match-pro.skel"); - PIXI.Assets.add("mixAndMatchAtlas", "./assets/mix-and-match-pma.atlas"); + PIXI.Assets.add({alias: "mixAndMatchData", src: "./assets/mix-and-match-pro.skel" }); + PIXI.Assets.add({alias: "mixAndMatchAtlas", src: "./assets/mix-and-match-pma.atlas" }); await PIXI.Assets.load(["mixAndMatchData", "mixAndMatchAtlas"]); // Create the Spine display object diff --git a/examples/mouse-following.html b/examples/mouse-following.html index 9038b26..b157ba7 100644 --- a/examples/mouse-following.html +++ b/examples/mouse-following.html @@ -2,7 +2,7 @@ Spine Pixi Example - + @@ -24,8 +24,8 @@ document.body.appendChild(app.view); // Pre-load the skeleton data and atlas. You can also load .json skeleton data. - PIXI.Assets.add("spineboyData", "./assets/spineboy-pro.skel"); - PIXI.Assets.add("spineboyAtlas", "./assets/spineboy-pma.atlas"); + PIXI.Assets.add({alias: "spineboyData", src: "./assets/spineboy-pro.skel" }); + PIXI.Assets.add({alias: "spineboyAtlas", src: "./assets/spineboy-pma.atlas" }); await PIXI.Assets.load(["spineboyData", "spineboyAtlas"]); // Create the spine display object @@ -47,6 +47,7 @@ // Add the display object to the stage. app.stage.addChild(spineboy); + app.stage.hitArea = new PIXI.Rectangle(0, 0, app.view.width, app.view.height); // Make the stage interactive and register pointer events app.stage.eventMode = "dynamic"; @@ -57,7 +58,7 @@ setBonePosition(e); }); - app.stage.on("pointermove", (e) => { + app.stage.on("globalpointermove", (e) => { if (isDragging) setBonePosition(e); }); diff --git a/examples/simple-input.html b/examples/simple-input.html index 8220bf1..5c13edd 100644 --- a/examples/simple-input.html +++ b/examples/simple-input.html @@ -2,7 +2,7 @@ spine-pixi - + @@ -24,8 +24,8 @@ document.body.appendChild(app.view); // Pre-load the skeleton data and atlas. You can also load .json skeleton data. - PIXI.Assets.add("spineboyData", "./assets/spineboy-pro.skel"); - PIXI.Assets.add("spineboyAtlas", "./assets/spineboy-pma.atlas"); + PIXI.Assets.add({alias: "spineboyData", src: "./assets/spineboy-pro.skel" }); + PIXI.Assets.add({alias: "spineboyAtlas", src: "./assets/spineboy-pma.atlas" }); await PIXI.Assets.load(["spineboyData", "spineboyAtlas"]); // Create the spine display object @@ -35,7 +35,7 @@ // Set the default animation and the // default mix for transitioning between animations. - spineboy.state.setAnimation(0, "run", true); + spineboy.state.setAnimation(0, "hoverboard", true); spineboy.state.data.defaultMix = 0.2; // Center the spine object on screen. @@ -43,20 +43,56 @@ spineboy.y = window.innerHeight / 2 + spineboy.getBounds().height / 2; // Make it so that you can interact with Spineboy. - // Also, handle the case that you click or tap on the screen. - // The callback function definition can be seen below. + // Handle the case that you click/tap the screen. spineboy.eventMode = 'static'; spineboy.on('pointerdown', onClick); - + // Add the display object to the stage. app.stage.addChild(spineboy); + + // Add variables for movement, speed. + let moveLeft = false; + let moveRight = false; + const speed = 5; + + // Handle the case that the keyboard keys specified below are pressed. + function onKeyDown(key) { + if (key.code === "ArrowLeft" || key.code === "KeyA") { + moveLeft = true; + spineboy.skeleton.scaleX = -1; + } else if (key.code === "ArrowRight" || key.code === "KeyD") { + moveRight = true; + spineboy.skeleton.scaleX = 1; + } + } + + // Handle when the keys are released, if they were pressed. + function onKeyUp(key) { + if (key.code === "ArrowLeft" || key.code === "KeyA") { + moveLeft = false; + } else if (key.code === "ArrowRight" || key.code === "KeyD") { + moveRight = false; + } + } - // This callback function handles what happens - // when you click or tap on the screen. + // Handle if you click/tap the screen. function onClick() { - spineboy.state.addAnimation(0, "jump", false, 0); - spineboy.state.addAnimation(0, "idle", true, 0); + spineboy.state.setAnimation(1, "shoot", false, 0); } + + // Add event listeners so that the window will correctly handle input. + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + + // Update the application to move Spineboy if input is detected. + app.ticker.add(() => { + if (moveLeft) { + spineboy.x -= speed; + } + if (moveRight) { + spineboy.x += speed; + } + }); })(); diff --git a/examples/slot-objects.html b/examples/slot-objects.html new file mode 100644 index 0000000..3f09381 --- /dev/null +++ b/examples/slot-objects.html @@ -0,0 +1,125 @@ + + + + spine-pixi + + + + + + + + + + diff --git a/src/BatchableClippedSpineSlot.ts b/src/BatchableClippedSpineSlot.ts deleted file mode 100644 index 9cae5da..0000000 --- a/src/BatchableClippedSpineSlot.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** **************************************************************************** - * Spine Runtimes License Agreement - * Last updated July 28, 2023. Replaces all prior versions. - * - * Copyright (c) 2013-2023, Esoteric Software LLC - * - * Integration of the Spine Runtimes into software or otherwise creating - * derivative works of the Spine Runtimes is permitted under the terms and - * conditions of Section 2 of the Spine Editor License Agreement: - * http://esotericsoftware.com/spine-editor-license - * - * Otherwise, it is permitted to integrate the Spine Runtimes into software or - * otherwise create derivative works of the Spine Runtimes (collectively, - * "Products"), provided that each user of the Products must obtain their own - * Spine Editor license and redistribution of the Products in any form must - * include this license and copyright notice. - * - * THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY - * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, - * BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE - * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - *****************************************************************************/ - -import { Spine } from './Spine'; - -import type { Batch, BatchableObject, Batcher, IndexBufferArray, Texture } from 'pixi.js'; -import type { SkeletonClipping, Slot } from '@esotericsoftware/spine-core'; - -export class BatchableClippedSpineSlot implements BatchableObject -{ - indexStart: number; - textureId: number; - texture: Texture; - location: number; - batcher: Batcher; - batch: Batch; - renderable: Spine; - - slot:Slot; - indexSize: number; - vertexSize: number; - clippedVertices: number[] = []; - clippedTriangles: number[] = []; - - roundPixels: 0 | 1; - - get blendMode() { return this.renderable.groupBlendMode; } - - reset() - { - this.renderable = null as any; - this.texture = null as any; - this.batcher = null as any; - this.batch = null as any; - } - - setClipper(clipper:SkeletonClipping) - { - // copy clipped verts and triangles - copyArray(clipper.clippedVertices, this.clippedVertices); - copyArray(clipper.clippedTriangles, this.clippedTriangles); - - this.vertexSize = (clipper.clippedVertices.length / 8); - this.indexSize = clipper.clippedTriangles.length; - } - - packIndex(indexBuffer: IndexBufferArray, index: number, indicesOffset: number) - { - const indices = this.clippedTriangles; - - for (let i = 0; i < indices.length; i++) - { - indexBuffer[index++] = indices[i] + indicesOffset; - } - } - - packAttributes( - float32View: Float32Array, - uint32View: Uint32Array, - index: number, - textureId: number - ) - { - const clippedVertices = this.clippedVertices; - const vertexSize = this.vertexSize; - - const abgr = this.renderable.groupColor; - - const textureIdAndRound = (textureId << 16) | (this.roundPixels & 0xFFFF); - - for (let i = 0; i < vertexSize; i++) - { - const localIndex = i * 8; - - // position - float32View[index++] = clippedVertices[localIndex]; - float32View[index++] = clippedVertices[localIndex + 1] * -1; - - // uv - float32View[index++] = clippedVertices[localIndex + 6]; - float32View[index++] = clippedVertices[localIndex + 7]; - // color - uint32View[index++] = abgr; - - // texture id - float32View[index++] = textureIdAndRound; - } - } -} - -function copyArray(a:number[], b:number[]) -{ - for (let i = 0; i < a.length; i++) - { - b[i] = a[i]; - } -} diff --git a/src/BatchableSpineSlot.ts b/src/BatchableSpineSlot.ts index 3ca8c9a..25ff043 100644 --- a/src/BatchableSpineSlot.ts +++ b/src/BatchableSpineSlot.ts @@ -27,12 +27,9 @@ * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ -import { Spine } from './Spine'; -import { MeshAttachment, RegionAttachment, Slot } from '@esotericsoftware/spine-core'; +import { AttachmentCacheData, Spine } from './Spine'; -import type { Batch, BatchableObject, Batcher, IndexBufferArray, Texture } from 'pixi.js'; - -const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0]; +import type { Batch, BatchableObject, Batcher, BLEND_MODES, IndexBufferArray, Texture } from 'pixi.js'; export class BatchableSpineSlot implements BatchableObject { @@ -44,43 +41,55 @@ export class BatchableSpineSlot implements BatchableObject batch: Batch; renderable: Spine; - slot:Slot; + vertices: Float32Array; + indices: number[] | Uint16Array; + uvs: Float32Array; + indexSize: number; vertexSize: number; roundPixels: 0 | 1; - - get blendMode() { return this.renderable.groupBlendMode; } - - reset() - { - this.renderable = null as any; - this.texture = null as any; - this.batcher = null as any; - this.batch = null as any; - } - - setSlot(slot:Slot) + data: AttachmentCacheData; + blendMode: BLEND_MODES; + + setData( + renderable:Spine, + data:AttachmentCacheData, + texture:Texture, + blendMode:BLEND_MODES, + roundPixels: 0 | 1) { - this.slot = slot; - - const attachment = slot.getAttachment(); + this.renderable = renderable; + this.data = data; - if (attachment instanceof RegionAttachment) + if (data.clipped) { - this.vertexSize = 4; - this.indexSize = 6; + const clippedData = data.clippedData; + + this.indexSize = clippedData.indicesCount; + this.vertexSize = clippedData.vertexCount; + this.vertices = clippedData.vertices; + this.indices = clippedData.indices; + this.uvs = clippedData.uvs; } - else if (attachment instanceof MeshAttachment) + else { - this.vertexSize = attachment.worldVerticesLength / 2; - this.indexSize = attachment.triangles.length; + this.indexSize = data.indices.length; + this.vertexSize = data.vertices.length / 2; + this.vertices = data.vertices; + this.indices = data.indices; + this.uvs = data.uvs; } + + this.texture = texture; + this.roundPixels = roundPixels; + + this.blendMode = blendMode; } packIndex(indexBuffer: IndexBufferArray, index: number, indicesOffset: number) { - const indices = (this.slot.getAttachment() as MeshAttachment).triangles ?? QUAD_TRIANGLES; + const indices = this.indices; for (let i = 0; i < indices.length; i++) { @@ -95,25 +104,13 @@ export class BatchableSpineSlot implements BatchableObject textureId: number ) { - const slot = this.slot; - const attachment = slot.getAttachment() as MeshAttachment | RegionAttachment; + const { uvs, vertices, vertexSize } = this; - if (attachment instanceof MeshAttachment) - { - attachment.computeWorldVertices(slot, 0, attachment.worldVerticesLength, float32View, index, 6); - } - else if (attachment instanceof RegionAttachment) - { - attachment.computeWorldVertices(slot, float32View, index, 6); - } - - const vertexSize = this.vertexSize; + const slotColor = this.data.color; - const parentColor:number = this.renderable.groupColor; // BGR + const parentColor:number = this.renderable.groupColor; const parentAlpha:number = this.renderable.groupAlpha; - const slotColor: {r: number, g:number, b: number, a: number} = slot.color; - let abgr:number; const mixedA = (slotColor.a * parentAlpha) * 255; @@ -135,8 +132,6 @@ export class BatchableSpineSlot implements BatchableObject abgr = ((mixedA) << 24) | ((slotColor.b * 255) << 16) | ((slotColor.g * 255) << 8) | (slotColor.r * 255); } - const uvs = attachment.uvs; - const matrix = this.renderable.groupTransform; const a = matrix.a; @@ -150,10 +145,8 @@ export class BatchableSpineSlot implements BatchableObject for (let i = 0; i < vertexSize; i++) { - // index++; - // float32View[index++] *= -1; - const x = float32View[index]; - const y = -float32View[index + 1]; + const x = vertices[i * 2]; + const y = vertices[(i * 2) + 1]; float32View[index++] = (a * x) + (c * y) + tx; float32View[index++] = (b * x) + (d * y) + ty; diff --git a/src/Spine.ts b/src/Spine.ts index a85223c..0f459b9 100644 --- a/src/Spine.ts +++ b/src/Spine.ts @@ -35,24 +35,31 @@ import { ContainerOptions, DEG_TO_RAD, DestroyOptions, - PointData, Ticker, - View + PointData, + Ticker, + View, } from 'pixi.js'; -import { getSkeletonBounds } from './getSkeletonBounds'; import { ISpineDebugRenderer } from './SpineDebugRenderer'; import { AnimationState, AnimationStateData, AtlasAttachmentLoader, + Attachment, Bone, + ClippingAttachment, + Color, + MeshAttachment, + RegionAttachment, Skeleton, SkeletonBinary, SkeletonBounds, + SkeletonClipping, SkeletonData, SkeletonJson, + Slot, type TextureAtlas, TrackEntry, - Vector2 + Vector2, } from '@esotericsoftware/spine-core'; export type SpineFromOptions = { @@ -62,6 +69,12 @@ export type SpineFromOptions = { }; const vectorAux = new Vector2(); +const lightColor = new Color(); +const darkColor = new Color(); + +Skeleton.yDown = true; + +const clipper = new SkeletonClipping(); export interface SpineOptions extends ContainerOptions { @@ -79,6 +92,23 @@ export interface SpineEvents start: [trackEntry: TrackEntry]; } +export interface AttachmentCacheData +{ + id: string; + clipped: boolean; + vertices: Float32Array; + uvs: Float32Array; + indices: number[]; + color: { r: number; g: number; b: number; a: number }; + clippedData?: { + vertices: Float32Array; + uvs: Float32Array; + indices: Uint16Array; + vertexCount: number; + indicesCount: number; + }; +} + export class Spine extends Container implements View { // Pixi properties @@ -88,7 +118,7 @@ export class Spine extends Container implements View public _didSpineUpdate = false; public _boundsDirty = true; public _roundPixels: 0 | 1; - private _bounds:Bounds = new Bounds(); + private _bounds: Bounds = new Bounds(); // Spine properties public skeleton: Skeleton; @@ -96,12 +126,32 @@ export class Spine extends Container implements View public skeletonBounds: SkeletonBounds; private _debug?: ISpineDebugRenderer | undefined = undefined; - private readonly _mappings:{bone:Bone, container:Container}[] = []; + readonly _slotsObject: Record = Object.create(null); + + private getSlotFromRef(slotRef: number | string | Slot): Slot + { + let slot: Slot | null; + + if (typeof slotRef === 'number') slot = this.skeleton.slots[slotRef]; + else if (typeof slotRef === 'string') slot = this.skeleton.findSlot(slotRef); + else slot = slotRef; + + if (!slot) throw new Error(`No slot found with the given slot reference: ${slotRef}`); + + return slot; + } + + public spineAttachmentsDirty: boolean; + private _lastAttachments: Attachment[]; + + private _stateChanged: boolean; + private attachmentCacheData: Record = {}; public get debug(): ISpineDebugRenderer | undefined { return this._debug; } + public set debug(value: ISpineDebugRenderer | undefined) { if (this._debug) @@ -114,12 +164,15 @@ export class Spine extends Container implements View } this._debug = value; } + private autoUpdateWarned = false; private _autoUpdate = true; + public get autoUpdate(): boolean { return this._autoUpdate; } + public set autoUpdate(value: boolean) { if (value) @@ -131,15 +184,16 @@ export class Spine extends Container implements View { Ticker.shared.remove(this.internalUpdate, this); } + this._autoUpdate = value; } - constructor(options:SpineOptions | SkeletonData) + constructor(options: SpineOptions | SkeletonData) { if (options instanceof SkeletonData) { options = { - skeletonData: options + skeletonData: options, }; } @@ -150,16 +204,21 @@ export class Spine extends Container implements View this.skeleton = new Skeleton(skeletonData); this.state = new AnimationState(new AnimationStateData(skeletonData)); this.autoUpdate = options?.autoUpdate ?? true; + + this._updateState(0); } public update(dt: number): void { if (this.autoUpdate && !this.autoUpdateWarned) { - // eslint-disable-next-line max-len - console.warn('You are calling update on a Spine instance that has autoUpdate set to true. This is probably not what you want.'); + console.warn( + // eslint-disable-next-line max-len + 'You are calling update on a Spine instance that has autoUpdate set to true. This is probably not what you want.', + ); this.autoUpdateWarned = true; } + this.internalUpdate(0, dt); } @@ -167,7 +226,7 @@ export class Spine extends Container implements View { // Because reasons, pixi uses deltaFrames at 60fps. // We ignore the default deltaFrames and use the deltaSeconds from pixi ticker. - this.updateState(deltaSeconds ?? Ticker.shared.deltaMS / 1000); + this._updateState(deltaSeconds ?? Ticker.shared.deltaMS / 1000); } get bounds() @@ -202,7 +261,7 @@ export class Spine extends Container implements View else { bone.x = vectorAux.x; - bone.y = -vectorAux.y; + bone.y = vectorAux.y; } } @@ -228,45 +287,304 @@ export class Spine extends Container implements View } outPos.x = bone.worldX; - outPos.y = -bone.worldY; + outPos.y = bone.worldY; return outPos; } - updateState(dt:number) + /** + * Will update the state based on the specified time, this will not apply the state to the skeleton + * as this is differed until the `applyState` method is called. + * + * @param time the time at which to set the state + * @internal + */ + _updateState(time: number) { - this.state.update(dt); + this.state.update(time); + + this._stateChanged = true; + this._boundsDirty = true; - // update the mappings.. + this.onViewUpdate(); + } + + /** + * Applies the state to this spine instance. + * - updates the state to the skeleton + * - updates its world transform (spine world transform) + * - validates the attachments - to flag if the attachments have changed this state + * - transforms the attachments - to update the vertices of the attachments based on the new positions + * - update the slot attachments - to update the position, rotation, scale, and visibility of the attached containers + * @internal + */ + _applyState() + { + if (!this._stateChanged) return; + this._stateChanged = false; + + const { skeleton } = this; + + this.state.apply(skeleton); + + skeleton.updateWorldTransform(); + + this.validateAttachments(); + + this.transformAttachments(); + + this.updateSlotObjects(); + } + + private validateAttachments() + { + const currentDrawOrder = this.skeleton.drawOrder; + + const lastAttachments = (this._lastAttachments ||= []); + + let index = 0; - this._mappings.forEach((mapping) => + let spineAttachmentsDirty = false; + + for (let i = 0; i < currentDrawOrder.length; i++) + { + const slot = currentDrawOrder[i]; + const attachment = slot.getAttachment(); + + if (attachment) + { + if (attachment !== lastAttachments[index]) + { + spineAttachmentsDirty = true; + lastAttachments[index] = attachment; + } + + index++; + } + } + + if (index !== lastAttachments.length) + { + spineAttachmentsDirty = true; + lastAttachments.length = index; + } + + this.spineAttachmentsDirty = spineAttachmentsDirty; + } + + private transformAttachments() + { + const currentDrawOrder = this.skeleton.drawOrder; + + for (let i = 0; i < currentDrawOrder.length; i++) + { + const slot = currentDrawOrder[i]; + + const attachment = slot.getAttachment(); + + if (attachment) + { + if (attachment instanceof MeshAttachment || attachment instanceof RegionAttachment) + { + const cacheData = this._getCachedData(slot, attachment); + + if (attachment instanceof RegionAttachment) + { + attachment.computeWorldVertices(slot, cacheData.vertices, 0, 2); + } + else + { + attachment.computeWorldVertices( + slot, + 0, + attachment.worldVerticesLength, + cacheData.vertices, + 0, + 2, + ); + } + + cacheData.clipped = false; + + if (clipper.isClipping()) + { + this.updateClippingData(cacheData); + } + } + else if (attachment instanceof ClippingAttachment) + { + clipper.clipStart(slot, attachment); + } + else + { + clipper.clipEndWithSlot(slot); + } + } + } + + clipper.clipEnd(); + } + + private updateClippingData(cacheData: AttachmentCacheData) + { + cacheData.clipped = true; + + clipper.clipTriangles( + cacheData.vertices, + cacheData.vertices.length, + cacheData.indices, + cacheData.indices.length, + cacheData.uvs, + lightColor, + darkColor, + false, + ); + + const { clippedVertices, clippedTriangles } = clipper; + + const verticesCount = clippedVertices.length / 8; + const indicesCount = clippedTriangles.length; + + if (!cacheData.clippedData) { - const { bone, container } = mapping; + cacheData.clippedData = { + vertices: new Float32Array(verticesCount * 2), + uvs: new Float32Array(verticesCount * 2), + vertexCount: verticesCount, + indices: new Uint16Array(indicesCount), + indicesCount, + }; + + this.spineAttachmentsDirty = true; + } - container.position.set(bone.worldX, -bone.worldY); + const clippedData = cacheData.clippedData; + + const sizeChange = clippedData.vertexCount !== verticesCount || indicesCount !== clippedData.indicesCount; + + if (sizeChange) + { + this.spineAttachmentsDirty = true; + + if (clippedData.vertexCount < verticesCount) + { + // buffer reuse! + clippedData.vertices = new Float32Array(verticesCount * 2); + clippedData.uvs = new Float32Array(verticesCount * 2); + } + + if (clippedData.indices.length < indicesCount) + { + clippedData.indices = new Uint16Array(indicesCount); + } + } + + const { vertices, uvs, indices } = clippedData; + + for (let i = 0; i < verticesCount; i++) + { + vertices[i * 2] = clippedVertices[i * 8]; + vertices[(i * 2) + 1] = clippedVertices[(i * 8) + 1]; + + uvs[i * 2] = clippedVertices[(i * 8) + 6]; + uvs[(i * 2) + 1] = clippedVertices[(i * 8) + 7]; + } + + clippedData.vertexCount = verticesCount; + + for (let i = 0; i < indices.length; i++) + { + indices[i] = clippedTriangles[i]; + } + + clippedData.indicesCount = indicesCount; + } + + /** + * ensure that attached containers map correctly to their slots + * along with their position, rotation, scale, and visibility. + */ + private updateSlotObjects() + { + for (const i in this._slotsObject) + { + const slotAttachment = this._slotsObject[i]; + + if (!slotAttachment) continue; + + this.updateSlotObject(slotAttachment); + } + } + + private updateSlotObject(slotAttachment: {slot:Slot, container:Container}) + { + const { slot, container } = slotAttachment; + + container.visible = this.skeleton.drawOrder.includes(slot); + + if (container.visible) + { + const bone = slot.bone; + + container.position.set(bone.worldX, bone.worldY); container.scale.x = bone.getWorldScaleX(); container.scale.y = bone.getWorldScaleY(); - const rotationX = bone.getWorldRotationX() * DEG_TO_RAD; - const rotationY = bone.getWorldRotationY() * DEG_TO_RAD; + container.rotation = bone.getWorldRotationX() * DEG_TO_RAD; + } + } - container.rotation = -Math.atan2( - Math.sin(rotationX) + Math.sin(rotationY), - Math.cos(rotationX) + Math.cos(rotationY) - ); - }); - this.onViewUpdate(); + /** @internal */ + _getCachedData(slot: Slot, attachment: RegionAttachment | MeshAttachment): AttachmentCacheData + { + const key = `${slot.data.index}-${attachment.name}`; + + return this.attachmentCacheData[key] || this.initCachedData(slot, attachment); + } + + private initCachedData(slot: Slot, attachment: RegionAttachment | MeshAttachment): AttachmentCacheData + { + const key = `${slot.data.index}-${attachment.name}`; + + let vertices: Float32Array; + + if (attachment instanceof RegionAttachment) + { + vertices = new Float32Array(8); + + this.attachmentCacheData[key] = { + id: key, + vertices, + clipped: false, + indices: [0, 1, 2, 0, 2, 3], + uvs: attachment.uvs as Float32Array, + color: slot.color, + }; + } + else + { + vertices = new Float32Array(attachment.worldVerticesLength); + + this.attachmentCacheData[key] = { + id: key, + vertices, + clipped: false, + indices: attachment.triangles, + uvs: attachment.uvs as Float32Array, + color: slot.color, + }; + } + + return this.attachmentCacheData[key]; } onViewUpdate() { // increment from the 12th bit! this._didChangeId += 1 << 12; - this._didSpineUpdate = true; - this._didSpineUpdate = true; this._boundsDirty = true; if (this.didViewUpdate) return; @@ -283,68 +601,92 @@ export class Spine extends Container implements View } /** - * Attaches a PixiJS container to a specified bone. This will map the world transform of the bone - * to the attached container. A container can only be attached to one bone at a time. + * Attaches a PixiJS container to a specified slot. This will map the world transform of the slots bone + * to the attached container. A container can only be attached to one slot at a time. * - * @param container - The container to attach to the bone - * @param bone - The bone id or bone to attach to + * @param container - The container to attach to the slot + * @param slotRef - The slot id or slot to attach to */ - attachToBone(container:Container, bone:string | Bone) + addSlotObject(slot: number | string | Slot, container: Container) { - this.detachFromBone(container, bone); + slot = this.getSlotFromRef(slot); - if (typeof bone === 'string') + // need to check in on the container too... + for (const i in this._slotsObject) { - bone = this.skeleton.findBone(bone) as Bone; + if (this._slotsObject[i]?.container === container) + { + this.removeSlotObject(this._slotsObject[i].slot); + } } - if (!bone) - { - throw new Error(`Bone ${bone} not found`); - } + this.removeSlotObject(slot); + + container.includeInBuild = false; // TODO only add once?? this.addChild(container); - // TODO search for copies... - one container - to one bone! - this._mappings.push({ - bone, - container - }); + this._slotsObject[slot.data.name] = { + container, + slot + }; + + this.updateSlotObject(this._slotsObject[slot.data.name]); } /** - * Removes a PixiJS container from the bone it is attached to. + * Removes a PixiJS container from the slot it is attached to. * - * @param container - The container to detach from the bone - * @param bone - The bone id or bone to detach from + * @param container - The container to detach from the slot + * @param slotOrContainer - The container, slot id or slot to detach from */ - detachFromBone(container:Container, bone:string | Bone) + removeSlotObject(slotOrContainer: number | string | Slot | Container) { - if (typeof bone === 'string') - { - bone = this.skeleton.findBone(bone) as Bone; - } + let containerToRemove: Container | undefined; - if (!bone) + if (slotOrContainer instanceof Container) { - throw new Error(`Bone ${bone} not found`); + for (const i in this._slotsObject) + { + if (this._slotsObject[i]?.container === slotOrContainer) + { + this._slotsObject[i] = null; + + containerToRemove = slotOrContainer; + break; + } + } } + else + { + const slot = this.getSlotFromRef(slotOrContainer); - this.removeChild(container); + containerToRemove = this._slotsObject[slot.data.name]?.container; + this._slotsObject[slot.data.name] = null; + } - for (let i = 0; i < this._mappings.length; i++) + if (containerToRemove) { - const mapping = this._mappings[i]; + this.removeChild(containerToRemove); - if (mapping.bone === bone && mapping.container === container) - { - this._mappings.splice(i, 1); - break; - } + containerToRemove.includeInBuild = true; } } + /** + * Returns a container attached to a slot, or undefined if no container is attached. + * + * @param slotRef - The slot id or slot to get the attachment from + * @returns - The container attached to the slot + */ + getSlotObject(slot: number | string | Slot) + { + slot = this.getSlotFromRef(slot); + + return this._slotsObject[slot.data.name].container; + } + updateBounds() { this._boundsDirty = false; @@ -357,10 +699,26 @@ export class Spine extends Container implements View if (skeletonBounds.minX === Infinity) { - this.state.apply(this.skeleton); + this._applyState(); + + const drawOrder = this.skeleton.drawOrder; + const bounds = this._bounds; + + bounds.clear(); + + for (let i = 0; i < drawOrder.length; i++) + { + const slot = drawOrder[i]; + + const attachment = slot.getAttachment(); + + if (attachment && (attachment instanceof RegionAttachment || attachment instanceof MeshAttachment)) + { + const cacheData = this._getCachedData(slot, attachment); - // now region bounding attachments.. - getSkeletonBounds(this.skeleton, this._bounds); + bounds.addVertexData(cacheData.vertices, 0, cacheData.vertices.length); + } + } } else { @@ -401,12 +759,15 @@ export class Spine extends Container implements View public override destroy(options: DestroyOptions = false) { super.destroy(options); + Ticker.shared.remove(this.internalUpdate, this); this.state.clearListeners(); this.debug = undefined; this.skeleton = null as any; this.state = null as any; - (this._mappings as any) = null; + (this._slotsObject as any) = null; + this._lastAttachments = null; + this.attachmentCacheData = null as any; } /** Whether or not to round the x/y position of the sprite. */ @@ -420,7 +781,7 @@ export class Spine extends Container implements View this._roundPixels = value ? 1 : 0; } - static from({ skeleton, atlas, scale = 1 }:SpineFromOptions) + static from({ skeleton, atlas, scale = 1 }: SpineFromOptions) { const cacheKey = `${skeleton}-${atlas}`; @@ -434,7 +795,10 @@ export class Spine extends Container implements View const atlasAsset = Assets.get(atlas); const attachmentLoader = new AtlasAttachmentLoader(atlasAsset); // eslint-disable-next-line max-len - const parser = skeletonAsset instanceof Uint8Array ? new SkeletonBinary(attachmentLoader) : new SkeletonJson(attachmentLoader); + const parser + = skeletonAsset instanceof Uint8Array + ? new SkeletonBinary(attachmentLoader) + : new SkeletonJson(attachmentLoader); // TODO scale? parser.scale = scale; @@ -443,7 +807,7 @@ export class Spine extends Container implements View Cache.set(cacheKey, skeletonData); return new Spine({ - skeletonData + skeletonData, }); } } diff --git a/src/SpinePipe.ts b/src/SpinePipe.ts index f2443fc..f87a6bd 100644 --- a/src/SpinePipe.ts +++ b/src/SpinePipe.ts @@ -27,18 +27,26 @@ * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ -import { BigPool, extensions, ExtensionType, type Renderer, type RenderPipe, Texture } from 'pixi.js'; -import { BatchableClippedSpineSlot } from './BatchableClippedSpineSlot'; +import { + collectAllRenderables, + extensions, ExtensionType, + InstructionSet, + type Renderer, + type RenderPipe, + Texture +} from 'pixi.js'; import { BatchableSpineSlot } from './BatchableSpineSlot'; import { Spine } from './Spine'; -import { ClippingAttachment, Color, MeshAttachment, RegionAttachment, SkeletonClipping } from '@esotericsoftware/spine-core'; +import { MeshAttachment, RegionAttachment, SkeletonClipping } from '@esotericsoftware/spine-core'; -import type { Bone } from '@esotericsoftware/spine-core'; +const clipper = new SkeletonClipping(); -const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0]; -const QUAD_VERTS = new Float32Array(8); -const lightColor = new Color(); -const darkColor = new Color(); +const spineBlendModeMap = { + 0: 'normal', + 1: 'add', + 2: 'multiply', + 3: 'screen' +}; // eslint-disable-next-line max-len export class SpinePipe implements RenderPipe @@ -55,153 +63,107 @@ export class SpinePipe implements RenderPipe renderer: Renderer; - private readonly activeBatchableSpineSlots: (BatchableSpineSlot | BatchableClippedSpineSlot)[] = []; + private gpuSpineData:Record = {}; constructor(renderer: Renderer) { this.renderer = renderer; - - renderer.runners.prerender.add({ - prerender: () => - { - this.buildStart(); - } - }); } - validateRenderable(_renderable: Spine): boolean + validateRenderable(spine: Spine): boolean { - return true; - } + spine._applyState(); + // loop through and see if the mesh lengths have changed.. - buildStart() - { - this._returnActiveBatches(); + return spine.spineAttachmentsDirty; } - addRenderable(spine: Spine) + addRenderable(spine: Spine, instructionSet:InstructionSet) { - const batcher = this.renderer.renderPipes.batch; + const gpuSpine = this.gpuSpineData[spine.uid] ||= { slotBatches: {} }; - const rootBone = spine.skeleton.getRootBone() as Bone; - - rootBone.x = 0; - rootBone.y = 0; - rootBone.scaleX = 1; - rootBone.scaleY = 1; - rootBone.rotation = 0; - - spine.state.apply(spine.skeleton); - spine.skeleton.updateWorldTransform(); + const batcher = this.renderer.renderPipes.batch; const drawOrder = spine.skeleton.drawOrder; - const activeBatchableSpineSlot = this.activeBatchableSpineSlots; + const roundPixels = (this.renderer._roundPixels | spine._roundPixels) as 0 | 1; - const clipper = new SkeletonClipping(); + spine._applyState(); for (let i = 0, n = drawOrder.length; i < n; i++) { const slot = drawOrder[i]; const attachment = slot.getAttachment(); + const blendMode = spineBlendModeMap[slot.data.blendMode]; if (attachment instanceof RegionAttachment || attachment instanceof MeshAttachment) { - if (clipper?.isClipping()) - { - if (attachment instanceof RegionAttachment) - { - const temp = QUAD_VERTS; - - attachment.computeWorldVertices(slot, temp, 0, 2); - - // TODO this function could be optimised.. no need to write colors for us! - clipper.clipTriangles( - QUAD_VERTS, - QUAD_VERTS.length, - QUAD_TRIANGLES, - QUAD_TRIANGLES.length, - attachment.uvs, - lightColor, - darkColor, - false // useDarkColor - ); - - // unwind it! - if (clipper.clippedVertices.length > 0) - { - const batchableSpineSlot = BigPool.get(BatchableClippedSpineSlot); - - activeBatchableSpineSlot.push(batchableSpineSlot); - - batchableSpineSlot.texture = (attachment.region?.texture.texture) || Texture.WHITE; - batchableSpineSlot.roundPixels = (this.renderer._roundPixels | spine._roundPixels) as 0 | 1; - - batchableSpineSlot.setClipper(clipper); - batchableSpineSlot.renderable = spine; - - batcher.addToBatch(batchableSpineSlot); - } - } - } - else - { - const batchableSpineSlot = BigPool.get(BatchableSpineSlot); - - activeBatchableSpineSlot.push(batchableSpineSlot); - - batchableSpineSlot.renderable = spine; + const cacheData = spine._getCachedData(slot, attachment); + const batchableSpineSlot = gpuSpine.slotBatches[cacheData.id] ||= new BatchableSpineSlot(); - batchableSpineSlot.setSlot(slot); - - batchableSpineSlot.texture = (attachment.region?.texture.texture) || Texture.EMPTY; - batchableSpineSlot.roundPixels = (this.renderer._roundPixels | spine._roundPixels) as 0 | 1; + if (!cacheData.clipped || (cacheData.clipped && cacheData.clippedData.vertices.length > 0)) + { + batchableSpineSlot.setData( + spine, + cacheData, + (attachment.region?.texture.texture) || Texture.EMPTY, + blendMode, + roundPixels + ); batcher.addToBatch(batchableSpineSlot); } } - else if (attachment instanceof ClippingAttachment) - { - clipper.clipStart(slot, attachment); - } - else + + const containerAttachment = spine._slotsObject[slot.data.name]; + + if (containerAttachment) { - clipper.clipEndWithSlot(slot); + const container = containerAttachment.container; + + container.includeInBuild = true; + collectAllRenderables(container, instructionSet, this.renderer.renderPipes); + container.includeInBuild = false; } } clipper.clipEnd(); } - updateRenderable(_renderable: Spine) + updateRenderable(spine: Spine) { - // this does not happen.. yet! // we assume that spine will always change its verts size.. + const gpuSpine = this.gpuSpineData[spine.uid]; + + spine._applyState(); + + const drawOrder = spine.skeleton.drawOrder; + + for (let i = 0, n = drawOrder.length; i < n; i++) + { + const slot = drawOrder[i]; + const attachment = slot.getAttachment(); + + if (attachment instanceof RegionAttachment || attachment instanceof MeshAttachment) + { + const batchableSpineSlot = gpuSpine.slotBatches[spine._getCachedData(slot, attachment).id]; + + batchableSpineSlot.batcher.updateElement(batchableSpineSlot); + } + } } - destroyRenderable(_renderable: Spine) + destroyRenderable(spine: Spine) { - this._returnActiveBatches(); + // TODO remove the renderable from the batcher + this.gpuSpineData[spine.uid] = null as any; } destroy() { - this._returnActiveBatches(); + this.gpuSpineData = null as any; this.renderer = null as any; } - - private _returnActiveBatches() - { - const activeBatchableSpineSlots = this.activeBatchableSpineSlots; - - for (let i = 0; i < activeBatchableSpineSlots.length; i++) - { - BigPool.return(activeBatchableSpineSlots[i]); - } - - // TODO this can be optimised - activeBatchableSpineSlots.length = 0; - } } extensions.add(SpinePipe); diff --git a/src/getSkeletonBounds.ts b/src/getSkeletonBounds.ts deleted file mode 100644 index d8f9e64..0000000 --- a/src/getSkeletonBounds.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** **************************************************************************** - * Spine Runtimes License Agreement - * Last updated July 28, 2023. Replaces all prior versions. - * - * Copyright (c) 2013-2023, Esoteric Software LLC - * - * Integration of the Spine Runtimes into software or otherwise creating - * derivative works of the Spine Runtimes is permitted under the terms and - * conditions of Section 2 of the Spine Editor License Agreement: - * http://esotericsoftware.com/spine-editor-license - * - * Otherwise, it is permitted to integrate the Spine Runtimes into software or - * otherwise create derivative works of the Spine Runtimes (collectively, - * "Products"), provided that each user of the Products must obtain their own - * Spine Editor license and redistribution of the Products in any form must - * include this license and copyright notice. - * - * THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY - * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, - * BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE - * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - *****************************************************************************/ - -import { - type Bone, - ClippingAttachment, - MeshAttachment, - RegionAttachment, - type Skeleton -} from '@esotericsoftware/spine-core'; - -import type { Bounds } from 'pixi.js'; - -const QUAD_VERTS = new Float32Array(8); -const tempVerts:number[] = []; - -export function getSkeletonBounds(skeleton:Skeleton, out:Bounds) -{ - out.clear(); - - const rootBone = skeleton.getRootBone() as Bone; - - rootBone.x = 0; - rootBone.y = 0; - rootBone.scaleX = 1; - rootBone.scaleY = -1; - rootBone.rotation = 0; - - skeleton.updateWorldTransform(); - - const drawOrder = skeleton.drawOrder; - - for (let i = 0, n = drawOrder.length; i < n; i++) - { - const slot = drawOrder[i]; - const attachment = slot.getAttachment(); - - if (attachment instanceof RegionAttachment) - { - const temp = QUAD_VERTS; - - attachment.computeWorldVertices(slot, temp, 0, 2); - - // TODO this can be skipped if matrix is local?? - out.addVertexData(temp, 0, 8); - } - else if (attachment instanceof MeshAttachment) - { - attachment.computeWorldVertices(slot, 0, attachment.worldVerticesLength, tempVerts, 0, 2); - - out.addVertexData(tempVerts as any as Float32Array, 0, attachment.worldVerticesLength); - } - else if (attachment instanceof ClippingAttachment) - { - console.warn('[Pixi Spine] ClippingAttachment bounds is not supported yet'); - } - } -}