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

visgl / luma.gl / 26033117099

18 May 2026 12:20PM UTC coverage: 74.769% (-0.1%) from 74.887%
26033117099

push

github

web-flow
feat(arrow) Streaming ArrowTextLayer (#2620)

6882 of 10396 branches covered (66.2%)

Branch coverage included in aggregate %.

194 of 250 new or added lines in 9 files covered. (77.6%)

13 existing lines in 7 files now uncovered.

15038 of 18921 relevant lines covered (79.48%)

914.25 hits per line

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

69.33
/modules/engine/src/dynamic-buffer/dynamic-buffer.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import type {BufferMapCallback, BufferProps, Device, Binding as CoreBinding} from '@luma.gl/core';
6
import {Buffer} from '@luma.gl/core';
7
import {uid} from '../utils/uid';
8

9
/** Controls whether a {@link DynamicBuffer} keeps a CPU-side debug mirror of recent writes. */
10
export type DynamicBufferDebugProps =
11
  | boolean
12
  | {
13
      /** Maximum number of bytes retained in {@link DynamicBuffer.debugData}. */
14
      maxByteLength?: number;
15
    };
16

17
/** Construction props for a {@link DynamicBuffer}. */
18
export type DynamicBufferProps = Omit<BufferProps, 'handle' | 'onMapped'> & {
19
  /** Enables and optionally sizes the CPU-side debug mirror. */
20
  debugData?: DynamicBufferDebugProps;
21
  /** Existing immutable buffer to expose through this dynamic wrapper without copying. */
22
  buffer?: Buffer;
23
  /** Whether this dynamic wrapper should destroy an adopted `buffer`. Defaults to `true`. */
24
  ownsBuffer?: boolean;
25
};
26

27
/** Buffer-like source accepted by dynamic buffer range helpers. */
28
export type DynamicBufferBindingSource = Buffer | DynamicBuffer;
29

30
/** Binding range that may point at either a {@link Buffer} or a {@link DynamicBuffer}. */
31
export type DynamicBufferRange = {
32
  /** Buffer source for the binding range. */
33
  buffer: DynamicBufferBindingSource;
34
  /** Byte offset into the current backing buffer. */
35
  offset?: number;
36
  /** Byte length of the binding range. */
37
  size?: number;
38
};
39

40
/** Generic buffer range binding accepted by engine binding resolution helpers. */
41
export type BufferRangeBinding = {
42
  /** Buffer source for the binding range. */
43
  buffer: DynamicBufferBindingSource;
44
  /** Byte offset into the current backing buffer. */
45
  offset?: number;
46
  /** Byte length of the binding range. */
47
  size?: number;
48
};
49

50
const DEFAULT_MAX_DEBUG_DATA_BYTE_LENGTH = Buffer.DEBUG_DATA_MAX_LENGTH;
92✔
51

52
/**
53
 * Mutable engine-level wrapper around an immutable core {@link Buffer}.
54
 *
55
 * `DynamicBuffer` keeps a stable application object while allowing the backing
56
 * GPU buffer to be replaced on resize. Engine classes such as {@link Model} and
57
 * {@link Material} resolve it to the current backing buffer at draw time and
58
 * invalidate cached bindings when {@link generation} changes.
59
 */
60
export class DynamicBuffer {
61
  /** Device that owns the backing buffer. */
62
  readonly device: Device;
63
  /** Application-provided or generated identifier. */
64
  readonly id: string;
65
  /** Ready promise provided for compatibility with other dynamic resources. */
66
  readonly ready: Promise<Buffer>;
67
  /** Usage flags applied to every backing buffer created by this wrapper. */
68
  readonly usage: number;
69
  /** Normalized buffer props reused when the backing buffer is recreated. */
70
  readonly props: Readonly<DynamicBufferProps>;
71

72
  /** Dynamic buffers are synchronously ready after construction. */
73
  isReady = true;
209✔
74
  /** Whether {@link destroy} has been called. */
75
  destroyed = false;
209✔
76
  /** Monotonic version that increments whenever the backing buffer is replaced. */
77
  generation = 0;
209✔
78
  /** Last update timestamp for writes, reads that populate debug data, or resize operations. */
79
  updateTimestamp: number;
80
  /** Token replaced whenever cache users need a new resource identity. */
81
  cacheToken: object = {};
209✔
82
  /** Optional CPU-side mirror of recent writes and readbacks for debugging. */
83
  debugData: ArrayBuffer = new ArrayBuffer(0);
209✔
84

85
  private readonly _debugDataEnabled: boolean;
86
  private readonly _maxDebugDataByteLength: number;
87
  private _ownsBuffer: boolean;
88
  private _buffer: Buffer;
89

90
  /** Current immutable core buffer backing this dynamic wrapper. */
91
  get buffer(): Buffer {
92
    return this._buffer;
26✔
93
  }
94

95
  /** Current byte length of the backing buffer. */
96
  get byteLength(): number {
97
    return this._buffer.byteLength;
82✔
98
  }
99

100
  /** String tag used by `Object.prototype.toString`. */
101
  get [Symbol.toStringTag](): string {
102
    return 'DynamicBuffer';
4✔
103
  }
104

105
  /** Human-readable debug string. */
106
  toString(): string {
107
    return `DynamicBuffer:"${this.id}":${this.byteLength}B`;
2✔
108
  }
109

110
  /** Compact serialization for assertion diffs and structured debug logs. */
111
  toJSON(): string {
112
    return this.toString();
1✔
113
  }
114

115
  /** Creates a dynamic buffer and its initial backing {@link Buffer}. */
116
  constructor(device: Device, props: DynamicBufferProps) {
117
    const {
118
      debugData: debugDataProps = false,
209✔
119
      buffer: adoptedBuffer,
120
      ownsBuffer = true,
209✔
121
      ...bufferProps
122
    } = props;
209✔
123
    if (adoptedBuffer && adoptedBuffer.device !== device) {
209!
124
      throw new Error('DynamicBuffer adopted buffers must belong to the supplied device');
×
125
    }
126
    if (adoptedBuffer && (bufferProps.byteLength !== undefined || bufferProps.data !== undefined)) {
209!
127
      throw new Error('DynamicBuffer cannot combine an adopted buffer with byteLength or data');
×
128
    }
129

130
    const id = props.id || adoptedBuffer?.id || uid('dynamic-buffer');
209✔
131
    const normalizedBufferProps: DynamicBufferProps = {
209✔
132
      ...bufferProps,
133
      id,
134
      usage: bufferProps.usage ?? adoptedBuffer?.usage,
367✔
135
      indexType: bufferProps.indexType ?? adoptedBuffer?.indexType
418✔
136
    };
137

138
    if ((normalizedBufferProps.usage || 0) & Buffer.INDEX && !normalizedBufferProps.indexType) {
209!
139
      if (bufferProps.data instanceof Uint32Array) {
×
140
        normalizedBufferProps.indexType = 'uint32';
×
141
      } else if (bufferProps.data instanceof Uint16Array) {
×
142
        normalizedBufferProps.indexType = 'uint16';
×
143
      } else if (bufferProps.data instanceof Uint8Array) {
×
144
        normalizedBufferProps.indexType = 'uint8';
×
145
      }
146
    }
147

148
    delete normalizedBufferProps.data;
209✔
149
    delete normalizedBufferProps.byteOffset;
209✔
150

151
    this.device = device;
209✔
152
    this.id = id;
209✔
153
    this.props = normalizedBufferProps;
209✔
154
    this.usage = normalizedBufferProps.usage || 0;
209✔
155
    this._debugDataEnabled = Boolean(debugDataProps);
209✔
156
    this._maxDebugDataByteLength =
209✔
157
      typeof debugDataProps === 'object' && debugDataProps.maxByteLength !== undefined
418!
158
        ? debugDataProps.maxByteLength
159
        : DEFAULT_MAX_DEBUG_DATA_BYTE_LENGTH;
160
    this._ownsBuffer = ownsBuffer;
209✔
161

162
    this._buffer = adoptedBuffer ?? this.device.createBuffer({...bufferProps, id});
209✔
163
    this.ready = Promise.resolve(this._buffer);
209✔
164
    this.updateTimestamp = this._buffer.updateTimestamp;
209✔
165

166
    this._resetDebugData(this._buffer.byteLength);
209✔
167
    if (bufferProps.data) {
209✔
168
      this._writeDebugData(bufferProps.data, bufferProps.byteOffset || 0);
37✔
169
    }
170
  }
171

172
  /**
173
   * Writes bytes into the current backing buffer.
174
   *
175
   * @param data - Bytes or typed-array view to upload.
176
   * @param byteOffset - Destination byte offset in the backing buffer.
177
   */
178
  write(data: ArrayBuffer | SharedArrayBuffer | ArrayBufferView, byteOffset: number = 0): void {
17✔
179
    this._buffer.write(data, byteOffset);
17✔
180
    this._touch();
17✔
181
    this._writeDebugData(data, byteOffset);
17✔
182
  }
183

184
  /**
185
   * Maps the current backing buffer for writing and mirrors the written range when debug data is enabled.
186
   *
187
   * @param callback - Callback invoked with the mapped range.
188
   * @param byteOffset - Byte offset of the mapped range.
189
   * @param byteLength - Byte length of the mapped range.
190
   */
191
  async mapAndWriteAsync(
192
    callback: BufferMapCallback<void | Promise<void>>,
193
    byteOffset: number = 0,
×
194
    byteLength: number = this.byteLength - byteOffset
×
195
  ): Promise<void> {
196
    let copiedBytes: Uint8Array | null = null;
×
197
    await this._buffer.mapAndWriteAsync(
×
198
      async (arrayBuffer, lifetime) => {
199
        await callback(arrayBuffer, lifetime);
×
200
        copiedBytes = new Uint8Array(arrayBuffer.slice(0, byteLength));
×
201
      },
202
      byteOffset,
203
      byteLength
204
    );
205
    this._touch();
×
206
    if (copiedBytes) {
×
207
      this._writeDebugData(copiedBytes, byteOffset);
×
208
    }
209
  }
210

211
  /**
212
   * Reads bytes from the current backing buffer.
213
   *
214
   * @param byteOffset - Source byte offset in the backing buffer.
215
   * @param byteLength - Number of bytes to read.
216
   */
217
  async readAsync(
218
    byteOffset: number = 0,
12✔
219
    byteLength = this.byteLength - byteOffset
12✔
220
  ): Promise<Uint8Array> {
221
    const data = await this._buffer.readAsync(byteOffset, byteLength);
12✔
222
    if (this._writeDebugData(data, byteOffset)) {
12✔
223
      this._touch();
7✔
224
    }
225
    return data;
12✔
226
  }
227

228
  /**
229
   * Maps the current backing buffer for reading.
230
   *
231
   * @param callback - Callback invoked with the mapped range.
232
   * @param byteOffset - Byte offset of the mapped range.
233
   * @param byteLength - Byte length of the mapped range.
234
   */
235
  async mapAndReadAsync<T>(
236
    callback: BufferMapCallback<T>,
237
    byteOffset: number = 0,
×
238
    byteLength: number = this.byteLength - byteOffset
×
239
  ): Promise<T> {
240
    let copiedBytes: Uint8Array | null = null;
×
241
    const result = await this._buffer.mapAndReadAsync(
×
242
      async (arrayBuffer, lifetime) => {
243
        copiedBytes = new Uint8Array(arrayBuffer.slice(0));
×
244
        return await callback(arrayBuffer, lifetime);
×
245
      },
246
      byteOffset,
247
      byteLength
248
    );
249
    if (copiedBytes && this._writeDebugData(copiedBytes, byteOffset)) {
×
250
      this._touch();
×
251
    }
252
    return result;
×
253
  }
254

255
  /**
256
   * Replaces the backing buffer with a new size.
257
   *
258
   * @param options.byteLength - New backing buffer byte length.
259
   * @param options.preserveData - Copies bytes from the old buffer into the new buffer.
260
   * @param options.copyByteLength - Maximum number of bytes to copy when preserving data.
261
   * @returns `true` when a new backing buffer was created.
262
   */
263
  resize(options: {byteLength: number; preserveData?: boolean; copyByteLength?: number}): boolean {
264
    const {byteLength, preserveData = false} = options;
23✔
265
    if (byteLength === this.byteLength) {
23!
266
      return false;
×
267
    }
268

269
    const copyByteLength = Math.min(
23✔
270
      options.copyByteLength ?? Math.min(this.byteLength, byteLength),
32✔
271
      this.byteLength,
272
      byteLength
273
    );
274

275
    const previousBuffer = this._buffer;
23✔
276
    const previousDebugData = this.debugData.slice(0);
23✔
277
    const {
278
      data: _initialData,
279
      byteOffset: _initialByteOffset,
280
      ...resizableBufferProps
281
    } = this.props;
23✔
282
    const nextBuffer = this.device.createBuffer({
23✔
283
      ...resizableBufferProps,
284
      byteLength
285
    });
286

287
    if (preserveData && copyByteLength > 0) {
23✔
288
      this._copyBufferContents(previousBuffer, nextBuffer, copyByteLength);
10✔
289
    }
290

291
    this._buffer = nextBuffer;
23✔
292
    this._resetDebugData(byteLength);
23✔
293
    if (preserveData && previousDebugData.byteLength > 0) {
23✔
294
      this._writeDebugData(previousDebugData, 0);
4✔
295
    }
296

297
    if (this._ownsBuffer) {
23!
298
      previousBuffer.destroy();
23✔
299
    }
300
    this._ownsBuffer = true;
23✔
301
    this.generation++;
23✔
302
    this.cacheToken = {};
23✔
303
    this._touch();
23✔
304
    return true;
23✔
305
  }
306

307
  /**
308
   * Grows the backing buffer when the requested size exceeds the current size.
309
   *
310
   * @param byteLength - Minimum required byte length.
311
   * @param options.preserveData - Copies existing bytes when growth is required.
312
   * @returns `true` when the backing buffer grew.
313
   */
314
  ensureSize(byteLength: number, options?: {preserveData?: boolean}): boolean {
315
    if (byteLength <= this.byteLength) {
3!
316
      return false;
3✔
317
    }
318

UNCOV
319
    return this.resize({
×
320
      byteLength,
321
      preserveData: options?.preserveData
322
    });
323
  }
324

325
  /**
326
   * Returns the current backing buffer or a range binding over it.
327
   *
328
   * @param range - Optional byte range for uniform/storage buffer bindings.
329
   */
330
  getBinding(range?: {offset?: number; size?: number}): CoreBinding {
331
    if (range?.offset === undefined && range?.size === undefined) {
×
332
      return this._buffer;
×
333
    }
334

335
    return {
×
336
      buffer: this._buffer,
337
      offset: range?.offset,
338
      size: range?.size
339
    };
340
  }
341

342
  /** Destroys the current backing buffer and clears debug data. */
343
  destroy(): void {
344
    if (!this.destroyed) {
51!
345
      if (this._ownsBuffer) {
51!
346
        this._buffer.destroy();
51✔
347
      }
348
      this.destroyed = true;
51✔
349
      this.debugData = new ArrayBuffer(0);
51✔
350
    }
351
  }
352

353
  private _copyBufferContents(
354
    sourceBuffer: Buffer,
355
    destinationBuffer: Buffer,
356
    byteLength: number
357
  ): void {
358
    const copyByteLength =
359
      this.device.type === 'webgpu' ? Math.ceil(byteLength / 4) * 4 : byteLength;
10✔
360
    const commandEncoder = this.device.createCommandEncoder();
10✔
361
    commandEncoder.copyBufferToBuffer({
10✔
362
      sourceBuffer,
363
      destinationBuffer,
364
      size: copyByteLength
365
    });
366
    this.device.submit(commandEncoder.finish());
10✔
367
  }
368

369
  private _touch(): void {
370
    this.updateTimestamp = this.device.incrementTimestamp();
47✔
371
  }
372

373
  private _resetDebugData(byteLength: number): void {
374
    if (!this._debugDataEnabled) {
232✔
375
      this.debugData = new ArrayBuffer(0);
215✔
376
      return;
215✔
377
    }
378

379
    this.debugData = new ArrayBuffer(Math.min(byteLength, this._maxDebugDataByteLength));
17✔
380
  }
381

382
  private _writeDebugData(
383
    data: ArrayBuffer | SharedArrayBuffer | ArrayBufferView,
384
    byteOffset: number
385
  ): boolean {
386
    if (
70✔
387
      !this._debugDataEnabled ||
112✔
388
      this.debugData.byteLength === 0 ||
389
      byteOffset >= this.debugData.byteLength
390
    ) {
391
      return false;
49✔
392
    }
393

394
    const source = ArrayBuffer.isView(data)
21✔
395
      ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
396
      : new Uint8Array(data);
397
    const target = new Uint8Array(this.debugData);
70✔
398
    const copyByteLength = Math.min(source.byteLength, target.byteLength - byteOffset);
70✔
399
    target.set(source.subarray(0, copyByteLength), byteOffset);
70✔
400
    return copyByteLength > 0;
70✔
401
  }
402
}
403

404
/** Returns `true` when a value has the structural shape of a buffer range binding. */
405
export function isBufferRangeBinding(binding: unknown): binding is BufferRangeBinding {
406
  return (
342✔
407
    binding !== null &&
1,026✔
408
    typeof binding === 'object' &&
409
    'buffer' in (binding as Record<string, unknown>)
410
  );
411
}
412

413
/** Returns `true` when a range binding points at a {@link DynamicBuffer}. */
414
export function isDynamicBufferRange(binding: unknown): binding is DynamicBufferRange {
415
  return isBufferRangeBinding(binding) && binding.buffer instanceof DynamicBuffer;
×
416
}
417

418
/** Extracts a {@link DynamicBuffer} from a direct binding or range binding. */
419
export function getDynamicBufferFromBinding(binding: unknown): DynamicBuffer | null {
420
  if (binding instanceof DynamicBuffer) {
20✔
421
    return binding;
5✔
422
  }
423

424
  if (isBufferRangeBinding(binding) && binding.buffer instanceof DynamicBuffer) {
15!
425
    return binding.buffer;
×
426
  }
427

428
  return null;
15✔
429
}
430

431
/** Resolves a static or dynamic buffer source to the current core {@link Buffer}. */
432
export function resolveBufferBindingSource(buffer: DynamicBufferBindingSource): Buffer {
433
  return buffer instanceof DynamicBuffer ? buffer.buffer : buffer;
×
434
}
435

436
/** Resolves a dynamic buffer range to a core buffer range over the current backing buffer. */
437
export function resolveDynamicBufferRangeBinding(binding: DynamicBufferRange): {
438
  buffer: Buffer;
439
  offset?: number;
440
  size?: number;
441
} {
442
  return resolveBufferRangeBinding(binding);
×
443
}
444

445
/** Resolves a buffer range to a core buffer range over the current backing buffer. */
446
export function resolveBufferRangeBinding(binding: BufferRangeBinding): {
447
  buffer: Buffer;
448
  offset?: number;
449
  size?: number;
450
} {
451
  return {
×
452
    buffer: resolveBufferBindingSource(binding.buffer),
453
    offset: binding.offset,
454
    size: binding.size
455
  };
456
}
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