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

visgl / luma.gl / 23412192171

22 Mar 2026 08:49PM UTC coverage: 73.59% (-0.6%) from 74.227%
23412192171

Pull #2439

github

web-flow
Merge 99091cdc8 into 7c172e633
Pull Request #2439: feat(engine): add async texture buffer read

4597 of 7074 branches covered (64.98%)

Branch coverage included in aggregate %.

111 of 213 new or added lines in 20 files covered. (52.11%)

40 existing lines in 8 files now uncovered.

10525 of 13475 relevant lines covered (78.11%)

263.46 hits per line

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

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

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

13
import {Buffer, Texture, Sampler, log} from '@luma.gl/core';
14

15
// import {loadImageBitmap} from '../application-utils/load-file';
16
import {uid} from '../utils/uid';
17
import {
18
  // cube constants
19
  type TextureCubeFace,
20
  TEXTURE_CUBE_FACE_MAP,
21

22
  // texture slice/mip data types
23
  type TextureSubresource,
24

25
  // props (dimension + data)
26
  type TextureDataProps,
27
  type TextureDataAsyncProps,
28

29
  // combined data for different texture types
30
  type Texture1DData,
31
  type Texture2DData,
32
  type Texture3DData,
33
  type TextureArrayData,
34
  type TextureCubeArrayData,
35
  type TextureCubeData,
36

37
  // Helpers
38
  getTextureSizeFromData,
39
  resolveTextureImageFormat,
40
  getTexture1DSubresources,
41
  getTexture2DSubresources,
42
  getTexture3DSubresources,
43
  getTextureCubeSubresources,
44
  getTextureArraySubresources,
45
  getTextureCubeArraySubresources
46
} from './texture-data';
47

48
/**
49
 * Properties for a dynamic texture
50
 */
51
export type DynamicTextureProps = Omit<TextureProps, 'data' | 'mipLevels' | 'width' | 'height'> &
52
  TextureDataAsyncProps & {
53
    /** Generate mipmaps after creating textures and setting data */
54
    mipmaps?: boolean;
55
    /** nipLevels can be set to 'auto' to generate max number of mipLevels */
56
    mipLevels?: number | 'auto';
57
    /** Width - can be auto-calculated when initializing from ExternalImage */
58
    width?: number;
59
    /** Height - can be auto-calculated when initializing from ExternalImage */
60
    height?: number;
61
  };
62

63
/**
64
 * Dynamic Textures
65
 *
66
 * - Mipmaps - DynamicTexture can generate mipmaps for textures (WebGPU does not provide built-in mipmap generation).
67
 *
68
 * - Texture initialization and updates - complex textures (2d array textures, cube textures, 3d textures) need multiple images
69
 *   `DynamicTexture` provides an API that makes it easy to provide the required data.
70
 *
71
 * - Texture resizing - Textures are immutable in WebGPU, meaning that they cannot be resized after creation.
72
 *   DynamicTexture provides a `resize()` method that internally creates a new texture with the same parameters
73
 *   but a different size.
74
 *
75
 * - Async image data initialization - It is often very convenient to be able to initialize textures with promises
76
 *   returned by image or data loading functions, as it allows a callback-free linear style of programming.
77
 *
78
 * @note GPU Textures are quite complex objects, with many subresources and modes of usage.
79
 * The `DynamicTexture` class allows luma.gl to provide some support for working with textures
80
 * without accumulating excessive complexity in the core Texture class which is designed as an immutable nature of GPU resource.
81
 */
82
export class DynamicTexture {
83
  readonly device: Device;
84
  readonly id: string;
85

86
  /** Props with defaults resolved (except `data` which is processed separately) */
87
  props: Readonly<Required<DynamicTextureProps>>;
88

89
  /** Created resources */
90
  private _texture: Texture | null = null;
29✔
91
  private _sampler: Sampler | null = null;
29✔
92
  private _view: TextureView | null = null;
29✔
93

94
  /** Ready when GPU texture has been created and data (if any) uploaded */
95
  readonly ready: Promise<Texture>;
96
  isReady = false;
29✔
97
  destroyed = false;
29✔
98

99
  private resolveReady: (t: Texture) => void = () => {};
×
100
  private rejectReady: (error: Error) => void = () => {};
×
101

102
  get texture(): Texture {
103
    if (!this._texture) throw new Error('Texture not initialized yet');
326!
104
    return this._texture;
326✔
105
  }
106
  get sampler(): Sampler {
107
    if (!this._sampler) throw new Error('Sampler not initialized yet');
×
108
    return this._sampler;
×
109
  }
110
  get view(): TextureView {
111
    if (!this._view) throw new Error('View not initialized yet');
×
112
    return this._view;
×
113
  }
114

115
  get [Symbol.toStringTag]() {
116
    return 'DynamicTexture';
×
117
  }
118
  toString(): string {
119
    return `DynamicTexture:"${this.id}":${this.texture.width}x${this.texture.height}px:(${this.isReady ? 'ready' : 'loading...'})`;
28!
120
  }
121

122
  constructor(device: Device, props: DynamicTextureProps) {
123
    this.device = device;
29✔
124

125
    const id = uid('dynamic-texture');
29✔
126
    // NOTE: We avoid holding on to data to make sure it can be garbage collected.
127
    const originalPropsWithAsyncData = props;
29✔
128
    this.props = {...DynamicTexture.defaultProps, id, ...props, data: null};
29✔
129
    this.id = this.props.id;
29✔
130

131
    this.ready = new Promise<Texture>((resolve, reject) => {
29✔
132
      this.resolveReady = resolve;
29✔
133
      this.rejectReady = reject;
29✔
134
    });
135

136
    this.initAsync(originalPropsWithAsyncData);
29✔
137
  }
138

139
  /** @note Fire and forget; caller can await `ready` */
140
  async initAsync(originalPropsWithAsyncData: DynamicTextureProps): Promise<void> {
141
    try {
29✔
142
      // TODO - Accept URL string for 2D: turn into ExternalImage promise
143
      // const dataProps =
144
      //   typeof props.data === 'string' && (props.dimension ?? '2d') === '2d'
145
      //     ? ({dimension: '2d', data: loadImageBitmap(props.data)} as const)
146
      //     : {};
147

148
      const propsWithSyncData = await this._loadAllData(originalPropsWithAsyncData);
29✔
149
      this._checkNotDestroyed();
29✔
150
      const subresources = propsWithSyncData.data
29!
151
        ? getTextureSubresources({
152
            ...propsWithSyncData,
153
            width: originalPropsWithAsyncData.width,
154
            height: originalPropsWithAsyncData.height,
155
            format: originalPropsWithAsyncData.format
156
          })
157
        : [];
158
      const userProvidedFormat =
159
        'format' in originalPropsWithAsyncData && originalPropsWithAsyncData.format !== undefined;
29✔
160
      const userProvidedUsage =
161
        'usage' in originalPropsWithAsyncData && originalPropsWithAsyncData.usage !== undefined;
29✔
162

163
      // Deduce size when not explicitly provided
164
      // TODO - what about depth?
165
      const deduceSize = (): {width: number; height: number} => {
29✔
166
        if (this.props.width && this.props.height) {
28✔
167
          return {width: this.props.width, height: this.props.height};
13✔
168
        }
169

170
        const size = getTextureSizeFromData(propsWithSyncData);
15✔
171
        if (size) {
15!
172
          return size;
15✔
173
        }
174

175
        return {width: this.props.width || 1, height: this.props.height || 1};
×
176
      };
177

178
      const size = deduceSize();
29✔
179
      if (!size || size.width <= 0 || size.height <= 0) {
29!
180
        throw new Error(`${this} size could not be determined or was zero`);
×
181
      }
182

183
      // Normalize caller-provided subresources into one validated mip chain description.
184
      const textureData = analyzeTextureSubresources(this.device, subresources, size, {
28✔
185
        format: userProvidedFormat ? originalPropsWithAsyncData.format : undefined
28✔
186
      });
187
      const resolvedFormat = textureData.format ?? this.props.format;
29✔
188

189
      // Create a minimal TextureProps and validate via `satisfies`
190
      const baseTextureProps = {
29✔
191
        ...this.props,
192
        ...size,
193
        format: resolvedFormat,
194
        mipLevels: 1, // temporary; updated below
195
        data: undefined
196
      } satisfies TextureProps;
197

198
      if (this.device.isTextureFormatCompressed(resolvedFormat) && !userProvidedUsage) {
29✔
199
        baseTextureProps.usage = Texture.SAMPLE | Texture.COPY_DST;
6✔
200
      }
201

202
      // Explicit mip arrays take ownership of the mip chain; otherwise we may auto-generate it.
203
      const shouldGenerateMipmaps =
204
        this.props.mipmaps &&
28✔
205
        !textureData.hasExplicitMipChain &&
206
        !this.device.isTextureFormatCompressed(resolvedFormat);
207

208
      if (this.device.type === 'webgpu' && shouldGenerateMipmaps) {
29✔
209
        const requiredUsage =
210
          this.props.dimension === '3d'
5✔
211
            ? Texture.SAMPLE | Texture.STORAGE | Texture.COPY_DST | Texture.COPY_SRC
212
            : Texture.SAMPLE | Texture.RENDER | Texture.COPY_DST | Texture.COPY_SRC;
213
        baseTextureProps.usage |= requiredUsage;
5✔
214
      }
215

216
      // Compute mip levels (auto clamps to max)
217
      const maxMips = this.device.getMipLevelCount(baseTextureProps.width, baseTextureProps.height);
28✔
218
      const desired = textureData.hasExplicitMipChain
28✔
219
        ? textureData.mipLevels
220
        : this.props.mipLevels === 'auto'
22✔
221
          ? maxMips
222
          : Math.max(1, Math.min(maxMips, this.props.mipLevels ?? 1));
17!
223

224
      const finalTextureProps: TextureProps = {...baseTextureProps, mipLevels: desired};
29✔
225

226
      this._texture = this.device.createTexture(finalTextureProps);
29✔
227
      this._sampler = this.texture.sampler;
29✔
228
      this._view = this.texture.view;
29✔
229

230
      // Upload data if provided
231
      if (textureData.subresources.length) {
29✔
232
        this._setTextureSubresources(textureData.subresources);
28✔
233
      }
234

235
      if (this.props.mipmaps && !textureData.hasExplicitMipChain && !shouldGenerateMipmaps) {
28!
236
        log.warn(`${this} skipping auto-generated mipmaps for compressed texture format`)();
×
237
      }
238

239
      if (shouldGenerateMipmaps) {
28✔
240
        this.generateMipmaps();
5✔
241
      }
242

243
      this.isReady = true;
28✔
244
      this.resolveReady(this.texture);
28✔
245

246
      log.info(0, `${this} created`)();
28✔
247
    } catch (e) {
248
      const err = e instanceof Error ? e : new Error(String(e));
1!
249
      this.rejectReady(err);
1✔
250
    }
251
  }
252

253
  destroy(): void {
254
    if (this._texture) {
26✔
255
      this._texture.destroy();
25✔
256
      this._texture = null;
25✔
257
      this._sampler = null;
25✔
258
      this._view = null;
25✔
259
    }
260
    this.destroyed = true;
26✔
261
  }
262

263
  generateMipmaps(): void {
264
    if (this.device.type === 'webgl') {
11!
265
      this.texture.generateMipmapsWebGL();
×
266
    } else if (this.device.type === 'webgpu') {
11!
267
      this.device.generateMipmapsWebGPU(this.texture);
11✔
268
    } else {
269
      log.warn(`${this} mipmaps not supported on ${this.device.type}`);
×
270
    }
271
  }
272

273
  /** Set sampler or create one from props */
274
  setSampler(sampler: Sampler | SamplerProps = {}): void {
×
275
    this._checkReady();
×
276
    const s = sampler instanceof Sampler ? sampler : this.device.createSampler(sampler);
×
277
    this.texture.setSampler(s);
×
278
    this._sampler = s;
×
279
  }
280

281
  /**
282
   * Copies texture contents into a GPU buffer and waits until the copy is complete.
283
   * The caller owns the returned buffer and must destroy it when finished.
284
   */
285
  async readBuffer(options: TextureReadOptions = {}): Promise<Buffer> {
1✔
286
    if (!this.isReady) {
1!
NEW
287
      await this.ready;
×
288
    }
289

290
    const width = options.width ?? this.texture.width;
1✔
291
    const height = options.height ?? this.texture.height;
1✔
292
    const depthOrArrayLayers = options.depthOrArrayLayers ?? this.texture.depth;
1✔
293
    const layout = this.texture.computeMemoryLayout({width, height, depthOrArrayLayers});
1✔
294

295
    const buffer = this.device.createBuffer({
1✔
296
      byteLength: layout.byteLength,
297
      usage: Buffer.COPY_DST | Buffer.MAP_READ
298
    });
299

300
    this.texture.readBuffer(
1✔
301
      {
302
        ...options,
303
        width,
304
        height,
305
        depthOrArrayLayers
306
      },
307
      buffer
308
    );
309

310
    const fence = this.device.createFence();
1✔
311
    await fence.signaled;
1✔
312
    fence.destroy();
1✔
313

314
    return buffer;
1✔
315
  }
316

317
  /** Reads texture contents back to CPU memory. */
318
  async readAsync(options: TextureReadOptions = {}): Promise<ArrayBuffer> {
1✔
319
    if (!this.isReady) {
1!
NEW
320
      await this.ready;
×
321
    }
322

323
    const width = options.width ?? this.texture.width;
1✔
324
    const height = options.height ?? this.texture.height;
1✔
325
    const depthOrArrayLayers = options.depthOrArrayLayers ?? this.texture.depth;
1✔
326
    const layout = this.texture.computeMemoryLayout({width, height, depthOrArrayLayers});
1✔
327

328
    const buffer = await this.readBuffer(options);
1✔
329
    const data = await buffer.readAsync(0, layout.byteLength);
1✔
330
    buffer.destroy();
1✔
331
    return data.buffer;
1✔
332
  }
333

334
  /**
335
   * Resize by cloning the underlying immutable texture.
336
   * Does not copy contents; caller may need to re-upload and/or regenerate mips.
337
   */
338
  resize(size: {width: number; height: number}): boolean {
339
    this._checkReady();
×
340

341
    if (size.width === this.texture.width && size.height === this.texture.height) {
×
342
      return false;
×
343
    }
344
    const prev = this.texture;
×
345
    this._texture = prev.clone(size);
×
346
    this._sampler = this.texture.sampler;
×
347
    this._view = this.texture.view;
×
348

349
    prev.destroy();
×
350
    log.info(`${this} resized`);
×
351
    return true;
×
352
  }
353

354
  /** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
355
  getCubeFaceIndex(face: TextureCubeFace): number {
356
    const index = TEXTURE_CUBE_FACE_MAP[face];
×
357
    if (index === undefined) throw new Error(`Invalid cube face: ${face}`);
×
358
    return index;
×
359
  }
360

361
  /** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
362
  getCubeArrayFaceIndex(cubeIndex: number, face: TextureCubeFace): number {
363
    return 6 * cubeIndex + this.getCubeFaceIndex(face);
×
364
  }
365

366
  /** @note experimental: Set multiple mip levels (1D) */
367
  setTexture1DData(data: Texture1DData): void {
368
    this._checkReady();
×
369
    if (this.texture.props.dimension !== '1d') {
×
370
      throw new Error(`${this} is not 1d`);
×
371
    }
372
    const subresources = getTexture1DSubresources(data);
×
373
    this._setTextureSubresources(subresources);
×
374
  }
375

376
  /** @note experimental: Set multiple mip levels (2D), optionally at `z`, slice (depth/array level) index */
377
  setTexture2DData(lodData: Texture2DData, z: number = 0): void {
×
378
    this._checkReady();
×
379
    if (this.texture.props.dimension !== '2d') {
×
380
      throw new Error(`${this} is not 2d`);
×
381
    }
382

383
    const subresources = getTexture2DSubresources(z, lodData);
×
384
    this._setTextureSubresources(subresources);
×
385
  }
386

387
  /** 3D: multiple depth slices, each may carry multiple mip levels */
388
  setTexture3DData(data: Texture3DData): void {
389
    if (this.texture.props.dimension !== '3d') {
×
390
      throw new Error(`${this} is not 3d`);
×
391
    }
392
    const subresources = getTexture3DSubresources(data);
×
393
    this._setTextureSubresources(subresources);
×
394
  }
395

396
  /** 2D array: multiple layers, each may carry multiple mip levels */
397
  setTextureArrayData(data: TextureArrayData): void {
398
    if (this.texture.props.dimension !== '2d-array') {
×
399
      throw new Error(`${this} is not 2d-array`);
×
400
    }
401
    const subresources = getTextureArraySubresources(data);
×
402
    this._setTextureSubresources(subresources);
×
403
  }
404

405
  /** Cube: 6 faces, each may carry multiple mip levels */
406
  setTextureCubeData(data: TextureCubeData): void {
407
    if (this.texture.props.dimension !== 'cube') {
×
408
      throw new Error(`${this} is not cube`);
×
409
    }
410
    const subresources = getTextureCubeSubresources(data);
×
411
    this._setTextureSubresources(subresources);
×
412
  }
413

414
  /** Cube array: multiple cubes (faces×layers), each face may carry multiple mips */
415
  setTextureCubeArrayData(data: TextureCubeArrayData): void {
416
    if (this.texture.props.dimension !== 'cube-array') {
×
417
      throw new Error(`${this} is not cube-array`);
×
418
    }
419
    const subresources = getTextureCubeArraySubresources(data);
×
420
    this._setTextureSubresources(subresources);
×
421
  }
422

423
  /** Sets multiple mip levels on different `z` slices (depth/array index) */
424
  private _setTextureSubresources(subresources: TextureSubresource[]): void {
425
    // If user supplied multiple mip levels, warn if auto-mips also requested
426
    // if (lodArray.length > 1 && this.props.mipmaps !== false) {
427
    //   log.warn(
428
    //     `Texture ${this.id}: provided multiple LODs and also requested mipmap generation.`
429
    //   )();
430
    // }
431

432
    for (const subresource of subresources) {
28✔
433
      const {z, mipLevel} = subresource;
116✔
434
      switch (subresource.type) {
116!
435
        case 'external-image':
436
          const {image, flipY} = subresource;
61✔
437
          this.texture.copyExternalImage({image, z, mipLevel, flipY});
61✔
438
          break;
61✔
439
        case 'texture-data':
440
          const {data, textureFormat} = subresource;
55✔
441
          if (textureFormat && textureFormat !== this.texture.format) {
55!
442
            throw new Error(
×
443
              `${this} mip level ${mipLevel} uses format "${textureFormat}" but texture format is "${this.texture.format}"`
444
            );
445
          }
446
          this.texture.writeData(data.data, {
55✔
447
            x: 0,
448
            y: 0,
449
            z,
450
            width: data.width,
451
            height: data.height,
452
            depthOrArrayLayers: 1,
453
            mipLevel
454
          });
455
          break;
55✔
456
        default:
457
          throw new Error('Unsupported 2D mip-level payload');
×
458
      }
459
    }
460
  }
461

462
  // ------------------ helpers ------------------
463

464
  /** Recursively resolve all promises in data structures */
465
  private async _loadAllData(props: TextureDataAsyncProps): Promise<TextureDataProps> {
466
    const syncData = await awaitAllPromises(props.data);
29✔
467
    const dimension = (props.dimension ?? '2d') as TextureDataProps['dimension'];
29✔
468
    return {dimension, data: syncData ?? null} as TextureDataProps;
29!
469
  }
470

471
  private _checkNotDestroyed() {
472
    if (this.destroyed) {
29!
473
      log.warn(`${this} already destroyed`);
×
474
    }
475
  }
476

477
  private _checkReady() {
478
    if (!this.isReady) {
×
479
      log.warn(`${this} Cannot perform this operation before ready`);
×
480
    }
481
  }
482

483
  static defaultProps: Required<DynamicTextureProps> = {
65✔
484
    ...Texture.defaultProps,
485
    dimension: '2d',
486
    data: null,
487
    mipmaps: false
488
  };
489
}
490

491
type TextureSubresourceAnalysis = {
492
  readonly subresources: TextureSubresource[];
493
  readonly mipLevels: number;
494
  readonly format?: TextureFormat;
495
  readonly hasExplicitMipChain: boolean;
496
};
497

498
// Flatten dimension-specific texture data into one list of uploadable subresources.
499
function getTextureSubresources(
500
  props: TextureDataProps & Partial<Pick<TextureProps, 'width' | 'height' | 'format'>>
501
): TextureSubresource[] {
502
  if (!props.data) {
29!
503
    return [];
×
504
  }
505

506
  const baseLevelSize =
507
    props.width && props.height ? {width: props.width, height: props.height} : undefined;
29✔
508
  const textureFormat = 'format' in props ? props.format : undefined;
29!
509

510
  switch (props.dimension) {
29!
511
    case '1d':
512
      return getTexture1DSubresources(props.data);
×
513
    case '2d':
514
      return getTexture2DSubresources(0, props.data, baseLevelSize, textureFormat);
20✔
515
    case '3d':
516
      return getTexture3DSubresources(props.data);
3✔
517
    case '2d-array':
518
      return getTextureArraySubresources(props.data);
1✔
519
    case 'cube':
520
      return getTextureCubeSubresources(props.data);
4✔
521
    case 'cube-array':
522
      return getTextureCubeArraySubresources(props.data);
1✔
523
    default:
524
      throw new Error(`Unhandled dimension ${(props as TextureDataProps).dimension}`);
×
525
  }
526
}
527

528
// Resolve a consistent texture format and the longest mip chain valid across all slices.
529
function analyzeTextureSubresources(
530
  device: Device,
531
  subresources: TextureSubresource[],
532
  size: {width: number; height: number},
533
  options: {format?: TextureFormat}
534
): TextureSubresourceAnalysis {
535
  if (subresources.length === 0) {
28!
536
    return {
×
537
      subresources,
538
      mipLevels: 1,
539
      format: options.format,
540
      hasExplicitMipChain: false
541
    };
542
  }
543

544
  const groupedSubresources = new Map<number, TextureSubresource[]>();
28✔
545
  for (const subresource of subresources) {
28✔
546
    const group = groupedSubresources.get(subresource.z) ?? [];
119✔
547
    group.push(subresource);
119✔
548
    groupedSubresources.set(subresource.z, group);
119✔
549
  }
550

551
  const hasExplicitMipChain = subresources.some(subresource => subresource.mipLevel > 0);
66✔
552
  let resolvedFormat = options.format;
28✔
553
  let resolvedMipLevels = Number.POSITIVE_INFINITY;
28✔
554
  const validSubresources: TextureSubresource[] = [];
28✔
555

556
  for (const [z, sliceSubresources] of groupedSubresources) {
28✔
557
    // Validate each slice independently, then keep only the mip levels that are valid everywhere.
558
    const sortedSubresources = [...sliceSubresources].sort(
65✔
559
      (left, right) => left.mipLevel - right.mipLevel
54✔
560
    );
561
    const baseLevel = sortedSubresources[0];
65✔
562
    if (!baseLevel || baseLevel.mipLevel !== 0) {
65!
563
      throw new Error(`DynamicTexture: slice ${z} is missing mip level 0`);
×
564
    }
565

566
    const baseSize = getTextureSubresourceSize(device, baseLevel);
65✔
567
    if (baseSize.width !== size.width || baseSize.height !== size.height) {
65!
568
      throw new Error(
×
569
        `DynamicTexture: slice ${z} base level dimensions ${baseSize.width}x${baseSize.height} do not match expected ${size.width}x${size.height}`
570
      );
571
    }
572

573
    const baseFormat = getTextureSubresourceFormat(baseLevel);
65✔
574
    if (baseFormat) {
65✔
575
      if (resolvedFormat && resolvedFormat !== baseFormat) {
13!
576
        throw new Error(
×
577
          `DynamicTexture: slice ${z} base level format "${baseFormat}" does not match texture format "${resolvedFormat}"`
578
        );
579
      }
580
      resolvedFormat = baseFormat;
13✔
581
    }
582

583
    const mipLevelLimit =
584
      resolvedFormat && device.isTextureFormatCompressed(resolvedFormat)
65✔
585
        ? // Block-compressed formats cannot have mips smaller than a single compression block.
586
          getMaxCompressedMipLevels(device, baseSize.width, baseSize.height, resolvedFormat)
587
        : device.getMipLevelCount(baseSize.width, baseSize.height);
588

589
    let validMipLevelsForSlice = 0;
65✔
590
    for (
65✔
591
      let expectedMipLevel = 0;
65✔
592
      expectedMipLevel < sortedSubresources.length;
593
      expectedMipLevel++
594
    ) {
595
      const subresource = sortedSubresources[expectedMipLevel];
119✔
596
      // Stop at the first gap so callers can provide extra trailing data without breaking creation.
597
      if (!subresource || subresource.mipLevel !== expectedMipLevel) {
119!
598
        break;
×
599
      }
600
      if (expectedMipLevel >= mipLevelLimit) {
119✔
601
        break;
1✔
602
      }
603

604
      const subresourceSize = getTextureSubresourceSize(device, subresource);
118✔
605
      const expectedWidth = Math.max(1, baseSize.width >> expectedMipLevel);
118✔
606
      const expectedHeight = Math.max(1, baseSize.height >> expectedMipLevel);
118✔
607
      if (subresourceSize.width !== expectedWidth || subresourceSize.height !== expectedHeight) {
118✔
608
        break;
1✔
609
      }
610

611
      const subresourceFormat = getTextureSubresourceFormat(subresource);
117✔
612
      if (subresourceFormat) {
117✔
613
        if (!resolvedFormat) {
17!
614
          resolvedFormat = subresourceFormat;
×
615
        }
616
        // Later mip levels must stay on the same format as the validated base level.
617
        if (subresourceFormat !== resolvedFormat) {
17✔
618
          break;
1✔
619
        }
620
      }
621

622
      validMipLevelsForSlice++;
116✔
623
      validSubresources.push(subresource);
116✔
624
    }
625

626
    resolvedMipLevels = Math.min(resolvedMipLevels, validMipLevelsForSlice);
65✔
627
  }
628

629
  const mipLevels = Number.isFinite(resolvedMipLevels) ? Math.max(1, resolvedMipLevels) : 1;
28!
630

631
  return {
28✔
632
    // Keep every slice trimmed to the same mip count so the texture shape stays internally consistent.
633
    subresources: validSubresources.filter(subresource => subresource.mipLevel < mipLevels),
116✔
634
    mipLevels,
635
    format: resolvedFormat,
636
    hasExplicitMipChain
637
  };
638
}
639

640
// Read the per-level format using the transitional textureFormat -> format fallback rules.
641
function getTextureSubresourceFormat(subresource: TextureSubresource): TextureFormat | undefined {
642
  if (subresource.type !== 'texture-data') {
182✔
643
    return undefined;
74✔
644
  }
645
  return subresource.textureFormat ?? resolveTextureImageFormat(subresource.data);
108✔
646
}
647

648
// Resolve dimensions from either raw bytes or external-image subresources.
649
function getTextureSubresourceSize(
650
  device: Device,
651
  subresource: TextureSubresource
652
): {width: number; height: number} {
653
  switch (subresource.type) {
183!
654
    case 'external-image':
655
      return device.getExternalImageSize(subresource.image);
74✔
656
    case 'texture-data':
657
      return {width: subresource.data.width, height: subresource.data.height};
109✔
658
    default:
659
      throw new Error('Unsupported texture subresource');
×
660
  }
661
}
662

663
// Count the mip levels that stay at or above one compression block in each dimension.
664
function getMaxCompressedMipLevels(
665
  device: Device,
666
  baseWidth: number,
667
  baseHeight: number,
668
  format: TextureFormat
669
): number {
670
  const {blockWidth = 1, blockHeight = 1} = device.getTextureFormatInfo(format);
6✔
671
  let mipLevels = 1;
6✔
672
  for (let mipLevel = 1; ; mipLevel++) {
6✔
673
    const width = Math.max(1, baseWidth >> mipLevel);
16✔
674
    const height = Math.max(1, baseHeight >> mipLevel);
16✔
675
    if (width < blockWidth || height < blockHeight) {
16✔
676
      break;
6✔
677
    }
678
    mipLevels++;
10✔
679
  }
680
  return mipLevels;
6✔
681
}
682

683
// HELPERS
684

685
/** Resolve all promises in a nested data structure */
686
async function awaitAllPromises(x: any): Promise<any> {
687
  x = await x;
346✔
688
  if (Array.isArray(x)) {
346✔
689
    return await Promise.all(x.map(awaitAllPromises));
18✔
690
  }
691
  if (x && typeof x === 'object' && x.constructor === Object) {
328✔
692
    const object: Record<string, any> = x;
63✔
693
    const values = await Promise.all(Object.values(object).map(awaitAllPromises));
63✔
694
    const keys = Object.keys(object);
63✔
695
    const resolvedObject: Record<string, any> = {};
63✔
696
    for (let i = 0; i < keys.length; i++) {
63✔
697
      resolvedObject[keys[i]] = values[i];
238✔
698
    }
699
    return resolvedObject;
63✔
700
  }
701
  return x;
265✔
702
}
703

704
// /** @note experimental: Set multiple mip levels (2D), optionally at `z`, slice (depth/array level) index */
705
// setTexture2DData(lodData: Texture2DData, z: number = 0): void {
706
//   this._checkReady();
707

708
//   const lodArray = this._normalizeTexture2DData(lodData);
709

710
//   // If user supplied multiple mip levels, warn if auto-mips also requested
711
//   if (lodArray.length > 1 && this.props.mipmaps !== false) {
712
//     log.warn(
713
//       `Texture ${this.id}: provided multiple LODs and also requested mipmap generation.`
714
//     )();
715
//   }
716

717
//   for (let mipLevel = 0; mipLevel < lodArray.length; mipLevel++) {
718
//     const imageData = lodArray[mipLevel];
719
//     if (this.device.isExternalImage(imageData)) {
720
//       this.texture.copyExternalImage({image: imageData, z, mipLevel, flipY: true});
721
//     } else if (this._isTextureImageData(imageData)) {
722
//       this.texture.copyImageData({data: imageData.data, z, mipLevel});
723
//     } else {
724
//       throw new Error('Unsupported 2D mip-level payload');
725
//     }
726
//   }
727
// }
728

729
// /** Normalize 2D layer payload into an array of mip-level items */
730
// private _normalizeTexture2DData(data: Texture2DData): (TextureImageData | ExternalImage)[] {
731
//   return Array.isArray(data) ? data : [data];
732
// }
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