Some visualizations animate, pulse, and flash. If you're sensitive to motion or flashing, turn on Reduce motion in your system settings.

poopdeck.gl
Renderer Backends

BackendDescriptor

Every STT renderer backend — @poopdeck.gl/layers (deck.gl), @poopdeck.gl/maplibre, @poopdeck.gl/three, @poopdeck.gl/cesium — publishes one BackendDescriptor: a plain data object that declares what the backend supports, expressed against a shared vocabulary owned by @poopdeck.gl/core. It is the single source of truth that a hand-maintained "which backend supports what" table would otherwise drift out of sync with: the descriptors feed a generated cross-backend matrix (docs/spec/backend-capabilities.md), and a paired over-claim gate stops a descriptor from declaring support it cannot back up.

This page explains the pattern itself — the shared vocabulary, the descriptor shape, the over-claim gate, and how to read the generated matrix. For each backend's actual layer catalog and usage, see stt-maplibre.md, stt-cesium.md, stt-three.md, and the deck.gl layer docs (starting at spatiotemporal-layer.md).

Installation#

import type {
BackendDescriptor,
LayerKind,
Capability,
LayerKindSupport,
Degradation,
ConformanceEvidence,
} from '@poopdeck.gl/core/capabilities';
import {
LAYER_KINDS,
CAPABILITIES,
degradeRequest,
assertDescriptorConsistent,
} from '@poopdeck.gl/core/capabilities';
import type { TimeFilterMode } from '@poopdeck.gl/core/time-filter';

TimeFilterMode is defined once in core/time-filter (see time-filter-extension.md) and re-exported from core/capabilities, so there is exactly one animation-mode vocabulary shared by the descriptor contract and the time-filter math itself.

The shared vocabulary#

LayerKind, Capability, and TimeFilterMode are frozen as const arrays — plain shared TS unions, not a codegen pipeline. Renaming or removing an entry is a tsc break everywhere it's consumed, which is the enforcement mechanism: every descriptor is typed so a Record<LayerKind, …> or Record<Capability, boolean> with a missing key fails to compile.

LayerKind — the visualization layer families#

point · path · polygon · arc · line · icon · column · trips · tripHeads ·
boundingBox · surfel · heatmap · h3Summary · quadbinSummary · flowmap ·
flowCorridor · flowStroke · isoLines · ego · text · mesh · pointCloud · hexbin

Each concrete layer class documented elsewhere in docs/api/ backs exactly one of these kinds (e.g. deck's AnimatedPointLayer, three's STTPointLayer and maplibre's / cesium's STTPointLayer all back point in their respective backends).

Capability — cross-cutting engine traits#

CapabilityMeaning
globeCan render on a spherical/globe projection, not just flat mercator.
pickingSupports feature picking (hover/click).
extrude3dPolygons/columns can be extruded to a 3D height.
metricSizingSizes can be specified in real-world meters, not just pixels.
gpuHeatmapDensity heatmaps are computed on the GPU.
liveBundlingSupports live (non-baked) edge bundling for flow visualizations.
timeAsHeightCan lift geometry by time (the "space-time cube" effect).
interleavedBasemapCan interleave into a host basemap's own GL context rather than needing a synced overlay canvas.
userExtensionsAccepts arbitrary user-supplied layer extensions/materials.
cameraRollThe camera model has a roll (bank) axis, not just heading/pitch.

Globe datum. globe: true says nothing about the datum: the deck and three globes are spherical by design (deck GlobeView parity), while Cesium's globe is the WGS84 ellipsoid — the two frames diverge by up to ~21 km at mid-latitudes. When registering STT geometry against Cesium (or any real WGS84 host), construct the shared GlobeProjection from @poopdeck.gl/core/geo with datum: 'wgs84'; the default datum: 'sphere' stays byte-identical to the deck/three globe output.

TimeFilterMode — animation modes#

none · window · wake · cumulative · trail — see time-filter-extension.md for what each mode does; a descriptor declares only the subset it implements.

The BackendDescriptor shape#

interface BackendDescriptor {
readonly id: string;
readonly capabilities: Readonly<Record<Capability, boolean>>;
readonly timeFilterModes: readonly TimeFilterMode[];
readonly layerKinds: Readonly<Record<LayerKind, LayerKindSupport>>;
readonly projectsOnCpu: boolean;
readonly tilesetOwnership: 'per-layer' | 'shared';
readonly pickMechanism: 'gpu-id' | 'cpu-ray' | 'id-fbo' | 'host' | 'none';
readonly interleavedBasemap: boolean;
readonly basemapProjection: 'mercator' | 'globe';
}
FieldDescription
idShort backend identifier ('deck', 'maplibre', 'three', 'cesium'); the column header in the generated matrix.
capabilitiesOne boolean per Capability — exhaustive, tsc-enforced.
timeFilterModesThe TimeFilterModes this backend implements.
layerKindsOne LayerKindSupport per LayerKind — exhaustive, tsc-enforced.
projectsOnCpuWhether lon/lat → world projection happens on the CPU (three, Cesium) vs. on the GPU against a host viewport (deck).
tilesetOwnership'shared' — one tileset feeds every layer (deck, three, Cesium) — vs. 'per-layer' — each layer class owns its own archive (MapLibre).
pickMechanismHow picking resolves: 'gpu-id' (a persistent GPU id-colour pass — deck, three), 'id-fbo' (a dedicated id framebuffer with synchronous on-demand 1×1 readback — MapLibre's STTBaseLayer.pick), 'host' (delegated to the host engine — Cesium's scene.pick), 'cpu-ray' (ray/OBB intersection; defined but declared by none of the four today — three's box path uses the technique, but its descriptor declares 'gpu-id' for the instanced-cloud id pass, since there is no 'hybrid' member), or 'none'.
interleavedBasemapWhether STT geometry can share the basemap's own GL/scene context vs. needing a camera-synced overlay canvas. Mirrored inside capabilities.interleavedBasemap, which is the value the over-claim gate actually checks; this top-level field is the same fact exposed as a direct trait for code that branches on it without a capability lookup.
basemapProjectionThe projection the backend's basemap integration assumes: 'mercator' or 'globe'.

LayerKindSupport — native, fallback, or unsupported#

type LayerKindSupport =
| { supported: true }
| { supported: false; fallbackKind?: LayerKind; reason: string };

Every LayerKind must appear in a descriptor's layerKinds map — there is no "absent means unsupported" shortcut, so a newly added LayerKind forces every backend's descriptor to make an explicit decision (a missing key is a tsc error against Record<LayerKind, LayerKindSupport>):

  • { supported: true } — native. The backend renders this kind directly (rendered as in the generated matrix).
  • { supported: false, fallbackKind, reason } — degrades to a different, supported kind (rendered as ↳ <fallbackKind>). One live case today: the deck.gl backend has no dedicated iso layer, so isoLines degrades to path (AnimatedPathLayer density mode).
  • { supported: false, reason } (no fallbackKind) — genuinely unsupported, with no in-backend substitute (rendered as ). Example: the deck.gl backend has no dedicated ego layer — AV cockpits compose it from point/icon layers at the application level instead.

reason is required on every unsupported entry (supported: false) and is a plain human-readable string, not part of the machine-checked contract.

Resolving a request: degradeRequest#

function degradeRequest(
d: BackendDescriptor,
kind: LayerKind,
mode?: TimeFilterMode, // default 'window'
): Degradation | null;

Given a requested (kind, mode), degradeRequest returns null when the backend fully supports the request as-is, or a typed Degradation describing how it doesn't:

  1. Kind checked first (an unsupported kind can't render regardless of mode). If layerKinds[kind].supported is false: returns { action: 'fallback', toKind, lost: [] } when a fallbackKind is declared, otherwise { action: 'skip', reason }.
  2. Mode checked second. If the kind is supported but mode is not in timeFilterModes: returns { action: 'fallbackMode', fromMode, toMode, lost: [] }, preferring 'window' as the fallback mode when the backend supports it, else the first mode the backend declares.

The Degradation union also defines a throw action ({ action: 'throw', reason }) for callers that want to hard-fail on an unresolvable request; degradeRequest itself never returns it — it always prefers a typed fallback or skip over throwing.

The over-claim gate#

function assertDescriptorConsistent(
d: BackendDescriptor,
proven: ConformanceEvidence,
): string[]; // [] means consistent
interface ConformanceEvidence {
capabilities: ReadonlySet<Capability>;
layerKinds: ReadonlySet<LayerKind>;
timeFilterModes: ReadonlySet<TimeFilterMode>;
}

A BackendDescriptor is a self-declaration — nothing stops it from claiming a capability the backend doesn't actually have. assertDescriptorConsistent closes that gap: it walks every Capability, LayerKind, and declared TimeFilterMode the descriptor claims true/supported/present, and reports a violation string for each one that has no matching entry in proven. A descriptor that claims less than it proves is fine (that's just a backend choosing to degrade something it could technically support); claiming more than it proves is what fails.

Each backend package supplies its own test/backend-descriptor.test.ts against this gate, building ConformanceEvidence from the package's own reality. What that evidence actually proves differs per package, and the difference is worth knowing before trusting a green gate:

  • @poopdeck.gl/maplibre is the strongest, and the shape to copy. Layer kinds are proven structurally (each supported kind maps to a class that must be a real, live export from src/index.ts), and every capability claimed true must have a behavioural predicate in CAPABILITY_EVIDENCE that passes — real constructed layers and real compiled shader sources, not the descriptor's own claim. A claim with no predicate is itself a failure, so a future capability flip cannot ride the declaration.
  • @poopdeck.gl/layers and @poopdeck.gl/three prove layer kinds structurally, not capabilities. deck derives capability evidence from deckBackend.capabilities itself; three deliberately passes the full CAPABILITIES set and says so in the test, on the grounds that deriving evidence from the descriptor is a tautology that can never fail. On both, the exported layer catalog is the part with teeth.
  • @poopdeck.gl/cesium's gate is self-referential on both axes. It builds evidence by filtering cesiumBackend's own claimed capabilities and supported kinds, with no class-export mapping, so it proves internal consistency and nothing more. It is the one package that has not caught up to maplibre's shape.

Reading the generated matrix#

docs/spec/backend-capabilities.md is produced by renderCapabilitiesMarkdown (@poopdeck.gl/core's render/capabilities-doc.ts) from the four live descriptors, run via:

node scripts/gen-capabilities-doc.mjs

(after building core and the four backend packages — see that script's header comment). The file is a checked-in snapshot, not something computed at doc-build time, so a BackendDescriptor change still needs a manual regenerate-and-commit step — but CI does catch it if you forget: the typescript job runs node scripts/gen-capabilities-doc.mjs --check, which re-renders the matrix from the live descriptors and byte-compares it with the committed file.

The generated file has four sections, one row group per id column (deck/three/maplibre/cesium today):

SectionRowsCell meaning
TraitsprojectsOnCpu, tilesetOwnership, pickMechanism, interleavedBasemap, basemapProjectionThe raw field value (yes/no, or the literal union value).
Capabilitiesevery Capability when capabilities[cap] is true, else .
Time-filter modesevery TimeFilterMode when the mode is in timeFilterModes, else .
Layer kindsevery LayerKind native, ↳ <kind> fallback, unsupported — see LayerKindSupport above.

Reading it top to bottom answers, in order: how does this backend project and own tiles (Traits), what can it do at all (Capabilities), what animation vocabulary does it accept (Time-filter modes), and what will it actually render for a given layer kind, natively or via fallback (Layer kinds).

The four concrete descriptors#

BackendPackageidDescriptor file
deck.gl@poopdeck.gl/layersdeckpackages/layers/src/backend-descriptor.ts
MapLibre GL@poopdeck.gl/maplibremaplibrepackages/maplibre/src/backend-descriptor.ts
Three.js@poopdeck.gl/threethreepackages/three/src/backend-descriptor.ts
CesiumJS@poopdeck.gl/cesiumcesiumpackages/cesium/src/backend-descriptor.ts

deck.gl remains the reference backend for shader-math conformance and for the fullest per-layer prop surface, but on catalog coverage the relationship has inverted: three, maplibre and cesium each render all 23 kinds natively, deck renders 21, and deck is the only one of the four without cameraRoll. Three genuine holes remain — deck's isoLines (degrades to path) and ego (no layer at all), cesium's gpuHeatmap (its heatmap is an honest CPU field), and three's interleavedBasemap, which is permanently structural: TSL compiles only on WebGPURenderer, every basemap-interleave path mechanically needs new WebGLRenderer({context: gl}), and WebGL and WebGPU are non-interoperable browser contexts. See each descriptor file's own header comment, and stt-cesium.md's "Backend descriptor" section for a worked walkthrough of one descriptor's fields end to end.

Adding a fifth backend#

  1. Import BackendDescriptor, LayerKind, Capability, LayerKindSupport, LAYER_KINDS, and CAPABILITIES from @poopdeck.gl/core/capabilities.
  2. Build an exhaustive layerKinds record — either satisfies Record<LayerKind, LayerKindSupport> on a literal object, or Object.fromEntries(LAYER_KINDS.map(...)) — so a LayerKind missing from the record is a tsc error. Give every supported: false entry a reason and, where a substitute exists, a fallbackKind.
  3. Declare all ten capabilities booleans and the subset of timeFilterModes the backend actually implements.
  4. Fill in the five traits (projectsOnCpu, tilesetOwnership, pickMechanism, interleavedBasemap, basemapProjection) honestly against how the backend actually works, not aspirationally.
  5. Export the descriptor (conventionally <name>Backend) from the package's src/index.ts.
  6. Add a test/backend-descriptor.test.ts modelled on packages/maplibre/'s, which is the standard: for every LayerKind claimed supported: true, map it to the concrete class backing it and assert that class is a real export; assert every unsupported kind carries a reason; give every capability claimed true a behavioural predicate that must pass; then build a ConformanceEvidence from those real exports and predicates and assert assertDescriptorConsistent(...) returns [].
  7. Add the new descriptor to the import list and array in scripts/gen-capabilities-doc.mjs and regenerate docs/spec/backend-capabilities.md.

Source#

packages/core/src/render/capabilities.ts · packages/core/src/render/capabilities-doc.ts · packages/core/src/render/time-filter.ts · packages/layers/src/backend-descriptor.ts · packages/maplibre/src/backend-descriptor.ts · packages/three/src/backend-descriptor.ts · packages/cesium/src/backend-descriptor.ts · scripts/gen-capabilities-doc.mjs · generated matrix: docs/spec/backend-capabilities.md