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

visgl / deck.gl / 30157453033

25 Jul 2026 12:08PM UTC coverage: 83.181% (-0.2%) from 83.386%
30157453033

Pull #10450

github

web-flow
Merge ee50fb74b into f1abf6d19
Pull Request #10450: feat(layers): extend WebGPU layer support

8186 of 10333 branches covered (79.22%)

Branch coverage included in aggregate %.

36 of 78 new or added lines in 11 files covered. (46.15%)

1 existing line in 1 file now uncovered.

14545 of 16994 relevant lines covered (85.59%)

18901.66 hits per line

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

62.6
/modules/layers/src/path-layer/path-layer.ts
1
// deck.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import {Layer, project32, color, picking, UNIT} from '@deck.gl/core';
6
import {Geometry} from '@luma.gl/engine';
7
import {Model} from '@luma.gl/engine';
8
import PathTesselator from './path-tesselator';
9

10
import {pathUniforms, PathProps} from './path-layer-uniforms';
11
import source from './path-layer.wgsl';
12
import vs from './path-layer-vertex.glsl';
13
import fs from './path-layer-fragment.glsl';
14

15
import type {
16
  LayerProps,
17
  LayerDataSource,
18
  Color,
19
  Accessor,
20
  AccessorFunction,
21
  Unit,
22
  UpdateParameters,
23
  GetPickingInfoParams,
24
  PickingInfo,
25
  DefaultProps
26
} from '@deck.gl/core';
27
import type {PathGeometry} from './path';
28

29
type _PathLayerProps<DataT> = {
30
  data: LayerDataSource<DataT>;
31
  /** The units of the line width, one of `'meters'`, `'common'`, and `'pixels'`
32
   * @default 'meters'
33
   */
34
  widthUnits?: Unit;
35
  /**
36
   * Path width multiplier.
37
   * @default 1
38
   */
39
  widthScale?: number;
40
  /**
41
   * The minimum path width in pixels. This prop can be used to prevent the path from getting too thin when zoomed out.
42
   * @default 0
43
   */
44
  widthMinPixels?: number;
45
  /**
46
   * The maximum path width in pixels. This prop can be used to prevent the path from getting too thick when zoomed in.
47
   * @default Number.MAX_SAFE_INTEGER
48
   */
49
  widthMaxPixels?: number;
50
  /**
51
   * Type of joint. If `true`, draw round joints. Otherwise draw miter joints.
52
   * @default false
53
   */
54
  jointRounded?: boolean;
55
  /**
56
   * Type of caps. If `true`, draw round caps. Otherwise draw square caps.
57
   * @default false
58
   */
59
  capRounded?: boolean;
60
  /**
61
   * The maximum extent of a joint in ratio to the stroke width. Only works if `jointRounded` is `false`.
62
   * @default 4
63
   */
64
  miterLimit?: number;
65
  /**
66
   * If `true`, extrude the path in screen space (width always faces the camera).
67
   * If `false`, the width always faces up (z).
68
   * @default false
69
   */
70
  billboard?: boolean;
71
  /**
72
   * (Experimental) If `'loop'` or `'open'`, will skip normalizing the coordinates returned by `getPath` and instead assume all paths are to be loops or open paths.
73
   * When normalization is disabled, paths must be specified in the format of flat array. Open paths must contain at least 2 vertices and closed paths must contain at least 3 vertices.
74
   * @default null
75
   */
76
  _pathType?: null | 'loop' | 'open';
77
  /**
78
   * Path geometry accessor.
79
   */
80
  getPath?: AccessorFunction<DataT, PathGeometry>;
81
  /**
82
   * Path color accessor.
83
   * @default [0, 0, 0, 255]
84
   */
85
  getColor?: Accessor<DataT, Color | Color[]>;
86
  /**
87
   * Path width accessor.
88
   * @default 1
89
   */
90
  getWidth?: Accessor<DataT, number | number[]>;
91
  /**
92
   * @deprecated Use `jointRounded` and `capRounded` instead
93
   */
94
  rounded?: boolean;
95
};
96

97
export type PathLayerProps<DataT = unknown> = _PathLayerProps<DataT> & LayerProps;
98

99
const DEFAULT_COLOR = [0, 0, 0, 255] as const;
5✔
100

101
const defaultProps: DefaultProps<PathLayerProps> = {
5✔
102
  widthUnits: 'meters',
103
  widthScale: {type: 'number', min: 0, value: 1},
104
  widthMinPixels: {type: 'number', min: 0, value: 0},
105
  widthMaxPixels: {type: 'number', min: 0, value: Number.MAX_SAFE_INTEGER},
106
  jointRounded: false,
107
  capRounded: false,
108
  miterLimit: {type: 'number', min: 0, value: 4},
109
  billboard: false,
110
  _pathType: null,
111

112
  getPath: {type: 'accessor', value: (object: any) => object.path},
8✔
113
  getColor: {type: 'accessor', value: DEFAULT_COLOR},
114
  getWidth: {type: 'accessor', value: 1},
115

116
  // deprecated props
117
  rounded: {deprecatedFor: ['jointRounded', 'capRounded']}
118
};
119

120
const ATTRIBUTE_TRANSITION = {
5✔
121
  enter: (value, chunk) => {
122
    return chunk.length ? chunk.subarray(chunk.length - value.length) : value;
×
123
  }
124
};
125

126
/** Render lists of coordinate points as extruded polylines with mitering. */
127
export default class PathLayer<DataT = any, ExtraPropsT extends {} = {}> extends Layer<
128
  ExtraPropsT & Required<_PathLayerProps<DataT>>
129
> {
130
  static defaultProps = defaultProps;
5✔
131
  static layerName = 'PathLayer';
5✔
132

133
  state!: {
134
    model?: Model;
135
    pathTesselator: PathTesselator;
136
  };
137

138
  getShaders() {
139
    return super.getShaders({
155✔
140
      vs,
141
      fs,
142
      source,
143
      modules: [project32, color, picking, pathUniforms]
144
    }); // 'project' module added by default.
145
  }
146

147
  get wrapLongitude(): boolean {
148
    return false;
536✔
149
  }
150

151
  getBounds(): [number[], number[]] | null {
NEW
152
    if (this.context.device.type === 'webgpu') {
×
NEW
153
      return null;
×
154
    }
UNCOV
155
    return this.getAttributeManager()?.getBounds(['vertexPositions']);
×
156
  }
157

158
  initializeState() {
159
    const noAlloc = true;
152✔
160
    const isWebGPU = this.context.device.type === 'webgpu';
152✔
161
    const attributeManager = this.getAttributeManager();
152✔
162
    /* eslint-disable max-len */
163
    attributeManager!.addInstanced({
152✔
164
      ...(isWebGPU
152!
165
        ? {
166
            // WebGPU cannot express WebGL's vertexOffset window in one vertex buffer layout.
167
            // Pack each segment's [left, start, end, right] high and low position parts instead.
168
            instancePositions: {
169
              size: 24,
170
              type: 'float32',
171
              transition: false,
172
              accessor: 'getPath',
173
              // eslint-disable-next-line @typescript-eslint/unbound-method
174
              update: this.calculateWebGPUPositions,
175
              shaderAttributes: {
176
                instanceLeftPositions: {size: 3, elementOffset: 0},
177
                instanceStartPositions: {size: 3, elementOffset: 3},
178
                instanceEndPositions: {size: 3, elementOffset: 6},
179
                instanceRightPositions: {size: 3, elementOffset: 9},
180
                instanceLeftPositions64Low: {size: 3, elementOffset: 12},
181
                instanceStartPositions64Low: {size: 3, elementOffset: 15},
182
                instanceEndPositions64Low: {size: 3, elementOffset: 18},
183
                instanceRightPositions64Low: {size: 3, elementOffset: 21}
184
              },
185
              noAlloc
186
            }
187
          }
188
        : {
189
            vertexPositions: {
190
              size: 3,
191
              // Start filling buffer from 1 vertex in
192
              vertexOffset: 1,
193
              type: 'float64',
194
              fp64: this.use64bitPositions(),
195
              transition: ATTRIBUTE_TRANSITION,
196
              accessor: 'getPath',
197
              // eslint-disable-next-line @typescript-eslint/unbound-method
198
              update: this.calculatePositions,
199
              noAlloc,
200
              shaderAttributes: {
201
                instanceLeftPositions: {
202
                  vertexOffset: 0
203
                },
204
                instanceStartPositions: {
205
                  vertexOffset: 1
206
                },
207
                instanceEndPositions: {
208
                  vertexOffset: 2
209
                },
210
                instanceRightPositions: {
211
                  vertexOffset: 3
212
                }
213
              }
214
            }
215
          }),
216
      instanceTypes: {
217
        size: 1,
218
        type: isWebGPU ? 'float32' : 'uint8',
152!
219
        // eslint-disable-next-line @typescript-eslint/unbound-method
220
        update: this.calculateSegmentTypes,
221
        noAlloc
222
      },
223
      instanceStrokeWidths: {
224
        size: 1,
225
        accessor: 'getWidth',
226
        transition: isWebGPU ? false : ATTRIBUTE_TRANSITION,
152!
227
        defaultValue: 1,
228
        bufferGroup: 'path-instance-data'
229
      },
230
      instanceColors: {
231
        size: this.props.colorFormat.length,
232
        type: 'unorm8',
233
        accessor: 'getColor',
234
        transition: isWebGPU ? false : ATTRIBUTE_TRANSITION,
152!
235
        defaultValue: DEFAULT_COLOR,
236
        bufferGroup: 'path-instance-data'
237
      },
238
      /** Source path row for each generated segment/joint instance. */
239
      rowIndexes: {
240
        size: 1,
241
        type: 'uint32',
242
        accessor: (object, {index}) => (object && object.__source ? object.__source.index : index),
10,174✔
243
        // AttributeManager only materializes buffer groups on WebGPU, so WebGL keeps its layout.
244
        bufferGroup: 'path-instance-data'
245
      }
246
    });
247
    /* eslint-enable max-len */
248

249
    this.setState({
152✔
250
      pathTesselator: new PathTesselator({
251
        fp64: this.use64bitPositions(),
252
        isWebGPU
253
      })
254
    });
255
  }
256

257
  updateState(params: UpdateParameters<this>) {
258
    super.updateState(params);
296✔
259
    const {props, changeFlags} = params;
296✔
260

261
    const attributeManager = this.getAttributeManager();
296✔
262

263
    const geometryChanged =
264
      changeFlags.dataChanged ||
296✔
265
      (changeFlags.updateTriggersChanged &&
266
        (changeFlags.updateTriggersChanged.all || changeFlags.updateTriggersChanged.getPath));
267

268
    if (geometryChanged) {
296✔
269
      const {pathTesselator} = this.state;
203✔
270
      const buffers = (props.data as any).attributes || {};
203✔
271

272
      pathTesselator.updateGeometry({
203✔
273
        data: props.data,
274
        geometryBuffer: buffers.getPath,
275
        buffers,
276
        normalize: !props._pathType,
277
        loop: props._pathType === 'loop',
278
        getGeometry: props.getPath,
279
        positionFormat: props.positionFormat,
280
        wrapLongitude: props.wrapLongitude,
281
        // TODO - move the flag out of the viewport
282
        resolution: this.context.viewport.resolution,
283
        dataChanged: changeFlags.dataChanged
284
      });
285
      this.setState({
203✔
286
        numInstances: pathTesselator.instanceCount,
287
        startIndices: pathTesselator.vertexStarts
288
      });
289
      if (!changeFlags.dataChanged) {
203!
290
        // Base `layer.updateState` only invalidates all attributes on data change
291
        // Cover the rest of the scenarios here
292
        attributeManager!.invalidateAll();
×
293
      }
294
    }
295

296
    if (changeFlags.extensionsChanged) {
296✔
297
      this.state.model?.destroy();
155✔
298
      this.state.model = this._getModel();
155✔
299
      attributeManager!.invalidateAll();
155✔
300
    }
301
  }
302

303
  getPickingInfo(params: GetPickingInfoParams): PickingInfo {
304
    const info = super.getPickingInfo(params);
38✔
305
    const {index} = info;
38✔
306
    const data = this.props.data as any[];
38✔
307

308
    // Check if data comes from a composite layer, wrapped with getSubLayerRow
309
    if (data[0] && data[0].__source) {
38✔
310
      // index decoded from picking color refers to the source index
311
      info.object = data.find(d => d.__source.index === index);
333✔
312
    }
313
    return info;
38✔
314
  }
315

316
  /** Override base Layer method */
317
  disablePickingIndex(objectIndex: number) {
318
    const data = this.props.data as any[];
3✔
319

320
    // Check if data comes from a composite layer, wrapped with getSubLayerRow
321
    if (data[0] && data[0].__source) {
3!
322
      // index decoded from picking color refers to the source index
323
      for (let i = 0; i < data.length; i++) {
×
324
        if (data[i].__source.index === objectIndex) {
×
325
          this._disablePickingIndex(i);
×
326
        }
327
      }
328
    } else {
329
      super.disablePickingIndex(objectIndex);
3✔
330
    }
331
  }
332

333
  draw({uniforms}) {
334
    const {
335
      jointRounded,
336
      capRounded,
337
      billboard,
338
      miterLimit,
339
      widthUnits,
340
      widthScale,
341
      widthMinPixels,
342
      widthMaxPixels
343
    } = this.props;
544✔
344

345
    const model = this.state.model!;
544✔
346
    const pathProps: PathProps = {
544✔
347
      jointType: Number(jointRounded),
348
      capType: Number(capRounded),
349
      billboard,
350
      widthUnits: UNIT[widthUnits],
351
      widthScale,
352
      miterLimit,
353
      widthMinPixels,
354
      widthMaxPixels
355
    };
356
    model.shaderInputs.setProps({path: pathProps});
544✔
357
    model.draw(this.context.renderPass);
544✔
358
  }
359

360
  protected _getModel(): Model {
361
    /*
362
     *       _
363
     *        "-_ 1                   3                       5
364
     *     _     "o---------------------o-------------------_-o
365
     *       -   / ""--..__              '.             _.-' /
366
     *   _     "@- - - - - ""--..__- - - - x - - - -_.@'    /
367
     *    "-_  /                   ""--..__ '.  _,-` :     /
368
     *       "o----------------------------""-o'    :     /
369
     *      0,2                            4 / '.  :     /
370
     *                                      /   '.:     /
371
     *                                     /     :'.   /
372
     *                                    /     :  ', /
373
     *                                   /     :     o
374
     */
375

376
    // prettier-ignore
377
    const SEGMENT_INDICES = [
155✔
378
      // start corner
379
      0, 1, 2,
380
      // body
381
      1, 4, 2,
382
      1, 3, 4,
383
      // end corner
384
      3, 5, 4
385
    ];
386

387
    // [0] position on segment - 0: start, 1: end
388
    // [1] side of path - -1: left, 0: center (joint), 1: right
389
    // prettier-ignore
390
    const SEGMENT_POSITIONS = [
155✔
391
      // bevel start corner
392
      0, 0,
393
      // start inner corner
394
      0, -1,
395
      // start outer corner
396
      0, 1,
397
      // end inner corner
398
      1, -1,
399
      // end outer corner
400
      1, 1,
401
      // bevel end corner
402
      1, 0
403
    ];
404

405
    return new Model(this.context.device, {
155✔
406
      ...this.getShaders(),
407
      id: this.props.id,
408
      bufferLayout: this.getAttributeManager()!.getBufferLayouts(),
409
      geometry: new Geometry({
410
        topology: 'triangle-list',
411
        attributes: {
412
          indices: new Uint16Array(SEGMENT_INDICES),
413
          positions: {value: new Float32Array(SEGMENT_POSITIONS), size: 2}
414
        }
415
      }),
416
      isInstanced: true
417
    });
418
  }
419

420
  protected calculatePositions(attribute) {
421
    const {pathTesselator} = this.state;
177✔
422

423
    attribute.startIndices = pathTesselator.vertexStarts;
177✔
424
    attribute.value = pathTesselator.get('positions');
177✔
425
  }
426

427
  protected calculateSegmentTypes(attribute) {
428
    const {pathTesselator} = this.state;
203✔
429

430
    attribute.startIndices = pathTesselator.vertexStarts;
203✔
431
    attribute.value = pathTesselator.get('segmentTypes');
203✔
432
  }
433

434
  protected calculateWebGPUPositions(attribute) {
NEW
435
    const {pathTesselator} = this.state;
×
NEW
436
    const value = pathTesselator.get('positions');
×
437

NEW
438
    if (!value) {
×
NEW
439
      attribute.value = null;
×
NEW
440
      return;
×
441
    }
442

NEW
443
    const numInstances = pathTesselator.instanceCount;
×
NEW
444
    const result = new Float32Array(numInstances * 24);
×
445
    // WebGL reads a padded neighbor window using `vertexOffset: 1`; this materializes
446
    // the same [-1, 0, 1, 2] access pattern explicitly for the WebGPU layout.
NEW
447
    const neighborOffsets = [-1, 0, 1, 2];
×
448

NEW
449
    for (let i = 0; i < numInstances; i++) {
×
NEW
450
      const targetIndex = i * 24;
×
NEW
451
      for (let vertexOffset = 0; vertexOffset < 4; vertexOffset++) {
×
NEW
452
        const sourceVertex = i + neighborOffsets[vertexOffset];
×
NEW
453
        const targetOffset = targetIndex + vertexOffset * 3;
×
NEW
454
        for (let j = 0; j < 3; j++) {
×
455
          const position =
NEW
456
            sourceVertex >= 0 && sourceVertex < numInstances ? value[sourceVertex * 3 + j] : 0;
×
NEW
457
          const highPart = Math.fround(position);
×
NEW
458
          result[targetOffset + j] = highPart;
×
NEW
459
          result[targetOffset + j + 12] = position - highPart;
×
460
        }
461
      }
462
    }
463

NEW
464
    attribute.startIndices = pathTesselator.vertexStarts;
×
NEW
465
    attribute.value = result;
×
466
  }
467
}
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