@cesium/engine
Version:
CesiumJS is a JavaScript library for creating 3D globes and 2D maps in a web browser without a plugin.
803 lines (701 loc) • 23.8 kB
JavaScript
import BoundingRectangle from "../Core/BoundingRectangle.js";
import Cartesian2 from "../Core/Cartesian2.js";
import Check from "../Core/Check.js";
import createGuid from "../Core/createGuid.js";
import Frozen from "../Core/Frozen.js";
import defined from "../Core/defined.js";
import destroyObject from "../Core/destroyObject.js";
import CesiumMath from "../Core/Math.js";
import PixelFormat from "../Core/PixelFormat.js";
import Resource from "../Core/Resource.js";
import RuntimeError from "../Core/RuntimeError.js";
import TexturePacker from "../Core/TexturePacker.js";
import Framebuffer from "./Framebuffer.js";
import Texture from "./Texture.js";
const defaultInitialDimensions = 16;
/**
* A TextureAtlas stores multiple images in one∂ texture and keeps
* track of the texture coordinates for each image. A 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's
* important to check {@link TextureAtlas#guid} before using old values.
*
* @alias TextureAtlas
* @constructor
*
* @param {object} options Object with the following properties:
* @param {PixelFormat} [options.pixelFormat=PixelFormat.RGBA] The pixel format of the texture.
* @param {Sampler} [options.sampler=new Sampler()] Information about how to sample 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 = options ?? Frozen.EMPTY_OBJECT;
const borderWidthInPixels = options.borderWidthInPixels ?? 1.0;
const initialSize =
options.initialSize ??
new Cartesian2(defaultInitialDimensions, defaultInitialDimensions);
//>>includeStart('debug', pragmas.debug);
Check.typeOf.number.greaterThanOrEquals(
"options.borderWidthInPixels",
borderWidthInPixels,
0,
);
Check.typeOf.number.greaterThan("options.initialSize.x", initialSize.x, 0);
Check.typeOf.number.greaterThan("options.initialSize.y", initialSize.y, 0);
//>>includeEnd('debug');
this._pixelFormat = options.pixelFormat ?? PixelFormat.RGBA;
this._sampler = options.sampler;
this._borderWidthInPixels = borderWidthInPixels;
this._initialSize = initialSize;
this._texturePacker = undefined;
this._rectangles = [];
this._subRegions = new Map();
this._guid = createGuid();
this._imagesToAddQueue = [];
this._indexById = new Map();
this._indexPromiseById = new Map();
this._nextIndex = 0;
}
Object.defineProperties(TextureAtlas.prototype, {
/**
* The amount of spacing between adjacent images in pixels.
* @memberof TextureAtlas.prototype
* @type {number}
* @readonly
* @private
*/
borderWidthInPixels: {
get: function () {
return this._borderWidthInPixels;
},
},
/**
* An array of {@link BoundingRectangle} pixel offset and dimensions 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.
* If the index is a subregion of an existing image, thea and y values are specified as offsets relative to the parent.
* The coordinates are in the order that the corresponding images were added to the atlas.
* @memberof TextureAtlas.prototype
* @type {BoundingRectangle[]}
* @readonly
* @private
*/
rectangles: {
get: function () {
return this._rectangles;
},
},
/**
* The texture that all of the images are being written to. The value will be <code>undefined</code> until the first update.
* @memberof TextureAtlas.prototype
* @type {Texture|undefined}
* @readonly
* @private
*/
texture: {
get: function () {
return this._texture;
},
},
/**
* The pixel format of the texture.
* @memberof TextureAtlas.prototype
* @type {PixelFormat}
* @readonly
* @private
*/
pixelFormat: {
get: function () {
return this._pixelFormat;
},
},
/**
* The sampler to use when sampling this texture. If <code>undefined</code>, the default sampler is used.
* @memberof TextureAtlas.prototype
* @type {Sampler|undefined}
* @readonly
* @private
*/
sampler: {
get: function () {
return this._sampler;
},
},
/**
* The number of images in the texture atlas. This value increases
* every time addImage or addImageSubRegion is called.
* Texture coordinates are subject to change if the texture atlas resizes, so it is
* important to check {@link TextureAtlas#guid} before using old values.
* @memberof TextureAtlas.prototype
* @type {number}
* @readonly
* @private
*/
numberOfImages: {
get: function () {
return this._nextIndex;
},
},
/**
* 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}
* @readonly
* @private
*/
guid: {
get: function () {
return this._guid;
},
},
/**
* Returns the size in bytes of the texture.
* @memberof TextureAtlas.prototype
* @type {number}
* @readonly
* @private
*/
sizeInBytes: {
get: function () {
if (!defined(this._texture)) {
return 0;
}
return this._texture.sizeInBytes;
},
},
});
/**
* Get the texture coordinates for reading the associated image in shaders.
* @param {number} index The index of the image region.
* @param {BoundingRectangle} [result] The object into which to store the result.
* @return {BoundingRectangle} The modified result parameter or a new BoundingRectangle instance if one was not provided.
* @private
* @example
* const index = await atlas.addImage("myImage", image);
* const rectangle = atlas.computeTextureCoordinates(index);
* BoundingRectangle.pack(rectangle, bufferView);
*/
TextureAtlas.prototype.computeTextureCoordinates = function (index, result) {
//>>includeStart('debug', pragmas.debug);
Check.typeOf.number.greaterThanOrEquals("index", index, 0);
//>>includeEnd('debug');
const texture = this._texture;
const rectangle = this._rectangles[index];
if (!defined(result)) {
result = new BoundingRectangle();
}
if (!defined(rectangle)) {
result.x = 0;
result.y = 0;
result.width = 0;
result.height = 0;
return result;
}
const atlasWidth = texture.width;
const atlasHeight = texture.height;
const width = rectangle.width;
const height = rectangle.height;
let x = rectangle.x;
let y = rectangle.y;
const parentIndex = this._subRegions.get(index);
if (defined(parentIndex)) {
const parentRectangle = this._rectangles[parentIndex];
x += parentRectangle.x;
y += parentRectangle.y;
}
result.x = x / atlasWidth;
result.y = y / atlasHeight;
result.width = width / atlasWidth;
result.height = height / atlasHeight;
return result;
};
/**
* Perform a WebGL texture copy for each existing image from its previous packed position to its new packed position in the new texture.
* @param {Context} context The rendering context
* @param {number} width The pixel width of the texture
* @param {number} height The pixel height of the texture
* @param {BoundingRectangle[]} rectangles The packed bounding rectangles for the reszied texture
* @param {number} queueOffset Index of the last queued item that was successfully packed
* @private
*/
TextureAtlas.prototype._copyFromTexture = function (
context,
width,
height,
rectangles,
) {
const pixelFormat = this._pixelFormat;
const sampler = this._sampler;
const newTexture = new Texture({
context,
height,
width,
pixelFormat,
sampler,
});
const gl = context._gl;
const target = newTexture._textureTarget;
const oldTexture = this._texture;
const framebuffer = new Framebuffer({
context,
colorTextures: [oldTexture],
destroyAttachments: false,
});
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(target, newTexture._texture);
framebuffer._bind();
// Copy any textures from the old atlas to its new position in the new atlas
const oldRectangles = this.rectangles;
const subRegions = this._subRegions;
for (let index = 0; index < oldRectangles.length; ++index) {
const rectangle = rectangles[index];
const frameBufferOffset = oldRectangles[index];
if (
!defined(rectangle) ||
!defined(frameBufferOffset) ||
defined(subRegions.get(index)) // The rectangle corresponds to a subregion of a parent image
) {
continue;
}
const { x, y, width, height } = rectangle;
gl.copyTexSubImage2D(
target,
0,
x,
y,
frameBufferOffset.x,
frameBufferOffset.y,
width,
height,
);
}
gl.bindTexture(target, null);
newTexture._initialized = true;
framebuffer._unBind();
framebuffer.destroy();
return newTexture;
};
/**
* Recreates the texture atlas texture with new dimensions and repacks images as needed.
* @param {Context} context The rendering context
* @param {number} [queueOffset = 0] Index of the last queued item that was successfully packed
* @private
*/
TextureAtlas.prototype._resize = function (context, queueOffset = 0) {
const borderPadding = this._borderWidthInPixels;
const oldRectangles = this._rectangles;
const queue = this._imagesToAddQueue;
const oldTexture = this._texture;
let width = oldTexture.width;
let height = oldTexture.height;
// Get the rectangles (width and height) of the current set of images,
// ignoring the subregions, which don't get packed
const subRegions = this._subRegions;
const toPack = oldRectangles
.map((image, index) => {
return new AddImageRequest({ index, image });
})
.filter(
(request, index) =>
defined(request.image) && !defined(subRegions.get(index)),
);
// Add the new set of images
let maxWidth = 0;
let maxHeight = 0;
let areaQueued = 0;
for (let i = queueOffset; i < queue.length; ++i) {
const { width, height } = queue[i].image;
maxWidth = Math.max(maxWidth, width);
maxHeight = Math.max(maxHeight, height);
areaQueued += width * height;
toPack.push(queue[i]);
}
// At minimum, the texture will need to scale to accommodate the largest width and height
width = Math.max(maxWidth, width);
height = Math.max(maxHeight, height);
if (!context.webgl2) {
width = CesiumMath.nextPowerOfTwo(width);
height = CesiumMath.nextPowerOfTwo(height);
}
// Determine by what factor the texture need to be scaled by at minimum
const areaDifference = areaQueued;
let scalingFactor = 1.0;
while (areaDifference / width / height >= 1.0) {
scalingFactor *= 2.0;
// Resize by one dimension
if (width > height) {
height *= scalingFactor;
} else {
width *= scalingFactor;
}
}
toPack.sort(
({ image: imageA }, { image: imageB }) =>
imageB.height * imageB.width - imageA.height * imageA.width,
);
const newRectangles = new Array(this._nextIndex);
for (const index of this._subRegions.keys()) {
// Subregions are specified relative to their parents,
// so we can copy them directly
if (defined(subRegions.get(index))) {
newRectangles[index] = oldRectangles[index];
}
}
let texturePacker,
packed = false;
while (!packed) {
texturePacker = new TexturePacker({ height, width, borderPadding });
let i;
for (i = 0; i < toPack.length; ++i) {
const { index, image } = toPack[i];
if (!defined(image)) {
continue;
}
const repackedNode = texturePacker.pack(index, image);
if (!defined(repackedNode)) {
// Could not fit everything into the new texture.
// Scale texture size and try again
if (width > height) {
// Resize height
height *= 2.0;
} else {
// Resize width
width *= 2.0;
}
break;
}
newRectangles[index] = repackedNode.rectangle;
}
packed = i === toPack.length;
}
this._texturePacker = texturePacker;
this._texture = this._copyFromTexture(context, width, height, newRectangles);
oldTexture.destroy();
this._rectangles = newRectangles;
this._guid = createGuid();
};
/**
* Return the index of the image region for the specified ID. 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.
* @private
*/
TextureAtlas.prototype.getImageIndex = function (id) {
//>>includeStart('debug', pragmas.debug);
Check.typeOf.string("id", id);
//>>includeEnd('debug');
return this._indexById.get(id);
};
/**
* Copy image data into the underlying texture atlas.
* @param {AddImageRequest} imageRequest The data needed to resolve the call to addImage in the queue
* @private
*/
TextureAtlas.prototype._copyImageToTexture = function ({
index,
image,
resolve,
reject,
}) {
const texture = this._texture;
const rectangle = this._rectangles[index];
try {
texture.copyFrom({
source: image,
xOffset: rectangle.x,
yOffset: rectangle.y,
});
if (defined(resolve)) {
resolve(index);
}
} catch (e) {
if (defined(reject)) {
reject(e);
return;
}
}
};
/**
* Info needed to add a queued image to the texture atlas when update operatons are executed, typically at the end of a frame.
* @constructor
* @private
* @param {object} options Object with the following properties:
* @param {number} options.index An identifier
* @param {TexturePacker.PackableObject} options.image An object, such as an <code>Image</code> with <code>width</code> and <code>height</code> properties in pixels
* @param {function} [options.resolve] The promise resolver
* @param {function} [options.reject] The promise rejecter
*/
function AddImageRequest({ index, image, resolve, reject }) {
this.index = index;
this.image = image;
this.resolve = resolve;
this.reject = reject;
this.rectangle = undefined;
}
/**
* Adds an image to the queue for this frame.
* The image will be copied to the texture at the end of the frame, resizing the texture if needed.
*
* @private
* @param {number} index An identifier
* @param {TexturePacker.PackableObject} image An object, such as an <code>Image</code> with <code>width</code> and <code>height</code> properties in pixels
* @returns {Promise<number>} Promise which resolves to the image index once the image has been added, or rejects if there was an error. The promise resolves to <code>-1</code> if the texture atlas is destoyed in the interim.
*/
TextureAtlas.prototype._addImage = function (index, image) {
//>>includeStart('debug', pragmas.debug);
Check.typeOf.number.greaterThanOrEquals("index", index, 0);
Check.defined("image", image);
//>>includeEnd('debug');
return new Promise((resolve, reject) => {
this._imagesToAddQueue.push(
new AddImageRequest({
index,
image,
resolve,
reject,
}),
);
this._imagesToAddQueue.sort(
({ image: imageA }, { image: imageB }) =>
imageB.height * imageB.width - imageA.height * imageA.width,
);
});
};
/**
* Process the image queue for this frame, copying to the texture atlas and resizing the texture as needed.
* @private
* @param {Context} context The rendering context
* @return {boolean} true if the texture was updated this frame
*/
TextureAtlas.prototype._processImageQueue = function (context) {
const queue = this._imagesToAddQueue;
if (queue.length === 0) {
return false;
}
this._rectangles.length = this._nextIndex;
let i, error;
for (i = 0; i < queue.length; ++i) {
const imageRequest = queue[i];
const { image, index } = imageRequest;
const node = this._texturePacker.pack(index, image);
if (!defined(node)) {
// Atlas cannot fit all images in the queue
// Bail early and resize
try {
this._resize(context, i);
} catch (e) {
error = e;
if (defined(imageRequest.reject)) {
imageRequest.reject(error);
}
}
break;
}
this._rectangles[index] = node.rectangle;
}
if (defined(error)) {
for (i = i + 1; i < queue.length; ++i) {
const { resolve } = queue[i];
if (defined(resolve)) {
resolve(-1);
}
}
queue.length = 0;
return false;
}
for (let i = 0; i < queue.length; ++i) {
this._copyImageToTexture(queue[i]);
}
queue.length = 0;
return true;
};
/**
* Processes any updates queued this frame, and updates rendering resources accordingly. Call before or after a frame has been rendered to avoid any race conditions for any dependant render commands.
* @private
* @param {Context} context The rendering context
* @return {boolean} true if rendering resources were updated.
*/
TextureAtlas.prototype.update = function (context) {
if (!defined(this._texture)) {
const width = this._initialSize.x;
const height = this._initialSize.y;
const pixelFormat = this._pixelFormat;
const sampler = this._sampler;
const borderPadding = this._borderWidthInPixels;
this._texture = new Texture({
context,
width,
height,
pixelFormat,
sampler,
});
this._texturePacker = new TexturePacker({
height,
width,
borderPadding,
});
}
return this._processImageQueue(context);
};
async function resolveImage(image, id) {
if (typeof image === "function") {
image = image(id);
}
if (typeof image === "string" || image instanceof Resource) {
// Fetch the resource
const resource = Resource.createIfNeeded(image);
image = resource.fetchImage();
}
return image;
}
/**
* Adds an image to the atlas. If the image is already in the atlas, the atlas is unchanged and
* the existing index is used.
* @private
* @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<number>} A Promise that resolves to the image region index. -1 is returned if resouces are in the process of being destroyed.
*/
TextureAtlas.prototype.addImage = function (id, image) {
//>>includeStart('debug', pragmas.debug);
Check.typeOf.string("id", id);
Check.defined("image", image);
//>>includeEnd('debug');
let promise = this._indexPromiseById.get(id);
if (defined(promise)) {
// This image has already been added
return promise;
}
const index = this._nextIndex++;
this._indexById.set(id, index);
const resolveAndAddImage = async () => {
image = await resolveImage(image, id);
//>>includeStart('debug', pragmas.debug);
Check.defined("image", image);
//>>includeEnd('debug');
if (this.isDestroyed() || !defined(image)) {
return -1;
}
return this._addImage(index, image);
};
promise = resolveAndAddImage();
this._indexPromiseById.set(id, promise);
return promise;
};
/**
* Add a sub-region of an existing atlas image as additional image indices.
* @private
* @param {string} id The identifier of the existing image.
* @param {BoundingRectangle} subRegion An {@link BoundingRectangle} defining a region of an existing image, measured in pixels from the bottom-left of the image.
* @returns {Promise<number>} A Promise that resolves to the image region index. -1 is returned if resouces are in the process of being destroyed.
*/
TextureAtlas.prototype.addImageSubRegion = function (id, subRegion) {
//>>includeStart('debug', pragmas.debug);
Check.typeOf.string("id", id);
Check.defined("subRegion", subRegion);
//>>includeEnd('debug');
const imageIndex = this._indexById.get(id);
if (!defined(imageIndex)) {
throw new RuntimeError(`image with id "${id}" not found in the atlas.`);
}
const indexPromise = this._indexPromiseById.get(id);
for (const [index, parentIndex] of this._subRegions.entries()) {
if (imageIndex === parentIndex) {
const boundingRegion = this._rectangles[index];
if (boundingRegion.equals(subRegion)) {
// The subregion is already being tracked
return indexPromise.then((resolvedImageIndex) => {
if (resolvedImageIndex === -1) {
// The atlas has been destroyed
return -1;
}
return index;
});
}
}
}
const index = this._nextIndex++;
this._subRegions.set(index, imageIndex);
this._rectangles[index] = subRegion.clone();
return indexPromise.then((imageIndex) => {
if (imageIndex === -1) {
// The atlas has been destroyed
return -1;
}
const rectangle = this._rectangles[imageIndex];
//>>includeStart('debug', pragmas.debug);
Check.typeOf.number.lessThanOrEquals(
"subRegion.x",
subRegion.x,
rectangle.width,
);
Check.typeOf.number.lessThanOrEquals(
"subRegion.x + subRegion.width",
subRegion.x + subRegion.width,
rectangle.width,
);
Check.typeOf.number.lessThanOrEquals(
"subRegion.y",
subRegion.y,
rectangle.height,
);
Check.typeOf.number.lessThanOrEquals(
"subRegion.y + subRegion.height",
subRegion.y + subRegion.height,
rectangle.height,
);
//>>includeEnd('debug');
return index;
});
};
/**
* Returns true if this object was destroyed; otherwise, false.
* <br /><br />
* If this object was destroyed, it should not be used; calling any function other than
* <code>isDestroyed</code> will result in a {@link DeveloperError} exception.
* @private
* @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.
* <br /><br />
* Once an object is destroyed, it should not be used; calling any function other than
* <code>isDestroyed</code> will result in a {@link DeveloperError} exception. Therefore,
* assign the return value (<code>undefined</code>) to the object as done in the example.
* @private
* @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();
this._imagesToAddQueue.forEach(({ resolve }) => {
if (defined(resolve)) {
resolve(-1);
}
});
return destroyObject(this);
};
/**
* A function that creates an image.
* @private
* @callback TextureAtlas.CreateImageCallback
* @param {string} id The identifier of the image to load.
* @returns {HTMLImageElement|Promise<HTMLImageElement>} The image, or a promise that will resolve to an image.
*/
export default TextureAtlas;