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

visgl / luma.gl / 26718853225

31 May 2026 05:01PM UTC coverage: 70.552% (+0.07%) from 70.487%
26718853225

push

github

web-flow
feat(tables) Align GPUVector types with luma.gl core types (#2646)

8709 of 13961 branches covered (62.38%)

Branch coverage included in aggregate %.

400 of 550 new or added lines in 26 files covered. (72.73%)

10 existing lines in 5 files now uncovered.

17964 of 23845 relevant lines covered (75.34%)

4286.17 hits per line

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

59.04
/modules/tables/src/engine/gpu-table-transform.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import {Buffer, type BufferLayout, Device, type RenderPassProps} from '@luma.gl/core';
6
import {BufferTransform, DynamicBuffer, type BufferTransformProps} from '@luma.gl/engine';
7
import type {GPUData} from '../table/gpu-data';
8
import {GPUVector} from '../table/gpu-vector';
9
import {GPURecordBatch} from '../table/gpu-record-batch';
10
import {GPUTable} from '../table/gpu-table';
11

12
type TableTransformBufferMap = NonNullable<Parameters<BufferTransform['run']>[0]>['outputBuffers'];
13
type TableTransformRunOptions = Parameters<BufferTransform['run']>[0];
14
type TableTransformInputVectors = Record<string, GPUVector> | GPUVector[];
15

16
/** Maps transform-feedback output varying names back to the input GPU vector they should update. */
17
export type TableTransformOutputCopyMap = Record<string, string>;
18

19
/** Options supplied for one {@link TableTransform.dispatchBatches} dispatch. */
20
export type TableTransformBatchOptions = RenderPassProps & {
21
  /** Output transform-feedback buffers for the current batch. */
22
  outputBuffers?:
23
    | TableTransformBufferMap
24
    | ((batch: GPURecordBatch, batchIndex: number) => TableTransformBufferMap);
25
};
26

27
/** Props for creating a WebGL transform backed by a GPU-resident table. */
28
export type TableTransformProps = BufferTransformProps & {
29
  /** Existing GPU table used as the source for transform input buffers. */
30
  table?: GPUTable;
31
  /** Existing GPU vectors converted into the transform input table. */
32
  inputVectors?: TableTransformInputVectors;
33
  /**
34
   * Allocates dense transform-feedback output vectors and copies them back into named input
35
   * vectors after each run.
36
   *
37
   * Output names are inferred as transform-feedback outputs when `outputs` is omitted.
38
   */
39
  copyOutputToInputVectors?: TableTransformOutputCopyMap;
40
  /** Controls whether GPU table row count is assigned to vertexCount. */
41
  tableCount?: 'vertex' | 'none';
42
};
43

44
type TableTransformState = {
45
  table: GPUTable;
46
  ownsTable: boolean;
47
  transformProps: BufferTransformProps;
48
  explicitAttributes: NonNullable<BufferTransformProps['attributes']>;
49
  explicitBufferLayout: BufferLayout[];
50
  inferVertexCount: boolean;
51
  copyOutputToInputVectors: TableTransformOutputCopyMap;
52
  outputVectors: Record<string, GPUVector>;
53
};
54

55
/**
56
 * A WebGL transform-feedback program whose input buffers come from a {@link GPUTable}.
57
 *
58
 * `TableTransform` keeps the regular {@link BufferTransform} execution model,
59
 * while adding table construction and batch-by-batch execution helpers.
60
 */
61
export class TableTransform extends BufferTransform {
62
  /** GPU table backing the transform input attributes. */
63
  readonly table: GPUTable;
64
  /** GPU vectors backing the transform input attributes. */
65
  readonly inputVectors: Record<string, GPUVector>;
66
  /** Dense transform-feedback output vectors allocated for automatic input writeback. */
67
  readonly outputVectors: Record<string, GPUVector>;
68
  private readonly ownsTable: boolean;
69
  private readonly explicitAttributes: NonNullable<BufferTransformProps['attributes']>;
70
  private readonly explicitBufferLayout: BufferLayout[];
71
  private readonly inferVertexCount: boolean;
72
  private readonly copyOutputToInputVectors: TableTransformOutputCopyMap;
73
  private tableTransformDestroyed = false;
1✔
74

75
  constructor(device: Device, props: TableTransformProps) {
76
    const {
77
      table,
78
      ownsTable,
79
      transformProps,
80
      explicitAttributes,
81
      explicitBufferLayout,
82
      inferVertexCount,
83
      copyOutputToInputVectors,
84
      outputVectors
85
    } = getTableTransformState(device, props);
2✔
86

87
    try {
2✔
88
      super(device, transformProps);
2✔
89
    } catch (error) {
90
      destroyGPUVectors(outputVectors);
×
91
      if (ownsTable) {
×
92
        table.destroy();
×
93
      }
94
      throw error;
×
95
    }
96

97
    this.table = table;
1✔
98
    this.inputVectors = table.gpuVectors;
1✔
99
    this.outputVectors = outputVectors;
1✔
100
    this.ownsTable = ownsTable;
1✔
101
    this.explicitAttributes = explicitAttributes;
1✔
102
    this.explicitBufferLayout = explicitBufferLayout;
1✔
103
    this.inferVertexCount = inferVertexCount;
1✔
104
    this.copyOutputToInputVectors = copyOutputToInputVectors;
1✔
105
  }
106

107
  /** Runs the transform once and optionally copies transform outputs back into input vectors. */
108
  override run(options: TableTransformRunOptions = {}): void {
1✔
109
    if (!this.hasAutomaticOutputWriteback()) {
1!
110
      super.run(options);
×
111
      return;
×
112
    }
113
    assertNoExplicitOutputBuffers(
1✔
114
      options?.outputBuffers,
115
      'TableTransform.run() cannot combine outputBuffers with copyOutputToInputVectors'
116
    );
117
    super.run({...options, outputBuffers: this.getAutomaticOutputBuffers()});
1✔
118
    this.copyOutputsToInputVectors();
1✔
119
  }
120

121
  /** Runs the transform once per preserved GPU record batch. */
122
  dispatchBatches(options: TableTransformBatchOptions = {}): void {
×
123
    if (this.hasAutomaticOutputWriteback() && options.outputBuffers) {
×
124
      throw new Error(
×
125
        'TableTransform.dispatchBatches() cannot combine outputBuffers with copyOutputToInputVectors'
126
      );
127
    }
128
    assertMatchingBufferLayouts(
×
129
      this.table.bufferLayout,
130
      this.explicitBufferLayout,
131
      this.model.bufferLayout,
132
      'TableTransform.dispatchBatches() model buffer layout does not match its GPU table'
133
    );
134

135
    try {
×
136
      this.table.batches.forEach((batch, batchIndex) => {
×
137
        assertMatchingBufferLayouts(
×
138
          this.table.bufferLayout,
139
          [],
140
          batch.bufferLayout,
141
          'TableTransform.dispatchBatches() requires every GPU batch to use the table buffer layout'
142
        );
143
        this.model.setAttributes({
×
144
          ...this.explicitAttributes,
145
          ...batch.attributes
146
        });
147
        if (this.inferVertexCount) {
×
148
          this.model.setVertexCount(batch.numRows);
×
149
        }
150

151
        const outputBuffers =
152
          typeof options.outputBuffers === 'function'
×
153
            ? options.outputBuffers(batch, batchIndex)
154
            : options.outputBuffers;
155
        const {outputBuffers: _ignoredOutputBuffers, ...renderPassProps} = options;
×
156
        if (this.hasAutomaticOutputWriteback()) {
×
157
          super.run({...renderPassProps, outputBuffers: this.getAutomaticOutputBuffers()});
×
158
        } else {
159
          super.run({...renderPassProps, outputBuffers});
×
160
        }
161
      });
162
      if (this.hasAutomaticOutputWriteback()) {
×
163
        this.copyOutputsToInputVectors();
×
164
      }
165
    } finally {
166
      this.model.setAttributes({
×
167
        ...this.explicitAttributes,
168
        ...this.table.attributes
169
      });
170
      if (this.inferVertexCount) {
×
171
        this.model.setVertexCount(this.table.numRows);
×
172
      }
173
    }
174
  }
175

176
  override destroy(): void {
177
    if (this.tableTransformDestroyed) {
1!
178
      return;
×
179
    }
180
    super.destroy();
1✔
181
    destroyGPUVectors(this.outputVectors);
1✔
182
    if (this.ownsTable) {
1!
183
      this.table.destroy();
1✔
184
    }
185
    this.tableTransformDestroyed = true;
1✔
186
  }
187

188
  private hasAutomaticOutputWriteback(): boolean {
189
    return Object.keys(this.copyOutputToInputVectors).length > 0;
1✔
190
  }
191

192
  private getAutomaticOutputBuffers(): TableTransformBufferMap {
193
    return Object.fromEntries(
1✔
194
      Object.entries(this.outputVectors).map(([outputName, vector]) => [
1✔
195
        outputName,
196
        getConcreteGPUDataBuffer(getSingleGPUVectorData(vector))
197
      ])
198
    );
199
  }
200

201
  private copyOutputsToInputVectors(): void {
202
    const commandEncoder = this.device.createCommandEncoder();
1✔
203
    let copyCount = 0;
1✔
204

205
    for (const [outputName, inputName] of Object.entries(this.copyOutputToInputVectors)) {
1✔
206
      const outputVector = this.outputVectors[outputName];
1✔
207
      const inputVector = this.inputVectors[inputName];
1✔
208
      if (!outputVector || !inputVector) {
1!
209
        throw new Error(`TableTransform writeback mapping "${outputName}" is incomplete`);
×
210
      }
211
      const size = inputVector.length * inputVector.byteStride;
1✔
212
      if (size === 0) {
1!
213
        continue;
×
214
      }
215
      commandEncoder.copyBufferToBuffer({
1✔
216
        sourceBuffer: getConcreteGPUDataBuffer(getSingleGPUVectorData(outputVector)),
217
        destinationBuffer: getConcreteGPUDataBuffer(getSingleGPUVectorData(inputVector)),
218
        size
219
      });
220
      copyCount++;
1✔
221
    }
222

223
    if (copyCount > 0) {
1!
224
      this.device.submit(commandEncoder.finish());
1✔
225
      return;
1✔
226
    }
227
    commandEncoder.destroy();
×
228
  }
229
}
230

231
function getTableTransformState(device: Device, props: TableTransformProps): TableTransformState {
232
  const {
233
    table: explicitTable,
234
    inputVectors,
235
    copyOutputToInputVectors = {},
2✔
236
    tableCount = 'vertex',
2✔
237
    ...transformProps
238
  } = props;
2✔
239

240
  validateTableTransformSources({table: explicitTable, inputVectors});
2✔
241
  if (!transformProps.shaderLayout) {
2!
242
    throw new Error('TableTransform requires shaderLayout');
×
243
  }
244

245
  const {table, ownsTable} = getInitialTable({
2✔
246
    table: explicitTable,
247
    inputVectors
248
  });
249

250
  const explicitAttributes = transformProps.attributes || {};
2✔
251
  const explicitBufferLayout = transformProps.bufferLayout || [];
2✔
252
  const inferVertexCount = tableCount === 'vertex' && transformProps.vertexCount === undefined;
2✔
253
  let outputVectors: Record<string, GPUVector> = {};
2✔
254
  let transformOutputs: string[] | undefined;
255

256
  try {
2✔
257
    assertNoDuplicateNames(
2✔
258
      Object.keys(explicitAttributes),
259
      Object.keys(table.attributes),
260
      'attribute'
261
    );
262
    assertNoDuplicateNames(
2✔
263
      getBufferLayoutNames(explicitBufferLayout),
264
      getBufferLayoutNames(table.bufferLayout),
265
      'buffer layout'
266
    );
267
    outputVectors = createAutomaticOutputVectors(device, table, copyOutputToInputVectors);
2✔
268
    transformOutputs = getTableTransformOutputs(transformProps, copyOutputToInputVectors);
2✔
269
  } catch (error) {
270
    destroyGPUVectors(outputVectors);
1✔
271
    if (ownsTable) {
1!
272
      table.destroy();
1✔
273
    }
274
    throw error;
1✔
275
  }
276

277
  return {
1✔
278
    table,
279
    ownsTable,
280
    explicitAttributes,
281
    explicitBufferLayout,
282
    inferVertexCount,
283
    copyOutputToInputVectors,
284
    outputVectors,
285
    transformProps: {
286
      ...transformProps,
287
      ...(transformOutputs ? {outputs: transformOutputs} : {}),
1!
288
      attributes: {...explicitAttributes, ...table.attributes},
289
      bufferLayout: [...explicitBufferLayout, ...table.bufferLayout],
290
      ...(inferVertexCount ? {vertexCount: table.numRows} : {})
1!
291
    }
292
  };
293
}
294

295
function getInitialTable(props: {table?: GPUTable; inputVectors?: TableTransformInputVectors}): {
296
  table: GPUTable;
297
  ownsTable: boolean;
298
} {
299
  if (props.table) {
2!
300
    return {table: props.table, ownsTable: false};
×
301
  }
302
  if (props.inputVectors) {
2!
303
    return {table: new GPUTable({vectors: props.inputVectors}), ownsTable: true};
2✔
304
  }
305
  throw new Error('TableTransform requires table or inputVectors');
×
306
}
307

308
function validateTableTransformSources(props: {
309
  table?: GPUTable;
310
  inputVectors?: TableTransformInputVectors;
311
}): void {
312
  const sourceCount = Number(Boolean(props.table)) + Number(Boolean(props.inputVectors));
2✔
313
  if (sourceCount > 1) {
2!
314
    throw new Error('TableTransform requires only one of table or inputVectors');
×
315
  }
316
  if (sourceCount === 0) {
2!
317
    throw new Error('TableTransform requires table or inputVectors');
×
318
  }
319
}
320

321
function createAutomaticOutputVectors(
322
  device: Device,
323
  table: GPUTable,
324
  copyOutputToInputVectors: TableTransformOutputCopyMap
325
): Record<string, GPUVector> {
326
  const outputEntries = Object.entries(copyOutputToInputVectors);
2✔
327
  if (outputEntries.length === 0) {
2!
328
    return {};
×
329
  }
330
  if (table.batches.length !== 1) {
2!
331
    throw new Error(
×
332
      'TableTransform copyOutputToInputVectors currently requires one directly bindable GPU batch'
333
    );
334
  }
335

336
  const copiedInputNames = new Set<string>();
2✔
337
  const outputVectors: Record<string, GPUVector> = {};
2✔
338

339
  try {
2✔
340
    for (const [outputName, inputName] of outputEntries) {
2✔
341
      if (copiedInputNames.has(inputName)) {
2!
342
        throw new Error(
×
343
          `TableTransform copyOutputToInputVectors maps more than one output to "${inputName}"`
344
        );
345
      }
346
      copiedInputNames.add(inputName);
2✔
347

348
      const inputVector = table.gpuVectors[inputName];
2✔
349
      if (!inputVector) {
2!
350
        throw new Error(
×
351
          `TableTransform copyOutputToInputVectors references missing input vector "${inputName}"`
352
        );
353
      }
354
      validateAutomaticWritebackVector(inputName, inputVector);
2✔
355
      if (!inputVector.format) {
2!
NEW
356
        throw new Error(
×
357
          `TableTransform copyOutputToInputVectors requires input vector "${inputName}" to have a format`
358
        );
359
      }
360

361
      const byteLength = inputVector.length * inputVector.byteStride;
1✔
362
      const outputBuffer = device.createBuffer({
1✔
363
        usage: Buffer.VERTEX | Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST,
364
        byteLength: Math.max(1, byteLength)
365
      });
366
      outputVectors[outputName] = new GPUVector({
1✔
367
        type: 'buffer',
368
        name: outputName,
369
        buffer: outputBuffer,
370
        format: inputVector.format,
371
        length: inputVector.length,
372
        stride: inputVector.stride,
373
        byteStride: inputVector.byteStride,
374
        rowByteLength: inputVector.rowByteLength,
375
        ownsBuffer: true
376
      });
377
    }
378
  } catch (error) {
379
    destroyGPUVectors(outputVectors);
1✔
380
    throw error;
1✔
381
  }
382

383
  return outputVectors;
1✔
384
}
385

386
function validateAutomaticWritebackVector(name: string, vector: GPUVector): void {
387
  if (vector.bufferLayout) {
2!
388
    throw new Error(
×
389
      `TableTransform copyOutputToInputVectors does not support interleaved input vector "${name}"`
390
    );
391
  }
392
  if (vector.byteOffset !== 0) {
2!
393
    throw new Error(
×
394
      `TableTransform copyOutputToInputVectors requires zero byteOffset for input vector "${name}"`
395
    );
396
  }
397
  if (vector.byteStride !== vector.rowByteLength) {
2✔
398
    throw new Error(
1✔
399
      `TableTransform copyOutputToInputVectors requires tightly packed input vector "${name}"`
400
    );
401
  }
402
  getConcreteGPUDataBuffer(getSingleGPUVectorData(vector));
1✔
403
}
404

405
function getTableTransformOutputs(
406
  transformProps: BufferTransformProps,
407
  copyOutputToInputVectors: TableTransformOutputCopyMap
408
): string[] | undefined {
409
  const inferredOutputs = Object.keys(copyOutputToInputVectors);
1✔
410
  if (inferredOutputs.length === 0) {
1!
411
    return transformProps.outputs;
×
412
  }
413

414
  const declaredOutputs = transformProps.outputs || transformProps.varyings;
1✔
415
  if (!declaredOutputs) {
1!
416
    return inferredOutputs;
1✔
417
  }
418
  assertSameOutputNames(declaredOutputs, inferredOutputs);
×
419
  return transformProps.outputs;
×
420
}
421

422
function assertSameOutputNames(declaredOutputs: string[], inferredOutputs: string[]): void {
423
  if (
×
424
    declaredOutputs.length !== inferredOutputs.length ||
×
425
    declaredOutputs.some(outputName => !inferredOutputs.includes(outputName))
×
426
  ) {
427
    throw new Error(
×
428
      'TableTransform outputs must match copyOutputToInputVectors output names when both are supplied'
429
    );
430
  }
431
}
432

433
function assertNoExplicitOutputBuffers(
434
  outputBuffers: TableTransformBufferMap | undefined,
435
  errorMessage: string
436
): void {
437
  if (outputBuffers) {
1!
438
    throw new Error(errorMessage);
×
439
  }
440
}
441

442
function getConcreteGPUDataBuffer(data: GPUData): Buffer {
443
  return data.buffer instanceof DynamicBuffer ? data.buffer.buffer : data.buffer;
4✔
444
}
445

446
function getSingleGPUVectorData(vector: GPUVector): GPUData {
447
  const [data, ...remainingData] = vector.data;
4✔
448
  if (!data || remainingData.length > 0) {
4!
NEW
449
    throw new Error(`TableTransform vector "${vector.name}" requires exactly one GPUData chunk`);
×
450
  }
451
  return data;
4✔
452
}
453

454
function destroyGPUVectors(vectors: Record<string, GPUVector>): void {
455
  for (const vector of Object.values(vectors)) {
3✔
456
    vector.destroy();
1✔
457
  }
458
}
459

460
function getBufferLayoutNames(bufferLayout: BufferLayout[]): string[] {
461
  return bufferLayout.map(layout => layout.name);
4✔
462
}
463

464
function assertNoDuplicateNames(
465
  explicitNames: string[],
466
  tableNames: string[],
467
  nameType: string
468
): void {
469
  const explicitNameSet = new Set(explicitNames);
4✔
470
  for (const tableName of tableNames) {
4✔
471
    if (explicitNameSet.has(tableName)) {
4!
472
      throw new Error(
×
473
        `TableTransform ${nameType} "${tableName}" duplicates an explicit ${nameType}`
474
      );
475
    }
476
  }
477
}
478

479
function assertMatchingBufferLayouts(
480
  tableBufferLayout: BufferLayout[],
481
  explicitBufferLayout: BufferLayout[],
482
  candidateBufferLayout: BufferLayout[],
483
  errorMessage: string
484
): void {
485
  const expectedBufferLayout = [...explicitBufferLayout, ...tableBufferLayout];
×
486
  if (JSON.stringify(expectedBufferLayout) !== JSON.stringify(candidateBufferLayout)) {
×
487
    throw new Error(errorMessage);
×
488
  }
489
}
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