Capabilities & extensions
On this page

Rendered from AUTHORING.md at commit 26de7be8. The package is imported here by its 0.2.0 name, typeshade.

Capabilities & extensions

After this page you can declare the GPU features a module needs, read what each one costs on each target, check a booted device against them, and tell which module shapes fail closed instead of emitting.

A capability is a neutral id for a GPU feature a module’s emit depends on, such as rendering into a float texture or blending into one.

Declaring a capability

A module lists the features it needs in enables:

module({ enables: ['floatRenderTarget'], funcs: [vs, fs] })

The vocabulary is fixed and target-neutral, so a raw EXT_* or OVR_* string never appears in a module. Each id folds into the capability gate that runs before any writer touches the module, so a backend that cannot spell the feature throws UnsupportedFeatureError (SD0030) naming the capability, and no source the driver would reject is ever produced.

What each id needs per target

Two different costs hide behind one id. A host feature is something the host must activate before it creates a pipeline: gl.getExtension('EXT_color_buffer_float') on WebGL2, a requiredFeatures entry on WebGPU. A source directive is a token the emitted shader itself must carry, #extension … : require on GLSL ES 3.00 and enable …; on WGSL, which the backend writes for you, deduped and sorted, right after the #version line on GLSL and ahead of the declarations on WGSL.

A capability can need either half, both halves, or neither.

enables idWebGL2 and GLSL ES 3.00WebGPU and WGSL
floatRenderTargethost feature EXT_color_buffer_floatcore, nothing to request
float32Blendhost feature EXT_float_blendhost feature float32-blendable
float32Filterablehost feature OES_texture_float_linearhost feature float32-filterable
multiviewdirective GL_OVR_multiview2 and host feature OVR_multiview2unsupported, fails closed
f16unsupported, fails closeddirective f16 and host feature shader-f16
subgroupsunsupported, fails closeddirective subgroups and host feature subgroups
clipDistancesunsupported, fails closeddirective clip_distances and host feature clip-distances
primitiveIndexunsupported, fails closeddirective primitive_index and host feature primitive-index
dualSourceBlendingunsupported, fails closeddirective dual_source_blending and host feature dual-source-blending
bgra8unormStorageunsupported, fails closedhost feature bgra8unorm-storage, derived
packed4x8Dotunsupported, fails closedcore, derived; a language feature to check

The bottom five rows are the ones nothing declares by hand. Writing @builtin("clip_distances"), @builtin("primitive_index") or @blend_src(n) derives the capability, because WGSL refuses each of those without the matching enable; a "bgra8unorm" storage texture and a call into the packed 4x8 family derive theirs from the binding and from the call. f16, which no use can derive, and subgroups, for a file that reads neither subgroup built-in value, are spelled as a string directive beside "use typeshade":

'use typeshade';
'enable subgroups';

A capability with a host half and no source half costs zero emitted bytes: declaring it moves no byte of the shader. The 32 in float32Blend and float32Filterable is load-bearing, because both underlying features are 32-bit float only, while EXT_color_buffer_float covers 16-bit and 32-bit targets, which is why floatRenderTarget carries no bit width.

capabilityMatrix reports the same information as data, derived from the backends themselves, so a tool or a page can print it without transcribing it:

capabilityMatrix([wgslBackend, glslEs300Backend])
// → [{ capability: 'storageBuffer', support: { wgsl: 'native', 'glsl-es300': 'unsupported' },
// declarable: false },
// …,
// { capability: 'f16', support: { wgsl: 'directive', 'glsl-es300': 'unsupported' },
// declarable: true }]

The result has one row per capability, eighteen in a fixed order, including the nine a module’s shape derives and never declares (storageBuffer through textureGather, then bgra8unormStorage and packed4x8Dot), which come back with declarable: false.

Two notes before you trust a row.

  • Support is not the same as reachability. f16 and multiview are supported on the target the table says and neither is authorable today, because there is no f16 scalar type and no way to spell layout(num_views = N) in; or read gl_ViewID_OVR. A module declaring multiview emits the directive and renders single-view. The other four are reachable: clipDistances, primitiveIndex and dualSourceBlending are what their attributes need, and subgroups is reached by @builtin("subgroup_invocation_id") or @builtin("subgroup_size") on a compute or fragment entry — the subgroup INTRINSICS (subgroupAdd and friends) are still absent, which is a separate gap from the capability. Whether a given adapter HAS one of the four is what reflect().requiredFeatures is for, and a device only HAS an optional feature if requestDevice was asked for it — the compile gate does exactly that, deriving the list from the corpus, which is how examples/clip-planes.shade.ts compiles on its Tint. Its software adapter offers clip-distances and subgroups but not primitive-index, so that one row’s host string is unconfirmed here.
  • An unsupported cell is a hard stop by design: the emit throws. To ask before you emit, diagnose(m, { backend }) reports the same missing capability as an SD0030 diagnostic and never throws.

Derived and implied capabilities

Some capabilities are derived, which means they are read off the module’s shape and never declared. A storage binding implies storageBuffer, a compute entry implies compute, a multisampled texture load implies msaaTextureLoad, a storage-texture binding implies storageTexture, a 1D texture implies texture1d, a cube-array texture implies textureCubeArray, a textureGather call implies textureGather, and a call to one of the eight packed 4x8 integer builtins implies packed4x8Dot. bgra8unormStorage is derived from a binding’s FORMAT rather than its kind: bgra8unorm is the one storage format that is not core, and a device refuses the bind group layout unless it requested bgra8unorm-storage (measured — and Tint compiles the module either way, so nothing but this capability carries the requirement to the host). enables is typed to exclude every derived id, so naming one is a compile error.

A capability is not the only thing a host may have to check. A WGSL language feature is a property of the browser’s shading-language implementation rather than of the device, so it is not requested at requestDevice at all. reflect().requiredLanguageFeatures lists the ones a module’s source uses, for navigator.gpu.wgslLanguageFeatures to answer. The WGSL writer emits requires readonly_and_readwrite_storage_textures; for a storage texture bound read or read_write, and no directive for the packed 4x8 family, which compiles without one.

One capability can also imply another. float32Blend pulls in floatRenderTarget, because blending into a float target needs that target to be renderable as a colour attachment first, and with only EXT_float_blend active the framebuffer comes back incomplete. reflect().requiredFeatures reports the closure, so a module that declares one gets both:

const m = module({ enables: ['float32Blend'], funcs: [vs, fs] })
reflect(m).requiredFeatures // ['float32Blend', 'floatRenderTarget']

The list is always present, and empty for a module that needs nothing.

Verifying at boot

Declaring a capability activates nothing. Device features are fixed when the device is created, well before a module is emitted: WebGPU’s requiredFeatures are settled at requestDevice and a feature missing there can never be added later, and a WebGL2 context has whatever extensions were fetched on it. Asking at pipeline-creation time is too late.

So the author’s job is to verify that the booted device covers what the module needs, and to fail loudly when it does not. reflect().requiredFeatures gives neutral ids, because reflection takes a module and knows no target, so translate them through hostFeaturesFor, which returns the concrete strings one backend’s host needs. It skips every capability with no host half, so there are no holes to hand a driver:

import { hostFeaturesFor, reflect, glslEs300Backend, wgslBackend } from 'typeshade'
// WebGL2: verify the already-booted context has each extension.
for (const ext of hostFeaturesFor(glslEs300Backend, reflect(m).requiredFeatures)) {
if (!gl.getExtension(ext)) throw new Error(`WebGL2 lacks ${ext}`)
}
// WebGPU: feed the same lookup into requestDevice, at boot.
const device = await adapter.requestDevice({
requiredFeatures: hostFeaturesFor(wgslBackend, reflect(m).requiredFeatures) as GPUFeatureName[],
})

Choosing between two modules

enables states a hard requirement, so it is the wrong tool for a feature you can live without. A fallback is two modules and one decision, made at boot where the device is already known:

const caps = reflect(fancy).requiredFeatures
const ok = hostFeaturesFor(glslEs300Backend, caps).every((e) => gl.getExtension(e))
const m = ok ? fancy : plain // two modules, one decision, made once

Each module still declares what it needs, so the one you did not pick would have failed closed if you had picked it on a device that cannot run it.

Portable compute kernels

A compute entry declared portable: true emits on both backends: natively as @compute on WGSL, and on GLSL ES 3.00 through a compute-to-fragment lowering that runs with no emit option. In exchange the kernel stays inside a gather-only tier, where every invocation reads what it likes and writes one element at its own index.

const dispatch = resource('dispatch', vec4uT, { group: 0, binding: 0 })
const field = storageBuffer('field', f32T, { group: 0, binding: 1, access: 'read' })
const outColor = storageBuffer('out_color', u32T, { group: 0, binding: 2, access: 'read_write' })
const kernel = fn(
'eval_field',
{ gid: builtin('global_invocation_id', vec3uT) },
voidT,
({ gid }) => {
const fid = gid.x
If(fid.ge(dispatch.node.x), () => {
Return()
})
outColor.at(fid).assign(pack4x8unorm(vec4(field.at(fid), 0, 0, 1)))
},
{ stage: 'compute', workgroupSize: 64, portable: true },
)
const m = module({
bindings: [dispatch.binding, field.binding, outColor.binding],
funcs: [kernel],
})

The tier is exactly this shape:

  • global_invocation_id is read as .x alone, the 1-D linear invocation index.
  • Exactly one read_write storage binding whose elements are u32, so its type is array<u32>, written exactly once at index gid.x. A scatter write, a second write, or zero writes fails.
  • The first uniform binding must be vec4<u32>, the dispatch uniform, whose .x is the invocation count and whose .y is the output-grid width. The other two components are reserved. First means first in the module’s bindings list, so declaration order matters.
  • No raw statements anywhere the entry’s call graph reaches, since per-target text contradicts the portability claim.

Anything outside that shape fails validation at every emit on both writers, with SD0111 and a remedy per violation. Declaring portable on an entry that is not compute fails at build time with SD0110.

The lowering changes how the kernel is dispatched as well as how it is emitted: on WebGL2 the host submits a fullscreen draw into an R32UI target in place of a compute dispatch. Declaring portable is what lets a WebGL2 host recognize the kernel as eligible for that path.

Barriers, workgroup memory, atomics, scatter writes and multi-output kernels are outside the tier. A kernel that needs one of them stays WebGPU-only, so omit portable, or restructure the work into several gather-only passes.

Edit this page Report a problem