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/src/sl.ts CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { parseColor as parseHex } from "./color"
26
26
  import {
27
- Builder, INPUTS, SLError, SL_IR_VERSION, TYPE, hashProgram, widthName,
27
+ Builder, INPUTS, RAMP_STOP_COMPUTED, SLError, TYPE, hashProgram, programVersion, widthName, writtenColour,
28
28
  type InputName, type NodeRef, type Program, type SLNode, type SLType, type UniformControl,
29
29
  } from "./ir"
30
30
  import { SLOP, type SLOpCode } from "./ops"
@@ -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.
@@ -288,13 +288,12 @@ export function program(fn: (inputs: ProgramInputs) => Vec4): Program {
288
288
  }
289
289
  const nodes: SLNode[] = b.nodes.slice()
290
290
  return {
291
- version: SL_IR_VERSION,
291
+ version: programVersion(nodes),
292
292
  nodes,
293
293
  result: out.ref,
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.
@@ -545,6 +533,10 @@ export function uniformDefaults(p: Program): number[] {
545
533
  * fields `fx.noise` draws: a value or simplex base, layered as fBm, or the
546
534
  * turbulence and ridged variants built on simplex. A program has no seed;
547
535
  * offset the input for a different field. Octaves are 1 to 4, as in fx.
536
+ *
537
+ * The octave count may be a value, a uniform say, rounded to the nearest whole
538
+ * number and held to 1 to 4 where it is read. It is an operand either way
539
+ * (IR 3); a constant one is checked here, and prints as the number it is.
548
540
  */
549
541
  export function noise(p: Vec2): Float {
550
542
  return mk(p.owner, p.owner.call(SLOP.NOISE, TYPE.FLOAT, [p.ref]), TYPE.FLOAT)
@@ -552,24 +544,31 @@ export function noise(p: Vec2): Float {
552
544
  export function simplex(p: Vec2): Float {
553
545
  return mk(p.owner, p.owner.call(SLOP.SIMPLEX, TYPE.FLOAT, [p.ref]), TYPE.FLOAT)
554
546
  }
555
- function octaveCount(name: string, octaves: number): number {
556
- if (!Number.isInteger(octaves) || octaves < 1 || octaves > 4) {
557
- throw new SLError(`${name} octaves must be a whole number from 1 to 4, got ${octaves}`)
547
+ /**
548
+ * An octave op: the point and the count as operands, with `rest` (fbm's kind)
549
+ * the immediates. A constant count is checked here.
550
+ */
551
+ function octaves(op: SLOpCode, name: string, p: Vec2, count: Num, rest: number[]): Float {
552
+ const c = components(p.owner, count)
553
+ if (c.length !== 1) throw new SLError(`${name}'s octave count is one number, and this is a ${widthName(c.length as SLType)}`)
554
+ const n = c[0]!
555
+ if (typeof n === "number" && (!Number.isInteger(n) || n < 1 || n > 4)) {
556
+ throw new SLError(`${name} octaves must be a whole number from 1 to 4, got ${n}`)
558
557
  }
559
- return octaves
558
+ const v = typeof n === "number" ? float(n) : n
559
+ return mk(p.owner, p.owner.call(op, TYPE.FLOAT, [p.ref, v.ref], rest), TYPE.FLOAT)
560
560
  }
561
561
  /** Layered noise. `base` picks the grid ("value", the default) or triangles ("simplex"). */
562
- export function fbm(p: Vec2, octaves = 3, base: "value" | "simplex" = "value"): Float {
563
- const kind = base === "simplex" ? 1 : 0
564
- return mk(p.owner, p.owner.call(SLOP.FBM, TYPE.FLOAT, [p.ref], [octaveCount("fbm", octaves), kind]), TYPE.FLOAT)
562
+ export function fbm(p: Vec2, octaveCount: Num = 3, base: "value" | "simplex" = "value"): Float {
563
+ return octaves(SLOP.FBM, "fbm", p, octaveCount, [base === "simplex" ? 1 : 0])
565
564
  }
566
565
  /** Sum of |simplex| octaves: creases that stack into veins and licks. Fire, smoke, marble. */
567
- export function turbulence(p: Vec2, octaves = 3): Float {
568
- return mk(p.owner, p.owner.call(SLOP.TURBULENCE, TYPE.FLOAT, [p.ref], [octaveCount("turbulence", octaves)]), TYPE.FLOAT)
566
+ export function turbulence(p: Vec2, octaveCount: Num = 3): Float {
567
+ return octaves(SLOP.TURBULENCE, "turbulence", p, octaveCount, [])
569
568
  }
570
569
  /** The turbulence crease made bright and squared: ridges, lightning, cracks. */
571
- export function ridged(p: Vec2, octaves = 3): Float {
572
- return mk(p.owner, p.owner.call(SLOP.RIDGED, TYPE.FLOAT, [p.ref], [octaveCount("ridged", octaves)]), TYPE.FLOAT)
570
+ export function ridged(p: Vec2, octaveCount: Num = 3): Float {
571
+ return octaves(SLOP.RIDGED, "ridged", p, octaveCount, [])
573
572
  }
574
573
 
575
574
  /** "#rgb", "#rrggbb" or "#rrggbbaa" to 0..1 components. Shared with fx. */
@@ -593,18 +592,23 @@ export function parseColor(hex: string): [number, number, number, number] {
593
592
  *
594
593
  * This is the argument for the EDSL in miniature. A library function that
595
594
  * composes from primitives costs one function here and nothing anywhere else.
595
+ *
596
+ * A stop is a colour as written: a hex, four numbers, or a value that is a
597
+ * colour as written, which is one `color`, `sl.uniform.colour` or a hex
598
+ * literal made (a colour uniform, say). The ramp blends the colours as
599
+ * written, so a value computed any other way, already in the working space,
600
+ * has nothing it could blend, and is refused.
596
601
  */
597
- export function ramp(t: Num, stops: Array<string | [number, number, number, number]>): Vec4 {
602
+ export function ramp(t: Num, stops: Array<string | [number, number, number, number] | Vec3 | Vec4>): Vec4 {
598
603
  if (stops.length < 2) throw new SLError(`a ramp needs at least 2 stops, got ${stops.length}`)
599
604
  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.
605
+ const written = stops.map((s) => (s instanceof Val ? asWritten(s) : null))
606
+ // Each stop is built immediately before the mix that consumes it. The graph
607
+ // and the hash are the same either way (the hash is over the shape, not the
608
+ // storage order); the order only mattered to the VM's register allocator.
607
609
  const colourAt = (i: number) => {
610
+ const w = written[i]
611
+ if (w !== null && w !== undefined) return w
608
612
  const v = typeof stops[i] === "string" ? parseColor(stops[i] as string) : (stops[i] as number[])
609
613
  return vec4(v[0], v[1], v[2], v[3])
610
614
  }
@@ -622,6 +626,18 @@ export function ramp(t: Num, stops: Array<string | [number, number, number, numb
622
626
  return toLinear(out)
623
627
  }
624
628
 
629
+ /**
630
+ * A colour value as it was written, a float4: the operand of the `toLinear`
631
+ * that reading it as a colour applied, with alpha 1 when it has three
632
+ * components. Anything else was computed in the working space.
633
+ */
634
+ function asWritten(v: Vec3 | Vec4): Vec4 {
635
+ const ref = writtenColour(v.owner.nodes, v.ref)
636
+ if (ref === null) throw new SLError(RAMP_STOP_COMPUTED)
637
+ const raw = mk(v.owner, ref, v.width) as Val
638
+ return v.width === TYPE.VEC4 ? (raw as Vec4) : vec4(raw, 1)
639
+ }
640
+
625
641
  /**
626
642
  * Signed distance to a shape, positive outside and negative inside.
627
643
  *
@@ -637,27 +653,45 @@ export function ramp(t: Num, stops: Array<string | [number, number, number, numb
637
653
  *
638
654
  * A shape takes up to `SL_SDF_PARAMS[kind]` parameters, six at most. Fewer
639
655
  * fills zeros, which is all a program could pass before six shapes grew their
640
- * fifth and sixth.
656
+ * fifth and sixth. A vector parameter counts as its components, so a box's
657
+ * half extents can be one float2.
641
658
  *
642
659
  * Which parameters a shape takes is the shape's own business; `circle` wants a
643
660
  * radius, `roundedBox` wants half extents and a corner. See `lib/sdf2d.hlsl`.
661
+ *
662
+ * A parameter may be any value, a uniform or one computed per pixel. The six
663
+ * are operands either way (IR 3), a float4 and a float2, and constant ones
664
+ * print as the numbers they are.
644
665
  */
645
- export function sdf(kind: SlSdfKind, p: Vec2, params: number[] = []): Float {
666
+ export function sdf(kind: SlSdfKind, p: Vec2, params: Num[] = []): Float {
646
667
  const id = SL_SDF_SHAPES[kind]
647
668
  if (id === undefined) throw new SLError(`"${kind}" is not a shape; see SL_SDF_SHAPES for the 42 names`)
669
+ const c = params.flatMap((v) => components(p.owner, v))
648
670
  // At least four, so a program that passed a shape more than it reads, which
649
671
  // was harmless when every shape took four, still builds.
650
672
  const most = Math.max(4, SL_SDF_PARAMS[kind])
651
- if (params.length > most) {
652
- throw new SLError(`sl.sdf("${kind}") takes at most ${most} parameters and was given ${params.length}`)
673
+ if (c.length > most) {
674
+ throw new SLError(`sl.sdf("${kind}") takes at most ${most} parameters and was given ${c.length}`)
653
675
  }
654
- for (const v of params) {
655
- if (!Number.isFinite(v)) throw new SLError(`sl.sdf parameters must be finite, got ${v}`)
676
+ for (const v of c) {
677
+ if (typeof v === "number" && !Number.isFinite(v)) throw new SLError(`sl.sdf parameters must be finite, got ${v}`)
656
678
  }
657
- // Four always, as before, and a fifth and sixth only when given: a program
658
- // using four or fewer is the same node, and hashes the same, as it was.
659
- const imm = [params[0] ?? 0, params[1] ?? 0, params[2] ?? 0, params[3] ?? 0, ...params.slice(4)]
660
- return mk(p.owner, p.owner.call(SLOP.SDF, TYPE.FLOAT, [p.ref], [id, ...imm]), TYPE.FLOAT)
679
+ const q = vec4(c[0] ?? 0, c[1] ?? 0, c[2] ?? 0, c[3] ?? 0)
680
+ const r = vec2(c[4] ?? 0, c[5] ?? 0)
681
+ return mk(p.owner, p.owner.call(SLOP.SDF, TYPE.FLOAT, [p.ref, q.ref, r.ref], [id]), TYPE.FLOAT)
682
+ }
683
+
684
+ /**
685
+ * A value as its components: numbers for a constant, which is what it would
686
+ * have been written as, and a float per component otherwise.
687
+ */
688
+ function components(b: Builder, v: Num): Num[] {
689
+ if (typeof v === "number") return [v]
690
+ if (v.owner !== b) throw new SLError("a value from another program cannot be used in this one")
691
+ const n = b.nodes[v.ref]!
692
+ if (n.k === "const") return n.v.slice()
693
+ if (v.width === 1) return [v]
694
+ return Array.from({ length: v.width }, (_, i) => v.swz("xyzw"[i] as "x"))
661
695
  }
662
696
 
663
697
  /** Distance to the nearest point of a jittered lattice. Cells, cracks, scales. */
@@ -712,7 +746,14 @@ export const uniform = {
712
746
  }
713
747
 
714
748
  export interface Texture {
749
+ /** The colour at `uv`, filtered as the texture's own settings say. */
715
750
  sample(uv: Vec2): Vec4
751
+ /**
752
+ * The colour at `uv` from mip level `lod`: 0 is the full size texture, 1
753
+ * half, and a fraction blends two levels where the texture's filter does.
754
+ * A texture with no mips has only level 0.
755
+ */
756
+ sampleLevel(uv: Vec2, lod: Num): Vec4
716
757
  }
717
758
 
718
759
  export function texture(name: string): Texture {
@@ -723,6 +764,15 @@ export function texture(name: string): Texture {
723
764
  if (uv.owner !== b) throw new SLError("a value from another program cannot be used in this one")
724
765
  return mk(b, b.call(SLOP.SAMPLE, TYPE.VEC4, [uv.ref], [slot]), TYPE.VEC4)
725
766
  },
767
+ sampleLevel(uv: Vec2, lod: Num): Vec4 {
768
+ if (uv.owner !== b) throw new SLError("a value from another program cannot be used in this one")
769
+ const level = typeof lod === "number" ? b.constant([lod]) : lod
770
+ if (typeof level !== "number") {
771
+ if (level.owner !== b) throw new SLError("a value from another program cannot be used in this one")
772
+ if (level.width !== 1) throw new SLError(`a mip level is one number, and this is a ${widthName(level.width)}`)
773
+ }
774
+ return mk(b, b.call(SLOP.SAMPLE_LOD, TYPE.VEC4, [uv.ref, typeof level === "number" ? level : level.ref], [slot]), TYPE.VEC4)
775
+ },
726
776
  }
727
777
  }
728
778
 
@@ -733,34 +783,13 @@ export function texture(name: string): Texture {
733
783
  *
734
784
  * Honest about what it is: a macro, not a loop. It covers fbm, layered noise and
735
785
  * 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.
786
+ * loop with a runtime count is `Specs/SL_NEXT.md` proposal 3b.
739
787
  */
740
788
  export function repeat<T extends Val>(n: number, body: (i: number, acc: T) => T, seed: T): T {
741
789
  if (!Number.isInteger(n) || n < 0 || n > 64) {
742
790
  throw new SLError(`repeat count must be a whole number from 0 to 64, got ${n}`)
743
791
  }
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
792
+ let acc = seed
793
+ for (let i = 0; i < n; i++) acc = body(i, acc)
794
+ return acc
766
795
  }
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,
@@ -53,6 +52,11 @@ function emitWeb(p: Program, lang: WebLanguage): string {
53
52
  const emitted = new Set<number>()
54
53
  const T = (t: SLType) => (W ? (t === 1 ? "f32" : `vec${t}f`) : t === 1 ? "float" : `vec${t}`)
55
54
  const typeOf = (ref: number) => p.nodes[ref].type
55
+ /** A constant node's value, or null for anything computed. */
56
+ const constantOf = (ref: number): number[] | null => {
57
+ const c = p.nodes[ref]!
58
+ return c.k === "const" ? c.v : null
59
+ }
56
60
 
57
61
  /** A node's value widened to `t` when it is a scalar and `t` is not. */
58
62
  const splat = (ref: number, t: SLType) => (typeOf(ref) === 1 && t > 1 ? `${T(t)}(n${ref})` : `n${ref}`)
@@ -159,7 +163,7 @@ function emitWeb(p: Program, lang: WebLanguage): string {
159
163
  case SLOP.MIX: return `mix(${s[0]}, ${s[1]}, ${s[2]})`
160
164
  case SLOP.STEP: return `step(${s[0]}, ${s[1]})`
161
165
  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
166
+ // As the HLSL emitter does it: branchless, so every
163
167
  // backend agrees on the edge value.
164
168
  case SLOP.SELECT: {
165
169
  const c = typeOf(n.args[0])
@@ -169,44 +173,72 @@ function emitWeb(p: Program, lang: WebLanguage): string {
169
173
  case SLOP.HSV2RGB: return `${lib("sl_hsv2rgb")}(${a[0]})`
170
174
  case SLOP.NOISE: return `${lib("sl_valueNoise")}(${a[0]})`
171
175
  case SLOP.SIMPLEX: return `${lib("sl_simplex")}(${a[0]})`
172
- case SLOP.FBM: return octaveCall(Math.round(imm[1] ?? 0), a[0], imm[0])
173
- case SLOP.TURBULENCE: return octaveCall(2, a[0], imm[0])
174
- case SLOP.RIDGED: return octaveCall(3, a[0], imm[0])
176
+ case SLOP.FBM: return octaveCall(Math.round((n.args.length > 1 ? imm[0] : imm[1]) ?? 0), a[0])
177
+ case SLOP.TURBULENCE: return octaveCall(2, a[0])
178
+ case SLOP.RIDGED: return octaveCall(3, a[0])
175
179
  case SLOP.VORONOI: return `${lib("sl_voronoi")}(${a[0]})`
176
- case SLOP.SDF: return sdfCall(Math.round(imm[0] ?? 0), a[0], imm)
180
+ case SLOP.SDF: return sdfCall(Math.round(imm[0] ?? 0), a[0], sdfParams())
177
181
  case SLOP.SAMPLE: {
178
182
  const slot = Math.round(imm[0] ?? 0)
179
183
  sampled.add(slot)
180
184
  return W ? `textureSample(sl_tex${slot}, sl_samp${slot}, ${a[0]})` : `texture(sl_Tex${slot}, ${a[0]})`
181
185
  }
186
+ case SLOP.SAMPLE_LOD: {
187
+ const slot = Math.round(imm[0] ?? 0)
188
+ sampled.add(slot)
189
+ return W
190
+ ? `textureSampleLevel(sl_tex${slot}, sl_samp${slot}, ${a[0]}, ${a[1]})`
191
+ : `textureLod(sl_Tex${slot}, ${a[0]}, ${a[1]})`
192
+ }
182
193
  default:
183
194
  throw new SLError(
184
195
  `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.`,
196
+ `differ between a browser and a Unity build, which is the one failure this design cannot tolerate.`,
186
197
  )
187
198
  }
188
199
 
189
200
  /**
190
201
  * The library's onejsFbmKind, resolved here since the kind is a
191
202
  * constant: calling the dispatcher would print a WGSL `select` that
192
- * runs two of the four fields to keep one.
203
+ * runs two of the four fields to keep one. The count is a literal when
204
+ * it is a constant, and otherwise rounded and held to 1 to 4 as
205
+ * `sl_fbm` holds it.
193
206
  */
194
- function octaveCall(kind: number, pt: string, octaves: number | undefined): string {
195
- const o = Math.min(4, Math.max(1, Math.round(octaves ?? 3)))
207
+ function octaveCall(kind: number, pt: string): string {
208
+ const c = constantOf(n.args[1]!)
209
+ const o = c !== null
210
+ ? String(Math.min(4, Math.max(1, Math.round(c[0]!))))
211
+ : `clamp(${W ? "i32" : "int"}(floor(${a[1]} + 0.5)), 1, 4)`
196
212
  const fn = kind === 2 ? "onejsTurbulence" : kind === 3 ? "onejsRidged" : kind === 1 ? "onejsFbmSimplex" : "onejsFbm"
197
213
  return `${lib(fn, ["float2", "float", "int", "float", "float"])}(${pt}, 0.0, ${o}, 2.0, 0.5)`
198
214
  }
199
215
 
216
+ /**
217
+ * The six shape parameters, each as a float expression and, when it
218
+ * is a constant, its number: literals from a constant float4 or
219
+ * float2, and their components otherwise.
220
+ */
221
+ function sdfParams(): Array<{ text: string; value?: number }> {
222
+ const part = (ref: number, chans: string[]) => {
223
+ const c = constantOf(ref)
224
+ return chans.map((ch, i) => c !== null ? { text: lit(c[i]!), value: c[i]! } : { text: `${a[n.args.indexOf(ref)]}.${ch}` })
225
+ }
226
+ return [...part(n.args[1]!, ["x", "y", "z", "w"]), ...part(n.args[2]!, ["x", "y"])]
227
+ }
228
+
200
229
  /** The shape is a constant, so this calls it directly instead of sl_sdfDistance's switch. */
201
- function sdfCall(id: number, pt: string, im: number[]): string {
230
+ function sdfCall(id: number, pt: string, v: Array<{ text: string; value?: number }>): string {
202
231
  const shape = SDF_CALLS[id]
203
232
  // sl_sdfDistance returns 1e6 for an id it does not know.
204
233
  if (shape === undefined) return lit(1e6)
205
234
  const fn = lib(shape.fn)
206
- const v = [im[1] ?? 0, im[2] ?? 0, im[3] ?? 0, im[4] ?? 0, im[5] ?? 0, im[6] ?? 0]
207
235
  const args = shape.args.map((arg) => {
208
- if ("int" in arg) return String(Math.trunc(v[arg.int]))
209
- const parts = arg.map((i) => lit(v[i]))
236
+ // Truncated toward zero, as HLSL's int() of the float4 does.
237
+ if ("int" in arg) {
238
+ const p = v[arg.int]!
239
+ return p.value !== undefined ? String(Math.trunc(p.value)) : W ? `i32(${p.text})` : `int(${p.text})`
240
+ }
241
+ const parts = arg.map((i) => v[i]!.text)
210
242
  return parts.length === 1 ? parts[0] : `${T(parts.length as SLType)}(${parts.join(", ")})`
211
243
  })
212
244
  return `${fn}(${[pt, ...args].join(", ")})`