Skip to content

(shader-module …)

Defines a named shader module containing WGSL code. The source is opaque text to SJON (the S-expression source language); pipelines reference the module by name for their vertex, fragment, or compute stages.

(shader-module :name name :code """
@fragment fn fs() -> @location(0) vec4f { return vec4f(1.0, 0.0, 0.0, 1.0); }
""")
Key Type Required Default Description
:name symbol Yes - Unique module name
:code string Yes - Complete WGSL shader code

Type: string

The complete WGSL shader source code including all entry points. Use a triple-quoted string ("""…""") for multi-line WGSL; the body is preserved verbatim.

(shader-module :name triangle :code """
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2f(0.0, 0.5),
vec2f(-0.5, -0.5),
vec2f(0.5, -0.5)
);
return vec4f(pos[i], 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return vec4f(1.0, 0.5, 0.0, 1.0);
}
""")

There is no (uniform …) form and no uniform-metadata key. Uniforms are declared in the WGSL:

@group(G) @binding(B) var<uniform> NAME : Type;

and the compiler reflects every var<uniform> binding into PNGB’s (the compiled bytecode’s) uniform table, so the runtime can address a field by its WGSL name without recompiling: setUniform(p, 'time', 0.25) from the JavaScript API. Nested structs are flattened into dot-notation paths at compile time, so a material.albedo field is addressed as "material.albedo", and a fixed-size array element is addressed with a dotted index, env.1.

A binding declared in the WGSL has to be bound in the document. A (bind-group …) entry supplies the resource; leave it out and the compiler reports binding ‘u’ (@group(0) @binding(0)) is used by the shader but never bound in SJON.

(shader-module :name main :code """
struct Uniforms {
time: f32,
resolution: vec2f,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
return vec4f(f32(i) - 1.0, f32(i & 1u) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return vec4f(sin(u.time), 0.0, 0.0, 1.0);
}
""")
(buffer :name uniformBuffer :size 16 :usage [uniform copy-dst])
(render-pipeline :name pipeline :layout auto
(vertex :module main :entry vs)
(fragment :module main :entry fs
(target :format preferred-canvas-format)))
(bind-group :name bindings :layout pipeline :group 0
(entry :binding 0 :buffer uniformBuffer))
(shader-module :name code :code """
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(
vec2f(0.0, 0.5),
vec2f(-0.5, -0.5),
vec2f(0.5, -0.5)
);
return vec4f(pos[i], 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return vec4f(1.0, 0.0, 0.0, 1.0);
}
""")
(render-pipeline :name pipeline
:layout auto
(vertex :module code :entry vs)
(fragment :module code :entry fs
(target :format preferred-canvas-format)))

A stage’s :entry may be omitted when the module holds exactly one entry point of that kind: (vertex :module code) picks vs here. With none, or with several, the compiler asks for a name.

(shader-module :name mesh :code """
struct Inputs {
mvp: mat4x4f,
time: f32,
}
@group(0) @binding(0) var<uniform> inputs: Inputs;
@vertex fn vs(@location(0) pos: vec3f) -> @builtin(position) vec4f {
return inputs.mvp * vec4f(pos, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return vec4f(sin(inputs.time), 0.0, 0.0, 1.0);
}
""")
(buffer :name uniformBuffer :size 80 :usage [uniform copy-dst])
(render-pipeline :name meshPipeline :layout auto
(vertex :module mesh :entry vs
(vertex-buffer :array-stride 12
(attribute :shader-location 0 :offset 0 :format float32x3)))
(fragment :module mesh :entry fs
(target :format preferred-canvas-format)))
(bind-group :name meshBindings :layout meshPipeline :group 0
(entry :binding 0 :buffer uniformBuffer))
(queue :name writeUniforms
(write-buffer :buffer uniformBuffer :offset 64 :data pngine-inputs))

pngine-inputs is the built-in 16-byte runtime source (time, canvas width, canvas height, aspect), written here after the 64-byte matrix.

(shader-module :name particleStep :code """
struct Particle {
pos: vec2f,
vel: vec2f,
}
@group(0) @binding(0) var<storage, read> particlesA: array<Particle>;
@group(0) @binding(1) var<storage, read_write> particlesB: array<Particle>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
particlesB[i].pos = particlesA[i].pos + particlesA[i].vel;
particlesB[i].vel = particlesA[i].vel;
}
""")
(buffer :name particlesA :size 16384 :usage [storage])
(buffer :name particlesB :size 16384 :usage [storage])
(compute-pipeline :name step :layout auto
(compute :module particleStep :entry main))
(bind-group :name particleBindings :layout step :group 0
(entry :binding 0 :buffer particlesA)
(entry :binding 1 :buffer particlesB))
Rule Error
Two (shader-module …) forms may not share a name duplicate_cross_ref_target
A name already declared by another form kind is a clash: names are one namespace duplicate name 'x': already declared as a (buffer …) at line L
:code key is required missing_required_key
The WGSL must compile located WGSL diagnostic (compile and render skip it with --no-validate)
Every binding the WGSL declares must be bound by a (bind-group …) entry binding 'u' (@group(0) @binding(0)) is used by the shader but never bound in SJON
A named :entry must exist in the module entry point 'nope' not found in shader module 'main'
An omitted :entry needs exactly one entry point of that stage kind shader module 'm' declares 2 fragment entry points, so pngine cannot infer which one this stage uses

Compiles to GPUShaderModule via:

device.createShaderModule({
code: shaderCode
});