diff --git a/README.md b/README.md index e12f008da..be3ab257b 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,9 @@ An awesome list of community-driven modules, integrations, and tools built with | **[arrow-layers](./modules/arrow-layers)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/arrow-layers.svg)](https://www.npmjs.com/package/@deck.gl-community/arrow-layers) | Apache Arrow visualization layers — render large columnar datasets directly on the GPU. | | **[editable-layers](./modules/editable-layers)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/editable-layers.svg)](https://www.npmjs.com/package/@deck.gl-community/editable-layers) | Interactive drawing and editing layers for geospatial features. | | **[geo-layers](./modules/geo-layers)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/geo-layers.svg)](https://www.npmjs.com/package/@deck.gl-community/geo-layers) | Suite of geospatial layers covering tile formats, terrain, and more. | -| **[layers](./modules/layers)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/layers.svg)](https://www.npmjs.com/package/@deck.gl-community/layers) | Additional community-contributed layers not yet in the official deck.gl release. | +| **[layers](./modules/layers)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/layers.svg)](https://www.npmjs.com/package/@deck.gl-community/layers) | Additional community-contributed layers, including the native procedural `TreeLayer`. | | **[infovis-layers](./modules/infovis-layers)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/infovis-layers.svg)](https://www.npmjs.com/package/@deck.gl-community/infovis-layers) | Information visualization layers for non-geospatial, chart-style rendering. | | **[graph-layers](./modules/graph-layers)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/graph-layers.svg)](https://www.npmjs.com/package/@deck.gl-community/graph-layers) | Graph and network visualization layers for deck.gl. | -| **[three](./modules/three)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/three.svg)](https://www.npmjs.com/package/@deck.gl-community/three) | Three.js-backed layers for deck.gl, including a fully parametric 3D `TreeLayer`. | | **[widgets](./modules/widgets)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/widgets.svg)](https://www.npmjs.com/package/@deck.gl-community/widgets) | UI widgets (pan, zoom range, etc.) that integrate with deck.gl view state management. | | **[react](./modules/react)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/react.svg)](https://www.npmjs.com/package/@deck.gl-community/react) | React components and hooks for building deck.gl applications. | | **[react-fiber](./modules/react-fiber)** | [![npm](https://img.shields.io/npm/v/@deck.gl-community/react-fiber.svg)](https://www.npmjs.com/package/@deck.gl-community/react-fiber) | React Fiber renderer for composing deck.gl layers and views as React elements. | diff --git a/docs/modules/layers/README.md b/docs/modules/layers/README.md index 56d87a139..c48c408bd 100644 --- a/docs/modules/layers/README.md +++ b/docs/modules/layers/README.md @@ -32,6 +32,8 @@ Release date: 2023 ## Exports +- [`TreeLayer`](./api-reference/tree-layer.md) (WebGL2 and WebGPU) + - [`FlameTrailLayer`](./api-reference/flame-trail-layer.md) (WebGL2 and WebGPU) - `DependencyArrowLayer` - `PathMarkerLayer` @@ -45,6 +47,8 @@ Release date: 2023 ## Examples +- [Tree Lab](/examples/layers/tree-lab) + - [FlameTrailLayer](/examples/layers/flame-trail) - [Path outline, marker, and dependency arrow](/examples/layers/path-outline-and-markers) - [SkyboxLayer MapView](/examples/layers/skybox-map-view) diff --git a/docs/modules/layers/api-reference/tree-layer.md b/docs/modules/layers/api-reference/tree-layer.md new file mode 100644 index 000000000..016588f02 --- /dev/null +++ b/docs/modules/layers/api-reference/tree-layer.md @@ -0,0 +1,62 @@ +# TreeLayer + +An instanced, procedural tree layer built entirely with deck.gl, luma.gl and math.gl. Five species share cached CPU meshes; winter deciduous trees expose branching crowns. Trunks and crowns always use the highest-quality geometry. No Three.js runtime dependency is required. + +```ts +import {TreeLayer} from '@deck.gl-community/layers'; + +new TreeLayer({ + data: trees, + getPosition: tree => tree.position, + getTreeType: tree => tree.species, + getHeight: tree => tree.height, + getSeason: () => 'summer', + windStrength: 0.025, + pickable: true +}); +``` + +Try [Tree Lab](/examples/layers/tree-lab) for synchronized original/native specimens of every species, seasonal controls, supplied crop points, shadows and wind. + +## Properties + +Inherits the deck.gl `CompositeLayer` properties. Accessors are functions of the source data object. + +| Property | Default | Meaning | +| --- | --- | --- | +| `getPosition` | `d => d.position` | Longitude/latitude position of the tree base; the host coordinate system also supports local coordinates. | +| `getElevation` | `() => 0` | Base elevation in metres. | +| `getTreeType` | `() => 'pine'` | `pine`, `oak`, `palm`, `birch` or `cherry`. | +| `getHeight` | `() => 10` | Total nominal height in metres. | +| `getTrunkHeightFraction` | `() => 0.35` | Trunk fraction, clamped to 0–1. Palm shafts extend into the crown. | +| `getTrunkRadius` | `() => 0.5` | Trunk base radius in metres. | +| `getCanopyRadius` | `() => 3` | Horizontal canopy scale, retaining the legacy approximate half-scale broadleaf radius. Palms and pines have their own silhouette envelopes. | +| `getTrunkColor` | `() => null` | Explicit bark color or the species default. | +| `getCanopyColor` | `() => null` | Explicit foliage color or the species/season default. Winter branches use bark color. Vertex tones multiply this color. | +| `getSeason` | `() => 'summer'` | `spring`, `summer`, `autumn` or `winter`. Winter removes deciduous foliage; pine and palm remain evergreen. | +| `getBranchLevels` | `() => 3` | Pine tier count, rounded and clamped to 1–5. | +| `getCrop` | `() => null` | Explicit crop configuration; the layer never invents seasonal yield. | +| `sizeScale` | `1` | Multiplier for tree and crop dimensions. | +| `windStrength` | `0` | Bend amplitude as a fraction of tree height. Zero stops continuous redraw; a value around `0.025` is a gentle breeze. | +| `windTime` | `null` | Wind time in seconds. `null` uses deck.gl's timeline; a number freezes the pose for controlled comparisons. | +| `shadowEnabled` | `true` | Cast shadows when enabled by the host `LightingEffect`. | + +## Crops + +`CropConfig` has `color`, `count`, `radius` and optional `droppedCount`. Radius is the actual sphere radius in metres. Counts are explicitly supplied, rounded down and clamped at zero. Broadleaf crops follow the shared outer crown envelope; palm crops cluster beneath the crown. Dropped spheres rest on the ground. Positions are deterministic, remain stable when counts grow, and picking returns the owning source tree. Attached crops share the tree's GPU bend; dropped crops remain still. + +## Animation and shadows + +Wind is implemented in GLSL and WGSL. Trunks, crowns, attached crops, picking and WebGL shadow passes use the same deformation. Time changes do not rebuild geometry, regroup data or regenerate instance attributes. + +For WebGL shadows, supply a deck.gl `LightingEffect` with a `DirectionalLight` whose `_shadow` option is enabled, and a receiving ground surface. Shadows are an extra rendering pass. WebGPU shadow effects are not supported by deck.gl; Tree Lab disables that control on WebGPU. + +## Migration + +Remove `detail` from TreeLayer props and `TreeDetail` from type imports. Trunks and crowns now always use the former high-detail meshes. Legacy demo `detail` query parameters are ignored. + +The legacy `@deck.gl-community/three` package has been removed from this repository. Replace its imports with `@deck.gl-community/layers`, including `TreeLayerProps`, `TreeType`, `Season` and `CropConfig`. Previously published package versions are unchanged. Three.js is used only by Tree Lab's frozen development fixture. + +Native crowns and winter silhouettes deliberately differ from the original geometry. `CropConfig.radius` now matches its documented radius: older geometry rendered half that radius. Halve an existing crop radius to retain its old apparent size. Inspect your explicit seasonal foliage overrides, canopy sublayer overrides and custom winter branches when migrating; the native crown sublayer IDs include foliage/winter and tier information. Legacy species overrides such as `canopy-cherry` remain supported and apply after native defaults. Exact grouped sublayer IDs take precedence over species aliases. Override accessors receive the source tree and its original accessor context; parameters and update triggers merge by key. + +[Source](https://github.com/visgl/deck.gl-community/tree/master/modules/layers/src/tree-layer) diff --git a/docs/modules/layers/sidebar.json b/docs/modules/layers/sidebar.json index 82ddb53ea..8d5cd9b7e 100644 --- a/docs/modules/layers/sidebar.json +++ b/docs/modules/layers/sidebar.json @@ -7,6 +7,7 @@ "modules/layers/api-reference/skybox-layer", "modules/layers/api-reference/dependency-arrow-layer", "modules/layers/api-reference/path-marker-layer", - "modules/layers/api-reference/path-outline-layer" + "modules/layers/api-reference/path-outline-layer", + "modules/layers/api-reference/tree-layer" ] } diff --git a/docs/modules/three/README.md b/docs/modules/three/README.md deleted file mode 100644 index e31aaa872..000000000 --- a/docs/modules/three/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Overview - -![WebGPU supported](https://img.shields.io/badge/webgpu-yes-green.svg?style=flat-square) - -A collection of deck.gl layers powered by [Three.js](https://threejs.org/), giving access to Three.js geometry primitives and scene graph tooling directly inside deck.gl visualisations. - -```bash -npm install @deck.gl-community/three -``` - -## Layers - -| Layer | Description | -|-------|-------------| -| [`TreeLayer`](/docs/modules/three/api-reference/tree-layer) | Procedural 3D trees with 5 species silhouettes, season colours, and crop/fruit visualisation | diff --git a/docs/modules/three/api-reference/tree-layer.md b/docs/modules/three/api-reference/tree-layer.md deleted file mode 100644 index 9034a797e..000000000 --- a/docs/modules/three/api-reference/tree-layer.md +++ /dev/null @@ -1,154 +0,0 @@ -import LayerLiveExample from '@site/src/components/docs/layer-live-example'; - -# TreeLayer - - - -Renders richly configurable 3D trees at geographic positions using procedural geometry generated with Three.js `BufferGeometry` primitives and rendered via deck.gl's `SimpleMeshLayer`. - -```tsx -import {TreeLayer} from '@deck.gl-community/three'; - -new TreeLayer({ - id: 'trees', - data: myForestData, - getPosition: d => d.coordinates, - getTreeType: d => d.species, - getHeight: d => d.heightMetres, - getSeason: () => 'autumn', - sizeScale: 1, - pickable: true -}); -``` - -Try the [Seasonal Farm demo](/examples/three/seasonal-farm): seven labelled plots with -varied trees, a season control, and hover details. Planting and seasonal traits are illustrative. - -## Features - -- **5 tree species / silhouettes**: pine (tiered cones), oak (sphere), date palm (ring-scarred trunk and pinnate fronds), birch (narrow oval), cherry (round sphere) -- **Organic canopy geometry**: smooth low-frequency vertex jitter baked into each species mesh at init time — no runtime cost, no mesh gaps -- **Per-tree variety**: position-derived random bearing and asymmetric XY scale give every instance a unique silhouette with zero extra draw calls -- **Parametric geometry**: per-instance height, trunk-to-canopy ratio, trunk radius, canopy radius -- **Season-driven colours**: spring / summer / autumn / winter palettes with species-specific defaults -- **Explicit colour overrides**: `getTrunkColor` and `getCanopyColor` accessors for full control -- **Pine tier density**: `getBranchLevels` (1–5) controls the number of overlapping cone tiers; each tier drifts progressively for a windswept look -- **Crop / fruit / flower visualisation**: `getCrop` places coloured spheres in the outer canopy volume and scattered on the ground beneath the tree -- **Global scale factor**: `sizeScale` multiplier for easy zoom-level adjustment - -## Properties - -Inherits from all [Base Layer](https://deck.gl/docs/api-reference/core/layer) properties. - -### Geometry - -#### `getPosition` (Accessor<Position>, optional) - -Tree base position as `[longitude, latitude]`. Default: `d => d.position`. - -#### `getElevation` (Accessor<number>, optional) - -Base elevation in metres above sea level. Default: `0`. - -#### `getTreeType` (Accessor<TreeType>, optional) - -Silhouette variant: `'pine'` | `'oak'` | `'palm'` | `'birch'` | `'cherry'`. Default: `'pine'`. - -#### `getHeight` (Accessor<number>, optional) - -Total tree height in metres. Default: `10`. - -#### `getTrunkHeightFraction` (Accessor<number>, optional) - -Fraction of total height occupied by the trunk (0–1). Default: `0.35`. - -#### `getTrunkRadius` (Accessor<number>, optional) - -Trunk base radius in metres. Default: `0.5`. - -#### `getCanopyRadius` (Accessor<number>, optional) - -Canopy horizontal radius in metres. Default: `3`. - -#### `getBranchLevels` (Accessor<number>, optional) - -Number of cone tiers for pine trees (1–5). Higher values produce a denser layered silhouette. Default: `3`. - -#### `sizeScale` (number, optional) - -Global size multiplier applied to all dimensions. Default: `1`. - -### Colour - -#### `getTrunkColor` (Accessor<Color | null>, optional) - -Explicit trunk colour `[r, g, b, a]`. When `null`, the species default is used. Default: `null`. - -#### `getCanopyColor` (Accessor<Color | null>, optional) - -Explicit canopy colour `[r, g, b, a]`. When `null`, the season × species default is used. Default: `null`. - -#### `getSeason` (Accessor<Season>, optional) - -Season used to pick the default canopy colour: `'spring'` | `'summer'` | `'autumn'` | `'winter'`. Default: `'summer'`. - -### Crops - -#### `getCrop` (Accessor<CropConfig | null>, optional) - -Crop configuration per tree. Return a `CropConfig` to render small spherical crop points in the outer canopy volume (live crops) and/or scattered on the ground around the trunk (dropped crops). Return `null` for no crops. Default: `null`. - -## Types - -### TreeType - -```ts -type TreeType = 'pine' | 'oak' | 'palm' | 'birch' | 'cherry'; -``` - -### Season - -```ts -type Season = 'spring' | 'summer' | 'autumn' | 'winter'; -``` - -### CropConfig - -```ts -type CropConfig = { - color: Color; // Colour of each crop sphere [r, g, b, a] - count: number; // Crop spheres in the outer canopy volume (live / in-tree) - droppedCount?: number; // Crop spheres on the ground (dropped / fallen). Default: 0 - radius: number; // Radius of each crop sphere in metres (scaled by sizeScale) -}; -``` - -### Crop example - -```tsx -import type {CropConfig} from '@deck.gl-community/three'; - -// Orange orchard -const citrusCrop: CropConfig = { - color: [255, 140, 0, 255], - count: 30, - droppedCount: 10, - radius: 0.12 -}; -``` - -Crop positions are seeded deterministically from each tree's geographic coordinates, so they are stable across re-renders and `sizeScale` changes. - -## Default canopy colours - -| Species | Spring | Summer | Autumn | Winter | -|---------|--------|--------|--------|--------| -| pine | `[34, 100, 34]` | `[0, 64, 0]` | `[0, 64, 0]` | `[0, 55, 0]` | -| oak | `[100, 180, 80]` | `[34, 120, 15]` | `[180, 85, 20]` | `[100, 80, 60]` (alpha 160) | -| palm | `[50, 160, 50]` | `[20, 145, 20]` | `[55, 150, 30]` | `[40, 130, 30]` | -| birch | `[150, 210, 110]` | `[80, 160, 60]` | `[230, 185, 40]` | `[180, 180, 170]` (alpha 90) | -| cherry | `[255, 180, 205]` | `[50, 140, 50]` | `[200, 60, 40]` | `[120, 90, 80]` (alpha 110) | - -## Source - -[modules/three/src/tree-layer](https://github.com/visgl/deck.gl-community/tree/master/modules/three/src/tree-layer) diff --git a/docs/modules/three/sidebar.json b/docs/modules/three/sidebar.json deleted file mode 100644 index 2942e3c22..000000000 --- a/docs/modules/three/sidebar.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "category", - "label": "@deck.gl-community/three", - "items": [ - "modules/three/README", - "modules/three/api-reference/tree-layer" - ] -} diff --git a/docs/upgrade-guide.md b/docs/upgrade-guide.md index fbd99afb5..540c90890 100644 --- a/docs/upgrade-guide.md +++ b/docs/upgrade-guide.md @@ -6,6 +6,17 @@ Please refer the documentation of each module for detailed upgrade guides. ## Unreleased +- Remove `detail` from `TreeLayer` props and `TreeDetail` from type imports. Trunks and crowns + always use the former high-detail meshes; legacy demo `detail` query parameters are ignored. + +- The `@deck.gl-community/three` compatibility package has been removed from the repository. + Replace its imports with `@deck.gl-community/layers`, including `TreeLayer`, `TreeLayerProps`, + `TreeType`, `Season` and `CropConfig`. Previously published versions are unchanged. + Existing TreeLayer documentation and Seasonal Farm URLs redirect to their new locations. + `CropConfig.radius` now renders the actual radius in metres; halve an existing radius to keep + its old apparent size. Review changed crowns, winter branches and canopy sublayer overrides + against the [TreeLayer migration notes](./modules/layers/api-reference/tree-layer.md#migration). + ### `@deck.gl-community/editable-layers` Edit modes now consistently accept `SimpleFeatureCollection`, which supports Point, diff --git a/docs/webgpu.md b/docs/webgpu.md index e4159e861..c2e265e15 100644 --- a/docs/webgpu.md +++ b/docs/webgpu.md @@ -42,7 +42,7 @@ deck.gl-community is adding WebGPU support incrementally while continuing to sup | `@deck.gl-community/editable-layers` | GeoJSON paths, polygons, and edit handles | ✅ | ✅ | Browser-verified `EditableGeoJsonLayer` rendering in `ModifyMode`, including the WebGPU picking-width shader path. | | `@deck.gl-community/editable-layers` | Editing and selection interactions | ✅ | 🚧 | Pointer, drag, snapping, and selection behavior still require browser interaction coverage on WebGPU. | | `@deck.gl-community/basemap-layers` | `BasemapLayer` | ✅ | 🚧 | Support depends on the selected style's polygon, path, and label sublayers. | -| `@deck.gl-community/three` | `TreeLayer` | ✅ | ✅ | Browser-verified procedural Three.js geometry rendered through upstream `SimpleMeshLayer`. | +| `@deck.gl-community/layers` | `TreeLayer` | ✅ | ✅ | Native vis.gl meshes and GLSL/WGSL wind through `SimpleMeshLayer`. deck.gl shadow effects remain WebGL2-only. | | `@deck.gl-community/leaflet` | Leaflet map overlay | ✅ | ❌ | A host-owned WebGL context cannot be switched to WebGPU. | | `@deck.gl-community/bing-maps` | Bing Maps overlay | ✅ | ❌ | A host-owned WebGL context cannot be switched to WebGPU. | | `@deck.gl-community/widgets` | `DeviceManagerController` and `DeviceTabsWidget` | ✅ | ✅ | Selects and attaches an independently managed real rendering device. | diff --git a/docs/whats-new.md b/docs/whats-new.md index a62e07ac2..dcbd6aa9b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## Unreleased +- [Tree Forest](/examples/layers/tree-forest) explores 10,000 or 20,000 native trees with wind, seasonal sunlight, shadows and a canopy flyover. A live stats display reports draw pacing; matched benchmark entries share its camera and report actual instance counts and warmed samples. + +- The `@deck.gl-community/three` compatibility package is removed from the repository. Import `TreeLayer` and its types from `@deck.gl-community/layers`. [Seasonal Farm](/examples/layers/seasonal-farm) now lives with the native layers examples. Old documentation and example links redirect to the new paths. Tree Lab retains its frozen Three.js comparison fixture. + +- `TreeLayer` moves to `@deck.gl-community/layers` with native vis.gl geometry, branching winter crowns, crop placement against species envelopes, optional GPU wind and highest-quality shared meshes. [Tree Lab](/examples/layers/tree-lab) compares every species against the original renderer; see the [migration notes](/docs/modules/layers/api-reference/tree-layer#migration) for crop radius and seasonal overrides. + - `SelectionLayer` polygon selection masks the full picking rectangle, fixing false positives outside wide, diagonal, and concave lassos. It keeps GPU picking and initializes the temporary mask synchronously instead of waiting 250 ms. @@ -85,11 +91,11 @@ - `MarkerLayer` is exported from graph-layers and has a website gallery example using workspace dependencies. -- [Seasonal Farm](/examples/three/seasonal-farm) shows seven labelled plots with varied trees, +- [Seasonal Farm](/examples/layers/seasonal-farm) shows seven labelled plots with varied trees, seasonal crops, and hover details. - [`@deck.gl-community/playground`](/docs/modules/playground) adds an installable panels-based JSON editor and live preview surface, with a standalone [deck.gl playground example](/examples/playground). -- [`TreeLayer`](/docs/modules/three/api-reference/tree-layer) fixes tree orientation and +- [`TreeLayer`](/docs/modules/layers/api-reference/tree-layer) fixes tree orientation and reuses pine geometry when attributes change. ## v9.4 @@ -213,7 +219,7 @@ Highlights: ### `@deck.gl-community/three` -- [`TreeLayer`](/docs/modules/three/api-reference/tree-layer) improves the `palm` silhouette with a +- [`TreeLayer`](/docs/modules/layers/api-reference/tree-layer) improves the `palm` silhouette with a detailed frond crown and ring-scarred trunk, and now supports WebGPU. ## v9.3 @@ -268,9 +274,9 @@ A new experimental basemap module for rendering style-defined basemaps directly New module for THREE.js integration experiments. -- [`TreeLayer`](/docs/modules/three/api-reference/tree-layer) (new) renders varied, seasonal 3D +- [`TreeLayer`](/docs/modules/layers/api-reference/tree-layer) (new) renders varied, seasonal 3D forests with five tree silhouettes and optional crops. See the - [Seasonal Farm example](https://github.com/visgl/deck.gl-community/tree/master/examples/three/seasonal-farm). + [Seasonal Farm example](https://github.com/visgl/deck.gl-community/tree/master/examples/layers/seasonal-farm). ## v9.2 diff --git a/examples/three/seasonal-farm/README.md b/examples/layers/seasonal-farm/README.md similarity index 95% rename from examples/three/seasonal-farm/README.md rename to examples/layers/seasonal-farm/README.md index 4120bd2a8..ba8ae57c8 100644 --- a/examples/three/seasonal-farm/README.md +++ b/examples/layers/seasonal-farm/README.md @@ -12,7 +12,7 @@ From the repository root: ```sh yarn -yarn --cwd examples/three/seasonal-farm start +yarn --cwd examples/layers/seasonal-farm start ``` TreeLayer loads from source. The same demo runs standalone, on the website, and in the diff --git a/examples/three/seasonal-farm/app.browser.spec.ts b/examples/layers/seasonal-farm/app.browser.spec.ts similarity index 99% rename from examples/three/seasonal-farm/app.browser.spec.ts rename to examples/layers/seasonal-farm/app.browser.spec.ts index 1e4864de5..478ff8fa9 100644 --- a/examples/three/seasonal-farm/app.browser.spec.ts +++ b/examples/layers/seasonal-farm/app.browser.spec.ts @@ -10,7 +10,7 @@ import { type WebMercatorViewport, type PickingInfo } from '@deck.gl/core'; -import {TreeLayer} from '@deck.gl-community/three'; +import {TreeLayer} from '@deck.gl-community/layers'; import {luma, type Device} from '@luma.gl/core'; import {webgl2Adapter} from '@luma.gl/webgl'; import {webgpuAdapter} from '@luma.gl/webgpu'; diff --git a/examples/three/seasonal-farm/app.tsx b/examples/layers/seasonal-farm/app.tsx similarity index 99% rename from examples/three/seasonal-farm/app.tsx rename to examples/layers/seasonal-farm/app.tsx index f1234aa91..cb64e9869 100644 --- a/examples/three/seasonal-farm/app.tsx +++ b/examples/layers/seasonal-farm/app.tsx @@ -14,7 +14,7 @@ import { } from '@deck.gl/core'; import {LineLayer, PolygonLayer, TextLayer} from '@deck.gl/layers'; import type {Device} from '@luma.gl/core'; -import {TreeLayer, type Season} from '@deck.gl-community/three'; +import {TreeLayer, type Season} from '@deck.gl-community/layers'; import { createFarmPlots, SEASONS, diff --git a/examples/three/seasonal-farm/farm-data.spec.ts b/examples/layers/seasonal-farm/farm-data.spec.ts similarity index 100% rename from examples/three/seasonal-farm/farm-data.spec.ts rename to examples/layers/seasonal-farm/farm-data.spec.ts diff --git a/examples/three/seasonal-farm/farm-data.ts b/examples/layers/seasonal-farm/farm-data.ts similarity index 99% rename from examples/three/seasonal-farm/farm-data.ts rename to examples/layers/seasonal-farm/farm-data.ts index ade17eaec..a0bc0ba72 100644 --- a/examples/three/seasonal-farm/farm-data.ts +++ b/examples/layers/seasonal-farm/farm-data.ts @@ -4,7 +4,7 @@ import {addMetersToLngLat} from '@math.gl/web-mercator'; import type {Color} from '@deck.gl/core'; -import type {CropConfig, Season, TreeType} from '@deck.gl-community/three'; +import type {CropConfig, Season, TreeType} from '@deck.gl-community/layers'; export type Species = 'date' | 'orange' | 'cherry' | 'pine' | 'birch' | 'cork-oak' | 'almond'; export type FarmTree = { diff --git a/examples/three/seasonal-farm/index.html b/examples/layers/seasonal-farm/index.html similarity index 100% rename from examples/three/seasonal-farm/index.html rename to examples/layers/seasonal-farm/index.html diff --git a/examples/three/seasonal-farm/index.tsx b/examples/layers/seasonal-farm/index.tsx similarity index 100% rename from examples/three/seasonal-farm/index.tsx rename to examples/layers/seasonal-farm/index.tsx diff --git a/examples/three/seasonal-farm/package.json b/examples/layers/seasonal-farm/package.json similarity index 91% rename from examples/three/seasonal-farm/package.json rename to examples/layers/seasonal-farm/package.json index 0aa8410e8..919c0a983 100644 --- a/examples/three/seasonal-farm/package.json +++ b/examples/layers/seasonal-farm/package.json @@ -5,7 +5,7 @@ "start-local": "vite --config ../../vite.config.local.mjs" }, "dependencies": { - "@deck.gl-community/three": "workspace:*", + "@deck.gl-community/layers": "workspace:*", "@deck.gl/core": "~9.4.0", "@deck.gl/layers": "~9.4.0", "@deck.gl/mesh-layers": "~9.4.0", diff --git a/examples/three/seasonal-farm/style.css b/examples/layers/seasonal-farm/style.css similarity index 100% rename from examples/three/seasonal-farm/style.css rename to examples/layers/seasonal-farm/style.css diff --git a/examples/three/seasonal-farm/tsconfig.json b/examples/layers/seasonal-farm/tsconfig.json similarity index 100% rename from examples/three/seasonal-farm/tsconfig.json rename to examples/layers/seasonal-farm/tsconfig.json diff --git a/examples/three/seasonal-farm/vite-env.d.ts b/examples/layers/seasonal-farm/vite-env.d.ts similarity index 100% rename from examples/three/seasonal-farm/vite-env.d.ts rename to examples/layers/seasonal-farm/vite-env.d.ts diff --git a/examples/three/seasonal-farm/vite.config.ts b/examples/layers/seasonal-farm/vite.config.ts similarity index 85% rename from examples/three/seasonal-farm/vite.config.ts rename to examples/layers/seasonal-farm/vite.config.ts index c7c42e6f9..20754d677 100644 --- a/examples/three/seasonal-farm/vite.config.ts +++ b/examples/layers/seasonal-farm/vite.config.ts @@ -14,7 +14,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ resolve: { alias: { - '@deck.gl-community/three': path.resolve(__dirname, '../../../modules/three/src/index.ts') + '@deck.gl-community/layers': path.resolve(__dirname, '../../../modules/layers/src/index.ts') } }, server: { diff --git a/examples/layers/tree-lab/README.md b/examples/layers/tree-lab/README.md new file mode 100644 index 000000000..726747893 --- /dev/null +++ b/examples/layers/tree-lab/README.md @@ -0,0 +1,21 @@ +# Tree Lab + +Run `yarn workspace @deck.gl-community/tree-lab-example start` from the repository root. Every species has a synchronized original/native pair. Seasons, attached and dropped crops, optional WebGL shadows and wind are independently controlled. The resolution control compares a fast 1× render with a sharp 2× render; performance entries always use 1×. Live wind pauses for offscreen specimens and hidden documents. The time slider freezes repeatable poses. The original fixture is frozen from master c8b25275 (9.4.2). + +Native wind runs in the vertex shader on both WebGL and WebGPU. The original renderer has no wind feature and is labelled static. Set `?backend=webgpu` to inspect that backend; shadows are disabled because deck.gl's shadow effect remains WebGL-only. + +Open `forest.html` for an exact 10,000 or 20,000 tree forest with all five species, stable procedural positions and varied dimensions. The scene always uses highest-quality native geometry and animates GPU wind, sunlight, seasons and a canopy flyover. Reduced-motion preferences start all three animations off; controls and an explicit `wind=1` opt in. Increasing the count extends the same forest without moving existing trees. Switch to overview to fit the full forest, drag to explore, or independently toggle wind and shadows. Stats shows a rolling draw count and p95 draw interval, updating once per second; turn it off for a clean presentation. Overview refits on resize. The matching website example is `/examples/layers/tree-forest`. + +For comparable performance, use `native.html` and `baseline.html` with identical `count`, `species`, `season`, `crops`, `shadows` and `wind` query parameters. Add `species=mixed` to benchmark the identical forest rows and dimensions. Legacy `detail` query parameters are ignored. Counts are capped at 20,000 and the raw result records the actual instance count; 20K is never silently reduced to 10K. The mixed forest shares the live canopy camera; add `view=overview` to fit the entire dataset in both entries. Each page runs one renderer. Keep the page visible and focused, pause other renderers, and use **Run three samples**, which automatically warms up for two seconds before three five-second camera orbits. Compare draw rates, median/p95 frame delivery, synchronous render-call wall time, long tasks and actual backend/device. Record a one-tree control before and after the matrix to detect scheduling changes. Overview refits on resize; a viewport change during measurement invalidates the sample and requires a new run. Draw counts do not certify presented frames. Neither browser frame intervals nor synchronous render-call wall time measures GPU execution time; do not infer cross-device speed from them. `wind=1` cannot be an equivalent workload because the original has no animation. + +`yarn workspace @deck.gl-community/tree-lab-example build` creates production benchmark entries. The native entry never loads the development-only Three.js fixture. Images and measurements in the review report identify the build, viewport and actual renderer. + +The default Auto tour moves the sunlight through a full rotation and cycles spring, summer, autumn and winter every 32 seconds. Manual season or sun-angle controls pause the tour; reduced-motion preferences start with the tour off. Shadow toggles retain the trees while switching the matched lighting effect. + +Open `film.html` for a 1920×1080 side-by-side tour. Each species gets 12 seconds and visits every season while the sun and camera move. Record a four-second proof before the full 60-second capture. A fixed 30 fps clock drives both renderers; each requested frame waits for both GPU draws before capture. WebCodecs encodes every requested frame with integer presentation timestamps; the download is VP9/IVF. Convert it with `ffmpeg -i tree-lab-60s.ivf -c:v libx264 -crf 20 -pix_fmt yuv420p -movflags +faststart tree-lab-60s.mp4` and verify before sharing. Slow rendering increases export time without dropping seasonal stages. + +See [review evidence](REVIEW.md) for the film, seasonal contact sheet, measured geometry budgets and qualified performance samples. + +Forest pitch is adjustable through 80°. The shared lab lighting fits a finite ground/crown slab, rejects shadow-map samples outside its volume and fades the far boundary; it covers the demo’s trees below 32m without changing the camera pitch. + +The shared WebGL lighting follows [NVIDIA's percentage-closer filtering](https://developer.nvidia.com/gpugems/gpugems/part-ii-lighting-and-shadows/chapter-11-shadow-map-antialiasing): native depth comparison samplers compare before interpolating. Four hardware reads reproduce a three-texel tent on tree surfaces. The lab's flat ground uses a reusable, half-resolution coverage mask, filtered horizontally and vertically once per light, then sampled once per ground fragment. This softens small leaflet gaps and silhouette edges without filtering raw depths. Depth maps have a fixed 1024-pixel longest axis, independent of display DPR; the depth pass skips material shading. The ground filter is specific to the lab's plane at -2cm; arbitrary terrain still needs receiver-aware filtering. WebGL regressions cover fractional leaf motion, narrow gaps versus large openings, resized texture replacement, shadow toggles and high pitches in every season. diff --git a/examples/layers/tree-lab/REVIEW.md b/examples/layers/tree-lab/REVIEW.md new file mode 100644 index 000000000..d0e2a52f4 --- /dev/null +++ b/examples/layers/tree-lab/REVIEW.md @@ -0,0 +1,69 @@ +# Tree Lab review evidence + +The current layer always uses the former high-detail trunk and canopy meshes. Detail selectors and query inputs are removed. Medium/low measurements below are historical; their source fingerprints identify the measured versions. + +[Watch the 60-second comparison](../../../website/static/videos/tree-lab-native.mp4) or inspect [all 20 seasonal stages](../../../website/static/videos/tree-lab-seasons.jpg). The website Tree Lab page embeds the film above the interactive comparison. Original geometry is frozen from master c8b25275 (9.4.2). Both columns share camera, light, dimensions and supplied crop configuration; the original has no wind and is labelled static. + +The film includes pine, oak, palm, birch and cherry, with 12 seconds per species and three seconds per season. Sunlight rotates, shadows move, and native trunks, crowns and attached crops bend. The final download contains exactly 1,800 paired and encoded frames with zero application rendering errors. Converted H.264 output is 1920×1080 at 30 fps for 60 seconds; a full decode and presentation timestamp check passed. All 20 tree/season combinations were inspected in the contact sheet. Film SHA-256: `d2cde8d23de2b970a29471bd2ee035e2b69c0c956f93197b41ddac0912d539dd`. + +The refreshed film uses native comparison depth maps and softened ground coverage in both columns. Winter leaders meet the trunk at its physical top radius without the former independent pitch, then smoothly taper into the branch structure. Cached mesh rings and an instance radius keep the join dimension-aware without per-tree mesh allocation. + +## Geometry and performance + +Native palm canopy triangles decrease from 4,448 to 1,760 at high detail, 1,224 at medium and 648 at low. Broadleaf high detail adds triangles to smooth the crown; low detail reduces them. Native geometry uses shared bounded CPU caches and instanced deck.gl meshes. Wind changes uniforms without regrouping trees or regenerating instance attributes. Production implementation chunks measure 101,543 bytes for the frozen original and 23,873 bytes for native; shared deck.gl/luma infrastructure is excluded. + +[Raw retained samples and geometry budgets](review.json) record the actual device and workload. On one Apple M4 Pro through ANGLE Metal, with one production renderer at a time, three five-second 90° camera orbits at 1236×680 and 1× pixels delivered: + +| Workload | Three median frame intervals | Three p95 frame intervals | Long tasks/errors | +| --- | --- | --- | --- | +| Original, 5,000 palms | 15.9 / 16.2 / 15.7 ms | 17.5 / 17.3 / 17.6 ms | 0 / 0 | +| Native high, 5,000 palms | 8.3 / 8.3 / 8.3 ms | 9.2 / 16.7 / 9.2 ms | 0 / 0 | +| Native high, 1,000 oaks + 44,000 crops + wind + filtered WebGL shadows | 8.3 / 8.3 / 8.3 ms | 9.1 / 9.1 / 9.3 ms | 0 / 0 | + +These are browser delivery intervals, not GPU execution time or universal FPS claims. Earlier warm original palm medians varied down to 9.3 ms; oak medians were 8.3 ms for both renderers. Scheduling and system load matter. These samples precede the final picking, override, recorder, shadow depth and winter join fixes. No mobile or physical-device performance claim is made. + +## Verification boundaries + +Root package/type/declaration build, Node tests, website build, Tree Lab typecheck/build and lint pass. The Tree Lab browser regression uses actual WebGL pixels to check all ten surfaces through shadows on → off → on, all five native species in four seasons, reduced winter foliage, changed native wind with static original fixtures, and synchronous owning-tree picking. Ground-only browser captures also check material/opacity-independent shadow footprints at four sun angles, including native trees in a standard LightingEffect host. The old shader fails this regression; the fixed shader passes. Geometry, trunk join and crop/cache/update-trigger tests check independent source contracts. PR #794 also verified the former compatibility export with an exact-constructor identity test; the stacked package-removal PR deletes that export and test. + +Actual non-fallback WebGPU rendering and wind were separately inspected in the hardware browser. Headless WebGPU cases skip when no adapter exists. deck.gl shadows are WebGL-only. Async picking readback, automated encoder error/concurrency recovery, numerical shader normals and cross-device performance remain unverified. Manual film capture is separate from those automated test lanes. + +Reproduce with the commands and matched benchmark entries in [README](README.md). + +## 10K / 20K forest + +`forest.html` and the website Tree Forest example use deterministic procedural positions, varied dimensions and all five species. The count controls allocate exactly 10,000 or 20,000 rows. Wind, shadows, seasons and sunlight are independent; the default flyover inspects crowns, while overview fits the full dataset. Browser checks render both counts, every season and shadow state, preserve default flyover during initialization and project the overview corners inside the viewport. + +[Retained scale samples](forest-review.json) record source/build fingerprints, exact instance counts and the camera shared with the live canopy view. One production WebGL2 renderer ran at a time on Apple M4 Pro/ANGLE Metal, at 1236×680 and 1× pixels. Each workload used a two-second warmup followed by three five-second 90° camera orbits. Every retained sample started visible and focused and ended visible. No builds or automated rendering tests ran during measurement. + +| Workload | 10,000 trees: median draws/s (range) | 20,000 trees: median draws/s (range) | +| --- | --- | --- | +| Original, high detail, static, shadows off | 98.0 (96.8–98.3) | 50.5 (44.7–52.7) | +| Native, high detail, static, shadows off | 119.8 (119.8–119.8) | 68.6 (65.1–74.1) | +| Native, medium detail, wind and shadows on | 72.3 (63.2–73.3) | 40.4 (39.1–44.5) | +| Native, medium detail, wind on, shadows off | — | 93.4 (92.8–93.6) | + +The one-tree control before the matrix delivered 119.8–120.1 draws/s, with p95 delivery intervals of 9.1–9.3 ms. Native 10K static samples approach that browser scheduling limit. All retained workloads had zero rendering errors and zero recorded long tasks. For the medium-detail animated workload, p95 frame intervals ranged from 17.1–25.0 ms at 10K and 25.7–33.3 ms at 20K. Disabling shadows at 20K reduced that range to 17.0–17.1 ms while retaining wind and all tree instances. + +These samples precede the final count-stability layout and reduced-motion default changes; their source/build fingerprints identify the measured version. The mesh renderer and detail budgets are unchanged. These are submitted draw rates and browser delivery intervals, not GPU execution time or proof of presentation. The raw report also includes synchronous render-call wall time; this measures CPU/driver submission, not GPU elapsed time. The original has no wind feature, so animated native results are not a matched speedup comparison. The current visual tour always uses the former high-detail meshes; medium-detail timings do not describe its workload. + +The earlier provisional matrix is retained in Git history at `7134bcf916be37faab029d76231e60cc768755d4`; it used a different camera and a scheduling-limited host and must not be combined with these samples. The browser approval service became unavailable before an ending control, low-detail result, winter performance sample or overview performance sample could be saved. Automated rendering checks cover every season at 20K; those checks establish rendering behavior, not seasonal performance. No mobile, peak-throughput or cross-device performance claim is made. + +### High-pitch shadows + +Screen-corner ground intersections flipped behind the camera above the horizon, making local shadows disappear at 71.5°. The shared Tree Lab lighting now bounds forward rays, includes the 0–32m crown slab and rejects out-of-volume samples with a soft boundary. A physical 2cm receiver bias preserves branch shadows as the light volume grows. Projection work is shared across layers; no extra shadow pass is added. A WebGL pixel regression checks local shadow coverage and absence of distant phantom shadows at nine pitches from 58° to 80°, including the exact horizon, all four seasons and two sun directions, with a fixed wind pose. The prior implementation fails that regression. The 20K forest also renders shadows on/off at 80° through its visible pitch control. This is an example lighting fix; an ordinary upstream LightingEffect retains its own frustum fitting. + + +### Softer ground shadows and native comparison maps + +The lab now uses depth-only maps with hardware comparison filtering. A fixed 1024-pixel longest axis caps shadow work independently of display DPR; the depth pass skips material shading. Tree surfaces use four hardware PCF reads. Flat ground uses one lookup into a half-resolution coverage mask, blurred in two reusable passes per light. A 45cm Gaussian scale blends narrow leaflet gaps and softens edges while preserving larger openings. This optimization is specific to the lab's plane at -2cm. + +Matched single-renderer camera-orbit samples on Apple M4 Pro / ANGLE Metal, 1236×680 at 1×, with wind and shadows on: + +| Workload | Previous filter, draws/s | Updated filter, draws/s | +| --- | --- | --- | +| 10K, medium | 36.3–37.8 | 76.5–80.8 | +| 20K, medium (final shader) | 29.7–31.6 | 43.4–49.1 | +| 20K, high | 23.4–24.2 | 40.2–41.5 | + +Each run includes a warmup and three five-second orbits; medium detail includes three updated runs to check timing variation. Inactive comparison renderers were unloaded and forest animations paused. All retained samples report zero rendering errors and long tasks. These are browser draw delivery and frame intervals, not GPU execution time; the shared machine varied between runs. The 10K medium and 20K high updated-filter rows precede the final fragment-hook correction that skips material shading in the depth pass. The 20K medium row reports only the corrected-shader samples; earlier measurements are retained in the raw report. A final matched 20K medium check with that correction delivered 43.4–49.1 draws/s and p95 intervals of 25.5–40.7ms, with no errors or long tasks. The map budget and receiving filters were unchanged. The assembled production shader is checked by the browser regression. Fourteen browser checks include narrow-gap blending, preserved large openings, fractional leaf coverage, resized texture replacement, shadow toggles, and palm/oak shadows at nine pitches through the horizon in every season and two sun directions. See the [matched shadow comparison](../../../website/static/videos/tree-lab-shadows.png) and [retained shadow measurements](shadow-review.json). diff --git a/examples/layers/tree-lab/app.browser.spec.ts b/examples/layers/tree-lab/app.browser.spec.ts new file mode 100644 index 000000000..2457a5d47 --- /dev/null +++ b/examples/layers/tree-lab/app.browser.spec.ts @@ -0,0 +1,228 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +// Value: protects=ten Tree Lab renders survive shadow toggles with seasonal color and wind deformation; +// fails_when=shadow samplers vanish, seasons stop changing, wind stays static, or owner picking breaks; +// why_new=no existing integration covers these ten WebGL surfaces; seam=none + +import {CompositeLayer, type Deck, type Layer} from '@deck.gl/core'; +import {describe, expect, it} from 'vitest'; +import {mountTreeLabExample} from './app'; +import {SEASONS, type SceneOptions, type Specimen} from './scene'; + +type ReviewSpecimen = { + deck: Deck; + renderer: string; + species: string; + data: Specimen[]; + rendered: number; +}; +type ReviewApi = { + readonly ready: number; + readonly errors: string[]; + setOptions: (next: Partial) => void; + getOptions: () => SceneOptions; + getDecks: () => ReviewSpecimen[]; +}; +type Frame = {width: number; height: number; pixels: Uint8Array}; + +function countTreePixels(frame: Frame): number { + const {pixels} = frame; + let count = 0; + // The receiving ground plane fills the frame and has uniform directional + // lighting. The corner is outside the specimen and its cast shadow. + for (let i = 0; i < pixels.length; i += 4) { + const distance = + Math.abs(pixels[i] - pixels[0]) + + Math.abs(pixels[i + 1] - pixels[1]) + + Math.abs(pixels[i + 2] - pixels[2]); + if (pixels[i + 3] > 0 && distance > 25) count++; + } + return count; +} + +function countChangedPixels(before: Frame, after: Frame): number { + expect([after.width, after.height]).toEqual([before.width, before.height]); + let count = 0; + for (let i = 0; i < before.pixels.length; i += 4) { + const distance = + Math.abs(before.pixels[i] - after.pixels[i]) + + Math.abs(before.pixels[i + 1] - after.pixels[i + 1]) + + Math.abs(before.pixels[i + 2] - after.pixels[i + 2]); + if (distance > 10) count++; + } + return count; +} + +describe('Tree Lab rendering controls', () => { + it('keeps all ten trees visible across shadow toggles, four seasons and frozen wind poses', async () => { + const originalUrl = location.href; + const reviewUrl = new URL(originalUrl); + reviewUrl.searchParams.set('auto', '0'); + reviewUrl.searchParams.set('season', 'invalid'); + reviewUrl.searchParams.delete('backend'); + history.replaceState(null, '', reviewUrl); + const container = document.createElement('div'); + container.style.width = '900px'; + document.body.append(container); + const cleanup = mountTreeLabExample(container, {benchmarkLinks: false}); + const api = (window as Window & {treeLab?: ReviewApi}).treeLab!; + const frames = new Map(); + try { + expect(api.getOptions().season).toBe('summer'); + await expect.poll(() => api.ready, {timeout: 15000}).toBe(10); + expect(api.errors).toEqual([]); + const specimens = api.getDecks(); + expect(specimens).toHaveLength(10); + for (const specimen of specimens) { + const originalAfterRender = specimen.deck.props.onAfterRender; + specimen.deck.setProps({ + onAfterRender(context) { + originalAfterRender?.(context); + const {gl} = context; + const width = gl.drawingBufferWidth; + const height = gl.drawingBufferHeight; + const pixels = new Uint8Array(width * height * 4); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + frames.set(specimen, {width, height, pixels}); + } + }); + } + const capture = async (change: () => void) => { + const previousFrames = specimens.map(specimen => specimen.rendered); + frames.clear(); + change(); + await expect + .poll( + () => + specimens.every( + (specimen, index) => + specimen.rendered > previousFrames[index] && frames.has(specimen) + ), + {timeout: 15000} + ) + .toBe(true); + expect(api.errors).toEqual([]); + return new Map(frames); + }; + const assertVisible = async ( + captures: Map, + nativeOnly = false, + verifyPicking = false + ) => { + for (const specimen of specimens) { + if (nativeOnly && specimen.renderer !== 'native') continue; + const frame = captures.get(specimen)!; + expect( + countTreePixels(frame), + `${specimen.renderer}/${specimen.species} tree pixels` + ).toBeGreaterThan(50); + if (verifyPicking && specimen.renderer === 'native') { + const canvas = specimen.deck.getCanvas()!; + expect(canvas.clientWidth).toBeGreaterThan(32); + expect(canvas.clientHeight).toBeGreaterThan(32); + const picked = specimen.deck.pickObjects({ + x: 0, + y: 0, + width: canvas.clientWidth, + height: canvas.clientHeight + }); + expect( + picked.some(info => info.object === specimen.data[0]), + `${specimen.renderer}/${specimen.species} owner picking: ${JSON.stringify( + picked.map(info => ({index: info.index, object: info.object})) + )}` + ).toBe(true); + } + } + }; + const initialShadows = await capture(() => + api.setOptions({ + season: 'summer', + shadows: true, + wind: false, + windTime: 0, + crops: false, + dropped: false, + pixelRatio: 1 + }) + ); + await assertVisible(initialShadows); + for (const specimen of specimens.filter(item => item.renderer === 'native')) { + const layers = specimen.deck.props.layers as Layer[]; + const models = layers.flatMap(layer => + (layer instanceof CompositeLayer ? layer.getSubLayers() : [layer]).flatMap(child => + child.getModels() + ) + ); + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + // Inspect the assembled shader, so a misspelled injection hook cannot + // silently run material shading for every depth-only fragment. + const main = model.pipeline.fs!.source.match(/void\s+main\s*\([^)]*\)\s*\{([\s\S]*)/)!; + expect(main[1]).toMatch(/^\s*if \(shadow.drawShadowMap\) \{ return; \}/); + } + } + const shadowToggle = container.querySelector('[data-option="shadows"]')!; + const withoutShadows = await capture(() => shadowToggle.click()); + expect(api.getOptions().shadows).toBe(false); + // Picking alone would still pass if only the visible color shader broke. + // The shadow-free RGB assertion specifically catches disappearing trees. + await assertVisible(withoutShadows); + const restoredShadows = await capture(() => shadowToggle.click()); + expect(api.getOptions().shadows).toBe(true); + await assertVisible(restoredShadows); + for (const specimen of specimens) { + expect( + countChangedPixels(initialShadows.get(specimen)!, withoutShadows.get(specimen)!), + `${specimen.renderer}/${specimen.species} shadow difference` + ).toBeGreaterThan(10); + expect( + countChangedPixels(withoutShadows.get(specimen)!, restoredShadows.get(specimen)!), + `${specimen.renderer}/${specimen.species} restored shadows` + ).toBeGreaterThan(10); + } + await capture(() => shadowToggle.click()); + const seasonalFrames = new Map>(); + for (const season of SEASONS) { + const button = container.querySelector(`[data-season="${season}"]`)!; + const captures = await capture(() => button.click()); + expect(button.getAttribute('aria-pressed')).toBe('true'); + expect(api.getOptions().season).toBe(season); + await assertVisible(captures, true); + seasonalFrames.set(season, captures); + } + for (const specimen of specimens.filter(item => item.renderer === 'native')) { + expect( + countChangedPixels( + seasonalFrames.get('spring')!.get(specimen)!, + seasonalFrames.get('autumn')!.get(specimen)! + ), + `${specimen.species} seasonal color` + ).toBeGreaterThan(10); + if (['oak', 'birch', 'cherry'].includes(specimen.species)) { + expect(countTreePixels(seasonalFrames.get('winter')!.get(specimen)!)).toBeLessThan( + countTreePixels(seasonalFrames.get('summer')!.get(specimen)!) + ); + } + } + const windStart = await capture(() => + api.setOptions({season: 'summer', wind: true, windTime: 0}) + ); + const windLater = await capture(() => api.setOptions({windTime: 2})); + for (const specimen of specimens) { + const changed = countChangedPixels(windStart.get(specimen)!, windLater.get(specimen)!); + if (specimen.renderer === 'native') + expect(changed, `${specimen.species} frozen wind deformation`).toBeGreaterThan(10); + else expect(changed, `${specimen.species} static original`).toBe(0); + } + await assertVisible(windLater, false, true); + expect(specimens.every(specimen => specimen.rendered > 8)).toBe(true); + expect(api.errors).toEqual([]); + } finally { + cleanup(); + container.remove(); + history.replaceState(null, '', originalUrl); + } + }, 90000); +}); diff --git a/examples/layers/tree-lab/app.ts b/examples/layers/tree-lab/app.ts new file mode 100644 index 000000000..36af9d21e --- /dev/null +++ b/examples/layers/tree-lab/app.ts @@ -0,0 +1,301 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {Deck, type MapViewState, type MapView} from '@deck.gl/core'; +import {TreeLayer} from '@deck.gl-community/layers'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {webgpuAdapter} from '@luma.gl/webgpu'; +import { + SPECIES, + SEASONS, + VIEW, + DEFAULT_OPTIONS, + createSpecimens, + createSceneLayers, + createLighting, + createViews, + type SceneOptions, + type TreeLayerConstructor +} from './scene'; +import {getTourFrame} from './tour'; +import './style.css'; + +const DESCRIPTIONS = { + pine: 'Layered evergreen crown · tier shading', + oak: 'Lobed crown · exposed winter branches', + palm: 'Feathered fronds · ribbed shaft · hanging crop clusters', + birch: 'Narrow crown · pale trunk · branching winter silhouette', + cherry: 'Blossom color · lobed crown · winter branches' +}; + +/** Interactive visual review of every species with shared controls and synchronized cameras. */ +export function mountTreeLabExample( + container: HTMLElement, + hostOptions: {scroll?: boolean; benchmarkLinks?: boolean} = {} +): () => void { + let options: SceneOptions = {...DEFAULT_OPTIONS, pixelRatio: 2}; + const query = new URLSearchParams(location.search); + options.season = SEASONS.find(season => season === query.get('season')) ?? DEFAULT_OPTIONS.season; + if (query.get('shadows') === '1') options.shadows = true; + if (query.get('backend') === 'webgpu') { + options.backend = 'webgpu'; + options.shadows = false; + } + let autoTour = + query.get('auto') !== '0' && !matchMedia('(prefers-reduced-motion: reduce)').matches; + let sunAngle = 0; + let tourStart = performance.now(); + let tourRequest = 0; + if (autoTour) { + options.wind = true; + options.windTime = null; + options.shadows = options.backend === 'webgl'; + } + const root = document.createElement('div'); + root.className = 'tree-lab'; + if (hostOptions.scroll) { + root.style.height = '100%'; + root.style.overflow = 'auto'; + } + root.innerHTML = ` +
vis.gl / Tree Lab

A forest, one tree at a time.

Original Three.js geometry and native vis.gl geometry. Same tree dimensions, camera, light, and supplied crops.

+
${SEASONS.map(season => ``).join('')}
+ ${[ + ['crops', 'Attached crops'], + ['dropped', 'Dropped crops'], + ['shadows', 'Shadows'], + ['wind', 'Wind'] + ] + .map( + ([key, label]) => + `` + ) + .join('')} + ${hostOptions.benchmarkLinks !== false ? 'Watch / record the film · Explore 10K / 20K trees' : ''} + + +
+
${SPECIES.map(species => `

${species[0].toUpperCase() + species.slice(1)}

${DESCRIPTIONS[species]}
${['baseline', 'native'].map(renderer => `
Preparing specimen…
${renderer === 'baseline' ? 'Original / Three.js' : 'Native / vis.gl'}${renderer === 'baseline' ? 'Baseline 9.4.2' : 'Shared geometry · GPU wind'}
`).join('')}
`).join('')}
+
Drag either specimen to inspect both cameras. Crops are illustrative and explicitly supplied; seasons do not invent yield.
Original has no wind feature. Native wind deforms the trunk, crown, and attached crops together. Shadows use deck.gl’s WebGL shadow maps.
${hostOptions.benchmarkLinks !== false ? 'Performance runs use one renderer at a time: Original benchmark · Native benchmark.' : 'Run the standalone Tree Lab workspace for isolated performance measurements.'}
`; + container.replaceChildren(root); + const decks: { + deck: Deck; + lighting: ReturnType; + renderer: string; + species: (typeof SPECIES)[number]; + element: HTMLDivElement; + LayerClass: TreeLayerConstructor; + rendered: number; + onScreen: boolean; + data: ReturnType; + }[] = []; + let disposed = false; + let camera: MapViewState = {...VIEW}; + let ready = 0; + const errors: string[] = []; + const refreshStatus = () => { + root.querySelector('.status')!.textContent = + `${options.backend.toUpperCase()} · ${options.season} · ${options.shadows ? 'shadows on' : 'shadows off'} · ${options.wind ? 'native wind on / original static' : 'wind off'} · ${ready}/10 specimens ready${errors.length ? ` · ${errors.length} rendering errors` : ''}`; + }; + const syncCamera = (view: MapViewState) => { + camera = view; + for (const item of decks) item.deck.setProps({viewState: camera}); + }; + const getSpecimenOptions = (item: (typeof decks)[number]): SceneOptions => ({ + ...options, + windTime: options.windTime ?? (item.onScreen && !document.hidden ? null : 0) + }); + const refreshSpecimen = (item: (typeof decks)[number]) => { + item.lighting.setProps(createLighting(options.shadows).props); + item.lighting.setSunDirection(getTourFrame((sunAngle / 360) * 32).direction); + item.deck.setProps({ + useDevicePixels: options.pixelRatio, + layers: createSceneLayers( + item.LayerClass, + item.renderer, + item.data, + getSpecimenOptions(item) + ), + effects: [item.lighting] + }); + }; + const visibilityObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + const item = decks.find(value => value.element === entry.target); + if (item && item.onScreen !== entry.isIntersecting) { + item.onScreen = entry.isIntersecting; + if (options.wind && options.windTime === null) refreshSpecimen(item); + } + } + }); + const refreshVisibility = () => { + if (options.wind && options.windTime === null) for (const item of decks) refreshSpecimen(item); + }; + document.addEventListener('visibilitychange', refreshVisibility); + const refresh = () => { + root.querySelector('#auto-tour')!.checked = autoTour; + root.querySelector('#sun-angle')!.value = String(Math.round(sunAngle)); + for (const season of root.querySelectorAll('[data-season]')) + season.setAttribute('aria-pressed', String(season.dataset.season === options.season)); + for (const control of root.querySelectorAll( + '[data-option]' + )) { + const key = control.dataset.option as keyof SceneOptions; + if (control instanceof HTMLInputElement) control.checked = Boolean(options[key]); + else control.value = String(options[key]); + if (key === 'shadows') control.disabled = options.backend === 'webgpu'; + } + root.querySelector('#wind-clock')!.disabled = !options.wind; + root.querySelector('#wind-clock')!.textContent = + options.windTime === null ? 'Pause wind' : 'Play wind'; + root.querySelector('#wind-time')!.disabled = !options.wind; + for (const item of decks) refreshSpecimen(item); + refreshStatus(); + }; + root.addEventListener('click', event => { + if ((event.target as HTMLElement).id === 'wind-clock') { + options.windTime = + options.windTime === null + ? Number(root.querySelector('#wind-time')!.value) + : null; + refresh(); + return; + } + const button = (event.target as HTMLElement).closest('[data-season]'); + if (button) { + autoTour = false; + options.season = button.dataset.season as SceneOptions['season']; + refresh(); + } + }); + root.addEventListener('change', event => { + const control = event.target as HTMLInputElement; + if (control.id === 'auto-tour') { + autoTour = control.checked; + tourStart = performance.now(); + refresh(); + return; + } + if (control.id === 'sun-angle') { + autoTour = false; + sunAngle = Number(control.value); + refresh(); + return; + } + if (control.id === 'wind-time') { + autoTour = false; + options.windTime = Number(control.value); + refresh(); + return; + } + if (control.id === 'camera') { + syncCamera({...camera, pitch: Number(control.value)}); + return; + } + const key = control.dataset.option as keyof SceneOptions; + if (!key) return; + Object.assign(options, {[key]: control.type === 'checkbox' ? control.checked : control.value}); + if (key === 'pixelRatio') options.pixelRatio = Number(control.value); + if (key === 'wind') options.windTime = options.wind ? null : 0; + refresh(); + }); + const mount = async () => { + const {TreeLayer: LegacyTreeLayer} = await import('./baseline/tree-layer'); + if (disposed) return; + for (const element of root.querySelectorAll('.canvas')) { + const species = element.dataset.species as (typeof SPECIES)[number]; + const renderer = element.dataset.renderer!; + const LayerClass = renderer === 'native' ? TreeLayer : LegacyTreeLayer; + element.replaceChildren(); + let firstFrame = true; + const item = { + renderer, + species, + element, + LayerClass, + lighting: createLighting(options.shadows), + rendered: 0, + onScreen: + element.getBoundingClientRect().top < window.innerHeight && + element.getBoundingClientRect().bottom > 0, + data: createSpecimens(species), + deck: undefined as unknown as Deck + }; + item.deck = new Deck({ + parent: element, + width: '100%', + height: '100%', + useDevicePixels: options.pixelRatio, + deviceProps: {type: options.backend, adapters: [webgl2Adapter, webgpuAdapter]}, + views: createViews(), + viewState: camera, + layers: createSceneLayers(LayerClass, renderer, item.data, getSpecimenOptions(item)), + effects: [item.lighting], + onViewStateChange: ({viewState}) => syncCamera(viewState as MapViewState), + onAfterRender: () => { + item.rendered++; + if (firstFrame) { + firstFrame = false; + ready++; + refreshStatus(); + } + }, + onError: error => { + errors.push(`${renderer}/${species}: ${error.message}`); + console.error(`${renderer}/${species}`, error); + refreshStatus(); + } + }); + decks.push(item as (typeof decks)[number]); + visibilityObserver.observe(element); + } + }; + refresh(); + void mount(); + const tickTour = (now: number) => { + if (disposed) return; + if (autoTour && !document.hidden) { + const frame = getTourFrame((now - tourStart) / 1000); + sunAngle = frame.sunAngle; + if (frame.season !== options.season) { + options.season = frame.season; + refresh(); + } + root.querySelector('#sun-angle')!.value = String(Math.round(sunAngle)); + for (const item of decks) { + if (!item.onScreen) continue; + item.lighting.setSunDirection(frame.direction); + item.deck.redraw('sun tour'); + } + } + tourRequest = requestAnimationFrame(tickTour); + }; + tourRequest = requestAnimationFrame(tickTour); + // A small deterministic review API used by the browser evidence runner. + const api = { + get ready() { + return ready; + }, + get errors() { + return [...errors]; + }, + setOptions(next: Partial) { + Object.assign(options, next); + refresh(); + }, + setCamera: syncCamera, + getOptions: () => ({...options}), + getDecks: () => decks + }; + Object.assign(window, {treeLab: api}); + return () => { + disposed = true; + cancelAnimationFrame(tourRequest); + visibilityObserver.disconnect(); + document.removeEventListener('visibilitychange', refreshVisibility); + for (const item of decks) item.deck.finalize(); + root.remove(); + const reviewWindow = window as Window & {treeLab?: typeof api}; + if (reviewWindow.treeLab === api) delete reviewWindow.treeLab; + }; +} diff --git a/examples/layers/tree-lab/baseline.html b/examples/layers/tree-lab/baseline.html new file mode 100644 index 000000000..2fbb2ecd7 --- /dev/null +++ b/examples/layers/tree-lab/baseline.html @@ -0,0 +1,2 @@ + +Tree Lab · vis.gl
diff --git a/examples/layers/tree-lab/baseline/README.md b/examples/layers/tree-lab/baseline/README.md new file mode 100644 index 000000000..229f33afc --- /dev/null +++ b/examples/layers/tree-lab/baseline/README.md @@ -0,0 +1 @@ +Frozen comparison fixture from master c8b25275 (9.4.2). Three.js is a development dependency of this example only. Never import this fixture from a published module. diff --git a/modules/three/src/tree-layer/tree-geometry.ts b/examples/layers/tree-lab/baseline/tree-geometry.ts similarity index 100% rename from modules/three/src/tree-layer/tree-geometry.ts rename to examples/layers/tree-lab/baseline/tree-geometry.ts diff --git a/modules/three/src/tree-layer/tree-layer.ts b/examples/layers/tree-lab/baseline/tree-layer.ts similarity index 100% rename from modules/three/src/tree-layer/tree-layer.ts rename to examples/layers/tree-lab/baseline/tree-layer.ts diff --git a/examples/layers/tree-lab/bench-baseline.ts b/examples/layers/tree-lab/bench-baseline.ts new file mode 100644 index 000000000..cd2ce17bf --- /dev/null +++ b/examples/layers/tree-lab/bench-baseline.ts @@ -0,0 +1,3 @@ +import {TreeLayer} from './baseline/tree-layer'; +import {mountTreeBenchmark} from './benchmark'; +mountTreeBenchmark(TreeLayer, 'baseline'); diff --git a/examples/layers/tree-lab/bench-native.ts b/examples/layers/tree-lab/bench-native.ts new file mode 100644 index 000000000..87d4e86d4 --- /dev/null +++ b/examples/layers/tree-lab/bench-native.ts @@ -0,0 +1,3 @@ +import {TreeLayer} from '@deck.gl-community/layers'; +import {mountTreeBenchmark} from './benchmark'; +mountTreeBenchmark(TreeLayer, 'native'); diff --git a/examples/layers/tree-lab/benchmark.browser.spec.ts b/examples/layers/tree-lab/benchmark.browser.spec.ts new file mode 100644 index 000000000..4a00781ad --- /dev/null +++ b/examples/layers/tree-lab/benchmark.browser.spec.ts @@ -0,0 +1,128 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +// Value: protects=legacy benchmark query values cannot inject HTML or lower geometry quality; +// fails_when=untrusted detail is interpolated into markup or passed to TreeLayer; +// why_new=the visual lab regression does not mount the separate benchmark entry point; seam=none + +import type {Deck} from '@deck.gl/core'; +import {TreeLayer} from '@deck.gl-community/layers'; +import {expect, it} from 'vitest'; +import {mountTreeBenchmark} from './benchmark'; + +type BenchmarkApi = { + ready: boolean; + errors: string[]; + deck: Deck; + measure: (durationMs?: number) => Promise<{ + treeInstances: number; + view: string; + renderCallP95Ms: number | null; + }>; +}; + +it.each([ + '', + 'invalid', + 'medium', + 'low' +])('ignores legacy benchmark detail safely for %s', async detail => { + const originalUrl = location.href; + const queryUrl = new URL(originalUrl); + queryUrl.search = ''; + queryUrl.searchParams.set('count', '1'); + queryUrl.searchParams.set('detail', detail); + history.replaceState(null, '', queryUrl); + const parent = document.createElement('div'); + parent.id = 'app'; + parent.style.width = '640px'; + parent.style.height = '480px'; + document.body.append(parent); + mountTreeBenchmark(TreeLayer, 'native'); + const api = (window as Window & {treeBenchmark?: BenchmarkApi}).treeBenchmark!; + try { + expect(parent.querySelector('#tree-query-injection')).toBeNull(); + expect(parent.querySelector('p')!.textContent).toContain('highest geometry detail'); + await expect.poll(() => api.ready, {timeout: 15000}).toBe(true); + expect(api.errors).toEqual([]); + const layers = api.deck.props.layers as TreeLayer[]; + expect(layers[1].props).not.toHaveProperty('detail'); + expect(document.documentElement.dataset.treeQueryInjected).toBeUndefined(); + } finally { + api.deck.finalize(); + parent.remove(); + delete document.documentElement.dataset.treeQueryInjected; + history.replaceState(null, '', originalUrl); + } +}); + +it('submits all 20,000 requested tree instances to the renderer', async () => { + const originalUrl = location.href; + const queryUrl = new URL(originalUrl); + queryUrl.search = '?count=20000&species=mixed&detail=low&season=winter'; + history.replaceState(null, '', queryUrl); + const parent = document.createElement('div'); + parent.id = 'app'; + parent.style.width = '640px'; + parent.style.height = '480px'; + document.body.append(parent); + mountTreeBenchmark(TreeLayer, 'native'); + const api = (window as Window & {treeBenchmark?: BenchmarkApi}).treeBenchmark!; + try { + await expect.poll(() => api.ready, {timeout: 30000}).toBe(true); + expect(parent.querySelector('h1')!.textContent).toContain('20,000'); + expect(parent.querySelector('p')!.textContent).toContain('winter'); + const layers = api.deck.props.layers as TreeLayer[]; + expect(layers[1].props.data).toHaveLength(20000); + const sample = await api.measure(1000); + expect(sample.treeInstances).toBe(20000); + expect(sample.view).toBe('canopy'); + expect(sample.renderCallP95Ms).not.toBeNull(); + expect(sample.renderCallP95Ms!).toBeGreaterThanOrEqual(0); + expect(api.errors).toEqual([]); + } finally { + api.deck.finalize(); + parent.remove(); + history.replaceState(null, '', originalUrl); + } +}, 45000); + +it('refits overview after resize and rejects measurements across viewport sizes', async () => { + const originalUrl = location.href; + const queryUrl = new URL(originalUrl); + queryUrl.search = '?count=100&species=mixed&detail=low&wind=0&shadows=0&view=overview'; + history.replaceState(null, '', queryUrl); + const parent = document.createElement('div'); + parent.id = 'app'; + parent.style.width = '900px'; + document.body.append(parent); + mountTreeBenchmark(TreeLayer, 'native'); + const api = (window as Window & {treeBenchmark?: BenchmarkApi}).treeBenchmark!; + try { + await expect.poll(() => api.ready, {timeout: 30000}).toBe(true); + const initialZoom = api.deck.getViewports()[0].zoom; + parent.style.width = '360px'; + await expect.poll(() => api.deck.width).toBe(316); + const viewport = api.deck.getViewports()[0]; + expect(viewport.zoom).toBeLessThan(initialZoom); + const halfSide = (Math.ceil(Math.sqrt(100)) * 9 + 20) / 111320; + for (const longitude of [-halfSide, halfSide]) { + for (const latitude of [-halfSide, halfSide]) { + const [x, y] = viewport.project([longitude, latitude, 30]); + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(viewport.width); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(viewport.height); + } + } + const rejected = expect(api.measure(500)).rejects.toThrow('Viewport resized'); + parent.style.width = '480px'; + await rejected; + await expect(api.measure(250)).resolves.toMatchObject({treeInstances: 100, view: 'overview'}); + expect(api.errors).toEqual([]); + } finally { + api.deck.finalize(); + parent.remove(); + history.replaceState(null, '', originalUrl); + } +}, 30000); diff --git a/examples/layers/tree-lab/benchmark.ts b/examples/layers/tree-lab/benchmark.ts new file mode 100644 index 000000000..7927006b3 --- /dev/null +++ b/examples/layers/tree-lab/benchmark.ts @@ -0,0 +1,277 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {Deck} from '@deck.gl/core'; +import type {Device} from '@luma.gl/core'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {webgpuAdapter} from '@luma.gl/webgpu'; +import { + VIEW, + DEFAULT_OPTIONS, + SPECIES, + SEASONS, + createSpecimens, + createSceneLayers, + createLighting, + createViews, + type SceneOptions, + type TreeLayerConstructor +} from './scene'; +import {createForestSpecimens, getTreeCount} from './forest-data'; +import {createForestSceneLayers, getForestViewState} from './forest-scene'; +import './style.css'; + +/** Identical single-renderer workloads; frame intervals describe browser delivery, not GPU time. */ +export function mountTreeBenchmark( + LayerClass: TreeLayerConstructor, + renderer: 'baseline' | 'native' +) { + const query = new URLSearchParams(location.search); + const count = getTreeCount(query.get('count')); + const species = + query.get('species') === 'mixed' + ? 'mixed' + : (SPECIES.find(value => value === query.get('species')) ?? 'oak'); + const options: SceneOptions = { + ...DEFAULT_OPTIONS, + season: SEASONS.find(value => value === query.get('season')) ?? DEFAULT_OPTIONS.season, + crops: query.get('crops') === '1', + dropped: query.get('crops') === '1', + shadows: query.get('shadows') === '1', + wind: query.get('wind') === '1', + windTime: null, + backend: query.get('backend') === 'webgpu' ? 'webgpu' : 'webgl' + }; + if (options.backend === 'webgpu') options.shadows = false; + const parent = document.querySelector('#app')!; + parent.innerHTML = `
Tree Lab / Performance

Visual comparison · 10K / 20K forest
Ready for measurement.
Raw measurements
Ready for measurement.
`; + parent.querySelector('h1')!.textContent = + `${renderer === 'native' ? 'Native vis.gl' : 'Original Three.js'} · ${count.toLocaleString()} ${species} trees`; + parent.querySelector('p')!.textContent = + `${options.backend} · ${options.season} · highest geometry detail · crops ${options.crops ? 'on' : 'off'} · shadows ${options.shadows ? 'on' : 'off'} · wind ${options.wind ? (renderer === 'native' ? 'on' : 'unsupported / static') : 'off'}`; + const summary = parent.querySelector('#summary')!; + const showSamples = ( + samples: { + medianFrameMs: number; + p95FrameMs: number; + deliveredFramesPerSecond: number; + longTaskCount: number; + renderCallP95Ms: number | null; + }[] + ) => { + summary.replaceChildren(); + const table = document.createElement('table'); + table.innerHTML = + 'SampleMedian framep95 frameDraws/sDraw call p95Long tasks'; + const body = document.createElement('tbody'); + for (const [index, sample] of samples.entries()) { + const row = document.createElement('tr'); + for (const value of [ + index + 1, + `${sample.medianFrameMs.toFixed(1)} ms`, + `${sample.p95FrameMs.toFixed(1)} ms`, + sample.deliveredFramesPerSecond.toFixed(1), + sample.renderCallP95Ms === null ? '—' : `${sample.renderCallP95Ms.toFixed(1)} ms`, + sample.longTaskCount + ]) { + const cell = document.createElement('td'); + cell.textContent = String(value); + row.append(cell); + } + body.append(row); + } + table.append(body); + summary.append(table); + }; + let frame = 0; + let ready = false; + const errors: string[] = []; + const firstStart = performance.now(); + let firstFrameMs: number | null = null; + let device: Device; + const data = species === 'mixed' ? createForestSpecimens(count) : createSpecimens(species, count); + const zoom = count === 1 ? VIEW.zoom : Math.max(13, 20.5 - Math.log2(Math.sqrt(count))); + const view = + species === 'mixed' ? (query.get('view') === 'overview' ? 'overview' : 'canopy') : 'grid'; + const {width, height} = parent.querySelector('.canvas')!.getBoundingClientRect(); + let camera = + species === 'mixed' + ? getForestViewState(count, width, height, view === 'overview') + : {...VIEW, zoom}; + parent.querySelector('p')!.textContent += ` · ${view} view`; + let measuring = false; + let resizeRevision = 0; + let drawStart = 0; + const renderCalls: number[] = []; + const deck = new Deck({ + parent: parent.querySelector('.canvas')!, + width: '100%', + height: '100%', + useDevicePixels: 1, + deviceProps: {type: options.backend, adapters: [webgl2Adapter, webgpuAdapter]}, + views: createViews(), + viewState: camera, + layers: + species === 'mixed' + ? createForestSceneLayers( + LayerClass, + renderer, + data as ReturnType, + options + ) + : createSceneLayers(LayerClass, renderer, data, options), + effects: [createLighting(options.shadows)], + onDeviceInitialized: initializedDevice => { + device = initializedDevice; + }, + onResize: ({width, height}) => { + resizeRevision++; + if (view === 'overview') { + camera = getForestViewState(count, width, height, true); + deck.setProps({viewState: camera}); + } + }, + onBeforeRender: () => { + if (measuring) drawStart = performance.now(); + }, + onAfterRender: () => { + frame++; + if (measuring) renderCalls.push(performance.now() - drawStart); + if (!ready) { + ready = true; + firstFrameMs = performance.now() - firstStart; + if (!errors.length) + for (const button of parent.querySelectorAll('button')) + button.disabled = false; + } + }, + onError: error => { + errors.push(error.message); + parent.querySelector('#result')!.textContent = errors.join('\n'); + for (const button of parent.querySelectorAll('button')) + button.disabled = true; + } + }); + const measure = async (durationMs = 5000) => { + if (!ready || measuring || document.hidden) + throw new Error( + 'Keep this benchmark visible; wait for readiness and any running measurement.' + ); + measuring = true; + const resizeAtStart = resizeRevision; + const visibilityAtStart = document.visibilityState; + const focusedAtStart = document.hasFocus(); + const idleStartFrame = frame; + const idleStart = performance.now(); + await new Promise(resolve => setTimeout(resolve, 500)); + const idleRenderedFrames = frame - idleStartFrame; + const idleDurationMs = performance.now() - idleStart; + const gaps: number[] = []; + const longTasks: {duration: number; startTime: number}[] = []; + const observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) + longTasks.push({duration: entry.duration, startTime: entry.startTime}); + }); + if (PerformanceObserver.supportedEntryTypes.includes('longtask')) + observer.observe({type: 'longtask'}); + renderCalls.length = 0; + const start = performance.now(); + const startFrame = frame; + let previous = start; + await new Promise(resolve => { + const tick = (now: number) => { + gaps.push(now - previous); + previous = now; + const elapsed = now - start; + deck.setProps({ + viewState: {...camera, bearing: (camera.bearing ?? 22) + (elapsed / durationMs) * 90} + }); + if (elapsed < durationMs) requestAnimationFrame(tick); + else resolve(); + }; + requestAnimationFrame(tick); + }); + observer.disconnect(); + measuring = false; + if (resizeRevision !== resizeAtStart) + throw new Error('Viewport resized during measurement. Rerun at one viewport size.'); + const sorted = [...gaps].sort((a, b) => a - b); + const sortedRenderCalls = [...renderCalls].sort((a, b) => a - b); + const result = { + renderer, + visibilityAtStart, + focusedAtStart, + visibilityAtEnd: document.visibilityState, + camera, + view, + species, + count, + options, + backend: device.type, + device: device.info, + firstFrameMs, + renderedFrames: frame - startFrame, + idleRenderedFrames, + idleDurationMs, + viewport: {width: deck.width, height: deck.height, devicePixelRatio: 1}, + durationMs: performance.now() - start, + deliveredFramesPerSecond: ((frame - startFrame) * 1000) / (performance.now() - start), + treeInstances: data.length, + renderCallMedianMs: sortedRenderCalls[Math.floor(sortedRenderCalls.length * 0.5)] ?? null, + renderCallP95Ms: sortedRenderCalls[Math.floor(sortedRenderCalls.length * 0.95)] ?? null, + medianFrameMs: sorted[Math.floor(sorted.length * 0.5)], + p95FrameMs: sorted[Math.floor(sorted.length * 0.95)], + maxFrameMs: sorted.at(-1), + over33ms: gaps.filter(gap => gap > 33.3).length, + longTaskCount: longTasks.length, + longTaskMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), + heapBytes: + (performance as Performance & {memory?: {usedJSHeapSize: number}}).memory?.usedJSHeapSize ?? + null, + userAgent: navigator.userAgent, + navigation: performance.getEntriesByType('navigation')[0]?.toJSON(), + resources: performance.getEntriesByType('resource').map(entry => entry.toJSON()), + errors: [...errors] + }; + parent.querySelector('#result')!.textContent = JSON.stringify(result, null, 2); + return result; + }; + const runSamples = async (count: number) => { + const buttons = parent.querySelectorAll('button'); + for (const button of buttons) button.disabled = true; + try { + const samples = []; + let warmup; + if (count > 1) { + summary.textContent = 'Warming up…'; + warmup = await measure(2000); + } + for (let index = 0; index < count; index++) { + summary.textContent = `Measuring sample ${index + 1}/${count}…`; + parent.querySelector('#result')!.textContent = summary.textContent; + samples.push(await measure()); + } + parent.querySelector('#result')!.textContent = JSON.stringify({warmup, samples}, null, 2); + showSamples(samples); + } catch (error) { + summary.textContent = error instanceof Error ? error.message : String(error); + parent.querySelector('#result')!.textContent = summary.textContent; + } finally { + for (const button of buttons) button.disabled = !ready || errors.length > 0; + } + }; + parent.querySelector('#measure')!.addEventListener('click', () => void runSamples(1)); + parent.querySelector('#repeat')!.addEventListener('click', () => void runSamples(3)); + Object.assign(window, { + treeBenchmark: { + get ready() { + return ready; + }, + get errors() { + return [...errors]; + }, + measure, + deck + } + }); +} diff --git a/examples/layers/tree-lab/film-entry.ts b/examples/layers/tree-lab/film-entry.ts new file mode 100644 index 000000000..bf5203671 --- /dev/null +++ b/examples/layers/tree-lab/film-entry.ts @@ -0,0 +1,7 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {mountTreeFilm} from './film'; +void mountTreeFilm(document.querySelector('#app')!).catch(error => { + document.querySelector('#progress')!.textContent = error.message; +}); diff --git a/examples/layers/tree-lab/film.html b/examples/layers/tree-lab/film.html new file mode 100644 index 000000000..df6f3da0f --- /dev/null +++ b/examples/layers/tree-lab/film.html @@ -0,0 +1 @@ +Tree Lab · A year in motion
diff --git a/examples/layers/tree-lab/film.ts b/examples/layers/tree-lab/film.ts new file mode 100644 index 000000000..6e4642985 --- /dev/null +++ b/examples/layers/tree-lab/film.ts @@ -0,0 +1,305 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {Deck, MapView} from '@deck.gl/core'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {TreeLayer} from '@deck.gl-community/layers'; +import {TreeLayer as OriginalTreeLayer} from './baseline/tree-layer'; +import {DEFAULT_OPTIONS, VIEW, createSpecimens, createSceneLayers, createLighting} from './scene'; +import {getTourFrame} from './tour'; +import './style.css'; + +/** Record actual paired deck.gl canvases; one frame clock drives every visual input. */ +export async function mountTreeFilm(container: HTMLElement) { + container.innerHTML = `
vis.gl / Tree Lab

Five trees. Four seasons. One moving sun.

Matched cameras and sunlight. Original geometry on the left; native vis.gl and GPU wind on the right.

All specimensPreparing both renderers…
`; + const output = container.querySelector('#film')!; + const ctx = output.getContext('2d')!; + const progress = container.querySelector('#progress')!; + const sourceCanvases = [...container.querySelectorAll('.film-sources canvas')]; + let disposed = false; + let playing = true; + let recording = false; + let elapsed = 0; + let previous = performance.now(); + let request = 0; + let currentKey = ''; + let ready = 0; + let liveRender: Promise | undefined; + let downloadUrl: string | undefined; + const errors: string[] = []; + const pending: (() => void)[] = []; + const lights = [createLighting(true), createLighting(true)]; + const decks = sourceCanvases.map( + (canvas, i) => + new Deck({ + canvas, + width: 880, + height: 800, + useDevicePixels: 1, + deviceProps: { + type: 'webgl', + adapters: [webgl2Adapter], + webgl: {preserveDrawingBuffer: true} + }, + views: new MapView({id: `film-${i}`}), + viewState: {...VIEW, zoom: 21.5}, + layers: createSceneLayers( + i ? TreeLayer : OriginalTreeLayer, + `film-${i}`, + createSpecimens('pine'), + {...DEFAULT_OPTIONS, shadows: true} + ), + effects: [lights[i]], + onLoad: () => { + ready++; + }, + onAfterRender: () => { + pending[i]?.(); + pending[i] = undefined; + }, + onError: error => { + errors.push(error.message); + progress.textContent = error.message; + } + }) + ); + const draw = (seconds: number) => { + const frame = getTourFrame(seconds, true); + ctx.fillStyle = '#f4f5ee'; + ctx.fillRect(0, 0, 1920, 1080); + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = '#647961'; + ctx.font = '600 19px system-ui'; + ctx.fillText('VIS.GL / TREE LAB', 60, 53); + ctx.fillStyle = '#233c2c'; + ctx.font = '650 54px system-ui'; + ctx.fillText(frame.species[0].toUpperCase() + frame.species.slice(1), 60, 121); + ctx.font = '500 28px system-ui'; + ctx.fillText(frame.season[0].toUpperCase() + frame.season.slice(1), 1490, 94); + ctx.font = '19px system-ui'; + ctx.fillStyle = '#647961'; + ctx.fillText('A year in motion', 1490, 126); + for (let i = 0; i < 2; i++) { + const x = 60 + i * 920; + ctx.fillStyle = '#e2e7de'; + ctx.fillRect(x, 180, 880, 800); + ctx.drawImage(sourceCanvases[i], x, 180, 880, 800); + ctx.fillStyle = i ? '#233c2c' : '#e7ecdf'; + ctx.fillRect(x + 20, 204, i ? 306 : 316, 47); + ctx.fillStyle = i ? '#ffffff' : '#435a43'; + ctx.font = '600 22px system-ui'; + ctx.fillText(i ? 'NATIVE / VIS.GL' : 'ORIGINAL / THREE.JS', x + 36, 235); + ctx.fillStyle = '#eaf0e4'; + ctx.fillRect(x + 20, 919, 836, 40); + ctx.fillStyle = '#435a43'; + ctx.font = '18px system-ui'; + ctx.fillText( + i + ? 'Shared geometry · GPU wind · crops follow the crown' + : 'Frozen 9.4.2 geometry · original wind is static', + x + 35, + 945 + ); + } + ctx.fillStyle = '#647961'; + ctx.font = '19px system-ui'; + ctx.fillText( + 'Same dimensions, camera and sunlight · WebGL shadows · illustrative supplied crops', + 60, + 1027 + ); + ctx.fillStyle = '#233c2c'; + ctx.font = '600 19px system-ui'; + ctx.fillText(`${Math.floor(seconds).toString().padStart(2, '0')} / 60`, 1742, 1027); + ctx.fillStyle = '#d1dccb'; + ctx.fillRect(60, 1050, 1800, 4); + ctx.fillStyle = '#233c2c'; + ctx.fillRect(60, 1050, (1800 * (seconds % 60)) / 60, 4); + }; + const render = async (seconds: number) => { + if (errors.length) throw new Error(errors[0]); + const frame = getTourFrame(seconds, true); + const key = `${frame.species}/${frame.season}`; + const promises = decks.map( + (deck, i) => + new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for paired GPU frames.')), + 10000 + ); + pending[i] = () => { + clearTimeout(timeout); + resolve(); + }; + lights[i].setSunDirection(frame.direction); + deck.setProps({ + viewState: {...VIEW, zoom: 21.5, bearing: frame.cameraBearing}, + layers: createSceneLayers( + i ? TreeLayer : OriginalTreeLayer, + `film-${i}`, + createSpecimens(frame.species), + { + ...DEFAULT_OPTIONS, + season: frame.season, + shadows: true, + crops: frame.season !== 'winter', + dropped: frame.season === 'autumn', + wind: true, + windTime: seconds + } + ) + }); + }) + ); + currentKey = key; + await Promise.all(promises); + if (disposed) return; + draw(seconds); + }; + const tick = async (now: number) => { + if (disposed) return; + if (!recording && playing && ready === 2) { + elapsed = (elapsed + Math.min(0.1, (now - previous) / 1000)) % 60; + try { + liveRender = render(elapsed); + await liveRender; + } catch (error) { + progress.textContent = String(error); + playing = false; + } finally { + liveRender = undefined; + } + } + previous = now; + request = requestAnimationFrame(tick); + }; + const startRecord = async (duration: number) => { + if (recording || ready !== 2) return; + recording = true; + playing = false; + for (const button of container.querySelectorAll('button')) + button.disabled = true; + const totalFrames = duration * 30; + let completedFrames = 0; + let encoder: VideoEncoder | undefined; + try { + await liveRender; + if (typeof VideoEncoder === 'undefined') + throw new Error('This browser does not support frame-accurate video encoding.'); + const config: VideoEncoderConfig = { + codec: 'vp09.00.40.08', + width: 1920, + height: 1080, + bitrate: 10000000, + framerate: 30, + latencyMode: 'quality' + }; + if (!(await VideoEncoder.isConfigSupported(config)).supported) + throw new Error('This browser cannot encode the paired film.'); + const chunks = new Map>(); + let encodingError: Error | undefined; + encoder = new VideoEncoder({ + output(chunk) { + const bytes = new Uint8Array(chunk.byteLength); + chunk.copyTo(bytes); + chunks.set(Math.round((chunk.timestamp * 30) / 1000000), bytes); + }, + error(error) { + encodingError = error; + } + }); + encoder.configure(config); + for (let frame = 0; frame < totalFrames; frame++) { + if (disposed) throw new Error('Film renderer was closed.'); + if (encodingError) throw encodingError; + await render(frame / 30); + const videoFrame = new VideoFrame(output, { + timestamp: Math.round((frame * 1000000) / 30), + duration: Math.round(1000000 / 30) + }); + try { + encoder.encode(videoFrame, {keyFrame: frame % 90 === 0}); + } finally { + videoFrame.close(); + } + // Bound encoder memory without skipping frames when rendering or encoding is slow. + if (encoder.encodeQueueSize >= 8) await encoder.flush(); + completedFrames++; + progress.textContent = `Recording ${completedFrames}/${totalFrames} frames · ${currentKey}`; + } + await encoder.flush(); + if (encodingError) throw encodingError; + if (chunks.size !== totalFrames) + throw new Error(`Encoder returned ${chunks.size}/${totalFrames} frames.`); + // IVF is a small, dependency-free VP9 interchange container, not a browser + // capture stream. Integer presentation timestamps preserve every seasonal pose. + // Layout: https://github.com/webmproject/libvpx/blob/main/ivfenc.c + const header = new Uint8Array(32); + header.set(new TextEncoder().encode('DKIF')); + header.set(new TextEncoder().encode('VP90'), 8); + const view = new DataView(header.buffer); + view.setUint16(6, 32, true); + view.setUint16(12, 1920, true); + view.setUint16(14, 1080, true); + view.setUint32(16, 30, true); + view.setUint32(20, 1, true); + view.setUint32(24, totalFrames, true); + const parts: BlobPart[] = [header]; + for (let frame = 0; frame < totalFrames; frame++) { + const bytes = chunks.get(frame); + if (!bytes) throw new Error(`Encoder omitted frame ${frame}.`); + const packet = new Uint8Array(12); + const packetView = new DataView(packet.buffer); + packetView.setUint32(0, bytes.length, true); + packetView.setUint32(4, frame, true); + parts.push(packet, bytes); + } + if (downloadUrl) URL.revokeObjectURL(downloadUrl); + downloadUrl = URL.createObjectURL(new Blob(parts, {type: 'video/x-ivf'})); + const link = container.querySelector('#download')!; + link.href = downloadUrl; + link.download = `tree-lab-${duration}s.ivf`; + link.hidden = false; + link.textContent = `Download ${duration}-second film`; + progress.textContent = `Ready · ${completedFrames}/${totalFrames} paired and encoded frames · ${errors.length} rendering errors`; + } catch (error) { + progress.textContent = `Capture failed after ${completedFrames}/${totalFrames} frames: ${String(error)}`; + } finally { + if (encoder?.state !== 'closed') encoder?.close(); + recording = false; + if (!disposed) { + for (const button of container.querySelectorAll('button')) + button.disabled = false; + container.querySelector('#play')!.textContent = 'Play tour'; + } + } + }; + container.querySelector('#preview')!.addEventListener('click', () => { + void startRecord(4); + }); + container.querySelector('#record')!.addEventListener('click', () => { + void startRecord(60); + }); + container.querySelector('#play')!.addEventListener('click', () => { + playing = !playing; + previous = performance.now(); + container.querySelector('#play')!.textContent = playing ? 'Pause tour' : 'Play tour'; + }); + const initializationDeadline = performance.now() + 10000; + while (ready < 2 && !errors.length && performance.now() < initializationDeadline) + await new Promise(resolve => setTimeout(resolve, 50)); + if (errors.length) throw new Error(errors[0]); + if (ready < 2) throw new Error('Timed out initializing paired renderers.'); + await render(0); + for (const button of container.querySelectorAll('button')) + button.disabled = false; + progress.textContent = 'Ready · live sunlight and seasonal tour · original wind is static'; + request = requestAnimationFrame(tick); + return () => { + disposed = true; + cancelAnimationFrame(request); + for (const deck of decks) deck.finalize(); + if (downloadUrl) URL.revokeObjectURL(downloadUrl); + container.replaceChildren(); + }; +} diff --git a/examples/layers/tree-lab/forest-data.spec.ts b/examples/layers/tree-lab/forest-data.spec.ts new file mode 100644 index 000000000..eb824ecb4 --- /dev/null +++ b/examples/layers/tree-lab/forest-data.spec.ts @@ -0,0 +1,56 @@ +import {describe, expect, it} from 'vitest'; +import {WebMercatorViewport} from '@deck.gl/core'; +import {getForestViewState} from './forest-scene'; +import {createForestSpecimens, getTreeCount} from './forest-data'; + +describe('forest workload contracts', () => { + it('keeps 10K and 20K counts exact instead of silently truncating the requested workload', () => { + for (const count of [10000, 20000]) { + const trees = createForestSpecimens(getTreeCount(String(count))); + expect(trees).toHaveLength(count); + expect(trees.at(-1)!.index).toBe(count - 1); + expect(new Set(trees.map(tree => tree.species)).size).toBe(5); + expect(new Set(trees.map(tree => tree.position.join(','))).size).toBe(count); + expect( + trees.every(tree => tree.height > 0 && tree.canopyRadius > 0 && tree.trunkRadius > 0) + ).toBe(true); + } + }); + it('preserves source positions and dimensions across repeated count changes', () => { + const forest = createForestSpecimens(20000); + expect(forest).toEqual(createForestSpecimens(20000)); + expect(createForestSpecimens(10000)).toEqual(forest.slice(0, 10000)); + }); + it('uses finite bounded integer counts for malformed or unsupported links', () => { + for (const value of ['invalid', 'Infinity', 'NaN', '']) expect(getTreeCount(value)).toBe(1000); + expect(getTreeCount('20000')).toBe(20000); + expect(getTreeCount('30000')).toBe(20000); + expect(getTreeCount('-1')).toBe(1); + expect(getTreeCount('1234.5')).toBe(1234); + }); +}); + +it('fits the entire forest across viewport sizes and overview rotation', () => { + for (const count of [10000, 20000]) { + const halfSide = (Math.ceil(Math.sqrt(count)) * 9 + 20) / 111320; + for (const [width, height] of [ + [360, 480], + [900, 420], + [1280, 520] + ]) { + for (const bearing of [-8, 0, 8]) { + const camera = getForestViewState(count, width, height, true); + const viewport = new WebMercatorViewport({...camera, width, height, bearing}); + for (const longitude of [-halfSide, halfSide]) { + for (const latitude of [-halfSide, halfSide]) { + const [x, y] = viewport.project([longitude, latitude, 30]); + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(width); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(height); + } + } + } + } + } +}); diff --git a/examples/layers/tree-lab/forest-data.ts b/examples/layers/tree-lab/forest-data.ts new file mode 100644 index 000000000..288efea13 --- /dev/null +++ b/examples/layers/tree-lab/forest-data.ts @@ -0,0 +1,51 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {SPECIES, type Specimen} from './scene'; + +export type ForestSpecimen = Specimen & {height: number; canopyRadius: number; trunkRadius: number}; + +function getNoise(index: number, salt: number): number { + let value = Math.imul(index + 1, 0x9e3779b1) ^ Math.imul(salt, 0x85ebca6b); + value = Math.imul(value ^ (value >>> 16), 0x85ebca6b); + value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35); + return ((value ^ (value >>> 16)) >>> 0) / 4294967296; +} + +// Grow a square spiral around the same origin; adding trees never moves an existing row. +function getGridPosition(index: number): [number, number] { + if (index === 0) return [0, 0]; + const radius = Math.ceil((Math.sqrt(index + 1) - 1) / 2); + const edge = radius * 2; + const offset = (radius * 2 + 1) ** 2 - 1 - index; + if (offset < edge) return [radius - offset, -radius]; + if (offset < edge * 2) return [-radius, -radius + offset - edge]; + if (offset < edge * 3) return [-radius + offset - edge * 2, radius]; + return [radius, radius - (offset - edge * 3)]; +} + +/** An explicitly procedural forest: stable positions and dimensions, with all five species. */ +export function createForestSpecimens(count: number): ForestSpecimen[] { + return Array.from({length: count}, (_, index) => { + const species = SPECIES[Math.floor(getNoise(index, 1) * SPECIES.length)]; + const scale = 0.75 + getNoise(index, 2) * 0.5; + const [x, y] = getGridPosition(index); + return { + index, + species, + position: [ + (x * 18 + (getNoise(index, 3) - 0.5) * 10) / 111320, + (y * 18 + (getNoise(index, 4) - 0.5) * 10) / 111320 + ], + height: (species === 'pine' ? 21 : species === 'cherry' ? 11 : 16) * scale, + canopyRadius: (species === 'palm' ? 4.5 : species === 'birch' ? 5.5 : 7) * scale, + trunkRadius: (species === 'palm' ? 0.25 : 0.38) * scale + }; + }); +} + +/** Query counts are exact integers; malformed values use the documented default. */ +export function getTreeCount(value: string | null, fallback = 1000): number { + const parsed = value === null || value.trim() === '' ? fallback : Number(value); + return Number.isFinite(parsed) ? Math.max(1, Math.min(20000, Math.floor(parsed))) : fallback; +} diff --git a/examples/layers/tree-lab/forest-entry.ts b/examples/layers/tree-lab/forest-entry.ts new file mode 100644 index 000000000..906854ec2 --- /dev/null +++ b/examples/layers/tree-lab/forest-entry.ts @@ -0,0 +1,2 @@ +import {mountTreeForestExample} from './forest'; +mountTreeForestExample(document.querySelector('#app')!, true); diff --git a/examples/layers/tree-lab/forest-review.json b/examples/layers/tree-lab/forest-review.json new file mode 100644 index 000000000..65af1adf4 --- /dev/null +++ b/examples/layers/tree-lab/forest-review.json @@ -0,0 +1,2450 @@ +{ + "status": "MEASURED_VISIBLE_SINGLE_HOST", + "qualification": "Sequential production WebGL2 samples on one Apple M4 Pro host. The document was visible and focused at the start of every retained sample, and visible at the end. A one-tree control before the matrix delivered approximately 120 draws/s. No builds or automated renderer tests ran during measurements. No ending control, low-detail result, winter performance sample or overview performance sample was retained because the browser approval service became unavailable. Draw counts and rAF delivery do not measure GPU execution time or certify presented frames. Synchronous render call timings are CPU/driver wall time. Results are workload-specific and are not maximum-throughput or cross-device claims. Samples precede the final square-spiral count-stability layout and reduced-motion default changes; source/build fingerprints identify the measured version. Renderer meshes and detail budgets are unchanged.", + "recipe": { + "baseCommit": "7134bcf916be37faab029d76231e60cc768755d4", + "sourceState": "uncommitted camera and measurement refinement over baseCommit", + "sourceAndBuildSha256": { + "examples/layers/tree-lab/forest-data.spec.ts": "ec88ced44a0360adac0f62c6e474fca47c372ec1c6a900bdcd0731fe2dbd77c9", + "examples/layers/tree-lab/forest-entry.ts": "a460512262ea0f3877f2aea637f7b7d631f64fdbed1773a23390fd369bfec32b", + "examples/layers/tree-lab/tour.ts": "3876e9e54e1a3ff4d0af8af11d8dd8280f34d7a1aec6dba93cf6077fb4f88c2f", + "examples/layers/tree-lab/main.ts": "f36c4840251a2fac6bfa7a80c99a1c7881f2b4b290fb7da71d2c4c65432d1b0b", + "examples/layers/tree-lab/forest.ts": "7214a25f0e3aed853f0ab6774cac6f2036a7b0d1e9190912c62dee2e272edf5d", + "examples/layers/tree-lab/bench-baseline.ts": "8005108c3e772862118abf84686baafc1da8167b90c2803410d8794716479a2e", + "examples/layers/tree-lab/film-entry.ts": "39a452b2306f646e68a1ebff46ab9e69f30b7422109f36afcace9505077ccc98", + "examples/layers/tree-lab/app.ts": "c28023a3cbfe04526621d755b0721bced2d340988046183e28798f5fd6b3c066", + "examples/layers/tree-lab/benchmark.browser.spec.ts": "f723d6c23b3a5002124b9cf7f32b20a3bb19153a641d98e515a4c412b7804182", + "examples/layers/tree-lab/forest.browser.spec.ts": "8c36defe228b308175a59e87af2dbae692f22a697de224c6c9a5b2201e39a83c", + "examples/layers/tree-lab/bench-native.ts": "a05f8e016624fdc3c421c94bd7dfe3dfbee24a202953d4519df3768bf5ff4f1d", + "examples/layers/tree-lab/style.d.ts": "7c31eff601bdf1f246e3e2c92e0c1fd8ed7c2ef8a97608c54d6bdf812e730cc1", + "examples/layers/tree-lab/scene.ts": "e8995490ba4cb35542a2e02254aff6884e9246a08d6ddac3e5415eecdb005af1", + "examples/layers/tree-lab/tree-lighting.ts": "b721b00692c090a348906e626c18f4beddb844d0d72e245d2ce32ed11c09244d", + "examples/layers/tree-lab/forest-scene.ts": "8ec9133dc500fc96b23858bdbab6bbbbf396a96f71f3c958a49760aa2ceb4482", + "examples/layers/tree-lab/vite.config.ts": "3bf287f34a2281e7aedc39038df2309a519c05c5684571d3fa055f64b9a1e57c", + "examples/layers/tree-lab/benchmark.ts": "768db5a6a3194cf7c7b9fd4af8cdfa49faa6fcfc1290d3046ca2cde8ebe5e497", + "examples/layers/tree-lab/forest-data.ts": "3ccbef353476c236a00b319405e3c55f60a89e1e6970e62980b222581a8f832c", + "examples/layers/tree-lab/app.browser.spec.ts": "b34c88edde443fa52a2f91600539de25d3f1a7ecc56b0da84928d0e7767a490e", + "examples/layers/tree-lab/shadow.browser.spec.ts": "5f89d154a826ae9005c06783b83be73c5cfe4d5a3c59c3de1b6a9edd03bb88ca", + "examples/layers/tree-lab/film.ts": "f59174271fc7ddb35e417b94ad98cffc0618365aef48ed6532d4ff3e9d5265a0", + "modules/layers/src/tree-layer/tree-mesh-layer.ts": "addf0ed6ecf2bdb29226e06aa750f02d59a101100d69896723fb71b495ecdf21", + "modules/layers/src/tree-layer/tree-geometry.ts": "aa5ee1dbf0af6d486f7105edcb7c7646c41888ee7840afcd5cd58d540caaa408", + "modules/layers/src/tree-layer/tree-layer.ts": "f438b280aa85fd3f13c7c19ef88ee9bdf2669536b5a6dd76b1ab06f78c7cd61c", + "examples/layers/tree-lab/dist/index.html": "53d07341b66810f8ebe59a34ab3cb3fe904c6d3a58506c7b7b9873e6de1036d8", + "examples/layers/tree-lab/dist/film.html": "a2c6fe1d19a23a2087dca2b421ed0c79e04bfdc20164d11fea06c605dafc17bf", + "examples/layers/tree-lab/dist/baseline.html": "34b80e3e64e8a36cd091f9b54c788f8abcc300f2f68b4ab70a813cb15daa9a10", + "examples/layers/tree-lab/dist/native.html": "df4de89e073e90260bab9d25a05f24c73e723e2655c194dd7888243515182a7b", + "examples/layers/tree-lab/dist/forest.html": "011d9946a76275b4bb75cb66356c6c65ac895651acba8a47b8cee885203b2e62", + "examples/layers/tree-lab/dist/assets/film-B0ecqkvd.js": "e9132b60708a45c61dbf658400ad1b46ebe753d565c032163ab69a193034b5d5", + "examples/layers/tree-lab/dist/assets/lab-qujKv7m9.js": "cc74b92cc48b1e2d23f39f619eb0b08bc998a0c2af3679ff876f863f84479db9", + "examples/layers/tree-lab/dist/assets/expression-Bl_GL2tO.js": "71e7147302c7fcea9e45948ec730d772f397a474154231115e5ecd040b6f02d2", + "examples/layers/tree-lab/dist/assets/webgpu-device-D5YRfn1X.js": "ef24902aef1be73456346f9e60ba758023d812f6193f440da5624424223f7ca4", + "examples/layers/tree-lab/dist/assets/baseline-BmwFbozR.js": "46b5fe177297de07309a9649433860b71f5cdce420cffdbdf4a27da14df3345d", + "examples/layers/tree-lab/dist/assets/tree-layer-DD2r4pfR.js": "6d6c18b23fdebacb1f5b9d485c9956793d5ba62da5ff1253b79d441853fd3227", + "examples/layers/tree-lab/dist/assets/style-DD--vZzc.css": "a7b5da16390e0425409d580489a145090c9cd970d170777489660b70781d686e", + "examples/layers/tree-lab/dist/assets/style-C3FG_OVO.js": "8d81883c5d4dead944beba9956fab850b1de38e01582a2bd6f4acc972fddfe96", + "examples/layers/tree-lab/dist/assets/forest-scene-BgX-nUT4.js": "8738276a97d62eda5d6574f14d9972b101425fe4b7c11cce638a641794e94c05", + "examples/layers/tree-lab/dist/assets/forest-CeqFBnBn.js": "893e340ce5455ac88820b76f2dd85f00b955c99185637775e5ffcf04f857f6ff", + "examples/layers/tree-lab/dist/assets/webgpu-adapter-KAjyOw-c.js": "a341b5b84660be7abdd11d4369109b80bda018c996ea97cedde81c97d33aa7dc", + "examples/layers/tree-lab/dist/assets/tour-CJGkGuAc.js": "0837098ea391d3eb600e698a64176402f762c17c86607af4d514e72146327136", + "examples/layers/tree-lab/dist/assets/native-DRz0G8Zx.js": "f1882ad0b2f63cba78edc275ab9fd15cfb522ca96b0a381ec91724962501db51", + "examples/layers/tree-lab/dist/assets/benchmark--Cm74-dm.js": "856768f44b93770827c8a3b0b5a8a2ede1cd31ad8c754d5e56af2f662946b45e", + "examples/layers/tree-lab/dist/assets/tree-layer-guVFv6gO.js": "4c1e362eecb726ffab80a73b841bd9fe5ee30d22f0229dc7e3ea8c4dbec06917", + "examples/layers/tree-lab/dist/assets/index-CqeP2a4g.js": "4ccc899339c7a13a8be6a5d67766d90bf4cf7f3dbd5d34fb1f3c42b394851627", + "examples/layers/tree-lab/dist/assets/index-D2zqynLO.js": "f6e3dc88da5f499df33f130674c8f168763f15008d9e92334c93be0a914a4527" + }, + "renderer": "one production WebGL2 renderer per case; other Tree Lab animations paused", + "dataset": "deterministic mixed five-species forest, exact instance counts", + "measurement": "2 second warmup then three 5 second 90 degree camera orbits, 500 ms idle before each orbit", + "metrics": "rAF intervals; submitted onAfterRender draw count; synchronous render call wall time, not GPU elapsed time", + "view": "shared forest canopy unless query view=overview", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "device": "Apple M4 Pro through ANGLE Metal" + }, + "supersedes": { + "commit": "7134bcf916be37faab029d76231e60cc768755d4", + "path": "examples/layers/tree-lab/forest-review.json", + "reason": "Earlier samples were limited by host scheduling and used a different camera. They cannot be compared directly with this matrix." + }, + "workloads": { + "trees-dial-baseline-10000-high-static": { + "warmup": { + "renderer": "baseline", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 52.60000002384186, + "renderedFrames": 207, + "idleRenderedFrames": 0, + "idleDurationMs": 500.5, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 2007.5999999642372, + "deliveredFramesPerSecond": 103.1081888840842, + "treeInstances": 10000, + "renderCallMedianMs": 0.699999988079071, + "renderCallP95Ms": 1, + "medianFrameMs": 8.300000000000182, + "p95FrameMs": 16.700000000000045, + "maxFrameMs": 25.700000000000045, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 29382669, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + "samples": [ + { + "renderer": "baseline", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 52.60000002384186, + "renderedFrames": 491, + "idleRenderedFrames": 1, + "idleDurationMs": 500.5, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5011.300000011921, + "deliveredFramesPerSecond": 97.97856843510307, + "treeInstances": 10000, + "renderCallMedianMs": 0.699999988079071, + "renderCallP95Ms": 1.0999999642372131, + "medianFrameMs": 8.399999999999636, + "p95FrameMs": 16.800000000000182, + "maxFrameMs": 25.199999999999818, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 28612080, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "baseline", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 52.60000002384186, + "renderedFrames": 493, + "idleRenderedFrames": 1, + "idleDurationMs": 500.19999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5015.600000023842, + "deliveredFramesPerSecond": 98.29332482607396, + "treeInstances": 10000, + "renderCallMedianMs": 0.699999988079071, + "renderCallP95Ms": 1.0999999642372131, + "medianFrameMs": 8.399999999999636, + "p95FrameMs": 16.700000000000728, + "maxFrameMs": 49.399999999999636, + "over33ms": 1, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 30173405, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "baseline", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 52.60000002384186, + "renderedFrames": 485, + "idleRenderedFrames": 1, + "idleDurationMs": 500.9000000357628, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5011.599999964237, + "deliveredFramesPerSecond": 96.7754808850389, + "treeInstances": 10000, + "renderCallMedianMs": 0.6000000238418579, + "renderCallP95Ms": 0.8999999761581421, + "medianFrameMs": 8.400000000001455, + "p95FrameMs": 16.799999999999272, + "maxFrameMs": 17.69999999999891, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 27036698, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + } + ], + "summary": { + "medianDrawsPerSecond": 97.97856843510307, + "drawsPerSecondRange": [ + 96.7754808850389, + 98.29332482607396 + ], + "p95FrameMsRange": [ + 16.700000000000728, + 16.800000000000182 + ], + "longTaskCount": 0, + "errorCount": 0 + } + }, + "trees-dial-baseline-20000-high-static": { + "warmup": { + "renderer": "baseline", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 59.89999997615814, + "renderedFrames": 113, + "idleRenderedFrames": 0, + "idleDurationMs": 500.30000001192093, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 2015.0999999642372, + "deliveredFramesPerSecond": 56.0766215086127, + "treeInstances": 20000, + "renderCallMedianMs": 0.7999999523162842, + "renderCallP95Ms": 1.300000011920929, + "medianFrameMs": 16.700000000000045, + "p95FrameMs": 25.199999999999818, + "maxFrameMs": 33.299999999999955, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 24251166, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + "samples": [ + { + "renderer": "baseline", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 59.89999997615814, + "renderedFrames": 224, + "idleRenderedFrames": 1, + "idleDurationMs": 501.69999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5011.5, + "deliveredFramesPerSecond": 44.69719644816921, + "treeInstances": 20000, + "renderCallMedianMs": 0.7999999523162842, + "renderCallP95Ms": 1.4000000357627869, + "medianFrameMs": 24.899999999999636, + "p95FrameMs": 41.59999999999991, + "maxFrameMs": 59, + "over33ms": 13, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 26367357, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "baseline", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 59.89999997615814, + "renderedFrames": 253, + "idleRenderedFrames": 1, + "idleDurationMs": 500.39999997615814, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5006.899999976158, + "deliveredFramesPerSecond": 50.53026823008343, + "treeInstances": 20000, + "renderCallMedianMs": 0.8999999761581421, + "renderCallP95Ms": 1.4000000357627869, + "medianFrameMs": 17.200000000000728, + "p95FrameMs": 25.799999999999272, + "maxFrameMs": 26.100000000000364, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 27444646, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "baseline", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 59.89999997615814, + "renderedFrames": 264, + "idleRenderedFrames": 1, + "idleDurationMs": 501, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5006.599999964237, + "deliveredFramesPerSecond": 52.73039587781844, + "treeInstances": 20000, + "renderCallMedianMs": 1, + "renderCallP95Ms": 1.4000000357627869, + "medianFrameMs": 16.799999999999272, + "p95FrameMs": 25.30000000000291, + "maxFrameMs": 26, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 24591191, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + } + ], + "summary": { + "medianDrawsPerSecond": 50.53026823008343, + "drawsPerSecondRange": [ + 44.69719644816921, + 52.73039587781844 + ], + "p95FrameMsRange": [ + 25.30000000000291, + 41.59999999999991 + ], + "longTaskCount": 0, + "errorCount": 0 + } + }, + "trees-dial-control-before": { + "warmup": { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 1, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 70.39999997615814, + "renderedFrames": 240, + "idleRenderedFrames": 0, + "idleDurationMs": 500.19999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 2008.800000011921, + "deliveredFramesPerSecond": 119.47431302199112, + "treeInstances": 1, + "renderCallMedianMs": 0.5, + "renderCallP95Ms": 0.699999988079071, + "medianFrameMs": 8.300000000000182, + "p95FrameMs": 9.100000000000364, + "maxFrameMs": 9.399999999999636, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 11724572, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + "samples": [ + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 1, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 70.39999997615814, + "renderedFrames": 600, + "idleRenderedFrames": 1, + "idleDurationMs": 500.19999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5007.699999988079, + "deliveredFramesPerSecond": 119.81548415468744, + "treeInstances": 1, + "renderCallMedianMs": 0.5, + "renderCallP95Ms": 0.6000000238418579, + "medianFrameMs": 8.300000000001091, + "p95FrameMs": 9.100000000000364, + "maxFrameMs": 9.5, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 15165060, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 1, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 70.39999997615814, + "renderedFrames": 600, + "idleRenderedFrames": 1, + "idleDurationMs": 500.39999997615814, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5007.800000011921, + "deliveredFramesPerSecond": 119.81309157685445, + "treeInstances": 1, + "renderCallMedianMs": 0.40000003576278687, + "renderCallP95Ms": 0.6000000238418579, + "medianFrameMs": 8.300000000001091, + "p95FrameMs": 9.299999999999272, + "maxFrameMs": 9.400000000001455, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 13725033, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 1, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 70.39999997615814, + "renderedFrames": 601, + "idleRenderedFrames": 1, + "idleDurationMs": 500.39999997615814, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5004.5, + "deliveredFramesPerSecond": 120.091917274453, + "treeInstances": 1, + "renderCallMedianMs": 0.5, + "renderCallP95Ms": 2.800000011920929, + "medianFrameMs": 8.299999999999272, + "p95FrameMs": 9.299999999999272, + "maxFrameMs": 9.5, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 14057045, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + } + ], + "summary": { + "medianDrawsPerSecond": 119.81548415468744, + "drawsPerSecondRange": [ + 119.81309157685445, + 120.091917274453 + ], + "p95FrameMsRange": [ + 9.100000000000364, + 9.299999999999272 + ], + "longTaskCount": 0, + "errorCount": 0 + } + }, + "trees-dial-native-10000-high-static": { + "warmup": { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 108, + "renderedFrames": 240, + "idleRenderedFrames": 0, + "idleDurationMs": 500.7000000476837, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 2001.6000000238419, + "deliveredFramesPerSecond": 119.90407673718089, + "treeInstances": 10000, + "renderCallMedianMs": 0.699999988079071, + "renderCallP95Ms": 0.9000000357627869, + "medianFrameMs": 8.300000000000182, + "p95FrameMs": 9.299999999999955, + "maxFrameMs": 9.400000000000091, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 26504881, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + "samples": [ + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 108, + "renderedFrames": 600, + "idleRenderedFrames": 1, + "idleDurationMs": 500.5999999642372, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5007.800000011921, + "deliveredFramesPerSecond": 119.81309157685445, + "treeInstances": 10000, + "renderCallMedianMs": 0.699999988079071, + "renderCallP95Ms": 0.8999999761581421, + "medianFrameMs": 8.300000000000182, + "p95FrameMs": 9.200000000000728, + "maxFrameMs": 9.400000000000546, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 27617126, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 108, + "renderedFrames": 600, + "idleRenderedFrames": 1, + "idleDurationMs": 501.19999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5006.800000011921, + "deliveredFramesPerSecond": 119.83702165026992, + "treeInstances": 10000, + "renderCallMedianMs": 0.699999988079071, + "renderCallP95Ms": 0.800000011920929, + "medianFrameMs": 8.300000000001091, + "p95FrameMs": 8.799999999999272, + "maxFrameMs": 9.299999999999272, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 25504739, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 108, + "renderedFrames": 600, + "idleRenderedFrames": 1, + "idleDurationMs": 501.19999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5006.699999988079, + "deliveredFramesPerSecond": 119.83941518393924, + "treeInstances": 10000, + "renderCallMedianMs": 0.6000000238418579, + "renderCallP95Ms": 0.8999999761581421, + "medianFrameMs": 8.300000000001091, + "p95FrameMs": 9.100000000000364, + "maxFrameMs": 9.30000000000291, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 23562888, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + } + ], + "summary": { + "medianDrawsPerSecond": 119.83702165026992, + "drawsPerSecondRange": [ + 119.81309157685445, + 119.83941518393924 + ], + "p95FrameMsRange": [ + 8.799999999999272, + 9.200000000000728 + ], + "longTaskCount": 0, + "errorCount": 0 + } + }, + "trees-dial-native-10000-medium-wind-shadows": { + "warmup": { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 88.89999997615814, + "renderedFrames": 151, + "idleRenderedFrames": 39, + "idleDurationMs": 500.30000001192093, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 2005.3999999761581, + "deliveredFramesPerSecond": 75.29669891383027, + "treeInstances": 10000, + "renderCallMedianMs": 1.5, + "renderCallP95Ms": 1.899999976158142, + "medianFrameMs": 16.5, + "p95FrameMs": 17.59999999999991, + "maxFrameMs": 49.89999999999998, + "over33ms": 1, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 30601367, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + "samples": [ + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 88.89999997615814, + "renderedFrames": 362, + "idleRenderedFrames": 35, + "idleDurationMs": 502.69999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5008.099999964237, + "deliveredFramesPerSecond": 72.28290169976339, + "treeInstances": 10000, + "renderCallMedianMs": 1.4000000357627869, + "renderCallP95Ms": 1.899999976158142, + "medianFrameMs": 16.599999999999454, + "p95FrameMs": 17.099999999999454, + "maxFrameMs": 25.099999999999454, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 39015418, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 88.89999997615814, + "renderedFrames": 367, + "idleRenderedFrames": 37, + "idleDurationMs": 501.30000001192093, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5006.300000011921, + "deliveredFramesPerSecond": 73.30763238302261, + "treeInstances": 10000, + "renderCallMedianMs": 1.300000011920929, + "renderCallP95Ms": 1.699999988079071, + "medianFrameMs": 16.5, + "p95FrameMs": 17.5, + "maxFrameMs": 17.700000000000728, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 48365179, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 88.89999997615814, + "renderedFrames": 317, + "idleRenderedFrames": 36, + "idleDurationMs": 501.4000000357628, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5017.899999976158, + "deliveredFramesPerSecond": 63.173837661473165, + "treeInstances": 10000, + "renderCallMedianMs": 1.399999976158142, + "renderCallP95Ms": 1.800000011920929, + "medianFrameMs": 16.600000000002183, + "p95FrameMs": 25, + "maxFrameMs": 41.70000000000073, + "over33ms": 4, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 48369596, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + } + ], + "summary": { + "medianDrawsPerSecond": 72.28290169976339, + "drawsPerSecondRange": [ + 63.173837661473165, + 73.30763238302261 + ], + "p95FrameMsRange": [ + 17.099999999999454, + 25 + ], + "longTaskCount": 0, + "errorCount": 0 + } + }, + "trees-dial-native-20000-high-static": { + "warmup": { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 115.69999998807907, + "renderedFrames": 167, + "idleRenderedFrames": 0, + "idleDurationMs": 500.89999997615814, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 2007.0999999642372, + "deliveredFramesPerSecond": 83.2046235877513, + "treeInstances": 20000, + "renderCallMedianMs": 0.8999999761581421, + "renderCallP95Ms": 1.2000000476837158, + "medianFrameMs": 8.799999999999955, + "p95FrameMs": 16.800000000000182, + "maxFrameMs": 25, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 52200315, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + "samples": [ + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 115.69999998807907, + "renderedFrames": 372, + "idleRenderedFrames": 1, + "idleDurationMs": 501, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5016.900000035763, + "deliveredFramesPerSecond": 74.14937511159246, + "treeInstances": 20000, + "renderCallMedianMs": 0.7999999523162842, + "renderCallP95Ms": 1.100000023841858, + "medianFrameMs": 15.800000000000182, + "p95FrameMs": 25, + "maxFrameMs": 34.29999999999927, + "over33ms": 8, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 33437083, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 115.69999998807907, + "renderedFrames": 344, + "idleRenderedFrames": 1, + "idleDurationMs": 500.5999999642372, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5013.100000023842, + "deliveredFramesPerSecond": 68.62021503627774, + "treeInstances": 20000, + "renderCallMedianMs": 0.7000000476837158, + "renderCallP95Ms": 1, + "medianFrameMs": 16.600000000000364, + "p95FrameMs": 17.30000000000109, + "maxFrameMs": 25.599999999998545, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 36686949, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 115.69999998807907, + "renderedFrames": 326, + "idleRenderedFrames": 1, + "idleDurationMs": 501.30000001192093, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5009.599999964237, + "deliveredFramesPerSecond": 65.0750558931506, + "treeInstances": 20000, + "renderCallMedianMs": 0.7000000476837158, + "renderCallP95Ms": 1.0999999642372131, + "medianFrameMs": 16.600000000002183, + "p95FrameMs": 17.400000000001455, + "maxFrameMs": 25, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 34176586, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + } + ], + "summary": { + "medianDrawsPerSecond": 68.62021503627774, + "drawsPerSecondRange": [ + 65.0750558931506, + 74.14937511159246 + ], + "p95FrameMsRange": [ + 17.30000000000109, + 25 + ], + "longTaskCount": 0, + "errorCount": 0 + } + }, + "trees-dial-native-20000-medium-wind-no-shadows": { + "warmup": { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 132.69999998807907, + "renderedFrames": 197, + "idleRenderedFrames": 50, + "idleDurationMs": 500.60000002384186, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 2019.9000000357628, + "deliveredFramesPerSecond": 97.52958067058373, + "treeInstances": 20000, + "renderCallMedianMs": 0.9000000357627869, + "renderCallP95Ms": 1.5999999642372131, + "medianFrameMs": 8.399999999999864, + "p95FrameMs": 17.100000000000364, + "maxFrameMs": 26, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 76361102, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + "samples": [ + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 132.69999998807907, + "renderedFrames": 469, + "idleRenderedFrames": 46, + "idleDurationMs": 501.89999997615814, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5012.399999976158, + "deliveredFramesPerSecond": 93.56795148077384, + "treeInstances": 20000, + "renderCallMedianMs": 0.800000011920929, + "renderCallP95Ms": 1.300000011920929, + "medianFrameMs": 8.400000000000546, + "p95FrameMs": 17.100000000000364, + "maxFrameMs": 17.700000000000728, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 81456578, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 132.69999998807907, + "renderedFrames": 468, + "idleRenderedFrames": 44, + "idleDurationMs": 500.69999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5012.299999952316, + "deliveredFramesPerSecond": 93.37030904065045, + "treeInstances": 20000, + "renderCallMedianMs": 0.699999988079071, + "renderCallP95Ms": 0.8999999761581421, + "medianFrameMs": 8.399999999999636, + "p95FrameMs": 17, + "maxFrameMs": 17.5, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 82180106, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 132.69999998807907, + "renderedFrames": 466, + "idleRenderedFrames": 47, + "idleDurationMs": 501.39999997615814, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5019.399999976158, + "deliveredFramesPerSecond": 92.83978164764981, + "treeInstances": 20000, + "renderCallMedianMs": 0.699999988079071, + "renderCallP95Ms": 1.199999988079071, + "medianFrameMs": 8.399999999999636, + "p95FrameMs": 17, + "maxFrameMs": 17.600000000000364, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 83192966, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + } + ], + "summary": { + "medianDrawsPerSecond": 93.37030904065045, + "drawsPerSecondRange": [ + 92.83978164764981, + 93.56795148077384 + ], + "p95FrameMsRange": [ + 17, + 17.100000000000364 + ], + "longTaskCount": 0, + "errorCount": 0 + } + }, + "trees-dial-native-20000-medium-wind-shadows": { + "warmup": { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 100.5, + "renderedFrames": 86, + "idleRenderedFrames": 19, + "idleDurationMs": 500.89999997615814, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 2008.6000000238419, + "deliveredFramesPerSecond": 42.81589166532868, + "treeInstances": 20000, + "renderCallMedianMs": 1.399999976158142, + "renderCallP95Ms": 1.699999988079071, + "medianFrameMs": 25, + "p95FrameMs": 25.5, + "maxFrameMs": 50, + "over33ms": 1, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 51531111, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + "samples": [ + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 100.5, + "renderedFrames": 223, + "idleRenderedFrames": 22, + "idleDurationMs": 501.5, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5011.5, + "deliveredFramesPerSecond": 44.497655392597025, + "treeInstances": 20000, + "renderCallMedianMs": 1.399999976158142, + "renderCallP95Ms": 1.7000000476837158, + "medianFrameMs": 24.899999999999636, + "p95FrameMs": 25.699999999999818, + "maxFrameMs": 31.399999999999636, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 61983950, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 100.5, + "renderedFrames": 203, + "idleRenderedFrames": 20, + "idleDurationMs": 502, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5019, + "deliveredFramesPerSecond": 40.4463040446304, + "treeInstances": 20000, + "renderCallMedianMs": 1.399999976158142, + "renderCallP95Ms": 1.699999988079071, + "medianFrameMs": 25, + "p95FrameMs": 25.900000000001455, + "maxFrameMs": 34.30000000000109, + "over33ms": 4, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 76467567, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + }, + { + "renderer": "native", + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "count": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "firstFrameMs": 100.5, + "renderedFrames": 196, + "idleRenderedFrames": 19, + "idleDurationMs": 500.89999997615814, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5010.5, + "deliveredFramesPerSecond": 39.117852509729566, + "treeInstances": 20000, + "renderCallMedianMs": 1.5, + "renderCallP95Ms": 2.100000023841858, + "medianFrameMs": 25, + "p95FrameMs": 33.29999999999927, + "maxFrameMs": 33.60000000000218, + "over33ms": 7, + "longTaskCount": 0, + "longTaskMs": 0, + "heapBytes": 57538700, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "errors": [] + } + ], + "summary": { + "medianDrawsPerSecond": 40.4463040446304, + "drawsPerSecondRange": [ + 39.117852509729566, + 44.497655392597025 + ], + "p95FrameMsRange": [ + 25.699999999999818, + 33.29999999999927 + ], + "longTaskCount": 0, + "errorCount": 0 + } + } + } +} diff --git a/examples/layers/tree-lab/forest-scene.ts b/examples/layers/tree-lab/forest-scene.ts new file mode 100644 index 000000000..486fd2b60 --- /dev/null +++ b/examples/layers/tree-lab/forest-scene.ts @@ -0,0 +1,61 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {WebMercatorViewport, type MapViewState} from '@deck.gl/core'; +import type {TreeLayer} from '@deck.gl-community/layers'; +import {VIEW, createSceneLayers, type SceneOptions, type TreeLayerConstructor} from './scene'; +import type {ForestSpecimen} from './forest-data'; + +const getHeight = (tree: ForestSpecimen) => tree.height; +const getCanopyRadius = (tree: ForestSpecimen) => tree.canopyRadius; +const getTrunkRadius = (tree: ForestSpecimen) => tree.trunkRadius; + +/** Both benchmarks and the live forest use the same dimensions and deterministic source rows. */ +export function createForestSceneLayers( + LayerClass: TreeLayerConstructor, + id: string, + data: ForestSpecimen[], + options: SceneOptions +) { + const layers = createSceneLayers(LayerClass, id, data, options); + layers[1] = (layers[1] as TreeLayer).clone({ + getHeight, + getCanopyRadius, + getTrunkRadius + }); + return layers; +} + +/** Shared forest cameras keep the live example and its benchmark at the same scale. */ +export function getForestViewState( + count: number, + width: number, + height: number, + overview = false +): MapViewState { + const camera: MapViewState = { + ...VIEW, + minZoom: 10, + zoom: 17.2, + pitch: 60, + position: [0, 0, 7] + }; + if (!overview) return camera; + const halfSide = (Math.ceil(Math.sqrt(count)) * 9 + 20) / 111320; + const fitted = new WebMercatorViewport({width, height}).fitBounds( + [ + [-halfSide, -halfSide], + [halfSide, halfSide] + ], + {padding: 40} + ); + return { + ...camera, + longitude: fitted.longitude, + latitude: fitted.latitude, + position: [0, 0, 0], + bearing: 0, + pitch: 35, + zoom: fitted.zoom - 0.1 + }; +} diff --git a/examples/layers/tree-lab/forest.browser.spec.ts b/examples/layers/tree-lab/forest.browser.spec.ts new file mode 100644 index 000000000..345a3758a --- /dev/null +++ b/examples/layers/tree-lab/forest.browser.spec.ts @@ -0,0 +1,137 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import type {Deck} from '@deck.gl/core'; +import type {TreeLayer} from '@deck.gl-community/layers'; +import {expect, it, vi} from 'vitest'; +import {mountTreeForestExample} from './forest'; + +type ForestApi = {ready: boolean; errors: string[]; count: number; deck: Deck}; + +it('renders 10K and 20K forests at highest geometry through season, shadow and view changes', async () => { + const originalUrl = location.href; + const queryUrl = new URL(originalUrl); + queryUrl.search = '?count=10000&detail=low&fly=1&sun=0&wind=0&shadows=0'; + history.replaceState(null, '', queryUrl); + const container = document.createElement('div'); + container.style.width = '900px'; + document.body.append(container); + const cleanup = mountTreeForestExample(container, true); + const api = (window as Window & {treeForest?: ForestApi}).treeForest!; + let frames = 0; + const originalAfterRender = api.deck.props.onAfterRender; + api.deck.setProps({ + onAfterRender(context) { + originalAfterRender?.(context); + frames++; + } + }); + const change = async (action: () => void) => { + const before = frames; + action(); + await expect.poll(() => frames, {timeout: 30000}).toBeGreaterThan(before); + expect(api.errors).toEqual([]); + }; + try { + // Check the initialization contract, then pause before the first GPU draw. + // A continuous flyover otherwise queues large software-renderer draws in CI. + const flyover = container.querySelector('[aria-label="Flyover"]')!; + expect(flyover.checked).toBe(!matchMedia('(prefers-reduced-motion: reduce)').matches); + if (flyover.checked) flyover.click(); + await expect.poll(() => api.ready, {timeout: 30000}).toBe(true); + expect(api.count).toBe(10000); + expect(container.querySelector('[aria-label="Detail"]')).toBeNull(); + expect((api.deck.props.layers[1] as TreeLayer).props).not.toHaveProperty('detail'); + await change(() => container.querySelector('[data-count="20000"]')!.click()); + expect(api.count).toBe(20000); + expect((api.deck.props.layers[1] as {props: {data: unknown[]}}).props.data).toHaveLength(20000); + const shadows = container.querySelector('[aria-label="Shadows"]')!; + // Keep the actual 20K shadow on/off regression. Seasonal behavior is drawn + // without shadows here; matched shadow pixels are covered by the lab tests. + expect(shadows.checked).toBe(false); + await change(() => shadows.click()); + expect(shadows.checked).toBe(true); + const pitch = container.querySelector('[aria-label="Pitch"]')!; + await change(() => { + pitch.value = '80'; + pitch.dispatchEvent(new Event('input')); + }); + expect((api.deck.getViewports()[0] as {pitch?: number}).pitch).toBe(80); + expect(container.querySelector('#forest-pitch')!.textContent).toBe('80°'); + await change(() => shadows.click()); + expect(shadows.checked).toBe(false); + const season = container.querySelector('[aria-label="Season"]')!; + for (const value of ['winter', 'spring', 'summer', 'autumn']) { + await change(() => { + season.value = value; + season.dispatchEvent(new Event('change')); + }); + } + await change(() => container.querySelector('#forest-view')!.click()); + const viewport = api.deck.getViewports()[0]; + const halfSide = (Math.ceil(Math.sqrt(20000)) * 9 + 20) / 111320; + for (const longitude of [-halfSide, halfSide]) { + for (const latitude of [-halfSide, halfSide]) { + const [x, y] = viewport.project([longitude, latitude, 0]); + expect(x).toBeGreaterThanOrEqual(0); + expect(x).toBeLessThanOrEqual(viewport.width); + expect(y).toBeGreaterThanOrEqual(0); + expect(y).toBeLessThanOrEqual(viewport.height); + } + } + expect(container.querySelector('.status')!.textContent).toContain('20,000'); + expect(api.errors).toEqual([]); + } finally { + cleanup(); + container.remove(); + history.replaceState(null, '', originalUrl); + } +}, 90000); + +it('honors reduced motion and the stats toggle, with an explicit wind opt-in', async () => { + const originalUrl = location.href; + const matchMedia = window.matchMedia.bind(window); + const preference = vi + .spyOn(window, 'matchMedia') + .mockImplementation(query => + query === '(prefers-reduced-motion: reduce)' + ? ({matches: true} as MediaQueryList) + : matchMedia(query) + ); + const container = document.createElement('div'); + document.body.append(container); + let cleanup: (() => void) | undefined; + try { + for (const wind of ['', '&wind=1']) { + const queryUrl = new URL(originalUrl); + queryUrl.search = `?count=1&shadows=0${wind}`; + history.replaceState(null, '', queryUrl); + cleanup = mountTreeForestExample(container); + const api = (window as Window & {treeForest?: ForestApi}).treeForest!; + await expect.poll(() => api.ready, {timeout: 30000}).toBe(true); + expect(container.querySelector('[aria-label="Wind"]')!.checked).toBe( + Boolean(wind) + ); + expect(container.querySelector('[aria-label="Flyover"]')!.checked).toBe( + false + ); + expect( + container.querySelector('[aria-label="Moving sunlight"]')!.checked + ).toBe(false); + const stats = container.querySelector('[aria-label="Show performance"]')!; + const performanceLabel = container.querySelector('#forest-performance')!; + stats.click(); + expect(getComputedStyle(performanceLabel).display).toBe('none'); + stats.click(); + expect(getComputedStyle(performanceLabel).display).not.toBe('none'); + expect(api.errors).toEqual([]); + cleanup(); + cleanup = undefined; + } + } finally { + cleanup?.(); + preference.mockRestore(); + container.remove(); + history.replaceState(null, '', originalUrl); + } +}, 60000); diff --git a/examples/layers/tree-lab/forest.html b/examples/layers/tree-lab/forest.html new file mode 100644 index 000000000..fc49c630e --- /dev/null +++ b/examples/layers/tree-lab/forest.html @@ -0,0 +1,2 @@ + +Tree Forest · 10K / 20K
diff --git a/examples/layers/tree-lab/forest.ts b/examples/layers/tree-lab/forest.ts new file mode 100644 index 000000000..edbbeaa01 --- /dev/null +++ b/examples/layers/tree-lab/forest.ts @@ -0,0 +1,271 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {Deck, type MapViewState} from '@deck.gl/core'; +import {TreeLayer} from '@deck.gl-community/layers'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {createForestSpecimens, getTreeCount} from './forest-data'; +import {DEFAULT_OPTIONS, SEASONS, createViews, createLighting, type SceneOptions} from './scene'; +import {getTourFrame} from './tour'; +import {createForestSceneLayers, getForestViewState} from './forest-scene'; +import './style.css'; + +/** One native renderer with a procedural mixed forest, highest-quality meshes and shared sunlight. */ +export function mountTreeForestExample(container: HTMLElement, standalone = false): () => void { + const query = new URLSearchParams(location.search); + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; + let count = getTreeCount(query.get('count'), 20000); + let data = createForestSpecimens(count); + const options: SceneOptions = { + ...DEFAULT_OPTIONS, + crops: false, + dropped: false, + shadows: query.get('shadows') !== '0', + wind: query.get('wind') === '1' || (query.get('wind') !== '0' && !reducedMotion), + windTime: null, + season: SEASONS.find(value => value === query.get('season')) ?? 'summer' + }; + let flyover = !reducedMotion && query.get('fly') !== '0'; + let sunlight = query.get('sun') !== '0' && !reducedMotion; + let overview = query.get('view') === 'overview'; + let showStats = query.get('stats') !== '0'; + let request = 0; + let ready = false; + let disposed = false; + const errors: string[] = []; + const root = document.createElement('div'); + root.className = 'tree-lab tree-forest'; + root.innerHTML = `
Native vis.gl / Forest scale

Five species. One shared forest. Explore the crowns, wind and seasonal shadows.

+
+ + + + +
+
20,000 trees in memoryProcedural scene · drag to explore · 1× pixelsMeasuring draws…
+
Preparing forest…
${standalone ? 'Measure this workload · Compare every tree · ' : ''}Overview fits the entire forest; flyover inspects individual crowns. WebGL shadows and GPU wind are independently controlled. This is illustrative data.
`; + container.replaceChildren(root); + const heading = root.querySelector('h1')!; + const status = root.querySelector('.status')!; + const badge = root.querySelector('.forest-caption strong')!; + const pitchControl = root.querySelector('[aria-label="Pitch"]')!; + const pitchLabel = root.querySelector('#forest-pitch')!; + const seasonControl = root.querySelector('[aria-label="Season"]')!; + const windControl = root.querySelector('[aria-label="Wind"]')!; + const shadowControl = root.querySelector('[aria-label="Shadows"]')!; + const sunControl = root.querySelector('[aria-label="Moving sunlight"]')!; + const flyControl = root.querySelector('[aria-label="Flyover"]')!; + const viewControl = root.querySelector('#forest-view')!; + const statsControl = root.querySelector('[aria-label="Show performance"]')!; + const performanceLabel = root.querySelector('#forest-performance')!; + const drawTimes = new Float64Array(512); + let drawIndex = 0; + let drawCount = 0; + let lastStats = 0; + const lighting = createLighting(options.shadows); + const getCamera = (fitOverview = overview): MapViewState => { + const {width, height} = root.querySelector('.canvas')!.getBoundingClientRect(); + return getForestViewState(count, width, height, fitOverview); + }; + let camera = getCamera(); + const getLayers = () => + createForestSceneLayers(TreeLayer, 'forest', data, { + ...options, + windTime: options.wind && !document.hidden ? null : 0 + }); + const refreshLabels = () => { + heading.textContent = `${count.toLocaleString()} trees`; + badge.textContent = `${data.length.toLocaleString()} trees · five species`; + pitchControl.value = String(camera.pitch ?? 0); + pitchLabel.value = `${Math.round(camera.pitch ?? 0)}°`; + seasonControl.value = options.season; + windControl.checked = options.wind; + shadowControl.checked = options.shadows; + sunControl.checked = sunlight; + flyControl.checked = flyover; + statsControl.checked = showStats; + performanceLabel.hidden = !showStats; + // The caption stylesheet sets display:block on spans, overriding HTML hidden. + performanceLabel.style.display = showStats ? '' : 'none'; + viewControl.textContent = overview ? 'Inspect the canopy' : 'Show entire forest'; + for (const button of root.querySelectorAll('[data-count]')) + button.setAttribute('aria-pressed', String(Number(button.dataset.count) === count)); + status.textContent = `${ready ? 'Ready' : 'Preparing'} · ${count.toLocaleString()} trees · ${options.season} · wind ${options.wind ? 'on' : 'off'} · shadows ${options.shadows ? 'on' : 'off'}${errors.length ? ` · ${errors.join('; ')}` : ''}`; + const benchmark = root.querySelector('#forest-benchmark'); + if (benchmark) + benchmark.href = `./native.html?count=${count}&species=mixed&wind=${Number(options.wind)}&shadows=${Number(options.shadows)}&season=${options.season}&view=${overview ? 'overview' : 'canopy'}`; + }; + const deck = new Deck({ + parent: root.querySelector('.canvas')!, + width: '100%', + height: '100%', + useDevicePixels: 1, + deviceProps: {type: 'webgl', adapters: [webgl2Adapter]}, + views: createViews(), + viewState: camera, + layers: getLayers(), + effects: [lighting], + onViewStateChange: ({viewState, interactionState}) => { + camera = viewState as MapViewState; + if ( + interactionState.isDragging || + interactionState.isPanning || + interactionState.isRotating || + interactionState.isZooming + ) + flyover = false; + deck.setProps({viewState: camera}); + refreshLabels(); + }, + onResize: () => { + if (overview) { + camera = getCamera(); + deck.setProps({viewState: camera}); + } + }, + onAfterRender: () => { + if (showStats) { + drawTimes[drawIndex] = performance.now(); + drawIndex = (drawIndex + 1) % drawTimes.length; + drawCount = Math.min(drawCount + 1, drawTimes.length); + } + if (!ready) { + ready = true; + refreshLabels(); + } + }, + onError: error => { + errors.push(error.message); + refreshLabels(); + } + }); + const refresh = () => { + deck.setProps({layers: getLayers()}); + refreshLabels(); + }; + for (const button of root.querySelectorAll('[data-count]')) + button.addEventListener('click', () => { + count = Number(button.dataset.count); + data = createForestSpecimens(count); + if (overview) { + camera = getCamera(); + deck.setProps({viewState: camera}); + } + refresh(); + }); + pitchControl.addEventListener('input', () => { + flyover = false; + camera = {...camera, pitch: Number(pitchControl.value)}; + deck.setProps({viewState: camera}); + refreshLabels(); + }); + seasonControl.addEventListener('change', () => { + sunlight = false; + options.season = seasonControl.value as SceneOptions['season']; + refresh(); + }); + windControl.addEventListener('change', () => { + options.wind = windControl.checked; + refresh(); + }); + shadowControl.addEventListener('change', () => { + options.shadows = shadowControl.checked; + lighting.setProps(createLighting(options.shadows).props); + deck.setProps({effects: [lighting]}); + deck.redraw('forest shadows'); + refreshLabels(); + }); + sunControl.addEventListener('change', () => { + sunlight = sunControl.checked; + refreshLabels(); + }); + flyControl.addEventListener('change', () => { + flyover = flyControl.checked; + refreshLabels(); + }); + viewControl.addEventListener('click', () => { + overview = !overview; + camera = getCamera(); + deck.setProps({viewState: camera}); + refreshLabels(); + }); + statsControl.addEventListener('change', () => { + showStats = statsControl.checked; + drawCount = 0; + lastStats = 0; + refreshLabels(); + }); + document.addEventListener('visibilitychange', refresh); + const start = performance.now(); + const tick = (now: number) => { + if (disposed) return; + if (!document.hidden) { + const seconds = (now - start) / 1000; + if (showStats && now - lastStats >= 1000) { + lastStats = now; + const sampleTime = performance.now(); + const gaps: number[] = []; + let previous = 0; + let recentDraws = 0; + for (let index = 0; index < drawCount; index++) { + const time = + drawTimes[(drawIndex - drawCount + index + drawTimes.length) % drawTimes.length]; + if (sampleTime - time > 1000) continue; + if (previous) gaps.push(time - previous); + previous = time; + recentDraws++; + } + gaps.sort((a, b) => a - b); + performanceLabel.textContent = gaps.length + ? `${recentDraws} draws/s · p95 ${gaps[Math.floor(gaps.length * 0.95)].toFixed(1)} ms` + : 'Idle'; + } + if (sunlight) { + const frame = getTourFrame(seconds); + if (options.season !== frame.season) { + options.season = frame.season; + refresh(); + } + lighting.setSunDirection(frame.direction); + deck.redraw('forest sunlight'); + } + if (flyover) { + const phase = seconds / 35; + const next = { + ...camera, + bearing: (overview ? 0 : 22) + Math.sin(phase) * (overview ? 8 : 24) + }; + if (!overview) { + next.longitude = Math.sin(phase * 0.7) * 0.00065; + next.latitude = Math.cos(phase * 0.7) * 0.00065; + } + deck.setProps({viewState: next}); + } + } + request = requestAnimationFrame(tick); + }; + request = requestAnimationFrame(tick); + refreshLabels(); + const api = { + get ready() { + return ready; + }, + get errors() { + return [...errors]; + }, + get count() { + return data.length; + }, + deck + }; + Object.assign(window, {treeForest: api}); + return () => { + disposed = true; + cancelAnimationFrame(request); + document.removeEventListener('visibilitychange', refresh); + deck.finalize(); + root.remove(); + const reviewWindow = window as Window & {treeForest?: typeof api}; + if (reviewWindow.treeForest === api) delete reviewWindow.treeForest; + }; +} diff --git a/examples/layers/tree-lab/ground-shadow-filter.browser.spec.ts b/examples/layers/tree-lab/ground-shadow-filter.browser.spec.ts new file mode 100644 index 000000000..f2e9f16e6 --- /dev/null +++ b/examples/layers/tree-lab/ground-shadow-filter.browser.spec.ts @@ -0,0 +1,76 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +// Value: protects=small shadow gaps blend while large openings remain lit; +// fails_when=coverage filtering leaves hard holes or binds textures replaced during resize; +// why_new=thin-leaf PCF checks do not exercise the reusable ground mask; seam=none + +import type {Texture} from '@luma.gl/core'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {expect, it} from 'vitest'; +import {GroundShadowFilter} from './ground-shadow-filter'; + +it('softens edges and small gaps without filling large openings, including after resize', async () => { + const canvas = document.createElement('canvas'); + const device = await webgl2Adapter.create({createCanvasContext: {canvas, autoResize: false}}); + const filter = new GroundShadowFilter(device); + const textures: Texture[] = []; + let previousMap: Texture | undefined; + try { + for (const width of [128, 256]) { + const height = 64; + const depths = new Float32Array(width * height).fill(1); + const scale = width / 128; + for (let y = 0; y < height; y++) { + for (let x = 16 * scale; x < 80 * scale; x++) { + if (x < 48 * scale || x >= 50 * scale) depths[y * width + x] = 0.5; + } + } + const depth = device.createTexture({ + format: 'depth32float', + width, + height, + data: depths, + sampler: { + type: 'comparison-sampler', + compare: 'less-equal', + minFilter: 'linear', + magFilter: 'linear', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge' + } + }); + textures.push(depth); + await expect + .poll( + () => { + filter.render(depth, [0, 0, 0.8, 0.001], [2 / 128, 2 / 64]); + return filter.ready; + }, + {timeout: 15000} + ) + .toBe(true); + const map = filter.map; + if (previousMap) expect(previousMap.destroyed).toBe(true); + previousMap = map; + expect([map.width, map.height]).toEqual([width / 2, height / 2]); + const pixels = device.readPixelsToArrayWebGL(map) as Uint8Array; + const value = (x: number) => pixels[(16 * map.width + Math.floor((x * scale) / 2)) * 4] / 255; + expect(value(32)).toBeGreaterThan(0.98); + expect(value(49), 'the narrow gap keeps blended shadow coverage').toBeGreaterThan(0.65); + expect(value(49)).toBeLessThan(0.95); + expect(value(100), 'the large opening remains lit').toBeLessThan(0.02); + const edge = Array.from({length: 12}, (_, x) => value(8 + x * 2)); + expect(edge.some(coverage => coverage > 0.1 && coverage < 0.9)).toBe(true); + for (let x = 1; x < edge.length; x++) { + expect(edge[x] - edge[x - 1]).toBeGreaterThanOrEqual(-1 / 255); + expect(edge[x] - edge[x - 1]).toBeLessThan(0.45); + } + expect(device.gl.getError()).toBe(device.gl.NO_ERROR); + } + } finally { + filter.destroy(); + for (const texture of textures) texture.destroy(); + device.destroy(); + } +}); diff --git a/examples/layers/tree-lab/ground-shadow-filter.ts b/examples/layers/tree-lab/ground-shadow-filter.ts new file mode 100644 index 000000000..9ee352d22 --- /dev/null +++ b/examples/layers/tree-lab/ground-shadow-filter.ts @@ -0,0 +1,130 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import type {Device, Framebuffer, Texture} from '@luma.gl/core'; +import {ClipSpace} from '@luma.gl/engine'; + +const FILTER_UNIFORMS = { + name: 'groundShadow', + uniformTypes: {plane: 'vec4', step: 'vec2'}, + fs: `uniform groundShadowUniforms { + vec4 plane; + vec2 step; + } groundShadow;` +} as const; + +// Separable nine-tap Gaussian, collapsed to five bilinear texture lookups. +// The horizontal pass compares native depths; the vertical pass filters coverage. +export const GROUND_SHADOW_FILTER = ` + float getCoverage(vec2 point) { + if (any(lessThanEqual(point, vec2(0.0))) || any(greaterThanEqual(point, vec2(1.0)))) return 0.0; + float depth = dot(groundShadow.plane.xyz, vec3(point, 1.0)) - groundShadow.plane.w; + if (depth <= 0.0 || depth >= 1.0) return 0.0; + return 1.0 - texture(depthMap, vec3(point, depth)); + } + void main() { + float result = getCoverage(uv) * 0.227027; + result += (getCoverage(uv + groundShadow.step * 1.384615) + getCoverage(uv - groundShadow.step * 1.384615)) * 0.316216; + result += (getCoverage(uv + groundShadow.step * 3.230769) + getCoverage(uv - groundShadow.step * 3.230769)) * 0.070270; + color = vec4(result, 0.0, 0.0, 1.0); + }`; + +const VERTICAL_FILTER = ` + void main() { + float result = texture(coverageMap, uv).r * 0.227027; + result += (texture(coverageMap, uv + groundShadow.step * 1.384615).r + texture(coverageMap, uv - groundShadow.step * 1.384615).r) * 0.316216; + result += (texture(coverageMap, uv + groundShadow.step * 3.230769).r + texture(coverageMap, uv - groundShadow.step * 3.230769).r) * 0.070270; + color = vec4(result, 0.0, 0.0, 1.0); + }`; + +/** Filter planar ground coverage once per light, rather than at every screen pixel. */ +export class GroundShadowFilter { + ready = false; + private readonly targets: Framebuffer[]; + private readonly models: ClipSpace[]; + + constructor(private readonly device: Device) { + const makeTexture = () => + device.createTexture({ + format: 'rgba8unorm', + width: 1, + height: 1, + sampler: { + minFilter: 'linear', + magFilter: 'linear', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge' + } + }); + this.targets = [makeTexture(), makeTexture()].map(texture => + device.createFramebuffer({colorAttachments: [texture]}) + ); + this.models = [ + new ClipSpace(device, { + id: 'ground-shadow-horizontal', + modules: [FILTER_UNIFORMS], + fs: `#version 300 es + precision highp float; + uniform highp sampler2DShadow depthMap; + in vec2 uv; + out vec4 color; + ${GROUND_SHADOW_FILTER}`, + parameters: {depthWriteEnabled: false, depthCompare: 'always', blend: false} + }), + new ClipSpace(device, { + id: 'ground-shadow-vertical', + modules: [FILTER_UNIFORMS], + fs: `#version 300 es + precision highp float; + uniform sampler2D coverageMap; + in vec2 uv; + out vec4 color; + ${VERTICAL_FILTER}`, + parameters: {depthWriteEnabled: false, depthCompare: 'always', blend: false} + }) + ]; + } + + get map(): Texture { + return this.targets[1].colorAttachments[0].texture; + } + + render(depth: Texture, plane: [number, number, number, number], step: [number, number]) { + this.ready = false; + const width = Math.max(1, Math.ceil(depth.width / 2)); + const height = Math.max(1, Math.ceil(depth.height / 2)); + for (const target of this.targets) { + if (target.width !== width || target.height !== height) { + const previous = target.colorAttachments[0].texture; + target.resize({width, height}); + previous.destroy(); + } + } + for (const [index, model] of this.models.entries()) { + model.setBindings( + index === 0 ? {depthMap: depth} : {coverageMap: this.targets[0].colorAttachments[0].texture} + ); + model.shaderInputs.setProps({ + groundShadow: {plane, step: index === 0 ? [step[0], 0] : [0, step[1]]} + }); + const pass = this.device.beginRenderPass({ + framebuffer: this.targets[index], + parameters: {viewport: [0, 0, width, height]}, + clearColor: [0, 0, 0, 0] + }); + const drawn = model.draw(pass); + pass.end(); + if (!drawn) return; + } + this.ready = true; + } + + destroy() { + for (const model of this.models) model.destroy(); + for (const target of this.targets) { + const texture = target.colorAttachments[0].texture; + target.destroy(); + texture.destroy(); + } + } +} diff --git a/examples/layers/tree-lab/high-pitch-shadow.browser.spec.ts b/examples/layers/tree-lab/high-pitch-shadow.browser.spec.ts new file mode 100644 index 000000000..b35adfca0 --- /dev/null +++ b/examples/layers/tree-lab/high-pitch-shadow.browser.spec.ts @@ -0,0 +1,123 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +// Value: protects=local ground shadows remain visible as the camera crosses the horizon; +// fails_when=screen-corner ground intersections flip behind the camera or stretch the shadow map; +// why_new=existing shadow tests keep a 58 degree camera; seam=none + +import {Deck} from '@deck.gl/core'; +import {TreeLayer} from '@deck.gl-community/layers'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {expect, it} from 'vitest'; +import { + createLighting, + createSceneLayers, + createSpecimens, + createViews, + DEFAULT_OPTIONS, + VIEW, + SEASONS +} from './scene'; + +it('keeps local shadows through the horizon at every season and moving sun angle', async () => { + const width = 600; + const height = 400; + const container = document.createElement('div'); + container.style.cssText = `width:${width}px;height:${height}px`; + document.body.append(container); + const lighting = createLighting(true); + const errors: string[] = []; + let frame = 0; + let pixels = new Uint8Array(); + const deck = new Deck({ + parent: container, + width, + height, + useDevicePixels: false, + deviceProps: {type: 'webgl', adapters: [webgl2Adapter]}, + views: createViews(), + viewState: VIEW, + effects: [lighting], + layers: [], + onError: error => errors.push(error.message), + onAfterRender({gl}) { + pixels = new Uint8Array(width * height * 4); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + if (gl.getError() !== gl.NO_ERROR) errors.push('Shadow readback failed'); + frame++; + } + }); + try { + let data = createSpecimens('oak'); + const capture = async (casters: boolean) => { + const before = frame; + deck.setProps({ + layerFilter: ({layer, renderPass}) => + renderPass === 'shadow' ? casters : layer.id.endsWith('-ground') + }); + await expect.poll(() => frame, {timeout: 15000}).toBeGreaterThan(before); + expect(errors).toEqual([]); + return pixels.slice(); + }; + for (const species of ['oak', 'palm'] as const) { + data = createSpecimens(species); + for (const season of SEASONS) { + for (const pitch of [58, 68, 70, 71.4, 71.5, 71.56505117707799, 72, 75, 80]) { + deck.setProps({ + viewState: {...VIEW, zoom: 19.5, pitch, bearing: 0, position: [0, 0, 0]}, + layers: createSceneLayers(TreeLayer, 'horizon', data, { + ...DEFAULT_OPTIONS, + season, + crops: false, + dropped: false, + shadows: true, + wind: true, + windTime: 1.5 + }) + }); + for (const direction of [ + [-1, -0.6, -1.4], + [1, 0.8, -0.7] + ] as [number, number, number][]) { + lighting.setSunDirection(direction); + const clear = await capture(false); + const shadowed = await capture(true); + let localShadowPixels = 0; + let distantShadowPixels = 0; + const viewport = deck.getViewports()[0]; + for (let offset = 0; offset < clear.length; offset += 4) { + const darkening = + clear[offset] - + shadowed[offset] + + clear[offset + 1] - + shadowed[offset + 1] + + clear[offset + 2] - + shadowed[offset + 2]; + if (clear[offset + 3] === 0 || darkening <= 15) continue; + const index = offset / 4; + const [longitude, latitude] = viewport.unproject([ + (index % width) + 0.5, + height - Math.floor(index / width) - 0.5 + ]); + // Each specimen and its shadow fit comfortably within 40m. + if (Math.hypot(longitude, latitude) * 111320 < 40) localShadowPixels++; + else distantShadowPixels++; + } + expect( + localShadowPixels, + `${species}, ${season}, pitch ${pitch}, sun ${direction}` + ).toBeGreaterThan(25); + expect( + distantShadowPixels, + `${species}, ${season}, pitch ${pitch}: no phantom distant shadows` + ).toBe(0); + } + } + } + } + expect(errors).toEqual([]); + } finally { + deck.finalize(); + container.remove(); + } +}, 90000); diff --git a/examples/layers/tree-lab/index.html b/examples/layers/tree-lab/index.html new file mode 100644 index 000000000..0d3ca1e73 --- /dev/null +++ b/examples/layers/tree-lab/index.html @@ -0,0 +1,2 @@ + +Tree Lab · vis.gl
diff --git a/examples/layers/tree-lab/main.ts b/examples/layers/tree-lab/main.ts new file mode 100644 index 000000000..4e4fba9ec --- /dev/null +++ b/examples/layers/tree-lab/main.ts @@ -0,0 +1,2 @@ +import {mountTreeLabExample} from './app'; +mountTreeLabExample(document.querySelector('#app')!); diff --git a/examples/layers/tree-lab/native.html b/examples/layers/tree-lab/native.html new file mode 100644 index 000000000..ef2f2bd5f --- /dev/null +++ b/examples/layers/tree-lab/native.html @@ -0,0 +1,2 @@ + +Tree Lab · vis.gl
diff --git a/examples/layers/tree-lab/package.json b/examples/layers/tree-lab/package.json new file mode 100644 index 000000000..f4df122a5 --- /dev/null +++ b/examples/layers/tree-lab/package.json @@ -0,0 +1,29 @@ +{ + "name": "@deck.gl-community/tree-lab-example", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "start": "vite --host 127.0.0.1 --port 5188", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1 --port 5189", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@deck.gl-community/layers": "workspace:*", + "@deck.gl/core": "~9.4.0", + "@deck.gl/mesh-layers": "~9.4.0", + "@luma.gl/core": "~9.4.0", + "@luma.gl/engine": "~9.4.0", + "@luma.gl/shadertools": "~9.4.0", + "@luma.gl/webgl": "~9.4.0", + "@luma.gl/webgpu": "~9.4.0", + "@math.gl/core": "^4.0.0" + }, + "devDependencies": { + "@types/three": "^0.170.0", + "three": "^0.170.0", + "typescript": "6.0.3", + "vite": "^7.3.1" + } +} diff --git a/examples/layers/tree-lab/review.json b/examples/layers/tree-lab/review.json new file mode 100644 index 000000000..a1d549ab5 --- /dev/null +++ b/examples/layers/tree-lab/review.json @@ -0,0 +1,456 @@ +{ + "method": "One production renderer per page; three 5-second camera orbits on Apple M4 Pro/ANGLE Metal at 1236x680, 1x pixels. Browser delivery intervals, not GPU timings. Captured before final picking/override/film/shadow-depth/winter-join correctness fixes.", + "samples": { + "production-palm-baseline": [ + { + "renderer": "baseline", + "species": "palm", + "count": 5000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 382, + "idleRenderedFrames": 0, + "idleDurationMs": 501.10000002384186, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5008.199999988079, + "medianFrameMs": 15.899999999999636, + "p95FrameMs": 17.5, + "maxFrameMs": 26, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + }, + { + "renderer": "baseline", + "species": "palm", + "count": 5000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 380, + "idleRenderedFrames": 1, + "idleDurationMs": 501.9000000357628, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5013.599999964237, + "medianFrameMs": 16.200000000000728, + "p95FrameMs": 17.299999999999272, + "maxFrameMs": 25.399999999999636, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + }, + { + "renderer": "baseline", + "species": "palm", + "count": 5000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 390, + "idleRenderedFrames": 1, + "idleDurationMs": 501.69999998807907, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5013.800000011921, + "medianFrameMs": 15.700000000000728, + "p95FrameMs": 17.599999999998545, + "maxFrameMs": 33.29999999999927, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + } + ], + "production-palm-native": [ + { + "renderer": "native", + "species": "palm", + "count": 5000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 588, + "idleRenderedFrames": 0, + "idleDurationMs": 501, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5003.400000035763, + "medianFrameMs": 8.300000000000182, + "p95FrameMs": 9.199999999999818, + "maxFrameMs": 24.900000000001455, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + }, + { + "renderer": "native", + "species": "palm", + "count": 5000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 539, + "idleRenderedFrames": 1, + "idleDurationMs": 501, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5007.100000023842, + "medianFrameMs": 8.300000000001091, + "p95FrameMs": 16.700000000000728, + "maxFrameMs": 17.700000000000728, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + }, + { + "renderer": "native", + "species": "palm", + "count": 5000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": false, + "wind": false, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 600, + "idleRenderedFrames": 1, + "idleDurationMs": 501.2000000476837, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5002.199999988079, + "medianFrameMs": 8.300000000001091, + "p95FrameMs": 9.200000000000728, + "maxFrameMs": 9.400000000001455, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + } + ], + "final-animated-forest-pcf": [ + { + "renderer": "native", + "species": "oak", + "count": 1000, + "options": { + "season": "summer", + "crops": true, + "dropped": true, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 601, + "idleRenderedFrames": 60, + "idleDurationMs": 501.4000000357628, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5002.600000023842, + "medianFrameMs": 8.300000000000182, + "p95FrameMs": 9.100000000000364, + "maxFrameMs": 9.399999999999636, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + }, + { + "renderer": "native", + "species": "oak", + "count": 1000, + "options": { + "season": "summer", + "crops": true, + "dropped": true, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 601, + "idleRenderedFrames": 60, + "idleDurationMs": 500.80000001192093, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5007, + "medianFrameMs": 8.300000000001091, + "p95FrameMs": 9.100000000000364, + "maxFrameMs": 9.399999999999636, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + }, + { + "renderer": "native", + "species": "oak", + "count": 1000, + "options": { + "season": "summer", + "crops": true, + "dropped": true, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "backend": "webgl", + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "renderedFrames": 601, + "idleRenderedFrames": 60, + "idleDurationMs": 501.10000002384186, + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5007.199999988079, + "medianFrameMs": 8.299999999999272, + "p95FrameMs": 9.299999999999272, + "maxFrameMs": 9.400000000001455, + "over33ms": 0, + "longTaskCount": 0, + "longTaskMs": 0, + "errors": [] + } + ] + }, + "implementationChunks": [ + { + "bytes": 101543, + "gzipBytes": 28903, + "sha256": "b05ed92a7e5aa568ecb86b9bce32f0fbecee7b1a5f4b3cc4d101fd0638088b5c" + }, + { + "bytes": 20653, + "gzipBytes": 7815, + "sha256": "c793b2356f0b56b04bab3db8023cb60e6f46f8222254f048a8ed68e267d12827" + } + ], + "geometry": [ + { + "species": "pine", + "baselineTriangles": 76, + "highTriangles": 140, + "mediumTriangles": 108, + "lowTriangles": 76 + }, + { + "species": "oak", + "baselineTriangles": 252, + "highTriangles": 288, + "mediumTriangles": 168, + "lowTriangles": 64 + }, + { + "species": "palm", + "baselineTriangles": 4448, + "highTriangles": 1760, + "mediumTriangles": 1224, + "lowTriangles": 648 + }, + { + "species": "birch", + "baselineTriangles": 140, + "highTriangles": 288, + "mediumTriangles": 168, + "lowTriangles": 64 + }, + { + "species": "cherry", + "baselineTriangles": 168, + "highTriangles": 288, + "mediumTriangles": 168, + "lowTriangles": 64 + } + ] +} diff --git a/examples/layers/tree-lab/scene.ts b/examples/layers/tree-lab/scene.ts new file mode 100644 index 000000000..b935461ea --- /dev/null +++ b/examples/layers/tree-lab/scene.ts @@ -0,0 +1,134 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {AmbientLight, DirectionalLight, MapView, type MapViewState} from '@deck.gl/core'; +import {TreeLightingEffect} from './tree-lighting'; +import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; +import {PlaneGeometry} from '@luma.gl/engine'; +import type {TreeType, Season, CropConfig} from '@deck.gl-community/layers'; + +export type TreeLayerConstructor = + | typeof import('@deck.gl-community/layers').TreeLayer + | typeof import('./baseline/tree-layer').TreeLayer; + +export const SPECIES: TreeType[] = ['pine', 'oak', 'palm', 'birch', 'cherry']; +export const SEASONS: Season[] = ['spring', 'summer', 'autumn', 'winter']; +export type Specimen = {position: [number, number]; species: TreeType; index: number}; +export type SceneOptions = { + season: Season; + crops: boolean; + dropped: boolean; + shadows: boolean; + wind: boolean; + backend: 'webgl' | 'webgpu'; + windTime: number | null; + pixelRatio: number; +}; +export const DEFAULT_OPTIONS: SceneOptions = { + season: 'summer', + crops: true, + dropped: true, + shadows: false, + wind: false, + backend: 'webgl', + windTime: 0, + pixelRatio: 1 +}; +export const VIEW: MapViewState = { + longitude: 0, + latitude: 0, + zoom: 20.5, + position: [0, 0, 5], + pitch: 58, + bearing: 22, + minZoom: 16, + maxZoom: 23, + maxPitch: 80 +}; +const GROUND = new PlaneGeometry({type: 'x,y', xlen: 4000, ylen: 4000, nx: 1, ny: 1}); + +export function createSpecimens(species: TreeType, count = 1): Specimen[] { + const width = Math.ceil(Math.sqrt(count)); + return Array.from({length: count}, (_, index) => ({ + index, + species, + position: + count === 1 + ? [0, 0] + : [ + (((index % width) - width / 2) * 18) / 111320, + ((Math.floor(index / width) - width / 2) * 18) / 111320 + ] + })); +} +export function getCrop(species: TreeType, options: SceneOptions): CropConfig | null { + if (!options.crops && !options.dropped) return null; + const color: [number, number, number, number] = + species === 'cherry' && options.season === 'spring' + ? [255, 208, 226, 255] + : species === 'palm' + ? [179, 102, 38, 255] + : [214, 73, 39, 255]; + return { + color, + count: options.crops ? 32 : 0, + droppedCount: options.dropped ? 12 : 0, + radius: 0.16 + }; +} +export function createSceneLayers( + LayerClass: TreeLayerConstructor, + id: string, + data: Specimen[], + options: SceneOptions +) { + return [ + new SimpleMeshLayer({ + id: `${id}-ground`, + data: [{position: [0, 0, -0.02]}], + mesh: GROUND, + getColor: [226, 231, 222], + material: {ambient: 1, diffuse: 0.15, shininess: 0}, + shadowEnabled: false + }), + new LayerClass({ + id: `${id}-trees`, + data, + getPosition: (d: Specimen) => d.position, + getTreeType: (d: Specimen) => d.species, + getHeight: () => 12, + getTrunkHeightFraction: (d: Specimen) => + d.species === 'palm' ? 0.72 : d.species === 'pine' ? 0.18 : 0.36, + getTrunkRadius: (d: Specimen) => (d.species === 'palm' ? 0.25 : 0.38), + getCanopyRadius: (d: Specimen) => + d.species === 'palm' ? 4.5 : d.species === 'birch' ? 5.5 : 7, + getBranchLevels: () => 4, + getSeason: () => options.season, + getCrop: (d: Specimen) => getCrop(d.species, options), + windStrength: options.wind ? 0.025 : 0, + windTime: options.windTime, + shadowEnabled: true, + pickable: true, + updateTriggers: { + getSeason: options.season, + getCrop: [options.crops, options.dropped, options.season] + } + }) + ]; +} +export function createLighting(shadows: boolean) { + const effect = new TreeLightingEffect({ + ambient: new AmbientLight({color: [255, 255, 255], intensity: 1}), + key: new DirectionalLight({ + color: [255, 246, 230], + intensity: 1.5, + direction: [-1, -0.6, -1.4], + _shadow: shadows + }) + }); + effect.shadowColor = [0.12, 0.18, 0.1, 0.28]; + return effect; +} +export function createViews() { + return new MapView({id: 'specimen', controller: true}); +} diff --git a/examples/layers/tree-lab/shadow-filter.browser.spec.ts b/examples/layers/tree-lab/shadow-filter.browser.spec.ts new file mode 100644 index 000000000..93d5a8cce --- /dev/null +++ b/examples/layers/tree-lab/shadow-filter.browser.spec.ts @@ -0,0 +1,111 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +// Value: protects=thin shadow coverage survives fractional motion across shadow texels; +// fails_when=depth interpolation erases silhouettes before the shadow comparison; +// why_new=scene coverage tests do not isolate filtering order or subtexel motion; seam=shader source + +import {expect, it} from 'vitest'; +import {SHADOW_FILTER} from './tree-lighting'; + +it('preserves a thin shadow while its receiver moves between texels', () => { + const canvas = document.createElement('canvas'); + canvas.width = 129; + canvas.height = 1; + const gl = canvas.getContext('webgl2')!; + expect(gl).not.toBeNull(); + const shaders: WebGLShader[] = []; + const program = gl.createProgram()!; + const texture = gl.createTexture()!; + const vao = gl.createVertexArray()!; + const compile = (type: number, source: string) => { + const shader = gl.createShader(type)!; + shaders.push(shader); + gl.shaderSource(shader, source); + gl.compileShader(shader); + expect(gl.getShaderParameter(shader, gl.COMPILE_STATUS), gl.getShaderInfoLog(shader)!).toBe( + true + ); + gl.attachShader(program, shader); + }; + try { + compile( + gl.VERTEX_SHADER, + `#version 300 es + void main() { + vec2 vertex = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + gl_Position = vec4(vertex * 2.0 - 1.0, 0.0, 1.0); + }` + ); + compile( + gl.FRAGMENT_SHADER, + `#version 300 es + precision highp float; + uniform highp sampler2DShadow shadowMap; + uniform float receiverDepth; + out vec4 color; + ${SHADOW_FILTER} + void main() { + float x = (5.5 + (gl_FragCoord.x - 0.5) / 32.0) / 16.0; + float occlusion = shadow_getShadowWeight(vec3(x, 0.5, receiverDepth), shadowMap, 0.001); + color = vec4(vec3(occlusion), 1.0); + }` + ); + gl.linkProgram(program); + expect(gl.getProgramParameter(program, gl.LINK_STATUS), gl.getProgramInfoLog(program)!).toBe( + true + ); + gl.useProgram(program); + gl.bindVertexArray(vao); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texture); + // A single-texel blade at depth 0.5 against clear depth 1.0. + // Native depth comparison samplers interpolate comparison results, never depths. + const depths = new Float32Array(16 * 8).fill(1); + for (let y = 0; y < 8; y++) depths[y * 16 + 7] = 0.5; + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.DEPTH_COMPONENT32F, + 16, + 8, + 0, + gl.DEPTH_COMPONENT, + gl.FLOAT, + depths + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.uniform1i(gl.getUniformLocation(program, 'shadowMap'), 0); + gl.viewport(0, 0, canvas.width, 1); + const curves: number[][] = []; + for (const depth of [0.51, 0.9]) { + gl.uniform1f(gl.getUniformLocation(program, 'receiverDepth'), depth); + gl.drawArrays(gl.TRIANGLES, 0, 3); + const pixels = new Uint8Array(canvas.width * 4); + gl.readPixels(0, 0, canvas.width, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + expect(gl.getError()).toBe(gl.NO_ERROR); + const coverage = Array.from({length: canvas.width}, (_, x) => pixels[x * 4]); + // The three-texel tent preserves fractional blade coverage between centers. + expect(Math.abs(coverage[32] - 255 / 4)).toBeLessThanOrEqual(1); + expect(Math.abs(coverage[64] - 255 / 2)).toBeLessThanOrEqual(1); + expect(Math.abs(coverage[96] - 255 / 4)).toBeLessThanOrEqual(1); + for (const value of coverage.slice(32, 97)) expect(value).toBeGreaterThanOrEqual(63); + for (let x = 1; x < coverage.length; x++) { + expect(Math.abs(coverage[x] - coverage[x - 1])).toBeLessThanOrEqual(3); + } + curves.push(coverage); + } + expect(curves[1]).toEqual(curves[0]); + } finally { + gl.deleteProgram(program); + for (const shader of shaders) gl.deleteShader(shader); + gl.deleteTexture(texture); + gl.deleteVertexArray(vao); + gl.getExtension('WEBGL_lose_context')?.loseContext(); + } +}); diff --git a/examples/layers/tree-lab/shadow-frustum.ts b/examples/layers/tree-lab/shadow-frustum.ts new file mode 100644 index 000000000..298022bed --- /dev/null +++ b/examples/layers/tree-lab/shadow-frustum.ts @@ -0,0 +1,159 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {project, shadow, type ProjectUniforms, type Viewport} from '@deck.gl/core'; +import {Matrix4} from '@math.gl/core'; +import type {Texture} from '@luma.gl/core'; + +// The lab's 12m specimens and varied forest dimensions remain below 32m, +// including wind. Include this entire slab so elevated crowns are not clipped. +const CASTER_HEIGHT = 32; +const corners = new WeakMap(); +const projections = new WeakMap>(); + +function getShadowCorners(viewport: Viewport): number[][] { + const cached = corners.get(viewport); + if (cached) return cached; + const [cameraX, cameraY, cameraZ] = viewport.cameraPosition; + const height = CASTER_HEIGHT * viewport.distanceScales.unitsPerMeter[2]; + const range = Math.max( + (2 * Math.max(viewport.width, viewport.height)) / viewport.scale, + height * 4 + ); + const unprojection = new Matrix4(viewport.pixelUnprojectionMatrix); + const points: number[][] = [ + [cameraX, cameraY, 0], + [cameraX, cameraY, height] + ]; + for (const x of [0, viewport.width]) { + for (const y of [0, viewport.height]) { + const far = unprojection.transformAsPoint([x, y, 1]); + const dx = far[0] - cameraX; + const dy = far[1] - cameraY; + const dz = far[2] - cameraZ; + const groundT = dz < 0 ? Math.max(0, -cameraZ / dz) : Infinity; + // Rays above the horizon never intersect the ground in front of the + // camera. End those rays at a finite distance instead of behind it. + const t = Math.min(groundT, range / Math.max(Math.hypot(dx, dy), 1e-10)); + points.push([cameraX + dx * t, cameraY + dy * t, 0]); + points.push([cameraX + dx * t, cameraY + dy * t, height]); + } + } + corners.set(viewport, points); + return points; +} + +function getShadowProjections(viewport: Viewport, lights: Matrix4[]): Matrix4[] { + let cache = projections.get(lights); + if (!cache) { + cache = new WeakMap(); + projections.set(lights, cache); + } + const cached = cache.get(viewport); + if (cached) return cached; + const points = getShadowCorners(viewport); + const result = lights.map(light => { + const view = light.clone().translate(viewport.center.map(value => -value)); + const positions = points.map(point => view.transformAsPoint(point)); + const padding = CASTER_HEIGHT * viewport.distanceScales.unitsPerMeter[2]; + return new Matrix4() + .ortho({ + left: Math.min(...positions.map(point => point[0])) - padding, + right: Math.max(...positions.map(point => point[0])) + padding, + bottom: Math.min(...positions.map(point => point[1])) - padding, + top: Math.max(...positions.map(point => point[1])) + padding, + near: Math.min(...positions.map(point => -point[2])) - padding, + far: Math.max(...positions.map(point => -point[2])) + padding + }) + .multiplyRight(light); + }); + cache.set(viewport, result); + return result; +} + +/** Use the same finite light volume in the depth pass and the receiving color pass. */ +type TreeShadowProps = Parameters[0] & { + groundMaps?: Texture[]; + dummyGroundMap?: Texture; + groundReceiver?: boolean; +}; + +export function getBoundedShadowUniforms(opts: TreeShadowProps) { + const uniforms = { + ...shadow.getUniforms(opts), + depthBias: [0.000015, 0.000015] as [number, number], + groundFilter: [opts.groundReceiver ? 1 : 0, 0] as [number, number], + shadow_uGroundMap0: opts.groundMaps?.[0] ?? opts.dummyGroundMap, + shadow_uGroundMap1: opts.groundMaps?.[1] ?? opts.dummyGroundMap + }; + const viewport = opts.project?.viewport; + if (!viewport?.isGeospatial || opts.shadowEnabled === false || !opts.shadowMatrices?.length) + return uniforms; + const projectUniforms = project.getUniforms(opts.project!) as ProjectUniforms; + const center = new Matrix4(viewport.viewProjectionMatrix) + .invert() + .transform(projectUniforms.center); + getShadowProjections(viewport, opts.shadowMatrices).forEach((matrix, index) => { + // Convert a 2cm receiver bias into normalized light depth. A fixed + // normalized bias grows into metres when the forest volume gets larger. + const depthScale = Math.hypot(matrix[2], matrix[6], matrix[10]); + uniforms.depthBias[index] = Math.max( + 0.000015, + 0.01 * depthScale * viewport.distanceScales.unitsPerMeter[2] + ); + const projection = matrix.clone(); + const centered = projection.clone().translate(viewport.center.map(value => -value)); + // deck.gl uses w=0 matrices with a separate clip-space center for its + // precision-preserving offset coordinates; preserve that shader convention. + const matrixKey = `viewProjectionMatrix${index}` as + | 'viewProjectionMatrix0' + | 'viewProjectionMatrix1'; + const centerKey = `projectCenter${index}` as 'projectCenter0' | 'projectCenter1'; + // Read the valid camera convention, not the upstream shadow matrix: + // that matrix itself can be non-finite exactly at the horizon. + if (projectUniforms.viewProjectionMatrix[15] === 0) { + const clipCenter = centered.transform(center); + uniforms[centerKey] = [clipCenter[0], clipCenter[1], clipCenter[2], clipCenter[3]]; + projection[12] = 0; + projection[13] = 0; + projection[14] = 0; + projection[15] = 0; + uniforms[matrixKey] = Array.from(projection) as typeof uniforms.viewProjectionMatrix0; + } else { + uniforms[centerKey] = [center[0], center[1], center[2], center[3]]; + uniforms[matrixKey] = Array.from(centered) as typeof uniforms.viewProjectionMatrix0; + } + }); + return uniforms; +} + +/** Ground is planar at -2cm in the lab; express its depth in each light's UV space. */ +export function getGroundShadowSettings( + viewport: Viewport, + lights: Matrix4[], + sizes: [number, number][] +) { + return getShadowProjections(viewport, lights).map((matrix, index) => { + const inverse = matrix + .clone() + .translate(viewport.center.map(value => -value)) + .invert(); + const groundHeight = -0.02 * viewport.distanceScales.unitsPerMeter[2]; + const a = -inverse[2] / inverse[10]; + const b = -inverse[6] / inverse[10]; + const c = 0.5 + (inverse[2] + inverse[6] - inverse[14] + groundHeight) / (2 * inverse[10]); + const depthScale = Math.hypot(matrix[2], matrix[6], matrix[10]); + const bias = Math.max(0.000015, 0.01 * depthScale * viewport.distanceScales.unitsPerMeter[2]); + // A 45cm Gaussian standard deviation blends leaflets without filling the crown silhouette. + const units = viewport.distanceScales.unitsPerMeter[2]; + const stepX = (0.45 / 1.69) * 0.5 * Math.hypot(matrix[0], matrix[4], matrix[8]) * units; + const stepY = (0.45 / 1.69) * 0.5 * Math.hypot(matrix[1], matrix[5], matrix[9]) * units; + return { + plane: [a, b, c, bias] as [number, number, number, number], + step: [Math.max(1 / sizes[index][0], stepX), Math.max(1 / sizes[index][1], stepY)] as [ + number, + number + ] + }; + }); +} diff --git a/examples/layers/tree-lab/shadow-review.json b/examples/layers/tree-lab/shadow-review.json new file mode 100644 index 000000000..9cde7e200 --- /dev/null +++ b/examples/layers/tree-lab/shadow-review.json @@ -0,0 +1,1571 @@ +{ + "baselineSource": "ecc51da5abf2a0f05c0baba3b263dd0f083a45b9", + "filteringSource": "116a0f21a974e77a630be8c3f1ef13cbc3b10cc7", + "method": "Warmup and three five-second 90-degree camera orbits per run, one active renderer. Inactive comparison pages unloaded; forest wind/sun paused. No concurrent build or test processes. Browser draw delivery and submission time, not GPU elapsed time.", + "postMeasurementChanges": "Explicit resize texture cleanup followed the initial 20K samples; receiving shaders and the 1024-pixel budget were unchanged. The 10K samples include cleanup. Initial samples precede the final fragment-hook correction; after-final-hook-20k-medium records the corrected shader in the same workload.", + "samples": { + "after-10k-medium": [ + { + "renderer": "native", + "count": 10000, + "treeInstances": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5006.900000035763, + "renderedFrames": 383, + "deliveredFramesPerSecond": 76.49443767546073, + "medianFrameMs": 15.800000000000182, + "p95FrameMs": 17.600000000000364, + "maxFrameMs": 25.800000000000182, + "renderCallMedianMs": 1.5, + "renderCallP95Ms": 1.899999976158142, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 10000, + "treeInstances": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5002.600000023842, + "renderedFrames": 404, + "deliveredFramesPerSecond": 80.7580058365799, + "medianFrameMs": 9.199999999998909, + "p95FrameMs": 17.200000000000728, + "maxFrameMs": 17.600000000000364, + "renderCallMedianMs": 1.399999976158142, + "renderCallP95Ms": 1.800000011920929, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 10000, + "treeInstances": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5018.099999964237, + "renderedFrames": 397, + "deliveredFramesPerSecond": 79.11360873693815, + "medianFrameMs": 15.900000000001455, + "p95FrameMs": 17.30000000000109, + "maxFrameMs": 17.700000000000728, + "renderCallMedianMs": 1.399999976158142, + "renderCallP95Ms": 1.800000011920929, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + } + ], + "after-20k-high": [ + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5015.5, + "renderedFrames": 204, + "deliveredFramesPerSecond": 40.67391087628352, + "medianFrameMs": 25, + "p95FrameMs": 25.800000000000182, + "maxFrameMs": 33.399999999999636, + "renderCallMedianMs": 1.5, + "renderCallP95Ms": 1.9000000357627869, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5019.300000011921, + "renderedFrames": 202, + "deliveredFramesPerSecond": 40.24465562917543, + "medianFrameMs": 25, + "p95FrameMs": 33.29999999999927, + "maxFrameMs": 33.600000000000364, + "renderCallMedianMs": 1.5, + "renderCallP95Ms": 1.899999976158142, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5011.800000011921, + "renderedFrames": 208, + "deliveredFramesPerSecond": 41.50205514974765, + "medianFrameMs": 25, + "p95FrameMs": 25.799999999999272, + "maxFrameMs": 33.39999999999782, + "renderCallMedianMs": 1.399999976158142, + "renderCallP95Ms": 1.7999999523162842, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + } + ], + "after-20k-medium": [ + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5016.300000011921, + "renderedFrames": 264, + "deliveredFramesPerSecond": 52.628431313791566, + "medianFrameMs": 16.700000000004366, + "p95FrameMs": 33.30000000000291, + "maxFrameMs": 50.099999999998545, + "renderCallMedianMs": 2.299999952316284, + "renderCallP95Ms": 3.799999952316284, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5020.800000011921, + "renderedFrames": 261, + "deliveredFramesPerSecond": 51.98374760981921, + "medianFrameMs": 16.80000000000291, + "p95FrameMs": 25.299999999995634, + "maxFrameMs": 41.69999999999709, + "renderCallMedianMs": 2, + "renderCallP95Ms": 3.5, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5022, + "renderedFrames": 251, + "deliveredFramesPerSecond": 49.98008761449621, + "medianFrameMs": 16.700000000004366, + "p95FrameMs": 33.30000000000291, + "maxFrameMs": 50.099999999998545, + "renderCallMedianMs": 1.9000000357627869, + "renderCallP95Ms": 3.399999976158142, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + } + ], + "after-repeat-20k-medium": [ + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5021.699999988079, + "renderedFrames": 229, + "deliveredFramesPerSecond": 45.60208694277707, + "medianFrameMs": 17.399999999999636, + "p95FrameMs": 25.5, + "maxFrameMs": 334.1999999999989, + "renderCallMedianMs": 1.5999999642372131, + "renderCallP95Ms": 2.199999988079071, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5012.199999988079, + "renderedFrames": 221, + "deliveredFramesPerSecond": 44.09241450870389, + "medianFrameMs": 24.80000000000109, + "p95FrameMs": 33.30000000000109, + "maxFrameMs": 34.099999999998545, + "renderCallMedianMs": 1.699999988079071, + "renderCallP95Ms": 3.399999976158142, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5017, + "renderedFrames": 228, + "deliveredFramesPerSecond": 45.445485349810646, + "medianFrameMs": 17.099999999998545, + "p95FrameMs": 25.5, + "maxFrameMs": 350, + "renderCallMedianMs": 1.5, + "renderCallP95Ms": 2, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + } + ], + "before-10k-medium": [ + { + "renderer": "native", + "count": 10000, + "treeInstances": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5048, + "renderedFrames": 189, + "deliveredFramesPerSecond": 37.4405705229794, + "medianFrameMs": 25, + "p95FrameMs": 41.600000000000364, + "maxFrameMs": 42.600000000000364, + "renderCallMedianMs": 1.800000011920929, + "renderCallP95Ms": 3.199999988079071, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 10000, + "treeInstances": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5026, + "renderedFrames": 190, + "deliveredFramesPerSecond": 37.80342220453641, + "medianFrameMs": 25, + "p95FrameMs": 41.900000000001455, + "maxFrameMs": 50.100000000000364, + "renderCallMedianMs": 2, + "renderCallP95Ms": 3.399999976158142, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 10000, + "treeInstances": 10000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5065.800000011921, + "renderedFrames": 184, + "deliveredFramesPerSecond": 36.32200244770165, + "medianFrameMs": 25, + "p95FrameMs": 49.70000000000073, + "maxFrameMs": 107.69999999999709, + "renderCallMedianMs": 1.9000000357627869, + "renderCallP95Ms": 3.399999976158142, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + } + ], + "before-20k-high": [ + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5022.599999964237, + "renderedFrames": 119, + "deliveredFramesPerSecond": 23.692908055757442, + "medianFrameMs": 41.69999999999982, + "p95FrameMs": 50, + "maxFrameMs": 50.19999999999982, + "renderCallMedianMs": 1.4000000357627869, + "renderCallP95Ms": 1.7999999523162842, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5043.900000035763, + "renderedFrames": 122, + "deliveredFramesPerSecond": 24.187632585724337, + "medianFrameMs": 41.69999999999891, + "p95FrameMs": 49.399999999999636, + "maxFrameMs": 50.099999999998545, + "renderCallMedianMs": 1.300000011920929, + "renderCallP95Ms": 1.5999999642372131, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "high", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5043.800000011921, + "renderedFrames": 118, + "deliveredFramesPerSecond": 23.395059280645764, + "medianFrameMs": 41.70000000000073, + "p95FrameMs": 50.5, + "maxFrameMs": 58.29999999999927, + "renderCallMedianMs": 1.699999988079071, + "renderCallP95Ms": 3.200000047683716, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + } + ], + "before-20k-medium": [ + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5022.299999952316, + "renderedFrames": 149, + "deliveredFramesPerSecond": 29.667682137947686, + "medianFrameMs": 33.30000000000109, + "p95FrameMs": 42, + "maxFrameMs": 83.39999999999964, + "renderCallMedianMs": 1.399999976158142, + "renderCallP95Ms": 1.7999999523162842, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5028.199999988079, + "renderedFrames": 158, + "deliveredFramesPerSecond": 31.422775545995503, + "medianFrameMs": 33.20000000000073, + "p95FrameMs": 33.89999999999782, + "maxFrameMs": 308.2999999999993, + "renderCallMedianMs": 1.300000011920929, + "renderCallP95Ms": 1.699999988079071, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5024.800000011921, + "renderedFrames": 159, + "deliveredFramesPerSecond": 31.643050469595366, + "medianFrameMs": 33.29999999999927, + "p95FrameMs": 41.599999999998545, + "maxFrameMs": 42, + "renderCallMedianMs": 1.300000011920929, + "renderCallP95Ms": 1.600000023841858, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + } + ], + "after-final-hook-20k-medium": [ + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5006.100000023842, + "renderedFrames": 221, + "deliveredFramesPerSecond": 44.146141706907066, + "medianFrameMs": 24.899999999999636, + "p95FrameMs": 25.5, + "maxFrameMs": 26.80000000000109, + "renderCallMedianMs": 1.699999988079071, + "renderCallP95Ms": 2.300000011920929, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5042.900000035763, + "renderedFrames": 219, + "deliveredFramesPerSecond": 43.42739296802374, + "medianFrameMs": 24.30000000000291, + "p95FrameMs": 40.70000000000073, + "maxFrameMs": 116.59999999999854, + "renderCallMedianMs": 1.5999999642372131, + "renderCallP95Ms": 2, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + }, + { + "renderer": "native", + "count": 20000, + "treeInstances": 20000, + "options": { + "season": "summer", + "crops": false, + "dropped": false, + "shadows": true, + "wind": true, + "detail": "medium", + "backend": "webgl", + "windTime": null, + "pixelRatio": 1 + }, + "camera": { + "longitude": 0, + "latitude": 0, + "zoom": 17.2, + "position": [ + 0, + 0, + 7 + ], + "pitch": 60, + "bearing": 22, + "minZoom": 10, + "maxZoom": 23, + "maxPitch": 80 + }, + "view": "canopy", + "species": "mixed", + "viewport": { + "width": 1236, + "height": 680, + "devicePixelRatio": 1 + }, + "durationMs": 5007.599999964237, + "renderedFrames": 246, + "deliveredFramesPerSecond": 49.12532949951211, + "medianFrameMs": 17.400000000001455, + "p95FrameMs": 25.700000000000728, + "maxFrameMs": 41.69999999999709, + "renderCallMedianMs": 1.5, + "renderCallP95Ms": 1.9000000357627869, + "longTaskCount": 0, + "longTaskMs": 0, + "device": { + "type": "webgl", + "gpu": "apple", + "gpuType": "integrated", + "gpuBackend": "metal", + "vendor": "Google Inc. (Apple)", + "renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)", + "version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)", + "shadingLanguage": "glsl", + "shadingLanguageVersion": 300 + }, + "visibilityAtStart": "visible", + "focusedAtStart": true, + "visibilityAtEnd": "visible", + "errors": [] + } + ] + }, + "film": { + "sha256": "d2cde8d23de2b970a29471bd2ee035e2b69c0c956f93197b41ddac0912d539dd", + "source": "116a0f21a974e77a630be8c3f1ef13cbc3b10cc7", + "size": [ + 1920, + 1080 + ], + "fps": 30, + "durationSeconds": 60, + "decodedFrames": 1800, + "renderingErrors": 0 + }, + "finalHookCheck": { + "sourceBase": "0c8a5449ce1c770a5547b04ea9cd6597d331dc3f", + "change": "fs:#main-start skips material shading; actual assembled fragment entry is asserted in the browser regression.", + "productionAssets": [ + "baseline-CEueKSKb.js", + "benchmark-BxaNo6YC.js", + "expression-Bl_GL2tO.js", + "film-7EWUYSTI.js", + "forest-BcstsbSk.js", + "forest-scene-BZyRn80Q.js", + "index-DA6Avmpj.js", + "index-DDUI5khl.js", + "lab-oK59ZcPT.js", + "native-8Pad6alB.js", + "style-DTIiAmHe.js", + "tour-DzDQTWr3.js", + "tree-layer-4me5GHnp.js", + "tree-layer-C5iTzxVG.js", + "webgpu-adapter-I0m49hE2.js", + "webgpu-device-C5l0G5rQ.js" + ], + "warmupDurationMs": 2021.199999988079 + } +} diff --git a/examples/layers/tree-lab/shadow.browser.spec.ts b/examples/layers/tree-lab/shadow.browser.spec.ts new file mode 100644 index 000000000..2e4b2b08b --- /dev/null +++ b/examples/layers/tree-lab/shadow.browser.spec.ts @@ -0,0 +1,210 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +// Value: protects=ground shadow footprints depend on geometry and sun rather than caster material; +// fails_when=lighting or opacity corrupts packed depth and creates material-dependent shadow holes; +// why_new=the Tree Lab controls test does not isolate and compare ground shadow pixels; seam=none + +import {Deck, LightingEffect, type DeckProps} from '@deck.gl/core'; +import {TreeLayer} from '@deck.gl-community/layers'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {describe, expect, it} from 'vitest'; +import {TreeLayer as OriginalTreeLayer} from './baseline/tree-layer'; +import { + createLighting, + createSceneLayers, + createSpecimens, + createViews, + DEFAULT_OPTIONS, + VIEW +} from './scene'; +import {getTourFrame} from './tour'; + +type Frame = {width: number; height: number; pixels: Uint8Array}; + +function getShadowMask(clear: Frame, shadowed: Frame): Uint8Array { + expect([shadowed.width, shadowed.height]).toEqual([clear.width, clear.height]); + const mask = new Uint8Array(clear.width * clear.height); + for (let pixel = 0; pixel < mask.length; pixel++) { + const offset = pixel * 4; + const distance = + clear.pixels[offset] - + shadowed.pixels[offset] + + clear.pixels[offset + 1] - + shadowed.pixels[offset + 1] + + clear.pixels[offset + 2] - + shadowed.pixels[offset + 2]; + if (clear.pixels[offset + 3] > 0 && distance > 15) mask[pixel] = 1; + } + return mask; +} + +function countChangedPixels(before: Frame, after: Frame): number { + expect([after.width, after.height]).toEqual([before.width, before.height]); + let count = 0; + for (let offset = 0; offset < before.pixels.length; offset += 4) { + const distance = + Math.abs(before.pixels[offset] - after.pixels[offset]) + + Math.abs(before.pixels[offset + 1] - after.pixels[offset + 1]) + + Math.abs(before.pixels[offset + 2] - after.pixels[offset + 2]); + if (distance > 10) count++; + } + return count; +} + +function countMaskDifferences(before: Uint8Array, after: Uint8Array): number { + let count = 0; + for (let pixel = 0; pixel < before.length; pixel++) { + if (before[pixel] !== after[pixel]) count++; + } + return count; +} + +describe.sequential('Tree Lab shadow depth', () => { + for (const renderer of ['native', 'original'] as const) { + it(`${renderer} preserves ground shadows across caster materials and sun angles`, async () => { + const width = 440; + const height = 400; + const container = document.createElement('div'); + container.style.cssText = `width:${width}px;height:${height}px`; + document.body.append(container); + const errors: string[] = []; + const matchedLighting = createLighting(true); + // Native shadows must work in an ordinary host, without the lab's final + // depth repair that is needed only by the frozen original SimpleMeshLayer. + const lighting = + renderer === 'native' ? new LightingEffect(matchedLighting.props) : matchedLighting; + lighting.shadowColor = matchedLighting.shadowColor; + let latestFrame: Frame | undefined; + let frameNumber = 0; + const deck = new Deck({ + parent: container, + width, + height, + useDevicePixels: false, + deviceProps: {type: 'webgl', adapters: [webgl2Adapter]}, + views: createViews(), + initialViewState: VIEW, + effects: [lighting], + layers: [], + onError: error => errors.push(error.message), + onAfterRender({gl}) { + const pixels = new Uint8Array(gl.drawingBufferWidth * gl.drawingBufferHeight * 4); + gl.readPixels( + 0, + 0, + gl.drawingBufferWidth, + gl.drawingBufferHeight, + gl.RGBA, + gl.UNSIGNED_BYTE, + pixels + ); + if (gl.getError() !== gl.NO_ERROR) errors.push('Shadow pixel read failed'); + latestFrame = {width: gl.drawingBufferWidth, height: gl.drawingBufferHeight, pixels}; + frameNumber++; + } + }); + try { + const data = createSpecimens('oak'); + const LayerClass = renderer === 'native' ? TreeLayer : OriginalTreeLayer; + const capture = async (props: Partial) => { + const previousFrame = frameNumber; + latestFrame = undefined; + deck.setProps(props); + await expect + .poll(() => frameNumber > previousFrame && Boolean(latestFrame), {timeout: 15000}) + .toBe(true); + expect(errors).toEqual([]); + expect([latestFrame!.width, latestFrame!.height]).toEqual([width, height]); + return latestFrame!; + }; + const filter = + (visibleCasters: boolean, shadowCasters: boolean): DeckProps['layerFilter'] => + ({layer, renderPass}) => + renderPass === 'shadow' + ? shadowCasters + : visibleCasters || layer.id.endsWith('-ground'); + let previousMask: Uint8Array | undefined; + let movingShadowPixels = 0; + // The last angle reproduces the winter oak segment with the reported + // hollow shadow. Half film dimensions and one less zoom keep its framing. + for (const seconds of [0, 4, 8, 21.9]) { + const tour = getTourFrame(seconds, true); + const key = lighting.props.key; + if (key.type === 'directional') key.direction = tour.direction; + const viewState = {...VIEW, bearing: tour.cameraBearing}; + const makeLayers = (unlit: boolean, opacity = 1) => { + const [ground, tree] = createSceneLayers(LayerClass, `shadow-${renderer}`, data, { + ...DEFAULT_OPTIONS, + season: tour.season, + shadows: true, + crops: false, + dropped: false, + wind: false + }); + const material = { + unlit, + ambient: 0.05, + diffuse: 1, + shininess: 64, + specularColor: [100, 100, 100] as [number, number, number] + }; + return [ + ground, + tree.clone({ + opacity, + _subLayerProps: {trunks: {material}, 'canopy-oak': {material}} + }) + ]; + }; + const unlitLayers = makeLayers(true); + const clear = await capture({ + viewState, + layers: unlitLayers, + layerFilter: filter(false, false) + }); + // Prime actual color draws before reading ground alone: shadow passes + // retain the material and lighting uniforms from the preceding draw. + const visibleUnlit = await capture({ + layers: unlitLayers, + layerFilter: filter(true, true) + }); + const unlit = await capture({layerFilter: filter(false, true)}); + const unlitMask = getShadowMask(clear, unlit); + expect( + unlitMask.reduce((sum, value) => sum + value, 0), + `${renderer} at ${seconds}s has a nonempty ground shadow` + ).toBeGreaterThan(120); + for (const opacity of [1, 0.35]) { + const visibleLit = await capture({ + layers: makeLayers(false, opacity), + layerFilter: filter(true, true) + }); + expect( + countChangedPixels(visibleUnlit, visibleLit), + `${renderer} at ${seconds}s material/opacity ${opacity} changes visible tree pixels` + ).toBeGreaterThan(30); + const lit = await capture({layerFilter: filter(false, true)}); + const litMask = getShadowMask(clear, lit); + expect( + countMaskDifferences(unlitMask, litMask), + `${renderer} at ${seconds}s material/opacity ${opacity} shadow footprint` + ).toBe(0); + expect( + countChangedPixels(unlit, lit), + `${renderer} at ${seconds}s material/opacity ${opacity} shadow intensity` + ).toBe(0); + } + if (previousMask) movingShadowPixels += countMaskDifferences(previousMask, unlitMask); + previousMask = unlitMask; + } + expect(movingShadowPixels, `${renderer} sunlight changes the shadow`).toBeGreaterThan(120); + expect(frameNumber).toBeGreaterThanOrEqual(28); + expect(errors).toEqual([]); + } finally { + deck.finalize(); + container.remove(); + } + }, 90000); + } +}); diff --git a/examples/layers/tree-lab/style.css b/examples/layers/tree-lab/style.css new file mode 100644 index 000000000..974c7cf04 --- /dev/null +++ b/examples/layers/tree-lab/style.css @@ -0,0 +1,6 @@ +.tree-lab,.tree-lab *{box-sizing:border-box}body:has(> #app){margin:0;background:#f5f6f0;color:#243a2c;font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:14px}.tree-lab button,.tree-lab select,.tree-lab input{font:inherit}.tree-lab button,.tree-lab select{border:1px solid #bac8b8;border-radius:7px;background:#fff;color:#243a2c;padding:8px 12px}.tree-lab button{cursor:pointer}.tree-lab button:hover{background:#e6eee2}.tree-lab button[aria-pressed=true]{background:#243a2c;color:#fff;border-color:#243a2c}.tree-lab header{padding:28px 36px 20px;border-bottom:1px solid #d7dfd0;background:#f5f6f0}.tree-lab h1{font-size:32px;letter-spacing:-1.1px;margin:3px 0 7px;font-weight:650}.tree-lab .eyebrow{text-transform:uppercase;letter-spacing:2px;font-size:10px;font-weight:700;color:#6b806c}.tree-lab p{margin:0;color:#657362;line-height:1.55}.tree-lab .toolbar{display:flex;gap:20px;flex-wrap:wrap;align-items:center;padding:17px 36px;background:#fff;border-bottom:1px solid #d7dfd0}.tree-lab .seasons{display:flex;gap:5px}.tree-lab .toggle{display:flex;align-items:center;gap:6px}.tree-lab .control{display:flex;align-items:center;gap:8px}.tree-lab .caption{font-size:12px;color:#6b806c}.tree-lab .board{padding:12px 36px 36px;max-width:1600px;margin:auto}.tree-lab .row{margin:18px 0 26px}.tree-lab .row-title{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:10px}.tree-lab .row-title h2{font-size:20px;font-weight:600;margin:0;letter-spacing:-.4px}.tree-lab .pair{display:grid;grid-template-columns:1fr 1fr;gap:14px}.tree-lab .specimen{position:relative;overflow:hidden;background:#e2e7de;border:1px solid #ccd6c6;border-radius:12px;height:340px}.tree-lab .canvas{height:100%;width:100%;position:relative}.tree-lab .label{position:absolute;left:15px;top:13px;z-index:2;font-size:11px;font-weight:650;letter-spacing:.7px;text-transform:uppercase;pointer-events:none;color:#435a43;background:#eef2e8dd;border-radius:5px;padding:6px 9px}.tree-lab .badge{position:absolute;right:14px;bottom:12px;z-index:2;font-size:11px;color:#4c624c;background:#eef2e8cc;padding:5px 8px;border-radius:5px;pointer-events:none}.tree-lab .native .label{color:#fff;background:#243a2c}.tree-lab .status{padding:10px 36px;border-bottom:1px solid #d7dfd0;background:#e8efdf;font-size:12px;line-height:1.6}.tree-lab .footer{padding:0 36px 32px;color:#667463;font-size:12px;line-height:1.7}.tree-lab.benchmark{padding:22px;max-width:1400px;margin:auto}.tree-lab.benchmark .canvas{height:680px;background:#e2e7de;border-radius:12px}.tree-lab.benchmark pre{white-space:pre-wrap;background:#fff;padding:14px;border-radius:8px;font-size:12px}.tree-lab.benchmark h1{font-size:24px}.tree-lab .loading{display:flex;align-items:center;justify-content:center;height:100%;color:#6b806c}.tree-lab canvas{display:block}@media(max-width:700px){.tree-lab header,.tree-lab .toolbar{padding:18px}.tree-lab .board{padding:8px 12px}.tree-lab .toolbar{gap:12px}.tree-lab .pair{gap:8px}.tree-lab .specimen{height:270px}.tree-lab .label{font-size:9px;left:6px;top:6px;padding:5px}.tree-lab .badge{font-size:9px;right:6px}.tree-lab .row-title .caption{max-width:55%;text-align:right;font-size:10px}.tree-lab .status{padding:8px 18px}.tree-lab h1{font-size:26px}.tree-lab .footer{padding:0 18px 24px}} +.tree-lab.film #film{display:block;width:100%;height:auto;max-width:1920px;margin:auto}.tree-lab.film .film-sources{position:absolute;left:-10000px;top:0;width:1760px;height:800px;display:flex;pointer-events:none}.tree-lab.film .film-sources canvas{width:880px;height:800px}.tree-lab.film .toolbar{justify-content:center}.tree-lab .film-link{color:#243a2c;text-decoration:underline;text-underline-offset:4px} + +.tree-lab.tree-forest header{padding:22px 30px 18px}.tree-lab.tree-forest .toolbar{padding:12px 30px;gap:16px}.tree-lab.tree-forest .forest-stage{position:relative;height:clamp(420px,68vh,900px);min-height:420px;background:#e2e7de;overflow:hidden}.tree-lab.tree-forest .forest-caption{position:absolute;left:28px;bottom:24px;padding:12px 16px;border-radius:9px;background:#f5f6f0e8;color:#243a2c;pointer-events:none;box-shadow:0 4px 24px #243a2c0d}.tree-lab.tree-forest .forest-caption strong,.tree-lab.tree-forest .forest-caption span{display:block}.tree-lab.tree-forest .forest-caption span{font-size:11px;color:#657362;margin-top:5px}.tree-lab.tree-forest .footer{padding:15px 30px}.tree-lab.tree-forest a{color:#243a2c}@media(max-width:700px){.tree-lab.tree-forest header,.tree-lab.tree-forest .toolbar{padding:16px}.tree-lab.tree-forest .forest-caption{left:16px;bottom:16px}.tree-lab.tree-forest .forest-stage{height:60vh}} + +.tree-lab.benchmark #summary{margin:14px 0}.tree-lab.benchmark table{border-collapse:collapse;width:100%;max-width:900px;background:#fff;border-radius:8px}.tree-lab.benchmark th,.tree-lab.benchmark td{padding:10px 14px;text-align:left;border-bottom:1px solid #d7dfd0}.tree-lab.benchmark details{margin-top:14px;color:#657362} diff --git a/examples/layers/tree-lab/style.d.ts b/examples/layers/tree-lab/style.d.ts new file mode 100644 index 000000000..12ffe64b7 --- /dev/null +++ b/examples/layers/tree-lab/style.d.ts @@ -0,0 +1,4 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +declare module '*.css'; diff --git a/examples/layers/tree-lab/tour.ts b/examples/layers/tree-lab/tour.ts new file mode 100644 index 000000000..e6930b681 --- /dev/null +++ b/examples/layers/tree-lab/tour.ts @@ -0,0 +1,27 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {SEASONS, SPECIES} from './scene'; + +/** One shared clock drives seasonal stages, sunlight and wind for repeatable reviews. */ +export function getTourFrame(seconds: number, film = false) { + const duration = film ? 12 : 32; + const time = ((seconds % duration) + duration) % duration; + const phase = time / duration; + const azimuth = phase * Math.PI * 2 - Math.PI * 0.3; + const elevation = 0.55 + 0.4 * Math.sin(phase * Math.PI); + const z = Math.sin(elevation); + const horizontal = Math.cos(elevation); + return { + season: SEASONS[Math.min(3, Math.floor(phase * 4))], + species: SPECIES[Math.floor(Math.max(0, seconds) / 12) % SPECIES.length], + sunAngle: phase * 360, + direction: [-Math.cos(azimuth) * horizontal, -Math.sin(azimuth) * horizontal, -z] as [ + number, + number, + number + ], + windTime: seconds, + cameraBearing: 22 + Math.sin(phase * Math.PI * 2) * 18 + }; +} diff --git a/examples/layers/tree-lab/tree-lighting.ts b/examples/layers/tree-lab/tree-lighting.ts new file mode 100644 index 000000000..909329617 --- /dev/null +++ b/examples/layers/tree-lab/tree-lighting.ts @@ -0,0 +1,219 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import { + LightingEffect, + shadow, + type EffectContext, + type Layer, + type LightingEffectProps, + type PreRenderOptions, + type DirectionalLight +} from '@deck.gl/core'; +import type {Texture} from '@luma.gl/core'; +import {Matrix4, Vector3} from '@math.gl/core'; +import {getBoundedShadowUniforms, getGroundShadowSettings} from './shadow-frustum'; +import {GroundShadowFilter} from './ground-shadow-filter'; +import {TreeShadowPass} from './tree-shadow-pass'; + +const SHADOW_WEIGHT = `float shadow_getShadowWeight(vec3 position, sampler2D shadowMap) {`; +export const SHADOW_FILTER = `float shadow_getShadowWeight(vec3 position, highp sampler2DShadow shadowMap, float bias) { + if (any(lessThanEqual(position, vec3(0.0))) || any(greaterThanEqual(position, vec3(1.0)))) return 0.0; + vec2 size = vec2(textureSize(shadowMap, 0)); + vec2 samplePosition = position.xy * size - 0.5; + vec2 base = floor(samplePosition); + vec2 f = fract(samplePosition); + // Pair a three-texel tent's four fractional weights into two hardware PCF reads per axis. + // Hardware compares each depth before interpolating, as in NVIDIA GPU Gems chapter 11. + vec2 wx = vec2(3.0 - 2.0 * f.x, 1.0 + 2.0 * f.x); + vec2 wy = vec2(3.0 - 2.0 * f.y, 1.0 + 2.0 * f.y); + vec2 ox = vec2(-1.0 + (2.0 - f.x) / wx.x, 1.0 + f.x / wx.y); + vec2 oy = vec2(-1.0 + (2.0 - f.y) / wy.x, 1.0 + f.y / wy.y); + float weight = 0.0; + for (int y = 0; y < 2; y++) { + for (int x = 0; x < 2; x++) { + vec2 uv = (base + vec2(ox[x], oy[y]) + 0.5) / size; + if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) continue; + weight += (1.0 - texture(shadowMap, vec3(uv, position.z - bias))) * wx[x] * wy[y]; + } + } + vec2 edge = min(position.xy, 1.0 - position.xy); + return weight / 16.0 * smoothstep(0.0, 0.04, min(edge.x, edge.y)); +}`; + +const GROUND_WEIGHT = ` + uniform sampler2D shadow_uGroundMap0; + uniform sampler2D shadow_uGroundMap1; + float shadow_getGroundWeight(vec3 position, sampler2D map) { + if (any(lessThanEqual(position, vec3(0.0))) || any(greaterThanEqual(position, vec3(1.0)))) return 0.0; + vec2 edge = min(position.xy, 1.0 - position.xy); + return texture(map, position.xy).r * smoothstep(0.0, 0.04, min(edge.x, edge.y)); + }`; +const SOFT_SHADOW = { + ...shadow, + getUniforms: getBoundedShadowUniforms, + inject: {...shadow.inject, 'fs:#main-start': 'if (shadow.drawShadowMap) { return; }'}, + uniformTypes: {...shadow.uniformTypes, depthBias: 'vec2', groundFilter: 'vec2'}, + vs: shadow.vs!.replace( + ' vec4 projectCenter1;', + ' vec4 projectCenter1;\n vec2 depthBias;\n vec2 groundFilter;' + ), + fs: shadow + .fs!.replace( + ' vec4 projectCenter1;', + ' vec4 projectCenter1;\n vec2 depthBias;\n vec2 groundFilter;' + ) + .replaceAll( + 'uniform sampler2D shadow_uShadowMap', + 'uniform highp sampler2DShadow shadow_uShadowMap' + ) + .replace( + /float shadow_getShadowWeight\(vec3 position, sampler2D shadowMap\) \{[\s\S]*?\n\}/, + SHADOW_FILTER + GROUND_WEIGHT + ) + .replace( + 'shadow_getShadowWeight(shadow_vPosition[0], shadow_uShadowMap0)', + '(shadow.groundFilter.x > 0.5 ? shadow_getGroundWeight(shadow_vPosition[0], shadow_uGroundMap0) : shadow_getShadowWeight(shadow_vPosition[0], shadow_uShadowMap0, shadow.depthBias.x))' + ) + .replace( + 'shadow_getShadowWeight(shadow_vPosition[1], shadow_uShadowMap1)', + '(shadow.groundFilter.x > 0.5 ? shadow_getGroundWeight(shadow_vPosition[1], shadow_uGroundMap1) : shadow_getShadowWeight(shadow_vPosition[1], shadow_uShadowMap1, shadow.depthBias.y))' + ) +}; + +/** Native comparison depth maps and prefiltered coverage for the lab's planar ground. */ +export class TreeLightingEffect extends LightingEffect { + useInPicking = true; + private neutralShadowMap?: Texture; + private neutralGroundMap?: Texture; + private filteredShadow = false; + private treePasses: TreeShadowPass[] = []; + private groundFilters: GroundShadowFilter[] = []; + private treeMatrices: Matrix4[] = []; + private pendingRedraw?: number; + + setProps(props: LightingEffectProps) { + this.props = props; + super.setProps(props); + } + + setSunDirection(direction: [number, number, number]) { + const key = this.props.key; + if (key?.type === 'directional') key.direction = direction; + } + + setup(context: EffectContext) { + super.setup(context); + if (context.device.type !== 'webgl') return; + this.neutralShadowMap ??= context.device.createTexture({ + format: 'depth16unorm', + width: 1, + height: 1, + data: new Uint16Array([65535]), + sampler: { + type: 'comparison-sampler', + compare: 'less-equal', + minFilter: 'linear', + magFilter: 'linear' + } + }); + this.neutralGroundMap ??= context.device.createTexture({ + width: 1, + height: 1, + data: new Uint8Array(4) + }); + if ( + Object.values(this.props).some(light => light.type === 'directional' && light.shadow) && + !this.filteredShadow + ) { + if (!shadow.fs!.includes(SHADOW_WEIGHT)) + throw new Error('Unsupported deck.gl shadow filter.'); + context.deck._removeDefaultShaderModule(shadow); + context.deck._addDefaultShaderModule(SOFT_SHADOW); + this.filteredShadow = true; + } + } + + preRender(options: PreRenderOptions) { + if (this.context?.device.type !== 'webgl') return super.preRender(options); + const lights = Object.values(this.props).filter( + (light): light is DirectionalLight => light.type === 'directional' && light.shadow + ); + for (const pass of this.treePasses.splice(lights.length)) pass.destroy(); + for (const filter of this.groundFilters.splice(lights.length)) filter.destroy(); + if (!lights.length) return; + this.treeMatrices = lights.map(light => + new Matrix4().lookAt({eye: new Vector3(light.direction).negate()}) + ); + for (const [index] of lights.entries()) { + this.treePasses[index] ??= new TreeShadowPass(this.context.device); + this.groundFilters[index] ??= new GroundShadowFilter(this.context.device); + this.treePasses[index].render({ + ...options, + effects: [], + shaderModuleProps: { + shadow: { + shadowLightId: index, + dummyShadowMap: this.neutralShadowMap, + dummyGroundMap: this.neutralGroundMap, + shadowMatrices: this.treeMatrices + } + } + }); + } + const settings = getGroundShadowSettings( + options.viewports[0], + this.treeMatrices, + this.treePasses.map(pass => [pass.depth.width, pass.depth.height]) + ); + for (const [index, setting] of settings.entries()) { + this.groundFilters[index].render(this.treePasses[index].depth, setting.plane, setting.step); + } + if (this.groundFilters.some(filter => !filter.ready) && this.pendingRedraw === undefined) { + this.pendingRedraw = requestAnimationFrame(() => { + this.pendingRedraw = undefined; + this.context?.deck.setProps({}); + }); + } + } + + getShaderModuleProps(layer: Layer, otherShaderModuleProps: Record) { + const props = super.getShaderModuleProps(layer, otherShaderModuleProps); + if (!this.neutralShadowMap) return props; + const enabled = Object.values(this.props).some( + light => light.type === 'directional' && light.shadow + ); + return { + ...props, + shadow: { + project: otherShaderModuleProps.project, + shadowMaps: enabled + ? this.treePasses.slice(0, this.treeMatrices.length).map(pass => pass.depth) + : [], + dummyShadowMap: this.neutralShadowMap, + dummyGroundMap: this.neutralGroundMap, + groundMaps: this.groundFilters.map(filter => filter.map), + groundReceiver: + layer.id.endsWith('-ground') && this.groundFilters.every(filter => filter.ready), + shadowColor: this.shadowColor, + shadowMatrices: this.treeMatrices + } + }; + } + + cleanup(context: EffectContext) { + if (this.pendingRedraw !== undefined) cancelAnimationFrame(this.pendingRedraw); + this.pendingRedraw = undefined; + for (const pass of this.treePasses) pass.destroy(); + for (const filter of this.groundFilters) filter.destroy(); + this.treePasses = []; + this.groundFilters = []; + super.cleanup(context); + if (this.filteredShadow) context.deck._removeDefaultShaderModule(SOFT_SHADOW); + this.filteredShadow = false; + this.neutralShadowMap?.destroy(); + this.neutralShadowMap = undefined; + this.neutralGroundMap?.destroy(); + this.neutralGroundMap = undefined; + } +} diff --git a/examples/layers/tree-lab/tree-shadow-pass.ts b/examples/layers/tree-lab/tree-shadow-pass.ts new file mode 100644 index 000000000..df152bcf9 --- /dev/null +++ b/examples/layers/tree-lab/tree-shadow-pass.ts @@ -0,0 +1,76 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {_LayersPass, type Layer, type LayersPassRenderOptions} from '@deck.gl/core'; +import type {Device, Framebuffer, Parameters, Texture} from '@luma.gl/core'; + +/** Native depth storage lets the GPU compare depths before bilinear filtering. */ +export class TreeShadowPass extends _LayersPass { + private readonly target: Framebuffer; + + constructor(device: Device) { + super(device); + const depth = device.createTexture({ + format: 'depth24plus', + width: 1, + height: 1, + sampler: { + type: 'comparison-sampler', + compare: 'less-equal', + minFilter: 'linear', + magFilter: 'linear', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge' + } + }); + this.target = device.createFramebuffer({colorAttachments: [], depthStencilAttachment: depth}); + } + + get depth(): Texture { + return this.target.depthStencilAttachment!.texture; + } + + render(options: LayersPassRenderOptions) { + const viewport = options.viewports[0]; + // A fixed budget keeps maps independent of display DPR and limits blur work. + const ratio = 1024 / Math.max(viewport.width, viewport.height); + const width = Math.max(1, Math.round(viewport.width * ratio)); + const height = Math.max(1, Math.round(viewport.height * ratio)); + if (width !== this.target.width || height !== this.target.height) { + const previous = this.depth; + this.target.resize({width, height}); + previous.destroy(); + } + super.render({ + ...options, + shaderModuleProps: {...options.shaderModuleProps, project: {devicePixelRatio: ratio}}, + target: this.target, + clearColor: [0, 0, 0, 0], + pass: 'shadow' + }); + } + + shouldDrawLayer(layer: Layer) { + return (layer.props as Layer['props'] & {shadowEnabled?: boolean}).shadowEnabled !== false; + } + + protected getLayerParameters(layer: Layer): Parameters { + return { + ...layer.props.parameters, + blend: false, + depthWriteEnabled: true, + depthCompare: 'less-equal' + }; + } + + // _LayersPass merges render overrides last, preserving the light ID and matrices. + protected getShaderModuleProps(layer: Layer, effects, otherShaderModuleProps) { + return {shadow: {project: otherShaderModuleProps.project, drawToShadowMap: true}}; + } + + destroy() { + const depth = this.depth; + this.target.destroy(); + depth.destroy(); + } +} diff --git a/examples/layers/tree-lab/tsconfig.json b/examples/layers/tree-lab/tsconfig.json new file mode 100644 index 000000000..92c194778 --- /dev/null +++ b/examples/layers/tree-lab/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/examples/layers/tree-lab/vite.config.ts b/examples/layers/tree-lab/vite.config.ts new file mode 100644 index 000000000..0984afdac --- /dev/null +++ b/examples/layers/tree-lab/vite.config.ts @@ -0,0 +1,23 @@ +import {defineConfig} from 'vite'; +import {fileURLToPath} from 'node:url'; +export default defineConfig({ + resolve: { + alias: { + '@deck.gl-community/layers': fileURLToPath( + new URL('../../../modules/layers/src/index.ts', import.meta.url) + ) + } + }, + server: {host: '127.0.0.1', port: 5188}, + build: { + rollupOptions: { + input: { + forest: fileURLToPath(new URL('./forest.html', import.meta.url)), + film: fileURLToPath(new URL('./film.html', import.meta.url)), + lab: fileURLToPath(new URL('./index.html', import.meta.url)), + native: fileURLToPath(new URL('./native.html', import.meta.url)), + baseline: fileURLToPath(new URL('./baseline.html', import.meta.url)) + } + } + } +}); diff --git a/examples/playground/package.json b/examples/playground/package.json index 85ab81aa1..098e1bb78 100644 --- a/examples/playground/package.json +++ b/examples/playground/package.json @@ -20,7 +20,6 @@ "@deck.gl-community/layers": "workspace:*", "@deck.gl-community/panels": "workspace:*", "@deck.gl-community/playground": "workspace:*", - "@deck.gl-community/three": "workspace:*", "@deck.gl-community/timeline-layers": "workspace:*", "@deck.gl/aggregation-layers": "~9.4.0", "@deck.gl/core": "~9.4.0", diff --git a/examples/playground/registry.spec.ts b/examples/playground/registry.spec.ts index eeac8d4a0..887aaa0a1 100644 --- a/examples/playground/registry.spec.ts +++ b/examples/playground/registry.spec.ts @@ -15,7 +15,6 @@ import * as communityGeo from '@deck.gl-community/geo-layers'; import * as graph from '@deck.gl-community/graph-layers'; import * as infovis from '@deck.gl-community/infovis-layers'; import * as communityLayers from '@deck.gl-community/layers'; -import * as three from '@deck.gl-community/three'; import * as timeline from '@deck.gl-community/timeline-layers'; import {tableFromArrays} from 'apache-arrow'; import ts from 'typescript'; @@ -41,7 +40,6 @@ const PACKAGES = { '@deck.gl-community/graph-layers': graph, '@deck.gl-community/infovis-layers': infovis, '@deck.gl-community/layers': communityLayers, - '@deck.gl-community/three': three, '@deck.gl-community/timeline-layers': timeline }; const ABSTRACT_LAYERS = new Set(['_AggregationLayer', '_GeoCellLayer']); @@ -87,7 +85,7 @@ test('registers every concrete public layer from each package without losing nam expected.push(key); } } - expect(Object.keys(registry.layers).sort()).toEqual(expected.sort()); + expect(Object.keys(registry.layers).sort()).toEqual([...new Set(expected)].sort()); expect(registeredType('GridLayer')).toBe(aggregation.GridLayer); expect(registeredType('GraphGridLayer')).toBe(graph.GridLayer); }); diff --git a/examples/playground/registry.ts b/examples/playground/registry.ts index 8318ce7f9..a4d9a5cdf 100644 --- a/examples/playground/registry.ts +++ b/examples/playground/registry.ts @@ -13,7 +13,6 @@ import * as communityGeo from '@deck.gl-community/geo-layers'; import * as graph from '@deck.gl-community/graph-layers'; import * as infovis from '@deck.gl-community/infovis-layers'; import * as communityLayers from '@deck.gl-community/layers'; -import * as three from '@deck.gl-community/three'; import * as timeline from '@deck.gl-community/timeline-layers'; import { GraphGridLayerSchema, @@ -37,7 +36,6 @@ const LAYERS = Object.fromEntries( ...graph, ...infovis, ...communityLayers, - ...three, ...timeline, GridLayer: aggregation.GridLayer }).filter( diff --git a/modules/layers/README.md b/modules/layers/README.md index 474f4f6c9..d0a5d66c3 100644 --- a/modules/layers/README.md +++ b/modules/layers/README.md @@ -7,5 +7,10 @@ This module contains a suite of non-official deck.gl layers. They can be quite useful in applications, however they are not officially supported by the deck.gl team, so use at your own risk. +[`TreeLayer`](../../docs/modules/layers/api-reference/tree-layer.md) renders instanced seasonal +trees with native vis.gl geometry, highest-quality shared meshes and optional GPU wind on WebGL2 and +WebGPU. Inspect original/native specimens and the recorded film in +[Tree Lab](../../examples/layers/tree-lab/README.md). + [`FlameTrailLayer`](../../docs/modules/layers/api-reference/flame-trail-layer.md) renders 3D flames and embers with the TripsLayer API. It requires WebGL2. diff --git a/modules/layers/src/dependency-arrow-layer/geometry-layer.ts b/modules/layers/src/dependency-arrow-layer/geometry-layer.ts index 04911fedf..e5084f397 100644 --- a/modules/layers/src/dependency-arrow-layer/geometry-layer.ts +++ b/modules/layers/src/dependency-arrow-layer/geometry-layer.ts @@ -4,7 +4,7 @@ import {color, Layer, picking, project32, UNIT} from '@deck.gl/core'; import {Geometry, Model} from '@luma.gl/engine'; -import source from './geometry-layer.wgsl'; +import source from './geometry-layer.wgsl.js'; import type { Accessor, diff --git a/modules/layers/src/flame-trail-layer/flame-trail-layer.ts b/modules/layers/src/flame-trail-layer/flame-trail-layer.ts index 99ae8e5ee..dda3479b8 100644 --- a/modules/layers/src/flame-trail-layer/flame-trail-layer.ts +++ b/modules/layers/src/flame-trail-layer/flame-trail-layer.ts @@ -9,7 +9,7 @@ import type {ShaderModule} from '@luma.gl/shadertools'; import {FLAME_FUNCTIONS, FLAME_COLOR} from './flame-trail-layer-fragment'; import {FLAME_VERTEX, FLAME_VERTEX_DECLARATIONS} from './flame-trail-layer-vertex'; import {createFlameGeometry} from './flame-trail-geometry'; -import {getFlameInjectionsWGSL} from './flame-trail-layer.wgsl'; +import {getFlameInjectionsWGSL} from './flame-trail-layer.wgsl.js'; /** TripsLayer's API, rendered as automatically animated flames. */ export type FlameTrailLayerProps = TripsLayerProps; diff --git a/modules/layers/src/index.ts b/modules/layers/src/index.ts index 29e29fba7..ec18ed732 100644 --- a/modules/layers/src/index.ts +++ b/modules/layers/src/index.ts @@ -24,3 +24,11 @@ export {SkyboxLayer} from './skybox-layer/skybox-layer'; export type {FlameTrailLayerProps} from './flame-trail-layer/flame-trail-layer'; export {FlameTrailLayer} from './flame-trail-layer/flame-trail-layer'; + +export {TreeLayer} from './tree-layer/tree-layer'; +export type { + TreeLayerProps, + TreeType, + Season, + CropConfig +} from './tree-layer/tree-layer'; diff --git a/modules/layers/src/tree-layer/tree-geometry.ts b/modules/layers/src/tree-layer/tree-geometry.ts new file mode 100644 index 000000000..fd913b6a1 --- /dev/null +++ b/modules/layers/src/tree-layer/tree-geometry.ts @@ -0,0 +1,602 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {SphereGeometry, TruncatedConeGeometry} from '@luma.gl/engine'; +import {Vector3} from '@math.gl/core'; + +/** Shared CPU mesh; uploaded and instanced by deck.gl's SimpleMeshLayer. */ +export type TreeMesh = { + attributes: { + POSITION: {value: Float32Array; size: 3}; + NORMAL: {value: Float32Array; size: 3}; + COLOR_0: {value: Float32Array; size: 3}; + TEXCOORD_0: {value: Float32Array; size: 2}; + }; + indices: {value: Uint32Array; size: 1}; + topology: 'triangle-list'; + mode: 4; +}; + +/** Geometry detail, independent of the number of rendered instances. */ +export type TreeDetail = 'low' | 'medium' | 'high'; +type Point = [number, number, number]; +export type CrownLobe = {center: Point; radius: Point}; +const DETAIL_SEGMENTS = {low: [8, 5], medium: [12, 8], high: [16, 10]} as const; + +/** Deterministic random source. Separate seeds keep crop counts and shapes independent. */ +export function createTreeRng(seed: number): () => number { + let value = seed >>> 0; + return () => { + value = (value + 0x9e3779b9) | 0; + let mixed = value ^ (value >>> 16); + mixed = Math.imul(mixed, 0x21f0aaad); + mixed ^= mixed >>> 15; + mixed = Math.imul(mixed, 0x735a2d97); + return ((mixed ^ (mixed >>> 15)) >>> 0) / 4294967296; + }; +} + +function createMesh(positions: Float32Array, sourceIndices: Uint32Array, shade = 1): TreeMesh { + // Primitive generators include zero-area cap/pole faces. Remove them once, + // before caching/uploading, so they never consume instanced draw work. + const triangles: number[] = []; + for (let i = 0; i < sourceIndices.length; i += 3) { + const a = sourceIndices[i] * 3; + const b = sourceIndices[i + 1] * 3; + const c = sourceIndices[i + 2] * 3; + const ab = [ + positions[b] - positions[a], + positions[b + 1] - positions[a + 1], + positions[b + 2] - positions[a + 2] + ]; + const ac = [ + positions[c] - positions[a], + positions[c + 1] - positions[a + 1], + positions[c + 2] - positions[a + 2] + ]; + const area = Math.hypot( + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0] + ); + if (area > 1e-12) triangles.push(sourceIndices[i], sourceIndices[i + 1], sourceIndices[i + 2]); + } + const indices = new Uint32Array(triangles); + const mesh: TreeMesh = { + attributes: { + POSITION: {value: positions, size: 3}, + NORMAL: {value: new Float32Array(positions.length), size: 3}, + COLOR_0: {value: new Float32Array(positions.length).fill(shade), size: 3}, + TEXCOORD_0: {value: new Float32Array((positions.length / 3) * 2), size: 2} + }, + indices: {value: indices, size: 1}, + topology: 'triangle-list', + mode: 4 + }; + computeNormals(mesh); + return mesh; +} + +/** Area-weighted normals. Welding is restricted to closed foliage, never two-sided blades. */ +function computeNormals(mesh: TreeMesh, weld = false): void { + const p = mesh.attributes.POSITION.value; + const n = mesh.attributes.NORMAL.value; + const indices = mesh.indices.value; + n.fill(0); + for (let i = 0; i < indices.length; i += 3) { + const a = indices[i] * 3; + const b = indices[i + 1] * 3; + const c = indices[i + 2] * 3; + const ab = new Vector3(p[b] - p[a], p[b + 1] - p[a + 1], p[b + 2] - p[a + 2]); + const ac = new Vector3(p[c] - p[a], p[c + 1] - p[a + 1], p[c + 2] - p[a + 2]); + const normal = ab.cross(ac); + for (const offset of [a, b, c]) { + n[offset] += normal[0]; + n[offset + 1] += normal[1]; + n[offset + 2] += normal[2]; + } + } + if (weld) { + const vertices = new Map(); + for (let i = 0; i < p.length; i += 3) { + const key = `${Math.round(p[i] * 1e6)},${Math.round(p[i + 1] * 1e6)},${Math.round(p[i + 2] * 1e6)}`; + const offsets = vertices.get(key) ?? []; + offsets.push(i); + vertices.set(key, offsets); + } + for (const offsets of vertices.values()) { + const sum = new Vector3(); + for (const offset of offsets) sum.add([n[offset], n[offset + 1], n[offset + 2]]); + for (const offset of offsets) n.set(sum, offset); + } + } + for (let i = 0; i < n.length; i += 3) { + const length = Math.hypot(n[i], n[i + 1], n[i + 2]); + if (length > 1e-12) { + n[i] /= length; + n[i + 1] /= length; + n[i + 2] /= length; + } else { + n[i + 2] = 1; + } + } +} + +/** Merge CPU arrays once; retain hard edges and opposite blade normals. */ +function mergeMeshes(meshes: TreeMesh[]): TreeMesh { + const length = meshes.reduce((sum, mesh) => sum + mesh.attributes.POSITION.value.length, 0); + const indexLength = meshes.reduce((sum, mesh) => sum + mesh.indices.value.length, 0); + const mesh: TreeMesh = { + attributes: { + POSITION: {value: new Float32Array(length), size: 3}, + NORMAL: {value: new Float32Array(length), size: 3}, + COLOR_0: {value: new Float32Array(length), size: 3}, + TEXCOORD_0: {value: new Float32Array((length / 3) * 2), size: 2} + }, + indices: {value: new Uint32Array(indexLength), size: 1}, + topology: 'triangle-list', + mode: 4 + }; + let vertexOffset = 0; + let indexOffset = 0; + for (const part of meshes) { + for (const key of ['POSITION', 'NORMAL', 'COLOR_0'] as const) { + mesh.attributes[key].value.set(part.attributes[key].value, vertexOffset * 3); + } + mesh.attributes.TEXCOORD_0.value.set(part.attributes.TEXCOORD_0.value, vertexOffset * 2); + for (const index of part.indices.value) + mesh.indices.value[indexOffset++] = index + vertexOffset; + vertexOffset += part.attributes.POSITION.value.length / 3; + } + return mesh; +} + +function createSphere( + radius: number, + detail: TreeDetail, + center: Point, + scale: Point = [1, 1, 1], + seed = 0 +): TreeMesh { + const [nlong, nlat] = DETAIL_SEGMENTS[detail]; + const geometry = new SphereGeometry({radius, nlong, nlat}); + const positions = new Float32Array(geometry.attributes.POSITION.value); + const rng = createTreeRng(seed); + const waves = Array.from({length: 4}, () => [ + 2 + rng() * 3, + 2 + rng() * 3, + 2 + rng() * 3, + rng() * Math.PI * 2 + ]); + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i]; + const y = positions[i + 1]; + const z = positions[i + 2]; + const r = Math.hypot(x, y, z); + const noise = + seed && r + ? waves.reduce( + (sum, wave) => + sum + Math.sin((x / r) * wave[0] + (y / r) * wave[1] + (z / r) * wave[2] + wave[3]), + 0 + ) * 0.035 + : 0; + // luma spheres are Y-up. Transform directly into the layer's Z-up frame. + positions[i] = center[0] + x * scale[0] * (1 + noise); + positions[i + 1] = center[1] - z * scale[1] * (1 + noise); + positions[i + 2] = center[2] + y * scale[2] * (1 + noise); + } + const mesh = createMesh(positions, new Uint32Array(geometry.indices!.value)); + computeNormals(mesh, true); + return mesh; +} + +function createBranch( + start: Point, + end: Point, + bottomRadius: number, + topRadius: number, + segments = 6 +): TreeMesh { + const axis = new Vector3(end).subtract(start); + const length = axis.len(); + axis.normalize(); + const u = new Vector3(Math.abs(axis[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0]).cross(axis).normalize(); + const v = new Vector3(axis).cross(u).normalize(); + const geometry = new TruncatedConeGeometry({ + bottomRadius, + topRadius, + height: length, + nradial: segments, + nvertical: 1, + topCap: true, + bottomCap: true, + verticalAxis: 'z' + }); + const positions = new Float32Array(geometry.attributes.POSITION.value); + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i]; + const y = positions[i + 1]; + const z = positions[i + 2] + length / 2; + for (let j = 0; j < 3; j++) positions[i + j] = start[j] + u[j] * x + v[j] * y + axis[j] * z; + } + return createMesh(positions, new Uint32Array(geometry.indices!.value)); +} + +/** A tapered Z-up trunk with the legacy unit bounds. */ +export function createTrunkMesh(segments = 10): TreeMesh { + return createBranch([0, 0, 0], [0, 0, 1], 1, 0.7, segments); +} + +/** Palm bark rings use a single ribbed shaft rather than fourteen separate toruses. */ +export function createDatePalmTrunkMesh(segments = 10, scarRings = 14): TreeMesh { + const p: number[] = []; + const indices: number[] = []; + const rings = scarRings * 3 + 2; + for (let ring = 0; ring <= rings; ring++) { + const z = ring / rings; + const rib = ring > 0 && ring < rings && ring % 3 === 1 ? 0.065 : 0; + const radius = 1 - z * 0.38 + rib; + for (let segment = 0; segment <= segments; segment++) { + const angle = (segment / segments) * Math.PI * 2; + p.push(Math.cos(angle) * radius, Math.sin(angle) * radius, z); + if (ring < rings && segment < segments) { + const a = ring * (segments + 1) + segment; + const b = a + segments + 1; + indices.push(a, a + 1, b, a + 1, b + 1, b); + } + } + } + return createMesh(new Float32Array(p), new Uint32Array(indices)); +} + +/** Layered pine cones; the seeded tier silhouette remains stable between details. */ +export function createPineCanopyMesh(levels = 3, segments = 12): TreeMesh { + const meshes: TreeMesh[] = []; + const {tiers, tipStart, tipEnd} = createPineTiers(levels); + for (const [i, tier] of tiers.entries()) { + const mesh = createBranch( + tier.base, + [tier.base[0], tier.base[1], tier.base[2] + tier.height], + tier.radius, + 0, + segments + ); + mesh.attributes.COLOR_0.value.fill(0.82 + (i / (levels - 1 || 1)) * 0.16); + meshes.push(mesh); + } + meshes.push(createBranch(tipStart, tipEnd, 0.08, 0, 6)); + return mergeMeshes(meshes); +} + +const PINE_TIER_CACHE = new Map>(); +function createPineTiers(levels: number) { + levels = Math.max(1, Math.min(5, Math.round(levels) || 3)); + let tiers = PINE_TIER_CACHE.get(levels); + if (!tiers) { + tiers = generatePineTiers(levels); + PINE_TIER_CACHE.set(levels, tiers); + } + return tiers; +} + +function generatePineTiers(levels: number) { + const rng = createTreeRng(levels * 2654435761); + const tierHeight = 1.6 / (levels + 1); + const tiers: {base: Point; radius: number; height: number}[] = []; + let z = 0; + for (let i = 0; i < levels; i++) { + const t = i / (levels - 1 || 1); + const radius = (1 - t * 0.5) * 0.85 * (0.8 + rng() * 0.4); + const height = tierHeight * (0.85 + rng() * 0.3); + const drift = levels > 1 ? i / (levels - 1) : 0; + tiers.push({ + base: [(rng() - 0.5) * 0.2 * drift, (rng() - 0.5) * 0.2 * drift, z], + radius, + height + }); + z += tierHeight / 2; + } + return { + tiers, + tipStart: [(rng() - 0.5) * 0.08, (rng() - 0.5) * 0.08, z - 0.06] as Point, + tipEnd: [0, 0, z + 0.16] as Point + }; +} + +/** Place supplied pine crop points against the outer tier envelope. */ +export function samplePineSurface(levels: number, z: number, theta: number): Point { + const direction = [Math.cos(theta), Math.sin(theta)]; + let distance = 0; + for (const tier of createPineTiers(levels).tiers) { + const t = (z - tier.base[2]) / tier.height; + if (t < 0 || t > 1) continue; + const radius = tier.radius * (1 - t); + const projected = tier.base[0] * direction[0] + tier.base[1] * direction[1]; + const discriminant = + projected * projected - tier.base[0] ** 2 - tier.base[1] ** 2 + radius * radius; + if (discriminant >= 0) distance = Math.max(distance, projected + Math.sqrt(discriminant)); + } + return [direction[0] * distance * 1.015, direction[1] * distance * 1.015, z]; +} + +/** Crown envelopes are shared by foliage generation and crop placement. */ +export function getCrownLobes(type: 'oak' | 'birch' | 'cherry'): CrownLobe[] { + const narrow = type === 'birch'; + const lobes: CrownLobe[] = [ + { + center: [0, 0, narrow ? 0.66 : 0.62], + radius: [narrow ? 0.25 : 0.34, narrow ? 0.25 : 0.34, narrow ? 0.42 : 0.37] + } + ]; + const count = narrow ? 5 : 7; + for (let i = 0; i < count; i++) { + const angle = (i / count) * Math.PI * 2; + const distance = narrow ? 0.17 : 0.25; + lobes.push({ + center: [Math.cos(angle) * distance, Math.sin(angle) * distance, 0.36 + (i % 3) * 0.1], + radius: [narrow ? 0.19 : 0.24, narrow ? 0.19 : 0.24, narrow ? 0.32 : 0.26] + }); + } + return lobes; +} + +/** Outer union envelope of crown lobes, sampled without hidden internal triangles. */ +export function sampleCrownSurface(type: 'oak' | 'birch' | 'cherry', direction: Point): Point { + const origin: Point = [0, 0, 0.5]; + let distance = 0; + for (const lobe of getCrownLobes(type)) { + let a = 0; + let b = 0; + let c = -1; + for (let i = 0; i < 3; i++) { + const delta = (origin[i] - lobe.center[i]) / lobe.radius[i]; + const ray = direction[i] / lobe.radius[i]; + a += ray * ray; + b += 2 * delta * ray; + c += delta * delta; + } + const discriminant = b * b - 4 * a * c; + if (discriminant >= 0 && a > 0) + distance = Math.max(distance, (-b + Math.sqrt(discriminant)) / (2 * a)); + } + return [direction[0] * distance, direction[1] * distance, 0.5 + direction[2] * distance]; +} + +function createBroadleafCanopy(type: 'oak' | 'birch' | 'cherry', detail: TreeDetail): TreeMesh { + const mesh = createSphere(1, detail, [0, 0, 0]); + const positions = mesh.attributes.POSITION.value; + const colors = mesh.attributes.COLOR_0.value; + for (let i = 0; i < positions.length; i += 3) { + const direction: Point = [positions[i], positions[i + 1], positions[i + 2]]; + const point = sampleCrownSurface(type, direction); + const noise = + 1 + 0.025 * Math.sin(direction[0] * 11 + direction[1] * 7) * Math.sin(direction[2] * 9); + positions.set([point[0] * noise, point[1] * noise, 0.5 + (point[2] - 0.5) * noise], i); + const shade = 0.8 + point[2] * 0.14 + Math.sin(direction[0] * 7 + direction[1] * 5) * 0.035; + colors.set([shade, shade, shade], i); + } + computeNormals(mesh, true); + return mesh; +} + +/** Lobed, shared foliage envelopes with smoothly welded lighting normals. */ +export function createOakCanopyMesh(detail: TreeDetail = 'high'): TreeMesh { + return createBroadleafCanopy('oak', detail); +} +export function createBirchCanopyMesh(detail: TreeDetail = 'high'): TreeMesh { + return createBroadleafCanopy('birch', detail); +} +export function createCherryCanopyMesh(detail: TreeDetail = 'high'): TreeMesh { + return createBroadleafCanopy('cherry', detail); +} + +/** Rings at the trunk join support a smooth per-instance radius transition. */ +function createWinterStem(detail: TreeDetail): TreeMesh { + const segments = detail === 'low' ? 8 : 12; + const heights = [0, 0.12, 0.22, 0.27, 0.32, 0.37, 0.42, 0.62, 0.85]; + const positions: number[] = []; + const indices: number[] = []; + for (const [ring, z] of heights.entries()) { + const t = Math.max(0, (z - 0.22) / 0.63); + const radius = 0.06 - 0.047 * t; + const bend = 0.015 * t * t; + for (let segment = 0; segment <= segments; segment++) { + const angle = (segment / segments) * Math.PI * 2; + positions.push(bend + Math.cos(angle) * radius, Math.sin(angle) * radius, z); + if (ring > 0 && segment < segments) { + const a = (ring - 1) * (segments + 1) + segment; + const b = a + segments + 1; + indices.push(a, a + 1, b, a + 1, b + 1, b); + } + } + } + for (const ring of [0, heights.length - 1]) { + const center = positions.length / 3; + const t = ring === 0 ? 0 : 1; + positions.push(0.015 * t, 0, heights[ring]); + for (let segment = 0; segment < segments; segment++) { + const a = ring * (segments + 1) + segment; + indices.push(...(ring === 0 ? [center, a + 1, a] : [center, a, a + 1])); + } + } + const mesh = createMesh(new Float32Array(positions), new Uint32Array(indices)); + computeNormals(mesh, true); + // UV.x marks only the leader. Side branches retain their original dimensions. + for (let i = 0; i < mesh.attributes.TEXCOORD_0.value.length; i += 2) + mesh.attributes.TEXCOORD_0.value[i] = 1; + return mesh; +} + +/** Actual branching structure is visible when deciduous trees lose their foliage. */ +export function createWinterCanopyMesh( + type: 'oak' | 'birch' | 'cherry', + detail: TreeDetail +): TreeMesh { + const segments = detail === 'low' ? 4 : 6; + const meshes = [createWinterStem(detail)]; + for (const [i, lobe] of getCrownLobes(type).entries()) { + const start: Point = [0, 0, 0.12 + i * 0.055]; + const end: Point = [lobe.center[0] * 1.65, lobe.center[1] * 1.65, lobe.center[2] + 0.18]; + meshes.push(createBranch(start, end, 0.03, 0.008, segments)); + for (const side of [-1, 1]) { + const fork: Point = [ + end[0] + side * 0.065, + end[1] + 0.06 * Math.cos(i), + Math.min(1, end[2] + 0.16) + ]; + const joint: Point = [ + start[0] + (end[0] - start[0]) * 0.7, + start[1] + (end[1] - start[1]) * 0.7, + start[2] + (end[2] - start[2]) * 0.7 + ]; + meshes.push(createBranch(joint, fork, 0.011, 0.002, segments)); + } + } + return mergeMeshes(meshes); +} + +function evaluateFrond(points: Point[], t: number): Vector3 { + const s = 1 - t; + return new Vector3(points[0]) + .scale(s ** 3) + .add(new Vector3(points[1]).scale(3 * s * s * t)) + .add(new Vector3(points[2]).scale(3 * s * t * t)) + .add(new Vector3(points[3]).scale(t ** 3)); +} + +function appendBlade( + positions: number[], + indices: number[], + base: Vector3, + direction: Vector3, + widthDirection: Vector3, + length: number, + width: number +): void { + const tip = new Vector3(base).add(new Vector3(direction).scale(length)); + const middle = new Vector3(base).lerp(tip, 0.52); + const left = new Vector3(middle).add(new Vector3(widthDirection).scale(width)); + const right = new Vector3(middle).subtract(new Vector3(widthDirection).scale(width)); + const offset = positions.length / 3; + for (const point of [base, left, tip, right]) positions.push(...point); + indices.push(offset, offset + 1, offset + 2, offset, offset + 2, offset + 3); +} + +/** Feathered palm crown; thin rachis ribbons avoid TubeGeometry's extra rings and faces. */ +export function createPalmCanopyMesh(detail: TreeDetail = 'high'): TreeMesh { + const positions: number[] = []; + const indices: number[] = []; + const count = detail === 'high' ? 20 : detail === 'medium' ? 16 : 10; + const upright = detail === 'high' ? 8 : detail === 'medium' ? 6 : 4; + const pairs = detail === 'high' ? 11 : detail === 'medium' ? 9 : 6; + for (let i = 0; i < count + upright; i++) { + const spear = i >= count; + const k = spear ? i - count : i; + const angle = + ((k + (spear ? 0.5 : 0)) / (spear ? upright : count)) * Math.PI * 2 + + (spear ? 0 : Math.sin(k * 5.37) * 0.07); + const tier = k % 4; + const radial = (radius: number, z: number): Point => [ + Math.cos(angle) * radius, + Math.sin(angle) * radius, + z + ]; + const points = spear + ? [radial(0.025, 0.44), radial(0.12, 0.82), radial(0.38, 1.04), radial(0.62, 0.96)] + : [ + radial(0.035, 0.43 + tier * 0.012), + radial(0.28, 0.72 - tier * 0.025), + radial(0.68, 0.55 - tier * 0.045), + radial(0.84 + ((k * 7) % 9) * 0.018, 0.25 - tier * 0.035) + ]; + const pairCount = spear ? Math.max(4, pairs - 3) : pairs; + for (let j = 0; j < pairCount; j++) { + const t = 0.2 + (j / (pairCount - 1)) * 0.72; + const point = evaluateFrond(points, t); + const tangent = evaluateFrond(points, Math.min(1, t + 0.001)) + .subtract(evaluateFrond(points, Math.max(0, t - 0.001))) + .normalize(); + const sideways = new Vector3(-tangent[1], tangent[0], 0).normalize(); + const fullness = Math.sin(((j + 0.75) / pairCount) * Math.PI); + for (const side of [-1, 1]) { + const base = new Vector3(point).add(new Vector3(sideways).scale(side * 0.008)); + const direction = new Vector3(sideways) + .scale(side) + .add(new Vector3(tangent).scale(0.18 + Math.sin(angle + j * 0.7) * 0.06)) + .add([0, 0, 0.12 - t * 0.2]) + .normalize(); + const widthDirection = new Vector3(tangent).add([0, 0, 0.08]).normalize(); + appendBlade( + positions, + indices, + base, + direction, + widthDirection, + 0.055 + fullness * 0.095, + 0.007 + fullness * 0.008 + ); + } + } + for (let j = 0; j < 10; j++) { + const start = evaluateFrond(points, j / 10); + const end = evaluateFrond(points, (j + 1) / 10); + const direction = new Vector3(end).subtract(start); + const length = direction.len(); + direction.normalize(); + const across = new Vector3(-direction[1], direction[0], 0).normalize(); + appendBlade(positions, indices, start, direction, across, length * 1.08, 0.012); + } + } + const leaflets = createMesh(new Float32Array(positions), new Uint32Array(indices)); + for (let i = 0; i < leaflets.attributes.COLOR_0.value.length; i += 3) { + const shade = 0.8 + Math.max(0, leaflets.attributes.POSITION.value[i + 2]) * 0.18; + leaflets.attributes.COLOR_0.value.set([shade, shade, shade], i); + } + return mergeMeshes([leaflets, createSphere(0.13, 'low', [0, 0, 0.43], [1, 1, 0.75])]); +} + +/** A centered sphere with radius one; getCrop.radius now expresses actual metres. */ +export function createCropMesh(): TreeMesh { + return createSphere(1, 'low', [0, 0, 0]); +} + +const MESH_CACHE = new Map(); +/** Bounded cache across layer instances and season switches; never changes mesh identity. */ +export function getTreeMesh( + part: 'trunk' | 'canopy' | 'crop', + type: 'pine' | 'oak' | 'palm' | 'birch' | 'cherry' = 'oak', + detail: TreeDetail = 'high', + winter = false, + levels = 3 +): TreeMesh { + levels = Math.max(1, Math.min(5, Math.round(levels) || 3)); + if (type !== 'pine' && type !== 'palm') levels = 3; + if (type === 'pine' || type === 'palm') winter = false; + if (part === 'crop') { + type = 'oak'; + detail = 'low'; + winter = false; + levels = 3; + } + const key = `${part}:${type}:${detail}:${winter}:${levels}`; + let mesh = MESH_CACHE.get(key); + if (!mesh) { + if (part === 'crop') mesh = createCropMesh(); + else if (part === 'trunk') + mesh = + type === 'palm' + ? createDatePalmTrunkMesh(detail === 'low' ? 8 : 10, detail === 'low' ? 8 : 14) + : createTrunkMesh(detail === 'low' ? 8 : 12); + else if (type === 'pine') + mesh = createPineCanopyMesh(levels, detail === 'high' ? 16 : detail === 'medium' ? 12 : 8); + else if (type === 'palm') mesh = createPalmCanopyMesh(detail); + else + mesh = winter + ? createWinterCanopyMesh(type as 'oak', detail) + : createBroadleafCanopy(type as 'oak', detail); + MESH_CACHE.set(key, mesh); + } + return mesh; +} diff --git a/modules/layers/src/tree-layer/tree-layer.ts b/modules/layers/src/tree-layer/tree-layer.ts new file mode 100644 index 000000000..d9e57edae --- /dev/null +++ b/modules/layers/src/tree-layer/tree-layer.ts @@ -0,0 +1,517 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {CompositeLayer, createIterable} from '@deck.gl/core'; +import type {Color, DefaultProps, LayerProps, Position} from '@deck.gl/core'; +import {Matrix4} from '@math.gl/core'; +import {SimpleMeshLayer} from '@deck.gl/mesh-layers'; +import {getTreeMesh, sampleCrownSurface, samplePineSurface, createTreeRng} from './tree-geometry'; +import {TreeMeshLayer} from './tree-mesh-layer'; + +/** Procedural species silhouette. */ +export type TreeType = 'pine' | 'oak' | 'palm' | 'birch' | 'cherry'; +/** Deciduous winter trees have branches; evergreens retain their crowns. */ +export type Season = 'spring' | 'summer' | 'autumn' | 'winter'; +/** Optional, explicitly supplied fruit, nuts, or flowering points. */ +export type CropConfig = { + color: Color; + count: number; + droppedCount?: number; + /** Actual sphere radius in metres. */ + radius: number; +}; + +const DEFAULT_TRUNK_COLORS: Record = { + pine: [80, 50, 20, 255], + oak: [91, 57, 23, 255], + palm: [140, 100, 55, 255], + birch: [220, 215, 205, 255], // white-grey birch bark + cherry: [100, 60, 40, 255] +}; + +/** Default canopy colours per (tree type, season) [r, g, b, a]. */ +const DEFAULT_CANOPY_COLORS: Record> = { + pine: { + spring: [34, 100, 34, 255], + summer: [0, 64, 0, 255], + autumn: [0, 64, 0, 255], // evergreen — no colour change + winter: [0, 55, 0, 255] + }, + oak: { + spring: [100, 180, 80, 255], + summer: [34, 120, 15, 255], + autumn: [180, 85, 20, 255], + winter: [100, 80, 60, 255] // bare branches + }, + palm: { + spring: [50, 160, 50, 255], + summer: [20, 145, 20, 255], + autumn: [55, 150, 30, 255], + winter: [40, 130, 30, 255] + }, + birch: { + spring: [150, 210, 110, 255], + summer: [80, 160, 60, 255], + autumn: [230, 185, 40, 255], + winter: [180, 180, 170, 255] // near-bare + }, + cherry: { + spring: [255, 180, 205, 255], // pink blossom + summer: [50, 140, 50, 255], + autumn: [200, 60, 40, 255], + winter: [120, 90, 80, 255] // bare + } +}; + +type _TreeLayerProps = { + /** Source data. */ + data: DataT[]; + + /** Longitude/latitude position of the tree base. */ + getPosition?: (d: DataT) => Position; + + /** Base elevation (metres above sea level). @default 0 */ + getElevation?: (d: DataT) => number; + + /** + * Silhouette / species variant. + * 'pine' – layered conical tiers (evergreen) + * 'oak' – wide lobed canopy + * 'palm' – ring-scarred palm trunk with arching pinnate fronds + * 'birch' – narrow oval canopy, pale bark + * 'cherry' – round lush canopy, seasonal blossom + * @default 'pine' + */ + getTreeType?: (d: DataT) => TreeType; + + /** + * Total tree height in metres. + * @default 10 + */ + getHeight?: (d: DataT) => number; + + /** + * Fraction of total height occupied by the trunk (0–1). + * @default 0.35 + */ + getTrunkHeightFraction?: (d: DataT) => number; + + /** + * Trunk base radius in metres. + * @default 0.5 + */ + getTrunkRadius?: (d: DataT) => number; + + /** + * Horizontal canopy scale in metres. Broadleaf envelopes retain the legacy approximate half-scale radius. + * @default 3 + */ + getCanopyRadius?: (d: DataT) => number; + + /** + * Explicit trunk colour [r, g, b, a]. + * When null the species default is used. + * @default null + */ + getTrunkColor?: (d: DataT) => Color | null; + + /** + * Explicit canopy colour [r, g, b, a]. + * When null the species × season default is used. + * @default null + */ + getCanopyColor?: (d: DataT) => Color | null; + + /** + * Season controls default foliage colour and deciduous winter branch geometry. + * @default 'summer' + */ + getSeason?: (d: DataT) => Season; + + /** + * Number of cone tiers for pine trees (1–5). + * Higher values produce a denser layered silhouette. + * @default 3 + */ + getBranchLevels?: (d: DataT) => number; + + /** + * Optional crop configuration for this tree. + * + * Return a `CropConfig` to render small spherical crop points in the outer + * canopy volume (live crops) and/or scattered on the ground around the trunk + * (dropped crops). Return `null` to show no crops for this tree. + * + * The same accessor can express fruit, nuts, or flowering stage — pass + * flower-coloured points (e.g. `[255, 200, 220, 255]`) for a blossom effect. + * + * Crop positions are randomised deterministically from the tree's geographic + * coordinates; they are stable across re-renders. + * + * @default null (no crops) + */ + getCrop?: (d: DataT) => CropConfig | null; + + /** + * Global size multiplier applied to all dimensions. + * @default 1 + */ + sizeScale?: number; + /** Fraction of tree height used for optional GPU wind deformation. Zero stops continuous redraw. @default 0 */ + windStrength?: number; + /** Wind clock in seconds, or null to use deck.gl's timeline. Set a number for repeatable comparisons. @default null */ + windTime?: number | null; + /** Cast shadows when the host LightingEffect enables them. WebGL only until deck.gl supports WebGPU shadows. @default true */ + shadowEnabled?: boolean; +}; + +export type TreeLayerProps = _TreeLayerProps & LayerProps; + +const defaultProps: DefaultProps> = { + getPosition: {type: 'accessor', value: (d: any) => d.position}, + getElevation: {type: 'accessor', value: (_d: any) => 0}, + getTreeType: {type: 'accessor', value: (_d: any) => 'pine' as TreeType}, + getHeight: {type: 'accessor', value: (_d: any) => 10}, + getTrunkHeightFraction: {type: 'accessor', value: (_d: any) => 0.35}, + getTrunkRadius: {type: 'accessor', value: (_d: any) => 0.5}, + getCanopyRadius: {type: 'accessor', value: (_d: any) => 3}, + getTrunkColor: {type: 'accessor', value: (_d: any) => null}, + getCanopyColor: {type: 'accessor', value: (_d: any) => null}, + getSeason: {type: 'accessor', value: (_d: any) => 'summer' as Season}, + getBranchLevels: {type: 'accessor', value: (_d: any) => 3}, + getCrop: {type: 'accessor', value: (_d: any) => null}, + sizeScale: {type: 'number', value: 1, min: 0}, + windStrength: {type: 'number', value: 0, min: 0, max: 0.2}, + windTime: null, + shadowEnabled: true +}; + +type TreeRow = { + object: DataT; + position: Position; + type: TreeType; + winter: boolean; + levels: number; + height: number; + trunkHeight: number; + trunkRadius: number; + scale: [number, number, number]; + orientation: [number, number, number]; + translation: [number, number, number]; + wind: [number, number, number]; +}; +type CropRow = { + position: Position; + translation: [number, number, number]; + radius: number; + color: Color; + wind: [number, number, number]; +}; +type TreeState = { + groups: Map[]>; + trunks: TreeRow[]; + palms: TreeRow[]; + liveCrops: CropRow[]; + droppedCrops: CropRow[]; +}; + +const GEOMETRY_PROPS = [ + 'getPosition', + 'getElevation', + 'getTreeType', + 'getHeight', + 'getTrunkHeightFraction', + 'getTrunkRadius', + 'getCanopyRadius', + 'getSeason', + 'getBranchLevels', + 'getCrop', + 'sizeScale' +] as const; + +const TREE_MESH_ACCESSORS = Object.entries({ + ...SimpleMeshLayer.defaultProps, + ...TreeMeshLayer.defaultProps +}) + .filter( + ([, definition]) => + definition && + typeof definition === 'object' && + 'type' in definition && + definition.type === 'accessor' + ) + .map(([name]) => name); + +/** Layered, instanced trees built with vis.gl geometry, seasonal crowns and optional GPU wind. */ +export class TreeLayer extends CompositeLayer< + ExtraPropsT & Required<_TreeLayerProps> +> { + static layerName = 'TreeLayer'; + static defaultProps = defaultProps; + declare state: TreeState; + + initializeState() { + this.state = {groups: new Map(), trunks: [], palms: [], liveCrops: [], droppedCrops: []}; + } + + updateState({props, oldProps, changeFlags}) { + const triggers = changeFlags.updateTriggersChanged; + const changed = + changeFlags.dataChanged || + GEOMETRY_PROPS.some( + key => props[key] !== oldProps[key] || triggers === true || triggers?.all || triggers?.[key] + ); + if (!changed) return; + const groups = new Map[]>(); + const trunks: TreeRow[] = []; + const palms: TreeRow[] = []; + const liveCrops: CropRow[] = []; + const droppedCrops: CropRow[] = []; + const {iterable, objectInfo} = createIterable(props.data); + for (const object of iterable as Iterable) { + objectInfo.index++; + const type: TreeType = props.getTreeType(object); + if (!['pine', 'oak', 'palm', 'birch', 'cherry'].includes(type)) continue; + const position = props.getPosition(object); + const seed = + ((Math.round(position[0] * 10000) * 92821) ^ (Math.round(position[1] * 10000) * 65537)) >>> + 0; + const height = Math.max(0, props.getHeight(object) * props.sizeScale); + const fraction = Math.max(0, Math.min(1, props.getTrunkHeightFraction(object))); + const canopyHeight = height * (1 - fraction); + const radius = Math.max(0, props.getCanopyRadius(object) * props.sizeScale); + const season: Season = props.getSeason(object); + const winter = season === 'winter' && type !== 'pine' && type !== 'palm'; + const levels = + type === 'pine' ? Math.max(1, Math.min(5, Math.round(props.getBranchLevels(object)))) : 3; + const row = this.getSubLayerRow>( + { + object, + position: [position[0], position[1], props.getElevation(object) || 0], + type, + winter, + levels, + height, + trunkHeight: height * fraction + (type === 'palm' ? canopyHeight * 0.24 : 0), + trunkRadius: Math.max(0, props.getTrunkRadius(object) * props.sizeScale), + scale: [ + radius * (1 + ((seed & 0xffff) / 65535 - 0.5) * 0.6), + radius * (1 + (((seed >>> 16) & 0xffff) / 65535 - 0.5) * 0.6), + canopyHeight + ], + orientation: [ + winter ? 0 : (((seed ^ (seed >>> 7)) & 0xff) / 255 - 0.5) * 24, + (((seed ^ (seed >>> 13)) & 0xffff) / 65535) * 360, + 0 + ], + translation: [0, 0, height * fraction - canopyHeight * 0.22], + wind: [height, (seed / 4294967296) * Math.PI * 2, 1] + }, + object, + objectInfo.index + ); + const key = `${type}-${winter ? 'winter' : 'foliage'}-${levels}`; + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + (type === 'palm' ? palms : trunks).push(row); + const crop = props.getCrop(object); + if (crop && crop.radius > 0) { + const cropRadius = crop.radius * props.sizeScale; + const rng = createTreeRng(seed); + // SimpleMeshLayer uses yaw about Z and pitch about Y (roll is zero). + const transform = new Matrix4() + .rotateZ((row.orientation[1] * Math.PI) / 180) + .rotateY((row.orientation[0] * Math.PI) / 180) + .scale(row.scale); + const count = Math.max(0, Math.floor(crop.count)); + for (let i = 0; i < count; i++) { + const theta = rng() * Math.PI * 2; + const vertical = -0.75 + rng() * 1.5; + const horizontal = Math.sqrt(1 - vertical * vertical); + let point: [number, number, number]; + if (type === 'palm') { + point = [Math.cos(theta) * 0.16, Math.sin(theta) * 0.16, 0.3 + rng() * 0.08]; + } else if (type === 'pine') { + const z = 0.18 + rng() * 0.5; + point = samplePineSurface(levels, z, theta); + } else { + point = sampleCrownSurface(type, [ + horizontal * Math.cos(theta), + horizontal * Math.sin(theta), + vertical + ]); + point = [point[0] * 1.04, point[1] * 1.04, 0.5 + (point[2] - 0.5) * 1.04]; + } + const offset = transform.transformAsVector(point) as [number, number, number]; + offset[2] += row.translation[2]; + liveCrops.push( + this.getSubLayerRow( + { + position: row.position, + translation: offset, + radius: cropRadius, + color: crop.color, + wind: row.wind + }, + object, + objectInfo.index + ) + ); + } + const groundRng = createTreeRng(seed ^ 0x1a2b3c4d); + for (let i = 0; i < Math.max(0, Math.floor(crop.droppedCount ?? 0)); i++) { + const theta = groundRng() * Math.PI * 2; + const r = Math.sqrt(groundRng()) * radius * 0.5; + droppedCrops.push( + this.getSubLayerRow( + { + position: row.position, + translation: [Math.cos(theta) * r, Math.sin(theta) * r, cropRadius], + radius: cropRadius, + color: crop.color, + wind: [height, row.wind[1], 0] + }, + object, + objectInfo.index + ) + ); + } + } + } + this.setState({groups, trunks, palms, liveCrops, droppedCrops}); + } + + private getTreeSubLayerProps(sublayerProps, legacyId?: string) { + const {id} = sublayerProps; + const legacyOverrides = legacyId ? this.props._subLayerProps?.[legacyId] : undefined; + const exactOverrides = this.props._subLayerProps?.[id]; + const overrides = {...legacyOverrides, ...exactOverrides}; + const parameters = { + ...this.props.parameters, + ...sublayerProps.parameters, + ...legacyOverrides?.parameters, + ...exactOverrides?.parameters + }; + const updateTriggers = { + all: this.props.updateTriggers?.all, + ...sublayerProps.updateTriggers, + ...legacyOverrides?.updateTriggers, + ...exactOverrides?.updateTriggers + }; + const props = this.getSubLayerProps({ + ...sublayerProps, + ...legacyOverrides, + id, + type: TreeMeshLayer, + parameters, + updateTriggers + }); + // deck.gl initializes child prop metadata lazily. Explicit wrapping also + // covers the first trunk and legacy aliases, before that metadata exists. + for (const name of TREE_MESH_ACCESSORS) { + if (name in overrides) props[name] = this.getSubLayerAccessor(overrides[name]); + } + return {...props, parameters, updateTriggers}; + } + + renderLayers() { + const {windStrength, windTime, shadowEnabled, getTrunkColor, getCanopyColor, getSeason} = + this.props; + const shared = {windStrength, windTime, shadowEnabled, pickable: this.props.pickable}; + const layers: TreeMeshLayer[] = []; + const trunkColor = (row: TreeRow) => + getTrunkColor(row.object) ?? DEFAULT_TRUNK_COLORS[row.type]; + // Prepared rows remain identical on animation, lighting and color-only updates. + const trunks = (data: TreeRow[], id: string, palm: boolean) => { + if (!data.length) return; + layers.push( + new TreeMeshLayer( + this.getTreeSubLayerProps({ + id, + data, + ...shared, + mesh: getTreeMesh('trunk', palm ? 'palm' : 'oak'), + getPosition: (row: TreeRow) => row.position, + getScale: (row: TreeRow) => [row.trunkRadius, row.trunkRadius, row.trunkHeight], + getOrientation: (row: TreeRow) => + row.winter ? [0, row.orientation[1], 0] : [0, 0, 0], + getColor: trunkColor, + getWind: (row: TreeRow) => row.wind, + material: {ambient: 0.4, diffuse: 0.7, shininess: 4}, + updateTriggers: {getColor: [getTrunkColor, this.props.updateTriggers?.getTrunkColor]} + }) + ) + ); + }; + trunks(this.state.trunks, 'trunks', false); + trunks(this.state.palms, 'trunks-palm', true); + for (const [key, data] of this.state.groups) { + const {type, winter, levels} = data[0]; + layers.push( + new TreeMeshLayer( + this.getTreeSubLayerProps( + { + id: `canopy-${key}`, + data, + ...shared, + mesh: getTreeMesh('canopy', type, 'high', winter, levels), + doubleSided: type === 'palm', + parameters: + type === 'palm' + ? {...this.props.parameters, cullMode: 'none'} + : this.props.parameters, + getPosition: (row: TreeRow) => row.position, + getTranslation: (row: TreeRow) => row.translation, + getScale: (row: TreeRow) => row.scale, + getStemRadius: (row: TreeRow) => (row.winter ? row.trunkRadius * 0.7 : -1), + getOrientation: (row: TreeRow) => row.orientation, + getWind: (row: TreeRow) => row.wind, + getColor: (row: TreeRow) => + winter + ? trunkColor(row) + : (getCanopyColor(row.object) ?? + DEFAULT_CANOPY_COLORS[type][getSeason(row.object) || 'summer']), + material: {ambient: 0.42, diffuse: 0.72, shininess: 2}, + updateTriggers: { + getColor: [ + getCanopyColor, + getTrunkColor, + getSeason, + this.props.updateTriggers?.getCanopyColor, + this.props.updateTriggers?.getTrunkColor, + this.props.updateTriggers?.getSeason + ] + } + }, + `canopy-${type}` + ) + ) + ); + } + for (const [id, data] of [ + ['live-crops', this.state.liveCrops], + ['dropped-crops', this.state.droppedCrops] + ] as const) { + if (!data.length) continue; + layers.push( + new TreeMeshLayer( + this.getTreeSubLayerProps({ + id, + data, + ...shared, + mesh: getTreeMesh('crop'), + getPosition: (row: CropRow) => row.position, + getTranslation: (row: CropRow) => row.translation, + getScale: (row: CropRow) => [row.radius, row.radius, row.radius], + getColor: (row: CropRow) => row.color, + getWind: (row: CropRow) => row.wind, + material: {ambient: 0.4, diffuse: 0.7, shininess: 24} + }) + ) + ); + } + return layers; + } +} diff --git a/modules/layers/src/tree-layer/tree-mesh-layer.ts b/modules/layers/src/tree-layer/tree-mesh-layer.ts new file mode 100644 index 000000000..58121d5b1 --- /dev/null +++ b/modules/layers/src/tree-layer/tree-mesh-layer.ts @@ -0,0 +1,191 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Accessor, DefaultProps} from '@deck.gl/core'; +import {SimpleMeshLayer, type SimpleMeshLayerProps} from '@deck.gl/mesh-layers'; +import type {ShaderModule} from '@luma.gl/shadertools'; + +type WindProps = { + /** [tree height in metres, stable wind phase, flex multiplier]. */ + getWind: Accessor; + /** Physical winter leader radius at its trunk join; negative disables the morph. */ + getStemRadius: Accessor; + windStrength: number; + windTime: number | null; + doubleSided: boolean; +}; +const UNIFORM_DECLARATION = [ + 'layout(std140) uniform treeWindUniforms {', + ' float strength;', + ' float time;', + ' float twoSided;', + '} treeWind;' +].join(String.fromCharCode(10)); +const TREE_WIND = { + name: 'treeWind', + vs: UNIFORM_DECLARATION, + fs: UNIFORM_DECLARATION, + source: `struct treeWindUniforms { + strength: f32, + time: f32, + twoSided: f32 +}; +@group(0) @binding(auto) var treeWind: treeWindUniforms;`, + uniformTypes: {strength: 'f32', time: 'f32', twoSided: 'f32'} +} as const satisfies ShaderModule<{strength: number; time: number; twoSided: number}>; + +const GLSL_STEM = ` + vec3 treePosition = positions; + vec3 treeLocalNormal = normals; + if (instanceStemRadius >= 0.0 && texCoords.x > 0.5) { + vec2 treeScale = max(vec2(length(instanceModelMatrixCol0), length(instanceModelMatrixCol1)), vec2(0.000001)); + vec2 treeTarget = instanceStemRadius / (treeScale * 0.06); + float treeT = clamp((positions.z - 0.22) / 0.2, 0.0, 1.0); + float treeBlend = 1.0 - treeT * treeT * (3.0 - 2.0 * treeT); + float treeDerivative = -6.0 * treeT * (1.0 - treeT) / 0.2; + vec2 treeFactor = max(mix(vec2(1.0), treeTarget, treeBlend), vec2(0.000001)); + treePosition.xy *= treeFactor; + treeLocalNormal.xy /= treeFactor; + treeLocalNormal.z -= dot(treeLocalNormal.xy, positions.xy * (treeTarget - 1.0) * treeDerivative); + } +`; +const WGSL_STEM = ` + var treePosition = attributes.positions; + var treeLocalNormal = attributes.normals; + if (attributes.instanceStemRadius >= 0.0 && attributes.texCoords.x > 0.5) { + let treeScale = max(vec2(length(attributes.instanceModelMatrixCol0), length(attributes.instanceModelMatrixCol1)), vec2(0.000001)); + let treeTarget = attributes.instanceStemRadius / (treeScale * 0.06); + let treeT = clamp((attributes.positions.z - 0.22) / 0.2, 0.0, 1.0); + let treeBlend = 1.0 - treeT * treeT * (3.0 - 2.0 * treeT); + let treeDerivative = -6.0 * treeT * (1.0 - treeT) / 0.2; + let treeFactor = max(mix(vec2(1.0), treeTarget, treeBlend), vec2(0.000001)); + treePosition = vec3(treePosition.xy * treeFactor, treePosition.z); + treeLocalNormal = vec3(treeLocalNormal.xy / treeFactor, treeLocalNormal.z); + treeLocalNormal.z -= dot(treeLocalNormal.xy, attributes.positions.xy * (treeTarget - 1.0) * treeDerivative); + } +`; + +const GLSL_WIND = ` + vec3 treeSquaredScale = max(vec3(dot(instanceModelMatrixCol0, instanceModelMatrixCol0), dot(instanceModelMatrixCol1, instanceModelMatrixCol1), dot(instanceModelMatrixCol2, instanceModelMatrixCol2)), vec3(0.000001)); + vec3 treeNormal = instanceModelMatrix * (treeLocalNormal / treeSquaredScale); + if (treeWind.strength > 0.0) { + float treeHeight = max(instanceTreeWind.x, 0.001); + float treeFlex = clamp(pos.z / treeHeight, 0.0, 1.0); + float treeWave = sin(treeWind.time * 1.6 + instanceTreeWind.y) + 0.35 * sin(treeWind.time * 2.7 + instanceTreeWind.y * 1.7); + vec2 treeBend = vec2(1.0, 0.45) * treeWind.strength * instanceTreeWind.z * treeWave; + pos.xy += treeBend * treeHeight * treeFlex * treeFlex; + treeNormal.z -= dot(treeNormal.xy, treeBend * 2.0 * treeFlex); + } +`; +const WGSL_WIND = ` + let treeSquaredScale = max(vec3(dot(attributes.instanceModelMatrixCol0, attributes.instanceModelMatrixCol0), dot(attributes.instanceModelMatrixCol1, attributes.instanceModelMatrixCol1), dot(attributes.instanceModelMatrixCol2, attributes.instanceModelMatrixCol2)), vec3(0.000001)); + var treeNormal = instanceModelMatrix * (treeLocalNormal / treeSquaredScale); + if (treeWind.strength > 0.0) { + let treeHeight = max(attributes.instanceTreeWind.x, 0.001); + let treeFlex = clamp(meshPosition.z / treeHeight, 0.0, 1.0); + let treeWave = sin(treeWind.time * 1.6 + attributes.instanceTreeWind.y) + 0.35 * sin(treeWind.time * 2.7 + attributes.instanceTreeWind.y * 1.7); + let treeBend = vec2(1.0, 0.45) * treeWind.strength * attributes.instanceTreeWind.z * treeWave; + meshPosition = vec3(meshPosition.xy + treeBend * treeHeight * treeFlex * treeFlex, meshPosition.z); + treeNormal.z -= dot(treeNormal.xy, treeBend * 2.0 * treeFlex); + } +`; + +/** Shared shader deformation for trunks, crowns and attached crops, including picking/shadows. */ +export class TreeMeshLayer extends SimpleMeshLayer> { + static layerName = 'TreeMeshLayer'; + static defaultProps: DefaultProps & WindProps> = { + getStemRadius: {type: 'accessor', value: -1}, + getWind: {type: 'accessor', value: [1, 0, 1]}, + windStrength: 0, + windTime: null, + doubleSided: false + }; + + initializeState() { + super.initializeState(); + this.getAttributeManager()!.addInstanced({ + instanceTreeWind: {size: 3, accessor: 'getWind', defaultValue: [1, 0, 1]}, + instanceStemRadius: {size: 1, accessor: 'getStemRadius', defaultValue: -1} + }); + } + + getShaders() { + const shaders = super.getShaders(); + if (this.context.device.type === 'webgpu') { + const anchor = ' attributes.instanceTranslation;'; + if (!shaders.source.includes(anchor)) + throw new Error('Unsupported SimpleMeshLayer WGSL wind anchor.'); + return { + ...shaders, + modules: [...shaders.modules, TREE_WIND], + source: shaders.source + .replace( + ' @location(10) instanceTranslation: vec3,', + ' @location(10) instanceTranslation: vec3,\n @location(11) instanceTreeWind: vec3,\n @location(12) instanceStemRadius: f32,' + ) + .replace(' let meshPosition =', `${WGSL_STEM}\n var meshPosition =`) + .replace( + '(instanceModelMatrix * attributes.positions)', + '(instanceModelMatrix * treePosition)' + ) + .replace( + 'fn fragmentMain(varyings: Varyings)', + 'fn fragmentMain(varyings: Varyings, @builtin(front_facing) frontFacing: bool)' + ) + .replace( + ' color = vec4(', + ' if (treeWind.twoSided > 0.5 && !frontFacing) { normal = -normal; }\n color = vec4(' + ) + .replace(anchor, `${anchor}\n${WGSL_WIND}`) + .replaceAll( + 'project_normal(instanceModelMatrix * attributes.normals)', + 'project_normal(treeNormal)' + ) + }; + } + const anchor = + 'vec3 pos = (instanceModelMatrix * positions) * simpleMesh.sizeScale + instanceTranslation;'; + if (!shaders.vs.includes(anchor)) + throw new Error('Unsupported SimpleMeshLayer GLSL wind anchor.'); + return { + ...shaders, + modules: [...shaders.modules, TREE_WIND], + // Filter the final shaded color. In a shadow pass the filter writes packed + // depth; lighting or opacity must never modify those RGBA bytes afterward. + fs: shaders.fs + .replace(' DECKGL_FILTER_COLOR(color, geometry);', '') + .replace( + 'vec3 lightColor =', + 'if (treeWind.twoSided > 0.5 && !gl_FrontFacing) normal = -normal;\nvec3 lightColor =' + ) + .replace( + 'fragColor = vec4(lightColor, color.a * layer.opacity);', + 'color = vec4(lightColor, color.a * layer.opacity);\n DECKGL_FILTER_COLOR(color, geometry);\n fragColor = color;' + ), + vs: shaders.vs + .replace( + 'in vec3 instanceTranslation;', + 'in vec3 instanceTranslation;\nin vec3 instanceTreeWind;\nin float instanceStemRadius;' + ) + .replace( + anchor, + `${GLSL_STEM}\n${anchor.replace('instanceModelMatrix * positions', 'instanceModelMatrix * treePosition')}\n${GLSL_WIND}` + ) + .replaceAll('project_normal(instanceModelMatrix * normals)', 'project_normal(treeNormal)') + }; + } + + draw(params) { + const {windStrength, windTime} = this.props; + this.state.model?.shaderInputs.setProps({ + treeWind: { + strength: windStrength, + time: windTime ?? this.context.timeline.getTime() / 1000, + twoSided: this.props.doubleSided ? 1 : 0 + } + }); + super.draw(params); + if (windStrength > 0 && windTime === null) this.setNeedsRedraw(); + } +} diff --git a/modules/layers/test/tree-layer/tree-layer.spec.ts b/modules/layers/test/tree-layer/tree-layer.spec.ts new file mode 100644 index 000000000..368144344 --- /dev/null +++ b/modules/layers/test/tree-layer/tree-layer.spec.ts @@ -0,0 +1,572 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors +import {describe, it, expect, vi} from 'vitest'; +import {Matrix4} from '@math.gl/core'; +import {TreeLayer} from '../../src/tree-layer/tree-layer'; +import { + getTreeMesh, + createPalmCanopyMesh, + createTrunkMesh, + createCropMesh, + samplePineSurface, + sampleCrownSurface, + type TreeMesh +} from '../../src/tree-layer/tree-geometry'; + +type Datum = { + position: [number, number]; + type: 'oak' | 'pine' | 'palm' | 'birch' | 'cherry'; + season: 'summer' | 'winter'; +}; +function createLayer(data: Datum[], extra = {}) { + const layer = new TreeLayer({ + id: 'trees', + data, + getTreeType: d => d.type, + getSeason: d => d.season, + ...extra + }); + layer.initializeState(); + vi.spyOn(layer, 'setState').mockImplementation(state => { + Object.assign(layer.state, state); + }); + const update = (overrides = {}, flags = {propsChanged: true}) => + layer.updateState({ + props: Object.assign(Object.create(layer.props), overrides), + oldProps: {}, + changeFlags: flags + }); + update({}, {dataChanged: true} as any); + return {layer, update}; +} +const OAK: Datum = {position: [0, 0], type: 'oak', season: 'summer'}; + +function getMeshRing(mesh: TreeMesh, height: number, leaderOnly = false) { + const points = new Map(); + const positions = mesh.attributes.POSITION.value; + const markers = mesh.attributes.TEXCOORD_0.value; + for (let i = 0; i < positions.length; i += 3) { + const point: [number, number, number] = [positions[i], positions[i + 1], positions[i + 2]]; + if (leaderOnly && markers[(i / 3) * 2] !== 1) continue; + if (Math.abs(point[2] - height) > 0.000001 || Math.hypot(point[0], point[1]) < 0.000001) + continue; + const key = point + .slice(0, 2) + .map(value => Math.round(value * 1e6)) + .join(','); + points.set(key, point); + } + return [...points.values()]; +} + +describe('native tree geometry', () => { + it('always renders highest-quality trunks and crowns, ignoring a legacy detail prop', () => { + for (const type of ['pine', 'oak', 'palm', 'birch', 'cherry'] as const) { + for (const season of ['summer', 'winter'] as const) { + const {layer} = createLayer([{position: [0, 0], type, season}], {detail: 'low'}); + const children = layer.renderLayers(); + const trunk = children.find(child => child.id.includes('trunks'))!; + const crown = children.find(child => child.id.includes('canopy'))!; + expect(trunk.props.mesh).toBe(getTreeMesh('trunk', type === 'palm' ? 'palm' : 'oak')); + expect(crown.props.mesh).toBe(getTreeMesh('canopy', type, 'high', season === 'winter')); + } + } + }); + it('has finite unit normals, valid triangles and correct bounds for every species/detail/season', () => { + for (const detail of ['low', 'medium', 'high'] as const) + for (const type of ['pine', 'oak', 'palm', 'birch', 'cherry'] as const) + for (const winter of [false, true]) { + const mesh = getTreeMesh('canopy', type, detail, winter); + const p = mesh.attributes.POSITION.value; + const n = mesh.attributes.NORMAL.value; + expect(mesh.indices.value.length % 3).toBe(0); + expect(mesh.indices.value.every(index => index < p.length / 3)).toBe(true); + expect(p.every(Number.isFinite)).toBe(true); + let maxNormalError = 0; + for (let i = 0; i < n.length; i += 3) + maxNormalError = Math.max( + maxNormalError, + Math.abs(Math.hypot(n[i], n[i + 1], n[i + 2]) - 1) + ); + expect(maxNormalError).toBeLessThan(0.0001); + const z = p.filter((_, i) => i % 3 === 2); + expect(Math.max(...z)).toBeGreaterThan(0.7); + expect(Math.min(...z)).toBeGreaterThanOrEqual(-0.03); + } + }); + it('shares meshes across instances and bounds broadleaf foliage to one sphere topology', () => { + const mesh = getTreeMesh('canopy', 'oak'); + expect(getTreeMesh('canopy', 'oak')).toBe(mesh); + expect(mesh.indices.value.length / 3).toBeLessThanOrEqual(320); + expect(getTreeMesh('canopy', 'oak', 'low').indices.value.length).toBeLessThan( + mesh.indices.value.length + ); + }); + it('keeps low-detail pine within the legacy triangle budget and drops redundant cap faces', () => { + expect( + getTreeMesh('canopy', 'pine', 'low', false, 4).indices.value.length / 3 + ).toBeLessThanOrEqual(80); + expect( + getTreeMesh('canopy', 'pine', 'high', false, 4).indices.value.length / 3 + ).toBeLessThanOrEqual(150); + }); + it('keeps the dense feathered palm silhouette deterministic', () => { + const first = createPalmCanopyMesh(); + expect(first.indices.value.length / 3).toBeLessThan(1800); + expect(createPalmCanopyMesh().attributes.POSITION.value).toEqual( + first.attributes.POSITION.value + ); + expect(first.attributes.POSITION.value.length / 3).toBeGreaterThan(3000); + const p = first.attributes.POSITION.value; + expect(Math.max(...p.filter((_, i) => i % 3 === 0))).toBeGreaterThan(0.9); + expect(Math.max(...p.filter((_, i) => i % 3 === 2))).toBeGreaterThan(0.95); + }); + it('tapers trunks upward and uses actual radius-one centered crop geometry', () => { + const p = createTrunkMesh().attributes.POSITION.value; + let bottom = 0; + let top = 0; + for (let i = 0; i < p.length; i += 3) { + const radius = Math.hypot(p[i], p[i + 1]); + if (p[i + 2] < 0.1) bottom = Math.max(bottom, radius); + if (p[i + 2] > 0.9) top = Math.max(top, radius); + } + expect(top).toBeLessThan(bottom); + const crop = createCropMesh().attributes.POSITION.value; + for (let i = 0; i < crop.length; i += 3) + expect(Math.hypot(crop[i], crop[i + 1], crop[i + 2])).toBeCloseTo(1, 5); + expect(Math.min(...crop.filter((_, i) => i % 3 === 2))).toBeCloseTo(-1, 5); + expect(Math.max(...crop.filter((_, i) => i % 3 === 2))).toBeCloseTo(1, 5); + }); +}); + +describe('winter trunk and leader join contracts', () => { + it('provides centered join rings with the same angular grid as the trunk at every detail', () => { + for (const detail of ['low', 'medium', 'high'] as const) { + const trunk = getTreeMesh('trunk', 'oak', detail); + const top = getMeshRing(trunk, 1); + for (const species of ['oak', 'birch', 'cherry'] as const) { + const crown = getTreeMesh('canopy', species, detail, true); + const join = getMeshRing(crown, 0.22, true); + expect(join.length).toBe(detail === 'low' ? 8 : 12); + expect(top.length).toBe(join.length); + for (const point of join) { + const radius = Math.hypot(point[0], point[1]); + expect(radius).toBeCloseTo(0.06, 6); + const matching = top.find( + candidate => + Math.hypot( + candidate[0] / 0.7 - point[0] / radius, + candidate[1] / 0.7 - point[1] / radius + ) < 0.000001 + ); + expect(matching).toBeDefined(); + } + for (const axis of [0, 1]) + expect(join.reduce((sum, point) => sum + point[axis], 0)).toBeCloseTo(0, 6); + // A taper needs real rings above the join, not just interpolated shader values. + expect(getMeshRing(crown, 0.27, true).length).toBe(join.length); + expect(getMeshRing(crown, 0.42, true).length).toBe(join.length); + expect( + crown.attributes.TEXCOORD_0.value.some((value, i) => i % 2 === 0 && value === 0) + ).toBe(true); + expect(trunk.attributes.TEXCOORD_0.value.every(value => value === 0)).toBe(true); + } + } + }); + + it('anchors the collar to the physical trunk radius and top across sizes, yaw and canopy anisotropy', () => { + type Specimen = Datum & { + height: number; + fraction: number; + trunkRadius: number; + canopyRadius: number; + elevation: number; + }; + const dimensions = [ + { + position: [0, 0], + height: 12, + fraction: 0.36, + trunkRadius: 0.38, + canopyRadius: 7, + elevation: 0 + }, + { + position: [-122.415, 37.775], + height: 20, + fraction: 0.2, + trunkRadius: 0.05, + canopyRadius: 12, + elevation: 130 + }, + { + position: [12.493, -8.251], + height: 5, + fraction: 0.7, + trunkRadius: 2, + canopyRadius: 0.2, + elevation: -12 + } + ]; + const sizeScale = 2.4; + for (const species of ['oak', 'birch', 'cherry'] as const) { + const data: Specimen[] = dimensions.map(d => ({ + ...d, + position: d.position as [number, number], + type: species, + season: 'winter' + })); + const {layer} = createLayer(data, { + sizeScale, + getHeight: (d: Specimen) => d.height, + getTrunkHeightFraction: (d: Specimen) => d.fraction, + getTrunkRadius: (d: Specimen) => d.trunkRadius, + getCanopyRadius: (d: Specimen) => d.canopyRadius, + getElevation: (d: Specimen) => d.elevation + }); + const children = layer.renderLayers(); + const trunk = children.find(child => child.id === 'trees-trunks')!; + const crown = children.find(child => child.id.includes('canopy'))!; + const top = getMeshRing(trunk.props.mesh, 1); + const join = getMeshRing(crown.props.mesh, 0.22, true); + expect(crown.props.mesh).toBe(getTreeMesh('canopy', species, 'high', true)); + expect(crown.props.data.some(row => Math.abs(row.scale[0] - row.scale[1]) > 0.001)).toBe( + true + ); + for (const row of crown.props.data) { + const source = row.object as Specimen; + const trunkScale = trunk.props.getScale(row); + const crownScale = crown.props.getScale(row); + const translation = crown.props.getTranslation(row); + const orientation = crown.props.getOrientation(row); + const radius = crown.props.getStemRadius(row); + expect(orientation[0]).toBe(0); + expect(orientation[2]).toBe(0); + expect(trunk.props.getOrientation(row)).toEqual(orientation); + expect(radius).toBeCloseTo(source.trunkRadius * sizeScale * 0.7, 8); + expect(radius).toBeCloseTo(trunkScale[0] * 0.7, 8); + expect(trunkScale[2]).toBeCloseTo(source.height * source.fraction * sizeScale, 8); + expect(translation[2] + join[0][2] * crownScale[2]).toBeCloseTo(trunkScale[2], 6); + expect(trunk.props.getPosition(row)).toEqual(crown.props.getPosition(row)); + expect(row.position[2]).toBe(source.elevation); + // The collar contract is a circular ring of physical radius, independent + // of canopy X/Y scale. Check its angular grid against the actual trunk. + const rotation = new Matrix4().rotateZ((orientation[1] * Math.PI) / 180); + const actualTop = top.map(point => + rotation.transformAsVector([ + point[0] * trunkScale[0], + point[1] * trunkScale[1], + trunkScale[2] + ]) + ); + for (const point of join) { + const localRadius = Math.hypot(point[0], point[1]); + const target = rotation.transformAsVector([ + (point[0] / localRadius) * radius, + (point[1] / localRadius) * radius, + translation[2] + point[2] * crownScale[2] + ]); + expect( + actualTop.some( + candidate => Math.hypot(...candidate.map((value, i) => value - target[i])) < 0.000001 + ) + ).toBe(true); + } + } + } + }); + + it('keeps accepted zero dimensions finite without allocating instance-specific meshes', () => { + const cases = [ + {getHeight: () => 0}, + {getCanopyRadius: () => 0}, + {getTrunkRadius: () => 0}, + {getTrunkHeightFraction: () => 0}, + {getTrunkHeightFraction: () => 1}, + {sizeScale: 0} + ]; + for (const extra of cases) { + const {layer} = createLayer([{...OAK, season: 'winter'}], extra); + const crown = layer.renderLayers().find(child => child.id.includes('canopy'))!; + const row = crown.props.data[0]; + expect(crown.props.mesh).toBe(getTreeMesh('canopy', 'oak', 'high', true)); + expect( + [ + ...crown.props.getScale(row), + ...crown.props.getTranslation(row), + ...crown.props.getOrientation(row), + crown.props.getStemRadius(row), + ...row.wind + ].every(Number.isFinite) + ).toBe(true); + expect(crown.props.getStemRadius(row)).toBeGreaterThanOrEqual(0); + } + }); + + it('leaves crop dimensions and placement independent of the collar and shares the wind frame', () => { + const data: Datum[] = [{...OAK, position: [-122.415, 37.775], season: 'winter'}]; + const extra = { + getCrop: () => ({count: 8, droppedCount: 4, radius: 0.2, color: [200, 80, 40, 255]}), + windStrength: 0.025, + windTime: 3 + }; + const small = createLayer(data, {...extra, getTrunkRadius: () => 0.05}).layer; + const large = createLayer(data, {...extra, getTrunkRadius: () => 2}).layer; + for (const key of ['liveCrops', 'droppedCrops'] as const) + expect(small.state[key].map(row => [row.translation, row.radius])).toEqual( + large.state[key].map(row => [row.translation, row.radius]) + ); + const children = small.renderLayers(); + const crown = children.find(child => child.id.includes('canopy'))!; + const tree = crown.props.data[0]; + for (const child of children) { + expect(child.props.windStrength).toBe(0.025); + expect(child.props.windTime).toBe(3); + const row = child.props.data[0]; + if (child.id.includes('dropped-crops')) expect(row.wind[2]).toBe(0); + else expect(child.props.getWind(row)).toEqual(tree.wind); + if (!child.id.includes('canopy')) expect(child.props.getStemRadius).toBe(-1); + } + const groups = small.state.groups; + vi.mocked(small.setState).mockClear(); + small.updateState({ + props: Object.create(small.props, {windTime: {value: 20}}), + oldProps: small.props, + changeFlags: {propsChanged: true} + }); + expect(small.setState).not.toHaveBeenCalled(); + expect(small.state.groups).toBe(groups); + }); +}); + +describe('TreeLayer sublayer override contracts', () => { + it('unwraps exact trunk and crop accessors on the first render, including owner context', () => { + const data: Datum[] = [{position: [1, 0], type: 'pine', season: 'summer'}, {...OAK}]; + const calls: {object: Datum; index: number; data: Datum[]}[] = []; + const getColor = (object: Datum, info) => { + calls.push({object, index: info.index, data: info.data}); + info.target[0] = 100 + info.index; + info.target[1] = 20; + info.target[2] = 30; + info.target[3] = 255; + return info.target; + }; + const overrides = { + visible: false, + material: false, + parameters: {depthWriteEnabled: false}, + getColor, + updateTriggers: {getColor: 'explicit-color'} + }; + const {layer} = createLayer(data, { + getCrop: () => ({count: 2, droppedCount: 2, radius: 0.1, color: [200, 80, 40, 255]}), + windStrength: 0.025, + windTime: 2, + updateTriggers: {all: 'owner-data'}, + _subLayerProps: { + trunks: {...overrides}, + 'live-crops': {...overrides}, + 'dropped-crops': {...overrides} + } + }); + const children = layer.renderLayers(); + for (const id of ['trunks', 'live-crops', 'dropped-crops']) { + const child = children.find(candidate => candidate.id === `trees-${id}`)!; + const rowIndex = id === 'trunks' ? 1 : 2; + const row = child.props.data[rowIndex]; + expect( + child.props.getColor(row, {index: rowIndex, data: child.props.data, target: []}) + ).toEqual([101, 20, 30, 255]); + expect(calls.at(-1)).toEqual({object: data[1], index: 1, data}); + expect(child.props.visible).toBe(false); + expect(child.props.material).toBe(false); + expect(child.props.parameters.depthWriteEnabled).toBe(false); + expect(child.props.updateTriggers).toMatchObject({ + all: 'owner-data', + getColor: 'explicit-color' + }); + expect(child.props.windStrength).toBe(0.025); + expect(child.props.windTime).toBe(2); + const picked = layer.getPickingInfo({info: {object: row, index: rowIndex}} as any); + expect(picked.object).toBe(data[1]); + expect(picked.index).toBe(1); + } + }); + + it('applies legacy species overrides last and gives exact canopy IDs precedence', () => { + const data: Datum[] = [{...OAK}, {...OAK, position: [2, 0], season: 'winter'}]; + const getAliasColor = vi.fn((object: Datum, info) => [ + object.type === 'oak' ? 90 : 0, + info.index, + 0, + 255 + ]); + const getExactColor = vi.fn((object: Datum, info) => [ + object.type === 'oak' ? 120 : 0, + info.index, + 0, + 255 + ]); + const getAliasScale = vi.fn((object: Datum, info) => [ + object.position[0] + 1, + info.index + 1, + 3 + ]); + const {layer} = createLayer(data, { + windStrength: 0.03, + windTime: 4, + parameters: {depthCompare: 'less-equal'}, + updateTriggers: {all: 'trees'}, + _subLayerProps: { + 'canopy-oak': { + visible: false, + material: false, + parameters: {depthWriteEnabled: false}, + getColor: getAliasColor, + getScale: getAliasScale, + updateTriggers: {getColor: 'alias-color', getScale: 'alias-scale'} + }, + 'canopy-oak-foliage-3': { + visible: true, + material: {ambient: 0.2}, + parameters: {blend: false}, + getColor: getExactColor, + updateTriggers: {getColor: 'exact-color'} + } + } + }); + const children = layer.renderLayers(); + const summer = children.find(child => child.id === 'trees-canopy-oak-foliage-3')!; + const winter = children.find(child => child.id === 'trees-canopy-oak-winter-3')!; + for (const [child, sourceIndex, expectedColor] of [ + [summer, 0, [120, 0, 0, 255]], + [winter, 1, [90, 1, 0, 255]] + ] as const) { + const row = child.props.data[0]; + const context = {index: 0, data: child.props.data, target: []}; + expect(child.props.getColor(row, context)).toEqual(expectedColor); + expect(child.props.getScale(row, context)).toEqual([ + data[sourceIndex].position[0] + 1, + sourceIndex + 1, + 3 + ]); + expect(child.props.parameters).toMatchObject({ + depthCompare: 'less-equal', + depthWriteEnabled: false + }); + expect(child.props.updateTriggers).toMatchObject({all: 'trees', getScale: 'alias-scale'}); + expect(child.props.windStrength).toBe(0.03); + expect(child.props.windTime).toBe(4); + } + expect(summer.props.visible).toBe(true); + expect(summer.props.material).toEqual({ambient: 0.2}); + expect(summer.props.parameters.blend).toBe(false); + expect(summer.props.updateTriggers.getColor).toBe('exact-color'); + expect(winter.props.visible).toBe(false); + expect(winter.props.material).toBe(false); + expect(winter.props.updateTriggers.getColor).toBe('alias-color'); + expect(getExactColor.mock.calls[0][0]).toBe(data[0]); + expect(getAliasColor.mock.calls[0][0]).toBe(data[1]); + expect(getAliasScale.mock.calls.map(call => call[1].index)).toEqual([0, 1]); + expect(getAliasScale.mock.calls.every(call => call[1].data === data)).toBe(true); + }); + + it('preserves palm wind and double-sided defaults while allowing explicit overrides', () => { + const data: Datum[] = [{position: [3, 0], type: 'palm', season: 'summer'}]; + const common = { + windStrength: 0.025, + windTime: 2, + _subLayerProps: {'canopy-palm': {parameters: {depthWriteEnabled: false}}} + }; + const canopy = createLayer(data, common) + .layer.renderLayers() + .find(child => child.id.includes('canopy'))!; + expect(canopy.props.doubleSided).toBe(true); + expect(canopy.props.parameters).toMatchObject({cullMode: 'none', depthWriteEnabled: false}); + expect(canopy.props.windStrength).toBe(0.025); + expect(canopy.props.windTime).toBe(2); + const changed = createLayer(data, { + ...common, + _subLayerProps: { + ...common._subLayerProps, + 'canopy-palm-foliage-3': { + doubleSided: false, + windStrength: 0.05, + windTime: 3, + shadowEnabled: false, + parameters: {cullMode: 'back'} + } + } + }) + .layer.renderLayers() + .find(child => child.id.includes('canopy'))!; + expect(changed.props.doubleSided).toBe(false); + expect(changed.props.parameters).toMatchObject({cullMode: 'back', depthWriteEnabled: false}); + expect(changed.props.windStrength).toBe(0.05); + expect(changed.props.windTime).toBe(3); + expect(changed.props.shadowEnabled).toBe(false); + }); +}); + +describe('TreeLayer prepared data and crop contracts', () => { + it('does not regroup data or rebuild crop instances for wind clock or color-only updates', () => { + const {layer} = createLayer([OAK]); + const state = layer.state; + const groups = state.groups; + (layer.setState as any).mockClear(); + layer.updateState({ + props: Object.assign(Object.create(layer.props), {windTime: 25, windStrength: 0.03}), + oldProps: layer.props, + changeFlags: {propsChanged: true} + }); + expect(layer.setState).not.toHaveBeenCalled(); + expect(layer.state.groups).toBe(groups); + }); + it('uses branching geometry in winter while evergreens retain their crown', () => { + const {layer} = createLayer([ + {...OAK, season: 'winter'}, + {position: [1, 0], type: 'pine', season: 'winter'} + ]); + expect([...layer.state.groups.keys()]).toContain('oak-winter-3'); + expect([...layer.state.groups.keys()]).toContain('pine-foliage-3'); + }); + it('keeps crop placement stable when count grows, keeps drops above ground, and preserves owner picking', () => { + const crop = {count: 8, droppedCount: 5, radius: 0.2, color: [200, 80, 40, 255]}; + const {layer} = createLayer([OAK], {getCrop: () => crop}); + const prefix = layer.state.liveCrops.map(point => point.translation); + const expanded = createLayer([OAK], {getCrop: () => ({...crop, count: 16})}).layer; + expect(expanded.state.liveCrops.slice(0, 8).map(point => point.translation)).toEqual(prefix); + expect(expanded.state.droppedCrops.map(point => point.translation)).toEqual( + layer.state.droppedCrops.map(point => point.translation) + ); + for (const point of layer.state.droppedCrops) expect(point.translation[2]).toBe(point.radius); + const info = layer.getPickingInfo({info: {object: layer.state.liveCrops[0], index: 0}} as any); + expect(info.object).toBe(OAK); + expect(info.index).toBe(0); + }); + it('respects accessor update triggers and mixed seasons', () => { + const datum = {...OAK}; + const {layer} = createLayer([datum]); + datum.season = 'winter'; + layer.updateState({ + props: layer.props, + oldProps: layer.props, + changeFlags: {updateTriggersChanged: {getSeason: true}} + }); + expect([...layer.state.groups.keys()]).toEqual(['oak-winter-3']); + }); + it('places pine crop points on a nonzero stable tier surface', () => { + for (const levels of [1, 2, 3, 4, 5]) { + const point = samplePineSurface(levels, 0.35, 0.8); + expect(Math.hypot(point[0], point[1])).toBeGreaterThan(0.1); + expect(point[2]).toBe(0.35); + expect(samplePineSurface(levels, 0.35, 0.8)).toEqual(point); + } + }); + it('samples crop points on the visible union envelope', () => { + const point = sampleCrownSurface('oak', [1, 0, 0]); + expect(point[0]).toBeGreaterThan(0.4); + expect(point[2]).toBe(0.5); + }); +}); diff --git a/modules/three/test/webgpu-layers.browser.spec.ts b/modules/layers/test/tree-layer/webgpu-layers.browser.spec.ts similarity index 84% rename from modules/three/test/webgpu-layers.browser.spec.ts rename to modules/layers/test/tree-layer/webgpu-layers.browser.spec.ts index 7eb48f906..72db6089f 100644 --- a/modules/three/test/webgpu-layers.browser.spec.ts +++ b/modules/layers/test/tree-layer/webgpu-layers.browser.spec.ts @@ -8,7 +8,7 @@ import {webgl2Adapter} from '@luma.gl/webgl'; import {webgpuAdapter} from '@luma.gl/webgpu'; import {describe, expect, it} from 'vitest'; -import {TreeLayer} from '../src'; +import {TreeLayer} from '../../src'; type BrowserGpu = {requestAdapter: () => Promise}; type NativeGpuError = {error?: {message?: string}}; @@ -58,9 +58,19 @@ async function renderTreeLayer(type: 'webgl' | 'webgpu'): Promise { layers: [ new TreeLayer({ id: `tree-${type}`, - data: [{position: [0, 0] as [number, number]}], + data: (['pine', 'oak', 'palm', 'birch', 'cherry'] as const).flatMap((species, i) => + (['summer', 'winter'] as const).map(season => ({ + position: [i * 0.00002, 0] as [number, number], + species, + season + })) + ), getPosition: datum => datum.position, - getTreeType: () => 'oak', + getTreeType: datum => datum.species, + getSeason: datum => datum.season, + getCrop: () => ({color: [210, 70, 40, 255], count: 2, droppedCount: 1, radius: 0.15}), + windStrength: 0.025, + windTime: 1.25, getHeight: () => 12, pickable: true }) diff --git a/modules/playground/src/schemas/community-geo-layers.ts b/modules/playground/src/schemas/community-geo-layers.ts index afcfb92ad..d75debe07 100644 --- a/modules/playground/src/schemas/community-geo-layers.ts +++ b/modules/playground/src/schemas/community-geo-layers.ts @@ -189,7 +189,10 @@ export const TreeLayerPropsSchema = CompositeLayerPropsSchema.extend({ getSeason: FunctionSchema.optional(), getBranchLevels: FunctionSchema.optional(), getCrop: FunctionSchema.optional(), - sizeScale: z.number().nonnegative().optional() + sizeScale: z.number().nonnegative().optional(), + windStrength: z.number().nonnegative().optional(), + windTime: z.number().nullable().optional(), + shadowEnabled: z.boolean().optional() }); /** JSON representation of TileSourceLayer. */ diff --git a/modules/playground/test/community-geo-schemas.spec.ts b/modules/playground/test/community-geo-schemas.spec.ts index 96b2a86ca..89235dfc7 100644 --- a/modules/playground/test/community-geo-schemas.spec.ts +++ b/modules/playground/test/community-geo-schemas.spec.ts @@ -88,6 +88,12 @@ for (const [name, schema] of Object.entries(CommunityGeoLayerSchemas)) { expect(validate(bad)).toBe(false); expect(schema.safeParse({...good, typoProperty: true}).success).toBe(false); expect(validate({...good, typoProperty: true})).toBe(false); + if (name === 'TreeLayer') { + for (const detail of ['low', 'medium', 'high']) { + expect(schema.safeParse({...good, detail}).success).toBe(false); + expect(validate({...good, detail})).toBe(false); + } + } }); } diff --git a/modules/three/README.md b/modules/three/README.md deleted file mode 100644 index e5f8a1311..000000000 --- a/modules/three/README.md +++ /dev/null @@ -1,190 +0,0 @@ -# @deck.gl-community/three - -[![NPM Version](https://img.shields.io/npm/v/@deck.gl-community/three.svg)](https://www.npmjs.com/package/@deck.gl-community/three) -[![NPM Downloads](https://img.shields.io/npm/dw/@deck.gl-community/three.svg)](https://www.npmjs.com/package/@deck.gl-community/three) -![WebGPU supported](https://img.shields.io/badge/webgpu-yes-green.svg?style=flat-square) - -A collection of deck.gl layers powered by [Three.js](https://threejs.org/), giving access to Three.js geometry primitives and scene graph tooling directly inside deck.gl visualisations. - -`TreeLayer` is the first layer in this module — a fully parametric 3D tree renderer backed by Three.js `BufferGeometry` and rendered via deck.gl's `SimpleMeshLayer`. - -## Layers - -| Layer | Description | -|-------|-------------| -| [`TreeLayer`](#treelayer) | Procedural 3D trees with 5 species silhouettes, season colours, and crop/fruit visualisation | - ---- - -## TreeLayer - -Renders richly configurable 3D trees at geographic positions using procedural geometry generated with Three.js `BufferGeometry` primitives. - -### Features - -- **5 tree species / silhouettes**: pine (tiered cones), oak (sphere), palm (ring-scarred trunk and pinnate fronds), birch (narrow oval), cherry (round sphere) -- **Organic canopy geometry**: smooth low-frequency vertex jitter baked into each species mesh at init time — no runtime cost, no mesh gaps -- **Per-tree variety**: position-derived random bearing and asymmetric XY scale give every instance a unique silhouette with zero extra draw calls -- **Parametric geometry**: per-instance height, trunk-to-canopy ratio, trunk radius, canopy radius -- **Season-driven colours**: spring / summer / autumn / winter palettes with species-specific defaults -- **Explicit colour overrides**: `getTrunkColor` and `getCanopyColor` accessors for full control -- **Pine tier density**: `getBranchLevels` (1–5) controls the number of overlapping cone tiers; each tier drifts progressively for a windswept look -- **Crop / fruit / flower visualisation**: `getCrop` places coloured spheres in the outer canopy volume and scattered on the ground beneath the tree -- **Global scale factor**: `sizeScale` multiplier for easy zoom-level adjustment - -## Install - -```bash -npm install @deck.gl-community/three -# or -yarn add @deck.gl-community/three -``` - -> Three.js is a peer dependency pulled in automatically. - -## Usage - -```tsx -import {TreeLayer} from '@deck.gl-community/three'; -import type {CropConfig} from '@deck.gl-community/three'; - -const layer = new TreeLayer({ - id: 'trees', - data: myForestData, - getPosition: d => d.coordinates, - getTreeType: d => d.species, // 'pine' | 'oak' | 'palm' | 'birch' | 'cherry' - getHeight: d => d.heightMetres, - getTrunkRadius: d => d.trunkRadius, - getCanopyRadius: d => d.canopyRadius, - getTrunkHeightFraction: d => 0.35, - getSeason: d => 'autumn', - getCrop: d => d.crop, // CropConfig | null - sizeScale: 1, - pickable: true, -}); -``` - -### Crop / fruit / flower example - -```tsx -import type {CropConfig} from '@deck.gl-community/three'; - -// Orange orchard -const citrusCrop: CropConfig = { - color: [255, 140, 0, 255], // orange - count: 30, // live fruits in canopy - droppedCount: 10, // fallen fruits on ground (rendered at 45% opacity) - radius: 0.12, // metres per fruit sphere (scaled by sizeScale) -}; - -// Cherry blossom (flowering stage — just use flower colour) -const blossomCrop: CropConfig = { - color: [255, 200, 220, 200], - count: 25, - droppedCount: 8, - radius: 0.07, -}; -``` - -Crop positions are seeded deterministically from each tree's geographic coordinates, so they are stable across re-renders and sizeScale changes. - ---- - -## TreeLayer Props - -### Geometry - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `getPosition` | accessor → `[lon, lat]` | `d.position` | Tree base position | -| `getElevation` | accessor → `number` | `0` | Base elevation in metres | -| `getTreeType` | accessor → `TreeType` | `'pine'` | Silhouette variant | -| `getHeight` | accessor → `number` | `10` | Total height (metres) | -| `getTrunkHeightFraction` | accessor → `number` | `0.35` | Fraction of total height occupied by trunk (0–1) | -| `getTrunkRadius` | accessor → `number` | `0.5` | Trunk base radius (metres) | -| `getCanopyRadius` | accessor → `number` | `3` | Canopy horizontal radius (metres) | -| `getBranchLevels` | accessor → `number` | `3` | Pine tier count (1–5) | -| `sizeScale` | `number` | `1` | Global size multiplier applied to all dimensions | - -### Colour - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `getTrunkColor` | accessor → `Color\|null` | `null` | Explicit trunk RGBA; `null` uses species default | -| `getCanopyColor` | accessor → `Color\|null` | `null` | Explicit canopy RGBA; `null` uses season default | -| `getSeason` | accessor → `Season` | `'summer'` | Drives default canopy colour when no explicit colour is given | - -### Crops - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `getCrop` | accessor → `CropConfig\|null` | `null` | Crop configuration per tree. `null` renders no crops | - ---- - -## Types - -### `TreeType` - -```ts -type TreeType = 'pine' | 'oak' | 'palm' | 'birch' | 'cherry'; -``` - -### `Season` - -```ts -type Season = 'spring' | 'summer' | 'autumn' | 'winter'; -``` - -### `CropConfig` - -Configuration for crop/fruit/flower visualisation on a single tree. - -```ts -type CropConfig = { - /** Colour of each crop sphere [r, g, b, a]. */ - color: Color; - - /** Number of crop spheres placed in the outer canopy volume (live / in-tree). */ - count: number; - - /** - * Number of crop spheres scattered on the ground within the canopy footprint - * (dropped / fallen). Rendered at ~45 % opacity relative to `color`. - * @default 0 - */ - droppedCount?: number; - - /** - * Radius of each individual crop sphere in metres (scaled by `sizeScale`). - * Typical values: 0.06–0.15 m for fruit, 0.05–0.10 m for nuts/blossoms. - */ - radius: number; -}; -``` - -#### Crop placement details - -- **Live crops** are placed on the outer 90–102 % of the canopy ellipsoid surface (equatorial band only — never on the crown or base) so they appear nestled into the canopy with tips just visible. -- **Dropped crops** are scattered uniformly across the ground disk within the canopy footprint at a fixed slight elevation (0.05 m), rendered semi-transparent. -- All positions are derived deterministically from the tree's geographic coordinates via a seeded PRNG, so they are stable across re-renders and `sizeScale` changes. - ---- - -## Default canopy colours - -| Species | Spring | Summer | Autumn | Winter | -|---------|--------|--------|--------|--------| -| pine | `[34, 100, 34]` | `[0, 64, 0]` | `[0, 64, 0]` | `[0, 55, 0]` | -| oak | `[100, 180, 80]` | `[34, 120, 15]` | `[180, 85, 20]` | `[100, 80, 60]` (α 160) | -| palm | `[50, 160, 50]` | `[20, 145, 20]` | `[55, 150, 30]` | `[40, 130, 30]` | -| birch | `[150, 210, 110]` | `[80, 160, 60]` | `[230, 185, 40]` | `[180, 180, 170]` (α 90) | -| cherry | `[255, 180, 205]` | `[50, 140, 50]` | `[200, 60, 40]` | `[120, 90, 80]` (α 110) | - ---- - -## Seasonal Farm example - -Explore seven labelled plots with seasonal trees and hover details in the -[Seasonal Farm demo](https://visgl.github.io/deck.gl-community/examples/three/seasonal-farm). See the -[example README](../../examples/three/seasonal-farm/README.md) for setup and data details. diff --git a/modules/three/package.json b/modules/three/package.json deleted file mode 100644 index 14e816c40..000000000 --- a/modules/three/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@deck.gl-community/three", - "version": "9.4.2", - "description": "Three.js-powered layers for deck.gl", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/visgl/deck.gl-community.git" - }, - "publishConfig": { - "access": "public" - }, - "keywords": [ - "layers", - "visualization", - "3d", - "threejs", - "trees", - "deck.gl" - ], - "type": "module", - "sideEffects": false, - "types": "./dist/index.d.ts", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.cjs", - "import": "./dist/index.js" - } - }, - "files": [ - "dist", - "src" - ], - "scripts": { - "test": "vitest run", - "test-watch": "vitest" - }, - "dependencies": { - "three": "^0.170.0" - }, - "peerDependencies": { - "@deck.gl/core": "~9.4.0", - "@deck.gl/mesh-layers": "~9.4.0" - }, - "devDependencies": { - "@types/three": "^0.170.0" - } -} diff --git a/modules/three/src/index.ts b/modules/three/src/index.ts deleted file mode 100644 index 67cc5312f..000000000 --- a/modules/three/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// deck.gl-community -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - -export type {TreeLayerProps, TreeType, Season, CropConfig} from './tree-layer/tree-layer'; -export {TreeLayer} from './tree-layer/tree-layer'; diff --git a/modules/three/test/tree-layer.spec.ts b/modules/three/test/tree-layer.spec.ts deleted file mode 100644 index 14c25597a..000000000 --- a/modules/three/test/tree-layer.spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -// deck.gl-community -// SPDX-License-Identifier: MIT -// Copyright (c) vis.gl contributors - -import {describe, it, expect, vi} from 'vitest'; -import {TreeLayer} from '../src/index'; -import { - createTrunkMesh, - createDatePalmTrunkMesh, - createPineCanopyMesh, - createOakCanopyMesh, - createPalmCanopyMesh, - createBirchCanopyMesh, - createCherryCanopyMesh -} from '../src/tree-layer/tree-geometry'; - -describe('TreeLayer', () => { - it('reuses pine meshes across scale and crop updates while removing unused tiers', () => { - const layer = new TreeLayer({ - data: [{position: [0, 0], levels: 2}], - getBranchLevels: d => d.levels - }); - layer.initializeState(); - vi.spyOn(layer, 'setState').mockImplementation(state => { - Object.assign(layer.state, state); - }); - const update = (overrides = {}) => - layer.updateState({ - props: Object.assign(Object.create(layer.props), overrides), - oldProps: layer.props, - changeFlags: {propsChanged: true} - }); - update(); - const mesh = layer.state.pineMeshes[2]; - update({sizeScale: 40}); - expect(layer.state.pineMeshes[2]).toBe(mesh); - update({getCrop: () => null}); - expect(layer.state.pineMeshes[2]).toBe(mesh); - update({data: [{position: [0, 0], levels: 4}]}); - expect(Object.keys(layer.state.pineMeshes)).toEqual(['4']); - }); - - it('exports TreeLayer', () => { - expect(TreeLayer).toBeTruthy(); - expect(TreeLayer.layerName).toBe('TreeLayer'); - }); - - it('has correct default props', () => { - const defaults = TreeLayer.defaultProps as Record; - expect(defaults.sizeScale.value).toBe(1); - expect(typeof defaults.getPosition.value).toBe('function'); - expect(typeof defaults.getTreeType.value).toBe('function'); - expect(defaults.getTreeType.value({})).toBe('pine'); - expect(defaults.getHeight.value({})).toBe(10); - expect(defaults.getSeason.value({})).toBe('summer'); - }); - - it('extends palm trunks into the crown', () => { - const datum = {position: [0, 0] as [number, number]}; - const layer = new TreeLayer({ - id: 'palm-crown-connection', - data: [datum], - getTreeType: () => 'palm', - getHeight: () => 10, - getTrunkHeightFraction: () => 0.8, - getTrunkRadius: () => 1 - }); - - layer.state = { - grouped: {pine: [], oak: [], palm: [datum], birch: [], cherry: []}, - pineMeshes: {}, - liveCropPoints: [], - droppedCropPoints: [] - } as typeof layer.state; - - const palmTrunk = layer.renderLayers().find(subLayer => subLayer.id.endsWith('trunks-palm')); - const scale = palmTrunk?.props.getScale(datum); - - expect(scale).toEqual([1, 1, 8.48]); - }); -}); - -describe('tree geometry generators', () => { - it('pine tiers point upward and trunks taper toward the canopy', () => { - for (const mesh of [createPineCanopyMesh(1), createTrunkMesh()]) { - const positions = mesh.attributes.POSITION.value; - let bottomRadius = 0; - let topRadius = 0; - let maxZ = 0; - for (let index = 2; index < positions.length; index += 3) { - maxZ = Math.max(maxZ, positions[index]); - } - for (let index = 0; index < positions.length; index += 3) { - const radius = Math.hypot(positions[index], positions[index + 1]); - if (positions[index + 2] < maxZ * 0.2) bottomRadius = Math.max(bottomRadius, radius); - if (positions[index + 2] > maxZ * 0.8) topRadius = Math.max(topRadius, radius); - } - expect(topRadius).toBeLessThan(bottomRadius); - } - }); - - it('createTrunkMesh returns valid mesh', () => { - const mesh = createTrunkMesh(); - expect(mesh.topology).toBe('triangle-list'); - expect(mesh.mode).toBe(4); - expect(mesh.attributes.POSITION.value.length).toBeGreaterThan(0); - expect(mesh.attributes.NORMAL.value.length).toBeGreaterThan(0); - expect(mesh.attributes.POSITION.size).toBe(3); - expect(mesh.attributes.NORMAL.size).toBe(3); - expect(mesh.indices.value.length).toBeGreaterThan(0); - }); - - it('createPineCanopyMesh returns valid mesh for different levels', () => { - for (const levels of [1, 3, 5]) { - const mesh = createPineCanopyMesh(levels); - expect(mesh.topology).toBe('triangle-list'); - expect(mesh.attributes.POSITION.value.length).toBeGreaterThan(0); - } - }); - - it('all canopy generators return valid meshes', () => { - const generators = [ - createOakCanopyMesh, - createPalmCanopyMesh, - createBirchCanopyMesh, - createCherryCanopyMesh - ]; - for (const gen of generators) { - const mesh = gen(); - expect(mesh.topology).toBe('triangle-list'); - expect(mesh.attributes.POSITION.value.length).toBeGreaterThan(0); - expect(mesh.indices.value.length).toBeGreaterThan(0); - } - }); - - it('trunk mesh vertices are Z-up (base near z=0, tip near z=1)', () => { - const mesh = createTrunkMesh(); - const positions = mesh.attributes.POSITION.value; - let minZ = Infinity; - let maxZ = -Infinity; - for (let i = 2; i < positions.length; i += 3) { - minZ = Math.min(minZ, positions[i]); - maxZ = Math.max(maxZ, positions[i]); - } - expect(minZ).toBeGreaterThanOrEqual(-0.01); - expect(maxZ).toBeCloseTo(1, 1); - }); - - it('palm trunk adds leaf-scar detail without changing unit bounds', () => { - const basicTrunk = createTrunkMesh(); - const palmTrunk = createDatePalmTrunkMesh(); - const positions = palmTrunk.attributes.POSITION.value; - let minZ = Infinity; - let maxZ = -Infinity; - - for (let i = 2; i < positions.length; i += 3) { - minZ = Math.min(minZ, positions[i]); - maxZ = Math.max(maxZ, positions[i]); - } - - expect(positions.length).toBeGreaterThan(basicTrunk.attributes.POSITION.value.length * 4); - expect(minZ).toBeGreaterThanOrEqual(-0.01); - expect(maxZ).toBeLessThanOrEqual(1.01); - }); - - it('palm crown has a dense radial, pinnate silhouette', () => { - const mesh = createPalmCanopyMesh(); - const positions = mesh.attributes.POSITION.value; - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - let minZ = Infinity; - let maxZ = -Infinity; - - for (let i = 0; i < positions.length; i += 3) { - minX = Math.min(minX, positions[i]); - maxX = Math.max(maxX, positions[i]); - minY = Math.min(minY, positions[i + 1]); - maxY = Math.max(maxY, positions[i + 1]); - minZ = Math.min(minZ, positions[i + 2]); - maxZ = Math.max(maxZ, positions[i + 2]); - } - - expect(positions.length / 3).toBeGreaterThan(4000); - expect(minX).toBeLessThan(-0.9); - expect(maxX).toBeGreaterThan(0.9); - expect(minY).toBeLessThan(-0.9); - expect(maxY).toBeGreaterThan(0.9); - expect(minZ).toBeGreaterThan(0); - expect(maxZ).toBeGreaterThan(0.95); - }); - - it('palm geometry is deterministic', () => { - const first = createPalmCanopyMesh(); - const second = createPalmCanopyMesh(); - - expect(second.attributes.POSITION.value).toEqual(first.attributes.POSITION.value); - expect(second.indices.value).toEqual(first.indices.value); - }); -}); diff --git a/modules/three/tsconfig.json b/modules/three/tsconfig.json deleted file mode 100644 index 175b131b2..000000000 --- a/modules/three/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src/**/*"], - "exclude": ["node_modules"], - "compilerOptions": { - "composite": true, - "rootDir": "src", - "outDir": "dist", - "noEmit": false - } -} diff --git a/tsconfig.json b/tsconfig.json index baa0a6627..cc965c58f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,7 +39,6 @@ "@deck.gl-community/react-fiber/*": ["./modules/react-fiber/src/*"], "@deck.gl-community/template": ["./modules/template/src"], "@deck.gl-community/graph-layers": ["./modules/graph-layers/src"], - "@deck.gl-community/three": ["./modules/three/src"], "@deck.gl-community/timeline-layers": ["./modules/timeline-layers/src"] }, "typeRoots": ["./node_modules/@types"], diff --git a/vitest.config.ts b/vitest.config.ts index 7ff571ff2..4fa85a730 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,10 +20,6 @@ const ALIASES = [ find: /^@deck\.gl-community\/playground$/, replacement: fileURLToPath(new URL('./modules/playground/src/index.ts', import.meta.url)) }, - { - find: '@deck.gl-community/three', - replacement: fileURLToPath(new URL('./modules/three/src/index.ts', import.meta.url)) - }, { find: '@deck.gl-community/basemap-layers/style-spec', replacement: fileURLToPath( diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index ac1014a3c..9427b0acf 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -67,6 +67,21 @@ const config = { ], plugins: [ + [ + '@docusaurus/plugin-client-redirects', + { + redirects: [ + { + from: ['/docs/modules/three', '/docs/modules/three/api-reference/tree-layer'], + to: '/docs/modules/layers/api-reference/tree-layer' + }, + { + from: '/examples/three/seasonal-farm', + to: '/examples/layers/seasonal-farm' + } + ] + } + ], // Improve build performance by disabling expensive optimizations // https://github.com/facebook/docusaurus/discussions/11199 function disableExpensiveBundlerOptimizationPlugin() { @@ -99,7 +114,6 @@ const config = { '@deck.gl-community/graph-layers': resolve('../modules/graph-layers/src'), '@deck.gl-community/infovis-layers': resolve('../modules/infovis-layers/src'), '@deck.gl-community/timeline-layers': resolve('../modules/timeline-layers/src'), - '@deck.gl-community/three': resolve('../modules/three/src'), '@deck.gl-community/react': resolve('../modules/react/src'), '@deck.gl-community/react-fiber': resolve('../modules/react-fiber/src/dom'), '@deck.gl-community/layers': resolve('../modules/layers/src'), @@ -172,7 +186,6 @@ const config = { test: /\.[jt]sx?$/, include: [ resolve('../modules/panels/src'), - resolve('../modules/three/src'), resolve('../modules/widgets/src'), resolve('../modules/editable-layers/src') ], @@ -201,7 +214,7 @@ const config = { // Modules using TypeScript `declare` class fields need allowDeclareFields { test: /\.[jt]sx?$/, - include: [resolve('../modules/three/src'), resolve('../modules/arrow-layers/src')], + include: [resolve('../modules/layers/src'), resolve('../modules/arrow-layers/src')], use: [ { loader: require.resolve('babel-loader'), diff --git a/website/package.json b/website/package.json index ed47ae7a1..5be5323ab 100644 --- a/website/package.json +++ b/website/package.json @@ -85,6 +85,7 @@ "@docusaurus/core": "^3.10.2", "@docusaurus/faster": "^3.10.2", "@docusaurus/module-type-aliases": "^3.10.2", + "@docusaurus/plugin-client-redirects": "^3.10.2", "@docusaurus/plugin-content-docs": "^3.10.2", "@docusaurus/preset-classic": "^3.10.2", "@docusaurus/tsconfig": "^3.10.2", diff --git a/website/src/components/docs/layer-live-example.jsx b/website/src/components/docs/layer-live-example.jsx index 8324435aa..7a64992f6 100644 --- a/website/src/components/docs/layer-live-example.jsx +++ b/website/src/components/docs/layer-live-example.jsx @@ -170,7 +170,7 @@ async function mountLayerDocsExample(container, highlight, mountProps = {}) { return mountGettingStartedExample(container, {showControlsWidget: false, ...mountProps}); } case 'tree-layer': { - const {mountSeasonalFarmExample} = await import('../../../../examples/three/seasonal-farm/app'); + const {mountSeasonalFarmExample} = await import('../../../../examples/layers/seasonal-farm/app'); return mountSeasonalFarmExample(container, mountProps); } case 'horizon-graph-layer': { diff --git a/website/src/components/docs/webgpu-support.js b/website/src/components/docs/webgpu-support.js index d7c69bfaa..b2b1cf393 100644 --- a/website/src/components/docs/webgpu-support.js +++ b/website/src/components/docs/webgpu-support.js @@ -15,11 +15,11 @@ const SUPPORTED_DOC_IDS = new Set([ 'modules/layers/api-reference/path-marker-layer', 'modules/layers/api-reference/path-outline-layer', 'modules/layers/api-reference/skybox-layer', + 'modules/layers/api-reference/tree-layer', 'modules/timeline-layers/api-reference/horizon-graph-layer', 'modules/timeline-layers/api-reference/multi-horizon-graph-layer', 'modules/timeline-layers/api-reference/time-axis-layer', 'modules/timeline-layers/api-reference/vertical-grid-layer', - 'modules/three/api-reference/tree-layer', ]); const UNSUPPORTED_DOC_IDS = new Set([ @@ -61,7 +61,6 @@ const MODULE_STATUS = { leaflet: 'unsupported', panels: 'not-applicable', react: 'not-applicable', - three: 'supported', 'timeline-layers': 'supported', widgets: 'supported' }; diff --git a/website/src/docs-sidebar.js b/website/src/docs-sidebar.js index b8c87f83c..e267780d7 100644 --- a/website/src/docs-sidebar.js +++ b/website/src/docs-sidebar.js @@ -17,7 +17,6 @@ const timelineLayerDocs = require('../../docs/modules/timeline-layers/sidebar.js const graphLayerDocs = require('../../docs/modules/graph-layers/sidebar.json'); const editableLayerDocs = require('../../docs/modules/editable-layers/sidebar.json'); // const arrowLayerDocs = require('../../docs/modules/arrow-layers/sidebar.json'); -const threeModuleDocs = require('../../docs/modules/three/sidebar.json'); const geoLayerDocs = require('../../docs/modules/geo-layers/sidebar.json'); @@ -58,8 +57,7 @@ const sidebars = { timelineLayerDocs, graphLayerDocs, geoLayerDocs, - editableLayerDocs, - threeModuleDocs + editableLayerDocs // arrowLayerDocs, ] }, diff --git a/website/src/examples-sidebar.js b/website/src/examples-sidebar.js index ba3c546b0..9f26720d3 100644 --- a/website/src/examples-sidebar.js +++ b/website/src/examples-sidebar.js @@ -44,6 +44,9 @@ const sidebars = { items: [ 'layers/path-outline-and-markers', 'layers/flame-trail', + 'layers/tree-lab', + 'layers/tree-forest', + 'layers/seasonal-farm', 'layers/skybox-map-view', 'layers/skybox-globe', 'layers/skybox-first-person' @@ -59,11 +62,6 @@ const sidebars = { label: '@deck.gl-community/basemap-layers', items: ['layers/basemap-layer-map-view'] }, - { - type: 'category', - label: '@deck.gl-community/three', - items: ['three/seasonal-farm'] - }, { type: 'category', label: '@deck.gl-community/timeline-layers', diff --git a/website/src/examples/index.mdx b/website/src/examples/index.mdx index de2f5e183..3fa4acc1e 100644 --- a/website/src/examples/index.mdx +++ b/website/src/examples/index.mdx @@ -1,7 +1,8 @@ import {ExamplesIndex} from '../components'; export const thumbnailOverrides = { - 'three/seasonal-farm': '/images/maps.jpg', + 'layers/seasonal-farm': '/images/maps.jpg', + 'layers/tree-forest': '/images/examples/layers/tree-forest.png', 'layers/path-outline-and-markers': '/images/examples/layers/path-outline-and-markers.svg', 'layers/skybox-map-view': '/images/examples/layers/skybox-map-view.png', 'layers/skybox-globe': '/images/examples/layers/skybox-globe.png', @@ -38,6 +39,7 @@ export const thumbnailOverrides = { }; export const descriptionOverrides = { + 'layers/tree-forest': 'Explore 10,000 or 20,000 mixed trees with seasons, moving sunlight, wind and shadows.', playground: 'Build and preview deck.gl layers and views from a JSON-driven playground.', 'geo-layers/global-grid-layer': 'Compare H3, A5, Geohash, Quadkey, and S2 cells while testing antimeridian handling.', @@ -59,7 +61,7 @@ export const descriptionOverrides = { 'layers/basemap-layer-map-view': 'Switch basemap styles while validating a standard MapView render path.', 'infovis-layers/layer-primitives': 'Explore reusable information-visualization primitives for blocks, arrows, and linked marks.', - 'three/seasonal-farm': 'Tour a seasonal farm scene populated with varied three-dimensional trees.', + 'layers/seasonal-farm': 'Tour a seasonal farm scene populated with varied three-dimensional trees.', 'timeline-layers/horizon-graph-layer': 'Encode a time series as a compact horizon graph with stacked bands.', 'timeline-layers/multi-horizon-graph-layer': 'Compare several horizon graphs in a coordinated timeline view.', 'editable-layers/getting-started': 'Start editing GeoJSON features with the community editable-layer tools.', diff --git a/website/src/examples/three/seasonal-farm.mdx b/website/src/examples/layers/seasonal-farm.mdx similarity index 100% rename from website/src/examples/three/seasonal-farm.mdx rename to website/src/examples/layers/seasonal-farm.mdx diff --git a/website/src/examples/three/seasonal-farm.tsx b/website/src/examples/layers/seasonal-farm.tsx similarity index 81% rename from website/src/examples/three/seasonal-farm.tsx rename to website/src/examples/layers/seasonal-farm.tsx index 988b3d1bc..8b8259d88 100644 --- a/website/src/examples/three/seasonal-farm.tsx +++ b/website/src/examples/layers/seasonal-farm.tsx @@ -4,10 +4,10 @@ import {makeImperativeExample} from '../../components'; export default makeImperativeExample( { title: 'Seasonal Farm · TreeLayer', - code: `${GITHUB_TREE}/examples/three/seasonal-farm`, + code: `${GITHUB_TREE}/examples/layers/seasonal-farm`, deviceTabs: {placement: 'top-right'}, async mount(container, props) { - const {mountSeasonalFarmExample} = await import('../../../../examples/three/seasonal-farm/app'); + const {mountSeasonalFarmExample} = await import('../../../../examples/layers/seasonal-farm/app'); return mountSeasonalFarmExample(container, props); } }, diff --git a/website/src/examples/layers/tree-forest.mdx b/website/src/examples/layers/tree-forest.mdx new file mode 100644 index 000000000..d56f67127 --- /dev/null +++ b/website/src/examples/layers/tree-forest.mdx @@ -0,0 +1,8 @@ +--- +title: Tree Forest +hide_title: true +--- + +import Demo from './tree-forest'; + + diff --git a/website/src/examples/layers/tree-forest.tsx b/website/src/examples/layers/tree-forest.tsx new file mode 100644 index 000000000..0a42e4a4a --- /dev/null +++ b/website/src/examples/layers/tree-forest.tsx @@ -0,0 +1,15 @@ +import {GITHUB_TREE} from '../../constants/defaults'; +import {makeImperativeExample} from '../../components'; + +export default makeImperativeExample( + { + title: 'Tree Forest', + deviceTabs: false, + code: `${GITHUB_TREE}/examples/layers/tree-lab`, + async mount(container) { + const {mountTreeForestExample} = await import('../../../../examples/layers/tree-lab/forest'); + return mountTreeForestExample(container); + } + }, + {addInfoPanel: false} +); diff --git a/website/src/examples/layers/tree-lab.mdx b/website/src/examples/layers/tree-lab.mdx new file mode 100644 index 000000000..0d4cf91b3 --- /dev/null +++ b/website/src/examples/layers/tree-lab.mdx @@ -0,0 +1,11 @@ +--- +title: Tree Lab +hide_title: true +--- + +import Demo from './tree-lab'; +import useBaseUrl from '@docusaurus/useBaseUrl'; + +