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

visgl / luma.gl / 27017296180

05 Jun 2026 01:20PM UTC coverage: 70.704% (+0.1%) from 70.564%
27017296180

push

github

web-flow
chore: Minor API completions and doc improvements (#2665)

8861 of 14167 branches covered (62.55%)

Branch coverage included in aggregate %.

174 of 196 new or added lines in 15 files covered. (88.78%)

1 existing line in 1 file now uncovered.

18353 of 24323 relevant lines covered (75.46%)

4341.87 hits per line

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

95.12
/modules/tables/src/utils/gpu-table-buffer-planner.ts
1
// luma.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import type {Device} from '@luma.gl/core';
6

7
/** Priority hint for assigning scarce dedicated vertex-buffer slots to table columns. */
8
export type GPUTableColumnPriority = 'high' | 'medium' | 'low';
9

10
/**
11
 * Physical GPU allocation shape chosen for a table column or group of columns.
12
 *
13
 * @remarks
14
 * These values describe planner output only. Callers remain responsible for
15
 * allocating buffers, packing data for interleaved groups, and binding any
16
 * storage-buffer groups.
17
 */
18
export type GPUTableBufferGroupKind =
19
  /** Interleaved vertex-rate columns that describe a reusable geometry shared by all table rows. */
20
  | 'interleaved-shared-geometry-columns'
21
  /** Position columns, including multiple named position columns and fp64 high components. */
22
  | 'position-attribute-columns'
23
  /** Interleaved small buffer for constant columns and fp64 low position components. */
24
  | 'interleaved-constant-attribute-columns'
25
  /** One vertex-buffer binding dedicated to one attribute column. */
26
  | 'separate-attribute-column'
27
  /** One interleaved vertex-buffer binding shared by lower-priority attribute columns. */
28
  | 'interleaved-attribute-columns'
29
  /** One storage-buffer binding dedicated to one original table column. */
30
  | 'separate-storage-column'
31
  /** One storage-buffer binding containing multiple whole-column slices at aligned offsets. */
32
  | 'stacked-storage-columns'
33
  /** A column that keeps its existing external allocation/publication path. */
34
  | 'unmanaged-attribute-column';
35

36
/**
37
 * Planner mode describing how source table rows map to draw-time geometry.
38
 *
39
 * @remarks
40
 * Shared-geometry mode is the default and models one table row as one instance.
41
 * Row-geometry mode models one table row as a variable number of generated
42
 * vertices, such as paths or polygons.
43
 */
44
export type GPUTableBufferPlannerMode =
45
  /** Each source table row draws one instance of reusable shared geometry. */
46
  | 'table-with-shared-geometry'
47
  /** Each source table row expands into its own inline generated vertices. */
48
  | 'table-with-row-geometries';
49

50
/** Model geometry hints used by {@link GPUTableBufferPlanner}. */
51
export type GPUTableBufferPlannerModelInfo = {
52
  /** Whether the model draws one shared geometry instance per table row. */
53
  isInstanced?: boolean;
54
  /** Vertex-buffer slots already consumed by model geometry. */
55
  reservedVertexBufferCount?: number;
56
};
57

58
/**
59
 * One allocation group emitted by {@link GPUTableBufferPlanner}.
60
 *
61
 * @remarks
62
 * A group represents one physical GPU allocation or one externally managed
63
 * column. For vertex-buffer groups, `columns` describes the attributes that
64
 * should be published from that allocation. For storage-buffer groups,
65
 * `byteLength` and `byteOffsets` describe whole-column storage slices.
66
 */
67
export type GPUTableBufferGroup = {
68
  /** Stable buffer/group id used by downstream model-binding code. */
69
  id: string;
70
  /** Physical allocation shape for this group. */
71
  kind: GPUTableBufferGroupKind;
72
  /** Vertex attribute columns or fp64 component views assigned to this group. */
73
  columns: GPUTablePlannedColumn[];
74
  /** Number of rows to materialize for small generated buffers, such as constants. */
75
  rowCount?: number;
76
  /** Vertex input step mode for groups whose row count is planner-owned. */
77
  stepMode?: 'vertex' | 'instance';
78
  /** Byte length for storage-buffer groups. */
79
  byteLength?: number;
80
  /** Per-column byte offsets for storage-buffer groups. */
81
  byteOffsets?: Record<string, number>;
82
};
83

84
/**
85
 * Source table column descriptor consumed by {@link GPUTableBufferPlanner}.
86
 *
87
 * @remarks
88
 * The planner intentionally works from abstract descriptors. It does not
89
 * inspect Arrow vectors, upload GPU buffers, run accessors, or pack typed
90
 * arrays. Callers derive these descriptors from their own table and shader
91
 * metadata, then consume the returned {@link GPUTableBufferPlan}.
92
 */
93
export type GPUTableColumnDescriptor = {
94
  /** Stable column id, usually the shader attribute id. */
95
  id: string;
96
  /** Byte stride contributed by this column to an interleaved vertex attribute buffer. */
97
  byteStride: number;
98
  /** Byte length of the original column when represented as storage data. */
99
  byteLength: number;
100
  /** Number of rows currently materialized for this column. */
101
  rowCount: number;
102
  /** Vertex input step mode this column would publish to a model. */
103
  stepMode: 'vertex' | 'instance';
104
  /** Whether this is a geometry-defining position column. */
105
  isPosition?: boolean;
106
  /** Whether this column is currently a constant value. */
107
  isConstant?: boolean;
108
  /** Whether this column is an index buffer. */
109
  isIndexed?: boolean;
110
  /** Whether this column is currently controlled by transitions. */
111
  isTransition?: boolean;
112
  /** Whether the column is backed only by an external GPU buffer and has no CPU value to pack. */
113
  isExternalBufferOnly?: boolean;
114
  /** Whether this is an fp64 source column. Non-position fp64 columns are unmanaged. */
115
  isDoublePrecision?: boolean;
116
  /** Whether this column should avoid standalone GPU buffers. */
117
  noAlloc?: boolean;
118
  /** Whether noAlloc may be ignored because the column is generated and CPU-backed. */
119
  allowNoAllocManaged?: boolean;
120
  /** Whether this column can be copied into planner-owned packed buffers. */
121
  supportsPackedBuffer?: boolean;
122
  /** Whether this generated row-geometry column must stay as a vertex attribute. */
123
  isGeneratedRowGeometry?: boolean;
124
  /** Priority for receiving a separate vertex-buffer binding. */
125
  priority?: GPUTableColumnPriority;
126
};
127

128
/**
129
 * Column view assigned to a {@link GPUTableBufferGroup}.
130
 *
131
 * @remarks
132
 * Double-precision position columns can produce separate `high` and `low`
133
 * component views so callers can publish fp64 shader attributes from different
134
 * physical groups.
135
 */
136
export type GPUTablePlannedColumn = {
137
  /** Source table column id. */
138
  id: string;
139
  /** Selects the fp64 high or low component view for position columns. */
140
  fp64Component?: 'high' | 'low';
141
};
142

143
/**
144
 * Complete planner output consumed by downstream model-binding code.
145
 *
146
 * @remarks
147
 * The plan is deterministic for a given set of descriptors and device limits.
148
 * `groups` is ordered for publication. The reverse lookups let callers update
149
 * or bind only the groups affected by a changed source column.
150
 */
151
export type GPUTableBufferPlan = {
152
  /** Ordered allocation groups to publish to a Model. */
153
  groups: GPUTableBufferGroup[];
154
  /** Reverse lookup from source column id to all groups containing that column. */
155
  groupsByColumnId: Record<string, GPUTableBufferGroup[]>;
156
  /** Reverse lookup from source column id to shader/model binding names and offsets. */
157
  mappingsByColumnId: Record<string, GPUTableBufferMapping[]>;
158
  /** Columns represented by planner-owned vertex buffers. */
159
  packedColumnIds: Set<string>;
160
  /** Columns represented by storage-buffer groups. */
161
  storageColumnIds: Set<string>;
162
};
163

164
/** Model-binding mapping for one planned vertex attribute or fp64 component view. */
165
export type GPUTableBufferMapping = {
166
  /** Source table column id. */
167
  columnId: string;
168
  /** Shader-visible attribute name, e.g. instanceSourcePositions64Low. */
169
  attributeName: string;
170
  /** Buffer/group id that contains this attribute view. */
171
  bufferName: string;
172
  /** Physical allocation shape of the containing group. */
173
  groupKind: GPUTableBufferGroupKind;
174
  /** fp64 component represented by this mapping, if any. */
175
  fp64Component?: 'high' | 'low';
176
  /** Byte offset within a storage-buffer group. */
177
  byteOffset?: number;
178
};
179

180
/** Inputs to {@link GPUTableBufferPlanner.getAllocationPlan}. */
181
export type GPUTableBufferPlannerProps = {
182
  /** Device whose vertex/storage binding limits constrain the plan. */
183
  device: Device;
184
  /** Candidate table columns. */
185
  columns: GPUTableColumnDescriptor[];
186
  /** Model geometry mode and reserved vertex-buffer slots. */
187
  modelInfo?: GPUTableBufferPlannerModelInfo;
188
  /** Optional explicit planner mode. Defaults from modelInfo.isInstanced. */
189
  mode?: GPUTableBufferPlannerMode;
190
  /** Enables planner-only storage-buffer allocation for row-geometry table columns. */
191
  useStorageBuffers?: boolean;
192
  /** Whether constant columns should be materialized into small planner-owned buffers. */
193
  generateConstantAttributes?: boolean;
194
};
195

196
const PRIORITY_RANK: Record<GPUTableColumnPriority, number> = {
41✔
197
  high: 0,
198
  medium: 1,
199
  low: 2
200
};
201
const POSITIONS_GROUP_ID = 'position-attribute-columns';
41✔
202
const STORAGE_OVERFLOW_COLUMN_ALIGNMENT = 256;
41✔
203

204
/**
205
 * Builds table-first GPU buffer allocation plans from abstract column descriptors.
206
 *
207
 * @remarks
208
 * `GPUTableBufferPlanner` is a pure planning utility. It does not allocate GPU
209
 * buffers, mutate descriptors, retain Arrow data, or bind model resources.
210
 */
211
export class GPUTableBufferPlanner {
212
  /**
213
   * Classifies columns, assigns allocation groups, validates device limits, and
214
   * returns model mappings.
215
   *
216
   * @param props - Planner inputs including device limits, candidate columns,
217
   * model geometry hints, and optional storage/constant planning flags.
218
   * @returns A deterministic table buffer allocation plan.
219
   */
220
  static getAllocationPlan({
221
    device,
222
    columns,
223
    modelInfo,
224
    mode = getPlannerMode(modelInfo),
13✔
225
    useStorageBuffers = false,
13✔
226
    generateConstantAttributes = false
13✔
227
  }: GPUTableBufferPlannerProps): GPUTableBufferPlan {
228
    const sortedColumns = [...columns].sort((a, b) => a.id.localeCompare(b.id));
32✔
229
    const columnsById = Object.fromEntries(
13✔
230
      sortedColumns.map(column => [column.id, column])
35✔
231
    ) as Record<string, GPUTableColumnDescriptor>;
232
    const groups: GPUTableBufferGroup[] = [];
13✔
233
    const geometryColumns: GPUTablePlannedColumn[] = [];
13✔
234
    const constantColumns: GPUTablePlannedColumn[] = [];
13✔
235
    const positionColumns: GPUTablePlannedColumn[] = [];
13✔
236
    const dataColumns: GPUTablePlannedColumn[] = [];
13✔
237
    const reservedVertexBufferCount = modelInfo?.reservedVertexBufferCount || 0;
13✔
238

239
    for (const column of sortedColumns) {
13✔
240
      if (
35✔
241
        column.isIndexed ||
241!
242
        column.isTransition ||
243
        column.isExternalBufferOnly ||
244
        (column.isConstant && !generateConstantAttributes) ||
245
        (column.isPosition && column.isDoublePrecision && !generateConstantAttributes) ||
246
        !canUsePlannedBuffer(column) ||
247
        (!column.supportsPackedBuffer && !column.isPosition)
248
      ) {
249
        groups.push({
5✔
250
          id: column.id,
251
          kind: 'unmanaged-attribute-column',
252
          columns: [{id: column.id}]
253
        });
254
      } else if (mode === 'table-with-row-geometries') {
30✔
255
        if (column.isPosition) {
15✔
256
          positionColumns.push({
6✔
257
            id: column.id,
258
            fp64Component: column.isDoublePrecision ? 'high' : undefined
6!
259
          });
260
          if (column.isDoublePrecision && generateConstantAttributes) {
6!
261
            constantColumns.push({id: column.id, fp64Component: 'low'});
×
262
          }
263
        } else if (column.isConstant && generateConstantAttributes) {
9✔
264
          constantColumns.push({id: column.id});
1✔
265
        } else {
266
          dataColumns.push({id: column.id});
8✔
267
        }
268
      } else if (column.stepMode === 'vertex') {
15✔
269
        geometryColumns.push({id: column.id});
2✔
270
      } else if (column.isPosition) {
13✔
271
        positionColumns.push({
3✔
272
          id: column.id,
273
          fp64Component: column.isDoublePrecision ? 'high' : undefined
3✔
274
        });
275
        if (column.isDoublePrecision && generateConstantAttributes) {
3✔
276
          constantColumns.push({id: column.id, fp64Component: 'low'});
2✔
277
        }
278
      } else if (column.isConstant && generateConstantAttributes) {
10✔
279
        constantColumns.push({id: column.id});
1✔
280
      } else {
281
        dataColumns.push({id: column.id});
9✔
282
      }
283
    }
284

285
    if (geometryColumns.length) {
13✔
286
      groups.push({
2✔
287
        id: 'interleaved-shared-geometry-columns',
288
        kind: 'interleaved-shared-geometry-columns',
289
        columns: geometryColumns
290
      });
291
    }
292
    if (constantColumns.length) {
13✔
293
      groups.push({
3✔
294
        id: 'interleaved-constant-attribute-columns',
295
        kind: 'interleaved-constant-attribute-columns',
296
        columns: constantColumns,
297
        rowCount:
298
          mode === 'table-with-row-geometries'
3✔
299
            ? 1
300
            : getPackedRowCount([...geometryColumns, ...positionColumns], columnsById),
301
        stepMode: mode === 'table-with-row-geometries' ? 'instance' : 'vertex'
3✔
302
      });
303
    }
304
    if (positionColumns.length) {
13✔
305
      groups.push({
8✔
306
        id: POSITIONS_GROUP_ID,
307
        kind: 'position-attribute-columns',
308
        columns: positionColumns
309
      });
310
    }
311

312
    const {storageGroups, vertexColumns} = allocateStorageAttributes({
13✔
313
      device,
314
      mode,
315
      useStorageBuffers,
316
      columns: dataColumns,
317
      columnsById
318
    });
319
    groups.push(...storageGroups);
13✔
320
    groups.push(
13✔
321
      ...allocateDataAttributes(
322
        device,
323
        groups,
324
        vertexColumns,
325
        columnsById,
326
        reservedVertexBufferCount
327
      )
328
    );
329
    validatePlan(device, groups, columnsById, reservedVertexBufferCount);
13✔
330

331
    const groupsByColumnId: Record<string, GPUTableBufferGroup[]> = {};
13✔
332
    const mappingsByColumnId: Record<string, GPUTableBufferMapping[]> = {};
13✔
333
    const packedColumnIds = new Set<string>();
13✔
334
    const storageColumnIds = new Set<string>();
13✔
335
    for (const group of groups) {
13✔
336
      for (const {id: columnId, fp64Component} of group.columns) {
29✔
337
        groupsByColumnId[columnId] = groupsByColumnId[columnId] || [];
33✔
338
        groupsByColumnId[columnId].push(group);
33✔
339
        mappingsByColumnId[columnId] = mappingsByColumnId[columnId] || [];
33✔
340
        const byteOffset = getStorageBufferByteOffset(group, columnId);
33✔
341
        mappingsByColumnId[columnId].push({
33✔
342
          columnId,
343
          attributeName: fp64Component === 'low' ? `${columnId}64Low` : columnId,
33✔
344
          bufferName: group.id,
345
          groupKind: group.kind,
346
          fp64Component,
347
          ...(byteOffset === undefined ? {} : {byteOffset})
33✔
348
        });
349
        if (group.kind === 'separate-storage-column' || group.kind === 'stacked-storage-columns') {
33✔
350
          storageColumnIds.add(columnId);
4✔
351
        } else if (group.kind !== 'unmanaged-attribute-column') {
29✔
352
          packedColumnIds.add(columnId);
24✔
353
        }
354
      }
355
    }
356

357
    return {
11✔
358
      groups,
359
      groupsByColumnId,
360
      mappingsByColumnId,
361
      packedColumnIds,
362
      storageColumnIds
363
    };
364
  }
365

366
  /**
367
   * Returns true when a column should compute CPU values but skip creating
368
   * its own standalone GPU buffer because the caller will publish it.
369
   *
370
   * @param column - Source column descriptor to classify.
371
   * @returns `true` when the column is eligible for caller-owned publication
372
   * through planner-managed buffers.
373
   */
374
  static shouldSkipColumnBuffer(column: GPUTableColumnDescriptor): boolean {
375
    return (
2✔
376
      !column.isIndexed &&
13!
377
      (!column.isDoublePrecision || Boolean(column.isPosition)) &&
378
      !column.isExternalBufferOnly &&
379
      (!column.noAlloc || Boolean(column.allowNoAllocManaged)) &&
380
      !column.isTransition &&
381
      (column.stepMode === 'vertex' || column.stepMode === 'instance')
382
    );
383
  }
384
}
385

386
/**
387
 * Optionally moves row-geometry data columns into storage-buffer groups.
388
 * Columns that cannot be represented as storage buffers fall back to vertex attributes.
389
 *
390
 * @param props - Storage planning inputs and the already-classified data columns.
391
 * @returns Storage-buffer groups plus columns that still need vertex-buffer planning.
392
 */
393
function allocateStorageAttributes({
394
  device,
395
  mode,
396
  useStorageBuffers,
397
  columns,
398
  columnsById
399
}: {
400
  device: Device;
401
  mode: GPUTableBufferPlannerMode;
402
  useStorageBuffers: boolean;
403
  columns: GPUTablePlannedColumn[];
404
  columnsById: Record<string, GPUTableColumnDescriptor>;
405
}): {storageGroups: GPUTableBufferGroup[]; vertexColumns: GPUTablePlannedColumn[]} {
406
  if (!shouldUseStorageBuffers(device, mode, useStorageBuffers) || !columns.length) {
13✔
407
    return {storageGroups: [], vertexColumns: columns};
9✔
408
  }
409

410
  const maxStorageBuffers = getMaxVertexStageStorageBuffers(device);
4✔
411
  const storageGroups: GPUTableBufferGroup[] = [];
4✔
412
  const vertexColumns: GPUTablePlannedColumn[] = [];
4✔
413
  const storageColumns: GPUTablePlannedColumn[] = [];
4✔
414

415
  for (const column of sortDataAttributes(columns, columnsById)) {
4✔
416
    const descriptor = columnsById[column.id];
7✔
417
    const byteLength = descriptor.byteLength;
7✔
418
    if (
7✔
419
      byteLength <= device.limits.maxStorageBufferBindingSize &&
12✔
420
      !descriptor.isGeneratedRowGeometry
421
    ) {
422
      storageColumns.push(column);
5✔
423
    } else {
424
      vertexColumns.push(column);
2✔
425
    }
426
  }
427

428
  const dedicatedCount =
429
    maxStorageBuffers >= storageColumns.length
4✔
430
      ? storageColumns.length
431
      : Math.max(0, maxStorageBuffers - 1);
432

433
  for (const column of storageColumns.slice(0, dedicatedCount)) {
13✔
434
    storageGroups.push({
2✔
435
      id: column.id,
436
      kind: 'separate-storage-column',
437
      columns: [column],
438
      byteLength: columnsById[column.id].byteLength,
439
      byteOffsets: {[column.id]: 0}
440
    });
441
  }
442

443
  const overflowColumns = storageColumns.slice(dedicatedCount);
4✔
444
  if (overflowColumns.length) {
4✔
445
    const layout = getStorageOverflowLayout(overflowColumns, columnsById);
2✔
446
    if (
2✔
447
      storageGroups.length < maxStorageBuffers &&
3✔
448
      layout.byteLength <= device.limits.maxStorageBufferBindingSize
449
    ) {
450
      storageGroups.push({
1✔
451
        id: 'stacked-storage-columns',
452
        kind: 'stacked-storage-columns',
453
        columns: overflowColumns,
454
        byteLength: layout.byteLength,
455
        byteOffsets: layout.byteOffsets
456
      });
457
    } else {
458
      vertexColumns.push(...overflowColumns);
1✔
459
    }
460
  }
461

462
  return {storageGroups, vertexColumns};
4✔
463
}
464

465
/**
466
 * Assigns ordinary data columns to dedicated vertex buffers while slots remain,
467
 * then packs the rest into one interleaved overflow attribute buffer.
468
 *
469
 * @param device - Device whose vertex-buffer count limits constrain allocation.
470
 * @param fixedGroups - Groups already allocated before data columns are assigned.
471
 * @param columns - Data columns eligible for vertex-buffer allocation.
472
 * @param columnsById - Source descriptors keyed by column id.
473
 * @param reservedVertexBufferCount - Vertex-buffer slots already consumed by model geometry.
474
 * @returns Additional data-column allocation groups.
475
 */
476
function allocateDataAttributes(
477
  device: Device,
478
  fixedGroups: GPUTableBufferGroup[],
479
  columns: GPUTablePlannedColumn[],
480
  columnsById: Record<string, GPUTableColumnDescriptor>,
481
  reservedVertexBufferCount: number
482
): GPUTableBufferGroup[] {
483
  if (!columns.length) {
13✔
484
    return [];
4✔
485
  }
486

487
  const sortedColumns = sortDataAttributes(columns, columnsById);
9✔
488

489
  const fixedVertexBufferCount = countVertexBufferGroups(fixedGroups, columnsById);
9✔
490
  const availableSlots =
491
    device.limits.maxVertexBuffers - reservedVertexBufferCount - fixedVertexBufferCount;
9✔
492
  const dedicatedCount =
493
    availableSlots >= sortedColumns.length ? sortedColumns.length : Math.max(0, availableSlots - 1);
9✔
494
  const groups: GPUTableBufferGroup[] = [];
13✔
495

496
  for (const column of sortedColumns.slice(0, dedicatedCount)) {
13✔
497
    groups.push({
8✔
498
      id: column.id,
499
      kind: 'separate-attribute-column',
500
      columns: [column]
501
    });
502
  }
503

504
  const overflowColumns = sortedColumns.slice(dedicatedCount);
9✔
505
  if (overflowColumns.length) {
9✔
506
    groups.push({
3✔
507
      id: 'interleaved-attribute-columns',
508
      kind: 'interleaved-attribute-columns',
509
      columns: overflowColumns
510
    });
511
  }
512

513
  return groups;
9✔
514
}
515

516
/**
517
 * Verifies vertex-buffer counts, storage-buffer counts/sizes, and vertex array stride limits.
518
 *
519
 * @param device - Device whose limits are enforced.
520
 * @param groups - Complete allocation groups to validate.
521
 * @param columnsById - Source descriptors keyed by column id.
522
 * @param reservedVertexBufferCount - Vertex-buffer slots already consumed by model geometry.
523
 */
524
function validatePlan(
525
  device: Device,
526
  groups: GPUTableBufferGroup[],
527
  columnsById: Record<string, GPUTableColumnDescriptor>,
528
  reservedVertexBufferCount: number
529
): void {
530
  const vertexBufferCount =
531
    reservedVertexBufferCount + countVertexBufferGroups(groups, columnsById);
13✔
532
  if (vertexBufferCount > device.limits.maxVertexBuffers) {
13✔
533
    throw new Error(
1✔
534
      `Attribute buffer allocation requires ${vertexBufferCount} vertex buffers, ` +
535
        `but this device supports ${device.limits.maxVertexBuffers}`
536
    );
537
  }
538

539
  const storageBufferGroups = groups.filter(
12✔
540
    group => group.kind === 'separate-storage-column' || group.kind === 'stacked-storage-columns'
30✔
541
  );
542
  const maxStorageBuffers = getMaxVertexStageStorageBuffers(device);
12✔
543
  if (storageBufferGroups.length > maxStorageBuffers) {
12!
UNCOV
544
    throw new Error(
×
545
      `Attribute buffer allocation requires ${storageBufferGroups.length} storage buffers, ` +
546
        `but this device supports ${maxStorageBuffers} in the vertex stage`
547
    );
548
  }
549

550
  for (const group of storageBufferGroups) {
12✔
551
    const byteLength = group.byteLength || columnsById[group.columns[0].id].byteLength;
3!
552
    if (byteLength > device.limits.maxStorageBufferBindingSize) {
3!
553
      throw new Error(
×
554
        `Attribute storage buffer group "${group.id}" requires byteLength ${byteLength}, ` +
555
          `but this device supports ${device.limits.maxStorageBufferBindingSize}`
556
      );
557
    }
558
  }
559

560
  for (const group of groups) {
12✔
561
    if (
30✔
562
      group.kind === 'unmanaged-attribute-column' ||
100✔
563
      group.kind === 'separate-storage-column' ||
564
      group.kind === 'stacked-storage-columns' ||
565
      columnsById[group.columns[0]?.id]?.isIndexed
566
    ) {
567
      continue;
8✔
568
    }
569
    const byteStride = getGroupByteStride(group, columnsById);
22✔
570
    if (byteStride > device.limits.maxVertexBufferArrayStride) {
22✔
571
      throw new Error(
1✔
572
        `Attribute buffer group "${group.id}" requires byteStride ${byteStride}, ` +
573
          `but this device supports ${device.limits.maxVertexBufferArrayStride}`
574
      );
575
    }
576
  }
577
}
578

579
/**
580
 * Counts groups that consume vertex-buffer bindings, excluding storage and index groups.
581
 *
582
 * @param groups - Allocation groups to inspect.
583
 * @param columnsById - Source descriptors keyed by column id.
584
 * @returns Number of vertex-buffer bindings consumed by the groups.
585
 */
586
function countVertexBufferGroups(
587
  groups: GPUTableBufferGroup[],
588
  columnsById: Record<string, GPUTableColumnDescriptor>
589
): number {
590
  return groups.filter(
22✔
591
    group =>
592
      group.kind !== 'separate-storage-column' &&
44✔
593
      group.kind !== 'stacked-storage-columns' &&
594
      !columnsById[group.columns[0]?.id]?.isIndexed
595
  ).length;
596
}
597

598
/**
599
 * Sorts data columns by layout priority and then by id for deterministic plans.
600
 *
601
 * @param columns - Planned column views to sort.
602
 * @param columnsById - Source descriptors keyed by column id.
603
 * @returns A new sorted column array.
604
 */
605
function sortDataAttributes(
606
  columns: GPUTablePlannedColumn[],
607
  columnsById: Record<string, GPUTableColumnDescriptor>
608
): GPUTablePlannedColumn[] {
609
  return [...columns].sort((a, b) => {
13✔
610
    const priorityDiff = getPriorityRank(columnsById[a.id]) - getPriorityRank(columnsById[b.id]);
7✔
611
    return priorityDiff || a.id.localeCompare(b.id);
7✔
612
  });
613
}
614

615
/**
616
 * Converts a layout priority into a sortable rank.
617
 *
618
 * @param column - Source column descriptor.
619
 * @returns Numeric rank where lower values receive dedicated buffers first.
620
 */
621
function getPriorityRank(column: GPUTableColumnDescriptor): number {
622
  return PRIORITY_RANK[column.priority || 'medium'];
14✔
623
}
624

625
/**
626
 * Selects the default planner mode from model instancing metadata.
627
 *
628
 * @param modelInfo - Optional model geometry hints.
629
 * @returns Row-geometry mode when `isInstanced` is exactly `false`; otherwise shared-geometry mode.
630
 */
631
function getPlannerMode(modelInfo?: GPUTableBufferPlannerModelInfo): GPUTableBufferPlannerMode {
632
  return modelInfo?.isInstanced === false
13✔
633
    ? 'table-with-row-geometries'
634
    : 'table-with-shared-geometry';
635
}
636

637
/**
638
 * Computes the materialized row count for shared geometry/constant packed groups.
639
 *
640
 * @param columns - Column views that determine shared geometry row count.
641
 * @param columnsById - Source descriptors keyed by column id.
642
 * @returns At least one row, or the maximum row count among the provided columns.
643
 */
644
function getPackedRowCount(
645
  columns: GPUTablePlannedColumn[],
646
  columnsById: Record<string, GPUTableColumnDescriptor>
647
): number {
648
  return Math.max(1, ...columns.map(({id}) => columnsById[id].rowCount));
4✔
649
}
650

651
/**
652
 * Returns whether a column can be represented by a planner-owned group.
653
 *
654
 * @param column - Source column descriptor.
655
 * @returns `true` when planner-owned allocation can preserve column semantics.
656
 */
657
function canUsePlannedBuffer(column: GPUTableColumnDescriptor): boolean {
658
  if (column.isDoublePrecision && !column.isPosition) {
32✔
659
    return false;
1✔
660
  }
661
  return !column.noAlloc || Boolean(column.allowNoAllocManaged);
31✔
662
}
663

664
/**
665
 * Returns whether optional storage-buffer planning is enabled for this device/mode.
666
 *
667
 * @param device - Device whose backend type is checked.
668
 * @param mode - Active planner mode.
669
 * @param useStorageBuffers - Caller opt-in flag.
670
 * @returns `true` only for WebGPU row-geometry planning with storage enabled.
671
 */
672
function shouldUseStorageBuffers(
673
  device: Device,
674
  mode: GPUTableBufferPlannerMode,
675
  useStorageBuffers: boolean
676
): boolean {
677
  return useStorageBuffers && device.type === 'webgpu' && mode === 'table-with-row-geometries';
13✔
678
}
679

680
function getMaxVertexStageStorageBuffers(device: Device): number {
681
  const limits = device.limits as unknown as Record<string, number | undefined>;
16✔
682
  const maxStorageBuffersPerShaderStage = limits['maxStorageBuffersPerShaderStage'] ?? 0;
16!
683
  const maxStorageBuffersInVertexStage =
684
    limits['maxStorageBuffersInVertexStage'] ?? maxStorageBuffersPerShaderStage;
16!
685
  return Math.max(0, Math.min(maxStorageBuffersPerShaderStage, maxStorageBuffersInVertexStage));
16✔
686
}
687

688
/**
689
 * Builds 256-byte-aligned whole-column slices for the stacked storage overflow buffer.
690
 *
691
 * @param columns - Storage-eligible columns assigned to the overflow group.
692
 * @param columnsById - Source descriptors keyed by column id.
693
 * @returns Total byte length and byte offsets for each source column.
694
 */
695
function getStorageOverflowLayout(
696
  columns: GPUTablePlannedColumn[],
697
  columnsById: Record<string, GPUTableColumnDescriptor>
698
): {
699
  byteLength: number;
700
  byteOffsets: Record<string, number>;
701
} {
702
  let byteLength = 0;
2✔
703
  const byteOffsets: Record<string, number> = {};
2✔
704

705
  for (const {id} of columns) {
2✔
706
    byteLength = alignTo(byteLength, STORAGE_OVERFLOW_COLUMN_ALIGNMENT);
3✔
707
    byteOffsets[id] = byteLength;
3✔
708
    byteLength += columnsById[id].byteLength;
3✔
709
  }
710

711
  return {byteLength: alignTo(byteLength, STORAGE_OVERFLOW_COLUMN_ALIGNMENT), byteOffsets};
2✔
712
}
713

714
/**
715
 * Returns a column's byte offset inside a storage-buffer group.
716
 *
717
 * @param group - Allocation group that may represent storage data.
718
 * @param columnId - Source column id.
719
 * @returns The byte offset for storage-buffer mappings, or `undefined` for vertex-buffer groups.
720
 */
721
function getStorageBufferByteOffset(
722
  group: GPUTableBufferGroup,
723
  columnId: string
724
): number | undefined {
725
  if (group.kind !== 'separate-storage-column' && group.kind !== 'stacked-storage-columns') {
33✔
726
    return undefined;
29✔
727
  }
728
  return group.byteOffsets?.[columnId] ?? 0;
4!
729
}
730

731
/**
732
 * Rounds a value up to the next multiple of an alignment.
733
 *
734
 * @param value - Byte offset or length to align.
735
 * @param alignment - Positive byte alignment.
736
 * @returns Aligned value.
737
 */
738
function alignTo(value: number, alignment: number): number {
739
  return Math.ceil(value / alignment) * alignment;
5✔
740
}
741

742
/**
743
 * Computes byte stride for an interleaved vertex attribute group.
744
 *
745
 * @param group - Allocation group containing one or more planned columns.
746
 * @param columnsById - Source descriptors keyed by column id.
747
 * @returns Sum of source column byte strides in the group.
748
 */
749
function getGroupByteStride(
750
  group: GPUTableBufferGroup,
751
  columnsById: Record<string, GPUTableColumnDescriptor>
752
): number {
753
  return group.columns.reduce((byteStride, {id}) => byteStride + columnsById[id].byteStride, 0);
26✔
754
}
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