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 - -![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square") -![WebGPU not supported](https://img.shields.io/badge/webgpu-no-red.svg?style=flat-square") +![deck.gl v9](https://img.shields.io/badge/deck.gl-v9-green.svg?style=flat-square) +![WebGPU partially supported](https://img.shields.io/badge/webgpu-partial-yellow.svg?style=flat-square) This module provides a suite of 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) from v9.3 experimental 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"