Premultiply on upload for all runtimes.

SkeletonRendererCore allows to request for pma color and color tint.
This commit is contained in:
Davide Tantillo 2025-12-05 16:25:42 +01:00
parent 86981a0935
commit e222db427b
16 changed files with 158 additions and 177 deletions

View File

@ -35,20 +35,19 @@ type AssetData = (Uint8Array | string | Texture | TextureAtlas | object) & Parti
type AssetCallback<T extends AssetData> = (path: string, data: T) => void;
type ErrorCallback = (path: string, message: string) => void;
export type TextureLoader = (image: HTMLImageElement | ImageBitmap, pma?: boolean) => Texture;
export class AssetManagerBase implements Disposable {
private pathPrefix: string = "";
private textureLoader: (image: HTMLImageElement | ImageBitmap) => Texture;
private downloader: Downloader;
private cache: AssetCache;
private errors: StringMap<string> = {};
private toLoad = 0;
private loaded = 0;
private texturePmaInfo: Record<string, boolean> = {};
constructor (textureLoader: (image: HTMLImageElement | ImageBitmap) => Texture, pathPrefix: string = "", downloader = new Downloader(), cache = new AssetCache()) {
this.textureLoader = textureLoader;
this.pathPrefix = pathPrefix;
this.downloader = downloader;
this.cache = cache;
constructor (
private textureLoader: TextureLoader,
private pathPrefix: string = "",
private downloader = new Downloader(),
private cache = new AssetCache()) {
}
private start (path: string): string {
@ -175,6 +174,7 @@ export class AssetManagerBase implements Disposable {
if (this.reuseAssets(path, success, error)) return;
const pma = this.texturePmaInfo[path];
this.cache.assetsLoaded[path] = new Promise<Texture>((resolve, reject) => {
const isBrowser = !!(typeof window !== 'undefined' && typeof navigator !== 'undefined' && window.document);
const isWebWorker = !isBrowser; // && typeof importScripts !== 'undefined';
@ -188,7 +188,7 @@ export class AssetManagerBase implements Disposable {
return blob ? createImageBitmap(blob, { premultiplyAlpha: "none", colorSpaceConversion: "none" }) : null;
}).then((bitmap) => {
if (bitmap) {
const texture = this.createTexture(path, bitmap);
const texture = this.createTexture(path, pma, bitmap);
this.success(success, path, texture);
resolve(texture);
};
@ -197,7 +197,7 @@ export class AssetManagerBase implements Disposable {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
const texture = this.createTexture(path, image);
const texture = this.createTexture(path, pma, image);
this.success(success, path, texture);
resolve(texture);
};
@ -216,7 +216,7 @@ export class AssetManagerBase implements Disposable {
path: string,
success: AssetCallback<TextureAtlas> = () => { },
error: ErrorCallback = () => { },
fileAlias?: { [keyword: string]: string }
fileAlias?: Record<string, string>
) {
const index = path.lastIndexOf("/");
const parent = index >= 0 ? path.substring(0, index + 1) : "";
@ -227,10 +227,11 @@ export class AssetManagerBase implements Disposable {
this.cache.assetsLoaded[path] = new Promise<TextureAtlas>((resolve, reject) => {
this.downloader.downloadText(path, (atlasText: string): void => {
try {
const atlas = this.createTextureAtlas(path, atlasText);
const atlas = this.createTextureAtlas(atlasText, parent, path, fileAlias);
let toLoad = atlas.pages.length, abort = false;
for (const page of atlas.pages) {
this.loadTexture(!fileAlias ? parent + page.name : fileAlias[page.name],
this.loadTexture(
this.texturePath(parent, page.name, fileAlias),
(imagePath: string, texture: Texture) => {
if (!abort) {
page.setTexture(texture);
@ -268,6 +269,8 @@ export class AssetManagerBase implements Disposable {
success: AssetCallback<TextureAtlas> = () => { },
error: ErrorCallback = () => { },
) {
const index = path.lastIndexOf("/");
const parent = index >= 0 ? path.substring(0, index + 1) : "";
path = this.start(path);
if (this.reuseAssets(path, success, error)) return;
@ -275,7 +278,7 @@ export class AssetManagerBase implements Disposable {
this.cache.assetsLoaded[path] = new Promise<TextureAtlas>((resolve, reject) => {
this.downloader.downloadText(path, (atlasText: string): void => {
try {
const atlas = this.createTextureAtlas(path, atlasText);
const atlas = this.createTextureAtlas(atlasText, parent, path);
this.success(success, path, atlas);
resolve(atlas);
} catch (e) {
@ -291,7 +294,6 @@ export class AssetManagerBase implements Disposable {
});
}
// Promisified versions of load function
async loadBinaryAsync (path: string) {
return new Promise((resolve, reject) => {
this.loadBinary(path,
@ -413,7 +415,7 @@ export class AssetManagerBase implements Disposable {
}
}
private createTextureAtlas (path: string, atlasText: string): TextureAtlas {
private createTextureAtlas (atlasText: string, parentPath: string, path: string, fileAlias?: Record<string, string>): TextureAtlas {
const atlas = new TextureAtlas(atlasText);
atlas.dispose = () => {
if (this.cache.assetsRefCount[path] <= 0) return;
@ -422,17 +424,26 @@ export class AssetManagerBase implements Disposable {
page.texture?.dispose();
}
}
for (const page of atlas.pages) {
const texturePath = this.texturePath(parentPath, page.name, fileAlias);
this.texturePmaInfo[this.pathPrefix + texturePath] = page.pma;
}
return atlas;
}
private createTexture (path: string, image: HTMLImageElement | ImageBitmap): Texture {
const texture = this.textureLoader(image);
private createTexture (path: string, pma: boolean, image: HTMLImageElement | ImageBitmap): Texture {
const texture = this.textureLoader(image, pma);
const textureDispose = texture.dispose.bind(texture);
texture.dispose = () => {
if (this.disposeAssetInternal(path)) textureDispose();
}
return texture;
}
private texturePath (parentPath: string, pageName: string, fileAlias?: Record<string, string>) {
if (!fileAlias) return parentPath + pageName;
return fileAlias[pageName];
}
}
export class AssetCache {

View File

@ -40,7 +40,7 @@ export class SkeletonRendererCore {
private clipping = new SkeletonClipping();
private renderCommands: RenderCommand[] = [];
render (skeleton: Skeleton): RenderCommand | undefined {
render (skeleton: Skeleton, pma = false, inColor?: [number, number, number, number]): RenderCommand | undefined {
this.commandPool.reset();
this.renderCommands.length = 0;
@ -56,8 +56,8 @@ export class SkeletonRendererCore {
}
const slotApplied = slot.applied;
const color = slotApplied.color;
const alpha = color.a;
const slotColor = slotApplied.color;
const alpha = slotColor.a;
if ((alpha === 0 || !slot.bone.active) && !(attachment instanceof ClippingAttachment)) {
clipper.clipEnd(slot);
continue;
@ -115,18 +115,51 @@ export class SkeletonRendererCore {
}
const skelColor = skeleton.color;
const r = Math.floor(skelColor.r * slotApplied.color.r * attachmentColor.r * 255);
const g = Math.floor(skelColor.g * slotApplied.color.g * attachmentColor.g * 255);
const b = Math.floor(skelColor.b * slotApplied.color.b * attachmentColor.b * 255);
const a = Math.floor(skelColor.a * slotApplied.color.a * attachmentColor.a * 255);
let color: number, darkColor: number;
if (pma) {
let a: number;
if (inColor) {
a = Math.floor(inColor[3] * skelColor.a * slotColor.a * attachmentColor.a * 255);
const r = Math.floor(a * inColor[0] * skelColor.r * slotColor.r * attachmentColor.r);
const g = Math.floor(a * inColor[1] * skelColor.g * slotColor.g * attachmentColor.g);
const b = Math.floor(a * inColor[2] * skelColor.b * slotColor.b * attachmentColor.b);
color = (a << 24) | (r << 16) | (g << 8) | b;
} else {
a = Math.floor(skelColor.a * slotColor.a * attachmentColor.a * 255);
const r = Math.floor(a * skelColor.r * slotColor.r * attachmentColor.r);
const g = Math.floor(a * skelColor.g * slotColor.g * attachmentColor.g);
const b = Math.floor(a * skelColor.b * slotColor.b * attachmentColor.b);
color = (a << 24) | (r << 16) | (g << 8) | b;
}
let darkColor = 0xff000000;
if (slotApplied.darkColor) {
const { r, g, b } = slotApplied.darkColor;
darkColor = 0xff000000 |
(Math.floor(r * 255) << 16) |
(Math.floor(g * 255) << 8) |
Math.floor(b * 255);
darkColor = 0xff000000;
if (slotApplied.darkColor) {
const { r, g, b } = slotApplied.darkColor;
darkColor = 0xff000000 |
(Math.floor(r * a) << 16) |
(Math.floor(g * a) << 8) |
Math.floor(b * a);
}
} else {
if (inColor) {
const a = Math.floor(inColor[3] * skelColor.a * slotColor.a * attachmentColor.a * 255);
const r = Math.floor(inColor[0] * skelColor.r * slotColor.r * attachmentColor.r * 255);
const g = Math.floor(inColor[1] * skelColor.g * slotColor.g * attachmentColor.g * 255);
const b = Math.floor(inColor[2] * skelColor.b * slotColor.b * attachmentColor.b * 255);
color = (a << 24) | (r << 16) | (g << 8) | b;
} else {
const a = Math.floor(skelColor.a * slotColor.a * attachmentColor.a * 255);
const r = Math.floor(skelColor.r * slotColor.r * attachmentColor.r * 255);
const g = Math.floor(skelColor.g * slotColor.g * attachmentColor.g * 255);
const b = Math.floor(skelColor.b * slotColor.b * attachmentColor.b * 255);
color = (a << 24) | (r << 16) | (g << 8) | b;
}
darkColor = 0;
if (slotApplied.darkColor) {
const { r, g, b } = slotApplied.darkColor;
darkColor = (Math.floor(r * 255) << 16) | (Math.floor(g * 255) << 8) | Math.floor(b * 255);
}
}
if (clipper.isClipping()) {
@ -146,7 +179,7 @@ export class SkeletonRendererCore {
cmd.uvs.set(uvs.subarray(0, verticesCount << 1));
for (let j = 0; j < verticesCount; j++) {
cmd.colors[j] = (a << 24) | (r << 16) | (g << 8) | b;
cmd.colors[j] = color;
cmd.darkColors[j] = darkColor;
}
@ -257,6 +290,8 @@ export class SkeletonRendererCore {
}
}
// values with under score is the original sized array, bigger than necessary
// values without under score is a view of the orignal array, sized as needed
interface RenderCommand {
positions: Float32Array;
uvs: Float32Array;

View File

@ -226,7 +226,6 @@ export class SpineGameObject extends DepthMixin(
animationState: AnimationState;
beforeUpdateWorldTransforms: (object: SpineGameObject) => void = () => { };
afterUpdateWorldTransforms: (object: SpineGameObject) => void = () => { };
private premultipliedAlpha = false;
private offsetX = 0;
private offsetY = 0;
@ -243,7 +242,6 @@ export class SpineGameObject extends DepthMixin(
super(scene, (window as any).SPINE_GAME_OBJECT_TYPE ? (window as any).SPINE_GAME_OBJECT_TYPE : SPINE_GAME_OBJECT_TYPE);
this.setPosition(x, y);
this.premultipliedAlpha = this.plugin.isAtlasPremultiplied(atlasKey);
this.skeleton = this.plugin.createSkeleton(dataKey, atlasKey);
this.animationStateData = new AnimationStateData(this.skeleton.data);
this.animationState = new AnimationState(this.animationStateData);
@ -374,7 +372,6 @@ export class SpineGameObject extends DepthMixin(
sceneRenderer.drawSkeleton(
src.skeleton,
src.premultipliedAlpha,
-1,
-1,
(vertices, numVertices, stride) => {

View File

@ -58,7 +58,7 @@ export interface SpineGameObjectConfig extends Phaser.Types.GameObjects.GameObje
* The scene's {@link LoaderPlugin} (`Scene.load`) gets these additional functions:
* * `spineBinary(key: string, url: string, xhrSettings?: XHRSettingsObject)`: loads a skeleton binary `.skel` file from the `url`.
* * `spineJson(key: string, url: string, xhrSettings?: XHRSettingsObject)`: loads a skeleton binary `.skel` file from the `url`.
* * `spineAtlas(key: string, url: string, premultipliedAlpha: boolean = true, xhrSettings?: XHRSettingsObject)`: loads a texture atlas `.atlas` file from the `url` as well as its correponding texture atlas page images.
* * `spineAtlas(key: string, url: string, xhrSettings?: XHRSettingsObject)`: loads a texture atlas `.atlas` file from the `url` as well as its correponding texture atlas page images.
*
* The scene's {@link GameObjectFactory} (`Scene.add`) gets these additional functions:
* * `spine(x: number, y: number, dataKey: string, atlasKey: string, boundsProvider: SpineGameObjectBoundsProvider = SetupPoseBoundsProvider())`:
@ -70,7 +70,7 @@ export interface SpineGameObjectConfig extends Phaser.Types.GameObjects.GameObje
* The plugin has additional public methods to work with Spine Runtime core API objects:
* * `getAtlas(atlasKey: string)`: returns the {@link TextureAtlas} instance for the given atlas key.
* * `getSkeletonData(skeletonDataKey: string)`: returns the {@link SkeletonData} instance for the given skeleton data key.
* * `createSkeleton(skeletonDataKey: string, atlasKey: string, premultipliedAlpha: boolean = true)`: creates a new {@link Skeleton} instance from the given skeleton data and atlas key.
* * `createSkeleton(skeletonDataKey: string, atlasKey: string)`: creates a new {@link Skeleton} instance from the given skeleton data and atlas key.
* * `isPremultipliedAlpha(atlasKey: string)`: returns `true` if the atlas with the given key has premultiplied alpha.
*/
export class SpinePlugin extends Phaser.Plugins.ScenePlugin {
@ -119,9 +119,8 @@ export class SpinePlugin extends Phaser.Plugins.ScenePlugin {
const atlasFileCallback = function (this: Phaser.Loader.LoaderPlugin, key: string,
url: string,
premultipliedAlpha: boolean,
xhrSettings: Phaser.Types.Loader.XHRSettingsObject) {
const file = new SpineAtlasFile(this, key, url, premultipliedAlpha, xhrSettings);
const file = new SpineAtlasFile(this, key, url, xhrSettings);
this.addFile(file.files);
return this;
};
@ -225,35 +224,26 @@ export class SpinePlugin extends Phaser.Plugins.ScenePlugin {
/** Returns the TextureAtlas instance for the given key */
getAtlas (atlasKey: string) {
let atlas: TextureAtlas;
if (this.atlasCache.exists(atlasKey)) {
atlas = this.atlasCache.get(atlasKey);
if (this.atlasCache.exists(atlasKey)) return this.atlasCache.get(atlasKey);
const atlas = new TextureAtlas(this.game.cache.text.get(atlasKey));
if (this.isWebGL && this.gl) {
const gl = this.gl;
for (const atlasPage of atlas.pages)
atlasPage.setTexture(new GLTexture(gl, this.game.textures.get(`${atlasKey}!${atlasPage.name}`).getSourceImage() as HTMLImageElement | ImageBitmap, atlasPage.pma, false));
} else {
const atlasFile = this.game.cache.text.get(atlasKey) as { data: string, premultipliedAlpha: boolean };
atlas = new TextureAtlas(atlasFile.data);
if (this.isWebGL && this.gl) {
const gl = this.gl;
const phaserUnpackPmaValue = gl.getParameter(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL);
if (phaserUnpackPmaValue) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
for (const atlasPage of atlas.pages) {
atlasPage.setTexture(new GLTexture(gl, this.game.textures.get(`${atlasKey}!${atlasPage.name}`).getSourceImage() as HTMLImageElement | ImageBitmap, false));
}
if (phaserUnpackPmaValue) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
} else {
for (const atlasPage of atlas.pages) {
atlasPage.setTexture(new CanvasTexture(this.game.textures.get(`${atlasKey}!${atlasPage.name}`).getSourceImage() as HTMLImageElement | ImageBitmap));
}
}
this.atlasCache.add(atlasKey, atlas);
for (const atlasPage of atlas.pages)
atlasPage.setTexture(new CanvasTexture(this.game.textures.get(`${atlasKey}!${atlasPage.name}`).getSourceImage() as HTMLImageElement | ImageBitmap));
}
this.atlasCache.add(atlasKey, atlas);
return atlas;
}
/** Returns whether the TextureAtlas uses premultiplied alpha */
isAtlasPremultiplied (atlasKey: string) {
const atlasFile = this.game.cache.text.get(atlasKey);
if (!atlasFile) return false;
return atlasFile.premultipliedAlpha;
const atlas: TextureAtlas = this.atlasCache.get(atlasKey);
if (!atlas || atlas.pages.length === 0) return false;
return atlas.pages[0].pma;
}
/** Returns the SkeletonData instance for the given data and atlas key */
@ -337,17 +327,15 @@ class SpineSkeletonDataFile extends Phaser.Loader.MultiFile {
interface SpineAtlasFileConfig {
key: string;
url: string;
premultipliedAlpha?: boolean;
xhrSettings?: Phaser.Types.Loader.XHRSettingsObject;
}
class SpineAtlasFile extends Phaser.Loader.MultiFile {
constructor (loader: Phaser.Loader.LoaderPlugin, key: string | SpineAtlasFileConfig, url?: string, public premultipliedAlpha?: boolean, xhrSettings?: Phaser.Types.Loader.XHRSettingsObject) {
constructor (loader: Phaser.Loader.LoaderPlugin, key: string | SpineAtlasFileConfig, url?: string, xhrSettings?: Phaser.Types.Loader.XHRSettingsObject) {
if (typeof key !== "string") {
const config = key;
key = config.key;
url = config.url;
premultipliedAlpha = config.premultipliedAlpha;
xhrSettings = config.xhrSettings;
}
@ -406,11 +394,6 @@ class SpineAtlasFile extends Phaser.Loader.MultiFile {
textureManager.addImage(file.key, file.data);
}
} else {
this.premultipliedAlpha = this.premultipliedAlpha ?? (file.data.indexOf("pma: true") >= 0 || file.data.indexOf("pma:true") >= 0);
file.data = {
data: file.data,
premultipliedAlpha: this.premultipliedAlpha,
};
file.addToCache();
}
}

View File

@ -226,7 +226,6 @@ export class SpineGameObject extends DepthMixin(
animationState: AnimationState;
beforeUpdateWorldTransforms: (object: SpineGameObject) => void = () => { };
afterUpdateWorldTransforms: (object: SpineGameObject) => void = () => { };
private premultipliedAlpha = false;
private offsetX = 0;
private offsetY = 0;
@ -243,7 +242,6 @@ export class SpineGameObject extends DepthMixin(
super(scene, (window as any).SPINE_GAME_OBJECT_TYPE ? (window as any).SPINE_GAME_OBJECT_TYPE : SPINE_GAME_OBJECT_TYPE);
this.setPosition(x, y);
this.premultipliedAlpha = this.plugin.isAtlasPremultiplied(atlasKey);
this.skeleton = this.plugin.createSkeleton(dataKey, atlasKey);
this.animationStateData = new AnimationStateData(this.skeleton.data);
this.animationState = new AnimationState(this.animationStateData);
@ -393,7 +391,6 @@ export class SpineGameObject extends DepthMixin(
sceneRenderer.drawSkeleton(
src.skeleton,
src.premultipliedAlpha,
-1,
-1,
(vertices, numVertices, stride) => {

View File

@ -58,7 +58,7 @@ export interface SpineGameObjectConfig extends Phaser.Types.GameObjects.GameObje
* The scene's {@link LoaderPlugin} (`Scene.load`) gets these additional functions:
* * `spineBinary(key: string, url: string, xhrSettings?: XHRSettingsObject)`: loads a skeleton binary `.skel` file from the `url`.
* * `spineJson(key: string, url: string, xhrSettings?: XHRSettingsObject)`: loads a skeleton binary `.skel` file from the `url`.
* * `spineAtlas(key: string, url: string, premultipliedAlpha: boolean = true, xhrSettings?: XHRSettingsObject)`: loads a texture atlas `.atlas` file from the `url` as well as its correponding texture atlas page images.
* * `spineAtlas(key: string, url: string, xhrSettings?: XHRSettingsObject)`: loads a texture atlas `.atlas` file from the `url` as well as its correponding texture atlas page images.
*
* The scene's {@link GameObjectFactory} (`Scene.add`) gets these additional functions:
* * `spine(x: number, y: number, dataKey: string, atlasKey: string, boundsProvider: SpineGameObjectBoundsProvider = SetupPoseBoundsProvider())`:
@ -70,8 +70,7 @@ export interface SpineGameObjectConfig extends Phaser.Types.GameObjects.GameObje
* The plugin has additional public methods to work with Spine Runtime core API objects:
* * `getAtlas(atlasKey: string)`: returns the {@link TextureAtlas} instance for the given atlas key.
* * `getSkeletonData(skeletonDataKey: string)`: returns the {@link SkeletonData} instance for the given skeleton data key.
* * `createSkeleton(skeletonDataKey: string, atlasKey: string, premultipliedAlpha: boolean = true)`: creates a new {@link Skeleton} instance from the given skeleton data and atlas key.
* * `isPremultipliedAlpha(atlasKey: string)`: returns `true` if the atlas with the given key has premultiplied alpha.
* * `createSkeleton(atlasKey: string)`: creates a new {@link Skeleton} instance from the given skeleton data and atlas key.
*/
export class SpinePlugin extends Phaser.Plugins.ScenePlugin {
game: Phaser.Game;
@ -116,9 +115,8 @@ export class SpinePlugin extends Phaser.Plugins.ScenePlugin {
const atlasFileCallback = function (this: Phaser.Loader.LoaderPlugin, key: string,
url: string,
premultipliedAlpha: boolean,
xhrSettings: Phaser.Types.Loader.XHRSettingsObject) {
const file = new SpineAtlasFile(this, key, url, premultipliedAlpha, xhrSettings);
const file = new SpineAtlasFile(this, key, url, xhrSettings);
this.addFile(file.files);
return this;
};
@ -204,35 +202,26 @@ export class SpinePlugin extends Phaser.Plugins.ScenePlugin {
/** Returns the TextureAtlas instance for the given key */
getAtlas (atlasKey: string) {
let atlas: TextureAtlas;
if (this.atlasCache.exists(atlasKey)) {
atlas = this.atlasCache.get(atlasKey);
if (this.atlasCache.exists(atlasKey)) return this.atlasCache.get(atlasKey);
const atlas = new TextureAtlas(this.game.cache.text.get(atlasKey));
if (this.isWebGL && this.gl) {
const gl = this.gl;
for (const atlasPage of atlas.pages)
atlasPage.setTexture(new GLTexture(gl, this.game.textures.get(`${atlasKey}!${atlasPage.name}`).getSourceImage() as HTMLImageElement | ImageBitmap, atlasPage.pma, false));
} else {
const atlasFile = this.game.cache.text.get(atlasKey) as { data: string, premultipliedAlpha: boolean };
atlas = new TextureAtlas(atlasFile.data);
if (this.isWebGL && this.gl) {
const gl = this.gl;
const phaserUnpackPmaValue = gl.getParameter(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL);
if (phaserUnpackPmaValue) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
for (const atlasPage of atlas.pages) {
atlasPage.setTexture(new GLTexture(gl, this.game.textures.get(`${atlasKey}!${atlasPage.name}`).getSourceImage() as HTMLImageElement | ImageBitmap, false));
}
if (phaserUnpackPmaValue) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
} else {
for (const atlasPage of atlas.pages) {
atlasPage.setTexture(new CanvasTexture(this.game.textures.get(`${atlasKey}!${atlasPage.name}`).getSourceImage() as HTMLImageElement | ImageBitmap));
}
}
this.atlasCache.add(atlasKey, atlas);
for (const atlasPage of atlas.pages)
atlasPage.setTexture(new CanvasTexture(this.game.textures.get(`${atlasKey}!${atlasPage.name}`).getSourceImage() as HTMLImageElement | ImageBitmap));
}
this.atlasCache.add(atlasKey, atlas);
return atlas;
}
/** Returns whether the TextureAtlas uses premultiplied alpha */
isAtlasPremultiplied (atlasKey: string) {
const atlasFile = this.game.cache.text.get(atlasKey);
if (!atlasFile) return false;
return atlasFile.premultipliedAlpha;
const atlas: TextureAtlas = this.atlasCache.get(atlasKey);
if (!atlas || atlas.pages.length === 0) return false;
return atlas.pages[0].pma;
}
/** Returns the SkeletonData instance for the given data and atlas key */
@ -326,17 +315,15 @@ class SpineSkeletonDataFile extends Phaser.Loader.MultiFile {
interface SpineAtlasFileConfig {
key: string;
url: string;
premultipliedAlpha?: boolean;
xhrSettings?: Phaser.Types.Loader.XHRSettingsObject;
}
class SpineAtlasFile extends Phaser.Loader.MultiFile {
constructor (loader: Phaser.Loader.LoaderPlugin, key: string | SpineAtlasFileConfig, url?: string, public premultipliedAlpha?: boolean, xhrSettings?: Phaser.Types.Loader.XHRSettingsObject) {
constructor (loader: Phaser.Loader.LoaderPlugin, key: string | SpineAtlasFileConfig, url?: string, xhrSettings?: Phaser.Types.Loader.XHRSettingsObject) {
if (typeof key !== "string") {
const config = key;
key = config.key;
url = config.url;
premultipliedAlpha = config.premultipliedAlpha;
xhrSettings = config.xhrSettings;
}
@ -395,11 +382,6 @@ class SpineAtlasFile extends Phaser.Loader.MultiFile {
textureManager.addImage(file.key, file.data);
}
} else {
this.premultipliedAlpha = this.premultipliedAlpha ?? (file.data.indexOf("pma: true") >= 0 || file.data.indexOf("pma:true") >= 0);
file.data = {
data: file.data,
premultipliedAlpha: this.premultipliedAlpha,
};
file.addToCache();
}
}

View File

@ -66,9 +66,6 @@ export interface SpinePlayerConfig {
/* Optional: List of skin names from which the user can choose. Default: all skins */
skins?: string[]
/* Optional: Whether the skeleton's atlas images use premultiplied alpha. Default: true */
premultipliedAlpha?: boolean
/* Optional: Whether to show the player controls. When false, no external CSS file is needed. Default: true */
showControls?: boolean
@ -311,7 +308,6 @@ export class SpinePlayer implements Disposable {
if (!config.backgroundColor) config.backgroundColor = config.alpha ? "00000000" : "000000";
if (!config.fullScreenBackgroundColor) config.fullScreenBackgroundColor = config.backgroundColor;
if (config.backgroundImage && !config.backgroundImage.url) config.backgroundImage = undefined;
if (config.premultipliedAlpha === void 0) config.premultipliedAlpha = true;
if (config.preserveDrawingBuffer === void 0) config.preserveDrawingBuffer = false;
if (config.mipmaps === void 0) config.mipmaps = true;
if (config.interactive === void 0) config.interactive = true;
@ -919,7 +915,7 @@ export class SpinePlayer implements Disposable {
}
// Draw the skeleton and debug output.
renderer.drawSkeleton(skeleton, config.premultipliedAlpha);
renderer.drawSkeleton(skeleton);
if (Number(renderer.skeletonDebugRenderer.drawBones = config.debug!.bones! ?? false)
+ Number(renderer.skeletonDebugRenderer.drawBoundingBoxes = config.debug!.bounds! ?? false)
+ Number(renderer.skeletonDebugRenderer.drawClipping = config.debug!.clipping! ?? false)
@ -928,7 +924,7 @@ export class SpinePlayer implements Disposable {
+ Number(renderer.skeletonDebugRenderer.drawRegionAttachments = config.debug!.regions! ?? false)
+ Number(renderer.skeletonDebugRenderer.drawMeshTriangles = config.debug!.meshes! ?? false) > 0
) {
renderer.drawSkeletonDebug(skeleton, config.premultipliedAlpha);
renderer.drawSkeletonDebug(skeleton);
}
// Draw the control bones.

View File

@ -31,9 +31,11 @@ import { AssetManagerBase, Downloader } from "@esotericsoftware/spine-core"
import { ThreeJsTexture } from "./ThreeJsTexture.js";
export class AssetManager extends AssetManagerBase {
constructor (pathPrefix: string = "", downloader: Downloader = new Downloader(), pma = false) {
super((image: HTMLImageElement | ImageBitmap) => {
return new ThreeJsTexture(image, pma);
}, pathPrefix, downloader);
constructor (pathPrefix: string = "", downloader: Downloader = new Downloader()) {
super(
(image: HTMLImageElement | ImageBitmap, pma = false) => new ThreeJsTexture(image, pma),
pathPrefix,
downloader,
);
}
}

View File

@ -499,7 +499,7 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr
const tempVector = new Vector3();
for (const widget of this.widgets) {
const { skeleton, pma, bounds, debug, offsetX, offsetY, dragX, dragY, fit, spinner, loading, clip, drag } = widget;
const { skeleton, bounds, debug, offsetX, offsetY, dragX, dragY, fit, spinner, loading, clip, drag } = widget;
if (widget.isOffScreenAndWasMoved()) continue;
const elementRef = widget.getHostElement();
@ -614,7 +614,7 @@ export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttr
widget.worldX = worldOffsetX;
widget.worldY = worldOffsetY;
renderer.drawSkeleton(skeleton, pma, -1, -1, (vertices, size, vertexSize) => {
renderer.drawSkeleton(skeleton, -1, -1, (vertices, size, vertexSize) => {
for (let i = 0; i < size; i += vertexSize) {
vertices[i] = vertices[i] + worldOffsetX;
vertices[i + 1] = vertices[i + 1] + worldOffsetY;

View File

@ -132,7 +132,6 @@ interface WidgetPublicProperties {
// Usage of this properties is discouraged because they can be made private in the future
interface WidgetInternalProperties {
pma: boolean
dprScale: number
dragging: boolean
dragX: number
@ -648,13 +647,6 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable
*/
public dragging = false;
/**
* @internal
* If true, the widget has texture with premultiplied alpha
* Do not rely on this properties. It might be made private in the future.
*/
public pma = false;
/**
* If true, indicate {@link dispose} has been called and the widget cannot be used anymore
*/
@ -972,7 +964,6 @@ export class SpineWebComponentSkeleton extends HTMLElement implements Disposable
]);
const atlas = this.overlay.assetManager.require(atlasPath) as TextureAtlas;
this.pma = atlas.pages[0]?.pma
const atlasLoader = new AtlasAttachmentLoader(atlas);

View File

@ -33,8 +33,10 @@ import type { ManagedWebGLRenderingContext } from "./WebGL.js";
export class AssetManager extends AssetManagerBase {
constructor (context: ManagedWebGLRenderingContext | WebGLRenderingContext, pathPrefix: string = "", downloader: Downloader = new Downloader()) {
super((image: HTMLImageElement | ImageBitmap) => {
return new GLTexture(context, image);
}, pathPrefix, downloader);
super(
(image: HTMLImageElement | ImageBitmap, pma = false) => new GLTexture(context, image, pma),
pathPrefix,
downloader,
);
}
}

View File

@ -34,13 +34,13 @@ export class GLTexture extends Texture implements Disposable, Restorable {
context: ManagedWebGLRenderingContext;
private texture: WebGLTexture | null = null;
private boundUnit = 0;
private useMipMaps = false;
private pma: boolean;
private useMipMaps: boolean;
public static DISABLE_UNPACK_PREMULTIPLIED_ALPHA_WEBGL = false;
constructor (context: ManagedWebGLRenderingContext | WebGLRenderingContext, image: HTMLImageElement | ImageBitmap, useMipMaps: boolean = false) {
constructor (context: ManagedWebGLRenderingContext | WebGLRenderingContext, image: HTMLImageElement | ImageBitmap, pma: boolean, useMipMaps: boolean = false) {
super(image);
this.context = context instanceof ManagedWebGLRenderingContext ? context : new ManagedWebGLRenderingContext(context);
this.pma = pma;
this.useMipMaps = useMipMaps;
this.restore();
this.context.addRestorable(this);
@ -90,13 +90,15 @@ export class GLTexture extends Texture implements Disposable, Restorable {
const gl = this.context.gl;
if (!this.texture) this.texture = this.context.gl.createTexture();
this.bind();
if (GLTexture.DISABLE_UNPACK_PREMULTIPLIED_ALPHA_WEBGL) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
const previousUnpackPmaValue = gl.getParameter(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, !this.pma);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this._image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, useMipMaps ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (useMipMaps) gl.generateMipmap(gl.TEXTURE_2D);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, previousUnpackPmaValue);
}
restore () {

View File

@ -35,10 +35,7 @@ import { ManagedWebGLRenderingContext } from "./WebGL.js";
const GL_ONE = 1;
const GL_ONE_MINUS_SRC_COLOR = 0x0301;
const GL_SRC_ALPHA = 0x0302;
const GL_ONE_MINUS_SRC_ALPHA = 0x0303;
// biome-ignore lint/correctness/noUnusedVariables: intentional
const GL_ONE_MINUS_DST_ALPHA = 0x0305;
const GL_DST_COLOR = 0x0306;
export class PolygonBatcher implements Disposable {
@ -88,16 +85,16 @@ export class PolygonBatcher implements Disposable {
}
}
private static blendModesGL: { srcRgb: number, srcRgbPma: number, dstRgb: number, srcAlpha: number }[] = [
{ srcRgb: GL_SRC_ALPHA, srcRgbPma: GL_ONE, dstRgb: GL_ONE_MINUS_SRC_ALPHA, srcAlpha: GL_ONE },
{ srcRgb: GL_SRC_ALPHA, srcRgbPma: GL_ONE, dstRgb: GL_ONE, srcAlpha: GL_ONE },
{ srcRgb: GL_DST_COLOR, srcRgbPma: GL_DST_COLOR, dstRgb: GL_ONE_MINUS_SRC_ALPHA, srcAlpha: GL_ONE },
{ srcRgb: GL_ONE, srcRgbPma: GL_ONE, dstRgb: GL_ONE_MINUS_SRC_COLOR, srcAlpha: GL_ONE }
private static blendModesGL: { srcRgbPma: number, dstRgb: number, srcAlpha: number }[] = [
{ srcRgbPma: GL_ONE, dstRgb: GL_ONE_MINUS_SRC_ALPHA, srcAlpha: GL_ONE },
{ srcRgbPma: GL_ONE, dstRgb: GL_ONE, srcAlpha: GL_ONE },
{ srcRgbPma: GL_DST_COLOR, dstRgb: GL_ONE_MINUS_SRC_ALPHA, srcAlpha: GL_ONE },
{ srcRgbPma: GL_ONE, dstRgb: GL_ONE_MINUS_SRC_COLOR, srcAlpha: GL_ONE }
]
setBlendMode (blendMode: BlendMode, premultipliedAlpha: boolean) {
setBlendMode (blendMode: BlendMode) {
const blendModeGL = PolygonBatcher.blendModesGL[blendMode];
const srcColorBlend = premultipliedAlpha ? blendModeGL.srcRgbPma : blendModeGL.srcRgb;
const srcColorBlend = blendModeGL.srcRgbPma;
const srcAlphaBlend = blendModeGL.srcAlpha;
const dstBlend = blendModeGL.dstRgb;

View File

@ -87,15 +87,13 @@ export class SceneRenderer implements Disposable {
this.enableRenderer(this.batcher);
}
drawSkeleton (skeleton: Skeleton, premultipliedAlpha = false, slotRangeStart = -1, slotRangeEnd = -1, transform: VertexTransformer | null = null) {
drawSkeleton (skeleton: Skeleton, slotRangeStart = -1, slotRangeEnd = -1, transform: VertexTransformer | null = null) {
this.enableRenderer(this.batcher);
this.skeletonRenderer.premultipliedAlpha = premultipliedAlpha;
this.skeletonRenderer.draw(this.batcher, skeleton, slotRangeStart, slotRangeEnd, transform);
}
drawSkeletonDebug (skeleton: Skeleton, premultipliedAlpha = false, ignoredBones?: Array<string>) {
drawSkeletonDebug (skeleton: Skeleton, ignoredBones?: Array<string>) {
this.enableRenderer(this.shapes);
this.skeletonDebugRenderer.premultipliedAlpha = premultipliedAlpha;
this.skeletonDebugRenderer.draw(this.shapes, skeleton, ignoredBones);
}

View File

@ -47,7 +47,6 @@ export class SkeletonDebugRenderer implements Disposable {
drawPaths = true;
drawSkeletonXY = false;
drawClipping = true;
premultipliedAlpha = false;
scale = 1;
boneWidth = 2;
@ -66,8 +65,7 @@ export class SkeletonDebugRenderer implements Disposable {
const skeletonX = skeleton.x;
const skeletonY = skeleton.y;
const gl = this.context.gl;
const srcFunc = this.premultipliedAlpha ? gl.ONE : gl.SRC_ALPHA;
shapes.setBlendMode(srcFunc, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
shapes.setBlendMode(gl.ONE, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
const bones = skeleton.bones;
if (this.drawBones) {

View File

@ -42,7 +42,6 @@ export type VertexTransformer = (vertices: NumberArrayLike, numVertices: number,
export class SkeletonRenderer {
static QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
premultipliedAlpha = false;
private tempColor = new Color();
private tempColor2 = new Color();
private vertices: NumberArrayLike;
@ -64,7 +63,6 @@ export class SkeletonRenderer {
draw (batcher: PolygonBatcher, skeleton: Skeleton, slotRangeStart: number = -1, slotRangeEnd: number = -1, transformer: VertexTransformer | null = null) {
const clipper = this.clipper;
const premultipliedAlpha = this.premultipliedAlpha;
const twoColorTint = this.twoColorTint;
let blendMode: BlendMode | null = null;
@ -134,33 +132,25 @@ export class SkeletonRenderer {
if (texture) {
const slotColor = pose.color;
const finalColor = this.tempColor;
finalColor.r = skeletonColor.r * slotColor.r * attachmentColor.r;
finalColor.g = skeletonColor.g * slotColor.g * attachmentColor.g;
finalColor.b = skeletonColor.b * slotColor.b * attachmentColor.b;
finalColor.a = skeletonColor.a * slotColor.a * attachmentColor.a;
if (premultipliedAlpha) {
finalColor.r *= finalColor.a;
finalColor.g *= finalColor.a;
finalColor.b *= finalColor.a;
}
const alpha = skeletonColor.a * slotColor.a * attachmentColor.a;
finalColor.r = skeletonColor.r * slotColor.r * attachmentColor.r * alpha;
finalColor.g = skeletonColor.g * slotColor.g * attachmentColor.g * alpha;
finalColor.b = skeletonColor.b * slotColor.b * attachmentColor.b * alpha;
finalColor.a = alpha;
const darkColor = this.tempColor2;
if (!pose.darkColor)
darkColor.set(0, 0, 0, 1.0);
else {
if (premultipliedAlpha) {
darkColor.r = pose.darkColor.r * finalColor.a;
darkColor.g = pose.darkColor.g * finalColor.a;
darkColor.b = pose.darkColor.b * finalColor.a;
} else {
darkColor.setFromColor(pose.darkColor);
}
darkColor.a = premultipliedAlpha ? 1.0 : 0.0;
darkColor.r = pose.darkColor.r * alpha;
darkColor.g = pose.darkColor.g * alpha;
darkColor.b = pose.darkColor.b * alpha;
darkColor.a = 1;
}
const slotBlendMode = slot.data.blendMode;
if (slotBlendMode !== blendMode) {
blendMode = slotBlendMode;
batcher.setBlendMode(blendMode, premultipliedAlpha);
batcher.setBlendMode(blendMode);
}
if (clipper.isClipping() && clipper.clipTriangles(renderable.vertices, triangles, triangles.length, uvs, finalColor, darkColor, twoColorTint, vertexSize)) {
@ -175,7 +165,7 @@ export class SkeletonRenderer {
verts[v] = finalColor.r;
verts[v + 1] = finalColor.g;
verts[v + 2] = finalColor.b;
verts[v + 3] = finalColor.a;
verts[v + 3] = alpha;
verts[v + 4] = uvs[u];
verts[v + 5] = uvs[u + 1];
}
@ -184,7 +174,7 @@ export class SkeletonRenderer {
verts[v] = finalColor.r;
verts[v + 1] = finalColor.g;
verts[v + 2] = finalColor.b;
verts[v + 3] = finalColor.a;
verts[v + 3] = alpha;
verts[v + 4] = uvs[u];
verts[v + 5] = uvs[u + 1];
verts[v + 6] = darkColor.r;