• Runtimes
  • Spine Typescript webgl & Phaser - support for compressed textures?

Just wanted to check if there is any plan to support compressed textures like PVR in Phaser or webgl implementation? Currently GLTexture webgl typescript implementation assumes texture is loaded from an Image and not directly to gpu through a compressed texture

    Related Discussions
    ...

    Currently, the Spine TypeScript WebGL implementation does not natively support loading compressed textures directly to the GPU, such as PVR textures. The GLTexture implementation in the spine-ts runtime assumes textures are loaded from an image. For Phaser, the spine-phaser runtime supports rendering via WebGL, but it does not specifically mention support for compressed textures. Implementing support for compressed textures would likely require custom modifications to the rendering pipeline to handle the specific compressed texture formats and their loading processes.

    I added a quick and hacky compressed texture approach that only supports PVR ASTC as of now locally as a test

    CompressedGLTexture class

    
    import { Texture, Disposable, Restorable, TextureFilter, TextureWrap } from "@esotericsoftware/spine-core";
    import { ManagedWebGLRenderingContext } from "@esotericsoftware/spine-webgl";
    import * as Phaser from "phaser";
    
    export class CompressedGLTexture extends Texture implements Disposable, Restorable {
        context: ManagedWebGLRenderingContext;
        private texture: WebGLTexture | null = null;
        private boundUnit = 0;
        private useMipMaps = false;
        private sourceData: Phaser.Types.Textures.CompressedTextureData | null = null;
        public static DISABLE_UNPACK_PREMULTIPLIED_ALPHA_WEBGL = false;
        private width: number = 0;
        private height: number = 0;
    
        constructor(context: ManagedWebGLRenderingContext | WebGLRenderingContext, 
            sourceData: Phaser.Types.Textures.CompressedTextureData,
                    useMipMaps: boolean = false, 
                    ) {
            const image = {};
            super(image);
            
            this.context = context instanceof ManagedWebGLRenderingContext ? context : new ManagedWebGLRenderingContext(context);
            this.useMipMaps = useMipMaps;
            this.sourceData = sourceData;
            if (sourceData ) {
                this.width = sourceData.width;
                this.height = sourceData.height;
                this.setSize(this.width, this.height);
            } 
            this.restore();
            
            this.context.addRestorable(this);
        }
    
        private setSize(width: number, height: number) {
            this.width = width;
            this.height = height;
            (this.getImage() as any).width = width;
            (this.getImage() as any).height = height;
        }
    
        setFilters(minFilter: TextureFilter, magFilter: TextureFilter) {
            let gl = this.context.gl;
            this.bind();
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, minFilter);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, CompressedGLTexture.validateMagFilter(magFilter));
            this.useMipMaps = CompressedGLTexture.usesMipMaps(minFilter);
            if (this.useMipMaps && this.sourceData && this.sourceData.mipmaps && this.sourceData.mipmaps.length > 1) {
                // Only generate mipmaps if we don't already have them in the sourceData
                if (!this.sourceData.mipmaps || this.sourceData.mipmaps.length <= 1) {
                    gl.generateMipmap(gl.TEXTURE_2D);
                }
            }
        }
    
        static validateMagFilter(magFilter: TextureFilter) {
            switch (magFilter) {
                case TextureFilter.MipMapLinearLinear:
                case TextureFilter.MipMapLinearNearest:
                case TextureFilter.MipMapNearestLinear:
                case TextureFilter.MipMapNearestNearest:
                    return TextureFilter.Linear;
                default:
                    return magFilter;
            }
        }
    
        static usesMipMaps(filter: TextureFilter) {
            switch (filter) {
                case TextureFilter.MipMapLinearLinear:
                case TextureFilter.MipMapLinearNearest:
                case TextureFilter.MipMapNearestLinear:
                case TextureFilter.MipMapNearestNearest:
                    return true;
                default:
                    return false;
            }
        }
    
        setWraps(uWrap: TextureWrap, vWrap: TextureWrap) {
            let gl = this.context.gl;
            this.bind();
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, uWrap);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, vWrap);
        }
    
        update(useMipMaps: boolean) {
            let gl = this.context.gl;
            this.bind();
            
            const minFilter = useMipMaps ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR;
            
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, minFilter);
            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 && (!this.sourceData || !this.sourceData.mipmaps || this.sourceData.mipmaps.length <= 1)) {
                gl.generateMipmap(gl.TEXTURE_2D);
            }
            
            this.useMipMaps = useMipMaps;
           
        }
    
        restore() {
            if (!this.texture && this.sourceData) {
                this.reloadFromCompressedData();
                return;
            }
            
            if (this.texture) {
                let gl = this.context.gl;
                this.bind();
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
                gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this.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 (this.useMipMaps && (!this.sourceData || !this.sourceData.mipmaps || this.sourceData.mipmaps.length <= 1)) {
                    gl.generateMipmap(gl.TEXTURE_2D);
                }
            }
        }
    
        private reloadFromCompressedData() {
            if (!this.sourceData) {
                console.error("No source data available to reload texture");
                return;
            }
    
            const gl = this.context.gl;
            
            if (!this.texture) {
                this.texture = gl.createTexture();
            }
            
            this.bind();
            
            if (this.sourceData.internalFormat) {
                const format = this.sourceData.internalFormat;
                const mipmaps = this.sourceData.mipmaps || [];
                
                if (mipmaps.length > 0) {
                    for (let i = 0; i < mipmaps.length; i++) {
                        const mipmap = mipmaps[i];
    					console.dir(mipmap)
                        gl.compressedTexImage2D(
                            gl.TEXTURE_2D,
                            i, 
                            format, 
                            mipmap.width, 
                            mipmap.height, 
                            0, 
                            mipmap.data
                        );
                    }
                    
                    this.useMipMaps = mipmaps.length > 1;
                } else {
                    console.error("No mipmap data available in sourceData");
                }
            } else {
                console.error("No format specified in compressed texture data");
            }
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this.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);
        }
    
        bind(unit: number = 0) {
            let gl = this.context.gl;
            this.boundUnit = unit;
            gl.activeTexture(gl.TEXTURE0 + unit);
            
            if (!this.texture) {
                if (this.sourceData) {
                    this.reloadFromCompressedData();
                } else {
                    console.error("No valid texture to bind");
                    return;
                }
            }
            
            gl.bindTexture(gl.TEXTURE_2D, this.texture);
        }
    
        unbind() {
            let gl = this.context.gl;
            gl.activeTexture(gl.TEXTURE0 + this.boundUnit);
            gl.bindTexture(gl.TEXTURE_2D, null);
        }
    
        dispose() {
            this.context.removeRestorable(this);
            this.texture = null;
            this.sourceData = null;
        }
    }

    updated getAtlas function

    for (let atlasPage of atlas.pages) {
    	const texture = this.game.textures.get(atlasKey + "!" + atlasPage.name);
    	if(texture.getSourceImage() ) {
    		atlasPage.setTexture(new GLTexture(gl, this.game.textures.get(atlasKey + "!" + atlasPage.name).getSourceImage() as HTMLImageElement | ImageBitmap, false));
    	} else {
    		atlasPage.setTexture(new CompressedGLTexture(gl, texture.source[0].source as Phaser.Types.Textures.CompressedTextureData, false));
    	}				
    }

    onFileComplete and addToCache to handle compressed texture loading for .pvr

    onFileComplete (file: Phaser.Loader.File) {
    	if (this.files.indexOf(file) != -1) {
    		this.pending--;
    
    		if (file.type == "text") {
    			var lines = file.data.split(/\r\n|\r|\n/);
    			let textures = [];
    			textures.push(lines[0]);
    			for (var t = 1; t < lines.length; t++) {
    				var line = lines[t];
    				if (line.trim() === '' && t < lines.length - 1) {
    					line = lines[t + 1];
    					textures.push(line);
    				}
    			}
    
    			let basePath = file.src.match(/^.*\//) ?? "";
    			for (var i = 0; i < textures.length; i++) {
    				var url:string = basePath + textures[i];
    				var key = file.key + "!" + textures[i];
    				if(url.endsWith(".pvr")) {
    					const compressedTextureEntry:Phaser.Types.Loader.FileTypes.CompressedTextureFileEntry = {
    						textureURL: url,
    						format: "ASTC",
    						type: "PVR"
    					}
    					const texture = new Phaser.Loader.FileTypes.CompressedTextureFile(
    						this.loader,key,compressedTextureEntry
    					)
    					for(const file of texture.files) {
    						if (!this.loader.keyExists(file)) {
    							file.config = compressedTextureEntry
    							this.addToMultiFile(file);
    							this.loader.addFile(file);
    						}
    					}
    					
    				} else {
    					var image = new Phaser.Loader.FileTypes.ImageFile(this.loader, key, url);
    
    					if (!this.loader.keyExists(image)) {
    						this.addToMultiFile(image);
    						this.loader.addFile(image);
    					}
    				}
    				
    			}
    		}
    	}
    }
    
    
    addToCache () {
    	if (this.isReadyToProcess()) {
    		let textureManager = this.loader.textureManager;
    		for (let file of this.files) {
    			if (file.type == "image") {
    				if (!textureManager.exists(file.key)) {
    					textureManager.addImage(file.key, file.data);
    				}
    			} else if(file.type ==="binary") {
    				if(!textureManager.exists(file.key)) {
    					const entry = file.config as Phaser.Types.Loader.FileTypes.CompressedTextureFileEntry;
    					if(entry.format !== "ASTC") {
    						console.log("Compressed texture format not supported", entry.format);
    						return;
    					}
    					const textureData:Phaser.Types.Textures.CompressedTextureData = Phaser.Textures.Parsers.PVRParser(file.data);
    					textureManager.addCompressedTexture(file.key, textureData);
    				}	
    			}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();
    			}
    		}
    	}
    }

    kimdanielarthur
    I'm sorry, but for now there's no plan to directly support them. As you experienced yourself, extending our components is quite easy and specific features can be integrated by users like you did. Thanks for sharing your code, it might be useful to other users looking for a similar solution.