Skip to content

(render-pipeline …)

Creates a render pipeline for vertex and fragment shader execution.

(render-pipeline :name pipelineName :layout auto
(vertex :module shaderName :entry vs
(vertex-buffer :array-stride 12
(attribute :shader-location 0 :offset 0 :format float32x3)))
(fragment :module shaderName :entry fs
(target :format preferred-canvas-format))
(primitive :topology triangle-list :cull-mode back :front-face ccw)
(depth-stencil :format depth24plus :depth-write-enabled true :depth-compare less)
(multisample :count 4))
Key / Sub-form Type Required Default Description
:name symbol Yes - Pipeline name
:layout auto or reference Yes - auto, or a declared (pipeline-layout …)
(vertex …) sub-form Yes - Vertex stage (exactly one)
(fragment …) sub-form No - Fragment stage (at most one)
(primitive …) sub-form No - Primitive assembly (at most one)
(depth-stencil …) sub-form No - Depth/stencil state (at most one)
(multisample …) sub-form No - MSAA state (at most one)

The five sub-forms are positional children, in any order. A second one of the same kind is positional_too_many; a missing (vertex …) is positional_missing.

Type: auto, or the name of a (pipeline-layout …)

:layout is one required key, mirroring GPUPipelineDescriptorBase.layout, which is one required member. auto derives the bind-group layouts from the WGSL, which is what most documents want:

(shader-module :name code :code """
@vertex fn vs() -> @builtin(position) vec4f { return vec4f(0, 0, 0, 1); }
@fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); }
""")
(render-pipeline :name pipe :layout auto
(vertex :module code :entry vs)
(fragment :module code :entry fs
(target :format bgra8unorm)))

For an explicit layout (binding @group(0), @group(1), … to distinct (bind-group-layout …) forms), declare a (pipeline-layout …) and name it here instead of auto:

(bind-group-layout :name bgl
(entry :binding 0 :visibility [fragment] (buffer :type uniform)))
(pipeline-layout :name pl :bind-group-layouts [bgl])
(render-pipeline :name pipe :layout pl
(vertex :module mod :entry vs)
(fragment :module mod :entry fs (target :format bgra8unorm)))

auto is a builtin spelling, so no form can be named auto. A :layout value that is neither auto nor a declared (pipeline-layout …) is a union_no_branch_matched validation error, naming a (bind-group-layout …) here included.

Vertex shader stage, mirroring GPUVertexState.

Key / Sub-form Type Required Description
:module reference Yes The (shader-module …) holding the @vertex entry
:entry symbol No Entry function name; defaults to the module’s sole @vertex fn
(vertex-buffer …) sub-form No One per bound vertex slot, in slot order
(constant …) sub-form No WGSL override values for this stage

:entry is optional: a module with exactly one @vertex function is used without naming it. Zero or several is a located compile error (shader module 'code' declares 2 vertex entry points, so pngine cannot infer which one this stage uses), as is naming a function the module does not declare.

Each (vertex-buffer …) is one bound vertex slot, holding its (attribute …) children:

Key / Sub-form Type Required Description
:array-stride number Yes Bytes between elements; multiple of 4
:step-mode symbol No vertex (default) or instance
(attribute …) sub-form No Attribute definitions

Attribute sub-form:

Key Type Required Description
:shader-location number Yes @location(N)
:offset number Yes Byte offset in vertex
:format symbol Yes Vertex format

Common vertex formats:

Format Description
float32 Single float
float32x2 vec2f
float32x3 vec3f
float32x4 vec4f
unorm8x4 4 normalized u8

The full GPUVertexFormat set is accepted: uint8/sint8/unorm8/snorm8 and their 16-bit counterparts in scalar, x2 and x4 widths, float16/ float16x2/float16x4, the 32-bit float32/uint32/sint32 in scalar, x2, x3 and x4 widths, plus the packed unorm10-10-10-2 and unorm8x4-bgra.

(vertex :module shader :entry vs
(vertex-buffer :array-stride 24
(attribute :shader-location 0 :offset 0 :format float32x3)
(attribute :shader-location 1 :offset 12 :format float32x3)))

Ordering is positional: the first (vertex-buffer …) is slot 0 and pairs with the first entry of the render pass’s :vertex-buffers [...] list. Omit the (vertex-buffer …) children entirely for pipelines that generate their vertices from @builtin(vertex_index).

Fragment shader stage, mirroring GPUFragmentState.

Key / Sub-form Type Required Description
:module reference Yes The (shader-module …) holding the @fragment entry
:entry symbol No Entry function name; defaults to the module’s sole @fragment fn
(target …) sub-form Yes Color target states; at least one
(constant …) sub-form No WGSL override values for this stage

A fragment stage with no (target …) is positional_missing. WebGPU allows an empty target list, for a depth-only stage; PNGine’s two backends do not build the same pipeline from one, so the floor is stated here instead.

One color target:

Key / Sub-form Type Required Default Description
:format symbol Yes - Format of the attachment this target renders to
:write-mask number or all No 15 Channel write mask
(blend …) sub-form No - Blend state (at most one)

:format takes preferred-canvas-format (resolved at runtime to the platform’s canvas format) or one of the 42 colour-renderable GPUTextureFormat spellings the schema lists, such as bgra8unorm, rgba8unorm, rgba16float or r32uint; a depth format or an unknown spelling is a not_member diagnostic.

:write-mask is either an integer bitmask (R=1 G=2 B=4 A=8, so 5 writes red and blue, and hex works: 0xF) or the symbol all (15, the WebGPU default). :write-mask 0 writes no colour at all, the idiom for a depth-only or occlusion pre-pass that still needs a fragment stage. all is the only symbol it accepts; a (define …) constant is written (* MASK 1) here.

Blend sub-form holds positional (color …) and (alpha …) components, both required, each taking an optional :src-factor (WebGPU default one), :dst-factor (default zero) and :operation (default add):

(blend
(color :src-factor src-alpha :dst-factor one-minus-src-alpha :operation add)
(alpha :src-factor one :dst-factor one-minus-src-alpha :operation add))

Blend factors: zero, one, src, one-minus-src, src-alpha, one-minus-src-alpha, dst, one-minus-dst, dst-alpha, one-minus-dst-alpha, src-alpha-saturated, constant, one-minus-constant. The constant factors multiply by the render pass’s :blend-constant.

Four more (src1, one-minus-src1, src1-alpha, one-minus-src1-alpha) reference the fragment shader’s second colour output (@blend_src(1)) and need the dual-source-blending device feature.

Operations: add, subtract, reverse-subtract, min, max (the :operation key is optional; min/max ignore the factors)

A (blend …) on an integer target format is a located compile error: WebGPU blends only blendable formats, and no uint or sint format is one.

Specialises a WGSL override with a compile-time value. The declaration becomes a const in the shipped shader, so the value participates in const-folding and dead-code elimination.

Key Type Required Description
:name symbol Yes The WGSL override identifier
:value number Yes Literal, expression over (define …) constants, or a bare define name
(shader-module :name code :code """
override SCALE: f32;
@vertex fn vs() -> @builtin(position) vec4f { return vec4f(0, 0, 0, 1); }
@fragment fn fs() -> @location(0) vec4f { return vec4f(SCALE, 0, 0, 1); }
""")
(render-pipeline :name pipe :layout auto
(vertex :module code :entry vs)
(fragment :module code :entry fs
(target :format bgra8unorm)
(constant :name SCALE :value 0.5)))

Constants are written per stage but resolved per module: the name must be a real override in that stage’s module, the value must fit the override’s declared WGSL type, and two stages specialising one module’s override differently is an error.

Primitive assembly configuration, mirroring GPUPrimitiveState. Omit the whole sub-form when the source authors no primitive state; the compiler’s emitter only writes what you declare.

Key Type Default Description
:topology symbol triangle-list Primitive topology
:front-face symbol ccw Front face winding
:cull-mode symbol none Culling mode
:strip-index-format symbol - uint16 / uint32: index width for indexed strip draws; legal only under a strip topology
:unclipped-depth boolean false Clamp fragment depth to [0,1] instead of clipping (needs depth-clip-control)

:strip-index-format is checked both ways. The key exists only under :topology triangle-strip or :topology line-strip: the schema declares it in a variant on those two values, so writing it under a list topology, or beside no :topology at all, is an unknown_key validation error whose message adds that :topology must be set before variant-only keys. The other direction is the compiler’s, because it crosses pipeline and pass: a (draw-indexed …) in a pass whose pipeline is a strip without it is an error too.

:unclipped-depth disables depth clipping and relies on the depth-clip-control device feature, which the runtime enables opportunistically when the adapter supports it.

Topologies:

Value Description
point-list Individual points
line-list Separate lines
line-strip Connected lines
triangle-list Separate triangles
triangle-strip Connected triangles

Cull modes: none, front, back

Front face: ccw (counter-clockwise), cw (clockwise)

Depth and stencil testing configuration, mirroring GPUDepthStencilState. This is pipeline state; the texture it tests against is pass state, declared by (depth-stencil-attachment …).

Key / Sub-form Type Required Default Description
:format symbol Yes - Depth/stencil attachment format this pipeline renders to
:depth-write-enabled boolean Conditional - Write to the depth buffer
:depth-compare symbol Conditional - Depth test function
:depth-bias number No 0 Constant depth bias (signed integer)
:depth-bias-slope-scale number No 0 Slope-scaled bias
:depth-bias-clamp number No 0 Bias clamp (0 = unclamped)
:stencil-read-mask number No 0xFFFFFFFF Mask ANDed with stencil values for tests
:stencil-write-mask number No 0xFFFFFFFF Mask ANDed with stencil writes
(stencil-front …) sub-form No - Front stencil state (at most one)
(stencil-back …) sub-form No - Back stencil state (at most one)

:format must name a depth or stencil format, never a colour one, and it must carry every aspect the pipeline uses. Four rules read the format’s value, so they are the compiler’s rather than the schema’s, and each is reported where you wrote it:

  • :depth-write-enabled is required once :format has a depth aspect. WebGPU gives it no default, so an omitted one used to mean the compiler picked.
  • :depth-compare is required once the pipeline writes depth, and also once a stencil face states a :depth-fail-op other than keep, since that op consults the depth test.
  • A stencil face state on a format with no stencil aspect (depth24plus, say) is an error, as is a colour format anywhere in :format.
  • :depth-bias, :depth-bias-slope-scale and :depth-bias-clamp must be 0 under a point or line topology: depth bias is derived from a triangle’s depth slope.

Compare functions: never, less, equal, less-equal, greater, not-equal, greater-equal, always

(stencil-front …) and (stencil-back …)

Section titled “(stencil-front …) and (stencil-back …)”

Per-face stencil state. The two faces are independent: an unauthored face takes the spec default (compare always, all ops keep), not a copy of the other one.

Key Type Required Default Description
:compare symbol No always Stencil reference (masked) vs stored value
:fail-op symbol No keep Applied when the stencil test fails
:depth-fail-op symbol No keep Applied when stencil passes but depth fails
:pass-op symbol No keep Applied when both tests pass

Stencil operations: keep, zero, replace, invert, increment-clamp, decrement-clamp, increment-wrap, decrement-wrap. The reference value that compare and replace use is pass state, :stencil-reference.

MSAA state, mirroring GPUMultisampleState.

Key Type Required Default Description
:count number No 1 Samples per pixel (1 or 4)
:mask number No 0xFFFFFFFF Sample coverage bitmask
:alpha-to-coverage-enabled boolean No false Derive coverage from fragment alpha

:count must equal the :sample-count of every attachment texture the pipeline renders to; a mismatch is a located compile error naming both. A texture’s :sample-count is 1 or 4 and nothing between, so those are the two counts a pipeline can carry.

:alpha-to-coverage-enabled requires :count above 1, a (fragment …) stage, and a first (target …) whose format has an alpha channel to derive the mask from.

(define :name SAMPLE_COUNT :value 4)
(shader-module :name code :code """
@vertex fn vs() -> @builtin(position) vec4f { return vec4f(0, 0, 0, 1); }
@fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); }
""")
(render-pipeline :name msaaPipeline :layout auto
(vertex :module code :entry vs)
(fragment :module code :entry fs
(target :format preferred-canvas-format))
(multisample :count SAMPLE_COUNT))
(shader-module :name code :code """
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
var pos = array<vec2f, 3>(vec2f(0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));
return vec4f(pos[i], 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(1, 0, 0, 1); }
""")
(render-pipeline :name triangle :layout auto
(vertex :module code :entry vs)
(fragment :module code :entry fs
(target :format preferred-canvas-format)))
(render-pipeline :name mesh :layout auto
(vertex :module shader :entry vs
(vertex-buffer :array-stride 32
(attribute :shader-location 0 :offset 0 :format float32x3)
(attribute :shader-location 1 :offset 12 :format float32x3)
(attribute :shader-location 2 :offset 24 :format float32x2)))
(fragment :module shader :entry fs
(target :format preferred-canvas-format))
(primitive :topology triangle-list :cull-mode back :front-face ccw)
(depth-stencil :format depth24plus :depth-write-enabled true :depth-compare less))
(render-pipeline :name instanced :layout auto
(vertex :module shader :entry vs
(vertex-buffer :array-stride 12 :step-mode vertex
(attribute :shader-location 0 :offset 0 :format float32x3))
(vertex-buffer :array-stride 16 :step-mode instance
(attribute :shader-location 1 :offset 0 :format float32x4))))
(render-pipeline :name transparent :layout auto
(vertex :module shader :entry vs)
(fragment :module shader :entry fs
(target :format preferred-canvas-format
(blend
(color :src-factor src-alpha :dst-factor one-minus-src-alpha :operation add)
(alpha :src-factor one :dst-factor one-minus-src-alpha :operation add)))))
Rule Error
:layout is required missing_required_key
:layout must be auto or a declared (pipeline-layout …) union_no_branch_matched
Exactly one (vertex …) sub-form positional_missing / positional_too_many
A (fragment …) stage needs at least one (target …) positional_missing
A (blend …) needs both (color …) and (alpha …) positional_missing
:strip-index-format only under a strip topology unknown_key
:array-stride must be a multiple of 4 number_not_multiple
:write-mask takes an integer 0-15 or all number_above_max for 16 and up; not_member for any other symbol
Two pipelines may not share a name duplicate_cross_ref_target
Referenced modules must exist not_cross_ref

A name is one namespace across every form kind, so a pipeline that reuses a buffer’s or texture’s name is a compiler diagnostic instead: duplicate name 'x': already declared as a (buffer …) at line N. The other compiler-level rules are the value-reading ones listed above under (depth-stencil …), (multisample …), (target …) and (constant …); they carry a located message and no code.

Maps to GPURenderPipeline via:

device.createRenderPipeline({
layout: 'auto',
vertex: { module: shaderModule, entryPoint: 'vs', buffers: [...] },
fragment: { module: shaderModule, entryPoint: 'fs', targets: [...] },
primitive: { topology: 'triangle-list', cullMode: 'back' },
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
multisample: { count: 4 }
});