From f299c6af4ccbe38a2261d625bc55ec27da563ffc Mon Sep 17 00:00:00 2001 From: Ib Green Date: Wed, 18 Mar 2026 09:54:57 -0400 Subject: [PATCH 1/8] feat(mesh-layers): ScenegraphLayer WebGPU support --- .../mesh-layers/scenegraph-layer.md | 1 + docs/developer-guide/webgpu.md | 2 +- examples/website/scenegraph/app.tsx | 4 + .../scenegraph-layer-uniforms.ts | 24 +++- .../scenegraph-layer-vertex.glsl.ts | 5 +- .../src/scenegraph-layer/scenegraph-layer.ts | 43 +++++-- .../scenegraph-layer/scenegraph-layer.wgsl.ts | 118 ++++++++++++++++++ .../scenegraph-pbr-material.ts | 36 ++++++ website/src/examples/scenegraph-layer.js | 4 + 9 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts create mode 100644 modules/mesh-layers/src/scenegraph-layer/scenegraph-pbr-material.ts diff --git a/docs/api-reference/mesh-layers/scenegraph-layer.md b/docs/api-reference/mesh-layers/scenegraph-layer.md index 5cb253a4cb4..2792ea93f31 100644 --- a/docs/api-reference/mesh-layers/scenegraph-layer.md +++ b/docs/api-reference/mesh-layers/scenegraph-layer.md @@ -1,4 +1,5 @@ # ScenegraphLayer +![webgpu](https://img.shields.io/badge/webgpu-supported-blue.svg?style=flat-square) import {ScenegraphLayerDemo} from '@site/src/doc-demos/mesh-layers'; diff --git a/docs/developer-guide/webgpu.md b/docs/developer-guide/webgpu.md index 8e242f51612..ba68dbfb43f 100644 --- a/docs/developer-guide/webgpu.md +++ b/docs/developer-guide/webgpu.md @@ -54,7 +54,7 @@ The table below covers the public layer exports from the layer packages. It is d | `@deck.gl/aggregation-layers` | `GridLayer` | ✅ | ❌ | | `@deck.gl/aggregation-layers` | `HeatmapLayer` | ✅ | ❌ | | `@deck.gl/mesh-layers` | `SimpleMeshLayer` | ✅ | ❌ | -| `@deck.gl/mesh-layers` | `ScenegraphLayer` | ✅ | ❌ | +| `@deck.gl/mesh-layers` | `ScenegraphLayer` | ✅ | ✅ | | `@deck.gl/geo-layers` | `A5Layer` | ✅ | ❌ | | `@deck.gl/geo-layers` | `GreatCircleLayer` | ✅ | ❌ | | `@deck.gl/geo-layers` | `S2Layer` | ✅ | ❌ | diff --git a/examples/website/scenegraph/app.tsx b/examples/website/scenegraph/app.tsx index 4a59d79946d..e254b20326d 100644 --- a/examples/website/scenegraph/app.tsx +++ b/examples/website/scenegraph/app.tsx @@ -11,6 +11,7 @@ import {ScenegraphLayer} from '@deck.gl/mesh-layers'; import type {ScenegraphLayerProps} from '@deck.gl/mesh-layers'; import type {PickingInfo, MapViewState} from '@deck.gl/core'; +import type {Device} from '@luma.gl/core'; // Data provided by the OpenSky Network, https://opensky-network.org const DATA_URL = 'https://opensky-network.org/api/states/all'; @@ -110,10 +111,12 @@ export function useInterval(callback: () => unknown, delay: number) { } export default function App({ + device, sizeScale = 25, onDataLoad, mapStyle = MAP_STYLE }: { + device?: Device; sizeScale?: number; onDataLoad?: (count: number) => void; mapStyle?: string; @@ -178,6 +181,7 @@ export default function App({ return ( , + composeModelMatrix: f32, +}; + +@group(0) @binding(20) +var scenegraph: ScenegraphUniforms; +`; + +const uniformBlockGLSL = /* glsl */ `\ layout(std140) uniform scenegraphUniforms { float sizeScale; float sizeMinPixels; float sizeMaxPixels; mat4 sceneModelMatrix; - bool composeModelMatrix; + float composeModelMatrix; } scenegraph; `; @@ -20,13 +33,14 @@ export type ScenegraphProps = { sizeMinPixels: number; sizeMaxPixels: number; sceneModelMatrix: Matrix4; - composeModelMatrix: boolean; + composeModelMatrix: number; }; export const scenegraphUniforms = { name: 'scenegraph', - vs: uniformBlock, - fs: uniformBlock, + source: uniformBlockWGSL, + vs: uniformBlockGLSL, + fs: uniformBlockGLSL, uniformTypes: { sizeScale: 'f32', sizeMinPixels: 'f32', diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-vertex.glsl.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-vertex.glsl.ts index fa94b0f60d1..f781c7caff7 100644 --- a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-vertex.glsl.ts +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-vertex.glsl.ts @@ -58,9 +58,10 @@ void main(void) { float originalSize = project_size_to_pixel(scenegraph.sizeScale); float clampedSize = clamp(originalSize, scenegraph.sizeMinPixels, scenegraph.sizeMaxPixels); + float sizeRatio = originalSize == 0.0 ? 0.0 : clampedSize / originalSize; - vec3 pos = (instanceModelMatrix * (scenegraph.sceneModelMatrix * vec4(positions, 1.0)).xyz) * scenegraph.sizeScale * (clampedSize / originalSize) + instanceTranslation; - if(scenegraph.composeModelMatrix) { + vec3 pos = (instanceModelMatrix * (scenegraph.sceneModelMatrix * vec4(positions, 1.0)).xyz) * scenegraph.sizeScale * sizeRatio + instanceTranslation; + if(scenegraph.composeModelMatrix > 0.5) { DECKGL_FILTER_SIZE(pos, geometry); // using instancePositions as world coordinates // when using globe mode, this branch does not re-orient the model to align with the surface of the earth diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.ts index 555163c25b6..6ec6c8fa87f 100644 --- a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.ts +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {Layer, project32, picking, log} from '@deck.gl/core'; -import type {Device} from '@luma.gl/core'; +import {Layer, project32, color, picking, log} from '@deck.gl/core'; +import type {Device, RenderPipelineParameters} from '@luma.gl/core'; import {pbrMaterial} from '@luma.gl/shadertools'; import {ScenegraphNode, GroupNode, ModelNode, Model} from '@luma.gl/engine'; import {GLTFAnimator, PBREnvironment, createScenegraphsFromGLTF} from '@luma.gl/gltf'; @@ -15,6 +15,8 @@ import {MATRIX_ATTRIBUTES, shouldComposeModelMatrix} from '../utils/matrix'; import {scenegraphUniforms, ScenegraphProps} from './scenegraph-layer-uniforms'; import vs from './scenegraph-layer-vertex.glsl'; import fs from './scenegraph-layer-fragment.glsl'; +import source from './scenegraph-layer.wgsl'; +import {scenegraphPbrMaterial} from './scenegraph-pbr-material'; import { UpdateParameters, @@ -77,7 +79,11 @@ type _ScenegraphLayerProps = { */ _imageBasedLightingEnvironment?: | PBREnvironment - | ((context: {gl: WebGL2RenderingContext; layer: ScenegraphLayer}) => PBREnvironment); + | ((context: { + device?: Device; + gl?: WebGL2RenderingContext; + layer: ScenegraphLayer; + }) => PBREnvironment); /** Anchor position accessor. */ getPosition?: Accessor; @@ -183,22 +189,26 @@ export default class ScenegraphLayer e getShaders() { const defines: {LIGHTING_PBR?: 1} = {}; let pbr; + const isWebGPU = this.context.device?.type === 'webgpu'; if (this.props._lighting === 'pbr') { - pbr = pbrMaterial; + pbr = isWebGPU ? scenegraphPbrMaterial : pbrMaterial; defines.LIGHTING_PBR = 1; + } else if (isWebGPU) { + pbr = scenegraphPbrMaterial; } else { // Dummy shader module needed to handle // pbrMaterial.pbr_baseColorSampler binding pbr = {name: 'pbrMaterial'}; } - const modules = [project32, picking, scenegraphUniforms, pbr]; - return super.getShaders({defines, vs, fs, modules}); + const modules = [project32, color, picking, scenegraphUniforms, pbr]; + return super.getShaders({defines, vs, fs, source, modules}); } initializeState() { const attributeManager = this.getAttributeManager(); + const supportsTransitions = this.context.device.type !== 'webgpu'; // attributeManager is always defined for primitive layers attributeManager!.addInstanced({ instancePositions: { @@ -206,14 +216,14 @@ export default class ScenegraphLayer e type: 'float64', fp64: this.use64bitPositions(), accessor: 'getPosition', - transition: true + transition: supportsTransitions }, instanceColors: { type: 'unorm8', size: this.props.colorFormat.length, accessor: 'getColor', defaultValue: DEFAULT_COLOR, - transition: true + transition: supportsTransitions }, instanceModelMatrix: MATRIX_ATTRIBUTES }); @@ -346,18 +356,31 @@ export default class ScenegraphLayer e let env: PBREnvironment | undefined; if (_imageBasedLightingEnvironment) { if (typeof _imageBasedLightingEnvironment === 'function') { - env = _imageBasedLightingEnvironment({gl: this.context.gl, layer: this}); + env = _imageBasedLightingEnvironment({ + device: this.context.device, + gl: this.context.gl, + layer: this + }); } else { env = _imageBasedLightingEnvironment; } } + const parameters = + this.context.device.type === 'webgpu' + ? ({ + depthWriteEnabled: true, + depthCompare: 'less-equal' + } satisfies RenderPipelineParameters) + : undefined; + return { imageBasedLightingEnvironment: env, modelOptions: { id: this.props.id, isInstanced: true, bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + parameters, ...this.getShaders() }, // tangents are not supported @@ -389,7 +412,7 @@ export default class ScenegraphLayer e sizeScale, sizeMinPixels, sizeMaxPixels, - composeModelMatrix: shouldComposeModelMatrix(viewport, coordinateSystem), + composeModelMatrix: shouldComposeModelMatrix(viewport, coordinateSystem) ? 1 : 0, sceneModelMatrix: worldMatrix }; diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts new file mode 100644 index 00000000000..eaec7a621ff --- /dev/null +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts @@ -0,0 +1,118 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +export default /* wgsl */ `\ +struct VertexInputs { + @location(0) positions: vec3, +#ifdef HAS_NORMALS + @location(1) normals: vec3, +#endif +#ifdef HAS_UV + @location(3) texCoords: vec2, +#endif + @location(6) instancePositions: vec3, + @location(7) instancePositions64Low: vec3, + @location(8) instanceColors: vec4, + @location(9) instancePickingColors: vec3, + @location(10) instanceModelMatrixCol0: vec3, + @location(11) instanceModelMatrixCol1: vec3, + @location(12) instanceModelMatrixCol2: vec3, + @location(13) instanceTranslation: vec3, +}; + +struct FragmentInputs { + @builtin(position) position: vec4, + @location(0) vColor: vec4, + @location(1) vTexCoord: vec2, + @location(2) pbrPosition: vec3, + @location(3) pbrUV: vec2, + @location(4) pbrNormal: vec3, +}; + +@vertex +fn vertexMain(inputs: VertexInputs) -> FragmentInputs { + var outputs: FragmentInputs; + + geometry.worldPosition = inputs.instancePositions; + geometry.pickingColor = inputs.instancePickingColors; + + var vertexPosition = inputs.positions; + var texCoord = vec2(0.0, 0.0); + var normal = vec3(0.0, 0.0, 1.0); + +#ifdef HAS_UV + texCoord = inputs.texCoords; +#endif +#ifdef HAS_NORMALS + normal = inputs.normals; +#endif + + geometry.uv = texCoord; + + let instanceModelMatrix = mat3x3( + inputs.instanceModelMatrixCol0, + inputs.instanceModelMatrixCol1, + inputs.instanceModelMatrixCol2 + ); + + let scenePosition = (scenegraph.sceneModelMatrix * vec4(vertexPosition, 1.0)).xyz; + let worldNormal = instanceModelMatrix * (scenegraph.sceneModelMatrix * vec4(normal, 0.0)).xyz; + + let originalSize = project_meter_size_to_pixel(scenegraph.sizeScale); + let clampedSize = clamp(originalSize, scenegraph.sizeMinPixels, scenegraph.sizeMaxPixels); + let sizeRatio = select(0.0, clampedSize / originalSize, originalSize > 0.0); + + let pos = + (instanceModelMatrix * scenePosition) * scenegraph.sizeScale * sizeRatio + + inputs.instanceTranslation; + + if (scenegraph.composeModelMatrix > 0.5) { + geometry.normal = project_normal(worldNormal); + geometry.worldPosition = inputs.instancePositions + pos; + geometry.position = vec4( + project_position_vec3_f64(inputs.instancePositions + pos, inputs.instancePositions64Low), + 1.0 + ); + } else { + let sizeAdjustedPos = project_size_vec3(pos); + geometry.position = vec4( + project_position_vec3_f64(inputs.instancePositions, inputs.instancePositions64Low) + + sizeAdjustedPos, + 1.0 + ); + geometry.normal = project_normal(worldNormal); + } + + outputs.position = project_common_position_to_clipspace(geometry.position); + outputs.vColor = inputs.instanceColors; + outputs.vTexCoord = texCoord; + outputs.pbrPosition = geometry.position.xyz; + outputs.pbrUV = texCoord; + outputs.pbrNormal = geometry.normal; + return outputs; +} + +@fragment +fn fragmentMain(inputs: FragmentInputs) -> @location(0) vec4 { + fragmentGeometry.uv = inputs.vTexCoord; + + var fragColor = inputs.vColor; + +#ifdef LIGHTING_PBR + fragmentInputs.pbr_vPosition = inputs.pbrPosition; + fragmentInputs.pbr_vUV = inputs.pbrUV; + fragmentInputs.pbr_vNormal = inputs.pbrNormal; + fragColor = fragColor * pbr_filterColor(vec4(0.0)); +#else +#ifdef HAS_BASECOLORMAP + fragColor = + fragColor * + textureSample(pbr_baseColorSampler, pbr_baseColorSamplerSampler, inputs.vTexCoord); +#endif +#endif + + fragColor.a *= color.opacity; + return deckgl_premultiplied_alpha(fragColor); +} +`; diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-pbr-material.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-pbr-material.ts new file mode 100644 index 00000000000..6daeec4ad56 --- /dev/null +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-pbr-material.ts @@ -0,0 +1,36 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {lighting, pbrMaterial} from '@luma.gl/shadertools'; + +import type { + PBRMaterialBindings, + PBRMaterialProps, + PBRMaterialUniforms, + ShaderModule +} from '@luma.gl/shadertools'; + +const source = (pbrMaterial.source as string) + .replace( + /fn pbr_setPositionNormalTangentUV\([\s\S]*?\n}\n/, + `fn pbr_setPositionNormalTangentUV(position: vec4f, normal: vec4f, tangent: vec4f, uv: vec2f) +{ + fragmentInputs.pbr_vPosition = position.xyz; + fragmentInputs.pbr_vNormal = normal.xyz; + fragmentInputs.pbr_vTBN = mat3x3f( + vec3f(1.0, 0.0, 0.0), + vec3f(0.0, 1.0, 0.0), + vec3f(0.0, 0.0, 1.0) + ); + fragmentInputs.pbr_vUV = uv; +} +` + ) + .replace(/pbrProjection\.camera/g, 'project.cameraPosition'); + +export const scenegraphPbrMaterial = { + ...pbrMaterial, + dependencies: [lighting], + source +} as const satisfies ShaderModule; diff --git a/website/src/examples/scenegraph-layer.js b/website/src/examples/scenegraph-layer.js index 13fb084f8c2..d6a3f042789 100644 --- a/website/src/examples/scenegraph-layer.js +++ b/website/src/examples/scenegraph-layer.js @@ -14,6 +14,8 @@ class ScenegraphDemo extends Component { static code = `${GITHUB_TREE}/examples/website/scenegraph`; + static hasDeviceTabs = true; + static parameters = { sizeScale: {displayName: 'Size', type: 'range', value: 25, step: 5, min: 5, max: 500} }; @@ -42,6 +44,8 @@ class ScenegraphDemo extends Component { return ( this.props.onStateChange({count})} /> From 3a9e376b5272d64a4a7de65ea851146f2e76f34f Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 23 Jul 2026 08:07:03 -0400 Subject: [PATCH 2/8] docs(scenegraph-layer): explain PBR module selection --- .../src/scenegraph-layer/scenegraph-layer.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.ts index 6ec6c8fa87f..a3e7b3f910c 100644 --- a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.ts +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.ts @@ -81,6 +81,7 @@ type _ScenegraphLayerProps = { | PBREnvironment | ((context: { device?: Device; + /** @deprecated Use `device.handle`. */ gl?: WebGL2RenderingContext; layer: ScenegraphLayer; }) => PBREnvironment); @@ -192,13 +193,21 @@ export default class ScenegraphLayer e const isWebGPU = this.context.device?.type === 'webgpu'; if (this.props._lighting === 'pbr') { + // The stock pbrMaterial module supplies the existing GLSL PBR path. + // WebGPU uses a Scenegraph-specific wrapper because its WGSL source must + // adapt the PBR position/normal inputs and camera binding to deck.gl's + // project module. pbr = isWebGPU ? scenegraphPbrMaterial : pbrMaterial; defines.LIGHTING_PBR = 1; } else if (isWebGPU) { + // The flat WGSL path can still sample a glTF base-color texture, so it + // needs the Scenegraph PBR module's sampler bindings even though + // LIGHTING_PBR is not enabled. pbr = scenegraphPbrMaterial; } else { - // Dummy shader module needed to handle - // pbrMaterial.pbr_baseColorSampler binding + // The flat GLSL shader declares pbr_baseColorSampler itself. Register a + // name-only module so the glTF material binding is still routed to that + // sampler without injecting the full PBR lighting module. pbr = {name: 'pbrMaterial'}; } From 441321337c9fc07a6e0bee0d380fc4f8db675ee9 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 23 Jul 2026 08:08:24 -0400 Subject: [PATCH 3/8] fix(examples): tolerate unavailable OpenSky data --- examples/website/scenegraph/app.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/examples/website/scenegraph/app.tsx b/examples/website/scenegraph/app.tsx index e254b20326d..9211d9d14da 100644 --- a/examples/website/scenegraph/app.tsx +++ b/examples/website/scenegraph/app.tsx @@ -8,6 +8,7 @@ import {createRoot} from 'react-dom/client'; import {Map} from 'react-map-gl/maplibre'; import {DeckGL} from '@deck.gl/react'; import {ScenegraphLayer} from '@deck.gl/mesh-layers'; +import {log} from '@deck.gl/core'; import type {ScenegraphLayerProps} from '@deck.gl/mesh-layers'; import type {PickingInfo, MapViewState} from '@deck.gl/core'; @@ -126,7 +127,15 @@ export default function App({ const [, setVersion] = useState(0); // Re-render on data change. const sync = useCallback(async () => { - let newData = await fetchData(abortController.signal); + let newData: Aircraft[]; + try { + newData = await fetchData(abortController.signal); + } catch (error) { + if (!abortController.signal.aborted) { + log.warn('Scenegraph example could not load live OpenSky data', error)(); + } + return; + } // In order to keep the animation smooth we need to always return the same // object at a given index. This function will discard new objects @@ -143,7 +152,7 @@ export default function App({ if (onDataLoad) { onDataLoad(newData.length); } - }, []); + }, [abortController, onDataLoad]); useInterval(sync, REFRESH_TIME_SECONDS * 1000); useEffect(() => () => abortController.abort(), []); From 38405f5a94fb909ed72b8ba13b1eeb620ec39b3c Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 23 Jul 2026 08:50:07 -0400 Subject: [PATCH 4/8] fix(examples): use local scenegraph flight snapshot --- examples/website/scenegraph/README.md | 3 +- examples/website/scenegraph/app.tsx | 104 +++++++++-------------- website/src/examples/scenegraph-layer.js | 4 +- 3 files changed, 45 insertions(+), 66 deletions(-) diff --git a/examples/website/scenegraph/README.md b/examples/website/scenegraph/README.md index cdd9a5ccd5a..e5ff2295859 100644 --- a/examples/website/scenegraph/README.md +++ b/examples/website/scenegraph/README.md @@ -20,7 +20,8 @@ The 3D model is created by `manilov.ap`, modified for this application. See [profile page on sketchfab](https://sketchfab.com/3d-models/boeing747-1a75633f5737462ebc1c7879869f6229), licensed under [Creative Commons](https://creativecommons.org/licenses/by/4.0/). -The real-time flight information is from [Opensky Network](https://opensky-network.org). +The example tries to load live flight data from [Opensky Network](https://opensky-network.org) +and falls back to a bundled snapshot if the API is unavailable. To use your own data, check out the [documentation of ScenegraphLayer](../../../docs/api-reference/mesh-layers/scenegraph-layer.md). diff --git a/examples/website/scenegraph/app.tsx b/examples/website/scenegraph/app.tsx index 9211d9d14da..6cd14f1c401 100644 --- a/examples/website/scenegraph/app.tsx +++ b/examples/website/scenegraph/app.tsx @@ -2,25 +2,25 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -/* global fetch, setInterval, clearInterval */ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +/* global fetch */ +import React, {useEffect, useRef, useState} from 'react'; import {createRoot} from 'react-dom/client'; import {Map} from 'react-map-gl/maplibre'; import {DeckGL} from '@deck.gl/react'; import {ScenegraphLayer} from '@deck.gl/mesh-layers'; import {log} from '@deck.gl/core'; +import sampleData from './all.json'; import type {ScenegraphLayerProps} from '@deck.gl/mesh-layers'; import type {PickingInfo, MapViewState} from '@deck.gl/core'; import type {Device} from '@luma.gl/core'; -// Data provided by the OpenSky Network, https://opensky-network.org +// Live data provided by the OpenSky Network, https://opensky-network.org. +// The API may reject browser requests from other origins, so the bundled +// snapshot below remains the default and fallback data. const DATA_URL = 'https://opensky-network.org/api/states/all'; -// For local debugging -// const DATA_URL = './all.json'; const MODEL_URL = 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/scenegraph-layer/airplane.glb'; -const REFRESH_TIME_SECONDS = 60; const DROP_IF_OLDER_THAN_SECONDS = 120; const ANIMATIONS: ScenegraphLayerProps['_animations'] = { @@ -73,14 +73,23 @@ const DATA_INDEX = { CATEGORY: 17 } as const; -async function fetchData(signal: AbortSignal): Promise { - const resp = await fetch(DATA_URL, {signal}); - const {time, states} = (await resp.json()) as {time: number; states: Aircraft[]}; +function normalizeData({time, states}: {time: number; states: Aircraft[]}): Aircraft[] { // make lastContact timestamp relative to response time - for (const a of states) { - a[DATA_INDEX.LAST_CONTACT] -= time; + return states.map(state => { + const aircraft = [...state] as Aircraft; + aircraft[DATA_INDEX.LAST_CONTACT] -= time; + return aircraft; + }); +} + +const DATA = normalizeData(sampleData as {time: number; states: Aircraft[]}); + +async function fetchData(signal: AbortSignal): Promise { + const response = await fetch(DATA_URL, {signal}); + if (!response.ok) { + throw new Error(`OpenSky request failed with status ${response.status}`); } - return states; + return normalizeData((await response.json()) as {time: number; states: Aircraft[]}); } function getTooltip({object}: PickingInfo) { @@ -95,22 +104,6 @@ function getTooltip({object}: PickingInfo) { ); } -export function useInterval(callback: () => unknown, delay: number) { - const savedCallback = useRef(callback); - - // Update callback. - useEffect(() => { - savedCallback.current = callback; - }, [callback]); - - // Loop. - useEffect(() => { - savedCallback.current(); // Initial call. - const id = setInterval(() => savedCallback.current(), delay); - return () => clearInterval(id); - }, [delay]); -} - export default function App({ device, sizeScale = 25, @@ -122,44 +115,32 @@ export default function App({ onDataLoad?: (count: number) => void; mapStyle?: string; }) { - const [abortController] = useState(new AbortController()); - const dataRef = useRef([]); // Callback requires stable reference to data. - const [, setVersion] = useState(0); // Re-render on data change. - - const sync = useCallback(async () => { - let newData: Aircraft[]; - try { - newData = await fetchData(abortController.signal); - } catch (error) { - if (!abortController.signal.aborted) { - log.warn('Scenegraph example could not load live OpenSky data', error)(); - } - return; - } - - // In order to keep the animation smooth we need to always return the same - // object at a given index. This function will discard new objects - // and only update existing ones. - if (dataRef.current.length > 0) { - const dataById: Record = {}; - newData.forEach(entry => (dataById[entry[DATA_INDEX.UNIQUE_ID]] = entry)); - newData = dataRef.current.map(entry => dataById[entry[DATA_INDEX.UNIQUE_ID]] || entry); - } + const [data, setData] = useState(DATA); + const onDataLoadRef = useRef(onDataLoad); - dataRef.current = newData; - setVersion(v => v + 1); + useEffect(() => { + onDataLoadRef.current = onDataLoad; + }, [onDataLoad]); - if (onDataLoad) { - onDataLoad(newData.length); - } - }, [abortController, onDataLoad]); + useEffect(() => { + onDataLoadRef.current?.(data.length); + }, [data]); - useInterval(sync, REFRESH_TIME_SECONDS * 1000); - useEffect(() => () => abortController.abort(), []); + useEffect(() => { + const abortController = new AbortController(); + fetchData(abortController.signal) + .then(setData) + .catch(error => { + if (!abortController.signal.aborted) { + log.warn('Scenegraph example is using its bundled flight snapshot', error)(); + } + }); + return () => abortController.abort(); + }, []); const layer = new ScenegraphLayer({ id: 'scenegraph-layer', - data: dataRef.current, + data, pickable: true, sizeScale, scenegraph: MODEL_URL, @@ -182,9 +163,6 @@ export default function App({ getScale: d => { const lastContact = d[DATA_INDEX.LAST_CONTACT]; return lastContact < -DROP_IF_OLDER_THAN_SECONDS ? [0, 0, 0] : [1, 1, 1]; - }, - transitions: { - getPosition: REFRESH_TIME_SECONDS * 1000 } }); diff --git a/website/src/examples/scenegraph-layer.js b/website/src/examples/scenegraph-layer.js index d6a3f042789..f6ff4472722 100644 --- a/website/src/examples/scenegraph-layer.js +++ b/website/src/examples/scenegraph-layer.js @@ -10,7 +10,7 @@ import App from 'website-examples/scenegraph/app'; import {makeExample} from '../components'; class ScenegraphDemo extends Component { - static title = 'Realtime Flight Tracker'; + static title = 'Flight Tracker'; static code = `${GITHUB_TREE}/examples/website/scenegraph`; @@ -26,7 +26,7 @@ class ScenegraphDemo extends Component { return (

- Data source: + Flight data: The OpenSky Network

From aebfa4a79d1d35a4a9522aa16112b5c4bb37a3da Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 23 Jul 2026 08:53:55 -0400 Subject: [PATCH 5/8] fix(scenegraph-layer): use automatic WGSL binding --- .../src/scenegraph-layer/scenegraph-layer-uniforms.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-uniforms.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-uniforms.ts index 8d4a855bfe9..d2b0a92035d 100644 --- a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-uniforms.ts +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer-uniforms.ts @@ -14,7 +14,7 @@ struct ScenegraphUniforms { composeModelMatrix: f32, }; -@group(0) @binding(20) +@group(0) @binding(auto) var scenegraph: ScenegraphUniforms; `; From ac192fc371a9ea7434901ba8c8282bb44e116bdf Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 23 Jul 2026 08:55:46 -0400 Subject: [PATCH 6/8] fix(scenegraph-layer): update PBR UV inputs --- .../src/scenegraph-layer/scenegraph-pbr-material.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-pbr-material.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-pbr-material.ts index 6daeec4ad56..4ef4baa6e39 100644 --- a/modules/mesh-layers/src/scenegraph-layer/scenegraph-pbr-material.ts +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-pbr-material.ts @@ -23,7 +23,8 @@ const source = (pbrMaterial.source as string) vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, 1.0) ); - fragmentInputs.pbr_vUV = uv; + fragmentInputs.pbr_vUV0 = uv; + fragmentInputs.pbr_vUV1 = uv; } ` ) From c2c6121c9729ab81b304cd05a31ebccfdf44d68e Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 23 Jul 2026 09:11:51 -0400 Subject: [PATCH 7/8] fix(scenegraph-layer): resolve WebGPU runtime errors --- examples/website/scenegraph/app.tsx | 3 +++ .../src/scenegraph-layer/scenegraph-layer.wgsl.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/website/scenegraph/app.tsx b/examples/website/scenegraph/app.tsx index 6cd14f1c401..f0a60602bc2 100644 --- a/examples/website/scenegraph/app.tsx +++ b/examples/website/scenegraph/app.tsx @@ -82,6 +82,9 @@ function normalizeData({time, states}: {time: number; states: Aircraft[]}): Airc }); } +// https://github.com/visgl/deck.gl/pull/10465 updated the live OpenSky URL, +// but the endpoint still rejects cross-origin browser requests. Start with this +// bundled snapshot so the demo can render immediately and keep it as fallback. const DATA = normalizeData(sampleData as {time: number; states: Aircraft[]}); async function fetchData(signal: AbortSignal): Promise { diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts index eaec7a621ff..9ecb847e914 100644 --- a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts @@ -14,7 +14,6 @@ struct VertexInputs { @location(6) instancePositions: vec3, @location(7) instancePositions64Low: vec3, @location(8) instanceColors: vec4, - @location(9) instancePickingColors: vec3, @location(10) instanceModelMatrixCol0: vec3, @location(11) instanceModelMatrixCol1: vec3, @location(12) instanceModelMatrixCol2: vec3, @@ -31,11 +30,14 @@ struct FragmentInputs { }; @vertex -fn vertexMain(inputs: VertexInputs) -> FragmentInputs { +fn vertexMain( + inputs: VertexInputs, + @builtin(instance_index) instanceIndex: u32 +) -> FragmentInputs { var outputs: FragmentInputs; geometry.worldPosition = inputs.instancePositions; - geometry.pickingColor = inputs.instancePickingColors; + geometry.pickingColor = picking_getPickingColorFromIndex(instanceIndex); var vertexPosition = inputs.positions; var texCoord = vec2(0.0, 0.0); @@ -112,7 +114,7 @@ fn fragmentMain(inputs: FragmentInputs) -> @location(0) vec4 { #endif #endif - fragColor.a *= color.opacity; + fragColor.a *= layer.opacity; return deckgl_premultiplied_alpha(fragColor); } `; From 23f2a37ab049dab3ccee92847d8f409feb93b97b Mon Sep 17 00:00:00 2001 From: Ib Green Date: Thu, 23 Jul 2026 14:29:55 -0400 Subject: [PATCH 8/8] fix(mesh-layers): address ScenegraphLayer WebGPU review feedback --- .../src/scenegraph-layer/scenegraph-layer.wgsl.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts index 9ecb847e914..b035b163830 100644 --- a/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts +++ b/modules/mesh-layers/src/scenegraph-layer/scenegraph-layer.wgsl.ts @@ -78,11 +78,14 @@ fn vertexMain( ); } else { let sizeAdjustedPos = project_size_vec3(pos); - geometry.position = vec4( - project_position_vec3_f64(inputs.instancePositions, inputs.instancePositions64Low) + - sizeAdjustedPos, - 1.0 + // Scenegraph offsets are east/north/up in globe mode. Use project32's helper so it can + // rotate the offset onto the local tangent plane before producing the common position. + let projectResult = project_position_to_clipspace_and_commonspace( + inputs.instancePositions, + inputs.instancePositions64Low, + sizeAdjustedPos ); + geometry.position = projectResult.commonPosition; geometry.normal = project_normal(worldNormal); } @@ -103,7 +106,9 @@ fn fragmentMain(inputs: FragmentInputs) -> @location(0) vec4 { #ifdef LIGHTING_PBR fragmentInputs.pbr_vPosition = inputs.pbrPosition; - fragmentInputs.pbr_vUV = inputs.pbrUV; + // scenegraphPbrMaterial uses the indexed UV fields from the current PBR module. + fragmentInputs.pbr_vUV0 = inputs.pbrUV; + fragmentInputs.pbr_vUV1 = vec2(0.0); fragmentInputs.pbr_vNormal = inputs.pbrNormal; fragColor = fragColor * pbr_filterColor(vec4(0.0)); #else