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

visgl / luma.gl / 25756190722

12 May 2026 07:06PM UTC coverage: 75.119% (+0.2%) from 74.932%
25756190722

push

github

web-flow
feat(arrow) Support RecordBatch stream to ArrowGPUTable (#2611)

5973 of 8932 branches covered (66.87%)

Branch coverage included in aggregate %.

271 of 333 new or added lines in 9 files covered. (81.38%)

3 existing lines in 2 files now uncovered.

13032 of 16368 relevant lines covered (79.62%)

831.06 hits per line

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

75.57
/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
  // texture slice/mip data types
22
  type TextureSubresource,
23
  // props (dimension + data)
24
  type TextureDataProps,
25
  type TextureDataAsyncProps,
26
  // combined data for different texture types
27
  type Texture1DData,
28
  type Texture2DData,
29
  type Texture3DData,
30
  type TextureArrayData,
31
  type TextureCubeArrayData,
32
  type TextureCubeData,
33
  // Helpers
34
  getTextureSizeFromData,
35
  resolveTextureImageFormat,
36
  getTexture1DSubresources,
37
  getTexture2DSubresources,
38
  getTexture3DSubresources,
39
  getTextureCubeSubresources,
40
  getTextureArraySubresources,
41
  getTextureCubeArraySubresources
42
} from './texture-data';
43

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

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

82
  /** Props with defaults resolved (except `data` which is processed separately) */
83
  props: Readonly<Required<DynamicTextureProps>>;
84

85
  /** Created resources */
86
  private _texture: Texture | null = null;
41✔
87
  private _sampler: Sampler | null = null;
41✔
88
  private _view: TextureView | null = null;
41✔
89

90
  /** Ready when GPU texture has been created and data (if any) uploaded */
91
  readonly ready: Promise<Texture>;
92
  isReady = false;
41✔
93
  destroyed = false;
41✔
94
  /** Monotonic version that increments whenever texture, view, or sampler identity changes. */
95
  generation = 0;
41✔
96
  /** Last update timestamp for texture content, readiness, or identity changes. */
97
  updateTimestamp: number;
98
  /** Token replaced whenever cache users need a new resource identity. */
99
  cacheToken: object = {};
41✔
100

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

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

117
  get [Symbol.toStringTag]() {
118
    return 'DynamicTexture';
×
119
  }
120
  toString(): string {
121
    const width = this._texture?.width ?? this.props.width ?? '?';
42!
122
    const height = this._texture?.height ?? this.props.height ?? '?';
42!
123
    return `DynamicTexture:"${this.id}":${width}x${height}px:(${this.isReady ? 'ready' : 'loading...'})`;
42!
124
  }
125

126
  constructor(device: Device, props: DynamicTextureProps) {
127
    this.device = device;
41✔
128

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

135
    this.ready = new Promise<Texture>((resolve, reject) => {
41✔
136
      this.resolveReady = resolve;
41✔
137
      this.rejectReady = reject;
41✔
138
    });
139
    this.updateTimestamp = this.device.incrementTimestamp();
41✔
140

141
    this.initAsync(originalPropsWithAsyncData);
41✔
142
  }
143

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

153
      const propsWithSyncData = await this._loadAllData(originalPropsWithAsyncData);
41✔
154
      this._checkNotDestroyed();
41✔
155
      const subresources = propsWithSyncData.data
41✔
156
        ? getTextureSubresources({
157
            ...propsWithSyncData,
158
            width: originalPropsWithAsyncData.width,
159
            height: originalPropsWithAsyncData.height,
160
            format: originalPropsWithAsyncData.format
161
          })
162
        : [];
163
      const userProvidedFormat =
164
        'format' in originalPropsWithAsyncData && originalPropsWithAsyncData.format !== undefined;
41✔
165
      const userProvidedUsage =
166
        'usage' in originalPropsWithAsyncData && originalPropsWithAsyncData.usage !== undefined;
41✔
167

168
      // Deduce size when not explicitly provided
169
      // TODO - what about depth?
170
      const deduceSize = (): {width: number; height: number} => {
41✔
171
        if (this.props.width && this.props.height) {
40✔
172
          return {width: this.props.width, height: this.props.height};
15✔
173
        }
174

175
        const size = getTextureSizeFromData(propsWithSyncData);
25✔
176
        if (size) {
25!
177
          return size;
25✔
178
        }
179

180
        return {width: this.props.width || 1, height: this.props.height || 1};
×
181
      };
182

183
      const size = deduceSize();
41✔
184
      if (!size || size.width <= 0 || size.height <= 0) {
41!
185
        throw new Error(`${this} size could not be determined or was zero`);
×
186
      }
187

188
      // Normalize caller-provided subresources into one validated mip chain description.
189
      const textureData = analyzeTextureSubresources(this.device, subresources, size, {
40✔
190
        format: userProvidedFormat ? originalPropsWithAsyncData.format : undefined
40✔
191
      });
192
      const resolvedFormat = textureData.format ?? this.props.format;
41✔
193

194
      // Create a minimal TextureProps and validate via `satisfies`
195
      const baseTextureProps = {
41✔
196
        ...this.props,
197
        ...size,
198
        format: resolvedFormat,
199
        mipLevels: 1, // temporary; updated below
200
        data: undefined
201
      } satisfies TextureProps;
202

203
      if (this.device.isTextureFormatCompressed(resolvedFormat) && !userProvidedUsage) {
41✔
204
        baseTextureProps.usage = Texture.SAMPLE | Texture.COPY_DST;
6✔
205
      }
206

207
      // Explicit mip arrays take ownership of the mip chain; otherwise we may auto-generate it.
208
      const shouldGenerateMipmaps =
209
        this.props.mipmaps &&
40✔
210
        !textureData.hasExplicitMipChain &&
211
        !this.device.isTextureFormatCompressed(resolvedFormat);
212

213
      if (this.device.type === 'webgpu' && shouldGenerateMipmaps) {
41✔
214
        const requiredUsage =
215
          this.props.dimension === '3d'
5✔
216
            ? Texture.SAMPLE | Texture.STORAGE | Texture.COPY_DST | Texture.COPY_SRC
217
            : Texture.SAMPLE | Texture.RENDER | Texture.COPY_DST | Texture.COPY_SRC;
218
        baseTextureProps.usage |= requiredUsage;
5✔
219
      }
220

221
      // Compute mip levels (auto clamps to max)
222
      const maxMips = this.device.getMipLevelCount(baseTextureProps.width, baseTextureProps.height);
40✔
223
      const desired = textureData.hasExplicitMipChain
40✔
224
        ? textureData.mipLevels
225
        : this.props.mipLevels === 'auto'
34✔
226
          ? maxMips
227
          : Math.max(1, Math.min(maxMips, this.props.mipLevels ?? 1));
29!
228

229
      const finalTextureProps: TextureProps = {...baseTextureProps, mipLevels: desired};
41✔
230

231
      this._texture = this.device.createTexture(finalTextureProps);
41✔
232
      this._sampler = this.texture.sampler;
41✔
233
      this._view = this.texture.view;
41✔
234
      this._touchGeneration();
41✔
235

236
      // Upload data if provided
237
      if (textureData.subresources.length) {
41✔
238
        this._setTextureSubresources(textureData.subresources);
39✔
239
      }
240

241
      if (this.props.mipmaps && !textureData.hasExplicitMipChain && !shouldGenerateMipmaps) {
40!
242
        log.warn(`${this} skipping auto-generated mipmaps for compressed texture format`)();
×
243
      }
244

245
      if (shouldGenerateMipmaps) {
40✔
246
        this.generateMipmaps();
5✔
247
      }
248

249
      this.isReady = true;
40✔
250
      this.resolveReady(this.texture);
40✔
251

252
      log.info(1, `${this} created`)();
40✔
253
    } catch (e) {
254
      const err = e instanceof Error ? e : new Error(String(e));
1!
255
      this.rejectReady(err);
1✔
256
    }
257
  }
258

259
  destroy(): void {
260
    if (this._texture) {
38✔
261
      this._texture.destroy();
37✔
262
      this._texture = null;
37✔
263
      this._sampler = null;
37✔
264
      this._view = null;
37✔
265
    }
266
    this.destroyed = true;
38✔
267
  }
268

269
  generateMipmaps(): void {
270
    if (this.device.type === 'webgl') {
11!
271
      this.texture.generateMipmapsWebGL();
×
NEW
272
      this._touch();
×
273
    } else if (this.device.type === 'webgpu') {
11!
274
      this.device.generateMipmapsWebGPU(this.texture);
11✔
275
      this._touch();
11✔
276
    } else {
277
      log.warn(`${this} mipmaps not supported on ${this.device.type}`);
×
278
    }
279
  }
280

281
  /** Set sampler or create one from props */
282
  setSampler(sampler: Sampler | SamplerProps = {}): void {
×
283
    this._checkReady();
×
284
    const s = sampler instanceof Sampler ? sampler : this.device.createSampler(sampler);
×
285
    this.texture.setSampler(s);
×
286
    this._sampler = s;
×
NEW
287
    this._touchGeneration();
×
288
  }
289

290
  /**
291
   * Copies texture contents into a GPU buffer and waits until the copy is complete.
292
   * The caller owns the returned buffer and must destroy it when finished.
293
   */
294
  async readBuffer(options: TextureReadOptions = {}): Promise<Buffer> {
1✔
295
    if (!this.isReady) {
1!
296
      await this.ready;
×
297
    }
298

299
    const width = options.width ?? this.texture.width;
1✔
300
    const height = options.height ?? this.texture.height;
1✔
301
    const depthOrArrayLayers = options.depthOrArrayLayers ?? this.texture.depth;
1✔
302
    const layout = this.texture.computeMemoryLayout({width, height, depthOrArrayLayers});
1✔
303

304
    const buffer = this.device.createBuffer({
1✔
305
      byteLength: layout.byteLength,
306
      usage: Buffer.COPY_DST | Buffer.MAP_READ
307
    });
308

309
    this.texture.readBuffer(
1✔
310
      {
311
        ...options,
312
        width,
313
        height,
314
        depthOrArrayLayers
315
      },
316
      buffer
317
    );
318

319
    const fence = this.device.createFence();
1✔
320
    await fence.signaled;
1✔
321
    fence.destroy();
1✔
322

323
    return buffer;
1✔
324
  }
325

326
  /** Reads texture contents back to CPU memory. */
327
  async readAsync(options: TextureReadOptions = {}): Promise<ArrayBuffer> {
1✔
328
    if (!this.isReady) {
1!
329
      await this.ready;
×
330
    }
331

332
    const width = options.width ?? this.texture.width;
1✔
333
    const height = options.height ?? this.texture.height;
1✔
334
    const depthOrArrayLayers = options.depthOrArrayLayers ?? this.texture.depth;
1✔
335
    const layout = this.texture.computeMemoryLayout({width, height, depthOrArrayLayers});
1✔
336

337
    const buffer = await this.readBuffer(options);
1✔
338
    const data = await buffer.readAsync(0, layout.byteLength);
1✔
339
    buffer.destroy();
1✔
340
    return data.buffer instanceof ArrayBuffer ? data.buffer : data.slice().buffer;
1!
341
  }
342

343
  /**
344
   * Resize by cloning the underlying immutable texture.
345
   * Does not copy contents; caller may need to re-upload and/or regenerate mips.
346
   */
347
  resize(size: {width: number; height: number}): boolean {
348
    this._checkReady();
2✔
349

350
    if (size.width === this.texture.width && size.height === this.texture.height) {
2!
351
      return false;
×
352
    }
353
    const prev = this.texture;
2✔
354
    this._texture = prev.clone(size);
2✔
355
    this._sampler = this.texture.sampler;
2✔
356
    this._view = this.texture.view;
2✔
357

358
    prev.destroy();
2✔
359
    this._touchGeneration();
2✔
360
    log.info(`${this} resized`);
2✔
361
    return true;
2✔
362
  }
363

364
  /** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
365
  getCubeFaceIndex(face: TextureCubeFace): number {
366
    const index = TEXTURE_CUBE_FACE_MAP[face];
×
367
    if (index === undefined) throw new Error(`Invalid cube face: ${face}`);
×
368
    return index;
×
369
  }
370

371
  /** Convert cube face label to texture slice index. Index can be used with `setTexture2DData()`. */
372
  getCubeArrayFaceIndex(cubeIndex: number, face: TextureCubeFace): number {
373
    return 6 * cubeIndex + this.getCubeFaceIndex(face);
×
374
  }
375

376
  /** @note experimental: Set multiple mip levels (1D) */
377
  setTexture1DData(data: Texture1DData): void {
378
    this._checkReady();
×
379
    if (this.texture.props.dimension !== '1d') {
×
380
      throw new Error(`${this} is not 1d`);
×
381
    }
382
    const subresources = getTexture1DSubresources(data);
×
383
    this._setTextureSubresources(subresources);
×
384
  }
385

386
  /** @note experimental: Set multiple mip levels (2D), optionally at `z`, slice (depth/array level) index */
387
  setTexture2DData(lodData: Texture2DData, z: number = 0): void {
1✔
388
    this._checkReady();
1✔
389
    if (this.texture.props.dimension !== '2d') {
1!
390
      throw new Error(`${this} is not 2d`);
×
391
    }
392

393
    const subresources = getTexture2DSubresources(z, lodData);
1✔
394
    this._setTextureSubresources(subresources);
1✔
395
  }
396

397
  /** 3D: multiple depth slices, each may carry multiple mip levels */
398
  setTexture3DData(data: Texture3DData): void {
399
    if (this.texture.props.dimension !== '3d') {
×
400
      throw new Error(`${this} is not 3d`);
×
401
    }
402
    const subresources = getTexture3DSubresources(data);
×
403
    this._setTextureSubresources(subresources);
×
404
  }
405

406
  /** 2D array: multiple layers, each may carry multiple mip levels */
407
  setTextureArrayData(data: TextureArrayData): void {
408
    if (this.texture.props.dimension !== '2d-array') {
×
409
      throw new Error(`${this} is not 2d-array`);
×
410
    }
411
    const subresources = getTextureArraySubresources(data);
×
412
    this._setTextureSubresources(subresources);
×
413
  }
414

415
  /** Cube: 6 faces, each may carry multiple mip levels */
416
  setTextureCubeData(data: TextureCubeData): void {
417
    if (this.texture.props.dimension !== 'cube') {
×
418
      throw new Error(`${this} is not cube`);
×
419
    }
420
    const subresources = getTextureCubeSubresources(data);
×
421
    this._setTextureSubresources(subresources);
×
422
  }
423

424
  /** Cube array: multiple cubes (faces×layers), each face may carry multiple mips */
425
  setTextureCubeArrayData(data: TextureCubeArrayData): void {
426
    if (this.texture.props.dimension !== 'cube-array') {
×
427
      throw new Error(`${this} is not cube-array`);
×
428
    }
429
    const subresources = getTextureCubeArraySubresources(data);
×
430
    this._setTextureSubresources(subresources);
×
431
  }
432

433
  /** Sets multiple mip levels on different `z` slices (depth/array index) */
434
  private _setTextureSubresources(subresources: TextureSubresource[]): void {
435
    // If user supplied multiple mip levels, warn if auto-mips also requested
436
    // if (lodArray.length > 1 && this.props.mipmaps !== false) {
437
    //   log.warn(
438
    //     `Texture ${this.id}: provided multiple LODs and also requested mipmap generation.`
439
    //   )();
440
    // }
441

442
    for (const subresource of subresources) {
40✔
443
      const {z, mipLevel} = subresource;
128✔
444
      switch (subresource.type) {
128!
445
        case 'external-image':
446
          const {image, flipY} = subresource;
61✔
447
          this.texture.copyExternalImage({image, z, mipLevel, flipY});
61✔
448
          break;
61✔
449
        case 'texture-data':
450
          const {data, textureFormat} = subresource;
67✔
451
          if (textureFormat && textureFormat !== this.texture.format) {
67!
452
            throw new Error(
×
453
              `${this} mip level ${mipLevel} uses format "${textureFormat}" but texture format is "${this.texture.format}"`
454
            );
455
          }
456
          this.texture.writeData(data.data, {
67✔
457
            x: 0,
458
            y: 0,
459
            z,
460
            width: data.width,
461
            height: data.height,
462
            depthOrArrayLayers: 1,
463
            mipLevel
464
          });
465
          break;
67✔
466
        default:
467
          throw new Error('Unsupported 2D mip-level payload');
×
468
      }
469
    }
470
    if (subresources.length > 0) {
40!
471
      this._touch();
40✔
472
    }
473
  }
474

475
  // ------------------ helpers ------------------
476

477
  /** Recursively resolve all promises in data structures */
478
  private async _loadAllData(props: TextureDataAsyncProps): Promise<TextureDataProps> {
479
    const syncData = await awaitAllPromises(props.data);
41✔
480
    const dimension = (props.dimension ?? '2d') as TextureDataProps['dimension'];
41✔
481
    return {dimension, data: syncData ?? null} as TextureDataProps;
41✔
482
  }
483

484
  private _checkNotDestroyed() {
485
    if (this.destroyed) {
41!
486
      log.warn(`${this} already destroyed`);
×
487
    }
488
  }
489

490
  private _checkReady() {
491
    if (!this.isReady) {
3!
492
      log.warn(`${this} Cannot perform this operation before ready`);
×
493
    }
494
  }
495

496
  private _touch(): void {
497
    this.updateTimestamp = this.device.incrementTimestamp();
88✔
498
  }
499

500
  private _touchGeneration(): void {
501
    this.generation++;
42✔
502
    this.cacheToken = {};
42✔
503
    this._touch();
42✔
504
  }
505

506
  static defaultProps: Required<DynamicTextureProps> = {
85✔
507
    ...Texture.defaultProps,
508
    dimension: '2d',
509
    data: null,
510
    mipmaps: false
511
  };
512
}
513

514
type TextureSubresourceAnalysis = {
515
  readonly subresources: TextureSubresource[];
516
  readonly mipLevels: number;
517
  readonly format?: TextureFormat;
518
  readonly hasExplicitMipChain: boolean;
519
};
520

521
// Flatten dimension-specific texture data into one list of uploadable subresources.
522
function getTextureSubresources(
523
  props: TextureDataProps & Partial<Pick<TextureProps, 'width' | 'height' | 'format'>>
524
): TextureSubresource[] {
525
  if (!props.data) {
40!
526
    return [];
×
527
  }
528

529
  const baseLevelSize =
530
    props.width && props.height ? {width: props.width, height: props.height} : undefined;
40✔
531
  const textureFormat = 'format' in props ? props.format : undefined;
40!
532

533
  switch (props.dimension) {
40!
534
    case '1d':
535
      return getTexture1DSubresources(props.data);
×
536
    case '2d':
537
      return getTexture2DSubresources(0, props.data, baseLevelSize, textureFormat);
31✔
538
    case '3d':
539
      return getTexture3DSubresources(props.data);
3✔
540
    case '2d-array':
541
      return getTextureArraySubresources(props.data);
1✔
542
    case 'cube':
543
      return getTextureCubeSubresources(props.data);
4✔
544
    case 'cube-array':
545
      return getTextureCubeArraySubresources(props.data);
1✔
546
    default:
547
      throw new Error(`Unhandled dimension ${(props as TextureDataProps).dimension}`);
×
548
  }
549
}
550

551
// Resolve a consistent texture format and the longest mip chain valid across all slices.
552
function analyzeTextureSubresources(
553
  device: Device,
554
  subresources: TextureSubresource[],
555
  size: {width: number; height: number},
556
  options: {format?: TextureFormat}
557
): TextureSubresourceAnalysis {
558
  if (subresources.length === 0) {
40✔
559
    return {
1✔
560
      subresources,
561
      mipLevels: 1,
562
      format: options.format,
563
      hasExplicitMipChain: false
564
    };
565
  }
566

567
  const groupedSubresources = new Map<number, TextureSubresource[]>();
39✔
568
  for (const subresource of subresources) {
39✔
569
    const group = groupedSubresources.get(subresource.z) ?? [];
130✔
570
    group.push(subresource);
130✔
571
    groupedSubresources.set(subresource.z, group);
130✔
572
  }
573

574
  const hasExplicitMipChain = subresources.some(subresource => subresource.mipLevel > 0);
77✔
575
  let resolvedFormat = options.format;
39✔
576
  let resolvedMipLevels = Number.POSITIVE_INFINITY;
39✔
577
  const validSubresources: TextureSubresource[] = [];
39✔
578

579
  for (const [z, sliceSubresources] of groupedSubresources) {
39✔
580
    // Validate each slice independently, then keep only the mip levels that are valid everywhere.
581
    const sortedSubresources = [...sliceSubresources].sort(
76✔
582
      (left, right) => left.mipLevel - right.mipLevel
54✔
583
    );
584
    const baseLevel = sortedSubresources[0];
76✔
585
    if (!baseLevel || baseLevel.mipLevel !== 0) {
76!
586
      throw new Error(`DynamicTexture: slice ${z} is missing mip level 0`);
×
587
    }
588

589
    const baseSize = getTextureSubresourceSize(device, baseLevel);
76✔
590
    if (baseSize.width !== size.width || baseSize.height !== size.height) {
76!
591
      throw new Error(
×
592
        `DynamicTexture: slice ${z} base level dimensions ${baseSize.width}x${baseSize.height} do not match expected ${size.width}x${size.height}`
593
      );
594
    }
595

596
    const baseFormat = getTextureSubresourceFormat(baseLevel);
76✔
597
    if (baseFormat) {
76✔
598
      if (resolvedFormat && resolvedFormat !== baseFormat) {
23!
599
        throw new Error(
×
600
          `DynamicTexture: slice ${z} base level format "${baseFormat}" does not match texture format "${resolvedFormat}"`
601
        );
602
      }
603
      resolvedFormat = baseFormat;
23✔
604
    }
605

606
    const mipLevelLimit =
607
      resolvedFormat && device.isTextureFormatCompressed(resolvedFormat)
76✔
608
        ? // Block-compressed formats cannot have mips smaller than a single compression block.
609
          getMaxCompressedMipLevels(device, baseSize.width, baseSize.height, resolvedFormat)
610
        : device.getMipLevelCount(baseSize.width, baseSize.height);
611

612
    let validMipLevelsForSlice = 0;
76✔
613
    for (
76✔
614
      let expectedMipLevel = 0;
76✔
615
      expectedMipLevel < sortedSubresources.length;
616
      expectedMipLevel++
617
    ) {
618
      const subresource = sortedSubresources[expectedMipLevel];
130✔
619
      // Stop at the first gap so callers can provide extra trailing data without breaking creation.
620
      if (!subresource || subresource.mipLevel !== expectedMipLevel) {
130!
621
        break;
×
622
      }
623
      if (expectedMipLevel >= mipLevelLimit) {
130✔
624
        break;
1✔
625
      }
626

627
      const subresourceSize = getTextureSubresourceSize(device, subresource);
129✔
628
      const expectedWidth = Math.max(1, baseSize.width >> expectedMipLevel);
129✔
629
      const expectedHeight = Math.max(1, baseSize.height >> expectedMipLevel);
129✔
630
      if (subresourceSize.width !== expectedWidth || subresourceSize.height !== expectedHeight) {
129✔
631
        break;
1✔
632
      }
633

634
      const subresourceFormat = getTextureSubresourceFormat(subresource);
128✔
635
      if (subresourceFormat) {
128✔
636
        if (!resolvedFormat) {
27!
637
          resolvedFormat = subresourceFormat;
×
638
        }
639
        // Later mip levels must stay on the same format as the validated base level.
640
        if (subresourceFormat !== resolvedFormat) {
27✔
641
          break;
1✔
642
        }
643
      }
644

645
      validMipLevelsForSlice++;
127✔
646
      validSubresources.push(subresource);
127✔
647
    }
648

649
    resolvedMipLevels = Math.min(resolvedMipLevels, validMipLevelsForSlice);
76✔
650
  }
651

652
  const mipLevels = Number.isFinite(resolvedMipLevels) ? Math.max(1, resolvedMipLevels) : 1;
39!
653

654
  return {
40✔
655
    // Keep every slice trimmed to the same mip count so the texture shape stays internally consistent.
656
    subresources: validSubresources.filter(subresource => subresource.mipLevel < mipLevels),
127✔
657
    mipLevels,
658
    format: resolvedFormat,
659
    hasExplicitMipChain
660
  };
661
}
662

663
// Read the per-level format using the transitional textureFormat -> format fallback rules.
664
function getTextureSubresourceFormat(subresource: TextureSubresource): TextureFormat | undefined {
665
  if (subresource.type !== 'texture-data') {
204✔
666
    return undefined;
74✔
667
  }
668
  return subresource.textureFormat ?? resolveTextureImageFormat(subresource.data);
130✔
669
}
670

671
// Resolve dimensions from either raw bytes or external-image subresources.
672
function getTextureSubresourceSize(
673
  device: Device,
674
  subresource: TextureSubresource
675
): {width: number; height: number} {
676
  switch (subresource.type) {
205!
677
    case 'external-image':
678
      return device.getExternalImageSize(subresource.image);
74✔
679
    case 'texture-data':
680
      return {width: subresource.data.width, height: subresource.data.height};
131✔
681
    default:
682
      throw new Error('Unsupported texture subresource');
×
683
  }
684
}
685

686
// Count the mip levels that stay at or above one compression block in each dimension.
687
function getMaxCompressedMipLevels(
688
  device: Device,
689
  baseWidth: number,
690
  baseHeight: number,
691
  format: TextureFormat
692
): number {
693
  const {blockWidth = 1, blockHeight = 1} = device.getTextureFormatInfo(format);
6✔
694
  let mipLevels = 1;
6✔
695
  for (let mipLevel = 1; ; mipLevel++) {
6✔
696
    const width = Math.max(1, baseWidth >> mipLevel);
16✔
697
    const height = Math.max(1, baseHeight >> mipLevel);
16✔
698
    if (width < blockWidth || height < blockHeight) {
16✔
699
      break;
6✔
700
    }
701
    mipLevels++;
10✔
702
  }
703
  return mipLevels;
6✔
704
}
705

706
// HELPERS
707

708
/** Resolve all promises in a nested data structure */
709
async function awaitAllPromises(x: any): Promise<any> {
710
  x = await x;
401✔
711
  if (Array.isArray(x)) {
401✔
712
    return await Promise.all(x.map(awaitAllPromises));
18✔
713
  }
714
  if (x && typeof x === 'object' && x.constructor === Object) {
383✔
715
    const object: Record<string, any> = x;
74✔
716
    const values = await Promise.all(Object.values(object).map(awaitAllPromises));
74✔
717
    const keys = Object.keys(object);
74✔
718
    const resolvedObject: Record<string, any> = {};
74✔
719
    for (let i = 0; i < keys.length; i++) {
74✔
720
      resolvedObject[keys[i]] = values[i];
281✔
721
    }
722
    return resolvedObject;
74✔
723
  }
724
  return x;
309✔
725
}
726

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

731
//   const lodArray = this._normalizeTexture2DData(lodData);
732

733
//   // If user supplied multiple mip levels, warn if auto-mips also requested
734
//   if (lodArray.length > 1 && this.props.mipmaps !== false) {
735
//     log.warn(
736
//       `Texture ${this.id}: provided multiple LODs and also requested mipmap generation.`
737
//     )();
738
//   }
739

740
//   for (let mipLevel = 0; mipLevel < lodArray.length; mipLevel++) {
741
//     const imageData = lodArray[mipLevel];
742
//     if (this.device.isExternalImage(imageData)) {
743
//       this.texture.copyExternalImage({image: imageData, z, mipLevel, flipY: true});
744
//     } else if (this._isTextureImageData(imageData)) {
745
//       this.texture.copyImageData({data: imageData.data, z, mipLevel});
746
//     } else {
747
//       throw new Error('Unsupported 2D mip-level payload');
748
//     }
749
//   }
750
// }
751

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