Migrating a GLSL shader
On this page

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

Migrating a GLSL shader

After this page you can take the GLSL construct in front of you, look up how it is spelled in the DSL, and see what that spelling turns into on each target. The rest of this guide is ordered for someone writing a new shader. This page is ordered for someone holding a working GLSL ES 3.00 shader and asking what each line becomes.

The construct table

A construct here is one piece of GLSL source you have to account for: a uniform block, a preprocessor branch, an #include, an extension directive. Find the row, follow the spelling to the page that explains it, and read the note for what changes.

GLSL constructDSL spellingWGSL resultNotes
uniform Block { … } that this module ownsuniformStruct@group/@binding var<uniform>the std140 layout comes from reflect(), so no offset is counted by hand
uniform float u_x; that the host prelude already declaresexternVarthe same reference, spelled per targetemits nothing, and lands in reflect().requires
uniform float u_x; that this module declares and the host ownshostUniform@group/@binding var<uniform>GLSL emits a loose default-block uniform instead of a block, and reflect() marks it owner: 'host'
a whole block the host ownshostBlockone @group/@binding var<uniform>glsl: 'loose' flattens it to one uniform per member and rewrites blk.field to field on the IR. The default 'std140-block' keeps the block. WGSL keeps the block either way, because a host bind group is one unit
a function the host providesexternFnthe same call on both targetstyped at the call site, with no declaration emitted
#ifdef FEATURE where this module decidesa builder parameter and a plain ifno preprocessorthe losing arm is never built, so its bindings are never declared
#ifdef FEATURE where the host decidesvariantFamilyone module per point in the matrixemitGuarded generates the #if ladder for a GLSL host that owns the define, and every arm is byte-identical to the standalone variant. For a ladder that goes inside an #include, emitGuardedFragment returns the same ladder with the preamble as data
a variant that changes only a valueoverrideConst with overrideValuesoverride plus pipeline constantsbuilding a separate variant for this multiplies pipelines for nothing
#include "helper.glsl"emitGlslFragment and emitFragmenta module fragment the host concatenatesthe header comes back as preamble, as data
a statement-level variant slot inside one modulecomposeModule with placeholderthe samestatement slots only, so it does not replace an #include
precision highp … on one declarationthe precision option on hostUniformnothing, since WGSL has no precision qualifiersthe stage preamble is the default. This option is for a fragment composed into a host program
usampler2D and isampler2Dtexture2duT and texture2diTtexture_2d<u32> and texture_2d<i32>the sampler precision line is emitted for you
#extension … : requireenablesenable …;fails closed with SD0030 on a backend whose profile has no row. That page also says which capabilities survive on WGSL
comparing two emits after an optimizer passsemanticDiffthe samecompares IR and reflection, so folding and renaming do not drown the diff. Declare the production plugins as transforms and their rewrites classify into explained

A block this module owns is the most common first row. uniformStruct takes the WGSL type name, the slot, and the field map, and gives back typed field access:

import { mat4x4fT, uniformStruct, vec2fT } from 'typeshade'
// uniform Camera { mat4 u_matrix; vec2 u_viewport_px; } u_camera;
const camera = uniformStruct(
'Camera',
{ group: 0, binding: 0, as: 'u_camera' },
{ u_matrix: mat4x4fT, u_viewport_px: vec2fT },
)
const mvp = camera.field.u_matrix

Builtin values

A builtin is a value the hardware supplies to a stage. The DSL’s vocabulary for them is WGSL’s, typed as the closed union WgslBuiltinName, so a gl_* spelling or a typo is a tsc error that names the union. Each backend then spells the id its own way.

GLSL globalDSL spellingNotes
gl_Positionbuiltin('position', vec4fT) on the vertex outputwrites gl_Position on GLSL
gl_FragCoordbuiltin('position', vec4fT) on a fragment inputreads gl_FragCoord on GLSL. Mind the y origin: GL window space is bottom-left and WGSL framebuffer space is top-left, so flip per target, or derive a y-symmetric value, before consuming .y
gl_VertexIDbuiltin('vertex_index', u32T)GLSL wraps the read as uint(gl_VertexID), since the DSL types it u32 and GLSL’s is int
gl_InstanceIDbuiltin('instance_index', u32T)the same uint() wrap
gl_FrontFacingbuiltin('front_facing', boolT)
gl_FragDepthbuiltin('frag_depth', f32T) as the return attribute
gl_PointSize and gl_PointCoordunsupported on both writerspoint size caps vary per vendor, and WebGPU point primitives are always one pixel. Expand an instanced quad in the vertex stage and interpolate a @location(n) corner uv
float mod(x, y)the free function mod()that is floor-mod. .mod() and % are trunc-mod, which is WGSL’s semantics and spells portably on GLSL too. Pick by the semantics you mean on negative operands

A fragment stage reads the framebuffer coordinate through the same position builtin the vertex stage writes:

import { builtin, f32, fn, vec4, vec4fT } from 'typeshade'
const fsCoord = fn(
'fs_coord',
{ pos: builtin('position', vec4fT) },
(p) => vec4(p.pos.x.mul(0.001), p.pos.y.mul(0.001), f32(0), f32(1)),
{ stage: 'fragment', retAttr: '@location(0)' },
)

Declarations the host owns

Four of the rows above are about the same question, which is who declares a symbol and who owns the memory behind it. externVar is for a symbol the host prelude already declares. It emits nothing, gives you a typed reference, and takes a per-target spelling so a move to a host that exposes the value differently is a map change. externFn forward-declares a callable whose body is defined elsewhere and linked in at emit, so the call is typed and no declaration is emitted. hostUniform and hostBlock are for symbols this module declares while the host owns the storage, so they do emit a declaration and mark it owner: 'host' in reflection.

hostBlock also carries the choice a GLSL host forces, since a prelude provides either a std140 block or loose uniforms and the wrong one will not link:

import { hostBlock, mat4x4fT, vec2fT } from 'typeshade'
const camera = hostBlock(
'CameraUniforms',
{ group: 0, binding: 0, as: 'u_camera' },
{ u_matrix: mat4x4fT, u_viewport_px: vec2fT },
{ glsl: 'loose' },
)
camera.field.u_matrix // emits `u_matrix` on GLSL, `u_camera.u_matrix` on WGSL

The rewrite from u_camera.u_matrix to u_matrix happens on the IR before emit, so it cannot corrupt an unrelated substring, it survives minify(), and reflect() still describes what was emitted. A loose block’s members must be scalars, vectors or matrices, and hostBlock throws SD0016 as you declare one that is not. Their names must also not collide with another loose block’s, since flattening puts them all in one namespace; that one is caught when the GLSL writer runs, with SD0030 naming both blocks.

Targeting WebGL2 only

Nothing on this page narrows what the GLSL writer can express. The neutral names are spellings, and several of the rules behind them exist to make WebGL2 output more defined: round emits roundEven, and float % emits a trunc-mod that GLSL ES 3.00 compiles.

For a GLSL construct the neutral surface does not model, rawStmt accepts a payload for one target only, and b.raw takes the same payload inside a fn() body. The point-size row above is the case this is for: the builtin has no neutral spelling, and a WebGL2-only build can still write it. The GLSL writer splices the payload verbatim, and the WGSL writer, if it ever runs on that module, fails closed on that statement:

import { f32, f32T, fn } from 'typeshade'
const sized = fn('sized', {}, f32T, (_p, b) => {
b.raw({ glsl: 'gl_PointSize = 4.0;' })
return f32(1)
})

That is the shape to reach for when you exclude WebGPU today and may want it later. What a raw costs the rest of the module is on Raw statements.

Edit this page Report a problem