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

visgl / deck.gl / 23721206481

29 Mar 2026 11:03PM UTC coverage: 80.299% (-0.1%) from 80.395%
23721206481

Pull #10113

github

web-flow
Merge a712ff358 into e784bae12
Pull Request #10113: feat(layers) Port PathLayer to WebGPU

3102 of 3750 branches covered (82.72%)

Branch coverage included in aggregate %.

13 of 38 new or added lines in 2 files covered. (34.21%)

1 existing line in 1 file now uncovered.

14204 of 17802 relevant lines covered (79.79%)

26651.44 hits per line

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

65.42
/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 {BufferLayout, Parameters} from '@luma.gl/core';
7
import {Geometry} from '@luma.gl/engine';
8
import {Model} from '@luma.gl/engine';
9
import PathTesselator from './path-tesselator';
10

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

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

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

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

100
const DEFAULT_COLOR = [0, 0, 0, 255] as const;
4✔
101

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

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

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

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

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

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

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

143
  get wrapLongitude(): boolean {
144
    return false;
520✔
145
  }
146

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

154
  initializeState() {
155
    const noAlloc = true;
148✔
156
    const attributeManager = this.getAttributeManager();
148✔
157
    const enableTransitions = this.context.device.type !== 'webgpu';
148✔
158
    /* eslint-disable max-len */
159
    if (this.context.device.type === 'webgpu') {
148✔
NEW
160
      attributeManager!.addInstanced({
×
161
        instancePositions: {
162
          size: 12,
163
          type: 'float32',
164
          // WebGPU keeps the fp64 path by uploading float32 high parts and reconstructing
165
          // with the matching `instancePositions64Low` residuals in WGSL.
166
          transition: false,
167
          accessor: 'getPath',
168
          // eslint-disable-next-line @typescript-eslint/unbound-method
169
          update: this.calculateInstancePositions,
170
          shaderAttributes: {
171
            instanceLeftPositions: {size: 3, elementOffset: 0},
172
            instanceStartPositions: {size: 3, elementOffset: 3},
173
            instanceEndPositions: {size: 3, elementOffset: 6},
174
            instanceRightPositions: {size: 3, elementOffset: 9}
175
          },
176
          noAlloc
177
        },
178
        instancePositions64Low: {
179
          size: 12,
180
          type: 'float32',
181
          // This is the low-part companion to `instancePositions`, not a plain-fp32 downgrade.
182
          transition: false,
183
          accessor: 'getPath',
184
          // eslint-disable-next-line @typescript-eslint/unbound-method
185
          update: this.calculateInstancePositions64Low,
186
          shaderAttributes: {
187
            instanceLeftPositions64Low: {size: 3, elementOffset: 0},
188
            instanceStartPositions64Low: {size: 3, elementOffset: 3},
189
            instanceEndPositions64Low: {size: 3, elementOffset: 6},
190
            instanceRightPositions64Low: {size: 3, elementOffset: 9}
191
          },
192
          noAlloc
193
        },
194
        instanceTypes: {
195
          size: 1,
196
          // eslint-disable-next-line @typescript-eslint/unbound-method
197
          update: this.calculateSegmentTypes,
198
          noAlloc
199
        },
200
        instanceStrokeWidths: {
201
          size: 1,
202
          accessor: 'getWidth',
203
          transition: false,
204
          defaultValue: 1
205
        },
206
        instanceColors: {
207
          size: this.props.colorFormat.length,
208
          type: 'unorm8',
209
          accessor: 'getColor',
210
          transition: false,
211
          defaultValue: DEFAULT_COLOR
212
        },
213
        instancePickingColors: {
214
          size: 4,
215
          type: 'uint8',
216
          accessor: (object, {index, target: value}) =>
NEW
217
            this.encodePickingColor(
×
218
              object && object.__source ? object.__source.index : index,
219
              value
220
            )
221
        }
222
      });
223
    } else {
224
      attributeManager!.addInstanced({
148✔
225
        vertexPositions: {
226
          size: 3,
227
          // Start filling buffer from 1 vertex in
228
          vertexOffset: 1,
229
          type: 'float64',
230
          fp64: this.use64bitPositions(),
231
          transition: enableTransitions ? ATTRIBUTE_TRANSITION : false,
232
          accessor: 'getPath',
233
          // eslint-disable-next-line @typescript-eslint/unbound-method
234
          update: this.calculatePositions,
235
          noAlloc,
236
          shaderAttributes: {
237
            instanceLeftPositions: {
238
              vertexOffset: 0
239
            },
240
            instanceStartPositions: {
241
              vertexOffset: 1
242
            },
243
            instanceEndPositions: {
244
              vertexOffset: 2
245
            },
246
            instanceRightPositions: {
247
              vertexOffset: 3
248
            }
249
          }
250
        },
251
        instanceTypes: {
252
          size: 1,
253
          // eslint-disable-next-line @typescript-eslint/unbound-method
254
          update: this.calculateSegmentTypes,
255
          noAlloc
256
        },
257
        instanceStrokeWidths: {
258
          size: 1,
259
          accessor: 'getWidth',
260
          transition: enableTransitions ? ATTRIBUTE_TRANSITION : false,
261
          defaultValue: 1
262
        },
263
        instanceColors: {
264
          size: this.props.colorFormat.length,
265
          type: 'unorm8',
266
          accessor: 'getColor',
267
          transition: enableTransitions ? ATTRIBUTE_TRANSITION : false,
268
          defaultValue: DEFAULT_COLOR
269
        },
270
        instancePickingColors: {
271
          size: 4,
272
          type: 'uint8',
273
          accessor: (object, {index, target: value}) =>
274
            this.encodePickingColor(
10,174✔
275
              object && object.__source ? object.__source.index : index,
276
              value
277
            )
278
        }
279
      });
280
    }
281
    /* eslint-enable max-len */
282

283
    this.setState({
148✔
284
      pathTesselator: new PathTesselator({
285
        fp64: this.use64bitPositions()
286
      })
287
    });
288
  }
289

290
  updateState(params: UpdateParameters<this>) {
291
    super.updateState(params);
292✔
292
    const {props, changeFlags} = params;
292✔
293

294
    const attributeManager = this.getAttributeManager();
292✔
295

296
    const geometryChanged =
297
      changeFlags.dataChanged ||
292✔
298
      (changeFlags.updateTriggersChanged &&
299
        (changeFlags.updateTriggersChanged.all || changeFlags.updateTriggersChanged.getPath));
300

301
    if (geometryChanged) {
292✔
302
      const {pathTesselator} = this.state;
199✔
303
      const buffers = (props.data as any).attributes || {};
199✔
304

305
      pathTesselator.updateGeometry({
199✔
306
        data: props.data,
307
        geometryBuffer: buffers.getPath,
308
        buffers,
309
        normalize: !props._pathType,
310
        loop: props._pathType === 'loop',
311
        getGeometry: props.getPath,
312
        positionFormat: props.positionFormat,
313
        wrapLongitude: props.wrapLongitude,
314
        // TODO - move the flag out of the viewport
315
        resolution: this.context.viewport.resolution,
316
        dataChanged: changeFlags.dataChanged
317
      });
318
      this.setState({
199✔
319
        numInstances: pathTesselator.instanceCount,
320
        startIndices: pathTesselator.vertexStarts
321
      });
322
      if (!changeFlags.dataChanged) {
199✔
323
        // Base `layer.updateState` only invalidates all attributes on data change
324
        // Cover the rest of the scenarios here
325
        attributeManager!.invalidateAll();
×
326
      }
327
    }
328

329
    if (changeFlags.extensionsChanged) {
292✔
330
      this.state.model?.destroy();
151✔
331
      this.state.model = this._getModel();
151✔
332
      attributeManager!.invalidateAll();
151✔
333
    }
334
  }
335

336
  getPickingInfo(params: GetPickingInfoParams): PickingInfo {
337
    const info = super.getPickingInfo(params);
38✔
338
    const {index} = info;
38✔
339
    const data = this.props.data as any[];
38✔
340

341
    // Check if data comes from a composite layer, wrapped with getSubLayerRow
342
    if (data[0] && data[0].__source) {
38✔
343
      // index decoded from picking color refers to the source index
344
      info.object = data.find(d => d.__source.index === index);
333✔
345
    }
346
    return info;
38✔
347
  }
348

349
  /** Override base Layer method */
350
  disablePickingIndex(objectIndex: number) {
351
    const data = this.props.data as any[];
3✔
352

353
    // Check if data comes from a composite layer, wrapped with getSubLayerRow
354
    if (data[0] && data[0].__source) {
3✔
355
      // index decoded from picking color refers to the source index
356
      for (let i = 0; i < data.length; i++) {
×
357
        if (data[i].__source.index === objectIndex) {
×
358
          this._disablePickingIndex(i);
×
359
        }
360
      }
361
    } else {
362
      super.disablePickingIndex(objectIndex);
3✔
363
    }
364
  }
365

366
  draw({uniforms}) {
367
    const {
368
      jointRounded,
369
      capRounded,
370
      billboard,
371
      miterLimit,
372
      widthUnits,
373
      widthScale,
374
      widthMinPixels,
375
      widthMaxPixels
376
    } = this.props;
528✔
377

378
    const model = this.state.model!;
528✔
379
    const pathProps: PathProps = {
528✔
380
      jointType: Number(jointRounded),
381
      capType: Number(capRounded),
382
      billboard,
383
      widthUnits: UNIT[widthUnits],
384
      widthScale,
385
      miterLimit,
386
      widthMinPixels,
387
      widthMaxPixels
388
    };
389
    model.shaderInputs.setProps({path: pathProps});
528✔
390
    model.draw(this.context.renderPass);
528✔
391
  }
392

393
  protected _getModel(): Model {
394
    const parameters =
395
      this.context.device.type === 'webgpu'
151✔
396
        ? ({
397
            depthWriteEnabled: true,
398
            depthCompare: 'less-equal'
399
          } satisfies Parameters)
400
        : undefined;
401

402
    /*
403
     *       _
404
     *        "-_ 1                   3                       5
405
     *     _     "o---------------------o-------------------_-o
406
     *       -   / ""--..__              '.             _.-' /
407
     *   _     "@- - - - - ""--..__- - - - x - - - -_.@'    /
408
     *    "-_  /                   ""--..__ '.  _,-` :     /
409
     *       "o----------------------------""-o'    :     /
410
     *      0,2                            4 / '.  :     /
411
     *                                      /   '.:     /
412
     *                                     /     :'.   /
413
     *                                    /     :  ', /
414
     *                                   /     :     o
415
     */
416

417
    // prettier-ignore
418
    const SEGMENT_INDICES = [
151✔
419
      // start corner
420
      0, 1, 2,
421
      // body
422
      1, 4, 2,
423
      1, 3, 4,
424
      // end corner
425
      3, 5, 4
426
    ];
427

428
    // [0] position on segment - 0: start, 1: end
429
    // [1] side of path - -1: left, 0: center (joint), 1: right
430
    // prettier-ignore
431
    const SEGMENT_POSITIONS = [
151✔
432
      // bevel start corner
433
      0, 0,
434
      // start inner corner
435
      0, -1,
436
      // start outer corner
437
      0, 1,
438
      // end inner corner
439
      1, -1,
440
      // end outer corner
441
      1, 1,
442
      // bevel end corner
443
      1, 0
444
    ];
445

446
    return new Model(this.context.device, {
151✔
447
      ...this.getShaders(),
448
      id: this.props.id,
449
      bufferLayout: this._getModelBufferLayouts(),
450
      geometry: new Geometry({
451
        topology: 'triangle-list',
452
        attributes: {
453
          indices: new Uint16Array(SEGMENT_INDICES),
454
          positions: {value: new Float32Array(SEGMENT_POSITIONS), size: 2}
455
        }
456
      }),
457
      parameters,
458
      isInstanced: true
459
    });
460
  }
461

462
  protected calculatePositions(attribute) {
463
    const {pathTesselator} = this.state;
173✔
464

465
    attribute.startIndices = pathTesselator.vertexStarts;
173✔
466
    attribute.value = pathTesselator.get('positions');
173✔
467
  }
468

469
  protected calculateInstancePositions(attribute) {
NEW
470
    this._calculateInterleavedInstancePositions(attribute, false);
×
471
  }
472

473
  protected calculateInstancePositions64Low(attribute) {
NEW
474
    this._calculateInterleavedInstancePositions(attribute, true);
×
475
  }
476

477
  protected calculateSegmentTypes(attribute) {
478
    const {pathTesselator} = this.state;
199✔
479

480
    attribute.startIndices = pathTesselator.vertexStarts;
199✔
481
    attribute.value = pathTesselator.get('segmentTypes');
199✔
482
  }
483

484
  protected _calculateInterleavedInstancePositions(attribute, lowPart: boolean) {
NEW
485
    const {pathTesselator} = this.state;
×
NEW
486
    const value = pathTesselator.get('positions');
×
487

NEW
488
    if (!value) {
×
NEW
489
      attribute.value = null;
×
NEW
490
      return;
×
491
    }
492

NEW
493
    const numInstances = pathTesselator.instanceCount;
×
NEW
494
    const result = new Float32Array(numInstances * 12);
×
495
    // WebGL reads a padded neighbor window using `vertexOffset: 1`; this materializes
496
    // the same [-1, 0, 1, 2] access pattern explicitly for the WebGPU layout.
NEW
497
    const neighborOffsets = [-1, 0, 1, 2];
×
498

NEW
499
    for (let i = 0; i < numInstances; i++) {
×
NEW
500
      const targetIndex = i * 12;
×
NEW
501
      for (let vertexOffset = 0; vertexOffset < 4; vertexOffset++) {
×
NEW
502
        const sourceVertex = i + neighborOffsets[vertexOffset];
×
NEW
503
        const targetOffset = targetIndex + vertexOffset * 3;
×
NEW
504
        for (let j = 0; j < 3; j++) {
×
505
          const position =
NEW
506
            sourceVertex >= 0 && sourceVertex < numInstances ? value[sourceVertex * 3 + j] : 0;
×
NEW
507
          result[targetOffset + j] = lowPart ? position - Math.fround(position) : position;
×
508
        }
509
      }
510
    }
511

NEW
512
    attribute.startIndices = pathTesselator.vertexStarts;
×
NEW
513
    attribute.value = result;
×
514
  }
515

516
  protected _getModelBufferLayouts(): BufferLayout[] {
517
    const bufferLayouts = this.getAttributeManager()!.getBufferLayouts();
151✔
518

519
    if (this.context.device.type === 'webgpu') {
151✔
NEW
520
      return bufferLayouts;
×
521
    }
522

523
    return bufferLayouts.map(layout =>
151✔
524
      layout.name === 'vertexPositions'
769✔
525
        ? {
526
            ...layout,
527
            attributes: (layout.attributes || []).filter(
528
              attribute =>
529
                attribute.attribute !== 'vertexPositions' &&
1,510✔
530
                attribute.attribute !== 'vertexPositions64Low'
531
            )
532
          }
533
        : layout
534
    );
535
  }
536
}
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