Skip to content

References

PNGine SJON (the S-expression source language) uses bare identifiers to reference resources. 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.

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.

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 (color 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.

(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))
(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.

(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))
; 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.

  • 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)

Every :name in a document lives in one namespace, whatever form kind declares it. Two buffers cannot share a name:

; invalid: duplicate_cross_ref_target
(buffer :name myResource :size 16 :usage [uniform])
(buffer :name myResource :size 32 :usage [storage]) ; ERROR: duplicate_cross_ref_target

and neither can a buffer and a sampler:

; invalid: already declared as a (buffer …)
(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, naming the kind that declared it first: duplicate name ‘noise’: already declared as a (buffer …) at line 1. The one namespace is what lets a slot admit several kinds at once, a bind group’s :layout or a frame step, without the reference becoming ambiguous.

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. PNGine reads them as the built-in wherever they appear, so such a declaration could never be referenced, and it is rejected instead.

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: a name already declared by another kind (duplicate name ‘x’: already declared as a (buffer …) at line L) and a built-in spelling used as a name (‘canvas’ is a builtin symbol and cannot name a (buffer …)).