onejs-sl 0.1.0 → 0.1.2

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/body.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * A program as the body of a function, in the HLSL and Metal shared subset,
3
+ * for any host that supplies the frame around it.
4
+ *
5
+ * `Specs/SL_PACKAGE.md` section 4. OneJS's Unity shader (`hlsl.ts`) is one
6
+ * frame over this and Magerie's compute kernel is another, so HLSL has one
7
+ * emitter rather than one per host. A target says only what differs between
8
+ * hosts: how an input, a uniform slot and a texture sample are spelled, and
9
+ * whether `toLinear` is real.
10
+ *
11
+ * The subset is HLSL's spelling (`float2`, `lerp`, `frac`, `fmod`, `atan2`,
12
+ * `saturate`), which Metal takes through a host's compatibility defines. `fmod`
13
+ * is the same truncating remainder in both. Nothing here uses `mul`, `static`,
14
+ * derivatives, or an overload the library does not resolve by width. The
15
+ * library text `emitLibrary` prints is the same subset, translated from
16
+ * `lib/*.hlsl` by `lib/translate.ts`.
17
+ *
18
+ * One local per reachable node, in order, named `n<index>` and unreadable on
19
+ * purpose: generated code that looks hand written invites hand editing, and a
20
+ * hand edit is lost the next time it is generated. One local per node also
21
+ * gives common subexpression elimination for free.
22
+ */
23
+
24
+ import { SLError, TYPE, type InputName, type Program, type SLNode, type SLType } from "./ir"
25
+ import { libClosure, LIB_FUNCTIONS } from "./lib"
26
+ import { LIB_HLSL } from "./lib/hlsl"
27
+ import { SLOP } from "./ops"
28
+
29
+ export interface BodyTarget {
30
+ /** An expression for each input, e.g. `{ uv: "SL_UV", time: "SL_TIME", ... }`. */
31
+ inputs: Record<InputName, string>
32
+ /** The float4 holding a uniform slot. The body swizzles it down to the uniform's width. */
33
+ uniform: (slot: number, name: string) => string
34
+ /** A float4 sample of the texture in `slot` at the float2 expression `uv`, straight alpha. */
35
+ sample: (slot: number, uv: string) => string
36
+ /**
37
+ * `linear`: `toLinear` calls `sl_toLinear`, for a host whose target holds
38
+ * linear light. `gamma`: `toLinear` is the identity, decided here, so hex
39
+ * colours and ramps stay as written.
40
+ */
41
+ colour: "gamma" | "linear"
42
+ /** Assign the result to this local instead of returning it. */
43
+ result?: string
44
+ /** Put in front of every line. Default four spaces. */
45
+ indent?: string
46
+ }
47
+
48
+ export interface Body {
49
+ body: string
50
+ uses: {
51
+ /** Uniform slots the body reads, ascending. */
52
+ uniforms: number[]
53
+ /** Texture slots the body samples, ascending. */
54
+ textures: number[]
55
+ /** Library functions the body calls, sorted: `sl_fbm`, `sl_sdfDistance`, ... `emitLibrary` prints them. */
56
+ helpers: string[]
57
+ }
58
+ }
59
+
60
+ const HLSL_TYPE: Record<SLType, string> = { 1: "float", 2: "float2", 3: "float3", 4: "float4" }
61
+
62
+ /** A literal that survives a float32 round trip and never reads as an int. */
63
+ export function lit(n: number): string {
64
+ if (!Number.isFinite(n)) throw new SLError(`cannot emit ${n} as a shader literal`)
65
+ const s = Number.isInteger(n) ? n.toFixed(1) : String(n)
66
+ return s
67
+ }
68
+
69
+ function ctor(type: SLType, parts: string[]): string {
70
+ return type === TYPE.FLOAT ? parts[0] : `${HLSL_TYPE[type]}(${parts.join(", ")})`
71
+ }
72
+
73
+ const SWZ = "xyzw"
74
+
75
+ export function emitBody(p: Program, target: BodyTarget): Body {
76
+ const indent = target.indent ?? " "
77
+ const lines: string[] = []
78
+ const name = (ref: number) => `n${ref}`
79
+ const emitted = new Set<number>()
80
+ const uniforms = new Set<number>()
81
+ const textures = new Set<number>()
82
+ const helpers = new Set<string>()
83
+ const helper = (fn: string) => { helpers.add(fn); return fn }
84
+ /** A node at width `w`: a scalar repeated into a constructor, since HLSL refuses float3(x) and Metal x.xxx. */
85
+ const splat = (ref: number, w: SLType) => (p.nodes[ref]!.type === TYPE.FLOAT && w > 1 ? ctor(w, Array(w).fill(name(ref))) : name(ref))
86
+
87
+ const expr = (n: SLNode): string => {
88
+ switch (n.k) {
89
+ case "const":
90
+ return ctor(n.type, n.v.map(lit))
91
+ case "input":
92
+ return target.inputs[n.name]
93
+ case "uniform": {
94
+ const u = p.uniforms[n.slot]
95
+ if (u === undefined) throw new SLError(`a node reads uniform slot ${n.slot}, which is not declared`)
96
+ uniforms.add(n.slot)
97
+ const v = target.uniform(n.slot, u.name)
98
+ return n.type === TYPE.VEC4 ? v : `${v}.${SWZ.slice(0, n.type)}`
99
+ }
100
+ case "swizzle":
101
+ // Metal has no swizzle of a scalar, so a scalar widens by constructor.
102
+ if (p.nodes[n.src]!.type === TYPE.FLOAT) return ctor(n.type, n.chans.map(() => name(n.src)))
103
+ return `${name(n.src)}.${n.chans.map((c) => SWZ[c]).join("")}`
104
+ case "call":
105
+ return call(n)
106
+ }
107
+ }
108
+
109
+ const call = (n: Extract<SLNode, { k: "call" }>): string => {
110
+ const a = n.args.map(name)
111
+ const t = n.type
112
+ // Every argument of an element wise intrinsic at the result's width,
113
+ // and every literal at its exact type. HLSL broadcasts a scalar and
114
+ // converts an int; Metal finds max(float2, float) has no overload and
115
+ // max(float, 0) is ambiguous, so the shared subset spells both out.
116
+ const s = n.args.map((r) => splat(r, t))
117
+ const k = (v: number, w: SLType) => ctor(w, Array(w).fill(lit(v)))
118
+ const w0 = n.args.length > 0 ? p.nodes[n.args[0]!]!.type : TYPE.FLOAT
119
+ const imm = n.imm ?? []
120
+ switch (n.op) {
121
+ case SLOP.COMPOSE: return ctor(n.type, a)
122
+
123
+ case SLOP.ADD: return `(${a[0]} + ${a[1]})`
124
+ case SLOP.SUB: return `(${a[0]} - ${a[1]})`
125
+ case SLOP.MUL: return `(${a[0]} * ${a[1]})`
126
+ case SLOP.DIV: return `(${a[0]} / ${a[1]})`
127
+ case SLOP.MOD: return `fmod(${s[0]}, ${s[1]})`
128
+ // abs on the base, matching the VM. pow of a negative base is
129
+ // undefined in HLSL and the backends must be undefined in the same
130
+ // direction.
131
+ case SLOP.POW: return `pow(abs(${s[0]}), ${s[1]})`
132
+ case SLOP.NEG: return `(-${a[0]})`
133
+ case SLOP.RECIP: return `(1.0 / ${a[0]})`
134
+
135
+ case SLOP.SIN: return `sin(${a[0]})`
136
+ case SLOP.COS: return `cos(${a[0]})`
137
+ case SLOP.TAN: return `tan(${a[0]})`
138
+ case SLOP.ASIN: return `asin(clamp(${a[0]}, ${k(-1, t)}, ${k(1, t)}))`
139
+ case SLOP.ACOS: return `acos(clamp(${a[0]}, ${k(-1, t)}, ${k(1, t)}))`
140
+ case SLOP.ATAN2: return `atan2(${s[0]}, ${s[1]})`
141
+ case SLOP.EXP: return `exp(${a[0]})`
142
+ case SLOP.LOG: return `log(max(${a[0]}, ${k(1e-8, t)}))`
143
+ case SLOP.SQRT: return `sqrt(max(${a[0]}, ${k(0, t)}))`
144
+ case SLOP.ABS: return `abs(${a[0]})`
145
+ case SLOP.SIGN: return `sign(${a[0]})`
146
+ case SLOP.FLOOR: return `floor(${a[0]})`
147
+ case SLOP.CEIL: return `ceil(${a[0]})`
148
+ case SLOP.ROUND: return `round(${a[0]})`
149
+ case SLOP.FRACT: return `frac(${a[0]})`
150
+ case SLOP.MIN: return `min(${s[0]}, ${s[1]})`
151
+ case SLOP.MAX: return `max(${s[0]}, ${s[1]})`
152
+ case SLOP.CLAMP: return `clamp(${s[0]}, ${s[1]}, ${s[2]})`
153
+ case SLOP.SATURATE: return `saturate(${a[0]})`
154
+
155
+ // Of a scalar, Metal's are ambiguous, so what the VM computes for one
156
+ // (its lanes past the width hold 0) is written out.
157
+ case SLOP.LENGTH: return w0 === 1 ? `abs(${a[0]})` : `length(${a[0]})`
158
+ case SLOP.DISTANCE: return w0 === 1 ? `abs(${a[0]} - ${a[1]})` : `distance(${a[0]}, ${a[1]})`
159
+ case SLOP.DOT: return w0 === 1 ? `(${a[0]} * ${a[1]})` : `dot(${a[0]}, ${a[1]})`
160
+ case SLOP.CROSS: return `cross(${a[0]}, ${a[1]})`
161
+ case SLOP.NORMALIZE: return w0 === 1 ? `(${a[0]} / abs(${a[0]}))` : `normalize(${a[0]})`
162
+ case SLOP.REFLECT: return w0 === 1 ? `(${a[0]} - 2.0 * ${a[1]} * ${a[0]} * ${a[1]})` : `reflect(${a[0]}, ${a[1]})`
163
+ case SLOP.LUMINANCE: return `${helper("sl_luminance")}(${a[0]}.rgb)`
164
+ case SLOP.TO_LINEAR: return target.colour === "linear" ? `${helper("sl_toLinear")}(${a[0]})` : a[0]!
165
+
166
+ case SLOP.MIX: return `lerp(${s[0]}, ${s[1]}, ${s[2]})`
167
+ case SLOP.STEP: return `step(${s[0]}, ${s[1]})`
168
+ case SLOP.SMOOTHSTEP: return `smoothstep(${s[0]}, ${s[1]}, ${s[2]})`
169
+ // Matches the VM exactly, branchlessly, rather than using an if.
170
+ // Two backends that pick differently here disagree on every edge
171
+ // value.
172
+ case SLOP.SELECT: return `lerp(${s[2]}, ${s[1]}, step(${k(0.5, t)}, ${s[0]}))`
173
+
174
+ case SLOP.HSV2RGB: return `${helper("sl_hsv2rgb")}(${a[0]})`
175
+ case SLOP.NOISE: return `${helper("sl_valueNoise")}(${a[0]})`
176
+ case SLOP.SIMPLEX: return `${helper("sl_simplex")}(${a[0]})`
177
+ case SLOP.FBM: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, ${Math.round(imm[1] ?? 0)})`
178
+ case SLOP.TURBULENCE: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 2)`
179
+ case SLOP.RIDGED: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 3)`
180
+ case SLOP.SDF: {
181
+ const id = Math.round(imm[0] ?? 0)
182
+ const q = [imm[1] ?? 0, imm[2] ?? 0, imm[3] ?? 0, imm[4] ?? 0].map(lit)
183
+ const r = [imm[5] ?? 0, imm[6] ?? 0].map(lit)
184
+ return `${helper("sl_sdfDistance")}(${id}, ${a[0]}, float4(${q.join(", ")}), float2(${r.join(", ")}))`
185
+ }
186
+ case SLOP.VORONOI: return `${helper("sl_voronoi")}(${a[0]})`
187
+ case SLOP.SAMPLE: {
188
+ const slot = Math.round(imm[0] ?? 0)
189
+ textures.add(slot)
190
+ return target.sample(slot, a[0]!)
191
+ }
192
+
193
+ default:
194
+ throw new SLError(
195
+ `the HLSL emitter has no case for opcode ${n.op}. A program using it would silently ` +
196
+ `differ between the VM and a compiled build, which is the one failure this design ` +
197
+ `cannot tolerate.`,
198
+ )
199
+ }
200
+ }
201
+
202
+ const walk = (ref: number): void => {
203
+ if (emitted.has(ref)) return
204
+ const n = p.nodes[ref]
205
+ const deps = n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
206
+ for (const d of deps) walk(d)
207
+ emitted.add(ref)
208
+ lines.push(`${indent}${HLSL_TYPE[n.type]} ${name(ref)} = ${expr(n)};`)
209
+ }
210
+ walk(p.result)
211
+ lines.push(target.result === undefined
212
+ ? `${indent}return ${name(p.result)};`
213
+ : `${indent}${target.result} = ${name(p.result)};`)
214
+
215
+ const sorted = (s: Set<number>) => [...s].sort((x, y) => x - y)
216
+ return {
217
+ body: lines.join("\n"),
218
+ uses: { uniforms: sorted(uniforms), textures: sorted(textures), helpers: [...helpers].sort() },
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The library functions a body calls (`Body.uses.helpers`) and everything they
224
+ * call, in the same subset and in dependency order, with the colour branch the
225
+ * target asked for: the text a host puts ahead of the function it wraps the
226
+ * body in.
227
+ *
228
+ * Separate from `emitBody` because OneJS's own frame never needs it: the Unity
229
+ * shader includes `SLCommon.cginc`, the library's source, and a bundle that
230
+ * prints only Unity shaders should not carry the library's text as well.
231
+ */
232
+ export function emitLibrary(helpers: readonly string[], colour: "gamma" | "linear"): string {
233
+ const wanted = new Set(helpers)
234
+ const named = LIB_FUNCTIONS.flatMap((f, i) => (wanted.has(f.name) ? [i] : []))
235
+ if (named.length < wanted.size) {
236
+ const known = new Set(LIB_FUNCTIONS.map((f) => f.name))
237
+ throw new SLError(`the library has no ${[...wanted].filter((h) => !known.has(h)).join(", ")}`)
238
+ }
239
+ return libClosure(named).map((i) => {
240
+ const text = LIB_HLSL[i]!
241
+ return (typeof text === "string" ? text : text[colour]).trim()
242
+ }).join("\n")
243
+ }
package/src/core.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  export * as sl from "./sl"
11
11
  export type { Float, Vec2, Vec3, Vec4, Num, ProgramInputs, Texture } from "./sl"
12
12
  export {
13
- TYPE, INPUTS, MAX_TEXTURES, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION,
13
+ TYPE, INPUTS, MAX_TEXTURES, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION, inputsUsed,
14
14
  } from "./ir"
15
15
  export type {
16
16
  SLType, InputName, NodeRef, SLNode, Program, UniformDecl, TextureDecl,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `onejs-sl/emit/hlsl-body`: a program as a function body in the HLSL and Metal
3
+ * shared subset, for a host that supplies its own frame (see `BodyTarget`), and
4
+ * the library functions that body calls, in the same subset.
5
+ */
6
+ export { emitBody, emitLibrary } from "../body"
7
+ export type { Body, BodyTarget } from "../body"
package/src/encode.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import {
27
- MAX_TEXTURES, SLError, TYPE, type NodeRef, type Program, type SLNode, type SLType,
27
+ MAX_TEXTURES, SLError, TYPE, reachable, type NodeRef, type Program, type SLNode, type SLType,
28
28
  } from "./ir"
29
29
  import { INPUT_ID, SLOP } from "./ops"
30
30
  import { emitShader } from "./hlsl"
@@ -34,6 +34,8 @@ import { uniformDefaults } from "./sl"
34
34
  // Lives in ops.ts with the other wire constants; re-exported so nothing that
35
35
  // reached it through the encoder has to move.
36
36
  export { INPUT_ID }
37
+ // Lives in ir.ts, since a host reads a graph with it too (`inputsUsed`).
38
+ export { reachable }
37
39
 
38
40
  /**
39
41
  * Registers in the VM's file. Phase 0's answer, not a preference.
@@ -143,29 +145,6 @@ interface Instr {
143
145
  imm: [number, number, number, number]
144
146
  }
145
147
 
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
148
  /**
170
149
  * The last position in `order` that reads each node.
171
150
  *
package/src/hlsl.ts CHANGED
@@ -22,23 +22,8 @@
22
22
  * next time it is generated.
23
23
  */
24
24
 
25
- import { SLError, TYPE, type Program, type SLNode, type SLType } from "./ir"
26
- import { INPUT_ID, SLOP } from "./ops"
27
-
28
- const HLSL_TYPE: Record<SLType, string> = { 1: "float", 2: "float2", 3: "float3", 4: "float4" }
29
-
30
- /** A literal that survives a float32 round trip and never reads as an int. */
31
- function lit(n: number): string {
32
- if (!Number.isFinite(n)) throw new SLError(`cannot emit ${n} as a shader literal`)
33
- const s = Number.isInteger(n) ? n.toFixed(1) : String(n)
34
- return s
35
- }
36
-
37
- function ctor(type: SLType, parts: string[]): string {
38
- return type === TYPE.FLOAT ? parts[0] : `${HLSL_TYPE[type]}(${parts.join(", ")})`
39
- }
40
-
41
- const SWZ = "xyzw"
25
+ import { SLError, type Program } from "./ir"
26
+ import { emitBody, lit, type BodyTarget } from "./body"
42
27
 
43
28
  /** The property name a uniform gets. Prefixed so it cannot collide with ours. */
44
29
  export function uniformProperty(name: string): string {
@@ -143,133 +128,39 @@ ${body}
143
128
  `
144
129
  }
145
130
 
146
- /** The straight line body: one local per reachable node, in order. */
147
- export function emitFragmentBody(p: Program): string {
148
- const lines: string[] = []
149
- const name = (ref: number) => `n${ref}`
150
- const emitted = new Set<number>()
151
-
152
- const walk = (ref: number): void => {
153
- if (emitted.has(ref)) return
154
- const n = p.nodes[ref]
155
- const deps = n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
156
- for (const d of deps) walk(d)
157
- emitted.add(ref)
158
- lines.push(` ${HLSL_TYPE[n.type]} ${name(ref)} = ${expr(n, name, p)};`)
159
- }
160
- walk(p.result)
161
- lines.push(` return ${name(p.result)};`)
162
- return lines.join("\n")
163
- }
164
-
165
- function expr(n: SLNode, name: (r: number) => string, p: Program): string {
166
- switch (n.k) {
167
- case "const":
168
- return ctor(n.type, n.v.map(lit))
169
- case "input": {
170
- // `_Res` is the TARGET's size, set by the host, not `_ScreenParams`.
171
- //
172
- // A program is drawn with Graphics.Blit into the element's own
173
- // render texture, and Unity leaves _ScreenParams at whatever the
174
- // last camera set: blitting into a 64x256 target reads it as the
175
- // game view's 1737x1226. So `resolution` and `fragCoord` were the
176
- // window's and `aspect` was the window's ratio, identically wrong
177
- // on both backends, which is why nothing caught it. Aspect
178
- // correction, the thing `aspect` exists for, stretched every
179
- // circle by the shape of the window it happened to be in.
180
- switch (INPUT_ID[n.name]) {
181
- case 0: return "i.uv"
182
- case 1: return "i.uv * _Res.xy"
183
- case 2: return "_Res.xy"
184
- case 3: return "_Secs"
185
- default: return "(_Res.x / max(_Res.y, 1.0))"
186
- }
187
- }
188
- case "uniform": {
189
- const u = p.uniforms[n.slot]
190
- const prop = uniformProperty(u.name)
191
- return n.type === TYPE.VEC4 ? prop : `${prop}.${SWZ.slice(0, n.type)}`
192
- }
193
- case "swizzle":
194
- return `${name(n.src)}.${n.chans.map((c) => SWZ[c]).join("")}`
195
- case "call":
196
- return call(n, name)
131
+ /**
132
+ * The frame's side of the body: OneJS's names for the inputs, a uniform's
133
+ * material property, and `tex2D` on the slot's sampler. `sl_toLinear` stays a
134
+ * call because `SLCommon.cginc` decides Gamma or Linear per project, at the
135
+ * shader compile, so this target always emits it.
136
+ */
137
+ function unityTarget(p: Program): BodyTarget {
138
+ return {
139
+ // `_Res` is the TARGET's size, set by the host, not `_ScreenParams`.
140
+ //
141
+ // A program is drawn with Graphics.Blit into the element's own render
142
+ // texture, and Unity leaves _ScreenParams at whatever the last camera
143
+ // set: blitting into a 64x256 target reads it as the game view's
144
+ // 1737x1226. So `resolution` and `fragCoord` were the window's and
145
+ // `aspect` was the window's ratio, identically wrong on both backends,
146
+ // which is why nothing caught it. Aspect correction, the thing `aspect`
147
+ // exists for, stretched every circle by the shape of the window it
148
+ // happened to be in.
149
+ inputs: {
150
+ uv: "i.uv",
151
+ fragCoord: "i.uv * _Res.xy",
152
+ resolution: "_Res.xy",
153
+ time: "_Secs",
154
+ aspect: "(_Res.x / max(_Res.y, 1.0))",
155
+ },
156
+ uniform: (slot) => uniformProperty(p.uniforms[slot]!.name),
157
+ sample: (slot, uv) => `tex2D(_Tex${slot}, ${uv})`,
158
+ colour: "linear",
159
+ indent: " ",
197
160
  }
198
161
  }
199
162
 
200
- function call(n: Extract<SLNode, { k: "call" }>, name: (r: number) => string): string {
201
- const a = n.args.map(name)
202
- const imm = n.imm ?? []
203
- switch (n.op) {
204
- case SLOP.COMPOSE: return ctor(n.type, a)
205
-
206
- case SLOP.ADD: return `(${a[0]} + ${a[1]})`
207
- case SLOP.SUB: return `(${a[0]} - ${a[1]})`
208
- case SLOP.MUL: return `(${a[0]} * ${a[1]})`
209
- case SLOP.DIV: return `(${a[0]} / ${a[1]})`
210
- case SLOP.MOD: return `fmod(${a[0]}, ${a[1]})`
211
- // abs on the base, matching the VM. pow of a negative base is undefined
212
- // in HLSL and the two backends must be undefined in the same direction.
213
- case SLOP.POW: return `pow(abs(${a[0]}), ${a[1]})`
214
- case SLOP.NEG: return `(-${a[0]})`
215
- case SLOP.RECIP: return `(1.0 / ${a[0]})`
216
-
217
- case SLOP.SIN: return `sin(${a[0]})`
218
- case SLOP.COS: return `cos(${a[0]})`
219
- case SLOP.TAN: return `tan(${a[0]})`
220
- case SLOP.ASIN: return `asin(clamp(${a[0]}, -1, 1))`
221
- case SLOP.ACOS: return `acos(clamp(${a[0]}, -1, 1))`
222
- case SLOP.ATAN2: return `atan2(${a[0]}, ${a[1]})`
223
- case SLOP.EXP: return `exp(${a[0]})`
224
- case SLOP.LOG: return `log(max(${a[0]}, 1e-8))`
225
- case SLOP.SQRT: return `sqrt(max(${a[0]}, 0))`
226
- case SLOP.ABS: return `abs(${a[0]})`
227
- case SLOP.SIGN: return `sign(${a[0]})`
228
- case SLOP.FLOOR: return `floor(${a[0]})`
229
- case SLOP.CEIL: return `ceil(${a[0]})`
230
- case SLOP.ROUND: return `round(${a[0]})`
231
- case SLOP.FRACT: return `frac(${a[0]})`
232
- case SLOP.MIN: return `min(${a[0]}, ${a[1]})`
233
- case SLOP.MAX: return `max(${a[0]}, ${a[1]})`
234
- case SLOP.CLAMP: return `clamp(${a[0]}, ${a[1]}, ${a[2]})`
235
- case SLOP.SATURATE: return `saturate(${a[0]})`
236
-
237
- case SLOP.LENGTH: return `length(${a[0]})`
238
- case SLOP.DISTANCE: return `distance(${a[0]}, ${a[1]})`
239
- case SLOP.DOT: return `dot(${a[0]}, ${a[1]})`
240
- case SLOP.CROSS: return `cross(${a[0]}, ${a[1]})`
241
- case SLOP.NORMALIZE: return `normalize(${a[0]})`
242
- case SLOP.REFLECT: return `reflect(${a[0]}, ${a[1]})`
243
- case SLOP.LUMINANCE: return `sl_luminance(${a[0]}.rgb)`
244
- case SLOP.TO_LINEAR: return `sl_toLinear(${a[0]})`
245
-
246
- case SLOP.MIX: return `lerp(${a[0]}, ${a[1]}, ${a[2]})`
247
- case SLOP.STEP: return `step(${a[0]}, ${a[1]})`
248
- case SLOP.SMOOTHSTEP: return `smoothstep(${a[0]}, ${a[1]}, ${a[2]})`
249
- // Matches the VM exactly, branchlessly, rather than using an if. Two
250
- // backends that pick differently here disagree on every edge value.
251
- case SLOP.SELECT: return `lerp(${a[2]}, ${a[1]}, step(0.5, ${a[0]}))`
252
-
253
- case SLOP.HSV2RGB: return `sl_hsv2rgb(${a[0]})`
254
- case SLOP.NOISE: return `sl_valueNoise(${a[0]})`
255
- case SLOP.SIMPLEX: return `sl_simplex(${a[0]})`
256
- case SLOP.FBM: return `sl_fbm(${a[0]}, ${Math.round(imm[0] ?? 3)}, ${Math.round(imm[1] ?? 0)})`
257
- case SLOP.TURBULENCE: return `sl_fbm(${a[0]}, ${Math.round(imm[0] ?? 3)}, 2)`
258
- case SLOP.RIDGED: return `sl_fbm(${a[0]}, ${Math.round(imm[0] ?? 3)}, 3)`
259
- case SLOP.SDF: {
260
- const id = Math.round(imm[0] ?? 0)
261
- const q = [imm[1] ?? 0, imm[2] ?? 0, imm[3] ?? 0, imm[4] ?? 0].map(lit)
262
- const r = [imm[5] ?? 0, imm[6] ?? 0].map(lit)
263
- return `sl_sdfDistance(${id}, ${a[0]}, float4(${q.join(", ")}), float2(${r.join(", ")}))`
264
- }
265
- case SLOP.VORONOI: return `sl_voronoi(${a[0]})`
266
- case SLOP.SAMPLE: return `tex2D(_Tex${Math.round(imm[0] ?? 0)}, ${a[0]})`
267
-
268
- default:
269
- throw new SLError(
270
- `the HLSL emitter has no case for opcode ${n.op}. A program using it would silently ` +
271
- `differ between the VM and a compiled build, which is the one failure this design ` +
272
- `cannot tolerate.`,
273
- )
274
- }
163
+ /** The straight line body: one local per reachable node, in order. */
164
+ export function emitFragmentBody(p: Program): string {
165
+ return emitBody(p, unityTarget(p)).body
275
166
  }
package/src/ir.ts CHANGED
@@ -57,6 +57,13 @@ export interface UniformDecl {
57
57
  type: SLType
58
58
  /** Default, used when a caller does not supply the uniform. */
59
59
  value: number[]
60
+ /**
61
+ * A colour: `value` is sRGB as written, what a host sets and a colour
62
+ * picker shows, and every read converts it to linear. Set by a hex default
63
+ * in a `.sl` file or `sl.uniform.colour`. Metadata for a host: the reads'
64
+ * conversion is already in the graph, so it is not part of the hash.
65
+ */
66
+ colour?: true
60
67
  }
61
68
 
62
69
  export interface TextureDecl {
@@ -154,17 +161,20 @@ export class Builder {
154
161
  return this.add({ k: "const", type: v.length as SLType, v: v.slice() })
155
162
  }
156
163
 
157
- uniform(name: string, type: SLType, value: number[]): NodeRef {
164
+ uniform(name: string, type: SLType, value: number[], colour = false): NodeRef {
158
165
  const existing = this.uniforms.findIndex((u) => u.name === name)
159
166
  if (existing >= 0) {
160
167
  const u = this.uniforms[existing]
161
168
  if (u.type !== type) {
162
169
  throw new SLError(`uniform "${name}" is declared as both a ${widthName(u.type)} and a ${widthName(type)}`)
163
170
  }
171
+ if ((u.colour === true) !== colour) {
172
+ throw new SLError(`uniform "${name}" is declared both as a colour and as plain numbers`)
173
+ }
164
174
  return this.add({ k: "uniform", type, slot: existing })
165
175
  }
166
176
  const slot = this.uniforms.length
167
- this.uniforms.push({ name, type, value: value.slice() })
177
+ this.uniforms.push(colour ? { name, type, value: value.slice(), colour: true } : { name, type, value: value.slice() })
168
178
  return this.add({ k: "uniform", type, slot })
169
179
  }
170
180
 
@@ -184,6 +194,43 @@ export class Builder {
184
194
  }
185
195
  }
186
196
 
197
+ /**
198
+ * Nodes the result actually depends on, in order.
199
+ *
200
+ * Dead nodes are dropped rather than encoded. An author can produce them easily
201
+ * by computing something and not using it, and the hash already ignores them, so
202
+ * encoding them would make the buffer disagree with its own hash about what the
203
+ * program is.
204
+ */
205
+ export function reachable(nodes: SLNode[], result: NodeRef): NodeRef[] {
206
+ const keep = new Set<NodeRef>()
207
+ const stack = [result]
208
+ while (stack.length > 0) {
209
+ const ref = stack.pop()!
210
+ if (keep.has(ref)) continue
211
+ keep.add(ref)
212
+ const n = nodes[ref]
213
+ if (n.k === "swizzle") stack.push(n.src)
214
+ else if (n.k === "call") for (const a of n.args) stack.push(a)
215
+ }
216
+ // Ascending, which is still topological because a node only refers backwards.
217
+ return [...keep].sort((x, y) => x - y)
218
+ }
219
+
220
+ /**
221
+ * The inputs a program reads, in `INPUTS` order. Dead nodes do not count: a
222
+ * value computed and never used is not in the picture, so a program that does
223
+ * that with `time` is still not animated.
224
+ */
225
+ export function inputsUsed(p: Program): InputName[] {
226
+ const read = new Set<InputName>()
227
+ for (const ref of reachable(p.nodes, p.result)) {
228
+ const n = p.nodes[ref]
229
+ if (n.k === "input") read.add(n.name)
230
+ }
231
+ return (Object.keys(INPUTS) as InputName[]).filter((name) => read.has(name))
232
+ }
233
+
187
234
  /** Structural key for hash consing. Order matters and is fixed by the node shape. */
188
235
  function keyOf(n: SLNode): string {
189
236
  switch (n.k) {
package/src/lang/lower.ts CHANGED
@@ -144,12 +144,15 @@ export function lower(checked: Checked): Program {
144
144
  for (const u of unit.uniforms) {
145
145
  const width = TYPE_WIDTH[u.type]
146
146
  const { components, colour } = uniformDefault(u.type, u.init)
147
- const raw = at(u.pos, () => declareUniform(u.name, u.type, components))
148
147
  // A hex default says the uniform IS a colour, so every read of it
149
148
  // converts, exactly as a hex literal in an expression does. Without
150
149
  // 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 })
150
+ // different colours in one file. `sl.uniform.colour` also marks
151
+ // the declaration, for a host's colour picker.
152
+ const value = colour && u.init?.k === "hex"
153
+ ? at(u.pos, () => sl.uniform.colour(u.name, (u.init as { hex: string }).hex, width as 3 | 4) as unknown as Val)
154
+ : at(u.pos, () => declareUniform(u.name, u.type, components))
155
+ global.declare(u.name, { width, value })
153
156
  }
154
157
 
155
158
  for (const t of unit.textures) samplers.set(t.name, at(t.pos, () => sl.texture(t.name)))