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.
@@ -45,13 +45,12 @@ const name = (op: number): string => {
45
45
  const val = (v: Num): Val => (typeof v === "number" ? (sl.float(v) as unknown as Val) : v)
46
46
 
47
47
  /**
48
- * Asserts an operand's width where the two backends would otherwise part ways.
48
+ * Asserts an operand's width where the backends would otherwise part ways.
49
49
  *
50
- * The VM keeps every value in a float4 register and writes `a.xy` or `a.rgb`
51
- * whatever it was handed, so a noise call on a float3 quietly works there. The
52
- * generated HLSL passes the real type to `sl_valueNoise(float2)` and either
53
- * truncates with a warning or fails to compile. Neither outcome is one an
54
- * author should discover after ejecting, so the narrow ops say so here.
50
+ * The generated HLSL passes the real type to `sl_valueNoise(float2)` and either
51
+ * truncates with a warning or fails to compile, while a browser may accept it.
52
+ * Neither outcome is one an author should discover in a build, so the narrow
53
+ * ops say so here.
55
54
  */
56
55
  function widthMustBe(fn: string, v: Num, want: 1 | 2 | 3 | 4, why: string): Val {
57
56
  const got = typeof v === "number" ? 1 : v.width
@@ -126,12 +125,10 @@ value(SLOP.LUMINANCE, 1, 1, (a) => sl.luminance(colourOperand("luminance", a[0]!
126
125
  value(SLOP.NOISE, 1, 1, (a) => sl.noise(point("noise", a[0]!) as never))
127
126
  value(SLOP.SIMPLEX, 1, 1, (a) => sl.simplex(point("simplex", a[0]!) as never))
128
127
  value(SLOP.VORONOI, 1, 1, (a) => sl.voronoi(point("voronoi", a[0]!) as never))
129
- // The octave count is an immediate, so it has to be a number at parse time.
130
- // `lower.ts` folds constants before it gets here and rejects anything else with
131
- // a message that says why, rather than letting this cast be the error.
132
- value(SLOP.FBM, 1, 2, (a) => sl.fbm(point("fbm", a[0]!) as never, octaves("fbm", a[1])))
133
- value(SLOP.TURBULENCE, 1, 2, (a) => sl.turbulence(point("turbulence", a[0]!) as never, octaves("turbulence", a[1])))
134
- value(SLOP.RIDGED, 1, 2, (a) => sl.ridged(point("ridged", a[0]!) as never, octaves("ridged", a[1])))
128
+ // The octave count is 3 when left out, and may be a value (`sl.fbm` says how).
129
+ value(SLOP.FBM, 1, 2, (a) => sl.fbm(point("fbm", a[0]!) as never, a[1] ?? 3))
130
+ value(SLOP.TURBULENCE, 1, 2, (a) => sl.turbulence(point("turbulence", a[0]!) as never, a[1] ?? 3))
131
+ value(SLOP.RIDGED, 1, 2, (a) => sl.ridged(point("ridged", a[0]!) as never, a[1] ?? 3))
135
132
 
136
133
  /** Every field is sampled at a float2. */
137
134
  function point(fn: string, v: Num): Val {
@@ -139,20 +136,10 @@ function point(fn: string, v: Num): Val {
139
136
  }
140
137
 
141
138
  special(SLOP.SAMPLE, 2, 2)
139
+ special(SLOP.SAMPLE_LOD, 3, 3)
142
140
  special(SLOP.SDF, 1, 7)
143
141
  special(SLOP.RAMP, 3, 64)
144
142
 
145
- function octaves(fn: string, v: Num | undefined): number {
146
- if (v === undefined) return 3
147
- if (typeof v !== "number") {
148
- throw new SLError(
149
- `${fn}'s octave count is baked into the instruction, so it has to be a constant. ` +
150
- `A uniform or a computed value cannot change how many octaves the shader runs.`,
151
- )
152
- }
153
- return v
154
- }
155
-
156
143
  export const BUILTINS: Readonly<Record<string, Builtin>> = table
157
144
 
158
145
  /**
package/src/lang/check.ts CHANGED
@@ -49,11 +49,15 @@ const DERIVED_NAMES = new Set(Object.keys(DERIVED_INPUTS))
49
49
  * The GLSL spellings whose HLSL name means the same wherever it is written, so
50
50
  * the hint can be a one click fix (`Specs/SL_NEXT.md` 5, Decision 1 A). The
51
51
  * rest of `SL_GLSL_HINT` gets the hint alone: `mod` floors where `%` truncates,
52
- * `ivec2` truncates where `float2` does not, `atan` is `atan2` only with two
53
- * arguments (handled at the call), and `textureLod` names a builtin that is not
54
- * implemented yet.
52
+ * `ivec2` truncates where `float2` does not, and `atan` is `atan2` only with two
53
+ * arguments (handled at the call).
55
54
  */
56
- const GLSL_RENAMES = new Set(["mix", "fract", "texture", "vec2", "vec3", "vec4", "gl_FragCoord", "iTime", "iResolution"])
55
+ const GLSL_RENAMES = new Set([
56
+ "mix", "fract", "texture", "textureLod", "vec2", "vec3", "vec4", "gl_FragCoord", "iTime", "iResolution",
57
+ ])
58
+
59
+ /** The builtins whose first argument is a texture, which is not a value anywhere else. */
60
+ const SAMPLES = new Set(["tex2D", "tex2Dlod"])
57
61
 
58
62
  const rename = (from: string, to: string): SLFix => ({ title: `Replace ${from} with ${to}`, replacement: to })
59
63
 
@@ -427,7 +431,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
427
431
  if (textures.has(e.name)) {
428
432
  fail(
429
433
  `"${e.name}" is a texture, and a texture is only ever the first argument of ` +
430
- `tex2D. Write \`tex2D(${e.name}, uv)\``,
434
+ `tex2D or tex2Dlod. Write \`tex2D(${e.name}, uv)\``,
431
435
  e.pos, e.name.length,
432
436
  )
433
437
  }
@@ -458,9 +462,9 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
458
462
  // Only reached while collecting: the call was refused, and its
459
463
  // arguments are separate questions, so a mistake inside one is
460
464
  // not hidden by the call's own. `fract(uv * wrap)` is two
461
- // mistakes. tex2D's first argument is the texture, which is a
462
- // value only there.
463
- const tex = e.callee.k === "ident" && e.callee.name === "tex2D"
465
+ // mistakes. A sample's first argument is the texture, which is
466
+ // not a value.
467
+ const tex = e.callee.k === "ident" && SAMPLES.has(e.callee.name)
464
468
  for (const a of tex ? e.args.slice(1) : e.args) attempt(() => checkExpr(fn, a, scope))
465
469
  return
466
470
  }
@@ -529,13 +533,22 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
529
533
 
530
534
  const builtin = BUILTINS[n]
531
535
  if (builtin !== undefined) {
532
- if (n === "tex2D") {
536
+ if (SAMPLES.has(n)) {
533
537
  const [tex, ...rest] = e.args
538
+ // HLSL's own tex2Dlod packs the level into a float4 with the uv.
539
+ if (n === "tex2Dlod" && e.args.length === 2) {
540
+ fail(
541
+ "tex2Dlod takes the uv and the mip level separately, `tex2Dlod(t, uv, lod)`, " +
542
+ "rather than HLSL's float4(uv, 0, lod)",
543
+ e.pos,
544
+ )
545
+ }
534
546
  arity(e, n, builtin.min, builtin.max)
535
547
  if (tex === undefined || tex.k !== "ident" || !textures.has(tex.name)) {
548
+ const lod = n === "tex2Dlod" ? ", 0" : ""
536
549
  fail(
537
- `tex2D samples a texture declared in this file, as in \`texture2D grain;\` ` +
538
- `then \`tex2D(grain, uv)\``,
550
+ `${n} samples a texture declared in this file, as in \`texture2D grain;\` ` +
551
+ `then \`${n}(grain, uv${lod})\``,
539
552
  (tex ?? e).pos,
540
553
  )
541
554
  }
package/src/lang/glsl.ts CHANGED
@@ -75,7 +75,7 @@ const GLSL_TYPES = new Set(["float", "int", "uint", "bool", "vec2", "vec3", "vec
75
75
  /** A straight rename, the meaning unchanged. */
76
76
  const RENAME: Record<string, string> = {
77
77
  vec2: "float2", vec3: "float3", vec4: "float4", mix: "lerp", fract: "frac",
78
- texture: "tex2D", texture2D: "tex2D", iTime: "time", iGlobalTime: "time", gl_FragCoord: "fragCoord",
78
+ texture: "tex2D", texture2D: "tex2D", textureLod: "tex2Dlod", iTime: "time", iGlobalTime: "time", gl_FragCoord: "fragCoord",
79
79
  }
80
80
 
81
81
  /** A rename that changes what the value can hold, so it is said. */
@@ -101,7 +101,6 @@ const MISSING: Record<string, string> = {
101
101
  iChannelTime: "a texture has no time of its own",
102
102
  iChannelResolution: "a texture's size cannot be read yet",
103
103
  iSampleRate: "there is no audio",
104
- textureLod: "tex2Dlod is not implemented yet; tex2D samples with the texture's own filtering",
105
104
  texelFetch: "a texture cannot be read without filtering yet",
106
105
  textureSize: "a texture's size cannot be read yet",
107
106
  dFdx: "there are no derivatives", dFdy: "there are no derivatives", fwidth: "there are no derivatives",
package/src/lang/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * import { parse } from "onejs-sl"
9
9
  *
10
10
  * const plasma = parse(source, { file: "plasma.sl" })
11
- * // ^ a Program: encode() it, emit HLSL from it, hash it
11
+ * // ^ a Program: compile() it, emit HLSL from it, hash it
12
12
  *
13
13
  * The parser is not a second authoring surface with its own semantics. It
14
14
  * lowers through the EDSL, so a `.sl` file and the `sl.program(...)` an author
package/src/lang/lower.ts CHANGED
@@ -15,8 +15,7 @@
15
15
  * existed. Reassignment is free; it is not a store.
16
16
  *
17
17
  * **`for` unrolls.** The bounds are constants, so the body is lowered once per
18
- * iteration with the counter substituted as a number. `sl.unrolled` records the
19
- * span so the instruction ceiling error can name the loop that spent it.
18
+ * iteration with the counter substituted as a number.
20
19
  *
21
20
  * **`if` becomes `select`.** Both sides are lowered and every local assigned in
22
21
  * either is selected at the join. A constant condition folds instead, which is
@@ -33,7 +32,9 @@
33
32
  * than an optimisation.
34
33
  */
35
34
 
36
- import { controlProblem, DERIVED_INPUTS, INPUTS, type Program, type SLType, type UniformControl } from "../ir"
35
+ import {
36
+ controlProblem, DERIVED_INPUTS, INPUTS, RAMP_STOP_COMPUTED, writtenColour, type Program, type SLType, type UniformControl,
37
+ } from "../ir"
37
38
  import type { SlSdfKind } from "../shapes"
38
39
  import * as sl from "../sl"
39
40
  import type { Num, Val } from "../sl"
@@ -421,20 +422,19 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
421
422
  count++
422
423
  if (count > MAX_UNROLL) {
423
424
  fail(
424
- `this loop would unroll to more than ${MAX_UNROLL} iterations. There is ` +
425
- `no loop on either backend, so every iteration is emitted in full and ` +
426
- `the VM runs at most 256 instructions`,
425
+ `this loop would unroll to more than ${MAX_UNROLL} iterations. A for ` +
426
+ `loop unrolls at build time, so every iteration is written out in full`,
427
427
  s.pos,
428
428
  )
429
429
  }
430
430
  }
431
- at(s.pos, () => sl.unrolled(count, () => {
431
+ at(s.pos, () => {
432
432
  for (let i = from; s.inclusive ? i <= to : i < to; i += step) {
433
433
  const inner = new Scope(scope)
434
434
  inner.declare(s.counter, { width: 1, value: i })
435
435
  exec(s.body, inner, ret)
436
436
  }
437
- }))
437
+ })
438
438
  break
439
439
  }
440
440
  default:
@@ -447,7 +447,7 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
447
447
  if (typeof v !== "number") {
448
448
  fail(
449
449
  `a for loop unrolls at build time, so its ${what} has to be a constant. This one is ` +
450
- `computed while the shader runs, and neither backend has a loop to run it in`,
450
+ `computed while the shader runs`,
451
451
  e.pos,
452
452
  )
453
453
  }
@@ -604,19 +604,7 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
604
604
  if (typeof p === "number" || p.width !== 2) {
605
605
  fail(`sdf.${shape} measures the distance to a point, so it takes a float2`, e.args[0]!.pos)
606
606
  }
607
- const params: number[] = []
608
- for (const a of e.args.slice(1)) {
609
- const v = lowerExpr(a, scope)
610
- if (typeof v !== "number") {
611
- fail(
612
- `sdf.${shape}'s shape parameters ride inside the instruction, so they have to ` +
613
- `be constants. A size that changes belongs on the point: scale or offset it ` +
614
- `before the call`,
615
- a.pos,
616
- )
617
- }
618
- params.push(v)
619
- }
607
+ const params = e.args.slice(1).map((a) => lowerExpr(a, scope))
620
608
  return at(e.pos, () => sl.sdf(shape, p as never, params))
621
609
  }
622
610
 
@@ -626,29 +614,35 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
626
614
  return construct(n as TypeName, e.args.map((a) => lowerExpr(a, scope)), e.pos)
627
615
  }
628
616
 
629
- if (n === "tex2D") {
617
+ if (n === "tex2D" || n === "tex2Dlod") {
630
618
  const texName = (e.args[0] as Extract<Expr, { k: "ident" }>).name
631
619
  const tex = samplers.get(texName)!
632
620
  const uv = lowerExpr(e.args[1]!, scope)
633
621
  if (typeof uv === "number" || uv.width !== 2) {
634
- fail("tex2D samples at a float2", e.args[1]!.pos)
622
+ fail(`${n} samples at a float2`, e.args[1]!.pos)
635
623
  }
636
- return at(e.pos, () => tex.sample(uv as never) as unknown as Val)
624
+ if (n === "tex2D") return at(e.pos, () => tex.sample(uv as never) as unknown as Val)
625
+ const lod = lowerExpr(e.args[2]!, scope)
626
+ if (typeof lod !== "number" && lod.width !== 1) {
627
+ fail(`a mip level is one number, and this is a ${widthType(lod.width)}`, e.args[2]!.pos)
628
+ }
629
+ return at(e.pos, () => tex.sampleLevel(uv as never, lod) as unknown as Val)
637
630
  }
638
631
 
639
632
  if (n === "ramp") {
640
633
  const t = lowerExpr(e.args[0]!, scope)
641
- const stops: string[] = []
642
- for (const a of e.args.slice(1)) {
643
- if (a.k !== "hex") {
644
- fail(
645
- "a ramp's stops are constants, written as colours: `ramp(t, #000018, #0080ff, " +
646
- "#ffffff)`. A stop that changes wants lerp between two uniforms instead",
647
- a.pos,
648
- )
634
+ // A hex stays the string it was, so a ramp of hexes is the program
635
+ // it always was. Anything else is a value, which has to be a colour
636
+ // as written (`sl.ramp` says why), a colour uniform say.
637
+ const stops = e.args.slice(1).map((a) => {
638
+ if (a.k === "hex") return a.hex
639
+ const v = lowerExpr(a, scope)
640
+ if (typeof v === "number" || v.width < 3) {
641
+ fail(`a ramp's stop is a colour, a float3 or a float4, and this is a ${typeof v === "number" ? "float" : widthType(v.width)}`, a.pos)
649
642
  }
650
- stops.push(a.hex)
651
- }
643
+ if (writtenColour(v.owner.nodes, v.ref) === null) fail(RAMP_STOP_COMPUTED, a.pos)
644
+ return v as never
645
+ })
652
646
  return at(e.pos, () => sl.ramp(t, stops) as unknown as Val)
653
647
  }
654
648
 
@@ -730,8 +724,8 @@ const ARITH: Record<ArithOp, (a: number, b: number) => number> = {
730
724
  "-": (a, b) => a - b,
731
725
  "*": (a, b) => a * b,
732
726
  "/": (a, b) => a / b,
733
- // JavaScript's % is the truncated remainder, which is what HLSL's fmod is
734
- // and what the VM's OP_MOD implements. Folding it in JS is the same answer.
727
+ // JavaScript's % is the truncated remainder, which is what HLSL's fmod is.
728
+ // Folding it in JS is the same answer.
735
729
  "%": (a, b) => a % b,
736
730
  }
737
731
 
@@ -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
@@ -176,8 +156,10 @@ export const SL_ARITY: Record<number, number> = {
176
156
 
177
157
  [SLOP.RAMP]: -1, [SLOP.HSV2RGB]: 1, [SLOP.RGB2HSV]: 1, [SLOP.LUMINANCE]: 1, [SLOP.TO_LINEAR]: 1,
178
158
 
179
- [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,
159
+ // An SDF takes the point, or the point and its parameters as operands; an
160
+ // octave op the point, or the point and its count (`formProblem` in ir.ts).
161
+ [SLOP.NOISE]: 1, [SLOP.SIMPLEX]: 1, [SLOP.FBM]: -1, [SLOP.SDF]: -1,
162
+ [SLOP.VORONOI]: 1, [SLOP.TURBULENCE]: -1, [SLOP.RIDGED]: -1,
181
163
 
182
164
  [SLOP.SAMPLE]: 1, [SLOP.SAMPLE_LOD]: 2,
183
165
  }
@@ -187,18 +169,10 @@ export const SL_NAME: Record<number, string> = Object.fromEntries(
187
169
  Object.entries(SLOP).map(([name, code]) => [code, name.toLowerCase()]),
188
170
  )
189
171
 
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
172
+ const FIRST_SAMPLING = 144
197
173
 
198
174
  /**
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.
175
+ * Ops that read a texture.
202
176
  */
203
177
  export function isSampling(op: number): boolean {
204
178
  return op >= FIRST_SAMPLING
@@ -292,7 +266,6 @@ export const SL_HLSL: Record<number, SLSurface> = {
292
266
  [SLOP.VORONOI]: { call: "voronoi" },
293
267
  [SLOP.TURBULENCE]: { call: "turbulence" },
294
268
  [SLOP.RIDGED]: { call: "ridged" },
295
- [SLOP.SDF_WIDE]: { syntax: "sdf.<shape>(p, ...) given a fifth or sixth parameter" },
296
269
 
297
270
  [SLOP.SAMPLE]: { call: "tex2D" },
298
271
  [SLOP.SAMPLE_LOD]: { call: "tex2Dlod" },
@@ -303,16 +276,14 @@ export const SL_HLSL: Record<number, SLSurface> = {
303
276
  *
304
277
  * Numbered so the families stay in order, and refused at the surface until
305
278
  * 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.
279
+ * nothing: the emitters have no case for them. So the parser names them rather
280
+ * than treating them as unknown identifiers.
309
281
  *
310
282
  * `REMAP` is here because the opcode is unimplemented; the SURFACE `remap` is a
311
283
  * macro over arithmetic and works everywhere.
312
284
  */
313
285
  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",
286
+ [SLOP.RGB2HSV]: "no backend has a case for it yet",
316
287
  [SLOP.REMAP]: "the surface form is a macro over arithmetic, so the opcode is unused",
317
288
  }
318
289
 
package/src/params.ts CHANGED
@@ -54,6 +54,7 @@ const INTRINSIC: Record<string, readonly string[]> = {
54
54
  distance: ["x", "y"], dot: ["x", "y"], cross: ["x", "y"], reflect: ["i", "n"],
55
55
  lerp: ["x", "y", "s"], step: ["y", "x"], smoothstep: ["min", "max", "x"],
56
56
  tex2D: ["s", "t"],
57
+ tex2Dlod: ["s", "t", "lod"],
57
58
  }
58
59
 
59
60
  /**
@@ -161,6 +162,7 @@ export const BUILTIN_DOCS: Readonly<Record<string, string>> = {
161
162
  turbulence: "Simplex creases layered in octaves, 1 to 4: fire, smoke, marble.",
162
163
  ridged: "Turbulence made bright at its creases: ridges, lightning, cracks.",
163
164
  tex2D: "The colour of texture s at the uv t, with straight alpha.",
165
+ tex2Dlod: "The colour of texture s at the uv t from mip level lod: 0 is the full size, 1 half, and so on.",
164
166
  sdf: "The signed distance from p to a shape, negative inside: `sdf.circle(p, r)`.",
165
167
  ramp: "t mapped through evenly spaced colour stops, from the first at 0 to the last at 1.",
166
168
  }
package/src/serial.ts CHANGED
@@ -9,14 +9,16 @@
9
9
  * older one is migrated to this one and rehashed under it.
10
10
  */
11
11
  import {
12
- INPUTS, SLError, SL_IR_VERSION, TYPE, checkCaps, controlOf, controlProblem, hashProgram,
13
- type NodeRef, type Program, type SLNode, type SLType, type TextureDecl, type UniformDecl,
12
+ INPUTS, SLError, SL_HASH_VERSION, SL_IR_VERSION, TYPE, checkCaps, controlOf, controlProblem, formProblem, hashProgram,
13
+ programVersion, type NodeRef, type Program, type SLNode, type SLType, type TextureDecl, type UniformDecl,
14
14
  } from "./ir"
15
- import { SL_ARITY, SL_NAME } from "./ops"
15
+ import { SLOP, SL_ARITY, SL_NAME } from "./ops"
16
16
 
17
17
  export interface ProgramJSON {
18
18
  /** `SL_IR_VERSION` of the writer. Absent means 1. */
19
19
  v: number
20
+ /** `SL_HASH_VERSION` of the writer, which `hash` was computed under. Absent means 1. */
21
+ h?: number
20
22
  nodes: SLNode[]
21
23
  result: NodeRef
22
24
  uniforms: UniformDecl[]
@@ -25,7 +27,10 @@ export interface ProgramJSON {
25
27
  }
26
28
 
27
29
  export function toJSON(p: Program): ProgramJSON {
28
- return { v: p.version, nodes: p.nodes, result: p.result, uniforms: p.uniforms, textures: p.textures, hash: p.hash }
30
+ return {
31
+ v: p.version, h: SL_HASH_VERSION, nodes: p.nodes, result: p.result, uniforms: p.uniforms, textures: p.textures,
32
+ hash: p.hash,
33
+ }
29
34
  }
30
35
 
31
36
  export function fromJSON(json: unknown): Program {
@@ -38,24 +43,71 @@ export function fromJSON(json: unknown): Program {
38
43
  `Update onejs-sl to read it, or rebuild it from its .sl source.`)
39
44
  }
40
45
  if (!Array.isArray(j.nodes) || j.nodes.length === 0) fail("it has no nodes")
41
- const nodes = j.nodes.map((n, i) => node(n, i))
42
- const result = j.result
43
- if (!Number.isInteger(result) || result! < 0 || result! >= nodes.length) fail(`its result ${String(result)} is not a node`)
44
- if (nodes[result!]!.type !== TYPE.VEC4) fail("its result is not a float4")
46
+ const written = j.nodes.map((n, i) => node(n, i))
47
+ if (!Number.isInteger(j.result) || j.result! < 0 || j.result! >= written.length) {
48
+ fail(`its result ${String(j.result)} is not a node`)
49
+ }
50
+ // The lowest version that has these nodes, which is what the writer
51
+ // recorded unless the file predates version 2 (whose nodes version 2 reads
52
+ // as they are) or was edited. One that claims less than its nodes need
53
+ // holds something its version never had.
54
+ const needs = programVersion(written)
55
+ if (v < needs && v >= 2) fail(`it says IR version ${v} and holds nodes version ${needs} added; it was changed after it was written`)
56
+ const { nodes, result } = v < 3 ? toOperands(written, j.result!) : { nodes: written, result: j.result! }
57
+ nodes.forEach((n, i) => {
58
+ const problem = n.k === "call" ? formProblem(n, nodes) : null
59
+ if (problem !== null) fail(`node ${i} is not a form its op has: ${problem}`)
60
+ })
61
+ if (nodes[result]!.type !== TYPE.VEC4) fail("its result is not a float4")
45
62
  const uniforms = (j.uniforms ?? []).map(uniform)
46
63
  const textures = (j.textures ?? []).map(texture)
47
64
  checkCaps({ uniforms, textures })
48
65
  for (const n of nodes) {
49
66
  if (n.k === "uniform" && n.slot >= uniforms.length) fail(`a node reads uniform slot ${n.slot}, which is not declared`)
50
67
  }
51
- const hash = hashProgram(nodes, result!, uniforms, textures)
52
- // Same version, so the same maths and the same hash; a different one means
53
- // the file was edited or damaged, and its cached shader belongs to
54
- // something else. An older version is rehashed under this one on purpose.
55
- if (v === SL_IR_VERSION && j.hash !== undefined && j.hash !== hash) {
68
+ const version = programVersion(nodes)
69
+ const hash = hashProgram(nodes, result, uniforms, textures)
70
+ // The same IR version and hash scheme, so the same hash; a different one
71
+ // means the file was edited or damaged, and its cached shader belongs to
72
+ // something else. A file an older version or scheme wrote is rehashed under
73
+ // this one on purpose.
74
+ if (v === version && (j.h ?? 1) === SL_HASH_VERSION && j.hash !== undefined && j.hash !== hash) {
56
75
  fail(`its hash ${j.hash} does not match its nodes (${hash}); it was changed after it was written`)
57
76
  }
58
- return { version: SL_IR_VERSION, nodes, result: result!, uniforms, textures, hash, loops: [] }
77
+ return { version, nodes, result, uniforms, textures, hash }
78
+ }
79
+
80
+ /**
81
+ * A graph written before IR 3, with the forms it had then moved to the one each
82
+ * op has now: an sdf's shape parameters after its id and an octave op's count
83
+ * were immediates, and are operands. Each constant goes in just before the
84
+ * call that reads it, and every later reference moves along.
85
+ */
86
+ function toOperands(written: readonly SLNode[], writtenResult: NodeRef): { nodes: SLNode[]; result: NodeRef } {
87
+ const nodes: SLNode[] = []
88
+ const at: NodeRef[] = []
89
+ const constant = (type: SLType, v: number[]): NodeRef => nodes.push({ k: "const", type, v }) - 1
90
+ written.forEach((n, i) => {
91
+ const imm = n.k === "call" ? n.imm ?? [] : []
92
+ if (n.k === "call" && n.op === SLOP.SDF && n.args.length === 1) {
93
+ if (imm.length < 5) fail(`node ${i} is an sdf with ${imm.length} immediates; one written before IR 3 has at least 5`)
94
+ const q = constant(TYPE.VEC4, [imm[1]!, imm[2]!, imm[3]!, imm[4]!])
95
+ const r = constant(TYPE.VEC2, [imm[5] ?? 0, imm[6] ?? 0])
96
+ at[i] = nodes.push({ ...n, args: [at[n.args[0]!]!, q, r], imm: [imm[0]!] }) - 1
97
+ return
98
+ }
99
+ const octaves = n.k === "call" && (n.op === SLOP.FBM || n.op === SLOP.TURBULENCE || n.op === SLOP.RIDGED)
100
+ if (octaves && n.args.length === 1) {
101
+ if (imm.length < 1) fail(`node ${i} is ${SL_NAME[n.op]} with no octave count`)
102
+ const count = constant(TYPE.FLOAT, [imm[0]!])
103
+ at[i] = nodes.push({ ...n, args: [at[n.args[0]!]!, count], imm: imm.slice(1) }) - 1
104
+ return
105
+ }
106
+ const moved: SLNode = n.k === "call" ? { ...n, args: n.args.map((a) => at[a]!) }
107
+ : n.k === "swizzle" ? { ...n, src: at[n.src]! } : n
108
+ at[i] = nodes.push(moved) - 1
109
+ })
110
+ return { nodes, result: at[writtenResult]! }
59
111
  }
60
112
 
61
113
  function node(n: unknown, i: number): SLNode {