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.
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.
/** 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.
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
requestDevicerejects with no obvious cause. A feature inrequiredFeaturesthat 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.
createQuerySetthrows 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-bcis desktop,etc2is mobile, andastcis 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.
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.
Related
- Initializing WebGPU devices for GIS workloads — the topic this page belongs to.
- Handling device lost and recreating GIS resources — where the capability record has to be rebuilt.
- Feature-detecting timestamp-query support — the probe for the most-used feature here, in detail.
- How to configure WebGPU adapter limits for large GeoJSON — the limits half of the same negotiation.
- Texture and tile atlas management in WebGPU — what the compression features actually change.