/*global define*/ define([ '../Core/BoundingRectangle', '../Core/Cartesian2', '../Core/createGuid', '../Core/defaultValue', '../Core/defined', '../Core/defineProperties', '../Core/destroyObject', '../Core/DeveloperError', '../Core/loadImage', '../Core/PixelFormat', '../Core/RuntimeError', '../ThirdParty/when' ], function( BoundingRectangle, Cartesian2, createGuid, defaultValue, defined, defineProperties, destroyObject, DeveloperError, loadImage, PixelFormat, RuntimeError, when) { "use strict"; // 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; } var 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 */ var TextureAtlas = function(options) { options = defaultValue(options, defaultValue.EMPTY_OBJECT); var borderWidthInPixels = defaultValue(options.borderWidthInPixels, 1.0); var 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 = {}; // Create initial texture and root. this._texture = this._context.createTexture2D({ width : initialSize.x, height : initialSize.y, pixelFormat : this._pixelFormat }); this._root = new TextureAtlasNode(new Cartesian2(), new Cartesian2(initialSize.x, initialSize.y)); }; 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() { 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) { var numImages = textureAtlas.numberOfImages; var scalingFactor = 2.0; if (numImages > 0) { var oldAtlasWidth = textureAtlas._texture.width; var oldAtlasHeight = textureAtlas._texture.height; var atlasWidth = scalingFactor * (oldAtlasWidth + image.width + textureAtlas._borderWidthInPixels); var atlasHeight = scalingFactor * (oldAtlasHeight + image.height + textureAtlas._borderWidthInPixels); var widthRatio = oldAtlasWidth / atlasWidth; var heightRatio = oldAtlasHeight / atlasHeight; // Create new node structure, putting the old root node in the bottom left. var nodeBottomRight = new TextureAtlasNode(new Cartesian2(oldAtlasWidth + textureAtlas._borderWidthInPixels, 0.0), new Cartesian2(atlasWidth, oldAtlasHeight)); var nodeBottomHalf = new TextureAtlasNode(new Cartesian2(), new Cartesian2(atlasWidth, oldAtlasHeight), textureAtlas._root, nodeBottomRight); var nodeTopHalf = new TextureAtlasNode(new Cartesian2(0.0, oldAtlasHeight + textureAtlas._borderWidthInPixels), new Cartesian2(atlasWidth, atlasHeight)); var nodeMain = new TextureAtlasNode(new Cartesian2(), new Cartesian2(atlasWidth, atlasHeight), nodeBottomHalf, nodeTopHalf); textureAtlas._root = nodeMain; // Resize texture coordinates. for (var i = 0; i < textureAtlas._textureCoordinates.length; i++) { var texCoord = textureAtlas._textureCoordinates[i]; if (defined(texCoord)) { texCoord.x *= widthRatio; texCoord.y *= heightRatio; texCoord.width *= widthRatio; texCoord.height *= heightRatio; } } // Copy larger texture. var newTexture = textureAtlas._context.createTexture2D({ width : atlasWidth, height : atlasHeight, pixelFormat : textureAtlas._pixelFormat }); // Copy old texture into new using an fbo. var framebuffer = textureAtlas._context.createFramebuffer({ colorTextures : [textureAtlas._texture] }); framebuffer._bind(); newTexture.copyFromFramebuffer(0, 0, 0, 0, oldAtlasWidth, oldAtlasHeight); framebuffer._unBind(); framebuffer.destroy(); textureAtlas._texture = newTexture; } else { // First image exceeds initialSize var initialWidth = scalingFactor * (image.width + textureAtlas._borderWidthInPixels); var initialHeight = scalingFactor * (image.height + textureAtlas._borderWidthInPixels); textureAtlas._texture = textureAtlas._texture && textureAtlas._texture.destroy(); textureAtlas._texture = textureAtlas._context.createTexture2D({ width : initialWidth, height : initialHeight, pixelFormat : textureAtlas._pixelFormat }); textureAtlas._root = new TextureAtlasNode(new Cartesian2(), 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; } var nodeWidth = node.topRight.x - node.bottomLeft.x; var nodeHeight = node.topRight.y - node.bottomLeft.y; var widthDifference = nodeWidth - image.width; var 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. var 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. var 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) { var 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 var atlasWidth = textureAtlas._texture.width; var atlasHeight = textureAtlas._texture.height; var nodeWidth = node.topRight.x - node.bottomLeft.x; var nodeHeight = node.topRight.y - node.bottomLeft.y; var x = node.bottomLeft.x / atlasWidth; var y = node.bottomLeft.y / atlasHeight; var w = nodeWidth / atlasWidth; var h = nodeHeight / atlasHeight; textureAtlas._textureCoordinates[index] = new BoundingRectangle(x, y, w, h); textureAtlas._texture.copyFrom(image, node.bottomLeft.x, node.bottomLeft.y); } else { // No node found, must resize the texture atlas. resizeAtlas(textureAtlas, image); addImage(textureAtlas, image, index); } textureAtlas._guid = createGuid(); } /** * 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 {Image|Canvas|String|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'); var 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') { // if image is a string, load it as an image image = loadImage(image); } var that = this; indexPromise = when(image, function(image) { if (that.isDestroyed()) { return -1; } var index = that.numberOfImages; addImage(that, image, 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'); var indexPromise = this._idHash[id]; if (!defined(indexPromise)) { throw new RuntimeError('image with id "' + id + '" not found in the atlas.'); } var that = this; return when(indexPromise, function(index) { if (index === -1) { // the atlas is destroyed return -1; } var atlasWidth = that._texture.width; var atlasHeight = that._texture.height; var numImages = that.numberOfImages; var baseRegion = that._textureCoordinates[index]; var x = baseRegion.x + (subRegion.x / atlasWidth); var y = baseRegion.y + (subRegion.y / atlasHeight); var w = subRegion.width / atlasWidth; var h = subRegion.height / atlasHeight; that._textureCoordinates.push(new BoundingRectangle(x, y, w, h)); that._guid = createGuid(); return numImages; }); }; /** * 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. * * @returns {undefined} * * @exception {DeveloperError} This object was destroyed, i.e., destroy() was called. * * @see TextureAtlas#isDestroyed * * @example * atlas = atlas && atlas.destroy(); */ 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 {Image|Promise} The image, or a promise that will resolve to an image. */ return TextureAtlas; });