UNPKG

shaku

Version:

A simple and effective JavaScript game development framework that knows its place!

287 lines (244 loc) 9.4 kB
/** * Implement texture atlas asset type. * * |-- copyright and license --| * @module Shaku * @file shaku\src\assets\texture_atlas_asset.js * @author Ronen Ness (ronenness@gmail.com | http://ronenness.com) * @copyright (c) 2021 Ronen Ness * @license MIT * |-- end copyright and license --| * */ 'use strict'; const Utils = require("../utils"); const { Rectangle } = require("../utils"); const Vector2 = require("../utils/vector2"); const Asset = require("./asset"); const TextureAsset = require("./texture_asset"); const TextureInAtlasAsset = require("./texture_in_atlas_asset"); const _logger = require('../logger.js').getLogger('assets'); // the webgl context to use var gl = null; /** * A texture atlas we can build at runtime to combine together multiple textures. */ class TextureAtlasAsset extends Asset { /** @inheritdoc */ constructor(url) { super(url); this.__textures = []; this.__sources = {}; } /** * Set the WebGL context. * @private */ static _setWebGl(_gl) { gl = _gl; } /** * Build the texture atlas. * @private * @param {Array<string>|Array<Image>} sources Source URLs or images to load into texture. If array of Images, should also contain an '__origin_url' property under them for asset key. * @param {Number=} maxWidth Optional texture atlas width limit. * @param {Number=} maxHeight Optional texture atlas height limit. * @param {Vector2=} extraMargins Extra pixels to add between textures. * @returns {Promise} Promise to resolve when done. */ async _build(sources, maxWidth, maxHeight, extraMargins) { // sanity if (this.__textures.length) { throw new Error("Texture Atlas already built!"); } // default margins extraMargins = extraMargins; // build atlas return new Promise(async (resolve, reject) => { // make sure all sources are valid loaded images sources = await loadAllSources(sources); // make sure maxWidth and maxHeight are valid if (maxWidth > gl.MAX_TEXTURE_SIZE) { maxWidth = gl.MAX_TEXTURE_SIZE; } if (maxHeight > gl.MAX_TEXTURE_SIZE) { maxHeight = gl.MAX_TEXTURE_SIZE; } // build atlas textures while there are still sources while (sources && sources.length) { // arrange textures let arranged = arrangeTextures(sources, maxWidth, maxHeight, extraMargins); // build the texture atlas! let atlasTexture = new TextureAsset(`_atlas_${this.url}_${this.__textures.length}`); // first create a canvas and get its 2d context let canvas = document.createElement("canvas"); canvas.width = arranged.width; canvas.height = arranged.height; let ctx = canvas.getContext("2d"); // now draw the sources and fill the sources dictionary let textureInAtlasIndex = 0; for (let imageData of arranged.rectangles) { ctx.drawImage(imageData.source, imageData.x, imageData.y); let url = imageData.source.src; let originUrl = imageData.source.__origin_url; let relativeUrl = url.substr(location.origin.length); let internalUrl = atlasTexture.url + '_' + (textureInAtlasIndex++).toString() + '_' + url.replaceAll('/', '_').replaceAll(':', ''); let sourceRectangle = new Rectangle(imageData.x, imageData.y, imageData.width, imageData.height); let textureInAtlas = new TextureInAtlasAsset(internalUrl, atlasTexture, sourceRectangle, this); this.__sources[url] = this.__sources[relativeUrl] = this.__sources[relativeUrl.substr(1)] = textureInAtlas; if (originUrl) { this.__sources[originUrl] = this.__sources[url]; } } // convert to texture let atlasSrcUrl = canvas.toDataURL(); let atlasImage = await loadImage(atlasSrcUrl); atlasTexture.fromImage(atlasImage); // push to textures list atlasTexture.utilized = arranged.utilized; this.__textures.push(atlasTexture); // get leftovers as next batch sources = arranged.leftovers; } // done! resolve(); }); } /** * Get a list with all textures in atlas. * @returns {Array<TextureAsset>} Textures in atlas. */ get textures() { return this.__textures.slice(0); } /** * Get texture asset and source rectangle for a desired image URL. * @param {String} url URL to fetch texture and source from. Can be full URL, relative URL, or absolute URL starting from /. * @returns {TextureInAtlasAsset} Texture in atlas asset, or null if not found. */ getTexture(url) { return this.__sources[url] || null; } /** @inheritdoc */ get valid() { return Boolean(this.__textures.length); } /** @inheritdoc */ destroy() { for (let texture of this.__textures) { texture.destroy(); } this.__textures = []; this.__sources = {}; } } /** * Efficiently arrange textures into minimal size area with maximum efficiency. * @private * @param {Array<Image>} sourceImages Array of images to pack. * @param {Number=} maxAtlasWidth Max width for result area. * @param {Number=} maxAtlasHeight Max height for result area. * @param {Vector2=} extraMargins Extra pixels to add between textures. */ function arrangeTextures(sourceImages, maxAtlasWidth, maxAtlasHeight, extraMargins) { // default max width and height maxAtlasWidth = maxAtlasWidth || gl.MAX_TEXTURE_SIZE; maxAtlasHeight = maxAtlasHeight || gl.MAX_TEXTURE_SIZE; // use the sorter algorithm let result = Utils.ItemsSorter.arrangeRectangles(sourceImages, (width) => { // make width a power of 2 let power = 1; while(power < width) { power *= 2; } width = power; // return new width and make sure don't exceed max size return Math.min(width, maxAtlasWidth); }, extraMargins); // exceed max limit? if (result.height > maxAtlasHeight) { // adjust result height result.height = maxAtlasHeight; // remove all textures that are outside limits result.leftovers = []; for (let i = result.rectangles.length - 1; i >= 0; --i) { let currRect = result.rectangles[i]; if (currRect.y + currRect.height > maxAtlasHeight) { result.rectangles.splice(i, 1); result.leftovers.push(currRect.source); } } } // return result return result; } /** * Load an image and return a promise. * @private */ function loadImage(path) { return new Promise((resolve, reject) => { const img = new Image() img.crossOrigin = 'Anonymous' // to avoid CORS if used with Canvas img.src = path img.__origin_url = path; img.onload = () => { resolve(img) } img.onerror = e => { reject(e) } }); } /** * Convert list of sources that are either Image instances or URL strings to fully loaded Image instances. * Wait for image loading if needed. */ async function loadAllSources(sources) { return new Promise(async (resolve, reject) => { // make sure all sources are image instances let waitFor = []; let images = []; for (let i = 0; i < sources.length; ++i) { // get current image / source let curr = sources[i]; // if its source url: if (typeof curr === 'string') { waitFor.push(loadImage(curr)); } // if its an image instance: else if (curr instanceof Image) { // if ready, push as-is if (curr.width) { images.push(curr); } // if not ready, push to wait list else { waitFor.push(new Promise((resolve, reject) => { curr.onload = resolve(curr); curr.onerror = reject; })); } } // unknown type else { reject(`Invalid source type: ${curr}. All sources must be either Image instances or URLs (string).`); } } // wait for all images that are loading for (let loadPromise of waitFor) { images.push(await loadPromise); } // return result resolve(images); }); } // export the asset type. module.exports = TextureAtlasAsset;