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.
@@ -0,0 +1,628 @@
1
+ /**
2
+ * AST to IR, through the EDSL.
3
+ *
4
+ * Phase A of `Specs/SL_TEXT.md` section 4, and the file the parity test is
5
+ * about. Nothing here touches the Builder: `sin(x)` in a file becomes the same
6
+ * `sl.sin(x)` call a TypeScript author would have written, so the two surfaces
7
+ * cannot produce different graphs. That is why a `.sl` file and its EDSL twin
8
+ * hash the same, and why the existing GPU and codegen golden tests cover the
9
+ * text form without being told about it.
10
+ *
11
+ * Four things happen here that the file's author does not see:
12
+ *
13
+ * **Locals become SSA.** A local is a JavaScript binding holding a recorded
14
+ * value, so `p = p * 2;` rebinds and the IR never learns that a variable
15
+ * existed. Reassignment is free; it is not a store.
16
+ *
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.
20
+ *
21
+ * **`if` becomes `select`.** Both sides are lowered and every local assigned in
22
+ * either is selected at the join. A constant condition folds instead, which is
23
+ * the one case where a branch really does disappear.
24
+ *
25
+ * **Functions inline.** A call lowers the callee's body against its arguments
26
+ * in a fresh scope. `ring(p, 0.3, 0.01)` costs exactly what writing the body
27
+ * out would.
28
+ *
29
+ * A NUMBER STAYS A NUMBER as long as it can. `uv * 8` reaches the EDSL as
30
+ * `uv.mul(8)`, not as `uv.mul(sl.float(8))`, because the first broadcasts to a
31
+ * float2 constant and the second builds a float plus a swizzle. Same picture,
32
+ * different graph, different hash, so the distinction is load bearing rather
33
+ * than an optimisation.
34
+ */
35
+
36
+ import { INPUTS, type Program, type SLType } from "../ir"
37
+ import type { SlSdfKind } from "../shapes"
38
+ import * as sl from "../sl"
39
+ import type { Num, Val } from "../sl"
40
+ import { TYPE_WIDTH, type Expr, type FuncDecl, type Stmt, type TypeName } from "./ast"
41
+ import { BUILTINS } from "./builtins"
42
+ import type { Checked } from "./check"
43
+ import { SLParseError, type Pos } from "./lexer"
44
+
45
+ /** A lowered value. A plain number is one that has not had to become a node yet. */
46
+ type LV = Val | number
47
+
48
+ interface Binding {
49
+ /** The declared width. A reassignment has to match it. */
50
+ width: SLType
51
+ value: LV
52
+ }
53
+
54
+ /** How many iterations a `for` may unroll to. `sl.repeat`'s ceiling, for the same reason. */
55
+ const MAX_UNROLL = 64
56
+
57
+ class Scope {
58
+ private readonly vars = new Map<string, Binding>()
59
+
60
+ constructor(readonly parent: Scope | null, readonly isGlobal = false) {}
61
+
62
+ declare(name: string, binding: Binding): void { this.vars.set(name, binding) }
63
+
64
+ lookup(name: string): Binding | undefined {
65
+ const own = this.vars.get(name)
66
+ if (own !== undefined) return own
67
+ return this.parent?.lookup(name)
68
+ }
69
+
70
+ /**
71
+ * Every binding an `if` could reassign: the locals, never the globals.
72
+ *
73
+ * Uniforms, textures, consts and inputs live in the global scope and the
74
+ * checker refuses assigning to any of them, so the join never has to
75
+ * consider one.
76
+ */
77
+ locals(): Binding[] {
78
+ if (this.isGlobal) return []
79
+ const out = this.parent === null ? [] : this.parent.locals()
80
+ for (const b of this.vars.values()) out.push(b)
81
+ return out
82
+ }
83
+ }
84
+
85
+ export function lower(checked: Checked): Program {
86
+ const { unit, funcs } = checked
87
+ const file = unit.file
88
+ const main = unit.main!
89
+
90
+ /**
91
+ * Runs an EDSL call and gives its error a place in the file.
92
+ *
93
+ * The EDSL's messages are already the right words ("z is component 3 of a
94
+ * vec2, which has 2"); what they lack is a line. Rethrowing here rather
95
+ * than writing a second set of messages is what keeps the two surfaces
96
+ * saying the same thing about the same mistake.
97
+ */
98
+ const at = <T,>(pos: Pos, fn: () => T): T => {
99
+ try {
100
+ return fn()
101
+ } catch (e) {
102
+ throw located(e, pos)
103
+ }
104
+ }
105
+
106
+ const located = (e: unknown, pos: Pos): unknown => {
107
+ if (e instanceof SLParseError) return e
108
+ const raw = e instanceof Error ? e.message : String(e)
109
+ return new SLParseError(raw.replace(/^\[onejs sl] /, ""), file, pos)
110
+ }
111
+
112
+ const fail: (message: string, pos: Pos, length?: number) => never =
113
+ (message, pos, length = 1) => {
114
+ throw new SLParseError(message, file, pos, length)
115
+ }
116
+
117
+ // The globals every helper below reads, built inside the recording callback
118
+ // and held out here so a function being inlined can see them and nothing
119
+ // else the caller had in scope.
120
+ const global = new Scope(null, true)
121
+ const samplers = new Map<string, sl.Texture>()
122
+ /** Where the last `return` lowered was, so an error about it points at it. */
123
+ let returnedAt: Pos = main.pos
124
+
125
+ try {
126
+ return record()
127
+ } catch (e) {
128
+ // Nothing should reach here with a position already: every EDSL call
129
+ // below is wrapped. This is the net, so that a program level refusal
130
+ // still names the file rather than arriving as a bare SLError.
131
+ throw located(e, main.pos)
132
+ }
133
+
134
+ function record(): Program {
135
+ return sl.program((inputs) => {
136
+ for (const [name, width] of Object.entries(INPUTS)) {
137
+ global.declare(name, { width: width as SLType, value: (inputs as never)[name] })
138
+ }
139
+
140
+ // Uniforms and textures take their slots in DECLARATION order, before
141
+ // anything is lowered, so a slot is a property of the file rather than
142
+ // of which uniform the program happens to read first. An unused
143
+ // declaration still takes its slot and still reaches the host.
144
+ for (const u of unit.uniforms) {
145
+ const width = TYPE_WIDTH[u.type]
146
+ const { components, colour } = uniformDefault(u.type, u.init)
147
+ const raw = at(u.pos, () => declareUniform(u.name, u.type, components))
148
+ // A hex default says the uniform IS a colour, so every read of it
149
+ // converts, exactly as a hex literal in an expression does. Without
150
+ // that, `#ff8040` and a uniform defaulting to `#ff8040` would be two
151
+ // different colours in one file.
152
+ global.declare(u.name, { width, value: colour ? at(u.pos, () => sl.toLinear(raw)) : raw })
153
+ }
154
+
155
+ for (const t of unit.textures) samplers.set(t.name, at(t.pos, () => sl.texture(t.name)))
156
+
157
+ for (const c of unit.consts) {
158
+ const v = lowerExpr(c.init, global)
159
+ assertWidth(v, TYPE_WIDTH[c.type], c.name, c.type, c.pos)
160
+ global.declare(c.name, { width: TYPE_WIDTH[c.type], value: v })
161
+ }
162
+
163
+ const out = exec(main.body, new Scope(global))
164
+ // Checked here rather than left to `sl.program`, which would refuse it
165
+ // in the EDSL's words ("a vec4") about a file whose types are spelled
166
+ // float4, and from outside the reach of a position.
167
+ if (out === undefined || typeof out === "number" || out.width !== 4) {
168
+ const got = out === undefined ? "nothing"
169
+ : typeof out === "number" ? "a single number"
170
+ : `a ${widthType(out.width)}`
171
+ fail(
172
+ `main returns a float4, a colour with alpha, and this returns ${got}. ` +
173
+ `Wrap it: float4(value, 1)`,
174
+ returnedAt, 6,
175
+ )
176
+ }
177
+ return out as never
178
+ })
179
+ }
180
+
181
+ // MARK: declarations
182
+
183
+ function declareUniform(name: string, type: TypeName, c: number[]): Val {
184
+ switch (type) {
185
+ case "float": return sl.uniform.float(name, c[0]!) as unknown as Val
186
+ case "float2": return sl.uniform.vec2(name, [c[0]!, c[1]!]) as unknown as Val
187
+ case "float3": return sl.uniform.vec3(name, [c[0]!, c[1]!, c[2]!]) as unknown as Val
188
+ case "float4": return sl.uniform.vec4(name, [c[0]!, c[1]!, c[2]!, c[3]!]) as unknown as Val
189
+ }
190
+ }
191
+
192
+ /**
193
+ * A uniform's default, folded to numbers.
194
+ *
195
+ * LITERALS ONLY, deliberately. The default is baked into the program before
196
+ * anything runs and it is what the generated shader writes into its
197
+ * Properties block, so it cannot depend on a value. Refusing identifiers
198
+ * outright also side steps an ordering puzzle nobody would enjoy: a const
199
+ * may read a uniform, so letting a uniform read a const would make the two
200
+ * tables depend on each other.
201
+ */
202
+ function uniformDefault(type: TypeName, init: Expr | null): { components: number[]; colour: boolean } {
203
+ const width = TYPE_WIDTH[type]
204
+ if (init === null) {
205
+ const zeros = new Array<number>(width).fill(0)
206
+ if (width === 4) zeros[3] = 1
207
+ return { components: zeros, colour: false }
208
+ }
209
+ if (init.k === "hex") {
210
+ if (width < 3) {
211
+ fail(
212
+ `a colour has three or four components, so ${type} cannot default to ${init.hex}`,
213
+ init.pos, init.hex.length,
214
+ )
215
+ }
216
+ const c = at(init.pos, () => sl.parseColor(init.hex))
217
+ return { components: c.slice(0, width), colour: true }
218
+ }
219
+ const c = constantComponents(init)
220
+ if (c === null) {
221
+ fail(
222
+ "a uniform's default is baked into the program before anything runs, so it has to be " +
223
+ "written out: a number, a float2, float3 or float4 of numbers, or a colour like #ff8040",
224
+ init.pos,
225
+ )
226
+ }
227
+ if (c.length !== width) {
228
+ fail(
229
+ `this default has ${c.length} component${c.length === 1 ? "" : "s"} and the uniform is ` +
230
+ `declared ${type}`,
231
+ init.pos,
232
+ )
233
+ }
234
+ return { components: c, colour: false }
235
+ }
236
+
237
+ /** The literal arithmetic a uniform default is allowed to be, or nothing. */
238
+ function constantComponents(e: Expr): number[] | null {
239
+ switch (e.k) {
240
+ case "num": return [e.value]
241
+ case "unary": {
242
+ const v = constantComponents(e.arg)
243
+ if (v === null) return null
244
+ if (e.op === "+") return v
245
+ if (e.op === "-") return v.map((n) => -n)
246
+ return v.map((n) => 1 - n)
247
+ }
248
+ case "binary": {
249
+ const a = constantComponents(e.a)
250
+ const b = constantComponents(e.b)
251
+ if (a === null || b === null) return null
252
+ if (!isArith(e.op)) return null
253
+ const fold = ARITH[e.op]
254
+ const n = Math.max(a.length, b.length)
255
+ if (a.length !== b.length && a.length !== 1 && b.length !== 1) return null
256
+ const out: number[] = []
257
+ for (let i = 0; i < n; i++) out.push(fold(a[a.length === 1 ? 0 : i]!, b[b.length === 1 ? 0 : i]!))
258
+ return out
259
+ }
260
+ case "call": {
261
+ if (e.callee.k !== "ident" || !(e.callee.name in TYPE_WIDTH)) return null
262
+ const width = TYPE_WIDTH[e.callee.name as TypeName]
263
+ const parts: number[] = []
264
+ for (const a of e.args) {
265
+ const v = constantComponents(a)
266
+ if (v === null) return null
267
+ parts.push(...v)
268
+ }
269
+ if (parts.length === 1 && width > 1) return new Array<number>(width).fill(parts[0]!)
270
+ return parts.length === width ? parts : null
271
+ }
272
+ default: return null
273
+ }
274
+ }
275
+
276
+ // MARK: statements
277
+
278
+ /** Runs a body. The value of its `return`, or nothing when it has none. */
279
+ function exec(body: Stmt[], scope: Scope): LV | undefined {
280
+ for (const s of body) {
281
+ switch (s.k) {
282
+ case "var":
283
+ case "const": {
284
+ const v = lowerExpr(s.init, scope)
285
+ assertWidth(v, TYPE_WIDTH[s.type], s.name, s.type, s.pos)
286
+ scope.declare(s.name, { width: TYPE_WIDTH[s.type], value: v })
287
+ break
288
+ }
289
+ case "assign": {
290
+ const name = (s.target as Extract<Expr, { k: "ident" }>).name
291
+ const b = scope.lookup(name)!
292
+ let v = lowerExpr(s.value, scope)
293
+ if (s.op !== "=") {
294
+ const op = s.op[0] as ArithOp
295
+ v = at(s.pos, () => arithmetic(op, b.value, v, s.pos))
296
+ }
297
+ assertWidth(v, b.width, name, widthType(b.width), s.pos)
298
+ b.value = v
299
+ break
300
+ }
301
+ case "if": {
302
+ const cond = lowerExpr(s.cond, scope)
303
+ if (typeof cond === "number") {
304
+ // The one case where a branch really does vanish: the
305
+ // other side is never lowered, so it costs nothing.
306
+ exec(cond >= 0.5 ? s.then : s.else, new Scope(scope))
307
+ break
308
+ }
309
+ const locals = scope.locals()
310
+ const before = locals.map((b) => b.value)
311
+ exec(s.then, new Scope(scope))
312
+ const whenTrue = locals.map((b) => b.value)
313
+ locals.forEach((b, i) => { b.value = before[i]! })
314
+ exec(s.else, new Scope(scope))
315
+ const whenFalse = locals.map((b) => b.value)
316
+ locals.forEach((b, i) => {
317
+ const t = whenTrue[i]!
318
+ const f = whenFalse[i]!
319
+ b.value = same(t, f) ? t : at(s.pos, () => sl.select(cond, t, f))
320
+ })
321
+ break
322
+ }
323
+ case "for": {
324
+ const from = constantBound(s.from, "start", scope)
325
+ const to = constantBound(s.to, "bound", scope)
326
+ const step = constantBound(s.step, "step", scope)
327
+ if (!(step > 0)) {
328
+ fail(
329
+ `this loop steps by ${step}, so it never ends. A for loop unrolls, which ` +
330
+ `means its count has to be known and finite`,
331
+ s.pos,
332
+ )
333
+ }
334
+ let count = 0
335
+ for (let i = from; s.inclusive ? i <= to : i < to; i += step) {
336
+ count++
337
+ if (count > MAX_UNROLL) {
338
+ fail(
339
+ `this loop would unroll to more than ${MAX_UNROLL} iterations. There is ` +
340
+ `no loop on either backend, so every iteration is emitted in full and ` +
341
+ `the VM runs at most 256 instructions`,
342
+ s.pos,
343
+ )
344
+ }
345
+ }
346
+ at(s.pos, () => sl.unrolled(count, () => {
347
+ for (let i = from; s.inclusive ? i <= to : i < to; i += step) {
348
+ const inner = new Scope(scope)
349
+ inner.declare(s.counter, { width: 1, value: i })
350
+ exec(s.body, inner)
351
+ }
352
+ }))
353
+ break
354
+ }
355
+ case "return":
356
+ returnedAt = s.pos
357
+ return lowerExpr(s.value, scope)
358
+ }
359
+ }
360
+ return undefined
361
+ }
362
+
363
+ function constantBound(e: Expr, what: string, scope: Scope): number {
364
+ const v = lowerExpr(e, scope)
365
+ if (typeof v !== "number") {
366
+ fail(
367
+ `a for loop unrolls at build time, so its ${what} has to be a constant. This one is ` +
368
+ `computed while the shader runs, and neither backend has a loop to run it in`,
369
+ e.pos,
370
+ )
371
+ }
372
+ return v
373
+ }
374
+
375
+ /** Two lowered values that are certainly the same node, so a join can skip the select. */
376
+ function same(a: LV, b: LV): boolean {
377
+ if (a === b) return true
378
+ return typeof a !== "number" && typeof b !== "number" && a.ref === b.ref && a.width === b.width
379
+ }
380
+
381
+ function assertWidth(v: LV, want: SLType, name: string, type: TypeName, pos: Pos): void {
382
+ const got = typeof v === "number" ? 1 : v.width
383
+ if (got === want) return
384
+ // A declaration is an assertion, not an input to inference
385
+ // (`Specs/SL_TEXT.md` 3.2), so this says which half is wrong rather
386
+ // than quietly promoting one to the other.
387
+ const hint = got === 1 ? ` Wrap it in ${type}(...) to broadcast the value across ${want} components.` : ""
388
+ fail(`${name} is declared ${type} and this is a ${widthType(got as SLType)}.${hint}`, pos)
389
+ }
390
+
391
+ // MARK: expressions
392
+
393
+ function lowerExpr(e: Expr, scope: Scope): LV {
394
+ switch (e.k) {
395
+ case "num": return e.value
396
+ case "hex": return at(e.pos, () => sl.color(e.hex) as unknown as Val)
397
+ case "ident": {
398
+ const b = scope.lookup(e.name)
399
+ if (b === undefined) fail(`"${e.name}" is not declared`, e.pos, e.name.length)
400
+ return b.value
401
+ }
402
+ case "member": {
403
+ const obj = lowerExpr(e.obj, scope)
404
+ return at(e.pos, () => asVal(obj).swz(e.name) as unknown as Val)
405
+ }
406
+ case "unary": {
407
+ const v = lowerExpr(e.arg, scope)
408
+ if (e.op === "+") return v
409
+ if (e.op === "-") return typeof v === "number" ? -v : at(e.pos, () => v.neg())
410
+ return oneMinus(v, e.pos)
411
+ }
412
+ case "binary": return lowerBinary(e, scope)
413
+ case "cond": {
414
+ const cond = lowerExpr(e.cond, scope)
415
+ if (typeof cond === "number") {
416
+ return lowerExpr(cond >= 0.5 ? e.then : e.else, scope)
417
+ }
418
+ const t = lowerExpr(e.then, scope)
419
+ const f = lowerExpr(e.else, scope)
420
+ return at(e.pos, () => sl.select(cond, t, f))
421
+ }
422
+ case "call": return lowerCall(e, scope)
423
+ }
424
+ }
425
+
426
+ function lowerBinary(e: Extract<Expr, { k: "binary" }>, scope: Scope): LV {
427
+ const a = lowerExpr(e.a, scope)
428
+ const b = lowerExpr(e.b, scope)
429
+ const pos = e.pos
430
+ return at(pos, () => {
431
+ switch (e.op) {
432
+ case "+": case "-": case "*": case "/": case "%":
433
+ return arithmetic(e.op, a, b, pos)
434
+ // Comparisons and logic are arithmetic the IR already has, as
435
+ // `Specs/SL_TEXT.md` 3.5 lays out. Nothing here branches, and
436
+ // every result is a float that is 0 or 1.
437
+ case "<=": return sl.step(a, b)
438
+ case ">=": return sl.step(b, a)
439
+ case "<": return oneMinus(sl.step(b, a), pos)
440
+ case ">": return oneMinus(sl.step(a, b), pos)
441
+ case "==": return oneMinus(notEqual(a, b, pos), pos)
442
+ case "!=": return notEqual(a, b, pos)
443
+ case "&&": return typeof a === "number" && typeof b === "number"
444
+ ? Math.min(a, b) : asVal(a).min(b)
445
+ case "||": return typeof a === "number" && typeof b === "number"
446
+ ? Math.max(a, b) : asVal(a).max(b)
447
+ }
448
+ })
449
+ }
450
+
451
+ /** `abs(sign(a - b))`: 0 when equal, 1 otherwise, component wise. */
452
+ function notEqual(a: LV, b: LV, pos: Pos): LV {
453
+ const d = arithmetic("-", a, b, pos)
454
+ if (typeof d === "number") return Math.abs(Math.sign(d))
455
+ return sl.sign(d).abs()
456
+ }
457
+
458
+ function oneMinus(v: LV, pos: Pos): LV {
459
+ if (typeof v === "number") return 1 - v
460
+ return at(pos, () => sl.float(1).sub(v))
461
+ }
462
+
463
+ function arithmetic(op: ArithOp, a: LV, b: LV, pos: Pos): LV {
464
+ if (typeof a === "number" && typeof b === "number") {
465
+ if ((op === "/" || op === "%") && b === 0) {
466
+ fail(`this divides ${a} by zero, and the result would not be a number`, pos)
467
+ }
468
+ return ARITH[op](a, b)
469
+ }
470
+ const m = METHOD[op]
471
+ // A scalar on the LEFT has to become a value first, so it broadcasts
472
+ // through a swizzle rather than as a wide constant. That is what
473
+ // `sl.float(n).sub(v)` does too, and the two forms have to agree.
474
+ return asVal(a)[m](b)
475
+ }
476
+
477
+ function lowerCall(e: Extract<Expr, { k: "call" }>, scope: Scope): LV {
478
+ const callee = e.callee
479
+
480
+ if (callee.k === "member") {
481
+ const shape = callee.name as SlSdfKind
482
+ const p = lowerExpr(e.args[0]!, scope)
483
+ if (typeof p === "number" || p.width !== 2) {
484
+ fail(`sdf.${shape} measures the distance to a point, so it takes a float2`, e.args[0]!.pos)
485
+ }
486
+ const params: number[] = []
487
+ for (const a of e.args.slice(1)) {
488
+ const v = lowerExpr(a, scope)
489
+ if (typeof v !== "number") {
490
+ fail(
491
+ `sdf.${shape}'s shape parameters ride inside the instruction, so they have to ` +
492
+ `be constants. A size that changes belongs on the point: scale or offset it ` +
493
+ `before the call`,
494
+ a.pos,
495
+ )
496
+ }
497
+ params.push(v)
498
+ }
499
+ return at(e.pos, () => sl.sdf(shape, p as never, params))
500
+ }
501
+
502
+ const n = (callee as Extract<Expr, { k: "ident" }>).name
503
+
504
+ if (n in TYPE_WIDTH) {
505
+ return construct(n as TypeName, e.args.map((a) => lowerExpr(a, scope)), e.pos)
506
+ }
507
+
508
+ if (n === "tex2D") {
509
+ const texName = (e.args[0] as Extract<Expr, { k: "ident" }>).name
510
+ const tex = samplers.get(texName)!
511
+ const uv = lowerExpr(e.args[1]!, scope)
512
+ if (typeof uv === "number" || uv.width !== 2) {
513
+ fail("tex2D samples at a float2", e.args[1]!.pos)
514
+ }
515
+ return at(e.pos, () => tex.sample(uv as never) as unknown as Val)
516
+ }
517
+
518
+ if (n === "ramp") {
519
+ const t = lowerExpr(e.args[0]!, scope)
520
+ const stops: string[] = []
521
+ for (const a of e.args.slice(1)) {
522
+ if (a.k !== "hex") {
523
+ fail(
524
+ "a ramp's stops are constants, written as colours: `ramp(t, #000018, #0080ff, " +
525
+ "#ffffff)`. A stop that changes wants lerp between two uniforms instead",
526
+ a.pos,
527
+ )
528
+ }
529
+ stops.push(a.hex)
530
+ }
531
+ return at(e.pos, () => sl.ramp(t, stops) as unknown as Val)
532
+ }
533
+
534
+ const builtin = BUILTINS[n]
535
+ if (builtin !== undefined) {
536
+ const args = e.args.map((a) => lowerExpr(a, scope))
537
+ return at(e.pos, () => builtin.lower!(args as Num[]))
538
+ }
539
+
540
+ const fn = funcs.get(n)!
541
+ return inline(fn, e.args.map((a) => lowerExpr(a, scope)), e)
542
+ }
543
+
544
+ /**
545
+ * Inlines a call: the body, lowered against these arguments, in a fresh
546
+ * scope that can see the globals and nothing else the caller had.
547
+ */
548
+ function inline(fn: FuncDecl, args: LV[], e: Extract<Expr, { k: "call" }>): LV {
549
+ const scope = new Scope(global)
550
+ fn.params.forEach((p, i) => {
551
+ const v = args[i]!
552
+ const got = typeof v === "number" ? 1 : v.width
553
+ if (got !== TYPE_WIDTH[p.type]) {
554
+ fail(
555
+ `${fn.name}'s parameter ${p.name} is a ${p.type} and this argument is a ` +
556
+ `${widthType(got as SLType)}`,
557
+ e.args[i]!.pos,
558
+ )
559
+ }
560
+ scope.declare(p.name, { width: TYPE_WIDTH[p.type], value: v })
561
+ })
562
+ const out = exec(fn.body, scope)
563
+ if (out === undefined) fail(`${fn.name} never returns a value`, e.pos)
564
+ const got = typeof out === "number" ? 1 : out.width
565
+ if (got !== TYPE_WIDTH[fn.ret]) {
566
+ fail(`${fn.name} is declared to return a ${fn.ret} and returns a ${widthType(got as SLType)}`, fn.pos)
567
+ }
568
+ return out
569
+ }
570
+
571
+ function construct(type: TypeName, args: LV[], pos: Pos): LV {
572
+ const width = TYPE_WIDTH[type]
573
+ if (width === 1) {
574
+ const v = args[0]!
575
+ const got = typeof v === "number" ? 1 : v.width
576
+ if (args.length !== 1 || got !== 1) {
577
+ fail("float(x) takes one float; use a swizzle to narrow a wider value", pos)
578
+ }
579
+ return v
580
+ }
581
+ if (args.length === 1) {
582
+ const v = args[0]!
583
+ const got = typeof v === "number" ? 1 : v.width
584
+ // HLSL's scalar broadcast, and the identity for a value that is
585
+ // already this wide. `float4(v4)` is `v4`, not four swizzles.
586
+ if (got === width) return v
587
+ if (got === 1) return at(pos, () => compose(width, new Array<LV>(width).fill(v)))
588
+ fail(`${type}(v) broadcasts a float; a ${widthType(got as SLType)} needs its components spelled out`, pos)
589
+ }
590
+ return at(pos, () => compose(width, args))
591
+ }
592
+
593
+ function compose(width: SLType, parts: LV[]): Val {
594
+ const ps = parts as Num[]
595
+ if (width === 2) return sl.vec2(...ps) as unknown as Val
596
+ if (width === 3) return sl.vec3(...ps) as unknown as Val
597
+ return sl.vec4(...ps) as unknown as Val
598
+ }
599
+
600
+ function asVal(v: LV): Val {
601
+ return typeof v === "number" ? (sl.float(v) as unknown as Val) : v
602
+ }
603
+ }
604
+
605
+ type ArithOp = "+" | "-" | "*" | "/" | "%"
606
+
607
+ const ARITH: Record<ArithOp, (a: number, b: number) => number> = {
608
+ "+": (a, b) => a + b,
609
+ "-": (a, b) => a - b,
610
+ "*": (a, b) => a * b,
611
+ "/": (a, b) => a / b,
612
+ // JavaScript's % is the truncated remainder, which is what HLSL's fmod is
613
+ // and what the VM's OP_MOD implements. Folding it in JS is the same answer.
614
+ "%": (a, b) => a % b,
615
+ }
616
+
617
+ const METHOD: Record<ArithOp, "add" | "sub" | "mul" | "div" | "mod"> = {
618
+ "+": "add", "-": "sub", "*": "mul", "/": "div", "%": "mod",
619
+ }
620
+
621
+ function isArith(op: string): op is ArithOp {
622
+ return op in ARITH
623
+ }
624
+
625
+ function widthType(w: SLType): TypeName {
626
+ return (["float", "float2", "float3", "float4"] as const)[w - 1]!
627
+ }
628
+