(`[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.
+
+
${SPECIES.map(species => `
${species[0].toUpperCase() + species.slice(1)} ${DESCRIPTIONS[species]} ${['baseline', 'native'].map(renderer => `
${renderer === 'baseline' ? 'Original / Three.js' : 'Native / vis.gl'} ${renderer === 'baseline' ? 'Baseline 9.4.2' : 'Shared geometry · GPU wind'} `).join('')}
`).join('')}
+ `;
+ 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
Measure 5-second camera orbit Run three samples 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 =
+ 'Sample Median frame p95 frame Draws/s Draw call p95 Long 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 = `
`;
+ 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 memory Procedural scene · drag to explore · 1× pixels Measuring draws…
+ Preparing forest…
`;
+ 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
-
-[](https://www.npmjs.com/package/@deck.gl-community/three)
-[](https://www.npmjs.com/package/@deck.gl-community/three)
-
-
-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';
+
+
+
+
diff --git a/website/src/examples/layers/tree-lab.tsx b/website/src/examples/layers/tree-lab.tsx
new file mode 100644
index 000000000..2e4530df5
--- /dev/null
+++ b/website/src/examples/layers/tree-lab.tsx
@@ -0,0 +1,12 @@
+import {GITHUB_TREE} from '../../constants/defaults';
+import {makeImperativeExample} from '../../components';
+
+export default makeImperativeExample({
+ title: 'Tree Lab',
+ deviceTabs: false,
+ code: `${GITHUB_TREE}/examples/layers/tree-lab`,
+ async mount(container) {
+ const {mountTreeLabExample} = await import('../../../../examples/layers/tree-lab/app');
+ return mountTreeLabExample(container, {scroll: true, benchmarkLinks: false});
+ }
+}, {addInfoPanel: false});
diff --git a/website/static/images/examples/layers/tree-forest.png b/website/static/images/examples/layers/tree-forest.png
new file mode 100644
index 000000000..096575fa7
Binary files /dev/null and b/website/static/images/examples/layers/tree-forest.png differ
diff --git a/website/static/videos/tree-lab-native.mp4 b/website/static/videos/tree-lab-native.mp4
new file mode 100644
index 000000000..c50416028
Binary files /dev/null and b/website/static/videos/tree-lab-native.mp4 differ
diff --git a/website/static/videos/tree-lab-seasons.jpg b/website/static/videos/tree-lab-seasons.jpg
new file mode 100644
index 000000000..69c5d3bb6
Binary files /dev/null and b/website/static/videos/tree-lab-seasons.jpg differ
diff --git a/website/static/videos/tree-lab-shadows.png b/website/static/videos/tree-lab-shadows.png
new file mode 100644
index 000000000..b295ac878
Binary files /dev/null and b/website/static/videos/tree-lab-shadows.png differ
diff --git a/website/yarn.lock b/website/yarn.lock
index 75080f87b..aaf3606d5 100644
--- a/website/yarn.lock
+++ b/website/yarn.lock
@@ -2779,6 +2779,26 @@ __metadata:
languageName: node
linkType: hard
+"@docusaurus/plugin-client-redirects@npm:^3.10.2":
+ version: 3.10.2
+ resolution: "@docusaurus/plugin-client-redirects@npm:3.10.2"
+ dependencies:
+ "@docusaurus/core": "npm:3.10.2"
+ "@docusaurus/logger": "npm:3.10.2"
+ "@docusaurus/utils": "npm:3.10.2"
+ "@docusaurus/utils-common": "npm:3.10.2"
+ "@docusaurus/utils-validation": "npm:3.10.2"
+ eta: "npm:^2.2.0"
+ fs-extra: "npm:^11.1.1"
+ lodash: "npm:^4.17.21"
+ tslib: "npm:^2.6.0"
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ checksum: 10c0/ab5ded6b97561047d35edf7fb6bee7e7bd795a38223873c89824e06597e762628d1ce3e72cf2c8c212c5579406a026bbacb3392d9c3b6dccbcace8239cd48801
+ languageName: node
+ linkType: hard
+
"@docusaurus/plugin-content-blog@npm:3.10.2":
version: 3.10.2
resolution: "@docusaurus/plugin-content-blog@npm:3.10.2"
@@ -15077,6 +15097,7 @@ __metadata:
"@docusaurus/core": "npm:^3.10.2"
"@docusaurus/faster": "npm:^3.10.2"
"@docusaurus/module-type-aliases": "npm:^3.10.2"
+ "@docusaurus/plugin-client-redirects": "npm:^3.10.2"
"@docusaurus/plugin-content-docs": "npm:^3.10.2"
"@docusaurus/preset-classic": "npm:^3.10.2"
"@docusaurus/tsconfig": "npm:^3.10.2"
diff --git a/yarn.lock b/yarn.lock
index f23757935..6a0d0a597 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1168,18 +1168,6 @@ __metadata:
languageName: unknown
linkType: soft
-"@deck.gl-community/three@workspace:*, @deck.gl-community/three@workspace:modules/three":
- version: 0.0.0-use.local
- resolution: "@deck.gl-community/three@workspace:modules/three"
- dependencies:
- "@types/three": "npm:^0.170.0"
- three: "npm:^0.170.0"
- peerDependencies:
- "@deck.gl/core": ~9.4.0
- "@deck.gl/mesh-layers": ~9.4.0
- languageName: unknown
- linkType: soft
-
"@deck.gl-community/timeline-layers@workspace:*, @deck.gl-community/timeline-layers@workspace:modules/timeline-layers":
version: 0.0.0-use.local
resolution: "@deck.gl-community/timeline-layers@workspace:modules/timeline-layers"
@@ -1202,6 +1190,26 @@ __metadata:
languageName: unknown
linkType: soft
+"@deck.gl-community/tree-lab-example@workspace:examples/layers/tree-lab":
+ version: 0.0.0-use.local
+ resolution: "@deck.gl-community/tree-lab-example@workspace:examples/layers/tree-lab"
+ dependencies:
+ "@deck.gl-community/layers": "workspace:*"
+ "@deck.gl/core": "npm:~9.4.0"
+ "@deck.gl/mesh-layers": "npm:~9.4.0"
+ "@luma.gl/core": "npm:~9.4.0"
+ "@luma.gl/engine": "npm:~9.4.0"
+ "@luma.gl/shadertools": "npm:~9.4.0"
+ "@luma.gl/webgl": "npm:~9.4.0"
+ "@luma.gl/webgpu": "npm:~9.4.0"
+ "@math.gl/core": "npm:^4.0.0"
+ "@types/three": "npm:^0.170.0"
+ three: "npm:^0.170.0"
+ typescript: "npm:6.0.3"
+ vite: "npm:^7.3.1"
+ languageName: unknown
+ linkType: soft
+
"@deck.gl-community/widgets@workspace:*, @deck.gl-community/widgets@workspace:modules/widgets":
version: 0.0.0-use.local
resolution: "@deck.gl-community/widgets@workspace:modules/widgets"
@@ -16128,7 +16136,6 @@ __metadata:
"@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": "npm:~9.4.0"
"@deck.gl/core": "npm:~9.4.0"
@@ -17454,11 +17461,11 @@ __metadata:
languageName: node
linkType: hard
-"seasonal-farm-6a8c04@workspace:examples/three/seasonal-farm":
+"seasonal-farm-7257db@workspace:examples/layers/seasonal-farm":
version: 0.0.0-use.local
- resolution: "seasonal-farm-6a8c04@workspace:examples/three/seasonal-farm"
+ resolution: "seasonal-farm-7257db@workspace:examples/layers/seasonal-farm"
dependencies:
- "@deck.gl-community/three": "workspace:*"
+ "@deck.gl-community/layers": "workspace:*"
"@deck.gl/core": "npm:~9.4.0"
"@deck.gl/layers": "npm:~9.4.0"
"@deck.gl/mesh-layers": "npm:~9.4.0"