onejs-sl 0.2.1 → 0.4.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/lib/common.hlsl CHANGED
@@ -1,7 +1,7 @@
1
1
  // Helpers shared by EVERY shader language backend.
2
2
  //
3
- // The VM (OneJS/FxProgram.shader) includes this as SLCommon.cginc, and every
4
- // shader generated from a program by the HLSL emitter includes it too. The web
3
+ // Every shader generated from a program by the HLSL emitter includes this as
4
+ // SLCommon.cginc. The web
5
5
  // emitters and a host's own frame (`emitBody`) get it translated, by
6
6
  // `lib/translate.ts`, from this one text. That sharing is the whole reason this
7
7
  // file exists: the backends have to agree, and the fastest way to make them
@@ -134,10 +134,9 @@ float sl_luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); }
134
134
  // In a Gamma project the written value is already right. The float4 form keeps
135
135
  // alpha, which is coverage rather than light. Mirrors fxColorToWorking.
136
136
  //
137
- // Every width uses Unity's cubic approximation, lane by lane. The VM has one
138
- // TO_LINEAR for all widths (its registers are float4, so it converts .rgb),
139
- // and a scalar or float2 on the exact curve here drew up to 5/255 apart from
140
- // it in the dark range: the editor and a player disagreed.
137
+ // Every width uses Unity's cubic approximation, lane by lane. A scalar or
138
+ // float2 on the exact curve once drew up to 5/255 apart from the float3 form
139
+ // in the dark range.
141
140
  //
142
141
  // The curve is Unity's GammaToLinearSpace, written out here so that a target
143
142
  // without UnityCG.cginc has it too. The switch below is the one piece of
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "onejs-sl",
3
- "version": "0.2.1",
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",
3
+ "version": "0.4.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 or GLSL ES",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "exports": {
@@ -9,8 +9,6 @@
9
9
  "./core": "./src/core.ts",
10
10
  "./compile": "./src/compile.ts",
11
11
  "./tables": "./src/tables.ts",
12
- "./limits": "./src/limits.ts",
13
- "./vm": "./src/vm.ts",
14
12
  "./emit/hlsl-body": "./src/emit/hlsl-body.ts",
15
13
  "./emit/unity": "./src/emit/unity.ts",
16
14
  "./emit/web": "./src/emit/web.ts",
package/src/body.ts CHANGED
@@ -43,7 +43,7 @@ export interface BodyTarget {
43
43
  * an sRGB texture is decoded before it is filtered, as an sRGB sampler
44
44
  * does; with `gamma`, rgb is as stored.
45
45
  * - **Filtered and wrapped by the host.** The body does neither. OneJS's
46
- * hosts, the generated shader, the VM and the web backends alike, use the
46
+ * hosts, the generated shader and the web backends alike, use the
47
47
  * bound texture's own filter and wrap modes, so a host that filters by
48
48
  * hand draws the same picture by following the texture's settings.
49
49
  *
@@ -51,6 +51,14 @@ export interface BodyTarget {
51
51
  * as it is, and a host that composites premultiplied multiplies on write.
52
52
  */
53
53
  sample: (slot: number, uv: string) => string
54
+ /**
55
+ * A float4 sample of the texture in `slot` at `uv` from mip level `lod`, a
56
+ * float expression: 0 is the full size texture, 1 half, and a fraction
57
+ * blends two levels where the texture's filter does. Otherwise the same
58
+ * contract as `sample`. `tex2Dlod` in a program; HLSL's
59
+ * `tex2Dlod(s, float4(uv, 0, lod))` and Metal's `sample(s, uv, level(lod))`.
60
+ */
61
+ sampleLevel: (slot: number, uv: string, lod: string) => string
54
62
  /**
55
63
  * `linear`: `toLinear` calls `sl_toLinear`, for a host whose target holds
56
64
  * linear light. `gamma`: `toLinear` is the identity, decided here, so hex
@@ -100,6 +108,11 @@ export function emitBody(p: Program, target: BodyTarget): Body {
100
108
  const helpers = new Set<string>()
101
109
  const helper = (fn: string) => { helpers.add(fn); return fn }
102
110
  /** A node at width `w`: a scalar repeated into a constructor, since HLSL refuses float3(x) and Metal x.xxx. */
111
+ /** A constant node's value, or null for anything computed. */
112
+ const constantOf = (ref: number): number[] | null => {
113
+ const c = p.nodes[ref]!
114
+ return c.k === "const" ? c.v : null
115
+ }
103
116
  const splat = (ref: number, w: SLType) => (p.nodes[ref]!.type === TYPE.FLOAT && w > 1 ? ctor(w, Array(w).fill(name(ref))) : name(ref))
104
117
 
105
118
  const expr = (n: SLNode): string => {
@@ -143,7 +156,7 @@ export function emitBody(p: Program, target: BodyTarget): Body {
143
156
  case SLOP.MUL: return `(${a[0]} * ${a[1]})`
144
157
  case SLOP.DIV: return `(${a[0]} / ${a[1]})`
145
158
  case SLOP.MOD: return `fmod(${s[0]}, ${s[1]})`
146
- // abs on the base, matching the VM. pow of a negative base is
159
+ // abs on the base, matching the web emitters. pow of a negative base is
147
160
  // undefined in HLSL and the backends must be undefined in the same
148
161
  // direction.
149
162
  case SLOP.POW: return `pow(abs(${s[0]}), ${s[1]})`
@@ -170,8 +183,8 @@ export function emitBody(p: Program, target: BodyTarget): Body {
170
183
  case SLOP.CLAMP: return `clamp(${s[0]}, ${s[1]}, ${s[2]})`
171
184
  case SLOP.SATURATE: return `saturate(${a[0]})`
172
185
 
173
- // Of a scalar, Metal's are ambiguous, so what the VM computes for one
174
- // (its lanes past the width hold 0) is written out.
186
+ // Of a scalar, Metal's are ambiguous, so the scalar case is written
187
+ // out.
175
188
  case SLOP.LENGTH: return w0 === 1 ? `abs(${a[0]})` : `length(${a[0]})`
176
189
  case SLOP.DISTANCE: return w0 === 1 ? `abs(${a[0]} - ${a[1]})` : `distance(${a[0]}, ${a[1]})`
177
190
  case SLOP.DOT: return w0 === 1 ? `(${a[0]} * ${a[1]})` : `dot(${a[0]}, ${a[1]})`
@@ -184,7 +197,7 @@ export function emitBody(p: Program, target: BodyTarget): Body {
184
197
  case SLOP.MIX: return `lerp(${s[0]}, ${s[1]}, ${s[2]})`
185
198
  case SLOP.STEP: return `step(${s[0]}, ${s[1]})`
186
199
  case SLOP.SMOOTHSTEP: return `smoothstep(${s[0]}, ${s[1]}, ${s[2]})`
187
- // Matches the VM exactly, branchlessly, rather than using an if.
200
+ // Branchless, as the web emitters do it, rather than using an if.
188
201
  // Two backends that pick differently here disagree on every edge
189
202
  // value.
190
203
  case SLOP.SELECT: return `lerp(${s[2]}, ${s[1]}, step(${k(0.5, t)}, ${s[0]}))`
@@ -192,14 +205,19 @@ export function emitBody(p: Program, target: BodyTarget): Body {
192
205
  case SLOP.HSV2RGB: return `${helper("sl_hsv2rgb")}(${a[0]})`
193
206
  case SLOP.NOISE: return `${helper("sl_valueNoise")}(${a[0]})`
194
207
  case SLOP.SIMPLEX: return `${helper("sl_simplex")}(${a[0]})`
195
- case SLOP.FBM: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, ${Math.round(imm[1] ?? 0)})`
196
- case SLOP.TURBULENCE: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 2)`
197
- case SLOP.RIDGED: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 3)`
208
+ case SLOP.FBM:
209
+ case SLOP.TURBULENCE:
210
+ case SLOP.RIDGED: {
211
+ // The count rounded here and held to 1 to 4 by sl_fbm; a constant
212
+ // one is the whole number it is. fbm's kind is the one immediate.
213
+ const c = constantOf(n.args[1]!)
214
+ const count = c !== null ? String(Math.round(c[0]!)) : `int(floor(${a[1]} + 0.5))`
215
+ const kind = n.op === SLOP.TURBULENCE ? 2 : n.op === SLOP.RIDGED ? 3 : Math.round(imm[0] ?? 0)
216
+ return `${helper("sl_fbm")}(${a[0]}, ${count}, ${kind})`
217
+ }
198
218
  case SLOP.SDF: {
199
219
  const id = Math.round(imm[0] ?? 0)
200
- const q = [imm[1] ?? 0, imm[2] ?? 0, imm[3] ?? 0, imm[4] ?? 0].map(lit)
201
- const r = [imm[5] ?? 0, imm[6] ?? 0].map(lit)
202
- return `${helper("sl_sdfDistance")}(${id}, ${a[0]}, float4(${q.join(", ")}), float2(${r.join(", ")}))`
220
+ return `${helper("sl_sdfDistance")}(${id}, ${a[0]}, ${a[1]}, ${a[2]})`
203
221
  }
204
222
  case SLOP.VORONOI: return `${helper("sl_voronoi")}(${a[0]})`
205
223
  case SLOP.SAMPLE: {
@@ -207,11 +225,16 @@ export function emitBody(p: Program, target: BodyTarget): Body {
207
225
  textures.add(slot)
208
226
  return target.sample(slot, a[0]!)
209
227
  }
228
+ case SLOP.SAMPLE_LOD: {
229
+ const slot = Math.round(imm[0] ?? 0)
230
+ textures.add(slot)
231
+ return target.sampleLevel(slot, a[0]!, a[1]!)
232
+ }
210
233
 
211
234
  default:
212
235
  throw new SLError(
213
236
  `the HLSL emitter has no case for opcode ${n.op}. A program using it would silently ` +
214
- `differ between the VM and a compiled build, which is the one failure this design ` +
237
+ `differ between a Unity build and a browser, which is the one failure this design ` +
215
238
  `cannot tolerate.`,
216
239
  )
217
240
  }
package/src/compile.ts CHANGED
@@ -6,10 +6,9 @@
6
6
  * editor to generate a shader from, WGSL and GLSL ES 3.00 for a browser to
7
7
  * compile on Unity's own device.
8
8
  *
9
- * There is no budget. `encode` refuses a program past the VM's 256
10
- * instructions or 8 registers, because the VM cannot run it; every backend
11
- * here is a real compiler, so a long program costs what it costs, as any
12
- * shader does. `Specs/SL_NEXT.md` section 0.5, step 3.
9
+ * There is no budget: every backend is a real compiler, so a long program
10
+ * costs what it costs, as any shader does. The caps on uniforms and textures
11
+ * are the only refusal (`checkCaps`).
13
12
  */
14
13
 
15
14
  import { checkCaps, type Program } from "./ir"
package/src/core.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  export * as sl from "./sl"
11
11
  export type { Float, Vec2, Vec3, Vec4, Num, ProgramInputs, Texture } from "./sl"
12
12
  export {
13
- TYPE, INPUTS, MAX_TEXTURES, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION, inputsUsed,
13
+ TYPE, INPUTS, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION, SL_HASH_VERSION, inputsUsed,
14
14
  CONTROL_FIELDS, controlProblem,
15
15
  } from "./ir"
16
16
  export type {
package/src/hlsl.ts CHANGED
@@ -1,19 +1,12 @@
1
1
  /**
2
- * The second backend: prints a program as real HLSL, compiled at build time.
2
+ * The Unity backend: prints a program as real HLSL, compiled at build time.
3
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.
4
+ * Phase 3 of `Specs/SHADER_LANG.md` section 6. Unity cannot compile a shader
5
+ * at runtime in a player build, so the editor generates one per program and the
6
+ * build ships it; a browser compiles the web emitters' output instead
7
+ * (`web.ts`). The same source draws both ways with no edit in between.
9
8
  *
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
9
+ * The IR's types become the HLSL types directly, so `float2` is a `float2` and nothing needs padding or
17
10
  * explaining. One local per node also gives common subexpression elimination for
18
11
  * free: a node is emitted once no matter how many nodes reference it.
19
12
  *
@@ -22,7 +15,7 @@
22
15
  * next time it is generated.
23
16
  */
24
17
 
25
- import { SLError, type Program } from "./ir"
18
+ import { SLError, SL_HASH_VERSION, type Program } from "./ir"
26
19
  import { emitBody, lit, type BodyTarget } from "./body"
27
20
 
28
21
  /** The property name a uniform gets. Prefixed so it cannot collide with ours. */
@@ -48,8 +41,8 @@ export interface EmitOptions {
48
41
  * Emits a complete `.shader` for a program.
49
42
  *
50
43
  * The shader's name carries the program hash, which is how the runtime finds it
51
- * again. If that link breaks, the runtime silently falls back to the VM and
52
- * nobody is told: correct output, quietly slow, no error. See `hashProgram`.
44
+ * again. If that link breaks, the program has no shader and draws nothing. See
45
+ * `hashProgram`.
53
46
  */
54
47
  export function emitShader(p: Program, options: EmitOptions = {}): string {
55
48
  const name = options.name ?? `Hidden/SLGenerated/${p.hash}`
@@ -81,7 +74,9 @@ export function emitShader(p: Program, options: EmitOptions = {}): string {
81
74
  //
82
75
  // Source of truth is the program this was emitted from; edits here are lost the
83
76
  // next time it is generated. The name carries the program hash, which is how the
84
- // runtime pairs the two. See Specs/SHADER_LANG.md section 6.
77
+ // runtime pairs the two. See Specs/SHADER_LANG.md section 6. The scheme that
78
+ // hash was made under, which a build compares with the apps' manifests:
79
+ // SL_HASH_VERSION ${SL_HASH_VERSION}
85
80
  Shader "${name}"
86
81
  {
87
82
  Properties
@@ -99,7 +94,7 @@ ${props.join("\n")}
99
94
  #pragma fragment frag
100
95
  #pragma target 3.0
101
96
  #include "UnityCG.cginc"
102
- // Shared with the VM, so both backends compute the same noise.
97
+ // Shared with the web frame, so every backend computes the same noise.
103
98
  #include "${include}"
104
99
 
105
100
  ${decls.join("\n")}
@@ -111,8 +106,8 @@ ${decls.join("\n")}
111
106
  {
112
107
  v2f o;
113
108
  o.pos = UnityObjectToClipPos(v.vertex);
114
- // Origin corrected here exactly as the VM does it, so an ejected
115
- // game is not upside down relative to the one on the site.
109
+ // Origin corrected here, so a Unity build is not upside down
110
+ // relative to the same program in a browser.
116
111
  o.uv = float2(v.uv.x, lerp(v.uv.y, 1.0 - v.uv.y, _FlipY));
117
112
  return o;
118
113
  }
@@ -130,7 +125,7 @@ ${body}
130
125
 
131
126
  /**
132
127
  * The frame's side of the body: OneJS's names for the inputs, a uniform's
133
- * material property, and `tex2D` on the slot's sampler. `sl_toLinear` stays a
128
+ * material property, and `tex2D` and `tex2Dlod` on the slot's sampler. `sl_toLinear` stays a
134
129
  * call because `SLCommon.cginc` decides Gamma or Linear per project, at the
135
130
  * shader compile, so this target always emits it.
136
131
  */
@@ -155,6 +150,7 @@ function unityTarget(p: Program): BodyTarget {
155
150
  },
156
151
  uniform: (slot) => uniformProperty(p.uniforms[slot]!.name),
157
152
  sample: (slot, uv) => `tex2D(_Tex${slot}, ${uv})`,
153
+ sampleLevel: (slot, uv, lod) => `tex2Dlod(_Tex${slot}, float4(${uv}, 0.0, ${lod}))`,
158
154
  colour: "linear",
159
155
  indent: " ",
160
156
  }
package/src/index.ts CHANGED
@@ -2,16 +2,14 @@
2
2
  * `onejs-sl`: the shader language, with no Unity in it.
3
3
  *
4
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
5
+ * with a hash. Everything a host does with a program (emit HLSL, WGSL or GLSL
6
+ * ES) is a separate entry, so a host bundles only what it
7
7
  * calls:
8
8
  *
9
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/compile what a host draws a program with, and no budget
13
- * onejs-sl/limits whether a program fits the VM, until the VM goes
14
- * onejs-sl/vm the VM encoder, until the VM goes
15
13
  * onejs-sl/emit/unity the ShaderLab shader a Unity editor generates
16
14
  * onejs-sl/emit/web WGSL and GLSL ES with OneJS's web frame
17
15
  *
package/src/ir.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
- * The shader language IR: one graph, two consumers.
2
+ * The shader language IR: one graph, and every backend a function of it.
3
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.
4
+ * Phase 1 of `Specs/SHADER_LANG.md` section 4. Every emitter (HLSL, WGSL, GLSL
5
+ * ES) reads this shape and nothing else.
7
6
  *
8
7
  * Four properties are load bearing:
9
8
  *
@@ -12,7 +11,7 @@
12
11
  * produces the author facing type errors.
13
12
  *
14
13
  * **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
14
+ * nodes by index only, never forwards, so an emitter can walk the array once and
16
15
  * emit in order. The hash deliberately does NOT depend on that order; see
17
16
  * `hashProgram`.
18
17
  *
@@ -21,8 +20,7 @@
21
20
  * pass. A tree would silently square the cost of the most natural way to write a
22
21
  * shader.
23
22
  *
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
23
+ * **Nothing here knows about shaders.** No HLSL, no texture layout, no binding. Those belong to the backends; this file is the contract between
26
24
  * them, and it is fully testable with no GPU.
27
25
  */
28
26
 
@@ -47,7 +45,7 @@ export type InputName = keyof typeof INPUTS
47
45
  * handed over by the host (`Specs/SL_NEXT.md` 6). Each read records the same
48
46
  * few nodes a program would if it wrote the expression out, and the builder's
49
47
  * hash consing makes every later read the same node. So there is no new
50
- * instruction, no VM or host change, and a program that never names one holds
48
+ * operation, no host change, and a program that never names one holds
51
49
  * no extra node, compiling to the bytes it always did.
52
50
  *
53
51
  * texel 1 / resolution: one pixel, in uv
@@ -172,20 +170,11 @@ export interface TextureDecl {
172
170
  slot: number
173
171
  }
174
172
 
175
- /**
176
- * The nodes one `sl.repeat` call unrolled into, as the half open range
177
- * `[start, end)` of `nodes`. Diagnostic only: the instruction ceiling error
178
- * uses it to say which loop the operations came from, since a program that is
179
- * "too long" has usually been made so by one count.
180
- */
181
- export interface LoopSpan {
182
- count: number
183
- start: NodeRef
184
- end: NodeRef
185
- }
186
-
187
173
  export interface Program {
188
- /** The `SL_IR_VERSION` this program's nodes mean what they mean under. */
174
+ /**
175
+ * The `SL_IR_VERSION` this program's nodes mean what they mean under: the
176
+ * lowest one that has every node it holds. See `programVersion`.
177
+ */
189
178
  version: number
190
179
  nodes: SLNode[]
191
180
  /** Must be VEC4: a program produces a colour. */
@@ -194,16 +183,8 @@ export interface Program {
194
183
  textures: TextureDecl[]
195
184
  /** Canonical, stable across machines. See `hashProgram`. */
196
185
  hash: string
197
- /** Not part of the hash: it changes nothing about what the program computes. */
198
- loops: LoopSpan[]
199
186
  }
200
187
 
201
- /**
202
- * @deprecated Use `TEXTURE_SLOTS`. Was 15, the WebGL2 sampler count, which let a
203
- * program declare textures no native host binds.
204
- */
205
- export const MAX_TEXTURES = TEXTURE_SLOTS
206
-
207
188
  /**
208
189
  * The refusals for a program past its caps, worded once for the parser (a
209
190
  * `file`) and the builder (a `program`) so the two cannot drift apart.
@@ -232,7 +213,25 @@ export function checkCaps(p: Pick<Program, "uniforms" | "textures">): void {
232
213
  if (p.textures.length > TEXTURE_SLOTS) throw new SLError(tooManyTextures(p.textures.length, "program"))
233
214
  }
234
215
 
235
- /** Instructions a single program may hold. Generous; the ceiling that matters is registers. */
216
+ /**
217
+ * The colour as written behind a colour value: the operand of the `TO_LINEAR`
218
+ * that reading a hex or a colour uniform applied, or null for a value computed
219
+ * in the working space. What a ramp's stop has to be, since a ramp blends its
220
+ * stops as written (`sl.ramp`).
221
+ */
222
+ export function writtenColour(nodes: readonly SLNode[], ref: NodeRef): NodeRef | null {
223
+ const n = nodes[ref]!
224
+ if (n.k !== "call" || n.op !== SLOP.TO_LINEAR || (n.type !== TYPE.VEC3 && n.type !== TYPE.VEC4)) return null
225
+ return n.args[0]!
226
+ }
227
+
228
+ /** Why a computed value is not a ramp's stop, in one wording for both surfaces. */
229
+ export const RAMP_STOP_COMPUTED =
230
+ "a ramp's stop is a colour as written: a hex like #ff8040, or a uniform or const that holds one. " +
231
+ "This one is computed, and a ramp blends its stops as written before it converts them, so it has " +
232
+ "no written colour to blend. Blend computed colours with lerp instead"
233
+
234
+ /** Operations a single program may hold. Generous: a guard against a runaway builder, not a budget. */
236
235
  export const MAX_NODES = 4096
237
236
 
238
237
  export class SLError extends Error {
@@ -253,7 +252,6 @@ export class Builder {
253
252
  readonly nodes: SLNode[] = []
254
253
  readonly uniforms: UniformDecl[] = []
255
254
  readonly textures: TextureDecl[] = []
256
- readonly loops: LoopSpan[] = []
257
255
  private readonly interned = new Map<string, NodeRef>()
258
256
 
259
257
  add(node: SLNode): NodeRef {
@@ -401,8 +399,7 @@ export function widthName(t: SLType): string {
401
399
  *
402
400
  * This is the link between a program and its compiled shader. If it differs
403
401
  * between the machine that generated the shader and the machine that runs it,
404
- * the runtime silently falls back to the VM and nobody is told, which is the
405
- * worst failure this design can have: correct output, quietly slow, no error.
402
+ * a native player finds no shader for the program and draws nothing.
406
403
  *
407
404
  * So it hashes the canonicalised node array and nothing else. Never object
408
405
  * identity, never insertion order of a Map, never a JSON stringify whose key
@@ -426,6 +423,13 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
426
423
  // digests, so two graphs that compute the same thing agree however they were
427
424
  // assembled. It also ignores nodes not reachable from the result, which is
428
425
  // correct: dead nodes generate no shader code.
426
+ //
427
+ // NORMALISED, so two ways of writing one computation agree (hash version 2).
428
+ // A commutative op's operands are digested in sorted order, so `a + b` is
429
+ // `b + a`; and a swizzle of a constant is digested as the constant it picks,
430
+ // so `8 * uv` (8 broadcast by a swizzle) is `uv * 8` (a float2 constant).
431
+ // Only where every backend computes the same bits either way: min and max
432
+ // are left out, since which of -0 and +0 they return depends on the order.
429
433
  const digest = new Map<NodeRef, string>()
430
434
 
431
435
  const of = (ref: NodeRef): string => {
@@ -441,8 +445,19 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
441
445
  // produce different shaders and must not share a hash. The names go
442
446
  // in separately below.
443
447
  case "uniform": body = `u:${n.slot}:${n.type}`; break
444
- case "swizzle": body = `s:${of(n.src)}:${n.chans.join("")}`; break
445
- case "call": body = `f:${n.op}:${n.args.map(of).join(",")}:${(n.imm ?? []).map(fixed).join(",")}`; break
448
+ case "swizzle": {
449
+ const src = nodes[n.src]!
450
+ body = src.k === "const"
451
+ ? `c:${n.type}:${n.chans.map((c) => fixed(src.v[c]!)).join(",")}`
452
+ : `s:${of(n.src)}:${n.chans.join("")}`
453
+ break
454
+ }
455
+ case "call": {
456
+ const args = n.args.map(of)
457
+ if (COMMUTATIVE.has(n.op)) args.sort()
458
+ body = `f:${n.op}:${args.join(",")}:${(n.imm ?? []).map(fixed).join(",")}`
459
+ break
460
+ }
446
461
  }
447
462
  const d = fnv1a(body + "|" + n.type)
448
463
  digest.set(ref, d)
@@ -450,12 +465,15 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
450
465
  }
451
466
 
452
467
  // The IR version is in the hash, so a change to what an opcode means in the
453
- // IR changes every hash and every cache keyed by one recompiles rather than
454
- // serving the old maths. A change to the helper library alone (a noise's
455
- // arithmetic, say) is not one: every host includes the library's text when
456
- // it compiles, and none caches a compiled program by hash across a
457
- // library update, so the new text reaches every shader without a bump.
458
- const parts: string[] = [`v${SL_HASH_VERSION}:${SL_IR_VERSION}`, of(result)]
468
+ // IR changes the hash of every program using it, and every cache keyed by
469
+ // one recompiles rather than serving the old maths. It is the PROGRAM's
470
+ // version, the lowest that has its nodes, so a version that adds a node
471
+ // leaves every program without one on the hash it had. A change to the
472
+ // helper library alone (a noise's arithmetic, say) is not one: every host
473
+ // includes the library's text when it compiles, and none caches a compiled
474
+ // program by hash across a library update, so the new text reaches every
475
+ // shader without a bump.
476
+ const parts: string[] = [`v${SL_HASH_VERSION}:${programVersion(nodes)}`, of(result)]
459
477
  for (const u of uniforms) parts.push(`U:${u.name}:${u.type}:${u.value.map(fixed).join(",")}`)
460
478
  for (const t of textures) parts.push(`T:${t.name}:${t.slot}`)
461
479
  return fnv1a(parts.join("|"))
@@ -473,8 +491,79 @@ export function controlOf(c: UniformControl): UniformControl {
473
491
  return out
474
492
  }
475
493
 
476
- /** Bumped when the hashing scheme changes, which invalidates generated shaders. */
477
- export const SL_HASH_VERSION = 1
494
+ /**
495
+ * The lowest `SL_IR_VERSION` that has every node here, and never below 2.
496
+ *
497
+ * A version that adds a node, or a form of one, raises only the programs that
498
+ * hold it. Everything else keeps the version, the hash and the JSON it had, so
499
+ * a program recorded before the bump still finds its shader, and a reader that
500
+ * predates the bump still reads it. Every node counts, used or not: the JSON
501
+ * carries them all, and an older reader has to refuse by the version rather
502
+ * than halfway through the nodes.
503
+ */
504
+ export function programVersion(nodes: readonly SLNode[]): number {
505
+ let v = 2
506
+ for (const n of nodes) v = Math.max(v, nodeVersion(n))
507
+ return v
508
+ }
509
+
510
+ /**
511
+ * The version that added this node's form. An sdf or octave call with one
512
+ * operand is the form before IR 3, which only a file an older compiler wrote
513
+ * holds, and `fromJSON` moves it to the form IR 3 has.
514
+ */
515
+ function nodeVersion(n: SLNode): number {
516
+ if (n.k !== "call") return 2
517
+ switch (n.op) {
518
+ case SLOP.SAMPLE_LOD: return 3
519
+ case SLOP.SDF:
520
+ case SLOP.FBM:
521
+ case SLOP.TURBULENCE:
522
+ case SLOP.RIDGED:
523
+ return n.args.length > 1 ? 3 : 2
524
+ default: return 2
525
+ }
526
+ }
527
+
528
+ /**
529
+ * What is wrong with a call's form, for the ops whose operand list alone does
530
+ * not say it, or null. The builder only makes the right ones; this is for a
531
+ * program read from JSON, which an emitter would otherwise print as something
532
+ * else.
533
+ *
534
+ * SDF the point, a float4 and a float2 holding the six shape parameters,
535
+ * the shape id the one immediate
536
+ * FBM, TURBULENCE, RIDGED the point and the octave count, FBM's kind the
537
+ * one immediate
538
+ */
539
+ export function formProblem(n: Extract<SLNode, { k: "call" }>, nodes: readonly SLNode[]): string | null {
540
+ const widths = n.args.map((a) => nodes[a]!.type).join(",")
541
+ switch (n.op) {
542
+ case SLOP.SDF:
543
+ if (widths === "2,4,2" && n.imm?.length === 1) return null
544
+ return "an sdf takes a float2, a float4 and a float2, and its shape as the one immediate"
545
+ case SLOP.FBM:
546
+ case SLOP.TURBULENCE:
547
+ case SLOP.RIDGED: {
548
+ const rest = n.op === SLOP.FBM ? 1 : 0
549
+ if (widths === "2,1" && (n.imm?.length ?? 0) === rest) return null
550
+ return `${SL_NAME[n.op]} takes a float2 and its octave count, a float`
551
+ }
552
+ default:
553
+ return null
554
+ }
555
+ }
556
+
557
+ /** The ops whose two operands the hash takes in either order: exact in IEEE arithmetic on every backend. */
558
+ const COMMUTATIVE = new Set<number>([SLOP.ADD, SLOP.MUL, SLOP.DOT, SLOP.DISTANCE])
559
+
560
+ /**
561
+ * Bumped when the hashing scheme changes, which invalidates generated shaders.
562
+ *
563
+ * 1 the Merkle hash over the reachable graph
564
+ * 2 normalised: commutative operands in either order, a swizzle of a constant as the constant
565
+ */
566
+ export const SL_HASH_VERSION = 2
478
567
 
479
568
  /**
480
569
  * Bumped whenever the IR changes in a way a reader has to know about: a new
@@ -491,13 +580,16 @@ export const SL_HASH_VERSION = 1
491
580
  * records it again, for no gain.
492
581
  *
493
582
  * A reader accepts every version up to its own and refuses a newer one with a
494
- * message naming both, the rule the particle wire and fx follow. Part of the
495
- * hash, so a bump recompiles every cached shader.
583
+ * message naming both, the rule the particle wire and fx follow. A program's
584
+ * version is the lowest that has its nodes (`programVersion`), and that is what
585
+ * its hash carries, so a bump rehashes only the programs using what it added.
496
586
  *
497
587
  * 1 the first versioned IR
498
588
  * 2 an SDF call carries up to six shape parameters, not four (#129)
589
+ * 3 SAMPLE_LOD; an SDF's shape parameters and a noise's octave count are
590
+ * operands, where they were immediates
499
591
  */
500
- export const SL_IR_VERSION = 2
592
+ export const SL_IR_VERSION = 3
501
593
 
502
594
  function fnv1a(s: string): string {
503
595
  let h = 0x811c9dc5
@@ -28,12 +28,12 @@ const plasma = parse(source, { file: "plasma.sl" })
28
28
  written, and the two produce the same graph. `parity.test.ts` asserts that as
29
29
  hash equality for every GPU fixture and for the example on play.onejs.com: same
30
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.
31
+ anything. It also means the existing GPU fixtures and the codegen goldens
32
+ cover the text form for free, because they run on the IR.
33
33
 
34
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.
35
+ whose hash does not match its generated shader draws nothing in a native
36
+ player. So parity is the test to keep green, not the parser's unit tests.
37
37
 
38
38
  | File | What it does |
39
39
  |---|---|
@@ -49,8 +49,8 @@ is told. So parity is the test to keep green, not the parser's unit tests.
49
49
 
50
50
  ## The loader
51
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
52
+ `slPlugin()`, in onejs-unity's `onejs-unity/esbuild`, parses and compiles at BUILD TIME, so an import resolves to a small
53
+ object of names, defaults and shader sources and the bundle carries neither the parser nor the source. A
54
54
  parse error becomes an esbuild error with the file, line and column, which the
55
55
  Play editor surfaces and every terminal editor links.
56
56
 
@@ -152,22 +152,18 @@ one the local does not have, is refused.
152
152
 
153
153
  ## Not yet spellable
154
154
 
155
- Three things the EDSL or the opcode table has and a file cannot say. Each is a
155
+ Two things the EDSL or the opcode table has and a file cannot say. Each is a
156
156
  recorded gap, not an oversight, and each has its own error message rather than
157
157
  "unknown identifier".
158
158
 
159
159
  | | Why |
160
160
  |---|---|
161
- | `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. |
161
+ | `rgb2hsv` | The opcode is numbered and **no backend implements it**; every emitter throws on it. |
162
162
  | `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. |
163
163
 
164
- Related, and worth knowing: **the EDSL lets a program declare 15 textures and
165
- the VM has 4.** `ir.ts` picked its ceiling from the WebGL2 sampler count rather
166
- than from `FxProgram.shader`, which declares `_Tex0` to `_Tex3` and samples
167
- `_Tex3` for every slot past it, so slots 4 and up are silently wrong in the
168
- browser and correct after an eject. A `.sl` file is held to the real number, 4,
169
- reported at the declaration. The EDSL's ceiling is untouched here because
170
- lowering it changes recorded behaviour rather than parser behaviour.
164
+ A `.sl` file and the EDSL are held to the same caps, `UNIFORM_SLOTS` (16) and
165
+ `TEXTURE_SLOTS` (4), reported at the declaration in one wording (`ir.ts`'s
166
+ `tooManyUniforms` and `tooManyTextures`).
171
167
 
172
168
  ## Attributes
173
169