diff --git a/dev/timeline-layers/examples/horizon-graph-layer/app.tsx b/dev/timeline-layers/examples/horizon-graph-layer/app.tsx index 8dd4f6d0..f0ae2944 100644 --- a/dev/timeline-layers/examples/horizon-graph-layer/app.tsx +++ b/dev/timeline-layers/examples/horizon-graph-layer/app.tsx @@ -2,8 +2,15 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {Deck, OrthographicView, type OrthographicViewState} from '@deck.gl/core'; -import {LineLayer, TextLayer} from '@deck.gl/layers'; +import { + Deck, + OrthographicView, + type OrthographicViewState, + type ViewStateChangeParameters, + type Widget +} from '@deck.gl/core'; +import {LineLayer} from '@deck.gl/layers'; +import {FastTextLayer} from '@deck.gl-community/infovis-layers'; import {MultiHorizonGraphLayer} from '@deck.gl-community/timeline-layers'; import { MarkdownPanel, @@ -12,6 +19,7 @@ import { type SettingsState } from '@deck.gl-community/panels'; import {BoxPanelWidget, SidebarPanelWidget} from '@deck.gl-community/widgets'; +import type {Device} from '@luma.gl/core'; import '@deck.gl/widgets/stylesheet.css'; @@ -81,9 +89,26 @@ type HorizonExampleConfig = { infoTitle?: string; infoMarkdown?: string; showInfoWidget?: boolean; - onDeckInitialized?: (deck: Deck) => void; + device?: Device; + widgets?: Widget[]; + initialViewState?: OrthographicViewState; + onViewStateChange?: ( + params: ViewStateChangeParameters + ) => ViewStateT; + onDeckInitialized?: (deck: Deck) => void; }; +type ResolvedHorizonExampleConfig = Required< + Pick< + HorizonExampleConfig, + 'layerId' | 'sidebarTitle' | 'infoTitle' | 'infoMarkdown' | 'showInfoWidget' + > +> & + Omit< + HorizonExampleConfig, + 'layerId' | 'sidebarTitle' | 'infoTitle' | 'infoMarkdown' | 'showInfoWidget' + >; + const VIEW = new OrthographicView({id: 'ortho'}); const INITIAL_VIEW_STATE: OrthographicViewState = { @@ -249,7 +274,10 @@ const SETTINGS_SCHEMA: SettingsSchema = { ] }; -const DEFAULT_EXAMPLE_CONFIG: Required> = { +const DEFAULT_EXAMPLE_CONFIG: Pick< + ResolvedHorizonExampleConfig, + 'layerId' | 'sidebarTitle' | 'infoTitle' | 'infoMarkdown' | 'showInfoWidget' +> = { layerId: 'horizon-graph-layer', sidebarTitle: 'Horizon Graph Controls', infoTitle: 'HorizonGraphLayer', @@ -266,7 +294,6 @@ export function mountHorizonGraphLayerExample( const rootElement = container.ownerDocument.createElement('div'); applyElementStyle(rootElement, ROOT_STYLE); container.replaceChildren(rootElement); - const state: ExampleState = { settings: cloneSettings(INITIAL_SETTINGS), derived: buildDerivedState(INITIAL_SETTINGS), @@ -293,7 +320,7 @@ export function mountHorizonGraphLayerExample( panel: settingsPanel }); - const widgets = []; + const widgets: Widget[] = [...(resolvedConfig.widgets ?? [])]; if (resolvedConfig.showInfoWidget && resolvedConfig.infoTitle && resolvedConfig.infoMarkdown) { widgets.push( @@ -315,11 +342,13 @@ export function mountHorizonGraphLayerExample( widgets.push(sidebarWidget); const deck = new Deck({ + device: resolvedConfig.device, parent: rootElement, width: '100%', height: '100%', views: VIEW, - initialViewState: INITIAL_VIEW_STATE, + initialViewState: resolvedConfig.initialViewState ?? INITIAL_VIEW_STATE, + onViewStateChange: resolvedConfig.onViewStateChange, controller: true, widgets, layers: buildLayers(state, resolvedConfig), @@ -336,7 +365,7 @@ export function mountHorizonGraphLayerExample( syncDeck(); } }); - config.onDeckInitialized?.(deck); + resolvedConfig.onDeckInitialized?.(deck); return () => { deck.finalize(); @@ -371,10 +400,7 @@ export function mountMultiHorizonGraphLayerExample( }); } -function buildLayers( - state: ExampleState, - config: Required> -) { +function buildLayers(state: ExampleState, config: ResolvedHorizonExampleConfig) { const {settings, derived, mousePosition} = state; const {x, y, width} = settings.layout; const {height} = derived; @@ -396,16 +422,15 @@ function buildLayers( width, height }), - new TextLayer({ + new FastTextLayer({ id: 'series-labels', data: derived.textLabels, getText: datum => datum.text, getPosition: datum => datum.position, - getSize: datum => datum.size, + size: 12, getColor: datum => datum.color, - getAngle: datum => datum.angle, - getTextAnchor: datum => datum.textAnchor, - getAlignmentBaseline: datum => datum.alignmentBaseline, + textAnchor: 'end', + alignmentBaseline: 'center', fontFamily: 'Arial, sans-serif', fontWeight: 'normal' }), @@ -418,16 +443,15 @@ function buildLayers( getWidth: 1, widthUnits: 'pixels' }), - new TextLayer({ + new FastTextLayer({ id: 'intersection-values', data: buildIntersectionData(state), getText: datum => datum.text, getPosition: datum => datum.position, - getSize: datum => datum.size, + size: 12, getColor: datum => datum.color, - getAngle: datum => datum.angle, - getTextAnchor: datum => datum.textAnchor, - getAlignmentBaseline: datum => datum.alignmentBaseline, + textAnchor: 'start', + alignmentBaseline: 'center', fontFamily: 'Arial, sans-serif', fontWeight: 'normal' }) diff --git a/dev/timeline-layers/examples/horizon-graph-layer/package.json b/dev/timeline-layers/examples/horizon-graph-layer/package.json index 5e9002af..b8c9413f 100644 --- a/dev/timeline-layers/examples/horizon-graph-layer/package.json +++ b/dev/timeline-layers/examples/horizon-graph-layer/package.json @@ -5,6 +5,7 @@ "start-local": "vite --config ../../vite.config.local.mjs" }, "dependencies": { + "@deck.gl-community/infovis-layers": "workspace:*", "@deck.gl-community/panels": "workspace:*", "@deck.gl-community/timeline-layers": "workspace:*", "@deck.gl-community/widgets": "workspace:*", diff --git a/docs/modules/geo-layers/api-reference/delaunay-cover-layer.md b/docs/modules/geo-layers/api-reference/delaunay-cover-layer.md index 17ab72ab..7a4491e3 100644 --- a/docs/modules/geo-layers/api-reference/delaunay-cover-layer.md +++ b/docs/modules/geo-layers/api-reference/delaunay-cover-layer.md @@ -3,8 +3,8 @@ import LayerLiveExample from '@site/src/components/docs/layer-live-example'; # DelaunayCoverLayer :::caution Work in progress -The station-surface appearance and API may change. Its `SolidPolygonLayer` sublayer is not yet -fully portable to WebGPU. +The station-surface appearance and API may change. Its native triangle primitive works on WebGL2 +and WebGPU; image-derived mountain terrain remains dependent on upstream `TerrainLayer` support. ::: `DelaunayCoverLayer` renders one elevation-colored polygon for each triangle in a shared @@ -43,7 +43,10 @@ const stationSurface = new DelaunayCoverLayer({ ## Sub-layers -- `terrain`: a `SolidPolygonLayer` containing the station-index Delaunay triangles. +- `terrain`: native GLSL/WGSL triangle geometry containing the station-index Delaunay triangles. + +The triangulated station surface, elevation scaling, and height-based colors render on both +WebGL2 and WebGPU. This surface is distinct from the smoothed image-based mountain terrain. Use the example's **Delaunay station surface** control to inspect the triangulation independently of the smoothed mountains. See the [wind showcase guide](../developer-guide/wind-showcase.md). diff --git a/docs/modules/geo-layers/api-reference/elevation-layer.md b/docs/modules/geo-layers/api-reference/elevation-layer.md index dfb2080d..d50d2659 100644 --- a/docs/modules/geo-layers/api-reference/elevation-layer.md +++ b/docs/modules/geo-layers/api-reference/elevation-layer.md @@ -37,6 +37,12 @@ const terrain = new ElevationLayer({ For smoother relief, filter the grayscale image once before passing it as `elevationData`. Do not recreate or decode the terrain mesh during particle animation. +Height-map rendering currently requires WebGL2 because upstream `TerrainLayer` uses a WebGL-only +mesh renderer. On WebGPU, `ElevationLayer` safely omits its terrain sub-layer; use +[`DelaunayCoverLayer`](/docs/modules/geo-layers/api-reference/delaunay-cover-layer) for a +WebGPU-compatible station-triangulated terrain surface. See the +[WebGPU support matrix](/docs/webgpu) for the current status. + ## Properties - `elevationData` (`string`, required): URL or data URL of a red-channel grayscale height map. diff --git a/docs/modules/geo-layers/api-reference/particle-layer.md b/docs/modules/geo-layers/api-reference/particle-layer.md index 6844bb18..9caede28 100644 --- a/docs/modules/geo-layers/api-reference/particle-layer.md +++ b/docs/modules/geo-layers/api-reference/particle-layer.md @@ -4,13 +4,14 @@ import LayerLiveExample from '@site/src/components/docs/layer-live-example'; :::caution Work in progress The wind-layer API, GPU simulation, particle appearance, and tuning controls are experimental and -may change. WebGL2 and WebGPU particle simulation are browser-tested independently; a complete -WebGPU wind scene still depends on upstream terrain, polygon, and path support. +may change. WebGL2 and WebGPU particle simulation, wind arrows, and station surfaces are +browser-tested independently; image-derived mountain terrain still depends on upstream support. ::: `ParticleLayer` animates GPU-resident particles through a station-interpolated geographic wind field. WebGL2 uses transform feedback and single-vertex point rendering; WebGPU uses a compute -shader and portable deck.gl sublayers. Neither simulation reads particle positions back to the CPU. +shader and native GPU-buffer-backed point primitives. Neither simulation reads particle positions +back to the CPU. @@ -117,11 +118,13 @@ Radius of moving particle heads in screen pixels. - WebGL2: cached `rgba32float` weather textures, transform-feedback ping-pong buffers, and one native point vertex per particle. -- WebGPU: cached weather textures, a WGSL compute pipeline, and GPU-buffer-backed sublayers. +- WebGPU: cached weather textures, a WGSL compute pipeline, and native GPU-buffer-backed point + primitives. - Coverage: invalid samples are respawned within the wind field; overlong segments are clipped. -- Cleanup: deck.gl finalization releases the weather textures, simulation buffers, and pipeline. -- Scope: GPU particle compatibility does not imply that `TerrainLayer`, `SolidPolygonLayer`, or - `PathLayer` is already WebGPU-compatible. +- Cleanup: deck.gl finalization releases the weather textures, simulation buffers, and pipeline + after submitted GPU work has completed. +- Scope: native wind arrows and station-triangulated surfaces support WebGPU, but image-derived + mountain terrain still depends on upstream `TerrainLayer` compatibility. See the [wind showcase guide](../developer-guide/wind-showcase.md), the [Wind Map example](/examples/geo-layers/wind), and the diff --git a/docs/modules/geo-layers/api-reference/wind-layer.md b/docs/modules/geo-layers/api-reference/wind-layer.md index 0f77e895..cb879201 100644 --- a/docs/modules/geo-layers/api-reference/wind-layer.md +++ b/docs/modules/geo-layers/api-reference/wind-layer.md @@ -3,8 +3,8 @@ import LayerLiveExample from '@site/src/components/docs/layer-live-example'; # WindLayer :::caution Work in progress -The wind arrow API and styling are experimental. Filled arrows depend on upstream -`SolidPolygonLayer` and `PathLayer`, so complete WebGPU scene support is still in progress. +The wind arrow API and styling are experimental. Native triangle glyphs and portable line +segments render on WebGL2 and WebGPU; image-based mountain terrain remains in progress. ::: `WindLayer` renders a Delaunay-interpolated station forecast as directional, speed-colored arrow @@ -61,9 +61,9 @@ than the animation frame rate. ## Sub-layers -- `glyphs`: filled `SolidPolygonLayer` arrows. -- `shafts`: a `PathLayer` for arrow shafts. -- `arrowheads`: a `PathLayer` for directional arrowheads. +- `glyphs`: native GLSL/WGSL triangle geometry for filled directional arrows. +- `shafts`: a dual-backend `LineLayer` for arrow shafts. +- `arrowheads`: a dual-backend `LineLayer` for directional arrowhead segments. ## Historical source diff --git a/docs/modules/geo-layers/developer-guide/wind-showcase.md b/docs/modules/geo-layers/developer-guide/wind-showcase.md index 4d3e0ec9..11b5ed29 100644 --- a/docs/modules/geo-layers/developer-guide/wind-showcase.md +++ b/docs/modules/geo-layers/developer-guide/wind-showcase.md @@ -4,9 +4,9 @@ import LayerLiveExample from '@site/src/components/docs/layer-live-example'; :::caution Work in progress The reusable wind layers and the historical showcase are experimental. GPU particle advection has -been independently verified on WebGL2 and WebGPU. The complete mountain-and-arrow scene currently -uses upstream terrain, polygon, and path layers, so its end-to-end WebGPU support remains a work -in progress. +been independently verified on WebGL2 and WebGPU, along with filled wind arrows and station +surfaces. Image-derived mountain terrain still depends on upstream `TerrainLayer` support, so the +WebGPU showcase substitutes the station-triangulated surface. ::: The Wind Map restores @@ -178,8 +178,9 @@ when debugging interpolation coverage; do not replace the mountain terrain with The example starts with `100_000` particles and provides a debounced slider from `1_000` to `1_000_000`. WebGL2 advances GPU-resident particle buffers using transform feedback and renders -single-vertex point primitives. WebGPU advances them with a WGSL compute shader. Neither production -animation path reads particle positions back to the CPU. +single-vertex point primitives. WebGPU advances them with a WGSL compute shader and renders native +GPU-buffer-backed point primitives. Neither production animation path reads particle positions +back to the CPU. Weather textures are cached, static terrain is retained, arrow resampling is throttled, and high-density rendering favors particle heads over additional line geometry. Changing the particle @@ -192,10 +193,10 @@ buffers on every slider event. | --- | :---: | :---: | --- | | `ParticleLayer` | Supported | Supported | Independently browser-tested GPU advection and rendering. | | Wind data and `DelaunayInterpolation` | Supported | Supported | Backend-independent indexing and explicit sampling. | -| `WindLayer` | Supported | In progress | Depends on upstream polygon and path rendering. | -| `ElevationLayer` | Supported | In progress | Depends on upstream `TerrainLayer`. | -| `DelaunayCoverLayer` | Supported | Blocked | Depends on upstream polygon rendering. | -| Complete original showcase | Supported | In progress | GPU particles are ready; remaining scene layers are not. | +| `WindLayer` | Supported | Supported | Native GLSL/WGSL filled arrows and portable line shafts. | +| `ElevationLayer` | Supported | Blocked | Image-derived mountain terrain depends on upstream `TerrainLayer`. | +| `DelaunayCoverLayer` | Supported | Supported | Native GLSL/WGSL station-triangulated surface. | +| Complete original showcase | Supported | In progress | WebGPU uses station terrain while image-derived mountains remain blocked. | See the full [WebGPU compatibility matrix](/docs/webgpu). diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md index 3482d83e..32ee070d 100644 --- a/docs/upgrade-guide.md +++ b/docs/upgrade-guide.md @@ -23,9 +23,9 @@ Please refer the documentation of each module for detailed upgrade guides. WebGL `Transform` and `Texture2D` dependencies. - `trailLength` controls the device-free CPU fallback; GPU rendering uses ping-pong buffers, lifetime fading, and high-density point rendering. -- WebGPU support for `ParticleLayer` does not imply support for the complete showcase. Terrain, - filled wind arrows, and station polygons remain dependent on upstream `TerrainLayer`, - `PathLayer`, and `SolidPolygonLayer` compatibility. See the +- WebGPU supports GPU-resident particles, filled wind arrows, and station-triangulated surfaces + through native shaders. Image-derived mountain terrain remains dependent on upstream + `TerrainLayer` compatibility. See the [WebGPU support matrix](./webgpu.md). ### `@deck.gl-community/panels` diff --git a/docs/webgpu.md b/docs/webgpu.md index 87ae0d86..bb52af25 100644 --- a/docs/webgpu.md +++ b/docs/webgpu.md @@ -16,12 +16,12 @@ deck.gl-community is adding WebGPU support incrementally while continuing to sup | `@deck.gl-community/layers` | `PathMarkerLayer` | โœ… | ๐Ÿšง | Marker geometry is portable; outlined and dashed paths remain blocked. | | `@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` | โœ… | ๐Ÿšง | Upstream line and text sublayers require end-to-end validation. | +| `@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` | `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` | `VerticalGridLayer` | โœ… | ๐Ÿšง | Upstream `LineLayer` is portable; dedicated rendering validation is pending. | +| `@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/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. | @@ -33,10 +33,10 @@ deck.gl-community is adding WebGPU support incrementally while continuing to sup | `@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. | -| `@deck.gl-community/geo-layers` | `WindLayer` | โœ… | ๐Ÿšง | Filled arrows depend on upstream `SolidPolygonLayer` and `PathLayer`. | -| `@deck.gl-community/geo-layers` | `ElevationLayer` | โœ… | ๐Ÿšง | Three-dimensional mountain rendering depends on upstream `TerrainLayer`. | -| `@deck.gl-community/geo-layers` | `DelaunayCoverLayer` | โœ… | โŒ | Station-surface rendering depends on upstream `SolidPolygonLayer`. | -| `@deck.gl-community/geo-layers` | Complete Wind Map showcase | โœ… | ๐Ÿšง | GPU particles are portable; upstream terrain, polygon, and path sublayers still block full-scene WebGPU compatibility. | +| `@deck.gl-community/geo-layers` | `WindLayer` | โœ… | โœ… | Native WGSL/GLSL filled-arrow triangles and portable line shafts and arrowheads. | +| `@deck.gl-community/geo-layers` | `ElevationLayer` | โœ… | โŒ | Image-derived mountain terrain depends on upstream `TerrainLayer`; skipped safely on WebGPU. | +| `@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. | @@ -57,34 +57,53 @@ import {Deck} from '@deck.gl/core'; import {DeviceManagerController, DeviceTabsWidget} from '@deck.gl-community/widgets'; const manager = new DeviceManagerController(); -manager.reparentCanvas(container); - -const deck = new Deck({ - parent: container, - layers, - widgets: [ - new DeviceTabsWidget({ - devices: ['webgpu', 'webgl2'], - manager - }) - ] -}); +let deck; +let activeDevice; +let currentViewState = initialViewState; const unsubscribe = manager.subscribe(({device}) => { - if (device && deck.device !== device) { - deck.setProps({device}); + if (!device || device === activeDevice) { + return; } + + activeDevice = device; + deck?.finalize(); + manager.reparentCanvas(container, device); + deck = new Deck({ + device, + parent: container, + initialViewState: currentViewState, + onViewStateChange: ({viewState}) => { + currentViewState = viewState; + return viewState; + }, + layers: createLayers(device), + widgets: [ + new DeviceTabsWidget({ + devices: ['webgpu', 'webgl2'], + manager + }) + ] + }); }); void manager.initialize(); // When the surface is removed: unsubscribe(); -deck.finalize(); +deck?.finalize(); manager.reset(); ``` -WebGPU is preferred when available. The manager respects a previously selected backend, disables unavailable devices, and falls back to WebGL2. Switching tabs changes the device used to draw the scene; it is not only a visual indicator. +WebGPU is preferred when available. The manager respects a previously selected backend, disables +unavailable devices, and falls back to WebGL2. A backend switch must recreate `Deck` with the newly +selected device; `deck.setProps({device})` does not migrate an existing renderer, canvas, or layer +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 +outline and marker demonstrations remain WebGL2-only until upstream path rendering supports WebGPU. ## Compatibility roadmap @@ -92,7 +111,7 @@ WebGPU is preferred when available. The manager respects a previously selected b | --- | --- | --- | | 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`, `ElevationLayer`, and `DelaunayCoverLayer` | WebGL2 transform-feedback and WebGPU compute particle paths are browser-verified. Complete scene support remains in progress until upstream terrain, polygon, and path rendering is portable. | +| 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. | | 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. | @@ -121,4 +140,13 @@ 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. +To explicitly run the complete Chromium suite with software WebGPU, use: + +```sh +DECK_GL_COMMUNITY_SOFTWARE_WEBGPU=true yarn test-headless +``` + +WebGPU rendering tests wait for submitted GPU work and fail on native shader, pipeline, and +validation errors. Browser environments without an adapter continue to run the WebGL2 assertions. + Do not update a package-wide WebGPU compatibility badge until all of the package's advertised layers and integrations have been validated. diff --git a/docs/whats-new.md b/docs/whats-new.md index a93ddff7..69195532 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -14,12 +14,16 @@ Scope tracked in the [v9.4 milestone](https://github.com/visgl/deck.gl-community - `SkyboxLayer`: validated the existing native WGSL and GLSL implementation with real WebGPU/WebGL2 device selection in the skybox example. - `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. - `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. +- `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. + support for up to one million animated particles; native point rendering preserves simulation + buffer ownership and defers resource cleanup until submitted GPU work completes. - `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. @@ -34,6 +38,8 @@ Scope tracked in the [v9.4 milestone](https://github.com/visgl/deck.gl-community vertically exaggerated image-based mountain terrain. - `DelaunayCoverLayer` exposes the weather-station mesh, while `DelaunayInterpolation` provides backend-independent explicit weather sampling and rasterization. +- `WindLayer` arrows and `DelaunayCoverLayer` station terrain now use native GLSL/WGSL triangle + geometry; image-based mountain terrain remains dependent on upstream WebGPU support. - Added public weather data, field, measurement, bounds, sample, triangle, and field-options types, including robust Delaunay station triangulation for skinny station hulls. - Added a complete wind showcase guide, six reference pages with inline live examples, documented @@ -52,11 +58,13 @@ Scope tracked in the [v9.4 milestone](https://github.com/visgl/deck.gl-community - Added generic animation, block, fast-text, UTF8 Arrow string-view, view-layout, and viewport-bounds helpers for trace-style visualizations. - `BlockLayer` now provides paired WebGPU WGSL and WebGL2 GLSL shaders for instanced fills, outlines, projection, and picking. - `FastTextLayer` now renders its existing packed glyphs and generated font atlases on WebGPU and WebGL2; optimized upstream text and Arrow renderers remain planned for luma.gl v10. +- `TimeDeltaLayer` now renders interval guides and headers using portable lines and fast text. ### `@deck.gl-community/timeline-layers` - `TimeAxisLayer` now supports adaptive trace-style duration and timestamp grids plus exported tick formatting helpers. - `HorizonGraphLayer` and `MultiHorizonGraphLayer` can render their floating-point data textures on WebGPU and WebGL2; stacked horizon dividers now use the upstream dual-backend `LineLayer`. +- `VerticalGridLayer` is browser-verified on both graphics backends. ### `@deck.gl-community/trace-layers` diff --git a/examples/geo-layers/wind/app.ts b/examples/geo-layers/wind/app.ts index d83d59b8..a622f00b 100644 --- a/examples/geo-layers/wind/app.ts +++ b/examples/geo-layers/wind/app.ts @@ -8,9 +8,12 @@ import { DirectionalLight, LightingEffect, MapView, - type MapViewState + type MapViewState, + type ViewStateChangeParameters, + type Widget } from '@deck.gl/core'; import {GeoJsonLayer, ScatterplotLayer, TextLayer} from '@deck.gl/layers'; +import {FastTextLayer} from '@deck.gl-community/infovis-layers'; import { createWindField, DelaunayCoverLayer, @@ -22,6 +25,7 @@ import { type WindField, type WindStation } from '@deck.gl-community/geo-layers'; +import type {Device} from '@luma.gl/core'; import {smoothWindElevation} from './terrain-data'; @@ -94,7 +98,17 @@ const WIND_LIGHTING = new LightingEffect({ */ export type WindExampleOptions = { /** Receives the initialized deck instance when the website manages GPU devices. */ - onDeckInitialized?: (deck: Deck) => void; + onDeckInitialized?: (deck: Deck) => void; + /** Optional rendering device supplied by the website example host. */ + device?: Device; + /** Optional widgets supplied by the website example host. */ + widgets?: Widget[]; + /** Camera state preserved when the website recreates the graphics backend. */ + initialViewState?: MapViewState; + /** Reports camera changes to the website example host. */ + onViewStateChange?: ( + params: ViewStateChangeParameters + ) => ViewStateT; /** Overrides the original public wind showcase dataset location. */ dataUrl?: string; }; @@ -116,10 +130,10 @@ type WindTerrainData = { type WindExampleLayerStack = { terrain: ElevationLayer | false; stationMesh: DelaunayCoverLayer | false; - boundaries: GeoJsonLayer; + boundaries: GeoJsonLayer | false; wind: WindLayer | false; particles: ParticleLayer | false; - labels: TextLayer; + labels: TextLayer | FastTextLayer; stations: ScatterplotLayer | false; }; @@ -323,13 +337,16 @@ export function mountWindExample( const status = panel.querySelector('[data-wind-status]'); const deck = new Deck({ + device: options.device, parent: container, width: '100%', height: '100%', useDevicePixels: Math.min(window.devicePixelRatio || 1, 1.5), views: new MapView({repeat: false}), - initialViewState: INITIAL_VIEW_STATE, + initialViewState: options.initialViewState ?? INITIAL_VIEW_STATE, + onViewStateChange: options.onViewStateChange, controller: {dragRotate: true, touchRotate: true, keyboard: true, inertia: 180}, + widgets: options.widgets ?? [], effects: [WIND_LIGHTING], parameters: {depthWriteEnabled: true}, getTooltip: ({object}) => { @@ -410,9 +427,12 @@ export function mountWindExample( return; } + const isWebgpu = options.device?.type === 'webgpu'; + layerStack = { terrain: settings.showTerrain && + !isWebgpu && new ElevationLayer({ id: 'wind-height-map', elevationData: terrainData.elevationData, @@ -424,47 +444,70 @@ export function mountWindExample( texture: terrainData.texture }), stationMesh: - settings.showStationMesh && + (settings.showStationMesh || (settings.showTerrain && isWebgpu)) && new DelaunayCoverLayer({ id: 'wind-station-terrain', windField: field, elevationScale: ELEVATION_SCALE, opacity: 0.32 }), - boundaries: new GeoJsonLayer({ - id: 'wind-state-boundaries', - data: US_STATE_BOUNDARIES, - filled: false, - stroked: true, - getLineColor: [177, 188, 205, 95], - getLineWidth: 1, - lineWidthUnits: 'pixels', - lineWidthMinPixels: 0.65, - parameters: {depthCompare: 'always', depthWriteEnabled: false}, - pickable: false - }), + boundaries: + !isWebgpu && + new GeoJsonLayer({ + id: 'wind-state-boundaries', + data: US_STATE_BOUNDARIES, + filled: false, + stroked: true, + getLineColor: [177, 188, 205, 95], + getLineWidth: 1, + lineWidthUnits: 'pixels', + lineWidthMinPixels: 0.65, + parameters: {depthCompare: 'always', depthWriteEnabled: false}, + pickable: false + }), wind: createWindLayer(field), particles: createParticleLayer(field), - labels: new TextLayer({ - id: 'wind-city-labels', - data: WIND_CITIES, - getPosition: city => { - const sample = sampleWindField(field, city.position, 0); - return [ - city.position[0], - city.position[1], - (sample?.elevation ?? 0) * ELEVATION_SCALE + 2_200 - ]; - }, - getText: city => city.name, - getColor: [231, 232, 238, 215], - getSize: 12, - sizeUnits: 'pixels', - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - parameters: {depthWriteEnabled: false}, - pickable: false - }), + labels: isWebgpu + ? new FastTextLayer({ + id: 'wind-city-labels', + data: WIND_CITIES, + getPosition: city => { + const sample = sampleWindField(field, city.position, 0); + return [ + city.position[0], + city.position[1], + (sample?.elevation ?? 0) * ELEVATION_SCALE + 2_200 + ]; + }, + getText: city => city.name, + getColor: [231, 232, 238, 215], + size: 12, + sizeUnits: 'pixels', + textAnchor: 'middle', + alignmentBaseline: 'center', + parameters: {depthWriteEnabled: false}, + pickable: false + }) + : new TextLayer({ + id: 'wind-city-labels', + data: WIND_CITIES, + getPosition: city => { + const sample = sampleWindField(field, city.position, 0); + return [ + city.position[0], + city.position[1], + (sample?.elevation ?? 0) * ELEVATION_SCALE + 2_200 + ]; + }, + getText: city => city.name, + getColor: [231, 232, 238, 215], + getSize: 12, + sizeUnits: 'pixels', + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + parameters: {depthWriteEnabled: false}, + pickable: false + }), stations: settings.showStations && new ScatterplotLayer({ diff --git a/examples/geo-layers/wind/package.json b/examples/geo-layers/wind/package.json index dad42cfa..001b2ced 100644 --- a/examples/geo-layers/wind/package.json +++ b/examples/geo-layers/wind/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@deck.gl-community/geo-layers": "workspace:*", + "@deck.gl-community/infovis-layers": "workspace:*", "@deck.gl/core": "~9.4.0-alpha.2", "@deck.gl/extensions": "~9.4.0-alpha.2", "@deck.gl/geo-layers": "~9.4.0-alpha.2", diff --git a/examples/geo-layers/wind/wind-device-tabs.spec.ts b/examples/geo-layers/wind/wind-device-tabs.spec.ts index 347cfb58..aaed4094 100644 --- a/examples/geo-layers/wind/wind-device-tabs.spec.ts +++ b/examples/geo-layers/wind/wind-device-tabs.spec.ts @@ -8,7 +8,9 @@ import {mountDeviceManagedExample} from '../../../website/src/components/example const deviceTabs = vi.hoisted(() => { const webgpuDevice = {id: 'wind-webgpu', type: 'webgpu'}; - let listener: ((state: {device: typeof webgpuDevice}) => void) | undefined; + let listener: + | ((state: {device: typeof webgpuDevice | {id: string; type: 'webgl'}}) => void) + | undefined; const unsubscribe = vi.fn(); const manager = { subscribe: vi.fn((nextListener: typeof listener) => { @@ -24,6 +26,9 @@ const deviceTabs = vi.hoisted(() => { return { webgpuDevice, + selectDevice: (device: typeof webgpuDevice | {id: string; type: 'webgl'}) => { + listener?.({device}); + }, manager, unsubscribe, Manager: vi.fn(function DeviceManagerController() { @@ -47,25 +52,26 @@ describe('wind showcase graphics backend selector', () => { it('installs working WebGL and WebGPU tabs into the mounted wind deck', async () => { const container = {} as HTMLElement; - const webglDevice = {id: 'wind-webgl', type: 'webgl'}; - const deck = { - device: webglDevice as typeof webglDevice | typeof deviceTabs.webgpuDevice, - props: {parent: container, widgets: [] as unknown[]}, - setProps: vi.fn((props: {device?: typeof deviceTabs.webgpuDevice; widgets?: unknown[]}) => { - if (props.device) { - deck.device = props.device; - } - if (props.widgets) { - deck.props.widgets = props.widgets; - } - }) - }; + const webglDevice = {id: 'wind-webgl', type: 'webgl' as const}; const cleanup = vi.fn(); const mount = vi.fn( ( _container: HTMLElement, - options: {onDeckInitialized: (initializedDeck: typeof deck) => void} + options: { + device: typeof webglDevice | typeof deviceTabs.webgpuDevice; + widgets: unknown[]; + initialViewState?: {longitude: number}; + onViewStateChange: (params: {viewState: {longitude: number}}) => {longitude: number}; + onDeckInitialized: (deck: { + device: typeof webglDevice | typeof deviceTabs.webgpuDevice; + props: {parent: HTMLElement; widgets: unknown[]}; + }) => void; + } ) => { + const deck = { + device: options.device, + props: {parent: container, widgets: options.widgets} + }; options.onDeckInitialized(deck); return cleanup; } @@ -90,15 +96,26 @@ describe('wind showcase graphics backend selector', () => { placement: 'top-right' }) ); - expect(deck.props.widgets).toHaveLength(1); + expect(mount).toHaveBeenCalledOnce(); + expect(mount.mock.calls[0][1].device).toBe(deviceTabs.webgpuDevice); + expect(mount.mock.calls[0][1].widgets).toHaveLength(1); expect(deviceTabs.manager.initialize).toHaveBeenCalledOnce(); - expect(deck.device).toBe(deviceTabs.webgpuDevice); + + mount.mock.calls[0][1].onViewStateChange({viewState: {longitude: -98}}); + deviceTabs.selectDevice(webglDevice); + await Promise.resolve(); + await Promise.resolve(); + + expect(mount).toHaveBeenCalledTimes(2); + expect(mount.mock.calls[1][1].device).toBe(webglDevice); + expect(mount.mock.calls[1][1].initialViewState).toEqual({longitude: -98}); + expect(cleanup).toHaveBeenCalledOnce(); dispose(); expect(deviceTabs.unsubscribe).toHaveBeenCalledOnce(); expect(deviceTabs.manager.reset).toHaveBeenCalledOnce(); - expect(cleanup).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledTimes(2); }); it('mounts without a device manager when graphics tabs are disabled', async () => { diff --git a/examples/infovis-layers/layer-primitives/app.ts b/examples/infovis-layers/layer-primitives/app.ts index 01614016..fcacb149 100644 --- a/examples/infovis-layers/layer-primitives/app.ts +++ b/examples/infovis-layers/layer-primitives/app.ts @@ -2,14 +2,24 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {COORDINATE_SYSTEM, Deck, OrthographicView, type Color, type Position} from '@deck.gl/core'; -import {LineLayer, TextLayer} from '@deck.gl/layers'; +import { + COORDINATE_SYSTEM, + Deck, + OrthographicView, + type Color, + type OrthographicViewState, + type Position, + type ViewStateChangeParameters, + type Widget +} from '@deck.gl/core'; +import {LineLayer} from '@deck.gl/layers'; import { AnimationLayer, BlockLayer, FastTextLayer, TimeDeltaLayer } from '@deck.gl-community/infovis-layers'; +import type {Device} from '@luma.gl/core'; type InfovisLayerHighlight = 'all' | 'animation-layer' | 'block-layer' | 'time-delta-layer'; @@ -19,8 +29,18 @@ export type InfovisLayerPrimitivesExampleProps = { highlight?: InfovisLayerHighlight; /** Whether to render the title overlay. @defaultValue true */ showInfoOverlay?: boolean; - /** Called when the example's Deck instance is ready for a host to configure. */ - onDeckInitialized?: (deck: Deck) => void; + /** Optional rendering device supplied by the website example host. */ + device?: Device; + /** Optional widgets supplied by the website example host. */ + widgets?: Widget[]; + /** Camera state preserved when the website recreates the graphics backend. */ + initialViewState?: OrthographicViewState; + /** Reports camera changes to the website example host. */ + onViewStateChange?: ( + params: ViewStateChangeParameters + ) => ViewStateT; + /** Receives the deck instance so the host can attach its managed canvas. */ + onDeckInitialized?: (deck: Deck) => void; }; type TraceBlock = { @@ -41,28 +61,28 @@ const TRACE_BLOCKS: TraceBlock[] = [ */ export function mountInfovisLayerPrimitivesExample( container: HTMLElement, - { - highlight = 'all', - showInfoOverlay = true, - onDeckInitialized - }: InfovisLayerPrimitivesExampleProps = {} + options: InfovisLayerPrimitivesExampleProps = {} ): () => void { + const {highlight = 'all', showInfoOverlay = true} = options; const rootElement = createRoot(container); if (showInfoOverlay) { rootElement.appendChild(createInfoOverlay(rootElement.ownerDocument)); } const deck = new Deck({ + device: options.device, parent: rootElement, views: new OrthographicView({id: 'infovis-layer-primitives', flipY: false}), - initialViewState: { + initialViewState: options.initialViewState ?? { target: [0, 0, 0], zoom: 0 }, + onViewStateChange: options.onViewStateChange, controller: true, + widgets: options.widgets ?? [], layers: createLayers(highlight) }); - onDeckInitialized?.(deck); + options.onDeckInitialized?.(deck); return () => { deck.finalize(); @@ -90,7 +110,6 @@ function createLayers(highlight: InfovisLayerHighlight) { coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, getPosition: datum => [datum.position[0] + datum.size[0] / 2, datum.position[1] + 27], getText: datum => datum.label, - characterSet: 'auto', size: 17, getColor: [255, 255, 255, 255], textAnchor: 'middle', @@ -152,7 +171,7 @@ function createTimeDeltaLayers() { yMax: 72, color: [99, 102, 241, 210] }), - new TextLayer({ + new FastTextLayer({ id: 'time-delta-example-labels', data: [ {position: [-160, 54], label: 'selection start'}, @@ -161,10 +180,10 @@ function createTimeDeltaLayers() { coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, getPosition: datum => datum.position, getText: datum => datum.label, - getSize: 15, + size: 15, getColor: [30, 41, 59, 255], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center' + textAnchor: 'middle', + alignmentBaseline: 'center' }) ]; } diff --git a/examples/layers/skybox-map-view/app.tsx b/examples/layers/skybox-map-view/app.tsx index e4e3142f..9c901f77 100644 --- a/examples/layers/skybox-map-view/app.tsx +++ b/examples/layers/skybox-map-view/app.tsx @@ -2,9 +2,16 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {Deck, MapView} from '@deck.gl/core'; +import { + Deck, + MapView, + type MapViewState, + type ViewStateChangeParameters, + type Widget +} from '@deck.gl/core'; import {BasemapLayer} from '@deck.gl-community/basemap-layers'; import {SkyboxLayer} from '@deck.gl-community/layers'; +import type {Device} from '@luma.gl/core'; import {SKYBOX_CUBEMAP} from '../skybox-assets/cubemap'; const INITIAL_VIEW_STATE = { @@ -19,7 +26,13 @@ const MAX_PITCH = 89.9; type SkyboxMapViewExampleOptions = { showInfoOverlay?: boolean; - onDeckInitialized?: (deck: Deck) => void; + device?: Device; + widgets?: Widget[]; + initialViewState?: MapViewState; + onViewStateChange?: ( + params: ViewStateChangeParameters + ) => ViewStateT; + onDeckInitialized?: (deck: Deck) => void; }; export function mountSkyboxMapViewExample( @@ -32,26 +45,33 @@ export function mountSkyboxMapViewExample( } const deck = new Deck({ + device: options.device, parent: rootElement, views: new MapView({repeat: true, maxPitch: MAX_PITCH}), - initialViewState: INITIAL_VIEW_STATE, + initialViewState: options.initialViewState ?? INITIAL_VIEW_STATE, + onViewStateChange: options.onViewStateChange, controller: { dragRotate: true, touchRotate: true, maxPitch: MAX_PITCH }, parameters: {clearColor: [0, 0, 0, 1]}, + widgets: options.widgets ?? [], layers: [ new SkyboxLayer({ id: 'skybox', cubemap: SKYBOX_CUBEMAP, orientation: 'y-up' }), - new BasemapLayer({ - id: 'basemap', - mode: 'map', - style: 'https://basemaps.cartocdn.com/gl/voyager-nolabels-gl-style/style.json' - }) + ...(options.device?.type === 'webgpu' + ? [] + : [ + new BasemapLayer({ + id: 'basemap', + mode: 'map', + style: 'https://basemaps.cartocdn.com/gl/voyager-nolabels-gl-style/style.json' + }) + ]) ] }); options.onDeckInitialized?.(deck); 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 cdb3c242..4f0309a2 100644 --- a/modules/geo-layers/src/wind-layer/delaunay-cover-layer.ts +++ b/modules/geo-layers/src/wind-layer/delaunay-cover-layer.ts @@ -3,9 +3,8 @@ // Copyright (c) vis.gl contributors import {CompositeLayer, type Color, type DefaultProps} from '@deck.gl/core'; -import {SolidPolygonLayer} from '@deck.gl/layers'; - import type {WindField, WindStation, WindTriangle} from './wind-data'; +import {WindTriangleLayer} from './wind-triangle-layer'; /** Configuration for the work-in-progress, station-triangulated {@link DelaunayCoverLayer}. */ export type DelaunayCoverLayerProps = { @@ -96,11 +95,12 @@ export class DelaunayCoverLayer extends CompositeLayer ) ); - return new SolidPolygonLayer(this.getSubLayerProps({id: 'terrain'}), { + return new WindTriangleLayer(this.getSubLayerProps({id: 'terrain'}), { data, - getPolygon: triangle => triangle.polygon, - getFillColor: triangle => triangle.color, - material: true, + getFirstPosition: triangle => triangle.polygon[0], + getSecondPosition: triangle => triangle.polygon[1], + getThirdPosition: triangle => triangle.polygon[2], + getColor: triangle => triangle.color, pickable: false }); } diff --git a/modules/geo-layers/src/wind-layer/elevation-layer.ts b/modules/geo-layers/src/wind-layer/elevation-layer.ts index aa6baa7a..8822424f 100644 --- a/modules/geo-layers/src/wind-layer/elevation-layer.ts +++ b/modules/geo-layers/src/wind-layer/elevation-layer.ts @@ -65,7 +65,7 @@ export class ElevationLayer extends CompositeLayer { renderLayers(): TerrainLayer | null { const {elevationData, bounds, elevationRange, elevationScale, meshMaxError, color, texture} = this.props; - if (!elevationData) { + if (!elevationData || this.context?.device?.type === 'webgpu') { return null; } diff --git a/modules/geo-layers/src/wind-layer/gpu-particle-point-layer.ts b/modules/geo-layers/src/wind-layer/gpu-particle-point-layer.ts index f282b28e..d4349651 100644 --- a/modules/geo-layers/src/wind-layer/gpu-particle-point-layer.ts +++ b/modules/geo-layers/src/wind-layer/gpu-particle-point-layer.ts @@ -28,6 +28,14 @@ type GpuParticlePointUniforms = { const gpuParticlePointUniforms = { name: 'windParticlePoint', + source: /* wgsl */ ` +struct WindParticlePointUniforms { + color: vec4, + pointSize: f32, +}; + +@group(0) @binding(auto) var windParticlePoint: WindParticlePointUniforms; +`, vs: `layout(std140) uniform windParticlePointUniforms { vec4 color; float pointSize; @@ -70,6 +78,37 @@ void main() { fragColor = vec4(particleColor.rgb, particleColor.a * edge); }`; +const POINT_WEBGPU_SHADER = /* wgsl */ ` +struct WindParticlePointVaryings { + @builtin(position) position: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vertexMain(@location(0) particlePosition: vec4) -> WindParticlePointVaryings { + let fadeIn = smoothstep(0.0, 16.0, particlePosition.w); + let fadeOut = 1.0 - smoothstep(152.0, 180.0, particlePosition.w); + let projected = project_position_to_clipspace_and_commonspace( + particlePosition.xyz, + vec3(0.0), + vec3(0.0) + ); + + var varyings: WindParticlePointVaryings; + varyings.position = projected.clipPosition; + varyings.color = vec4( + windParticlePoint.color.rgb, + windParticlePoint.color.a * fadeIn * fadeOut * layer.opacity + ); + return varyings; +} + +@fragment +fn fragmentMain(varyings: WindParticlePointVaryings) -> @location(0) vec4 { + return varyings.color; +} +`; + const defaultProps: DefaultProps = { simulation: {type: 'object', value: undefined!}, color: [237, 247, 255, 46], @@ -80,7 +119,7 @@ const defaultProps: DefaultProps = { * Draws one GPU vertex per simulated particle, matching the historical WebGL showcase. * * @remarks - * This is an internal WebGL rendering primitive. Applications should construct + * This is an internal dual-backend rendering primitive. Applications should construct * {@link ParticleLayer} rather than importing this implementation detail. * * @internal @@ -93,6 +132,7 @@ export class GpuParticlePointLayer extends Layer { + this.transform?.destroy(); + this.computation?.destroy(); + this.computeUniforms?.destroy(); + for (const buffer of this.buffers) { + buffer.destroy(); + } + this.trailBuffer.destroy(); + for (const texture of this.textures) { + texture.destroy(); + } + }; + + if (this.device.type === 'webgpu') { + const nativeDevice = ( + this.device as Device & { + handle?: {queue: {onSubmittedWorkDone: () => Promise}}; + } + ).handle; + if (nativeDevice) { + // Layer replacement can happen inside the active render pass. Wait until that + // pass is submitted and complete before releasing its compute/vertex resources. + setTimeout(() => { + void nativeDevice.queue.onSubmittedWorkDone().then(destroyResources, destroyResources); + }, 0); + return; + } } + + destroyResources(); } } diff --git a/modules/geo-layers/src/wind-layer/particle-layer.ts b/modules/geo-layers/src/wind-layer/particle-layer.ts index bf61c95d..1884ed0f 100644 --- a/modules/geo-layers/src/wind-layer/particle-layer.ts +++ b/modules/geo-layers/src/wind-layer/particle-layer.ts @@ -14,8 +14,8 @@ import {sampleWindField, type WindBounds, type WindField} from './wind-data'; * Configuration for the work-in-progress, GPU-advected {@link ParticleLayer}. * * @remarks - * WebGL2 uses transform feedback and point primitives; WebGPU uses compute and portable - * point sublayers. Particle positions are not read back during animation. + * WebGL2 uses transform feedback and point primitives; WebGPU uses compute and native + * GPU-buffer-backed point primitives. Particle positions are not read back during animation. */ export type ParticleLayerProps = { /** Indexed, time-varying weather station data. */ @@ -109,35 +109,6 @@ class GpuParticleTrailLayer extends LineLayer { } } -/** GPU-computed particle heads use the same tightly packed float32 positions. */ -class GpuParticleHeadLayer extends ScatterplotLayer { - static layerName = 'GpuParticleHeadLayer'; - - initializeState(): void { - super.initializeState(); - if (this.context.device.type === 'webgl') { - this.getAttributeManager()?.addInstanced({ - windParticleAges: {size: 1, accessor: 'getParticleAge'} - }); - } - } - - getShaders() { - const shaders = super.getShaders(); - return { - ...shaders, - modules: [ - ...shaders.modules, - ...(this.context.device.type === 'webgl' ? [windParticleAgeFade] : []) - ] - }; - } - - use64bitPositions(): boolean { - return false; - } -} - const PARTICLE_FRAME_RATE = 30; const WEATHER_FRAME_DURATION_MS = 1800; @@ -233,13 +204,9 @@ export class ParticleLayer extends CompositeLayer { } /** Re-seeds on field changes and advances existing trails when animation time changes. */ - updateState({props, oldProps, changeFlags}: UpdateParameters): void { + updateState({props, oldProps}: UpdateParameters): void { if (this.state.gpu) { - if ( - changeFlags.dataChanged || - props.windField !== oldProps.windField || - props.numParticles !== oldProps.numParticles - ) { + if (props.windField !== oldProps.windField || props.numParticles !== oldProps.numParticles) { this.state.gpu.destroy(); const gpu = new GpuParticleSimulation( this.context.device, @@ -268,11 +235,7 @@ export class ParticleLayer extends CompositeLayer { return; } - if ( - changeFlags.dataChanged || - props.windField !== oldProps.windField || - props.numParticles !== oldProps.numParticles - ) { + if (props.windField !== oldProps.windField || props.numParticles !== oldProps.numParticles) { this.setState({particles: this.createParticles(), lastTime: props.time}); return; } @@ -390,41 +353,31 @@ export class ParticleLayer extends CompositeLayer { const {color, widthMinPixels, pointRadiusPixels, time} = this.props; if (this.state.gpu) { const {gpu} = this.state; - const positions = { + const isWebgl = this.context.device.type === 'webgl'; + const ages = isWebgl + ? {getParticleAge: {buffer: gpu.targetBuffer, size: 1, stride: 16, offset: 12}} + : {}; + const trailPositions = { length: gpu.particleCount, attributes: { getSourcePosition: {buffer: gpu.sourceBuffer, size: 3, stride: 16}, getTargetPosition: {buffer: gpu.targetBuffer, size: 3, stride: 16}, - getPosition: {buffer: gpu.targetBuffer, size: 3, stride: 16}, - getParticleAge: {buffer: gpu.targetBuffer, size: 1, stride: 16, offset: 12} + ...ages } }; - - const heads = - this.context.device.type === 'webgl' - ? new GpuParticlePointLayer(this.getSubLayerProps({id: 'heads'}), { - simulation: gpu, - color: [237, 247, 255, Math.min(255, (color[3] ?? 255) + 12)], - pointRadiusPixels, - pickable: false - }) - : new GpuParticleHeadLayer(this.getSubLayerProps({id: 'heads'}), { - data: positions, - getFillColor: [237, 247, 255, Math.min(255, (color[3] ?? 255) + 12)], - getRadius: pointRadiusPixels, - radiusUnits: 'pixels', - radiusMinPixels: pointRadiusPixels, - billboard: true, - parameters: {depthWriteEnabled: false}, - pickable: false - }); - if (gpu.particleCount > 250_000) { + const heads = new GpuParticlePointLayer(this.getSubLayerProps({id: 'heads'}), { + simulation: gpu, + color: [237, 247, 255, Math.min(255, (color[3] ?? 255) + 12)], + pointRadiusPixels, + pickable: false + }); + if (!isWebgl || gpu.particleCount > 250_000) { return [heads]; } return [ new GpuParticleTrailLayer(this.getSubLayerProps({id: 'trails'}), { - data: positions, + data: trailPositions, getColor: color, getWidth: 1, widthUnits: 'pixels', diff --git a/modules/geo-layers/src/wind-layer/wind-layer.ts b/modules/geo-layers/src/wind-layer/wind-layer.ts index fd512688..1ce63ed9 100644 --- a/modules/geo-layers/src/wind-layer/wind-layer.ts +++ b/modules/geo-layers/src/wind-layer/wind-layer.ts @@ -3,16 +3,17 @@ // Copyright (c) vis.gl contributors import {CompositeLayer, type Color, type DefaultProps} from '@deck.gl/core'; -import {PathLayer, SolidPolygonLayer} from '@deck.gl/layers'; +import {LineLayer} from '@deck.gl/layers'; import {sampleWindField, type WindField} from './wind-data'; +import {WindTriangleLayer} from './wind-triangle-layer'; /** * Configuration for the work-in-progress, station-interpolated {@link WindLayer}. * * @remarks - * Filled arrows currently depend on deck.gl polygon and path sublayers. Full-scene - * WebGPU compatibility therefore depends on the upstream support of those sublayers. + * Filled arrows use a native dual-backend triangle primitive, and outlines use portable + * `LineLayer` segments. Image-based terrain remains dependent on upstream WebGPU support. */ export type WindLayerProps = { /** Indexed, time-varying weather station data. */ @@ -44,6 +45,29 @@ type WindArrow = { color: Color; }; +type WindArrowTriangle = { + positions: [ + WindArrow['polygon'][number], + WindArrow['polygon'][number], + WindArrow['polygon'][number] + ]; + color: Color; +}; + +type WindArrowheadSegment = { + source: WindArrow['head'][number]; + target: WindArrow['head'][number]; + color: Color; +}; + +const ARROW_TRIANGLE_INDICES: readonly [number, number, number][] = [ + [0, 1, 6], + [1, 5, 6], + [1, 2, 5], + [2, 3, 5], + [3, 4, 5] +]; + const defaultProps: DefaultProps = { windField: {type: 'object', value: undefined!}, time: 0, @@ -71,8 +95,8 @@ function mixColor(from: Color, to: Color, factor: number): Color { * * @remarks * This API is a work in progress. Create one shared `WindField` and preserve the layer's - * `id` while advancing fractional forecast time. Polygon and path compatibility is subject - * to upstream deck.gl support on WebGPU. + * `id` while advancing fractional forecast time. Filled arrows, shafts, and arrowheads + * render on both WebGL2 and WebGPU. * * @example * ```ts @@ -191,34 +215,45 @@ export class WindLayer extends CompositeLayer { } } + const triangles: WindArrowTriangle[] = arrows.flatMap(arrow => + ARROW_TRIANGLE_INDICES.map(([first, second, third]) => ({ + positions: [arrow.polygon[first], arrow.polygon[second], arrow.polygon[third]], + color: arrow.color + })) + ); + const arrowheads: WindArrowheadSegment[] = arrows.flatMap(arrow => [ + {source: arrow.head[0], target: arrow.head[1], color: arrow.color}, + {source: arrow.head[1], target: arrow.head[2], color: arrow.color} + ]); + return [ - new SolidPolygonLayer(this.getSubLayerProps({id: 'glyphs'}), { - data: arrows, - getPolygon: arrow => arrow.polygon, - getFillColor: arrow => arrow.color, - material: {ambient: 0.75, diffuse: 0.45, shininess: 16}, + new WindTriangleLayer(this.getSubLayerProps({id: 'glyphs'}), { + data: triangles, + getFirstPosition: triangle => triangle.positions[0], + getSecondPosition: triangle => triangle.positions[1], + getThirdPosition: triangle => triangle.positions[2], + getColor: triangle => triangle.color, parameters: {depthWriteEnabled: false}, pickable: false }), - new PathLayer(this.getSubLayerProps({id: 'shafts'}), { + new LineLayer(this.getSubLayerProps({id: 'shafts'}), { data: arrows, - getPath: arrow => arrow.shaft, + getSourcePosition: arrow => arrow.shaft[0], + getTargetPosition: arrow => arrow.shaft[1], getColor: arrow => arrow.color, - getWidth: 1, + getWidth: widthMinPixels, widthUnits: 'pixels', widthMinPixels, - capRounded: true, pickable: false }), - new PathLayer(this.getSubLayerProps({id: 'arrowheads'}), { - data: arrows, - getPath: arrow => arrow.head, - getColor: arrow => arrow.color, - getWidth: 1, + new LineLayer(this.getSubLayerProps({id: 'arrowheads'}), { + data: arrowheads, + getSourcePosition: segment => segment.source, + getTargetPosition: segment => segment.target, + getColor: segment => segment.color, + getWidth: widthMinPixels, widthUnits: 'pixels', widthMinPixels, - jointRounded: true, - capRounded: true, pickable: false }) ]; diff --git a/modules/geo-layers/src/wind-layer/wind-triangle-layer.ts b/modules/geo-layers/src/wind-layer/wind-triangle-layer.ts new file mode 100644 index 00000000..7acc046b --- /dev/null +++ b/modules/geo-layers/src/wind-layer/wind-triangle-layer.ts @@ -0,0 +1,150 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {color, Layer, picking, project32} from '@deck.gl/core'; +import {Geometry, Model} from '@luma.gl/engine'; + +import source from './wind-triangle-layer.wgsl'; + +import type { + Accessor, + Color, + DefaultProps, + LayerProps, + Position, + UpdateParameters +} from '@deck.gl/core'; + +type _WindTriangleLayerProps = { + getFirstPosition?: Accessor; + getSecondPosition?: Accessor; + getThirdPosition?: Accessor; + getColor?: Accessor; +}; + +/** @internal Props for the dual-backend triangle primitive used by wind sublayers. */ +export type WindTriangleLayerProps = LayerProps & _WindTriangleLayerProps; + +const defaultProps: DefaultProps<_WindTriangleLayerProps> = { + getFirstPosition: {type: 'accessor', value: [0, 0, 0]}, + getSecondPosition: {type: 'accessor', value: [0, 0, 0]}, + getThirdPosition: {type: 'accessor', value: [0, 0, 0]}, + getColor: {type: 'accessor', value: [0, 0, 0, 255]} +}; + +const vs = /* glsl */ `\ +#version 300 es +#define SHADER_NAME wind-triangle-layer-vertex-shader + +in vec2 positions; +in vec3 instanceFirstPositions; +in vec3 instanceSecondPositions; +in vec3 instanceThirdPositions; +in vec4 instanceColors; +in vec3 instancePickingColors; + +out vec4 vColor; + +void main(void) { + vec3 position = + instanceFirstPositions * (1.0 - positions.x - positions.y) + + instanceSecondPositions * positions.x + + instanceThirdPositions * positions.y; + + geometry.worldPosition = position; + geometry.pickingColor = instancePickingColors; + gl_Position = project_position_to_clipspace( + position, + vec3(0.0), + vec3(0.0), + geometry.position + ); + DECKGL_FILTER_GL_POSITION(gl_Position, geometry); + + vColor = vec4(instanceColors.rgb, instanceColors.a * layer.opacity); + DECKGL_FILTER_COLOR(vColor, geometry); +} +`; + +const fs = /* glsl */ `\ +#version 300 es +#define SHADER_NAME wind-triangle-layer-fragment-shader + +precision highp float; + +in vec4 vColor; +out vec4 fragColor; + +void main(void) { + fragColor = vColor; + DECKGL_FILTER_COLOR(fragColor, geometry); +} +`; + +/** + * Internal dual-backend triangle primitive for wind glyphs and station terrain. + * + * @internal + */ +export class WindTriangleLayer extends Layer>> { + static override layerName = 'WindTriangleLayer'; + static override defaultProps = defaultProps; + + override state: {model?: Model} = {}; + + override getShaders() { + return super.getShaders({source, vs, fs, modules: [project32, color, picking]}); + } + + override initializeState(): void { + this.getAttributeManager()!.addInstanced({ + instanceFirstPositions: { + size: 3, + accessor: 'getFirstPosition', + transition: true + }, + instanceSecondPositions: { + size: 3, + accessor: 'getSecondPosition', + transition: true + }, + instanceThirdPositions: { + size: 3, + accessor: 'getThirdPosition', + transition: true + }, + instanceColors: { + size: 4, + type: 'unorm8', + accessor: 'getColor', + defaultValue: [0, 0, 0, 255], + transition: true + } + }); + } + + override updateState(params: UpdateParameters): void { + super.updateState(params); + if (params.changeFlags.extensionsChanged) { + this.state.model?.destroy(); + this.state.model = new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + geometry: new Geometry({ + topology: 'triangle-list', + attributes: { + positions: {size: 2, value: new Float32Array([0, 0, 1, 0, 0, 1])} + } + }), + isInstanced: true + }); + this.getAttributeManager()!.invalidateAll(); + } + } + + override draw(): void { + this.state.model?.draw(this.context.renderPass); + } +} diff --git a/modules/geo-layers/src/wind-layer/wind-triangle-layer.wgsl.ts b/modules/geo-layers/src/wind-layer/wind-triangle-layer.wgsl.ts new file mode 100644 index 00000000..9d49fdab --- /dev/null +++ b/modules/geo-layers/src/wind-layer/wind-triangle-layer.wgsl.ts @@ -0,0 +1,76 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** Native WebGPU shaders for instanced wind and station-surface triangles. */ +export default /* wgsl */ ` +struct WindTriangleAttributes { + @location(0) positions: vec2, + @location(1) instanceFirstPositions: vec3, + @location(2) instanceSecondPositions: vec3, + @location(3) instanceThirdPositions: vec3, + @location(4) instanceColors: vec4, + @location(5) instancePickingColors: vec3, +}; + +struct WindTriangleVaryings { + @builtin(position) position: vec4, + @location(0) color: vec4, + @location(1) @interpolate(flat) pickingColor: vec3, +}; + +@vertex +fn vertexMain(attributes: WindTriangleAttributes) -> WindTriangleVaryings { + let position = + attributes.instanceFirstPositions * (1.0 - attributes.positions.x - attributes.positions.y) + + attributes.instanceSecondPositions * attributes.positions.x + + attributes.instanceThirdPositions * attributes.positions.y; + + geometry.worldPosition = position; + geometry.pickingColor = attributes.instancePickingColors; + + let projected = project_position_to_clipspace_and_commonspace( + position, + vec3(0.0), + vec3(0.0) + ); + geometry.position = projected.commonPosition; + + var varyings: WindTriangleVaryings; + varyings.position = projected.clipPosition; + varyings.color = vec4( + attributes.instanceColors.rgb, + attributes.instanceColors.a * layer.opacity + ); + varyings.pickingColor = attributes.instancePickingColors; + return varyings; +} + +@fragment +fn fragmentMain(varyings: WindTriangleVaryings) -> @location(0) vec4 { + if (picking.isActive > 0.5) { + if (!picking_isColorValid(varyings.pickingColor)) { + discard; + } + return vec4(picking_normalizeColor(varyings.pickingColor), 1.0); + } + + var fragColor = varyings.color; + if (picking.isHighlightActive > 0.5) { + let highlightedColor = picking_normalizeColor(picking.highlightedObjectColor); + let objectColor = picking_normalizeColor(varyings.pickingColor); + if (picking_isColorZero(abs(objectColor - highlightedColor))) { + let highlightAlpha = picking.highlightColor.a; + let blendedAlpha = highlightAlpha + fragColor.a * (1.0 - highlightAlpha); + if (blendedAlpha > 0.0) { + fragColor = vec4( + mix(fragColor.rgb, picking.highlightColor.rgb, highlightAlpha / blendedAlpha), + blendedAlpha + ); + } + } + } + + return deckgl_premultiplied_alpha(fragColor); +} +`; 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 0da4c3d5..6f9a8052 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 @@ -20,6 +20,12 @@ import { } 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 STATIONS: WindStation[] = [ {name: 'southwest', long: 100, lat: 35, elv: 120}, @@ -61,19 +67,19 @@ function createLayers( elevationScale: 2 }); - if (deviceType === 'webgpu') { - return [particles]; - } - return [ - new ElevationLayer({ - id: 'test-wind-height-map', - elevationData, - bounds: [-100, 35, -96, 39], - elevationRange: [0, 255], - elevationScale: 2, - meshMaxError: 4 - }), + ...(deviceType === 'webgpu' + ? [] + : [ + new ElevationLayer({ + id: 'test-wind-height-map', + elevationData, + bounds: [-100, 35, -96, 39], + elevationRange: [0, 255], + elevationScale: 2, + meshMaxError: 4 + }) + ]), new DelaunayCoverLayer({id: 'test-wind-terrain', windField: field, elevationScale: 2}), new WindLayer({ id: 'test-wind-arrows', @@ -125,6 +131,11 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise { let device: Device | undefined; let deck: Deck | undefined; let particleLayer: ParticleLayer | undefined; + let nativeDevice: NativeGpuDevice | undefined; + const validationErrors: string[] = []; + const captureValidationError = (event: NativeGpuError): void => { + validationErrors.push(event.error?.message ?? 'Unknown WebGPU validation error.'); + }; const elevationData = createElevationData(); try { @@ -133,6 +144,14 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise { adapters: [webgl2Adapter, webgpuAdapter], createCanvasContext: {container: parent} }); + if (type === 'webgpu') { + 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(() => { @@ -166,7 +185,9 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise { }); }); - expect(deck.device?.type).toBe(type); + await nativeDevice?.queue.onSubmittedWorkDone(); + expect(device.type).toBe(type); + expect(validationErrors).toEqual([]); expect(particleLayer?.state.gpu).toBeInstanceOf(GpuParticleSimulation); expect(particleLayer?.state.gpu?.particleCount).toBe(4096); expect(particleLayer?.state.particles).toHaveLength(0); @@ -194,6 +215,8 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise { } } } finally { + nativeDevice?.removeEventListener('uncapturederror', captureValidationError); + nativeDevice?.removeEventListener('uncapturederror', captureValidationError); deck?.finalize(); device?.destroy(); parent.remove(); @@ -205,7 +228,9 @@ describe('wind showcase rendering', () => { await renderWindLayers('webgl'); }, 20_000); - it('computes and animates GPU-resident wind particles on WebGPU', async ({skip}) => { + it('renders portable wind arrows, station terrain, and GPU-resident particles 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.'); diff --git a/modules/geo-layers/test/wind-layer/wind-layers.spec.ts b/modules/geo-layers/test/wind-layer/wind-layers.spec.ts index 6be01add..0b79b650 100644 --- a/modules/geo-layers/test/wind-layer/wind-layers.spec.ts +++ b/modules/geo-layers/test/wind-layer/wind-layers.spec.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {LineLayer, PathLayer, ScatterplotLayer, SolidPolygonLayer} from '@deck.gl/layers'; +import {LineLayer, ScatterplotLayer} from '@deck.gl/layers'; import {TerrainLayer} from '@deck.gl/geo-layers'; import {describe, expect, it, vi} from 'vitest'; @@ -14,6 +14,7 @@ import { WindLayer, type WindStation } from '../../src'; +import {WindTriangleLayer} from '../../src/wind-layer/wind-triangle-layer'; const STATIONS: WindStation[] = [ {name: 'southwest', long: 2, lat: 0, elv: 10}, @@ -49,20 +50,20 @@ describe('reusable wind showcase layers', () => { layer.getSubLayerProps = vi.fn(props => props); const [glyphs, shafts, heads] = layer.renderLayers() as [ - SolidPolygonLayer, - PathLayer, - PathLayer + WindTriangleLayer, + LineLayer, + LineLayer ]; - expect(glyphs).toBeInstanceOf(SolidPolygonLayer); + expect(glyphs).toBeInstanceOf(WindTriangleLayer); expect(glyphs.props.id).toBe('glyphs'); - expect(shafts).toBeInstanceOf(PathLayer); - expect(heads).toBeInstanceOf(PathLayer); + expect(shafts).toBeInstanceOf(LineLayer); + expect(heads).toBeInstanceOf(LineLayer); expect(shafts.props.id).toBe('shafts'); expect(heads.props.id).toBe('arrowheads'); expect(shafts.props.data).not.toHaveLength(0); - expect(glyphs.props.data).toHaveLength((shafts.props.data as unknown[]).length); - expect(heads.props.data).toHaveLength((shafts.props.data as unknown[]).length); + expect(glyphs.props.data).toHaveLength((shafts.props.data as unknown[]).length * 5); + expect(heads.props.data).toHaveLength((shafts.props.data as unknown[]).length * 2); }); it('decodes the original grayscale elevation image as a real exaggerated terrain mesh', () => { @@ -98,14 +99,25 @@ describe('reusable wind showcase layers', () => { }); layer.getSubLayerProps = vi.fn(props => props); - const terrain = layer.renderLayers() as SolidPolygonLayer; + const terrain = layer.renderLayers() as WindTriangleLayer; - expect(terrain).toBeInstanceOf(SolidPolygonLayer); + expect(terrain).toBeInstanceOf(WindTriangleLayer); expect(terrain.props.id).toBe('terrain'); expect(terrain.props.data).toHaveLength(FIELD.triangles.length); expect((terrain.props.data as {polygon: number[][]}[])[0].polygon[0][2]).toBeGreaterThan(0); }); + it('does not instantiate upstream WebGL-only height-map terrain on WebGPU', () => { + const layer = new ElevationLayer({ + id: 'webgpu-elevation-test', + elevationData: 'https://example.com/elevation.png', + bounds: [-125, 24.4, -66.7, 49.6] + }); + Object.defineProperty(layer, 'context', {value: {device: {type: 'webgpu'}}}); + + expect(layer.renderLayers()).toBeNull(); + }); + it('advects particles a visible, elapsed-time-scaled distance through the wind field', () => { const createAdvancedParticle = (time: number) => { const layer = new ParticleLayer({ diff --git a/modules/infovis-layers/src/layers/time-delta-layer.ts b/modules/infovis-layers/src/layers/time-delta-layer.ts index 734ce9b9..3f22bc79 100644 --- a/modules/infovis-layers/src/layers/time-delta-layer.ts +++ b/modules/infovis-layers/src/layers/time-delta-layer.ts @@ -8,6 +8,7 @@ import {LineLayer, TextLayer, type TextLayerProps} from '@deck.gl/layers'; // import {PathStyleExtension} from '@deck.gl/extensions'; import {formatTimeMs} from '../utils/format-utils'; +import {FastTextLayer} from './fast-text-layer/fast-text-layer'; /** Properties supported by {@link TimeDeltaLayer}. */ export type TimeDeltaLayerProps = LayerProps & _TimeDeltaLayerProps; @@ -92,6 +93,7 @@ export class TimeDeltaLayer extends CompositeLayer d.position, - getText: d => d.text, - characterSet: '-0123456789.dhmsยต', - getSize: fontSize, - fontFamily, - fontSettings, - fontWeight, - getColor: color, - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - background: true, - getBackgroundColor: [255 - color[0], 255 - color[1], 255 - color[2], 255], - backgroundPadding: [4, 2] // Horizontal and vertical padding - }) + this.context?.device?.type === 'webgpu' + ? new FastTextLayer({ + id: 'header-time-delta-label', + data: labelData, + getPosition: d => d.position, + getText: d => d.text, + characterSet: '-0123456789.dhmsยต', + size: fontSize, + fontFamily, + fontSettings, + fontWeight, + getColor: color, + textAnchor: 'middle', + alignmentBaseline: 'center' + }) + : new TextLayer({ + id: 'header-time-delta-label', + data: labelData, + getPosition: d => d.position, + getText: d => d.text, + characterSet: '-0123456789.dhmsยต', + getSize: fontSize, + fontFamily, + fontSettings, + fontWeight, + getColor: color, + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + background: true, + getBackgroundColor: [255 - color[0], 255 - color[1], 255 - color[2], 255], + backgroundPadding: [4, 2] + }) ]; } } diff --git a/modules/infovis-layers/test/fast-text-layer.browser.spec.ts b/modules/infovis-layers/test/fast-text-layer.browser.spec.ts index 9a236be5..8e1ee775 100644 --- a/modules/infovis-layers/test/fast-text-layer.browser.spec.ts +++ b/modules/infovis-layers/test/fast-text-layer.browser.spec.ts @@ -10,8 +10,12 @@ import {describe, expect, it} from 'vitest'; import {FastTextLayer} from '../src'; -type BrowserGpu = { - requestAdapter: () => Promise; +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}; }; async function renderFastText(type: 'webgl' | 'webgpu', sdf: boolean): Promise { @@ -21,7 +25,12 @@ async function renderFastText(type: 'webgl' | 'webgpu', sdf: boolean): Promise | 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({ @@ -29,6 +38,10 @@ async function renderFastText(type: 'webgl' | 'webgpu', sdf: boolean): Promise 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}; +}; function createPortableLayers() { return [ @@ -59,6 +69,24 @@ function createPortableLayers() { getSize: [10, 6], pickable: true }), + new TimeDeltaLayer({ + id: 'webgpu-test-time-delta', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + header: true, + startTimeMs: -15, + endTimeMs: 15, + y: -25, + color: [15, 23, 42, 255] + }), + new VerticalGridLayer({ + id: 'webgpu-test-vertical-grid', + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + xMin: -30, + xMax: 30, + yMin: -30, + yMax: 30, + tickCount: 4 + }), new HorizonGraphLayer({ id: 'webgpu-test-horizon', coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, @@ -96,7 +124,12 @@ async function renderPortableLayers(type: 'webgl' | 'webgpu'): Promise { document.body.append(parent); let device: Device | undefined; - let deck: Deck | 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({ @@ -104,6 +137,10 @@ async function renderPortableLayers(type: 'webgl' | 'webgpu'): Promise { 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(() => { @@ -129,8 +166,11 @@ async function renderPortableLayers(type: 'webgl' | 'webgpu'): Promise { }); }); - expect(deck.device?.type).toBe(type); + await nativeDevice?.queue.onSubmittedWorkDone(); + expect(device.type).toBe(type); + expect(validationErrors).toEqual([]); } finally { + nativeDevice?.removeEventListener('uncapturederror', captureValidationError); deck?.finalize(); device?.destroy(); parent.remove(); @@ -138,11 +178,11 @@ async function renderPortableLayers(type: 'webgl' | 'webgpu'): Promise { } describe('community graphics backend compatibility', () => { - it('renders skybox, blocks, fast text, dependency markers, and horizon textures on WebGL2', async () => { + it('renders skybox, blocks, text, dependency markers, and horizon textures on WebGL2', async () => { await renderPortableLayers('webgl'); }, 20_000); - it('renders skybox, blocks, fast text, dependency markers, and horizon textures on WebGPU', async ({ + it('renders skybox, blocks, text, dependency markers, and horizon textures 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 93bc2f0e..b650f93e 100644 --- a/modules/layers/test/webgpu-shaders.spec.ts +++ b/modules/layers/test/webgpu-shaders.spec.ts @@ -10,6 +10,7 @@ import blockSource from '../../infovis-layers/src/layers/block-layer/block-layer import {blockUniforms} from '../../infovis-layers/src/layers/block-layer/block-layer-uniforms'; import fastTextSource from '../../infovis-layers/src/layers/fast-text-layer/fast-text-layer.wgsl'; import {fastTextUniforms} from '../../infovis-layers/src/layers/fast-text-layer/fast-text-layer'; +import windTriangleSource from '../../geo-layers/src/wind-layer/wind-triangle-layer.wgsl'; import horizonSource from '../../../dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer.wgsl'; import {horizonLayerUniforms} from '../../../dev/timeline-layers/src/layers/horizon-graph-layer/horizon-graph-layer-uniforms'; import geometrySource from '../src/dependency-arrow-layer/geometry-layer.wgsl'; @@ -78,6 +79,24 @@ describe('community WebGPU shaders', () => { expect(shader.source).not.toContain('@binding(auto)'); }); + it('assembles packed glyph projection, atlas sampling, clipping, and SDF text', () => { + const shader = assembleWebgpuShader(fastTextSource, [project32, color, fastTextUniforms]); + + expect(shader.source).toContain('fast_text_clip_glyph_vertex'); + expect(shader.source).toContain('textureSample'); + expect(shader.source).toContain('fastText.sdfEnabled'); + expect(shader.source).not.toContain('@binding(auto)'); + }); + + it('assembles native wind-triangle projection, colors, and picking', () => { + const shader = assembleWebgpuShader(windTriangleSource, [project32, color, picking]); + + expect(shader.source).toContain('WindTriangleAttributes'); + expect(shader.source).toContain('project_position_to_clipspace_and_commonspace'); + expect(shader.source).toContain('picking_normalizeColor'); + expect(shader.source).not.toContain('@binding(auto)'); + }); + it('assembles horizon floating-point texture and uniform bindings', () => { const shader = assembleWebgpuShader(horizonSource, [project32, horizonLayerUniforms]); diff --git a/modules/widgets/src/lib/device-manager/device-manager.test.tsx b/modules/widgets/src/lib/device-manager/device-manager.test.tsx index cf674167..9a5f3942 100644 --- a/modules/widgets/src/lib/device-manager/device-manager.test.tsx +++ b/modules/widgets/src/lib/device-manager/device-manager.test.tsx @@ -24,6 +24,7 @@ function createMockDevice(label: string): Device { canvas.dataset.deviceLabel = label; return { + destroy: vi.fn(), getDefaultCanvasContext: () => ({ canvas }) @@ -175,6 +176,42 @@ describe('DeviceManagerController', () => { expect(document.body.querySelector('[data-device-manager-canvas-parent]')).toBeNull(); }); + it('destroys every cached backend when the manager is reset', async () => { + const webgpuDevice = createMockDevice('webgpu'); + const webglDevice = createMockDevice('webgl'); + createDeviceMock.mockImplementation(({type}: {type: 'webgl' | 'webgpu'}) => + Promise.resolve(type === 'webgpu' ? webgpuDevice : webglDevice) + ); + + await manager.createDevice('webgpu'); + await manager.createDevice('webgl'); + manager.reset(); + await Promise.resolve(); + + expect(webgpuDevice.destroy).toHaveBeenCalledOnce(); + expect(webglDevice.destroy).toHaveBeenCalledOnce(); + }); + + it('destroys devices that finish creation after the manager is reset', async () => { + const webgpuDevice = createMockDevice('webgpu'); + let finishCreation: ((device: Device) => void) | undefined; + createDeviceMock.mockImplementation( + () => + new Promise(resolve => { + finishCreation = resolve; + }) + ); + + const pendingDevice = manager.createDevice('webgpu'); + manager.reset(); + finishCreation?.(webgpuDevice); + await pendingDevice; + await Promise.resolve(); + + expect(webgpuDevice.destroy).toHaveBeenCalledOnce(); + expect(manager.getState().device).toBeUndefined(); + }); + it('stops notifying a listener after it unsubscribes', async () => { createDeviceMock.mockResolvedValue(createMockDevice('webgpu')); const listener = vi.fn(); diff --git a/modules/widgets/src/lib/device-manager/device-manager.ts b/modules/widgets/src/lib/device-manager/device-manager.ts index 12600e2e..46eb7f93 100644 --- a/modules/widgets/src/lib/device-manager/device-manager.ts +++ b/modules/widgets/src/lib/device-manager/device-manager.ts @@ -282,11 +282,13 @@ export class DeviceManagerController { } /** - * Clears cached devices, DOM state, and subscriptions. + * Destroys cached devices and clears the managed device and canvas state. * * Primarily intended for tests and controlled teardown. */ reset(): void { + const cachedDevices = Object.values(this.#cachedDevices); + this.#requestGeneration++; this.#state = { deviceType: undefined, device: undefined, @@ -295,10 +297,12 @@ export class DeviceManagerController { }; this.#cachedDevices = {}; this.#cachedDeviceAvailability = {}; - this.#requestGeneration = 0; this.#canvasParent = null; this.#hiddenCanvasParent?.remove(); this.#hiddenCanvasParent = null; + for (const devicePromise of cachedDevices) { + void devicePromise.then(device => device.destroy()).catch(() => {}); + } this.#emitState(); } diff --git a/website/src/components/example/mount-device-managed-example.js b/website/src/components/example/mount-device-managed-example.js index fc0d5ee5..0af2b87e 100644 --- a/website/src/components/example/mount-device-managed-example.js +++ b/website/src/components/example/mount-device-managed-example.js @@ -10,7 +10,12 @@ export async function mountDeviceManagedExample(container, mount, mountProps = { const {DeviceManagerController, DeviceTabsWidget} = await import('@deck.gl-community/widgets'); const manager = new DeviceManagerController(); - let deck; + let cleanup; + let activeDevice; + let currentViewState = mountProps.initialViewState; + let disposed = false; + let mountGeneration = 0; + let mountQueue = Promise.resolve(); const widgetOptions = typeof deviceTabs === 'object' ? deviceTabs : {}; const deviceTabsWidget = new DeviceTabsWidget({ id: `${mountLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-device-tabs`, @@ -20,35 +25,60 @@ export async function mountDeviceManagedExample(container, mount, mountProps = { }); const unsubscribe = manager.subscribe(({device}) => { - if (deck && device && deck.device !== device) { - deck.setProps({device}); + if (!device || device === activeDevice || disposed) { + return; } - }); - try { - const cleanup = await mount(container, { - ...mountProps, - onDeckInitialized(initializedDeck) { - deck = initializedDeck; - manager.reparentCanvas(deck.props.parent ?? container); - const existingWidgets = deck.props.widgets ?? []; - if (!existingWidgets.includes(deviceTabsWidget)) { - deck.setProps({widgets: [...existingWidgets, deviceTabsWidget]}); + activeDevice = device; + const generation = ++mountGeneration; + mountQueue = mountQueue.then(async () => { + if (disposed || generation !== mountGeneration) { + return; + } + + cleanup?.(); + cleanup = undefined; + manager.reparentCanvas(container, device); + + const nextCleanup = await mount(container, { + ...mountProps, + device, + initialViewState: currentViewState, + widgets: [...(mountProps.widgets ?? []), deviceTabsWidget], + onViewStateChange(params) { + currentViewState = params.viewState; + return mountProps.onViewStateChange?.(params) ?? params.viewState; + }, + onDeckInitialized(deck) { + manager.reparentCanvas(deck.props.parent ?? container, device); + mountProps.onDeckInitialized?.(deck); } - mountProps.onDeckInitialized?.(deck); - void manager.initialize(); + }); + + if (disposed || generation !== mountGeneration) { + nextCleanup?.(); + return; } + cleanup = nextCleanup; }); + }); + + try { + await manager.initialize(); + await mountQueue; return () => { + disposed = true; + mountGeneration++; unsubscribe(); cleanup?.(); - deck = undefined; manager.reset(); }; } catch (error) { + disposed = true; + mountGeneration++; unsubscribe(); - deck = undefined; + cleanup?.(); manager.reset(); throw error; } diff --git a/yarn.lock b/yarn.lock index e39499d3..88ed61d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9583,6 +9583,7 @@ __metadata: version: 0.0.0-use.local resolution: "horizon-graph-layer-289f4b@workspace:dev/timeline-layers/examples/horizon-graph-layer" dependencies: + "@deck.gl-community/infovis-layers": "workspace:*" "@deck.gl-community/panels": "workspace:*" "@deck.gl-community/timeline-layers": "workspace:*" "@deck.gl-community/widgets": "workspace:*" @@ -17162,6 +17163,7 @@ __metadata: resolution: "wind-layer-example@workspace:examples/geo-layers/wind" dependencies: "@deck.gl-community/geo-layers": "workspace:*" + "@deck.gl-community/infovis-layers": "workspace:*" "@deck.gl/core": "npm:~9.4.0-alpha.2" "@deck.gl/extensions": "npm:~9.4.0-alpha.2" "@deck.gl/geo-layers": "npm:~9.4.0-alpha.2"