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/src/hlsl.ts ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The second backend: prints a program as real HLSL, compiled at build time.
3
+ *
4
+ * Phase 3 of `Specs/SHADER_LANG.md` section 6, and the half the whole design
5
+ * exists for. Unity cannot compile a shader at runtime in a player build, so on
6
+ * play.onejs.com a program has to be interpreted. Ejecting to a Unity project
7
+ * does not change what the author wrote, it changes what is POSSIBLE, because
8
+ * an editor compiles shaders at build time.
9
+ *
10
+ * So the same source is interpreted in the browser and compiled after an eject,
11
+ * with no edit in between. That is what stops "Play games eject cleanly" from
12
+ * quietly acquiring an asterisk.
13
+ *
14
+ * WHY THIS IS THE EASY BACKEND. The VM keeps every value in a float4 register
15
+ * and has to be told how wide each one really is. Here the IR's types become the
16
+ * HLSL types directly, so `float2` is a `float2` and nothing needs padding or
17
+ * explaining. One local per node also gives common subexpression elimination for
18
+ * free: a node is emitted once no matter how many nodes reference it.
19
+ *
20
+ * The generated names are `n<index>` and are unreadable on purpose. Generated
21
+ * code that looks hand written invites hand editing, and a hand edit is lost the
22
+ * next time it is generated.
23
+ */
24
+
25
+ import { SLError, TYPE, type Program, type SLNode, type SLType } from "./ir"
26
+ import { INPUT_ID, SLOP } from "./ops"
27
+
28
+ const HLSL_TYPE: Record<SLType, string> = { 1: "float", 2: "float2", 3: "float3", 4: "float4" }
29
+
30
+ /** A literal that survives a float32 round trip and never reads as an int. */
31
+ function lit(n: number): string {
32
+ if (!Number.isFinite(n)) throw new SLError(`cannot emit ${n} as a shader literal`)
33
+ const s = Number.isInteger(n) ? n.toFixed(1) : String(n)
34
+ return s
35
+ }
36
+
37
+ function ctor(type: SLType, parts: string[]): string {
38
+ return type === TYPE.FLOAT ? parts[0] : `${HLSL_TYPE[type]}(${parts.join(", ")})`
39
+ }
40
+
41
+ const SWZ = "xyzw"
42
+
43
+ /** The property name a uniform gets. Prefixed so it cannot collide with ours. */
44
+ export function uniformProperty(name: string): string {
45
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
46
+ throw new SLError(`uniform "${name}" is not a usable shader property name`)
47
+ }
48
+ return "_u_" + name
49
+ }
50
+
51
+ export interface EmitOptions {
52
+ /** Shader name. Defaults to a name derived from the program hash. */
53
+ name?: string
54
+ /**
55
+ * Path used for the shared helpers include. Unity resolves this relative to
56
+ * the generated shader's own folder, so a generator writing somewhere other
57
+ * than beside SLCommon.cginc has to say where it went.
58
+ */
59
+ include?: string
60
+ }
61
+
62
+ /**
63
+ * Emits a complete `.shader` for a program.
64
+ *
65
+ * The shader's name carries the program hash, which is how the runtime finds it
66
+ * again. If that link breaks, the runtime silently falls back to the VM and
67
+ * nobody is told: correct output, quietly slow, no error. See `hashProgram`.
68
+ */
69
+ export function emitShader(p: Program, options: EmitOptions = {}): string {
70
+ const name = options.name ?? `Hidden/SLGenerated/${p.hash}`
71
+ const include = options.include ?? "SLCommon.cginc"
72
+ const body = emitFragmentBody(p)
73
+
74
+ const props: string[] = [
75
+ ` _Secs ("Seconds", Float) = 0`,
76
+ ` _FlipY ("Flip Y", Float) = 0`,
77
+ ` _Res ("Target size", Vector) = (1, 1, 0, 0)`,
78
+ ]
79
+ const decls: string[] = [` float _Secs;`, ` float _FlipY;`, ` float4 _Res;`]
80
+
81
+ for (const u of p.uniforms) {
82
+ const prop = uniformProperty(u.name)
83
+ // Declared as a float4 whatever its width, so the host sets uniforms the
84
+ // same way for every program and a widening edit does not change the
85
+ // binding. The body swizzles down to the width the program uses.
86
+ const d = [u.value[0] ?? 0, u.value[1] ?? 0, u.value[2] ?? 0, u.value[3] ?? 1]
87
+ props.push(` ${prop} ("${u.name}", Vector) = (${d.map(lit).join(", ")})`)
88
+ decls.push(` float4 ${prop};`)
89
+ }
90
+ for (const t of p.textures) {
91
+ props.push(` _Tex${t.slot} ("${t.name}", 2D) = "white" {}`)
92
+ decls.push(` sampler2D _Tex${t.slot};`)
93
+ }
94
+
95
+ return `// GENERATED from a shader language program. Do not edit.
96
+ //
97
+ // Source of truth is the program this was emitted from; edits here are lost the
98
+ // next time it is generated. The name carries the program hash, which is how the
99
+ // runtime pairs the two. See Specs/SHADER_LANG.md section 6.
100
+ Shader "${name}"
101
+ {
102
+ Properties
103
+ {
104
+ ${props.join("\n")}
105
+ }
106
+
107
+ SubShader
108
+ {
109
+ Cull Off ZWrite Off ZTest Always
110
+ Pass
111
+ {
112
+ CGPROGRAM
113
+ #pragma vertex vert
114
+ #pragma fragment frag
115
+ #pragma target 3.0
116
+ #include "UnityCG.cginc"
117
+ // Shared with the VM, so both backends compute the same noise.
118
+ #include "${include}"
119
+
120
+ ${decls.join("\n")}
121
+
122
+ struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; };
123
+ struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; };
124
+
125
+ v2f vert(appdata v)
126
+ {
127
+ v2f o;
128
+ o.pos = UnityObjectToClipPos(v.vertex);
129
+ // Origin corrected here exactly as the VM does it, so an ejected
130
+ // game is not upside down relative to the one on the site.
131
+ o.uv = float2(v.uv.x, lerp(v.uv.y, 1.0 - v.uv.y, _FlipY));
132
+ return o;
133
+ }
134
+
135
+ fixed4 frag(v2f i) : SV_Target
136
+ {
137
+ ${body}
138
+ }
139
+ ENDCG
140
+ }
141
+ }
142
+ }
143
+ `
144
+ }
145
+
146
+ /** The straight line body: one local per reachable node, in order. */
147
+ export function emitFragmentBody(p: Program): string {
148
+ const lines: string[] = []
149
+ const name = (ref: number) => `n${ref}`
150
+ const emitted = new Set<number>()
151
+
152
+ const walk = (ref: number): void => {
153
+ if (emitted.has(ref)) return
154
+ const n = p.nodes[ref]
155
+ const deps = n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
156
+ for (const d of deps) walk(d)
157
+ emitted.add(ref)
158
+ lines.push(` ${HLSL_TYPE[n.type]} ${name(ref)} = ${expr(n, name, p)};`)
159
+ }
160
+ walk(p.result)
161
+ lines.push(` return ${name(p.result)};`)
162
+ return lines.join("\n")
163
+ }
164
+
165
+ function expr(n: SLNode, name: (r: number) => string, p: Program): string {
166
+ switch (n.k) {
167
+ case "const":
168
+ return ctor(n.type, n.v.map(lit))
169
+ case "input": {
170
+ // `_Res` is the TARGET's size, set by the host, not `_ScreenParams`.
171
+ //
172
+ // A program is drawn with Graphics.Blit into the element's own
173
+ // render texture, and Unity leaves _ScreenParams at whatever the
174
+ // last camera set: blitting into a 64x256 target reads it as the
175
+ // game view's 1737x1226. So `resolution` and `fragCoord` were the
176
+ // window's and `aspect` was the window's ratio, identically wrong
177
+ // on both backends, which is why nothing caught it. Aspect
178
+ // correction, the thing `aspect` exists for, stretched every
179
+ // circle by the shape of the window it happened to be in.
180
+ switch (INPUT_ID[n.name]) {
181
+ case 0: return "i.uv"
182
+ case 1: return "i.uv * _Res.xy"
183
+ case 2: return "_Res.xy"
184
+ case 3: return "_Secs"
185
+ default: return "(_Res.x / max(_Res.y, 1.0))"
186
+ }
187
+ }
188
+ case "uniform": {
189
+ const u = p.uniforms[n.slot]
190
+ const prop = uniformProperty(u.name)
191
+ return n.type === TYPE.VEC4 ? prop : `${prop}.${SWZ.slice(0, n.type)}`
192
+ }
193
+ case "swizzle":
194
+ return `${name(n.src)}.${n.chans.map((c) => SWZ[c]).join("")}`
195
+ case "call":
196
+ return call(n, name)
197
+ }
198
+ }
199
+
200
+ function call(n: Extract<SLNode, { k: "call" }>, name: (r: number) => string): string {
201
+ const a = n.args.map(name)
202
+ const imm = n.imm ?? []
203
+ switch (n.op) {
204
+ case SLOP.COMPOSE: return ctor(n.type, a)
205
+
206
+ case SLOP.ADD: return `(${a[0]} + ${a[1]})`
207
+ case SLOP.SUB: return `(${a[0]} - ${a[1]})`
208
+ case SLOP.MUL: return `(${a[0]} * ${a[1]})`
209
+ case SLOP.DIV: return `(${a[0]} / ${a[1]})`
210
+ case SLOP.MOD: return `fmod(${a[0]}, ${a[1]})`
211
+ // abs on the base, matching the VM. pow of a negative base is undefined
212
+ // in HLSL and the two backends must be undefined in the same direction.
213
+ case SLOP.POW: return `pow(abs(${a[0]}), ${a[1]})`
214
+ case SLOP.NEG: return `(-${a[0]})`
215
+ case SLOP.RECIP: return `(1.0 / ${a[0]})`
216
+
217
+ case SLOP.SIN: return `sin(${a[0]})`
218
+ case SLOP.COS: return `cos(${a[0]})`
219
+ case SLOP.TAN: return `tan(${a[0]})`
220
+ case SLOP.ASIN: return `asin(clamp(${a[0]}, -1, 1))`
221
+ case SLOP.ACOS: return `acos(clamp(${a[0]}, -1, 1))`
222
+ case SLOP.ATAN2: return `atan2(${a[0]}, ${a[1]})`
223
+ case SLOP.EXP: return `exp(${a[0]})`
224
+ case SLOP.LOG: return `log(max(${a[0]}, 1e-8))`
225
+ case SLOP.SQRT: return `sqrt(max(${a[0]}, 0))`
226
+ case SLOP.ABS: return `abs(${a[0]})`
227
+ case SLOP.SIGN: return `sign(${a[0]})`
228
+ case SLOP.FLOOR: return `floor(${a[0]})`
229
+ case SLOP.CEIL: return `ceil(${a[0]})`
230
+ case SLOP.ROUND: return `round(${a[0]})`
231
+ case SLOP.FRACT: return `frac(${a[0]})`
232
+ case SLOP.MIN: return `min(${a[0]}, ${a[1]})`
233
+ case SLOP.MAX: return `max(${a[0]}, ${a[1]})`
234
+ case SLOP.CLAMP: return `clamp(${a[0]}, ${a[1]}, ${a[2]})`
235
+ case SLOP.SATURATE: return `saturate(${a[0]})`
236
+
237
+ case SLOP.LENGTH: return `length(${a[0]})`
238
+ case SLOP.DISTANCE: return `distance(${a[0]}, ${a[1]})`
239
+ case SLOP.DOT: return `dot(${a[0]}, ${a[1]})`
240
+ case SLOP.CROSS: return `cross(${a[0]}, ${a[1]})`
241
+ case SLOP.NORMALIZE: return `normalize(${a[0]})`
242
+ case SLOP.REFLECT: return `reflect(${a[0]}, ${a[1]})`
243
+ case SLOP.LUMINANCE: return `sl_luminance(${a[0]}.rgb)`
244
+ case SLOP.TO_LINEAR: return `sl_toLinear(${a[0]})`
245
+
246
+ case SLOP.MIX: return `lerp(${a[0]}, ${a[1]}, ${a[2]})`
247
+ case SLOP.STEP: return `step(${a[0]}, ${a[1]})`
248
+ case SLOP.SMOOTHSTEP: return `smoothstep(${a[0]}, ${a[1]}, ${a[2]})`
249
+ // Matches the VM exactly, branchlessly, rather than using an if. Two
250
+ // backends that pick differently here disagree on every edge value.
251
+ case SLOP.SELECT: return `lerp(${a[2]}, ${a[1]}, step(0.5, ${a[0]}))`
252
+
253
+ case SLOP.HSV2RGB: return `sl_hsv2rgb(${a[0]})`
254
+ case SLOP.NOISE: return `sl_valueNoise(${a[0]})`
255
+ case SLOP.SIMPLEX: return `sl_simplex(${a[0]})`
256
+ case SLOP.FBM: return `sl_fbm(${a[0]}, ${Math.round(imm[0] ?? 3)}, ${Math.round(imm[1] ?? 0)})`
257
+ case SLOP.TURBULENCE: return `sl_fbm(${a[0]}, ${Math.round(imm[0] ?? 3)}, 2)`
258
+ case SLOP.RIDGED: return `sl_fbm(${a[0]}, ${Math.round(imm[0] ?? 3)}, 3)`
259
+ case SLOP.SDF: {
260
+ const id = Math.round(imm[0] ?? 0)
261
+ const q = [imm[1] ?? 0, imm[2] ?? 0, imm[3] ?? 0, imm[4] ?? 0].map(lit)
262
+ const r = [imm[5] ?? 0, imm[6] ?? 0].map(lit)
263
+ return `sl_sdfDistance(${id}, ${a[0]}, float4(${q.join(", ")}), float2(${r.join(", ")}))`
264
+ }
265
+ case SLOP.VORONOI: return `sl_voronoi(${a[0]})`
266
+ case SLOP.SAMPLE: return `tex2D(_Tex${Math.round(imm[0] ?? 0)}, ${a[0]})`
267
+
268
+ default:
269
+ throw new SLError(
270
+ `the HLSL emitter has no case for opcode ${n.op}. A program using it would silently ` +
271
+ `differ between the VM and a compiled build, which is the one failure this design ` +
272
+ `cannot tolerate.`,
273
+ )
274
+ }
275
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `onejs-sl`: the shader language, with no Unity in it.
3
+ *
4
+ * Source text or the TypeScript form in, a `Program` out: a flat typed graph
5
+ * with a hash. Everything a host does with a program (run it on the VM, emit
6
+ * HLSL, WGSL or GLSL ES) is a separate entry, so a host bundles only what it
7
+ * calls:
8
+ *
9
+ * onejs-sl this: parse, the TypeScript form, the IR, its JSON
10
+ * onejs-sl/core the same without the parser, for a game at run time
11
+ * onejs-sl/tables builtins, opcodes, inputs, shapes: what an editor reads
12
+ * onejs-sl/limits whether a program fits the VM, without encoding it
13
+ * onejs-sl/vm the VM encoder
14
+ * onejs-sl/emit/unity the ShaderLab shader a Unity editor generates
15
+ * onejs-sl/emit/web WGSL and GLSL ES with OneJS's web frame
16
+ *
17
+ * Plain TypeScript with no Node or DOM API anywhere, so it runs in a browser,
18
+ * a Cloudflare worker, and QuickJS; `npm run test:quickjs` holds it to that.
19
+ */
20
+ export * from "./core"
21
+ export {
22
+ analyze, parse, parseUnit, preludeFunctions, tokenize, PRELUDE_SOURCE, SLParseError,
23
+ } from "./lang"
24
+ export type { Checked, Expr, FuncDecl, ParseOptions, Pos, Stmt, Token, Unit } from "./lang"
package/src/ir.ts ADDED
@@ -0,0 +1,301 @@
1
+ /**
2
+ * The shader language IR: one graph, two consumers.
3
+ *
4
+ * Phase 1 of `Specs/SHADER_LANG.md` section 4. Everything the VM encoder and the
5
+ * HLSL emitter do is a function of this shape, so it is the part worth getting
6
+ * right before either exists.
7
+ *
8
+ * Four properties are load bearing:
9
+ *
10
+ * **Every node carries its type.** Both backends need it and computing it twice
11
+ * is how they would disagree. It is inferred while recording, which is also what
12
+ * produces the author facing type errors.
13
+ *
14
+ * **Nodes are a flat array in topological order.** A node refers to earlier
15
+ * nodes by index only, never forwards, so an encoder can walk the array once and
16
+ * emit in order. The hash deliberately does NOT depend on that order; see
17
+ * `hashProgram`.
18
+ *
19
+ * **The graph is a DAG, not a tree.** `const p = ...` used twice is one node
20
+ * with two references, enforced here by hash consing rather than left to a later
21
+ * pass. A tree would silently square the cost of the most natural way to write a
22
+ * shader.
23
+ *
24
+ * **Nothing here knows about shaders.** No HLSL, no texture layout, no register
25
+ * allocation. Those belong to the backends; this file is the contract between
26
+ * them, and it is fully testable with no GPU.
27
+ */
28
+
29
+ import { SLOP, SL_ARITY, SL_NAME, type SLOpCode } from "./ops"
30
+
31
+ /** Component count. The only notion of type the IR has. */
32
+ export const TYPE = { FLOAT: 1, VEC2: 2, VEC3: 3, VEC4: 4 } as const
33
+ export type SLType = 1 | 2 | 3 | 4
34
+
35
+ /** What a program is given per pixel. See section 3.4. */
36
+ export const INPUTS = {
37
+ uv: TYPE.VEC2,
38
+ fragCoord: TYPE.VEC2,
39
+ resolution: TYPE.VEC2,
40
+ time: TYPE.FLOAT,
41
+ aspect: TYPE.FLOAT,
42
+ } as const
43
+ export type InputName = keyof typeof INPUTS
44
+
45
+ /** Index into `Program.nodes`. Always refers backwards. */
46
+ export type NodeRef = number
47
+
48
+ export type SLNode =
49
+ | { k: "const"; type: SLType; v: number[] }
50
+ | { k: "input"; type: SLType; name: InputName }
51
+ | { k: "uniform"; type: SLType; slot: number }
52
+ | { k: "swizzle"; type: SLType; src: NodeRef; chans: number[] }
53
+ | { k: "call"; type: SLType; op: SLOpCode; args: NodeRef[]; imm?: number[] }
54
+
55
+ export interface UniformDecl {
56
+ name: string
57
+ type: SLType
58
+ /** Default, used when a caller does not supply the uniform. */
59
+ value: number[]
60
+ }
61
+
62
+ export interface TextureDecl {
63
+ name: string
64
+ /** Sampler slot, assigned in declaration order. */
65
+ slot: number
66
+ }
67
+
68
+ /**
69
+ * The nodes one `sl.repeat` call unrolled into, as the half open range
70
+ * `[start, end)` of `nodes`. Diagnostic only: the instruction ceiling error
71
+ * uses it to say which loop the operations came from, since a program that is
72
+ * "too long" has usually been made so by one count.
73
+ */
74
+ export interface LoopSpan {
75
+ count: number
76
+ start: NodeRef
77
+ end: NodeRef
78
+ }
79
+
80
+ export interface Program {
81
+ /** The `SL_IR_VERSION` this program's nodes mean what they mean under. */
82
+ version: number
83
+ nodes: SLNode[]
84
+ /** Must be VEC4: a program produces a colour. */
85
+ result: NodeRef
86
+ uniforms: UniformDecl[]
87
+ textures: TextureDecl[]
88
+ /** Canonical, stable across machines. See `hashProgram`. */
89
+ hash: string
90
+ /** Not part of the hash: it changes nothing about what the program computes. */
91
+ loops: LoopSpan[]
92
+ }
93
+
94
+ /**
95
+ * Sampler slots in a fragment shader on the WebGL2 baseline, minus one for the
96
+ * program texture itself. Exceeding it is refused when the program is written
97
+ * rather than when it is drawn, with a message naming the limit.
98
+ */
99
+ export const MAX_TEXTURES = 15
100
+
101
+ /** Instructions a single program may hold. Generous; the ceiling that matters is registers. */
102
+ export const MAX_NODES = 4096
103
+
104
+ export class SLError extends Error {
105
+ constructor(message: string) {
106
+ super("[onejs sl] " + message)
107
+ this.name = "SLError"
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Records a graph. One per `program()` call.
113
+ *
114
+ * Hash consing happens here, at `add`, rather than as a later pass, because the
115
+ * author already told us what is shared when they wrote `const`. Recomputing it
116
+ * afterwards would be doing work to recover information we were handed.
117
+ */
118
+ export class Builder {
119
+ readonly nodes: SLNode[] = []
120
+ readonly uniforms: UniformDecl[] = []
121
+ readonly textures: TextureDecl[] = []
122
+ readonly loops: LoopSpan[] = []
123
+ private readonly interned = new Map<string, NodeRef>()
124
+
125
+ add(node: SLNode): NodeRef {
126
+ if (this.nodes.length >= MAX_NODES) {
127
+ throw new SLError(`a program may hold at most ${MAX_NODES} operations`)
128
+ }
129
+ const key = keyOf(node)
130
+ const seen = this.interned.get(key)
131
+ if (seen !== undefined) return seen
132
+ const ref = this.nodes.push(node) - 1
133
+ this.interned.set(key, ref)
134
+ return ref
135
+ }
136
+
137
+ call(op: SLOpCode, type: SLType, args: NodeRef[], imm?: number[]): NodeRef {
138
+ const arity = SL_ARITY[op]
139
+ if (arity >= 0 && args.length !== arity) {
140
+ throw new SLError(`${SL_NAME[op]} takes ${arity} argument(s), got ${args.length}`)
141
+ }
142
+ for (const a of args) {
143
+ if (a < 0 || a >= this.nodes.length) {
144
+ throw new SLError(`${SL_NAME[op]} refers to a node that does not exist yet`)
145
+ }
146
+ }
147
+ return this.add(imm === undefined ? { k: "call", type, op, args } : { k: "call", type, op, args, imm })
148
+ }
149
+
150
+ constant(v: number[]): NodeRef {
151
+ for (const n of v) {
152
+ if (!Number.isFinite(n)) throw new SLError(`a constant must be finite, got ${n}`)
153
+ }
154
+ return this.add({ k: "const", type: v.length as SLType, v: v.slice() })
155
+ }
156
+
157
+ uniform(name: string, type: SLType, value: number[]): NodeRef {
158
+ const existing = this.uniforms.findIndex((u) => u.name === name)
159
+ if (existing >= 0) {
160
+ const u = this.uniforms[existing]
161
+ if (u.type !== type) {
162
+ throw new SLError(`uniform "${name}" is declared as both a ${widthName(u.type)} and a ${widthName(type)}`)
163
+ }
164
+ return this.add({ k: "uniform", type, slot: existing })
165
+ }
166
+ const slot = this.uniforms.length
167
+ this.uniforms.push({ name, type, value: value.slice() })
168
+ return this.add({ k: "uniform", type, slot })
169
+ }
170
+
171
+ texture(name: string): number {
172
+ const existing = this.textures.findIndex((t) => t.name === name)
173
+ if (existing >= 0) return this.textures[existing].slot
174
+ if (this.textures.length >= MAX_TEXTURES) {
175
+ throw new SLError(
176
+ `a program may sample at most ${MAX_TEXTURES} textures, and this one asks for ` +
177
+ `${this.textures.length + 1}. That ceiling is the fragment shader's sampler slots ` +
178
+ `on the WebGL2 baseline, so it cannot be widened.`,
179
+ )
180
+ }
181
+ const slot = this.textures.length
182
+ this.textures.push({ name, slot })
183
+ return slot
184
+ }
185
+ }
186
+
187
+ /** Structural key for hash consing. Order matters and is fixed by the node shape. */
188
+ function keyOf(n: SLNode): string {
189
+ switch (n.k) {
190
+ case "const": return `c:${n.type}:${n.v.map(fixed).join(",")}`
191
+ case "input": return `i:${n.name}`
192
+ case "uniform": return `u:${n.slot}`
193
+ case "swizzle": return `s:${n.src}:${n.chans.join("")}`
194
+ case "call": return `f:${n.op}:${n.args.join(",")}:${(n.imm ?? []).map(fixed).join(",")}`
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Constants at a fixed precision, so two constants that differ only in float
200
+ * noise intern to one node and, more importantly, hash the same on every
201
+ * machine. See `hashProgram` for why that matters.
202
+ */
203
+ export function fixed(n: number): string {
204
+ return Object.is(n, -0) ? "0" : n.toPrecision(9)
205
+ }
206
+
207
+ export function widthName(t: SLType): string {
208
+ return t === 1 ? "float" : `vec${t}`
209
+ }
210
+
211
+ /**
212
+ * A canonical, machine independent hash of a program.
213
+ *
214
+ * This is the link between a program and its compiled shader. If it differs
215
+ * between the machine that generated the shader and the machine that runs it,
216
+ * the runtime silently falls back to the VM and nobody is told, which is the
217
+ * worst failure this design can have: correct output, quietly slow, no error.
218
+ *
219
+ * So it hashes the canonicalised node array and nothing else. Never object
220
+ * identity, never insertion order of a Map, never a JSON stringify whose key
221
+ * order is an implementation detail. Constants go through `fixed` so 0.1 + 0.2
222
+ * and 0.30000000000000004 do not produce different shaders.
223
+ *
224
+ * FNV-1a, because it needs to be stable and identical in TypeScript and in C#,
225
+ * not cryptographic.
226
+ */
227
+ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformDecl[], textures: TextureDecl[]): string {
228
+ // MERKLE, not a walk of the array in storage order.
229
+ //
230
+ // The first version hashed nodes[] front to back, which made the hash depend
231
+ // on the order the author happened to build things in. Hoisting a shared
232
+ // subexpression into a `const` moved a node earlier, changed the hash, and
233
+ // orphaned the program from a shader generated for it, without changing what
234
+ // the program computes. That is the exact failure this hash exists to
235
+ // prevent, so the fix is to hash the SHAPE and not the storage.
236
+ //
237
+ // Each node's digest is computed from its kind, its type and its children's
238
+ // digests, so two graphs that compute the same thing agree however they were
239
+ // assembled. It also ignores nodes not reachable from the result, which is
240
+ // correct: dead nodes generate no shader code.
241
+ const digest = new Map<NodeRef, string>()
242
+
243
+ const of = (ref: NodeRef): string => {
244
+ const seen = digest.get(ref)
245
+ if (seen !== undefined) return seen
246
+ const n = nodes[ref]
247
+ let body: string
248
+ switch (n.k) {
249
+ case "const": body = `c:${n.type}:${n.v.map(fixed).join(",")}`; break
250
+ case "input": body = `i:${n.name}`; break
251
+ // By slot rather than by name: the slot is what the generated shader
252
+ // lays out, so two programs whose uniforms differ only in name still
253
+ // produce different shaders and must not share a hash. The names go
254
+ // in separately below.
255
+ case "uniform": body = `u:${n.slot}:${n.type}`; break
256
+ case "swizzle": body = `s:${of(n.src)}:${n.chans.join("")}`; break
257
+ case "call": body = `f:${n.op}:${n.args.map(of).join(",")}:${(n.imm ?? []).map(fixed).join(",")}`; break
258
+ }
259
+ const d = fnv1a(body + "|" + n.type)
260
+ digest.set(ref, d)
261
+ return d
262
+ }
263
+
264
+ // The IR version is in the hash, so a change to what an opcode computes
265
+ // changes every hash and every cache keyed by one (an editor's generated
266
+ // shaders, Magerie's pipelines) recompiles rather than serving the old maths.
267
+ const parts: string[] = [`v${SL_HASH_VERSION}:${SL_IR_VERSION}`, of(result)]
268
+ for (const u of uniforms) parts.push(`U:${u.name}:${u.type}:${u.value.map(fixed).join(",")}`)
269
+ for (const t of textures) parts.push(`T:${t.name}:${t.slot}`)
270
+ return fnv1a(parts.join("|"))
271
+ }
272
+
273
+ /** Bumped when the hashing scheme changes, which invalidates generated shaders. */
274
+ export const SL_HASH_VERSION = 1
275
+
276
+ /**
277
+ * Bumped whenever the IR changes in a way a reader has to know about: a new
278
+ * opcode, a new shape, a change to what an existing opcode computes, or a
279
+ * change to the JSON shape (which also gets a migration in `fromJSON`).
280
+ *
281
+ * A reader accepts every version up to its own and refuses a newer one with a
282
+ * message naming both, the rule the particle wire and fx follow. Part of the
283
+ * hash, so a bump recompiles every cached shader.
284
+ *
285
+ * 1 the first versioned IR
286
+ * 2 an SDF call carries up to six shape parameters, not four (#129)
287
+ */
288
+ export const SL_IR_VERSION = 2
289
+
290
+ function fnv1a(s: string): string {
291
+ let h = 0x811c9dc5
292
+ for (let i = 0; i < s.length; i++) {
293
+ h ^= s.charCodeAt(i)
294
+ // >>> 0 after each step: JS bitwise ops are signed 32 bit, and a C#
295
+ // implementation of the same hash must agree bit for bit.
296
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
297
+ }
298
+ return h.toString(16).padStart(8, "0")
299
+ }
300
+
301
+ export { SLOP }