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/tables.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `onejs-sl/tables`: what completion and highlighting read.
3
+ *
4
+ * The same tables the checker and the emitters use, so an editor offers
5
+ * exactly the builtins that compile, with the arities they take.
6
+ */
7
+ export { BUILTINS, NOT_YET } from "./lang/builtins"
8
+ export type { Builtin } from "./lang/builtins"
9
+ export {
10
+ SLOP, SL_ARITY, SL_NAME, SL_HLSL, SL_CALL_NAMES, SL_GLSL_HINT, SL_UNIMPLEMENTED,
11
+ } from "./ops"
12
+ export type { SLSurface } from "./ops"
13
+ export { INPUTS } from "./ir"
14
+ export { SL_SDF_SHAPES, SL_SDF_PARAMS } from "./shapes"
15
+ export type { SlSdfKind } from "./shapes"
package/src/vm.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `onejs-sl/vm`: the buffer OneJS's shader language VM evaluates.
3
+ *
4
+ * Only a host that runs the VM needs this. `SL_WIRE_VERSION` is the newest
5
+ * encoding it writes; each result's `wire` is the oldest VM that can run it.
6
+ */
7
+ export {
8
+ encode, forVm, reachable, liveRanges, REGISTERS, MAX_INSTRUCTIONS, TEXELS_PER_INSTRUCTION,
9
+ } from "./encode"
10
+ export type { Encoded } from "./encode"
11
+ export { SL_WIRE_VERSION, INPUT_ID, VM_UNIFORMS, VM_TEXTURES } from "./ops"
package/src/web.ts ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The web backends: prints a program as GLSL ES 3.00 or WGSL, for a browser to
3
+ * compile at runtime on Unity's own graphics device.
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
10
+ * OneJS (`Plugins/WebGL/OneJSSLWeb.jslib`); this file only prints them.
11
+ *
12
+ * Structurally the HLSL emitter again: one local per reachable node, in order,
13
+ * named `n<index>`. What differs is the contract with the host, which is fixed
14
+ * and the same for both languages:
15
+ *
16
+ * sl_Res (target width, target height, seconds, 0)
17
+ * sl_Opt (1 in a Linear colour space project else 0, 1 to flip uv.y, 0, 0)
18
+ * sl_U[16] uniform slots, each a vec4, in the program's slot order
19
+ * textures by slot: GLSL `sl_Tex<slot>`; WGSL `sl_tex<slot>` at binding
20
+ * 2 + 2 * slot with its own sampler `sl_samp<slot>` at 1 + 2 * slot,
21
+ * all in group 0 beside the frame block at binding 0. Only the
22
+ * slots the program samples are declared.
23
+ *
24
+ * WGSL also carries its vertex stage (`sl_vs`, a full-target triangle from the
25
+ * vertex index) so one module is one pipeline; the GLSL host supplies its own.
26
+ */
27
+
28
+ import { SLError, TYPE, type Program, type SLNode, type SLType } from "./ir"
29
+ import { INPUT_ID, SLOP, VM_UNIFORMS } from "./ops"
30
+ import { SDF_CALLS, WEB_LIB } from "./weblib"
31
+
32
+ export type WebLanguage = "glsl" | "wgsl"
33
+
34
+ /** Uniform slots the host always provides, so every program shares one layout. */
35
+ export const WEB_UNIFORM_SLOTS = VM_UNIFORMS
36
+
37
+ export function emitGLSL(p: Program): string {
38
+ return emitWeb(p, "glsl")
39
+ }
40
+
41
+ export function emitWGSL(p: Program): string {
42
+ return emitWeb(p, "wgsl")
43
+ }
44
+
45
+ function emitWeb(p: Program, lang: WebLanguage): string {
46
+ const W = lang === "wgsl"
47
+ const need = new Set<string>()
48
+ const sampled = new Set<number>()
49
+ const lines: string[] = []
50
+ const emitted = new Set<number>()
51
+ const T = (t: SLType) => (W ? (t === 1 ? "f32" : `vec${t}f`) : t === 1 ? "float" : `vec${t}`)
52
+ const typeOf = (ref: number) => p.nodes[ref].type
53
+
54
+ /** A node's value widened to `t` when it is a scalar and `t` is not. */
55
+ const splat = (ref: number, t: SLType) => (typeOf(ref) === 1 && t > 1 ? `${T(t)}(n${ref})` : `n${ref}`)
56
+ /** A literal at width `t`. */
57
+ const k = (v: number, t: SLType) => (t > 1 ? `${T(t)}(${lit(v)})` : lit(v))
58
+
59
+ const walk = (ref: number): void => {
60
+ if (emitted.has(ref)) return
61
+ const n = p.nodes[ref]
62
+ const deps = n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
63
+ for (const d of deps) walk(d)
64
+ emitted.add(ref)
65
+ const e = expr(n)
66
+ lines.push(W ? ` let n${ref}: ${T(n.type)} = ${e};` : ` ${T(n.type)} n${ref} = ${e};`)
67
+ }
68
+
69
+ const expr = (n: SLNode): string => {
70
+ switch (n.k) {
71
+ case "const": return n.type === 1 ? lit(n.v[0]) : `${T(n.type)}(${n.v.map(lit).join(", ")})`
72
+ case "input": {
73
+ const res = W ? "sl.res" : "sl_Res"
74
+ switch (INPUT_ID[n.name]) {
75
+ case 0: return "sl_uv"
76
+ case 1: return `(sl_uv * ${res}.xy)`
77
+ case 2: return `${res}.xy`
78
+ case 3: return `${res}.z`
79
+ default: return `(${res}.x / max(${res}.y, 1.0))`
80
+ }
81
+ }
82
+ case "uniform": {
83
+ const u = W ? `sl.u[${n.slot}]` : `sl_U[${n.slot}]`
84
+ return n.type === TYPE.VEC4 ? u : `${u}.${"xyzw".slice(0, n.type)}`
85
+ }
86
+ case "swizzle": {
87
+ // A scalar has one component and no swizzle in either language.
88
+ if (typeOf(n.src) === 1) return n.type === 1 ? `n${n.src}` : `${T(n.type)}(n${n.src})`
89
+ return `n${n.src}.${n.chans.map((c) => "xyzw"[c]).join("")}`
90
+ }
91
+ case "call": return call(n)
92
+ }
93
+ }
94
+
95
+ const call = (n: Extract<SLNode, { k: "call" }>): string => {
96
+ const t = n.type
97
+ const a = n.args.map((r) => `n${r}`)
98
+ // Every argument at the result's width, for the element wise ops. WGSL
99
+ // refuses min(vec2f, f32) where HLSL and GLSL broadcast.
100
+ const s = n.args.map((r) => splat(r, t))
101
+ const w0 = n.args.length > 0 ? typeOf(n.args[0]) : 1
102
+ const imm = n.imm ?? []
103
+ const lib = (name: string) => { need.add(name); return name }
104
+ switch (n.op) {
105
+ case SLOP.COMPOSE: return t === 1 ? a[0] : `${T(t)}(${a.join(", ")})`
106
+
107
+ case SLOP.ADD: return `(${s[0]} + ${s[1]})`
108
+ case SLOP.SUB: return `(${s[0]} - ${s[1]})`
109
+ case SLOP.MUL: return `(${s[0]} * ${s[1]})`
110
+ case SLOP.DIV: return `(${s[0]} / ${s[1]})`
111
+ // HLSL fmod: truncating, sign of the dividend.
112
+ case SLOP.MOD: return `(${s[0]} - ${s[1]} * trunc(${s[0]} / ${s[1]}))`
113
+ case SLOP.POW: return `pow(abs(${s[0]}), ${s[1]})`
114
+ case SLOP.NEG: return `(-${a[0]})`
115
+ case SLOP.RECIP: return `(${k(1, t)} / ${a[0]})`
116
+
117
+ case SLOP.SIN: return `sin(${a[0]})`
118
+ case SLOP.COS: return `cos(${a[0]})`
119
+ case SLOP.TAN: return `tan(${a[0]})`
120
+ case SLOP.ASIN: return `asin(clamp(${a[0]}, ${k(-1, t)}, ${k(1, t)}))`
121
+ case SLOP.ACOS: return `acos(clamp(${a[0]}, ${k(-1, t)}, ${k(1, t)}))`
122
+ case SLOP.ATAN2: return W ? `atan2(${s[0]}, ${s[1]})` : `atan(${s[0]}, ${s[1]})`
123
+ case SLOP.EXP: return `exp(${a[0]})`
124
+ case SLOP.LOG: return `log(max(${a[0]}, ${k(1e-8, t)}))`
125
+ case SLOP.SQRT: return `sqrt(max(${a[0]}, ${k(0, t)}))`
126
+ case SLOP.ABS: return `abs(${a[0]})`
127
+ case SLOP.SIGN: return `sign(${a[0]})`
128
+ case SLOP.FLOOR: return `floor(${a[0]})`
129
+ case SLOP.CEIL: return `ceil(${a[0]})`
130
+ case SLOP.ROUND: return `round(${a[0]})`
131
+ case SLOP.FRACT: return `fract(${a[0]})`
132
+ case SLOP.MIN: return `min(${s[0]}, ${s[1]})`
133
+ case SLOP.MAX: return `max(${s[0]}, ${s[1]})`
134
+ case SLOP.CLAMP: return `clamp(${s[0]}, ${s[1]}, ${s[2]})`
135
+ case SLOP.SATURATE: return `clamp(${a[0]}, ${k(0, t)}, ${k(1, t)})`
136
+
137
+ // HLSL takes these of a scalar; WGSL does not, so the scalar case is
138
+ // written out as what HLSL computes for it.
139
+ case SLOP.LENGTH: return w0 === 1 ? `abs(${a[0]})` : `length(${a[0]})`
140
+ case SLOP.DISTANCE: return w0 === 1 ? `abs(${a[0]} - ${a[1]})` : `distance(${a[0]}, ${a[1]})`
141
+ case SLOP.DOT: return w0 === 1 ? `(${a[0]} * ${a[1]})` : `dot(${a[0]}, ${a[1]})`
142
+ case SLOP.NORMALIZE: return w0 === 1 ? `sign(${a[0]})` : `normalize(${a[0]})`
143
+ case SLOP.CROSS: return `cross(${a[0]}, ${a[1]})`
144
+ case SLOP.REFLECT: return w0 === 1
145
+ ? `(${a[0]} - 2.0 * ${a[1]} * ${a[0]} * ${a[1]})`
146
+ : `reflect(${a[0]}, ${a[1]})`
147
+ case SLOP.LUMINANCE: return `dot(${a[0]}.rgb, ${T(3)}(0.2126, 0.7152, 0.0722))`
148
+ case SLOP.TO_LINEAR: return `${lib(`sl_toLinear${t}`)}(${a[0]})`
149
+
150
+ case SLOP.MIX: return `mix(${s[0]}, ${s[1]}, ${s[2]})`
151
+ case SLOP.STEP: return `step(${s[0]}, ${s[1]})`
152
+ case SLOP.SMOOTHSTEP: return `${lib(`sl_smoothstep${t}`)}(${s[0]}, ${s[1]}, ${s[2]})`
153
+ // As the VM and the HLSL emitter do it: branchless, so every
154
+ // backend agrees on the edge value.
155
+ case SLOP.SELECT: {
156
+ const c = typeOf(n.args[0])
157
+ return `mix(${splat(n.args[2], t)}, ${splat(n.args[1], t)}, step(${k(0.5, c)}, ${a[0]}))`
158
+ }
159
+
160
+ case SLOP.HSV2RGB: return `${lib("sl_hsv2rgb")}(${a[0]})`
161
+ case SLOP.NOISE: return `${lib("oj_vnoise")}(${a[0]}, 0.0)`
162
+ case SLOP.SIMPLEX: return `${lib("oj_simplex")}(${a[0]}, 0.0)`
163
+ case SLOP.FBM: return octaveCall(Math.round(imm[1] ?? 0), a[0], imm[0])
164
+ case SLOP.TURBULENCE: return octaveCall(2, a[0], imm[0])
165
+ case SLOP.RIDGED: return octaveCall(3, a[0], imm[0])
166
+ case SLOP.VORONOI: return `${lib("sl_voronoi")}(${a[0]})`
167
+ case SLOP.SDF: return sdfCall(Math.round(imm[0] ?? 0), a[0], imm)
168
+ case SLOP.SAMPLE: {
169
+ const slot = Math.round(imm[0] ?? 0)
170
+ sampled.add(slot)
171
+ return W ? `textureSample(sl_tex${slot}, sl_samp${slot}, ${a[0]})` : `texture(sl_Tex${slot}, ${a[0]})`
172
+ }
173
+ default:
174
+ throw new SLError(
175
+ `the ${lang.toUpperCase()} emitter has no case for opcode ${n.op}. A program using it would ` +
176
+ `differ between the VM and a compiled build, which is the one failure this design cannot tolerate.`,
177
+ )
178
+ }
179
+
180
+ /** Noise2D.cginc's onejsFbmKind, resolved here since the kind is a constant. */
181
+ function octaveCall(kind: number, pt: string, octaves: number | undefined): string {
182
+ const o = Math.min(4, Math.max(1, Math.round(octaves ?? 3)))
183
+ const fn = kind === 2 ? "oj_turbulence" : kind === 3 ? "oj_ridged" : kind === 1 ? "oj_fbmSimplex" : "oj_fbm"
184
+ return `${lib(fn)}(${pt}, 0.0, ${o})`
185
+ }
186
+
187
+ /** The shape is a constant, so this calls it directly instead of SLCommon's switch. */
188
+ function sdfCall(id: number, pt: string, im: number[]): string {
189
+ const shape = SDF_CALLS[id]
190
+ // SLCommon's dispatcher returns 1e6 for an id it does not know.
191
+ if (shape === undefined) return lit(1e6)
192
+ lib(shape.fn)
193
+ const v = [im[1] ?? 0, im[2] ?? 0, im[3] ?? 0, im[4] ?? 0, im[5] ?? 0, im[6] ?? 0]
194
+ const args = shape.args.map((arg) => {
195
+ if (!Array.isArray(arg)) return String(Math.trunc(v[arg.int]))
196
+ const parts = arg.map((i) => lit(v[i]))
197
+ return parts.length === 1 ? parts[0] : `${T(parts.length as SLType)}(${parts.join(", ")})`
198
+ })
199
+ return `${shape.fn}(${[pt, ...args].join(", ")})`
200
+ }
201
+ }
202
+
203
+ walk(p.result)
204
+ const body = lines.join("\n")
205
+ const library = librarySource(need, lang)
206
+ const header = `// GENERATED from a shader language program (${p.hash}). Do not edit.`
207
+ // Only the textures the program samples. WebGPU's automatic layout leaves
208
+ // out a binding the shader never reads, and a bind group that supplies one
209
+ // anyway does not validate; the host reads these declarations back to know
210
+ // which slots to bind.
211
+ const textures = p.textures.filter((t) => sampled.has(t.slot))
212
+
213
+ if (W) {
214
+ const bindings = textures.map((t) =>
215
+ `@group(0) @binding(${1 + 2 * t.slot}) var sl_samp${t.slot}: sampler;\n` +
216
+ `@group(0) @binding(${2 + 2 * t.slot}) var sl_tex${t.slot}: texture_2d<f32>;`).join("\n")
217
+ return `${header}
218
+ struct SLFrame { res: vec4f, opt: vec4f, u: array<vec4f, ${WEB_UNIFORM_SLOTS}> }
219
+ @group(0) @binding(0) var<uniform> sl: SLFrame;
220
+ ${bindings}
221
+ ${library}
222
+ @vertex fn sl_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
223
+ let c = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
224
+ return vec4f(c * 2.0 - 1.0, 0.0, 1.0);
225
+ }
226
+ @fragment fn sl_fs(@builtin(position) sl_pos: vec4f) -> @location(0) vec4f {
227
+ var sl_uv = sl_pos.xy / sl.res.xy;
228
+ if (sl.opt.y > 0.5) { sl_uv.y = 1.0 - sl_uv.y; }
229
+ ${body}
230
+ return n${p.result};
231
+ }
232
+ `
233
+ }
234
+ const samplers = textures.map((t) => `uniform sampler2D sl_Tex${t.slot};`).join("\n")
235
+ return `#version 300 es
236
+ ${header}
237
+ precision highp float;
238
+ precision highp int;
239
+ uniform vec4 sl_Res;
240
+ uniform vec4 sl_Opt;
241
+ uniform vec4 sl_U[${WEB_UNIFORM_SLOTS}];
242
+ ${samplers}
243
+ out vec4 sl_Out;
244
+ ${library}
245
+ void main() {
246
+ vec2 sl_uv = gl_FragCoord.xy / sl_Res.xy;
247
+ if (sl_Opt.y > 0.5) sl_uv.y = 1.0 - sl_uv.y;
248
+ ${body}
249
+ sl_Out = n${p.result};
250
+ }
251
+ `
252
+ }
253
+
254
+ /** The needed entries and everything they depend on, in library order. */
255
+ function librarySource(need: Set<string>, lang: WebLanguage): string {
256
+ const want = new Set<string>()
257
+ const byName = new Map(WEB_LIB.map((e) => [e.name, e]))
258
+ const add = (name: string) => {
259
+ if (want.has(name)) return
260
+ const entry = byName.get(name)
261
+ if (entry === undefined) throw new SLError(`internal: the web library has no entry "${name}"`)
262
+ want.add(name)
263
+ for (const d of entry.deps) add(d)
264
+ }
265
+ for (const n of need) add(n)
266
+ return WEB_LIB.filter((e) => want.has(e.name)).map((e) => (lang === "wgsl" ? e.wgsl : e.glsl).trim()).join("\n")
267
+ }
268
+
269
+ /** A literal that survives a float32 round trip and never reads as an int. */
270
+ function lit(n: number): string {
271
+ if (!Number.isFinite(n)) throw new SLError(`cannot emit ${n} as a shader literal`)
272
+ return Number.isInteger(n) ? n.toFixed(1) : String(n)
273
+ }