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/encode.ts ADDED
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Turns a recorded program into the buffer the VM shader evaluates.
3
+ *
4
+ * Phase 2 of `Specs/SHADER_LANG.md`, and the first consumer of the Phase 1 IR.
5
+ * Three passes, all here, all pure TypeScript:
6
+ *
7
+ * 1. reachable drop anything the result does not depend on
8
+ * 2. live ranges the last instruction that reads each value
9
+ * 3. linear scan assign a register, free it when its range ends
10
+ *
11
+ * WHY EIGHT REGISTERS. Phase 0 measured a dynamically indexed register file in a
12
+ * fragment shader on three stacks. On ANGLE D3D11 and on NVIDIA's GL driver,
13
+ * shrinking the file from 16 entries to 8 costs 3.0x to 4.0x less, which is what
14
+ * a spill to per thread local memory looks like. Eight is not a guess, it is the
15
+ * number the hardware asked for.
16
+ *
17
+ * WHY AN INDEXED STORE AND NOT AN UNROLLED CHAIN. At 8 registers the two Windows
18
+ * backends want opposite things: D3D11 is 1.38x to 1.44x FASTER with the indexed
19
+ * store, GL is about 0.84x faster with the unrolled chain, and both reach the
20
+ * same best case on their preferred one. Branching on the renderer would take
21
+ * the best of both and ship a code path that can only ever be tested on one of
22
+ * the two machines that disagree. One shape everywhere has the better worst
23
+ * case, 0.199 against 0.237, and no untestable branch.
24
+ */
25
+
26
+ import {
27
+ MAX_TEXTURES, SLError, TYPE, type NodeRef, type Program, type SLNode, type SLType,
28
+ } from "./ir"
29
+ import { INPUT_ID, SLOP } from "./ops"
30
+ import { emitShader } from "./hlsl"
31
+ import { emitGLSL, emitWGSL } from "./web"
32
+ import { uniformDefaults } from "./sl"
33
+
34
+ // Lives in ops.ts with the other wire constants; re-exported so nothing that
35
+ // reached it through the encoder has to move.
36
+ export { INPUT_ID }
37
+
38
+ /**
39
+ * Registers in the VM's file. Phase 0's answer, not a preference.
40
+ *
41
+ * A program that needs more is refused rather than spilled. A spill slot is a
42
+ * second dynamic index into a second array, and any program that needs one is
43
+ * already past the budget this design is honest about.
44
+ */
45
+ export const REGISTERS = 8
46
+
47
+ /** Instructions in one program. The VM's loop is bounded and must be. */
48
+ export const MAX_INSTRUCTIONS = 256
49
+
50
+ /**
51
+ * Two texels per instruction, fixed width, so instruction `i` sits at texels
52
+ * `2i` and `2i+1` and the shader needs no cursor to advance.
53
+ *
54
+ * Fixed width wastes a texel on instructions with no immediate. That is the
55
+ * right trade: variable width means decoding a length and advancing a cursor,
56
+ * which is a dependent read per instruction and defeats the point of storing the
57
+ * program in a texture at all.
58
+ *
59
+ * texel 2i [ op, dst, a, b ]
60
+ * texel 2i+1 depends on the op:
61
+ * SWIZZLE [ c0, c1, c2, c3 ], unused channels are -1
62
+ * COMPOSE [ c, d, argCount, 0 ], the third and fourth args
63
+ * SAMPLE [ slot, 0, 0, 0 ]
64
+ * FBM [ octaves, 0, 0, 0 ]
65
+ * CONST [ x, y, z, w ]
66
+ * otherwise unused, and zero
67
+ *
68
+ * Every value is a float. Integers ride in floats exactly up to 2^24, which is
69
+ * far above any field here, and the encoder asserts each one is in range rather
70
+ * than trusting it.
71
+ */
72
+ export const TEXELS_PER_INSTRUCTION = 2
73
+
74
+ export interface Encoded {
75
+ /** Flat RGBA float data, `TEXELS_PER_INSTRUCTION * 4` numbers per instruction. */
76
+ data: Float32Array
77
+ instructions: number
78
+ /** Register holding the result when the program halts. */
79
+ resultRegister: number
80
+ /** Registers actually used, for the shader to size nothing and for reporting. */
81
+ registersUsed: number
82
+ /**
83
+ * Uniform names in SLOT ORDER, so a host can set one by name.
84
+ *
85
+ * The VM addresses uniforms by slot and knows nothing about names, and the
86
+ * encoded instructions carry the slot only. Without this the host had a
87
+ * name from the game and no way to turn it into a slot, so it set a
88
+ * material property instead and every uniform stayed at zero.
89
+ */
90
+ uniforms: string[]
91
+ /**
92
+ * Declared uniform defaults, four floats per slot in slot order.
93
+ *
94
+ * The generated shader writes these into its Properties block, so a
95
+ * compiled material starts at them, while the VM's uniform array starts at
96
+ * zero. Without carrying them across, an unset uniform was its default
97
+ * after an eject and 0 in the browser: one program, two pictures, nothing
98
+ * to see in either. The host seeds these right after uploading and lets
99
+ * the caller's own `uniforms` write over them.
100
+ */
101
+ defaults: number[]
102
+ /**
103
+ * Texture names in SLOT order, so a host can bind one by name.
104
+ *
105
+ * The same reason the uniform names are here: the VM addresses a texture by
106
+ * slot and the generated shader declares `_Tex0`, so a host handed the name
107
+ * an author wrote had no way to reach either. Setting a material property
108
+ * called `grain` bound nothing, in both backends, silently.
109
+ */
110
+ textures: string[]
111
+ hash: string
112
+ /**
113
+ * The lowest VM encoding that can run this buffer. A VM older than this
114
+ * refuses the program; see `SL_WIRE_VERSION`.
115
+ */
116
+ wire: number
117
+ /**
118
+ * The program as HLSL, for a host that can compile it.
119
+ *
120
+ * Lazy, and absent from enumeration: in Play nothing ever reads it, so the
121
+ * emitter never runs there. In an editor the host asks for it once per
122
+ * program it has no compiled shader for, records it, and generates the
123
+ * shader, which is how an ejected game ends up compiled without anybody
124
+ * writing a manifest.
125
+ */
126
+ readonly hlsl: string
127
+ /**
128
+ * The program as WGSL and as GLSL ES 3.00, for a browser to compile on
129
+ * Unity's own device (WebGPU and WebGL2 respectively). Lazy and absent
130
+ * from enumeration like `hlsl`: a `.sl` import carries them as plain
131
+ * strings from the build instead, and a host reads only the one its
132
+ * backend needs.
133
+ */
134
+ readonly wgsl: string
135
+ readonly glsl: string
136
+ }
137
+
138
+ interface Instr {
139
+ op: number
140
+ dst: number
141
+ a: number
142
+ b: number
143
+ imm: [number, number, number, number]
144
+ }
145
+
146
+ /**
147
+ * Nodes the result actually depends on, in order.
148
+ *
149
+ * Dead nodes are dropped rather than encoded. An author can produce them easily
150
+ * by computing something and not using it, and the hash already ignores them, so
151
+ * encoding them would make the buffer disagree with its own hash about what the
152
+ * program is.
153
+ */
154
+ export function reachable(nodes: SLNode[], result: NodeRef): NodeRef[] {
155
+ const keep = new Set<NodeRef>()
156
+ const stack = [result]
157
+ while (stack.length > 0) {
158
+ const ref = stack.pop()!
159
+ if (keep.has(ref)) continue
160
+ keep.add(ref)
161
+ const n = nodes[ref]
162
+ if (n.k === "swizzle") stack.push(n.src)
163
+ else if (n.k === "call") for (const a of n.args) stack.push(a)
164
+ }
165
+ // Ascending, which is still topological because a node only refers backwards.
166
+ return [...keep].sort((x, y) => x - y)
167
+ }
168
+
169
+ /**
170
+ * The last position in `order` that reads each node.
171
+ *
172
+ * A register is free the instant its value's last reader has run, which is what
173
+ * lets an eight entry file hold a program with far more than eight values in it.
174
+ */
175
+ export function liveRanges(nodes: SLNode[], order: NodeRef[], result: NodeRef): Map<NodeRef, number> {
176
+ const pos = new Map<NodeRef, number>()
177
+ order.forEach((ref, i) => pos.set(ref, i))
178
+ const last = new Map<NodeRef, number>()
179
+ order.forEach((ref, i) => {
180
+ const n = nodes[ref]
181
+ const reads: NodeRef[] = n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
182
+ for (const r of reads) last.set(r, i)
183
+ })
184
+ // The result outlives every instruction, or it could be freed and overwritten
185
+ // before anybody reads it.
186
+ last.set(result, order.length)
187
+ for (const ref of order) if (!last.has(ref)) last.set(ref, pos.get(ref)!)
188
+ return last
189
+ }
190
+
191
+ /**
192
+ * Names the loops behind an over-long program. A loop unrolls, so a count that
193
+ * reads as one line of source is emitted once per iteration, and without this
194
+ * the error blames "instructions" the author never wrote. A loop is named only
195
+ * when it fills a quarter of the budget or more: a two iteration loop in a
196
+ * program made long by other code is not the thing to lower.
197
+ *
198
+ * Worded for both surfaces: the span comes from `sl.repeat` in the EDSL and
199
+ * from `for` in a `.sl` file, and an author who wrote one should not be told
200
+ * about the other.
201
+ */
202
+ function blameLoops(program: Program, order: NodeRef[]): string {
203
+ const parts: string[] = []
204
+ for (const loop of program.loops ?? []) {
205
+ const ops = order.filter((r) => r >= loop.start && r < loop.end).length
206
+ if (ops * 4 >= MAX_INSTRUCTIONS) parts.push(`a loop of ${loop.count} accounts for ${ops}`)
207
+ }
208
+ if (parts.length === 0) return ""
209
+ return ` A loop unrolls, so every iteration is emitted in full: ${parts.join(", ")}. ` +
210
+ `Lower the count or slim the body.`
211
+ }
212
+
213
+ /**
214
+ * The program as the VM runs it.
215
+ *
216
+ * The IR carries a shape's whole parameter list, up to six. One instruction
217
+ * holds a shape id and four immediates, so a shape given a fifth or sixth
218
+ * becomes SDF_WIDE: its first two parameters stay immediates and the other
219
+ * four come from a constant register. Only the VM sees this; every compiled
220
+ * backend reads the IR's SDF directly. The hash is the program's own, since
221
+ * nothing about what it computes changed.
222
+ */
223
+ export function forVm(program: Program): Program {
224
+ const wide = (n: SLNode) => n.k === "call" && n.op === SLOP.SDF && (n.imm?.length ?? 0) > 5
225
+ if (!program.nodes.some(wide)) return program
226
+ const nodes: SLNode[] = []
227
+ const map: NodeRef[] = []
228
+ program.nodes.forEach((n, i) => {
229
+ if (n.k === "call" && wide(n)) {
230
+ const im = n.imm!
231
+ const rest = nodes.push({ k: "const", type: TYPE.VEC4, v: [im[3] ?? 0, im[4] ?? 0, im[5] ?? 0, im[6] ?? 0] }) - 1
232
+ map[i] = nodes.push({
233
+ k: "call", type: n.type, op: SLOP.SDF_WIDE, args: [map[n.args[0]!]!, rest], imm: [im[0] ?? 0, im[1] ?? 0, im[2] ?? 0, 0],
234
+ }) - 1
235
+ return
236
+ }
237
+ if (n.k === "swizzle") map[i] = nodes.push({ ...n, src: map[n.src]! }) - 1
238
+ else if (n.k === "call") map[i] = nodes.push({ ...n, args: n.args.map((a) => map[a]!) }) - 1
239
+ else map[i] = nodes.push(n) - 1
240
+ })
241
+ const at = (ref: NodeRef) => ref < map.length ? map[ref]! : nodes.length
242
+ return { ...program, nodes, result: map[program.result]!, loops: program.loops.map((l) => ({ ...l, start: at(l.start), end: at(l.end) })) }
243
+ }
244
+
245
+ /**
246
+ * The limits `encode` refuses a program for that are not about registers: its
247
+ * length and its sampler slots. Shared with `vmFit`, so the two cannot
248
+ * disagree about what fits.
249
+ */
250
+ export function checkBudget(program: Program, order: NodeRef[]): void {
251
+ if (order.length > MAX_INSTRUCTIONS) {
252
+ throw new SLError(
253
+ `this program is ${order.length} operations and the VM runs at most ${MAX_INSTRUCTIONS}. ` +
254
+ `The loop in the shader is bounded and has to be.` + blameLoops(program, order),
255
+ )
256
+ }
257
+ for (const t of program.textures) {
258
+ if (t.slot >= MAX_TEXTURES) throw new SLError(`texture "${t.name}" is past the sampler budget`)
259
+ }
260
+ }
261
+
262
+ /**
263
+ * A register for every node in `order`, by linear scan, and the most in use
264
+ * at once. Throws when the file runs out. Shared with `vmFit`.
265
+ */
266
+ export function allocate(program: Program, order: NodeRef[]): { reg: Map<NodeRef, number>, peak: number } {
267
+ const last = liveRanges(program.nodes, order, program.result)
268
+ const reg = new Map<NodeRef, number>()
269
+ const free: number[] = []
270
+ for (let r = REGISTERS - 1; r >= 0; r--) free.push(r)
271
+ /** Which node currently owns each register, for freeing. */
272
+ const owner = new Array<NodeRef | null>(REGISTERS).fill(null)
273
+ let peak = 0
274
+
275
+ order.forEach((ref, i) => {
276
+ // Free first, so a value whose last reader is THIS instruction can hand
277
+ // its register straight to this instruction's result. Without that an
278
+ // eight register file would behave like a seven register one on every
279
+ // chained expression, which is most of them.
280
+ for (let r = 0; r < REGISTERS; r++) {
281
+ const o = owner[r]
282
+ if (o !== null && last.get(o)! < i) { owner[r] = null; free.push(r) }
283
+ }
284
+
285
+ const args = readsOf(program.nodes[ref]).map((r) => {
286
+ const rr = reg.get(r)
287
+ if (rr === undefined) throw new SLError(`internal: node ${r} has no register when ${ref} needs it`)
288
+ return rr
289
+ })
290
+
291
+ // Operands are read before the destination is written, so a register
292
+ // whose last use is this instruction can also be the destination.
293
+ for (let r = 0; r < REGISTERS; r++) {
294
+ const o = owner[r]
295
+ if (o !== null && last.get(o)! === i && !reg.has(ref)) {
296
+ // Only reclaim a register this instruction is actually reading;
297
+ // anything else is still live for a later instruction.
298
+ if (args.includes(r)) { owner[r] = null; free.push(r) }
299
+ }
300
+ }
301
+
302
+ const dst = free.pop()
303
+ if (dst === undefined) {
304
+ throw new SLError(
305
+ `this program needs more than ${REGISTERS} registers at once. The VM has ${REGISTERS} ` +
306
+ `because that is what the hardware measured fastest, and spilling is deliberately not ` +
307
+ `implemented: a spill slot is a second dynamic index into a second array. Split the ` +
308
+ `program, or reuse fewer intermediate values at the same time.`,
309
+ )
310
+ }
311
+ owner[dst] = ref
312
+ reg.set(ref, dst)
313
+ peak = Math.max(peak, REGISTERS - free.length)
314
+ })
315
+ return { reg, peak }
316
+ }
317
+
318
+ function readsOf(n: SLNode): NodeRef[] {
319
+ return n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
320
+ }
321
+
322
+ /**
323
+ * The lowest VM that can run `vm`, the result of `forVm(source)`: 2 only where
324
+ * a wide shape made SDF_WIDE, so everything else still runs on a wire 1 VM.
325
+ */
326
+ export function wireOf(source: Program, vm: Program): number {
327
+ return vm === source ? 1 : 2
328
+ }
329
+
330
+ export function encode(source: Program): Encoded {
331
+ const program = forVm(source)
332
+ const order = reachable(program.nodes, program.result)
333
+ checkBudget(program, order)
334
+ const { reg, peak } = allocate(program, order)
335
+
336
+ const out = order.map((ref) => {
337
+ const n = program.nodes[ref]
338
+ const reads = readsOf(n)
339
+ // The width of the first operand, for the ops whose result narrows.
340
+ const srcWidth = reads.length > 0 ? program.nodes[reads[0]].type : undefined
341
+ return emit(n, reg.get(ref)!, reads.map((r) => reg.get(r)!), srcWidth)
342
+ })
343
+
344
+ const data = new Float32Array(out.length * TEXELS_PER_INSTRUCTION * 4)
345
+ out.forEach((ins, i) => {
346
+ const o = i * 8
347
+ data[o] = ins.op; data[o + 1] = ins.dst; data[o + 2] = ins.a; data[o + 3] = ins.b
348
+ data[o + 4] = ins.imm[0]; data[o + 5] = ins.imm[1]
349
+ data[o + 6] = ins.imm[2]; data[o + 7] = ins.imm[3]
350
+ })
351
+
352
+ const encoded = {
353
+ data,
354
+ instructions: out.length,
355
+ resultRegister: reg.get(program.result)!,
356
+ registersUsed: peak,
357
+ // Slot order, which is declaration order: Builder.uniform pushes and
358
+ // uses the resulting index as the slot.
359
+ uniforms: program.uniforms.map((u) => u.name),
360
+ defaults: uniformDefaults(program),
361
+ textures: program.textures.map((t) => t.name),
362
+ hash: program.hash,
363
+ wire: wireOf(source, program),
364
+ } as Encoded
365
+ let hlsl: string | undefined
366
+ let wgsl: string | undefined
367
+ let glsl: string | undefined
368
+ Object.defineProperty(encoded, "hlsl", {
369
+ enumerable: false,
370
+ get: () => (hlsl ??= emitShader(source)),
371
+ })
372
+ Object.defineProperty(encoded, "wgsl", {
373
+ enumerable: false,
374
+ get: () => (wgsl ??= emitWGSL(source)),
375
+ })
376
+ Object.defineProperty(encoded, "glsl", {
377
+ enumerable: false,
378
+ get: () => (glsl ??= emitGLSL(source)),
379
+ })
380
+ return encoded
381
+ }
382
+
383
+ const NONE: [number, number, number, number] = [0, 0, 0, 0]
384
+
385
+ /**
386
+ * Ops whose result is narrower than their input, so the shader cannot infer how
387
+ * many components to read from the destination type.
388
+ */
389
+ const WIDTH_SENSITIVE = new Set<number>([
390
+ SLOP.LENGTH, SLOP.DISTANCE, SLOP.DOT, SLOP.NORMALIZE, SLOP.LUMINANCE,
391
+ ])
392
+
393
+ function emit(n: SLNode, dst: number, args: number[], srcWidth?: SLType): Instr {
394
+ switch (n.k) {
395
+ case "const": {
396
+ const v = n.v
397
+ return { op: SLOP.CONST, dst, a: 0, b: n.type, imm: [v[0] ?? 0, v[1] ?? 0, v[2] ?? 0, v[3] ?? 0] }
398
+ }
399
+ case "input":
400
+ return { op: SLOP.INPUT, dst, a: INPUT_ID[n.name], b: n.type, imm: [...NONE] }
401
+ case "uniform":
402
+ return { op: SLOP.UNIFORM, dst, a: n.slot, b: n.type, imm: [...NONE] }
403
+ case "swizzle": {
404
+ const c = n.chans
405
+ return {
406
+ op: SLOP.SWIZZLE, dst, a: args[0], b: n.type,
407
+ imm: [c[0] ?? -1, c[1] ?? -1, c[2] ?? -1, c[3] ?? -1],
408
+ }
409
+ }
410
+ case "call": {
411
+ if (n.op === SLOP.COMPOSE) {
412
+ if (args.length > 4) throw new SLError(`compose takes at most 4 parts, got ${args.length}`)
413
+ return {
414
+ op: SLOP.COMPOSE, dst, a: args[0] ?? 0, b: args[1] ?? 0,
415
+ imm: [args[2] ?? 0, args[3] ?? 0, args.length, n.type],
416
+ }
417
+ }
418
+ // Ops whose RESULT is narrower than their input need the source
419
+ // width, because a register is a float4 whatever the value in it is
420
+ // and length(vec2) is not length of four components. Found by
421
+ // writing the shader, not by writing the encoder: nothing in the IR
422
+ // is wrong, the information simply was not reaching the consumer.
423
+ if (WIDTH_SENSITIVE.has(n.op)) {
424
+ const src = srcWidth
425
+ if (src === undefined) throw new SLError(`internal: ${n.op} has no source width`)
426
+ return { op: n.op, dst, a: args[0] ?? 0, b: args[1] ?? 0, imm: [src, 0, 0, 0] }
427
+ }
428
+ // SDF carries a shape id plus four parameters, which is one more
429
+ // number than the immediate holds. The id goes in the second
430
+ // operand slot, free because SDF reads one register.
431
+ if (n.op === SLOP.SDF) {
432
+ const im = n.imm ?? []
433
+ return {
434
+ op: SLOP.SDF, dst, a: args[0] ?? 0, b: im[0] ?? 0,
435
+ imm: [im[1] ?? 0, im[2] ?? 0, im[3] ?? 0, im[4] ?? 0],
436
+ }
437
+ }
438
+ if (args.length > 3) {
439
+ throw new SLError(`internal: ${n.op} has ${args.length} operands and the encoding holds 3`)
440
+ }
441
+ // A third operand rides in the immediate, which is free for every op
442
+ // that has one (clamp, mix, smoothstep, select) because none of them
443
+ // carries a literal.
444
+ const imm: [number, number, number, number] = n.imm !== undefined
445
+ ? [n.imm[0] ?? 0, n.imm[1] ?? 0, n.imm[2] ?? 0, n.imm[3] ?? 0]
446
+ : [args[2] ?? 0, 0, 0, 0]
447
+ return { op: n.op, dst, a: args[0] ?? 0, b: args[1] ?? 0, imm }
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Every field must survive a float32 round trip exactly, or an instruction
454
+ * decodes as a different instruction. Cheap to check and impossible to debug
455
+ * from the wrong picture it would otherwise produce.
456
+ */
457
+ export function checkEncodable(v: number, what: string): void {
458
+ if (!Number.isFinite(v)) throw new SLError(`${what} is ${v}, which cannot be encoded`)
459
+ if (Number.isInteger(v) && Math.abs(v) > 0x1000000) {
460
+ throw new SLError(`${what} is ${v}, past the 2^24 an integer survives in a float`)
461
+ }
462
+ }