onejs-sl 0.3.0 → 0.5.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/ir.ts CHANGED
@@ -64,12 +64,75 @@ export const SOURCE_INPUTS: Readonly<Record<SourceInputName, SLType>> = { ...INP
64
64
  /** Index into `Program.nodes`. Always refers backwards. */
65
65
  export type NodeRef = number
66
66
 
67
- export type SLNode =
68
- | { k: "const"; type: SLType; v: number[] }
69
- | { k: "input"; type: SLType; name: InputName }
70
- | { k: "uniform"; type: SLType; slot: number }
71
- | { k: "swizzle"; type: SLType; src: NodeRef; chans: number[] }
72
- | { k: "call"; type: SLType; op: SLOpCode; args: NodeRef[]; imm?: number[] }
67
+ /**
68
+ * What a value holds, beside its width. A node without one holds floats, which
69
+ * is every node before IR 4, so a program of floats is the graph and the hash
70
+ * it always was.
71
+ */
72
+ export type SLKind = "int" | "uint" | "bool"
73
+
74
+ /**
75
+ * A node that is a value. `param` is a loop's carried value at the top of an
76
+ * iteration, `proj` one result of an `if` or a `loop`.
77
+ */
78
+ export type ValueNode =
79
+ | { k: "const"; type: SLType; kind?: SLKind; v: number[] }
80
+ | { k: "input"; type: SLType; kind?: undefined; name: InputName }
81
+ | { k: "uniform"; type: SLType; kind?: undefined; slot: number }
82
+ | { k: "swizzle"; type: SLType; kind?: SLKind; src: NodeRef; chans: number[] }
83
+ | { k: "call"; type: SLType; kind?: SLKind; op: SLOpCode; args: NodeRef[]; imm?: number[] }
84
+ | { k: "param"; type: SLType; kind?: SLKind; loop: number; index: number }
85
+ | { k: "proj"; type: SLType; kind?: SLKind; src: NodeRef; index: number }
86
+
87
+ /**
88
+ * Structured control flow (IR 4), as regions of the one graph rather than as
89
+ * statements: the graph stays a pure DAG, hash consed and hashed as before, and
90
+ * an emitter places each node in the innermost region that uses it
91
+ * (`structure.ts`), which is what makes a branch skip what only it needs.
92
+ *
93
+ * if `then` or `else` are the results, per the bool `cond`; its `proj`s read them
94
+ * loop the `param`s of loop `id` start as `init`; while `cond` holds and fewer
95
+ * than `max` turns have run, they become `next`. Its `proj`s are the params
96
+ * when it stops. `cond` and `next` are computed from the params.
97
+ */
98
+ export type ControlNode =
99
+ | { k: "if"; cond: NodeRef; then: NodeRef[]; else: NodeRef[] }
100
+ | { k: "loop"; id: number; init: NodeRef[]; cond: NodeRef; next: NodeRef[]; max: number }
101
+
102
+ export type SLNode = ValueNode | ControlNode
103
+
104
+ /**
105
+ * A float to an int or a uint as the GPU converts one: as a 32 bit float,
106
+ * held to the widest such float the kind can hold, then truncated toward zero.
107
+ * The emitters clamp to these bounds, so a folded conversion
108
+ * and a computed one agree.
109
+ */
110
+ export function truncateHeld(v: number, kind: "int" | "uint"): number {
111
+ const [lo, hi] = INT_BOUNDS[kind]
112
+ return Math.trunc(Math.min(hi, Math.max(lo, Math.fround(v))))
113
+ }
114
+
115
+ /** The widest 32 bit floats an int and a uint hold, which a conversion clamps to first. */
116
+ export const INT_BOUNDS = { int: [-2147483648, 2147483520], uint: [0, 4294967040] } as const
117
+
118
+ /** The value node at `ref`. A control node is only ever read through its projs. */
119
+ export function valueAt(nodes: readonly SLNode[], ref: NodeRef): ValueNode {
120
+ const n = nodes[ref]
121
+ if (n === undefined || n.k === "if" || n.k === "loop") throw new SLError(`node ${ref} is not a value`)
122
+ return n
123
+ }
124
+
125
+ /** The refs a node reads, in order. */
126
+ export function operands(n: SLNode): NodeRef[] {
127
+ switch (n.k) {
128
+ case "swizzle": return [n.src]
129
+ case "call": return n.args
130
+ case "proj": return [n.src]
131
+ case "if": return [n.cond, ...n.then, ...n.else]
132
+ case "loop": return [...n.init, n.cond, ...n.next]
133
+ default: return []
134
+ }
135
+ }
73
136
 
74
137
  /**
75
138
  * How a host presents a uniform: a slider, a checkbox, a dropdown, a heading
@@ -171,7 +234,10 @@ export interface TextureDecl {
171
234
  }
172
235
 
173
236
  export interface Program {
174
- /** The `SL_IR_VERSION` this program's nodes mean what they mean under. */
237
+ /**
238
+ * The `SL_IR_VERSION` this program's nodes mean what they mean under: the
239
+ * lowest one that has every node it holds. See `programVersion`.
240
+ */
175
241
  version: number
176
242
  nodes: SLNode[]
177
243
  /** Must be VEC4: a program produces a colour. */
@@ -210,6 +276,24 @@ export function checkCaps(p: Pick<Program, "uniforms" | "textures">): void {
210
276
  if (p.textures.length > TEXTURE_SLOTS) throw new SLError(tooManyTextures(p.textures.length, "program"))
211
277
  }
212
278
 
279
+ /**
280
+ * The colour as written behind a colour value: the operand of the `TO_LINEAR`
281
+ * that reading a hex or a colour uniform applied, or null for a value computed
282
+ * in the working space. What a ramp's stop has to be, since a ramp blends its
283
+ * stops as written (`sl.ramp`).
284
+ */
285
+ export function writtenColour(nodes: readonly SLNode[], ref: NodeRef): NodeRef | null {
286
+ const n = nodes[ref]!
287
+ if (n.k !== "call" || n.op !== SLOP.TO_LINEAR || (n.type !== TYPE.VEC3 && n.type !== TYPE.VEC4)) return null
288
+ return n.args[0]!
289
+ }
290
+
291
+ /** Why a computed value is not a ramp's stop, in one wording for both surfaces. */
292
+ export const RAMP_STOP_COMPUTED =
293
+ "a ramp's stop is a colour as written: a hex like #ff8040, or a uniform or const that holds one. " +
294
+ "This one is computed, and a ramp blends its stops as written before it converts them, so it has " +
295
+ "no written colour to blend. Blend computed colours with lerp instead"
296
+
213
297
  /** Operations a single program may hold. Generous: a guard against a runaway builder, not a budget. */
214
298
  export const MAX_NODES = 4096
215
299
 
@@ -233,6 +317,10 @@ export class Builder {
233
317
  readonly textures: TextureDecl[] = []
234
318
  private readonly interned = new Map<string, NodeRef>()
235
319
 
320
+ /** The next loop's id: unique within the program, so two loops' params never intern to one node. */
321
+ private loops = 0
322
+ newLoop(): number { return this.loops++ }
323
+
236
324
  add(node: SLNode): NodeRef {
237
325
  if (this.nodes.length >= MAX_NODES) {
238
326
  throw new SLError(`a program may hold at most ${MAX_NODES} operations`)
@@ -245,7 +333,7 @@ export class Builder {
245
333
  return ref
246
334
  }
247
335
 
248
- call(op: SLOpCode, type: SLType, args: NodeRef[], imm?: number[]): NodeRef {
336
+ call(op: SLOpCode, type: SLType, args: NodeRef[], imm?: number[], kind?: SLKind): NodeRef {
249
337
  const arity = SL_ARITY[op]
250
338
  if (arity >= 0 && args.length !== arity) {
251
339
  throw new SLError(`${SL_NAME[op]} takes ${arity} argument(s), got ${args.length}`)
@@ -255,14 +343,19 @@ export class Builder {
255
343
  throw new SLError(`${SL_NAME[op]} refers to a node that does not exist yet`)
256
344
  }
257
345
  }
258
- return this.add(imm === undefined ? { k: "call", type, op, args } : { k: "call", type, op, args, imm })
346
+ const node: SLNode = imm === undefined ? { k: "call", type, op, args } : { k: "call", type, op, args, imm }
347
+ if (kind !== undefined) node.kind = kind
348
+ return this.add(node)
259
349
  }
260
350
 
261
- constant(v: number[]): NodeRef {
351
+ constant(v: number[], kind?: SLKind): NodeRef {
262
352
  for (const n of v) {
263
353
  if (!Number.isFinite(n)) throw new SLError(`a constant must be finite, got ${n}`)
354
+ if (kind !== undefined && !Number.isInteger(n)) throw new SLError(`an ${kind} constant must be a whole number, got ${n}`)
264
355
  }
265
- return this.add({ k: "const", type: v.length as SLType, v: v.slice() })
356
+ const node: SLNode = { k: "const", type: v.length as SLType, v: v.slice() }
357
+ if (kind !== undefined) node.kind = kind
358
+ return this.add(node)
266
359
  }
267
360
 
268
361
  uniform(name: string, type: SLType, value: number[], colour = false, control: UniformControl = {}): NodeRef {
@@ -327,9 +420,7 @@ export function reachable(nodes: SLNode[], result: NodeRef): NodeRef[] {
327
420
  const ref = stack.pop()!
328
421
  if (keep.has(ref)) continue
329
422
  keep.add(ref)
330
- const n = nodes[ref]
331
- if (n.k === "swizzle") stack.push(n.src)
332
- else if (n.k === "call") for (const a of n.args) stack.push(a)
423
+ for (const a of operands(nodes[ref]!)) stack.push(a)
333
424
  }
334
425
  // Ascending, which is still topological because a node only refers backwards.
335
426
  return [...keep].sort((x, y) => x - y)
@@ -351,12 +442,17 @@ export function inputsUsed(p: Program): InputName[] {
351
442
 
352
443
  /** Structural key for hash consing. Order matters and is fixed by the node shape. */
353
444
  function keyOf(n: SLNode): string {
445
+ const kind = "kind" in n && n.kind !== undefined ? `@${n.kind}` : ""
354
446
  switch (n.k) {
355
- case "const": return `c:${n.type}:${n.v.map(fixed).join(",")}`
447
+ case "const": return `c:${n.type}:${n.v.map((v) => constText(v, n.kind)).join(",")}${kind}`
356
448
  case "input": return `i:${n.name}`
357
449
  case "uniform": return `u:${n.slot}`
358
- case "swizzle": return `s:${n.src}:${n.chans.join("")}`
359
- case "call": return `f:${n.op}:${n.args.join(",")}:${(n.imm ?? []).map(fixed).join(",")}`
450
+ case "swizzle": return `s:${n.src}:${n.chans.join("")}${kind}`
451
+ case "call": return `f:${n.op}:${n.args.join(",")}:${(n.imm ?? []).map(fixed).join(",")}${kind}`
452
+ case "param": return `p:${n.loop}:${n.index}`
453
+ case "proj": return `j:${n.src}:${n.index}`
454
+ case "if": return `?:${n.cond}:${n.then.join(",")}:${n.else.join(",")}`
455
+ case "loop": return `L:${n.max}:${n.init.join(",")}:${n.cond}:${n.next.join(",")}`
360
456
  }
361
457
  }
362
458
 
@@ -369,6 +465,11 @@ export function fixed(n: number): string {
369
465
  return Object.is(n, -0) ? "0" : n.toPrecision(9)
370
466
  }
371
467
 
468
+ /** A constant's value as keyed and hashed: a float at `fixed`'s precision, an int, a uint or a bool exactly. */
469
+ function constText(n: number, kind: SLKind | undefined): string {
470
+ return kind === undefined ? fixed(n) : String(n)
471
+ }
472
+
372
473
  export function widthName(t: SLType): string {
373
474
  return t === 1 ? "float" : `vec${t}`
374
475
  }
@@ -402,41 +503,113 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
402
503
  // digests, so two graphs that compute the same thing agree however they were
403
504
  // assembled. It also ignores nodes not reachable from the result, which is
404
505
  // correct: dead nodes generate no shader code.
506
+ //
507
+ // NORMALISED, so two ways of writing one computation agree (hash version 2).
508
+ // A commutative op's operands are digested in sorted order, so `a + b` is
509
+ // `b + a`; and a swizzle of a constant is digested as the constant it picks,
510
+ // so `8 * uv` (8 broadcast by a swizzle) is `uv * 8` (a float2 constant).
511
+ // Only where every backend computes the same bits either way: min and max
512
+ // are left out, since which of -0 and +0 they return depends on the order.
405
513
  const digest = new Map<NodeRef, string>()
514
+ const depth = loopDepths(nodes, result)
406
515
 
407
516
  const of = (ref: NodeRef): string => {
408
517
  const seen = digest.get(ref)
409
518
  if (seen !== undefined) return seen
410
- const n = nodes[ref]
519
+ const n = nodes[ref]!
411
520
  let body: string
412
521
  switch (n.k) {
413
- case "const": body = `c:${n.type}:${n.v.map(fixed).join(",")}`; break
522
+ // Control nodes have no type of their own: their projs carry it.
523
+ case "if": {
524
+ body = `?:${of(n.cond)}:${n.then.map(of).join(",")}:${n.else.map(of).join(",")}`
525
+ const d = fnv1a(body)
526
+ digest.set(ref, d)
527
+ return d
528
+ }
529
+ case "loop": {
530
+ body = `L:${n.max}:${n.init.map(of).join(",")}:${of(n.cond)}:${n.next.map(of).join(",")}`
531
+ const d = fnv1a(body)
532
+ digest.set(ref, d)
533
+ return d
534
+ }
535
+ // By nesting depth, not by id: two programs that nest the same
536
+ // loops the same way agree however their ids were handed out, and
537
+ // two loops nested in one another never share a digest for a param.
538
+ case "param": body = `p:${depth.get(n.loop) ?? 1}:${n.index}`; break
539
+ case "proj": body = `j:${of(n.src)}:${n.index}`; break
540
+ case "const": body = `c:${n.type}:${n.v.map((v) => constText(v, n.kind)).join(",")}`; break
414
541
  case "input": body = `i:${n.name}`; break
415
542
  // By slot rather than by name: the slot is what the generated shader
416
543
  // lays out, so two programs whose uniforms differ only in name still
417
544
  // produce different shaders and must not share a hash. The names go
418
545
  // in separately below.
419
546
  case "uniform": body = `u:${n.slot}:${n.type}`; break
420
- case "swizzle": body = `s:${of(n.src)}:${n.chans.join("")}`; break
421
- case "call": body = `f:${n.op}:${n.args.map(of).join(",")}:${(n.imm ?? []).map(fixed).join(",")}`; break
547
+ case "swizzle": {
548
+ const src = nodes[n.src]!
549
+ body = src.k === "const" && src.kind === n.kind
550
+ ? `c:${n.type}:${n.chans.map((c) => constText(src.v[c]!, src.kind)).join(",")}`
551
+ : `s:${of(n.src)}:${n.chans.join("")}`
552
+ break
553
+ }
554
+ case "call": {
555
+ const args = n.args.map(of)
556
+ if (COMMUTATIVE.has(n.op)) args.sort()
557
+ body = `f:${n.op}:${args.join(",")}:${(n.imm ?? []).map(fixed).join(",")}`
558
+ break
559
+ }
422
560
  }
423
- const d = fnv1a(body + "|" + n.type)
561
+ const d = fnv1a(body + "|" + n.type + (n.kind === undefined ? "" : n.kind))
424
562
  digest.set(ref, d)
425
563
  return d
426
564
  }
427
565
 
428
566
  // The IR version is in the hash, so a change to what an opcode means in the
429
- // IR changes every hash and every cache keyed by one recompiles rather than
430
- // serving the old maths. A change to the helper library alone (a noise's
431
- // arithmetic, say) is not one: every host includes the library's text when
432
- // it compiles, and none caches a compiled program by hash across a
433
- // library update, so the new text reaches every shader without a bump.
434
- const parts: string[] = [`v${SL_HASH_VERSION}:${SL_IR_VERSION}`, of(result)]
567
+ // IR changes the hash of every program using it, and every cache keyed by
568
+ // one recompiles rather than serving the old maths. It is the PROGRAM's
569
+ // version, the lowest that has its nodes, so a version that adds a node
570
+ // leaves every program without one on the hash it had. A change to the
571
+ // helper library alone (a noise's arithmetic, say) is not one: every host
572
+ // includes the library's text when it compiles, and none caches a compiled
573
+ // program by hash across a library update, so the new text reaches every
574
+ // shader without a bump.
575
+ const parts: string[] = [`v${SL_HASH_VERSION}:${programVersion(nodes, result)}`, of(result)]
435
576
  for (const u of uniforms) parts.push(`U:${u.name}:${u.type}:${u.value.map(fixed).join(",")}`)
436
577
  for (const t of textures) parts.push(`T:${t.name}:${t.slot}`)
437
578
  return fnv1a(parts.join("|"))
438
579
  }
439
580
 
581
+ /**
582
+ * Each reachable loop's nesting depth, 1 for a loop inside no other: 1 more than
583
+ * the deepest loop whose params it reads. Read from the graph, so it does not
584
+ * depend on the order anything was built or walked in.
585
+ */
586
+ function loopDepths(nodes: readonly SLNode[], result: NodeRef): Map<number, number> {
587
+ const free = new Map<NodeRef, Set<number>>()
588
+ const loops = new Map<number, Extract<SLNode, { k: "loop" }>>()
589
+ const freeOf = (ref: NodeRef): Set<number> => free.get(ref) ?? new Set()
590
+ for (const ref of reachable(nodes as SLNode[], result)) {
591
+ const n = nodes[ref]!
592
+ const out = new Set<number>()
593
+ if (n.k === "param") out.add(n.loop)
594
+ for (const a of operands(n)) for (const l of freeOf(a)) out.add(l)
595
+ if (n.k === "loop") { out.delete(n.id); loops.set(n.id, n) }
596
+ free.set(ref, out)
597
+ }
598
+ const loopFree = new Map<number, Set<number>>()
599
+ for (const [ref, n] of nodes.entries()) if (n.k === "loop" && free.has(ref)) loopFree.set(n.id, free.get(ref)!)
600
+ const depth = new Map<number, number>()
601
+ const of = (id: number): number => {
602
+ const seen = depth.get(id)
603
+ if (seen !== undefined) return seen
604
+ let d = 1
605
+ for (const outer of loopFree.get(id) ?? []) d = Math.max(d, of(outer) + 1)
606
+ depth.set(id, d)
607
+ return d
608
+ }
609
+ for (const id of loops.keys()) of(id)
610
+ return depth
611
+ }
612
+
440
613
  /** The control fields of `c` that are set, copied, in `CONTROL_FIELDS` order. */
441
614
  export function controlOf(c: UniformControl): UniformControl {
442
615
  const out: UniformControl = {}
@@ -449,8 +622,91 @@ export function controlOf(c: UniformControl): UniformControl {
449
622
  return out
450
623
  }
451
624
 
452
- /** Bumped when the hashing scheme changes, which invalidates generated shaders. */
453
- export const SL_HASH_VERSION = 1
625
+ /**
626
+ * The lowest `SL_IR_VERSION` that has every node here, and never below 2.
627
+ *
628
+ * A version that adds a node, or a form of one, raises only the programs that
629
+ * hold it. Everything else keeps the version, the hash and the JSON it had, so
630
+ * a program recorded before the bump still finds its shader, and a reader that
631
+ * predates the bump still reads it. Every node counts, used or not: the JSON
632
+ * carries them all, and an older reader has to refuse by the version rather
633
+ * than halfway through the nodes.
634
+ *
635
+ * Only the nodes the result reads count: lowering leaves dead nodes behind (an
636
+ * int constant a fold used up, say), and they draw nothing. `toJSON` writes
637
+ * only the live ones, so an older reader never meets a dead newer node.
638
+ */
639
+ export function programVersion(nodes: readonly SLNode[], result: NodeRef): number {
640
+ let v = 2
641
+ for (const ref of reachable(nodes as SLNode[], result)) v = Math.max(v, nodeVersion(nodes[ref]!))
642
+ return v
643
+ }
644
+
645
+ /** The ops IR 4 added. */
646
+ const IR4_OPS = new Set<number>([
647
+ SLOP.CAST, SLOP.LT, SLOP.LE, SLOP.GT, SLOP.GE, SLOP.EQ, SLOP.NE, SLOP.AND, SLOP.OR, SLOP.NOT,
648
+ SLOP.BIT_AND, SLOP.BIT_OR, SLOP.BIT_XOR, SLOP.BIT_NOT, SLOP.SHL, SLOP.SHR, SLOP.CHOOSE,
649
+ ])
650
+
651
+ /**
652
+ * The version that added this node's form. An sdf or octave call with one
653
+ * operand is the form before IR 3, which only a file an older compiler wrote
654
+ * holds, and `fromJSON` moves it to the form IR 3 has.
655
+ */
656
+ function nodeVersion(n: SLNode): number {
657
+ if (n.k === "if" || n.k === "loop" || n.k === "param" || n.k === "proj" || n.kind !== undefined) return 4
658
+ if (n.k !== "call") return 2
659
+ if (IR4_OPS.has(n.op)) return 4
660
+ switch (n.op) {
661
+ case SLOP.SAMPLE_LOD: return 3
662
+ case SLOP.SDF:
663
+ case SLOP.FBM:
664
+ case SLOP.TURBULENCE:
665
+ case SLOP.RIDGED:
666
+ return n.args.length > 1 ? 3 : 2
667
+ default: return 2
668
+ }
669
+ }
670
+
671
+ /**
672
+ * What is wrong with a call's form, for the ops whose operand list alone does
673
+ * not say it, or null. The builder only makes the right ones; this is for a
674
+ * program read from JSON, which an emitter would otherwise print as something
675
+ * else.
676
+ *
677
+ * SDF the point, a float4 and a float2 holding the six shape parameters,
678
+ * the shape id the one immediate
679
+ * FBM, TURBULENCE, RIDGED the point and the octave count, FBM's kind the
680
+ * one immediate
681
+ */
682
+ export function formProblem(n: Extract<SLNode, { k: "call" }>, nodes: readonly SLNode[]): string | null {
683
+ const widths = n.args.map((a) => { const v = nodes[a]!; return v.k === "if" || v.k === "loop" ? "control" : v.type }).join(",")
684
+ switch (n.op) {
685
+ case SLOP.SDF:
686
+ if (widths === "2,4,2" && n.imm?.length === 1) return null
687
+ return "an sdf takes a float2, a float4 and a float2, and its shape as the one immediate"
688
+ case SLOP.FBM:
689
+ case SLOP.TURBULENCE:
690
+ case SLOP.RIDGED: {
691
+ const rest = n.op === SLOP.FBM ? 1 : 0
692
+ if (widths === "2,1" && (n.imm?.length ?? 0) === rest) return null
693
+ return `${SL_NAME[n.op]} takes a float2 and its octave count, a float`
694
+ }
695
+ default:
696
+ return null
697
+ }
698
+ }
699
+
700
+ /** The ops whose two operands the hash takes in either order: exact in IEEE arithmetic on every backend. */
701
+ const COMMUTATIVE = new Set<number>([SLOP.ADD, SLOP.MUL, SLOP.DOT, SLOP.DISTANCE])
702
+
703
+ /**
704
+ * Bumped when the hashing scheme changes, which invalidates generated shaders.
705
+ *
706
+ * 1 the Merkle hash over the reachable graph
707
+ * 2 normalised: commutative operands in either order, a swizzle of a constant as the constant
708
+ */
709
+ export const SL_HASH_VERSION = 2
454
710
 
455
711
  /**
456
712
  * Bumped whenever the IR changes in a way a reader has to know about: a new
@@ -467,13 +723,17 @@ export const SL_HASH_VERSION = 1
467
723
  * records it again, for no gain.
468
724
  *
469
725
  * A reader accepts every version up to its own and refuses a newer one with a
470
- * message naming both, the rule the particle wire and fx follow. Part of the
471
- * hash, so a bump recompiles every cached shader.
726
+ * message naming both, the rule the particle wire and fx follow. A program's
727
+ * version is the lowest that has its nodes (`programVersion`), and that is what
728
+ * its hash carries, so a bump rehashes only the programs using what it added.
472
729
  *
473
730
  * 1 the first versioned IR
474
731
  * 2 an SDF call carries up to six shape parameters, not four (#129)
732
+ * 3 SAMPLE_LOD; an SDF's shape parameters and a noise's octave count are
733
+ * operands, where they were immediates
734
+ * 4 control flow (if, loop, their params and projs), ints, uints and bools
475
735
  */
476
- export const SL_IR_VERSION = 2
736
+ export const SL_IR_VERSION = 4
477
737
 
478
738
  function fnv1a(s: string): string {
479
739
  let h = 0x811c9dc5
@@ -152,13 +152,13 @@ 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 **no backend implements them**; every emitter throws on them. |
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
164
  A `.sl` file and the EDSL are held to the same caps, `UNIFORM_SLOTS` (16) and
package/src/lang/ast.ts CHANGED
@@ -11,41 +11,71 @@
11
11
  * pass over the source.
12
12
  */
13
13
 
14
+ import type { SLKind } from "../ir"
14
15
  import type { Pos } from "./lexer"
15
16
 
16
- /** The four value types, under their HLSL names. `texture2D` is not one: it declares a slot. */
17
- export type TypeName = "float" | "float2" | "float3" | "float4"
17
+ /** The value types, under their HLSL names. `texture2D` is not one: it declares a slot. */
18
+ export type TypeName = "float" | "float2" | "float3" | "float4" | "int" | "uint" | "bool"
18
19
 
19
20
  export const TYPE_WIDTH: Record<TypeName, 1 | 2 | 3 | 4> = {
20
- float: 1, float2: 2, float3: 3, float4: 4,
21
+ float: 1, float2: 2, float3: 3, float4: 4, int: 1, uint: 1, bool: 1,
22
+ }
23
+
24
+ /** What a type holds beside its width; the floats hold nothing else. */
25
+ export const TYPE_KIND: Record<TypeName, SLKind | undefined> = {
26
+ float: undefined, float2: undefined, float3: undefined, float4: undefined, int: "int", uint: "uint", bool: "bool",
21
27
  }
22
28
 
23
29
  export type BinaryOp =
24
30
  | "+" | "-" | "*" | "/" | "%"
25
31
  | "<" | "<=" | ">" | ">=" | "==" | "!="
26
32
  | "&&" | "||"
33
+ | "&" | "|" | "^" | "<<" | ">>"
27
34
 
28
35
  export type Expr =
29
- | { k: "num"; value: number; pos: Pos }
36
+ /**
37
+ * `whole` is a number written with no point and no exponent, `3` not `3.0`:
38
+ * a float, except beside an int, where it is an int. `unsigned` is `3u`.
39
+ */
40
+ | { k: "num"; value: number; whole: boolean; unsigned?: boolean; pos: Pos }
41
+ | { k: "bool"; value: boolean; pos: Pos }
30
42
  | { k: "hex"; hex: string; pos: Pos }
31
43
  | { k: "ident"; name: string; pos: Pos }
32
44
  /** A swizzle, or the shape half of `sdf.circle`. Which one is decided later. */
33
45
  | { k: "member"; obj: Expr; name: string; pos: Pos }
34
46
  | { k: "call"; callee: Expr; args: Expr[]; pos: Pos }
35
- | { k: "unary"; op: "-" | "+" | "!"; arg: Expr; pos: Pos }
47
+ | { k: "unary"; op: "-" | "+" | "!" | "~"; arg: Expr; pos: Pos }
36
48
  | { k: "binary"; op: BinaryOp; a: Expr; b: Expr; pos: Pos }
37
49
  | { k: "cond"; cond: Expr; then: Expr; else: Expr; pos: Pos }
38
50
 
39
- export type AssignOp = "=" | "+=" | "-=" | "*=" | "/=" | "%="
51
+ export type AssignOp = "=" | "+=" | "-=" | "*=" | "/=" | "%=" | "&=" | "|=" | "^=" | "<<=" | ">>="
52
+
53
+ /** One `case` group of a switch: its labels, `null` for `default`, and its body without the closing break. */
54
+ export interface SwitchCase {
55
+ labels: Array<Expr | null>
56
+ body: Stmt[]
57
+ /** Whether it ended in a break, which is dropped from `body`. */
58
+ closed: boolean
59
+ pos: Pos
60
+ }
40
61
 
41
62
  export type Stmt =
42
63
  | { k: "var"; type: TypeName; name: string; init: Expr; pos: Pos }
43
64
  /** A compile time constant: usable as a value and as a `for` bound. */
44
65
  | { k: "const"; type: TypeName; name: string; init: Expr; pos: Pos }
66
+ /** `x++` is `x += 1`, with the 1 whole. */
45
67
  | { k: "assign"; target: Expr; op: AssignOp; value: Expr; pos: Pos }
46
68
  | { k: "if"; cond: Expr; then: Stmt[]; else: Stmt[]; pos: Pos }
47
- | { k: "for"; counter: string; from: Expr; to: Expr; inclusive: boolean; step: Expr; body: Stmt[]; pos: Pos }
69
+ /** `for (type counter = from; cond; update)`: the update changes the counter and nothing else. */
70
+ | { k: "for"; type: TypeName; counter: string; from: Expr; cond: Expr; update: Extract<Stmt, { k: "assign" }>; body: Stmt[]; pos: Pos }
71
+ | { k: "while"; cond: Expr; body: Stmt[]; pos: Pos }
72
+ | { k: "break"; pos: Pos }
73
+ | { k: "continue"; pos: Pos }
74
+ /** Cases never fall through: each ends in break, return or continue, and the break is dropped here. */
75
+ | { k: "switch"; value: Expr; cases: SwitchCase[]; pos: Pos }
48
76
  | { k: "return"; value: Expr; pos: Pos }
77
+ /** `{ ... }` on its own: a body with its own scope, as a case's braces are. */
78
+ | { k: "block"; body: Stmt[]; pos: Pos }
49
79
 
50
80
  export interface Param {
51
81
  type: TypeName
@@ -125,12 +125,10 @@ value(SLOP.LUMINANCE, 1, 1, (a) => sl.luminance(colourOperand("luminance", a[0]!
125
125
  value(SLOP.NOISE, 1, 1, (a) => sl.noise(point("noise", a[0]!) as never))
126
126
  value(SLOP.SIMPLEX, 1, 1, (a) => sl.simplex(point("simplex", a[0]!) as never))
127
127
  value(SLOP.VORONOI, 1, 1, (a) => sl.voronoi(point("voronoi", a[0]!) as never))
128
- // The octave count is an immediate, so it has to be a number at parse time.
129
- // `lower.ts` folds constants before it gets here and rejects anything else with
130
- // a message that says why, rather than letting this cast be the error.
131
- value(SLOP.FBM, 1, 2, (a) => sl.fbm(point("fbm", a[0]!) as never, octaves("fbm", a[1])))
132
- value(SLOP.TURBULENCE, 1, 2, (a) => sl.turbulence(point("turbulence", a[0]!) as never, octaves("turbulence", a[1])))
133
- 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))
134
132
 
135
133
  /** Every field is sampled at a float2. */
136
134
  function point(fn: string, v: Num): Val {
@@ -138,20 +136,10 @@ function point(fn: string, v: Num): Val {
138
136
  }
139
137
 
140
138
  special(SLOP.SAMPLE, 2, 2)
139
+ special(SLOP.SAMPLE_LOD, 3, 3)
141
140
  special(SLOP.SDF, 1, 7)
142
141
  special(SLOP.RAMP, 3, 64)
143
142
 
144
- function octaves(fn: string, v: Num | undefined): number {
145
- if (v === undefined) return 3
146
- if (typeof v !== "number") {
147
- throw new SLError(
148
- `${fn}'s octave count is part of the operation, so it has to be a constant. ` +
149
- `A uniform or a computed value cannot change how many octaves the shader runs.`,
150
- )
151
- }
152
- return v
153
- }
154
-
155
143
  export const BUILTINS: Readonly<Record<string, Builtin>> = table
156
144
 
157
145
  /**