WebGPU and WebGL2
On this page

WebGPU and WebGL2

A host uses a TypeShade module one of three ways. It compiles the module and hands the shader text to its own WebGPU or WebGL2 code, which owns the device, the pipelines, the bind groups, the buffers and the textures. It imports the module through the Vite plugin and calls its entry points, and TypeShade's runtime creates those objects for the calls. Or it loads the compiled program into the program runtime and draws it in frames of its own: the runtime creates those objects, and the host keeps its render state and its frame loop. Knowing which side owns what is most of what a first TypeShade program needs.

Ownership

One row per object a WebGPU application creates, for a host that compiles the module and wires it into its own code.

ObjectWhat the application doesWhat TypeShade contributes
DeviceAsks for an adapter and a GPUDevice, and keeps them for the life of the page.Nothing. The compiled module touches no WebGPU object.
PipelineCreates a render or compute pipeline and names an entry point for each stage.The shader text of the module, and the name of every entry point in it.
Bind group layoutDescribes each binding by its group, its index, its kind and the stages that see it.That same description, read back from the compiled module by reflect().
BufferAllocates the buffer and writes the bytes into it.The offset, the size and the type of every field of a uniform struct.
Texture and samplerCreates them and puts them in a bind group.The binding the shader declares and the type it expects to find there.

Importing a module

A host can import a .shade.ts into its own TypeScript through the typeshade/vite plugin instead, and call what the module exports. A helper runs on the CPU. A @compute entry is await entry(bindings, workgroups), and a full-screen @fragment entry is entry(canvas, bindings), drawn on WebGPU, then WebGL2, then the CPU. The application's column of the table above is then the runtime's: it requests the device on the first call and every later call shares it, builds each entry's pipeline, packs each binding from the object the call passes, and reads back what a compute entry wrote.

Loading a program

A host that draws its own frames, an engine or a renderer, loads each compiled program into the program runtime, typeshade/runtime, and runs it there. A program travels as its manifest, one object that holds the shader code, the bindings and the entry points. packModule(compile(source).module) returns it, a module's host import gives it as its default export, and it is plain JSON a build can write to disk.

import { createRuntime } from 'typeshade/runtime'
import scene from './scene.shade.ts' // the manifest
const rt = await createRuntime({ device })
const format = navigator.gpu.getPreferredCanvasFormat()
context.configure({ device, format })
const draw = await rt.load(scene).render({ targets: [format] })
const frame = rt.frame()
frame.pass({ color: [context] }, (pass) => {
pass.draw(draw, { u: { time } }, { count: 3 })
})
await frame.submit()

The runtime then does most of what the table above gives the application. It uses the application's GPUDevice when createRuntime({ device }) names one, and never destroys it, or requests a device with the features the programs need. It builds every pipeline and bind group from the layouts in the manifest, packs each binding from the value a draw or a dispatch passes by name, and makes the buffers, textures and samplers. The application writes what the compiler cannot know: each pipeline's targets, depth and topology, and when a frame is drawn. A GPUBuffer, GPUTexture or GPUSampler of its own binds as it is, and the frame's encoder and a pass's raw encoder take its own commands.

Set a program's overrides by name through RenderState.constants for a draw or the constants option of Program.compute() for a dispatch. Frame.submit() returns the console's line and dropped-call counts for each recorded entry. A host with a console sink reads those counts to report calls that did not fit in the recording buffer.

A host that enables console recording after the build can pass repack from typeshade/emit as createRuntime({ emit: repack }). Build the manifest with packModule(module, { ir: true }) so it carries the portable IR. The load-time emitter uses the emit options stored in the manifest and carries no TypeScript front end.

The program runtime runs on WebGPU only. A host that draws on WebGL2 compiles the module, or imports it and calls its entry points.

Reflection

reflect() reads a compiled module and returns its bindings: the group and index the shader declared, the address space, the access the shader needs, and for a uniform struct the fields with their offsets and sizes under the std140 and std430 layouts. A host builds its bind group layout entries out of that list and packs its uniform buffer from those offsets. The numbers the shader was compiled with are the numbers the host writes, so the two sides stay in step.

const shader = emitModule(paintModule)
const layout = reflect(paintModule)
for (const group of layout.bindGroups) {
for (const entry of group.entries) {
// entry.group, entry.binding, entry.space, entry.access, entry.resourceKind
}
}
for (const struct of layout.uniforms) {
for (const field of struct.fields) {
// field.name, field.type, field.offset, field.size
}
}

A field renamed in the shader changes the reflection at the next build, and the host code that reads the reflection follows it.

Runtime

A module the host compiles needs nothing of TypeShade in the browser. The compiler runs where the shader text is produced: in a build, in a test, or in an editor through the language service, and what reaches the browser is the emitted shader source and the host code the application already had. A module the host imports carries typeshade/runtime into the bundle, the code its calls run on, and no compiler, because the Vite plugin compiles the module when the bundle is built. A call runs on the first tier the browser has, WebGPU, then WebGL2, then the CPU; configure({ prefer }) orders the tiers a compute entry and a kernel function try, and a Resident keeps an array on the GPU between calls. A host that loads programs into the program runtime ships the same typeshade/runtime and the manifests, and no compiler either. TypeShade installs 1 runtime dependency, TypeScript, which compile() and the language service read source with.

Where WebGL2 differs

The same source compiles for WebGL2, and the host side of it looks different.

  • There are no bind groups. A uniform block is bound to a binding point on the linked program and a sampler is set through its uniform location, so a host uses the same reflection in a different shape.
  • There is no compute stage. A module with a @compute entry emits WGSL and refuses to emit GLSL ES 3.00.
  • Precision belongs to the source. Every emitted GLSL ES 3.00 program declares its default precision above its declarations, which WGSL has no need of.
  • A GPU feature is turned on by the host, which asks the context for the extension, and where the extension has a directive the emitted source declares it as well. Compiler internals describes how the compiler splits those halves.

Further reading

Next in this path: WGSL and GLSL, which prints what one source compiles to on both targets.

Edit this page Report a problem