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

visgl / deck.gl / 29924638951

22 Jul 2026 01:36PM UTC coverage: 83.331% (+0.2%) from 83.166%
29924638951

push

github

web-flow
feat(core): add explicit WebGPU buffer groups (#10151)

8106 of 10217 branches covered (79.34%)

Branch coverage included in aggregate %.

127 of 131 new or added lines in 3 files covered. (96.95%)

14491 of 16900 relevant lines covered (85.75%)

19008.65 hits per line

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

97.14
/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 type {BufferAttributeLayout, BufferLayout, Device} from '@luma.gl/core';
10

11
type ModelInfo = {
12
  isInstanced?: boolean;
13
};
14

15
type PackedGroup = {
16
  id: string;
17
  attributes: Attribute[];
18
  layout: BufferLayout;
19
  byteStride: number;
20
  byteOffsets: Record<string, number>;
21
  rowCount: number;
22
};
23

24
type PackedGroupState = {
25
  buffer: Buffer;
26
  layoutKey: string;
27
};
28

29
/** Internal bindings produced for explicitly grouped WebGPU attributes. */
30
export type AttributeBufferGroupBindings = {
31
  /** Layouts for the current model, including ungrouped fallback attributes. */
32
  bufferLayouts: BufferLayout[];
33
  /** Shared vertex buffers keyed by group id. */
34
  buffers: Record<string, Buffer>;
35
  /** Source attribute ids consumed by shared buffers. */
36
  groupedAttributeIds: Set<string>;
37
};
38

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

52
  constructor(
53
    device: Device,
54
    {
55
      id,
56
      isTransitionAttribute
57
    }: {
58
      id: string;
59
      isTransitionAttribute: (attributeName: string) => boolean;
60
    }
61
  ) {
62
    this.device = device;
5✔
63
    this.id = id;
5✔
64
    this.isTransitionAttribute = isTransitionAttribute;
5✔
65
  }
66

67
  /** Returns whether any attributes explicitly request WebGPU grouping. */
68
  hasGroups(attributes: Record<string, Attribute>): boolean {
69
    return (
6✔
70
      this.device.type === 'webgpu' &&
12✔
71
      Object.values(attributes).some(attribute => Boolean(attribute.settings.bufferGroup))
13✔
72
    );
73
  }
74

75
  /** Deletes shared buffers created by this helper. */
76
  finalize(): void {
77
    for (const state of Object.values(this.packedBuffers)) {
5✔
78
      state.buffer.delete();
2✔
79
    }
80
    this.packedBuffers = {};
5✔
81
  }
82

83
  /**
84
   * Returns constructor-time layouts. Values may not exist yet, so runtime-only fallbacks are
85
   * resolved by {@link getBindings} before the first draw.
86
   */
87
  getBufferLayouts(attributes: Record<string, Attribute>, modelInfo?: ModelInfo): BufferLayout[] {
88
    const groups = this._getPackedGroups(attributes, modelInfo, {
2✔
89
      requireValues: false,
90
      excludeAttributes: {}
91
    });
92
    return this._getBufferLayouts(attributes, groups, modelInfo);
2✔
93
  }
94

95
  /** Returns runtime layouts and shared buffers for a grouped WebGPU model update. */
96
  getBindings(
97
    attributes: Record<string, Attribute>,
98
    changedAttributes: Record<string, Attribute>,
99
    modelInfo: ModelInfo | undefined,
100
    excludeAttributes: Record<string, boolean>
101
  ): AttributeBufferGroupBindings {
102
    const groups = this._getPackedGroups(attributes, modelInfo, {
7✔
103
      requireValues: true,
104
      excludeAttributes
105
    });
106
    const buffers: Record<string, Buffer> = {};
7✔
107
    const groupedAttributeIds = new Set<string>();
7✔
108

109
    for (const group of groups.values()) {
7✔
110
      const needsUpload =
111
        !this.packedBuffers[group.id] ||
3✔
112
        group.attributes.some(attribute => Boolean(changedAttributes[attribute.id]));
1✔
113
      buffers[group.id] = this._getPackedBuffer(group, needsUpload);
3✔
114
      for (const attribute of group.attributes) {
3✔
115
        groupedAttributeIds.add(attribute.id);
8✔
116
      }
117
    }
118

119
    return {
7✔
120
      bufferLayouts: this._getBufferLayouts(attributes, groups, modelInfo),
121
      buffers,
122
      groupedAttributeIds
123
    };
124
  }
125

126
  private _getPackedGroups(
127
    attributes: Record<string, Attribute>,
128
    modelInfo: ModelInfo | undefined,
129
    {
130
      requireValues,
131
      excludeAttributes
132
    }: {
133
      requireValues: boolean;
134
      excludeAttributes: Record<string, boolean>;
135
    }
136
  ): Map<string, PackedGroup> {
137
    const groupedAttributes = new Map<string, Attribute[]>();
9✔
138

139
    for (const attribute of Object.values(attributes)) {
9✔
140
      const groupId = attribute.settings.bufferGroup;
29✔
141
      if (!groupId) {
29✔
142
        continue;
5✔
143
      }
144
      const group = groupedAttributes.get(groupId) || [];
24✔
145
      group.push(attribute);
29✔
146
      groupedAttributes.set(groupId, group);
29✔
147
    }
148

149
    const packedGroups = new Map<string, PackedGroup>();
9✔
150
    for (const [groupId, groupAttributes] of groupedAttributes) {
9✔
151
      const group = this._getPackedGroup(
9✔
152
        groupId,
153
        groupAttributes,
154
        modelInfo,
155
        requireValues,
156
        excludeAttributes
157
      );
158
      if (group) {
9✔
159
        packedGroups.set(groupId, group);
5✔
160
      }
161
    }
162
    return packedGroups;
9✔
163
  }
164

165
  // eslint-disable-next-line complexity
166
  private _getPackedGroup(
167
    id: string,
168
    attributes: Attribute[],
169
    modelInfo: ModelInfo | undefined,
170
    requireValues: boolean,
171
    excludeAttributes: Record<string, boolean>
172
  ): PackedGroup | null {
173
    if (attributes.length < 2) {
9!
NEW
174
      return null;
×
175
    }
176

177
    const layouts = attributes.map(attribute => attribute.getBufferLayout(modelInfo));
24✔
178
    const stepMode = layouts[0].stepMode;
9✔
179
    const rowCount = Math.max(1, attributes[0].numInstances);
9✔
180

181
    for (let index = 0; index < attributes.length; index++) {
9✔
182
      const attribute = attributes[index];
21✔
183
      const accessor = attribute.getAccessor();
21✔
184
      const naturalStride = attribute.size * accessor.bytesPerElement;
21✔
185

186
      if (
21✔
187
        excludeAttributes[attribute.id] ||
231✔
188
        attribute.settings.isIndexed ||
189
        attribute.settings.noAlloc ||
190
        attribute.doublePrecision ||
191
        this.isTransitionAttribute(attribute.id) ||
192
        layouts[index].stepMode !== stepMode ||
193
        attribute.numInstances !== attributes[0].numInstances ||
194
        (accessor.offset || 0) !== 0 ||
195
        (accessor.vertexOffset || 0) !== 0 ||
196
        getStride(accessor) !== naturalStride ||
197
        (requireValues &&
198
          (!ArrayBuffer.isView(attribute.value) ||
199
            attribute.value.byteLength < rowCount * naturalStride))
200
      ) {
201
        return null;
4✔
202
      }
203
    }
204

205
    const byteOffsets: Record<string, number> = {};
5✔
206
    const layoutAttributes: BufferAttributeLayout[] = [];
5✔
207
    let byteStride = 0;
5✔
208

209
    for (let index = 0; index < attributes.length; index++) {
5✔
210
      const attribute = attributes[index];
16✔
211
      byteStride = alignTo4(byteStride);
16✔
212
      byteOffsets[attribute.id] = byteStride;
16✔
213
      for (const layoutAttribute of layouts[index].attributes || []) {
16!
214
        layoutAttributes.push({
24✔
215
          ...layoutAttribute,
216
          byteOffset: byteStride + (layoutAttribute.byteOffset || 0)
40✔
217
        });
218
      }
219
      byteStride += getStride(attribute.getAccessor());
16✔
220
    }
221

222
    byteStride = alignTo4(byteStride);
5✔
223
    return {
5✔
224
      id,
225
      attributes,
226
      byteStride,
227
      byteOffsets,
228
      rowCount,
229
      layout: {
230
        name: id,
231
        byteStride,
232
        stepMode,
233
        attributes: layoutAttributes
234
      }
235
    };
236
  }
237

238
  private _getBufferLayouts(
239
    attributes: Record<string, Attribute>,
240
    groups: Map<string, PackedGroup>,
241
    modelInfo?: ModelInfo
242
  ): BufferLayout[] {
243
    const layouts: BufferLayout[] = [];
9✔
244
    const emittedGroups = new Set<string>();
9✔
245
    const groupedAttributeIds = new Set<string>();
9✔
246

247
    for (const group of groups.values()) {
9✔
248
      for (const attribute of group.attributes) {
5✔
249
        groupedAttributeIds.add(attribute.id);
16✔
250
      }
251
    }
252

253
    for (const attribute of Object.values(attributes)) {
9✔
254
      const groupId = attribute.settings.bufferGroup;
29✔
255
      const group = groupId && groups.get(groupId);
29✔
256
      if (group && groupedAttributeIds.has(attribute.id)) {
29✔
257
        if (!emittedGroups.has(group.id)) {
16✔
258
          layouts.push(group.layout);
5✔
259
          emittedGroups.add(group.id);
5✔
260
        }
261
      } else {
262
        layouts.push(attribute.getBufferLayout(modelInfo));
13✔
263
      }
264
    }
265

266
    return layouts;
9✔
267
  }
268

269
  private _getPackedBuffer(group: PackedGroup, upload: boolean): Buffer {
270
    const byteLength = group.rowCount * group.byteStride;
3✔
271
    const layoutKey = JSON.stringify(group.layout);
3✔
272
    let state = this.packedBuffers[group.id];
3✔
273

274
    if (!state || state.buffer.byteLength < byteLength || state.layoutKey !== layoutKey) {
3✔
275
      state?.buffer.delete();
2✔
276
      state = {
2✔
277
        buffer: this.device.createBuffer({
278
          id: `${this.id}-${group.id}`,
279
          usage: Buffer.VERTEX | Buffer.COPY_DST,
280
          byteLength
281
        }),
282
        layoutKey
283
      };
284
      this.packedBuffers[group.id] = state;
2✔
285
      upload = true;
2✔
286
    }
287

288
    if (upload) {
3!
289
      const data = new Uint8Array(byteLength);
3✔
290
      for (const attribute of group.attributes) {
3✔
291
        const value = attribute.value as ArrayBufferView;
8✔
292
        const source = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
8✔
293
        const sourceStride = getStride(attribute.getAccessor());
8✔
294
        const targetOffset = group.byteOffsets[attribute.id];
8✔
295

296
        for (let row = 0; row < group.rowCount; row++) {
8✔
297
          const sourceOffset = row * sourceStride;
16✔
298
          data.set(
16✔
299
            source.subarray(sourceOffset, sourceOffset + sourceStride),
300
            row * group.byteStride + targetOffset
301
          );
302
        }
303
      }
304
      state.buffer.write(data);
3✔
305
    }
306

307
    return state.buffer;
3✔
308
  }
309
}
310

311
function alignTo4(value: number): number {
312
  return Math.ceil(value / 4) * 4;
21✔
313
}
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