Skip to content

References

In the JavaScript API you wire WebGPU together by holding object references: createRenderPipeline returns a value, and you hand that value to setPipeline. A document has no values to hold, only names. So in PNGine SJON (the S-expression source language) every resource declares a :name, every slot that needs a resource takes a bare identifier, and the validator resolves each identifier document-wide against the declared forms. A name that doesn’t resolve to a declaration of the expected kind is a not_cross_ref diagnostic.

The triangle from Getting Started is already a resolution chain:

(frame :name main)
:perform [pass]
|
v
(render-pass :name pass) --:pipeline--> (render-pipeline :name pipeline)
:module
|
v
(shader-module :name code)

Three names, three slots, and each slot knows what kind of form its name must find. This page is the rulebook for that resolution: which slot admits which kinds, the built-in spellings that are not names at all, and the one-namespace rule that keeps every link in such a chain unambiguous (that rule gets its own section below).

Reference resources by their bare name, with no # prefix and no quotes:

:pipeline mainPipeline
:buffer vertexData
:module shaderCode

The slot determines which kind of declaration the name must resolve to. When you write :module code, the validator requires a (shader-module …) named code.

Every slot that resolves a name, and what it resolves to:

Slot Resolves to
:module (pipeline stage) (shader-module …)
:pipeline (render pass, render bundle) (render-pipeline …)
:pipeline (compute pass) (compute-pipeline …)
:layout (pipeline) auto, or a (pipeline-layout …)
:layout (bind group) (render-pipeline …), (compute-pipeline …) or (bind-group-layout …)
:bind-group-layouts (bind-group-layout …)
:buffer, :vertex-buffers, :index-buffer (buffer …)
:texture (texture …); a copy endpoint’s :texture also takes context-current-texture
:texture-view (texture-view …)
:view (colour attachment) context-current-texture or a (texture …)
:view (depth-stencil attachment) (texture …)
:sampler (sampler …)
:data (buffer, image-bitmap) (data …)
:data (write-buffer) a runtime source, a (data …), or a (wasm-call …)
:image (image copy source) (image-bitmap …)
:query-set, :occlusion-query-set (query-set …)
:bind-groups (bind-group …)
:execute-bundles (render-bundle …)
:init (compute-pass …), or (init …), which lowers to one
:before (queue …)
:perform (render-pass …), (compute-pass …) or (queue …)
numeric slots (:size, :offset, :vertex-count, …) (define …)

A slot admits one target group, and every :name in the document lives in one namespace, so a reference has exactly one possible match. Where a group spans several kinds (a bind group’s :layout, a frame step), the one namespace is what keeps the choice unambiguous.

The same chain, slot family by slot family. Each example is the triangle’s shape with one kind of reference swapped into focus.

The base chain, in full:

(shader-module :name code :code """
@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(1.0, 0.4, 0.1, 1.0);
}
""")
(render-pipeline :name mainPipeline :layout auto
(vertex :module code :entry vs)
(fragment :module code :entry fs
(target :format preferred-canvas-format)))
(render-pass :name pass
(color-attachment :view context-current-texture
:clear-value [0 0 0 1] :load-op clear :store-op store)
:pipeline mainPipeline ; resolves to the (render-pipeline …)
(draw :vertex-count 3))

Split the one module in two, and each stage names its own:

(shader-module :name vertexShader :code """
@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);
}
""")
(shader-module :name fragmentCode :code """
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.2, 0.7, 1.0, 1.0); }
""")
(render-pipeline :name render :layout auto
(vertex :module vertexShader :entry vs) ; (shader-module …)
(fragment :module fragmentCode :entry fs ; (shader-module …)
(target :format preferred-canvas-format)))

A stage’s :entry is optional when the module holds exactly one entry point of that kind: (vertex :module vertexShader) picks vs on its own. Zero or several, and the compiler asks you to name one.

Give the pass geometry to draw, and two more names enter the chain:

(buffer :name positions :size 1024 :usage [vertex])
(buffer :name indices :size 256 :usage [index])
(render-pass :name draw
(color-attachment :view context-current-texture
:clear-value [0 0 0 1] :load-op clear :store-op store)
:pipeline meshPipeline
:vertex-buffers [positions] ; resolves to the (buffer …)
:index-buffer indices ; resolves to the (buffer …)
(draw-indexed :index-count 100))

A bind group states one :layout, naming either a pipeline (whose auto-derived layout it uses, selected by :group) or an explicit (bind-group-layout …):

(buffer :name settings :size 16 :usage [uniform copy-dst])
(texture :name noise :size [64 64] :format rgba8unorm :usage [texture-binding])
(bind-group :name uniforms :layout drawPipeline :group 0
(entry :binding 0 :buffer settings))
(bind-group :name textures :layout drawPipeline :group 1
(entry :binding 0 :texture noise))
(render-pass :name render
(color-attachment :view context-current-texture
:clear-value [0 0 0 1] :load-op clear :store-op store)
:pipeline drawPipeline
:bind-groups [uniforms textures] ; each resolves to a (bind-group …)
(draw :vertex-count 3))

At the top of the chain, the frame names everything it performs:

; declared elsewhere in the document:
; (render-pass :name shadowPass …) (render-pass :name mainPass …)
; (compute-pass :name physics …) (queue :name writeUniforms …)
(frame :name game
:before [writeUniforms] ; (queue …)
:perform [physics shadowPass mainPass]) ; (compute-pass …), (render-pass …)

A frame step is one name across three kinds, so the pass and queue names in :perform are drawn from the same namespace as everything else.

Some identifiers are built-in symbols rather than cross-references: the validator accepts them as enum members or runtime sources, not as names of declared forms. The triangle uses two of them (context-current-texture and preferred-canvas-format) without declaring either.

  • auto: auto-derive the bind-group layouts from the shaders, in a pipeline’s required :layout key
:layout auto
  • canvas: a texture that tracks the canvas size at runtime
:size canvas
  • context-current-texture: the current canvas texture
:view context-current-texture
  • clear, load: load operations
  • store, discard: store operations
:load-op clear
:store-op store
  • preferred-canvas-format: the browser’s preferred format
  • WebGPU formats: bgra8unorm, rgba8unorm, depth24plus, etc.
:format preferred-canvas-format
:format rgba8unorm
  • triangle-list, triangle-strip, line-list, line-strip, point-list
:topology triangle-list
  • none, front, back: cull modes
  • ccw, cw: front-face winding
:cull-mode back
:front-face ccw
  • nearest, linear: texture filters
  • clamp-to-edge, repeat, mirror-repeat: address modes
:mag-filter linear
:address-mode repeat
  • pngine-inputs (time and canvas size), scene-time-inputs, pointer-inputs: built-in write-buffer data sources
(write-buffer :buffer uniforms :data pngine-inputs)

Now the rule promised at the top. Every :name in a document lives in one namespace, whatever form kind declares it. Two buffers cannot share a name, and neither can a buffer and a sampler:

; invalid: duplicate name 'noise'
(buffer :name noise :size 256 :usage [storage])
(sampler :name noise :mag-filter linear) ; ERROR: the name is taken

The second declaration is the one reported, by line, and the message states the rule itself:

duplicate name 'noise': already declared as a (buffer …) at line 2 —
names are one namespace across every form, whatever kind declares them

This is not tidiness for its own sake. The one namespace is what lets a slot admit several kinds at once (a bind group’s :layout, a frame step) without a reference ever becoming ambiguous: whatever kind noise turns out to be, there is exactly one noise.

The built-in spellings are reserved on the same principle: canvas, context-current-texture, preferred-canvas-format, pngine-inputs, scene-time-inputs, pointer-inputs, auto and the enum members cannot name a form. An exercise: try it anyway. Declare (buffer :name canvas :size 16 :usage [uniform]) in any document and run pngine validate:

'canvas' is a builtin symbol and cannot name a (buffer …) — pngine
reads it as the builtin wherever it appears, so this declaration can
never be referenced

The refusal is the design argument in one sentence: a form nothing could ever reference is a form that shouldn’t exist.

Error Cause
not_cross_ref No declaration of a kind the slot admits has that name (a misspelt (define …) in a numeric slot reports here too)
duplicate_cross_ref_target Two forms of the same kind declare that name
not_member A symbol is not a member of the slot’s enum
missing_required_key A required :key is absent
unknown_key The form has no such key
positional_missing A required sub-form is absent (a compute pass with no (dispatch …))
required_one_of_missing An exclusive group has no alternative present (a bind-group (entry …) with no resource)
dependent_key_missing A key that :requires another was written without it (:offset without :buffer)

Two name rules are the compiler’s rather than the validator’s, so they carry a located message and no code: the duplicate-name and builtin-spelling refusals quoted in the One Namespace section above.