onejs-sl 0.1.0 → 0.1.1
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/CHANGELOG.md +10 -0
- package/README.md +43 -2
- package/goldens/goldens.json +480 -0
- package/package.json +5 -2
- package/src/body.ts +202 -0
- package/src/core.ts +1 -1
- package/src/emit/hlsl-body.ts +6 -0
- package/src/encode.ts +3 -24
- package/src/hlsl.ts +34 -143
- package/src/ir.ts +49 -2
- package/src/lang/lower.ts +6 -3
- package/src/serial.ts +3 -1
- package/src/sl.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onejs-sl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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, GLSL ES or a VM buffer",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./tables": "./src/tables.ts",
|
|
11
11
|
"./limits": "./src/limits.ts",
|
|
12
12
|
"./vm": "./src/vm.ts",
|
|
13
|
+
"./emit/hlsl-body": "./src/emit/hlsl-body.ts",
|
|
13
14
|
"./emit/unity": "./src/emit/unity.ts",
|
|
14
15
|
"./emit/web": "./src/emit/web.ts"
|
|
15
16
|
},
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
"src",
|
|
19
20
|
"!src/**/*.test.ts",
|
|
20
21
|
"!src/fixtures",
|
|
22
|
+
"goldens/goldens.json",
|
|
21
23
|
"CHANGELOG.md"
|
|
22
24
|
],
|
|
23
25
|
"keywords": [
|
|
@@ -43,7 +45,8 @@
|
|
|
43
45
|
"typecheck": "tsc --noEmit",
|
|
44
46
|
"lint": "eslint .",
|
|
45
47
|
"test": "vitest run && node quickjs/run.mjs",
|
|
46
|
-
"test:quickjs": "node quickjs/run.mjs"
|
|
48
|
+
"test:quickjs": "node quickjs/run.mjs",
|
|
49
|
+
"goldens": "node goldens/run.mjs"
|
|
47
50
|
},
|
|
48
51
|
"devDependencies": {
|
|
49
52
|
"@eslint/js": "^10.0.1",
|
package/src/body.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
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.
|
|
15
|
+
*
|
|
16
|
+
* One local per reachable node, in order, named `n<index>` and unreadable on
|
|
17
|
+
* purpose: generated code that looks hand written invites hand editing, and a
|
|
18
|
+
* hand edit is lost the next time it is generated. One local per node also
|
|
19
|
+
* gives common subexpression elimination for free.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { SLError, TYPE, type InputName, type Program, type SLNode, type SLType } from "./ir"
|
|
23
|
+
import { SLOP } from "./ops"
|
|
24
|
+
|
|
25
|
+
export interface BodyTarget {
|
|
26
|
+
/** An expression for each input, e.g. `{ uv: "SL_UV", time: "SL_TIME", ... }`. */
|
|
27
|
+
inputs: Record<InputName, string>
|
|
28
|
+
/** The float4 holding a uniform slot. The body swizzles it down to the uniform's width. */
|
|
29
|
+
uniform: (slot: number, name: string) => string
|
|
30
|
+
/** A float4 sample of the texture in `slot` at the float2 expression `uv`, straight alpha. */
|
|
31
|
+
sample: (slot: number, uv: string) => string
|
|
32
|
+
/**
|
|
33
|
+
* `linear`: `toLinear` calls `sl_toLinear`, for a host whose target holds
|
|
34
|
+
* linear light. `gamma`: `toLinear` is the identity, decided here, so hex
|
|
35
|
+
* colours and ramps stay as written.
|
|
36
|
+
*/
|
|
37
|
+
colour: "gamma" | "linear"
|
|
38
|
+
/** Assign the result to this local instead of returning it. */
|
|
39
|
+
result?: string
|
|
40
|
+
/** Put in front of every line. Default four spaces. */
|
|
41
|
+
indent?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Body {
|
|
45
|
+
body: string
|
|
46
|
+
uses: {
|
|
47
|
+
/** Uniform slots the body reads, ascending. */
|
|
48
|
+
uniforms: number[]
|
|
49
|
+
/** Texture slots the body samples, ascending. */
|
|
50
|
+
textures: number[]
|
|
51
|
+
/** Library functions the body calls, sorted: `sl_fbm`, `sl_sdfDistance`, ... */
|
|
52
|
+
helpers: string[]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const HLSL_TYPE: Record<SLType, string> = { 1: "float", 2: "float2", 3: "float3", 4: "float4" }
|
|
57
|
+
|
|
58
|
+
/** A literal that survives a float32 round trip and never reads as an int. */
|
|
59
|
+
export function lit(n: number): string {
|
|
60
|
+
if (!Number.isFinite(n)) throw new SLError(`cannot emit ${n} as a shader literal`)
|
|
61
|
+
const s = Number.isInteger(n) ? n.toFixed(1) : String(n)
|
|
62
|
+
return s
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ctor(type: SLType, parts: string[]): string {
|
|
66
|
+
return type === TYPE.FLOAT ? parts[0] : `${HLSL_TYPE[type]}(${parts.join(", ")})`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SWZ = "xyzw"
|
|
70
|
+
|
|
71
|
+
export function emitBody(p: Program, target: BodyTarget): Body {
|
|
72
|
+
const indent = target.indent ?? " "
|
|
73
|
+
const lines: string[] = []
|
|
74
|
+
const name = (ref: number) => `n${ref}`
|
|
75
|
+
const emitted = new Set<number>()
|
|
76
|
+
const uniforms = new Set<number>()
|
|
77
|
+
const textures = new Set<number>()
|
|
78
|
+
const helpers = new Set<string>()
|
|
79
|
+
const helper = (fn: string) => { helpers.add(fn); return fn }
|
|
80
|
+
|
|
81
|
+
const expr = (n: SLNode): string => {
|
|
82
|
+
switch (n.k) {
|
|
83
|
+
case "const":
|
|
84
|
+
return ctor(n.type, n.v.map(lit))
|
|
85
|
+
case "input":
|
|
86
|
+
return target.inputs[n.name]
|
|
87
|
+
case "uniform": {
|
|
88
|
+
const u = p.uniforms[n.slot]
|
|
89
|
+
if (u === undefined) throw new SLError(`a node reads uniform slot ${n.slot}, which is not declared`)
|
|
90
|
+
uniforms.add(n.slot)
|
|
91
|
+
const v = target.uniform(n.slot, u.name)
|
|
92
|
+
return n.type === TYPE.VEC4 ? v : `${v}.${SWZ.slice(0, n.type)}`
|
|
93
|
+
}
|
|
94
|
+
case "swizzle":
|
|
95
|
+
return `${name(n.src)}.${n.chans.map((c) => SWZ[c]).join("")}`
|
|
96
|
+
case "call":
|
|
97
|
+
return call(n)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const call = (n: Extract<SLNode, { k: "call" }>): string => {
|
|
102
|
+
const a = n.args.map(name)
|
|
103
|
+
const imm = n.imm ?? []
|
|
104
|
+
switch (n.op) {
|
|
105
|
+
case SLOP.COMPOSE: return ctor(n.type, a)
|
|
106
|
+
|
|
107
|
+
case SLOP.ADD: return `(${a[0]} + ${a[1]})`
|
|
108
|
+
case SLOP.SUB: return `(${a[0]} - ${a[1]})`
|
|
109
|
+
case SLOP.MUL: return `(${a[0]} * ${a[1]})`
|
|
110
|
+
case SLOP.DIV: return `(${a[0]} / ${a[1]})`
|
|
111
|
+
case SLOP.MOD: return `fmod(${a[0]}, ${a[1]})`
|
|
112
|
+
// abs on the base, matching the VM. pow of a negative base is
|
|
113
|
+
// undefined in HLSL and the backends must be undefined in the same
|
|
114
|
+
// direction.
|
|
115
|
+
case SLOP.POW: return `pow(abs(${a[0]}), ${a[1]})`
|
|
116
|
+
case SLOP.NEG: return `(-${a[0]})`
|
|
117
|
+
case SLOP.RECIP: return `(1.0 / ${a[0]})`
|
|
118
|
+
|
|
119
|
+
case SLOP.SIN: return `sin(${a[0]})`
|
|
120
|
+
case SLOP.COS: return `cos(${a[0]})`
|
|
121
|
+
case SLOP.TAN: return `tan(${a[0]})`
|
|
122
|
+
case SLOP.ASIN: return `asin(clamp(${a[0]}, -1, 1))`
|
|
123
|
+
case SLOP.ACOS: return `acos(clamp(${a[0]}, -1, 1))`
|
|
124
|
+
case SLOP.ATAN2: return `atan2(${a[0]}, ${a[1]})`
|
|
125
|
+
case SLOP.EXP: return `exp(${a[0]})`
|
|
126
|
+
case SLOP.LOG: return `log(max(${a[0]}, 1e-8))`
|
|
127
|
+
case SLOP.SQRT: return `sqrt(max(${a[0]}, 0))`
|
|
128
|
+
case SLOP.ABS: return `abs(${a[0]})`
|
|
129
|
+
case SLOP.SIGN: return `sign(${a[0]})`
|
|
130
|
+
case SLOP.FLOOR: return `floor(${a[0]})`
|
|
131
|
+
case SLOP.CEIL: return `ceil(${a[0]})`
|
|
132
|
+
case SLOP.ROUND: return `round(${a[0]})`
|
|
133
|
+
case SLOP.FRACT: return `frac(${a[0]})`
|
|
134
|
+
case SLOP.MIN: return `min(${a[0]}, ${a[1]})`
|
|
135
|
+
case SLOP.MAX: return `max(${a[0]}, ${a[1]})`
|
|
136
|
+
case SLOP.CLAMP: return `clamp(${a[0]}, ${a[1]}, ${a[2]})`
|
|
137
|
+
case SLOP.SATURATE: return `saturate(${a[0]})`
|
|
138
|
+
|
|
139
|
+
case SLOP.LENGTH: return `length(${a[0]})`
|
|
140
|
+
case SLOP.DISTANCE: return `distance(${a[0]}, ${a[1]})`
|
|
141
|
+
case SLOP.DOT: return `dot(${a[0]}, ${a[1]})`
|
|
142
|
+
case SLOP.CROSS: return `cross(${a[0]}, ${a[1]})`
|
|
143
|
+
case SLOP.NORMALIZE: return `normalize(${a[0]})`
|
|
144
|
+
case SLOP.REFLECT: return `reflect(${a[0]}, ${a[1]})`
|
|
145
|
+
case SLOP.LUMINANCE: return `${helper("sl_luminance")}(${a[0]}.rgb)`
|
|
146
|
+
case SLOP.TO_LINEAR: return target.colour === "linear" ? `${helper("sl_toLinear")}(${a[0]})` : a[0]!
|
|
147
|
+
|
|
148
|
+
case SLOP.MIX: return `lerp(${a[0]}, ${a[1]}, ${a[2]})`
|
|
149
|
+
case SLOP.STEP: return `step(${a[0]}, ${a[1]})`
|
|
150
|
+
case SLOP.SMOOTHSTEP: return `smoothstep(${a[0]}, ${a[1]}, ${a[2]})`
|
|
151
|
+
// Matches the VM exactly, branchlessly, rather than using an if.
|
|
152
|
+
// Two backends that pick differently here disagree on every edge
|
|
153
|
+
// value.
|
|
154
|
+
case SLOP.SELECT: return `lerp(${a[2]}, ${a[1]}, step(0.5, ${a[0]}))`
|
|
155
|
+
|
|
156
|
+
case SLOP.HSV2RGB: return `${helper("sl_hsv2rgb")}(${a[0]})`
|
|
157
|
+
case SLOP.NOISE: return `${helper("sl_valueNoise")}(${a[0]})`
|
|
158
|
+
case SLOP.SIMPLEX: return `${helper("sl_simplex")}(${a[0]})`
|
|
159
|
+
case SLOP.FBM: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, ${Math.round(imm[1] ?? 0)})`
|
|
160
|
+
case SLOP.TURBULENCE: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 2)`
|
|
161
|
+
case SLOP.RIDGED: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 3)`
|
|
162
|
+
case SLOP.SDF: {
|
|
163
|
+
const id = Math.round(imm[0] ?? 0)
|
|
164
|
+
const q = [imm[1] ?? 0, imm[2] ?? 0, imm[3] ?? 0, imm[4] ?? 0].map(lit)
|
|
165
|
+
const r = [imm[5] ?? 0, imm[6] ?? 0].map(lit)
|
|
166
|
+
return `${helper("sl_sdfDistance")}(${id}, ${a[0]}, float4(${q.join(", ")}), float2(${r.join(", ")}))`
|
|
167
|
+
}
|
|
168
|
+
case SLOP.VORONOI: return `${helper("sl_voronoi")}(${a[0]})`
|
|
169
|
+
case SLOP.SAMPLE: {
|
|
170
|
+
const slot = Math.round(imm[0] ?? 0)
|
|
171
|
+
textures.add(slot)
|
|
172
|
+
return target.sample(slot, a[0]!)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
default:
|
|
176
|
+
throw new SLError(
|
|
177
|
+
`the HLSL emitter has no case for opcode ${n.op}. A program using it would silently ` +
|
|
178
|
+
`differ between the VM and a compiled build, which is the one failure this design ` +
|
|
179
|
+
`cannot tolerate.`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const walk = (ref: number): void => {
|
|
185
|
+
if (emitted.has(ref)) return
|
|
186
|
+
const n = p.nodes[ref]
|
|
187
|
+
const deps = n.k === "swizzle" ? [n.src] : n.k === "call" ? n.args : []
|
|
188
|
+
for (const d of deps) walk(d)
|
|
189
|
+
emitted.add(ref)
|
|
190
|
+
lines.push(`${indent}${HLSL_TYPE[n.type]} ${name(ref)} = ${expr(n)};`)
|
|
191
|
+
}
|
|
192
|
+
walk(p.result)
|
|
193
|
+
lines.push(target.result === undefined
|
|
194
|
+
? `${indent}return ${name(p.result)};`
|
|
195
|
+
: `${indent}${target.result} = ${name(p.result)};`)
|
|
196
|
+
|
|
197
|
+
const sorted = (s: Set<number>) => [...s].sort((x, y) => x - y)
|
|
198
|
+
return {
|
|
199
|
+
body: lines.join("\n"),
|
|
200
|
+
uses: { uniforms: sorted(uniforms), textures: sorted(textures), helpers: [...helpers].sort() },
|
|
201
|
+
}
|
|
202
|
+
}
|
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,
|
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,
|
|
26
|
-
import {
|
|
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
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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)))
|
package/src/serial.ts
CHANGED
|
@@ -123,7 +123,9 @@ function uniform(u: unknown, i: number): UniformDecl {
|
|
|
123
123
|
if (typeof x.name !== "string" || ![1, 2, 3, 4].includes(x.type as number) || !Array.isArray(x.value)) {
|
|
124
124
|
fail(`uniform ${i} needs a name, a width and a value`)
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
const out: UniformDecl = { name: x.name!, type: x.type as SLType, value: x.value!.slice() }
|
|
127
|
+
if (x.colour === true) out.colour = true
|
|
128
|
+
return out
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
function texture(t: unknown, i: number): TextureDecl {
|
package/src/sl.ts
CHANGED
|
@@ -664,6 +664,24 @@ export const uniform = {
|
|
|
664
664
|
const b = ctx()
|
|
665
665
|
return mk(b, b.uniform(name, TYPE.VEC4, value), TYPE.VEC4)
|
|
666
666
|
},
|
|
667
|
+
/**
|
|
668
|
+
* A colour, defaulting to `hex` as written (sRGB, the way CSS reads it).
|
|
669
|
+
* Returns it converted to linear, as a hex literal is, and marks the
|
|
670
|
+
* declaration `colour` so a host can offer a colour picker for it. Three
|
|
671
|
+
* components when `width` is 3, dropping the alpha.
|
|
672
|
+
*/
|
|
673
|
+
colour: ((name: string, hex: string, width: 3 | 4 = 4): Vec3 | Vec4 => {
|
|
674
|
+
const b = ctx()
|
|
675
|
+
const c = parseHex(hex)
|
|
676
|
+
const type = width === 3 ? TYPE.VEC3 : TYPE.VEC4
|
|
677
|
+
const raw = mk(b, b.uniform(name, type, c.slice(0, width), true), type)
|
|
678
|
+
return toLinear(raw) as Vec3 | Vec4
|
|
679
|
+
}) as {
|
|
680
|
+
(name: string, hex: string): Vec4
|
|
681
|
+
(name: string, hex: string, width: 3): Vec3
|
|
682
|
+
(name: string, hex: string, width: 4): Vec4
|
|
683
|
+
(name: string, hex: string, width: 3 | 4): Vec3 | Vec4
|
|
684
|
+
},
|
|
667
685
|
}
|
|
668
686
|
|
|
669
687
|
export interface Texture {
|