onejs-sl 0.1.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 ADDED
@@ -0,0 +1,721 @@
1
+ /**
2
+ * The authoring surface: a TypeScript EDSL that records a shader graph.
3
+ *
4
+ * Phase 1 of `Specs/SHADER_LANG.md` section 3. Calling a program function does
5
+ * not execute per pixel; it records a DAG once, at module load.
6
+ *
7
+ * Why an EDSL and not a text language: completion on every function, type errors
8
+ * at the call site, jump to definition, rename, and the author's existing editor,
9
+ * all for free. Monaco in the Play editor already has these types. A text syntax
10
+ * remains possible later and costs only a parser, because the parser would emit
11
+ * this same IR.
12
+ *
13
+ * It also gives common subexpression elimination for nothing, which is the most
14
+ * valuable optimisation here: a `const` in the host language IS the shared node.
15
+ *
16
+ * const plasma = sl.program(({ uv, time }) => {
17
+ * const p = uv.mul(8).add(time.mul(0.4))
18
+ * const v = sl.sin(p.x).add(sl.sin(p.y))
19
+ * return sl.vec4(v.mul(0.5).add(0.5), 0, 0, 1)
20
+ * })
21
+ *
22
+ * `p` is written once and used twice, so it is one node with two references.
23
+ */
24
+
25
+ import { parseColor as parseHex } from "./color"
26
+ import {
27
+ Builder, INPUTS, SLError, SL_IR_VERSION, TYPE, hashProgram, widthName,
28
+ type InputName, type NodeRef, type Program, type SLNode, type SLType,
29
+ } from "./ir"
30
+ import { SLOP, type SLOpCode } from "./ops"
31
+ import { SL_SDF_PARAMS, SL_SDF_SHAPES, type SlSdfKind } from "./shapes"
32
+
33
+ // MARK: values
34
+
35
+ type Swz = "x" | "y" | "z" | "w" | "r" | "g" | "b" | "a"
36
+ const CHAN: Record<string, number> = { x: 0, y: 1, z: 2, w: 3, r: 0, g: 1, b: 2, a: 3 }
37
+
38
+ /** Width of a swizzle string, at the type level, so `swz("wzyx")` is a Vec4. */
39
+ type Widen<S extends string> =
40
+ S extends `${Swz}${Swz}${Swz}${Swz}` ? Vec4 :
41
+ S extends `${Swz}${Swz}${Swz}` ? Vec3 :
42
+ S extends `${Swz}${Swz}` ? Vec2 :
43
+ S extends `${Swz}` ? Float : never
44
+
45
+ /** Anything acceptable where a value is expected. Plain numbers broadcast. */
46
+ export type Num = Val | number
47
+
48
+ /**
49
+ * A recorded value. One runtime class; the four exported types are TypeScript's
50
+ * view of it, which is where mixing a Vec2 with a Vec3 becomes an error at the
51
+ * call site rather than a black rectangle.
52
+ */
53
+ // The `interface Val` further down declares the swizzle getters that the PAIRS
54
+ // loop installs on this prototype. They exist at runtime, so the merge is the
55
+ // point rather than a hazard; see the comment above that loop.
56
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
57
+ export class Val {
58
+ constructor(readonly owner: Builder, readonly ref: NodeRef, readonly width: SLType) {}
59
+
60
+ // Arithmetic. Mixing a vector with a Float broadcasts, as in HLSL.
61
+ add(o: Num): this { return bin(SLOP.ADD, this, o) as this }
62
+ sub(o: Num): this { return bin(SLOP.SUB, this, o) as this }
63
+ mul(o: Num): this { return bin(SLOP.MUL, this, o) as this }
64
+ div(o: Num): this { return bin(SLOP.DIV, this, o) as this }
65
+ mod(o: Num): this { return bin(SLOP.MOD, this, o) as this }
66
+ pow(o: Num): this { return bin(SLOP.POW, this, o) as this }
67
+ neg(): this { return un(SLOP.NEG, this) as this }
68
+ recip(): this { return un(SLOP.RECIP, this) as this }
69
+ abs(): this { return un(SLOP.ABS, this) as this }
70
+ saturate(): this { return un(SLOP.SATURATE, this) as this }
71
+ fract(): this { return un(SLOP.FRACT, this) as this }
72
+ floor(): this { return un(SLOP.FLOOR, this) as this }
73
+ min(o: Num): this { return bin(SLOP.MIN, this, o) as this }
74
+ max(o: Num): this { return bin(SLOP.MAX, this, o) as this }
75
+ clamp(lo: Num, hi: Num): this {
76
+ const { refs, width } = alignN([this, lo, hi])
77
+ return mk(this.owner, this.owner.call(SLOP.CLAMP, width, refs), width) as this
78
+ }
79
+
80
+ /** Length, distance and dot collapse to a Float whatever the input width. */
81
+ length(): Float { return mk(this.owner, this.owner.call(SLOP.LENGTH, TYPE.FLOAT, [this.ref]), TYPE.FLOAT) }
82
+ distance(o: Num): Float {
83
+ const [a, c] = align2(this, o)
84
+ return mk(this.owner, this.owner.call(SLOP.DISTANCE, TYPE.FLOAT, [a, c]), TYPE.FLOAT)
85
+ }
86
+ dot(o: Num): Float {
87
+ const [a, c] = align2(this, o)
88
+ return mk(this.owner, this.owner.call(SLOP.DOT, TYPE.FLOAT, [a, c]), TYPE.FLOAT)
89
+ }
90
+ normalize(): this { return un(SLOP.NORMALIZE, this) as this }
91
+
92
+ /**
93
+ * Arbitrary swizzle. The common single and full width ones are also getters
94
+ * (`p.x`, `c.rgb`); this covers everything else and is typed by its argument,
95
+ * so `c.swz("wzyx")` is a Vec4 and `c.swz("yx")` is a Vec2.
96
+ */
97
+ swz<S extends string>(s: S): Widen<S> {
98
+ if (s.length < 1 || s.length > 4) throw new SLError(`a swizzle takes 1 to 4 components, got "${s}"`)
99
+ const chans: number[] = []
100
+ for (const ch of s) {
101
+ const c = CHAN[ch]
102
+ if (c === undefined) throw new SLError(`"${ch}" is not a component; use xyzw or rgba`)
103
+ if (c >= this.width) {
104
+ throw new SLError(`"${ch}" is component ${c + 1} of a ${widthName(this.width)}, which has ${this.width}`)
105
+ }
106
+ chans.push(c)
107
+ }
108
+ const t = chans.length as SLType
109
+ return mk(this.owner, this.owner.add({ k: "swizzle", type: t, src: this.ref, chans }), t) as Widen<S>
110
+ }
111
+
112
+ get x(): Float { return this.swz("x") }
113
+ get y(): Float { return this.swz("y") }
114
+ get z(): Float { return this.swz("z") }
115
+ get w(): Float { return this.swz("w") }
116
+ get r(): Float { return this.swz("r") }
117
+ get g(): Float { return this.swz("g") }
118
+ get b(): Float { return this.swz("b") }
119
+ get a(): Float { return this.swz("a") }
120
+ get xyz(): Vec3 { return this.swz("xyz") }
121
+ get xyzw(): Vec4 { return this.swz("xyzw") }
122
+ get rgb(): Vec3 { return this.swz("rgb") }
123
+ get rgba(): Vec4 { return this.swz("rgba") }
124
+ }
125
+
126
+ /**
127
+ * Every two component swizzle, as a real getter.
128
+ *
129
+ * These are defined rather than listed because `uv.yx` is an ordinary thing to
130
+ * write and JavaScript answers an undeclared property with `undefined` rather
131
+ * than an error. That `undefined` then travels into `vec4(...)` and fails
132
+ * somewhere else entirely, about a value the author never wrote. Anything
133
+ * reachable at runtime should be reachable in the types too, so the interfaces
134
+ * below declare the same set.
135
+ *
136
+ * Three and four component permutations stay on `swz()`, which is typed by its
137
+ * argument. There are 320 of them and almost nobody writes `.zwyx`.
138
+ */
139
+ const PAIRS: string[] = []
140
+ for (const set of ["xyzw", "rgba"]) {
141
+ for (const a of set) for (const b of set) PAIRS.push(a + b)
142
+ }
143
+ for (const p of PAIRS) {
144
+ if (p in Val.prototype) continue
145
+ Object.defineProperty(Val.prototype, p, {
146
+ get(this: Val) { return this.swz(p) },
147
+ enumerable: false,
148
+ configurable: true,
149
+ })
150
+ }
151
+
152
+ /** The two component swizzles defined above, so the types match the runtime. */
153
+ type Pair<S extends string> = { readonly [K in `${S}${S}` & string]: Vec2 }
154
+ // Deliberate: the type half of the getters defined on Val.prototype directly above.
155
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
156
+ export interface Val extends Pair<"x" | "y" | "z" | "w">, Pair<"r" | "g" | "b" | "a"> {}
157
+
158
+ /** TypeScript's view of a recorded value. One runtime class, four static types. */
159
+ export interface Float extends Val { readonly width: 1 }
160
+ export interface Vec2 extends Val { readonly width: 2 }
161
+ export interface Vec3 extends Val { readonly width: 3 }
162
+ export interface Vec4 extends Val { readonly width: 4 }
163
+
164
+ function mk(b: Builder, ref: NodeRef, width: SLType): any {
165
+ return new Val(b, ref, width)
166
+ }
167
+
168
+ // MARK: recording context
169
+
170
+ let current: Builder | null = null
171
+
172
+ function ctx(): Builder {
173
+ if (current === null) {
174
+ throw new SLError(
175
+ "sl values can only be built inside sl.program(). A value recorded outside one has no " +
176
+ "graph to belong to, and a value from a different program cannot be mixed into this one.",
177
+ )
178
+ }
179
+ return current
180
+ }
181
+
182
+ /** Lifts a plain number to a node of the given width. */
183
+ function lift(b: Builder, v: Num, width: SLType): NodeRef {
184
+ if (typeof v === "number") return b.constant(new Array(width).fill(v))
185
+ if (v.owner !== b) throw new SLError("a value from another program cannot be used in this one")
186
+ return v.ref
187
+ }
188
+
189
+ /**
190
+ * Aligns two operands. A Float broadcasts against a vector, as in HLSL; two
191
+ * vectors of different widths are a type error, which TypeScript already catches
192
+ * at the call site for typed code and this catches for everything else.
193
+ */
194
+ function align2(a: Val, o: Num): [NodeRef, NodeRef] {
195
+ if (typeof o === "number") return [a.ref, lift(a.owner, o, a.width)]
196
+ if (o.owner !== a.owner) throw new SLError("a value from another program cannot be used in this one")
197
+ if (o.width === a.width) return [a.ref, o.ref]
198
+ if (o.width === 1) return [a.ref, broadcast(o, a.width)]
199
+ if (a.width === 1) return [broadcast(a, o.width), o.ref]
200
+ throw new SLError(`cannot combine a ${widthName(a.width)} with a ${widthName(o.width)}`)
201
+ }
202
+
203
+ /**
204
+ * Aligns any number of operands of a component wise op to their widest.
205
+ *
206
+ * THE RESULT WIDTH IS THE WIDEST OPERAND, not the first one. `align2` gets that
207
+ * right for two operands because `bin` takes the max itself; the three and four
208
+ * operand ops used to take the width from one chosen operand, so
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
211
+ * HLSL emitter declared `float` and truncated, so the two backends rendered
212
+ * different pictures from one program. Anything component wise with more than
213
+ * two operands goes through here.
214
+ */
215
+ function alignN(vs: Num[]): { refs: NodeRef[]; width: SLType } {
216
+ let width: SLType = 1
217
+ let owner: Builder | null = null
218
+ for (const v of vs) {
219
+ if (typeof v === "number") continue
220
+ if (owner === null) owner = v.owner
221
+ else if (v.owner !== owner) throw new SLError("a value from another program cannot be used in this one")
222
+ if (v.width > width) width = v.width
223
+ }
224
+ const b = owner ?? ctx()
225
+ const refs = vs.map((v) => {
226
+ if (typeof v === "number") return lift(b, v, width)
227
+ if (v.width === width) return v.ref
228
+ if (v.width === 1) return broadcast(v, width)
229
+ throw new SLError(`cannot combine a ${widthName(v.width)} with a ${widthName(width)}`)
230
+ })
231
+ return { refs, width }
232
+ }
233
+
234
+ /** float -> vecN by repeating the component. */
235
+ function broadcast(v: Val, width: SLType): NodeRef {
236
+ return v.owner.add({ k: "swizzle", type: width, src: v.ref, chans: new Array(width).fill(0) })
237
+ }
238
+
239
+ function bin(op: SLOpCode, a: Val, o: Num): Val {
240
+ const [x, y] = align2(a, o)
241
+ const width = typeof o === "number" ? a.width : (Math.max(a.width, o.width) as SLType)
242
+ return mk(a.owner, a.owner.call(op, width, [x, y]), width)
243
+ }
244
+
245
+ function un(op: SLOpCode, a: Val): Val {
246
+ return mk(a.owner, a.owner.call(op, a.width, [a.ref]), a.width)
247
+ }
248
+
249
+ // MARK: the public surface
250
+
251
+ export interface ProgramInputs {
252
+ uv: Vec2
253
+ fragCoord: Vec2
254
+ resolution: Vec2
255
+ time: Float
256
+ aspect: Float
257
+ }
258
+
259
+ /**
260
+ * Records a program. The function runs ONCE, here, not per pixel.
261
+ *
262
+ * It must return a Vec4: a program produces a colour.
263
+ */
264
+ export function program(fn: (inputs: ProgramInputs) => Vec4): Program {
265
+ if (current !== null) throw new SLError("sl.program() cannot be nested")
266
+ const b = new Builder()
267
+ current = b
268
+ try {
269
+ const inputs = {} as ProgramInputs
270
+ for (const [name, width] of Object.entries(INPUTS)) {
271
+ const ref = b.add({ k: "input", type: width as SLType, name: name as InputName })
272
+ ;(inputs as any)[name] = mk(b, ref, width as SLType)
273
+ }
274
+ const out = fn(inputs)
275
+ if (!(out instanceof Val)) throw new SLError("a program must return an sl value, not " + typeof out)
276
+ if (out.width !== TYPE.VEC4) {
277
+ throw new SLError(`a program must return a vec4, got a ${widthName(out.width)}. Wrap it: sl.vec4(value, 1)`)
278
+ }
279
+ const nodes: SLNode[] = b.nodes.slice()
280
+ return {
281
+ version: SL_IR_VERSION,
282
+ nodes,
283
+ result: out.ref,
284
+ uniforms: b.uniforms.slice(),
285
+ textures: b.textures.slice(),
286
+ hash: hashProgram(nodes, out.ref, b.uniforms, b.textures),
287
+ loops: b.loops.slice(),
288
+ }
289
+ } finally {
290
+ current = null
291
+ }
292
+ }
293
+
294
+ export function float(v: Num): Float {
295
+ const b = ctx()
296
+ return typeof v === "number" ? mk(b, b.constant([v]), TYPE.FLOAT) : (v as Float)
297
+ }
298
+
299
+ /** Builds a wider value from narrower parts, which must add up exactly. */
300
+ function compose(width: SLType, parts: Num[]): Val {
301
+ const b = ctx()
302
+ // Wide parts are SPLIT into their components here, so COMPOSE only ever
303
+ // sees scalars.
304
+ //
305
+ // The VM keeps every value in a float4 register and took the x of each
306
+ // operand, which silently dropped everything after the first component:
307
+ // vec4(uv, 0, 1) rendered (uv.x, 0, 1, 0) instead of (uv.x, uv.y, 0, 1).
308
+ // Teaching the shader each part's width would need widths in an encoding
309
+ // that has no room for them, and would put the complexity in the half that
310
+ // is hardest to test. Splitting at record time costs a few extra swizzle
311
+ // nodes, which the register allocator reclaims immediately, and leaves the
312
+ // VM's COMPOSE trivially correct.
313
+ // A constructor of literals is ONE constant, not a COMPOSE of four.
314
+ //
315
+ // `vec4(0.1, 0.2, 0.3, 0.4)` was five instructions holding four registers
316
+ // at once, because every part became its own CONST and COMPOSE kept them
317
+ // all live. The IR has always been able to say it in one node: a const
318
+ // carries up to four components. A two stop `ramp` used five of the eight
319
+ // registers on nothing but its own stop colours, which put the language's
320
+ // headline colour tool within one or two registers of unusable in any
321
+ // program that also did something.
322
+ if (parts.length === width && parts.every((p) => typeof p === "number")) {
323
+ return mk(b, b.constant(parts as number[]), width)
324
+ }
325
+
326
+ const refs: NodeRef[] = []
327
+ let total = 0
328
+ for (const p of parts) {
329
+ if (typeof p === "number") { refs.push(b.constant([p])); total += 1; continue }
330
+ if (p.owner !== b) throw new SLError("a value from another program cannot be used in this one")
331
+ if (p.width === 1) { refs.push(p.ref); total += 1; continue }
332
+ for (let c = 0; c < p.width; c++) {
333
+ refs.push(b.add({ k: "swizzle", type: TYPE.FLOAT, src: p.ref, chans: [c] }))
334
+ total += 1
335
+ }
336
+ }
337
+ if (total !== width) {
338
+ throw new SLError(`vec${width} needs ${width} components, got ${total}`)
339
+ }
340
+ return mk(b, b.call(SLOP.COMPOSE, width, refs), width)
341
+ }
342
+
343
+ export function vec2(...parts: Num[]): Vec2 { return compose(TYPE.VEC2, parts) as Vec2 }
344
+ export function vec3(...parts: Num[]): Vec3 { return compose(TYPE.VEC3, parts) as Vec3 }
345
+ export function vec4(...parts: Num[]): Vec4 { return compose(TYPE.VEC4, parts) as Vec4 }
346
+
347
+ /**
348
+ * A one argument op, typed so the width survives.
349
+ *
350
+ * Overloads rather than a conditional return type: the conditional form widened
351
+ * `sl.sin(aFloat)` to `Float | Val`, so `let v = uv.x; v = sl.sin(v)` failed to
352
+ * typecheck. An author hitting that would reasonably conclude the types were
353
+ * decorative.
354
+ */
355
+ function unary(op: SLOpCode) {
356
+ function f(v: number): Float
357
+ function f<T extends Val>(v: T): T
358
+ function f(v: Num): Val {
359
+ return un(op, typeof v === "number" ? (float(v) as Val) : v)
360
+ }
361
+ return f
362
+ }
363
+
364
+ export const sin = unary(SLOP.SIN)
365
+ export const cos = unary(SLOP.COS)
366
+ export const tan = unary(SLOP.TAN)
367
+ export const asin = unary(SLOP.ASIN)
368
+ export const acos = unary(SLOP.ACOS)
369
+ export const exp = unary(SLOP.EXP)
370
+ export const log = unary(SLOP.LOG)
371
+ export const sqrt = unary(SLOP.SQRT)
372
+ export const sign = unary(SLOP.SIGN)
373
+ export const ceil = unary(SLOP.CEIL)
374
+ export const round = unary(SLOP.ROUND)
375
+
376
+ /**
377
+ * Collapses a colour to a single brightness.
378
+ *
379
+ * NOT declared through `unary`, which preserves width. Luminance is one of the
380
+ * few ops whose result is narrower than its input, and having it return a Vec4
381
+ * meant `sl.vec4(sl.mix(lum, c.x, 0.5), c.y, c.z, c.w)` silently became seven
382
+ * components. The error surfaced two calls away from the cause, which is what
383
+ * width preserving by default costs when it is wrong.
384
+ */
385
+ /**
386
+ * A colour as written (sRGB, the way CSS reads a hex) to the working space the
387
+ * target holds. `ramp` and `color` already apply it; call it yourself on a
388
+ * vec4 built from raw components that mean a colour. Alpha is left alone.
389
+ */
390
+ export const toLinear = unary(SLOP.TO_LINEAR)
391
+
392
+ /** A hex colour as a vec4 in the working space: `parseColor` plus `toLinear`. */
393
+ export function color(hex: string): Vec4 {
394
+ const v = parseColor(hex)
395
+ return toLinear(vec4(v[0], v[1], v[2], v[3]))
396
+ }
397
+
398
+ export function luminance(c: Num): Float {
399
+ const v = typeof c === "number" ? float(c) : c
400
+ return mk(v.owner, v.owner.call(SLOP.LUMINANCE, TYPE.FLOAT, [v.ref]), TYPE.FLOAT)
401
+ }
402
+
403
+ /**
404
+ * Hue, saturation and value to RGB.
405
+ *
406
+ * The opcode was already implemented in both backends and simply had no name
407
+ * out here, which is the kind of gap only writing something real finds: a demo
408
+ * wanted a hue uniform, and a ramp cannot have one because its stops are
409
+ * constants.
410
+ */
411
+ export function hsv2rgb(c: Vec3): Vec3 {
412
+ return mk(c.owner, c.owner.call(SLOP.HSV2RGB, TYPE.VEC3, [c.ref]), TYPE.VEC3)
413
+ }
414
+
415
+ /**
416
+ * Cross and reflect, both vec3 only.
417
+ *
418
+ * The VM evaluates them as `cross(a.xyz, b.xyz)`, so a vec2 or vec4 would mean
419
+ * one thing in a compiled build and another in the interpreter. Refusing the
420
+ * other widths here is what keeps the two honest.
421
+ */
422
+ export function cross(a: Vec3, b: Vec3): Vec3 {
423
+ if (a.width !== TYPE.VEC3 || b.width !== TYPE.VEC3) {
424
+ throw new SLError(`cross takes two vec3s, got a ${widthName(a.width)} and a ${widthName(b.width)}`)
425
+ }
426
+ const [x, y] = align2(a, b)
427
+ return mk(a.owner, a.owner.call(SLOP.CROSS, TYPE.VEC3, [x, y]), TYPE.VEC3)
428
+ }
429
+
430
+ export function reflect(incident: Vec3, normal: Vec3): Vec3 {
431
+ if (incident.width !== TYPE.VEC3 || normal.width !== TYPE.VEC3) {
432
+ throw new SLError(
433
+ `reflect takes two vec3s, got a ${widthName(incident.width)} and a ${widthName(normal.width)}`,
434
+ )
435
+ }
436
+ const [i, n] = align2(incident, normal)
437
+ return mk(incident.owner, incident.owner.call(SLOP.REFLECT, TYPE.VEC3, [i, n]), TYPE.VEC3)
438
+ }
439
+
440
+ /**
441
+ * One range to another, linearly and without clamping.
442
+ *
443
+ * A MACRO, like `ramp`, and for the same reason: it expands into arithmetic
444
+ * both backends already have, so it needs no opcode, no second implementation
445
+ * in the emitter and no VM case. `SLOP.REMAP` stays a reserved number.
446
+ *
447
+ * Unclamped on purpose, which is what HLSL authors expect of the one liner they
448
+ * would otherwise write; wrap it in `saturate` when the ends matter.
449
+ */
450
+ export function remap(v: Num, fromMin: Num, fromMax: Num, toMin: Num, toMax: Num): Val {
451
+ const x = typeof v === "number" ? float(v) : v
452
+ const t = x.sub(fromMin).div(difference(fromMax, fromMin))
453
+ return mix(toMin, toMax, t)
454
+ }
455
+
456
+ /** `a - b` where either side may still be a plain number, so a constant stays one. */
457
+ function difference(a: Num, b: Num): Num {
458
+ if (typeof a === "number" && typeof b === "number") return a - b
459
+ return (typeof a === "number" ? float(a) : a).sub(b)
460
+ }
461
+
462
+ export function atan2(y: Num, x: Num): Float {
463
+ const b = ctx()
464
+ const yy = typeof y === "number" ? float(y) : y
465
+ const [a, c] = align2(yy as Val, x)
466
+ return mk(b, b.call(SLOP.ATAN2, TYPE.FLOAT, [a, c]), TYPE.FLOAT)
467
+ }
468
+
469
+ /**
470
+ * Branchless selection. Both sides are evaluated, which is why v1 has this
471
+ * rather than an `if`: a real branch would have to survive both backends
472
+ * identically, and this does not.
473
+ */
474
+ export function select(cond: Num, whenTrue: Num, whenFalse: Num): Val {
475
+ const b = ctx()
476
+ const c = typeof cond === "number" ? float(cond) : cond
477
+ const t = typeof whenTrue === "number" ? float(whenTrue) : whenTrue
478
+ const f = typeof whenFalse === "number" ? float(whenFalse) : whenFalse
479
+ // The condition is widened with the branches, not left at its own width.
480
+ // A register is a float4 whatever it holds, so a scalar condition sits
481
+ // there as (c, 0, 0, 0) and the VM's `step(0.5, cond)` answered 0 for
482
+ // components y, z and w, while the HLSL backend broadcasts a scalar
483
+ // itself. The two DISAGREED on every vector valued select, which is the one
484
+ // failure this design cannot tolerate.
485
+ const { refs, width } = alignN([c, t, f])
486
+ return mk(b, b.call(SLOP.SELECT, width, refs), width)
487
+ }
488
+
489
+ /**
490
+ * The interpolant is broadcast to the operand width before it crosses.
491
+ *
492
+ * A register is a float4 whatever it holds, so a scalar `t` sits there as
493
+ * (t, 0, 0, 0) and the shader's lerp ran per component against those zeros:
494
+ * a black to white ramp at its midpoint rendered (0.5, 0, 0, 1) instead of
495
+ * grey. Broadcasting here rather than in the shader keeps `t` free to be a
496
+ * genuine per component vector when an author wants one.
497
+ */
498
+ export function mix(a: Num, bv: Num, t: Num): Val {
499
+ const { refs, width } = alignN([a, bv, t])
500
+ const b = ctx()
501
+ return mk(b, b.call(SLOP.MIX, width, refs), width)
502
+ }
503
+
504
+ export function step(edge: Num, x: Num): Val {
505
+ const { refs, width } = alignN([edge, x])
506
+ const b = ctx()
507
+ return mk(b, b.call(SLOP.STEP, width, refs), width)
508
+ }
509
+
510
+ export function smoothstep(e0: Num, e1: Num, x: Num): Val {
511
+ const { refs, width } = alignN([e0, e1, x])
512
+ const b = ctx()
513
+ return mk(b, b.call(SLOP.SMOOTHSTEP, width, refs), width)
514
+ }
515
+
516
+ /** Uniform defaults, in slot order, for a host that has to seed them. */
517
+ export function uniformDefaults(p: Program): number[] {
518
+ const out: number[] = []
519
+ for (const u of p.uniforms) {
520
+ for (let i = 0; i < 4; i++) out.push(u.value[i] ?? (i === 3 ? 1 : 0))
521
+ }
522
+ return out
523
+ }
524
+
525
+ /**
526
+ * Noise, as superinstructions rather than graphs of primitives, and the same
527
+ * fields `fx.noise` draws: a value or simplex base, layered as fBm, or the
528
+ * turbulence and ridged variants built on simplex. A program has no seed;
529
+ * offset the input for a different field. Octaves are 1 to 4, as in fx.
530
+ */
531
+ export function noise(p: Vec2): Float {
532
+ return mk(p.owner, p.owner.call(SLOP.NOISE, TYPE.FLOAT, [p.ref]), TYPE.FLOAT)
533
+ }
534
+ export function simplex(p: Vec2): Float {
535
+ return mk(p.owner, p.owner.call(SLOP.SIMPLEX, TYPE.FLOAT, [p.ref]), TYPE.FLOAT)
536
+ }
537
+ function octaveCount(name: string, octaves: number): number {
538
+ if (!Number.isInteger(octaves) || octaves < 1 || octaves > 4) {
539
+ throw new SLError(`${name} octaves must be a whole number from 1 to 4, got ${octaves}`)
540
+ }
541
+ return octaves
542
+ }
543
+ /** Layered noise. `base` picks the grid ("value", the default) or triangles ("simplex"). */
544
+ export function fbm(p: Vec2, octaves = 3, base: "value" | "simplex" = "value"): Float {
545
+ const kind = base === "simplex" ? 1 : 0
546
+ return mk(p.owner, p.owner.call(SLOP.FBM, TYPE.FLOAT, [p.ref], [octaveCount("fbm", octaves), kind]), TYPE.FLOAT)
547
+ }
548
+ /** Sum of |simplex| octaves: creases that stack into veins and licks. Fire, smoke, marble. */
549
+ export function turbulence(p: Vec2, octaves = 3): Float {
550
+ return mk(p.owner, p.owner.call(SLOP.TURBULENCE, TYPE.FLOAT, [p.ref], [octaveCount("turbulence", octaves)]), TYPE.FLOAT)
551
+ }
552
+ /** The turbulence crease made bright and squared: ridges, lightning, cracks. */
553
+ export function ridged(p: Vec2, octaves = 3): Float {
554
+ return mk(p.owner, p.owner.call(SLOP.RIDGED, TYPE.FLOAT, [p.ref], [octaveCount("ridged", octaves)]), TYPE.FLOAT)
555
+ }
556
+
557
+ /** "#rgb", "#rrggbb" or "#rrggbbaa" to 0..1 components. Shared with fx. */
558
+ export function parseColor(hex: string): [number, number, number, number] {
559
+ try {
560
+ return parseHex(hex)
561
+ } catch (e) {
562
+ throw new SLError((e as Error).message)
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Maps 0..1 through evenly spaced colour stops.
568
+ *
569
+ * A MACRO, not an opcode. It expands into the mixes and smoothsteps already in
570
+ * the table, which is worth more than a dedicated instruction would be: it needs
571
+ * no new encoding, no ramp uniforms competing for space with the program, and no
572
+ * second implementation in the HLSL emitter. Both backends get it right by
573
+ * getting `mix` right, and a ramp of any length works rather than however many
574
+ * stops an instruction could carry.
575
+ *
576
+ * This is the argument for the EDSL in miniature. A library function that
577
+ * composes from primitives costs one function here and nothing anywhere else.
578
+ */
579
+ export function ramp(t: Num, stops: Array<string | [number, number, number, number]>): Vec4 {
580
+ if (stops.length < 2) throw new SLError(`a ramp needs at least 2 stops, got ${stops.length}`)
581
+ const tv = (typeof t === "number" ? float(t) : t).saturate()
582
+ // Each stop is built IMMEDIATELY BEFORE the mix that consumes it, not all
583
+ // of them up front. The graph is identical either way and so is the hash,
584
+ // which is a Merkle hash over the shape and not over storage order, but the
585
+ // register allocator walks the array: built up front, every stop stayed
586
+ // live until the last mix, and a four stop ramp reserved four of the VM's
587
+ // eight registers before the program did anything. That put the language's
588
+ // headline colour tool a couple of registers from unusable.
589
+ const colourAt = (i: number) => {
590
+ const v = typeof stops[i] === "string" ? parseColor(stops[i] as string) : (stops[i] as number[])
591
+ return vec4(v[0], v[1], v[2], v[3])
592
+ }
593
+ const spans = stops.length - 1
594
+ let out = colourAt(0)
595
+ for (let i = 0; i < spans; i++) {
596
+ // Local 0..1 across this span, clamped, so stops outside it contribute
597
+ // nothing and the chain reads as "each span paints over the last".
598
+ const local = (tv.mul(spans).sub(i) as Float).saturate()
599
+ out = mix(out, colourAt(i + 1), local) as Vec4
600
+ }
601
+ // The stops are sRGB as written and the mixes ran in that space, which is
602
+ // what reads as an even ramp; one conversion at the end puts the result in
603
+ // the target's working space. Same rule as fx's gradient and ramp.
604
+ return toLinear(out)
605
+ }
606
+
607
+ /**
608
+ * Signed distance to a shape, positive outside and negative inside.
609
+ *
610
+ * The 42 shapes are the same ones `fx` draws, from the same HLSL, so a hexagon
611
+ * means one thing across the whole library rather than two.
612
+ *
613
+ * const d = sl.sdf("hexagon", uv.sub(0.5), [0.3])
614
+ * return sl.vec4(sl.smoothstep(0.01, 0, d), 0, 0, 1)
615
+ *
616
+ * POSITION AND ROTATION ARE NOT PARAMETERS, deliberately. Transform the point
617
+ * before calling, as `uv.sub(centre)` above does. That is how signed distance
618
+ * code is normally written and it composes with everything else in the language.
619
+ *
620
+ * A shape takes up to `SL_SDF_PARAMS[kind]` parameters, six at most. Fewer
621
+ * fills zeros, which is all a program could pass before six shapes grew their
622
+ * fifth and sixth.
623
+ *
624
+ * Which parameters a shape takes is the shape's own business; `circle` wants a
625
+ * radius, `roundedBox` wants half extents and a corner. See SDF2D.cginc.
626
+ */
627
+ export function sdf(kind: SlSdfKind, p: Vec2, params: number[] = []): Float {
628
+ const id = SL_SDF_SHAPES[kind]
629
+ if (id === undefined) throw new SLError(`"${kind}" is not a shape; see SL_SDF_SHAPES for the 42 names`)
630
+ // At least four, so a program that passed a shape more than it reads, which
631
+ // was harmless when every shape took four, still builds.
632
+ const most = Math.max(4, SL_SDF_PARAMS[kind])
633
+ if (params.length > most) {
634
+ throw new SLError(`sl.sdf("${kind}") takes at most ${most} parameters and was given ${params.length}`)
635
+ }
636
+ for (const v of params) {
637
+ if (!Number.isFinite(v)) throw new SLError(`sl.sdf parameters must be finite, got ${v}`)
638
+ }
639
+ // Four always, as before, and a fifth and sixth only when given: a program
640
+ // using four or fewer is the same node, and hashes the same, as it was.
641
+ const imm = [params[0] ?? 0, params[1] ?? 0, params[2] ?? 0, params[3] ?? 0, ...params.slice(4)]
642
+ return mk(p.owner, p.owner.call(SLOP.SDF, TYPE.FLOAT, [p.ref], [id, ...imm]), TYPE.FLOAT)
643
+ }
644
+
645
+ /** Distance to the nearest point of a jittered lattice. Cells, cracks, scales. */
646
+ export function voronoi(p: Vec2): Float {
647
+ return mk(p.owner, p.owner.call(SLOP.VORONOI, TYPE.FLOAT, [p.ref]), TYPE.FLOAT)
648
+ }
649
+
650
+ export const uniform = {
651
+ float(name: string, value = 0): Float {
652
+ const b = ctx()
653
+ return mk(b, b.uniform(name, TYPE.FLOAT, [value]), TYPE.FLOAT)
654
+ },
655
+ vec2(name: string, value: [number, number] = [0, 0]): Vec2 {
656
+ const b = ctx()
657
+ return mk(b, b.uniform(name, TYPE.VEC2, value), TYPE.VEC2)
658
+ },
659
+ vec3(name: string, value: [number, number, number] = [0, 0, 0]): Vec3 {
660
+ const b = ctx()
661
+ return mk(b, b.uniform(name, TYPE.VEC3, value), TYPE.VEC3)
662
+ },
663
+ vec4(name: string, value: [number, number, number, number] = [0, 0, 0, 1]): Vec4 {
664
+ const b = ctx()
665
+ return mk(b, b.uniform(name, TYPE.VEC4, value), TYPE.VEC4)
666
+ },
667
+ }
668
+
669
+ export interface Texture {
670
+ sample(uv: Vec2): Vec4
671
+ }
672
+
673
+ export function texture(name: string): Texture {
674
+ const b = ctx()
675
+ const slot = b.texture(name)
676
+ return {
677
+ sample(uv: Vec2): Vec4 {
678
+ if (uv.owner !== b) throw new SLError("a value from another program cannot be used in this one")
679
+ return mk(b, b.call(SLOP.SAMPLE, TYPE.VEC4, [uv.ref], [slot]), TYPE.VEC4)
680
+ },
681
+ }
682
+ }
683
+
684
+ /**
685
+ * A loop that UNROLLS at record time. `n` is a JavaScript number, so the count
686
+ * is known while the graph is being built and both backends see straight line
687
+ * code.
688
+ *
689
+ * Honest about what it is: a macro, not a loop. It covers fbm, layered noise and
690
+ * small iterated distance fields, which is most of what 2D shaders loop for. A
691
+ * data dependent loop is out of scope, because in the VM it would need a nested
692
+ * bounded loop with a dynamic trip count while codegen would handle it fine, and
693
+ * the two backends agreeing is the property this whole design protects.
694
+ */
695
+ export function repeat<T extends Val>(n: number, body: (i: number, acc: T) => T, seed: T): T {
696
+ if (!Number.isInteger(n) || n < 0 || n > 64) {
697
+ throw new SLError(`repeat count must be a whole number from 0 to 64, got ${n}`)
698
+ }
699
+ return unrolled(n, () => {
700
+ let acc = seed
701
+ for (let i = 0; i < n; i++) acc = body(i, acc)
702
+ return acc
703
+ })
704
+ }
705
+
706
+ /**
707
+ * Records the nodes a body produced as one unrolled span.
708
+ *
709
+ * Diagnostic only: the instruction ceiling error uses the span to say which
710
+ * loop spent the budget, so the fix reads as "lower this count" rather than
711
+ * "fewer instructions". `repeat` is the EDSL's form of it and the text
712
+ * language's `for` is the other, and neither should be reaching into the
713
+ * Builder to push a span itself.
714
+ */
715
+ export function unrolled<T>(count: number, body: () => T): T {
716
+ const b = ctx()
717
+ const start = b.nodes.length
718
+ const out = body()
719
+ b.loops.push({ count, start, end: b.nodes.length })
720
+ return out
721
+ }