Skip to content

Where the Words Come From

The question I get asked most about PNGine is not about shaders or PNGs. It is “where do these words come from?” Someone reads the (buffer …) page, sees :usage and copy-dst, and wants to know who decided those were words, what they mean precisely, and where to read the definition. This page answers that for every key and every value in the language, and it ends with a table on every reference page that answers it per word, with a link.

The example throughout is the uniform buffer from Getting Started, the one the spinning triangle writes its time into:

(buffer :name uniforms :size 16 :usage [uniform copy-dst])

By the end you will be able to say, for each of the eight words on that line, whose word it is and where it is written down. I’ll take them from the outside in.

PNGine is a compiler from a text format to a payload a browser can run, and the text format is SJON: S-expressions (forms in parentheses) whose contents are checked against a schema. Three parties own the words on the line above, and the split is exact.

SJON, the language the schema WebGPU
(the punctuation) (which words are legal) (the words themselves)
( buffer :name uniforms :size 16 :usage [ uniform copy-dst ] )
^ ^^^^^^ ^^^^^ ^^^^^^^^ ^^^^^ ^^ ^^^^^^ ^ ^^^^^^^ ^^^^^^^^ ^ ^
| | | | | | | | | | | |
| | | | | | | | | | | +- SJON
| | | | | | | | | | +--- SJON
| | | | | | | | | +---------- WebGPU: COPY_DST
| | | | | | | | +------------------- WebGPU: UNIFORM
| | | | | | | +--------------------- SJON: a vector
| | | | | | +------------------------------ WebGPU: usage
| | | | | +---------------------------------- SJON: a number
| | | | +---------------------------------------- WebGPU: size
| | | +-------------------------------------------------- yours
| | +-------------------------------------------------------- WebGPU: label (nearly; see below)
| +---------------------------------------------------------------- WebGPU: GPUBufferDescriptor
+------------------------------------------------------------------- SJON

SJON owns the punctuation. Parentheses make a form, a word with a leading colon is a key, square brackets make a vector, 16 is a number. SJON is a separate project with its own documentation, and PNGine is one of its hosts: a program that hands SJON a schema and asks it to validate documents against it. Nothing on this page is SJON’s except the grammar.

The schema owns the vocabulary. It is one file, schema/pngine.sjon, and it says which form heads exist, which keys each form takes, and which values each key accepts. If a word is not in that file, the compiler rejects it. I’ll read the relevant part of it with you in a moment.

WebGPU owns the words. WebGPU is the browser’s GPU API, and its specification defines every descriptor a program can hand the GPU, member by member, with the legal value of each. I did not invent a vocabulary for PNGine. The schema’s words are WebGPU’s words, re-spelled for a text format, and that is the whole answer to “where does :usage come from”: from the usage member of the descriptor createBuffer() takes.

uniforms is yours. It is the one word on the line that no specification and no schema knows. SJON resolves it wherever another form says :buffer uniforms, which is the subject of References.

Recall that the schema says which keys (buffer …) takes. Here is what it says, with the descriptions trimmed so the shape shows:

(form :name buffer
(key :name name :type symbol)
(key :name size :type byte-count :optional true)
(key :name usage :type usage-list)
(key :name pool :type pool-size :optional true)
(key :name data :type data-ref :optional true)
(key :name index-of :type data-ref :optional true)
(key :name index-format :type index-format :optional true)
(key :name file :type file-path :optional true))

A (form …) declares a head and its keys; a (key …) names a key and the value kind it accepts, the schema’s name for a type. :usage accepts a usage-list, and a value kind is declared once and referenced by name, so we follow it:

(value-kind :name usage-list
:underlying vector
:vector (vector-shape :element buffer-usage :min-len 1))
(value-kind :name buffer-usage
:underlying symbol
:members (member-set
(member :name vertex)
(member :name index)
(member :name uniform)
(member :name storage)
(member :name copy-dst)
(member :name copy-src)
(member :name indirect)
(member :name query-resolve)
(member :name map-read)
(member :name map-write)))

A usage-list is a vector of at least one buffer-usage, and a buffer-usage is one of ten symbols. That is where copy-dst comes from, in the most literal sense: it is a (member …) in that file, and the validator that rejects :usage [copy-dest] is reading this list when it does so. Every key on every reference page resolves the same way, form to key to kind to members, and you can do it yourself by searching the file for value-kind :name followed by the kind the key names.

(form :name buffer …)
|
| (key :name usage :type usage-list)
v
(value-kind :name usage-list …) "a vector, at least one element"
|
| :element buffer-usage
v
(value-kind :name buffer-usage …) "one of these symbols"
|
+-- vertex index uniform storage copy-dst copy-src
indirect query-resolve map-read map-write

That answers where the schema keeps the words. It does not answer where the schema got them, and ten symbols someone typed into a file are only as trustworthy as the person who typed them. So, next: where they came from.

The WebGPU specification is written for two readers at once. The prose is for people. Interleaved with it is WebIDL, a small interface-definition language the browser vendors generate their bindings from, and it is the part that states, without ambiguity, what a descriptor contains. The one behind createBuffer() reads:

dictionary GPUBufferDescriptor : GPUObjectDescriptorBase {
required GPUSize64 size;
required GPUBufferUsageFlags usage;
boolean mappedAtCreation = false;
};
namespace GPUBufferUsage {
const GPUFlagsConstant MAP_READ = 0x0001;
const GPUFlagsConstant MAP_WRITE = 0x0002;
const GPUFlagsConstant COPY_SRC = 0x0004;
const GPUFlagsConstant COPY_DST = 0x0008;
const GPUFlagsConstant INDEX = 0x0010;
const GPUFlagsConstant VERTEX = 0x0020;
const GPUFlagsConstant UNIFORM = 0x0040;
const GPUFlagsConstant STORAGE = 0x0080;
const GPUFlagsConstant INDIRECT = 0x0100;
const GPUFlagsConstant QUERY_RESOLVE = 0x0200;
};

A dictionary is a descriptor: a bag of named members, some required, some with defaults. An enum is a closed set of strings ("line-list", "triangle-list"). A namespace of constants like the one above is a set of flags, meant to be OR’d together. Those three shapes are the entire vocabulary of WebGPU’s descriptors, and they are exactly the three things the schema has names for: a form, a symbol kind, and a vector of symbols.

Put the JavaScript a page would write next to the SJON, and the re-spelling is visible:

JavaScript, as the spec has it SJON, as PNGine spells it
device.createBuffer({ (buffer :name uniforms
label: "uniforms", :size 16
size: 16, :usage [uniform copy-dst])
usage: GPUBufferUsage.UNIFORM
| GPUBufferUsage.COPY_DST,
})

The rules are few, and once you know them you can predict the SJON spelling of any WebGPU word before you look it up:

WebGPU SJON Rule
GPUBufferDescriptor (buffer …) drop GPU, drop Descriptor, lower-case, dashes between words
size :size a member is a key
mappedAtCreation :mapped-at-creation camelCase becomes kebab-case (this one is then renamed; see below)
GPUBufferUsage.COPY_DST copy-dst a flag constant loses its namespace, lower-cases, and underscores become dashes
"line-list" line-list an enum string is used as is, without quotes
GPUPrimitiveState (primitive …) a sub-dictionary is a positional child form, State dropped like Descriptor

Notice that the spelling never adds a word. Where WebGPU says frontFace, the schema says :front-face, and the reference page for the form says the same, so what you know from MDN or the spec carries over character for character.

I’ve been telling you that every key is a member of the dictionary, and the schema excerpt above has eight keys while the dictionary has three members. Look at the excerpt again and guess which five keys WebGPU has never heard of before reading on.

The answer is that :size and :usage are members, :data is a member in disguise, and the other five are PNGine’s own:

  • :name is the label of GPUObjectDescriptorBase, the base every descriptor inherits, but it does more than a label does: it is the name every cross-reference resolves. It is a WebGPU word wearing a second job.
  • :data is mappedAtCreation. A buffer with :data is created mapped, as the IDL member says, but the member is a boolean and :data also names the bytes. WebGPU has no member for the bytes because a page writes them into the mapped range in a separate call. A .sjon document has no separate call, so the key that means “created mapped” is the key that says what with.
  • :pool, :index-of, :index-format and :file exist because the document is not a program. A page that wants two buffers to alternate between writes a loop; a document says :pool 2. A page reading an index buffer’s format at bind time has a variable to read it from; a document carries it on the buffer. These keys are the cost of having no code, and each reference page marks them “PNGine’s own” so you never go looking for them in the spec.

So the true statement is: every key is either a member of the dictionary the form mirrors, re-spelled by the rules above, or a PNGine word marked as such. The same holds for values, with one exception you have already met: preferred-canvas-format is a symbol the runtime resolves to whatever format the canvas negotiated, and it appears in the format tables beside forty-nine real GPUTextureFormat strings, marked as the runtime’s.

A spelling rule and a promise are not enough, because the spec moves. Between two revisions it can add a flag to GPUBufferUsage, rename a member, or make an optional one required, and a schema that was a faithful re-spelling on Monday is a lie on Friday with nothing to say so. I did not want to be the only thing standing between the schema and the spec, so the engine holds them together by machine.

gpuweb/spec/index.bs the spec's source, with the WebIDL inline
|
| scripts/extract-webgpu-enums.mjs, at one pinned revision
v
schema/webgpu-enums.json what the spec says: every enum, every flag
| namespace, every dictionary and its members
| (with `required` and each default), every limit
|
| the conformance test, holding both sides to three rules
|
| 1. every value the schema names is a spec value
| 2. schema values + the leave-out ledger = the spec set, exactly
| 3. form keys + positional children + the not-yet ledger
| = the dictionary's members, exactly
v
schema/pngine.sjon + schema/webgpu-mapping.mjs
what PNGine accepts which kind spells which enum, which form
mirrors which dictionary, which keys are
PNGine's, and what is left out

The first arrow is an extractor, scripts/extract-webgpu-enums.mjs. It reads the specification’s source, pulls every dictionary, enum and namespace block out of the WebIDL, and writes them to a committed file, schema/webgpu-enums.json, stamped with the spec revision it read. That file is the spec as far as the engine is concerned: a snapshot, moved on purpose by re-running the extractor against a newer checkout, never by hand.

The second arrow is a test in the engine’s suite, tests/npm/webgpu-conformance.test.js, and it is the part that matters. For our buffer it reads the ten (member …) symbols under buffer-usage, re-spells each one back the other way (copy-dst to COPY_DST), and asserts two things: that every one of the ten is a constant of GPUBufferUsage, and that the ten plus the values the engine deliberately leaves out are the constants of GPUBufferUsage, all of them and no others. For the form it does the same with members: :size and :usage re-spelled, :data translated through its alias, the five PNGine keys skipped as declared, and the result must equal {size, usage, mappedAtCreation} exactly.

The word “exactly” is doing the work. A subset check would let the spec grow a flag the schema never learned; an exact partition means the day the spec adds GPUBufferUsage.SOMETHING_NEW, re-running the extractor makes this test fail, and it stays failed until someone either adds something-new to the schema or writes it into the leave-out ledger with a reason. There is no third option, and in particular there is no silent one. The same test holds the IDL’s required keyword and its defaults: a member the spec requires is required in the schema, and where the schema fills in a value the author left out, it fills in the value the IDL names.

The declarations the test consumes live in schema/webgpu-mapping.mjs: which value kind spells which enum or namespace, which form mirrors which dictionary, which keys are PNGine’s, which renames are not mechanical, and which spec values and members the engine cannot offer yet. It is a data file, and it ships with the engine so that this site can read it, which brings us to the tables.

Every form’s reference page ends with a section titled “Where these words come from”, and it is generated, not written: a script on this site reads the schema, the snapshot and the mapping above, and emits one row per key and one row per value, each linked to its definition. For our buffer the key rows are:

Key WebGPU Note
:size GPUBufferDescriptor.size
:usage GPUBufferDescriptor.usage
:pool PNGine’s own ping-pong: N instances, one selected per frame

and the value rows for :usage name each flag, copy-dst as GPUBufferUsage.COPY_DST and so on. The whole table is on the (buffer …) page.

The link on a WebGPU name goes to the specification, to the anchor of that exact member or constant, because the spec is where the meaning is defined. The MDN link beside each form goes to the page for the method that takes the descriptor (createBuffer() for our buffer), because MDN is where the meaning is explained, with examples and a browser-support table. Read MDN to learn what a member does; read the spec when you need to know what it does in every case. Three kinds of row appear:

  • a WebGPU name, linked: the key or value is that member or constant, re-spelled;
  • “PNGine’s own”, with a reason: the key exists because the document is not a program, as above;
  • “a positional child, not a key”: the member is a sub-dictionary and arrives as a nested form, the way (primitive …) is GPURenderPipelineDescriptor.primitive.

Under a form’s table you may also find “Not expressible in PNGine yet”, listing the dictionary’s members the engine has no key for. That list is the ledger from the exact-partition rule, printed, so a reader who needs depthSlice on a colour attachment learns on the page that they cannot have it, instead of by searching for a key that does not exist.

Two things about the tables are hand-written and say so on the page. The forms that mirror a method rather than a dictionary ((draw …) is a call to draw(vertexCount, instanceCount, firstVertex, firstInstance), and the snapshot extracts no methods) are mapped by hand in the site’s generator, as are the forms that are PNGine’s own shape ((frame …), (data …), the sugar). What holds those to the truth is weaker than the engine’s test, and I want you to know exactly how much weaker: the generator refuses to run if a hand table names a key the schema no longer has, or misses one it has, and a verification pass fetches every linked document and fails if any anchor is gone. It cannot tell that an argument was renamed. The engine’s tables can.

One more limit. The tables are checked against a pinned revision of the spec, named at the bottom of each one, while the links go to the current draft at w3.org. Between the two, the draft can move; when it does, the next snapshot bump is what brings the tables up to it.

Everything the tables say, an editor can say at the cursor, because the tool that does it reads the same file the compiler does. SJON ships a language server, sjon-lsp: a program your editor runs beside the file to answer, as you type, what may go here, what it means, and what is wrong. For a .sjon document every answer comes from the schema, and the :description strings I trimmed out of the excerpt above are what it shows, so the words are PNGine’s in the editor exactly as they are on these pages. Here is what that looks like for our buffer, taken from the server’s own answers rather than from memory:

  • Type ( and it offers every form head. Choosing buffer inserts (buffer :name … :usage …) with the two required keys already in place; it read which keys are required from the same (key …) lines you did.
  • Type : inside the form and it offers the seven keys you have not written yet, each with its kind, whether it is required, and its description.
  • Type :usage [ and it offers the ten flags, each with one line saying what it is for: copy-dst, “Destination of copies and write-buffer ops”; map-read, “Mappable for CPU readback; combines with copy-dst and nothing else”. A texture’s :format offers all fifty formats the same way.
  • Hover on :usage and it shows the key’s kind (usage-list), that it is required, its description, its constraint (at least one element, each a buffer-usage) and the plugin it came from. Hover on the head and it lists every key of the form with the same detail, allowed values included.
  • Write copy-dest and the diagnostic is the one pngine validate prints, allowed values and all, before you have saved the file.
  • Put the cursor on a cross-reference such as the uniforms in (entry :binding 0 :buffer uniforms) and go-to-definition lands on the (buffer :name uniforms …) that declares it.

Two limits, so you know where the tables still earn their place. The editor does not link to the specification: it knows PNGine’s one-line description of copy-dst, and the table is where GPUBufferUsage.COPY_DST is a click away. And hover on a value inside a vector (the copy-dst in [uniform copy-dst]) shows nothing today, while hover on a scalar value such as uint16 does; the completion list is where a vector’s values carry their descriptions.

Getting it takes a build and one file. sjon-lsp is not prebuilt: clone the SJON repository, run zig build lsp (Zig 0.16), put zig-out/bin on your PATH, and wire it to your editor following SJON’s tooling guide, which carries the paste-in configuration for Neovim, Helix, Emacs and Sublime Text, and a local-install VS Code extension in the same repository (nothing is on a marketplace). Then, at the root of your project, one file names the vocabulary your documents speak:

(project :plugins ["./node_modules/pngine/schema/pngine.sjon"])

That file is sjon-project.sjon, and the path is the schema the pngine npm package ships. Without it every form head reports unknown_form, which is correct: a bare .sjon file declares nothing, and this file, not the editor, is what makes yours a PNGine document.

“Where do those functions come from?” is the same question about the things on a line that are not WebGPU words, and the answer has the same shape. Take a variant of our buffer:

(define :name FLOATS :value 4)
(buffer :name uniforms :size (* FLOATS 4) :usage [uniform copy-dst])

(* FLOATS 4) is an expression, and expressions are SJON’s: the language evaluates them at compile time over the operators + - * / ceil floor fract, with FLOATS resolving to the (define …) above it. Nothing in WebGPU knows an expression happened; it receives 16. The full set is on the Expressions page, and the rest of what looks like a function call on these pages sorts into the same few owners:

You see Whose word Read it
(* FLOATS 4), (ceil (/ NUM 64)) SJON, the language Expressions
FLOATS, a bare name in a number slot a (define …) in your document (define …)
(cube …), (teapot …), position4 PNGine’s compile-time shape generators (data …)
pngine-inputs, context-current-texture, canvas PNGine’s runtime builtins References
(pass …), (init …) PNGine’s sugar, lowered to ordinary forms (pass …), (frame …)
sin, vec4f, @group(0), inside a :code string WGSL, WebGPU’s shading language the WGSL specification
:usage, copy-dst, (buffer …) WebGPU this page, and the tables

The WGSL row deserves a sentence. The text inside (shader-module :code """…""") is not SJON and not the schema’s business at all; it is a complete program in WGSL, the shading language the WebGPU specification defines in a second document. PNGine validates it (the errors you see from pngine validate about a shader are WGSL errors) and reflects the bindings out of it, but every word in it is WGSL’s, and the samples link each builtin they use to its definition there.

You now hold the rules, so test them before trusting a table. Take this texture:

(texture :name depth :size canvas :format depth24plus :usage [render-attachment])

Without opening the (texture …) page, write down the WebGPU name of render-attachment and of depth24plus, and which of the three owners canvas belongs to. Then open the page’s table and check. If you got all three, you can answer the question this page is named after for any line in any document; if the third one surprised you, the References page is where it is explained.

For the ambitious: find, on the (render-pass …) page, a member the table says PNGine cannot express yet, follow its link, and decide from the definition whether a document you would write needs it. If it does, that is precisely the kind of report the engine’s ledger exists to receive.