2025-08-12 10:32:17 +02:00

259 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/******************************************************************************
* 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.
*****************************************************************************/
export * from "@esotericsoftware/spine-core";
import {
AnimationState,
AnimationStateData,
AtlasAttachmentLoader,
BlendMode,
Physics,
Skeleton,
SkeletonBinary,
type SkeletonData,
SkeletonJson,
SkeletonRendererCore,
Texture,
TextureAtlas,
} from "@esotericsoftware/spine-core";
import type { Canvas, CanvasKit, Image, Paint, Shader } from "canvaskit-wasm";
Skeleton.yDown = true;
type CanvasKitImage = {
shaders: Shader[];
paintPerBlendMode: Map<BlendMode, Paint>;
image: Image;
};
// CanvasKit blend modes for premultiplied alpha
function toCkBlendMode (ck: CanvasKit, blendMode: BlendMode) {
switch (blendMode) {
case BlendMode.Normal:
return ck.BlendMode.SrcOver;
case BlendMode.Additive:
return ck.BlendMode.Plus;
case BlendMode.Multiply:
return ck.BlendMode.SrcOver;
case BlendMode.Screen:
return ck.BlendMode.Screen;
default:
return ck.BlendMode.SrcOver;
}
}
function bufferToUtf8String (buffer: ArrayBuffer | Buffer) {
if (typeof Buffer !== "undefined") {
return buffer.toString("utf-8");
} else if (typeof TextDecoder !== "undefined") {
return new TextDecoder("utf-8").decode(buffer);
} else {
throw new Error("Unsupported environment");
}
}
class CanvasKitTexture extends Texture {
getImage (): CanvasKitImage {
return this._image;
}
setFilters (): void { }
setWraps (): void { }
dispose (): void {
const data: CanvasKitImage = this._image;
for (const paint of data.paintPerBlendMode.values()) {
paint.delete();
}
for (const shader of data.shaders) {
shader.delete();
}
data.image.delete();
this._image = null;
}
static async fromFile (
ck: CanvasKit,
path: string,
readFile: (path: string) => Promise<ArrayBuffer | Buffer>
): Promise<CanvasKitTexture> {
const imgData = await readFile(path);
if (!imgData) throw new Error(`Could not load image ${path}`);
const image = ck.MakeImageFromEncoded(imgData);
if (!image) throw new Error(`Could not load image ${path}`);
const paintPerBlendMode = new Map<BlendMode, Paint>();
const shaders: Shader[] = [];
for (const blendMode of [
BlendMode.Normal,
BlendMode.Additive,
BlendMode.Multiply,
BlendMode.Screen,
]) {
const paint = new ck.Paint();
const shader = image.makeShaderOptions(
ck.TileMode.Clamp,
ck.TileMode.Clamp,
ck.FilterMode.Linear,
ck.MipmapMode.Linear
);
paint.setShader(shader);
paint.setBlendMode(toCkBlendMode(ck, blendMode));
paintPerBlendMode.set(blendMode, paint);
shaders.push(shader);
}
return new CanvasKitTexture({ shaders, paintPerBlendMode, image });
}
}
/**
* Loads a {@link TextureAtlas} and its atlas page images from the given file path using the `readFile(path: string): Promise<Buffer>` function.
* Throws an `Error` if the file or one of the atlas page images could not be loaded.
*/
export async function loadTextureAtlas (
ck: CanvasKit,
atlasFile: string,
readFile: (path: string) => Promise<ArrayBuffer | Buffer>
): Promise<TextureAtlas> {
const atlas = new TextureAtlas(bufferToUtf8String(await readFile(atlasFile)));
const slashIndex = atlasFile.lastIndexOf("/");
const parentDir =
slashIndex >= 0 ? atlasFile.substring(0, slashIndex + 1) : "";
for (const page of atlas.pages) {
const texture = await CanvasKitTexture.fromFile(
ck,
parentDir + page.name,
readFile
);
page.setTexture(texture);
}
return atlas;
}
/**
* Loads a {@link SkeletonData} from the given file path (`.json` or `.skel`) using the `readFile(path: string): Promise<Buffer>` function.
* Attachments will be looked up in the provided atlas.
*/
export async function loadSkeletonData (
skeletonFile: string,
atlas: TextureAtlas,
readFile: (path: string) => Promise<ArrayBuffer | Buffer>,
scale = 1
): Promise<SkeletonData> {
const attachmentLoader = new AtlasAttachmentLoader(atlas);
const loader = skeletonFile.endsWith(".json")
? new SkeletonJson(attachmentLoader)
: new SkeletonBinary(attachmentLoader);
loader.scale = scale;
const data = await readFile(skeletonFile);
if (loader instanceof SkeletonJson) {
return loader.readSkeletonData(bufferToUtf8String(data))
}
return loader.readSkeletonData(data);
}
/**
* Manages a {@link Skeleton} and its associated {@link AnimationState}. A drawable is constructed from a {@link SkeletonData}, which can
* be shared by any number of drawables.
*/
export class SkeletonDrawable {
public readonly skeleton: Skeleton;
public readonly animationState: AnimationState;
/**
* Constructs a new drawble from the skeleton data.
*/
constructor (skeletonData: SkeletonData) {
this.skeleton = new Skeleton(skeletonData);
this.animationState = new AnimationState(
new AnimationStateData(skeletonData)
);
}
/**
* Updates the animation state and skeleton time by the delta time. Applies the
* animations to the skeleton and calculates the final pose of the skeleton.
*
* @param deltaTime the time since the last update in seconds
* @param physicsUpdate optional {@link Physics} update mode.
*/
update (deltaTime: number, physicsUpdate: Physics = Physics.update) {
this.animationState.update(deltaTime);
this.skeleton.update(deltaTime);
this.animationState.apply(this.skeleton);
this.skeleton.updateWorldTransform(physicsUpdate);
}
}
/**
* Renders a {@link Skeleton} or {@link SkeletonDrawable} to a CanvasKit {@link Canvas}.
*/
export class SkeletonRenderer {
private skeletonRenderer = new SkeletonRendererCore();
/**
* Creates a new skeleton renderer.
* @param ck the {@link CanvasKit} instance returned by `CanvasKitInit()`.
*/
constructor (private ck: CanvasKit) { }
render (canvas: Canvas, skeleton: Skeleton | SkeletonDrawable) {
if (skeleton instanceof SkeletonDrawable) skeleton = skeleton.skeleton;
let command = this.skeletonRenderer.render(skeleton);
while (command) {
const { positions, uvs, colors, indices } = command;
const ckImage = command.texture.getImage();
const image = ckImage.image;
const width = image.width();
const height = image.height();
for (let i = 0; i < uvs.length; i += 2) {
uvs[i] = uvs[i] * width;
uvs[i + 1] = uvs[i + 1] * height;
}
const vertices = this.ck.MakeVertices(
this.ck.VertexMode.Triangles,
positions,
uvs,
colors,
indices as any as number[],
false
);
const ckPaint = ckImage.paintPerBlendMode.get(command.blendMode);
if (ckPaint) canvas.drawVertices(vertices, this.ck.BlendMode.Modulate, ckPaint);
vertices.delete();
command = command.next;
}
}
}