Rendered from AUTHORING.md at commit 26de7be8. The package is imported here by its 0.2.0 name, typeshade.
Production emit
After this page you can compose the ship-time transforms into an emit call, and read a driver log that comes back in the renamed text.
A bundler minifies your JavaScript and never touches the shader string you hand to
createShaderModule or to gl.shaderSource. The transforms here do that half. They live
on their own subpath, typeshade/emit-prod, so a build that never imports it
bundles none of them and a plain emit call keeps the bytes it has.
The plugin bag
An emit plugin is an EmitPlugin, a named transform you pass to an emit call, the way a
Vite or Webpack plugin is passed to a build. Both emitModule and emitGlslModule take a
plugins array in their options. A plugin acts in one of two stages: transformIR
rewrites the module before it is assembled, transformText rewrites the emitted string.
Every IR stage runs before any text stage, and within a stage they run in array order.
import { emitModule, emitGlslModule } from 'typeshade'import { mangle, minify, obfuscate } from 'typeshade/emit-prod'
const renames = new Map<string, string>()const wgsl = emitModule(m, { plugins: [mangle({ renames }), minify()] })
// obfuscate() is the standard preset: [mangle, prune, aliasTypes, minify]const vs = emitGlslModule(m, 'vertex', { plugins: obfuscate() })const fs = emitGlslModule(m, 'fragment', { parens: 'minimal', plugins: obfuscate() })parens: 'minimal' sits in the emit options beside plugins, because parenthesis is a
decision the emitter makes while writing the text. It omits a paren only where WGSL and
GLSL ES 3.00 define the same precedence: unary minus over *, / and % over + and
-. Relational, logical, bitwise and shift operators keep their parens, because WGSL
gives them no chaining precedence. It never reassociates, so a + (b + c) keeps its
parens. The default 'full' leaves every emitted byte alone.
Shorter names
Mangling is renaming the identifiers the emitted text does not need to keep. mangle()
renames helper functions, plain structs, module consts, helper parameters and every local
to short base-52 names, a, b, and on to aa. Function-scoped names restart from the
same pool in every function, so the short end of the alphabet is reused instead of
counting upward, and that reuse is where most of the bytes are. The rename is
deterministic per module, so the two GLSL stage emits agree on every shared name and the
program still links.
Pass a Map as renames and you receive authored to emitted names. That map is the
shader source map. Keep it out of the shipped bundle.
const renames = new Map<string, string>()const wgsl = emitModule(m, { plugins: [mangle({ renames })] })
renames.get('terrain_shade') // 'b'renames.get('noise.coordinate') // 'f'; a function-scoped key is `authoredFn.authoredName`Smaller text
Three plugins shrink the text itself.
minify() lexes the emitted source and re-emits the token stream, writing a separator
only where the two neighbours would otherwise merge into one token. Comments go, #
directives keep their own line, and numeric literals are canonicalized without changing
their value, so 0.500 becomes .5. { numbers: 'f32' } re-spells each float as the
shortest decimal that rounds to the same f32. Pass { numbers: false } when diffing
against a hand-checked baseline.
aliasTypes() gives each heavily used type a short name and declares it once, WGSL
alias A=vec2<f32>; and GLSL #define A vec2. Both targets accept the short name
everywhere the type was spelled, constructor position included. Both languages reserve
type names, so mangle() may not touch them and they are the largest category left after
mangling. A spelling that would not pay for its own declaration is skipped. It reports
type to alias into the same renames map.
prune() drops GLSL forward prototypes whose definition already declares the function at
each of its uses, and keeps every other one. It is a no-op on WGSL. The GLSL backend
emits a prototype only where the call graph forces one, so reach for this on GLSL the
backend did not author: a raw body, or a fragment a host splices in.
import { aliasTypes, minify, minifyShaderText, prune } from 'typeshade/emit-prod'
const glsl = emitGlslModule(m, 'fragment', { parens: 'minimal', plugins: [prune(), aliasTypes({ renames }), minify({ numbers: 'f32' })],})
minifyShaderText(wgsl, { numbers: 'f32' }) // the same pass over a string you holdInlining
inline() flattens the call graph. Every safely inlinable helper is inlined at all its
call sites, so those functions disappear from the output. Single-return helpers inline by
expression substitution, and single-exit multi-statement helpers inline by lifting their
statements into the caller. Entry points and recursive functions are always left intact.
It is not a size win, because a helper called from several places is duplicated at each
one. The point is removing structure a reader could follow, so pair it with mangle() and
minify(). The preset leaves it out. Place it before mangle(), since both act in the IR
stage.
Its one axis is opaque, which decides what happens to helpers carrying the
do-not-optimize flag that the f64 lowering stamps on the emulation library it injects.
'keep', the default, leaves them alone. 'single-call' also unlocks the ones with one
call site, where removing the declaration and its call duplicates nothing. 'all' unlocks
every one, and costs 5.1x to 27.2x the emitted bytes. maxGrowth caps how far the
module’s operation count may grow while unlocking, as a multiplier, and report collects
one decision per helper considered.
import { inline, obfuscate, type InlineDecision } from 'typeshade/emit-prod'
const decisions: InlineDecision[] = []const wgsl = emitModule(m, { parens: 'minimal', plugins: [inline({ opaque: 'all', maxGrowth: 4, report: decisions }), ...obfuscate()],})
decisions[0] // { fn, callSites, ops, growth, inlined, reason: 'inlined' | 'over-budget' | 'not-inlinable' }Decoding a driver log
The shipped text is unreadable on purpose, so a driver error reads
no matching overload in 'b' for arg of type 'l'. decodeShaderLog(log, renames) turns
that back into authored names. Substitution is token-wise, so the driver’s own prose, its
line numbers and its source excerpts are untouched, and a short name inside a longer word is
never a hit. Decoding is a build-time step, so the decoder and the map both stay on the
emit-prod subpath and out of the shipped bundle.
A name that inverts uniquely is replaced, which covers module-scope names and type
aliases, the ones a driver actually names. A function-scoped name is reused across
functions on purpose, so one that inverts to several candidates is annotated with all of
them. invertRenames(renames) hands you the table as data.
import { decodeShaderLog, invertRenames } from 'typeshade/emit-prod'
decodeShaderLog("no matching overload in 'b' for arg of type 'l'", renames)// "no matching overload in 'terrain_shade' for arg of type 'vec2<f32>'"
decodeShaderLog('undeclared identifier f', renames)// 'undeclared identifier f⟨coordinate (in noise) | tint (in shade)⟩'
invertRenames(renames).get('b') // { emitted: 'b', authored: ['terrain_shade'] }Names that are never renamed
Some names are the interface a host resolves at run time, so mangling them would break the
binding. Five kinds are left alone. Entry-point names, because a WebGPU pipeline names its
entryPoint. Entry-point parameter names, because a non-struct entry parameter is the GLSL
varying name, and the vertex side spells that varying from its return struct’s field name in
a separate emit call. Binding names, including the _fp64 guard, because hosts resolve them
by name. Binding-struct names, because that is the GLSL uniform block tag. Struct field
names, because they carry std140 packing and because GLSL varyings link the two stages by
name. A module that holds a raw statement is left unmangled altogether, which
Raw statements explains.
const renames = new Map<string, string>()emitModule(m, { parens: 'minimal', plugins: obfuscate({ renames }) })
renames.get('terrain_shade') // 'b', a helper functionrenames.get('vec2<f32>') // 'l', a type aliasrenames.has('U') // false; a binding name a host resolvesrenames.has('fs') // false; an entry-point name a pipeline namesProving the shipped module is the one you tested
semanticDiff(dev, prod) reports the differences between two modules in four buckets, and
a production pipeline moves lines into some of them by design. Hand it the same plugin
array as transforms and every difference your declared pipeline provably causes is
classified out of those buckets into explained, each entry naming the plugin, the bucket
and the fact line.
import { isSemanticallyEqual, semanticDiff } from 'typeshade'
const d = semanticDiff(devModule, prodModule, { transforms: [inline(), ...obfuscate()] })
isSemanticallyEqual(d) // true when prod differs from dev only as the declared pipeline dictatesd.explained // [{ transform: 'inline', bucket: 'controlFlow', line: '…' }, …]Classification is by construction. A line moves to explained only when applying the
declared plugin’s own transformIR to the dev side actually removes it from the diff, so
a regression that resembles an optimizer rewrite stays in its bucket and a parity gate
budgets only the residue. Text-stage plugins explain nothing, because the comparator never
sees emitted text, so declaring the whole production array is safe.
Over the example corpus in this repository obfuscate() with parens: 'minimal' takes
plain emit from 182,437 characters to 93,753, and two gates hold it to its properties.
examples/minify-safety.test.ts asserts that the lexed token stream and every literal’s
f32 value survive minification and that the pass is idempotent.
examples/reserved-word-safety.test.ts runs the corpus through obfuscate() and through
[inline(), ...obfuscate()] and asserts that neither plugin invents a name either
language reserves.