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.
Three owners
Section titled “Three owners”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 +------------------------------------------------------------------- SJONSJON 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.
The schema, read directly
Section titled “The schema, read directly”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-writeThat 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 IDL
Section titled “The IDL”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 said “every key”
Section titled “I said “every key””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:
:nameis thelabelofGPUObjectDescriptorBase, 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.:dataismappedAtCreation. A buffer with:datais created mapped, as the IDL member says, but the member is a boolean and:dataalso names the bytes. WebGPU has no member for the bytes because a page writes them into the mapped range in a separate call. A.sjondocument has no separate call, so the key that means “created mapped” is the key that says what with.:pool,:index-of,:index-formatand:fileexist 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.
What keeps the tracing true
Section titled “What keeps the tracing true”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 outThe 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.
The tables on every reference page
Section titled “The tables on every reference page”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 …)isGPURenderPipelineDescriptor.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.
Ask the editor
Section titled “Ask the editor”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. Choosingbufferinserts(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:formatoffers all fifty formats the same way. - Hover on
:usageand it shows the key’s kind (usage-list), that it is required, its description, its constraint (at least one element, each abuffer-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-destand the diagnostic is the onepngine validateprints, allowed values and all, before you have saved the file. - Put the cursor on a cross-reference such as the
uniformsin(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.
The other question: the functions
Section titled “The other question: the functions”“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.
An exercise
Section titled “An exercise”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.
Related
Section titled “Related”- SJON Syntax - The tour of the language
- SJON Reference - Every form, with its table
- References - The names that are yours, and the builtins
- SJON tooling guide - Building
sjon-lspand wiring it to an editor - WebGPU specification - Where the words are defined
- WebGPU on MDN - Where they are explained