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

visgl / deck.gl / 30946614753

04 Aug 2026 08:10PM UTC coverage: 83.748% (-0.03%) from 83.773%
30946614753

Pull #10518

github

web-flow
Merge 22f257de7 into df5812af6
Pull Request #10518: feat(core): interleave attribute buffer group on GPU

8383 of 10503 branches covered (79.82%)

Branch coverage included in aggregate %.

29 of 34 new or added lines in 2 files covered. (85.29%)

14765 of 17137 relevant lines covered (86.16%)

18809.59 hits per line

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

91.72
/modules/core/src/lib/attribute/attribute-buffer-groups.ts
1
// deck.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import Attribute from './attribute';
6
import {getStride} from './gl-utils';
7

8
import {Buffer} from '@luma.gl/core';
9
import {
10
  backendRegistry,
11
  cleanEvaluateSync,
12
  GPUDataEvaluator,
13
  interleave as interleaveTables
14
} from '@luma.gl/gpgpu';
15
import {interleave as webgpuInterleave} from '@luma.gl/gpgpu/webgpu';
16
import type {BufferAttributeLayout, BufferLayout, Device} from '@luma.gl/core';
17

18
type ModelInfo = {
19
  isInstanced?: boolean;
20
};
21

22
type PackedGroup = {
23
  id: string;
24
  attributes: Attribute[];
25
  layout: BufferLayout;
26
  byteStride: number;
27
  byteOffsets: Record<string, number>;
28
  rowCount: number;
29
};
30

31
type PackedGroupState = {
32
  packed: GPUDataEvaluator;
33
  layoutKey: string;
34
};
35

36
/** Internal bindings produced for explicitly grouped WebGPU attributes. */
37
export type AttributeBufferGroupBindings = {
38
  /** Layouts for the current model, including ungrouped fallback attributes. */
39
  bufferLayouts: BufferLayout[];
40
  /** Shared vertex buffers keyed by group id. */
41
  buffers: Record<string, Buffer>;
42
  /** Source attribute ids consumed by shared buffers. */
43
  groupedAttributeIds: Set<string>;
44
};
45

46
/**
47
 * Packs explicitly grouped CPU-backed attributes into additional WebGPU vertex buffers.
48
 *
49
 * This intentionally does not replace Attribute-owned buffers. Keeping the legacy upload path
50
 * intact makes unsupported states safe to fall back to and keeps this 9.4 compatibility path
51
 * isolated from existing WebGL behavior.
52
 */
53
export default class AttributeBufferGroups {
54
  private device: Device;
55
  private id: string;
56
  private isTransitionAttribute: (attributeName: string) => boolean;
57
  private packedBuffers: Record<string, PackedGroupState> = {};
45✔
58

59
  constructor(
60
    device: Device,
61
    {
62
      id,
63
      isTransitionAttribute
64
    }: {
65
      id: string;
66
      isTransitionAttribute: (attributeName: string) => boolean;
67
    }
68
  ) {
69
    this.device = device;
45✔
70
    this.id = id;
45✔
71
    this.isTransitionAttribute = isTransitionAttribute;
45✔
72

73
    if (this.device.type === 'webgpu') {
45!
74
      backendRegistry.add('webgpu', {
45✔
75
        interleave: webgpuInterleave
76
      });
77
    }
78
  }
79

80
  /** Returns whether any attributes explicitly request WebGPU grouping. */
81
  hasGroups(attributes: Record<string, Attribute>): boolean {
82
    return (
105✔
83
      this.device.type === 'webgpu' &&
210✔
84
      Object.values(attributes).some(attribute => Boolean(attribute.settings.bufferGroup))
426✔
85
    );
86
  }
87

88
  /** Deletes shared buffers created by this helper. */
89
  finalize(): void {
90
    for (const state of Object.values(this.packedBuffers)) {
44✔
91
      state.packed.destroy();
27✔
92
    }
93
    this.packedBuffers = {};
44✔
94
  }
95

96
  /**
97
   * Returns constructor-time layouts. Values may not exist yet, so runtime-only fallbacks are
98
   * resolved by {@link getBindings} before the first draw.
99
   */
100
  getBufferLayouts(attributes: Record<string, Attribute>, modelInfo?: ModelInfo): BufferLayout[] {
101
    const groups = this._getPackedGroups(attributes, modelInfo, {
35✔
102
      requireValues: false,
103
      excludeAttributes: {}
104
    });
105
    return this._getBufferLayouts(attributes, groups, modelInfo);
35✔
106
  }
107

108
  /** Returns runtime layouts and shared buffers for a grouped WebGPU model update. */
109
  getBindings(
110
    attributes: Record<string, Attribute>,
111
    changedAttributes: Record<string, Attribute>,
112
    modelInfo: ModelInfo | undefined,
113
    excludeAttributes: Record<string, boolean>
114
  ): AttributeBufferGroupBindings {
115
    const groups = this._getPackedGroups(attributes, modelInfo, {
49✔
116
      requireValues: true,
117
      excludeAttributes
118
    });
119
    const buffers: Record<string, Buffer> = {};
49✔
120
    const groupedAttributeIds = new Set<string>();
49✔
121

122
    for (const group of groups.values()) {
49✔
123
      const needsUpload =
124
        !this.packedBuffers[group.id] ||
45✔
125
        group.attributes.some(attribute => Boolean(changedAttributes[attribute.id]));
18✔
126
      buffers[group.id] = this._getPackedBuffer(group, needsUpload);
45✔
127
      for (const attribute of group.attributes) {
45✔
128
        groupedAttributeIds.add(attribute.id);
166✔
129
      }
130
    }
131

132
    return {
49✔
133
      bufferLayouts: this._getBufferLayouts(attributes, groups, modelInfo).filter(
134
        layout => !excludeAttributes[layout.name] && !attributes[layout.name]?.settings.isIndexed
199✔
135
      ),
136
      buffers,
137
      groupedAttributeIds
138
    };
139
  }
140

141
  private _getPackedGroups(
142
    attributes: Record<string, Attribute>,
143
    modelInfo: ModelInfo | undefined,
144
    {
145
      requireValues,
146
      excludeAttributes
147
    }: {
148
      requireValues: boolean;
149
      excludeAttributes: Record<string, boolean>;
150
    }
151
  ): Map<string, PackedGroup> {
152
    const groupedAttributes = new Map<string, Attribute[]>();
84✔
153

154
    for (const attribute of Object.values(attributes)) {
84✔
155
      const groupId = attribute.settings.bufferGroup;
567✔
156
      if (!groupId) {
567✔
157
        continue;
259✔
158
      }
159
      const group = groupedAttributes.get(groupId) || [];
308✔
160
      group.push(attribute);
567✔
161
      groupedAttributes.set(groupId, group);
567✔
162
    }
163

164
    const packedGroups = new Map<string, PackedGroup>();
84✔
165
    for (const [groupId, groupAttributes] of groupedAttributes) {
84✔
166
      const group = this._getPackedGroup(
84✔
167
        groupId,
168
        groupAttributes,
169
        modelInfo,
170
        requireValues,
171
        excludeAttributes
172
      );
173
      if (group) {
84✔
174
        packedGroups.set(groupId, group);
80✔
175
      }
176
    }
177
    return packedGroups;
84✔
178
  }
179

180
  // eslint-disable-next-line complexity
181
  private _getPackedGroup(
182
    id: string,
183
    attributes: Attribute[],
184
    modelInfo: ModelInfo | undefined,
185
    requireValues: boolean,
186
    excludeAttributes: Record<string, boolean>
187
  ): PackedGroup | null {
188
    if (attributes.length < 2) {
84!
189
      return null;
×
190
    }
191

192
    const layouts = attributes.map(attribute => attribute.getBufferLayout(modelInfo));
308✔
193
    const stepMode = layouts[0].stepMode;
84✔
194
    const rowCount = Math.max(1, attributes[0].numInstances);
84✔
195

196
    for (let index = 0; index < attributes.length; index++) {
84✔
197
      const attribute = attributes[index];
305✔
198
      const accessor = attribute.getAccessor();
305✔
199
      const naturalStride = attribute.size * accessor.bytesPerElement;
305✔
200

201
      if (
305✔
202
        excludeAttributes[attribute.id] ||
3,671✔
203
        attribute.settings.isIndexed ||
204
        attribute.settings.noAlloc ||
205
        attribute.doublePrecision ||
206
        this.isTransitionAttribute(attribute.id) ||
207
        layouts[index].stepMode !== stepMode ||
208
        attribute.numInstances !== attributes[0].numInstances ||
209
        (accessor.offset || 0) !== 0 ||
210
        (accessor.vertexOffset || 0) !== 0 ||
211
        getStride(accessor) !== naturalStride ||
212
        (requireValues &&
213
          (!ArrayBuffer.isView(attribute.value) ||
214
            attribute.value.byteLength < rowCount * naturalStride))
215
      ) {
216
        return null;
4✔
217
      }
218
    }
219

220
    const byteOffsets: Record<string, number> = {};
80✔
221
    const layoutAttributes: BufferAttributeLayout[] = [];
80✔
222
    let byteStride = 0;
80✔
223

224
    for (let index = 0; index < attributes.length; index++) {
80✔
225
      const attribute = attributes[index];
300✔
226
      byteStride = alignTo4(byteStride);
300✔
227
      byteOffsets[attribute.id] = byteStride;
300✔
228
      for (const layoutAttribute of layouts[index].attributes || []) {
300!
229
        layoutAttributes.push({
308✔
230
          ...layoutAttribute,
231
          byteOffset: byteStride + (layoutAttribute.byteOffset || 0)
608✔
232
        });
233
      }
234
      byteStride += getStride(attribute.getAccessor());
300✔
235
    }
236

237
    byteStride = alignTo4(byteStride);
80✔
238
    return {
80✔
239
      id,
240
      attributes,
241
      byteStride,
242
      byteOffsets,
243
      rowCount,
244
      layout: {
245
        name: id,
246
        byteStride,
247
        stepMode,
248
        attributes: layoutAttributes
249
      }
250
    };
251
  }
252

253
  private _getBufferLayouts(
254
    attributes: Record<string, Attribute>,
255
    groups: Map<string, PackedGroup>,
256
    modelInfo?: ModelInfo
257
  ): BufferLayout[] {
258
    const layouts: BufferLayout[] = [];
84✔
259
    const emittedGroups = new Set<string>();
84✔
260
    const groupedAttributeIds = new Set<string>();
84✔
261

262
    for (const group of groups.values()) {
84✔
263
      for (const attribute of group.attributes) {
80✔
264
        groupedAttributeIds.add(attribute.id);
300✔
265
      }
266
    }
267

268
    for (const attribute of Object.values(attributes)) {
84✔
269
      const groupId = attribute.settings.bufferGroup;
567✔
270
      const group = groupId && groups.get(groupId);
567✔
271
      if (group && groupedAttributeIds.has(attribute.id)) {
567✔
272
        if (!emittedGroups.has(group.id)) {
300✔
273
          layouts.push(group.layout);
80✔
274
          emittedGroups.add(group.id);
80✔
275
        }
276
      } else {
277
        layouts.push(attribute.getBufferLayout(modelInfo));
267✔
278
      }
279
    }
280

281
    return layouts;
84✔
282
  }
283

284
  private _getPackedBuffer(group: PackedGroup, upload: boolean): Buffer {
285
    // Step mode is pipeline metadata; it does not change the packed buffer contents. A layer may
286
    // share one group across instanced and non-instanced models, as SolidPolygonLayer does for
287
    // its side and top models.
288
    const layoutKey = JSON.stringify({
45✔
289
      byteStride: group.layout.byteStride,
290
      attributes: group.layout.attributes
291
    });
292
    const state: PackedGroupState | undefined = this.packedBuffers[group.id];
45✔
293

294
    if (!state || state.layoutKey !== layoutKey) {
45✔
295
      upload = true;
27✔
296
    }
297

298
    if (upload) {
45!
299
      if (state) {
45✔
300
        state.packed.destroy();
18✔
301
        delete this.packedBuffers[group.id];
18✔
302
      }
303
      const packed = this._interleavePackedGroup(group);
45✔
304
      this.packedBuffers[group.id] = {packed, layoutKey};
45✔
305
      return packed.buffer;
45✔
306
    }
307

NEW
308
    if (!state) {
×
NEW
309
      throw new Error(`Attribute buffer group ${group.id} has no packed buffer`);
×
310
    }
NEW
311
    return state.packed.buffer;
×
312
  }
313

314
  private _interleavePackedGroup(group: PackedGroup): GPUDataEvaluator {
315
    const evaluators = group.attributes.map(attribute =>
45✔
316
      this._getInterleaveInput(group, attribute)
166✔
317
    );
318
    const packed = interleaveTables(...evaluators);
45✔
319
    cleanEvaluateSync(this.device, packed);
45✔
320
    return packed;
45✔
321
  }
322

323
  private _getInterleaveInput(group: PackedGroup, attribute: Attribute): GPUDataEvaluator {
324
    const buffer = attribute.getBuffer();
166✔
325
    const rowByteLength = getStride(attribute.getAccessor());
166✔
326
    const byteOffset = attribute.byteOffset;
166✔
327
    const stride = attribute.getAccessor().stride || rowByteLength;
166✔
328
    const groupByteOffset = group.byteOffsets[attribute.id];
166✔
329

330
    assertU32Aligned(`${group.id}.${attribute.id} byteOffset`, byteOffset);
166✔
331
    assertU32Aligned(`${group.id}.${attribute.id} stride`, stride);
166✔
332
    assertU32Aligned(`${group.id}.${attribute.id} rowByteLength`, rowByteLength);
166✔
333
    assertU32Aligned(`${group.id}.${attribute.id} groupByteOffset`, groupByteOffset);
166✔
334

335
    if (!buffer) {
166!
NEW
336
      throw new Error(
×
337
        `Attribute group ${group.id} cannot interleave missing buffer ${attribute.id}`
338
      );
339
    }
340

341
    return new GPUDataEvaluator({
166✔
342
      id: attribute.id,
343
      type: 'uint32',
344
      size: rowByteLength / 4,
345
      offset: byteOffset,
346
      stride,
347
      length: group.rowCount,
348
      buffer
349
    });
350
  }
351
}
352

353
function alignTo4(value: number): number {
354
  return Math.ceil(value / 4) * 4;
380✔
355
}
356

357
function assertU32Aligned(label: string, value: number): void {
358
  if (value % 4 !== 0) {
664!
NEW
359
    throw new Error(`Attribute buffer groups require 32-bit alignment: ${label}=${value}`);
×
360
  }
361
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc