• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / luma.gl / 16050834082

03 Jul 2025 12:47PM UTC coverage: 74.38% (-0.01%) from 74.394%
16050834082

push

github

web-flow
fix(engine): AsyncTexture correctly resolve Promise on data load (#2402)

2086 of 2718 branches covered (76.75%)

Branch coverage included in aggregate %.

0 of 14 new or added lines in 1 file covered. (0.0%)

26848 of 36182 relevant lines covered (74.2%)

48.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

32.31
/modules/engine/src/async-texture/async-texture.ts
1
// luma.gl, MIT license
1✔
2
// Copyright (c) vis.gl contributors
1✔
3

1✔
4
import type {
1✔
5
  TextureProps,
1✔
6
  SamplerProps,
1✔
7
  TextureView,
1✔
8
  Device,
1✔
9
  TypedArray,
1✔
10
  TextureFormat,
1✔
11
  ExternalImage
1✔
12
} from '@luma.gl/core';
1✔
13

1✔
14
import {Texture, Sampler, log} from '@luma.gl/core';
1✔
15

1✔
16
import {loadImageBitmap} from '../application-utils/load-file';
1✔
17
import {uid} from '../utils/uid';
1✔
18

1✔
19
type AsyncTextureDataProps =
1✔
20
  | AsyncTexture1DProps
1✔
21
  | AsyncTexture2DProps
1✔
22
  | AsyncTexture3DProps
1✔
23
  | AsyncTextureArrayProps
1✔
24
  | AsyncTextureCubeProps
1✔
25
  | AsyncTextureCubeArrayProps;
1✔
26

1✔
27
type AsyncTexture1DProps = {dimension: '1d'; data: Promise<Texture1DData> | Texture1DData | null};
1✔
28
type AsyncTexture2DProps = {dimension?: '2d'; data: Promise<Texture2DData> | Texture2DData | null};
1✔
29
type AsyncTexture3DProps = {dimension: '3d'; data: Promise<Texture3DData> | Texture3DData | null};
1✔
30
type AsyncTextureArrayProps = {
1✔
31
  dimension: '2d-array';
1✔
32
  data: Promise<TextureArrayData> | TextureArrayData | null;
1✔
33
};
1✔
34
type AsyncTextureCubeProps = {
1✔
35
  dimension: 'cube';
1✔
36
  data: Promise<TextureCubeData> | TextureCubeData | null;
1✔
37
};
1✔
38
type AsyncTextureCubeArrayProps = {
1✔
39
  dimension: 'cube-array';
1✔
40
  data: Promise<TextureCubeArrayData> | TextureCubeArrayData | null;
1✔
41
};
1✔
42

1✔
43
type AsyncTextureData = AsyncTextureProps['data'];
1✔
44

1✔
45
/** Names of cube texture faces */
1✔
46
export type TextureCubeFace = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z';
1✔
47
export const TextureCubeFaces: TextureCubeFace[] = ['+X', '-X', '+Y', '-Y', '+Z', '-Z'];
1✔
48
// prettier-ignore
1✔
49
export const TextureCubeFaceMap = {'+X': 0, '-X': 1, '+Y': 2, '-Y': 3, '+Z': 4, '-Z': 5};
1✔
50

1✔
51
/**
1✔
52
 * One mip level
1✔
53
 * Basic data structure is similar to `ImageData`
1✔
54
 * additional optional fields can describe compressed texture data.
1✔
55
 */
1✔
56
export type TextureImageData = {
1✔
57
  /** WebGPU style format string. Defaults to 'rgba8unorm' */
1✔
58
  format?: TextureFormat;
1✔
59
  data: TypedArray;
1✔
60
  width: number;
1✔
61
  height: number;
1✔
62

1✔
63
  compressed?: boolean;
1✔
64
  byteLength?: number;
1✔
65
  hasAlpha?: boolean;
1✔
66
};
1✔
67

1✔
68
export type TextureLevelSource = TextureImageData | ExternalImage;
1✔
69

1✔
70
/** Texture data can be one or more mip levels */
1✔
71
export type TextureData = TextureImageData | ExternalImage | (TextureImageData | ExternalImage)[];
1✔
72

1✔
73
/** @todo - define what data type is supported for 1D textures */
1✔
74
export type Texture1DData = TypedArray | TextureImageData;
1✔
75

1✔
76
/** Texture data can be one or more mip levels */
1✔
77
export type Texture2DData =
1✔
78
  | TypedArray
1✔
79
  | TextureImageData
1✔
80
  | ExternalImage
1✔
81
  | (TextureImageData | ExternalImage)[];
1✔
82

1✔
83
/** 6 face textures */
1✔
84
export type TextureCubeData = Record<TextureCubeFace, TextureData>;
1✔
85

1✔
86
/** Array of textures */
1✔
87
export type Texture3DData = TextureData[];
1✔
88

1✔
89
/** Array of textures */
1✔
90
export type TextureArrayData = TextureData[];
1✔
91

1✔
92
/** Array of 6 face textures */
1✔
93
export type TextureCubeArrayData = Record<TextureCubeFace, TextureData>[];
1✔
94

1✔
95
export const CubeFaces: TextureCubeFace[] = ['+X', '-X', '+Y', '-Y', '+Z', '-Z'];
1✔
96

1✔
97
/** Properties for an async texture */
1✔
98
export type AsyncTextureProps = Omit<TextureProps, 'data' | 'mipLevels' | 'width' | 'height'> &
1✔
99
  AsyncTextureDataProps & {
1✔
100
    /** Generate mipmaps after creating textures and setting data */
1✔
101
    mipmaps?: boolean;
1✔
102
    /** nipLevels can be set to 'auto' to generate max number of mipLevels */
1✔
103
    mipLevels?: number | 'auto';
1✔
104
    /** Width - can be auto-calculated when initializing from ExternalImage */
1✔
105
    width?: number;
1✔
106
    /** Height - can be auto-calculated when initializing from ExternalImage */
1✔
107
    height?: number;
1✔
108
  };
1✔
109

1✔
110
/**
1✔
111
 * It is very convenient to be able to initialize textures with promises
1✔
112
 * This can add considerable complexity to the Texture class, and doesn't
1✔
113
 * fit with the immutable nature of WebGPU resources.
1✔
114
 * Instead, luma.gl offers async textures as a separate class.
1✔
115
 */
1✔
116
export class AsyncTexture {
1!
117
  readonly device: Device;
×
118
  readonly id: string;
×
119
  props: Required<Omit<AsyncTextureProps, 'data'>>;
×
120

×
121
  // TODO - should we type these as possibly `null`? It will make usage harder?
×
122
  // @ts-expect-error
×
123
  texture: Texture;
×
124
  // @ts-expect-error
×
125
  sampler: Sampler;
×
126
  // @ts-expect-error
×
127
  view: TextureView;
×
128

×
129
  readonly ready: Promise<void>;
×
130
  isReady: boolean = false;
×
131
  destroyed: boolean = false;
×
132

×
133
  protected resolveReady: () => void = () => {};
×
134
  protected rejectReady: (error: Error) => void = () => {};
×
135

×
136
  get [Symbol.toStringTag]() {
×
137
    return 'AsyncTexture';
×
138
  }
×
139

×
140
  toString(): string {
×
141
    return `AsyncTexture:"${this.id}"(${this.isReady ? 'ready' : 'loading'})`;
×
142
  }
×
143

×
144
  constructor(device: Device, props: AsyncTextureProps) {
×
145
    this.device = device;
×
146

×
147
    // TODO - if we support URL strings as data...
×
148
    const id = uid('async-texture'); // typeof props?.data === 'string' ? props.data.slice(-20) : uid('async-texture');
×
149
    this.props = {...AsyncTexture.defaultProps, id, ...props};
×
150
    this.id = this.props.id;
×
151

×
152
    props = {...props};
×
153
    // Signature: new AsyncTexture(device, {data: url})
×
154
    if (typeof props?.data === 'string' && props.dimension === '2d') {
×
155
      props.data = loadImageBitmap(props.data);
×
156
    }
×
157

×
158
    // If mipmaps are requested, we need to allocate space for them
×
159
    if (props.mipmaps) {
×
160
      props.mipLevels = 'auto';
×
161
    }
×
162

×
163
    this.ready = new Promise<void>((resolve, reject) => {
×
164
      this.resolveReady = () => {
×
165
        this.isReady = true;
×
166
        resolve();
×
167
      };
×
168
      this.rejectReady = reject;
×
169
    });
×
170

×
171
    this.initAsync(props);
×
172
  }
×
173

×
174
  async initAsync(props: AsyncTextureProps): Promise<void> {
×
175
    const asyncData: AsyncTextureData = props.data;
×
NEW
176
    // @ts-expect-error not clear how to convince TS that null will be returned
×
NEW
177
    const data: TextureData | null = await awaitAllPromises(asyncData).then(
×
NEW
178
      undefined,
×
NEW
179
      this.rejectReady
×
NEW
180
    );
×
181

×
182
    // Check that we haven't been destroyed while waiting for texture data to load
×
183
    if (this.destroyed) {
×
184
      return;
×
185
    }
×
186

×
187
    // Now we can actually create the texture
×
188

×
189
    // Auto-deduce width and height if not supplied
×
190
    const size =
×
191
      this.props.width && this.props.height
×
192
        ? {width: this.props.width, height: this.props.height}
×
193
        : this.getTextureDataSize(data);
×
194
    if (!size) {
×
195
      throw new Error('Texture size could not be determined');
×
196
    }
×
197
    const syncProps: TextureProps = {...size, ...props, data: undefined, mipLevels: 1};
×
198

×
199
    // Auto-calculate the number of mip levels as a convenience
×
200
    // TODO - Should we clamp to 1-getMipLevelCount?
×
201
    const maxMips = this.device.getMipLevelCount(syncProps.width, syncProps.height);
×
202
    syncProps.mipLevels =
×
203
      this.props.mipLevels === 'auto' ? maxMips : Math.min(maxMips, this.props.mipLevels);
×
204

×
205
    this.texture = this.device.createTexture(syncProps);
×
206
    this.sampler = this.texture.sampler;
×
207
    this.view = this.texture.view;
×
208

×
209
    if (props.data) {
×
210
      switch (this.props.dimension) {
×
211
        case '1d':
×
212
          this._setTexture1DData(this.texture, data as Texture1DData);
×
213
          break;
×
214
        case '2d':
×
NEW
215
          this._setTexture2DData(data as Texture2DData);
×
216
          break;
×
217
        case '3d':
×
218
          this._setTexture3DData(this.texture, data as Texture3DData);
×
219
          break;
×
220
        case '2d-array':
×
221
          this._setTextureArrayData(this.texture, data as TextureArrayData);
×
222
          break;
×
223
        case 'cube':
×
224
          this._setTextureCubeData(this.texture, data as unknown as TextureCubeData);
×
225
          break;
×
226
        case 'cube-array':
×
227
          this._setTextureCubeArrayData(this.texture, data as unknown as TextureCubeArrayData);
×
228
          break;
×
229
      }
×
230
    }
×
231

×
232
    // Do we need to generate mipmaps?
×
233
    if (this.props.mipmaps) {
×
234
      this.generateMipmaps();
×
235
    }
×
236

×
237
    log.info(1, `${this} loaded`);
×
NEW
238
    this.resolveReady();
×
239
  }
×
240

×
241
  destroy(): void {
×
242
    if (this.texture) {
×
243
      this.texture.destroy();
×
244
      // @ts-expect-error
×
245
      this.texture = null;
×
246
    }
×
247
    this.destroyed = true;
×
248
  }
×
249

×
250
  generateMipmaps(): void {
×
251
    // if (this.device.type === 'webgl') {
×
252
    this.texture.generateMipmapsWebGL();
×
253
    // }
×
254
  }
×
255

×
256
  /** Set sampler or create and set new Sampler from SamplerProps */
×
257
  setSampler(sampler: Sampler | SamplerProps = {}): void {
×
258
    this.texture.setSampler(
×
259
      sampler instanceof Sampler ? sampler : this.device.createSampler(sampler)
×
260
    );
×
261
  }
×
262

×
263
  /**
×
264
   * Textures are immutable and cannot be resized after creation,
×
265
   * but we can create a similar texture with the same parameters but a new size.
×
266
   * @note Does not copy contents of the texture
×
267
   * @note Mipmaps may need to be regenerated after resizing / setting new data
×
268
   * @todo Abort pending promise and create a texture with the new size?
×
269
   */
×
270
  resize(size: {width: number; height: number}): boolean {
×
271
    if (!this.isReady) {
×
272
      throw new Error('Cannot resize texture before it is ready');
×
273
    }
×
274

×
275
    if (size.width === this.texture.width && size.height === this.texture.height) {
×
276
      return false;
×
277
    }
×
278

×
279
    if (this.texture) {
×
280
      const texture = this.texture;
×
281
      this.texture = texture.clone(size);
×
282
      texture.destroy();
×
283
    }
×
284

×
285
    return true;
×
286
  }
×
287

×
288
  /** Check if texture data is a typed array */
×
289
  isTextureLevelData(data: TextureData): data is TextureImageData {
×
290
    const typedArray = (data as TextureImageData)?.data;
×
291
    return ArrayBuffer.isView(typedArray);
×
292
  }
×
293

×
294
  /** Get the size of the texture described by the provided TextureData */
×
295
  getTextureDataSize(
×
NEW
296
    data:
×
NEW
297
      | TextureData
×
NEW
298
      | TextureCubeData
×
NEW
299
      | TextureArrayData
×
NEW
300
      | TextureCubeArrayData
×
NEW
301
      | TypedArray
×
NEW
302
      | null
×
303
  ): {width: number; height: number} | null {
×
304
    if (!data) {
×
305
      return null;
×
306
    }
×
307
    if (ArrayBuffer.isView(data)) {
×
308
      return null;
×
309
    }
×
310
    // Recurse into arrays (array of miplevels)
×
311
    if (Array.isArray(data)) {
×
312
      return this.getTextureDataSize(data[0]);
×
313
    }
×
314
    if (this.device.isExternalImage(data)) {
×
315
      return this.device.getExternalImageSize(data);
×
316
    }
×
317
    if (data && typeof data === 'object' && data.constructor === Object) {
×
318
      const textureDataArray = Object.values(data);
×
319
      const untypedData = textureDataArray[0];
×
320
      return {width: untypedData.width, height: untypedData.height};
×
321
    }
×
322
    throw new Error('texture size deduction failed');
×
323
  }
×
324

×
325
  /** Convert luma.gl cubemap face constants to depth index */
×
326
  getCubeFaceDepth(face: TextureCubeFace): number {
×
327
    // prettier-ignore
×
328
    switch (face) {
×
329
        case '+X': return  0;
×
330
        case '-X': return  1;
×
331
        case '+Y': return  2;
×
332
        case '-Y': return  3;
×
333
        case '+Z': return  4;
×
334
        case '-Z': return  5;
×
335
        default: throw new Error(face);
×
336
      }
×
337
  }
×
338

×
339
  // EXPERIMENTAL
×
340

×
341
  setTextureData(data: TextureData) {}
×
342

×
343
  /** Experimental: Set multiple mip levels */
×
344
  _setTexture1DData(texture: Texture, data: Texture1DData): void {
×
345
    throw new Error('setTexture1DData not supported in WebGL.');
×
346
  }
×
347

×
348
  /** Experimental: Set multiple mip levels */
×
349
  _setTexture2DData(lodData: Texture2DData, depth = 0): void {
×
350
    if (!this.texture) {
×
351
      throw new Error('Texture not initialized');
×
352
    }
×
353

×
354
    const lodArray = this._normalizeTextureData(lodData);
×
355

×
356
    // If the user provides multiple LODs, then automatic mipmap
×
357
    // generation generateMipmap() should be disabled to avoid overwriting them.
×
358
    if (lodArray.length > 1 && this.props.mipmaps !== false) {
×
359
      log.warn(`Texture ${this.id} mipmap and multiple LODs.`)();
×
360
    }
×
361

×
362
    for (let mipLevel = 0; mipLevel < lodArray.length; mipLevel++) {
×
363
      const imageData = lodArray[mipLevel];
×
364
      if (this.device.isExternalImage(imageData)) {
×
365
        this.texture.copyExternalImage({image: imageData, depth, mipLevel, flipY: true});
×
366
      } else {
×
367
        this.texture.copyImageData({data: imageData.data /* , depth */, mipLevel});
×
368
      }
×
369
    }
×
370
  }
×
371

×
372
  /**
×
373
   * Experimental: Sets 3D texture data: multiple depth slices, multiple mip levels
×
374
   * @param data
×
375
   */
×
376
  _setTexture3DData(texture: Texture, data: Texture3DData): void {
×
377
    if (this.texture?.props.dimension !== '3d') {
×
378
      throw new Error(this.id);
×
379
    }
×
380
    for (let depth = 0; depth < data.length; depth++) {
×
381
      this._setTexture2DData(data[depth], depth);
×
382
    }
×
383
  }
×
384

×
385
  /**
×
386
   * Experimental: Set Cube texture data, multiple faces, multiple mip levels
×
387
   * @todo - could support TextureCubeArray with depth
×
388
   * @param data
×
389
   * @param index
×
390
   */
×
391
  _setTextureCubeData(texture: Texture, data: TextureCubeData): void {
×
392
    if (this.texture?.props.dimension !== 'cube') {
×
393
      throw new Error(this.id);
×
394
    }
×
395
    for (const [face, faceData] of Object.entries(data)) {
×
396
      const faceDepth = CubeFaces.indexOf(face as TextureCubeFace);
×
397
      this._setTexture2DData(faceData, faceDepth);
×
398
    }
×
399
  }
×
400

×
401
  /**
×
402
   * Experimental: Sets texture array data, multiple levels, multiple depth slices
×
403
   * @param data
×
404
   */
×
405
  _setTextureArrayData(texture: Texture, data: TextureArrayData): void {
×
406
    if (this.texture?.props.dimension !== '2d-array') {
×
407
      throw new Error(this.id);
×
408
    }
×
409
    for (let depth = 0; depth < data.length; depth++) {
×
410
      this._setTexture2DData(data[depth], depth);
×
411
    }
×
412
  }
×
413

×
414
  /**
×
415
   * Experimental: Sets texture cube array, multiple faces, multiple levels, multiple mip levels
×
416
   * @param data
×
417
   */
×
418
  _setTextureCubeArrayData(texture: Texture, data: TextureCubeArrayData): void {
×
419
    throw new Error('setTextureCubeArrayData not supported in WebGL2.');
×
420
  }
×
421

×
422
  /** Experimental */
×
423
  _setTextureCubeFaceData(
×
424
    texture: Texture,
×
425
    lodData: Texture2DData,
×
426
    face: TextureCubeFace,
×
427
    depth: number = 0
×
428
  ): void {
×
429
    // assert(this.props.dimension === 'cube');
×
430

×
431
    // If the user provides multiple LODs, then automatic mipmap
×
432
    // generation generateMipmap() should be disabled to avoid overwriting them.
×
433
    if (Array.isArray(lodData) && lodData.length > 1 && this.props.mipmaps !== false) {
×
434
      log.warn(`${this.id} has mipmap and multiple LODs.`)();
×
435
    }
×
436

×
437
    const faceDepth = TextureCubeFaces.indexOf(face);
×
438
    this._setTexture2DData(lodData, faceDepth);
×
439
  }
×
440

×
441
  /**
×
442
   * Normalize TextureData to an array of TextureImageData / ExternalImages
×
443
   * @param data
×
444
   * @param options
×
445
   * @returns array of TextureImageData / ExternalImages
×
446
   */
×
447
  _normalizeTextureData(data: Texture2DData): (TextureImageData | ExternalImage)[] {
×
448
    const options: {width: number; height: number; depth: number} = this.texture;
×
449
    let mipLevelArray: (TextureImageData | ExternalImage)[];
×
450
    if (ArrayBuffer.isView(data)) {
×
451
      mipLevelArray = [
×
452
        {
×
453
          // ts-expect-error does data really need to be Uint8ClampedArray?
×
454
          data,
×
455
          width: options.width,
×
456
          height: options.height
×
457
          // depth: options.depth
×
458
        }
×
459
      ];
×
460
    } else if (!Array.isArray(data)) {
×
461
      mipLevelArray = [data];
×
462
    } else {
×
463
      mipLevelArray = data;
×
464
    }
×
465
    return mipLevelArray;
×
466
  }
×
467

×
468
  static defaultProps: Required<AsyncTextureProps> = {
×
469
    ...Texture.defaultProps,
×
470
    data: null,
×
471
    mipmaps: false
×
472
  };
×
473
}
×
474

1✔
475
// TODO - Remove when texture refactor is complete
1✔
476

1✔
477
/*
1✔
478
setCubeMapData(options: {
1✔
479
  width: number;
1✔
480
  height: number;
1✔
481
  data: Record<GL, Texture2DData> | Record<TextureCubeFace, Texture2DData>;
1✔
482
  format?: any;
1✔
483
  type?: any;
1✔
484
  /** @deprecated Use .data *
1✔
485
  pixels: any;
1✔
486
}): void {
1✔
487
  const {gl} = this;
1✔
488

1✔
489
  const {width, height, pixels, data, format = GL.RGBA, type = GL.UNSIGNED_BYTE} = options;
1✔
490

1✔
491
  // pixel data (imageDataMap) is an Object from Face to Image or Promise.
1✔
492
  // For example:
1✔
493
  // {
1✔
494
  // GL.TEXTURE_CUBE_MAP_POSITIVE_X : Image-or-Promise,
1✔
495
  // GL.TEXTURE_CUBE_MAP_NEGATIVE_X : Image-or-Promise,
1✔
496
  // ... }
1✔
497
  // To provide multiple level-of-details (LODs) this can be Face to Array
1✔
498
  // of Image or Promise, like this
1✔
499
  // {
1✔
500
  // GL.TEXTURE_CUBE_MAP_POSITIVE_X : [Image-or-Promise-LOD-0, Image-or-Promise-LOD-1],
1✔
501
  // GL.TEXTURE_CUBE_MAP_NEGATIVE_X : [Image-or-Promise-LOD-0, Image-or-Promise-LOD-1],
1✔
502
  // ... }
1✔
503

1✔
504
  const imageDataMap = this._getImageDataMap(pixels || data);
1✔
505

1✔
506
  const resolvedFaces = WEBGLTexture.FACES.map(face => {
1✔
507
    const facePixels = imageDataMap[face];
1✔
508
    return Array.isArray(facePixels) ? facePixels : [facePixels];
1✔
509
  });
1✔
510
  this.bind();
1✔
511

1✔
512
  WEBGLTexture.FACES.forEach((face, index) => {
1✔
513
    if (resolvedFaces[index].length > 1 && this.props.mipmaps !== false) {
1✔
514
      // If the user provides multiple LODs, then automatic mipmap
1✔
515
      // generation generateMipmaps() should be disabled to avoid overwritting them.
1✔
516
      log.warn(`${this.id} has mipmap and multiple LODs.`)();
1✔
517
    }
1✔
518
    resolvedFaces[index].forEach((image, lodLevel) => {
1✔
519
      // TODO: adjust width & height for LOD!
1✔
520
      if (width && height) {
1✔
521
        gl.texImage2D(face, lodLevel, format, width, height, 0 /* border*, format, type, image);
1✔
522
      } else {
1✔
523
        gl.texImage2D(face, lodLevel, format, format, type, image);
1✔
524
      }
1✔
525
    });
1✔
526
  });
1✔
527

1✔
528
  this.unbind();
1✔
529
}
1✔
530
*/
1✔
531

1✔
532
// HELPERS
1✔
533

1✔
534
/** Resolve all promises in a nested data structure */
1✔
535
async function awaitAllPromises(x: any): Promise<any> {
×
536
  x = await x;
×
537
  if (Array.isArray(x)) {
×
538
    return await Promise.all(x.map(awaitAllPromises));
×
539
  }
×
540
  if (x && typeof x === 'object' && x.constructor === Object) {
×
541
    const object: Record<string, any> = x;
×
542
    const values = await Promise.all(Object.values(object));
×
543
    const keys = Object.keys(object);
×
544
    const resolvedObject: Record<string, any> = {};
×
545
    for (let i = 0; i < keys.length; i++) {
×
546
      resolvedObject[keys[i]] = values[i];
×
547
    }
×
548
    return resolvedObject;
×
549
  }
×
550
  return x;
×
551
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc