onejs-sl 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-sl",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The OneJS shader language: a small typed language for per pixel programs, compiled to a portable IR and emitted as HLSL, WGSL or GLSL ES",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/body.ts CHANGED
@@ -21,10 +21,11 @@
21
21
  * gives common subexpression elimination for free.
22
22
  */
23
23
 
24
- import { SLError, TYPE, type InputName, type Program, type SLNode, type SLType } from "./ir"
24
+ import { INT_BOUNDS, SLError, TYPE, valueAt, type InputName, type NodeRef, type Program, type SLKind, type SLType, type ValueNode } from "./ir"
25
25
  import { libClosure, LIB_FUNCTIONS } from "./lib"
26
26
  import { LIB_HLSL } from "./lib/hlsl"
27
27
  import { SLOP } from "./ops"
28
+ import { inVaryingFlow, local, printBody, structure, type Syntax } from "./structure"
28
29
 
29
30
  export interface BodyTarget {
30
31
  /** An expression for each input, e.g. `{ uv: "SL_UV", time: "SL_TIME", ... }`. */
@@ -51,6 +52,14 @@ export interface BodyTarget {
51
52
  * as it is, and a host that composites premultiplied multiplies on write.
52
53
  */
53
54
  sample: (slot: number, uv: string) => string
55
+ /**
56
+ * A float4 sample of the texture in `slot` at `uv` from mip level `lod`, a
57
+ * float expression: 0 is the full size texture, 1 half, and a fraction
58
+ * blends two levels where the texture's filter does. Otherwise the same
59
+ * contract as `sample`. `tex2Dlod` in a program; HLSL's
60
+ * `tex2Dlod(s, float4(uv, 0, lod))` and Metal's `sample(s, uv, level(lod))`.
61
+ */
62
+ sampleLevel: (slot: number, uv: string, lod: string) => string
54
63
  /**
55
64
  * `linear`: `toLinear` calls `sl_toLinear`, for a host whose target holds
56
65
  * linear light. `gamma`: `toLinear` is the identity, decided here, so hex
@@ -59,6 +68,12 @@ export interface BodyTarget {
59
68
  colour: "gamma" | "linear"
60
69
  /** Assign the result to this local instead of returning it. */
61
70
  result?: string
71
+ /**
72
+ * Put before every loop the body prints: `[loop]` for HLSL, so a compiler
73
+ * does not try to unroll a loop whose turns are capped only by a counter.
74
+ * Nothing for Metal, which has no such attribute.
75
+ */
76
+ loopAttribute?: string
62
77
  /** Put in front of every line. Default four spaces. */
63
78
  indent?: string
64
79
  }
@@ -77,6 +92,25 @@ export interface Body {
77
92
 
78
93
  const HLSL_TYPE: Record<SLType, string> = { 1: "float", 2: "float2", 3: "float3", 4: "float4" }
79
94
 
95
+ /** A value's HLSL type, which Metal spells the same through a host's defines. */
96
+ function typeName(n: { type: SLType; kind?: SLKind }): string {
97
+ if (n.kind === undefined) return HLSL_TYPE[n.type]
98
+ return n.type === 1 ? n.kind : `${n.kind}${n.type}`
99
+ }
100
+
101
+ /** An int, uint or bool constant as a literal. */
102
+ function kindLit(kind: SLKind, v: number): string {
103
+ if (kind === "bool") return v !== 0 ? "true" : "false"
104
+ if (kind === "uint") return `${v >>> 0}u`
105
+ return v < 0 ? `(${v})` : String(v)
106
+ }
107
+
108
+ /** The int range a float is held to on its way to an int, so every backend truncates the same. */
109
+ export const INT_LIMITS = {
110
+ int: INT_BOUNDS.int.map((n) => lit(n)),
111
+ uint: INT_BOUNDS.uint.map((n) => lit(n)),
112
+ } as const
113
+
80
114
  /** A literal that survives a float32 round trip and never reads as an int. */
81
115
  export function lit(n: number): string {
82
116
  if (!Number.isFinite(n)) throw new SLError(`cannot emit ${n} as a shader literal`)
@@ -92,19 +126,25 @@ const SWZ = "xyzw"
92
126
 
93
127
  export function emitBody(p: Program, target: BodyTarget): Body {
94
128
  const indent = target.indent ?? " "
95
- const lines: string[] = []
96
- const name = (ref: number) => `n${ref}`
97
- const emitted = new Set<number>()
129
+ const name = local
130
+ const shape = structure(p)
98
131
  const uniforms = new Set<number>()
99
132
  const textures = new Set<number>()
100
133
  const helpers = new Set<string>()
101
134
  const helper = (fn: string) => { helpers.add(fn); return fn }
102
135
  /** A node at width `w`: a scalar repeated into a constructor, since HLSL refuses float3(x) and Metal x.xxx. */
103
- const splat = (ref: number, w: SLType) => (p.nodes[ref]!.type === TYPE.FLOAT && w > 1 ? ctor(w, Array(w).fill(name(ref))) : name(ref))
136
+ /** A constant node's value, or null for anything computed. */
137
+ const constantOf = (ref: number): number[] | null => {
138
+ const c = p.nodes[ref]!
139
+ return c.k === "const" ? c.v : null
140
+ }
141
+ const splat = (ref: number, w: SLType) => (valueAt(p.nodes, ref).type === TYPE.FLOAT && w > 1 ? ctor(w, Array(w).fill(name(ref))) : name(ref))
104
142
 
105
- const expr = (n: SLNode): string => {
143
+ const expr = (ref: NodeRef): string => {
144
+ const n = valueAt(p.nodes, ref)
106
145
  switch (n.k) {
107
146
  case "const":
147
+ if (n.kind !== undefined) return n.type === 1 ? kindLit(n.kind, n.v[0]!) : `${typeName(n)}(${n.v.map((v) => kindLit(n.kind!, v)).join(", ")})`
108
148
  return ctor(n.type, n.v.map(lit))
109
149
  case "input":
110
150
  return target.inputs[n.name]
@@ -117,14 +157,18 @@ export function emitBody(p: Program, target: BodyTarget): Body {
117
157
  }
118
158
  case "swizzle":
119
159
  // Metal has no swizzle of a scalar, so a scalar widens by constructor.
120
- if (p.nodes[n.src]!.type === TYPE.FLOAT) return ctor(n.type, n.chans.map(() => name(n.src)))
160
+ if (valueAt(p.nodes, n.src).type === TYPE.FLOAT) {
161
+ return n.type === 1 ? name(n.src) : `${typeName(n)}(${n.chans.map(() => name(n.src)).join(", ")})`
162
+ }
121
163
  return `${name(n.src)}.${n.chans.map((c) => SWZ[c]).join("")}`
122
164
  case "call":
123
- return call(n)
165
+ return call(n, ref)
166
+ default:
167
+ throw new SLError(`the HLSL emitter reached a ${n.k} as an expression`)
124
168
  }
125
169
  }
126
170
 
127
- const call = (n: Extract<SLNode, { k: "call" }>): string => {
171
+ const call = (n: Extract<ValueNode, { k: "call" }>, ref: NodeRef): string => {
128
172
  const a = n.args.map(name)
129
173
  const t = n.type
130
174
  // Every argument of an element wise intrinsic at the result's width,
@@ -133,10 +177,55 @@ export function emitBody(p: Program, target: BodyTarget): Body {
133
177
  // max(float, 0) is ambiguous, so the shared subset spells both out.
134
178
  const s = n.args.map((r) => splat(r, t))
135
179
  const k = (v: number, w: SLType) => ctor(w, Array(w).fill(lit(v)))
136
- const w0 = n.args.length > 0 ? p.nodes[n.args[0]!]!.type : TYPE.FLOAT
180
+ const w0 = n.args.length > 0 ? valueAt(p.nodes, n.args[0]!).type : TYPE.FLOAT
137
181
  const imm = n.imm ?? []
182
+ const argKind = n.args.length > 0 ? valueAt(p.nodes, n.args[0]!).kind : undefined
183
+ // IR 4: the ops that take ints, uints and bools, by the kinds involved.
184
+ if (n.kind !== undefined || argKind !== undefined) {
185
+ const zero = kindLit(n.kind ?? "int", 0)
186
+ switch (n.op) {
187
+ case SLOP.ADD: return `(${a[0]} + ${a[1]})`
188
+ case SLOP.SUB: return `(${a[0]} - ${a[1]})`
189
+ case SLOP.MUL: return `(${a[0]} * ${a[1]})`
190
+ // Truncating, and 0 for a zero divisor, which HLSL leaves undefined.
191
+ case SLOP.DIV: return `(${a[1]} == ${zero} ? ${zero} : ${a[0]} / ${a[1]})`
192
+ case SLOP.MOD: return `(${a[1]} == ${zero} ? ${zero} : ${a[0]} % ${a[1]})`
193
+ case SLOP.NEG: return `(-${a[0]})`
194
+ case SLOP.MIN: return `min(${a[0]}, ${a[1]})`
195
+ case SLOP.MAX: return `max(${a[0]}, ${a[1]})`
196
+ case SLOP.ABS: return `abs(${a[0]})`
197
+ case SLOP.CLAMP: return `clamp(${a[0]}, ${a[1]}, ${a[2]})`
198
+ }
199
+ }
138
200
  switch (n.op) {
139
- case SLOP.COMPOSE: return ctor(n.type, a)
201
+ case SLOP.COMPOSE: return n.kind === undefined ? ctor(n.type, a) : `${typeName(n)}(${a.join(", ")})`
202
+ case SLOP.CAST: {
203
+ const from = valueAt(p.nodes, n.args[0]!).kind
204
+ if (n.kind === undefined) return `${typeName(n)}(${a[0]})`
205
+ if (n.kind === "bool") return `(${a[0]} != ${from === undefined ? "0.0" : kindLit(from, 0)})`
206
+ if (from === undefined) {
207
+ const [lo, hi] = INT_LIMITS[n.kind]
208
+ return `${n.kind}(clamp(${a[0]}, ${lo}, ${hi}))`
209
+ }
210
+ return `${n.kind}(${a[0]})`
211
+ }
212
+ case SLOP.LT: return `(${a[0]} < ${a[1]})`
213
+ case SLOP.LE: return `(${a[0]} <= ${a[1]})`
214
+ case SLOP.GT: return `(${a[0]} > ${a[1]})`
215
+ case SLOP.GE: return `(${a[0]} >= ${a[1]})`
216
+ case SLOP.EQ: return `(${a[0]} == ${a[1]})`
217
+ case SLOP.NE: return `(${a[0]} != ${a[1]})`
218
+ case SLOP.AND: return `(${a[0]} && ${a[1]})`
219
+ case SLOP.OR: return `(${a[0]} || ${a[1]})`
220
+ case SLOP.NOT: return `(!${a[0]})`
221
+ case SLOP.BIT_AND: return `(${a[0]} & ${a[1]})`
222
+ case SLOP.BIT_OR: return `(${a[0]} | ${a[1]})`
223
+ case SLOP.BIT_XOR: return `(${a[0]} ^ ${a[1]})`
224
+ case SLOP.BIT_NOT: return `(~${a[0]})`
225
+ // The count modulo 32, which HLSL does itself and GLSL leaves undefined past 31.
226
+ case SLOP.SHL: return `(${a[0]} << (${a[1]} & ${kindLit(valueAt(p.nodes, n.args[1]!).kind ?? "int", 31)}))`
227
+ case SLOP.SHR: return `(${a[0]} >> (${a[1]} & ${kindLit(valueAt(p.nodes, n.args[1]!).kind ?? "int", 31)}))`
228
+ case SLOP.CHOOSE: return `(${a[0]} ? ${a[1]} : ${a[2]})`
140
229
 
141
230
  case SLOP.ADD: return `(${a[0]} + ${a[1]})`
142
231
  case SLOP.SUB: return `(${a[0]} - ${a[1]})`
@@ -192,20 +281,32 @@ export function emitBody(p: Program, target: BodyTarget): Body {
192
281
  case SLOP.HSV2RGB: return `${helper("sl_hsv2rgb")}(${a[0]})`
193
282
  case SLOP.NOISE: return `${helper("sl_valueNoise")}(${a[0]})`
194
283
  case SLOP.SIMPLEX: return `${helper("sl_simplex")}(${a[0]})`
195
- case SLOP.FBM: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, ${Math.round(imm[1] ?? 0)})`
196
- case SLOP.TURBULENCE: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 2)`
197
- case SLOP.RIDGED: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 3)`
284
+ case SLOP.FBM:
285
+ case SLOP.TURBULENCE:
286
+ case SLOP.RIDGED: {
287
+ // The count rounded here and held to 1 to 4 by sl_fbm; a constant
288
+ // one is the whole number it is. fbm's kind is the one immediate.
289
+ const c = constantOf(n.args[1]!)
290
+ const count = c !== null ? String(Math.round(c[0]!)) : `int(floor(${a[1]} + 0.5))`
291
+ const kind = n.op === SLOP.TURBULENCE ? 2 : n.op === SLOP.RIDGED ? 3 : Math.round(imm[0] ?? 0)
292
+ return `${helper("sl_fbm")}(${a[0]}, ${count}, ${kind})`
293
+ }
198
294
  case SLOP.SDF: {
199
295
  const id = Math.round(imm[0] ?? 0)
200
- const q = [imm[1] ?? 0, imm[2] ?? 0, imm[3] ?? 0, imm[4] ?? 0].map(lit)
201
- const r = [imm[5] ?? 0, imm[6] ?? 0].map(lit)
202
- return `${helper("sl_sdfDistance")}(${id}, ${a[0]}, float4(${q.join(", ")}), float2(${r.join(", ")}))`
296
+ return `${helper("sl_sdfDistance")}(${id}, ${a[0]}, ${a[1]}, ${a[2]})`
203
297
  }
204
298
  case SLOP.VORONOI: return `${helper("sl_voronoi")}(${a[0]})`
205
299
  case SLOP.SAMPLE: {
206
300
  const slot = Math.round(imm[0] ?? 0)
207
301
  textures.add(slot)
208
- return target.sample(slot, a[0]!)
302
+ // Where pixels part ways there are no neighbours to take a mip
303
+ // level from, so every backend samples level 0 there (`structure.ts`).
304
+ return inVaryingFlow(shape, ref) ? target.sampleLevel(slot, a[0]!, "0.0") : target.sample(slot, a[0]!)
305
+ }
306
+ case SLOP.SAMPLE_LOD: {
307
+ const slot = Math.round(imm[0] ?? 0)
308
+ textures.add(slot)
309
+ return target.sampleLevel(slot, a[0]!, a[1]!)
209
310
  }
210
311
 
211
312
  default:
@@ -217,15 +318,20 @@ export function emitBody(p: Program, target: BodyTarget): Body {
217
318
  }
218
319
  }
219
320
 
220
- const walk = (ref: number): void => {
221
- if (emitted.has(ref)) return
222
- const n = p.nodes[ref]
223
- const deps = n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
224
- for (const d of deps) walk(d)
225
- emitted.add(ref)
226
- lines.push(`${indent}${HLSL_TYPE[n.type]} ${name(ref)} = ${expr(n)};`)
321
+ const typeOf = (ref: NodeRef) => typeName(valueAt(p.nodes, ref))
322
+ const syntax: Syntax = {
323
+ value: (ref, e) => `${typeOf(ref)} ${name(ref)} = ${e};`,
324
+ mutable: (n, like, init) => `${typeOf(like)} ${n}${init === undefined ? "" : ` = ${init}`};`,
325
+ counter: (n) => `int ${n} = 0;`,
326
+ assign: (n, v) => `${n} = ${v};`,
327
+ increment: (n) => `${n}++;`,
328
+ ifOpen: (c) => `if (${c}) {`,
329
+ elseOpen: "} else {",
330
+ close: "}",
331
+ loopOpen: `${target.loopAttribute === undefined ? "" : target.loopAttribute + " "}for (;;) {`,
332
+ breakUnless: (c, turns, max) => `if (!(${c}) || ${turns} >= ${max}) break;`,
227
333
  }
228
- walk(p.result)
334
+ const lines = printBody(p, shape, syntax, expr, indent)
229
335
  lines.push(target.result === undefined
230
336
  ? `${indent}return ${name(p.result)};`
231
337
  : `${indent}${target.result} = ${name(p.result)};`)
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_NODES, SLError, hashProgram, widthName, SL_IR_VERSION, inputsUsed,
13
+ TYPE, INPUTS, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION, SL_HASH_VERSION, inputsUsed,
14
14
  CONTROL_FIELDS, controlProblem,
15
15
  } from "./ir"
16
16
  export type {
package/src/hlsl.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * next time it is generated.
16
16
  */
17
17
 
18
- import { SLError, type Program } from "./ir"
18
+ import { SLError, SL_HASH_VERSION, type Program } from "./ir"
19
19
  import { emitBody, lit, type BodyTarget } from "./body"
20
20
 
21
21
  /** The property name a uniform gets. Prefixed so it cannot collide with ours. */
@@ -74,7 +74,9 @@ export function emitShader(p: Program, options: EmitOptions = {}): string {
74
74
  //
75
75
  // Source of truth is the program this was emitted from; edits here are lost the
76
76
  // next time it is generated. The name carries the program hash, which is how the
77
- // runtime pairs the two. See Specs/SHADER_LANG.md section 6.
77
+ // runtime pairs the two. See Specs/SHADER_LANG.md section 6. The scheme that
78
+ // hash was made under, which a build compares with the apps' manifests:
79
+ // SL_HASH_VERSION ${SL_HASH_VERSION}
78
80
  Shader "${name}"
79
81
  {
80
82
  Properties
@@ -123,7 +125,7 @@ ${body}
123
125
 
124
126
  /**
125
127
  * The frame's side of the body: OneJS's names for the inputs, a uniform's
126
- * material property, and `tex2D` on the slot's sampler. `sl_toLinear` stays a
128
+ * material property, and `tex2D` and `tex2Dlod` on the slot's sampler. `sl_toLinear` stays a
127
129
  * call because `SLCommon.cginc` decides Gamma or Linear per project, at the
128
130
  * shader compile, so this target always emits it.
129
131
  */
@@ -148,8 +150,10 @@ function unityTarget(p: Program): BodyTarget {
148
150
  },
149
151
  uniform: (slot) => uniformProperty(p.uniforms[slot]!.name),
150
152
  sample: (slot, uv) => `tex2D(_Tex${slot}, ${uv})`,
153
+ sampleLevel: (slot, uv, lod) => `tex2Dlod(_Tex${slot}, float4(${uv}, 0.0, ${lod}))`,
151
154
  colour: "linear",
152
155
  indent: " ",
156
+ loopAttribute: "[loop]",
153
157
  }
154
158
  }
155
159