onejs-sl 0.2.1 → 0.3.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.
@@ -344,8 +344,8 @@ class Parser {
344
344
  if (t.text === "for") return this.parseFor()
345
345
  if (t.text === "while" || t.text === "do") {
346
346
  this.fail(
347
- `there is no ${t.text} loop: the VM has no branches, so a loop has to unroll, and ` +
348
- `only a for loop with constant bounds can. See a for loop`,
347
+ `there is no ${t.text} loop: a loop unrolls at build time, and only a for loop ` +
348
+ `with constant bounds can. See a for loop`,
349
349
  t,
350
350
  )
351
351
  }
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * ADDING TO IT COSTS A FUNCTION AND A TEST, NOT AN OPCODE IN THREE PLACES. That
9
9
  * is the whole argument for having a text language with inlining functions: a
10
- * library entry is source, so it lands in the VM, in generated HLSL and in the
11
- * hash by being ordinary code, and neither backend learns anything new.
10
+ * library entry is source, so it lands in every emitter's output and in the
11
+ * hash by being ordinary code, and no backend learns anything new.
12
12
  *
13
13
  * WHY THE SOURCE IS A STRING AND NOT A FILE. `onejs-sl` ships raw TypeScript
14
14
  * and runs inside a browser worker on play.onejs.com, where
package/src/ops.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * The shader language opcode table.
3
3
  *
4
- * Phase 1 of `Specs/SHADER_LANG.md`. This is the contract three things will
5
- * share: the encoder that packs a program into a texture, the VM shader that
6
- * evaluates it, and the HLSL emitter that prints it. Two of those do not exist
7
- * yet, which is exactly why the numbering is fixed now rather than later.
4
+ * Phase 1 of `Specs/SHADER_LANG.md`. The numbers are in every program's hash
5
+ * and in its JSON, so an opcode keeps its number for good, and one that goes
6
+ * leaves its number unused.
8
7
  *
9
8
  * Numbered in families with gaps, the convention `fx/ops.ts` set, so a reader
10
9
  * can tell what an opcode is from its value:
@@ -22,18 +21,6 @@
22
21
  * a dense table.
23
22
  */
24
23
 
25
- /**
26
- * The newest VM encoding this encoder writes. The VM accepts 1..its own and
27
- * refuses newer, where there is a VM: a WebGL player draws compiled and never
28
- * reads the buffer.
29
- *
30
- * A payload records the LOWEST version that can run it (`Encoded.wire`), not
31
- * this: a program that uses nothing new stays 1, so a new bundle still runs on
32
- * an older Play container, and only a program that needs a newer instruction
33
- * is refused by an older one rather than drawn wrong.
34
- */
35
- export const SL_WIRE_VERSION = 2
36
-
37
24
  /**
38
25
  * How many uniforms and textures one program may declare: the language's caps,
39
26
  * for a host to read.
@@ -50,11 +37,7 @@ export const SL_WIRE_VERSION = 2
50
37
  export const UNIFORM_SLOTS = 16
51
38
  export const TEXTURE_SLOTS = 4
52
39
 
53
- /** The VM's names for the same two caps, which go when the VM does. */
54
- export const VM_UNIFORMS = UNIFORM_SLOTS
55
- export const VM_TEXTURES = TEXTURE_SLOTS
56
-
57
- /** Input ids, fixed here because the shader switches on them. */
40
+ /** Input ids, which the web emitter switches on. */
58
41
  export const INPUT_ID: Record<string, number> = {
59
42
  uv: 0, fragCoord: 1, resolution: 2, time: 3, aspect: 4,
60
43
  }
@@ -136,10 +119,7 @@ export const SLOP = {
136
119
  // made bright. imm.x carries the octave count, as FBM does.
137
120
  TURBULENCE: 133,
138
121
  RIDGED: 134,
139
- // VM only: an SDF given a fifth or sixth parameter, which one instruction's
140
- // four immediates and id cannot hold. The encoder makes it (`forVm`); the
141
- // IR and every compiled backend only ever see SDF.
142
- SDF_WIDE: 135,
122
+ // 135 was SDF_WIDE, the VM's form of an SDF with more than four parameters.
143
123
 
144
124
  // Sampling
145
125
  SAMPLE: 144,
@@ -149,7 +129,7 @@ export const SLOP = {
149
129
  export type SLOpCode = (typeof SLOP)[keyof typeof SLOP]
150
130
 
151
131
  /**
152
- * How many arguments each op takes, for validation and for the encoder.
132
+ * How many arguments each op takes, for validation.
153
133
  *
154
134
  * -1 means variadic: COMPOSE takes however many parts add up to its width, RAMP
155
135
  * takes a value plus its stops. Everything else is fixed, and a mismatch is a
@@ -177,7 +157,7 @@ export const SL_ARITY: Record<number, number> = {
177
157
  [SLOP.RAMP]: -1, [SLOP.HSV2RGB]: 1, [SLOP.RGB2HSV]: 1, [SLOP.LUMINANCE]: 1, [SLOP.TO_LINEAR]: 1,
178
158
 
179
159
  [SLOP.NOISE]: 1, [SLOP.SIMPLEX]: 1, [SLOP.FBM]: 1, [SLOP.SDF]: 1,
180
- [SLOP.VORONOI]: 1, [SLOP.TURBULENCE]: 1, [SLOP.RIDGED]: 1, [SLOP.SDF_WIDE]: 2,
160
+ [SLOP.VORONOI]: 1, [SLOP.TURBULENCE]: 1, [SLOP.RIDGED]: 1,
181
161
 
182
162
  [SLOP.SAMPLE]: 1, [SLOP.SAMPLE_LOD]: 2,
183
163
  }
@@ -187,18 +167,10 @@ export const SL_NAME: Record<number, string> = Object.fromEntries(
187
167
  Object.entries(SLOP).map(([name, code]) => [code, name.toLowerCase()]),
188
168
  )
189
169
 
190
- export const FIRST_ARITHMETIC = 16
191
- export const FIRST_MATHS = 48
192
- export const FIRST_GEOMETRY = 80
193
- export const FIRST_INTERPOLATE = 96
194
- export const FIRST_COLOUR = 112
195
- export const FIRST_PROCEDURAL = 128
196
- export const FIRST_SAMPLING = 144
170
+ const FIRST_SAMPLING = 144
197
171
 
198
172
  /**
199
- * Ops that read a texture. Sampler slots are the one resource ceiling neither
200
- * backend can widen (16 in a fragment shader on WebGL2), so these are counted
201
- * at record time.
173
+ * Ops that read a texture.
202
174
  */
203
175
  export function isSampling(op: number): boolean {
204
176
  return op >= FIRST_SAMPLING
@@ -292,7 +264,6 @@ export const SL_HLSL: Record<number, SLSurface> = {
292
264
  [SLOP.VORONOI]: { call: "voronoi" },
293
265
  [SLOP.TURBULENCE]: { call: "turbulence" },
294
266
  [SLOP.RIDGED]: { call: "ridged" },
295
- [SLOP.SDF_WIDE]: { syntax: "sdf.<shape>(p, ...) given a fifth or sixth parameter" },
296
267
 
297
268
  [SLOP.SAMPLE]: { call: "tex2D" },
298
269
  [SLOP.SAMPLE_LOD]: { call: "tex2Dlod" },
@@ -303,16 +274,15 @@ export const SL_HLSL: Record<number, SLSurface> = {
303
274
  *
304
275
  * Numbered so the families stay in order, and refused at the surface until
305
276
  * somebody writes both halves. A program reaching one would render as whatever
306
- * the VM's dispatch falls through to and fail outright in the HLSL emitter,
307
- * which is the silent-disagreement failure this design exists to prevent, so
308
- * the parser names them rather than treating them as unknown identifiers.
277
+ * nothing: the emitters have no case for them. So the parser names them rather
278
+ * than treating them as unknown identifiers.
309
279
  *
310
280
  * `REMAP` is here because the opcode is unimplemented; the SURFACE `remap` is a
311
281
  * macro over arithmetic and works everywhere.
312
282
  */
313
283
  export const SL_UNIMPLEMENTED: Record<number, string> = {
314
- [SLOP.RGB2HSV]: "neither the VM shader nor the HLSL emitter has a case for it",
315
- [SLOP.SAMPLE_LOD]: "the VM samples without an explicit LOD; use tex2D",
284
+ [SLOP.RGB2HSV]: "no backend has a case for it yet",
285
+ [SLOP.SAMPLE_LOD]: "no backend samples at an explicit LOD yet; use tex2D",
316
286
  [SLOP.REMAP]: "the surface form is a macro over arithmetic, so the opcode is unused",
317
287
  }
318
288
 
package/src/serial.ts CHANGED
@@ -55,7 +55,7 @@ export function fromJSON(json: unknown): Program {
55
55
  if (v === SL_IR_VERSION && j.hash !== undefined && j.hash !== hash) {
56
56
  fail(`its hash ${j.hash} does not match its nodes (${hash}); it was changed after it was written`)
57
57
  }
58
- return { version: SL_IR_VERSION, nodes, result: result!, uniforms, textures, hash, loops: [] }
58
+ return { version: SL_IR_VERSION, nodes, result: result!, uniforms, textures, hash }
59
59
  }
60
60
 
61
61
  function node(n: unknown, i: number): SLNode {
package/src/sl.ts CHANGED
@@ -207,7 +207,7 @@ function align2(a: Val, o: Num): [NodeRef, NodeRef] {
207
207
  * right for two operands because `bin` takes the max itself; the three and four
208
208
  * operand ops used to take the width from one chosen operand, so
209
209
  * `mix(0, aVec3, t)` produced a node typed float that held three components.
210
- * The VM keeps every value in a float4 register and never noticed, while the
210
+ * The VM, which kept every value in a float4 register, never noticed, while the
211
211
  * HLSL emitter declared `float` and truncated, so the two backends rendered
212
212
  * different pictures from one program. Anything component wise with more than
213
213
  * two operands goes through here.
@@ -294,7 +294,6 @@ export function program(fn: (inputs: ProgramInputs) => Vec4): Program {
294
294
  uniforms: b.uniforms.slice(),
295
295
  textures: b.textures.slice(),
296
296
  hash: hashProgram(nodes, out.ref, b.uniforms, b.textures),
297
- loops: b.loops.slice(),
298
297
  }
299
298
  } finally {
300
299
  current = null
@@ -312,23 +311,14 @@ function compose(width: SLType, parts: Num[]): Val {
312
311
  // Wide parts are SPLIT into their components here, so COMPOSE only ever
313
312
  // sees scalars.
314
313
  //
315
- // The VM keeps every value in a float4 register and took the x of each
316
- // operand, which silently dropped everything after the first component:
317
- // vec4(uv, 0, 1) rendered (uv.x, 0, 1, 0) instead of (uv.x, uv.y, 0, 1).
318
- // Teaching the shader each part's width would need widths in an encoding
319
- // that has no room for them, and would put the complexity in the half that
320
- // is hardest to test. Splitting at record time costs a few extra swizzle
321
- // nodes, which the register allocator reclaims immediately, and leaves the
322
- // VM's COMPOSE trivially correct.
314
+ // The VM (removed in 0.3.0) took the x of each operand, which dropped
315
+ // everything after the first component: vec4(uv, 0, 1) rendered
316
+ // (uv.x, 0, 1, 0). Splitting at record time costs a few extra swizzle
317
+ // nodes and keeps every emitter's COMPOSE trivial.
323
318
  // A constructor of literals is ONE constant, not a COMPOSE of four.
324
319
  //
325
- // `vec4(0.1, 0.2, 0.3, 0.4)` was five instructions holding four registers
326
- // at once, because every part became its own CONST and COMPOSE kept them
327
- // all live. The IR has always been able to say it in one node: a const
328
- // carries up to four components. A two stop `ramp` used five of the eight
329
- // registers on nothing but its own stop colours, which put the language's
330
- // headline colour tool within one or two registers of unusable in any
331
- // program that also did something.
320
+ // Every part as its own CONST, joined by a COMPOSE, is five nodes where the
321
+ // IR can say it in one: a const carries up to four components.
332
322
  if (parts.length === width && parts.every((p) => typeof p === "number")) {
333
323
  return mk(b, b.constant(parts as number[]), width)
334
324
  }
@@ -425,9 +415,8 @@ export function hsv2rgb(c: Vec3): Vec3 {
425
415
  /**
426
416
  * Cross and reflect, both vec3 only.
427
417
  *
428
- * The VM evaluates them as `cross(a.xyz, b.xyz)`, so a vec2 or vec4 would mean
429
- * one thing in a compiled build and another in the interpreter. Refusing the
430
- * other widths here is what keeps the two honest.
418
+ * Refusing the other widths here keeps every backend drawing the same thing,
419
+ * rather than each deciding what a vec2 or vec4 cross product means.
431
420
  */
432
421
  export function cross(a: Vec3, b: Vec3): Vec3 {
433
422
  if (a.width !== TYPE.VEC3 || b.width !== TYPE.VEC3) {
@@ -452,7 +441,7 @@ export function reflect(incident: Vec3, normal: Vec3): Vec3 {
452
441
  *
453
442
  * A MACRO, like `ramp`, and for the same reason: it expands into arithmetic
454
443
  * both backends already have, so it needs no opcode, no second implementation
455
- * in the emitter and no VM case. `SLOP.REMAP` stays a reserved number.
444
+ * in the emitters. `SLOP.REMAP` stays a reserved number.
456
445
  *
457
446
  * Unclamped on purpose, which is what HLSL authors expect of the one liner they
458
447
  * would otherwise write; wrap it in `saturate` when the ends matter.
@@ -495,11 +484,10 @@ export function select(cond: Num, whenTrue: Num, whenFalse: Num): Val {
495
484
  const t = typeof whenTrue === "number" ? float(whenTrue) : whenTrue
496
485
  const f = typeof whenFalse === "number" ? float(whenFalse) : whenFalse
497
486
  // The condition is widened with the branches, not left at its own width.
498
- // A register is a float4 whatever it holds, so a scalar condition sits
499
- // there as (c, 0, 0, 0) and the VM's `step(0.5, cond)` answered 0 for
500
- // components y, z and w, while the HLSL backend broadcasts a scalar
501
- // itself. The two DISAGREED on every vector valued select, which is the one
502
- // failure this design cannot tolerate.
487
+ // The VM (removed in 0.3.0) held a scalar condition as (c, 0, 0, 0) and
488
+ // answered 0 for components y, z and w, while HLSL broadcasts a scalar
489
+ // itself; the two disagreed on every vector valued select. Widening here
490
+ // keeps every emitter's SELECT the same simple form.
503
491
  const { refs, width } = alignN([c, t, f])
504
492
  return mk(b, b.call(SLOP.SELECT, width, refs), width)
505
493
  }
@@ -507,8 +495,8 @@ export function select(cond: Num, whenTrue: Num, whenFalse: Num): Val {
507
495
  /**
508
496
  * The interpolant is broadcast to the operand width before it crosses.
509
497
  *
510
- * A register is a float4 whatever it holds, so a scalar `t` sits there as
511
- * (t, 0, 0, 0) and the shader's lerp ran per component against those zeros:
498
+ * The VM (removed in 0.3.0) held a scalar `t` as (t, 0, 0, 0) and its lerp
499
+ * ran per component against those zeros:
512
500
  * a black to white ramp at its midpoint rendered (0.5, 0, 0, 1) instead of
513
501
  * grey. Broadcasting here rather than in the shader keeps `t` free to be a
514
502
  * genuine per component vector when an author wants one.
@@ -597,13 +585,9 @@ export function parseColor(hex: string): [number, number, number, number] {
597
585
  export function ramp(t: Num, stops: Array<string | [number, number, number, number]>): Vec4 {
598
586
  if (stops.length < 2) throw new SLError(`a ramp needs at least 2 stops, got ${stops.length}`)
599
587
  const tv = (typeof t === "number" ? float(t) : t).saturate()
600
- // Each stop is built IMMEDIATELY BEFORE the mix that consumes it, not all
601
- // of them up front. The graph is identical either way and so is the hash,
602
- // which is a Merkle hash over the shape and not over storage order, but the
603
- // register allocator walks the array: built up front, every stop stayed
604
- // live until the last mix, and a four stop ramp reserved four of the VM's
605
- // eight registers before the program did anything. That put the language's
606
- // headline colour tool a couple of registers from unusable.
588
+ // Each stop is built immediately before the mix that consumes it. The graph
589
+ // and the hash are the same either way (the hash is over the shape, not the
590
+ // storage order); the order only mattered to the VM's register allocator.
607
591
  const colourAt = (i: number) => {
608
592
  const v = typeof stops[i] === "string" ? parseColor(stops[i] as string) : (stops[i] as number[])
609
593
  return vec4(v[0], v[1], v[2], v[3])
@@ -733,34 +717,13 @@ export function texture(name: string): Texture {
733
717
  *
734
718
  * Honest about what it is: a macro, not a loop. It covers fbm, layered noise and
735
719
  * small iterated distance fields, which is most of what 2D shaders loop for. A
736
- * data dependent loop is out of scope, because in the VM it would need a nested
737
- * bounded loop with a dynamic trip count while codegen would handle it fine, and
738
- * the two backends agreeing is the property this whole design protects.
720
+ * loop with a runtime count is `Specs/SL_NEXT.md` proposal 3b.
739
721
  */
740
722
  export function repeat<T extends Val>(n: number, body: (i: number, acc: T) => T, seed: T): T {
741
723
  if (!Number.isInteger(n) || n < 0 || n > 64) {
742
724
  throw new SLError(`repeat count must be a whole number from 0 to 64, got ${n}`)
743
725
  }
744
- return unrolled(n, () => {
745
- let acc = seed
746
- for (let i = 0; i < n; i++) acc = body(i, acc)
747
- return acc
748
- })
749
- }
750
-
751
- /**
752
- * Records the nodes a body produced as one unrolled span.
753
- *
754
- * Diagnostic only: the instruction ceiling error uses the span to say which
755
- * loop spent the budget, so the fix reads as "lower this count" rather than
756
- * "fewer instructions". `repeat` is the EDSL's form of it and the text
757
- * language's `for` is the other, and neither should be reaching into the
758
- * Builder to push a span itself.
759
- */
760
- export function unrolled<T>(count: number, body: () => T): T {
761
- const b = ctx()
762
- const start = b.nodes.length
763
- const out = body()
764
- b.loops.push({ count, start, end: b.nodes.length })
765
- return out
726
+ let acc = seed
727
+ for (let i = 0; i < n; i++) acc = body(i, acc)
728
+ return acc
766
729
  }
package/src/web.ts CHANGED
@@ -2,11 +2,10 @@
2
2
  * The web backends: prints a program as GLSL ES 3.00 or WGSL, for a browser to
3
3
  * compile at runtime on Unity's own graphics device.
4
4
  *
5
- * Unity cannot compile a shader in a built game, which is why the VM exists.
6
- * A browser can, and the Play container runs on WebGPU with WebGL2 behind it,
7
- * so a program becomes WGSL on one and GLSL ES on the other and runs compiled:
8
- * no register budget, no interpreter, and on the spike's measurements 100 to
9
- * 600 times cheaper per pixel. The host that compiles and draws these lives in
5
+ * Unity cannot compile a shader in a built game, so a native build ships the
6
+ * shaders the editor generated. A browser can compile, and the Play container
7
+ * runs on WebGPU with WebGL2 behind it, so a program becomes WGSL on one and
8
+ * GLSL ES on the other. The host that compiles and draws these lives in
10
9
  * OneJS (`Plugins/WebGL/OneJSSLWeb.jslib`); this file only prints them.
11
10
  *
12
11
  * Structurally the HLSL emitter again: one local per reachable node, in order,
@@ -159,7 +158,7 @@ function emitWeb(p: Program, lang: WebLanguage): string {
159
158
  case SLOP.MIX: return `mix(${s[0]}, ${s[1]}, ${s[2]})`
160
159
  case SLOP.STEP: return `step(${s[0]}, ${s[1]})`
161
160
  case SLOP.SMOOTHSTEP: smoothsteps.add(t); return `sl_smoothstep${t}(${s[0]}, ${s[1]}, ${s[2]})`
162
- // As the VM and the HLSL emitter do it: branchless, so every
161
+ // As the HLSL emitter does it: branchless, so every
163
162
  // backend agrees on the edge value.
164
163
  case SLOP.SELECT: {
165
164
  const c = typeOf(n.args[0])
@@ -182,7 +181,7 @@ function emitWeb(p: Program, lang: WebLanguage): string {
182
181
  default:
183
182
  throw new SLError(
184
183
  `the ${lang.toUpperCase()} emitter has no case for opcode ${n.op}. A program using it would ` +
185
- `differ between the VM and a compiled build, which is the one failure this design cannot tolerate.`,
184
+ `differ between a browser and a Unity build, which is the one failure this design cannot tolerate.`,
186
185
  )
187
186
  }
188
187