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 1d3b983..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]; - - // 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 850b0f3..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 7ab2e37..3e47243 100644 --- a/src/Spine.ts +++ b/src/Spine.ts @@ -37,24 +37,29 @@ import { DestroyOptions, PointData, Ticker, - View + 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 = { @@ -64,9 +69,13 @@ 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 { skeletonData: SkeletonData; @@ -83,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 @@ -92,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; @@ -100,12 +126,32 @@ export class Spine extends Container implements View public skeletonBounds: SkeletonBounds; private _debug?: ISpineDebugRenderer | undefined = undefined; - readonly _slotAttachments:{slot:Slot, 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) @@ -118,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) @@ -135,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, }; } @@ -160,10 +210,13 @@ export class Spine extends Container implements View { 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); } @@ -171,7 +224,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() @@ -237,14 +290,226 @@ export class Spine extends Container implements View 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; - for (let i = 0; i < this._slotAttachments.length; i++) + 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.updateSlotAttachments(); + } + + private validateAttachments() + { + const currentDrawOrder = this.skeleton.drawOrder; + + const lastAttachments = (this._lastAttachments ||= []); + + let index = 0; + + 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) + { + cacheData.clippedData = { + vertices: new Float32Array(verticesCount * 2), + uvs: new Float32Array(verticesCount * 2), + vertexCount: verticesCount, + indices: new Uint16Array(indicesCount), + indicesCount, + }; + + this.spineAttachmentsDirty = true; + } + + const clippedData = cacheData.clippedData; + + const sizeChange = clippedData.vertexCount !== verticesCount || indicesCount !== clippedData.indicesCount; + + if (sizeChange) { - const slotAttachment = this._slotAttachments[i]; + 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 updateSlotAttachments() + { + for (const i in this._slotsObject) + { + const slotAttachment = this._slotsObject[i]; + + if (!slotAttachment) continue; const { slot, container } = slotAttachment; @@ -264,21 +529,60 @@ export class Spine extends Container implements View container.rotation = Math.atan2( Math.sin(rotationX) + Math.sin(rotationY), - Math.cos(rotationX) + Math.cos(rotationY) + Math.cos(rotationX) + Math.cos(rotationY), ); } } + } - this.onViewUpdate(); + 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; @@ -299,32 +603,40 @@ export class Spine extends Container implements View * to the attached container. A container can only be attached to one slot at a time. * * @param container - The container to attach to the slot - * @param slot - The slot id or slot to attach to + * @param slotRef - The slot id or slot to attach to */ - attachToSlot(container:Container, slot:string | Slot) + addSlotObject(slot: number | string | Slot, container: Container) { - this.detachFromSlot(container, slot); - - container.includeInBuild = false; + slot = this.getSlotFromRef(slot); - if (typeof slot === 'string') + // need to check in on the container too... + for (const i in this._slotsObject) { - slot = this.skeleton.findSlot(slot) as Slot; + if (this._slotsObject[i]?.container === container) + { + this.removeSlotObject(this._slotsObject[i].slot); + } } - if (!slot) - { - throw new Error(`Slot ${slot} 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._slotAttachments.push({ - slot, - container - }); + this._slotsObject[slot.data.name] = { + container, + slot + }; + + const renderGroup = this.renderGroup || this.parentRenderGroup; + + if (renderGroup) + { + renderGroup.structureDidChange = true; + } } /** @@ -333,32 +645,33 @@ export class Spine extends Container implements View * @param container - The container to detach from the slot * @param slot - The slot id or slot to detach from */ - detachFromSlot(container:Container, slot:string | Slot) + removeSlotObject(slot: number | string | Slot) { - container.includeInBuild = true; + slot = this.getSlotFromRef(slot); - if (typeof slot === 'string') - { - slot = this.skeleton.findSlot(slot) as Slot; - } + const container = this._slotsObject[slot.data.name]?.container; - if (!slot) + if (container) { - throw new Error(`Bone ${slot} not found`); + this.removeChild(container); + + container.includeInBuild = true; } - this.removeChild(container); + this._slotsObject[slot.data.name] = null; + } - for (let i = 0; i < this._slotAttachments.length; i++) - { - const mapping = this._slotAttachments[i]; + /** + * 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); - if (mapping.slot === slot && mapping.container === container) - { - this._slotAttachments.splice(i, 1); - break; - } - } + return this._slotsObject[slot.data.name].container; } updateBounds() @@ -373,10 +686,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]; - // now region bounding attachments.. - getSkeletonBounds(this.skeleton, this._bounds); + const attachment = slot.getAttachment(); + + if (attachment && (attachment instanceof RegionAttachment || attachment instanceof MeshAttachment)) + { + const cacheData = this.getCachedData(slot, attachment); + + bounds.addVertexData(cacheData.vertices, 0, cacheData.vertices.length); + } + } } else { @@ -417,12 +746,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._slotAttachments 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. */ @@ -436,7 +768,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}`; @@ -450,7 +782,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; @@ -459,7 +794,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 18f8047..9c8d432 100644 --- a/src/SpinePipe.ts +++ b/src/SpinePipe.ts @@ -28,7 +28,6 @@ *****************************************************************************/ import { - BigPool, collectAllRenderables, extensions, ExtensionType, InstructionSet, @@ -36,20 +35,19 @@ import { type RenderPipe, Texture } from 'pixi.js'; -import { BatchableClippedSpineSlot } from './BatchableClippedSpineSlot'; import { BatchableSpineSlot } from './BatchableSpineSlot'; import { Spine } from './Spine'; -import { ClippingAttachment, Color, MeshAttachment, RegionAttachment, SkeletonClipping } from '@esotericsoftware/spine-core'; - -import type { Bone } from '@esotericsoftware/spine-core'; - -const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0]; -const QUAD_VERTS = new Float32Array(8); -const lightColor = new Color(); -const darkColor = new Color(); +import { MeshAttachment, RegionAttachment, SkeletonClipping } from '@esotericsoftware/spine-core'; const clipper = new SkeletonClipping(); +const spineBlendModeMap = { + 0: 'normal', + 1: 'add', + 2: 'multiply', + 3: 'screen' +}; + // eslint-disable-next-line max-len export class SpinePipe implements RenderPipe { @@ -65,121 +63,59 @@ 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, 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; + 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 = roundPixels; - - batchableSpineSlot.setClipper(clipper); - batchableSpineSlot.renderable = spine; - - batcher.addToBatch(batchableSpineSlot); - } - } - } - else - { - const batchableSpineSlot = BigPool.get(BatchableSpineSlot); - - activeBatchableSpineSlot.push(batchableSpineSlot); + const cacheData = spine.getCachedData(slot, attachment); + const batchableSpineSlot = gpuSpine.slotBatches[cacheData.id] ||= new BatchableSpineSlot(); - batchableSpineSlot.renderable = spine; - - 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 - { - clipper.clipEndWithSlot(slot); - } - const containerAttachment = spine._slotAttachments.find((mapping) => mapping.slot === slot); + const containerAttachment = spine._slotsObject[slot.data.name]; if (containerAttachment) { @@ -194,35 +130,40 @@ export class SpinePipe implements RenderPipe 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 fb34f21..0000000 --- a/src/getSkeletonBounds.ts +++ /dev/null @@ -1,75 +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 { - 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(); - - 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'); - } - } -}