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 id | WebGL2 and GLSL ES 3.00 | WebGPU and WGSL |
|---|---|---|
floatRenderTarget | host feature EXT_color_buffer_float | core, nothing to request |
float32Blend | host feature EXT_float_blend | host feature float32-blendable |
float32Filterable | host feature OES_texture_float_linear | host feature float32-filterable |
multiview | directive GL_OVR_multiview2 and host feature OVR_multiview2 | unsupported, fails closed |
f16 | unsupported, fails closed | directive f16 and host feature shader-f16 |
subgroups | unsupported, fails closed | directive subgroups and host feature subgroups |
clipDistances | unsupported, fails closed | directive clip_distances and host feature clip-distances |
primitiveIndex | unsupported, fails closed | directive primitive_index and host feature primitive-index |
dualSourceBlending | unsupported, fails closed | directive dual_source_blending and host feature dual-source-blending |
bgra8unormStorage | unsupported, fails closed | host feature bgra8unorm-storage, derived |
packed4x8Dot | unsupported, fails closed | core, 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.
f16andmultivieware supported on the target the table says and neither is authorable today, because there is nof16scalar type and no way to spelllayout(num_views = N) in;or readgl_ViewID_OVR. A module declaringmultiviewemits the directive and renders single-view. The other four are reachable:clipDistances,primitiveIndexanddualSourceBlendingare what their attributes need, andsubgroupsis reached by@builtin("subgroup_invocation_id")or@builtin("subgroup_size")on a compute or fragment entry — the subgroup INTRINSICS (subgroupAddand friends) are still absent, which is a separate gap from the capability. Whether a given adapter HAS one of the four is whatreflect().requiredFeaturesis for, and a device only HAS an optional feature ifrequestDevicewas asked for it — the compile gate does exactly that, deriving the list from the corpus, which is howexamples/clip-planes.shade.tscompiles on its Tint. Its software adapter offersclip-distancesandsubgroupsbut notprimitive-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 anSD0030diagnostic 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).requiredFeaturesconst ok = hostFeaturesFor(glslEs300Backend, caps).every((e) => gl.getExtension(e))const m = ok ? fancy : plain // two modules, one decision, made onceEach 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_idis read as.xalone, the 1-D linear invocation index.- Exactly one
read_writestorage binding whose elements areu32, so its type isarray<u32>, written exactly once at indexgid.x. A scatter write, a second write, or zero writes fails. - The first
uniformbinding must bevec4<u32>, the dispatch uniform, whose.xis the invocation count and whose.yis the output-grid width. The other two components are reserved. First means first in the module’sbindingslist, so declaration order matters. - No
rawstatements 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.