plugins_textureSheet.js

/**
 * LittleJS Texture Sheet Plugin
 * - Packs images into texture sheets as they are loaded
 * - Sprites are placed automatically, callers get a TileInfo
 * - Sheets are created and filled as needed
 * - Sheets fill in call order, images decode in parallel
 * - Animation frames keep layout and wrap across rows as needed
 * - WebGL textures upload once per batch of loads
 * - loadAtlas imports pre-packed atlases (TexturePacker and Aseprite json)
 * @namespace TextureSheets
 */

'use strict';

/** Width and height in pixels of texture sheets created by loadSprite
 *  @type {number}
 *  @default
 *  @memberof Settings */
let textureSheetSize = 2048;

/** Default padding pixels around each frame packed by loadSprite
 *  @type {number}
 *  @default
 *  @memberof Settings */
let textureSheetPadding = 1;

/** Array of texture sheets created by loadSprite
 *  @type {Array<TextureSheet>}
 *  @memberof TextureSheets */
let textureSheets = [];

// pending loads pack through a queue so sheets fill in call order
let textureSheetQueue = Promise.resolve();
let textureSheetPendingCount = 0;

/**
 * Texture Sheet - A texture that images are packed into as they load
 * Uses shelf packing, images are placed left to right then wrap to a new row
 * @memberof TextureSheets
 */
class TextureSheet
{
    /** Create a texture sheet, called automatically by loadSprite
     *  @param {number} [size] - Width and height of the sheet in pixels */
    constructor(size=textureSheetSize)
    {
        ASSERT(size > 0, 'texture sheet size must be positive');

        /** @property {number} - Width and height of the sheet in pixels */
        this.size = size;
        /** @property {OffscreenCanvas} - Canvas holding the packed images */
        this.canvas = headlessMode ? undefined : new OffscreenCanvas(size, size);
        /** @property {OffscreenCanvasRenderingContext2D} - 2d context for the canvas */
        this.context = this.canvas?.getContext('2d');
        /** @property {TextureInfo} - The texture info for this sheet */
        this.textureInfo = new TextureInfo(this.canvas);
        /** @property {Vector2} - Where the next image will be packed */
        this.cursor = vec2();
        /** @property {number} - Height of the row being packed */
        this.rowHeight = 0;
        /** @property {boolean} - Has the canvas changed since the last webgl upload? */
        this.glDirty = false;

        if (headlessMode)
        {
            // tiles still need bounds when there is no canvas to measure
            this.textureInfo.size = vec2(size);
            this.textureInfo.sizeInverse = vec2(1/size);
        }
    }

    /** Find a spot for an image on this sheet without drawing it
     *  @param {Vector2} imageSize - Size of the source image in pixels
     *  @param {Vector2} [frameSize] - Size of each frame, or the whole image if not passed
     *  @param {number} [padding] - How many pixels padding around each frame
     *  @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image
     *  @return {TileInfo} Tile for the packed image, or undefined if the sheet is full */
    tryAdd(imageSize, frameSize=imageSize, padding=textureSheetPadding, sourcePadding=0)
    {
        ASSERT(isVector2(imageSize) && isVector2(frameSize), 'sizes must be vec2');
        ASSERT(frameSize.x > 0 && frameSize.y > 0, 'frame size must be positive');

        if (isNumber(sourcePadding))
            sourcePadding = vec2(sourcePadding);
        ASSERT(isVector2(sourcePadding) && sourcePadding.x >= 0 && sourcePadding.y >= 0,
            'sourcePadding must be a number or vec2 >= 0');

        // the source may have its own padding baked in around each frame
        const sourceCellWidth = frameSize.x + sourcePadding.x*2;
        const sourceCellHeight = frameSize.y + sourcePadding.y*2;
        ASSERT(imageSize.x % sourceCellWidth === 0 && imageSize.y % sourceCellHeight === 0,
            'image size must be a multiple of the padded frame size');

        const cellWidth = frameSize.x + padding*2;
        const cellHeight = frameSize.y + padding*2;
        const maxColumns = this.size / cellWidth | 0;
        ASSERT(maxColumns > 0, 'frame is too wide to fit on a texture sheet');

        // keep the layout of the source image, but narrow it if a row is too wide
        // frames wrap down to the next row, which TileInfo.frame handles via columns
        const sourceColumns = imageSize.x / sourceCellWidth;
        const frameCount = sourceColumns * (imageSize.y / sourceCellHeight);
        const columns = min(sourceColumns, maxColumns);
        const blockWidth = columns * cellWidth;
        const blockHeight = ceil(frameCount / columns) * cellHeight;

        // probe the placement using locals so a failed try leaves the sheet unchanged
        let x = this.cursor.x, y = this.cursor.y, rowHeight = this.rowHeight;
        if (x + blockWidth > this.size)
        {
            // start a new row if this one does not have enough space left
            x = 0;
            y += rowHeight;
            rowHeight = 0;
        }

        // out of space, the caller needs to use a different sheet
        if (y + blockHeight > this.size)
            return undefined;

        // commit the placement, tile pos points inside the padding to match how tile() works
        this.cursor.x = x + blockWidth;
        this.cursor.y = y;
        this.rowHeight = max(rowHeight, blockHeight);
        return new TileInfo(vec2(x + padding, y + padding), frameSize, this.textureInfo, padding, 0, columns);
    }

    /** Draw an image into this sheet at a tile returned by tryAdd
     *  @param {HTMLImageElement} image - Source image to copy from
     *  @param {TileInfo} tileInfo - Where to put it, from tryAdd
     *  @param {boolean} [update] - Upload to webgl now, pass false when batching
     *  @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image */
    drawImage(image, tileInfo, update=true, sourcePadding=0)
    {
        ASSERT(!!this.context, 'texture sheet has no canvas');

        if (isNumber(sourcePadding))
            sourcePadding = vec2(sourcePadding);

        // copy frames in order, reading the source left to right, top to bottom
        // the destination wraps at tileInfo.columns which may be narrower than the source
        const frameSize = tileInfo.size;
        const sourceCellWidth = frameSize.x + sourcePadding.x*2;
        const sourceCellHeight = frameSize.y + sourcePadding.y*2;
        const sourceColumns = image.width / sourceCellWidth;
        const frameCount = sourceColumns * (image.height / sourceCellHeight);
        const columns = tileInfo.columns || frameCount;
        const cellWidth = frameSize.x + tileInfo.padding*2;
        const cellHeight = frameSize.y + tileInfo.padding*2;
        for (let i = frameCount; i--;)
        {
            const sourceX = (i % sourceColumns) * sourceCellWidth + sourcePadding.x;
            const sourceY = (i / sourceColumns | 0) * sourceCellHeight + sourcePadding.y;
            this.context.drawImage(image,
                sourceX, sourceY, frameSize.x, frameSize.y,
                tileInfo.pos.x + (i % columns) * cellWidth,
                tileInfo.pos.y + (i / columns | 0) * cellHeight,
                frameSize.x, frameSize.y);
        }

        // upload now unless the caller is batching more images
        this.glDirty = true;
        update && this.updateTexture();
    }

    /** Upload the canvas to webgl if it has changed since the last upload
     *  Only needed after batching, drawImage uploads automatically by default */
    updateTexture()
    {
        if (!this.glDirty) return;
        this.glDirty = false;
        this.textureInfo.createWebGLTexture();
    }
}

///////////////////////////////////////////////////////////////////////////////

/** Load an image and pack it into a texture sheet
 *  - Returns a TileInfo immediately which is filled in when the image loads
 *  - Nothing is visible until it loads, use spritesReady to wait for it
 *  - Pass frameSize for animations, then step through them with TileInfo.frame
 *  - Grid images keep their layout and frames wrap down to the next row
 *  - Pass sourcePadding if the source image has padding baked in around frames
 *  @param {string} src - Image source path
 *  @param {Vector2|number} [frameSize] - Size of each animation frame in pixels
 *  @param {number} [padding] - How many pixels padding around each frame
 *  @param {number|Vector2} [sourcePadding] - How many pixels padding around each frame in the source image
 *  @return {TileInfo}
 *  @example
 *  const playerTile = loadSprite('player.png');     // a single sprite
 *  const runTile = loadSprite('run.png', vec2(16)); // a 16x16 frame animation
 *  @memberof TextureSheets */
function loadSprite(src, frameSize, padding=textureSheetPadding, sourcePadding=0)
{
    ASSERT(isStringLike(src), 'image src must be a string');
    ASSERT(!frameSize || isVector2(frameSize) || isNumber(frameSize), 'frameSize must be a vec2 or number');
    ASSERT(isNumber(padding), 'padding must be a number');
    ASSERT(isNumber(sourcePadding) || isVector2(sourcePadding), 'sourcePadding must be a number or vec2');

    if (isNumber(frameSize))
        frameSize = vec2(frameSize);

    // start with an empty tile that gets filled in when the image loads
    const tileInfo = new TileInfo(vec2(), vec2(), undefined, padding, 0);
    if (headlessMode) return tileInfo;

    // point at a sheet right away so drawing before it loads picks up empty pixels
    tileInfo.textureInfo = (textureSheets[0] || textureSheetCreate()).textureInfo;

    // start decoding right away, images decode in parallel
    const image = new Image;
    const imagePromise = new Promise(resolve =>
    {
        image.onerror = image.onload = resolve;
        image.crossOrigin = 'anonymous';
        image.src = src;
    });

    // pack through a queue so sheets fill in call order, not decode order
    ++textureSheetPendingCount;
    textureSheetQueue = textureSheetQueue.then(async ()=>
    {
        await imagePromise;
        if (image.width)
        {
            // pack onto a sheet, then fill in the tile that was already handed out,
            // copying every field so nothing is missed if TileInfo gains more of them
            const imageSize = vec2(image.width, image.height);
            const {sheet, tile} = textureSheetAdd(imageSize, frameSize, padding, sourcePadding);
            Object.assign(tileInfo, tile);
            sheet.drawImage(image, tileInfo, false, sourcePadding); // upload once per batch below
        }
        else
        {
            // leave the tile empty if the image failed to load
            LOG('loadSprite failed to load image:', src);
        }

        // upload to webgl once per batch, when the last pending load finishes
        if (!--textureSheetPendingCount)
            textureSheets.forEach(s=> s.updateTexture());
    });

    return tileInfo;
}

/** Load a pre-packed texture atlas and repack it onto texture sheets
 *  - Supports TexturePacker json (hash and array) and Aseprite json
 *  - Returns an empty object which is filled with TileInfos when loaded
 *  - Frames are named by the json, animations are grouped automatically
 *  - Aseprite frame tags become animations, so do names like run_0, run_1
 *  - Trimmed frames are restored to their full source size when packed
 *  - Rotated frames are rotated back upright when packed
 *  @param {string} imageSrc - Atlas image path
 *  @param {string|Object} jsonSrc - Atlas json path, or already parsed json data
 *  @param {number} [padding] - How many pixels padding around each frame
 *  @return {Object} Object mapping frame and animation names to TileInfos
 *  @example
 *  const atlas = loadAtlas('sprites.png', 'sprites.json');
 *  await spritesReady();
 *  drawTile(pos, size, atlas.player);          // a single frame
 *  drawTile(pos, size, atlas.run.frame(2));    // frame 2 of the run animation
 *  @memberof TextureSheets */
function loadAtlas(imageSrc, jsonSrc, padding=textureSheetPadding)
{
    ASSERT(isStringLike(imageSrc), 'atlas image src must be a string');
    ASSERT(isStringLike(jsonSrc) || typeof jsonSrc === 'object', 'atlas json must be a path or object');
    ASSERT(isNumber(padding), 'padding must be a number');

    const atlas = {};
    if (headlessMode) return atlas;

    // start fetching the json and decoding the image right away, in parallel
    const jsonPromise = typeof jsonSrc === 'object' ? Promise.resolve(jsonSrc) :
        fetch(jsonSrc).then(r=> r.ok && r.json()).catch(()=> undefined);
    const image = new Image;
    const imagePromise = new Promise(resolve =>
    {
        image.onerror = image.onload = resolve;
        image.crossOrigin = 'anonymous';
        image.src = imageSrc;
    });

    // pack through a queue so sheets fill in call order, not decode order
    ++textureSheetPendingCount;
    textureSheetQueue = textureSheetQueue.then(async ()=>
    {
        const data = await jsonPromise;
        await imagePromise;
        if (image.width && data)
        {
            for (const group of parseAtlas(data))
            {
                // reserve a block of full size cells, one per frame
                const sourceSize = group.frames[0].sourceSize;
                const blockSize = vec2(sourceSize.x*group.frames.length, sourceSize.y);
                const {sheet, tile} = textureSheetAdd(blockSize, sourceSize, padding);

                // draw each frame untrimmed into its cell
                const context = sheet.context;
                const cellWidth = sourceSize.x + padding*2;
                const cellHeight = sourceSize.y + padding*2;
                group.frames.forEach((f, i)=>
                {
                    const x = tile.pos.x + (i % tile.columns)*cellWidth + f.offset.x;
                    const y = tile.pos.y + (i / tile.columns |0)*cellHeight + f.offset.y;
                    if (f.rotated)
                    {
                        // stored rotated 90 degrees clockwise, draw it back upright
                        context.save();
                        context.translate(x, y);
                        context.rotate(-PI/2);
                        context.drawImage(image, f.pos.x, f.pos.y, f.size.y, f.size.x,
                            -f.size.y, 0, f.size.y, f.size.x);
                        context.restore();
                    }
                    else
                        context.drawImage(image, f.pos.x, f.pos.y, f.size.x, f.size.y,
                            x, y, f.size.x, f.size.y);
                });
                sheet.glDirty = true;
                atlas[group.name] = tile;
            }
        }
        else
        {
            // leave the atlas empty if either file failed to load
            LOG('loadAtlas failed to load:', imageSrc, jsonSrc);
        }

        // upload to webgl once per batch, when the last pending load finishes
        if (!--textureSheetPendingCount)
            textureSheets.forEach(s=> s.updateTexture());
    });

    return atlas;
}

/** Parse atlas json into a list of named frame groups, used by loadAtlas
 *  - Accepts TexturePacker json (hash and array) and Aseprite json
 *  - Frames tagged in Aseprite or named like run_0, run_1 group into animations
 *  @param {Object} data - Parsed atlas json data
 *  @return {Array<Object>} List of {name, frames} groups in atlas order
 *  @memberof TextureSheets */
function parseAtlas(data)
{
    ASSERT(!!data?.frames, 'unrecognized atlas format, expected TexturePacker or Aseprite json');

    // normalize both hash and array frame layouts into a single list
    const frames = (isArray(data.frames) ?
        data.frames.map(f=> [f.filename, f]) : Object.entries(data.frames))
        .map(([name, f])=> ({
            name: name.replace(/\.[^.\\/]+$/, ''), // strip file extension
            pos:        vec2(f.frame.x, f.frame.y),
            size:       vec2(f.frame.w, f.frame.h),
            offset:     vec2(f.spriteSourceSize?.x ?? 0, f.spriteSourceSize?.y ?? 0),
            sourceSize: vec2(f.sourceSize?.w ?? f.frame.w, f.sourceSize?.h ?? f.frame.h),
            rotated:    !!f.rotated,
        }));

    const groups = [];
    const tags = data.meta?.frameTags;
    if (tags?.length)
    {
        // aseprite tags are authoritative, untagged frames stay individual
        const tagged = new Set;
        for (const tag of tags)
        {
            groups.push({name: tag.name, frames: frames.slice(tag.from, tag.to + 1)});
            for (let i = tag.from; i <= tag.to; ++i)
                tagged.add(i);
        }
        frames.forEach((f, i)=> tagged.has(i) || groups.push({name: f.name, frames: [f]}));
        return groups;
    }

    // group frames that share a name stem with contiguous trailing numbers
    // run_0.png and run_1.png become a 2 frame animation named run
    const stems = new Map;
    for (const f of frames)
    {
        let match = f.name.match(/^(.+?)([-_ ])?(\d+)$/);
        if (match && !match[2] && /\d$/.test(match[1]))
            match = undefined; // all digit tails like 10 are a name, not frame 0 of 1
        const stem = match ? match[1] : f.name;
        f.groupIndex = match ? Number(match[3]) : undefined;
        stems.has(stem) || stems.set(stem, []);
        stems.get(stem).push(f);
    }
    for (const [stem, list] of stems)
    {
        // only group 2 or more frames with contiguous indices and matching sizes
        list.sort((a, b)=> a.groupIndex - b.groupIndex);
        const grouped = list.length > 1 &&
            list.every((f, i)=> f.groupIndex === list[0].groupIndex + i) &&
            list.every(f=> f.sourceSize.x === list[0].sourceSize.x &&
                           f.sourceSize.y === list[0].sourceSize.y);
        if (grouped)
            groups.push({name: stem, frames: list});
        else
            list.forEach(f=> groups.push({name: f.name, frames: [f]}));
    }
    return groups;
}

/** Wait for everything started by loadSprite and loadAtlas to finish packing
 *  @return {Promise}
 *  @example
 *  async function gameInit()
 *  {
 *      playerTile = loadSprite('player.png');
 *      runTile = loadSprite('run.png', vec2(16));
 *      await spritesReady();
 *  }
 *  @memberof TextureSheets */
async function spritesReady()
{
    // keep waiting until the queue drains, more sprites may load while waiting
    while (textureSheetPendingCount)
        await textureSheetQueue;
}

// create a new texture sheet and add it to the list
function textureSheetCreate()
{
    const sheet = new TextureSheet;
    textureSheets.push(sheet);
    return sheet;
}

// use the first sheet with enough space, or make a new one
function textureSheetAdd(imageSize, frameSize, padding, sourcePadding)
{
    let sheet, tile;
    for (sheet of textureSheets)
        if (tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding))
            break;
    if (!tile)
    {
        sheet = textureSheetCreate();
        tile = sheet.tryAdd(imageSize, frameSize, padding, sourcePadding);
        ASSERT(!!tile, 'image is too large to fit on a texture sheet');
    }
    return {sheet, tile};
}

///////////////////////////////////////////////////////////////////////////////
// Texture sheet setting setters

/** Set width and height in pixels of texture sheets created by loadSprite
 *  @param {number} size
 *  @memberof Settings */
function setTextureSheetSize(size) { textureSheetSize = size; }

/** Set default padding pixels around each frame packed by loadSprite
 *  @param {number} padding
 *  @memberof Settings */
function setTextureSheetPadding(padding) { textureSheetPadding = padding; }