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

visgl / luma.gl / 25879749619

14 May 2026 07:05PM UTC coverage: 74.881% (-0.2%) from 75.089%
25879749619

push

github

web-flow
feat(text) TextArrowModel (#2615)

6380 of 9600 branches covered (66.46%)

Branch coverage included in aggregate %.

462 of 672 new or added lines in 6 files covered. (68.75%)

123 existing lines in 9 files now uncovered.

13975 of 17583 relevant lines covered (79.48%)

782.31 hits per line

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

68.78
/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;
89✔
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;
131✔
74
  /** Whether {@link destroy} has been called. */
75
  destroyed = false;
131✔
76
  /** Monotonic version that increments whenever the backing buffer is replaced. */
77
  generation = 0;
131✔
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 = {};
131✔
82
  /** Optional CPU-side mirror of recent writes and readbacks for debugging. */
83
  debugData: ArrayBuffer = new ArrayBuffer(0);
131✔
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;
36✔
93
  }
94

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

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

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

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

125
    const id = props.id || adoptedBuffer?.id || uid('dynamic-buffer');
131✔
126
    const normalizedBufferProps: DynamicBufferProps = {
131✔
127
      ...bufferProps,
128
      id,
129
      usage: bufferProps.usage ?? adoptedBuffer?.usage,
220✔
130
      indexType: bufferProps.indexType ?? adoptedBuffer?.indexType
262✔
131
    };
132

133
    if ((normalizedBufferProps.usage || 0) & Buffer.INDEX && !normalizedBufferProps.indexType) {
131!
UNCOV
134
      if (bufferProps.data instanceof Uint32Array) {
×
UNCOV
135
        normalizedBufferProps.indexType = 'uint32';
×
UNCOV
136
      } else if (bufferProps.data instanceof Uint16Array) {
×
UNCOV
137
        normalizedBufferProps.indexType = 'uint16';
×
UNCOV
138
      } else if (bufferProps.data instanceof Uint8Array) {
×
UNCOV
139
        normalizedBufferProps.indexType = 'uint8';
×
140
      }
141
    }
142

143
    delete normalizedBufferProps.data;
131✔
144
    delete normalizedBufferProps.byteOffset;
131✔
145

146
    this.device = device;
131✔
147
    this.id = id;
131✔
148
    this.props = normalizedBufferProps;
131✔
149
    this.usage = normalizedBufferProps.usage || 0;
131✔
150
    this._debugDataEnabled = Boolean(debugDataProps);
131✔
151
    this._maxDebugDataByteLength =
131✔
152
      typeof debugDataProps === 'object' && debugDataProps.maxByteLength !== undefined
262!
153
        ? debugDataProps.maxByteLength
154
        : DEFAULT_MAX_DEBUG_DATA_BYTE_LENGTH;
155
    this._ownsBuffer = ownsBuffer;
131✔
156

157
    this._buffer = adoptedBuffer ?? this.device.createBuffer({...bufferProps, id});
131✔
158
    this.ready = Promise.resolve(this._buffer);
131✔
159
    this.updateTimestamp = this._buffer.updateTimestamp;
131✔
160

161
    this._resetDebugData(this._buffer.byteLength);
131✔
162
    if (bufferProps.data) {
131✔
163
      this._writeDebugData(bufferProps.data, bufferProps.byteOffset || 0);
5✔
164
    }
165
  }
166

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

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

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

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

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

264
    const copyByteLength = Math.min(
47✔
265
      options.copyByteLength ?? Math.min(this.byteLength, byteLength),
94✔
266
      this.byteLength,
267
      byteLength
268
    );
269

270
    const previousBuffer = this._buffer;
47✔
271
    const previousDebugData = this.debugData.slice(0);
47✔
272
    const {
273
      data: _initialData,
274
      byteOffset: _initialByteOffset,
275
      ...resizableBufferProps
276
    } = this.props;
47✔
277
    const nextBuffer = this.device.createBuffer({
47✔
278
      ...resizableBufferProps,
279
      byteLength
280
    });
281

282
    if (preserveData && copyByteLength > 0) {
47✔
283
      this._copyBufferContents(previousBuffer, nextBuffer, copyByteLength);
42✔
284
    }
285

286
    this._buffer = nextBuffer;
47✔
287
    this._resetDebugData(byteLength);
47✔
288
    if (preserveData && previousDebugData.byteLength > 0) {
47✔
289
      this._writeDebugData(previousDebugData, 0);
2✔
290
    }
291

292
    if (this._ownsBuffer) {
47!
293
      previousBuffer.destroy();
47✔
294
    }
295
    this._ownsBuffer = true;
47✔
296
    this.generation++;
47✔
297
    this.cacheToken = {};
47✔
298
    this._touch();
47✔
299
    return true;
47✔
300
  }
301

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

314
    return this.resize({
40✔
315
      byteLength,
316
      preserveData: options?.preserveData
317
    });
318
  }
319

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

UNCOV
330
    return {
×
331
      buffer: this._buffer,
332
      offset: range?.offset,
333
      size: range?.size
334
    };
335
  }
336

337
  /** Destroys the current backing buffer and clears debug data. */
338
  destroy(): void {
339
    if (!this.destroyed) {
42!
340
      if (this._ownsBuffer) {
42!
341
        this._buffer.destroy();
42✔
342
      }
343
      this.destroyed = true;
42✔
344
      this.debugData = new ArrayBuffer(0);
42✔
345
    }
346
  }
347

348
  private _copyBufferContents(
349
    sourceBuffer: Buffer,
350
    destinationBuffer: Buffer,
351
    byteLength: number
352
  ): void {
353
    const commandEncoder = this.device.createCommandEncoder();
42✔
354
    commandEncoder.copyBufferToBuffer({
42✔
355
      sourceBuffer,
356
      destinationBuffer,
357
      size: byteLength
358
    });
359
    this.device.submit(commandEncoder.finish());
42✔
360
  }
361

362
  private _touch(): void {
363
    this.updateTimestamp = this.device.incrementTimestamp();
103✔
364
  }
365

366
  private _resetDebugData(byteLength: number): void {
367
    if (!this._debugDataEnabled) {
178✔
368
      this.debugData = new ArrayBuffer(0);
165✔
369
      return;
165✔
370
    }
371

372
    this.debugData = new ArrayBuffer(Math.min(byteLength, this._maxDebugDataByteLength));
13✔
373
  }
374

375
  private _writeDebugData(
376
    data: ArrayBuffer | SharedArrayBuffer | ArrayBufferView,
377
    byteOffset: number
378
  ): boolean {
379
    if (
65✔
380
      !this._debugDataEnabled ||
95✔
381
      this.debugData.byteLength === 0 ||
382
      byteOffset >= this.debugData.byteLength
383
    ) {
384
      return false;
50✔
385
    }
386

387
    const source = ArrayBuffer.isView(data)
15✔
388
      ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
389
      : new Uint8Array(data);
390
    const target = new Uint8Array(this.debugData);
65✔
391
    const copyByteLength = Math.min(source.byteLength, target.byteLength - byteOffset);
65✔
392
    target.set(source.subarray(0, copyByteLength), byteOffset);
65✔
393
    return copyByteLength > 0;
65✔
394
  }
395
}
396

397
/** Returns `true` when a value has the structural shape of a buffer range binding. */
398
export function isBufferRangeBinding(binding: unknown): binding is BufferRangeBinding {
399
  return (
342✔
400
    binding !== null &&
1,026✔
401
    typeof binding === 'object' &&
402
    'buffer' in (binding as Record<string, unknown>)
403
  );
404
}
405

406
/** Returns `true` when a range binding points at a {@link DynamicBuffer}. */
407
export function isDynamicBufferRange(binding: unknown): binding is DynamicBufferRange {
UNCOV
408
  return isBufferRangeBinding(binding) && binding.buffer instanceof DynamicBuffer;
×
409
}
410

411
/** Extracts a {@link DynamicBuffer} from a direct binding or range binding. */
412
export function getDynamicBufferFromBinding(binding: unknown): DynamicBuffer | null {
413
  if (binding instanceof DynamicBuffer) {
20✔
414
    return binding;
5✔
415
  }
416

417
  if (isBufferRangeBinding(binding) && binding.buffer instanceof DynamicBuffer) {
15!
UNCOV
418
    return binding.buffer;
×
419
  }
420

421
  return null;
15✔
422
}
423

424
/** Resolves a static or dynamic buffer source to the current core {@link Buffer}. */
425
export function resolveBufferBindingSource(buffer: DynamicBufferBindingSource): Buffer {
UNCOV
426
  return buffer instanceof DynamicBuffer ? buffer.buffer : buffer;
×
427
}
428

429
/** Resolves a dynamic buffer range to a core buffer range over the current backing buffer. */
430
export function resolveDynamicBufferRangeBinding(binding: DynamicBufferRange): {
431
  buffer: Buffer;
432
  offset?: number;
433
  size?: number;
434
} {
UNCOV
435
  return resolveBufferRangeBinding(binding);
×
436
}
437

438
/** Resolves a buffer range to a core buffer range over the current backing buffer. */
439
export function resolveBufferRangeBinding(binding: BufferRangeBinding): {
440
  buffer: Buffer;
441
  offset?: number;
442
  size?: number;
443
} {
UNCOV
444
  return {
×
445
    buffer: resolveBufferBindingSource(binding.buffer),
446
    offset: binding.offset,
447
    size: binding.size
448
  };
449
}
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