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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ The shader language compiler as its own package, moved out of `onejs-unity` with its history and its tests. It behaves exactly as `onejs-unity` 0.5.15's `sl` did, at IR version 2 and VM wire version 2.
6
+
7
+ - Entry points `onejs-sl`, `/core`, `/tables`, `/limits`, `/vm`, `/emit/unity` and `/emit/web`
8
+ - `vmFit` says whether the VM runs a program, and why not, without encoding it
9
+ - `parseColor` is exported from `onejs-sl/core`
10
+ - The test suite also runs in QuickJS-ng and must match Node byte for byte
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # onejs-sl
2
+
3
+ The OneJS shader language: a small typed language for per pixel programs. A
4
+ program is written as a `.sl` file (HLSL text) or with the TypeScript form, and
5
+ recorded as one portable IR, a flat typed graph with a hash. The package emits
6
+ that IR as the buffer OneJS's VM evaluates, a Unity ShaderLab shader, WGSL and
7
+ GLSL ES.
8
+
9
+ It is the compiler [OneJS](https://onejs.com) uses, taken out of `onejs-unity`
10
+ so a host without Unity can run it too. `onejs-unity/sl` and
11
+ `onejs-unity/sl/compiler` re-export it, so nothing in a OneJS project imports
12
+ this package directly. User documentation is the
13
+ [shader language guide](https://onejs.com/docs/guides/shader-language); this
14
+ file is the design.
15
+
16
+ ```ts
17
+ import { parse, sl } from "onejs-sl"
18
+
19
+ const ripple = parse(`float4 main() {
20
+ float v = sin(length(uv - 0.5) * 40 - time * 4);
21
+ return float4(v * 0.5 + 0.5, 0, 0, 1);
22
+ }`, { file: "ripple.sl" })
23
+
24
+ const plasma = sl.program(({ uv, time }) => {
25
+ const p = uv.mul(8).add(time.mul(0.4))
26
+ const v = sl.sin(p.x).add(sl.sin(p.y))
27
+ return sl.vec4(v.mul(0.5).add(0.5), 0, 0, 1)
28
+ })
29
+ ```
30
+
31
+ ## Entry points
32
+
33
+ `"sideEffects": false`, and each backend is its own entry, so a host bundles
34
+ only what it calls.
35
+
36
+ | Entry | Contents |
37
+ |---|---|
38
+ | `onejs-sl` | `parse` and `analyze`, the TypeScript form `sl`, the IR types, `SL_IR_VERSION`, `toJSON`/`fromJSON`, `SLParseError` (file, line, column, length) |
39
+ | `onejs-sl/core` | the same without the parser: what a game needs at run time |
40
+ | `onejs-sl/tables` | `BUILTINS`, `SL_HLSL`, `INPUTS`, `SL_SDF_SHAPES`, `SL_SDF_PARAMS`: what completion and highlighting read |
41
+ | `onejs-sl/limits` | `vmFit(program)`: whether the VM runs it, and why not, without encoding |
42
+ | `onejs-sl/vm` | `encode`, `SL_WIRE_VERSION` |
43
+ | `onejs-sl/emit/unity` | `emitShader`: the `.shader` a Unity editor generates |
44
+ | `onejs-sl/emit/web` | `emitWGSL`, `emitGLSL`: OneJS's web frame |
45
+
46
+ `src/entries.test.ts` pins every name, since removing one breaks a host.
47
+
48
+ ## Runs anywhere ES2020 runs
49
+
50
+ The source reaches for no host API: no `fs`, `process`, `Buffer`,
51
+ `TextEncoder`, `performance`, `structuredClone` or `Intl`. It runs in a
52
+ browser (the Play editor's diagnostics), a Cloudflare worker (the Play build)
53
+ and QuickJS (Magerie). Two checks hold it to that:
54
+
55
+ - lint refuses those globals and any Node module in `src/`;
56
+ - `npm run test:quickjs` bundles `quickjs/corpus.ts` as one ES2020 IIFE, the
57
+ way Magerie bundles a script, runs it in QuickJS-ng and in a bare Node
58
+ context, and fails unless the two results agree byte for byte. The corpus
59
+ parses, encodes, fits and emits every program in `quickjs/corpus/`, every
60
+ shape at its full parameter count, and the error paths. `npm test` runs it
61
+ after vitest.
62
+
63
+ ## Releasing
64
+
65
+ Push a tag `v<version>` matching `package.json`; `.github/workflows/publish.yml`
66
+ publishes through npm trusted publishing (OIDC), so no token exists anywhere.
67
+ Note the release in `CHANGELOG.md` first. The one exception is the first version: npm
68
+ attaches a trusted publisher to a package that already exists, so 0.1.0 was
69
+ published by hand, and the workflow checks its tag and publishes nothing. In the OneJS container this package
70
+ is checked out at `JSModules/onejs-sl`, and `onejs-unity` links it with
71
+ `file:../onejs-sl` as a dev dependency beside its `^` peer range.
72
+
73
+ ## The one idea
74
+
75
+ **One authoring surface, one IR, two backends.** Unity cannot compile a shader
76
+ at runtime in a player build, on any graphics API, so in the browser a program
77
+ has to become data that a fixed shader evaluates. Ejecting to a Unity project
78
+ does not change what the author wrote, it changes what is possible, because an
79
+ editor compiles shaders at build time.
80
+
81
+ So the same source is interpreted by a VM on play.onejs.com and compiled from
82
+ generated HLSL after an eject, with no edit in between. Both backends exist:
83
+ `encode.ts` feeds OneJS's `Runtime/SL/SLProgramBridge.cs` and `FxProgram.shader`,
84
+ and `hlsl.ts` feeds its `Editor/SLShaderGenerator.cs`. Nobody writes a manifest for the
85
+ second: an editor that interprets a program asks the encoded program for its
86
+ `hlsl` (a lazy getter, never read in Play), records it into
87
+ `Assets/OneJS.Generated/Shaders/Recorded.sl.json`, generates the shader and
88
+ moves the live material onto it. `manifest()` is still there for an app that
89
+ would rather write its programs out at build time.
90
+
91
+ **A third and fourth backend, for the browser.** A player cannot compile a
92
+ shader, but the page it runs in can. `web.ts` prints every program as WGSL and
93
+ as GLSL ES 3.00 (`weblib.ts` holds the noise, colour and 42 distance functions
94
+ ported from `SLCommon.cginc`, `Noise2D.cginc` and `SDF2D.cginc`), and OneJS's
95
+ `Plugins/WebGL/OneJSSLWeb.jslib` compiles whichever one Unity's device speaks
96
+ and draws it into the element's target in place of the VM. A `.sl` import
97
+ carries both strings, printed at build time; an `encode()` result has them as
98
+ lazy getters, like `hlsl`. The VM draws until the compiled program is ready,
99
+ and for good if it fails to compile. The host contract (the frame block, the
100
+ 16 uniform slots, one binding pair per sampled texture) is written out at the
101
+ top of `web.ts`.
102
+
103
+ The emitters match the HLSL emitter's semantics rather than each language's
104
+ own: `%` truncates like `fmod`, `pow` takes `abs` of its base, `asin` and
105
+ `acos` clamp, `log` and `sqrt` guard their argument, a select is the VM's
106
+ branchless `lerp`. `web.test.ts` checks the structure (every shape, every
107
+ opcode, the library order); whether the output matches the VM within 1/255 is
108
+ measured in a browser, through the real element, by `Tools/sl-web-parity` in
109
+ the container.
110
+
111
+ ## Two ways to write one
112
+
113
+ A `.sl` file is HLSL text and `sl.program` is a TypeScript EDSL, and they record
114
+ the same graph: a file lowers THROUGH the EDSL, so `sin(x)` and `sl.sin(x)` are
115
+ one call. `lang/` is the parser and `lang/README.md` covers it; the rest of this
116
+ file is the IR, the hash and the EDSL, which both surfaces sit on.
117
+
118
+ ```hlsl
119
+ float4 main() {
120
+ float2 p = uv * 8 + time * 0.4;
121
+ float v = sin(p.x) + sin(p.y);
122
+ return float4(v * 0.5 + 0.5, 0, 0, 1);
123
+ }
124
+ ```
125
+
126
+ Docs lead with the file. The EDSL is the programmatic form: the IR builder, the
127
+ parser's target, and what a program built by code uses.
128
+
129
+ ## Why an EDSL came first
130
+
131
+ A TypeScript EDSL inherits completion, type errors at the call site, jump to
132
+ definition, rename and the author's editor for free. Monaco in the Play editor
133
+ already has these types loaded.
134
+
135
+ It also gives common subexpression elimination for nothing, which is the most
136
+ valuable optimisation here, because **a `const` in the host language IS the
137
+ shared node**. `const p = uv.mul(8)` used three times is one node with three
138
+ references, and writing it out long hand three times costs exactly the same,
139
+ because nodes are interned as they are built.
140
+
141
+ That reasoning is why the parser, when it came, cost only a parser: it emits
142
+ this same IR, so it inherited the encoder, the emitter, the hash and every test
143
+ that runs on a program.
144
+
145
+ ## What is checked, and when
146
+
147
+ Everything an author can get wrong is refused **when the program is written**,
148
+ at module load, not at draw time:
149
+
150
+ | Mistake | What happens |
151
+ |---|---|
152
+ | `vec2` combined with a `vec3` | TypeScript error at the call site, and a runtime error behind it |
153
+ | `uv.z` on a two component value | "z is component 3 of a vec2, which has 2" |
154
+ | A program returning something other than a `vec4` | "a program must return a vec4. Wrap it: sl.vec4(value, 1)" |
155
+ | `vec4` given the wrong number of parts | "vec4 needs 4 components, got 3" |
156
+ | One uniform name at two widths | "declared as both a float and a vec4" |
157
+ | More than 15 textures | Names the limit and why it cannot be widened |
158
+ | A value borrowed from another program | "a value from another program cannot be used in this one" |
159
+
160
+ The texture ceiling is the fragment shader's sampler slots on the WebGL2
161
+ baseline, which is the one resource neither backend can widen.
162
+
163
+ ## The hash is the fragile part
164
+
165
+ `Program.hash` is what will link a program to its compiled shader. If it differs
166
+ between the machine that generated the shader and the machine that runs it, the
167
+ runtime falls back to the VM and **nobody is told**: correct output, quietly
168
+ slow, no error. That is the worst failure this design can have.
169
+
170
+ So it is a Merkle hash over the graph reachable from the result, not a walk of
171
+ the node array. An earlier version hashed storage order, which meant hoisting a
172
+ shared subexpression into a `const` changed the hash without changing what the
173
+ program computed. Constants go through a fixed precision, so `0.1 + 0.2` and
174
+ `0.3` do not produce different shaders. It is eight lowercase hex characters
175
+ from FNV-1a, chosen so a C# implementation can produce the same string rather
176
+ than for any cryptographic reason.
177
+
178
+ ## Versions
179
+
180
+ Two numbers, with one rule: a reader accepts every version up to its own and
181
+ refuses a newer one with a message naming both.
182
+
183
+ - **`SL_IR_VERSION`** (`ir.ts`) is on every `Program` and in the hash, and is
184
+ bumped whenever an opcode, a shape or what one computes changes. Because it
185
+ is in the hash, a bump recompiles every cached shader. `toJSON` and
186
+ `fromJSON` (`serial.ts`) are the IR as JSON for a host that stores programs;
187
+ `fromJSON` checks everything an emitter relies on, refuses a newer version,
188
+ and migrates an older one.
189
+ - **`SL_WIRE_VERSION`** (`ops.ts`) is the newest VM encoding, and
190
+ `SLProgramBridge.WireVersion` in OneJS must match it (a container test
191
+ compares the two). `Encoded.wire`, and the `wire` in a `.sl` import, is the
192
+ LOWEST version that can run that program, so a program using nothing new
193
+ stays 1 and still runs on an older Play container. The VM refuses a newer
194
+ one, only where the VM runs; a WebGL player draws compiled and never reads
195
+ the buffer.
196
+
197
+ IR 2 and wire 2 came with #129. A shape takes as many parameters as it reads
198
+ (`SL_SDF_PARAMS`, six at most, and never fewer than four accepted), where it
199
+ used to take four and lose the rest. One instruction holds a shape id and four
200
+ immediates, so the encoder's `forVm` turns a shape given a fifth or sixth into
201
+ `SDF_WIDE`, which reads the remaining four from a constant register. Only the
202
+ VM sees it, and only those programs are wire 2.
203
+
204
+ ## Control flow
205
+
206
+ There is none in the IR, deliberately. `sl.select`, `sl.step`, `sl.smoothstep` and
207
+ `sl.mix` cover branching without branching, and `sl.repeat(n, body, seed)`
208
+ unrolls at record time because `n` is a JavaScript number.
209
+
210
+ `repeat` is honest about being a macro rather than a loop. It covers fbm,
211
+ layered noise and small iterated distance fields, which is most of what 2D
212
+ shaders loop for. A data dependent loop is out of scope: the VM would need a
213
+ nested bounded loop with a dynamic trip count while codegen would handle it
214
+ fine, and the two backends agreeing is the property the whole design protects.
215
+ Because it unrolls, the count multiplies the body's operation count toward the
216
+ VM's 256-instruction ceiling. The ceiling error names any `repeat` that fills a
217
+ quarter of the budget or more, so the fix reads as "lower this count" rather
218
+ than "fewer instructions".
219
+
220
+ Every loop that reaches a GPU is therefore bounded by a constant: `repeat` is
221
+ unrolled, fbm's octaves are a constant 1 to 4, the helper loops in the noise
222
+ and Voronoi functions have fixed trip counts. No program can hang a GPU today,
223
+ so the compiled backends carry no loop cap. A data dependent loop (the
224
+ raymarching tier) would need one emitted into every loop it prints, since a GPU
225
+ reset takes the whole page's device, Unity's included.
226
+
227
+ ## See also
228
+
229
+ - `Specs/SHADER_LANG.md` in the OneJS container, sections 3 and 4, and section 5.4 for the Phase 0
230
+ measurements that decided the VM's shape
231
+ - `Tools/shader-vm-spike/`, the harness behind those numbers
232
+ - `onejs-unity`'s `fx`, the image pipeline this becomes a source and an operand for
233
+
234
+ ## What a program is given
235
+
236
+ `uv`, `fragCoord`, `resolution`, `time` and `aspect`, and the last three are the
237
+ **target's**, not the window's. A program is drawn with `Graphics.Blit` into the
238
+ element's own render texture, and Unity sets `_ScreenParams` per camera and
239
+ leaves it alone for a blit: reading it from a 64x256 target answers with the
240
+ game view's size. Both backends read the same wrong thing, so they agreed with
241
+ each other and the eject test, which compares them, saw nothing. What saw it was
242
+ a picture, because aspect correction, the one thing `aspect` exists for,
243
+ stretched every circle by the shape of whatever window it was in. The host now
244
+ sets `_Res` from the target and both backends read that.
245
+
246
+ `fx` had already learned this: `ShaderEffectElement` sets `_Aspect` from the
247
+ render texture with a comment saying why. The lesson did not travel.
248
+
249
+ ## Colours
250
+
251
+ A hex colour is sRGB as written, the way CSS reads it, and the target holds
252
+ linear light. `sl.ramp` mixes its stops in sRGB, which is what reads as an even
253
+ ramp, and converts the result once through `TO_LINEAR`; `sl.color("#hex")` is
254
+ `parseColor` plus that conversion, and `sl.toLinear` is the conversion on its
255
+ own for a vec4 built from raw components. Both backends implement it gamma
256
+ aware (`sl_toLinear` in `SLCommon.cginc`), so a Gamma project gets the value as
257
+ written. Alpha is coverage and is never converted. Same rule as `fx`.
258
+
259
+ ## Noise
260
+
261
+ `sl.noise`, `sl.simplex`, `sl.fbm(p, octaves, base)`, `sl.turbulence` and
262
+ `sl.ridged` are the fields `fx.noise` draws, from the same `Noise2D.cginc`, so
263
+ a simplex here is the simplex there. Octaves are 1 to 4. A program has no seed;
264
+ offset the input for a different field. `sl.simplex` used to be value noise on
265
+ a rotated lattice, and `sl.fbm` had its own value noise; both changed on
266
+ 2026-09-06 when the fields were unified.
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "onejs-sl",
3
+ "version": "0.1.0",
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
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./core": "./src/core.ts",
10
+ "./tables": "./src/tables.ts",
11
+ "./limits": "./src/limits.ts",
12
+ "./vm": "./src/vm.ts",
13
+ "./emit/unity": "./src/emit/unity.ts",
14
+ "./emit/web": "./src/emit/web.ts"
15
+ },
16
+ "sideEffects": false,
17
+ "files": [
18
+ "src",
19
+ "!src/**/*.test.ts",
20
+ "!src/fixtures",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "keywords": [
24
+ "onejs",
25
+ "shader",
26
+ "shading-language",
27
+ "hlsl",
28
+ "wgsl",
29
+ "glsl",
30
+ "compiler",
31
+ "sdf",
32
+ "noise",
33
+ "procedural"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Singtaa/onejs-sl.git"
38
+ },
39
+ "homepage": "https://onejs.com/docs/guides/shader-language",
40
+ "author": "Singtaa",
41
+ "license": "MIT",
42
+ "scripts": {
43
+ "typecheck": "tsc --noEmit",
44
+ "lint": "eslint .",
45
+ "test": "vitest run && node quickjs/run.mjs",
46
+ "test:quickjs": "node quickjs/run.mjs"
47
+ },
48
+ "devDependencies": {
49
+ "@eslint/js": "^10.0.1",
50
+ "@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
51
+ "@types/node": "^22.0.0",
52
+ "esbuild": "^0.25.0",
53
+ "eslint": "^10.9.1",
54
+ "globals": "^17.11.0",
55
+ "quickjs-emscripten-core": "^0.32.0",
56
+ "typescript": "^5.7.0",
57
+ "typescript-eslint": "^8.68.0",
58
+ "vitest": "^2.1.0"
59
+ }
60
+ }
package/src/color.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * "#rgb", "#rrggbb" or "#rrggbbaa" to 0..1 components.
3
+ *
4
+ * Shared by `sl` and `fx`, so a colour is written the same way in a program
5
+ * and in an image chain and neither has to import the other.
6
+ */
7
+ export function parseColor(hex: string): [number, number, number, number] {
8
+ const m = /^#([0-9a-fA-F]{3,8})$/.exec(hex.trim())
9
+ if (m === null) throw new Error(`"${hex}" is not a colour; use #rgb, #rrggbb or #rrggbbaa`)
10
+ const h = m[1]!
11
+ const grab = (i: number, n: number) => parseInt(n === 1 ? h[i]! + h[i]! : h.slice(i * 2, i * 2 + 2), 16) / 255
12
+ if (h.length === 3) return [grab(0, 1), grab(1, 1), grab(2, 1), 1]
13
+ if (h.length === 6) return [grab(0, 2), grab(1, 2), grab(2, 2), 1]
14
+ if (h.length === 8) return [grab(0, 2), grab(1, 2), grab(2, 2), grab(3, 2)]
15
+ throw new Error(`"${hex}" is not a colour; use #rgb, #rrggbb or #rrggbbaa`)
16
+ }
package/src/core.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `onejs-sl/core`: a program and nothing that reads source text.
3
+ *
4
+ * The TypeScript form, the IR, its version and its JSON, the opcode and shape
5
+ * tables. What a game needs at run time, which is why OneJS's `onejs-unity/sl`
6
+ * is built on this entry and not on `onejs-sl`: the eject scaffold vendors
7
+ * every file an entry reaches, and the parser is two thousand lines a played
8
+ * game never executes.
9
+ */
10
+ export * as sl from "./sl"
11
+ export type { Float, Vec2, Vec3, Vec4, Num, ProgramInputs, Texture } from "./sl"
12
+ export {
13
+ TYPE, INPUTS, MAX_TEXTURES, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION,
14
+ } from "./ir"
15
+ export type {
16
+ SLType, InputName, NodeRef, SLNode, Program, UniformDecl, TextureDecl,
17
+ } from "./ir"
18
+ export { SLOP, SL_ARITY, SL_NAME, INPUT_ID, isSampling } from "./ops"
19
+ export type { SLOpCode } from "./ops"
20
+ export { SL_SDF_SHAPES, SL_SDF_PARAMS } from "./shapes"
21
+ export type { SlSdfKind } from "./shapes"
22
+ export { toJSON, fromJSON } from "./serial"
23
+ export type { ProgramJSON } from "./serial"
24
+ export { parseColor } from "./color"
@@ -0,0 +1,3 @@
1
+ /** `onejs-sl/emit/unity`: the ShaderLab `.shader` a Unity editor generates for a program. */
2
+ export { emitShader, emitFragmentBody, uniformProperty } from "../hlsl"
3
+ export type { EmitOptions } from "../hlsl"
@@ -0,0 +1,3 @@
1
+ /** `onejs-sl/emit/web`: WGSL and GLSL ES 3.00 in OneJS's web frame, for a browser to compile. */
2
+ export { emitGLSL, emitWGSL, WEB_UNIFORM_SLOTS } from "../web"
3
+ export type { WebLanguage } from "../web"