onejs-sl 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8
4
+
5
+ A pasted GLSL shader becomes a `.sl` file (`Specs/SL_NEXT.md` 5). Nothing a program compiles to changes.
6
+
7
+ - `fromGLSL` converts a Shadertoy or WebGL fragment shader, with a note on everything it could not carry over
8
+ - `INPUT_DOCS` describe the output and the clock, not an element and an effect
9
+ - A body never closed is not also reported as a missing main
10
+
11
+ ## 0.1.7
12
+
13
+ `diagnose` finds a mistake inside a refused call's arguments and on both sides of an operator, found by the Play editor's first run in a real browser. Nothing a program compiles to changes.
14
+
15
+ - `fract(uv * wrap)` reports both `fract` and `wrap`
16
+
3
17
  ## 0.1.6
4
18
 
5
19
  `diagnose(source)` returns every error in a file, for an editor checking as it is typed (`Specs/SL_NEXT.md` 5). Nothing a program compiles to changes.
package/README.md CHANGED
@@ -35,7 +35,7 @@ only what it calls.
35
35
 
36
36
  | Entry | Contents |
37
37
  |---|---|
38
- | `onejs-sl` | `parse` and `analyze`, `diagnose` (every error in a file, not just the first), `classify` (every token with its class, comments kept, never throws), the TypeScript form `sl`, the IR types, `SL_IR_VERSION`, `toJSON`/`fromJSON`, `SLParseError` (file, line, column, offset, length, the bare `text`, and a `fix` where one is certain) |
38
+ | `onejs-sl` | `parse` and `analyze`, `diagnose` (every error in a file, not just the first), `classify` (every token with its class, comments kept, never throws), `fromGLSL` (a pasted Shadertoy or WebGL shader as a `.sl` file, with notes on what did not carry over), the TypeScript form `sl`, the IR types, `SL_IR_VERSION`, `toJSON`/`fromJSON`, `SLParseError` (file, line, column, offset, length, the bare `text`, and a `fix` where one is certain) |
39
39
  | `onejs-sl/core` | the same without the parser: what a game needs at run time |
40
40
  | `onejs-sl/tables` | `BUILTINS`, `SL_HLSL`, `INPUTS`, `SL_SDF_SHAPES`, `SL_SDF_PARAMS`, `SL_KEYWORDS`, `SL_TYPES`, `TYPE_WIDTH`, `PRELUDE_NAMES`: what completion and highlighting read. `BUILTIN_PARAMS`, `SL_SDF_PARAM_NAMES` and `LIB_SIGNATURES` name every parameter, so an editor can show `lerp(x, y, s)`, and `BUILTIN_DOCS`, `INPUT_DOCS` and `PRELUDE_DOCS` give each a line for its tooltip |
41
41
  | `onejs-sl/limits` | `vmFit(program)`: whether the VM runs it, and why not, without encoding |
@@ -159,11 +159,27 @@ and QuickJS (Magerie). Two checks hold it to that:
159
159
 
160
160
  Push a tag `v<version>` matching `package.json`; `.github/workflows/publish.yml`
161
161
  publishes through npm trusted publishing (OIDC), so no token exists anywhere.
162
- Note the release in `CHANGELOG.md` first. The one exception is the first version: npm
163
- attaches a trusted publisher to a package that already exists, so 0.1.0 was
164
- published by hand, and the workflow checks its tag and publishes nothing. In the OneJS container this package
165
- is checked out at `JSModules/onejs-sl`, and `onejs-unity` links it with
166
- `file:../onejs-sl` as a dev dependency beside its `^` peer range.
162
+ Before the tag:
163
+
164
+ 1. Note the release in `CHANGELOG.md` and bump `package.json`.
165
+ 2. Prove the corpus: a program that compiled before compiles to the same bytes,
166
+ unless the release says otherwise.
167
+ 3. Run PlaySite's checks in the container, where the Play editor bundles this
168
+ package: `node scripts/gen-sl-parser.mjs`, then `npx vitest run`, in
169
+ `PlaySite/`. It is a private repo, so no workflow here can. When `diagnose`
170
+ or an error's fix changed, also run `node Tools/editor-sl-smoke/editor-sl-smoke.mjs`
171
+ from the container root, which drives the Play editor in real Monaco.
172
+ 4. Push `main` and wait for CI, whose `consumers` job runs onejs-unity's
173
+ and onejs-play's typecheck and tests against the commit (`consumers.yml`). `publish.yml`
174
+ runs the same job and publishes nothing if it fails. When a consumer has to
175
+ follow a change, land it here, fix the consumer, then re-run and tag.
176
+
177
+ The one exception to publishing by tag is the first version: npm attaches a
178
+ trusted publisher to a package that already exists, so 0.1.0 was published by
179
+ hand, and the workflow checks its tag and publishes nothing. In the OneJS
180
+ container this package is checked out at `JSModules/onejs-sl`, and
181
+ `onejs-unity` links it with `file:../onejs-sl` as a dev dependency beside its
182
+ `^` peer range.
167
183
 
168
184
  ## The one idea
169
185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-sl",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "The OneJS shader language: a small typed language for per pixel programs, compiled to a portable IR and emitted as HLSL, WGSL, GLSL ES or a VM buffer",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * HLSL, WGSL or GLSL ES) is a separate entry, so a host bundles only what it
7
7
  * calls:
8
8
  *
9
- * onejs-sl this: parse, diagnose, classify, the TypeScript form, the IR, its JSON
9
+ * onejs-sl this: parse, diagnose, classify, fromGLSL, the TypeScript form, the IR, its JSON
10
10
  * onejs-sl/core the same without the parser, for a game at run time
11
11
  * onejs-sl/tables builtins, opcodes, inputs, shapes: what an editor reads
12
12
  * onejs-sl/limits whether a program fits the VM, without encoding it
@@ -24,3 +24,5 @@ export {
24
24
  export type {
25
25
  Checked, Expr, FuncDecl, ParseOptions, Pos, SLClassifiedToken, SLFix, SLTokenClass, Stmt, Token, Unit,
26
26
  } from "./lang"
27
+ export { fromGLSL } from "./lang/glsl"
28
+ export type { FromGLSL } from "./lang/glsl"
@@ -195,7 +195,9 @@ statement or declaration, the checker and the lowering move to the next one,
195
195
  and a local whose line was refused is still declared, as a zero of its type, so
196
196
  its uses are not reported again. A later stage runs only when the earlier ones
197
197
  found nothing: names are not checked in a file that cannot be read, since those
198
- errors would be about the hole. `diagnose.test.ts` holds it to the property that
198
+ errors would be about the hole. For the same reason a file with a syntax error
199
+ is not also told it declares no main: an unclosed body reads to the end of the
200
+ file, main with it, and one mistake should be one error. `diagnose.test.ts` holds it to the property that
199
201
  matters, that whatever `parse` refuses a file with, `diagnose` reports at the
200
202
  same place, over every program one token short of a real one.
201
203
 
@@ -212,6 +214,37 @@ name may be neither. `onejs-sl/tables` carries them with a one line description
212
214
  of every builtin, input and prelude function; a prelude function's is the
213
215
  comment above it in `prelude-source.ts`.
214
216
 
217
+ ## From GLSL
218
+
219
+ `fromGLSL(glsl)` (`glsl.ts`) turns a pasted Shadertoy or WebGL fragment shader
220
+ into a `.sl` file, and returns it with `notes`, one line each on what it could
221
+ not carry over or changed the meaning of, and `errors`, which is `diagnose` on
222
+ the result. It never throws, since a paste arrives a piece at a time.
223
+
224
+ It rewrites tokens and keeps everything else: comments, layout and names stay
225
+ where they were, so the result reads as the shader it came from. What it does:
226
+
227
+ - The spellings that mean the same (`vec3`, `mix`, `fract`, `texture`,
228
+ `iTime`, `gl_FragCoord`) become the HLSL ones. So do the calls that need an
229
+ expression: `mod` floors (`a - b * floor(a / b)`, never `%`, which
230
+ truncates), and `atan`, `inversesqrt`, `radians`, `degrees`, `exp2` and
231
+ `log2` become what they compute.
232
+ - `mainImage` becomes `float4 main()`, with its colour parameter as a local it
233
+ returns; `void main()` writing `gl_FragColor` or a WebGL2 `out vec4` does the
234
+ same. Shadertoy's `uv = fragCoord / iResolution.xy` line is dropped, since
235
+ that is the input `uv`.
236
+ - `iResolution`, `iMouse`, `iChannel0` to `3` and `uniform sampler2D` become
237
+ `resolution`, a `mouse` uniform and declared textures. `#define NAME value`
238
+ becomes a const; any other directive is kept as a comment, with a note.
239
+ - A name the language already means something by gets a `_` suffix, and an
240
+ `int` becomes a float (a for-loop counter stays an int), each with a note.
241
+ - What has no counterpart (`iFrame`, `textureLod`, `dFdx`, a function returning
242
+ nothing) is left in place with a note, for `errors` to point at.
243
+
244
+ It is not a GLSL parser, and is not meant to be: a shader it cannot follow comes
245
+ out with errors on the lines to look at, which is where a person pasting one
246
+ starts anyway.
247
+
215
248
  ## See also
216
249
 
217
250
  - `Specs/SL_TEXT.md`, the whole design, including the four decisions taken
package/src/lang/check.ts CHANGED
@@ -453,20 +453,31 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
453
453
  }
454
454
  checkExpr(fn, e.obj, scope)
455
455
  return
456
- case "call":
457
- checkCall(fn, e, scope)
456
+ case "call": {
457
+ if (attempt(() => checkCall(fn, e, scope))) return
458
+ // Only reached while collecting: the call was refused, and its
459
+ // arguments are separate questions, so a mistake inside one is
460
+ // not hidden by the call's own. `fract(uv * wrap)` is two
461
+ // mistakes. tex2D's first argument is the texture, which is a
462
+ // value only there.
463
+ const tex = e.callee.k === "ident" && e.callee.name === "tex2D"
464
+ for (const a of tex ? e.args.slice(1) : e.args) attempt(() => checkExpr(fn, a, scope))
458
465
  return
466
+ }
467
+ // Each operand on its own, so that while collecting a mistake on one
468
+ // side does not hide one on the other. Without a collector, `attempt`
469
+ // is the plain call, and the first error throws as it always did.
459
470
  case "unary":
460
471
  checkExpr(fn, e.arg, scope)
461
472
  return
462
473
  case "binary":
463
- checkExpr(fn, e.a, scope)
464
- checkExpr(fn, e.b, scope)
474
+ attempt(() => checkExpr(fn, e.a, scope))
475
+ attempt(() => checkExpr(fn, e.b, scope))
465
476
  return
466
477
  case "cond":
467
- checkExpr(fn, e.cond, scope)
468
- checkExpr(fn, e.then, scope)
469
- checkExpr(fn, e.else, scope)
478
+ attempt(() => checkExpr(fn, e.cond, scope))
479
+ attempt(() => checkExpr(fn, e.then, scope))
480
+ attempt(() => checkExpr(fn, e.else, scope))
470
481
  return
471
482
  }
472
483
  }
@@ -0,0 +1,416 @@
1
+ /**
2
+ * A pasted GLSL fragment shader, Shadertoy's `mainImage` included, rewritten as
3
+ * a `.sl` file.
4
+ *
5
+ * Decision 1 kept as `Specs/SL_NEXT.md` 5 A has it: the language accepts one
6
+ * spelling, and porting from the other is one command rather than a second
7
+ * column in every table. So this is a converter, not an alias list, and what
8
+ * it cannot carry over it says rather than guesses.
9
+ *
10
+ * A rewrite of tokens, not of a tree. Comments, layout and the author's own
11
+ * names survive, because the result is a file somebody goes on editing. The
12
+ * rewrites that need to know a call's arguments (`mod`, `atan`, `inversesqrt`)
13
+ * find them by matching brackets.
14
+ *
15
+ * What comes back is the text, what the conversion changed that the author
16
+ * should know (`notes`), and what still does not compile (`errors`, from
17
+ * `diagnose`, at their places in the new text).
18
+ */
19
+
20
+ import { INPUTS } from "../ir"
21
+ import { BUILTINS } from "./builtins"
22
+ import { diagnose } from "./index"
23
+ import type { SLParseError } from "./lexer"
24
+ import { SL_KEYWORDS, SL_TYPES } from "./words"
25
+
26
+ export interface FromGLSL {
27
+ /** The `.sl` file. */
28
+ source: string
29
+ /** What the conversion changed or dropped that the author should know, each naming the line it was on. */
30
+ notes: string[]
31
+ /** What still does not compile, at its place in `source`. Empty when it compiles. */
32
+ errors: SLParseError[]
33
+ }
34
+
35
+ type Kind = "ws" | "comment" | "ident" | "number" | "punct" | "directive"
36
+ interface Tok { kind: Kind; text: string; line: number }
37
+
38
+ /** Every character in exactly one token, so joining the texts gives the input back. */
39
+ function tokenize(src: string): Tok[] {
40
+ const out: Tok[] = []
41
+ let i = 0
42
+ let line = 1
43
+ const push = (kind: Kind, end: number) => {
44
+ const text = src.slice(i, end)
45
+ out.push({ kind, text, line })
46
+ for (const c of text) if (c === "\n") line++
47
+ i = end
48
+ }
49
+ const ident = /[A-Za-z_]\w*/y
50
+ const number = /(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?[uUfF]?/y
51
+ while (i < src.length) {
52
+ const c = src[i]!
53
+ if (/\s/.test(c)) { let j = i; while (j < src.length && /\s/.test(src[j]!)) j++; push("ws", j); continue }
54
+ if (src.startsWith("//", i)) { const j = src.indexOf("\n", i); push("comment", j < 0 ? src.length : j); continue }
55
+ if (src.startsWith("/*", i)) { const j = src.indexOf("*/", i + 2); push("comment", j < 0 ? src.length : j + 2); continue }
56
+ if (c === "#") {
57
+ // To the end of the line, a backslash continuing it.
58
+ let j = i
59
+ while (j < src.length && src[j] !== "\n") j += src[j] === "\\" && src[j + 1] === "\n" ? 2 : 1
60
+ push("directive", j)
61
+ continue
62
+ }
63
+ ident.lastIndex = i
64
+ if (ident.test(src)) { push("ident", ident.lastIndex); continue }
65
+ number.lastIndex = i
66
+ if (/[\d.]/.test(c) && number.test(src) && number.lastIndex > i + (c === "." ? 1 : 0)) { push("number", number.lastIndex); continue }
67
+ push("punct", i + 1)
68
+ }
69
+ return out
70
+ }
71
+
72
+ const GLSL_TYPES = new Set(["float", "int", "uint", "bool", "vec2", "vec3", "vec4", "ivec2", "ivec3", "ivec4",
73
+ "uvec2", "uvec3", "uvec4", "bvec2", "bvec3", "bvec4", "mat2", "mat3", "mat4", "void", "sampler2D"])
74
+
75
+ /** A straight rename, the meaning unchanged. */
76
+ const RENAME: Record<string, string> = {
77
+ vec2: "float2", vec3: "float3", vec4: "float4", mix: "lerp", fract: "frac",
78
+ texture: "tex2D", texture2D: "tex2D", iTime: "time", iGlobalTime: "time", gl_FragCoord: "fragCoord",
79
+ }
80
+
81
+ /** A rename that changes what the value can hold, so it is said. */
82
+ const NARROW: Record<string, [string, string]> = {
83
+ ivec2: ["float2", "an ivec2 is a float2 here, so dividing one no longer truncates"],
84
+ ivec3: ["float3", "an ivec3 is a float3 here, so dividing one no longer truncates"],
85
+ ivec4: ["float4", "an ivec4 is a float4 here, so dividing one no longer truncates"],
86
+ uvec2: ["float2", "a uvec2 is a float2 here"], uvec3: ["float3", "a uvec3 is a float3 here"],
87
+ uvec4: ["float4", "a uvec4 is a float4 here"],
88
+ bvec2: ["float2", "a bvec2 is a float2 of 0s and 1s here"], bvec3: ["float3", "a bvec3 is a float3 of 0s and 1s here"],
89
+ bvec4: ["float4", "a bvec4 is a float4 of 0s and 1s here"],
90
+ bool: ["float", "a bool is a float that is 0 or 1 here; a comparison already is one"],
91
+ int: ["float", "an int is a float here, so dividing one no longer truncates; wrap it in floor() where that mattered"],
92
+ uint: ["float", "a uint is a float here"],
93
+ }
94
+
95
+ /** Shadertoy's inputs with no counterpart yet, and why. */
96
+ const MISSING: Record<string, string> = {
97
+ iFrame: "there is no frame counter yet",
98
+ iTimeDelta: "there is no delta time yet",
99
+ iFrameRate: "there is no frame rate",
100
+ iDate: "there is no date",
101
+ iChannelTime: "a texture has no time of its own",
102
+ iChannelResolution: "a texture's size cannot be read yet",
103
+ iSampleRate: "there is no audio",
104
+ textureLod: "tex2Dlod is not implemented yet; tex2D samples with the texture's own filtering",
105
+ texelFetch: "a texture cannot be read without filtering yet",
106
+ textureSize: "a texture's size cannot be read yet",
107
+ dFdx: "there are no derivatives", dFdy: "there are no derivatives", fwidth: "there are no derivatives",
108
+ }
109
+
110
+ /** Names a pasted value or function cannot keep, because the language already means something by them. */
111
+ function taken(name: string, isFunction: boolean): boolean {
112
+ if (name in INPUTS || (SL_KEYWORDS as readonly string[]).includes(name) || (SL_TYPES as readonly string[]).includes(name)) return true
113
+ // A value may take a builtin's name; a function may not.
114
+ return isFunction && BUILTINS[name] !== undefined
115
+ }
116
+
117
+ /** `fromGLSL(source)`: a GLSL fragment shader as a `.sl` file, with what changed and what still does not compile. */
118
+ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLSL {
119
+ const toks = tokenize(glsl.replace(/\r\n/g, "\n"))
120
+ const notes: string[] = []
121
+ const note = (line: number, text: string) => {
122
+ const n = `line ${line}: ${text}`
123
+ if (!notes.includes(n)) notes.push(n)
124
+ }
125
+ const said = new Set<string>()
126
+ const noteOnce = (key: string, line: number, text: string) => { if (!said.has(key)) { said.add(key); note(line, text) } }
127
+
128
+ // ---------- token plumbing ----------
129
+
130
+ const skip = (i: number) => { while (i < toks.length && (toks[i]!.kind === "ws" || toks[i]!.kind === "comment")) i++; return i }
131
+ const back = (i: number) => { while (i >= 0 && (toks[i]!.kind === "ws" || toks[i]!.kind === "comment")) i--; return i }
132
+ const is = (i: number, text: string) => i >= 0 && i < toks.length && toks[i]!.text === text && toks[i]!.kind !== "comment"
133
+ /** The index of the bracket closing the one at `open`, or the end. */
134
+ const closing = (open: number): number => {
135
+ const [o, c] = toks[open]!.text === "(" ? ["(", ")"] : toks[open]!.text === "{" ? ["{", "}"] : ["[", "]"]
136
+ let depth = 0
137
+ for (let i = open; i < toks.length; i++) {
138
+ if (toks[i]!.kind !== "punct") continue
139
+ if (toks[i]!.text === o) depth++
140
+ else if (toks[i]!.text === c && --depth === 0) return i
141
+ }
142
+ return toks.length
143
+ }
144
+ /** A call's arguments, as token ranges, for the call whose "(" is at `open`. */
145
+ const args = (open: number, close: number): Array<[number, number]> => {
146
+ const out: Array<[number, number]> = []
147
+ let depth = 0
148
+ let start = open + 1
149
+ for (let i = open + 1; i < close; i++) {
150
+ const t = toks[i]!
151
+ if (t.kind !== "punct") continue
152
+ if (t.text === "(" || t.text === "[" || t.text === "{") depth++
153
+ else if (t.text === ")" || t.text === "]" || t.text === "}") depth--
154
+ else if (t.text === "," && depth === 0) { out.push([start, i]); start = i + 1 }
155
+ }
156
+ if (skip(start) < close) out.push([start, close])
157
+ return out
158
+ }
159
+
160
+ // ---------- what the file declares ----------
161
+
162
+ /** Names the pasted file declares that the language already means something by. */
163
+ const clashes = new Map<string, string>()
164
+ const functions = new Set<string>()
165
+ for (let i = 0; i < toks.length; i++) {
166
+ if (toks[i]!.kind !== "ident" || !GLSL_TYPES.has(toks[i]!.text)) continue
167
+ const n = skip(i + 1)
168
+ if (toks[n]?.kind !== "ident") continue
169
+ const name = toks[n]!.text
170
+ const isFunction = is(skip(n + 1), "(")
171
+ if (isFunction) functions.add(name)
172
+ if (name === "mainImage" || name === "main") continue
173
+ if (taken(name, isFunction)) clashes.set(name, name + "_")
174
+ }
175
+
176
+ // ---------- the entry point ----------
177
+
178
+ let colour = "fragColor"
179
+ /** The mainImage parameter that is Shadertoy's pixel position, when it is not called fragCoord. */
180
+ let coordParam: string | null = null
181
+ let entry: { header: [number, number]; open: number; close: number } | null = null
182
+ /** A WebGL2 style `out vec4 name;` at the top level, which becomes the local instead. */
183
+ let outDecl: [number, number] | null = null
184
+ for (let i = 0; i < toks.length; i++) {
185
+ if (!is(i, "void")) continue
186
+ const n = skip(i + 1)
187
+ const name = toks[n]?.text
188
+ if (name !== "mainImage" && name !== "main") continue
189
+ const open = skip(n + 1)
190
+ if (!is(open, "(")) continue
191
+ const close = closing(open)
192
+ const body = skip(close + 1)
193
+ if (!is(body, "{")) continue
194
+ if (name === "mainImage") {
195
+ for (const [a, b] of args(open, close)) {
196
+ const words = toks.slice(a, b).filter((t) => t.kind === "ident").map((t) => t.text)
197
+ const pname = words[words.length - 1]
198
+ if (pname === undefined) continue
199
+ if (words.includes("vec4")) colour = pname
200
+ else if (words.includes("vec2") && pname !== "fragCoord") coordParam = pname
201
+ }
202
+ }
203
+ entry = { header: [i, close + 1], open: body, close: closing(body) }
204
+ // Its pixel position is the input, not a clash with it.
205
+ if (name === "mainImage") clashes.delete("fragCoord")
206
+ break
207
+ }
208
+ if (entry === null) {
209
+ notes.push("there is no mainImage or main to convert; add `float4 main() { ... }` returning the colour")
210
+ }
211
+ for (let i = 0; i < toks.length; i++) {
212
+ if (!is(i, "out")) continue
213
+ const t = skip(i + 1)
214
+ const n = skip(t + 1)
215
+ const semi = skip(n + 1)
216
+ if (is(t, "vec4") && toks[n]?.kind === "ident" && is(semi, ";") && (entry === null || i < entry.header[0])) {
217
+ colour = toks[n]!.text
218
+ outDecl = [i, semi + 1]
219
+ break
220
+ }
221
+ }
222
+
223
+ // ---------- the rewrite ----------
224
+
225
+ const textures = new Set<string>()
226
+ let mouse = false
227
+ let uvDropped = false
228
+
229
+ /** Tokens `from` to `to`, converted. */
230
+ const emit = (from: number, to: number, inEntry: boolean): string => {
231
+ let out = ""
232
+ for (let i = from; i < to; i++) {
233
+ const t = toks[i]!
234
+ if (t.kind === "directive") { out += directive(t); continue }
235
+ if (t.kind === "number") { out += t.text.replace(/[uU]$/, ""); continue }
236
+ if (t.kind !== "ident") { out += t.text; continue }
237
+
238
+ const name = t.text
239
+ const prev = back(i - 1)
240
+ if (is(prev, ".")) { out += name; continue }
241
+ const next = skip(i + 1)
242
+ const called = is(next, "(")
243
+
244
+ if (name === "precision") {
245
+ // `precision mediump float;` means nothing outside GLSL ES.
246
+ let j = i
247
+ while (j < to && !is(j, ";")) j++
248
+ i = skip(j + 1) - 1
249
+ continue
250
+ }
251
+ if (name === "highp" || name === "mediump" || name === "lowp" || (name === "in" && inParams(i))) {
252
+ i = skip(i + 1) - 1
253
+ continue
254
+ }
255
+ if (name === "out" || name === "inout") {
256
+ note(t.line, `${name} parameters do not exist here; return the value instead`)
257
+ out += name
258
+ continue
259
+ }
260
+ if (name === "uniform" && is(skip(i + 1), "sampler2D")) {
261
+ // `uniform sampler2D tex;` is `texture2D tex;`.
262
+ out += "texture2D"
263
+ i = skip(i + 1)
264
+ continue
265
+ }
266
+ if (name === "sampler2D") { out += "texture2D"; continue }
267
+ if (inEntry && coordParam !== null && name === coordParam) { out += "fragCoord"; continue }
268
+ if (inEntry && name === "gl_FragColor") { out += colour; continue }
269
+ const clash = clashes.get(name)
270
+ if (clash !== undefined) {
271
+ noteOnce("clash:" + name, t.line, `${name} is renamed ${clash}, since ${name} already means something here`)
272
+ out += clash
273
+ continue
274
+ }
275
+ if (RENAME[name] !== undefined && !(name === "texture" && !called)) {
276
+ if (name === "gl_FragCoord" && !is(next, ".")) note(t.line, "gl_FragCoord is a vec4 in GLSL and fragCoord is a float2 here")
277
+ out += RENAME[name]
278
+ continue
279
+ }
280
+ if (NARROW[name] !== undefined) {
281
+ // A for loop's counter is the one int the language has.
282
+ const forCounter = name === "int" && is(back(i - 1), "(") && is(back(back(i - 1) - 1), "for")
283
+ if (forCounter) { out += name; continue }
284
+ const [to2, why] = NARROW[name]!
285
+ noteOnce("narrow:" + name, t.line, why)
286
+ out += to2
287
+ continue
288
+ }
289
+ if (/^iChannel\d$/.test(name)) { textures.add(name); out += name; continue }
290
+ if (name === "iMouse") {
291
+ mouse = true
292
+ noteOnce("mouse", t.line, "iMouse is the uniform mouse, which the host sets; Shadertoy's sign encoding of clicks is not carried over")
293
+ out += "mouse"
294
+ continue
295
+ }
296
+ if (name === "iResolution") {
297
+ const dot = is(next, ".") ? skip(next + 1) : -1
298
+ const member = dot >= 0 ? toks[dot]?.text ?? null : null
299
+ if (member === null) {
300
+ out += "float3(resolution, 1.0)"
301
+ } else if (/^[xy]+$/.test(member)) {
302
+ out += "resolution"
303
+ } else {
304
+ note(t.line, `iResolution.${member}: resolution is a float2 here, and the third component was always 1`)
305
+ out += "resolution"
306
+ }
307
+ continue
308
+ }
309
+ if (MISSING[name] !== undefined) {
310
+ note(t.line, `${name} has no counterpart: ${MISSING[name]}`)
311
+ out += name
312
+ continue
313
+ }
314
+ if (name === "void" && !functions.has(toks[skip(i + 1)]?.text ?? "")) { out += name; continue }
315
+ if (name === "void") {
316
+ note(t.line, `${toks[skip(i + 1)]!.text} returns nothing, and every function here returns a value`)
317
+ out += name
318
+ continue
319
+ }
320
+
321
+ if (called) {
322
+ const close = closing(next)
323
+ const a = args(next, close).map(([x, y]) => emit(x, y, inEntry).trim())
324
+ const wrap = (s: string) => (/^[\w.]+$/.test(s) || /^[\w.]+\(.*\)$/.test(s) ? s : `(${s})`)
325
+ let call: string | null = null
326
+ if (name === "mod" && a.length === 2) {
327
+ // GLSL's mod floors; % and fmod truncate. Written out, so negative values stay right.
328
+ call = `(${wrap(a[0]!)} - ${wrap(a[1]!)} * floor(${wrap(a[0]!)} / ${wrap(a[1]!)}))`
329
+ } else if (name === "atan" && a.length === 2) {
330
+ call = `atan2(${a[0]}, ${a[1]})`
331
+ } else if (name === "atan" && a.length === 1) {
332
+ call = `atan2(${a[0]}, 1.0)`
333
+ } else if (name === "inversesqrt" && a.length === 1) {
334
+ call = `(1.0 / sqrt(${a[0]}))`
335
+ } else if (name === "radians" && a.length === 1) {
336
+ call = `(${wrap(a[0]!)} * 0.017453292519943295)`
337
+ } else if (name === "degrees" && a.length === 1) {
338
+ call = `(${wrap(a[0]!)} * 57.29577951308232)`
339
+ } else if (name === "exp2" && a.length === 1) {
340
+ call = `pow(2.0, ${a[0]})`
341
+ } else if (name === "log2" && a.length === 1) {
342
+ call = `(log(${a[0]}) * 1.4426950408889634)`
343
+ }
344
+ if (call !== null) { out += call; i = close; continue }
345
+ }
346
+ out += name
347
+ }
348
+ return out
349
+ }
350
+
351
+ /** Whether the token at `i` is inside a function's parameter list. */
352
+ function inParams(i: number): boolean {
353
+ let depth = 0
354
+ for (let j = i - 1; j >= 0; j--) {
355
+ const t = toks[j]!
356
+ if (t.kind !== "punct") continue
357
+ if (t.text === ")") depth++
358
+ else if (t.text === "(") { if (depth === 0) return toks[back(j - 1)]?.kind === "ident"; depth-- }
359
+ else if (t.text === ";" || t.text === "{" || t.text === "}") return false
360
+ }
361
+ return false
362
+ }
363
+
364
+ /**
365
+ * `#define NAME value` becomes a const, typed by a leading constructor
366
+ * (`vec3(...)` is a float3) and a float otherwise; a macro with parameters
367
+ * and any other directive are kept as a comment.
368
+ */
369
+ function directive(t: Tok): string {
370
+ const define = /^#\s*define\s+([A-Za-z_]\w*)\s+(.+?)\s*$/s.exec(t.text)
371
+ if (define !== null && !/^\(/.test(t.text.slice(t.text.indexOf(define[1]!) + define[1]!.length))) {
372
+ const value = define[2]!.replace(/\\\n/g, " ").replace(/\b(vec[234]|mix|fract|iTime|iGlobalTime)\b/g, (w) => RENAME[w]!)
373
+ const type = /^\s*(float[234])\s*\(/.exec(value)?.[1] ?? "float"
374
+ return `const ${type} ${define[1]} = ${value};`
375
+ }
376
+ const guard = /^#\s*(ifdef|ifndef|if|else|elif|endif)\b/.test(t.text) && /GL_ES|^#\s*(else|endif)/.test(t.text)
377
+ if (!guard) note(t.line, `${t.text.trim()} has no counterpart, since there is no preprocessor; it is kept as a comment`)
378
+ return "// " + t.text
379
+ }
380
+
381
+ // The entry point: its header, a local for the colour, the colour returned.
382
+ let body: string
383
+ if (entry === null) {
384
+ body = emit(0, toks.length, false)
385
+ } else {
386
+ const { header, open, close } = entry
387
+ // In file order, so a note names the first line a name is on.
388
+ const before = outDecl === null ? emit(0, header[0], false) : emit(0, outDecl[0], false) + emit(outDecl[1], header[0], false)
389
+ const inner = emit(open + 1, close, true)
390
+ .replace(/\breturn\s*;/g, `return ${colour};`)
391
+ // Shadertoy's own uv, which is exactly the input: y up, 0 to 1 across the element.
392
+ .replace(/\n?[ \t]*float2\s+uv_\s*=\s*fragCoord(\.xy)?\s*\/\s*resolution(\.xy)?\s*;[ \t]*/, () => {
393
+ uvDropped = true
394
+ return ""
395
+ })
396
+ const indent = /\n([ \t]+)\S/.exec(inner)?.[1] ?? " "
397
+ body = before + "float4 main()" + emit(header[1], open, true) + "{\n" + indent +
398
+ `float4 ${colour} = float4(0.0);` + inner.replace(/\s*$/, "") + "\n" + indent + `return ${colour};\n}` +
399
+ emit(close + 1, toks.length, false)
400
+ }
401
+ // Shadertoy's own uv line is exactly the input, so it goes, and the name is the input's again.
402
+ if (uvDropped) {
403
+ body = body.replace(/\buv_\b/g, "uv")
404
+ const renamed = notes.findIndex((n) => n.includes("uv is renamed uv_"))
405
+ if (renamed >= 0) notes.splice(renamed, 1)
406
+ const at = toks.findIndex((t, i) => t.text === "uv" && is(back(i - 1), "vec2"))
407
+ note(at >= 0 ? toks[at]!.line : 1, "the uv line is dropped: fragCoord / resolution is the input uv, y up")
408
+ }
409
+
410
+ const declared = [...textures].sort().map((t) => `texture2D ${t};`)
411
+ if (mouse) declared.unshift("uniform float4 mouse;")
412
+ const source = (declared.length > 0 ? declared.join("\n") + "\n" : "") + body.replace(/^\n+/, "")
413
+ const lineOf = (n: string) => Number(/^line (\d+)/.exec(n)?.[1] ?? 0)
414
+ notes.sort((a, b) => lineOf(a) - lineOf(b))
415
+ return { source, notes, errors: diagnose(source, { file: options.file ?? "program.sl" }) }
416
+ }
@@ -184,7 +184,9 @@ class Parser {
184
184
  this.recover(() => this.parseDeclaration(unit), "declaration")
185
185
  }
186
186
 
187
- if (unit.main === null && this.requireMain) {
187
+ // Collecting, a syntax error may have swallowed main (an unclosed body
188
+ // reads to the end of the file), so "no main" only when nothing else went wrong.
189
+ if (unit.main === null && this.requireMain && (this.errors?.length ?? 0) === 0) {
188
190
  this.report(
189
191
  "this file declares no main. A .sl file is one fragment function: add " +
190
192
  "`float4 main() { ... }`",
package/src/params.ts CHANGED
@@ -164,11 +164,15 @@ export const BUILTIN_DOCS: Readonly<Record<string, string>> = {
164
164
  ramp: "t mapped through evenly spaced colour stops, from the first at 0 to the last at 1.",
165
165
  }
166
166
 
167
- /** One line on each input, for the same tooltips. */
167
+ /**
168
+ * One line on each input, for the same tooltips. In the words of no one host:
169
+ * the output is a UI element in OneJS and an image on a Magerie board, and the
170
+ * clock is whichever one the host runs.
171
+ */
168
172
  export const INPUT_DOCS: Readonly<Record<InputName, string>> = {
169
- uv: "Where this pixel is in the element, 0 to 1 on each axis, with y up.",
170
- fragCoord: "Where this pixel is in the element, in pixels, with y up.",
171
- resolution: "The element's size in pixels.",
172
- time: "Seconds since the effect started.",
173
- aspect: "The element's width divided by its height.",
173
+ uv: "Where this pixel is in the output, 0 to 1 on each axis, with y up.",
174
+ fragCoord: "Where this pixel is in the output, in pixels, with y up.",
175
+ resolution: "The output's size in pixels.",
176
+ time: "The clock, in seconds.",
177
+ aspect: "The output's width divided by its height.",
174
178
  }