SoT helpers

Rendered from AUTHORING.md at commit d894fc0. The package is imported here by its 0.1.0 name, typeshade.

SoT helpers

A vertex/uniform layout used to be hand-written in up to four places (struct decl, binding decl, binding ref node, every field access) that had to agree by hand — the source of the polygon slot-drift bug family. The SoT (single-source-of-truth) helpers declare a layout once and derive the rest, with the type checker covering field names and types.

IO structs — ioStruct

const VsOut = ioStruct('VsOut', {
pos: builtin('position', vec4fT),
uv: location(0, vec2fT),
vis: location(1, f32T),
view_w: location(2, f32T),
})
  • builtin(name, type) → a @builtin(<name>) field. name is the WGSL builtin id ('position', 'vertex_index', 'instance_index', 'front_facing', 'frag_depth', 'global_invocation_id', …), passed through verbatim — typed as the closed WgslBuiltinName union, so a typo or a GLSL-only spelling is a tsc error at the authoring line, not a naga error at pipeline creation.
  • location → a @location(n) field, optionally @interpolate(<mode>) with mode ∈ 'flat' | 'linear' | 'perspective''flat' is the mode the GLSL backend also honors (emits the flat qualifier on both sides).
  • VsOut.type — the struct’s ShaderType (use it as a param type, e.g. { input: VsOut.type }).
  • VsOut.decl — the StructDecl for the module’s structs: array.
  • VsOut.of(node).uv — typed field read off a value of the struct.
  • VsOut.var('out') — declare a var of the struct and get typed, ASSIGNABLE fields: o.pos.assign(…), then return o — the proxy duck-types as the raw node in value positions (#763 X14). o.$ is the raw struct-value node for explicit passes.
  • VsOut.construct({ pos, uv, vis, view_w }) — build the struct value in one expression (field-keyed; a missing/extra field is a TS error). This replaces the imperative var out; out.uv = …; return out when no mutation is needed.
const pin = VsOut.of(p.input) // pin.uv, pin.vis, … are typed reads
If(pin.vis.lt(0), () => { Discard() })
return RasterFragmentOutput.construct({ color: …, depth: … })

Uniforms — uniformStruct

Declares the struct + its binding together:

const U = uniformStruct(
'Uniforms',
{ group: 0, binding: 0, as: 'u' },
{
mvp: mat4x4fT,
proj_params: vec4fT,
raster_params: vec4fT,
},
)
// usage — `.field` for field access, `.x` chains straight off it:
const opacity = U.field.raster_params.x
const m = U.field.mvp
  • U.struct / U.binding — for the module’s structs: / bindings: arrays.
  • U.field.<name> — typed field access node (chain .x, .mul, … directly).
  • U.node — the binding access node (rarely needed directly).

Plain & storage-element structs — structDecl

For a storage-buffer element type or a nested struct:

export const ShapeSegment = structDecl('ShapeSegment', {
kind: u32T,
color_idx: u32T,
flags: u32T,
_pad: u32T,
p0: vec2fT,
p1: vec2fT,
p2: vec2fT,
p3: vec2fT,
})

.decl / .type for the module and as a type token; .of(node).p0 or .get(node, 'p0') for typed field reads.

Storage buffers — storageBuffer(name, element, …).at(i).field

A bound array<Element> declared from its element (a structDecl / ioStruct handle, or a scalar type). .at(i) returns the element’s typed field proxy directly — no .of(), no element-type argument:

const segmentsB = storageBuffer('segments', ShapeSegment, { group: 0, binding: 9, access: 'read' })
const seg = segmentsB.at(i) // typed
seg.p0 // → Node<'vec2<f32>'>
seg.kind // → Node<'u32'>

For a scalar element, .at(i) returns the element Node directly. .binding / .node are available for the module wiring.

Textures / samplers — resource

const tex = resource('tex', texture2dfT, { group: 0, binding: 1 })
const texSampler = resource('tex_sampler', samplerT, { group: 0, binding: 2 })
// usage:
const c = textureSample(tex.node, texSampler.node, pin.uv)

r.node keeps the specific key (Node<'texture_2d<f32>'>, Node<'sampler'>), so the texture/sampler ops are type-checked. r.binding goes in the bindings: array.

textureSample uses an implicit LOD from screen-space derivatives, so it is fragment-only (a vertex/compute use is an SD0109 lint error). Read an explicit level instead with textureSampleLevel(tex, smp, uv, level) — legal in every stage. The derivative builtins fwidth / dpdx / dpdy are fragment-only the same way (SD0109, #1654) — no vertex/compute form exists, so precompute the quantity and pass it in.

2D array textures — texture2dArrayfT

An atlas of N layers behind one binding, with the layer chosen per sample:

const atlas = resource('atlas', texture2dArrayfT, { group: 0, binding: 1 })
const atlasSampler = resource('atlas_sampler', samplerT, { group: 0, binding: 2 })
textureSample(atlas.node, atlasSampler.node, uv, layer) // implicit LOD (fragment-only)
textureSampleLevel(atlas.node, atlasSampler.node, uv, layer, level) // explicit LOD, any stage
textureLoad(atlas.node, coord, layer, level) // unfiltered texel fetch
textureNumLayers(atlas.node) // → Node<'u32'> — how many layers the atlas has

Same three function names — the first argument’s key picks the array form, and the layer argument is then required (a missing one is a tsc error, not a runtime surprise). A number layer lifts to an i32 literal. WGSL spells the layer as its own argument (textureSample(t, s, uv, layer) on a texture_2d_array<f32>); GLSL ES 3.00 folds it into the coordinate (texture(sampler2DArray, vec3(uv, float(layer)))) — both CORE, so no capability is required on either target. reflect() reports the dim on the bind entry (textureDim: '2d-array') so a host can create the matching view.

textureDimensions(atlas.node) returns the width/height only (vec2<u32>) — for an array texture too. The layer count is the separate textureNumLayers(atlas.node) (u32, #1658); wrap it in toF32 for float math. It is array-key only (a plain 2d texture is a tsc error), and the targets spell it differently — WGSL has the dedicated textureNumLayers(t), GLSL ES 3.00 has none and reads uint(textureSize(t, 0).z) (the lod argument is required there; the layer count is lod-invariant, so 0 is always right).

Integer textures — texture2duT / texture2diT (+ the array twins)

A texture whose texels are exact 32-bit integers rather than filtered floats — an id map, a packed-colour table, a bitfield lookup:

const ids = resource('ids', texture2duT, { group: 0, binding: 1 })
textureLoad(ids.node, coord, 0) // → Node<'vec4<u32>'>
textureDimensions(ids.node) // → Node<'vec2<u32>'>, same as any other texture

Four constants: texture2duT / texture2diT and texture2dArrayuT / texture2dArrayiT. The load result follows the texture’s elementvec4<u32> off an unsigned one, vec4<i32> off a signed one — so assigning it to the wrong key is a tsc error rather than a silent reinterpretation. Both are CORE in both targets (WGSL texture_2d<u32>, GLSL ES 3.00 usampler2D), so neither needs a capability.

textureSample and textureSampleLevel reject these keys at tsc, by design. Filtering is a weighted average and interpolating integer texels is undefined, so WGSL has no textureSample for texture_2d<u32> at all. GLSL’s texture(usampler2D, …) would work (NEAREST), and allowing it is exactly the trap this DSL exists to avoid: it would mint a construct that compiles on WebGL2 and cannot be expressed on WebGPU. The honest intersection of the two targets is textureLoad + textureDimensions + textureNumLayers, and that is the whole surface.

A multisampled integer texture is unrepresentable{ dim: '2d-ms', elem: 'u32' } does not typecheck, rather than throwing at emit.

reflect() reports the element alongside the dim (textureElem: 'u32'), which a host needs to build the binding: WebGPU’s sampleType must be 'uint' / 'sint', and WebGL2 must back it with an integer internal format. Getting that wrong does not raise — a texture whose format disagrees with its sampler type is merely INCOMPLETE, and texelFetch on an incomplete texture silently returns 0.

array<u32> / array<i32> storage on WebGL2

These are what let a top-level integer storage array work on the GLSL backend. WebGL2 has no SSBO, so a var<storage, read> array lowers to a data texture — and an integer one now lowers to a typed texture (usampler2D over R32UI, isampler2D over R32I) instead of failing closed:

const featIds = storageBuffer('feat_ids', u32T, { group: 0, binding: 0, access: 'read' })
featIds.at(i) // → Node<'u32'>; on GLSL this is a texelFetch, on WGSL a real SSBO read

Whoever allocates that data texture must give it the matching internal formatR32UI for the usampler2D an array<u32> lowers to, R32I for isampler2D, R32F for the float case. Nothing enforces the pairing at runtime: a texture whose format disagrees with its sampler type is merely INCOMPLETE, and texelFetch on it silently returns 0. Read the element off reflect() rather than tracking it separately.

Not the alternative you might reach for first — carrying the integers through the existing R32F texture and recovering them with floatBitsToUint. GLSL ES 3.00 §2.1.1 permits an implementation to flush any denormal to zero, and small integers are denormal f32 bit patterns (1u is 1.4e-45), so that route can legally lose values. It survives on every driver measured so far, which is precisely why it is not a foundation to build on.

Typed const handles + fn handles

Module-level WGSL consts are imported as typed handles from shaders/consts.ts instead of bare constRef('NAME') strings (a typo in a string compiles, then fails at WGSL link time):

import { PI, EARTH_R } from './consts'
const latRad = f32(2)
.mul(atan(exp(mercYAbs)))
.sub(PI.div(2))

To declare a module constant, a scalar that needs the truncated-vs-full-precision split (PI) is authored as the { wgslValue, cpuValue } ConstDecl directly. For a non-scalar constant — a vec4<f32> colour, an array<vec4<f32>, N> palette, a struct — use constExpr, which takes a constant-foldable literal Node and emits const <name>: <type> = <value>; on both WGSL + GLSL and evaluates it on the CPU oracle:

const SKY = constExpr('SKY', vec4fT, vec4(0.4, 0.6, 0.9, 1))
const PALETTE = constExpr('PALETTE', arrayT(vec4fT, 3), arrayLit(vec4fT, c0, c1, c2))

Functions are handles too — import them and call directly, no callFn('name'):

import { lonlatToEcef } from './ecef'
import { project, flat_rel } from './projections'
const ecef = lonlatToEcef(lonRad, latRad, f32(0))

A handle accepts either positional args foo(a, b) (loose NodeLike) or a typed object foo({ lon, lat }) — the object form checks arg names, types, and completeness, and autocompletes the params.

externFn is the call-only counterpart for a function whose body is linked in later (the projection fns, built after configureProjections()). You call an externFn the same way (f({a, b}) or f(a, b)); only the body-linking differs. Authors of ordinary shaders import the real fn handle.

Edit this page