diff --git a/docs/modules/geo-layers/README.md b/docs/modules/geo-layers/README.md
index 6725bc5b..63d8e3a0 100644
--- a/docs/modules/geo-layers/README.md
+++ b/docs/modules/geo-layers/README.md
@@ -1,13 +1,14 @@
# Overview
-
-
-
+
+
This module provides a suite of geospatial layers for [deck.gl](https://deck.gl).
:::caution
-The deck.gl-community repository is semi-maintaned. One of its goals is to collect and preserve valuable deck.gl ecosystem related code that does not have a dedicated home. Some modules may no longer have dedicated maintainers. This means that there is sometimes no one who can respond quickly to issues.
+The deck.gl-community repository is semi-maintained. One of its goals is to collect and preserve
+valuable deck.gl ecosystem code that does not have a dedicated home. Some modules may no longer
+have dedicated maintainers, so responses to issues are not guaranteed.
:::
## Installation
@@ -18,10 +19,50 @@ npm install @deck.gl-community/geo-layers
## Background
-This modules exports various geospatial deck.gl layers developed by the community that could be of use to others.
+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 showcase guide](./developer-guide/wind-showcase.md) recreates Nicolas Belmonte's
+[original deck.gl wind showcase](https://github.com/visgl/deck.gl/tree/master/showcases/wind/src)
+using the original 72-hour forecast, smoothed three-dimensional mountain terrain, directional
+arrows, and up to one million GPU-animated particles.
+
+```ts
+import {
+ createWindField,
+ parseWindData,
+ ParticleLayer,
+ WindLayer
+} from '@deck.gl-community/geo-layers';
+
+const frames = parseWindData(weatherBuffer, stations.length);
+const windField = createWindField(stations, frames);
+
+const layers = [
+ new WindLayer({id: 'wind-arrows', windField, time}),
+ new ParticleLayer({
+ id: 'wind-particles',
+ windField,
+ time,
+ numParticles: 100_000
+ })
+];
+```
+
+Create and share the wind field once; advance `time` without rebuilding the station triangulation,
+weather textures, or GPU particle buffers.
## API Reference
+- [Wind field and weather data](./api-reference/wind-field.md) documents the original binary
+ forecast, positive-west station coordinates, robust triangulation, and geographic interpolation.
- [WindLayer](/docs/modules/geo-layers/api-reference/wind-layer) renders Delaunay-interpolated,
speed-colored wind arrows.
- [ParticleLayer](/docs/modules/geo-layers/api-reference/particle-layer) animates fading trails
@@ -31,7 +72,7 @@ This modules exports various geospatial deck.gl layers developed by the communit
- [DelaunayCoverLayer](/docs/modules/geo-layers/api-reference/delaunay-cover-layer) renders the
elevation-colored station triangulation.
- [DelaunayInterpolation](/docs/modules/geo-layers/api-reference/delaunay-interpolation) samples
- or rasterizes time-varying station measurements without WebGL-only transforms.
+ or explicitly rasterizes time-varying station measurements independently of the graphics backend.
- [SharedTile2DLayer](/docs/modules/geo-layers/api-reference/shared-tile-2d-layer)
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 58da8812..17ab72ab 100644
--- a/docs/modules/geo-layers/api-reference/delaunay-cover-layer.md
+++ b/docs/modules/geo-layers/api-reference/delaunay-cover-layer.md
@@ -1,29 +1,49 @@
+import LayerLiveExample from '@site/src/components/docs/layer-live-example';
+
# DelaunayCoverLayer
-`DelaunayCoverLayer` renders a terrain surface from the same weather-station triangulation used to
-interpolate a `WindField`. Each triangle follows measured station elevations and is colored by its
-average height. It recreates the elevation surface in the original deck.gl wind showcase.
+:::caution Work in progress
+The station-surface appearance and API may change. Its `SolidPolygonLayer` sublayer is not yet
+fully portable to WebGPU.
+:::
+
+`DelaunayCoverLayer` renders one elevation-colored polygon for each triangle in a shared
+[`WindField`](./wind-field.md). It visualizes the real station interpolation hull; it is not the
+same as the smoothed image-based mountain surface rendered by
+[`ElevationLayer`](./elevation-layer.md).
+
+
+
+## Import
```ts
import {DelaunayCoverLayer} from '@deck.gl-community/geo-layers';
+```
+
+## Example
-const layer = new DelaunayCoverLayer({
- id: 'wind-terrain',
+```ts
+const stationSurface = new DelaunayCoverLayer({
+ id: 'wind-station-surface',
windField,
- elevationScale: 14,
+ elevationScale: 24,
lowColor: [17, 34, 49, 220],
- highColor: [102, 151, 127, 235]
+ highColor: [102, 151, 127, 235],
+ opacity: 0.32
});
```
## Properties
-- `windField`: A `WindField` returned by `createWindField`.
-- `elevationScale`: Multiplier for each station elevation.
-- `lowColor`, `highColor`: RGBA colors interpolated by average triangle elevation.
+- `windField` (`WindField`, required): shared robust station triangulation and forecast.
+- `elevationScale` (`number`, default `1`): station-height multiplier.
+- `lowColor` (`Color`, default `[17, 34, 49, 220]`): fill color for the lowest stations.
+- `highColor` (`Color`, default `[102, 151, 127, 235]`): fill color for the highest stations.
+- Standard deck.gl `CompositeLayer` properties, including `opacity` and `visible`.
## Sub-layers
-- `terrain`: A `SolidPolygonLayer` containing the station Delaunay triangles.
+- `terrain`: a `SolidPolygonLayer` containing the station-index Delaunay triangles.
-See the [Wind Map example](/examples/geo-layers/wind).
+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/delaunay-interpolation.md b/docs/modules/geo-layers/api-reference/delaunay-interpolation.md
index 3c635af2..88e3ec3f 100644
--- a/docs/modules/geo-layers/api-reference/delaunay-interpolation.md
+++ b/docs/modules/geo-layers/api-reference/delaunay-interpolation.md
@@ -1,8 +1,19 @@
+import LayerLiveExample from '@site/src/components/docs/layer-live-example';
+
# DelaunayInterpolation
-`DelaunayInterpolation` samples or rasterizes the station-interpolated vector field used by the
-wind showcase layers. It replaces the original showcase's off-screen WebGL transform with a
-backend-independent implementation suitable for WebGL, WebGPU, preprocessing, and Node.js.
+:::caution Work in progress
+The wind raster layout and interpolation options may evolve. This utility performs explicit CPU
+sampling; GPU particle advection is implemented separately by
+[`ParticleLayer`](./particle-layer.md).
+:::
+
+`DelaunayInterpolation` samples or rasterizes a station-interpolated geographic wind field. It is
+usable in the browser, in Node.js, and as an input-preprocessing step for either graphics backend.
+
+
+
+## Import
```ts
import {
@@ -10,31 +21,61 @@ import {
DelaunayInterpolation,
parseWindData
} from '@deck.gl-community/geo-layers';
+```
-const stations = await fetch('/stations.json').then(response => response.json());
-const weather = await fetch('/weather.bin').then(response => response.arrayBuffer());
+## Sample a geographic location
+
+```ts
+const stations = await fetch('/wind/stations.json').then(response => response.json());
+const weather = await fetch('/wind/weather.bin').then(response => response.arrayBuffer());
const field = createWindField(stations, parseWindData(weather, stations.length));
-const interpolation = new DelaunayInterpolation({field, width: 256});
+const interpolation = new DelaunayInterpolation({
+ field,
+ width: 128,
+ height: 64
+});
+
const sample = interpolation.sample([-97, 38], 12.5);
+
+if (sample) {
+ console.log(sample.direction, sample.speed, sample.temperature);
+ console.log(sample.velocity, sample.elevation);
+}
+```
+
+Positions outside the triangulated station hull return `null`. Forecast time interpolates between
+adjacent frames and wraps at the end of the forecast.
+
+## Rasterize a forecast frame
+
+```ts
const raster = interpolation.rasterize(12.5);
+const center = (Math.floor(raster.height / 2) * raster.width + Math.floor(raster.width / 2)) * 4;
+
+const direction = raster.data[center];
+const speed = raster.data[center + 1];
+const temperature = raster.data[center + 2];
+const elevation = raster.data[center + 3];
```
-`sample` returns direction in radians, wind speed, temperature, elevation, and an east/north
-velocity vector. Points outside the triangulated station coverage return `null`.
+`rasterize` returns [`WindRaster`](./wind-field.md): `{width, height, data}`. Its `Float32Array`
+contains row-major `[direction, speed, temperature, elevation]` pixels. Uncovered pixels are zero.
+
+## Constructor properties
+
+- `field` (`WindField`, required): shared indexed station forecast.
+- `width` (`number`, default `256`): raster width, at least `2` pixels.
+- `height` (`number`, optional): raster height, at least `2` pixels; inferred from the geographic
+ aspect ratio when omitted.
+
+## Methods
-`rasterize` returns `{width, height, data}`, where `data` is a `Float32Array` containing direction,
-speed, temperature, and elevation in RGBA component order.
+- `sample(position, time?)`: returns a [`WindSample`](./wind-field.md) or `null`.
+- `rasterize(time?)`: returns one lazily generated [`WindRaster`](./wind-field.md).
-## Related utilities
+Do not call `rasterize` for each animated particle or browser frame. `ParticleLayer` caches the
+weather textures and performs per-particle movement entirely on the graphics device.
-- `parseWindData(buffer, stationCount, frameCount?)`: Decodes the original station-major binary
- forecast. The default forecast length is 72 hourly frames.
-- `getWindBounds(stations)`: Converts positive-west legacy station longitudes into geographic
- bounds.
-- `triangulateWindStations(stations)`: Builds a Delaunay triangulation without external
- triangulation dependencies.
-- `createWindField(stations, frames, options?)`: Creates a spatially indexed, time-varying wind
- field shared by all three layers.
-- `sampleWindField(field, position, time?)`: Samples a field without constructing an interpolation
- wrapper.
+See the [wind showcase guide](../developer-guide/wind-showcase.md) and the
+[Wind Map example](/examples/geo-layers/wind).
diff --git a/docs/modules/geo-layers/api-reference/elevation-layer.md b/docs/modules/geo-layers/api-reference/elevation-layer.md
index 4c4e8764..dfb2080d 100644
--- a/docs/modules/geo-layers/api-reference/elevation-layer.md
+++ b/docs/modules/geo-layers/api-reference/elevation-layer.md
@@ -1,39 +1,57 @@
+import LayerLiveExample from '@site/src/components/docs/layer-live-example';
+
# ElevationLayer
-`ElevationLayer` renders a grayscale elevation image as a genuinely three-dimensional, illuminated
-terrain mesh. It ports the height-map surface used by Nicolas Belmonte's original wind showcase,
-including the original elevation image, geographic bounds, and elevation range.
+:::caution Work in progress
+The terrain API, height-map smoothing, material, and exaggeration are experimental. WebGPU
+compatibility depends on upstream `TerrainLayer` and loaders.gl support.
+:::
+
+`ElevationLayer` decodes a grayscale height map into illuminated, extruded mountain geometry.
+Unlike [`DelaunayCoverLayer`](./delaunay-cover-layer.md), its mesh follows the original elevation
+image rather than the sparse weather-station triangles.
+
+
+
+## Import
```ts
import {ElevationLayer} from '@deck.gl-community/geo-layers';
+```
+
+## Example
-const layer = new ElevationLayer({
- id: 'wind-elevation',
+```ts
+const terrain = new ElevationLayer({
+ id: 'wind-mountain-terrain',
elevationData:
'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/wind/elevation.png',
bounds: [-125, 24.4, -66.7, 49.6],
elevationRange: [-100, 4126],
- elevationScale: 80,
- meshMaxError: 480,
- color: [42, 60, 77, 255]
+ elevationScale: 24,
+ meshMaxError: 12,
+ color: [35, 49, 64, 255]
});
```
-The layer decodes the original height map into terrain geometry using the in-process loaders.gl
-terrain loader. It does not require an externally hosted terrain worker.
+For smoother relief, filter the grayscale image once before passing it as `elevationData`. Do not
+recreate or decode the terrain mesh during particle animation.
## Properties
-- `elevationData`: URL of the red-channel grayscale terrain height map.
-- `bounds`: Geographic `[west, south, east, north]` extent.
-- `elevationRange`: Minimum and maximum image elevations, in meters.
-- `elevationScale`: Vertical exaggeration applied to the decoded mesh.
-- `meshMaxError`: Simplification error tolerance for the generated terrain mesh.
-- `color`: RGBA color applied to the lit terrain surface.
-- `texture`: Optional image draped over the three-dimensional mesh.
+- `elevationData` (`string`, required): URL or data URL of a red-channel grayscale height map.
+- `bounds` (`[number, number, number, number]`, required): geographic west, south, east, and north.
+- `elevationRange` (`[number, number]`, default `[-100, 4126]`): source elevation range in meters.
+- `elevationScale` (`number`, default `1`): vertical exaggeration of the decoded mesh.
+- `meshMaxError` (`number`, default `80`): terrain simplification error; use approximately `12`
+ for the smoothed wind showcase.
+- `color` (`Color`, default `[42, 58, 72, 255]`): shaded mountain-surface color.
+- `texture` (`string`, optional): image draped over the decoded terrain.
## Sub-layers
-- `terrain-mesh`: A `TerrainLayer` containing the decoded elevation mesh.
+- `terrain-mesh`: a loaders.gl-backed `TerrainLayer`.
-See the [Wind Map example](/examples/geo-layers/wind).
+Terrain decoding runs in-process, so the example does not require a separately hosted terrain
+worker. See the [wind showcase guide](../developer-guide/wind-showcase.md) and the
+[Wind Map example](/examples/geo-layers/wind).
diff --git a/docs/modules/geo-layers/api-reference/particle-layer.md b/docs/modules/geo-layers/api-reference/particle-layer.md
index 51bbec1b..6844bb18 100644
--- a/docs/modules/geo-layers/api-reference/particle-layer.md
+++ b/docs/modules/geo-layers/api-reference/particle-layer.md
@@ -1,47 +1,128 @@
+import LayerLiveExample from '@site/src/components/docs/layer-live-example';
+
# ParticleLayer
-`ParticleLayer` renders animated, fading particle trails advected through a Delaunay-interpolated
-geographic wind field. It ports the particle animation from the original deck.gl wind showcase
-without relying on WebGL-only transform feedback.
+:::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.
+:::
+
+`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.
+
+
+
+## Import
```ts
import {ParticleLayer} from '@deck.gl-community/geo-layers';
+```
+
+## Example
+
+```ts
+import {Deck} from '@deck.gl/core';
+import {createWindField, parseWindData, ParticleLayer} from '@deck.gl-community/geo-layers';
-const layer = new ParticleLayer({
- id: 'wind-particles',
- windField,
- time,
- numParticles: 2400,
- trailLength: 12,
- speedScale: 0.085,
- color: [194, 246, 224, 210],
- elevationScale: 10,
- surfaceOffset: 160,
- pointRadiusPixels: 1.6
+const stations = await fetch('/wind/stations.json').then(response => response.json());
+const weather = await fetch('/wind/weather.bin').then(response => response.arrayBuffer());
+const windField = createWindField(stations, parseWindData(weather, stations.length));
+
+const deck = new Deck({
+ initialViewState: {longitude: -98, latitude: 38, zoom: 4, pitch: 45},
+ controller: true
});
+
+let time = 0;
+
+function animate() {
+ deck.setProps({
+ layers: [
+ new ParticleLayer({
+ id: 'wind-particles',
+ windField,
+ time,
+ numParticles: 100_000,
+ speedScale: 0.16,
+ color: [186, 233, 223, 34],
+ elevationScale: 24,
+ surfaceOffset: 1_700,
+ pointRadiusPixels: 0.7
+ })
+ ]
+ });
+
+ time += 1 / 108;
+ requestAnimationFrame(animate);
+}
+
+animate();
```
-Advance `time` and pass a new `ParticleLayer` with the same `id` to `deck.setProps`. Deck.gl
-transfers the existing particle state into the new layer, preserving continuous trails.
-Particle advection is scaled to elapsed animation time rather than the browser frame rate, and
-direction and speed are interpolated to produce smooth, curved streamlines.
+Keep `windField` and `id` stable. deck.gl transfers the existing simulation state to the next
+layer instance, so advancing `time` does not reallocate the particle buffers.
## Properties
-- `windField`: A `WindField` returned by `createWindField`.
-- `time`: Fractional, cyclic weather-frame and particle-animation time.
-- `numParticles`: Number of deterministic, continuously advected particles.
-- `trailLength`: Maximum number of positions retained per particle.
-- `speedScale`: Geographic particle movement per elapsed, 30-frame-per-second animation step.
-- `widthMinPixels`: Minimum screen-space trail width.
-- `color`: RGBA trail color; segments fade toward their historical positions.
-- `elevationScale`: Multiplier for interpolated station elevation.
-- `surfaceOffset`: Vertical separation above the rendered terrain surface, in meters.
-- `pointRadiusPixels`: Radius of each bright, moving particle head.
+### `windField` (`WindField`, required)
+
+Shared station forecast created with [`createWindField`](./wind-field.md). Changing the field
+creates a new GPU simulation.
+
+### `time` (`number`, default `0`)
+
+Fractional forecast-frame index and simulation time. Forecast frames wrap automatically.
+
+### `numParticles` (`number`, default `2400`)
+
+Number of simulated GPU particles. The showcase defaults to `100_000` and lets you select up to
+`1_000_000`. Changing the count necessarily reallocates the simulation buffers; debounce sliders
+rather than reallocating on every pointer-move event.
+
+### `trailLength` (`number`, default `12`)
+
+Maximum retained history for the device-free CPU fallback. The GPU path instead renders directly
+from current and previous GPU buffers and fades particles by their GPU-resident lifetime. At high
+densities it prioritizes single-vertex points over additional trail geometry.
+
+### `speedScale` (`number`, default `0.085`)
+
+Geographic distance per 30-fps-equivalent simulation step. Simulation speed is adjusted for elapsed
+time instead of assuming a fixed browser frame rate.
+
+### `widthMinPixels` (`number`, default `1.1`)
+
+Minimum visible trail width in screen pixels.
+
+### `color` (`Color`, default `[194, 246, 224, 210]`)
+
+RGBA particle and trail color. The GPU multiplies alpha by fade-in and fade-out lifetime curves.
+Use a lower alpha when rendering hundreds of thousands of overlapping particles.
+
+### `elevationScale` (`number`, default `1`)
+
+Multiplier applied to station-interpolated elevation in meters.
+
+### `surfaceOffset` (`number`, default `160`)
+
+Vertical separation in meters above the interpolated terrain.
+
+### `pointRadiusPixels` (`number`, default `1.6`)
+
+Radius of moving particle heads in screen pixels.
-## Sub-layers
+## Rendering and lifecycle
-- `trails`: A `LineLayer` containing independently faded particle-trail segments.
-- `heads`: A `ScatterplotLayer` containing the animated, bright white particle heads.
+- 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.
+- 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.
-See the [Wind Map example](/examples/geo-layers/wind).
+See the [wind showcase guide](../developer-guide/wind-showcase.md), the
+[Wind Map example](/examples/geo-layers/wind), and the
+[WebGPU compatibility matrix](/docs/webgpu).
diff --git a/docs/modules/geo-layers/api-reference/wind-field.md b/docs/modules/geo-layers/api-reference/wind-field.md
new file mode 100644
index 00000000..c5c08b26
--- /dev/null
+++ b/docs/modules/geo-layers/api-reference/wind-field.md
@@ -0,0 +1,162 @@
+import LayerLiveExample from '@site/src/components/docs/layer-live-example';
+
+# Wind field and weather data
+
+:::caution Work in progress
+The wind-field data format, interpolation utilities, and wind-layer APIs are experimental and may
+change. The particle simulation runs on the graphics device; the field-building and explicit
+sampling utilities described here are deliberately backend-independent.
+:::
+
+A `WindField` is the shared, indexed weather forecast used by `WindLayer`, `ParticleLayer`,
+`ElevationLayer` examples, `DelaunayCoverLayer`, and `DelaunayInterpolation`. Build the field
+once and reuse the same object throughout an animation.
+
+
+
+## Import
+
+```ts
+import {
+ createWindField,
+ getWindBounds,
+ parseWindData,
+ sampleWindField,
+ triangulateWindStations,
+ type WindBounds,
+ type WindField,
+ type WindFieldOptions,
+ type WindMeasurement,
+ type WindSample,
+ type WindStation,
+ type WindTriangle
+} from '@deck.gl-community/geo-layers';
+```
+
+## Load the original forecast
+
+```ts
+import {
+ createWindField,
+ parseWindData,
+ type WindStation
+} from '@deck.gl-community/geo-layers';
+
+const dataUrl =
+ 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/wind';
+
+const [stations, weather] = await Promise.all([
+ fetch(`${dataUrl}/stations.json`).then(response => response.json()) as Promise,
+ fetch(`${dataUrl}/weather.bin`).then(response => response.arrayBuffer())
+]);
+
+const frames = parseWindData(weather, stations.length);
+const windField = createWindField(stations, frames);
+```
+
+The original `weather.bin` contains 72 hourly forecast frames. Each weather station stores its
+frames consecutively, with three unsigned 16-bit values per frame:
+`[direction, speed, temperature]`. `parseWindData` converts this station-major binary layout into
+frame-major measurements.
+
+## Station coordinates
+
+The historical dataset uses **positive-west** longitude:
+
+```ts
+const station: WindStation = {
+ name: 'Denver',
+ long: 104.99,
+ lat: 39.739,
+ elv: 1609
+};
+
+const deckPosition = [-station.long, station.lat, station.elv];
+```
+
+`createWindField`, `getWindBounds`, `triangulateWindStations`, and the wind layers perform this
+conversion internally. Do not pre-negate `station.long` before passing the original dataset to
+these utilities.
+
+## `parseWindData(buffer, stationCount, frameCount?)`
+
+```ts
+const frames: WindMeasurement[][] = parseWindData(weather, stations.length, 72);
+```
+
+- `buffer`: station-major `ArrayBuffer` containing packed `Uint16` measurements.
+- `stationCount`: number of weather stations represented in every frame.
+- `frameCount`: number of forecast frames; defaults to `72`.
+- Returns frame-major `[direction, speed, temperature]` measurements.
+- Throws `RangeError` when the dimensions or binary length are invalid.
+
+Direction is measured in eighth-turns: `0` points east, and successive values rotate
+counterclockwise by 45 degrees. `sampleWindField` converts the result into radians and an
+eastward/northward velocity.
+
+## `createWindField(stations, frames, options?)`
+
+```ts
+const windField: WindField = createWindField(stations, frames);
+
+const options: WindFieldOptions = {
+ triangles: triangulateWindStations(stations)
+};
+
+const fieldWithExistingTriangles = createWindField(stations, frames, options);
+```
+
+Creates the shared forecast, computes geographic bounds and observed value ranges, and indexes the
+station triangulation for fast repeated sampling. By default, the robust Delaunay triangulation is
+computed once with `delaunator`; provide `options.triangles` when reusing a known station mesh.
+
+`WindField` exposes `stations`, `frames`, `triangles`, `bounds`, `speedRange`, and
+`temperatureRange`. Its `spatialIndex` is an internal implementation detail, not a public
+construction API.
+
+## `sampleWindField(field, position, time?)`
+
+```ts
+const sample: WindSample | null = sampleWindField(windField, [-104.99, 39.739], 12.5);
+
+if (sample) {
+ console.log(sample.direction, sample.speed, sample.temperature);
+ console.log(sample.elevation, sample.velocity);
+}
+```
+
+Interpolates measurements spatially inside the station triangulation and temporally between
+adjacent forecast frames. Fractional times wrap around the forecast; positions outside measured
+station coverage return `null` instead of being extrapolated.
+
+`WindSample.direction` is in radians, `velocity` is `[east, north]`, and `elevation` is in meters.
+
+## `triangulateWindStations(stations)`
+
+```ts
+const triangles: WindTriangle[] = triangulateWindStations(stations);
+```
+
+Returns station-index triples covering the real station hull. Duplicate station coordinates are
+ignored. Fewer than three distinct, non-collinear station positions produce an empty triangulation.
+
+## `getWindBounds(stations)`
+
+```ts
+const bounds: WindBounds = getWindBounds(stations);
+const elevationBounds = [bounds.minLng, bounds.minLat, bounds.maxLng, bounds.maxLat];
+```
+
+Returns geographic west, south, east, and north limits using deck.gl longitude conventions.
+
+## GPU simulation boundary
+
+`ParticleLayer` turns the shared field into cached weather textures and keeps particle positions in
+GPU buffers. WebGL2 uses transform feedback; WebGPU uses compute. Explicitly calling
+`sampleWindField` or `DelaunayInterpolation.rasterize` is CPU work and is not necessary for each
+animation frame. Do not rebuild the field, triangulation, raster, or particle simulation on every
+`requestAnimationFrame`.
+
+See the [complete wind showcase guide](../developer-guide/wind-showcase.md),
+[ParticleLayer](./particle-layer.md), and
+[original wind showcase](https://github.com/visgl/deck.gl/tree/master/showcases/wind/src).
diff --git a/docs/modules/geo-layers/api-reference/wind-layer.md b/docs/modules/geo-layers/api-reference/wind-layer.md
index 33c1ea63..0f77e895 100644
--- a/docs/modules/geo-layers/api-reference/wind-layer.md
+++ b/docs/modules/geo-layers/api-reference/wind-layer.md
@@ -1,54 +1,77 @@
+import LayerLiveExample from '@site/src/components/docs/layer-live-example';
+
# WindLayer
-`WindLayer` renders a time-varying weather vector field as directionally accurate, speed-colored
-arrow glyphs. It ports the wind-vector component of Nicolas Belmonte's original deck.gl wind
-showcase into an independently reusable, WebGL- and WebGPU-compatible composite layer.
+:::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.
+:::
+
+`WindLayer` renders a Delaunay-interpolated station forecast as directional, speed-colored arrow
+glyphs. Its measurements, temporal interpolation, and terrain elevation are shared with
+[`ParticleLayer`](./particle-layer.md).
+
+
+
+## Import
```ts
import {createWindField, parseWindData, WindLayer} from '@deck.gl-community/geo-layers';
+```
-const stations = await fetch('/stations.json').then(response => response.json());
-const weather = await fetch('/weather.bin').then(response => response.arrayBuffer());
+## Example
+
+```ts
+const stations = await fetch('/wind/stations.json').then(response => response.json());
+const weather = await fetch('/wind/weather.bin').then(response => response.arrayBuffer());
const windField = createWindField(stations, parseWindData(weather, stations.length));
-const layer = new WindLayer({
- id: 'wind-vectors',
+const arrows = new WindLayer({
+ id: 'wind-arrows',
windField,
time: 12.5,
- gridWidth: 64,
- gridHeight: 32,
- speedScale: 0.65,
- elevationScale: 10
+ gridWidth: 40,
+ gridHeight: 22,
+ speedScale: 1.8,
+ widthMinPixels: 1.1,
+ lowColor: [52, 190, 160, 195],
+ highColor: [239, 163, 137, 230],
+ elevationScale: 24,
+ surfaceOffset: 1_200
});
```
+Update `time` with a stable layer `id` to interpolate cyclically between adjacent hourly frames.
+Unlike particles, arrow geometry is relatively low resolution and can be refreshed less frequently
+than the animation frame rate.
+
## Properties
-- `windField`: A `WindField` returned by `createWindField`.
-- `time`: Fractional weather-frame index. Animation wraps at the final frame.
-- `gridWidth`, `gridHeight`: Horizontal and vertical arrow sampling resolution.
-- `speedScale`: Arrow-length multiplier relative to the geographic sample spacing.
-- `widthMinPixels`: Minimum on-screen arrow width.
-- `lowColor`, `highColor`: RGBA colors interpolated by observed wind speed.
-- `elevationScale`: Multiplier for interpolated station elevation.
-- `surfaceOffset`: Vertical separation above the terrain surface, in meters.
+- `windField` (`WindField`, required): indexed station forecast returned by
+ [`createWindField`](./wind-field.md).
+- `time` (`number`, default `0`): fractional, automatically wrapping forecast frame.
+- `gridWidth` (`number`, default `64`): arrow samples along longitude.
+- `gridHeight` (`number`, default `32`): arrow samples along latitude.
+- `speedScale` (`number`, default `0.65`): geographic arrow-length multiplier.
+- `widthMinPixels` (`number`, default `1.25`): minimum shaft and arrowhead width.
+- `lowColor` (`Color`, default `[70, 190, 168, 190]`): color at the minimum observed speed.
+- `highColor` (`Color`, default `[247, 105, 76, 235]`): color at the maximum observed speed.
+- `elevationScale` (`number`, default `1`): station-elevation multiplier.
+- `surfaceOffset` (`number`, default `200`): elevation above the wind surface, in meters.
## Sub-layers
-- `glyphs`: A `SolidPolygonLayer` containing filled directional wind arrows.
-- `shafts`: A `PathLayer` containing wind-vector shafts.
-- `arrowheads`: A `PathLayer` containing directional arrowheads.
-
-See the [Wind Map example](/examples/geo-layers/wind) for the complete recreation of the original
-showcase.
+- `glyphs`: filled `SolidPolygonLayer` arrows.
+- `shafts`: a `PathLayer` for arrow shafts.
+- `arrowheads`: a `PathLayer` for directional arrowheads.
## Historical source
-The original implementation lives in deck.gl's
-[6.4-release wind showcase](https://github.com/visgl/deck.gl/tree/6.4-release/showcases/wind).
-Earlier upstream work includes the
-[luma.gl v4 wind-layer port](https://github.com/visgl/deck.gl/pull/794), the
-[transform-feedback and instanced-particle update](https://github.com/visgl/deck.gl/pull/1346),
-and the [Delaunay interpolation transform update](https://github.com/visgl/deck.gl/pull/2318).
-The community port preserves the original station and forecast data while replacing obsolete,
-WebGL-only rendering APIs with modern, backend-portable deck.gl sub-layers.
+The implementation preserves the station data and visual design of
+[Nicolas Belmonte's original wind showcase](https://github.com/visgl/deck.gl/tree/master/showcases/wind/src).
+Prior work includes the [luma.gl v4 wind-layer port](https://github.com/visgl/deck.gl/pull/794),
+the [transform-feedback and instanced-particle update](https://github.com/visgl/deck.gl/pull/1346),
+and the [Delaunay interpolation update](https://github.com/visgl/deck.gl/pull/2318).
+
+See the [wind showcase guide](../developer-guide/wind-showcase.md) and the
+[Wind Map example](/examples/geo-layers/wind).
diff --git a/docs/modules/geo-layers/developer-guide/wind-showcase.md b/docs/modules/geo-layers/developer-guide/wind-showcase.md
new file mode 100644
index 00000000..4d3e0ec9
--- /dev/null
+++ b/docs/modules/geo-layers/developer-guide/wind-showcase.md
@@ -0,0 +1,220 @@
+import LayerLiveExample from '@site/src/components/docs/layer-live-example';
+
+# Recreating the wind showcase
+
+:::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.
+:::
+
+The Wind Map restores
+[Nicolas Belmonte's original deck.gl wind showcase](https://github.com/visgl/deck.gl/tree/master/showcases/wind/src)
+using reusable, publicly imported community layers and the original United States station forecast.
+
+
+
+## Install
+
+```bash
+npm install @deck.gl/core @deck.gl-community/geo-layers
+```
+
+## Load and index the forecast once
+
+```ts
+import {
+ createWindField,
+ parseWindData,
+ type WindField,
+ type WindStation
+} from '@deck.gl-community/geo-layers';
+
+const dataUrl =
+ 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/wind';
+
+async function loadWindField(): Promise {
+ const [stations, weather] = await Promise.all([
+ fetch(`${dataUrl}/stations.json`).then(response => response.json()) as Promise,
+ fetch(`${dataUrl}/weather.bin`).then(response => response.arrayBuffer())
+ ]);
+
+ return createWindField(stations, parseWindData(weather, stations.length));
+}
+
+const windField = await loadWindField();
+```
+
+The forecast contains 72 hourly frames. Station longitudes use the original positive-west format;
+`createWindField` converts them into deck.gl geographic coordinates and computes a robust Delaunay
+triangulation once.
+
+## Render terrain, arrows, and animated particles
+
+```ts
+import {
+ AmbientLight,
+ Deck,
+ DirectionalLight,
+ LightingEffect,
+ MapView
+} from '@deck.gl/core';
+import {
+ ElevationLayer,
+ ParticleLayer,
+ WindLayer,
+ type WindField
+} from '@deck.gl-community/geo-layers';
+
+function mountWindMap(container: HTMLElement, windField: WindField): () => void {
+ const terrain = new ElevationLayer({
+ id: 'wind-terrain',
+ elevationData: `${dataUrl}/elevation.png`,
+ bounds: [-125, 24.4, -66.7, 49.6],
+ elevationRange: [-100, 4126],
+ elevationScale: 24,
+ meshMaxError: 12
+ });
+
+ const lighting = new LightingEffect({
+ ambient: new AmbientLight({color: [194, 210, 235], intensity: 0.7}),
+ sunlight: new DirectionalLight({
+ color: [255, 226, 198],
+ intensity: 1.15,
+ direction: [-1, -2, -2]
+ })
+ });
+
+ const deck = new Deck({
+ parent: container,
+ views: new MapView({repeat: false}),
+ initialViewState: {
+ longitude: -98.319,
+ latitude: 37.614,
+ zoom: 4.05,
+ pitch: 52,
+ maxPitch: 85
+ },
+ controller: {dragRotate: true, touchRotate: true},
+ effects: [lighting]
+ });
+
+ let animationFrame = 0;
+ let lastTimestamp = 0;
+ let lastArrowUpdate = -Infinity;
+ let time = 0;
+ let arrows: WindLayer | undefined;
+
+ function animate(timestamp: number): void {
+ const elapsed = lastTimestamp ? Math.min(timestamp - lastTimestamp, 100) : 0;
+ lastTimestamp = timestamp;
+ time += elapsed / 1800;
+
+ if (timestamp - lastArrowUpdate >= 250) {
+ arrows = new WindLayer({
+ id: 'wind-arrows',
+ windField,
+ time,
+ gridWidth: 40,
+ gridHeight: 22,
+ speedScale: 1.8,
+ elevationScale: 24,
+ surfaceOffset: 1200
+ });
+ lastArrowUpdate = timestamp;
+ }
+
+ deck.setProps({
+ layers: [
+ terrain,
+ arrows,
+ new ParticleLayer({
+ id: 'wind-particles',
+ windField,
+ time,
+ numParticles: 100_000,
+ speedScale: 0.16,
+ elevationScale: 24,
+ surfaceOffset: 1700,
+ pointRadiusPixels: 0.7,
+ color: [186, 233, 223, 34]
+ })
+ ]
+ });
+
+ animationFrame = requestAnimationFrame(animate);
+ }
+
+ animationFrame = requestAnimationFrame(animate);
+
+ return () => {
+ cancelAnimationFrame(animationFrame);
+ deck.finalize();
+ };
+}
+
+const unmount = mountWindMap(document.querySelector('#app')!, windField);
+
+// Call unmount() before removing the containing element.
+```
+
+Keep the `windField`, terrain layer, particle layer `id`, and graphics device stable. deck.gl
+transfers particle simulation state to each new `ParticleLayer` instance; only its time advances.
+Update the station-sampled arrows less frequently than the GPU particle frame to avoid repeating
+CPU interpolation every frame.
+
+## Smooth terrain versus the station mesh
+
+`ElevationLayer` creates the actual mountain terrain from the original `elevation.png`. The full
+showcase applies two separable Gaussian smoothing passes to that image before displaying it, then
+uses `elevationScale: 24` and `meshMaxError: 12`. This preserves broad mountain ridges without
+turning the weather-station Delaunay triangles into jagged terrain.
+
+`DelaunayCoverLayer` visualizes the **weather station mesh**, not the elevation image. Enable it
+when debugging interpolation coverage; do not replace the mountain terrain with it.
+
+## Particle density and performance
+
+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.
+
+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
+count necessarily allocates a new simulation, so debounce density controls rather than rebuilding
+buffers on every slider event.
+
+## Backend compatibility
+
+| Component | WebGL2 | WebGPU | Notes |
+| --- | :---: | :---: | --- |
+| `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. |
+
+See the full [WebGPU compatibility matrix](/docs/webgpu).
+
+## Run the repository example
+
+```bash
+yarn
+yarn workspace wind-layer-example start
+```
+
+The standalone example is in `examples/geo-layers/wind`. The website mounts the same named
+`mountWindExample` implementation for the [Wind Map](/examples/geo-layers/wind) and the inline
+wind-layer documentation.
+
+## API references
+
+- [Wind field and weather data](../api-reference/wind-field.md)
+- [ParticleLayer](../api-reference/particle-layer.md)
+- [WindLayer](../api-reference/wind-layer.md)
+- [ElevationLayer](../api-reference/elevation-layer.md)
+- [DelaunayCoverLayer](../api-reference/delaunay-cover-layer.md)
+- [DelaunayInterpolation](../api-reference/delaunay-interpolation.md)
diff --git a/docs/modules/geo-layers/sidebar.json b/docs/modules/geo-layers/sidebar.json
index d26c6598..e37c4061 100644
--- a/docs/modules/geo-layers/sidebar.json
+++ b/docs/modules/geo-layers/sidebar.json
@@ -3,16 +3,24 @@
"label": "@deck.gl-community/geo-layers",
"items": [
"modules/geo-layers/README",
- "modules/geo-layers/api-reference/delaunay-cover-layer",
- "modules/geo-layers/api-reference/delaunay-interpolation",
- "modules/geo-layers/api-reference/elevation-layer",
- "modules/geo-layers/api-reference/particle-layer",
+ {
+ "type": "category",
+ "label": "Wind showcase (work in progress)",
+ "items": [
+ "modules/geo-layers/developer-guide/wind-showcase",
+ "modules/geo-layers/api-reference/wind-field",
+ "modules/geo-layers/api-reference/wind-layer",
+ "modules/geo-layers/api-reference/particle-layer",
+ "modules/geo-layers/api-reference/elevation-layer",
+ "modules/geo-layers/api-reference/delaunay-cover-layer",
+ "modules/geo-layers/api-reference/delaunay-interpolation"
+ ]
+ },
"modules/geo-layers/api-reference/shared-tile-2d-layer",
"modules/geo-layers/api-reference/shared-tileset-2d",
"modules/geo-layers/api-reference/tile-grid-layer",
"modules/geo-layers/api-reference/tile-source-layer",
"modules/geo-layers/api-reference/global-grid-layer",
- "modules/geo-layers/api-reference/wind-layer",
{
"type": "category",
"label": "Grids",
diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md
index 59d0af9a..3482d83e 100644
--- a/docs/upgrade-guide.md
+++ b/docs/upgrade-guide.md
@@ -6,6 +6,28 @@ Please refer the documentation of each module for detailed upgrade guides.
## v9.4
+### `@deck.gl-community/geo-layers`
+
+- The reusable wind showcase is an additive, **work-in-progress** API. Existing geospatial layers
+ do not require a breaking migration.
+- Import `WindLayer`, `ParticleLayer`, `ElevationLayer`, `DelaunayCoverLayer`,
+ `DelaunayInterpolation`, and weather-field utilities from `@deck.gl-community/geo-layers`
+ instead of copying the original showcase's private layer files or raw WebGL transforms.
+- Preserve the historical station dataset's positive-west `station.long` values; `createWindField`
+ and `getWindBounds` convert them to deck.gl's negative-west longitude convention.
+- Decode the station-major, 72-frame binary forecast with
+ `parseWindData(weather, stations.length)`; create and retain one shared field with
+ `createWindField(stations, frames)`.
+- Animate `ParticleLayer` by advancing its `time` property while preserving its layer `id` and
+ field. Do not reintroduce CPU particle arrays, GPU readbacks, per-frame triangulation, or raw
+ 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 support matrix](./webgpu.md).
+
### `@deck.gl-community/panels`
- Breaking change: panel composition APIs no longer expose `Widget*` names from `@deck.gl-community/panels`.
diff --git a/docs/webgpu.md b/docs/webgpu.md
index 9f34fe01..87ae0d86 100644
--- a/docs/webgpu.md
+++ b/docs/webgpu.md
@@ -31,6 +31,12 @@ deck.gl-community is adding WebGPU support incrementally while continuing to sup
| `@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` | `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` | 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. |
@@ -86,6 +92,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. |
| 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. |
diff --git a/docs/whats-new.md b/docs/whats-new.md
index 591effc9..d3794876 100644
--- a/docs/whats-new.md
+++ b/docs/whats-new.md
@@ -15,10 +15,30 @@ Scope tracked in the [v9.4 milestone](https://github.com/visgl/deck.gl-community
- `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.
+- `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.
- `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.
+### `@deck.gl-community/geo-layers`
+
+- Added the **work-in-progress** reusable wind showcase, ported from Nicolas Belmonte's original
+ deck.gl example and the original 72-hour United States station forecast.
+- `ParticleLayer` advances up to one million GPU-resident particles with WebGL2 transform feedback
+ or WebGPU compute; its production animation does not read particle buffers back to the CPU.
+- `WindLayer` renders interpolated, speed-colored wind arrows; `ElevationLayer` restores smooth,
+ vertically exaggerated image-based mountain terrain.
+- `DelaunayCoverLayer` exposes the weather-station mesh, while `DelaunayInterpolation` provides
+ backend-independent explicit weather sampling and rasterization.
+- 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
+ WebGPU limitations, migration guidance, and public API TSDoc.
+- The [Wind Map](/examples/geo-layers/wind) includes original forecast data, three-dimensional
+ mountains, tilt-and-rotate camera controls, and a 1,000-to-1,000,000-particle density slider.
+
### `@deck.gl-community/layers`
- `DependencyArrowLayer` - NEW directional marker layer for dependency links with path, line, or arc routing.
diff --git a/examples/geo-layers/wind/app.ts b/examples/geo-layers/wind/app.ts
index 45d380d2..d83d59b8 100644
--- a/examples/geo-layers/wind/app.ts
+++ b/examples/geo-layers/wind/app.ts
@@ -23,16 +23,20 @@ import {
type WindStation
} from '@deck.gl-community/geo-layers';
+import {smoothWindElevation} from './terrain-data';
+
const WIND_DATA_ROOT = 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/wind';
const US_STATE_BOUNDARIES =
'https://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json';
const ELEVATION_BOUNDS: [number, number, number, number] = [-125, 24.4, -66.7, 49.6];
-const ELEVATION_SCALE = 80;
+const ELEVATION_SCALE = 24;
+const PARTICLE_FRAME_RATE = 60;
+const WIND_VECTOR_UPDATE_INTERVAL_MS = 250;
const INITIAL_VIEW_STATE: MapViewState = {
longitude: -98.319,
latitude: 37.614,
zoom: 4.05,
- pitch: 46,
+ pitch: 52,
bearing: -0.64,
minPitch: 0,
maxPitch: 85
@@ -68,20 +72,26 @@ const WIND_CITIES: WindCity[] = [
];
const WIND_LIGHTING = new LightingEffect({
- ambient: new AmbientLight({color: [194, 210, 235], intensity: 0.8}),
+ ambient: new AmbientLight({color: [194, 210, 235], intensity: 0.7}),
sunlight: new DirectionalLight({
color: [255, 226, 198],
- intensity: 1.7,
+ intensity: 1.15,
direction: [-1, -2, -2]
}),
fill: new DirectionalLight({
color: [125, 170, 223],
- intensity: 0.5,
+ intensity: 0.3,
direction: [2, 1, -1]
})
});
-/** Options accepted by the standalone and embedded historical wind showcase. */
+/**
+ * Options for mounting the work-in-progress historical wind showcase.
+ *
+ * @remarks
+ * Standalone examples remain independent of website device-management widgets. Hosts
+ * can use `onDeckInitialized` to attach a WebGL2 or WebGPU device after construction.
+ */
export type WindExampleOptions = {
/** Receives the initialized deck instance when the website manages GPU devices. */
onDeckInitialized?: (deck: Deck) => void;
@@ -90,6 +100,7 @@ export type WindExampleOptions = {
};
type WindSettings = {
+ numParticles: number;
showParticles: boolean;
showWind: boolean;
showTerrain: boolean;
@@ -97,6 +108,21 @@ type WindSettings = {
showStations: boolean;
};
+type WindTerrainData = {
+ elevationData: string;
+ texture: string;
+};
+
+type WindExampleLayerStack = {
+ terrain: ElevationLayer | false;
+ stationMesh: DelaunayCoverLayer | false;
+ boundaries: GeoJsonLayer;
+ wind: WindLayer | false;
+ particles: ParticleLayer | false;
+ labels: TextLayer;
+ stations: ScatterplotLayer | false;
+};
+
async function loadWindField(dataUrl: string, signal: AbortSignal): Promise {
const [stationsResponse, weatherResponse] = await Promise.all([
fetch(`${dataUrl}/stations.json`, {signal}),
@@ -116,8 +142,8 @@ async function loadWindField(dataUrl: string, signal: AbortSignal): Promise {
+/** Smooths and shades the original height map once, before animation begins. */
+async function loadTerrainData(dataUrl: string, signal: AbortSignal): Promise {
const response = await fetch(`${dataUrl}/elevation.png`, {signal});
if (!response.ok) {
throw new Error(`Could not load terrain elevation: ${response.status}.`);
@@ -137,18 +163,35 @@ async function loadTerrainTexture(dataUrl: string, signal: AbortSignal): Promise
image.close();
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const {data} = imageData;
+ const smoothedElevation = smoothWindElevation(data, canvas.width, canvas.height);
+
+ for (let pixel = 0; pixel < smoothedElevation.length; pixel++) {
+ const offset = pixel * 4;
+ const height = smoothedElevation[pixel];
+ data[offset] = height;
+ data[offset + 1] = height;
+ data[offset + 2] = height;
+ data[offset + 3] = 255;
+ }
+ context.putImageData(imageData, 0, 0);
+ const elevationData = canvas.toDataURL('image/png');
+
for (let offset = 0; offset < data.length; offset += 4) {
- const height = data[offset];
- data[offset] = 20 + Math.round(height * 0.15);
- data[offset + 1] = 30 + Math.round(height * 0.19);
- data[offset + 2] = 43 + Math.round(height * 0.22);
- data[offset + 3] = height > 2 ? 245 : 0;
+ const height = smoothedElevation[offset / 4];
+ data[offset] = 24 + Math.round(height * 0.17);
+ data[offset + 1] = 38 + Math.round(height * 0.22);
+ data[offset + 2] = 49 + Math.round(height * 0.25);
+ data[offset + 3] = height > 3 ? 245 : 0;
}
context.putImageData(imageData, 0, 0);
- return canvas.toDataURL('image/png');
+ return {elevationData, texture: canvas.toDataURL('image/png')};
}
-function createSettingsPanel(settings: WindSettings, onChange: () => void): HTMLElement {
+function createSettingsPanel(
+ settings: WindSettings,
+ onChange: () => void,
+ onParticleCountChange: () => void
+): HTMLElement {
const panel = document.createElement('section');
panel.style.cssText =
'position:absolute;top:18px;left:18px;z-index:1;width:min(286px,calc(100% - 36px));' +
@@ -167,7 +210,7 @@ function createSettingsPanel(settings: WindSettings, onChange: () => void): HTML
description.style.cssText = 'margin:0 0 13px;color:rgba(232,247,240,.72)';
panel.append(description);
- const controls: [keyof WindSettings, string][] = [
+ const controls: [Exclude, string][] = [
['showParticles', 'Animated wind particles'],
['showWind', 'Filled wind-direction arrows'],
['showTerrain', 'Extruded 3D mountain terrain'],
@@ -189,6 +232,42 @@ function createSettingsPanel(settings: WindSettings, onChange: () => void): HTML
panel.append(label);
}
+ const particleControl = document.createElement('label');
+ particleControl.style.cssText =
+ 'display:grid;grid-template-columns:1fr auto;gap:7px;margin:14px 0 0;' +
+ 'color:rgba(232,247,240,.85)';
+ const particleLabel = document.createElement('span');
+ particleLabel.textContent = 'Particle count';
+ const particleValue = document.createElement('output');
+ particleValue.dataset.windParticleCount = 'true';
+ particleValue.textContent = settings.numParticles.toLocaleString();
+ particleValue.style.cssText = 'font-variant-numeric:tabular-nums;color:#9de8ca';
+ const particleSlider = document.createElement('input');
+ particleSlider.type = 'range';
+ particleSlider.min = '1000';
+ particleSlider.max = '1000000';
+ particleSlider.step = '1000';
+ particleSlider.value = String(settings.numParticles);
+ particleSlider.dataset.windParticleSlider = 'true';
+ particleSlider.setAttribute('aria-label', 'Wind particle count');
+ particleSlider.style.cssText = 'grid-column:1/-1;width:100%;margin:0;accent-color:#72d8b2';
+ let particleCountUpdate: number | undefined;
+ const applyParticleCount = () => {
+ window.clearTimeout(particleCountUpdate);
+ particleCountUpdate = undefined;
+ settings.numParticles = Number(particleSlider.value);
+ particleValue.textContent = settings.numParticles.toLocaleString();
+ onParticleCountChange();
+ };
+ particleSlider.addEventListener('input', () => {
+ particleValue.textContent = Number(particleSlider.value).toLocaleString();
+ window.clearTimeout(particleCountUpdate);
+ particleCountUpdate = window.setTimeout(applyParticleCount, 120);
+ });
+ particleSlider.addEventListener('change', applyParticleCount);
+ particleControl.append(particleLabel, particleValue, particleSlider);
+ panel.append(particleControl);
+
const status = document.createElement('p');
status.dataset.windStatus = 'true';
status.textContent = 'Loading original weather stations and 72-hour forecastβ¦';
@@ -199,12 +278,26 @@ function createSettingsPanel(settings: WindSettings, onChange: () => void): HTML
return panel;
}
-/** Mounts the original wind showcase using exclusively exported, reusable geo-layers. */
+/**
+ * Mounts the historical wind showcase using only public geo-layer exports.
+ *
+ * @param container - Element that owns the deck canvas and example controls.
+ * @param options - Optional original-data URL and embedding-device callback.
+ * @returns A cleanup function that cancels animation, aborts data loads, and finalizes deck.
+ *
+ * @example
+ * ```ts
+ * const unmount = mountWindExample(document.querySelector('#app')!);
+ * // When the host is removed:
+ * unmount();
+ * ```
+ */
export function mountWindExample(
container: HTMLElement,
options: WindExampleOptions = {}
): () => void {
const settings: WindSettings = {
+ numParticles: 100_000,
showParticles: true,
showWind: true,
showTerrain: true,
@@ -213,15 +306,19 @@ export function mountWindExample(
};
const abortController = new AbortController();
let field: WindField | null = null;
- let terrainTexture: string | null = null;
+ let terrainData: WindTerrainData | null = null;
+ let layerStack: WindExampleLayerStack | null = null;
let animationFrame = 0;
let lastFrameTime = 0;
+ let lastVectorFrameTime = 0;
+ let frameSampleStart = 0;
+ let measuredFrames = 0;
let animationTime = 0;
let disposed = false;
container.style.position = 'relative';
container.style.background = '#0b1520';
- const panel = createSettingsPanel(settings, updateLayers);
+ const panel = createSettingsPanel(settings, updateLayers, updateParticleCount);
container.append(panel);
const status = panel.querySelector('[data-wind-status]');
@@ -229,6 +326,7 @@ export function mountWindExample(
parent: container,
width: '100%',
height: '100%',
+ useDevicePixels: Math.min(window.devicePixelRatio || 1, 1.5),
views: new MapView({repeat: false}),
initialViewState: INITIAL_VIEW_STATE,
controller: {dragRotate: true, touchRotate: true, keyboard: true, inertia: 180},
@@ -242,122 +340,185 @@ export function mountWindExample(
});
options.onDeckInitialized?.(deck);
- function updateLayers(): void {
- if (!field || disposed) {
+ function createWindLayer(windField: WindField): WindLayer | false {
+ return (
+ settings.showWind &&
+ new WindLayer({
+ id: 'wind-vectors',
+ windField,
+ time: animationTime,
+ gridWidth: 40,
+ gridHeight: 22,
+ speedScale: 1.8,
+ widthMinPixels: 1.1,
+ lowColor: [52, 190, 160, 195],
+ highColor: [239, 163, 137, 230],
+ elevationScale: ELEVATION_SCALE,
+ surfaceOffset: 1_200
+ })
+ );
+ }
+
+ function createParticleLayer(windField: WindField): ParticleLayer | false {
+ return (
+ settings.showParticles &&
+ new ParticleLayer({
+ id: 'wind-particles',
+ windField,
+ time: animationTime,
+ numParticles: settings.numParticles,
+ trailLength: 12,
+ speedScale: 0.16,
+ widthMinPixels: 0.7,
+ pointRadiusPixels: 0.7,
+ color: [186, 233, 223, 34],
+ elevationScale: ELEVATION_SCALE,
+ surfaceOffset: 1_700
+ })
+ );
+ }
+
+ function publishLayers(): void {
+ if (!layerStack || disposed) {
return;
}
deck.setProps({
layers: [
+ layerStack.terrain,
+ layerStack.stationMesh,
+ layerStack.boundaries,
+ layerStack.wind,
+ layerStack.particles,
+ layerStack.labels,
+ layerStack.stations
+ ]
+ });
+ }
+
+ function updateParticleCount(): void {
+ if (!field || !layerStack || disposed) {
+ return;
+ }
+
+ layerStack.particles = createParticleLayer(field);
+ publishLayers();
+ }
+
+ function updateLayers(): void {
+ if (!field || !terrainData || disposed) {
+ return;
+ }
+
+ layerStack = {
+ terrain:
settings.showTerrain &&
- new ElevationLayer({
- id: 'wind-height-map',
- elevationData: `${options.dataUrl ?? WIND_DATA_ROOT}/elevation.png`,
- bounds: ELEVATION_BOUNDS,
- elevationRange: [-100, 4126],
- elevationScale: ELEVATION_SCALE,
- meshMaxError: 480,
- color: [42, 60, 77, 255],
- texture: terrainTexture ?? undefined
- }),
- settings.showStationMesh &&
- new DelaunayCoverLayer({
- id: 'wind-station-terrain',
- windField: field,
- elevationScale: ELEVATION_SCALE,
- opacity: 0.32
- }),
- 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
+ new ElevationLayer({
+ id: 'wind-height-map',
+ elevationData: terrainData.elevationData,
+ bounds: ELEVATION_BOUNDS,
+ elevationRange: [-100, 4126],
+ elevationScale: ELEVATION_SCALE,
+ meshMaxError: 12,
+ color: [35, 49, 64, 255],
+ texture: terrainData.texture
}),
- settings.showWind &&
- new WindLayer({
- id: 'wind-vectors',
- windField: field,
- time: animationTime,
- gridWidth: 40,
- gridHeight: 22,
- speedScale: 1.8,
- widthMinPixels: 1.1,
- lowColor: [52, 190, 160, 195],
- highColor: [239, 163, 137, 230],
- elevationScale: ELEVATION_SCALE,
- surfaceOffset: 3_200
- }),
- settings.showParticles &&
- new ParticleLayer({
- id: 'wind-particles',
- windField: field,
- time: animationTime,
- numParticles: 3_600,
- trailLength: 20,
- speedScale: 0.16,
- widthMinPixels: 1.1,
- pointRadiusPixels: 1,
- color: [237, 247, 255, 168],
- elevationScale: ELEVATION_SCALE,
- surfaceOffset: 4_600
- }),
- new TextLayer({
- id: 'wind-city-labels',
- data: WIND_CITIES,
- getPosition: city => {
- const sample = sampleWindField(field, city.position, animationTime);
- return [
- city.position[0],
- city.position[1],
- (sample?.elevation ?? 0) * ELEVATION_SCALE + 6_000
- ];
- },
- getText: city => city.name,
- getColor: [231, 232, 238, 215],
- getSize: 12,
- sizeUnits: 'pixels',
- getTextAnchor: 'middle',
- getAlignmentBaseline: 'center',
- parameters: {depthWriteEnabled: false},
- pickable: false
+ stationMesh:
+ settings.showStationMesh &&
+ 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
+ }),
+ 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
+ }),
+ stations:
settings.showStations &&
- new ScatterplotLayer({
- id: 'wind-stations',
- data: field.stations,
- getPosition: station => [
- -station.long,
- station.lat,
- station.elv * ELEVATION_SCALE + 5_200
- ],
- getFillColor: [255, 227, 165, 205],
- getRadius: 3,
- radiusUnits: 'pixels',
- radiusMinPixels: 2,
- pickable: true
- })
- ]
- });
+ new ScatterplotLayer({
+ id: 'wind-stations',
+ data: field.stations,
+ getPosition: station => [
+ -station.long,
+ station.lat,
+ station.elv * ELEVATION_SCALE + 1_900
+ ],
+ getFillColor: [255, 227, 165, 205],
+ getRadius: 3,
+ radiusUnits: 'pixels',
+ radiusMinPixels: 2,
+ pickable: true
+ })
+ };
+ publishLayers();
}
function animate(timestamp: number): void {
if (disposed) {
return;
}
- if (timestamp - lastFrameTime >= 1000 / 30) {
+ if (timestamp - lastFrameTime >= 1000 / PARTICLE_FRAME_RATE - 1) {
const elapsed = lastFrameTime ? Math.min(timestamp - lastFrameTime, 100) : 0;
animationTime += elapsed / 1800;
lastFrameTime = timestamp;
+ if (field && layerStack) {
+ layerStack.particles = createParticleLayer(field);
+ if (timestamp - lastVectorFrameTime >= WIND_VECTOR_UPDATE_INTERVAL_MS) {
+ layerStack.wind = createWindLayer(field);
+ lastVectorFrameTime = timestamp;
+ }
+ publishLayers();
+ }
if (status && field) {
status.dataset.windFrame = animationTime.toFixed(3);
+ measuredFrames++;
+ if (!frameSampleStart) {
+ frameSampleStart = timestamp;
+ } else if (timestamp - frameSampleStart >= 1000) {
+ const framesPerSecond = Math.round(
+ (measuredFrames * 1000) / (timestamp - frameSampleStart)
+ );
+ status.dataset.windFps = String(framesPerSecond);
+ status.textContent =
+ `${field.stations.length.toLocaleString()} stations Β· ` +
+ `${settings.numParticles.toLocaleString()} GPU particles Β· ` +
+ `${framesPerSecond} fps`;
+ measuredFrames = 0;
+ frameSampleStart = timestamp;
+ }
}
- updateLayers();
}
animationFrame = window.requestAnimationFrame(animate);
}
@@ -365,14 +526,14 @@ export function mountWindExample(
const dataUrl = options.dataUrl ?? WIND_DATA_ROOT;
void Promise.all([
loadWindField(dataUrl, abortController.signal),
- loadTerrainTexture(dataUrl, abortController.signal)
+ loadTerrainData(dataUrl, abortController.signal)
])
- .then(([windField, nextTerrainTexture]) => {
+ .then(([windField, nextTerrainData]) => {
if (disposed) {
return;
}
field = windField;
- terrainTexture = nextTerrainTexture;
+ terrainData = nextTerrainData;
if (status) {
status.textContent = `${windField.stations.length.toLocaleString()} stations Β· ${windField.frames.length} hourly weather frames`;
}
diff --git a/examples/geo-layers/wind/terrain-data.ts b/examples/geo-layers/wind/terrain-data.ts
new file mode 100644
index 00000000..e3def93f
--- /dev/null
+++ b/examples/geo-layers/wind/terrain-data.ts
@@ -0,0 +1,65 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+/**
+ * Smooths an elevation image with two separable three-tap Gaussian passes.
+ *
+ * The original wind showcase filters the elevation texture and averages adjacent samples
+ * in its terrain vertex shader. Pre-filtering once preserves that smooth relief without
+ * repeating terrain work while particles animate.
+ *
+ * @param pixels - Row-major RGBA pixels from the original grayscale elevation image.
+ * @param width - Image width in pixels.
+ * @param height - Image height in pixels.
+ * @returns One smoothed grayscale elevation sample for each input pixel.
+ * @throws RangeError if the pixel buffer does not match the image dimensions.
+ */
+export function smoothWindElevation(
+ pixels: Uint8ClampedArray,
+ width: number,
+ height: number
+): Uint8ClampedArray {
+ if (
+ !Number.isInteger(width) ||
+ !Number.isInteger(height) ||
+ width <= 0 ||
+ height <= 0 ||
+ pixels.length !== width * height * 4
+ ) {
+ throw new RangeError('Wind elevation pixels must match positive image dimensions.');
+ }
+
+ let elevations = Uint8ClampedArray.from(
+ {length: width * height},
+ (_, index) => pixels[index * 4]
+ );
+
+ for (let pass = 0; pass < 2; pass++) {
+ const horizontal = new Uint8ClampedArray(elevations.length);
+ const smoothed = new Uint8ClampedArray(elevations.length);
+
+ for (let row = 0; row < height; row++) {
+ const rowOffset = row * width;
+ for (let column = 0; column < width; column++) {
+ const index = rowOffset + column;
+ const left = elevations[rowOffset + Math.max(0, column - 1)];
+ const right = elevations[rowOffset + Math.min(width - 1, column + 1)];
+ horizontal[index] = (left + 2 * elevations[index] + right) / 4;
+ }
+ }
+
+ for (let row = 0; row < height; row++) {
+ for (let column = 0; column < width; column++) {
+ const index = row * width + column;
+ const above = horizontal[Math.max(0, row - 1) * width + column];
+ const below = horizontal[Math.min(height - 1, row + 1) * width + column];
+ smoothed[index] = (above + 2 * horizontal[index] + below) / 4;
+ }
+ }
+
+ elevations = smoothed;
+ }
+
+ return elevations;
+}
diff --git a/examples/geo-layers/wind/wind-device-tabs.spec.ts b/examples/geo-layers/wind/wind-device-tabs.spec.ts
new file mode 100644
index 00000000..347cfb58
--- /dev/null
+++ b/examples/geo-layers/wind/wind-device-tabs.spec.ts
@@ -0,0 +1,118 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+
+import {mountDeviceManagedExample} from '../../../website/src/components/example/mount-device-managed-example';
+
+const deviceTabs = vi.hoisted(() => {
+ const webgpuDevice = {id: 'wind-webgpu', type: 'webgpu'};
+ let listener: ((state: {device: typeof webgpuDevice}) => void) | undefined;
+ const unsubscribe = vi.fn();
+ const manager = {
+ subscribe: vi.fn((nextListener: typeof listener) => {
+ listener = nextListener;
+ return unsubscribe;
+ }),
+ reparentCanvas: vi.fn(),
+ initialize: vi.fn(async () => {
+ listener?.({device: webgpuDevice});
+ }),
+ reset: vi.fn()
+ };
+
+ return {
+ webgpuDevice,
+ manager,
+ unsubscribe,
+ Manager: vi.fn(function DeviceManagerController() {
+ return manager;
+ }),
+ Widget: vi.fn(function DeviceTabsWidget(props: unknown) {
+ return {props};
+ })
+ };
+});
+
+vi.mock('@deck.gl-community/widgets', () => ({
+ DeviceManagerController: deviceTabs.Manager,
+ DeviceTabsWidget: deviceTabs.Widget
+}));
+
+describe('wind showcase graphics backend selector', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ 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 cleanup = vi.fn();
+ const mount = vi.fn(
+ (
+ _container: HTMLElement,
+ options: {onDeckInitialized: (initializedDeck: typeof deck) => void}
+ ) => {
+ options.onDeckInitialized(deck);
+ return cleanup;
+ }
+ );
+
+ const dispose = await mountDeviceManagedExample(
+ container,
+ mount,
+ {},
+ {
+ deviceTabs: true,
+ mountLabel: 'Wind Map'
+ }
+ );
+
+ expect(deviceTabs.Manager).toHaveBeenCalledOnce();
+ expect(deviceTabs.Widget).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'wind-map-device-tabs',
+ devices: ['webgpu', 'webgl2'],
+ manager: deviceTabs.manager,
+ placement: 'top-right'
+ })
+ );
+ expect(deck.props.widgets).toHaveLength(1);
+ expect(deviceTabs.manager.initialize).toHaveBeenCalledOnce();
+ expect(deck.device).toBe(deviceTabs.webgpuDevice);
+
+ dispose();
+
+ expect(deviceTabs.unsubscribe).toHaveBeenCalledOnce();
+ expect(deviceTabs.manager.reset).toHaveBeenCalledOnce();
+ expect(cleanup).toHaveBeenCalledOnce();
+ });
+
+ it('mounts without a device manager when graphics tabs are disabled', async () => {
+ const container = {} as HTMLElement;
+ const cleanup = vi.fn();
+ const mount = vi.fn(() => cleanup);
+
+ const dispose = await mountDeviceManagedExample(container, mount);
+
+ expect(mount).toHaveBeenCalledWith(container, {});
+ expect(deviceTabs.Manager).not.toHaveBeenCalled();
+
+ dispose();
+
+ expect(cleanup).toHaveBeenCalledOnce();
+ });
+});
diff --git a/examples/geo-layers/wind/wind-terrain.spec.ts b/examples/geo-layers/wind/wind-terrain.spec.ts
new file mode 100644
index 00000000..f3e5c954
--- /dev/null
+++ b/examples/geo-layers/wind/wind-terrain.spec.ts
@@ -0,0 +1,38 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {describe, expect, it} from 'vitest';
+
+import {smoothWindElevation} from './terrain-data';
+
+describe('original wind showcase terrain', () => {
+ it('smooths isolated elevation spikes into a continuous mountain surface', () => {
+ const pixels = new Uint8ClampedArray(5 * 5 * 4);
+ pixels[(2 * 5 + 2) * 4] = 255;
+
+ const smoothed = smoothWindElevation(pixels, 5, 5);
+ const peak = smoothed[2 * 5 + 2];
+
+ expect(peak).toBeGreaterThan(0);
+ expect(peak).toBeLessThan(80);
+ expect(smoothed[2 * 5 + 1]).toBeGreaterThan(0);
+ expect(smoothed[1 * 5 + 2]).toBeGreaterThan(0);
+ expect(smoothed[1 * 5 + 1]).toBeGreaterThan(0);
+ });
+
+ it('preserves uniform elevation across image edges', () => {
+ const pixels = new Uint8ClampedArray(3 * 3 * 4);
+ for (let index = 0; index < pixels.length; index += 4) {
+ pixels[index] = 96;
+ }
+
+ expect([...smoothWindElevation(pixels, 3, 3)]).toEqual(Array.from({length: 9}, () => 96));
+ });
+
+ it('rejects mismatched terrain image dimensions', () => {
+ expect(() => smoothWindElevation(new Uint8ClampedArray(4), 2, 2)).toThrow(
+ 'match positive image dimensions'
+ );
+ });
+});
diff --git a/modules/geo-layers/README.md b/modules/geo-layers/README.md
index f5352681..ade2b71d 100644
--- a/modules/geo-layers/README.md
+++ b/modules/geo-layers/README.md
@@ -9,15 +9,37 @@ They can be quite useful in applications, however they are not officially suppor
## Wind layers
-The package includes the reusable layers and interpolation utilities from the historical deck.gl
-wind showcase:
+**Status: work in progress.** The package includes reusable layers and interpolation utilities
+from Nicolas Belmonte's historical deck.gl wind showcase:
- `WindLayer` renders interpolated, speed-colored wind-direction arrows.
-- `ParticleLayer` renders fading particle trails through the wind field.
+- `ParticleLayer` animates GPU-resident particles through the wind field using WebGL2 transform
+ feedback or WebGPU compute, without per-frame particle readbacks.
- `ElevationLayer` renders the original elevation image as illuminated 3D mountain terrain.
- `DelaunayCoverLayer` renders elevation-colored station-triangulated terrain.
- `DelaunayInterpolation` samples or rasterizes the same field.
-- `parseWindData`, `triangulateWindStations`, `createWindField`, and `sampleWindField` load and
- interpolate the original station-major, 72-hour weather forecast.
+- `parseWindData`, `triangulateWindStations`, `createWindField`, `getWindBounds`, and
+ `sampleWindField` load and interpolate the original station-major, 72-hour weather forecast.
-The standalone example is in `examples/geo-layers/wind`.
+```ts
+import {
+ createWindField,
+ parseWindData,
+ ParticleLayer,
+ WindLayer
+} from '@deck.gl-community/geo-layers';
+
+const windField = createWindField(stations, parseWindData(weather, stations.length));
+
+const layers = [
+ new WindLayer({id: 'wind-arrows', windField, time}),
+ new ParticleLayer({id: 'wind-particles', windField, time, numParticles: 100_000})
+];
+```
+
+WebGL2 and WebGPU particle simulation are independently browser-tested. Complete WebGPU rendering
+of the mountain-and-arrow scene remains in progress because its upstream terrain, polygon, and
+path sublayers are not yet universally portable.
+
+See the [wind showcase guide](https://deck.gl-community.github.io/docs/modules/geo-layers/developer-guide/wind-showcase)
+and [standalone example](https://github.com/visgl/deck.gl-community/tree/master/examples/geo-layers/wind).
diff --git a/modules/geo-layers/package.json b/modules/geo-layers/package.json
index 24d5911a..de2623d2 100644
--- a/modules/geo-layers/package.json
+++ b/modules/geo-layers/package.json
@@ -44,6 +44,7 @@
"@math.gl/core": "^4.0.0",
"@probe.gl/stats": "^4.1.1",
"a5-js": "^0.5.0",
+ "delaunator": "^5.1.0",
"h3-js": "^4.2.1"
},
"peerDependencies": {
diff --git a/modules/geo-layers/src/index.ts b/modules/geo-layers/src/index.ts
index 33c1f6c4..c71281a0 100644
--- a/modules/geo-layers/src/index.ts
+++ b/modules/geo-layers/src/index.ts
@@ -29,6 +29,7 @@ export {
export type {
WindBounds,
WindField,
+ WindFieldOptions,
WindMeasurement,
WindSample,
WindStation,
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 f9d703de..cdb3c242 100644
--- a/modules/geo-layers/src/wind-layer/delaunay-cover-layer.ts
+++ b/modules/geo-layers/src/wind-layer/delaunay-cover-layer.ts
@@ -7,15 +7,15 @@ import {SolidPolygonLayer} from '@deck.gl/layers';
import type {WindField, WindStation, WindTriangle} from './wind-data';
-/** Properties for the station-triangulated terrain beneath a geographic wind field. */
+/** Configuration for the work-in-progress, station-triangulated {@link DelaunayCoverLayer}. */
export type DelaunayCoverLayerProps = {
/** Wind field supplying the geographic station triangulation. */
windField: WindField;
- /** Elevation multiplier applied to station heights in meters. */
+ /** Station-height multiplier; defaults to `1`. */
elevationScale?: number;
- /** Color for the lowest station elevation. */
+ /** RGBA fill for the lowest station elevation. */
lowColor?: Color;
- /** Color for the highest station elevation. */
+ /** RGBA fill for the highest station elevation. */
highColor?: Color;
};
@@ -54,7 +54,23 @@ function createTerrainTriangle(
};
}
-/** Recreates the wind showcase's elevation-colored Delaunay terrain as a reusable layer. */
+/**
+ * Renders elevation-colored terrain from the actual station Delaunay triangulation.
+ *
+ * @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.
+ *
+ * @example
+ * ```ts
+ * new DelaunayCoverLayer({
+ * id: 'wind-station-mesh',
+ * windField,
+ * elevationScale: 24
+ * });
+ * ```
+ */
export class DelaunayCoverLayer extends CompositeLayer {
static layerName = 'DelaunayCoverLayer';
static defaultProps: DefaultProps = defaultProps;
diff --git a/modules/geo-layers/src/wind-layer/delaunay-interpolation.ts b/modules/geo-layers/src/wind-layer/delaunay-interpolation.ts
index 1f81fdc4..3393dea6 100644
--- a/modules/geo-layers/src/wind-layer/delaunay-interpolation.ts
+++ b/modules/geo-layers/src/wind-layer/delaunay-interpolation.ts
@@ -4,17 +4,29 @@
import {sampleWindField, type WindField, type WindSample} from './wind-data';
-/** An RGBA float raster containing direction, speed, temperature, and elevation. */
+/**
+ * A row-major float raster containing direction, speed, temperature, and elevation.
+ *
+ * @remarks
+ * Each pixel contains four consecutive `Float32Array` entries. Invalid or uncovered
+ * geographic positions retain four zero-valued entries.
+ */
export type WindRaster = {
+ /** Number of geographic samples per row. */
width: number;
+ /** Number of geographic sample rows. */
height: number;
+ /** Row-major `[direction, speed, temperature, elevation]` samples. */
data: Float32Array;
};
/** Options for rasterizing a Delaunay-interpolated wind field. */
export type DelaunayInterpolationProps = {
+ /** Indexed station forecast returned by {@link createWindField}. */
field: WindField;
+ /** Raster width in pixels; defaults to `256`. */
width?: number;
+ /** Optional raster height; otherwise inferred from the geographic aspect ratio. */
height?: number;
};
@@ -23,12 +35,33 @@ export type DelaunayInterpolationProps = {
*
* Unlike the original showcase's off-screen WebGL transform, this implementation can be
* shared by WebGL, WebGPU, server-side preprocessing, and deterministic tests.
+ *
+ * @remarks
+ * The wind APIs are work in progress. This utility intentionally performs explicit CPU
+ * sampling; {@link ParticleLayer} separately caches GPU weather textures and advances
+ * particle positions on the active graphics device.
+ *
+ * @example
+ * ```ts
+ * const interpolation = new DelaunayInterpolation({field, width: 128, height: 64});
+ * const sample = interpolation.sample([-97, 38], 12.5);
+ * const raster = interpolation.rasterize(12.5);
+ * ```
*/
export class DelaunayInterpolation {
+ /** Shared, indexed station forecast. */
readonly field: WindField;
+ /** Raster width in pixels. */
readonly width: number;
+ /** Raster height in pixels. */
readonly height: number;
+ /**
+ * Creates a reusable interpolation wrapper for a geographic wind field.
+ *
+ * @param options - Field and output raster dimensions.
+ * @throws RangeError if either provided raster dimension is smaller than two.
+ */
constructor({field, width = 256, height}: DelaunayInterpolationProps) {
if (!Number.isInteger(width) || width < 2) {
throw new RangeError('A wind raster width must be an integer greater than one.');
@@ -49,12 +82,23 @@ export class DelaunayInterpolation {
this.height = height ?? inferredHeight;
}
- /** Samples a geographic position at a fractional, cyclic weather-frame time. */
+ /**
+ * Samples a geographic position at a fractional, cyclic forecast time.
+ *
+ * @param position - Geographic `[longitude, latitude]` coordinates.
+ * @param time - Fractional forecast frame; defaults to the first frame.
+ * @returns Interpolated weather, or `null` outside station coverage.
+ */
sample(position: readonly [number, number], time = 0): WindSample | null {
return sampleWindField(this.field, position, time);
}
- /** Rasterizes one time step without eagerly materializing the complete weather animation. */
+ /**
+ * Rasterizes one weather frame without materializing the full forecast animation.
+ *
+ * @param time - Fractional forecast frame; defaults to the first frame.
+ * @returns Row-major direction, speed, temperature, and elevation samples.
+ */
rasterize(time = 0): WindRaster {
const {bounds} = this.field;
const data = new Float32Array(this.width * this.height * 4);
diff --git a/modules/geo-layers/src/wind-layer/elevation-layer.ts b/modules/geo-layers/src/wind-layer/elevation-layer.ts
index aa381b08..aa6baa7a 100644
--- a/modules/geo-layers/src/wind-layer/elevation-layer.ts
+++ b/modules/geo-layers/src/wind-layer/elevation-layer.ts
@@ -6,17 +6,17 @@ import {CompositeLayer, type Color, type DefaultProps} from '@deck.gl/core';
import {TerrainLayer} from '@deck.gl/geo-layers';
import {TerrainLoader} from '@loaders.gl/terrain';
-/** Properties for a height-map-based geographic wind-showcase terrain surface. */
+/** Configuration for the work-in-progress, height-map-based {@link ElevationLayer}. */
export type ElevationLayerProps = {
/** Grayscale image encoding real terrain elevations in its red channel. */
elevationData: string;
/** Geographic extent of the elevation image: west, south, east, north. */
bounds: [number, number, number, number];
- /** Minimum and maximum elevations encoded by the image, in meters. */
+ /** Encoded minimum and maximum elevation in meters; defaults to `[-100, 4126]`. */
elevationRange?: [number, number];
- /** Vertical exaggeration applied to the decoded terrain geometry. */
+ /** Vertical exaggeration of the decoded terrain; defaults to `1`. */
elevationScale?: number;
- /** Error tolerance used to simplify the generated terrain mesh. */
+ /** Mesh simplification error tolerance; defaults to `80`. */
meshMaxError?: number;
/** Color applied to the shaded three-dimensional terrain surface. */
color?: Color;
@@ -39,6 +39,23 @@ const defaultProps: DefaultProps = {
*
* The image is decoded with the in-process loaders.gl terrain parser so the standalone
* showcase does not rely on an externally hosted terrain-worker bundle.
+ *
+ * @remarks
+ * This API is a work in progress. Smooth the source height map before applying strong
+ * exaggeration; use a smaller `meshMaxError` to preserve mountain detail. WebGPU support
+ * depends on the upstream `TerrainLayer` and active loaders.gl rendering path.
+ *
+ * @example
+ * ```ts
+ * new ElevationLayer({
+ * id: 'wind-terrain',
+ * elevationData: '/wind/elevation.png',
+ * bounds: [-125, 24.4, -66.7, 49.6],
+ * elevationRange: [-100, 4126],
+ * elevationScale: 24,
+ * meshMaxError: 12
+ * });
+ * ```
*/
export class ElevationLayer extends CompositeLayer {
static layerName = 'ElevationLayer';
@@ -64,7 +81,7 @@ export class ElevationLayer extends CompositeLayer {
meshMaxError,
color,
texture: texture || elevationData,
- material: {ambient: 0.42, diffuse: 0.82, shininess: 28, specularColor: [76, 91, 105]},
+ material: {ambient: 0.58, diffuse: 0.68, shininess: 48, specularColor: [18, 23, 28]},
loaders: [TerrainLoader],
loadOptions: {worker: false},
pickable: false
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
new file mode 100644
index 00000000..f282b28e
--- /dev/null
+++ b/modules/geo-layers/src/wind-layer/gpu-particle-point-layer.ts
@@ -0,0 +1,144 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {
+ Layer,
+ project32,
+ type Color,
+ type DefaultProps,
+ type LayerProps,
+ type UpdateParameters
+} from '@deck.gl/core';
+import {Model} from '@luma.gl/engine';
+import type {ShaderModule} from '@luma.gl/shadertools';
+
+import type {GpuParticleSimulation} from './gpu-particle-simulation';
+
+type GpuParticlePointLayerProps = LayerProps & {
+ simulation: GpuParticleSimulation;
+ color?: Color;
+ pointRadiusPixels?: number;
+};
+
+type GpuParticlePointUniforms = {
+ color: [number, number, number, number];
+ pointSize: number;
+};
+
+const gpuParticlePointUniforms = {
+ name: 'windParticlePoint',
+ vs: `layout(std140) uniform windParticlePointUniforms {
+ vec4 color;
+ float pointSize;
+} windParticlePoint;`,
+ uniformTypes: {color: 'vec4', pointSize: 'f32'}
+} as const satisfies ShaderModule;
+
+const POINT_VERTEX_SHADER = `#version 300 es
+#define SHADER_NAME wind-particle-point-vertex
+precision highp float;
+
+in vec4 particlePosition;
+out vec4 particleColor;
+
+void main() {
+ float fadeIn = smoothstep(0.0, 16.0, particlePosition.w);
+ float fadeOut = 1.0 - smoothstep(152.0, 180.0, particlePosition.w);
+ particleColor = vec4(
+ windParticlePoint.color.rgb,
+ windParticlePoint.color.a * fadeIn * fadeOut
+ );
+ gl_Position = project_position_to_clipspace(particlePosition.xyz, vec3(0.0), vec3(0.0));
+ gl_PointSize = max(1.0, windParticlePoint.pointSize);
+}`;
+
+const POINT_FRAGMENT_SHADER = `#version 300 es
+#define SHADER_NAME wind-particle-point-fragment
+precision highp float;
+
+in vec4 particleColor;
+out vec4 fragColor;
+
+void main() {
+ vec2 offset = gl_PointCoord - vec2(0.5);
+ float radius = length(offset);
+ if (radius > 0.5 || particleColor.a <= 0.0) {
+ discard;
+ }
+ float edge = 1.0 - smoothstep(0.32, 0.5, radius);
+ fragColor = vec4(particleColor.rgb, particleColor.a * edge);
+}`;
+
+const defaultProps: DefaultProps = {
+ simulation: {type: 'object', value: undefined!},
+ color: [237, 247, 255, 46],
+ pointRadiusPixels: 1
+};
+
+/**
+ * Draws one GPU vertex per simulated particle, matching the historical WebGL showcase.
+ *
+ * @remarks
+ * This is an internal WebGL rendering primitive. Applications should construct
+ * {@link ParticleLayer} rather than importing this implementation detail.
+ *
+ * @internal
+ */
+export class GpuParticlePointLayer extends Layer> {
+ static layerName = 'GpuParticlePointLayer';
+ static defaultProps: DefaultProps = defaultProps;
+
+ declare state: {model?: Model};
+
+ getShaders() {
+ return super.getShaders({
+ vs: POINT_VERTEX_SHADER,
+ fs: POINT_FRAGMENT_SHADER,
+ modules: [project32, gpuParticlePointUniforms]
+ });
+ }
+
+ initializeState(): void {
+ const {simulation} = this.props;
+ const model = new Model(this.context.device, {
+ ...this.getShaders(),
+ id: `${this.props.id}-model`,
+ topology: 'point-list',
+ vertexCount: simulation.particleCount,
+ bufferLayout: [{name: 'particlePosition', format: 'float32x4'}],
+ attributes: {particlePosition: simulation.targetBuffer},
+ parameters: {depthWriteEnabled: false}
+ });
+ this.setState({model});
+ }
+
+ updateState(parameters: UpdateParameters): void {
+ super.updateState(parameters);
+ const {model} = this.state;
+ if (!model) {
+ return;
+ }
+
+ const {simulation} = this.props;
+ model.setVertexCount(simulation.particleCount);
+ model.setAttributes({particlePosition: simulation.targetBuffer});
+ }
+
+ draw(): void {
+ const {model} = this.state;
+ if (!model) {
+ return;
+ }
+
+ const {simulation, color, pointRadiusPixels} = this.props;
+ model.setAttributes({particlePosition: simulation.targetBuffer});
+ model.shaderInputs.setProps({
+ windParticlePoint: {
+ color: [color[0] / 255, color[1] / 255, color[2] / 255, (color[3] ?? 255) / 255],
+ pointSize: Math.max(1, pointRadiusPixels * 2)
+ } satisfies GpuParticlePointUniforms
+ });
+ model.draw(this.context.renderPass);
+ }
+}
diff --git a/modules/geo-layers/src/wind-layer/gpu-particle-simulation.ts b/modules/geo-layers/src/wind-layer/gpu-particle-simulation.ts
new file mode 100644
index 00000000..816107c5
--- /dev/null
+++ b/modules/geo-layers/src/wind-layer/gpu-particle-simulation.ts
@@ -0,0 +1,481 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {Buffer, type Device, type Texture} from '@luma.gl/core';
+import {BufferTransform, Computation} from '@luma.gl/engine';
+import type {ShaderModule} from '@luma.gl/shadertools';
+
+import {sampleWindField, type WindField} from './wind-data';
+
+const WIND_TEXTURE_WIDTH = 128;
+const WIND_TEXTURE_HEIGHT = 64;
+const PARTICLE_WORKGROUP_SIZE = 256;
+const WIND_RASTER_CACHE = new WeakMap>();
+
+type ParticleUniforms = {
+ bounds: [number, number, number, number];
+ speedScale: number;
+ elevationScale: number;
+ surfaceOffset: number;
+ frameMix: number;
+ elapsedFrames: number;
+ particleCount: number;
+};
+
+const particleUniforms = {
+ name: 'windParticle',
+ vs: `layout(std140) uniform windParticleUniforms {
+ vec4 bounds;
+ float speedScale;
+ float elevationScale;
+ float surfaceOffset;
+ float frameMix;
+ float elapsedFrames;
+ float particleCount;
+} windParticle;`,
+ uniformTypes: {
+ bounds: 'vec4',
+ speedScale: 'f32',
+ elevationScale: 'f32',
+ surfaceOffset: 'f32',
+ frameMix: 'f32',
+ elapsedFrames: 'f32',
+ particleCount: 'f32'
+ }
+} as const satisfies ShaderModule;
+
+const PARTICLE_TRANSFORM_VERTEX = `#version 300 es
+#define SHADER_NAME wind-particle-gpu-transform
+precision highp float;
+
+in vec4 particlePosition;
+uniform sampler2D windFrom;
+uniform sampler2D windTo;
+out vec4 nextParticlePosition;
+out vec4 previousParticlePosition;
+
+float randomValue(vec2 value) {
+ return fract(sin(dot(value, vec2(12.9898, 78.233))) * 43758.5453);
+}
+
+void main() {
+ vec2 span = windParticle.bounds.zw - windParticle.bounds.xy;
+ vec2 uv = (particlePosition.xy - windParticle.bounds.xy) / span;
+ vec4 wind = mix(texture(windFrom, uv), texture(windTo, uv), windParticle.frameMix);
+ vec2 nextPosition = particlePosition.xy +
+ wind.xy * windParticle.speedScale * windParticle.elapsedFrames;
+ float age = particlePosition.w + windParticle.elapsedFrames;
+
+ bool respawn = wind.w < 0.5 || any(lessThan(nextPosition, windParticle.bounds.xy)) ||
+ any(greaterThan(nextPosition, windParticle.bounds.zw)) || age > 180.0;
+ if (respawn) {
+ vec2 seed = particlePosition.xy + vec2(age, float(gl_VertexID));
+ nextPosition = windParticle.bounds.xy + span * vec2(
+ randomValue(seed), randomValue(seed.yx + 7.13)
+ );
+ vec2 candidateUV = (nextPosition - windParticle.bounds.xy) / span;
+ if (texture(windFrom, candidateUV).w < 0.5) {
+ nextPosition = windParticle.bounds.xy + span * 0.5;
+ }
+ age = 0.0;
+ }
+
+ nextParticlePosition = vec4(
+ nextPosition,
+ wind.z * windParticle.elevationScale + windParticle.surfaceOffset,
+ age
+ );
+ previousParticlePosition = respawn ? nextParticlePosition : particlePosition;
+ gl_Position = vec4(0.0);
+}`;
+
+const PARTICLE_COMPUTE_SHADER = `
+struct WindParticleUniforms {
+ bounds: vec4,
+ speedScale: f32,
+ elevationScale: f32,
+ surfaceOffset: f32,
+ frameMix: f32,
+ elapsedFrames: f32,
+ 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;
+
+fn randomValue(value: vec2) -> f32 {
+ return fract(sin(dot(value, vec2(12.9898, 78.233))) * 43758.5453);
+}
+
+@compute @workgroup_size(256)
+fn main(@builtin(global_invocation_id) invocation: vec3) {
+ let index = invocation.x;
+ if (index >= u32(windParticle.particleCount)) {
+ return;
+ }
+
+ 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
+ );
+ var nextPosition = particlePosition.xy +
+ wind.xy * windParticle.speedScale * windParticle.elapsedFrames;
+ var age = particlePosition.w + windParticle.elapsedFrames;
+
+ let respawn = wind.w < 0.5 || any(nextPosition < windParticle.bounds.xy) ||
+ any(nextPosition > windParticle.bounds.zw) || age > 180.0;
+ if (respawn) {
+ let seed = particlePosition.xy + vec2(age, f32(index));
+ nextPosition = windParticle.bounds.xy + span * vec2(
+ randomValue(seed), randomValue(seed.yx + 7.13)
+ );
+ let candidateUV = (nextPosition - windParticle.bounds.xy) / span;
+ if (textureSampleLevel(windFrom, windSampler, candidateUV, 0.0).w < 0.5) {
+ nextPosition = windParticle.bounds.xy + span * 0.5;
+ }
+ age = 0.0;
+ }
+
+ nextParticlePositions[index] = vec4(
+ nextPosition,
+ wind.z * windParticle.elevationScale + windParticle.surfaceOffset,
+ age
+ );
+ previousParticlePositions[index] = select(
+ particlePosition,
+ nextParticlePositions[index],
+ respawn
+ );
+}`;
+
+/**
+ * Rasterizes one station-interpolated frame for GPU weather-texture sampling.
+ *
+ * @param field - Indexed wind field shared by the particle simulation.
+ * @param frame - Integer forecast-frame index.
+ * @param width - Weather-texture width; defaults to `128`.
+ * @param height - Weather-texture height; defaults to `64`.
+ * @returns RGBA float texels containing east, north, elevation, and coverage.
+ * @throws RangeError if either raster dimension is not a positive integer.
+ * @internal
+ */
+export function rasterizeParticleWindField(
+ field: WindField,
+ frame: number,
+ width = WIND_TEXTURE_WIDTH,
+ height = WIND_TEXTURE_HEIGHT
+): Float32Array {
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
+ throw new RangeError('A GPU wind texture requires positive integer dimensions.');
+ }
+
+ const raster = new Float32Array(width * height * 4);
+ const {bounds, speedRange} = field;
+ const speedSpan = Math.max(speedRange[1] - speedRange[0], 1);
+
+ for (let row = 0; row < height; row++) {
+ const latitude = bounds.minLat + ((row + 0.5) / height) * (bounds.maxLat - bounds.minLat);
+ for (let column = 0; column < width; column++) {
+ const longitude = bounds.minLng + ((column + 0.5) / width) * (bounds.maxLng - bounds.minLng);
+ const sample = sampleWindField(field, [longitude, latitude], frame);
+ if (!sample) {
+ continue;
+ }
+
+ const offset = (row * width + column) * 4;
+ const normalizedSpeed =
+ 0.18 + Math.sqrt(Math.max(0, (sample.speed - speedRange[0]) / speedSpan)) * 0.82;
+ const directionLength = Math.max(Math.hypot(...sample.velocity), 1e-10);
+ raster[offset] = (sample.velocity[0] / directionLength) * normalizedSpeed;
+ raster[offset + 1] = (sample.velocity[1] / directionLength) * normalizedSpeed;
+ raster[offset + 2] = sample.elevation;
+ raster[offset + 3] = 1;
+ }
+ }
+
+ return raster;
+}
+
+function getCachedParticleWindRaster(field: WindField, frame: number): Float32Array {
+ let rasters = WIND_RASTER_CACHE.get(field);
+ if (!rasters) {
+ rasters = new Map();
+ WIND_RASTER_CACHE.set(field, rasters);
+ }
+
+ let raster = rasters.get(frame);
+ if (!raster) {
+ raster = rasterizeParticleWindField(field, frame);
+ rasters.set(frame, raster);
+ }
+ return raster;
+}
+
+function createSeedPositions(field: WindField, count: number, surfaceOffset: number): Float32Array {
+ const positions = new Float32Array(count * 4);
+ const {bounds} = field;
+ const columns = Math.max(1, Math.ceil(Math.sqrt(count * 2)));
+ const rows = Math.max(1, Math.ceil(count / columns));
+
+ for (let index = 0; index < count; index++) {
+ const offset = index * 4;
+ positions[offset] =
+ bounds.minLng + (((index % columns) + 0.5) / columns) * (bounds.maxLng - bounds.minLng);
+ positions[offset + 1] =
+ bounds.minLat +
+ ((Math.floor(index / columns) + 0.5) / rows) * (bounds.maxLat - bounds.minLat);
+ positions[offset + 2] = surfaceOffset;
+ positions[offset + 3] = (index * 0.61803398875) % 180;
+ }
+
+ return positions;
+}
+
+/**
+ * GPU-resident wind simulation for WebGL transform feedback and WebGPU compute.
+ *
+ * @remarks
+ * This implementation detail is not exported from the package entry point. Use
+ * {@link ParticleLayer} as the public, backend-selecting layer API.
+ *
+ * @internal
+ */
+export class GpuParticleSimulation {
+ /** Active luma.gl rendering device. */
+ readonly device: Device;
+ /** Number of simulated GPU-resident particles. */
+ readonly particleCount: number;
+ /** Current and next station-interpolated weather textures. */
+ readonly textures: [Texture, Texture];
+ /** Ping-pong particle-position storage and vertex buffers. */
+ readonly buffers: [Buffer, Buffer];
+ /** Previous valid position used to avoid cross-map respawn trails. */
+ readonly trailBuffer: Buffer;
+
+ private readonly field: WindField;
+ private readonly transform: BufferTransform | null;
+ private readonly computation: Computation | null;
+ private readonly computeUniforms: Buffer | null;
+ private readonly uniformValues = new Float32Array(12);
+ private currentBufferIndex = 0;
+ private currentWeatherFrame = -1;
+
+ /**
+ * Allocates GPU particle buffers, cached weather textures, and the backend pipeline.
+ *
+ * @param device - Active WebGL2 or WebGPU rendering device.
+ * @param field - Indexed station forecast.
+ * @param count - Number of GPU particles to allocate.
+ * @param surfaceOffset - Initial particle elevation in meters.
+ */
+ constructor(device: Device, field: WindField, count: number, surfaceOffset: number) {
+ this.device = device;
+ this.field = field;
+ this.particleCount = Math.max(0, Math.floor(count));
+
+ const usage = Buffer.VERTEX | Buffer.STORAGE | Buffer.COPY_DST;
+ const initialPositions = createSeedPositions(field, this.particleCount, surfaceOffset);
+ this.buffers = [
+ device.createBuffer({id: 'wind-particle-positions-a', data: initialPositions, usage}),
+ device.createBuffer({
+ id: 'wind-particle-positions-b',
+ byteLength: initialPositions.byteLength,
+ usage
+ })
+ ];
+ this.trailBuffer = device.createBuffer({
+ id: 'wind-particle-trail-positions',
+ byteLength: initialPositions.byteLength,
+ usage
+ });
+ const textureProps = {
+ width: WIND_TEXTURE_WIDTH,
+ height: WIND_TEXTURE_HEIGHT,
+ format: 'rgba32float' as const,
+ sampler: {
+ minFilter: 'nearest' as const,
+ magFilter: 'nearest' as const,
+ addressModeU: 'clamp-to-edge' as const,
+ addressModeV: 'clamp-to-edge' as const
+ }
+ };
+ this.textures = [
+ device.createTexture({...textureProps, id: 'wind-particle-weather-from'}),
+ device.createTexture({...textureProps, id: 'wind-particle-weather-to'})
+ ];
+
+ if (device.type === 'webgl') {
+ this.computeUniforms = null;
+ this.computation = null;
+ this.transform = new BufferTransform(device, {
+ id: 'wind-particle-transform-feedback',
+ vs: PARTICLE_TRANSFORM_VERTEX,
+ modules: [particleUniforms],
+ bufferLayout: [{name: 'particlePosition', format: 'float32x4'}],
+ outputs: ['nextParticlePosition', 'previousParticlePosition'],
+ vertexCount: this.particleCount,
+ disableWarnings: true
+ });
+ this.transform.model.setBindings({windFrom: this.textures[0], windTo: this.textures[1]});
+ } else {
+ this.transform = null;
+ this.computeUniforms = device.createBuffer({
+ id: 'wind-particle-compute-uniforms',
+ byteLength: 48,
+ usage: Buffer.UNIFORM | Buffer.COPY_DST
+ });
+ this.computation = new Computation(device, {
+ id: 'wind-particle-compute',
+ source: PARTICLE_COMPUTE_SHADER,
+ shaderLayout: {
+ bindings: [
+ {
+ name: 'windFrom',
+ type: 'texture',
+ group: 0,
+ location: 0,
+ sampleType: 'unfilterable-float'
+ },
+ {
+ name: 'windSampler',
+ type: 'sampler',
+ group: 0,
+ location: 1,
+ samplerType: 'non-filtering'
+ },
+ {
+ name: 'windTo',
+ type: 'texture',
+ group: 0,
+ location: 2,
+ sampleType: 'unfilterable-float'
+ },
+ {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}
+ ]
+ }
+ });
+ }
+ }
+
+ /** GPU trail-source positions without synchronous or asynchronous readback. */
+ get sourceBuffer(): Buffer {
+ return this.trailBuffer;
+ }
+
+ /** Current GPU-computed particle positions. */
+ get targetBuffer(): Buffer {
+ return this.buffers[this.currentBufferIndex];
+ }
+
+ /**
+ * Advances GPU-resident particles and uploads a weather texture only when its frame changes.
+ *
+ * @param time - Fractional, cyclic weather-frame index.
+ * @param speedScale - Geographic distance per normalized simulation frame.
+ * @param elevationScale - Multiplier for interpolated station elevation.
+ * @param surfaceOffset - Separation above the station-interpolated surface.
+ * @param elapsedFrames - Elapsed, frame-rate-independent simulation steps.
+ */
+ advance(
+ time: number,
+ speedScale: number,
+ elevationScale: number,
+ surfaceOffset: number,
+ elapsedFrames: number
+ ): void {
+ if (this.particleCount === 0) {
+ return;
+ }
+
+ const frame =
+ ((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));
+ this.textures[1].writeData(
+ getCachedParticleWindRaster(this.field, (frame + 1) % this.field.frames.length)
+ );
+ this.currentWeatherFrame = frame;
+ }
+
+ const {bounds} = this.field;
+ const uniforms: ParticleUniforms = {
+ bounds: [bounds.minLng, bounds.minLat, bounds.maxLng, bounds.maxLat],
+ speedScale,
+ elevationScale,
+ surfaceOffset,
+ frameMix: ((time % 1) + 1) % 1,
+ elapsedFrames,
+ particleCount: this.particleCount
+ };
+ const input = this.buffers[this.currentBufferIndex];
+ const output = this.buffers[1 - this.currentBufferIndex];
+
+ if (this.transform) {
+ this.transform.model.shaderInputs.setProps({windParticle: uniforms});
+ this.transform.run({
+ discard: true,
+ inputBuffers: {particlePosition: input},
+ outputBuffers: {
+ nextParticlePosition: output,
+ previousParticlePosition: this.trailBuffer
+ }
+ });
+ } else if (this.computation && this.computeUniforms) {
+ this.uniformValues.set(uniforms.bounds);
+ this.uniformValues.set(
+ [
+ speedScale,
+ elevationScale,
+ surfaceOffset,
+ uniforms.frameMix,
+ elapsedFrames,
+ this.particleCount
+ ],
+ 4
+ );
+ 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,
+ nextParticlePositions: output,
+ windParticle: this.computeUniforms
+ });
+ const pass = this.device.beginComputePass({id: 'wind-particle-compute-pass'});
+ this.computation.dispatch(pass, Math.ceil(this.particleCount / PARTICLE_WORKGROUP_SIZE));
+ pass.end();
+ }
+
+ this.currentBufferIndex = 1 - this.currentBufferIndex;
+ }
+
+ /** Destroys owned particle buffers, weather textures, and the active GPU pipeline. */
+ destroy(): void {
+ 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();
+ }
+ }
+}
diff --git a/modules/geo-layers/src/wind-layer/particle-layer.ts b/modules/geo-layers/src/wind-layer/particle-layer.ts
index 1d47ca0b..bf61c95d 100644
--- a/modules/geo-layers/src/wind-layer/particle-layer.ts
+++ b/modules/geo-layers/src/wind-layer/particle-layer.ts
@@ -4,30 +4,39 @@
import {CompositeLayer, type Color, type DefaultProps, type UpdateParameters} from '@deck.gl/core';
import {LineLayer, ScatterplotLayer} from '@deck.gl/layers';
+import type {ShaderModule} from '@luma.gl/shadertools';
+import {GpuParticlePointLayer} from './gpu-particle-point-layer';
+import {GpuParticleSimulation} from './gpu-particle-simulation';
import {sampleWindField, type WindBounds, type WindField} from './wind-data';
-/** Properties for animated wind-field particle trails. */
+/**
+ * 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.
+ */
export type ParticleLayerProps = {
/** Indexed, time-varying weather station data. */
windField: WindField;
- /** Fractional weather-frame and animation time. */
+ /** Fractional, cyclic forecast and animation time; defaults to `0`. */
time?: number;
- /** Number of continuously advected particles. */
+ /** Number of GPU-resident animated particles; defaults to `2400`. */
numParticles?: number;
- /** Number of historical positions retained in each trail. */
+ /** Maximum CPU-fallback trail history; defaults to `12`. */
trailLength?: number;
- /** Geographic distance advanced per elapsed, 30-frame-per-second animation step. */
+ /** Geographic distance per 30-fps-equivalent simulation step; defaults to `0.085`. */
speedScale?: number;
- /** Screen-space trail width in pixels. */
+ /** Minimum on-screen trail width in pixels; defaults to `1.1`. */
widthMinPixels?: number;
- /** Trail color; opacity increases toward each particle's current position. */
+ /** RGBA trail color, modulated by GPU particle lifetime. */
color?: Color;
- /** Elevation multiplier applied to interpolated station elevation. */
+ /** Station-elevation multiplier; defaults to `1`. */
elevationScale?: number;
- /** Vertical separation above the shaded terrain surface, in meters. */
+ /** Separation above station-interpolated terrain in meters; defaults to `160`. */
surfaceOffset?: number;
- /** Radius in pixels of each bright, moving particle head. */
+ /** Radius of each moving particle head in pixels; defaults to `1.6`. */
pointRadiusPixels?: number;
};
@@ -38,7 +47,96 @@ type Particle = {
direction?: number;
speed?: number;
};
-type ParticleSegment = {source: ParticlePosition; target: ParticlePosition; color: Color};
+type BinaryParticleSegments = {
+ length: number;
+ attributes: {
+ getSourcePosition: {value: Float32Array; size: 3};
+ getTargetPosition: {value: Float32Array; size: 3};
+ getColor: {value: Uint8Array; size: 4};
+ };
+};
+
+/** GPU particle buffers contain float32 geographic positions, not split fp64 attributes. */
+const windParticleTrailClip = {
+ name: 'windParticleTrailClip',
+ inject: {
+ 'vs:DECKGL_FILTER_GL_POSITION': `
+ if (distance(geometry.worldPosition.xy, geometry.worldPositionAlt.xy) > 0.75) {
+ position = vec4(2.0, 2.0, 2.0, 1.0);
+ }
+ `
+ }
+} as const satisfies ShaderModule;
+
+const windParticleAgeFade = {
+ name: 'windParticleAgeFade',
+ inject: {
+ 'vs:#decl': 'in float windParticleAges;',
+ 'vs:DECKGL_FILTER_COLOR': `
+ float windFadeIn = smoothstep(0.0, 16.0, windParticleAges);
+ float windFadeOut = 1.0 - smoothstep(152.0, 180.0, windParticleAges);
+ color.a *= windFadeIn * windFadeOut;
+ `
+ }
+} as const satisfies ShaderModule;
+
+class GpuParticleTrailLayer extends LineLayer {
+ static layerName = 'GpuParticleTrailLayer';
+
+ 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,
+ windParticleTrailClip,
+ ...(this.context.device.type === 'webgl' ? [windParticleAgeFade] : [])
+ ]
+ };
+ }
+
+ use64bitPositions(): boolean {
+ return false;
+ }
+}
+
+/** 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;
@@ -84,22 +182,92 @@ function createParticlePosition(
/**
* Advects fading particle trails through a Delaunay-interpolated wind field.
*
- * Particle positions are backend independent, while built-in deck.gl line sublayers handle
- * instanced GPU rendering on both WebGL and WebGPU.
+ * Keeps particle positions and advection on the GPU: WebGL uses transform feedback and WebGPU
+ * uses a compute shader. Built-in deck.gl layers render directly from the ping-pong GPU buffers.
+ * The CPU fallback is retained for lightweight, device-free construction and unit testing.
+ *
+ * @remarks
+ * This API is a work in progress. Preserve the layer `id` while updating `time`; deck.gl
+ * transfers the existing simulation state instead of recreating the GPU particle buffers.
+ * At high densities the renderer prioritizes single-vertex particle heads over extra trails.
+ *
+ * @example
+ * ```ts
+ * new ParticleLayer({
+ * id: 'wind-particles',
+ * windField,
+ * time: 12.5,
+ * numParticles: 100_000,
+ * color: [186, 233, 223, 34]
+ * });
+ * ```
*/
export class ParticleLayer extends CompositeLayer {
static layerName = 'ParticleLayer';
static defaultProps: DefaultProps = defaultProps;
- declare state: {particles: Particle[]; lastTime: number};
+ declare state: {particles: Particle[]; lastTime: number; gpu?: GpuParticleSimulation};
/** Seeds particles reproducibly throughout the measured station coverage. */
initializeState(): void {
+ const {device} = this.context || {};
+ if (device?.type === 'webgl' || device?.type === 'webgpu') {
+ const gpu = new GpuParticleSimulation(
+ device,
+ this.props.windField,
+ this.props.numParticles,
+ this.props.surfaceOffset
+ );
+ gpu.advance(
+ this.props.time,
+ this.props.speedScale,
+ this.props.elevationScale,
+ this.props.surfaceOffset,
+ 0.2
+ );
+ this.setState({gpu, particles: [], lastTime: this.props.time});
+ return;
+ }
+
this.setState({particles: this.createParticles(), lastTime: this.props.time});
}
/** Re-seeds on field changes and advances existing trails when animation time changes. */
updateState({props, oldProps, changeFlags}: UpdateParameters): void {
+ if (this.state.gpu) {
+ if (
+ changeFlags.dataChanged ||
+ props.windField !== oldProps.windField ||
+ props.numParticles !== oldProps.numParticles
+ ) {
+ this.state.gpu.destroy();
+ const gpu = new GpuParticleSimulation(
+ this.context.device,
+ props.windField,
+ props.numParticles,
+ props.surfaceOffset
+ );
+ gpu.advance(props.time, props.speedScale, props.elevationScale, props.surfaceOffset, 0.2);
+ this.setState({gpu, particles: [], lastTime: props.time});
+ return;
+ }
+
+ if (props.time !== this.state.lastTime) {
+ const elapsedFrames =
+ Math.abs(props.time - this.state.lastTime) *
+ (WEATHER_FRAME_DURATION_MS / (1000 / PARTICLE_FRAME_RATE));
+ this.state.gpu.advance(
+ props.time,
+ props.speedScale,
+ props.elevationScale,
+ props.surfaceOffset,
+ Math.max(0.2, Math.min(elapsedFrames, 3))
+ );
+ this.setState({lastTime: props.time});
+ }
+ return;
+ }
+
if (
changeFlags.dataChanged ||
props.windField !== oldProps.windField ||
@@ -182,7 +350,12 @@ export class ParticleLayer extends CompositeLayer {
sample.elevation * elevationScale + surfaceOffset
];
- if (!sampleWindField(windField, [next[0], next[1]], time)) {
+ if (
+ next[0] < windField.bounds.minLng ||
+ next[0] > windField.bounds.maxLng ||
+ next[1] < windField.bounds.minLat ||
+ next[1] > windField.bounds.maxLat
+ ) {
particle.seed += 101.7;
particle.direction = undefined;
particle.speed = undefined;
@@ -208,31 +381,95 @@ export class ParticleLayer extends CompositeLayer {
}
}
- /** Renders independently faded, GPU-instanced trail segments. */
+ /** Streams faded particle trails to GPU layers as compact binary attributes. */
renderLayers() {
if (!this.state?.particles) {
return null;
}
- const {color, widthMinPixels, pointRadiusPixels} = this.props;
- const segments: ParticleSegment[] = [];
+ const {color, widthMinPixels, pointRadiusPixels, time} = this.props;
+ if (this.state.gpu) {
+ const {gpu} = this.state;
+ const positions = {
+ 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}
+ }
+ };
+
+ 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) {
+ return [heads];
+ }
+
+ return [
+ new GpuParticleTrailLayer(this.getSubLayerProps({id: 'trails'}), {
+ data: positions,
+ getColor: color,
+ getWidth: 1,
+ widthUnits: 'pixels',
+ widthMinPixels,
+ parameters: {depthWriteEnabled: false},
+ pickable: false
+ }),
+ heads
+ ];
+ }
+
+ const segmentCount = this.state.particles.reduce(
+ (count, particle) => count + Math.max(0, particle.positions.length - 1),
+ 0
+ );
+ const sourcePositions = new Float32Array(segmentCount * 3);
+ const targetPositions = new Float32Array(segmentCount * 3);
+ const colors = new Uint8Array(segmentCount * 4);
+ let segmentIndex = 0;
+
for (const particle of this.state.particles) {
for (let index = 1; index < particle.positions.length; index++) {
const opacity = index / Math.max(1, particle.positions.length - 1);
- segments.push({
- source: particle.positions[index - 1],
- target: particle.positions[index],
- color: [color[0], color[1], color[2], Math.round((color[3] ?? 255) * opacity)]
- });
+ sourcePositions.set(particle.positions[index - 1], segmentIndex * 3);
+ targetPositions.set(particle.positions[index], segmentIndex * 3);
+ colors.set(
+ [color[0], color[1], color[2], Math.round((color[3] ?? 255) * opacity)],
+ segmentIndex * 4
+ );
+ segmentIndex++;
}
}
+ const segments: BinaryParticleSegments = {
+ length: segmentCount,
+ attributes: {
+ getSourcePosition: {value: sourcePositions, size: 3},
+ getTargetPosition: {value: targetPositions, size: 3},
+ getColor: {value: colors, size: 4}
+ }
+ };
+
return [
- new LineLayer(this.getSubLayerProps({id: 'trails'}), {
+ new LineLayer(this.getSubLayerProps({id: 'trails'}), {
data: segments,
- getSourcePosition: segment => segment.source,
- getTargetPosition: segment => segment.target,
- getColor: segment => segment.color,
getWidth: 1,
widthUnits: 'pixels',
widthMinPixels,
@@ -247,9 +484,16 @@ export class ParticleLayer extends CompositeLayer {
radiusUnits: 'pixels',
radiusMinPixels: pointRadiusPixels,
billboard: true,
+ updateTriggers: {getPosition: time},
parameters: {depthWriteEnabled: false},
pickable: false
})
];
}
+
+ /** Releases the ping-pong particle buffers, weather textures, and GPU pipeline. */
+ finalizeState(): void {
+ this.state?.gpu?.destroy();
+ super.finalizeState(this.context);
+ }
}
diff --git a/modules/geo-layers/src/wind-layer/wind-data.ts b/modules/geo-layers/src/wind-layer/wind-data.ts
index 4f1678d9..31838dff 100644
--- a/modules/geo-layers/src/wind-layer/wind-data.ts
+++ b/modules/geo-layers/src/wind-layer/wind-data.ts
@@ -2,48 +2,97 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
-/** A weather station in the original deck.gl wind showcase dataset. */
+import Delaunator from 'delaunator';
+
+/**
+ * A weather station in the original wind-showcase dataset.
+ *
+ * @remarks
+ * This work-in-progress format uses positive-west `long` values. Use
+ * {@link getWindBounds} or {@link createWindField} to convert those values to deck.gl
+ * longitude/latitude coordinates.
+ */
export type WindStation = {
+ /** Human-readable station name. */
name: string;
+ /** Positive-west longitude from the historical station dataset. */
long: number;
+ /** Latitude in decimal degrees. */
lat: number;
+ /** Station elevation in meters. */
elv: number;
+ /** Optional International Civil Aviation Organization station identifier. */
icao?: string;
+ /** Optional US state name or abbreviation. */
state?: string;
+ /** Optional abbreviated station name. */
abbr?: string;
};
-/** West, south, east, and north bounds of a geographic wind field. */
+/** Geographic longitude/latitude coverage for a station-interpolated wind field. */
export type WindBounds = {
+ /** Westernmost geographic longitude in decimal degrees. */
minLng: number;
+ /** Southernmost latitude in decimal degrees. */
minLat: number;
+ /** Easternmost geographic longitude in decimal degrees. */
maxLng: number;
+ /** Northernmost latitude in decimal degrees. */
maxLat: number;
};
-/** Direction in eighth-turns, wind speed, and temperature at one station. */
-export type WindMeasurement = readonly [number, number, number];
+/**
+ * One station measurement: direction in eighth-turns, wind speed, and temperature.
+ *
+ * @remarks
+ * Direction `0` points east; each subsequent unit rotates by 45 degrees.
+ */
+export type WindMeasurement = readonly [direction: number, speed: number, temperature: number];
/** Three indices into a wind field's station and measurement arrays. */
-export type WindTriangle = readonly [number, number, number];
+export type WindTriangle = readonly [first: number, second: number, third: number];
-/** A time-varying Delaunay-interpolated geographic vector field. */
+/** Optional configuration for {@link createWindField}. */
+export type WindFieldOptions = {
+ /** Existing station-index triangles; omit this to compute a robust Delaunay triangulation. */
+ triangles?: readonly WindTriangle[];
+};
+
+/**
+ * A time-varying, station-interpolated geographic wind field.
+ *
+ * @remarks
+ * Construct this object with {@link createWindField} rather than assembling its spatial
+ * index manually. The reusable wind, particle, and station-surface layers share this field.
+ */
export type WindField = {
+ /** Weather stations in the original positive-west coordinate format. */
stations: readonly WindStation[];
+ /** Forecast frames, each containing one measurement per station. */
frames: readonly (readonly WindMeasurement[])[];
+ /** Robust Delaunay triangles referencing {@link WindField.stations}. */
triangles: readonly WindTriangle[];
+ /** Geographic station bounds in deck.gl longitude/latitude coordinates. */
bounds: WindBounds;
+ /** Minimum and maximum nonzero observed wind speeds. */
speedRange: readonly [number, number];
+ /** Minimum and maximum nonzero observed temperatures. */
temperatureRange: readonly [number, number];
+ /** @internal Spatial lookup shared by field sampling and GPU weather rasterization. */
spatialIndex: WindSpatialIndex;
};
-/** A sampled and temporally interpolated wind vector. */
+/** A spatially and temporally interpolated wind observation. */
export type WindSample = {
+ /** Counterclockwise wind direction in radians, measured from the east. */
direction: number;
+ /** Interpolated wind speed in the dataset's original units. */
speed: number;
+ /** Interpolated temperature in the dataset's original units. */
temperature: number;
+ /** Interpolated station elevation in meters. */
elevation: number;
+ /** Eastward and northward velocity components. */
velocity: [number, number];
};
@@ -53,12 +102,31 @@ type WindSpatialIndex = {
cells: number[][];
};
-type Circumcircle = {x: number; y: number; radiusSquared: number};
type Point = readonly [number, number];
const EPSILON = 1e-10;
-
-/** Parses the station-major, 72-hour binary format used by the original wind showcase. */
+const WIND_DIRECTION_EAST = Float64Array.from({length: 8}, (_, index) =>
+ Math.cos((index * Math.PI) / 4)
+);
+const WIND_DIRECTION_NORTH = Float64Array.from({length: 8}, (_, index) =>
+ Math.sin((index * Math.PI) / 4)
+);
+
+/**
+ * Decodes the station-major binary forecast from the historical wind showcase.
+ *
+ * @param buffer - Unsigned 16-bit forecast data in station-major order.
+ * @param stationCount - Number of stations represented by each forecast frame.
+ * @param frameCount - Number of forecast frames; the original dataset contains 72.
+ * @returns Frame-major direction, speed, and temperature measurements.
+ * @throws RangeError if the forecast dimensions or binary length are invalid.
+ *
+ * @example
+ * ```ts
+ * const weather = await fetch('/weather.bin').then(response => response.arrayBuffer());
+ * const frames = parseWindData(weather, stations.length);
+ * ```
+ */
export function parseWindData(
buffer: ArrayBuffer,
stationCount: number,
@@ -87,7 +155,14 @@ export function parseWindData(
);
}
-/** Calculates geographic bounds for stations whose legacy longitudes are positive-west. */
+/**
+ * Converts legacy positive-west station coordinates into geographic field bounds.
+ *
+ * @param stations - Historical station records.
+ * @returns Western, southern, eastern, and northern geographic coverage.
+ * @throws RangeError if no station is provided.
+ * @throws TypeError if any station coordinate is not finite.
+ */
export function getWindBounds(stations: readonly WindStation[]): WindBounds {
if (stations.length === 0) {
throw new RangeError('A wind field requires at least one station.');
@@ -112,85 +187,33 @@ export function getWindBounds(stations: readonly WindStation[]): WindBounds {
return {minLng, minLat, maxLng, maxLat};
}
-function getCircumcircle(a: Point, b: Point, c: Point): Circumcircle | null {
- const determinant = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1]));
- if (Math.abs(determinant) < EPSILON) {
- return null;
- }
- const aSquared = a[0] * a[0] + a[1] * a[1];
- const bSquared = b[0] * b[0] + b[1] * b[1];
- const cSquared = c[0] * c[0] + c[1] * c[1];
- const x =
- (aSquared * (b[1] - c[1]) + bSquared * (c[1] - a[1]) + cSquared * (a[1] - b[1])) / determinant;
- const y =
- (aSquared * (c[0] - b[0]) + bSquared * (a[0] - c[0]) + cSquared * (b[0] - a[0])) / determinant;
- return {x, y, radiusSquared: (x - a[0]) ** 2 + (y - a[1]) ** 2};
-}
-
-/** Builds a dependency-free Delaunay triangulation of weather-station positions. */
+/**
+ * Builds a robust Delaunay triangulation of positive-west weather stations.
+ *
+ * @remarks
+ * Duplicate coordinates are ignored. Fewer than three distinct non-collinear positions
+ * produce an empty triangulation.
+ *
+ * @param stations - Historical station records in measurement-array order.
+ * @returns Station-index triangles covering the valid station hull.
+ * @throws TypeError if any station coordinate is not finite.
+ */
export function triangulateWindStations(stations: readonly WindStation[]): WindTriangle[] {
if (stations.length < 3) {
return [];
}
- const bounds = getWindBounds(stations);
- const centerX = (bounds.minLng + bounds.maxLng) / 2;
- const centerY = (bounds.minLat + bounds.maxLat) / 2;
- const span = Math.max(bounds.maxLng - bounds.minLng, bounds.maxLat - bounds.minLat, 1);
- const points: Point[] = stations.map(station => [-station.long, station.lat]);
- const count = points.length;
- points.push(
- [centerX - 32 * span, centerY - span],
- [centerX, centerY + 32 * span],
- [centerX + 32 * span, centerY - span]
+ getWindBounds(stations);
+ const {triangles} = Delaunator.from(
+ Array.from(stations),
+ station => -station.long,
+ station => station.lat
);
- let triangles: WindTriangle[] = [[count, count + 1, count + 2]];
- const seenPoints = new Set();
-
- for (let pointIndex = 0; pointIndex < count; pointIndex++) {
- const point = points[pointIndex];
- const pointKey = `${point[0]},${point[1]}`;
- if (seenPoints.has(pointKey)) {
- continue;
- }
- seenPoints.add(pointKey);
-
- const surviving: WindTriangle[] = [];
- const boundary = new Map();
-
- for (const triangle of triangles) {
- const circle = getCircumcircle(points[triangle[0]], points[triangle[1]], points[triangle[2]]);
- const inside =
- circle !== null &&
- (point[0] - circle.x) ** 2 + (point[1] - circle.y) ** 2 <= circle.radiusSquared + EPSILON;
-
- if (!inside) {
- surviving.push(triangle);
- continue;
- }
-
- for (const [start, end] of [
- [triangle[0], triangle[1]],
- [triangle[1], triangle[2]],
- [triangle[2], triangle[0]]
- ] as [number, number][]) {
- const edgeKey = start < end ? `${start}:${end}` : `${end}:${start}`;
- if (boundary.has(edgeKey)) {
- boundary.delete(edgeKey);
- } else {
- boundary.set(edgeKey, [start, end]);
- }
- }
- }
-
- for (const [start, end] of boundary.values()) {
- surviving.push([start, end, pointIndex]);
- }
- triangles = surviving;
- }
-
- return triangles.filter(triangle => triangle.every(index => index < count));
+ return Array.from({length: triangles.length / 3}, (_, index) => {
+ const offset = index * 3;
+ return [triangles[offset], triangles[offset + 1], triangles[offset + 2]];
+ });
}
function getRange(
@@ -245,11 +268,25 @@ function createSpatialIndex(
return {columns, rows, cells};
}
-/** Creates the shared, indexed wind field consumed by all wind showcase layers. */
+/**
+ * Creates the indexed, time-varying wind field shared by the reusable wind layers.
+ *
+ * @param stations - Weather stations using the historical positive-west format.
+ * @param frames - Frame-major measurements with one observation per station.
+ * @param options - Optional precomputed station triangulation.
+ * @returns Geographic bounds, station triangles, observed ranges, and a spatial index.
+ * @throws RangeError if fewer than three stations are supplied or a frame is misaligned.
+ *
+ * @example
+ * ```ts
+ * const frames = parseWindData(weather, stations.length);
+ * const field = createWindField(stations, frames);
+ * ```
+ */
export function createWindField(
stations: readonly WindStation[],
frames: readonly (readonly WindMeasurement[])[],
- options: {triangles?: readonly WindTriangle[]} = {}
+ options: WindFieldOptions = {}
): WindField {
if (stations.length < 3) {
throw new RangeError('A wind field requires at least three stations.');
@@ -273,25 +310,42 @@ export function createWindField(
function getBarycentricWeights(
position: Point,
- a: Point,
- b: Point,
- c: Point
+ a: WindStation,
+ b: WindStation,
+ c: WindStation
): [number, number, number] | null {
- const determinant = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
+ const ax = -a.long;
+ const ay = a.lat;
+ const bx = -b.long;
+ const by = b.lat;
+ const cx = -c.long;
+ const cy = c.lat;
+ const determinant = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy);
if (Math.abs(determinant) < EPSILON) {
return null;
}
- const first =
- ((b[1] - c[1]) * (position[0] - c[0]) + (c[0] - b[0]) * (position[1] - c[1])) / determinant;
- const second =
- ((c[1] - a[1]) * (position[0] - c[0]) + (a[0] - c[0]) * (position[1] - c[1])) / determinant;
+ const first = ((by - cy) * (position[0] - cx) + (cx - bx) * (position[1] - cy)) / determinant;
+ const second = ((cy - ay) * (position[0] - cx) + (ax - cx) * (position[1] - cy)) / determinant;
const third = 1 - first - second;
return first >= -EPSILON && second >= -EPSILON && third >= -EPSILON
? [first, second, third]
: null;
}
-/** Samples the wind field using spatial Delaunay and circular temporal interpolation. */
+/**
+ * Samples a wind field with barycentric spatial and circular temporal interpolation.
+ *
+ * @param field - Indexed wind field returned by {@link createWindField}.
+ * @param position - Geographic `[longitude, latitude]` position.
+ * @param time - Fractional forecast-frame index; values wrap in either direction.
+ * @returns The interpolated observation, or `null` outside the station hull.
+ *
+ * @example
+ * ```ts
+ * const sample = sampleWindField(field, [-97, 38], 12.5);
+ * console.log(sample?.velocity, sample?.elevation);
+ * ```
+ */
export function sampleWindField(field: WindField, position: Point, time = 0): WindSample | null {
const {bounds, spatialIndex, triangles, stations, frames} = field;
if (
@@ -332,9 +386,9 @@ export function sampleWindField(field: WindField, position: Point, time = 0): Wi
const triangle = triangles[triangleIndex];
const weights = getBarycentricWeights(
position,
- [-stations[triangle[0]].long, stations[triangle[0]].lat],
- [-stations[triangle[1]].long, stations[triangle[1]].lat],
- [-stations[triangle[2]].long, stations[triangle[2]].lat]
+ stations[triangle[0]],
+ stations[triangle[1]],
+ stations[triangle[2]]
);
if (!weights) {
continue;
@@ -351,10 +405,16 @@ export function sampleWindField(field: WindField, position: Point, time = 0): Wi
const from = frames[frameIndex][stationIndex];
const to = frames[nextFrameIndex][stationIndex];
const weight = weights[vertex];
- const fromAngle = (from[0] * Math.PI) / 4;
- const toAngle = (to[0] * Math.PI) / 4;
- east += weight * ((1 - frameMix) * Math.cos(fromAngle) + frameMix * Math.cos(toAngle));
- north += weight * ((1 - frameMix) * Math.sin(fromAngle) + frameMix * Math.sin(toAngle));
+ const fromDirection = from[0] % WIND_DIRECTION_EAST.length;
+ const toDirection = to[0] % WIND_DIRECTION_EAST.length;
+ east +=
+ weight *
+ ((1 - frameMix) * WIND_DIRECTION_EAST[fromDirection] +
+ frameMix * WIND_DIRECTION_EAST[toDirection]);
+ north +=
+ weight *
+ ((1 - frameMix) * WIND_DIRECTION_NORTH[fromDirection] +
+ frameMix * WIND_DIRECTION_NORTH[toDirection]);
speed += weight * ((1 - frameMix) * from[1] + frameMix * to[1]);
temperature += weight * ((1 - frameMix) * from[2] + frameMix * to[2]);
elevation += weight * stations[stationIndex].elv;
@@ -364,12 +424,13 @@ export function sampleWindField(field: WindField, position: Point, time = 0): Wi
return null;
}
const direction = Math.atan2(north, east);
+ const directionLength = Math.hypot(east, north);
return {
direction,
speed,
temperature,
elevation,
- velocity: [Math.cos(direction) * speed, Math.sin(direction) * speed]
+ velocity: [(east / directionLength) * speed, (north / directionLength) * speed]
};
}
diff --git a/modules/geo-layers/src/wind-layer/wind-layer.ts b/modules/geo-layers/src/wind-layer/wind-layer.ts
index 672b1097..fd512688 100644
--- a/modules/geo-layers/src/wind-layer/wind-layer.ts
+++ b/modules/geo-layers/src/wind-layer/wind-layer.ts
@@ -7,27 +7,33 @@ import {PathLayer, SolidPolygonLayer} from '@deck.gl/layers';
import {sampleWindField, type WindField} from './wind-data';
-/** Properties for rendering geographic wind vectors as directional arrow glyphs. */
+/**
+ * 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.
+ */
export type WindLayerProps = {
/** Indexed, time-varying weather station data. */
windField: WindField;
- /** Fractional weather-frame time. Frame indices wrap automatically. */
+ /** Fractional, automatically wrapping forecast-frame index; defaults to `0`. */
time?: number;
- /** Number of arrow samples in the longitudinal direction. */
+ /** Longitudinal arrow-sample count; defaults to `64`. */
gridWidth?: number;
- /** Number of arrow samples in the latitudinal direction. */
+ /** Latitudinal arrow-sample count; defaults to `32`. */
gridHeight?: number;
- /** Geographic length multiplier for wind arrows. */
+ /** Geographic arrow-length multiplier; defaults to `0.65`. */
speedScale?: number;
- /** Minimum on-screen arrow width in pixels. */
+ /** Minimum on-screen arrow width in pixels; defaults to `1.25`. */
widthMinPixels?: number;
- /** Color used for slower wind vectors. */
+ /** RGBA color for the minimum observed wind speed. */
lowColor?: Color;
- /** Color used for faster wind vectors. */
+ /** RGBA color for the maximum observed wind speed. */
highColor?: Color;
- /** Elevation multiplier applied to sampled station elevations. */
+ /** Multiplier for station elevation in meters; defaults to `1`. */
elevationScale?: number;
- /** Vertical separation above the terrain surface, in meters. */
+ /** Separation above the station-interpolated terrain in meters; defaults to `200`. */
surfaceOffset?: number;
};
@@ -60,12 +66,30 @@ function mixColor(from: Color, to: Color, factor: number): Color {
];
}
-/** Renders the original wind showcase's directional field as reusable deck.gl arrow glyphs. */
+/**
+ * Renders the historical wind showcase as sampled, speed-colored directional arrows.
+ *
+ * @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.
+ *
+ * @example
+ * ```ts
+ * new WindLayer({
+ * id: 'wind-arrows',
+ * windField,
+ * time: 12.5,
+ * gridWidth: 40,
+ * gridHeight: 22
+ * });
+ * ```
+ */
export class WindLayer extends CompositeLayer {
static layerName = 'WindLayer';
static defaultProps: DefaultProps = defaultProps;
- /** Samples the field and renders arrow shafts and directional arrowheads. */
+ /** Samples valid station coverage and builds filled, shaft, and arrowhead sublayers. */
renderLayers() {
const {
windField,
diff --git a/modules/geo-layers/test/imports.node.spec.ts b/modules/geo-layers/test/imports.node.spec.ts
index e45c9ff8..2996bae6 100644
--- a/modules/geo-layers/test/imports.node.spec.ts
+++ b/modules/geo-layers/test/imports.node.spec.ts
@@ -4,6 +4,7 @@
import {it, expect} from 'vitest';
import * as GeoLayers from '../src/index';
+import type {WindFieldOptions} from '../src/index';
import * as SharedTilesetSurface from '../src/tileset/index';
it('exports TileSourceLayer', () => {
@@ -33,11 +34,17 @@ it('exports the reusable wind showcase layers and field utilities', () => {
expect(GeoLayers.ElevationLayer).toBeDefined();
expect(GeoLayers.DelaunayInterpolation).toBeDefined();
expect(GeoLayers.createWindField).toBeDefined();
+ expect(GeoLayers.getWindBounds).toBeDefined();
expect(GeoLayers.parseWindData).toBeDefined();
expect(GeoLayers.sampleWindField).toBeDefined();
expect(GeoLayers.triangulateWindStations).toBeDefined();
});
+it('exports typed wind field options', () => {
+ const options: WindFieldOptions = {triangles: [[0, 1, 2]]};
+ expect(options.triangles).toEqual([[0, 1, 2]]);
+});
+
it('exports grid systems', () => {
expect(GeoLayers.H3Grid).toBeDefined();
expect(GeoLayers.S2Grid).toBeDefined();
diff --git a/modules/geo-layers/test/wind-layer/gpu-particle-simulation.spec.ts b/modules/geo-layers/test/wind-layer/gpu-particle-simulation.spec.ts
new file mode 100644
index 00000000..82f0551e
--- /dev/null
+++ b/modules/geo-layers/test/wind-layer/gpu-particle-simulation.spec.ts
@@ -0,0 +1,59 @@
+// deck.gl-community
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {describe, expect, it} from 'vitest';
+
+import {rasterizeParticleWindField} from '../../src/wind-layer/gpu-particle-simulation';
+import {createWindField, type WindStation} from '../../src/wind-layer/wind-data';
+
+const STATIONS: WindStation[] = [
+ {name: 'southwest', long: 2, lat: 0, elv: 10},
+ {name: 'southeast', long: 0, lat: 0, elv: 20},
+ {name: 'northwest', long: 2, lat: 2, elv: 30},
+ {name: 'northeast', long: 0, lat: 2, elv: 40}
+];
+
+const FIELD = createWindField(STATIONS, [
+ [
+ [0, 10, 20],
+ [0, 20, 30],
+ [0, 30, 40],
+ [0, 40, 50]
+ ],
+ [
+ [2, 20, 30],
+ [2, 30, 40],
+ [2, 40, 50],
+ [2, 50, 60]
+ ]
+]);
+
+describe('GPU wind-particle rasterization', () => {
+ it('encodes normalized eastward wind, terrain elevation, and valid coverage', () => {
+ const raster = rasterizeParticleWindField(FIELD, 0, 3, 3);
+ const center = (1 * 3 + 1) * 4;
+
+ expect(raster).toHaveLength(3 * 3 * 4);
+ expect(raster[center]).toBeGreaterThan(0);
+ expect(raster[center + 1]).toBeCloseTo(0);
+ expect(raster[center + 2]).toBeCloseTo(25);
+ expect(raster[center + 3]).toBe(1);
+ });
+
+ it('updates wind direction for a new hourly weather texture', () => {
+ const first = rasterizeParticleWindField(FIELD, 0, 1, 1);
+ const next = rasterizeParticleWindField(FIELD, 1, 1, 1);
+
+ expect(first[0]).toBeGreaterThan(0);
+ expect(first[1]).toBeCloseTo(0);
+ expect(next[0]).toBeCloseTo(0);
+ expect(next[1]).toBeGreaterThan(0);
+ expect(next[3]).toBe(1);
+ });
+
+ it('rejects invalid GPU weather-texture dimensions', () => {
+ expect(() => rasterizeParticleWindField(FIELD, 0, 0, 8)).toThrow('positive integer');
+ expect(() => rasterizeParticleWindField(FIELD, 0, 8, 1.5)).toThrow('positive integer');
+ });
+});
diff --git a/modules/geo-layers/test/wind-layer/wind-data.spec.ts b/modules/geo-layers/test/wind-layer/wind-data.spec.ts
index 4c6c8f6f..b0f6894f 100644
--- a/modules/geo-layers/test/wind-layer/wind-data.spec.ts
+++ b/modules/geo-layers/test/wind-layer/wind-data.spec.ts
@@ -66,6 +66,43 @@ describe('wind showcase data', () => {
expect(new Set(triangles.flat())).toEqual(new Set([0, 1, 2, 3]));
});
+ it('preserves a skinny, non-collinear station hull and its interpolated wind', () => {
+ const stations: WindStation[] = [
+ {name: 'northwest', long: 74.7453, lat: 38.9114, elv: 120},
+ {name: 'southwest', long: 86.4623, lat: 29.6477, elv: 180},
+ {name: 'northeast', long: 65.4474, lat: 46.4987, elv: 240}
+ ];
+ const field = createWindField(stations, [
+ [
+ [0, 12, 20],
+ [0, 18, 26],
+ [0, 24, 32]
+ ]
+ ]);
+ const center: [number, number] = [
+ -(stations[0].long + stations[1].long + stations[2].long) / 3,
+ (stations[0].lat + stations[1].lat + stations[2].lat) / 3
+ ];
+
+ expect(field.triangles).toHaveLength(1);
+ expect(new Set(field.triangles[0])).toEqual(new Set([0, 1, 2]));
+ const sample = sampleWindField(field, center, 0);
+ expect(sample).not.toBeNull();
+ expect(sample?.speed).toBeCloseTo(18);
+ expect(sample?.temperature).toBeCloseTo(26);
+ expect(sample?.elevation).toBeCloseTo(180);
+ });
+
+ it('returns no faces for genuinely collinear stations', () => {
+ const stations: WindStation[] = [
+ {name: 'southwest', long: 3, lat: 0, elv: 10},
+ {name: 'center', long: 2, lat: 1, elv: 20},
+ {name: 'northeast', long: 1, lat: 2, elv: 30}
+ ];
+
+ expect(triangulateWindStations(stations)).toEqual([]);
+ });
+
it('ignores duplicate station coordinates without creating degenerate triangles', () => {
const triangles = triangulateWindStations([...STATIONS, {...STATIONS[0], name: 'duplicate'}]);
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 6b2d64f3..0da4c3d5 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
@@ -8,6 +8,7 @@ import {webgl2Adapter} from '@luma.gl/webgl';
import {webgpuAdapter} from '@luma.gl/webgpu';
import {describe, expect, it} from 'vitest';
+import {GpuParticleSimulation} from '../../src/wind-layer/gpu-particle-simulation';
import {
createWindField,
DelaunayCoverLayer,
@@ -45,7 +46,25 @@ function createElevationData(): string {
return canvas.toDataURL('image/png');
}
-function createLayers(field: WindField, time: number, elevationData: string) {
+function createLayers(
+ field: WindField,
+ time: number,
+ elevationData: string,
+ deviceType: 'webgl' | 'webgpu'
+) {
+ const particles = new ParticleLayer({
+ id: 'test-wind-particles',
+ windField: field,
+ time,
+ numParticles: 4096,
+ trailLength: 4,
+ elevationScale: 2
+ });
+
+ if (deviceType === 'webgpu') {
+ return [particles];
+ }
+
return [
new ElevationLayer({
id: 'test-wind-height-map',
@@ -64,14 +83,7 @@ function createLayers(field: WindField, time: number, elevationData: string) {
gridHeight: 6,
elevationScale: 2
}),
- new ParticleLayer({
- id: 'test-wind-particles',
- windField: field,
- time,
- numParticles: 24,
- trailLength: 4,
- elevationScale: 2
- })
+ particles
];
}
@@ -112,6 +124,7 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise {
let device: Device | undefined;
let deck: Deck | undefined;
+ let particleLayer: ParticleLayer | undefined;
const elevationData = createElevationData();
try {
@@ -134,11 +147,13 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise {
height: 120,
views: new MapView({id: 'wind-test'}),
initialViewState: {longitude: -98, latitude: 37, zoom: 4},
- layers: createLayers(field, 0, elevationData),
+ layers: createLayers(field, 0, elevationData, type),
onAfterRender: () => {
if (!renderedAnimation) {
renderedAnimation = true;
- deck?.setProps({layers: createLayers(field, 0.5, elevationData)});
+ const layers = createLayers(field, 0.5, elevationData, type);
+ particleLayer = layers.find(layer => layer instanceof ParticleLayer) as ParticleLayer;
+ deck?.setProps({layers});
return;
}
window.clearTimeout(timeout);
@@ -152,6 +167,32 @@ async function renderWindLayers(type: 'webgl' | 'webgpu'): Promise {
});
expect(deck.device?.type).toBe(type);
+ expect(particleLayer?.state.gpu).toBeInstanceOf(GpuParticleSimulation);
+ expect(particleLayer?.state.gpu?.particleCount).toBe(4096);
+ expect(particleLayer?.state.particles).toHaveLength(0);
+ expect(particleLayer?.state.gpu?.targetBuffer.byteLength).toBe(4096 * 4 * 4);
+ if (type === 'webgl' && particleLayer?.state.gpu) {
+ const sourceBytes = particleLayer.state.gpu.sourceBuffer.readSyncWebGL();
+ const targetBytes = particleLayer.state.gpu.targetBuffer.readSyncWebGL();
+ const sources = new Float32Array(
+ sourceBytes.buffer,
+ sourceBytes.byteOffset,
+ sourceBytes.byteLength / Float32Array.BYTES_PER_ELEMENT
+ );
+ const targets = new Float32Array(
+ targetBytes.buffer,
+ targetBytes.byteOffset,
+ targetBytes.byteLength / Float32Array.BYTES_PER_ELEMENT
+ );
+
+ for (let index = 0; index < sources.length; index += 4) {
+ expect(Number.isFinite(sources[index])).toBe(true);
+ expect(Number.isFinite(targets[index])).toBe(true);
+ expect(
+ Math.hypot(sources[index] - targets[index], sources[index + 1] - targets[index + 1])
+ ).toBeLessThan(0.5);
+ }
+ }
} finally {
deck?.finalize();
device?.destroy();
@@ -164,7 +205,7 @@ describe('wind showcase rendering', () => {
await renderWindLayers('webgl');
}, 20_000);
- it('renders and animates terrain, arrows, and particles on WebGPU', async ({skip}) => {
+ it('computes and animates GPU-resident wind 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 0c580e6c..6be01add 100644
--- a/modules/geo-layers/test/wind-layer/wind-layers.spec.ts
+++ b/modules/geo-layers/test/wind-layer/wind-layers.spec.ts
@@ -170,15 +170,31 @@ describe('reusable wind showcase layers', () => {
};
const [trails, heads] = layer.renderLayers() as [LineLayer, ScatterplotLayer];
- const segments = trails.props.data as {color: number[]}[];
+ const segments = trails.props.data as {
+ length: number;
+ attributes: {
+ getSourcePosition: {value: Float32Array; size: number};
+ getTargetPosition: {value: Float32Array; size: number};
+ getColor: {value: Uint8Array; size: number};
+ };
+ };
expect(trails).toBeInstanceOf(LineLayer);
expect(heads).toBeInstanceOf(ScatterplotLayer);
expect(trails.props.id).toBe('trails');
- expect(segments).toHaveLength(2);
- expect(segments[0].color[3]).toBeLessThan(segments[1].color[3]);
- expect(segments[1].color).toEqual([100, 150, 200, 180]);
+ expect(segments.length).toBe(2);
+ expect(segments.attributes.getSourcePosition.value).toEqual(
+ new Float32Array([-1.5, 0.5, 10, -1.25, 0.5, 12])
+ );
+ expect(segments.attributes.getTargetPosition.value).toEqual(
+ new Float32Array([-1.25, 0.5, 12, -1, 0.5, 14])
+ );
+ expect(segments.attributes.getColor.value[3]).toBeLessThan(
+ segments.attributes.getColor.value[7]
+ );
+ expect([...segments.attributes.getColor.value.slice(4, 8)]).toEqual([100, 150, 200, 180]);
expect(heads.props.data).toHaveLength(1);
+ expect(heads.props.updateTriggers).toMatchObject({getPosition: 0});
});
it('does not render particles before their simulation is initialized', () => {
diff --git a/website/src/components/docs/layer-live-example.jsx b/website/src/components/docs/layer-live-example.jsx
index e689c662..25f7267b 100644
--- a/website/src/components/docs/layer-live-example.jsx
+++ b/website/src/components/docs/layer-live-example.jsx
@@ -159,6 +159,15 @@ async function mountLayerDocsExample(container, highlight, mountProps = {}) {
);
return mountSharedTile2DLayerExample(container, {mode: 'compact', showInfoWidget: false});
}
+ case 'delaunay-cover-layer':
+ case 'delaunay-interpolation':
+ case 'elevation-layer':
+ case 'particle-layer':
+ case 'wind-field':
+ case 'wind-layer': {
+ const {mountWindExample} = await import('../../../../examples/geo-layers/wind/app');
+ return mountWindExample(container, mountProps);
+ }
case 'global-grid-layer':
return mountGlobalGridLayerExample(container);
case 'tile-source-layer':
diff --git a/website/src/examples/geo-layers/wind.mdx b/website/src/examples/geo-layers/wind.mdx
index 9688ecc7..a69602d5 100644
--- a/website/src/examples/geo-layers/wind.mdx
+++ b/website/src/examples/geo-layers/wind.mdx
@@ -6,3 +6,20 @@ hide_title: true
import Demo from './wind';
+
+:::caution Work in progress
+The reusable wind showcase and its layer APIs are experimental. GPU particle simulation is
+browser-tested on WebGL2 and WebGPU; the complete terrain-and-arrows scene remains WebGL2-first
+while upstream terrain, polygon, and path compatibility is in progress.
+:::
+
+The example recreates
+[Nicolas Belmonte's original wind showcase](https://github.com/visgl/deck.gl/tree/master/showcases/wind/src)
+using the historical 72-hour forecast, image-derived three-dimensional mountain terrain,
+interpolated wind arrows, and reusable
+[`@deck.gl-community/geo-layers`](/docs/modules/geo-layers/).
+
+Use the particle-density slider to select between 1,000 and 1,000,000 GPU-animated particles.
+Right-drag, Control-drag, or use a touch rotation gesture to tilt and rotate the terrain. See the
+[complete wind showcase guide](/docs/modules/geo-layers/developer-guide/wind-showcase) for the
+data format, source code, GPU architecture, and backend limitations.
diff --git a/yarn.lock b/yarn.lock
index a69dfa81..423de0f0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -526,6 +526,7 @@ __metadata:
"@probe.gl/stats": "npm:^4.1.1"
"@probe.gl/test-utils": "npm:^4.1.1"
a5-js: "npm:^0.5.0"
+ delaunator: "npm:^5.1.0"
h3-js: "npm:^4.2.1"
peerDependencies:
"@deck.gl/core": ~9.3.0
@@ -7258,6 +7259,15 @@ __metadata:
languageName: node
linkType: hard
+"delaunator@npm:^5.1.0":
+ version: 5.1.0
+ resolution: "delaunator@npm:5.1.0"
+ dependencies:
+ robust-predicates: "npm:^3.0.2"
+ checksum: 10c0/6489e3598212ab8759575e30f3ac26063471846e25c779048f441f2ccefd25efb9a52275e7e8e3dcc5bf5bc5c35169cf1de27f985d359fd5a24057be88fd1817
+ languageName: node
+ linkType: hard
+
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"