import BoundingRectangle from "../Core/BoundingRectangle.js"; import Cartesian2 from "../Core/Cartesian2.js"; import createGuid from "../Core/createGuid.js"; import defaultValue from "../Core/defaultValue.js"; import defined from "../Core/defined.js"; import destroyObject from "../Core/destroyObject.js"; import DeveloperError from "../Core/DeveloperError.js"; import PixelFormat from "../Core/PixelFormat.js"; import Resource from "../Core/Resource.js"; import RuntimeError from "../Core/RuntimeError.js"; import Framebuffer from "../Renderer/Framebuffer.js"; import Texture from "../Renderer/Texture.js"; // The atlas is made up of regions of space called nodes that contain images or child nodes. function TextureAtlasNode( bottomLeft, topRight, childNode1, childNode2, imageIndex ) { this.bottomLeft = defaultValue(bottomLeft, Cartesian2.ZERO); this.topRight = defaultValue(topRight, Cartesian2.ZERO); this.childNode1 = childNode1; this.childNode2 = childNode2; this.imageIndex = imageIndex; } const defaultInitialSize = new Cartesian2(16.0, 16.0); /** * A TextureAtlas stores multiple images in one square texture and keeps * track of the texture coordinates for each image. TextureAtlas is dynamic, * meaning new images can be added at any point in time. * Texture coordinates are subject to change if the texture atlas resizes, so it is * important to check {@link TextureAtlas#getGUID} before using old values. * * @alias TextureAtlas * @constructor * * @param {object} options Object with the following properties: * @param {Scene} options.context The context in which the texture gets created. * @param {PixelFormat} [options.pixelFormat=PixelFormat.RGBA] The pixel format of the texture. * @param {number} [options.borderWidthInPixels=1] The amount of spacing between adjacent images in pixels. * @param {Cartesian2} [options.initialSize=new Cartesian2(16.0, 16.0)] The initial side lengths of the texture. * * @exception {DeveloperError} borderWidthInPixels must be greater than or equal to zero. * @exception {DeveloperError} initialSize must be greater than zero. * * @private */ function TextureAtlas(options) { options = defaultValue(options, defaultValue.EMPTY_OBJECT); const borderWidthInPixels = defaultValue(options.borderWidthInPixels, 1.0); const initialSize = defaultValue(options.initialSize, defaultInitialSize); //>>includeStart('debug', pragmas.debug); if (!defined(options.context)) { throw new DeveloperError("context is required."); } if (borderWidthInPixels < 0) { throw new DeveloperError( "borderWidthInPixels must be greater than or equal to zero." ); } if (initialSize.x < 1 || initialSize.y < 1) { throw new DeveloperError("initialSize must be greater than zero."); } //>>includeEnd('debug'); this._context = options.context; this._pixelFormat = defaultValue(options.pixelFormat, PixelFormat.RGBA); this._borderWidthInPixels = borderWidthInPixels; this._textureCoordinates = []; this._guid = createGuid(); this._idHash = {}; this._indexHash = {}; this._initialSize = initialSize; this._root = undefined; } Object.defineProperties(TextureAtlas.prototype, { /** * The amount of spacing between adjacent images in pixels. * @memberof TextureAtlas.prototype * @type {number} */ borderWidthInPixels: { get: function () { return this._borderWidthInPixels; }, }, /** * An array of {@link BoundingRectangle} texture coordinate regions for all the images in the texture atlas. * The x and y values of the rectangle correspond to the bottom-left corner of the texture coordinate. * The coordinates are in the order that the corresponding images were added to the atlas. * @memberof TextureAtlas.prototype * @type {BoundingRectangle[]} */ textureCoordinates: { get: function () { return this._textureCoordinates; }, }, /** * The texture that all of the images are being written to. * @memberof TextureAtlas.prototype * @type {Texture} */ texture: { get: function () { if (!defined(this._texture)) { this._texture = new Texture({ context: this._context, width: this._initialSize.x, height: this._initialSize.y, pixelFormat: this._pixelFormat, }); } return this._texture; }, }, /** * The number of images in the texture atlas. This value increases * every time addImage or addImages is called. * Texture coordinates are subject to change if the texture atlas resizes, so it is * important to check {@link TextureAtlas#getGUID} before using old values. * @memberof TextureAtlas.prototype * @type {number} */ numberOfImages: { get: function () { return this._textureCoordinates.length; }, }, /** * The atlas' globally unique identifier (GUID). * The GUID changes whenever the texture atlas is modified. * Classes that use a texture atlas should check if the GUID * has changed before processing the atlas data. * @memberof TextureAtlas.prototype * @type {string} */ guid: { get: function () { return this._guid; }, }, }); // Builds a larger texture and copies the old texture into the new one. function resizeAtlas(textureAtlas, image) { const context = textureAtlas._context; const numImages = textureAtlas.numberOfImages; const scalingFactor = 2.0; const borderWidthInPixels = textureAtlas._borderWidthInPixels; if (numImages > 0) { const oldAtlasWidth = textureAtlas._texture.width; const oldAtlasHeight = textureAtlas._texture.height; const atlasWidth = scalingFactor * (oldAtlasWidth + image.width + borderWidthInPixels); const atlasHeight = scalingFactor * (oldAtlasHeight + image.height + borderWidthInPixels); const widthRatio = oldAtlasWidth / atlasWidth; const heightRatio = oldAtlasHeight / atlasHeight; // Create new node structure, putting the old root node in the bottom left. const nodeBottomRight = new TextureAtlasNode( new Cartesian2(oldAtlasWidth + borderWidthInPixels, borderWidthInPixels), new Cartesian2(atlasWidth, oldAtlasHeight) ); const nodeBottomHalf = new TextureAtlasNode( new Cartesian2(), new Cartesian2(atlasWidth, oldAtlasHeight), textureAtlas._root, nodeBottomRight ); const nodeTopHalf = new TextureAtlasNode( new Cartesian2(borderWidthInPixels, oldAtlasHeight + borderWidthInPixels), new Cartesian2(atlasWidth, atlasHeight) ); const nodeMain = new TextureAtlasNode( new Cartesian2(), new Cartesian2(atlasWidth, atlasHeight), nodeBottomHalf, nodeTopHalf ); // Resize texture coordinates. for (let i = 0; i < textureAtlas._textureCoordinates.length; i++) { const texCoord = textureAtlas._textureCoordinates[i]; if (defined(texCoord)) { texCoord.x *= widthRatio; texCoord.y *= heightRatio; texCoord.width *= widthRatio; texCoord.height *= heightRatio; } } // Copy larger texture. const newTexture = new Texture({ context: textureAtlas._context, width: atlasWidth, height: atlasHeight, pixelFormat: textureAtlas._pixelFormat, }); const framebuffer = new Framebuffer({ context: context, colorTextures: [textureAtlas._texture], destroyAttachments: false, }); framebuffer._bind(); newTexture.copyFromFramebuffer(0, 0, 0, 0, atlasWidth, atlasHeight); framebuffer._unBind(); framebuffer.destroy(); textureAtlas._texture = textureAtlas._texture && textureAtlas._texture.destroy(); textureAtlas._texture = newTexture; textureAtlas._root = nodeMain; } else { // First image exceeds initialSize let initialWidth = scalingFactor * (image.width + 2 * borderWidthInPixels); let initialHeight = scalingFactor * (image.height + 2 * borderWidthInPixels); if (initialWidth < textureAtlas._initialSize.x) { initialWidth = textureAtlas._initialSize.x; } if (initialHeight < textureAtlas._initialSize.y) { initialHeight = textureAtlas._initialSize.y; } textureAtlas._texture = textureAtlas._texture && textureAtlas._texture.destroy(); textureAtlas._texture = new Texture({ context: textureAtlas._context, width: initialWidth, height: initialHeight, pixelFormat: textureAtlas._pixelFormat, }); textureAtlas._root = new TextureAtlasNode( new Cartesian2(borderWidthInPixels, borderWidthInPixels), new Cartesian2(initialWidth, initialHeight) ); } } // A recursive function that finds the best place to insert // a new image based on existing image 'nodes'. // Inspired by: http://blackpawn.com/texts/lightmaps/default.html function findNode(textureAtlas, node, image) { if (!defined(node)) { return undefined; } // If a leaf node if (!defined(node.childNode1) && !defined(node.childNode2)) { // Node already contains an image, don't add to it. if (defined(node.imageIndex)) { return undefined; } const nodeWidth = node.topRight.x - node.bottomLeft.x; const nodeHeight = node.topRight.y - node.bottomLeft.y; const widthDifference = nodeWidth - image.width; const heightDifference = nodeHeight - image.height; // Node is smaller than the image. if (widthDifference < 0 || heightDifference < 0) { return undefined; } // If the node is the same size as the image, return the node if (widthDifference === 0 && heightDifference === 0) { return node; } // Vertical split (childNode1 = left half, childNode2 = right half). if (widthDifference > heightDifference) { node.childNode1 = new TextureAtlasNode( new Cartesian2(node.bottomLeft.x, node.bottomLeft.y), new Cartesian2(node.bottomLeft.x + image.width, node.topRight.y) ); // Only make a second child if the border gives enough space. const childNode2BottomLeftX = node.bottomLeft.x + image.width + textureAtlas._borderWidthInPixels; if (childNode2BottomLeftX < node.topRight.x) { node.childNode2 = new TextureAtlasNode( new Cartesian2(childNode2BottomLeftX, node.bottomLeft.y), new Cartesian2(node.topRight.x, node.topRight.y) ); } } // Horizontal split (childNode1 = bottom half, childNode2 = top half). else { node.childNode1 = new TextureAtlasNode( new Cartesian2(node.bottomLeft.x, node.bottomLeft.y), new Cartesian2(node.topRight.x, node.bottomLeft.y + image.height) ); // Only make a second child if the border gives enough space. const childNode2BottomLeftY = node.bottomLeft.y + image.height + textureAtlas._borderWidthInPixels; if (childNode2BottomLeftY < node.topRight.y) { node.childNode2 = new TextureAtlasNode( new Cartesian2(node.bottomLeft.x, childNode2BottomLeftY), new Cartesian2(node.topRight.x, node.topRight.y) ); } } return findNode(textureAtlas, node.childNode1, image); } // If not a leaf node return ( findNode(textureAtlas, node.childNode1, image) || findNode(textureAtlas, node.childNode2, image) ); } // Adds image of given index to the texture atlas. Called from addImage and addImages. function addImage(textureAtlas, image, index) { const node = findNode(textureAtlas, textureAtlas._root, image); if (defined(node)) { // Found a node that can hold the image. node.imageIndex = index; // Add texture coordinate and write to texture const atlasWidth = textureAtlas._texture.width; const atlasHeight = textureAtlas._texture.height; const nodeWidth = node.topRight.x - node.bottomLeft.x; const nodeHeight = node.topRight.y - node.bottomLeft.y; const x = node.bottomLeft.x / atlasWidth; const y = node.bottomLeft.y / atlasHeight; const w = nodeWidth / atlasWidth; const h = nodeHeight / atlasHeight; textureAtlas._textureCoordinates[index] = new BoundingRectangle(x, y, w, h); textureAtlas._texture.copyFrom({ source: image, xOffset: node.bottomLeft.x, yOffset: node.bottomLeft.y, }); } else { // No node found, must resize the texture atlas. resizeAtlas(textureAtlas, image); addImage(textureAtlas, image, index); } textureAtlas._guid = createGuid(); } function getIndex(atlas, image) { if (!defined(atlas) || atlas.isDestroyed()) { return -1; } const index = atlas.numberOfImages; addImage(atlas, image, index); return index; } /** * If the image is already in the atlas, the existing index is returned. Otherwise, the result is undefined. * * @param {string} id An identifier to detect whether the image already exists in the atlas. * @returns {number|undefined} The image index, or undefined if the image does not exist in the atlas. */ TextureAtlas.prototype.getImageIndex = function (id) { //>>includeStart('debug', pragmas.debug); if (!defined(id)) { throw new DeveloperError("id is required."); } //>>includeEnd('debug'); return this._indexHash[id]; }; /** * Adds an image to the atlas synchronously. If the image is already in the atlas, the atlas is unchanged and * the existing index is used. * * @param {string} id An identifier to detect whether the image already exists in the atlas. * @param {HTMLImageElement|HTMLCanvasElement} image An image or canvas to add to the texture atlas. * @returns {number} The image index. */ TextureAtlas.prototype.addImageSync = function (id, image) { //>>includeStart('debug', pragmas.debug); if (!defined(id)) { throw new DeveloperError("id is required."); } if (!defined(image)) { throw new DeveloperError("image is required."); } //>>includeEnd('debug'); let index = this._indexHash[id]; if (defined(index)) { // we're already aware of this source return index; } index = getIndex(this, image); // store the promise this._idHash[id] = Promise.resolve(index); this._indexHash[id] = index; // but return the value synchronously return index; }; /** * Adds an image to the atlas. If the image is already in the atlas, the atlas is unchanged and * the existing index is used. * * @param {string} id An identifier to detect whether the image already exists in the atlas. * @param {HTMLImageElement|HTMLCanvasElement|string|Resource|Promise|TextureAtlas.CreateImageCallback} image An image or canvas to add to the texture atlas, * or a URL to an Image, or a Promise for an image, or a function that creates an image. * @returns {Promise} A Promise for the image index. */ TextureAtlas.prototype.addImage = function (id, image) { //>>includeStart('debug', pragmas.debug); if (!defined(id)) { throw new DeveloperError("id is required."); } if (!defined(image)) { throw new DeveloperError("image is required."); } //>>includeEnd('debug'); let indexPromise = this._idHash[id]; if (defined(indexPromise)) { // we're already aware of this source return indexPromise; } // not in atlas, create the promise for the index if (typeof image === "function") { // if image is a function, call it image = image(id); //>>includeStart('debug', pragmas.debug); if (!defined(image)) { throw new DeveloperError("image is required."); } //>>includeEnd('debug'); } else if (typeof image === "string" || image instanceof Resource) { // Get a resource const resource = Resource.createIfNeeded(image); image = resource.fetchImage(); } const that = this; indexPromise = Promise.resolve(image).then(function (image) { const index = getIndex(that, image); that._indexHash[id] = index; return index; }); // store the promise this._idHash[id] = indexPromise; return indexPromise; }; /** * Add a sub-region of an existing atlas image as additional image indices. * * @param {string} id The identifier of the existing image. * @param {BoundingRectangle} subRegion An {@link BoundingRectangle} sub-region measured in pixels from the bottom-left. * * @returns {Promise} A Promise for the image index. */ TextureAtlas.prototype.addSubRegion = function (id, subRegion) { //>>includeStart('debug', pragmas.debug); if (!defined(id)) { throw new DeveloperError("id is required."); } if (!defined(subRegion)) { throw new DeveloperError("subRegion is required."); } //>>includeEnd('debug'); const indexPromise = this._idHash[id]; if (!defined(indexPromise)) { throw new RuntimeError(`image with id "${id}" not found in the atlas.`); } const that = this; return Promise.resolve(indexPromise).then(function (index) { if (index === -1) { // the atlas is destroyed return -1; } const atlasWidth = that._texture.width; const atlasHeight = that._texture.height; const baseRegion = that._textureCoordinates[index]; const x = baseRegion.x + subRegion.x / atlasWidth; const y = baseRegion.y + subRegion.y / atlasHeight; const w = subRegion.width / atlasWidth; const h = subRegion.height / atlasHeight; const newIndex = that._textureCoordinates.push(new BoundingRectangle(x, y, w, h)) - 1; that._indexHash[id] = newIndex; that._guid = createGuid(); return newIndex; }); }; /** * Returns true if this object was destroyed; otherwise, false. *

* If this object was destroyed, it should not be used; calling any function other than * isDestroyed will result in a {@link DeveloperError} exception. * * @returns {boolean} True if this object was destroyed; otherwise, false. * * @see TextureAtlas#destroy */ TextureAtlas.prototype.isDestroyed = function () { return false; }; /** * Destroys the WebGL resources held by this object. Destroying an object allows for deterministic * release of WebGL resources, instead of relying on the garbage collector to destroy this object. *

* Once an object is destroyed, it should not be used; calling any function other than * isDestroyed will result in a {@link DeveloperError} exception. Therefore, * assign the return value (undefined) to the object as done in the example. * * @exception {DeveloperError} This object was destroyed, i.e., destroy() was called. * * * @example * atlas = atlas && atlas.destroy(); * * @see TextureAtlas#isDestroyed */ TextureAtlas.prototype.destroy = function () { this._texture = this._texture && this._texture.destroy(); return destroyObject(this); }; /** * A function that creates an image. * @callback TextureAtlas.CreateImageCallback * @param {string} id The identifier of the image to load. * @returns {HTMLImageElement|Promise} The image, or a promise that will resolve to an image. */ export default TextureAtlas;