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#
| Capability | Meaning |
|---|---|
globe | Can render on a spherical/globe projection, not just flat mercator. |
picking | Supports feature picking (hover/click). |
extrude3d | Polygons/columns can be extruded to a 3D height. |
metricSizing | Sizes can be specified in real-world meters, not just pixels. |
gpuHeatmap | Density heatmaps are computed on the GPU. |
liveBundling | Supports live (non-baked) edge bundling for flow visualizations. |
timeAsHeight | Can lift geometry by time (the "space-time cube" effect). |
interleavedBasemap | Can interleave into a host basemap's own GL context rather than needing a synced overlay canvas. |
userExtensions | Accepts arbitrary user-supplied layer extensions/materials. |
cameraRoll | The camera model has a roll (bank) axis, not just heading/pitch. |
Globe datum.
globe: truesays nothing about the datum: the deck and three globes are spherical by design (deckGlobeViewparity), 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 sharedGlobeProjectionfrom@poopdeck.gl/core/geowithdatum: 'wgs84'; the defaultdatum: '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';}
| Field | Description |
|---|---|
id | Short backend identifier ('deck', 'maplibre', 'three', 'cesium'); the column header in the generated matrix. |
capabilities | One boolean per Capability — exhaustive, tsc-enforced. |
timeFilterModes | The TimeFilterModes this backend implements. |
layerKinds | One LayerKindSupport per LayerKind — exhaustive, tsc-enforced. |
projectsOnCpu | Whether 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). |
pickMechanism | How 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'. |
interleavedBasemap | Whether 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. |
basemapProjection | The 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, soisoLinesdegrades topath(AnimatedPathLayerdensity mode).{ supported: false, reason }(nofallbackKind) — genuinely unsupported, with no in-backend substitute (rendered as—). Example: the deck.gl backend has no dedicatedegolayer — 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:
- Kind checked first (an unsupported kind can't render regardless of
mode). If
layerKinds[kind].supportedis false: returns{ action: 'fallback', toKind, lost: [] }when afallbackKindis declared, otherwise{ action: 'skip', reason }. - Mode checked second. If the kind is supported but
modeis not intimeFilterModes: 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/maplibreis 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 fromsrc/index.ts), and every capability claimedtruemust have a behavioural predicate inCAPABILITY_EVIDENCEthat 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/layersand@poopdeck.gl/threeprove layer kinds structurally, not capabilities. deck derives capability evidence fromdeckBackend.capabilitiesitself; three deliberately passes the fullCAPABILITIESset 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 filteringcesiumBackend'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):
| Section | Rows | Cell meaning |
|---|---|---|
| Traits | projectsOnCpu, tilesetOwnership, pickMechanism, interleavedBasemap, basemapProjection | The raw field value (yes/no, or the literal union value). |
| Capabilities | every Capability | ✅ when capabilities[cap] is true, else —. |
| Time-filter modes | every TimeFilterMode | ✅ when the mode is in timeFilterModes, else —. |
| Layer kinds | every 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#
| Backend | Package | id | Descriptor file |
|---|---|---|---|
| deck.gl | @poopdeck.gl/layers | deck | packages/layers/src/backend-descriptor.ts |
| MapLibre GL | @poopdeck.gl/maplibre | maplibre | packages/maplibre/src/backend-descriptor.ts |
| Three.js | @poopdeck.gl/three | three | packages/three/src/backend-descriptor.ts |
| CesiumJS | @poopdeck.gl/cesium | cesium | packages/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#
- Import
BackendDescriptor,LayerKind,Capability,LayerKindSupport,LAYER_KINDS, andCAPABILITIESfrom@poopdeck.gl/core/capabilities. - Build an exhaustive
layerKindsrecord — eithersatisfies Record<LayerKind, LayerKindSupport>on a literal object, orObject.fromEntries(LAYER_KINDS.map(...))— so aLayerKindmissing from the record is atscerror. Give everysupported: falseentry areasonand, where a substitute exists, afallbackKind. - Declare all ten
capabilitiesbooleans and the subset oftimeFilterModesthe backend actually implements. - Fill in the five traits (
projectsOnCpu,tilesetOwnership,pickMechanism,interleavedBasemap,basemapProjection) honestly against how the backend actually works, not aspirationally. - Export the descriptor (conventionally
<name>Backend) from the package'ssrc/index.ts. - Add a
test/backend-descriptor.test.tsmodelled onpackages/maplibre/'s, which is the standard: for everyLayerKindclaimedsupported: true, map it to the concrete class backing it and assert that class is a real export; assert every unsupported kind carries areason; give every capability claimedtruea behavioural predicate that must pass; then build aConformanceEvidencefrom those real exports and predicates and assertassertDescriptorConsistent(...)returns[]. - Add the new descriptor to the import list and array in
scripts/gen-capabilities-doc.mjsand regeneratedocs/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