• Runtimes
  • Spine Phaser - Load individual png per attachment

Hi,

I have a character where there will be thousands of varieties per attachment and i cannot assemble a custom atlas on server as the image generation would be to load heavy and user can change their character very frequently.

So I want to load each attachment from a seperate png image. I have seen some posts and examples where the suggestion is using custom loaders, however not been able to find ways to implement this in the phaser runtime,.

Are there any examples or references? What would be the easiest way using Spine Phaser plugin to create a new Attachment at runtime when i already have an image loaded in the scene cache?

Thanks for help!

Related Discussions
...

To load individual PNGs per attachment in Spine using the Phaser runtime, you can implement a custom AttachmentLoader. This approach allows you to dynamically assign images to attachments at runtime without relying on a pre-packed atlas. Here's a general approach you can take:

  1. Custom AttachmentLoader: Create a custom AttachmentLoader that overrides the default behavior. This loader should be able to fetch images from the Phaser scene cache and assign them to the appropriate attachments.

  2. Loading Images: Ensure that your images are preloaded into the Phaser scene cache. You can use Phaser's loading mechanisms to load individual PNGs.

  3. Assigning Images: When creating or updating an attachment, use your custom AttachmentLoader to fetch the image from the cache and assign it to the attachment. This involves setting the texture region for the attachment using the image data.

  4. Example: Unfortunately, there are no direct examples for Phaser, but the concept is similar to other runtimes like Unity or C#. You can refer to the Spine Runtimes Guide on AttachmentLoader for more details on how to implement a custom loader.

By following these steps, you can dynamically load and assign images to attachments in Spine using the Phaser runtime, allowing for flexible character customization without the need for a pre-packed atlas.

Thanks mr. Bot, I have taken a look and wonder how i can create a custom AttachmentLoader and override the spine phaser implementation as there does not seem to be ways to pass custom loaders to the spine loading functions on the phaser scene?

My solution so far has been to create and load seperate atlas per individual attachment for then to create a new RegionAttachment manually and apply it.

I could not find a good way to override the loaders or just load an individual Texture from png without creating an atlas, seem to struggle to always struggle to get the right relationship between phaser and spine textures when doing so. Would preferr to just load the skeleton json, then iterate overall attatchments and apply a texture from individually loaded png somehow., any tips?

function preload(this: Phaser.Scene) {
			if (this.load.spineJson) {
				this.load.spineJson("spineCharacter", `${SERVER_URL}/tiny/character/spineboy-ess.json`);
				this.load.spineAtlas("spineAtlas", `${SERVER_URL}/tiny/character/spineboy-ess.atlas`, false);
				this.load.spineAtlas("external-gun0", `${SERVER_URL}/tiny/character/external-gun0.atlas`, false);
				this.load.spineAtlas("external-gun1", `${SERVER_URL}/tiny/character/external-gun1.atlas`, false);
			} else {
				console.error("Spine plugin not available. Check plugin configuration.");
			}
		}

const swapGun = () => {
		const spineGameObject = spineObjectRef.current;
		const scene = sceneRef.current;
		const spinePlugin = scene?.spine;
		const externalScale = 0.5;
		if (!spineGameObject || !scene || !spinePlugin) {
			console.error("Spine object, scene or plugin is not available");
			return;
		}

		const newExternalGun = `external-gun${currentGun}`;
		setCurrentGun((prev) => (prev === 1 ? 0 : 1));

		const slot = spineGameObject.skeleton.findSlot("gun");
		if (!slot) {
			console.error("Slot 'gun' not found");
			return;
		}
		const region = spinePlugin.getAtlas(newExternalGun)?.findRegion(newExternalGun);
		if (!region) {
			console.error("Region not found");
			return;
		}
		const newAttachment = new RegionAttachment(newExternalGun, newExternalGun);
		newAttachment.width = region.width * externalScale;
		newAttachment.height = region.height * externalScale;
		newAttachment.region = region;
		newAttachment.updateRegion();

		slot.setAttachment(newAttachment);
	};

For the initial load i find it OK to just modify the .atlas file to reference individual pngs/pages per attachment, this i can store/generate for each user so that it can initialise the character on load without generating unique textureatlast png.

And the above in previous post allows me to swap at runtime, i guess I could also just generate the .atlas file on client instead of fetching from server also.

Just seems cleaner if i could find a way to intercept the loaders and not have to load an atlas at all

spine-phaser relies on spine-webgl, which means you need to provide a Spine Texture compatible with the renderer being used.

In the case of spine-webgl, that means a GLTexture, which, as you can see, is created by the Spine Phaser plugin loader here.
Starting from an image already loaded in the Phaser cache, you can do something like:

const phaserTexture = this.game.textures.get("cache-key").getSourceImage();
const spineTexture = new spine.GLTexture(this.renderer.gl, phaserTexture);

Before creating a custom attachment, you’ll need to create a TextureRegion, which is required for the attachment.
Since we’re not using atlases, we won’t create a TextureAtlasRegion, but a plain TextureRegion. This involves a bit more boilerplate, but it’s quite straightforward:

const region = new spine.TextureRegion();
region.u2 = region.v2 = 1; // the region spans the entire texture (u=0, v=0, u2=1, v2=1)
region.width = region.originalWidth = phaserTexture.width;
region.height = region.originalHeight = phaserTexture.height;
region.texture = spineTexture; // assign the previously created texture

Now you’re ready to create the attachment. The easiest way is to copy an existing one to preserve its transform:

const slot = spineboy.skeleton.findSlot("gun");
const customRegionAttachment = slot.attachment.copy();
customRegionAttachment.region = region;
customRegionAttachment.updateRegion();

Finally, replace the slot’s attachment:

slot.setAttachment(customRegionAttachment);

In this example, I used Spineboy, so the code references its slots and attachments.

Keep in mind that this method breaks batching, since each custom attachment uses its own texture. However, you can still optimize by extracting regions from a shared atlas, reducing the number of textures involved.

Excellent, that solved my texture issues with previous attempts thanks!

Complete code for test setup is now below and i can just replace as needed with single phaser texture/image.

Since you are so responsive, maybe you could also hint to if its possible for me to create my SpineGameObject without having an Atlas on first creation? So that i could just provide or apply individual attachments for each of the slots defined in the Skeleton without loading initial atlas?

import { SERVER_URL } from "@/data/constants";
import { TINY_GAME } from "@/data/games";
import { Game, Types } from "phaser";
import React, { useEffect, useRef } from "react";

import { GLTexture, RegionAttachment, SpineGameObject, SpinePlugin, TextureRegion } from "@esotericsoftware/spine-phaser";

type Props = {
	children?: React.ReactNode;
};

export function TinyGameScene({ children }: Props) {
	const loaded = useRef(false);
	const [currentGun, setCurrentGun] = React.useState(1);
	const gameRef = useRef<Game | null>(null);
	const sceneRef = useRef<Phaser.Scene | null>(null);
	const spineObjectRef = useRef<SpineGameObject | null>(null);

	const swapGun = () => {
		const spineGameObject = spineObjectRef.current;
		const scene = sceneRef.current;
		const spinePlugin = scene?.spine;

		if (!spineGameObject || !scene || !spinePlugin || !spinePlugin.webGLRenderer?.context) {
			console.error("Spine object, scene or plugin is not available or misconfigured");
			return;
		}

		const slot = spineGameObject.skeleton.findSlot("gun");
		if (!slot || !slot.attachment) {
			console.error("Slot 'gun' not found");
			return;
		}

		const newExternalGun = `external-gun${currentGun}`;
		setCurrentGun((prev) => (prev === 1 ? 0 : 1));

		const phaserTexture = scene.textures.get(newExternalGun).getSourceImage();
		const spineTexture = new GLTexture(spinePlugin.webGLRenderer?.context, phaserTexture as HTMLImageElement | ImageBitmap);

		const region = new TextureRegion();
		region.u2 = region.v2 = 1;
		region.width = region.originalWidth = phaserTexture.width;
		region.height = region.originalHeight = phaserTexture.height;
		region.texture = spineTexture;

		const newAttachment: RegionAttachment = slot.attachment.copy() as RegionAttachment;
		newAttachment.region = region;
		newAttachment.updateRegion();

		slot.setAttachment(newAttachment);
	};

	const addCharacter = () => {
		const sceneObject = sceneRef.current;
		if (!sceneObject) {
			console.error("Scene is not available");
			return;
		}
		const spineObject = sceneObject.make.spine({
			x: 200,
			y: 500,
			scale: 0.5,

			dataKey: "spineCharacter",
			atlasKey: "spineAtlas",
		});
		spineObject.setInteractive();
		spineObjectRef.current = spineObject;

		spineObject.on("pointerdown", (pointer: SpineGameObject) => {
			spineObject.animationState.setAnimation(0, "jump", false);
		});
		spineObject.animationState.addListener({
			complete: (entry) => {
				if (entry.animation?.name === "jump") {
					spineObject.animationState.setAnimation(0, "walk", true);
				}
			},
		});
		spineObject.animationState.setAnimation(0, "walk", true);
		sceneObject.add.existing(spineObject);
	};

	useEffect(() => {
		if (loaded.current) {
			return;
		}
		loaded.current = true;
		function preload(this: Phaser.Scene) {
			if (this.load.spineJson) {
				this.load.spineJson("spineCharacter", `${SERVER_URL}/tiny/character/spineboy-ess.json`);
				this.load.spineAtlas("spineAtlas", `${SERVER_URL}/tiny/character/spineboy-split.atlas`, false);
				this.load.image("external-gun0", `${SERVER_URL}/tiny/character/external-gun0.png`);
				this.load.image("external-gun1", `${SERVER_URL}/tiny/character/external-gun1.png`);
			} else {
				console.error("Spine plugin not available");
			}
		}

		function create(this: Phaser.Scene) {
			sceneRef.current = this;
			addCharacter();
		}

		function update(this: Phaser.Scene) {}

		const config: Types.Core.GameConfig = {
			type: Phaser.AUTO,
			parent: "gamecanvas",
			backgroundColor: TINY_GAME.colours.bright,
			width: "100%",
			antialias: true,
			height: "100%",
			scene: {
				preload,
				create,
				update,
			},
			plugins: {
				scene: [
					{
						key: "SpinePlugin",
						plugin: SpinePlugin,
						mapping: "spine",
					},
				],
			},
		};

		gameRef.current = new Game(config);

		return () => {
			if (gameRef.current) {
				gameRef.current.destroy(true);
				gameRef.current = null;
			}
		};
	}, []);

	return (
		<div className="relative w-full h-full" id="gamecanvas">
			<div className="absolute flex flex-row ">
				<div
					className="px-5 mt-10 cursor-pointer"
					onClick={() => {
						addCharacter();
					}}
				>
					Add character
				</div>
				<div
					className="px-5 mt-10 cursor-pointer"
					onClick={() => {
						swapGun();
					}}
				>
					Test swap
				</div>
			</div>
		</div>
	);
}

    kimdanielarthur
    Since you're so responsive, maybe you could also hint at whether it's possible for me to create a SpineGameObject without an atlas at creation? So that I could just provide or apply individual attachments to each slot defined in the skeleton, without loading an initial atlas?

    You should mimic what the add or make functions do. Here’s the code.

    If you dig a bit deeper, you’ll see that SpineGameObject depends on the atlas.
    Unfortunately, this means you can't use that constructor directly—you’ll need to extend it to create your own version that doesn't rely on an atlas.

    Eventually, you’ll notice that all the skeleton loaders, like SkeletonJson, depend on an AttachmentLoader. The default is AtlasAttachmentLoader, which—as the name suggests—requires an atlas.

    So you'll need to implement a custom AttachmentLoader that doesn't depend on an atlas. That’s because you’ll be manually assigning the TextureRegion to the Attachments (region, mesh, sequence).

    It's not the easiest task, but it's definitely doable 🙂

    Yeah i see there, thanks. OK I'll give it a go later if/when that optimisation is needed. Currently I can just generate .atlas file referencing individual pages/pngs per attachment based on users configured character on first load then substitute parts at runtime when changing so should work ok!

    Thanks for help and thanks for replying!