@luma.gl/engine
Version:
3D Engine Components for luma.gl
558 lines • 25.1 kB
JavaScript
// luma.gl, MIT license
// Copyright (c) vis.gl contributors
import { Buffer, Texture, Sampler, log } from '@luma.gl/core';
// import {loadImageBitmap} from '../application-utils/load-file';
import { uid } from "../utils/uid.js";
import { TEXTURE_CUBE_FACE_MAP,
// Helpers
getTextureSizeFromData, resolveTextureImageFormat, getTexture1DSubresources, getTexture2DSubresources, getTexture3DSubresources, getTextureCubeSubresources, getTextureArraySubresources, getTextureCubeArraySubresources } from "./texture-data.js";
/**
* Dynamic Textures
*
* - Mipmaps - DynamicTexture can generate mipmaps for textures (WebGPU does not provide built-in mipmap generation).
*
* - Texture initialization and updates - complex textures (2d array textures, cube textures, 3d textures) need multiple images
* `DynamicTexture` provides an API that makes it easy to provide the required data.
*
* - Texture resizing - Textures are immutable in WebGPU, meaning that they cannot be resized after creation.
* DynamicTexture provides a `resize()` method that internally creates a new texture with the same parameters
* but a different size.
*
* - Async image data initialization - It is often very convenient to be able to initialize textures with promises
* returned by image or data loading functions, as it allows a callback-free linear style of programming.
*
* @note GPU Textures are quite complex objects, with many subresources and modes of usage.
* The `DynamicTexture` class allows luma.gl to provide some support for working with textures
* without accumulating excessive complexity in the core Texture class which is designed as an immutable nature of GPU resource.
*/
export class DynamicTexture {
device;
id;
/** Props with defaults resolved (except `data` which is processed separately) */
props;
/** Created resources */
_texture = null;
_sampler = null;
_view = null;
/** Ready when GPU texture has been created and data (if any) uploaded */
ready;
isReady = false;
destroyed = false;
resolveReady = () => { };
rejectReady = () => { };
get texture() {
if (!this._texture)
throw new Error('Texture not initialized yet');
return this._texture;
}
get sampler() {
if (!this._sampler)
throw new Error('Sampler not initialized yet');
return this._sampler;
}
get view() {
if (!this._view)
throw new Error('View not initialized yet');
return this._view;
}
get [Symbol.toStringTag]() {
return 'DynamicTexture';
}
toString() {
const width = this._texture?.width ?? this.props.width ?? '?';
const height = this._texture?.height ?? this.props.height ?? '?';
return `DynamicTexture:"${this.id}":${width}x${height}px:(${this.isReady ? 'ready' : 'loading...'})`;
}
constructor(device, props) {
this.device = device;
const id = uid('dynamic-texture');
// NOTE: We avoid holding on to data to make sure it can be garbage collected.
const originalPropsWithAsyncData = props;
this.props = { ...DynamicTexture.defaultProps, id, ...props, data: null };
this.id = this.props.id;
this.ready = new Promise((resolve, reject) => {
this.resolveReady = resolve;
this.rejectReady = reject;
});
this.initAsync(originalPropsWithAsyncData);
}
/** @note Fire and forget; caller can await `ready` */
async initAsync(originalPropsWithAsyncData) {
try {
// TODO - Accept URL string for 2D: turn into ExternalImage promise
// const dataProps =
// typeof props.data === 'string' && (props.dimension ?? '2d') === '2d'
// ? ({dimension: '2d', data: loadImageBitmap(props.data)} as const)
// : {};
const propsWithSyncData = await this._loadAllData(originalPropsWithAsyncData);
this._checkNotDestroyed();
const subresources = propsWithSyncData.data
? getTextureSubresources({
...propsWithSyncData,
width: originalPropsWithAsyncData.width,
height: originalPropsWithAsyncData.height,
format: originalPropsWithAsyncData.format
})
: [];
const userProvidedFormat = 'format' in originalPropsWithAsyncData && originalPropsWithAsyncData.format !== undefined;
const userProvidedUsage = 'usage' in originalPropsWithAsyncData && originalPropsWithAsyncData.usage !== undefined;
// Deduce size when not explicitly provided
// TODO - what about depth?
const deduceSize = () => {
if (this.props.width && this.props.height) {
return { width: this.props.width, height: this.props.height };
}
const size = getTextureSizeFromData(propsWithSyncData);
if (size) {
return size;
}
return { width: this.props.width || 1, height: this.props.height || 1 };
};
const size = deduceSize();
if (!size || size.width <= 0 || size.height <= 0) {
throw new Error(`${this} size could not be determined or was zero`);
}
// Normalize caller-provided subresources into one validated mip chain description.
const textureData = analyzeTextureSubresources(this.device, subresources, size, {
format: userProvidedFormat ? originalPropsWithAsyncData.format : undefined
});
const resolvedFormat = textureData.format ?? this.props.format;
// Create a minimal TextureProps and validate via `satisfies`
const baseTextureProps = {
...this.props,
...size,
format: resolvedFormat,
mipLevels: 1, // temporary; updated below
data: undefined
};
if (this.device.isTextureFormatCompressed(resolvedFormat) && !userProvidedUsage) {
baseTextureProps.usage = Texture.SAMPLE | Texture.COPY_DST;
}
// Explicit mip arrays take ownership of the mip chain; otherwise we may auto-generate it.
const shouldGenerateMipmaps = this.props.mipmaps &&
!textureData.hasExplicitMipChain &&
!this.device.isTextureFormatCompressed(resolvedFormat);
if (this.device.type === 'webgpu' && shouldGenerateMipmaps) {
const requiredUsage = this.props.dimension === '3d'
? Texture.SAMPLE | Texture.STORAGE | Texture.COPY_DST | Texture.COPY_SRC
: Texture.SAMPLE | Texture.RENDER | Texture.COPY_DST | Texture.COPY_SRC;
baseTextureProps.usage |= requiredUsage;
}
// Compute mip levels (auto clamps to max)
const maxMips = this.device.getMipLevelCount(baseTextureProps.width, baseTextureProps.height);
const desired = textureData.hasExplicitMipChain
? textureData.mipLevels
: this.props.mipLevels === 'auto'
? maxMips
: Math.max(1, Math.min(maxMips, this.props.mipLevels ?? 1));
const finalTextureProps = { ...baseTextureProps, mipLevels: desired };
this._texture = this.device.createTexture(finalTextureProps);
this._sampler = this.texture.sampler;
this._view = this.texture.view;
// Upload data if provided
if (textureData.subresources.length) {
this._setTextureSubresources(textureData.subresources);
}
if (this.props.mipmaps && !textureData.hasExplicitMipChain && !shouldGenerateMipmaps) {
log.warn(`${this} skipping auto-generated mipmaps for compressed texture format`)();
}
if (shouldGenerateMipmaps) {
this.generateMipmaps();
}
this.isReady = true;
this.resolveReady(this.texture);
log.info(0, `${this} created`)();
}
catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
this.rejectReady(err);
}
}
destroy() {
if (this._texture) {
this._texture.destroy();
this._texture = null;
this._sampler = null;
this._view = null;
}
this.destroyed = true;
}
generateMipmaps() {
if (this.device.type === 'webgl') {
this.texture.generateMipmapsWebGL();
}
else if (this.device.type === 'webgpu') {
this.device.generateMipmapsWebGPU(this.texture);
}
else {
log.warn(`${this} mipmaps not supported on ${this.device.type}`);
}
}
/** Set sampler or create one from props */
setSampler(sampler = {}) {
this._checkReady();
const s = sampler instanceof Sampler ? sampler : this.device.createSampler(sampler);
this.texture.setSampler(s);
this._sampler = s;
}
/**
* Copies texture contents into a GPU buffer and waits until the copy is complete.
* The caller owns the returned buffer and must destroy it when finished.
*/
async readBuffer(options = {}) {
if (!this.isReady) {
await this.ready;
}
const width = options.width ?? this.texture.width;
const height = options.height ?? this.texture.height;
const depthOrArrayLayers = options.depthOrArrayLayers ?? this.texture.depth;
const layout = this.texture.computeMemoryLayout({ width, height, depthOrArrayLayers });
const buffer = this.device.createBuffer({
byteLength: layout.byteLength,
usage: Buffer.COPY_DST | Buffer.MAP_READ
});
this.texture.readBuffer({
...options,
width,
height,
depthOrArrayLayers
}, buffer);
const fence = this.device.createFence();
await fence.signaled;
fence.destroy();
return buffer;
}
/** Reads texture contents back to CPU memory. */
async readAsync(options = {}) {
if (!this.isReady) {
await this.ready;
}
const width = options.width ?? this.texture.width;
const height = options.height ?? this.texture.height;
const depthOrArrayLayers = options.depthOrArrayLayers ?? this.texture.depth;
const layout = this.texture.computeMemoryLayout({ width, height, depthOrArrayLayers });
const buffer = await this.readBuffer(options);
const data = await buffer.readAsync(0, layout.byteLength);
buffer.destroy();
return data.buffer;
}
/**
* Resize by cloning the underlying immutable texture.
* Does not copy contents; caller may need to re-upload and/or regenerate mips.
*/
resize(size) {
this._checkReady();
if (size.width === this.texture.width && size.height === this.texture.height) {
return false;
}
const prev = this.texture;
this._texture = prev.clone(size);
this._sampler = this.texture.sampler;
this._view = this.texture.view;
prev.destroy();
log.info(`${this} resized`);
return true;
}
/** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
getCubeFaceIndex(face) {
const index = TEXTURE_CUBE_FACE_MAP[face];
if (index === undefined)
throw new Error(`Invalid cube face: ${face}`);
return index;
}
/** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
getCubeArrayFaceIndex(cubeIndex, face) {
return 6 * cubeIndex + this.getCubeFaceIndex(face);
}
/** @note experimental: Set multiple mip levels (1D) */
setTexture1DData(data) {
this._checkReady();
if (this.texture.props.dimension !== '1d') {
throw new Error(`${this} is not 1d`);
}
const subresources = getTexture1DSubresources(data);
this._setTextureSubresources(subresources);
}
/** @note experimental: Set multiple mip levels (2D), optionally at `z`, slice (depth/array level) index */
setTexture2DData(lodData, z = 0) {
this._checkReady();
if (this.texture.props.dimension !== '2d') {
throw new Error(`${this} is not 2d`);
}
const subresources = getTexture2DSubresources(z, lodData);
this._setTextureSubresources(subresources);
}
/** 3D: multiple depth slices, each may carry multiple mip levels */
setTexture3DData(data) {
if (this.texture.props.dimension !== '3d') {
throw new Error(`${this} is not 3d`);
}
const subresources = getTexture3DSubresources(data);
this._setTextureSubresources(subresources);
}
/** 2D array: multiple layers, each may carry multiple mip levels */
setTextureArrayData(data) {
if (this.texture.props.dimension !== '2d-array') {
throw new Error(`${this} is not 2d-array`);
}
const subresources = getTextureArraySubresources(data);
this._setTextureSubresources(subresources);
}
/** Cube: 6 faces, each may carry multiple mip levels */
setTextureCubeData(data) {
if (this.texture.props.dimension !== 'cube') {
throw new Error(`${this} is not cube`);
}
const subresources = getTextureCubeSubresources(data);
this._setTextureSubresources(subresources);
}
/** Cube array: multiple cubes (faces×layers), each face may carry multiple mips */
setTextureCubeArrayData(data) {
if (this.texture.props.dimension !== 'cube-array') {
throw new Error(`${this} is not cube-array`);
}
const subresources = getTextureCubeArraySubresources(data);
this._setTextureSubresources(subresources);
}
/** Sets multiple mip levels on different `z` slices (depth/array index) */
_setTextureSubresources(subresources) {
// If user supplied multiple mip levels, warn if auto-mips also requested
// if (lodArray.length > 1 && this.props.mipmaps !== false) {
// log.warn(
// `Texture ${this.id}: provided multiple LODs and also requested mipmap generation.`
// )();
// }
for (const subresource of subresources) {
const { z, mipLevel } = subresource;
switch (subresource.type) {
case 'external-image':
const { image, flipY } = subresource;
this.texture.copyExternalImage({ image, z, mipLevel, flipY });
break;
case 'texture-data':
const { data, textureFormat } = subresource;
if (textureFormat && textureFormat !== this.texture.format) {
throw new Error(`${this} mip level ${mipLevel} uses format "${textureFormat}" but texture format is "${this.texture.format}"`);
}
this.texture.writeData(data.data, {
x: 0,
y: 0,
z,
width: data.width,
height: data.height,
depthOrArrayLayers: 1,
mipLevel
});
break;
default:
throw new Error('Unsupported 2D mip-level payload');
}
}
}
// ------------------ helpers ------------------
/** Recursively resolve all promises in data structures */
async _loadAllData(props) {
const syncData = await awaitAllPromises(props.data);
const dimension = (props.dimension ?? '2d');
return { dimension, data: syncData ?? null };
}
_checkNotDestroyed() {
if (this.destroyed) {
log.warn(`${this} already destroyed`);
}
}
_checkReady() {
if (!this.isReady) {
log.warn(`${this} Cannot perform this operation before ready`);
}
}
static defaultProps = {
...Texture.defaultProps,
dimension: '2d',
data: null,
mipmaps: false
};
}
// Flatten dimension-specific texture data into one list of uploadable subresources.
function getTextureSubresources(props) {
if (!props.data) {
return [];
}
const baseLevelSize = props.width && props.height ? { width: props.width, height: props.height } : undefined;
const textureFormat = 'format' in props ? props.format : undefined;
switch (props.dimension) {
case '1d':
return getTexture1DSubresources(props.data);
case '2d':
return getTexture2DSubresources(0, props.data, baseLevelSize, textureFormat);
case '3d':
return getTexture3DSubresources(props.data);
case '2d-array':
return getTextureArraySubresources(props.data);
case 'cube':
return getTextureCubeSubresources(props.data);
case 'cube-array':
return getTextureCubeArraySubresources(props.data);
default:
throw new Error(`Unhandled dimension ${props.dimension}`);
}
}
// Resolve a consistent texture format and the longest mip chain valid across all slices.
function analyzeTextureSubresources(device, subresources, size, options) {
if (subresources.length === 0) {
return {
subresources,
mipLevels: 1,
format: options.format,
hasExplicitMipChain: false
};
}
const groupedSubresources = new Map();
for (const subresource of subresources) {
const group = groupedSubresources.get(subresource.z) ?? [];
group.push(subresource);
groupedSubresources.set(subresource.z, group);
}
const hasExplicitMipChain = subresources.some(subresource => subresource.mipLevel > 0);
let resolvedFormat = options.format;
let resolvedMipLevels = Number.POSITIVE_INFINITY;
const validSubresources = [];
for (const [z, sliceSubresources] of groupedSubresources) {
// Validate each slice independently, then keep only the mip levels that are valid everywhere.
const sortedSubresources = [...sliceSubresources].sort((left, right) => left.mipLevel - right.mipLevel);
const baseLevel = sortedSubresources[0];
if (!baseLevel || baseLevel.mipLevel !== 0) {
throw new Error(`DynamicTexture: slice ${z} is missing mip level 0`);
}
const baseSize = getTextureSubresourceSize(device, baseLevel);
if (baseSize.width !== size.width || baseSize.height !== size.height) {
throw new Error(`DynamicTexture: slice ${z} base level dimensions ${baseSize.width}x${baseSize.height} do not match expected ${size.width}x${size.height}`);
}
const baseFormat = getTextureSubresourceFormat(baseLevel);
if (baseFormat) {
if (resolvedFormat && resolvedFormat !== baseFormat) {
throw new Error(`DynamicTexture: slice ${z} base level format "${baseFormat}" does not match texture format "${resolvedFormat}"`);
}
resolvedFormat = baseFormat;
}
const mipLevelLimit = resolvedFormat && device.isTextureFormatCompressed(resolvedFormat)
? // Block-compressed formats cannot have mips smaller than a single compression block.
getMaxCompressedMipLevels(device, baseSize.width, baseSize.height, resolvedFormat)
: device.getMipLevelCount(baseSize.width, baseSize.height);
let validMipLevelsForSlice = 0;
for (let expectedMipLevel = 0; expectedMipLevel < sortedSubresources.length; expectedMipLevel++) {
const subresource = sortedSubresources[expectedMipLevel];
// Stop at the first gap so callers can provide extra trailing data without breaking creation.
if (!subresource || subresource.mipLevel !== expectedMipLevel) {
break;
}
if (expectedMipLevel >= mipLevelLimit) {
break;
}
const subresourceSize = getTextureSubresourceSize(device, subresource);
const expectedWidth = Math.max(1, baseSize.width >> expectedMipLevel);
const expectedHeight = Math.max(1, baseSize.height >> expectedMipLevel);
if (subresourceSize.width !== expectedWidth || subresourceSize.height !== expectedHeight) {
break;
}
const subresourceFormat = getTextureSubresourceFormat(subresource);
if (subresourceFormat) {
if (!resolvedFormat) {
resolvedFormat = subresourceFormat;
}
// Later mip levels must stay on the same format as the validated base level.
if (subresourceFormat !== resolvedFormat) {
break;
}
}
validMipLevelsForSlice++;
validSubresources.push(subresource);
}
resolvedMipLevels = Math.min(resolvedMipLevels, validMipLevelsForSlice);
}
const mipLevels = Number.isFinite(resolvedMipLevels) ? Math.max(1, resolvedMipLevels) : 1;
return {
// Keep every slice trimmed to the same mip count so the texture shape stays internally consistent.
subresources: validSubresources.filter(subresource => subresource.mipLevel < mipLevels),
mipLevels,
format: resolvedFormat,
hasExplicitMipChain
};
}
// Read the per-level format using the transitional textureFormat -> format fallback rules.
function getTextureSubresourceFormat(subresource) {
if (subresource.type !== 'texture-data') {
return undefined;
}
return subresource.textureFormat ?? resolveTextureImageFormat(subresource.data);
}
// Resolve dimensions from either raw bytes or external-image subresources.
function getTextureSubresourceSize(device, subresource) {
switch (subresource.type) {
case 'external-image':
return device.getExternalImageSize(subresource.image);
case 'texture-data':
return { width: subresource.data.width, height: subresource.data.height };
default:
throw new Error('Unsupported texture subresource');
}
}
// Count the mip levels that stay at or above one compression block in each dimension.
function getMaxCompressedMipLevels(device, baseWidth, baseHeight, format) {
const { blockWidth = 1, blockHeight = 1 } = device.getTextureFormatInfo(format);
let mipLevels = 1;
for (let mipLevel = 1;; mipLevel++) {
const width = Math.max(1, baseWidth >> mipLevel);
const height = Math.max(1, baseHeight >> mipLevel);
if (width < blockWidth || height < blockHeight) {
break;
}
mipLevels++;
}
return mipLevels;
}
// HELPERS
/** Resolve all promises in a nested data structure */
async function awaitAllPromises(x) {
x = await x;
if (Array.isArray(x)) {
return await Promise.all(x.map(awaitAllPromises));
}
if (x && typeof x === 'object' && x.constructor === Object) {
const object = x;
const values = await Promise.all(Object.values(object).map(awaitAllPromises));
const keys = Object.keys(object);
const resolvedObject = {};
for (let i = 0; i < keys.length; i++) {
resolvedObject[keys[i]] = values[i];
}
return resolvedObject;
}
return x;
}
// /** @note experimental: Set multiple mip levels (2D), optionally at `z`, slice (depth/array level) index */
// setTexture2DData(lodData: Texture2DData, z: number = 0): void {
// this._checkReady();
// const lodArray = this._normalizeTexture2DData(lodData);
// // If user supplied multiple mip levels, warn if auto-mips also requested
// if (lodArray.length > 1 && this.props.mipmaps !== false) {
// log.warn(
// `Texture ${this.id}: provided multiple LODs and also requested mipmap generation.`
// )();
// }
// for (let mipLevel = 0; mipLevel < lodArray.length; mipLevel++) {
// const imageData = lodArray[mipLevel];
// if (this.device.isExternalImage(imageData)) {
// this.texture.copyExternalImage({image: imageData, z, mipLevel, flipY: true});
// } else if (this._isTextureImageData(imageData)) {
// this.texture.copyImageData({data: imageData.data, z, mipLevel});
// } else {
// throw new Error('Unsupported 2D mip-level payload');
// }
// }
// }
// /** Normalize 2D layer payload into an array of mip-level items */
// private _normalizeTexture2DData(data: Texture2DData): (TextureImageData | ExternalImage)[] {
// return Array.isArray(data) ? data : [data];
// }
//# sourceMappingURL=dynamic-texture.js.map