From 5627b8fec214149525d63e24c53576f4f607d465 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 30 Jul 2026 12:43:56 -0400 Subject: [PATCH 1/2] feat: port remaining path and polygon layers to WebGPU --- dev/timeline-layers/README.md | 2 +- .../horizon-graph-layer.ts | 11 +- .../horizon-graph-layer.wgsl.ts | 7 +- .../src/layers/time-axis-layer.ts | 11 +- .../test/time-axis-layer.spec.ts | 1 + docs/modules/editable-layers/README.md | 2 +- docs/modules/geo-layers/README.md | 6 +- docs/modules/graph-layers/README.md | 2 +- .../layers/path-rounded-rectangle-layer.md | 11 +- .../layers/rounded-rectangle-layer.md | 14 +- docs/modules/layers/README.md | 2 +- .../api-reference/path-outline-layer.md | 10 +- docs/webgpu.md | 42 +-- docs/whats-new.md | 18 +- .../test/webgpu-layers.browser.spec.ts | 157 +++++++++++ .../editable-layers/editable-path-layer.ts | 19 ++ .../src/wind-layer/delaunay-cover-layer.ts | 2 +- .../src/wind-layer/gpu-particle-simulation.ts | 68 ++--- .../wind-layer/wind-layers.browser.spec.ts | 5 - .../layers/edge-layers/edge-arrow-layer.ts | 84 +++--- .../rounded-rectangle-layer-fragment.ts | 29 -- .../node-layers/rounded-rectangle-layer.ts | 60 +---- .../test/layers/edge-arrow-layer.spec.ts | 54 +++- .../path-outline-layer/path-outline-layer.ts | 19 +- .../webgpu-dash-path-layer.ts | 163 ++++++++++++ .../path-outline-layer.spec.ts | 21 ++ .../layers/test/webgpu-layers.browser.spec.ts | 251 +++++++++++++++++- modules/layers/test/webgpu-shaders.spec.ts | 5 +- 28 files changed, 863 insertions(+), 213 deletions(-) create mode 100644 modules/arrow-layers/test/webgpu-layers.browser.spec.ts delete mode 100644 modules/graph-layers/src/layers/node-layers/rounded-rectangle-layer-fragment.ts create mode 100644 modules/layers/src/path-outline-layer/webgpu-dash-path-layer.ts diff --git a/dev/timeline-layers/README.md b/dev/timeline-layers/README.md index e1366a031..65e490feb 100644 --- a/dev/timeline-layers/README.md +++ b/dev/timeline-layers/README.md @@ -3,7 +3,7 @@ [![NPM Version](https://img.shields.io/npm/v/@deck.gl-community/timeline-layers.svg)](https://www.npmjs.com/package/@deck.gl-community/timeline-layers) [![NPM Downloads](https://img.shields.io/npm/dw/@deck.gl-community/timeline-layers.svg)](https://www.npmjs.com/package/@deck.gl-community/timeline-layers) ![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square") -![WebGPU not supported](https://img.shields.io/badge/webgpu-no-red.svg?style=flat-square") +![WebGPU partially supported](https://img.shields.io/badge/webgpu-partial-yellow.svg?style=flat-square") Experimental timeline visualization layers for [deck.gl](https://deck.gl), including HorizonGraph primitives and timeline axes/grid utilities for compact time series displays. diff --git a/dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.ts b/dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.ts index 823c2c137..a045df177 100644 --- a/dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.ts +++ b/dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.ts @@ -85,13 +85,16 @@ export class HorizonGraphLayer extends Layer< } // TODO: use the right way to only submit the minimum amount of data - const data = new Float32Array(dataTextureSize * dataTextureSize); - data.set(_data, 0); + const floatData = new Float32Array(dataTextureSize * dataTextureSize); + floatData.set(_data, 0); + const isWebGpu = device.type === 'webgpu'; return { dataTexture: device.createTexture({ - data, - format: 'r32float', + data: isWebGpu ? new Uint32Array(floatData.buffer) : floatData, + // WebGPU float32 textures are unfilterable on baseline adapters. Preserve every float bit + // in an integer texture and recover it with bitcast in WGSL; WebGL keeps the sampler path. + format: isWebGpu ? 'r32uint' : 'r32float', dimension: '2d', width: dataTextureSize, height: dataTextureSize, diff --git a/dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.wgsl.ts b/dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.wgsl.ts index 4873129d8..0c1fdfb7d 100644 --- a/dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.wgsl.ts +++ b/dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.wgsl.ts @@ -16,7 +16,7 @@ struct HorizonLayerUniforms { }; @group(0) @binding(auto) var horizonLayer: HorizonLayerUniforms; -@group(0) @binding(auto) var dataTexture: texture_2d; +@group(0) @binding(auto) var dataTexture: texture_2d; struct HorizonAttributes { @location(0) positions: vec3, @@ -49,8 +49,9 @@ fn fragmentMain(varyings: HorizonVaryings) -> @location(0) vec4 { ); let row = floor(index * horizonLayer.dataTextureSizeInv); let column = index - row * horizonLayer.dataTextureSize; - let value = textureLoad(dataTexture, vec2(i32(column), i32(row)), 0).r * - horizonLayer.yAxisScaleInv; + let value = bitcast( + textureLoad(dataTexture, vec2(i32(column), i32(row)), 0).r + ) * horizonLayer.yAxisScaleInv; let scaledBand = abs(value) * horizonLayer.bands; let bandIndex = clamp(floor(scaledBand), 0.0, horizonLayer.bands - 1.0); let bandFraction = fract(scaledBand); diff --git a/dev/timeline-layers/src/layers/time-axis-layer.ts b/dev/timeline-layers/src/layers/time-axis-layer.ts index 889623158..4ee8303be 100644 --- a/dev/timeline-layers/src/layers/time-axis-layer.ts +++ b/dev/timeline-layers/src/layers/time-axis-layer.ts @@ -246,6 +246,12 @@ export class TimeAxisLayer extends CompositeLayer { : configuration.maxX; const minorTextColor = withHalfAlpha(configuration.textColor); const minorGridColor = withHalfAlpha(configuration.gridColor); + const fontProps = { + ...(this.props.characterSet === undefined ? {} : {characterSet: this.props.characterSet}), + ...(this.props.fontFamily === undefined ? {} : {fontFamily: this.props.fontFamily}), + ...(this.props.fontSettings === undefined ? {} : {fontSettings: this.props.fontSettings}), + ...(this.props.fontWeight === undefined ? {} : {fontWeight: this.props.fontWeight}) + }; return [ configuration.axisLine && @@ -285,10 +291,7 @@ export class TimeAxisLayer extends CompositeLayer { getColor: tick => (tick.type === 'major' ? configuration.textColor : minorTextColor), getTextAnchor: configuration.legacyRange ? 'middle' : 'start', getAlignmentBaseline: configuration.legacyRange ? 'top' : 'bottom', - characterSet: this.props.characterSet, - fontFamily: this.props.fontFamily, - fontSettings: this.props.fontSettings, - fontWeight: this.props.fontWeight, + ...fontProps, updateTriggers: { getText: [this.props.formatTick, configuration.mode, configuration.legacyRange] } diff --git a/dev/timeline-layers/test/time-axis-layer.spec.ts b/dev/timeline-layers/test/time-axis-layer.spec.ts index 398bb855a..00e9b41ab 100644 --- a/dev/timeline-layers/test/time-axis-layer.spec.ts +++ b/dev/timeline-layers/test/time-axis-layer.spec.ts @@ -90,6 +90,7 @@ describe('TimeAxisLayer', () => { const tickLabels = layer.renderLayers().find(sublayer => sublayer instanceof TextLayer); expect(tickLabels).toBeInstanceOf(TextLayer); + expect(tickLabels?.props.fontSettings).toEqual({}); }); it('forwards optional font properties directly to the tick label layer', () => { diff --git a/docs/modules/editable-layers/README.md b/docs/modules/editable-layers/README.md index 7695825ed..89a061940 100644 --- a/docs/modules/editable-layers/README.md +++ b/docs/modules/editable-layers/README.md @@ -1,7 +1,7 @@ # Overview ![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square") -![WebGPU not supported](https://img.shields.io/badge/webgpu-no-red.svg?style=flat-square") +![WebGPU partially supported](https://img.shields.io/badge/webgpu-partial-yellow.svg?style=flat-square") Provides a suite of editable deck.gl layers, primarly focused on GeoJSON visualization and editing. diff --git a/docs/modules/geo-layers/README.md b/docs/modules/geo-layers/README.md index 63d8e3a0f..518cd34d1 100644 --- a/docs/modules/geo-layers/README.md +++ b/docs/modules/geo-layers/README.md @@ -24,9 +24,9 @@ This module exports geospatial deck.gl layers developed by the community. ## Wind showcase :::caution Work in progress -The wind-layer API and historical showcase are experimental. `ParticleLayer` has verified WebGL2 -and WebGPU GPU simulation. The complete terrain-and-arrows scene remains WebGL2-first because -upstream terrain, polygon, and path support on WebGPU is still in progress. +The wind-layer API and historical showcase are experimental. `ParticleLayer`, filled arrows, and +station-triangulated terrain are verified on WebGL2 and WebGPU. The original image-derived mountain +terrain remains WebGL2-only because upstream `TerrainLayer` does not yet support WebGPU. ::: The [wind showcase guide](./developer-guide/wind-showcase.md) recreates Nicolas Belmonte's diff --git a/docs/modules/graph-layers/README.md b/docs/modules/graph-layers/README.md index 6fc4df112..3c5807e2d 100644 --- a/docs/modules/graph-layers/README.md +++ b/docs/modules/graph-layers/README.md @@ -1,7 +1,7 @@ # Overview ![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square") -![WebGPU not supported](https://img.shields.io/badge/webgpu-no-red.svg?style=flat-square") +![WebGPU partially supported](https://img.shields.io/badge/webgpu-partial-yellow.svg?style=flat-square") `graph-layers` is a deck.gl layer pack for GPU-powered visualization of large graphs. diff --git a/docs/modules/graph-layers/api-reference/layers/path-rounded-rectangle-layer.md b/docs/modules/graph-layers/api-reference/layers/path-rounded-rectangle-layer.md index 2c3828354..13580067b 100644 --- a/docs/modules/graph-layers/api-reference/layers/path-rounded-rectangle-layer.md +++ b/docs/modules/graph-layers/api-reference/layers/path-rounded-rectangle-layer.md @@ -5,10 +5,9 @@ import LayerLiveExample from '@site/src/components/docs/layer-live-example'; `PathBasedRoundedRectangleLayer` renders rounded rectangles by tessellating a -polygon path. Unlike [`RoundedRectangleLayer`](./rounded-rectangle-layer.md), -which shaders the rounding in the fragment stage, this layer generates explicit -geometry using `generateRoundedCorners` so it can work with Deck.gl's standard -polygon shaders. +polygon path. `RoundedRectangleLayer` now extends this implementation, so both +style names generate explicit geometry with `generateRoundedCorners` and use +deck.gl's standard dual-backend polygon shaders. ## Usage @@ -34,8 +33,8 @@ const layer = new PathBasedRoundedRectangleLayer({ }); ``` -Use this renderer when you need rounded rectangles without the custom shader -module required by `RoundedRectangleLayer`. +Use this renderer directly for the explicit `path-rounded-rectangle` graph style; +the regular `rounded-rectangle` style shares the same portable implementation. ## Properties diff --git a/docs/modules/graph-layers/api-reference/layers/rounded-rectangle-layer.md b/docs/modules/graph-layers/api-reference/layers/rounded-rectangle-layer.md index 1ac87d7a9..453437b87 100644 --- a/docs/modules/graph-layers/api-reference/layers/rounded-rectangle-layer.md +++ b/docs/modules/graph-layers/api-reference/layers/rounded-rectangle-layer.md @@ -5,8 +5,8 @@ import LayerLiveExample from '@site/src/components/docs/layer-live-example'; `RoundedRectangleLayer` renders rectangles with programmable corner radii. It -extends [`RectangleLayer`](./rectangle-layer.md) and injects a fragment shader -uniform so each instance can round corners independently. +CPU-tessellates each rounded outline and renders it through deck.gl's +dual-backend `PolygonLayer`, so the same geometry works on WebGL2 and WebGPU. ## Usage @@ -37,19 +37,17 @@ const layer = new RoundedRectangleLayer({ ## Properties -All [`RectangleLayer` props](./rectangle-layer.md#properties) apply, plus the -options below. +The options below control the generated polygon geometry. ### `cornerRadius` (number, optional) -Controls how round each corner should be. The shader expects a normalized value: -`0` renders a sharp corner while `1` approximates a circle. The stylesheet may -supply a constant or accessor via the `cornerRadius` attribute. +Controls the radius of each CPU-tessellated corner in the layer's coordinate +units. The stylesheet may supply a constant or accessor. ### `stylesheet` ([`GraphStylesheetEngine`](../internal/graph-stylesheet-engine.md), required) Must expose `getCornerRadius`, `getWidth`, and `getHeight` accessors so the layer -can size each node and update its shader uniforms. +can rebuild each node polygon when its style changes. ### `positionUpdateTrigger` (any, optional) diff --git a/docs/modules/layers/README.md b/docs/modules/layers/README.md index 9e8f9111e..da96e4a33 100644 --- a/docs/modules/layers/README.md +++ b/docs/modules/layers/README.md @@ -1,7 +1,7 @@ # Overview ![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square") -![WebGPU not supported](https://img.shields.io/badge/webgpu-no-red.svg?style=flat-square") +![WebGPU supported](https://img.shields.io/badge/webgpu-yes-green.svg?style=flat-square") This module provides a suite of reusable layers for [deck.gl](https://deck.gl). The layers in this module are generic primitives that are intended to be usable in both geospatial and non-geospatial visualizations. diff --git a/docs/modules/layers/api-reference/path-outline-layer.md b/docs/modules/layers/api-reference/path-outline-layer.md index 571a5ade8..76232bdd4 100644 --- a/docs/modules/layers/api-reference/path-outline-layer.md +++ b/docs/modules/layers/api-reference/path-outline-layer.md @@ -11,6 +11,9 @@ on top. This keeps the layer on the deck.gl/luma.gl v9 render path while letting you emphasize overlapping paths (for example, trails or transit lines) without managing multiple layers manually. +The solid and dashed variants are browser-verified on WebGL2 and WebGPU. On WebGPU, the layer +bridges the currently GLSL-only `PathStyleExtension` with an internal WGSL dash plugin. + Use [`PathMarkerLayer`](./path-marker-layer.md) when you need arrows or other markers along a path. The `PathOutlineLayer` is focused purely on the outline pass and can be combined with any other overlays. @@ -80,10 +83,11 @@ your data ordering when outlines overlap. Defaults to `() => 0`. `widthScale * outlineWidthScale`. 2. `path`: rendered second with the original `getColor` and `widthScale`. -When `getDashArray` is supplied, the layer attaches +When `getDashArray` is supplied on WebGL2, the layer attaches `PathStyleExtension({dash: true, highPrecisionDash: true})` unless a caller -already provided a path-style extension, so `getDashArray` and `dashJustified` -work on both sublayers. It also disables depth writes and uses +already provided a path-style extension. WebGPU uses the equivalent internal +WGSL dash implementation. Both paths support `getDashArray` and `dashJustified` +on both sublayers. The layer also disables depth writes and uses `depthCompare: 'always'` on both passes to avoid z-fighting between colocated path strokes. diff --git a/docs/webgpu.md b/docs/webgpu.md index bb52af252..2efb70dc2 100644 --- a/docs/webgpu.md +++ b/docs/webgpu.md @@ -4,32 +4,34 @@ deck.gl-community is adding WebGPU support incrementally while continuing to sup ## Layer support matrix -✅ means implemented and verified, 🚧 means partial, planned, or dependent on additional validation, and ❌ means unavailable or blocked by an upstream renderer. +✅ means implemented and verified, 🚧 means partial, planned, or dependent on additional validation, and ❌ means unavailable on that backend. | Module | Layer or integration | WebGL2 | WebGPU | Notes | | --- | --- | :---: | :---: | --- | | `@deck.gl-community/layers` | `SkyboxLayer` | ✅ | ✅ | Native GLSL and WGSL cubemap shaders. | | `@deck.gl-community/layers` | `DependencyArrowLayer`, `line` mode | ✅ | ✅ | Portable `LineLayer` and native WGSL marker geometry. | -| `@deck.gl-community/layers` | `DependencyArrowLayer`, `arc` mode | ✅ | 🚧 | Marker geometry is portable; upstream `ArcLayer` requires validation. | -| `@deck.gl-community/layers` | `DependencyArrowLayer`, `path` mode | ✅ | ❌ | Blocked by upstream `PathLayer`. | -| `@deck.gl-community/layers` | `PathOutlineLayer` | ✅ | ❌ | Blocked by upstream `PathLayer` and `PathStyleExtension`. | -| `@deck.gl-community/layers` | `PathMarkerLayer` | ✅ | 🚧 | Marker geometry is portable; outlined and dashed paths remain blocked. | +| `@deck.gl-community/layers` | `DependencyArrowLayer`, `arc` mode | ✅ | ✅ | Browser-verified upstream `ArcLayer` and native WGSL marker geometry. | +| `@deck.gl-community/layers` | `DependencyArrowLayer`, `path` mode | ✅ | ✅ | Browser-verified upstream `PathLayer`, outlines, and native WGSL markers. | +| `@deck.gl-community/layers` | `PathOutlineLayer` | ✅ | ✅ | Upstream dual-backend `PathLayer`; a local WGSL dash plugin bridges the still-GLSL-only `PathStyleExtension`. | +| `@deck.gl-community/layers` | `PathMarkerLayer` | ✅ | ✅ | Browser-verified outlined and dashed paths with native WGSL marker geometry. | | `@deck.gl-community/infovis-layers` | `BlockLayer` | ✅ | ✅ | Native WGSL, projection, picking, fills, outlines, and float32 binary attributes. | | `@deck.gl-community/infovis-layers` | `AnimationLayer` | ✅ | 🚧 | Depends on the wrapped layer's backend support. | | `@deck.gl-community/infovis-layers` | `TimeDeltaLayer` | ✅ | ✅ | Portable interval guides and native WGSL `FastTextLayer` labels. | | `@deck.gl-community/infovis-layers` | `FastTextLayer` | ✅ | ✅ | Native WGSL adapted from luma.gl's text-renderer patterns; existing packed glyphs, bitmap/SDF atlases, clipping, alignment, and mipmaps work on both backends. | -| `@deck.gl-community/timeline-layers` | `HorizonGraphLayer` | ✅ | ✅ | Native WGSL and `r32float` data textures. | +| `@deck.gl-community/timeline-layers` | `HorizonGraphLayer` | ✅ | ✅ | Native WGSL; WebGPU preserves float bits in baseline-compatible `r32uint` textures. | | `@deck.gl-community/timeline-layers` | `MultiHorizonGraphLayer` | ✅ | ✅ | Portable horizon shaders and dual-backend line dividers. | -| `@deck.gl-community/timeline-layers` | `TimeAxisLayer` | ✅ | 🚧 | Grid lines use the portable `LineLayer`; tick labels depend on upstream `TextLayer` WebGPU support. | +| `@deck.gl-community/timeline-layers` | `TimeAxisLayer` | ✅ | 🚧 | Grid lines are portable; upstream `TextLayer` labels still require stable WebGPU validation. | | `@deck.gl-community/timeline-layers` | `VerticalGridLayer` | ✅ | ✅ | Browser-verified portable `LineLayer` grid marks and viewport-driven ticks. | -| `@deck.gl-community/timeline-layers` | `TimelineLayer` | ✅ | ❌ | Blocked by upstream `SolidPolygonLayer`. | +| `@deck.gl-community/timeline-layers` | `TimelineLayer` geometry | ✅ | ✅ | Browser-verified tracks, clips, scrubber polygons, and lines using upstream dual-backend layers. | +| `@deck.gl-community/timeline-layers` | `TimelineLayer` labels and interactions | ✅ | 🚧 | Text labels and pointer/drag behavior still require stable WebGPU browser coverage. | | `@deck.gl-community/trace-layers` | `TraceGraphLayer` and `TracePreparedStateLayer` | ✅ | ✅ | Browser-verified span blocks, backgrounds, outlines, row separators, fast labels, and straight dependency markers. | | `@deck.gl-community/trace-layers` | `TraceProcessLayer` | ✅ | ✅ | Automatically selects WebGPU-compatible binary blocks, fast span and overflow labels, and straight dependency rendering. | | `@deck.gl-community/trace-layers` | Counter sparklines | ✅ | ✅ | Preserves every sparkline vertex as a dual-backend `LineLayer` segment. | | `@deck.gl-community/trace-layers` | Curved dependencies and dashed separators | ✅ | 🚧 | Curves depend on upstream `ArcLayer`; portable separators use solid horizontal lines. | | `@deck.gl-community/trace-layers` | `DeckTraceGraph` and Tracevis overview | ✅ | 🚧 | Managed devices and backend-neutral timing are available; complete legend, minimap, picking, and application validation remains in progress. | -| `@deck.gl-community/graph-layers` | `GraphLayer`, `EdgeLayer`, and node layers | ✅ | 🚧 | Custom shaders, picking, and graph styling require porting and validation. | -| `@deck.gl-community/graph-layers` | `RoundedRectangleLayer` | ✅ | ❌ | Custom fragment shader has no native WGSL implementation. | +| `@deck.gl-community/graph-layers` | `GraphLayer`, `EdgeLayer`, and node layers | ✅ | 🚧 | Static path edges, arrow decorators, and rounded nodes are portable; complete graph styling, images, labels, layouts, and picking still require end-to-end validation. | +| `@deck.gl-community/graph-layers` | `RoundedRectangleLayer` | ✅ | ✅ | Rounded corners are CPU-tessellated and rendered with upstream dual-backend `PolygonLayer`. | +| `@deck.gl-community/graph-layers` | `PathEdgeLayer` and `EdgeArrowLayer` | ✅ | ✅ | Browser-verified upstream path rendering and polygon arrowheads. | | `@deck.gl-community/graph-layers` | `FlowPathLayer` | ❌ | ❌ | Existing transform-feedback implementation is incomplete; requires redesign. | | `@deck.gl-community/geo-layers` | `ParticleLayer` | ✅ | ✅ | Browser-verified WebGL2 transform-feedback and WebGPU compute advection; production rendering uses GPU particle buffers without readbacks. | | `@deck.gl-community/geo-layers` | Wind-field utilities and `DelaunayInterpolation` | ✅ | ✅ | Backend-independent station indexing, explicit sampling, and optional CPU rasterization. | @@ -38,8 +40,10 @@ deck.gl-community is adding WebGPU support incrementally while continuing to sup | `@deck.gl-community/geo-layers` | `DelaunayCoverLayer` | ✅ | ✅ | Native WGSL/GLSL station triangles, elevation scaling, and height-based coloring. | | `@deck.gl-community/geo-layers` | Complete Wind Map showcase | ✅ | 🚧 | GPU particles, arrows, labels, and station terrain are portable; image terrain and map boundaries remain upstream-dependent. | | `@deck.gl-community/geo-layers` | Tile and global-grid layers | ✅ | 🚧 | Validate upstream sublayers, tile formats, and picking. | -| `@deck.gl-community/arrow-layers` | GeoArrow layers | ✅ | 🚧 | Validate binary attributes and each upstream rendering layer. | -| `@deck.gl-community/editable-layers` | Editing and selection layers | ✅ | 🚧 | Validate editing interactions and upstream GeoJSON and path layers. | +| `@deck.gl-community/arrow-layers` | `GeoArrowPathLayer` and `GeoArrowSolidPolygonLayer` | ✅ | ✅ | Browser-verified zero-copy binary path and polygon attributes. | +| `@deck.gl-community/arrow-layers` | Remaining GeoArrow layers | ✅ | 🚧 | Validate each upstream renderer; `GeoArrowTripsLayer` still has custom shader work. | +| `@deck.gl-community/editable-layers` | GeoJSON paths, polygons, and edit handles | ✅ | ✅ | Browser-verified `EditableGeoJsonLayer` rendering in `ModifyMode`, including the WebGPU picking-width shader path. | +| `@deck.gl-community/editable-layers` | Editing and selection interactions | ✅ | 🚧 | Pointer, drag, snapping, and selection behavior still require browser interaction coverage on WebGPU. | | `@deck.gl-community/basemap-layers` | `BasemapLayer` | ✅ | 🚧 | Support depends on the selected style's polygon, path, and label sublayers. | | `@deck.gl-community/three` | `TreeLayer` | ✅ | ❌ | Depends on the external Three.js renderer and canvas integration. | | `@deck.gl-community/leaflet` | Leaflet map overlay | ✅ | ❌ | A host-owned WebGL context cannot be switched to WebGPU. | @@ -102,8 +106,8 @@ resources. Preserve view state across recreation, create only layers supported b backend, and call `manager.reset()` after finalizing the renderer to destroy every cached device. The documentation website injects device tabs in its shared imperative-example host. Standalone -example applications accept an optional device and widgets but do not own device management. Path -outline and marker demonstrations remain WebGL2-only until upstream path rendering supports WebGPU. +example applications accept an optional device and widgets but do not own device management. +Path outlines, path markers, and dependency routes now render through the selected backend. ## Compatibility roadmap @@ -112,13 +116,13 @@ outline and marker demonstrations remain WebGL2-only until upstream path renderi | Existing reference | `SkyboxLayer` | Provides native WGSL and GLSL sources, portable cubemap bindings, and a switchable skybox example. | | First wave | `BlockLayer`, `DependencyArrowLayer` marker geometry, `HorizonGraphLayer`, and `MultiHorizonGraphLayer` | Native WGSL and existing GLSL are maintained together. Stacked horizon dividers use the upstream dual-backend `LineLayer`; the website injects real WebGPU/WebGL2 device selection into the skybox, path, block, and horizon examples. | | Wind showcase | `ParticleLayer`, wind-field utilities, `WindLayer`, and `DelaunayCoverLayer` | WebGL2 transform-feedback, WebGPU compute, native arrow triangles, and station-surface rendering are browser-verified. Image-based mountain terrain still depends on upstream `TerrainLayer`. | -| Upstream-dependent paths | `PathOutlineLayer`, `PathMarkerLayer`, dashed path routing, and `DependencyArrowLayer` path mode | Full route rendering depends on native WGSL support for deck.gl's `PathLayer` and `PathStyleExtension`. Directional marker geometry and line-mode dependencies can be ported independently, but outlined or dashed paths must not be advertised as fully WebGPU-compatible yet. | +| Path and polygon unblock | `PathOutlineLayer`, `PathMarkerLayer`, `DependencyArrowLayer`, `TimelineLayer` geometry, GeoArrow paths and polygons, editable GeoJSON, and static graph geometry | deck.gl 9.4 alpha.2 supplies dual-backend path and polygon shaders. Community layers use them directly, with a local WGSL dash plugin until `PathStyleExtension` gains native WGSL. | | Second wave | `FastTextLayer` | Add a small WGSL compatibility shader to the existing glyph layer, following luma.gl `master`'s `TextRenderer` and Arrow text patterns while retaining the published luma.gl 9.3 dependency line. | | Trace rendering | `TraceGraphLayer`, `TracePreparedStateLayer`, `TraceProcessLayer`, and counter sparklines | Reuse shared dual-backend blocks, fast text, and lines; preserve external float32 trace attributes; automatically select portable text and straight dependency routes on WebGPU. | | Upstream v10 | `TextRenderer` and Arrow text | Replace the compatibility path with luma.gl's optimized text and Arrow renderers after their currently private v10 modules are published. | -| Third wave | `RoundedRectangleLayer` and graph node and edge layers | Audit custom fragment shaders, picking, graph styling, and inherited deck.gl layer compatibility before claiming support. | +| Graph geometry | `RoundedRectangleLayer`, `PathEdgeLayer`, and `EdgeArrowLayer` | Replace the fragment-only rounded rectangle and mesh arrowhead with CPU-tessellated polygons, then validate static path and polygon geometry on both backends. Full `GraphLayer` integration remains in progress. | | Dedicated redesign | `FlowPathLayer` and animated graph flows | The current transform-feedback implementation is incomplete and WebGL-specific. Replace it with a backend-neutral animation or compute design; do not treat shader translation alone as a port. | -| Subsequent validation | Arrow, editable, geospatial, and basemap layers | Validate upstream sublayers, binary attributes, picking, shader extensions, tile and texture formats, and each demonstrated example independently. | +| Subsequent validation | Remaining Arrow, editable interactions, geospatial tiles, and basemap layers | Validate remaining upstream sublayers, picking interactions, tile and texture formats, and each demonstrated example independently. | | Host-dependent integrations | Three.js, Leaflet, Bing Maps, and external map renderers | Support depends on the host renderer and canvas ownership. A host-owned WebGL context cannot be switched to WebGPU by adding device tabs. | The skybox map example also composes a basemap. `SkyboxLayer` itself has native WebGPU shaders, while complete basemap compatibility remains subject to the downstream GeoJSON, polygon, path, and label sublayers used by the selected style. @@ -140,6 +144,10 @@ getShaders() { Declare WGSL resource bindings with `@binding(auto)`, keep each shader module's uniform types in the same order as its WGSL structure, and use `Model`, `Geometry`, `Texture`, and `renderPass` rather than a raw WebGL context. Include real browser coverage for available devices, and explicitly skip WebGPU rendering when a browser cannot supply a WebGPU adapter. +Do not assume `r32float` or `rgba32float` textures are filterable on a baseline WebGPU adapter. +For nearest-neighbor data textures, an integer texture plus WGSL `bitcast` preserves the original +float bits without requiring the optional `float32-filterable` feature. + To explicitly run the complete Chromium suite with software WebGPU, use: ```sh diff --git a/docs/whats-new.md b/docs/whats-new.md index 69195532f..666994f74 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -15,15 +15,25 @@ Scope tracked in the [v9.4 milestone](https://github.com/visgl/deck.gl-community - `BlockLayer`: added native WGSL for instanced block fills, outlines, projection, opacity, and picking while preserving the existing WebGL2 shaders. - `FastTextLayer`: added an upstream-informed WGSL compatibility shader for existing packed bitmap and signed-distance-field glyphs, font-atlas bindings, clipping, alignment, and WebGPU mipmaps. - `TimeDeltaLayer`: uses portable line guides and native-WGSL fast-text labels on WebGPU while preserving WebGL2 label backgrounds. -- `DependencyArrowLayer`: added native WGSL for directional arrow-marker geometry, picking, and line-mode dependencies; path mode remains blocked on upstream `PathLayer` support. -- `HorizonGraphLayer`: added native WGSL and portable `r32float` data-texture bindings. +- `DependencyArrowLayer`: added native WGSL for directional arrow-marker geometry and picking; line, arc, and path routing are browser-verified on both backends with deck.gl 9.4 alpha.2. +- `PathOutlineLayer` and `PathMarkerLayer`: use the upstream dual-backend `PathLayer` and add a local WGSL dash plugin until `PathStyleExtension` gains native WGSL. +- `HorizonGraphLayer`: added native WGSL and baseline-compatible WebGPU integer data textures that preserve the original float bits. - `MultiHorizonGraphLayer`: made stacked horizon graphs portable by using dual-backend `LineLayer` dividers alongside the new horizon shaders. - `VerticalGridLayer`: validated viewport-driven timeline ticks and grid lines on both graphics backends. +- `TimelineLayer`: validated track, clip, scrubber, and line geometry with the upstream WebGPU polygon and line layers; text labels and interactions remain in progress. - `WindLayer` and `DelaunayCoverLayer`: render filled directional arrows and station-triangulated surfaces using native WebGL2/WebGPU triangle shaders. - `ParticleLayer`: restored the historical wind showcase's GPU-resident particle advection using WebGL2 transform feedback and native WebGPU compute, with no production particle readbacks and support for up to one million animated particles; native point rendering preserves simulation - buffer ownership and defers resource cleanup until submitted GPU work completes. + buffer ownership and defers resource cleanup until submitted GPU work completes. WebGPU weather + textures preserve float data in integer textures so baseline adapters do not require + `float32-filterable`. +- `RoundedRectangleLayer`, `PathEdgeLayer`, and `EdgeArrowLayer`: replaced WebGL-only graph + primitives with CPU-tessellated polygons and browser-verified upstream path rendering. +- GeoArrow path and solid-polygon layers now have browser coverage for zero-copy binary attributes + on WebGL2 and WebGPU. +- `EditableGeoJsonLayer`: browser-verified polygon, path, and edit-handle rendering on WebGPU, + including its picking-width shader customization. - `TraceGraphLayer`, `TracePreparedStateLayer`, and `TraceProcessLayer`: ported trace backgrounds, binary span blocks, outlines, labels, overflow labels, separators, and straight dependencies by reusing dual-backend community layers. - Trace counter sparklines now preserve their full geometry using portable `LineLayer` segments. - The website injects luma.gl-style device tabs into skybox, path and dependency-marker, information-visualization, horizon-graph, and trace examples, with independent managers, WebGPU preference, WebGL2 fallback, and real renderer switching; standalone examples remain free of device-management dependencies. @@ -50,7 +60,7 @@ Scope tracked in the [v9.4 milestone](https://github.com/visgl/deck.gl-community ### `@deck.gl-community/layers` - `DependencyArrowLayer` - NEW directional marker layer for dependency links with path, line, or arc routing. -- `DependencyArrowLayer` marker geometry now includes a native WGSL shader alongside its existing WebGL2 shader, including directional markers and picking; complete path-mode compatibility remains dependent on upstream `PathLayer` support. +- `DependencyArrowLayer` marker geometry includes native WGSL alongside its existing WebGL2 shader; line, path, and arc routing are supported on both backends. - `PathOutlineLayer` and `PathMarkerLayer` now use deck.gl v9-native sublayers for outlined paths, dashed strokes, and pixel-sized directional markers, restoring the path outline and marker example. ### `@deck.gl-community/infovis-layers` diff --git a/modules/arrow-layers/test/webgpu-layers.browser.spec.ts b/modules/arrow-layers/test/webgpu-layers.browser.spec.ts new file mode 100644 index 000000000..5183761ce --- /dev/null +++ b/modules/arrow-layers/test/webgpu-layers.browser.spec.ts @@ -0,0 +1,157 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {COORDINATE_SYSTEM, Deck, OrthographicView} from '@deck.gl/core'; +import {luma, type Device} from '@luma.gl/core'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {webgpuAdapter} from '@luma.gl/webgpu'; +import { + Field, + FixedSizeList, + Float32, + List, + tableFromArrays, + vectorFromArray +} from 'apache-arrow'; +import {describe, expect, it} from 'vitest'; + +import {GeoArrowPathLayer, GeoArrowSolidPolygonLayer} from '../src'; + +type BrowserGpu = {requestAdapter: () => Promise}; +type NativeGpuError = {error?: {message?: string}}; +type NativeGpuDevice = { + addEventListener: (type: 'uncapturederror', listener: (event: NativeGpuError) => void) => void; + removeEventListener: (type: 'uncapturederror', listener: (event: NativeGpuError) => void) => void; + queue: {onSubmittedWorkDone: () => Promise}; +}; + +const pointType = new FixedSizeList(2, new Field('xy', new Float32())); +const lineStringType = new List(new Field('vertices', pointType)); +const polygonType = new List(new Field('rings', lineStringType)); +const table = tableFromArrays({id: [0]}); +const lineStrings = vectorFromArray( + [ + [ + [-24, 16], + [0, 28], + [24, 16] + ] + ], + lineStringType +); +const polygons = vectorFromArray( + [ + [ + [ + [-20, -20], + [20, -20], + [20, 4], + [-20, 4], + [-20, -20] + ] + ] + ], + polygonType +); + +async function renderGeoArrowLayers(type: 'webgl' | 'webgpu'): Promise { + const parent = document.createElement('div'); + parent.style.width = '128px'; + parent.style.height = '128px'; + document.body.append(parent); + + const pathLayer = new GeoArrowPathLayer({ + id: `geoarrow-path-${type}`, + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: table, + getPath: lineStrings, + getColor: [14, 165, 233, 255], + getWidth: 4, + widthUnits: 'pixels', + pickable: true + }); + const polygonLayer = new GeoArrowSolidPolygonLayer({ + id: `geoarrow-polygon-${type}`, + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: table, + getPolygon: polygons, + getFillColor: [168, 85, 247, 180], + getLineColor: [88, 28, 135, 255], + earcutWorkerUrl: null, + material: false, + pickable: true + }); + + let device: Device | undefined; + let deck: Deck | undefined; + let nativeDevice: NativeGpuDevice | undefined; + const validationErrors: string[] = []; + const captureValidationError = (event: NativeGpuError): void => { + validationErrors.push(event.error?.message ?? 'Unknown WebGPU validation error.'); + }; + + try { + device = await luma.createDevice({ + type, + adapters: [webgl2Adapter, webgpuAdapter], + createCanvasContext: {container: parent} + }); + if (type === 'webgpu') { + nativeDevice = (device as Device & {handle?: NativeGpuDevice}).handle; + nativeDevice?.addEventListener('uncapturederror', captureValidationError); + } + + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + reject(new Error(`Timed out while rendering GeoArrow layers with ${type}.`)); + }, 10_000); + + deck = new Deck({ + device, + parent, + width: 128, + height: 128, + views: new OrthographicView({id: 'geoarrow-webgpu-test', flipY: false}), + initialViewState: {target: [0, 0, 0], zoom: 0}, + layers: [polygonLayer, pathLayer], + onAfterRender: () => { + if (!polygonLayer.state.table || !polygonLayer.state.triangles) { + return; + } + window.clearTimeout(timeout); + resolve(); + }, + onError: error => { + window.clearTimeout(timeout); + reject(error); + } + }); + }); + + await nativeDevice?.queue.onSubmittedWorkDone(); + expect(device.type).toBe(type); + expect(polygonLayer.state.triangles?.[0]?.length).toBeGreaterThan(0); + expect(validationErrors).toEqual([]); + } finally { + nativeDevice?.removeEventListener('uncapturederror', captureValidationError); + deck?.finalize(); + device?.destroy(); + parent.remove(); + } +} + +describe('GeoArrow graphics backend compatibility', () => { + it('renders binary path and polygon attributes on WebGL2', async () => { + await renderGeoArrowLayers('webgl'); + }, 20_000); + + it('renders binary path and polygon attributes on WebGPU', async ({skip}) => { + const gpu = (navigator as Navigator & {gpu?: BrowserGpu}).gpu; + if (!gpu || !(await gpu.requestAdapter())) { + skip('This browser does not expose an available WebGPU adapter.'); + } + + await renderGeoArrowLayers('webgpu'); + }, 20_000); +}); diff --git a/modules/editable-layers/src/editable-layers/editable-path-layer.ts b/modules/editable-layers/src/editable-layers/editable-path-layer.ts index 530aaac06..52270909d 100644 --- a/modules/editable-layers/src/editable-layers/editable-path-layer.ts +++ b/modules/editable-layers/src/editable-layers/editable-path-layer.ts @@ -14,12 +14,21 @@ uniform pickingLineWidthUniforms { } pickingLineWidth; `; +const uniformBlockWGSL = /* wgsl */ `\ +struct PickingLineWidthUniforms { + extraPixels: f32, +}; + +@group(0) @binding(auto) var pickingLineWidth: PickingLineWidthUniforms; +`; + export type PickingLineWidthProps = { extraPixels: number; }; export const pickingUniforms = { name: 'pickingLineWidth', + source: uniformBlockWGSL, vs: uniformBlock, fs: uniformBlock, uniformTypes: { @@ -49,6 +58,16 @@ export class EditablePathLayer extends PathLayer { } ` ); + shaders.source = insertBefore( + shaders.source.replace('let widthPixels =', 'var widthPixels ='), + 'if (path.billboard != 0.0) {', + ` + if (picking.isActive > 0.5) { + widthPixels += pickingLineWidth.extraPixels; + } + +` + ); return { ...shaders, diff --git a/modules/geo-layers/src/wind-layer/delaunay-cover-layer.ts b/modules/geo-layers/src/wind-layer/delaunay-cover-layer.ts index 4f0309a28..a2b6f0d25 100644 --- a/modules/geo-layers/src/wind-layer/delaunay-cover-layer.ts +++ b/modules/geo-layers/src/wind-layer/delaunay-cover-layer.ts @@ -59,7 +59,7 @@ function createTerrainTriangle( * @remarks * This API is a work in progress. It visualizes station triangles; use * {@link ElevationLayer} when a smooth image-derived mountain mesh is required. - * The underlying `SolidPolygonLayer` currently limits full WebGPU support. + * Native triangle shaders render the same station mesh on WebGL2 and WebGPU. * * @example * ```ts diff --git a/modules/geo-layers/src/wind-layer/gpu-particle-simulation.ts b/modules/geo-layers/src/wind-layer/gpu-particle-simulation.ts index 19649ef1d..e7efe7318 100644 --- a/modules/geo-layers/src/wind-layer/gpu-particle-simulation.ts +++ b/modules/geo-layers/src/wind-layer/gpu-particle-simulation.ts @@ -101,18 +101,27 @@ struct WindParticleUniforms { particleCount: f32, }; -@group(0) @binding(0) var windFrom: texture_2d; -@group(0) @binding(1) var windSampler: sampler; -@group(0) @binding(2) var windTo: texture_2d; -@group(0) @binding(3) var particlePositions: array>; -@group(0) @binding(4) var previousParticlePositions: array>; -@group(0) @binding(5) var nextParticlePositions: array>; -@group(0) @binding(6) var windParticle: WindParticleUniforms; +@group(0) @binding(0) var windFrom: texture_2d; +@group(0) @binding(1) var windTo: texture_2d; +@group(0) @binding(2) var particlePositions: array>; +@group(0) @binding(3) var previousParticlePositions: array>; +@group(0) @binding(4) var nextParticlePositions: array>; +@group(0) @binding(5) var windParticle: WindParticleUniforms; fn randomValue(value: vec2) -> f32 { return fract(sin(dot(value, vec2(12.9898, 78.233))) * 43758.5453); } +fn sampleWind(texture: texture_2d, uv: vec2) -> vec4 { + let dimensions = textureDimensions(texture); + let texel = clamp( + vec2(uv * vec2(dimensions)), + vec2(0), + vec2(dimensions) - vec2(1) + ); + return bitcast>(textureLoad(texture, texel, 0)); +} + @compute @workgroup_size(256) fn main(@builtin(global_invocation_id) invocation: vec3) { let index = invocation.x; @@ -123,11 +132,7 @@ fn main(@builtin(global_invocation_id) invocation: vec3) { let particlePosition = particlePositions[index]; let span = windParticle.bounds.zw - windParticle.bounds.xy; let uv = (particlePosition.xy - windParticle.bounds.xy) / span; - let wind = mix( - textureSampleLevel(windFrom, windSampler, uv, 0.0), - textureSampleLevel(windTo, windSampler, uv, 0.0), - windParticle.frameMix - ); + let wind = mix(sampleWind(windFrom, uv), sampleWind(windTo, uv), windParticle.frameMix); var nextPosition = particlePosition.xy + wind.xy * windParticle.speedScale * windParticle.elapsedFrames; var age = particlePosition.w + windParticle.elapsedFrames; @@ -140,7 +145,7 @@ fn main(@builtin(global_invocation_id) invocation: vec3) { randomValue(seed), randomValue(seed.yx + 7.13) ); let candidateUV = (nextPosition - windParticle.bounds.xy) / span; - if (textureSampleLevel(windFrom, windSampler, candidateUV, 0.0).w < 0.5) { + if (sampleWind(windFrom, candidateUV).w < 0.5) { nextPosition = windParticle.bounds.xy + span * 0.5; } age = 0.0; @@ -302,7 +307,9 @@ export class GpuParticleSimulation { const textureProps = { width: WIND_TEXTURE_WIDTH, height: WIND_TEXTURE_HEIGHT, - format: 'rgba32float' as const, + // Baseline WebGPU cannot bind rgba32float as a filterable texture. Store the identical + // float bits in an integer texture and recover them with bitcast in the compute shader. + format: device.type === 'webgpu' ? ('rgba32uint' as const) : ('rgba32float' as const), sampler: { minFilter: 'nearest' as const, magFilter: 'nearest' as const, @@ -345,26 +352,19 @@ export class GpuParticleSimulation { type: 'texture', group: 0, location: 0, - sampleType: 'unfilterable-float' - }, - { - name: 'windSampler', - type: 'sampler', - group: 0, - location: 1, - samplerType: 'non-filtering' + sampleType: 'uint' }, { name: 'windTo', type: 'texture', group: 0, - location: 2, - sampleType: 'unfilterable-float' + location: 1, + sampleType: 'uint' }, - {name: 'particlePositions', type: 'read-only-storage', group: 0, location: 3}, - {name: 'previousParticlePositions', type: 'storage', group: 0, location: 4}, - {name: 'nextParticlePositions', type: 'storage', group: 0, location: 5}, - {name: 'windParticle', type: 'uniform', group: 0, location: 6} + {name: 'particlePositions', type: 'read-only-storage', group: 0, location: 2}, + {name: 'previousParticlePositions', type: 'storage', group: 0, location: 3}, + {name: 'nextParticlePositions', type: 'storage', group: 0, location: 4}, + {name: 'windParticle', type: 'uniform', group: 0, location: 5} ] } }); @@ -405,9 +405,16 @@ export class GpuParticleSimulation { ((Math.floor(time) % this.field.frames.length) + this.field.frames.length) % this.field.frames.length; if (frame !== this.currentWeatherFrame) { - this.textures[0].writeData(getCachedParticleWindRaster(this.field, frame)); + const fromRaster = getCachedParticleWindRaster(this.field, frame); + const toRaster = getCachedParticleWindRaster( + this.field, + (frame + 1) % this.field.frames.length + ); + this.textures[0].writeData( + this.device.type === 'webgpu' ? new Uint32Array(fromRaster.buffer) : fromRaster + ); this.textures[1].writeData( - getCachedParticleWindRaster(this.field, (frame + 1) % this.field.frames.length) + this.device.type === 'webgpu' ? new Uint32Array(toRaster.buffer) : toRaster ); this.currentWeatherFrame = frame; } @@ -451,7 +458,6 @@ export class GpuParticleSimulation { this.computeUniforms.write(this.uniformValues); this.computation.setBindings({ windFrom: this.textures[0], - windSampler: this.textures[0].sampler, windTo: this.textures[1], particlePositions: input, previousParticlePositions: this.trailBuffer, diff --git a/modules/geo-layers/test/wind-layer/wind-layers.browser.spec.ts b/modules/geo-layers/test/wind-layer/wind-layers.browser.spec.ts index 6f9a80523..8f7c557d8 100644 --- a/modules/geo-layers/test/wind-layer/wind-layers.browser.spec.ts +++ b/modules/geo-layers/test/wind-layer/wind-layers.browser.spec.ts @@ -148,10 +148,6 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise { nativeDevice = (device as Device & {handle?: NativeGpuDevice}).handle; nativeDevice?.addEventListener('uncapturederror', captureValidationError); } - if (type === 'webgpu') { - nativeDevice = (device as Device & {handle?: NativeGpuDevice}).handle; - nativeDevice?.addEventListener('uncapturederror', captureValidationError); - } await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { @@ -215,7 +211,6 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise { } } } finally { - nativeDevice?.removeEventListener('uncapturederror', captureValidationError); nativeDevice?.removeEventListener('uncapturederror', captureValidationError); deck?.finalize(); device?.destroy(); diff --git a/modules/graph-layers/src/layers/edge-layers/edge-arrow-layer.ts b/modules/graph-layers/src/layers/edge-layers/edge-arrow-layer.ts index 68e8a8039..a8d3ab6b5 100644 --- a/modules/graph-layers/src/layers/edge-layers/edge-arrow-layer.ts +++ b/modules/graph-layers/src/layers/edge-layers/edge-arrow-layer.ts @@ -3,11 +3,7 @@ // Copyright (c) vis.gl contributors import {CompositeLayer} from '@deck.gl/core'; -import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; - -import {Arrow2DGeometry} from './arrow-2d-geometry'; - -const DEFAULT_ARROW_GEOMETRY = new Arrow2DGeometry({length: 1, headWidth: 0.6}); +import {PolygonLayer} from '@deck.gl/layers'; type LayoutInfo = { sourcePosition: number[]; @@ -112,6 +108,48 @@ export function getArrowTransform({ return {position, angle}; } +/** Returns the world-space triangle used for one directed edge arrow. */ +export function getArrowPolygon({ + layout, + size, + offset = null +}: { + layout: LayoutInfo; + size: number; + offset?: number[] | null; +}): [number[], number[], number[]] { + const {target, direction} = getTerminalDirection(layout); + const unit = normalizeVector(direction); + const resolvedSize = resolveSize(size); + const {along, perpendicular} = getOffsetComponents(offset); + const perpendicularUnit = [-unit[1], unit[0], 0]; + const tip = [ + (target[0] ?? 0) - unit[0] * along + perpendicularUnit[0] * perpendicular, + (target[1] ?? 0) - unit[1] * along + perpendicularUnit[1] * perpendicular, + (target[2] ?? DEFAULT_Z) - unit[2] * along + ]; + const baseCenter = [ + tip[0] - unit[0] * resolvedSize, + tip[1] - unit[1] * resolvedSize, + tip[2] - unit[2] * resolvedSize + ]; + const halfWidth = resolvedSize * 0.3; + + return [ + tip, + [ + baseCenter[0] + perpendicularUnit[0] * halfWidth, + baseCenter[1] + perpendicularUnit[1] * halfWidth, + baseCenter[2] + ], + [ + baseCenter[0] - perpendicularUnit[0] * halfWidth, + baseCenter[1] - perpendicularUnit[1] * halfWidth, + baseCenter[2] + ] + ]; +} + export class EdgeArrowLayer extends CompositeLayer { static layerName = 'EdgeArrowLayer'; @@ -127,42 +165,26 @@ export class EdgeArrowLayer extends CompositeLayer { const updateTriggers = stylesheet.getDeckGLUpdateTriggers(); return [ - new SimpleMeshLayer( + new PolygonLayer( this.getSubLayerProps({ id: '__edge-arrow-layer', data: directedEdges, - mesh: DEFAULT_ARROW_GEOMETRY, - getColor, - getScale: edge => { - const size = resolveSize(getSize(edge)); - return [size, size, size]; - }, - getOrientation: edge => { - const layout = getLayoutInfo(edge); - const size = resolveSize(getSize(edge)); - const offset = getOffset ? getOffset(edge) : null; - const {angle} = getArrowTransform({layout, size, offset}); - return [0, -angle, 0]; - }, - getPosition: edge => { + filled: true, + stroked: false, + getFillColor: getColor, + getPolygon: edge => { const layout = getLayoutInfo(edge); const size = resolveSize(getSize(edge)); const offset = getOffset ? getOffset(edge) : null; - const {position} = getArrowTransform({layout, size, offset}); - return position; + return getArrowPolygon({layout, size, offset}); }, parameters: { - depthTest: false + depthCompare: 'always', + depthWriteEnabled: false }, updateTriggers: { - getColor: updateTriggers.getColor, - getScale: updateTriggers.getSize, - getOrientation: [ - positionUpdateTrigger, - updateTriggers.getSize, - updateTriggers.getOffset - ], - getPosition: [positionUpdateTrigger, updateTriggers.getSize, updateTriggers.getOffset] + getFillColor: updateTriggers.getColor, + getPolygon: [positionUpdateTrigger, updateTriggers.getSize, updateTriggers.getOffset] } }) ) diff --git a/modules/graph-layers/src/layers/node-layers/rounded-rectangle-layer-fragment.ts b/modules/graph-layers/src/layers/node-layers/rounded-rectangle-layer-fragment.ts deleted file mode 100644 index 390a1ba58..000000000 --- a/modules/graph-layers/src/layers/node-layers/rounded-rectangle-layer-fragment.ts +++ /dev/null @@ -1,29 +0,0 @@ -// deck.gl-community -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - -export const fs = /* glsl */ `\ -#define SHADER_NAME rounded-rectangle-layer-fragment-shader - -precision highp float; - -varying vec4 vFillColor; -varying vec2 unitPosition; - -void main(void) { - - float distToCenter = length(unitPosition); - - /* Calculate the cutoff radius for the rounded corners */ - float threshold = sqrt(2.0) * (1.0 - roundedRectangle.cornerRadius) + 1.0 * roundedRectangle.cornerRadius; - if (distToCenter <= threshold) { - gl_FragColor = vFillColor; - } else { - discard; - } - - gl_FragColor = picking_filterHighlightColor(gl_FragColor); - - gl_FragColor = picking_filterPickingColor(gl_FragColor); -} -`; diff --git a/modules/graph-layers/src/layers/node-layers/rounded-rectangle-layer.ts b/modules/graph-layers/src/layers/node-layers/rounded-rectangle-layer.ts index a575a6479..595771b83 100644 --- a/modules/graph-layers/src/layers/node-layers/rounded-rectangle-layer.ts +++ b/modules/graph-layers/src/layers/node-layers/rounded-rectangle-layer.ts @@ -2,55 +2,15 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -// import {ScatterplotLayer} from '@deck.gl/layers'; -import type {ShaderModule} from '@luma.gl/shadertools'; -import type {Model} from '@luma.gl/engine'; -import {fs} from './rounded-rectangle-layer-fragment'; -import {RectangleLayer} from './rectangle-layer'; - -const uniformBlock = `\ -uniform roundedRectangleUniforms { - float cornerRadius; -} roundedRectangle; -`; - -export type RoundedRectangleProps = { - cornerRadius: number; -}; - -export const roundedRectangleUniforms = { - name: 'roundedRectangle', - vs: uniformBlock, - fs: uniformBlock, - uniformTypes: { - cornerRadius: 'f32' - } -} as const satisfies ShaderModule; - -export class RoundedRectangleLayer extends RectangleLayer { +import {PathBasedRoundedRectangleLayer} from './path-rounded-rectangle-layer'; + +/** + * Renders rounded graph nodes with deck.gl's dual-backend PolygonLayer. + * + * @remarks + * Rounded corners are tessellated on the CPU, so the same polygon and path shaders are used on + * WebGL2 and WebGPU. + */ +export class RoundedRectangleLayer extends PathBasedRoundedRectangleLayer { static layerName = 'RoundedRectangleLayer'; - - draw(props) { - const {cornerRadius} = this.props as any; - const roundedRectangleProps: RoundedRectangleProps = {cornerRadius}; - const model = this.state.model as Model; - model.shaderInputs.setProps({roundedRectangle: roundedRectangleProps}); - super.draw(props); - } - - getShaders() { - // use object.assign to make sure we don't overwrite existing fields like `vs`, `modules`... - const shaders = super.getShaders(undefined!); - return { - ...shaders, - fs, - modules: [...shaders.modules, roundedRectangleUniforms] - }; - } } - -RoundedRectangleLayer.defaultProps = { - // cornerRadius: the amount of rounding at the rectangle corners - // 0 - rectangle. 1 - circle. - cornerRadius: 0.1 -}; diff --git a/modules/graph-layers/test/layers/edge-arrow-layer.spec.ts b/modules/graph-layers/test/layers/edge-arrow-layer.spec.ts index 0f1fb23a3..0112f69b8 100644 --- a/modules/graph-layers/test/layers/edge-arrow-layer.spec.ts +++ b/modules/graph-layers/test/layers/edge-arrow-layer.spec.ts @@ -2,9 +2,15 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {describe, it, expect} from 'vitest'; +import {describe, expect, it, vi} from 'vitest'; -import {getArrowTransform, isEdgeDirected} from '../../src/layers/edge-layers/edge-arrow-layer'; +import {PolygonLayer} from '@deck.gl/layers'; +import { + EdgeArrowLayer, + getArrowPolygon, + getArrowTransform, + isEdgeDirected +} from '../../src/layers/edge-layers/edge-arrow-layer'; describe('EdgeArrowLayer helpers', () => { it('identifies directed edges', () => { @@ -55,4 +61,48 @@ describe('EdgeArrowLayer helpers', () => { expect(position).toEqual([5, 5, 0]); expect(angle).toBe(0); }); + + it('builds a portable arrow triangle at the target', () => { + const polygon = getArrowPolygon({ + layout: { + sourcePosition: [0, 0, 0], + targetPosition: [10, 0, 0] + }, + size: 4, + offset: [2, 1] + }); + + expect(polygon[0]).toEqual([8, 1, 0]); + expect(polygon[1]).toEqual([4, 2.2, 0]); + expect(polygon[2][0]).toBe(4); + expect(polygon[2][1]).toBeCloseTo(-0.2); + expect(polygon[2][2]).toBe(0); + }); + + it('renders directed edge arrows with PolygonLayer', () => { + const edge = {directed: true}; + const layer = new EdgeArrowLayer({ + id: 'portable-arrows', + data: [edge], + getLayoutInfo: () => ({ + sourcePosition: [0, 0, 0], + targetPosition: [10, 0, 0] + }), + stylesheet: { + getDeckGLAccessors: () => ({ + getColor: () => [255, 0, 0, 255], + getSize: () => 4, + getOffset: () => [0, 0] + }), + getDeckGLUpdateTriggers: () => ({}) + } + } as any); + layer.getSubLayerProps = vi.fn(props => props) as any; + + const [arrowLayer] = layer.renderLayers() as PolygonLayer[]; + + expect(arrowLayer).toBeInstanceOf(PolygonLayer); + expect(arrowLayer.props.filled).toBe(true); + expect(arrowLayer.props.stroked).toBe(false); + }); }); diff --git a/modules/layers/src/path-outline-layer/path-outline-layer.ts b/modules/layers/src/path-outline-layer/path-outline-layer.ts index fb443d483..e3f41bfdc 100644 --- a/modules/layers/src/path-outline-layer/path-outline-layer.ts +++ b/modules/layers/src/path-outline-layer/path-outline-layer.ts @@ -5,6 +5,7 @@ import {CompositeLayer} from '@deck.gl/core'; import {PathStyleExtension} from '@deck.gl/extensions'; import {PathLayer} from '@deck.gl/layers'; +import {WebGpuDashPathLayer} from './webgpu-dash-path-layer'; import type {Accessor, Color, DefaultProps, Layer, LayerExtension} from '@deck.gl/core'; import type {PathLayerProps} from '@deck.gl/layers'; @@ -61,8 +62,12 @@ export class PathOutlineLayer< getZLevel: _getZLevel } = this.props as PathOutlineLayerProps; + const useWebGpuDashLayer = Boolean(getDashArray && this.context?.device?.type === 'webgpu'); + const PathLayerType = useWebGpuDashLayer ? WebGpuDashPathLayer : PathLayer; const pathExtensions = getDashArray - ? ensurePathStyleExtension(extensions) + ? useWebGpuDashLayer + ? removePathStyleExtensions(extensions) + : ensurePathStyleExtension(extensions) : getLayerExtensions(extensions); const pathParameters = getPathRenderParameters(parameters); const baseWidthScale = widthScale ?? 1; @@ -81,7 +86,7 @@ export class PathOutlineLayer< : {}; return [ - new PathLayer( + new PathLayerType( this.props as unknown as PathLayerProps, this.getSubLayerProps({ ...outlineDashProps, @@ -97,7 +102,7 @@ export class PathOutlineLayer< widthScale: baseWidthScale * resolvedOutlineWidthScale }) ), - new PathLayer( + new PathLayerType( this.props as unknown as PathLayerProps, this.getSubLayerProps({ ...pathDashProps, @@ -129,6 +134,14 @@ function getLayerExtensions(extensions: readonly LayerExtension[] = []): LayerEx return [...extensions]; } +function removePathStyleExtensions(extensions: readonly LayerExtension[] = []): LayerExtension[] { + return extensions.filter( + extension => + (extension.constructor as typeof PathStyleExtension).extensionName !== + PathStyleExtension.extensionName + ); +} + function normalizeDashArrayAccessor( getDashArray: PathOutlineLayerProps['getDashArray'], scale = 1 diff --git a/modules/layers/src/path-outline-layer/webgpu-dash-path-layer.ts b/modules/layers/src/path-outline-layer/webgpu-dash-path-layer.ts new file mode 100644 index 000000000..e5cf297d7 --- /dev/null +++ b/modules/layers/src/path-outline-layer/webgpu-dash-path-layer.ts @@ -0,0 +1,163 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {PathLayer} from '@deck.gl/layers'; + +import type {NumericArray} from '@math.gl/core'; +import type {ShaderModule, ShaderPlugin} from '@luma.gl/shadertools'; + +type WebGpuPathStyleProps = { + dashAlignMode: number; + dashGapPickable: number; +}; + +type WebGpuDashPathProps = { + dashJustified?: boolean; + dashGapPickable?: boolean; +}; + +const webgpuPathStyleUniforms = { + name: 'webgpuPathStyle', + source: /* wgsl */ `\ +struct WebGpuPathStyleUniforms { + dashAlignMode: f32, + dashGapPickable: i32, +}; + +@group(0) @binding(auto) var webgpuPathStyle: WebGpuPathStyleUniforms; +`, + uniformTypes: { + dashAlignMode: 'f32', + dashGapPickable: 'i32' + } +} as const satisfies ShaderModule; + +const webgpuPathStylePlugin: ShaderPlugin = { + name: 'webgpu-path-style', + wgsl: { + modules: [webgpuPathStyleUniforms], + vertexInputs: { + instanceDashArrays: 'vec2', + instanceDashOffsets: 'f32' + }, + varyings: { + webgpuDashArray: {type: 'vec2'}, + webgpuDashOffset: {type: 'f32'} + }, + injections: [ + { + target: 'vs:#main-end', + injection: /* wgsl */ `\ +webgpuDashArray = instanceDashArrays; +webgpuDashOffset = instanceDashOffsets / max(widthPixels, 0.0001); +` + }, + { + target: 'fs:#main-start', + injection: /* wgsl */ `\ +let solidLength = webgpuDashArray.x; +let gapLength = webgpuDashArray.y; +var unitLength = solidLength + gapLength; +var dashOffset = webgpuDashOffset; + +if (unitLength > 0.0) { + if (webgpuPathStyle.dashAlignMode > 0.5) { + unitLength = varyings.vPathLength / max(round(varyings.vPathLength / unitLength), 1.0); + dashOffset = solidLength / 2.0; + } + + let positionInUnit = varyings.vPathPosition.y + dashOffset; + let unitOffset = positionInUnit - floor(positionInUnit / unitLength) * unitLength; + if (gapLength > 0.0 && unitOffset > solidLength) { + if (path.capType <= 0.5) { + if (!(webgpuPathStyle.dashGapPickable != 0 && picking.isActive > 0.5)) { + discard; + } + } else { + let distanceToSolid = length(vec2( + min(unitOffset - solidLength, unitLength - unitOffset), + varyings.vPathPosition.x + )); + if (distanceToSolid > 1.0 && + !(webgpuPathStyle.dashGapPickable != 0 && picking.isActive > 0.5)) { + discard; + } + } + } +} +` + } + ] + } +}; + +/** + * PathLayer variant that supplies the missing WGSL half of PathStyleExtension. + * + * @remarks + * This is internal compatibility code for deck.gl 9.4 alpha.2. The upstream path geometry is + * reused unchanged; only dash attributes, varyings, and fragment masking are added. + * + * @internal + */ +export class WebGpuDashPathLayer extends PathLayer { + static override layerName = 'WebGpuDashPathLayer'; + + override initializeState(): void { + super.initializeState(); + this.getAttributeManager()!.addInstanced({ + instanceDashArrays: { + size: 2, + accessor: 'getDashArray' + }, + instanceDashOffsets: { + size: 1, + accessor: 'getPath', + transform: this.getDashOffsets.bind(this) + } + }); + } + + override getShaders() { + const shaders = super.getShaders(); + return { + ...shaders, + plugins: [...(shaders.plugins ?? []), webgpuPathStylePlugin] + }; + } + + override draw(params): void { + this.state.model!.shaderInputs.setProps({ + webgpuPathStyle: { + dashAlignMode: this.props.dashJustified ? 1 : 0, + dashGapPickable: this.props.dashGapPickable ? 1 : 0 + } + }); + super.draw(params); + } + + private getDashOffsets(path: NumericArray | NumericArray[]): number[] { + const result = [0]; + const positionSize = this.props.positionFormat === 'XY' ? 2 : 3; + const isNested = Array.isArray(path[0]); + const geometrySize = isNested ? path.length : path.length / positionSize; + let previousPosition: number[] | undefined; + + for (let index = 0; index < geometrySize - 1; index++) { + const position = isNested + ? (path[index] as NumericArray) + : (path as NumericArray).slice(index * positionSize, index * positionSize + positionSize); + const projectedPosition = this.projectPosition(position as number[]); + if (index > 0) { + result[index] = + result[index - 1] + + Math.hypot(...projectedPosition.map((x, i) => x - previousPosition![i])); + } + previousPosition = projectedPosition; + } + + result[geometrySize - 1] = 0; + return result; + } +} diff --git a/modules/layers/test/path-outline-layer/path-outline-layer.spec.ts b/modules/layers/test/path-outline-layer/path-outline-layer.spec.ts index 4d10ddeb3..19f1b9f84 100644 --- a/modules/layers/test/path-outline-layer/path-outline-layer.spec.ts +++ b/modules/layers/test/path-outline-layer/path-outline-layer.spec.ts @@ -7,6 +7,7 @@ import {PathLayer} from '@deck.gl/layers'; import {describe, expect, it, vi} from 'vitest'; import {PathOutlineLayer} from '../../src/path-outline-layer/path-outline-layer'; +import {WebGpuDashPathLayer} from '../../src/path-outline-layer/webgpu-dash-path-layer'; type PathOutlineHarness = PathOutlineLayer & { getSubLayerProps: ReturnType; @@ -45,6 +46,10 @@ function createRenderHarness(props: Record = {}): PathOutlineHa widthScale: 2, ...props }; + Object.defineProperty(layer, 'context', { + configurable: true, + value: {device: {type: 'webgl'}} + }); return layer; } @@ -92,6 +97,22 @@ describe('PathOutlineLayer', () => { expect(outlineLayer.props.extensions).toEqual([extension]); }); + it('uses the native-WGSL dash layer without the GLSL-only extension on WebGPU', () => { + const extension = new PathStyleExtension({dash: true}); + const layer = createRenderHarness({extensions: [extension], getDashArray: () => [4, 2]}); + Object.defineProperty(layer, 'context', { + configurable: true, + value: {device: {type: 'webgpu'}} + }); + + const [outlineLayer, pathLayer] = layer.renderLayers() as PathLayer[]; + + expect(outlineLayer).toBeInstanceOf(WebGpuDashPathLayer); + expect(pathLayer).toBeInstanceOf(WebGpuDashPathLayer); + expect(outlineLayer.props.extensions).toEqual([]); + expect(pathLayer.props.extensions).toEqual([]); + }); + it('does not attach dash extension for solid paths', () => { const layer = createRenderHarness(); const [outlineLayer, pathLayer] = layer.renderLayers() as PathLayer[]; diff --git a/modules/layers/test/webgpu-layers.browser.spec.ts b/modules/layers/test/webgpu-layers.browser.spec.ts index d588d3c56..3ee009918 100644 --- a/modules/layers/test/webgpu-layers.browser.spec.ts +++ b/modules/layers/test/webgpu-layers.browser.spec.ts @@ -11,10 +11,21 @@ import {describe, expect, it} from 'vitest'; import { HorizonGraphLayer, MultiHorizonGraphLayer, + TimelineLayer, VerticalGridLayer } from '../../../dev/timeline-layers/src'; import {BlockLayer, FastTextLayer, TimeDeltaLayer} from '../../infovis-layers/src'; -import {SkyboxLayer} from '../src'; +import {EditableGeoJsonLayer, ModifyMode} from '../../editable-layers/src'; +import {EdgeArrowLayer} from '../../graph-layers/src/layers/edge-layers/edge-arrow-layer'; +import {PathEdgeLayer} from '../../graph-layers/src/layers/edge-layers/path-edge-layer'; +import {RoundedRectangleLayer} from '../../graph-layers/src/layers/node-layers/rounded-rectangle-layer'; +import { + DependencyArrowLayer, + PathDirection, + PathMarkerLayer, + PathOutlineLayer, + SkyboxLayer +} from '../src'; import {GeometryLayer} from '../src/dependency-arrow-layer/geometry-layer'; type BrowserGpu = { @@ -28,6 +39,33 @@ type NativeGpuDevice = { }; function createPortableLayers() { + const nodeStylesheet = { + getDeckGLAccessor: (name: string) => + ({ + getCornerRadius: () => 4, + getFillColor: () => [34, 197, 94, 255], + getHeight: () => 12, + getLineColor: () => [20, 83, 45, 255], + getLineWidth: () => 1, + getWidth: () => 24 + })[name], + getDeckGLAccessors: () => ({ + getFillColor: () => [34, 197, 94, 255], + getLineColor: () => [20, 83, 45, 255], + getLineWidth: () => 1 + }), + getDeckGLAccessorUpdateTrigger: () => 0, + getDeckGLUpdateTriggers: () => ({}) + }; + const edgeStylesheet = { + getDeckGLAccessors: () => ({ + getColor: () => [225, 29, 72, 255], + getOffset: () => [0, 0], + getSize: () => 5 + }), + getDeckGLUpdateTriggers: () => ({}) + }; + return [ new SkyboxLayer({id: 'webgpu-test-skybox', cubemap: null}), new BlockLayer({ @@ -87,6 +125,213 @@ function createPortableLayers() { yMax: 30, tickCount: 4 }), + new PathOutlineLayer({ + id: 'webgpu-test-dashed-outline', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: [ + { + path: [ + [-28, 34], + [-8, 28], + [10, 35] + ] + } + ], + getPath: datum => datum.path, + getColor: [14, 165, 233, 255], + getOutlineColor: [15, 23, 42, 255], + getWidth: 4, + getDashArray: [3, 2], + widthUnits: 'pixels', + outlineWidthScale: 1.6, + pickable: true + }), + new PathMarkerLayer({ + id: 'webgpu-test-path-markers', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: [ + { + path: [ + [-28, 40], + [0, 45], + [28, 38] + ], + direction: PathDirection.FORWARD + } + ], + getPath: datum => datum.path, + getDirection: datum => datum.direction, + getColor: [124, 58, 237, 255], + getMarkerColor: [124, 58, 237, 255], + getMarkerPercentages: () => [0.5], + getWidth: 3, + getDashArray: [4, 2], + widthUnits: 'pixels', + sizeScale: 8, + pickable: true + }), + new DependencyArrowLayer({ + id: 'webgpu-test-path-dependency', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: [ + { + path: [ + [-28, 24], + [0, 18], + [28, 24] + ] + } + ], + mode: 'path', + getPath: datum => datum.path, + getColor: [239, 68, 68, 255], + getWidth: 3, + markerSizeScale: 8, + outlineWidthScale: 1.5, + pickable: true + }), + new DependencyArrowLayer({ + id: 'webgpu-test-arc-dependency', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: [ + { + path: [ + [-25, 5], + [25, 5] + ] + } + ], + mode: 'arc', + getPath: datum => datum.path, + getColor: [245, 158, 11, 255], + getWidth: 2, + getArcHeight: 8, + markerSizeScale: 7, + pickable: true + }), + new RoundedRectangleLayer({ + id: 'webgpu-test-rounded-graph-node', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: [{position: [-12, -36, 0]}], + getPosition: datum => datum.position, + stylesheet: nodeStylesheet, + pickable: true + }), + new PathEdgeLayer({ + id: 'webgpu-test-graph-path-edge', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: [ + { + layout: { + sourcePosition: [-28, -52, 0], + controlPoints: [[0, -45, 0]], + targetPosition: [28, -52, 0] + } + } + ], + getLayoutInfo: datum => datum.layout, + getColor: [59, 130, 246, 255], + getWidth: 3, + widthUnits: 'pixels', + pickable: true + }), + new EdgeArrowLayer({ + id: 'webgpu-test-graph-edge-arrow', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: [ + { + directed: true, + layout: { + sourcePosition: [-28, -52, 0], + targetPosition: [28, -52, 0] + } + } + ], + getLayoutInfo: datum => datum.layout, + stylesheet: edgeStylesheet, + pickable: true + }), + new TimelineLayer({ + id: 'webgpu-test-timeline', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: [ + { + id: 'track', + clips: [ + { + id: 'clip-a', + startMs: 0, + endMs: 450, + color: [14, 165, 233, 255] + }, + { + id: 'clip-b', + startMs: 520, + endMs: 900, + color: [168, 85, 247, 255] + } + ] + } + ], + timelineStart: 0, + timelineEnd: 1000, + currentTimeMs: 500, + x: -30, + y: -12, + width: 60, + trackHeight: 10, + trackSpacing: 2, + showAxis: false, + showClipLabels: false, + showTrackLabels: false, + showScrubber: true, + pickable: true + }), + new EditableGeoJsonLayer({ + id: 'webgpu-test-editable-geojson', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + data: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [4, -34], + [28, -34], + [28, -22], + [4, -22], + [4, -34] + ] + ] + } + }, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [ + [-28, -20], + [-18, -12], + [-6, -18] + ] + } + } + ] + }, + mode: ModifyMode, + selectedFeatureIndexes: [0], + pickingLineWidthExtraPixels: 6, + getFillColor: [8, 145, 178, 120], + getLineColor: [14, 116, 144, 255], + getLineWidth: 2, + onEdit: () => {}, + pickable: true + }), new HorizonGraphLayer({ id: 'webgpu-test-horizon', coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, @@ -178,11 +423,11 @@ async function renderPortableLayers(type: 'webgl' | 'webgpu'): Promise { } describe('community graphics backend compatibility', () => { - it('renders skybox, blocks, text, dependency markers, and horizon textures on WebGL2', async () => { + it('renders custom shaders, paths, polygons, graph, timeline, and editing on WebGL2', async () => { await renderPortableLayers('webgl'); }, 20_000); - it('renders skybox, blocks, text, dependency markers, and horizon textures on WebGPU', async ({ + it('renders custom shaders, paths, polygons, graph, timeline, and editing on WebGPU', async ({ skip }) => { const gpu = (navigator as Navigator & {gpu?: BrowserGpu}).gpu; diff --git a/modules/layers/test/webgpu-shaders.spec.ts b/modules/layers/test/webgpu-shaders.spec.ts index b650f93ed..29360216c 100644 --- a/modules/layers/test/webgpu-shaders.spec.ts +++ b/modules/layers/test/webgpu-shaders.spec.ts @@ -97,11 +97,12 @@ describe('community WebGPU shaders', () => { expect(shader.source).not.toContain('@binding(auto)'); }); - it('assembles horizon floating-point texture and uniform bindings', () => { + it('assembles horizon bit-preserving integer texture and uniform bindings', () => { const shader = assembleWebgpuShader(horizonSource, [project32, horizonLayerUniforms]); - expect(shader.source).toContain('texture_2d'); + expect(shader.source).toContain('texture_2d'); expect(shader.source).toContain('textureLoad(dataTexture'); + expect(shader.source).toContain('bitcast'); expect(shader.source).toContain('horizonLayer'); expect(shader.source).not.toContain('@binding(auto)'); }); From c25c1bc718c45790ba91044e8440f5502ae89249 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 30 Jul 2026 13:01:16 -0400 Subject: [PATCH 2/2] docs: expose WebGPU status across examples and docs --- docs/modules/arrow-layers/README.md | 2 +- docs/modules/infovis-layers/README.md | 2 +- docs/webgpu.md | 16 ++- docs/whats-new.md | 7 +- .../geo-layers/wind/wind-device-tabs.spec.ts | 9 +- modules/widgets/README.md | 2 +- .../components/docs/layer-live-example.jsx | 128 ++++++++++-------- website/src/components/docs/webgpu-badge.jsx | 25 ++++ .../components/docs/webgpu-badge.module.css | 63 +++++++++ website/src/components/docs/webgpu-support.js | 83 ++++++++++++ .../example/make-imperative-example.jsx | 2 +- .../example/mount-device-managed-example.js | 37 ++++- .../src/examples/editable-layers/3d-tiles.tsx | 4 +- .../src/examples/editable-layers/advanced.tsx | 4 +- .../editable-h3-cluster-layer.tsx | 4 +- .../examples/editable-layers/editor-react.tsx | 4 +- .../src/examples/editable-layers/editor.tsx | 4 +- .../editable-layers/getting-started.tsx | 4 +- .../src/examples/editable-layers/no-map.tsx | 4 +- website/src/examples/editable-layers/sf.tsx | 4 +- .../src/examples/editable-layers/widget.tsx | 4 +- .../geo-layers/shared-tile-2d-layer.tsx | 4 +- .../layers/basemap-layer-map-view.tsx | 4 +- .../examples/layers/skybox-first-person.tsx | 4 +- website/src/examples/layers/skybox-globe.tsx | 4 +- website/src/examples/leaflet/get-started.tsx | 4 +- .../DocCategoryGeneratedIndexPage/index.js | 13 ++ website/src/theme/DocItem/Content/index.js | 16 +++ 28 files changed, 367 insertions(+), 94 deletions(-) create mode 100644 website/src/components/docs/webgpu-badge.jsx create mode 100644 website/src/components/docs/webgpu-badge.module.css create mode 100644 website/src/components/docs/webgpu-support.js create mode 100644 website/src/theme/DocCategoryGeneratedIndexPage/index.js create mode 100644 website/src/theme/DocItem/Content/index.js diff --git a/docs/modules/arrow-layers/README.md b/docs/modules/arrow-layers/README.md index 71bb959c6..221be01c4 100644 --- a/docs/modules/arrow-layers/README.md +++ b/docs/modules/arrow-layers/README.md @@ -1,7 +1,7 @@ # Overview ![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square") -![WebGPU not supported](https://img.shields.io/badge/webgpu-no-red.svg?style=flat-square") +![WebGPU partially supported](https://img.shields.io/badge/webgpu-partial-yellow.svg?style=flat-square") This module provides deck.gl layers that accept Apache Arrow and [GeoArrow](https://geoarrow.org) tables. These layers take advantage of the deck.gl [low-level binary interface](https://deck.gl/docs/developer-guide/performance#supply-attributes-directly) to provide binary arrow data from Apache Arrow tables directly to the GPU. diff --git a/docs/modules/infovis-layers/README.md b/docs/modules/infovis-layers/README.md index ddc99e2e9..206bbdfc1 100644 --- a/docs/modules/infovis-layers/README.md +++ b/docs/modules/infovis-layers/README.md @@ -1,7 +1,7 @@ # Overview ![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square") -![WebGPU not supported](https://img.shields.io/badge/webgpu-no-red.svg?style=flat-square") +![WebGPU partially supported](https://img.shields.io/badge/webgpu-partial-yellow.svg?style=flat-square") This module provides a suite of layers and view helpers for [deck.gl](https://deck.gl) focused on non-geospatial visualization. diff --git a/docs/webgpu.md b/docs/webgpu.md index 2efb70dc2..2d6d4d2e2 100644 --- a/docs/webgpu.md +++ b/docs/webgpu.md @@ -52,7 +52,13 @@ deck.gl-community is adding WebGPU support incrementally while continuing to sup ## Selecting a graphics backend -The website injects luma.gl-style WebGPU/WebGL2 tabs into the selected gallery examples and their corresponding layer-reference examples. Its shared imperative-example host owns a separate `DeviceManagerController` and `DeviceTabsWidget` for each mounted surface, preserves the example's existing widgets and view state, and passes the selected luma.gl device to the actual `Deck` instance. Standalone examples stay independent: they only expose an optional `onDeckInitialized` callback so a website or another embedding application can configure their `Deck`. +The website injects luma.gl-style WebGPU/WebGL2 tabs into every gallery example and live +layer-reference example. Its shared imperative-example host owns a separate +`DeviceManagerController` and standalone `DeviceTabsWidget` for each mounted surface, preserves the +example's existing widgets and view state, and passes the selected luma.gl device to the actual +`Deck` instance. Standalone examples stay independent: they only expose an optional +`onDeckInitialized` callback so a website or another embedding application can configure their +`Deck`. The `@deck.gl-community/widgets` package also exposes both primitives for applications that want to manage their own backend selection: @@ -105,9 +111,11 @@ selected device; `deck.setProps({device})` does not migrate an existing renderer resources. Preserve view state across recreation, create only layers supported by the selected backend, and call `manager.reset()` after finalizing the renderer to destroy every cached device. -The documentation website injects device tabs in its shared imperative-example host. Standalone -example applications accept an optional device and widgets but do not own device management. -Path outlines, path markers, and dependency routes now render through the selected backend. +Every documentation page also displays a generated WebGPU status badge linked to this matrix. +Specific verified or blocked layer pages override their package's aggregate status, while +backend-neutral APIs are marked not applicable. Standalone example applications accept an optional +device and widgets but do not own device management. Path outlines, path markers, and dependency +routes now render through the selected backend. ## Compatibility roadmap diff --git a/docs/whats-new.md b/docs/whats-new.md index 666994f74..894d5a4c5 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -36,7 +36,12 @@ Scope tracked in the [v9.4 milestone](https://github.com/visgl/deck.gl-community including its picking-width shader customization. - `TraceGraphLayer`, `TracePreparedStateLayer`, and `TraceProcessLayer`: ported trace backgrounds, binary span blocks, outlines, labels, overflow labels, separators, and straight dependencies by reusing dual-backend community layers. - Trace counter sparklines now preserve their full geometry using portable `LineLayer` segments. -- The website injects luma.gl-style device tabs into skybox, path and dependency-marker, information-visualization, horizon-graph, and trace examples, with independent managers, WebGPU preference, WebGL2 fallback, and real renderer switching; standalone examples remain free of device-management dependencies. +- Every website gallery example and live layer-reference example now receives a standalone + `DeviceTabsWidget` from the shared imperative host, with an independent device manager, WebGPU + preference, WebGL2 fallback, renderer remounting, and preserved view state. +- Every documentation page now displays a generated WebGPU compatibility badge linked to the + support matrix. Verified and blocked layer pages override their package-level status, and + backend-neutral APIs are marked not applicable. ### `@deck.gl-community/geo-layers` diff --git a/examples/geo-layers/wind/wind-device-tabs.spec.ts b/examples/geo-layers/wind/wind-device-tabs.spec.ts index aaed40942..7402ea6a3 100644 --- a/examples/geo-layers/wind/wind-device-tabs.spec.ts +++ b/examples/geo-layers/wind/wind-device-tabs.spec.ts @@ -35,7 +35,12 @@ const deviceTabs = vi.hoisted(() => { return manager; }), Widget: vi.fn(function DeviceTabsWidget(props: unknown) { - return {props}; + return { + props, + onAdd: vi.fn(), + onRenderHTML: vi.fn(), + onRemove: vi.fn() + }; }) }; }); @@ -98,7 +103,7 @@ describe('wind showcase graphics backend selector', () => { ); expect(mount).toHaveBeenCalledOnce(); expect(mount.mock.calls[0][1].device).toBe(deviceTabs.webgpuDevice); - expect(mount.mock.calls[0][1].widgets).toHaveLength(1); + expect(mount.mock.calls[0][1].widgets).toHaveLength(0); expect(deviceTabs.manager.initialize).toHaveBeenCalledOnce(); mount.mock.calls[0][1].onViewStateChange({viewState: {longitude: -98}}); diff --git a/modules/widgets/README.md b/modules/widgets/README.md index adf222b2b..b4b4ddbdd 100644 --- a/modules/widgets/README.md +++ b/modules/widgets/README.md @@ -7,7 +7,7 @@ The deck.gl-community repository is semi-maintained. One of its goals is to coll [![NPM Version](https://img.shields.io/npm/v/@deck.gl-community/widgets.svg)](https://www.npmjs.com/package/@deck.gl-community/widgets) [![NPM Downloads](https://img.shields.io/npm/dw/@deck.gl-community/widgets.svg)](https://www.npmjs.com/package/@deck.gl-community/widgets) ![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square") -![WebGPU not supported](https://img.shields.io/badge/webgpu-no-red.svg?style=flat-square") +![WebGPU supported](https://img.shields.io/badge/webgpu-yes-green.svg?style=flat-square") This module packages UI widgets that integrate with [deck.gl](https://deck.gl) view state management. It includes classic navigation widgets such as `PanWidget` and `ZoomRangeWidget`, HTML overlays, and `PanelWidget`, the deck adapter for panel-owned UI components. diff --git a/website/src/components/docs/layer-live-example.jsx b/website/src/components/docs/layer-live-example.jsx index 25f7267b4..8df1d777a 100644 --- a/website/src/components/docs/layer-live-example.jsx +++ b/website/src/components/docs/layer-live-example.jsx @@ -44,18 +44,6 @@ const GRAPH_LAYER_HIGHLIGHTS = new Set([ 'zoomable-text-layer' ]); -const DEVICE_MANAGED_LAYER_HIGHLIGHTS = new Set([ - 'animation-layer', - 'block-layer', - 'dependency-arrow-layer', - 'horizon-graph-layer', - 'multi-horizon-graph-layer', - 'path-marker-layer', - 'path-outline-layer', - 'skybox-layer', - 'time-delta-layer' -]); - const INFO_COPY = { 'arrow-layers': { title: 'GeoArrow layers', @@ -86,14 +74,12 @@ function LayerLiveExampleHost({highlight, height}) { (container, mountProps) => mountLayerDocsExample(container, highlight, mountProps), {}, { - deviceTabs: DEVICE_MANAGED_LAYER_HIGHLIGHTS.has(highlight) - ? { - placement: - highlight === 'horizon-graph-layer' || highlight === 'multi-horizon-graph-layer' - ? 'bottom-right' - : 'top-right' - } - : false, + deviceTabs: { + placement: + highlight === 'horizon-graph-layer' || highlight === 'multi-horizon-graph-layer' + ? 'bottom-right' + : 'top-right' + }, mountLabel: highlight } ) @@ -142,7 +128,7 @@ async function mountLayerDocsExample(container, highlight, mountProps = {}) { const {mountBasemapLayerMapViewExample} = await import( '../../../../examples/layers/basemap-layer-map-view/app' ); - return mountBasemapLayerMapViewExample(container); + return mountBasemapLayerMapViewExample(container, mountProps); } case 'dependency-arrow-layer': case 'path-marker-layer': @@ -157,7 +143,11 @@ async function mountLayerDocsExample(container, highlight, mountProps = {}) { const {mountSharedTile2DLayerExample} = await import( '../../../../examples/geo-layers/shared-tile-2d-layer/app' ); - return mountSharedTile2DLayerExample(container, {mode: 'compact', showInfoWidget: false}); + return mountSharedTile2DLayerExample(container, { + mode: 'compact', + showInfoWidget: false, + ...mountProps + }); } case 'delaunay-cover-layer': case 'delaunay-interpolation': @@ -169,19 +159,19 @@ async function mountLayerDocsExample(container, highlight, mountProps = {}) { return mountWindExample(container, mountProps); } case 'global-grid-layer': - return mountGlobalGridLayerExample(container); + return mountGlobalGridLayerExample(container, mountProps); case 'tile-source-layer': - return mountTileSourceLayerExample(container); + return mountTileSourceLayerExample(container, mountProps); case 'editable-geojson-layer': case 'selection-layer': { const {mountGettingStartedExample} = await import( '../../../../examples/editable-layers/getting-started/app' ); - return mountGettingStartedExample(container, {showControlsWidget: false}); + return mountGettingStartedExample(container, {showControlsWidget: false, ...mountProps}); } case 'tree-layer': { const {mountWildForestExample} = await import('../../../../examples/three/wild-forest/app'); - return mountWildForestExample(container, {showControlsWidget: false}); + return mountWildForestExample(container, {showControlsWidget: false, ...mountProps}); } case 'horizon-graph-layer': { const {mountHorizonGraphLayerExample} = await import( @@ -196,9 +186,9 @@ async function mountLayerDocsExample(container, highlight, mountProps = {}) { return mountMultiHorizonGraphLayerExample(container, {showInfoWidget: false, ...mountProps}); } case 'time-axis-layer': - return mountTimeAxisLayerExample(container); + return mountTimeAxisLayerExample(container, mountProps); case 'vertical-grid-layer': - return mountVerticalGridLayerExample(container); + return mountVerticalGridLayerExample(container, mountProps); case 'animation-layer': case 'block-layer': case 'time-delta-layer': { @@ -213,13 +203,17 @@ async function mountLayerDocsExample(container, highlight, mountProps = {}) { } default: if (GRAPH_LAYER_HIGHLIGHTS.has(highlight)) { - return mountGraphLayerDocsExample(container, highlight); + return mountGraphLayerDocsExample(container, highlight, mountProps); } - return mountInfoDeck(container, INFO_COPY[highlight] ?? INFO_COPY['arrow-layers']); + return mountInfoDeck( + container, + INFO_COPY[highlight] ?? INFO_COPY['arrow-layers'], + mountProps + ); } } -async function mountGlobalGridLayerExample(container) { +async function mountGlobalGridLayerExample(container, mountProps = {}) { const {Deck} = await import('@deck.gl/core'); const {GlobalGridLayer, GeohashGrid} = await import('@deck.gl-community/geo-layers'); const rootElement = createRoot(container); @@ -232,16 +226,21 @@ async function mountGlobalGridLayerExample(container) { ]; const deck = new Deck({ + device: mountProps.device, parent: rootElement, - initialViewState: { - longitude: -122.42, - latitude: 37.77, - zoom: 10.5, - pitch: 35, - bearing: -20 - }, + initialViewState: + mountProps.initialViewState ?? + { + longitude: -122.42, + latitude: 37.77, + zoom: 10.5, + pitch: 35, + bearing: -20 + }, controller: true, parameters: {clearColor: [0.94, 0.97, 1, 1]}, + widgets: mountProps.widgets, + onViewStateChange: mountProps.onViewStateChange, layers: [ new GlobalGridLayer({ id: 'global-grid-layer-docs', @@ -261,6 +260,7 @@ async function mountGlobalGridLayerExample(container) { ], getTooltip: ({object}) => object && `${object.cellId}: ${object.value}` }); + mountProps.onDeckInitialized?.(deck); return () => { deck.finalize(); @@ -269,21 +269,26 @@ async function mountGlobalGridLayerExample(container) { }; } -async function mountTileSourceLayerExample(container) { +async function mountTileSourceLayerExample(container, mountProps = {}) { const {Deck} = await import('@deck.gl/core'); const {TileSourceLayer} = await import('@deck.gl-community/geo-layers'); const rootElement = createRoot(container); const tileSource = createCanvasTileSource(rootElement.ownerDocument); const deck = new Deck({ + device: mountProps.device, parent: rootElement, - initialViewState: { - longitude: -122.42, - latitude: 37.77, - zoom: 9.5 - }, + initialViewState: + mountProps.initialViewState ?? + { + longitude: -122.42, + latitude: 37.77, + zoom: 9.5 + }, controller: true, parameters: {clearColor: [0.94, 0.97, 1, 1]}, + widgets: mountProps.widgets, + onViewStateChange: mountProps.onViewStateChange, layers: [ new TileSourceLayer({ id: 'tile-source-layer-docs', @@ -292,6 +297,7 @@ async function mountTileSourceLayerExample(container) { }) ] }); + mountProps.onDeckInitialized?.(deck); return () => { deck.finalize(); @@ -300,7 +306,7 @@ async function mountTileSourceLayerExample(container) { }; } -async function mountGraphLayerDocsExample(container, highlight) { +async function mountGraphLayerDocsExample(container, highlight, mountProps = {}) { const {Deck, OrthographicView, COORDINATE_SYSTEM} = await import('@deck.gl/core'); const {LineLayer, TextLayer} = await import('@deck.gl/layers'); const rootElement = createRoot(container); @@ -315,14 +321,18 @@ async function mountGraphLayerDocsExample(container, highlight) { }); const deck = new Deck({ + device: mountProps.device, parent: rootElement, views: new OrthographicView({id: 'graph-docs'}), - initialViewState: {target: [0, 0, 0], zoom: 0.25}, + initialViewState: mountProps.initialViewState ?? {target: [0, 0, 0], zoom: 0.25}, controller: true, parameters: {clearColor: [0.96, 0.98, 1, 1]}, + widgets: mountProps.widgets, + onViewStateChange: mountProps.onViewStateChange, layers: Array.isArray(layer) ? layer : [layer], getTooltip: ({object}) => object?.label || object?._data?.label || object?.id || null }); + mountProps.onDeckInitialized?.(deck); return () => { deck.finalize(); @@ -695,7 +705,7 @@ function createStaticFlowDocsLayers({highlight, LineLayer, TextLayer, COORDINATE ]; } -async function mountTimeAxisLayerExample(container) { +async function mountTimeAxisLayerExample(container, mountProps = {}) { const {Deck, OrthographicView} = await import('@deck.gl/core'); const {LineLayer, TextLayer} = await import('@deck.gl/layers'); const {TimeAxisLayer} = await import('@deck.gl-community/timeline-layers'); @@ -705,14 +715,17 @@ async function mountTimeAxisLayerExample(container) { rootElement.style.background = 'linear-gradient(180deg, #f8fafc 0%, #eef5ff 100%)'; const deck = new Deck({ + device: mountProps.device, parent: rootElement, views: new OrthographicView({id: 'timeline-docs'}), - initialViewState: { + initialViewState: mountProps.initialViewState ?? { target: [500, 0, 0], zoom: 0 }, controller: true, parameters: {clearColor: [0.97, 0.98, 1, 1]}, + widgets: mountProps.widgets, + onViewStateChange: mountProps.onViewStateChange, layers: [ new LineLayer({ id: 'time-axis-docs-baseline', @@ -743,6 +756,7 @@ async function mountTimeAxisLayerExample(container) { }) ] }); + mountProps.onDeckInitialized?.(deck); return () => { deck.finalize(); @@ -751,7 +765,7 @@ async function mountTimeAxisLayerExample(container) { }; } -async function mountVerticalGridLayerExample(container) { +async function mountVerticalGridLayerExample(container, mountProps = {}) { const {Deck, OrthographicView} = await import('@deck.gl/core'); const {LineLayer, TextLayer} = await import('@deck.gl/layers'); const {VerticalGridLayer} = await import('@deck.gl-community/timeline-layers'); @@ -761,14 +775,17 @@ async function mountVerticalGridLayerExample(container) { rootElement.style.background = 'linear-gradient(180deg, #fff7ed 0%, #f8fafc 100%)'; const deck = new Deck({ + device: mountProps.device, parent: rootElement, views: new OrthographicView({id: 'timeline-docs'}), - initialViewState: { + initialViewState: mountProps.initialViewState ?? { target: [500, 0, 0], zoom: 0 }, controller: true, parameters: {clearColor: [1, 0.97, 0.93, 1]}, + widgets: mountProps.widgets, + onViewStateChange: mountProps.onViewStateChange, layers: [ new VerticalGridLayer({ id: 'vertical-grid-docs', @@ -803,6 +820,7 @@ async function mountVerticalGridLayerExample(container) { }) ] }); + mountProps.onDeckInitialized?.(deck); return () => { deck.finalize(); @@ -811,7 +829,7 @@ async function mountVerticalGridLayerExample(container) { }; } -async function mountInfoDeck(container, {title, markdown}) { +async function mountInfoDeck(container, {title, markdown}, mountProps = {}) { const {Deck} = await import('@deck.gl/core'); const {MarkdownPanel} = await import('@deck.gl-community/panels'); const {BoxPanelWidget} = await import('@deck.gl-community/widgets'); @@ -819,12 +837,14 @@ async function mountInfoDeck(container, {title, markdown}) { rootElement.style.background = 'linear-gradient(135deg, #f8fafc 0%, #dbeafe 50%, #ecfeff 100%)'; const deck = new Deck({ + device: mountProps.device, parent: rootElement, - initialViewState: {longitude: 0, latitude: 0, zoom: 1}, + initialViewState: mountProps.initialViewState ?? {longitude: 0, latitude: 0, zoom: 1}, controller: false, parameters: {clearColor: [0.96, 0.98, 1, 1]}, layers: [], widgets: [ + ...(mountProps.widgets ?? []), new BoxPanelWidget({ id: `${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-docs-info`, placement: 'top-left', @@ -837,8 +857,10 @@ async function mountInfoDeck(container, {title, markdown}) { markdown }) }) - ] + ], + onViewStateChange: mountProps.onViewStateChange }); + mountProps.onDeckInitialized?.(deck); return () => { deck.finalize(); diff --git a/website/src/components/docs/webgpu-badge.jsx b/website/src/components/docs/webgpu-badge.jsx new file mode 100644 index 000000000..bd1327b94 --- /dev/null +++ b/website/src/components/docs/webgpu-badge.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; + +import {getDocWebGpuStatus, WEBGPU_STATUS} from './webgpu-support'; +import styles from './webgpu-badge.module.css'; + +export function WebGpuBadge({docId}) { + const status = getDocWebGpuStatus(docId); + const metadata = WEBGPU_STATUS[status]; + const compatibilityUrl = useBaseUrl('/docs/webgpu'); + + return ( + + ); +} diff --git a/website/src/components/docs/webgpu-badge.module.css b/website/src/components/docs/webgpu-badge.module.css new file mode 100644 index 000000000..828740cc5 --- /dev/null +++ b/website/src/components/docs/webgpu-badge.module.css @@ -0,0 +1,63 @@ +.badgeRow { + position: relative; + z-index: 1; + display: flex; + justify-content: flex-end; + min-height: 24px; + margin-bottom: -24px; + pointer-events: none; +} + +.badge { + display: inline-flex; + overflow: hidden; + border-radius: 4px; + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 20px; + text-decoration: none; + box-shadow: 0 1px 2px rgb(15 23 42 / 18%); + pointer-events: auto; +} + +.badge:hover { + color: #fff; + text-decoration: none; + filter: brightness(1.06); +} + +.badge span { + padding: 0 7px; +} + +.name { + background: #555; +} + +.supported { + background: #2e7d32; +} + +.partial { + background: #b7791f; +} + +.unsupported { + background: #c62828; +} + +.not-applicable { + background: #607d8b; +} + +.mixed { + background: #2563eb; +} + +@media (width <= 600px) { + .badgeRow { + justify-content: flex-start; + margin-bottom: 8px; + } +} diff --git a/website/src/components/docs/webgpu-support.js b/website/src/components/docs/webgpu-support.js new file mode 100644 index 000000000..eeb8fd7d4 --- /dev/null +++ b/website/src/components/docs/webgpu-support.js @@ -0,0 +1,83 @@ +const SUPPORTED_DOC_IDS = new Set([ + 'modules/geo-layers/api-reference/delaunay-cover-layer', + 'modules/geo-layers/api-reference/delaunay-interpolation', + 'modules/geo-layers/api-reference/particle-layer', + 'modules/geo-layers/api-reference/wind-field', + 'modules/geo-layers/api-reference/wind-layer', + 'modules/graph-layers/api-reference/layers/edge-arrow-layer', + 'modules/graph-layers/api-reference/layers/path-edge-layer', + 'modules/graph-layers/api-reference/layers/path-rounded-rectangle-layer', + 'modules/graph-layers/api-reference/layers/rounded-rectangle-layer', + 'modules/infovis-layers/api-reference/block-layer', + 'modules/infovis-layers/api-reference/time-delta-layer', + 'modules/timeline-layers/api-reference/horizon-graph-layer', + 'modules/timeline-layers/api-reference/multi-horizon-graph-layer', + 'modules/timeline-layers/api-reference/vertical-grid-layer', + 'modules/trace-layers/api-reference/layers/trace-graph-layer', + 'modules/trace-layers/api-reference/layers/trace-prepared-state-layer' +]); + +const UNSUPPORTED_DOC_IDS = new Set([ + 'modules/geo-layers/api-reference/elevation-layer', + 'modules/graph-layers/api-reference/layers/flow-layer', + 'modules/graph-layers/api-reference/layers/flow-path-layer' +]); + +const MODULE_STATUS = { + 'arrow-layers': 'partial', + 'basemap-layers': 'partial', + 'bing-maps': 'unsupported', + 'editable-layers': 'partial', + experimental: 'unsupported', + 'geo-layers': 'partial', + 'graph-layers': 'partial', + 'infovis-layers': 'partial', + layers: 'supported', + leaflet: 'unsupported', + panels: 'not-applicable', + react: 'not-applicable', + three: 'unsupported', + 'timeline-layers': 'partial', + 'trace-layers': 'partial', + widgets: 'supported' +}; + +export const WEBGPU_STATUS = { + supported: { + label: 'supported', + description: 'This API is verified on WebGL2 and WebGPU.' + }, + partial: { + label: 'partial', + description: 'Some rendering paths are verified on WebGPU; see the compatibility matrix.' + }, + unsupported: { + label: 'not supported', + description: 'This API currently requires WebGL2 or another host renderer.' + }, + 'not-applicable': { + label: 'not applicable', + description: 'This API does not own a graphics backend.' + }, + mixed: { + label: 'mixed', + description: 'WebGPU support varies by module and layer; see the compatibility matrix.' + } +}; + +/** + * Returns the WebGPU status shown on a generated documentation page. + * + * Specific verified or blocked layer pages take precedence over their package's aggregate status. + */ +export function getDocWebGpuStatus(docId = '') { + if (SUPPORTED_DOC_IDS.has(docId)) { + return 'supported'; + } + if (UNSUPPORTED_DOC_IDS.has(docId)) { + return 'unsupported'; + } + + const moduleName = /^modules\/([^/]+)/.exec(docId)?.[1]; + return MODULE_STATUS[moduleName] ?? 'mixed'; +} diff --git a/website/src/components/example/make-imperative-example.jsx b/website/src/components/example/make-imperative-example.jsx index e7c2d6c02..bc4dc4262 100644 --- a/website/src/components/example/make-imperative-example.jsx +++ b/website/src/components/example/make-imperative-example.jsx @@ -59,7 +59,7 @@ function ImperativeExampleHost({mount, mountLabel, deviceTabs, ...mountProps}) { } export default function makeImperativeExample( - {title, code, renderInfo = () => null, mount, parameters, mapStyle, data, deviceTabs}, + {title, code, renderInfo = () => null, mount, parameters, mapStyle, data, deviceTabs = true}, options ) { function ImperativeDemo(props) { diff --git a/website/src/components/example/mount-device-managed-example.js b/website/src/components/example/mount-device-managed-example.js index 0af2b87e4..d54309c56 100644 --- a/website/src/components/example/mount-device-managed-example.js +++ b/website/src/components/example/mount-device-managed-example.js @@ -17,12 +17,24 @@ export async function mountDeviceManagedExample(container, mount, mountProps = { let mountGeneration = 0; let mountQueue = Promise.resolve(); const widgetOptions = typeof deviceTabs === 'object' ? deviceTabs : {}; + const deviceTabsHost = container.ownerDocument?.createElement?.('div'); const deviceTabsWidget = new DeviceTabsWidget({ id: `${mountLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-device-tabs`, - devices: ['webgpu', 'webgl2'], + devices: widgetOptions.devices ?? ['webgpu', 'webgl2'], placement: widgetOptions.placement ?? 'top-right', manager }); + deviceTabsWidget.onAdd?.(); + + if (deviceTabsHost) { + Object.assign(deviceTabsHost.style, { + position: 'absolute', + zIndex: '20', + pointerEvents: 'auto', + ...(getDeviceTabsPosition(widgetOptions.placement) ?? {}) + }); + deviceTabsHost.dataset.deviceTabsHost = 'true'; + } const unsubscribe = manager.subscribe(({device}) => { if (!device || device === activeDevice || disposed) { @@ -44,7 +56,7 @@ export async function mountDeviceManagedExample(container, mount, mountProps = { ...mountProps, device, initialViewState: currentViewState, - widgets: [...(mountProps.widgets ?? []), deviceTabsWidget], + widgets: [...(mountProps.widgets ?? [])], onViewStateChange(params) { currentViewState = params.viewState; return mountProps.onViewStateChange?.(params) ?? params.viewState; @@ -60,6 +72,10 @@ export async function mountDeviceManagedExample(container, mount, mountProps = { return; } cleanup = nextCleanup; + if (deviceTabsHost) { + container.append(deviceTabsHost); + deviceTabsWidget.onRenderHTML?.(deviceTabsHost); + } }); }); @@ -72,6 +88,8 @@ export async function mountDeviceManagedExample(container, mount, mountProps = { mountGeneration++; unsubscribe(); cleanup?.(); + deviceTabsWidget.onRemove?.(); + deviceTabsHost?.remove(); manager.reset(); }; } catch (error) { @@ -79,7 +97,22 @@ export async function mountDeviceManagedExample(container, mount, mountProps = { mountGeneration++; unsubscribe(); cleanup?.(); + deviceTabsWidget.onRemove?.(); + deviceTabsHost?.remove(); manager.reset(); throw error; } } + +function getDeviceTabsPosition(placement = 'top-right') { + switch (placement) { + case 'top-left': + return {top: '12px', left: '12px'}; + case 'bottom-left': + return {bottom: '12px', left: '12px'}; + case 'bottom-right': + return {right: '12px', bottom: '12px'}; + default: + return {top: '12px', right: '12px'}; + } +} diff --git a/website/src/examples/editable-layers/3d-tiles.tsx b/website/src/examples/editable-layers/3d-tiles.tsx index afa408447..9124d0db0 100644 --- a/website/src/examples/editable-layers/3d-tiles.tsx +++ b/website/src/examples/editable-layers/3d-tiles.tsx @@ -4,10 +4,10 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: '3D Tiles', code: `${GITHUB_TREE}/examples/editable-layers/3d-tiles`, - async mount(container) { + async mount(container, props) { const {mountEditableLayers3DTilesExample} = await import( '../../../../examples/editable-layers/3d-tiles/app' ); - return mountEditableLayers3DTilesExample(container); + return mountEditableLayers3DTilesExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/editable-layers/advanced.tsx b/website/src/examples/editable-layers/advanced.tsx index aa4a9e99d..7d8766358 100644 --- a/website/src/examples/editable-layers/advanced.tsx +++ b/website/src/examples/editable-layers/advanced.tsx @@ -4,10 +4,10 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'Advanced', code: `${GITHUB_TREE}/examples/editable-layers/advanced`, - async mount(container) { + async mount(container, props) { const {mountEditableLayersAdvancedExample} = await import( '../../../../examples/editable-layers/advanced/src/app' ); - return mountEditableLayersAdvancedExample(container); + return mountEditableLayersAdvancedExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/editable-layers/editable-h3-cluster-layer.tsx b/website/src/examples/editable-layers/editable-h3-cluster-layer.tsx index 7d8b2316e..029d67dc9 100644 --- a/website/src/examples/editable-layers/editable-h3-cluster-layer.tsx +++ b/website/src/examples/editable-layers/editable-h3-cluster-layer.tsx @@ -4,10 +4,10 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'Editable H3 Cluster Layer', code: `${GITHUB_TREE}/examples/editable-layers/editable-h3-cluster-layer`, - async mount(container) { + async mount(container, props) { const {mountEditableH3ClusterLayerExample} = await import( '../../../../examples/editable-layers/editable-h3-cluster-layer/app' ); - return mountEditableH3ClusterLayerExample(container); + return mountEditableH3ClusterLayerExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/editable-layers/editor-react.tsx b/website/src/examples/editable-layers/editor-react.tsx index dd722c9a1..e03408da8 100644 --- a/website/src/examples/editable-layers/editor-react.tsx +++ b/website/src/examples/editable-layers/editor-react.tsx @@ -4,10 +4,10 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'Editor (React)', code: `${GITHUB_TREE}/examples/editable-layers/editor`, - async mount(container) { + async mount(container, props) { const {mountEditableLayersEditorExample} = await import( '../../../../examples/editable-layers/editor/app' ); - return mountEditableLayersEditorExample(container); + return mountEditableLayersEditorExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/editable-layers/editor.tsx b/website/src/examples/editable-layers/editor.tsx index 34b5f7deb..b8a4f5207 100644 --- a/website/src/examples/editable-layers/editor.tsx +++ b/website/src/examples/editable-layers/editor.tsx @@ -4,10 +4,10 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'Editor', code: `${GITHUB_TREE}/examples/editable-layers/widget`, - async mount(container) { + async mount(container, props) { const {mountEditableLayersWidgetExample} = await import( '../../../../examples/editable-layers/widget/app' ); - return mountEditableLayersWidgetExample(container); + return mountEditableLayersWidgetExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/editable-layers/getting-started.tsx b/website/src/examples/editable-layers/getting-started.tsx index 6c1942a28..2ca877fab 100644 --- a/website/src/examples/editable-layers/getting-started.tsx +++ b/website/src/examples/editable-layers/getting-started.tsx @@ -4,10 +4,10 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'Getting Started', code: `${GITHUB_TREE}/examples/editable-layers/getting-started`, - async mount(container) { + async mount(container, props) { const {mountGettingStartedExample} = await import( '../../../../examples/editable-layers/getting-started/app' ); - return mountGettingStartedExample(container); + return mountGettingStartedExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/editable-layers/no-map.tsx b/website/src/examples/editable-layers/no-map.tsx index 4cacaafa7..642dee656 100644 --- a/website/src/examples/editable-layers/no-map.tsx +++ b/website/src/examples/editable-layers/no-map.tsx @@ -4,8 +4,8 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'No Map', code: `${GITHUB_TREE}/examples/editable-layers/no-map`, - async mount(container) { + async mount(container, props) { const {mountNoMapExample} = await import('../../../../examples/editable-layers/no-map/app'); - return mountNoMapExample(container); + return mountNoMapExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/editable-layers/sf.tsx b/website/src/examples/editable-layers/sf.tsx index fbf29e164..1c633f3ca 100644 --- a/website/src/examples/editable-layers/sf.tsx +++ b/website/src/examples/editable-layers/sf.tsx @@ -4,8 +4,8 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'SF Polygons', code: `${GITHUB_TREE}/examples/editable-layers/sf`, - async mount(container) { + async mount(container, props) { const {mountSfExample} = await import('../../../../examples/editable-layers/sf/app'); - return mountSfExample(container); + return mountSfExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/editable-layers/widget.tsx b/website/src/examples/editable-layers/widget.tsx index d7f65d739..def2c40d6 100644 --- a/website/src/examples/editable-layers/widget.tsx +++ b/website/src/examples/editable-layers/widget.tsx @@ -4,10 +4,10 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'Editor (Widgets)', code: `${GITHUB_TREE}/examples/editable-layers/widget`, - async mount(container) { + async mount(container, props) { const {mountEditableLayersWidgetExample} = await import( '../../../../examples/editable-layers/widget/app' ); - return mountEditableLayersWidgetExample(container); + return mountEditableLayersWidgetExample(container, props); } }, {addInfoPanel: false}); diff --git a/website/src/examples/geo-layers/shared-tile-2d-layer.tsx b/website/src/examples/geo-layers/shared-tile-2d-layer.tsx index d0b2693b5..f7491c512 100644 --- a/website/src/examples/geo-layers/shared-tile-2d-layer.tsx +++ b/website/src/examples/geo-layers/shared-tile-2d-layer.tsx @@ -4,11 +4,11 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'SharedTile2DLayer', code: `${GITHUB_TREE}/examples/geo-layers/shared-tile-2d-layer`, - async mount(container) { + async mount(container, props) { const {mountSharedTile2DLayerExample} = await import( '../../../../examples/geo-layers/shared-tile-2d-layer/app' ); - return mountSharedTile2DLayerExample(container); + return mountSharedTile2DLayerExample(container, props); } }, { addInfoPanel: false diff --git a/website/src/examples/layers/basemap-layer-map-view.tsx b/website/src/examples/layers/basemap-layer-map-view.tsx index f6b9ffb2a..f15276300 100644 --- a/website/src/examples/layers/basemap-layer-map-view.tsx +++ b/website/src/examples/layers/basemap-layer-map-view.tsx @@ -5,11 +5,11 @@ export default makeImperativeExample( { title: 'BasemapLayer MapView', code: `${GITHUB_TREE}/examples/layers/basemap-layer-map-view`, - async mount(container) { + async mount(container, props) { const {mountBasemapLayerMapViewExample} = await import( '../../../../examples/layers/basemap-layer-map-view/app' ); - return mountBasemapLayerMapViewExample(container); + return mountBasemapLayerMapViewExample(container, props); } }, {addInfoPanel: false} diff --git a/website/src/examples/layers/skybox-first-person.tsx b/website/src/examples/layers/skybox-first-person.tsx index f232598e6..72fff1ec9 100644 --- a/website/src/examples/layers/skybox-first-person.tsx +++ b/website/src/examples/layers/skybox-first-person.tsx @@ -5,11 +5,11 @@ export default makeImperativeExample( { title: 'SkyboxLayer FirstPersonView', code: `${GITHUB_TREE}/examples/layers/skybox-first-person`, - async mount(container) { + async mount(container, props) { const {mountSkyboxFirstPersonExample} = await import( '../../../../examples/layers/skybox-first-person/app' ); - return mountSkyboxFirstPersonExample(container); + return mountSkyboxFirstPersonExample(container, props); } }, {addInfoPanel: false} diff --git a/website/src/examples/layers/skybox-globe.tsx b/website/src/examples/layers/skybox-globe.tsx index 784875e19..9da71d29e 100644 --- a/website/src/examples/layers/skybox-globe.tsx +++ b/website/src/examples/layers/skybox-globe.tsx @@ -5,11 +5,11 @@ export default makeImperativeExample( { title: 'SkyboxLayer GlobeView', code: `${GITHUB_TREE}/examples/layers/skybox-globe`, - async mount(container) { + async mount(container, props) { const {mountSkyboxGlobeExample} = await import( '../../../../examples/layers/skybox-globe/app' ); - return mountSkyboxGlobeExample(container); + return mountSkyboxGlobeExample(container, props); } }, {addInfoPanel: false} diff --git a/website/src/examples/leaflet/get-started.tsx b/website/src/examples/leaflet/get-started.tsx index cfb387f0b..e3173b742 100644 --- a/website/src/examples/leaflet/get-started.tsx +++ b/website/src/examples/leaflet/get-started.tsx @@ -4,9 +4,9 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample({ title: 'Leaflet as deck.gl Basemap', code: `${GITHUB_TREE}/examples/leaflet/get-started`, - mount(container) { + mount(container, props) { return import('../../../../examples/leaflet/get-started/app').then( - ({mountLeafletGetStartedExample}) => mountLeafletGetStartedExample(container), + ({mountLeafletGetStartedExample}) => mountLeafletGetStartedExample(container, props), ); }, }, {addInfoPanel: false}); diff --git a/website/src/theme/DocCategoryGeneratedIndexPage/index.js b/website/src/theme/DocCategoryGeneratedIndexPage/index.js new file mode 100644 index 000000000..3d12e06bb --- /dev/null +++ b/website/src/theme/DocCategoryGeneratedIndexPage/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import OriginalDocCategoryGeneratedIndexPage from '@theme-original/DocCategoryGeneratedIndexPage'; + +import {WebGpuBadge} from '../../components/docs/webgpu-badge'; + +export default function DocCategoryGeneratedIndexPage(props) { + return ( + <> + + + + ); +} diff --git a/website/src/theme/DocItem/Content/index.js b/website/src/theme/DocItem/Content/index.js new file mode 100644 index 000000000..32e89e36d --- /dev/null +++ b/website/src/theme/DocItem/Content/index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import {useDoc} from '@docusaurus/plugin-content-docs/client'; +import OriginalDocItemContent from '@theme-original/DocItem/Content'; + +import {WebGpuBadge} from '../../../components/docs/webgpu-badge'; + +export default function DocItemContent({children}) { + const {metadata} = useDoc(); + + return ( + <> + + {children} + + ); +}