Requesting Optional Features for Spatial Workloads

requestDevice has one behaviour that surprises people the first time: a feature listed in requiredFeatures that the adapter does not support does not degrade — it rejects the device entirely. A single optimistic entry can therefore turn a working map into a blank page on a machine that was perfectly capable of rendering it. The correct pattern is to keep a wish list, intersect it with adapter.features before the request, and record what was actually granted so the rest of the application can branch on capability rather than on hope. This page is that pattern, plus which features a spatial pipeline genuinely benefits from and what each one costs. It is one stage of initializing WebGPU devices for GIS workloads.

Intersecting the wish list before requesting A decision diagram. The question asks whether every feature in the request is present in the adapter feature set. On the yes branch requestDevice resolves, the granted features are recorded in a capability record, and the application branches on that record. On the no branch requestDevice rejects outright: there is no partial grant and no degradation, so a single unsupported entry costs the whole device and the map renders nothing at all. requiredFeatures · ALL OR NOTHING Every requested feature supported? checked against adapter.features Device resolves record what was granted yes Branch on the record not on hope requestDevice rejects no partial grant no The map renders nothing on capable hardware Requested limits behave the same way: above the ceiling is a rejection, not a clamp.
There is no middle outcome. That asymmetry is the entire argument for filtering the wish list against the adapter first — it turns a fatal request into a capability question.

Runnable reference implementation

The intersection is four lines. What matters is that it happens before the request, on the adapter, and that the result is recorded rather than re-derived.

typescript
/** Features this application would use if offered, in priority order. */
const WISH_LIST: GPUFeatureName[] = [
  "timestamp-query",          // per-pass GPU profiling
  "float32-filterable",       // interpolating r32float elevation
  "texture-compression-bc",   // desktop compressed tiles
  "texture-compression-etc2", // mobile compressed tiles
  "indirect-first-instance",  // instance offsets in indirect draws
];

export interface Capabilities {
  readonly features: ReadonlySet<GPUFeatureName>;
  readonly maxBufferSize: number;
  readonly maxStorageBufferBindingSize: number;
}

export async function acquire(): Promise<{ device: GPUDevice; caps: Capabilities }> {
  const adapter = await navigator.gpu?.requestAdapter({ powerPreference: "high-performance" });
  if (!adapter) throw new Error("no adapter — route to the fallback renderer");

  // The intersection. Requesting anything not in this list rejects the device.
  const granted = WISH_LIST.filter((f) => adapter.features.has(f));

  const device = await adapter.requestDevice({
    label: "spatial-device",
    requiredFeatures: granted,
    requiredLimits: {
      maxBufferSize: Math.min(256 * 1024 * 1024, adapter.limits.maxBufferSize),
      maxStorageBufferBindingSize: Math.min(
        256 * 1024 * 1024, adapter.limits.maxStorageBufferBindingSize),
    },
  });

  return {
    device,
    caps: {
      features: new Set(granted),
      maxBufferSize: device.limits.maxBufferSize,
      maxStorageBufferBindingSize: device.limits.maxStorageBufferBindingSize,
    },
  };
}

The Math.min on the limits is the same idea applied to a different axis: a requested limit above the adapter’s ceiling is also a rejection, not a clamp. Taking the smaller of what you want and what exists turns both classes of rejection into a capability record the application reads once.

Parameter reference

Feature What it buys a spatial pipeline Cost of not having it
timestamp-query per-pass GPU timings fall back to wall-clock deltas around onSubmittedWorkDone — no pass attribution
float32-filterable linear sampling of r32float elevation sample with nearest and interpolate manually, or use an encoded RGB height tile
texture-compression-bc quarter-size tiles on desktop upload uncompressed; four times the VRAM per tile
texture-compression-etc2 the same on mobile as above, where it hurts most
indirect-first-instance non-zero firstInstance in an indirect draw pack the offset into the instance data instead
shader-f16 half-precision arithmetic in kernels use f32; rarely a real loss for spatial work
depth32float-stencil8 combined depth and stencil at full precision separate attachments, or a lower-precision depth format

Two of these are worth calling out for what they are not. shader-f16 sounds attractive for coordinate work and is exactly wrong for it — half precision carries about three decimal digits, which is coarser than anything on the coordinate precision page can rescue. And the compression features are optimisations rather than architecture: a deployment must have the uncompressed path anyway, so treat them as a bonus rather than as a design input.

What each optional feature is worth to a map A table of five optional features with what each buys and what it costs to go without. Timestamp query buys per-pass profiling and costs pass attribution when absent. Float32-filterable buys linear sampling of float elevation and costs a manual decode when absent. Texture compression buys a quarter of the VRAM per tile and costs four times the memory when absent. Indirect-first-instance buys a non-zero instance offset and costs packing the offset into instance data. Shader f16 buys half-precision arithmetic and costs essentially nothing for spatial work, because coordinates should not be f16 anyway. FEATURE · VALUE · COST WITHOUT IT Buys Without it timestamp-query per-pass timings wall clock only float32-filterable linear f32 sampling manual decode texture-compression 4× less VRAM 4× the memory indirect-first-instance instance offsets pack it yourself shader-f16 half-precision maths nothing much shader-f16 is the one to be wary of — half precision is far too coarse for coordinates.
Only the third row changes an architecture; the rest change convenience or observability. Ranking them this way is what stops a wish list turning into a requirement list.

Why the capability record beats re-querying

device.features.has(...) is available everywhere and cheap, which makes it tempting to call at each use site. That is worse than recording the result once, for two reasons.

The first is agreement. A tile cache that decides its byte budget at start-up and a layer that tests for a feature every frame can end up on different sides of the same question after a device is lost and re-acquired with a different adapter — a laptop switching from discrete to integrated graphics is the concrete case. One record, rebuilt whole on recovery, cannot disagree with itself.

The second is that the record is the natural place to put derived decisions. Whether the profiler is enabled is not a feature; it is a function of a feature and a build flag. Whether tiles are compressed is a function of two features and the platform. Computing those once, next to the features they depend on, keeps the branching in one file rather than scattered through the renderer.

The record should be immutable and small: the granted feature set, the granted limits, and a handful of booleans derived from them. Anything larger is usually a sign that application state has crept into it.

Features that change the shape of a pipeline

Most of the wish list is convenience, but two entries genuinely change what a pipeline looks like, and both deserve a decision at design time rather than a runtime branch.

timestamp-query decides whether performance work is measured or guessed. Without it, a frame can be timed but not attributed, so the difference between a slow compute pass and a slow render pass is invisible. Because it is widely but not universally available, the honest design is a profiler interface with two implementations — a real one and a wall-clock one — chosen once from the capability record, with the same shape so the dashboard reading them does not care which is live. That is a small amount of extra code and it keeps every measurement comparable.

The compression features decide how many tiles fit in VRAM, which decides the eviction policy, which decides how aggressively the map prefetches. A deployment that assumes compression and then runs without it does not merely use more memory — it thrashes, because a cache sized for quarter-size tiles is a quarter too small for full-size ones. Sizing the cache from the capability record rather than from a constant is what keeps that from becoming a platform-specific bug report.

The rest of the list — indirect-first-instance, shader-f16, depth32float-stencil8 — can be branched on locally, at the one or two places that use them, without any structural consequence.

Failure modes

  • requestDevice rejects with no obvious cause. A feature in requiredFeatures that the adapter does not support, or a limit above its ceiling. Detection: the rejection message names the feature or limit. Fix: intersect before requesting.
  • A feature works in development and not in production. The development machine has a discrete GPU and the target does not. This is the whole reason the wish list is a list rather than a constant.
  • createQuerySet throws even though the feature was granted. The query set was created against a device acquired before the feature was added to the request — usually a stale device reference after a recovery.
  • Compressed textures fail on one platform only. texture-compression-bc is desktop, etc2 is mobile, and astc is neither universally. Wishing for all three and using whichever was granted is the portable pattern.
  • The capability record is stale after a device loss. It was reused rather than rebuilt. The new adapter may report different limits entirely; rebuild it as the first step of recovery.
Building the capability record once Three steps. The wish list is filtered against the adapter feature set, producing the exact set that will be requested. Requested limits are clamped to the adapter ceilings with a minimum, because an over-request rejects the device just as an unsupported feature does. The granted features and limits are then frozen into one immutable record, along with the handful of derived booleans the renderer actually branches on, and every subsystem reads that record instead of querying the device again. CAPABILITY RECORD · THREE STEPS 1 Filter the wish list adapter.features.has(f) before the request, never after 2 Clamp the limits Math.min(want, ceiling) over-requesting also rejects 3 Freeze one record features + limits + derived flags rebuilt whole on recovery Keep it small — if application state is creeping in, it has stopped being a capability record.
The record is the interface between negotiation and everything else. Subsystems that read it cannot disagree with each other, and recovery is a matter of rebuilding one object rather than auditing every call site.

Backend / Python interop note

Two of the features above change what the server should send, which makes the capability record something the client has to act on at fetch time rather than only at render time.

Compressed textures are the clearest case. If texture-compression-bc was granted, the client should request a .ktx2 tile with BC7 payload; if etc2 was granted, an ETC2 payload; if neither, a PNG. That is a content negotiation decision, and putting it in the tile URL — a path segment rather than a header — keeps it cacheable and debuggable. On the Python side, generating the variants is a build-time step with KTX-Software or basisu, not a per-request one.

float32-filterable changes the elevation format the same way: granted, and the server can ship r32float; not granted, and the encoded RGB height tile described under texture and tile atlas management is the portable choice. Because the encoded form works everywhere, a service that only produces one format should produce that one — and let clients with the feature use it anyway, at the cost of a shader decode they could have skipped.

Recording which features were granted alongside every telemetry sample is the other half of this. A support ticket that says the map is slow is worth very little; the same ticket carrying the granted feature set, the granted limits and the adapter’s reported vendor turns a guess into a diagnosis, and it is three fields on an event the application is already sending.