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

visgl / luma.gl / 17025693971

17 Aug 2025 08:48PM UTC coverage: 74.549% (-0.07%) from 74.617%
17025693971

push

github

web-flow
feat(engine): stretch background texture (#2433)

2133 of 2777 branches covered (76.81%)

Branch coverage included in aggregate %.

34 of 76 new or added lines in 3 files covered. (44.74%)

5 existing lines in 1 file now uncovered.

27618 of 37131 relevant lines covered (74.38%)

57.31 hits per line

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

32.68
/modules/engine/src/dynamic-texture/dynamic-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 DynamicTextureDataProps =
1✔
20
  | DynamicTexture1DProps
1✔
21
  | DynamicTexture2DProps
1✔
22
  | DynamicTexture3DProps
1✔
23
  | DynamicTextureArrayProps
1✔
24
  | DynamicTextureCubeProps
1✔
25
  | DynamicTextureCubeArrayProps;
1✔
26

1✔
27
type DynamicTexture1DProps = {dimension: '1d'; data: Promise<Texture1DData> | Texture1DData | null};
1✔
28
type DynamicTexture2DProps = {
1✔
29
  dimension?: '2d';
1✔
30
  data: Promise<Texture2DData> | Texture2DData | null;
1✔
31
};
1✔
32
type DynamicTexture3DProps = {dimension: '3d'; data: Promise<Texture3DData> | Texture3DData | null};
1✔
33
type DynamicTextureArrayProps = {
1✔
34
  dimension: '2d-array';
1✔
35
  data: Promise<TextureArrayData> | TextureArrayData | null;
1✔
36
};
1✔
37
type DynamicTextureCubeProps = {
1✔
38
  dimension: 'cube';
1✔
39
  data: Promise<TextureCubeData> | TextureCubeData | null;
1✔
40
};
1✔
41
type DynamicTextureCubeArrayProps = {
1✔
42
  dimension: 'cube-array';
1✔
43
  data: Promise<TextureCubeArrayData> | TextureCubeArrayData | null;
1✔
44
};
1✔
45

1✔
46
type DynamicTextureData = DynamicTextureProps['data'];
1✔
47

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

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

1✔
66
  compressed?: boolean;
1✔
67
  byteLength?: number;
1✔
68
  hasAlpha?: boolean;
1✔
69
};
1✔
70

1✔
71
export type TextureLevelSource = TextureImageData | ExternalImage;
1✔
72

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

1✔
76
/** @todo - define what data type is supported for 1D textures */
1✔
77
export type Texture1DData = TypedArray | TextureImageData;
1✔
78

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

1✔
86
/** 6 face textures */
1✔
87
export type TextureCubeData = Record<TextureCubeFace, TextureData>;
1✔
88

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

1✔
92
/** Array of textures */
1✔
93
export type TextureArrayData = TextureData[];
1✔
94

1✔
95
/** Array of 6 face textures */
1✔
96
export type TextureCubeArrayData = Record<TextureCubeFace, TextureData>[];
1✔
97

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

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

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

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

×
NEW
132
  readonly ready: Promise<Texture>;
×
133
  isReady: boolean = false;
×
134
  destroyed: boolean = false;
×
135

×
136
  protected resolveReady: () => void = () => {};
×
137
  protected rejectReady: (error: Error) => void = () => {};
×
138

×
139
  get [Symbol.toStringTag]() {
×
140
    return 'DynamicTexture';
×
141
  }
×
142

×
143
  toString(): string {
×
144
    return `DynamicTexture:"${this.id}"(${this.isReady ? 'ready' : 'loading'})`;
×
145
  }
×
146

×
147
  constructor(device: Device, props: DynamicTextureProps) {
×
148
    this.device = device;
×
149

×
150
    // TODO - if we support URL strings as data...
×
151
    const id = uid('dynamic-texture'); // typeof props?.data === 'string' ? props.data.slice(-20) : uid('dynamic-texture');
×
152
    this.props = {...DynamicTexture.defaultProps, id, ...props};
×
153
    this.id = this.props.id;
×
154

×
155
    props = {...props};
×
156
    // Signature: new DynamicTexture(device, {data: url})
×
157
    if (typeof props?.data === 'string' && props.dimension === '2d') {
×
158
      props.data = loadImageBitmap(props.data);
×
159
    }
×
160

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

×
NEW
166
    this.ready = new Promise<Texture>((resolve, reject) => {
×
167
      this.resolveReady = () => {
×
168
        this.isReady = true;
×
NEW
169
        resolve(this.texture);
×
170
      };
×
171
      this.rejectReady = reject;
×
172
    });
×
173

×
174
    this.initAsync(props);
×
175
  }
×
176

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

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

×
190
    // Now we can actually create the texture
×
191

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

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

×
208
    this.texture = this.device.createTexture(syncProps);
×
209
    this.sampler = this.texture.sampler;
×
210
    this.view = this.texture.view;
×
211

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

×
235
    // Do we need to generate mipmaps?
×
236
    if (this.props.mipmaps) {
×
237
      this.generateMipmaps();
×
238
    }
×
239

×
240
    log.info(1, `${this} loaded`);
×
241
    this.resolveReady();
×
242
  }
×
243

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

×
253
  generateMipmaps(): void {
×
254
    // if (this.device.type === 'webgl') {
×
255
    this.texture.generateMipmapsWebGL();
×
256
    // }
×
257
  }
×
258

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

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

×
278
    if (size.width === this.texture.width && size.height === this.texture.height) {
×
279
      return false;
×
280
    }
×
281

×
282
    if (this.texture) {
×
283
      const texture = this.texture;
×
284
      this.texture = texture.clone(size);
×
285
      texture.destroy();
×
286
    }
×
287

×
288
    return true;
×
289
  }
×
290

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

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

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

×
342
  // EXPERIMENTAL
×
343

×
344
  setTextureData(data: TextureData) {}
×
345

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

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

×
357
    const lodArray = this._normalizeTextureData(lodData);
×
358

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

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

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

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

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

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

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

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

×
440
    const faceDepth = TextureCubeFaces.indexOf(face);
×
441
    this._setTexture2DData(lodData, faceDepth);
×
442
  }
×
443

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

×
471
  static defaultProps: Required<DynamicTextureProps> = {
×
472
    ...Texture.defaultProps,
×
473
    data: null,
×
474
    mipmaps: false
×
475
  };
×
476
}
×
477

1✔
478
// TODO - Remove when texture refactor is complete
1✔
479

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

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

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

1✔
507
  const imageDataMap = this._getImageDataMap(pixels || data);
1✔
508

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

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

1✔
531
  this.unbind();
1✔
532
}
1✔
533
*/
1✔
534

1✔
535
// HELPERS
1✔
536

1✔
537
/** Resolve all promises in a nested data structure */
1✔
538
async function awaitAllPromises(x: any): Promise<any> {
×
539
  x = await x;
×
540
  if (Array.isArray(x)) {
×
541
    return await Promise.all(x.map(awaitAllPromises));
×
542
  }
×
543
  if (x && typeof x === 'object' && x.constructor === Object) {
×
544
    const object: Record<string, any> = x;
×
545
    const values = await Promise.all(Object.values(object));
×
546
    const keys = Object.keys(object);
×
547
    const resolvedObject: Record<string, any> = {};
×
548
    for (let i = 0; i < keys.length; i++) {
×
549
      resolvedObject[keys[i]] = values[i];
×
550
    }
×
551
    return resolvedObject;
×
552
  }
×
553
  return x;
×
554
}
×
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