• 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

80.28
/modules/webgpu/src/adapter/resources/webgpu-texture.ts
1
// luma.gl, MIT license
2
import {
3
  type TextureProps,
4
  type TextureViewProps,
5
  type CopyExternalImageOptions,
6
  type TextureReadOptions,
7
  type TextureWriteOptions,
8
  type SamplerProps,
9
  Buffer,
10
  Texture,
11
  log,
12
  textureFormatDecoder
13
} from '@luma.gl/core';
14

15
import {getWebGPUTextureFormat} from '../helpers/convert-texture-format';
16
import type {WebGPUDevice} from '../webgpu-device';
17
import {WebGPUSampler} from './webgpu-sampler';
18
import {WebGPUTextureView} from './webgpu-texture-view';
19
import {WebGPUBuffer} from './webgpu-buffer';
20

21
/** WebGPU implementation of the luma.gl core Texture resource */
22
export class WebGPUTexture extends Texture {
23
  readonly device: WebGPUDevice;
24
  readonly handle: GPUTexture;
25
  sampler: WebGPUSampler;
26
  view: WebGPUTextureView;
27
  private _allocatedByteLength: number = 0;
125✔
28

29
  constructor(device: WebGPUDevice, props: TextureProps) {
30
    // WebGPU buffer copies use 256-byte row alignment. queue.writeTexture() can use tightly packed rows.
31
    super(device, props, {byteAlignment: 256});
125✔
32
    this.device = device;
125✔
33

34
    if (props.sampler instanceof WebGPUSampler) {
125✔
35
      this.sampler = props.sampler;
1✔
36
    } else if (props.sampler === undefined) {
124✔
37
      this.sampler = this.device.getDefaultSampler();
96✔
38
    } else {
39
      this.sampler = new WebGPUSampler(this.device, (props.sampler as SamplerProps) || {});
28!
40
      this.attachResource(this.sampler);
28✔
41
    }
42

43
    this.device.pushErrorScope('out-of-memory');
125✔
44
    this.device.pushErrorScope('validation');
125✔
45

46
    this.handle =
125✔
47
      this.props.handle ||
241✔
48
      this.device.handle.createTexture({
49
        label: this.id,
50
        size: {
51
          width: this.width,
52
          height: this.height,
53
          depthOrArrayLayers: this.depth
54
        },
55
        usage: this.props.usage || Texture.TEXTURE | Texture.COPY_DST,
116!
56
        dimension: this.baseDimension,
57
        format: getWebGPUTextureFormat(this.format),
58
        mipLevelCount: this.mipLevels,
59
        sampleCount: this.props.samples
60
      });
61
    this.device.popErrorScope((error: GPUError) => {
125✔
62
      this.device.reportError(new Error(`${this} constructor: ${error.message}`), this)();
1✔
63
      this.device.debug();
1✔
64
    });
65
    this.device.popErrorScope((error: GPUError) => {
125✔
66
      this.device.reportError(new Error(`${this} out of memory: ${error.message}`), this)();
×
67
      this.device.debug();
×
68
    });
69

70
    if (this.props.handle) {
125✔
71
      this.handle.label ||= this.id;
9✔
72
      // @ts-expect-error readonly
73
      this.width = this.handle.width;
9✔
74
      // @ts-expect-error readonly
75
      this.height = this.handle.height;
9✔
76
    }
77

78
    this.view = new WebGPUTextureView(this.device, {
125✔
79
      ...this.props,
80
      texture: this,
81
      mipLevelCount: this.mipLevels,
82
      // Note: arrayLayerCount controls the view of array textures, but does not apply to 3d texture depths
83
      arrayLayerCount: this.dimension !== '3d' ? this.depth : 1
125✔
84
    });
85
    this.attachResource(this.view);
125✔
86

87
    // Set initial data
88
    // Texture base class strips out the data prop from this.props, so we need to handle it here
89
    this._initializeData(props.data);
125✔
90

91
    this._allocatedByteLength = this.getAllocatedByteLength();
125✔
92

93
    if (!this.props.handle) {
125✔
94
      this.trackAllocatedMemory(this._allocatedByteLength, 'Texture');
116✔
95
    } else {
96
      this.trackReferencedMemory(this._allocatedByteLength, 'Texture');
9✔
97
    }
98
  }
99

100
  override destroy(): void {
101
    if (this.destroyed) {
87✔
102
      return;
1✔
103
    }
104

105
    if (!this.props.handle && this.handle) {
86✔
106
      this.trackDeallocatedMemory('Texture');
82✔
107
      this.handle.destroy();
82✔
108
    } else if (this.handle) {
4!
109
      this.trackDeallocatedReferencedMemory('Texture');
4✔
110
    }
111

112
    this.destroyResource();
86✔
113
    // @ts-expect-error readonly
114
    this.handle = null;
86✔
115
  }
116

117
  createView(props: TextureViewProps): WebGPUTextureView {
118
    return new WebGPUTextureView(this.device, {...props, texture: this});
38✔
119
  }
120

121
  copyExternalImage(options_: CopyExternalImageOptions): {width: number; height: number} {
122
    const options = this._normalizeCopyExternalImageOptions(options_);
4✔
123

124
    this.device.pushErrorScope('validation');
4✔
125
    this.device.handle.queue.copyExternalImageToTexture(
4✔
126
      // source: GPUImageCopyExternalImage
127
      {
128
        source: options.image,
129
        origin: [options.sourceX, options.sourceY],
130
        flipY: false // options.flipY
131
      },
132
      // destination: GPUImageCopyTextureTagged
133
      {
134
        texture: this.handle,
135
        origin: [options.x, options.y, options.z],
136
        mipLevel: options.mipLevel,
137
        aspect: options.aspect,
138
        colorSpace: options.colorSpace,
139
        premultipliedAlpha: options.premultipliedAlpha
140
      },
141
      // copySize: GPUExtent3D
142
      [options.width, options.height, options.depth] // depth is always 1 for 2D textures
143
    );
144
    this.device.popErrorScope((error: GPUError) => {
4✔
145
      this.device.reportError(new Error(`copyExternalImage: ${error.message}`), this)();
×
146
      this.device.debug();
×
147
    });
148

149
    // TODO - should these be clipped to the texture size minus x,y,z?
150
    return {width: options.width, height: options.height};
4✔
151
  }
152

153
  override generateMipmapsWebGL(): void {
154
    log.warn(`${this}: generateMipmaps not supported in WebGPU`)();
×
155
  }
156

157
  getImageDataLayout(options: TextureReadOptions): {
158
    byteLength: number;
159
    bytesPerRow: number;
160
    rowsPerImage: number;
161
  } {
162
    return {
×
163
      byteLength: 0,
164
      bytesPerRow: 0,
165
      rowsPerImage: 0
166
    };
167
  }
168

169
  override readBuffer(
170
    options: TextureReadOptions & {byteOffset?: number} = {},
52✔
171
    buffer?: Buffer
172
  ): Buffer {
173
    if (!buffer) {
52✔
174
      throw new Error(`${this} readBuffer requires a destination buffer`);
1✔
175
    }
176
    const {x, y, z, width, height, depthOrArrayLayers, mipLevel, aspect} =
177
      this._getSupportedColorReadOptions(options);
51✔
178
    const byteOffset = options.byteOffset ?? 0;
51✔
179

180
    const layout = this.computeMemoryLayout({width, height, depthOrArrayLayers, mipLevel});
52✔
181

182
    const {byteLength} = layout;
52✔
183

184
    if (buffer.byteLength < byteOffset + byteLength) {
52!
185
      throw new Error(
×
186
        `${this} readBuffer target is too small (${buffer.byteLength} < ${byteOffset + byteLength})`
187
      );
188
    }
189

190
    const gpuDevice = this.device.handle;
46✔
191
    this.device.pushErrorScope('validation');
46✔
192
    const commandEncoder = gpuDevice.createCommandEncoder();
46✔
193
    this.copyToBuffer(
46✔
194
      commandEncoder,
195
      {x, y, z, width, height, depthOrArrayLayers, mipLevel, aspect, byteOffset},
196
      buffer
197
    );
198

199
    const commandBuffer = commandEncoder.finish();
46✔
200
    this.device.handle.queue.submit([commandBuffer]);
46✔
201
    this.device.popErrorScope((error: GPUError) => {
46✔
NEW
202
      this.device.reportError(new Error(`${this} readBuffer: ${error.message}`), this)();
×
NEW
203
      this.device.debug();
×
204
    });
205

206
    return buffer;
46✔
207
  }
208

209
  override async readDataAsync(options: TextureReadOptions = {}): Promise<ArrayBuffer> {
×
NEW
210
    throw new Error(
×
211
      `${this} readDataAsync is deprecated; use readBuffer() with an explicit destination buffer or DynamicTexture.readAsync()`
212
    );
213
  }
214

215
  copyToBuffer(
216
    commandEncoder: GPUCommandEncoder,
217
    options: TextureReadOptions & {
46✔
218
      byteOffset?: number;
219
      bytesPerRow?: number;
220
      rowsPerImage?: number;
221
    } = {},
222
    buffer: Buffer
223
  ): void {
224
    const {
225
      byteOffset = 0,
46✔
226
      bytesPerRow: requestedBytesPerRow,
227
      rowsPerImage: requestedRowsPerImage,
228
      ...textureReadOptions
229
    } = options;
46✔
230
    const {x, y, z, width, height, depthOrArrayLayers, mipLevel, aspect} =
231
      this._getSupportedColorReadOptions(textureReadOptions);
46✔
232
    const layout = this.computeMemoryLayout({width, height, depthOrArrayLayers, mipLevel});
46✔
233
    const effectiveBytesPerRow = requestedBytesPerRow ?? layout.bytesPerRow;
46✔
234
    const effectiveRowsPerImage = requestedRowsPerImage ?? layout.rowsPerImage;
46✔
235
    const webgpuBuffer = buffer as WebGPUBuffer;
46✔
236

237
    commandEncoder.copyTextureToBuffer(
46✔
238
      {
239
        texture: this.handle,
240
        origin: {x, y, z},
241
        mipLevel,
242
        aspect
243
      },
244
      {
245
        buffer: webgpuBuffer.handle,
246
        offset: byteOffset,
247
        bytesPerRow: effectiveBytesPerRow,
248
        rowsPerImage: effectiveRowsPerImage
249
      },
250
      {
251
        width,
252
        height,
253
        depthOrArrayLayers
254
      }
255
    );
256
  }
257

258
  override writeBuffer(buffer: Buffer, options_: TextureWriteOptions = {}) {
7✔
259
    const options = this._normalizeTextureWriteOptions(options_);
7✔
260
    const {
261
      x,
262
      y,
263
      z,
264
      width,
265
      height,
266
      depthOrArrayLayers,
267
      mipLevel,
268
      aspect,
269
      byteOffset,
270
      bytesPerRow,
271
      rowsPerImage
272
    } = options;
7✔
273

274
    const gpuDevice = this.device.handle;
7✔
275

276
    this.device.pushErrorScope('validation');
7✔
277
    const commandEncoder = gpuDevice.createCommandEncoder();
7✔
278
    commandEncoder.copyBufferToTexture(
7✔
279
      {
280
        buffer: buffer.handle as GPUBuffer,
281
        offset: byteOffset,
282
        bytesPerRow,
283
        rowsPerImage
284
      },
285
      {
286
        texture: this.handle,
287
        origin: {x, y, z},
288
        mipLevel,
289
        aspect
290
      },
291
      {width, height, depthOrArrayLayers}
292
    );
293
    const commandBuffer = commandEncoder.finish();
7✔
294
    this.device.handle.queue.submit([commandBuffer]);
7✔
295
    this.device.popErrorScope((error: GPUError) => {
7✔
296
      this.device.reportError(new Error(`${this} writeBuffer: ${error.message}`), this)();
×
297
      this.device.debug();
×
298
    });
299
  }
300

301
  override writeData(
302
    data: ArrayBuffer | SharedArrayBuffer | ArrayBufferView,
303
    options_: TextureWriteOptions = {}
66✔
304
  ): void {
305
    const device = this.device;
66✔
306
    const options = this._normalizeTextureWriteOptions(options_);
66✔
307
    const {x, y, z, width, height, depthOrArrayLayers, mipLevel, aspect, byteOffset} = options;
66✔
308
    const source = data as GPUAllowSharedBufferSource;
66✔
309
    const formatInfo = this.device.getTextureFormatInfo(this.format);
66✔
310
    // queue.writeTexture() defaults to tightly packed rows, unlike WebGPU buffer copy paths.
311
    const packedSourceLayout = textureFormatDecoder.computeMemoryLayout({
66✔
312
      format: this.format,
313
      width,
314
      height,
315
      depth: depthOrArrayLayers,
316
      byteAlignment: 1
317
    });
318
    const bytesPerRow = options_.bytesPerRow ?? packedSourceLayout.bytesPerRow;
66✔
319
    const rowsPerImage = options_.rowsPerImage ?? packedSourceLayout.rowsPerImage;
66✔
320
    let copyWidth = width;
66✔
321
    let copyHeight = height;
66✔
322

323
    if (formatInfo.compressed) {
66!
324
      const blockWidth = formatInfo.blockWidth || 1;
×
325
      const blockHeight = formatInfo.blockHeight || 1;
×
326
      copyWidth = Math.ceil(width / blockWidth) * blockWidth;
×
327
      copyHeight = Math.ceil(height / blockHeight) * blockHeight;
×
328
    }
329

330
    this.device.pushErrorScope('validation');
65✔
331
    device.handle.queue.writeTexture(
65✔
332
      {
333
        texture: this.handle,
334
        mipLevel,
335
        aspect,
336
        origin: {x, y, z}
337
      },
338
      source,
339
      {
340
        offset: byteOffset,
341
        bytesPerRow,
342
        rowsPerImage
343
      },
344
      {width: copyWidth, height: copyHeight, depthOrArrayLayers}
345
    );
346
    this.device.popErrorScope((error: GPUError) => {
65✔
347
      this.device.reportError(new Error(`${this} writeData: ${error.message}`), this)();
×
348
      this.device.debug();
×
349
    });
350
  }
351

352
  /**
353
   * Internal-only hook for the cached CanvasContext/PresentationContext swapchain path.
354
   * Rebinds this handle-backed texture wrapper to the current per-frame canvas texture
355
   * without allocating a new luma.gl Texture or TextureView wrapper.
356
   */
357
  _reinitialize(handle: GPUTexture, props?: Partial<TextureProps>): void {
358
    const nextWidth = props?.width ?? handle.width ?? this.width;
25!
359
    const nextHeight = props?.height ?? handle.height ?? this.height;
25!
360
    const nextDepth = props?.depth ?? this.depth;
25✔
361
    const nextFormat = props?.format ?? this.format;
25!
362
    const allocationMayHaveChanged =
363
      nextWidth !== this.width ||
25✔
364
      nextHeight !== this.height ||
365
      nextDepth !== this.depth ||
366
      nextFormat !== this.format;
367
    handle.label ||= this.id;
25✔
368

369
    // @ts-expect-error readonly
370
    this.handle = handle;
25✔
371
    // @ts-expect-error readonly
372
    this.width = nextWidth;
25✔
373
    // @ts-expect-error readonly
374
    this.height = nextHeight;
25✔
375

376
    if (props?.depth !== undefined) {
25!
377
      // @ts-expect-error readonly
378
      this.depth = nextDepth;
×
379
    }
380
    if (props?.format !== undefined) {
25!
381
      // @ts-expect-error readonly
382
      this.format = nextFormat;
25✔
383
    }
384

385
    this.props.handle = handle;
25✔
386
    if (props?.width !== undefined) {
25!
387
      this.props.width = props.width;
25✔
388
    }
389
    if (props?.height !== undefined) {
25!
390
      this.props.height = props.height;
25✔
391
    }
392
    if (props?.depth !== undefined) {
25!
393
      this.props.depth = props.depth;
×
394
    }
395
    if (props?.format !== undefined) {
25!
396
      this.props.format = props.format;
25✔
397
    }
398

399
    if (allocationMayHaveChanged) {
25✔
400
      const nextAllocation = this.getAllocatedByteLength();
1✔
401
      if (nextAllocation !== this._allocatedByteLength) {
1!
402
        this._allocatedByteLength = nextAllocation;
1✔
403
        this.trackReferencedMemory(nextAllocation, 'Texture');
1✔
404
      }
405
    }
406
    this.view._reinitialize(this);
25✔
407
  }
408
}
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