onejs-sl 0.1.0

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.
@@ -0,0 +1,167 @@
1
+ # `.sl`: the shader language as a file
2
+
3
+ Phase A of `Specs/SL_TEXT.md`. Source text in, the same `Program` the EDSL
4
+ records out.
5
+
6
+ ```hlsl
7
+ // plasma.sl
8
+ uniform float warp = 0.5;
9
+ uniform float hue = 0.5;
10
+
11
+ float4 main() {
12
+ float2 p = (uv - 0.5) * (warp * 14 + 2);
13
+ float v = sin(p.x + time) + sin(p.y - time * 0.8);
14
+ float n = saturate(v * 0.22 + 0.5);
15
+ return float4(hsv2rgb(float3(frac(hue + n * 0.18), 0.75, n)), 1);
16
+ }
17
+ ```
18
+
19
+ ```ts
20
+ import { parse } from "onejs-sl"
21
+ const plasma = parse(source, { file: "plasma.sl" })
22
+ ```
23
+
24
+ ## The one idea
25
+
26
+ **It is not a second language.** Every construct lowers through the EDSL, so
27
+ `sin(x)` in a file is the same `sl.sin(x)` call a TypeScript author would have
28
+ written, and the two produce the same graph. `parity.test.ts` asserts that as
29
+ hash equality for every GPU fixture and for the example on play.onejs.com: same
30
+ hash means same generated shader and same pixels, established without rendering
31
+ anything. It also means the existing GPU fixtures, the codegen goldens and the
32
+ C# VM tests cover the text form for free, because they run on the IR.
33
+
34
+ If that property ever breaks, the failure is silent in the worst way: a program
35
+ whose hash does not match its generated shader falls back to the VM and nobody
36
+ is told. So parity is the test to keep green, not the parser's unit tests.
37
+
38
+ | File | What it does |
39
+ |---|---|
40
+ | `lexer.ts` | Characters to tokens, each carrying line and column |
41
+ | `ast.ts` | The node shapes, all of them, which is not many |
42
+ | `parser.ts` | Pratt parser: shape only, HLSL precedence |
43
+ | `check.ts` | Declarations, names, statement shape, budgets, recursion |
44
+ | `lower.ts` | AST to IR through the EDSL: SSA, unrolling, `select`, inlining |
45
+ | `builtins.ts` | What each name does, keyed by the spelling `ops.ts` gives it |
46
+ | `prelude.ts` | The standard library, written in the language |
47
+ | `onejs-unity`'s `src/esbuild/sl.mjs` | The loader: `import plasma from "./plasma.sl"` |
48
+ | `../index.ts` and `../core.ts` | The package with the parser, and without it. See below |
49
+
50
+ ## The loader
51
+
52
+ `slPlugin()`, in onejs-unity's `onejs-unity/esbuild`, parses and encodes at BUILD TIME, so an import resolves to a small
53
+ object of numbers and the bundle carries neither the parser nor the source. A
54
+ parse error becomes an esbuild error with the file, line and column, which the
55
+ Play editor surfaces and every terminal editor links.
56
+
57
+ It writes two things beside the code:
58
+
59
+ - **`<name>.sl.d.ts`**, the way a USS module gets one, carrying the uniform
60
+ names in the type. `uniforms={{ wrap: 1 }}` is then a call site error rather
61
+ than a console warning on a frame nobody is looking at.
62
+ - **`app.sl.json`** beside the bundle, the manifest `SLShaderGenerator` already
63
+ watches for. This is the thing the file format makes possible and the EDSL
64
+ cannot: a program in a file is known statically, so **an ejected game is
65
+ compiled from its first frame** rather than from its second. Runtime
66
+ recording stays for EDSL programs and for a build older than the loader; the
67
+ editor reads every `*.sl.json` it can find.
68
+
69
+ An empty manifest is written only over one that is already there. Deleting the
70
+ last `.sl` file has to stop its shaders being generated, and a project that has
71
+ never had one should not find a new file beside its bundle.
72
+
73
+ ### Why the parser is compiled rather than imported
74
+
75
+ The parser is TypeScript and the plugin runs under plain Node: an app's
76
+ `esbuild.config.mjs` is executed by `node`, which cannot load a `.ts` file.
77
+ Every plugin in that folder has the same constraint, which is why the Tailwind
78
+ generator beside them is `.mjs`; a parser with two hundred tests is not going
79
+ to be maintained twice.
80
+
81
+ So the plugin compiles `sl/compiler.ts` once per process, with the esbuild
82
+ already running the build, and imports the result: one source of truth, no
83
+ generated artifact to go stale, about thirty milliseconds once. A Cloudflare
84
+ Worker cannot evaluate code it builds, so PlaySite imports the parser
85
+ statically and hands it in as `compiler`, and that path never runs there.
86
+
87
+ ### Why there are two barrels
88
+
89
+ `onejs-sl/core` is what a **game** imports (through `onejs-unity/sl`), and the
90
+ Play eject scaffold vendors every file it reaches into the downloaded project.
91
+ `onejs-sl`, which adds this folder, is what a **build** imports (through
92
+ `onejs-unity/sl/compiler`). Re-exporting the parser from the first put two thousand
93
+ lines a played game never executes into every ejected project's source tree,
94
+ where they could only read as clutter. The scaffold vendors what an entry actually
95
+ reaches rather than every file beside it, so the split is enforced by the
96
+ eject's own test.
97
+
98
+ ## Where the types are checked
99
+
100
+ In `lower.ts`, once, by the EDSL, which computes a width as it records. A
101
+ declaration is then an **assertion** against that width rather than an input to
102
+ inference. `check.ts` deliberately does no type inference: two implementations
103
+ of a type system are two type systems, and the one that would have been written
104
+ here is the one nothing else uses.
105
+
106
+ ## What surprises people
107
+
108
+ **A scalar on the left of an operator broadcasts through a swizzle.** `uv * 8`
109
+ is `uv.mul(8)`, one constant node of two components. `8 * uv` is
110
+ `sl.float(8).mul(uv)`, a one component constant and a broadcast. Same picture,
111
+ different graph, different hash. Write the vector on the left when it matters.
112
+
113
+ **Literal arithmetic folds; a builtin call never does.** `2 * 3 + 1` is the
114
+ constant 7 before the IR sees it. `sin(0.5)` is two instructions, because
115
+ folding it would mean a second implementation of `sin` in JavaScript, and a
116
+ second implementation is somewhere for the two backends to disagree.
117
+
118
+ **A hex default makes a uniform a colour.** `uniform float4 tint = #ff8040;`
119
+ stores the sRGB components as its default, which is what the host sets and what
120
+ the generated shader's Properties block shows, and every read of `tint` goes
121
+ through `toLinear`. Without that rule, `#ff8040` written as a literal and
122
+ `#ff8040` written as a default would be two different colours in one file.
123
+ A default built out of numbers (`float4(1, 0.5, 0.25, 1)`) is not a colour and
124
+ is read unconverted.
125
+
126
+ **A uniform's default has to be written out.** Numbers, constructors of numbers
127
+ and colours, and nothing else. It is baked into the program before anything
128
+ runs, so it cannot name a const, which could itself name a uniform.
129
+
130
+ **Shadowing is refused.** A local may not take the name of an input, a uniform,
131
+ a texture, a const or a builtin. Allowing it would make "is this a texture?"
132
+ depend on where the question is asked, for no gain in a language whose
133
+ functions are six lines long. A file function may shadow a *prelude* function,
134
+ which is the sanctioned way to replace one.
135
+
136
+ ## Not yet spellable
137
+
138
+ Three things the EDSL or the opcode table has and a file cannot say. Each is a
139
+ recorded gap, not an oversight, and each has its own error message rather than
140
+ "unknown identifier".
141
+
142
+ | | Why |
143
+ |---|---|
144
+ | `rgb2hsv`, `tex2Dlod` | The opcodes are numbered and **neither backend implements them**. Writing one would render as whatever the VM's dispatch falls through to and fail outright in the HLSL emitter. |
145
+ | `fbm`'s simplex base | `sl.fbm(p, octaves, "simplex")` picks the base with a string, and the language has no strings. `fbm(p, octaves)` is the value base, and `turbulence` and `ridged` are the simplex family. |
146
+
147
+ Related, and worth knowing: **the EDSL lets a program declare 15 textures and
148
+ the VM has 4.** `ir.ts` picked its ceiling from the WebGL2 sampler count rather
149
+ than from `FxProgram.shader`, which declares `_Tex0` to `_Tex3` and samples
150
+ `_Tex3` for every slot past it, so slots 4 and up are silently wrong in the
151
+ browser and correct after an eject. A `.sl` file is held to the real number, 4,
152
+ reported at the declaration. The EDSL's ceiling is untouched here because
153
+ lowering it changes recorded behaviour rather than parser behaviour.
154
+
155
+ ## Errors
156
+
157
+ Every one carries file, line and column, and an error that starts inside the
158
+ EDSL keeps the EDSL's wording. `"z" is component 3 of a vec2, which has 2` is
159
+ already the right sentence; what it lacked was a place. `errors.test.ts` checks
160
+ the positions as strictly as the words, because a marker under the wrong
161
+ character sends the reader to look at something that is fine.
162
+
163
+ ## See also
164
+
165
+ - `Specs/SL_TEXT.md`, the whole design, including the four decisions taken
166
+ - `../README.md`, the IR, the EDSL and the hash this lowers to
167
+ - `Specs/SHADER_LANG.md`, the backends
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The AST the parser builds and the checker and lowering walk.
3
+ *
4
+ * Phase A of `Specs/SL_TEXT.md`. Deliberately small: a `.sl` file is one
5
+ * fragment function and its declarations, so there is no module system, no
6
+ * struct, no array and no statement that is not one of six shapes.
7
+ *
8
+ * Every node carries the position of the token it started at. That is what lets
9
+ * an error from the middle of lowering say which character to underline, and it
10
+ * is why the position lives on the node rather than being recovered by a second
11
+ * pass over the source.
12
+ */
13
+
14
+ import type { Pos } from "./lexer"
15
+
16
+ /** The four value types, under their HLSL names. `texture2D` is not one: it declares a slot. */
17
+ export type TypeName = "float" | "float2" | "float3" | "float4"
18
+
19
+ export const TYPE_WIDTH: Record<TypeName, 1 | 2 | 3 | 4> = {
20
+ float: 1, float2: 2, float3: 3, float4: 4,
21
+ }
22
+
23
+ export type BinaryOp =
24
+ | "+" | "-" | "*" | "/" | "%"
25
+ | "<" | "<=" | ">" | ">=" | "==" | "!="
26
+ | "&&" | "||"
27
+
28
+ export type Expr =
29
+ | { k: "num"; value: number; pos: Pos }
30
+ | { k: "hex"; hex: string; pos: Pos }
31
+ | { k: "ident"; name: string; pos: Pos }
32
+ /** A swizzle, or the shape half of `sdf.circle`. Which one is decided later. */
33
+ | { k: "member"; obj: Expr; name: string; pos: Pos }
34
+ | { k: "call"; callee: Expr; args: Expr[]; pos: Pos }
35
+ | { k: "unary"; op: "-" | "+" | "!"; arg: Expr; pos: Pos }
36
+ | { k: "binary"; op: BinaryOp; a: Expr; b: Expr; pos: Pos }
37
+ | { k: "cond"; cond: Expr; then: Expr; else: Expr; pos: Pos }
38
+
39
+ export type AssignOp = "=" | "+=" | "-=" | "*=" | "/=" | "%="
40
+
41
+ export type Stmt =
42
+ | { k: "var"; type: TypeName; name: string; init: Expr; pos: Pos }
43
+ /** A compile time constant: usable as a value and as a `for` bound. */
44
+ | { k: "const"; type: TypeName; name: string; init: Expr; pos: Pos }
45
+ | { k: "assign"; target: Expr; op: AssignOp; value: Expr; pos: Pos }
46
+ | { k: "if"; cond: Expr; then: Stmt[]; else: Stmt[]; pos: Pos }
47
+ | { k: "for"; counter: string; from: Expr; to: Expr; inclusive: boolean; step: Expr; body: Stmt[]; pos: Pos }
48
+ | { k: "return"; value: Expr; pos: Pos }
49
+
50
+ export interface Param {
51
+ type: TypeName
52
+ name: string
53
+ pos: Pos
54
+ }
55
+
56
+ export interface FuncDecl {
57
+ name: string
58
+ ret: TypeName
59
+ params: Param[]
60
+ body: Stmt[]
61
+ pos: Pos
62
+ /** Prelude functions are shadowed by a function of the same name in the file. */
63
+ prelude: boolean
64
+ }
65
+
66
+ export interface UniformDecl {
67
+ name: string
68
+ type: TypeName
69
+ /** Null when the declaration gave no default; the slot then starts at zero. */
70
+ init: Expr | null
71
+ pos: Pos
72
+ }
73
+
74
+ export interface TextureDecl {
75
+ name: string
76
+ pos: Pos
77
+ }
78
+
79
+ export interface Unit {
80
+ file: string
81
+ uniforms: UniformDecl[]
82
+ textures: TextureDecl[]
83
+ consts: Extract<Stmt, { k: "const" }>[]
84
+ funcs: FuncDecl[]
85
+ /** `float4 main()`. Exactly one per file, and the parser refuses a file without it. */
86
+ main: FuncDecl | null
87
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * What each builtin name does, keyed by the name `ops.ts` gives it.
3
+ *
4
+ * Phase A of `Specs/SL_TEXT.md` 3.4. THE NAMES ARE NOT WRITTEN HERE. Every key
5
+ * comes from `SL_HLSL`, the one column the EDSL and the parser share, so a new
6
+ * op cannot arrive with a spelling in one surface and not the other. A contract
7
+ * test (`lang/surface.test.ts`) walks the same table and refuses an op that has
8
+ * neither a lowering nor a stated reason to lack one.
9
+ *
10
+ * Everything lowers through the EDSL rather than through the Builder. That is
11
+ * the property the parity test rests on: `sin(x)` in a file and `sl.sin(x)` in
12
+ * TypeScript are the same call, so they cannot produce different graphs.
13
+ */
14
+
15
+ import { SLError } from "../ir"
16
+ import { SLOP, SL_HLSL, SL_UNIMPLEMENTED } from "../ops"
17
+ import * as sl from "../sl"
18
+ import type { Num, Val } from "../sl"
19
+
20
+ /**
21
+ * How a builtin consumes its arguments.
22
+ *
23
+ * `value` is the ordinary case: every argument is a value and the lowering gets
24
+ * them as `Num`, so a plain literal stays a literal and broadcasts the way the
25
+ * EDSL broadcasts it. `special` names the three whose arguments are not values
26
+ * at all: a texture slot, a shape's constant parameters, a list of colour
27
+ * stops. Those are lowered in `lower.ts`, where the AST is still in reach.
28
+ */
29
+ export interface Builtin {
30
+ op: number
31
+ kind: "value" | "special"
32
+ min: number
33
+ max: number
34
+ /** Present for `kind: "value"`. */
35
+ lower?: (args: Num[]) => Val
36
+ }
37
+
38
+ const name = (op: number): string => {
39
+ const call = SL_HLSL[op]?.call
40
+ if (call === undefined) throw new Error(`opcode ${op} has no call spelling in SL_HLSL`)
41
+ return call
42
+ }
43
+
44
+ /** A plain number where a value is needed, for the ops whose first operand must be one. */
45
+ const val = (v: Num): Val => (typeof v === "number" ? (sl.float(v) as unknown as Val) : v)
46
+
47
+ /**
48
+ * Asserts an operand's width where the two backends would otherwise part ways.
49
+ *
50
+ * The VM keeps every value in a float4 register and writes `a.xy` or `a.rgb`
51
+ * whatever it was handed, so a noise call on a float3 quietly works there. The
52
+ * generated HLSL passes the real type to `sl_valueNoise(float2)` and either
53
+ * truncates with a warning or fails to compile. Neither outcome is one an
54
+ * author should discover after ejecting, so the narrow ops say so here.
55
+ */
56
+ function widthMustBe(fn: string, v: Num, want: 1 | 2 | 3 | 4, why: string): Val {
57
+ const got = typeof v === "number" ? 1 : v.width
58
+ if (got !== want) {
59
+ throw new SLError(`${fn} takes ${why}, and this is a ${WIDTH_NAME[got]}`)
60
+ }
61
+ return val(v)
62
+ }
63
+
64
+ const WIDTH_NAME: Record<number, string> = { 1: "float", 2: "float2", 3: "float3", 4: "float4" }
65
+
66
+ /** A colour operand: three components, or four with alpha along for the ride. */
67
+ function colourOperand(fn: string, v: Num): Val {
68
+ const got = typeof v === "number" ? 1 : v.width
69
+ if (got < 3) throw new SLError(`${fn} takes a colour, so a float3 or a float4, and this is a ${WIDTH_NAME[got]}`)
70
+ return val(v)
71
+ }
72
+
73
+ const table: Record<string, Builtin> = {}
74
+
75
+ function value(op: number, min: number, max: number, lower: (args: Num[]) => Val): void {
76
+ table[name(op)] = { op, kind: "value", min, max, lower }
77
+ }
78
+
79
+ function special(op: number, min: number, max: number): void {
80
+ table[name(op)] = { op, kind: "special", min, max }
81
+ }
82
+
83
+ /** Width preserving one argument ops, which the EDSL exposes as free functions. */
84
+ const UNARY: Array<[number, (v: Num) => Val]> = [
85
+ [SLOP.SIN, (v) => sl.sin(val(v))],
86
+ [SLOP.COS, (v) => sl.cos(val(v))],
87
+ [SLOP.TAN, (v) => sl.tan(val(v))],
88
+ [SLOP.ASIN, (v) => sl.asin(val(v))],
89
+ [SLOP.ACOS, (v) => sl.acos(val(v))],
90
+ [SLOP.EXP, (v) => sl.exp(val(v))],
91
+ [SLOP.LOG, (v) => sl.log(val(v))],
92
+ [SLOP.SQRT, (v) => sl.sqrt(val(v))],
93
+ [SLOP.SIGN, (v) => sl.sign(val(v))],
94
+ [SLOP.CEIL, (v) => sl.ceil(val(v))],
95
+ [SLOP.ROUND, (v) => sl.round(val(v))],
96
+ [SLOP.TO_LINEAR, (v) => sl.toLinear(val(v))],
97
+ [SLOP.ABS, (v) => val(v).abs()],
98
+ [SLOP.FLOOR, (v) => val(v).floor()],
99
+ [SLOP.FRACT, (v) => val(v).fract()],
100
+ [SLOP.SATURATE, (v) => val(v).saturate()],
101
+ [SLOP.RECIP, (v) => val(v).recip()],
102
+ [SLOP.NORMALIZE, (v) => val(v).normalize()],
103
+ ]
104
+ for (const [op, fn] of UNARY) value(op, 1, 1, (a) => fn(a[0]!))
105
+
106
+ value(SLOP.POW, 2, 2, (a) => val(a[0]!).pow(a[1]!))
107
+ value(SLOP.MIN, 2, 2, (a) => val(a[0]!).min(a[1]!))
108
+ value(SLOP.MAX, 2, 2, (a) => val(a[0]!).max(a[1]!))
109
+ value(SLOP.CLAMP, 3, 3, (a) => val(a[0]!).clamp(a[1]!, a[2]!))
110
+ value(SLOP.ATAN2, 2, 2, (a) => sl.atan2(a[0]!, a[1]!))
111
+
112
+ value(SLOP.LENGTH, 1, 1, (a) => val(a[0]!).length())
113
+ value(SLOP.DISTANCE, 2, 2, (a) => val(a[0]!).distance(a[1]!))
114
+ value(SLOP.DOT, 2, 2, (a) => val(a[0]!).dot(a[1]!))
115
+ value(SLOP.CROSS, 2, 2, (a) => sl.cross(val(a[0]!) as never, val(a[1]!) as never))
116
+ value(SLOP.REFLECT, 2, 2, (a) => sl.reflect(val(a[0]!) as never, val(a[1]!) as never))
117
+
118
+ value(SLOP.MIX, 3, 3, (a) => sl.mix(a[0]!, a[1]!, a[2]!))
119
+ value(SLOP.STEP, 2, 2, (a) => sl.step(a[0]!, a[1]!))
120
+ value(SLOP.SMOOTHSTEP, 3, 3, (a) => sl.smoothstep(a[0]!, a[1]!, a[2]!))
121
+ value(SLOP.REMAP, 5, 5, (a) => sl.remap(a[0]!, a[1]!, a[2]!, a[3]!, a[4]!))
122
+
123
+ value(SLOP.HSV2RGB, 1, 1, (a) => sl.hsv2rgb(widthMustBe("hsv2rgb", a[0]!, 3, "a float3 of hue, saturation and value") as never))
124
+ value(SLOP.LUMINANCE, 1, 1, (a) => sl.luminance(colourOperand("luminance", a[0]!)))
125
+
126
+ value(SLOP.NOISE, 1, 1, (a) => sl.noise(point("noise", a[0]!) as never))
127
+ value(SLOP.SIMPLEX, 1, 1, (a) => sl.simplex(point("simplex", a[0]!) as never))
128
+ value(SLOP.VORONOI, 1, 1, (a) => sl.voronoi(point("voronoi", a[0]!) as never))
129
+ // The octave count is an immediate, so it has to be a number at parse time.
130
+ // `lower.ts` folds constants before it gets here and rejects anything else with
131
+ // a message that says why, rather than letting this cast be the error.
132
+ value(SLOP.FBM, 1, 2, (a) => sl.fbm(point("fbm", a[0]!) as never, octaves("fbm", a[1])))
133
+ value(SLOP.TURBULENCE, 1, 2, (a) => sl.turbulence(point("turbulence", a[0]!) as never, octaves("turbulence", a[1])))
134
+ value(SLOP.RIDGED, 1, 2, (a) => sl.ridged(point("ridged", a[0]!) as never, octaves("ridged", a[1])))
135
+
136
+ /** Every field is sampled at a float2. */
137
+ function point(fn: string, v: Num): Val {
138
+ return widthMustBe(fn, v, 2, "a float2 to sample at")
139
+ }
140
+
141
+ special(SLOP.SAMPLE, 2, 2)
142
+ special(SLOP.SDF, 1, 7)
143
+ special(SLOP.RAMP, 3, 64)
144
+
145
+ function octaves(fn: string, v: Num | undefined): number {
146
+ if (v === undefined) return 3
147
+ if (typeof v !== "number") {
148
+ throw new SLError(
149
+ `${fn}'s octave count is baked into the instruction, so it has to be a constant. ` +
150
+ `A uniform or a computed value cannot change how many octaves the shader runs.`,
151
+ )
152
+ }
153
+ return v
154
+ }
155
+
156
+ export const BUILTINS: Readonly<Record<string, Builtin>> = table
157
+
158
+ /**
159
+ * Names that exist in the opcode table and cannot be written yet, with why.
160
+ *
161
+ * Separate from "unknown identifier" on purpose: an author who writes
162
+ * `rgb2hsv` has read the right documentation and deserves the real answer.
163
+ */
164
+ export const NOT_YET: Readonly<Record<string, string>> = Object.fromEntries(
165
+ Object.entries(SL_UNIMPLEMENTED)
166
+ .filter(([op]) => SL_HLSL[Number(op)]?.call !== undefined && table[SL_HLSL[Number(op)]!.call!] === undefined)
167
+ .map(([op, why]) => [SL_HLSL[Number(op)]!.call!, why]),
168
+ )