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/CHANGELOG.md +10 -0
- package/README.md +266 -0
- package/package.json +60 -0
- package/src/color.ts +16 -0
- package/src/core.ts +24 -0
- package/src/emit/unity.ts +3 -0
- package/src/emit/web.ts +3 -0
- package/src/encode.ts +462 -0
- package/src/hlsl.ts +275 -0
- package/src/index.ts +24 -0
- package/src/ir.ts +301 -0
- package/src/lang/README.md +167 -0
- package/src/lang/ast.ts +87 -0
- package/src/lang/builtins.ts +168 -0
- package/src/lang/check.ts +541 -0
- package/src/lang/index.ts +55 -0
- package/src/lang/lexer.ts +176 -0
- package/src/lang/lower.ts +628 -0
- package/src/lang/parser.ts +460 -0
- package/src/lang/prelude.ts +75 -0
- package/src/limits.ts +50 -0
- package/src/ops.ts +346 -0
- package/src/serial.ts +137 -0
- package/src/shapes.ts +49 -0
- package/src/sl.ts +721 -0
- package/src/tables.ts +15 -0
- package/src/vm.ts +11 -0
- package/src/web.ts +273 -0
- package/src/weblib.ts +1303 -0
package/src/sl.ts
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authoring surface: a TypeScript EDSL that records a shader graph.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 of `Specs/SHADER_LANG.md` section 3. Calling a program function does
|
|
5
|
+
* not execute per pixel; it records a DAG once, at module load.
|
|
6
|
+
*
|
|
7
|
+
* Why an EDSL and not a text language: completion on every function, type errors
|
|
8
|
+
* at the call site, jump to definition, rename, and the author's existing editor,
|
|
9
|
+
* all for free. Monaco in the Play editor already has these types. A text syntax
|
|
10
|
+
* remains possible later and costs only a parser, because the parser would emit
|
|
11
|
+
* this same IR.
|
|
12
|
+
*
|
|
13
|
+
* It also gives common subexpression elimination for nothing, which is the most
|
|
14
|
+
* valuable optimisation here: a `const` in the host language IS the shared node.
|
|
15
|
+
*
|
|
16
|
+
* const plasma = sl.program(({ uv, time }) => {
|
|
17
|
+
* const p = uv.mul(8).add(time.mul(0.4))
|
|
18
|
+
* const v = sl.sin(p.x).add(sl.sin(p.y))
|
|
19
|
+
* return sl.vec4(v.mul(0.5).add(0.5), 0, 0, 1)
|
|
20
|
+
* })
|
|
21
|
+
*
|
|
22
|
+
* `p` is written once and used twice, so it is one node with two references.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { parseColor as parseHex } from "./color"
|
|
26
|
+
import {
|
|
27
|
+
Builder, INPUTS, SLError, SL_IR_VERSION, TYPE, hashProgram, widthName,
|
|
28
|
+
type InputName, type NodeRef, type Program, type SLNode, type SLType,
|
|
29
|
+
} from "./ir"
|
|
30
|
+
import { SLOP, type SLOpCode } from "./ops"
|
|
31
|
+
import { SL_SDF_PARAMS, SL_SDF_SHAPES, type SlSdfKind } from "./shapes"
|
|
32
|
+
|
|
33
|
+
// MARK: values
|
|
34
|
+
|
|
35
|
+
type Swz = "x" | "y" | "z" | "w" | "r" | "g" | "b" | "a"
|
|
36
|
+
const CHAN: Record<string, number> = { x: 0, y: 1, z: 2, w: 3, r: 0, g: 1, b: 2, a: 3 }
|
|
37
|
+
|
|
38
|
+
/** Width of a swizzle string, at the type level, so `swz("wzyx")` is a Vec4. */
|
|
39
|
+
type Widen<S extends string> =
|
|
40
|
+
S extends `${Swz}${Swz}${Swz}${Swz}` ? Vec4 :
|
|
41
|
+
S extends `${Swz}${Swz}${Swz}` ? Vec3 :
|
|
42
|
+
S extends `${Swz}${Swz}` ? Vec2 :
|
|
43
|
+
S extends `${Swz}` ? Float : never
|
|
44
|
+
|
|
45
|
+
/** Anything acceptable where a value is expected. Plain numbers broadcast. */
|
|
46
|
+
export type Num = Val | number
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A recorded value. One runtime class; the four exported types are TypeScript's
|
|
50
|
+
* view of it, which is where mixing a Vec2 with a Vec3 becomes an error at the
|
|
51
|
+
* call site rather than a black rectangle.
|
|
52
|
+
*/
|
|
53
|
+
// The `interface Val` further down declares the swizzle getters that the PAIRS
|
|
54
|
+
// loop installs on this prototype. They exist at runtime, so the merge is the
|
|
55
|
+
// point rather than a hazard; see the comment above that loop.
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
57
|
+
export class Val {
|
|
58
|
+
constructor(readonly owner: Builder, readonly ref: NodeRef, readonly width: SLType) {}
|
|
59
|
+
|
|
60
|
+
// Arithmetic. Mixing a vector with a Float broadcasts, as in HLSL.
|
|
61
|
+
add(o: Num): this { return bin(SLOP.ADD, this, o) as this }
|
|
62
|
+
sub(o: Num): this { return bin(SLOP.SUB, this, o) as this }
|
|
63
|
+
mul(o: Num): this { return bin(SLOP.MUL, this, o) as this }
|
|
64
|
+
div(o: Num): this { return bin(SLOP.DIV, this, o) as this }
|
|
65
|
+
mod(o: Num): this { return bin(SLOP.MOD, this, o) as this }
|
|
66
|
+
pow(o: Num): this { return bin(SLOP.POW, this, o) as this }
|
|
67
|
+
neg(): this { return un(SLOP.NEG, this) as this }
|
|
68
|
+
recip(): this { return un(SLOP.RECIP, this) as this }
|
|
69
|
+
abs(): this { return un(SLOP.ABS, this) as this }
|
|
70
|
+
saturate(): this { return un(SLOP.SATURATE, this) as this }
|
|
71
|
+
fract(): this { return un(SLOP.FRACT, this) as this }
|
|
72
|
+
floor(): this { return un(SLOP.FLOOR, this) as this }
|
|
73
|
+
min(o: Num): this { return bin(SLOP.MIN, this, o) as this }
|
|
74
|
+
max(o: Num): this { return bin(SLOP.MAX, this, o) as this }
|
|
75
|
+
clamp(lo: Num, hi: Num): this {
|
|
76
|
+
const { refs, width } = alignN([this, lo, hi])
|
|
77
|
+
return mk(this.owner, this.owner.call(SLOP.CLAMP, width, refs), width) as this
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Length, distance and dot collapse to a Float whatever the input width. */
|
|
81
|
+
length(): Float { return mk(this.owner, this.owner.call(SLOP.LENGTH, TYPE.FLOAT, [this.ref]), TYPE.FLOAT) }
|
|
82
|
+
distance(o: Num): Float {
|
|
83
|
+
const [a, c] = align2(this, o)
|
|
84
|
+
return mk(this.owner, this.owner.call(SLOP.DISTANCE, TYPE.FLOAT, [a, c]), TYPE.FLOAT)
|
|
85
|
+
}
|
|
86
|
+
dot(o: Num): Float {
|
|
87
|
+
const [a, c] = align2(this, o)
|
|
88
|
+
return mk(this.owner, this.owner.call(SLOP.DOT, TYPE.FLOAT, [a, c]), TYPE.FLOAT)
|
|
89
|
+
}
|
|
90
|
+
normalize(): this { return un(SLOP.NORMALIZE, this) as this }
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Arbitrary swizzle. The common single and full width ones are also getters
|
|
94
|
+
* (`p.x`, `c.rgb`); this covers everything else and is typed by its argument,
|
|
95
|
+
* so `c.swz("wzyx")` is a Vec4 and `c.swz("yx")` is a Vec2.
|
|
96
|
+
*/
|
|
97
|
+
swz<S extends string>(s: S): Widen<S> {
|
|
98
|
+
if (s.length < 1 || s.length > 4) throw new SLError(`a swizzle takes 1 to 4 components, got "${s}"`)
|
|
99
|
+
const chans: number[] = []
|
|
100
|
+
for (const ch of s) {
|
|
101
|
+
const c = CHAN[ch]
|
|
102
|
+
if (c === undefined) throw new SLError(`"${ch}" is not a component; use xyzw or rgba`)
|
|
103
|
+
if (c >= this.width) {
|
|
104
|
+
throw new SLError(`"${ch}" is component ${c + 1} of a ${widthName(this.width)}, which has ${this.width}`)
|
|
105
|
+
}
|
|
106
|
+
chans.push(c)
|
|
107
|
+
}
|
|
108
|
+
const t = chans.length as SLType
|
|
109
|
+
return mk(this.owner, this.owner.add({ k: "swizzle", type: t, src: this.ref, chans }), t) as Widen<S>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get x(): Float { return this.swz("x") }
|
|
113
|
+
get y(): Float { return this.swz("y") }
|
|
114
|
+
get z(): Float { return this.swz("z") }
|
|
115
|
+
get w(): Float { return this.swz("w") }
|
|
116
|
+
get r(): Float { return this.swz("r") }
|
|
117
|
+
get g(): Float { return this.swz("g") }
|
|
118
|
+
get b(): Float { return this.swz("b") }
|
|
119
|
+
get a(): Float { return this.swz("a") }
|
|
120
|
+
get xyz(): Vec3 { return this.swz("xyz") }
|
|
121
|
+
get xyzw(): Vec4 { return this.swz("xyzw") }
|
|
122
|
+
get rgb(): Vec3 { return this.swz("rgb") }
|
|
123
|
+
get rgba(): Vec4 { return this.swz("rgba") }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every two component swizzle, as a real getter.
|
|
128
|
+
*
|
|
129
|
+
* These are defined rather than listed because `uv.yx` is an ordinary thing to
|
|
130
|
+
* write and JavaScript answers an undeclared property with `undefined` rather
|
|
131
|
+
* than an error. That `undefined` then travels into `vec4(...)` and fails
|
|
132
|
+
* somewhere else entirely, about a value the author never wrote. Anything
|
|
133
|
+
* reachable at runtime should be reachable in the types too, so the interfaces
|
|
134
|
+
* below declare the same set.
|
|
135
|
+
*
|
|
136
|
+
* Three and four component permutations stay on `swz()`, which is typed by its
|
|
137
|
+
* argument. There are 320 of them and almost nobody writes `.zwyx`.
|
|
138
|
+
*/
|
|
139
|
+
const PAIRS: string[] = []
|
|
140
|
+
for (const set of ["xyzw", "rgba"]) {
|
|
141
|
+
for (const a of set) for (const b of set) PAIRS.push(a + b)
|
|
142
|
+
}
|
|
143
|
+
for (const p of PAIRS) {
|
|
144
|
+
if (p in Val.prototype) continue
|
|
145
|
+
Object.defineProperty(Val.prototype, p, {
|
|
146
|
+
get(this: Val) { return this.swz(p) },
|
|
147
|
+
enumerable: false,
|
|
148
|
+
configurable: true,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The two component swizzles defined above, so the types match the runtime. */
|
|
153
|
+
type Pair<S extends string> = { readonly [K in `${S}${S}` & string]: Vec2 }
|
|
154
|
+
// Deliberate: the type half of the getters defined on Val.prototype directly above.
|
|
155
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
156
|
+
export interface Val extends Pair<"x" | "y" | "z" | "w">, Pair<"r" | "g" | "b" | "a"> {}
|
|
157
|
+
|
|
158
|
+
/** TypeScript's view of a recorded value. One runtime class, four static types. */
|
|
159
|
+
export interface Float extends Val { readonly width: 1 }
|
|
160
|
+
export interface Vec2 extends Val { readonly width: 2 }
|
|
161
|
+
export interface Vec3 extends Val { readonly width: 3 }
|
|
162
|
+
export interface Vec4 extends Val { readonly width: 4 }
|
|
163
|
+
|
|
164
|
+
function mk(b: Builder, ref: NodeRef, width: SLType): any {
|
|
165
|
+
return new Val(b, ref, width)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// MARK: recording context
|
|
169
|
+
|
|
170
|
+
let current: Builder | null = null
|
|
171
|
+
|
|
172
|
+
function ctx(): Builder {
|
|
173
|
+
if (current === null) {
|
|
174
|
+
throw new SLError(
|
|
175
|
+
"sl values can only be built inside sl.program(). A value recorded outside one has no " +
|
|
176
|
+
"graph to belong to, and a value from a different program cannot be mixed into this one.",
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
return current
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Lifts a plain number to a node of the given width. */
|
|
183
|
+
function lift(b: Builder, v: Num, width: SLType): NodeRef {
|
|
184
|
+
if (typeof v === "number") return b.constant(new Array(width).fill(v))
|
|
185
|
+
if (v.owner !== b) throw new SLError("a value from another program cannot be used in this one")
|
|
186
|
+
return v.ref
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Aligns two operands. A Float broadcasts against a vector, as in HLSL; two
|
|
191
|
+
* vectors of different widths are a type error, which TypeScript already catches
|
|
192
|
+
* at the call site for typed code and this catches for everything else.
|
|
193
|
+
*/
|
|
194
|
+
function align2(a: Val, o: Num): [NodeRef, NodeRef] {
|
|
195
|
+
if (typeof o === "number") return [a.ref, lift(a.owner, o, a.width)]
|
|
196
|
+
if (o.owner !== a.owner) throw new SLError("a value from another program cannot be used in this one")
|
|
197
|
+
if (o.width === a.width) return [a.ref, o.ref]
|
|
198
|
+
if (o.width === 1) return [a.ref, broadcast(o, a.width)]
|
|
199
|
+
if (a.width === 1) return [broadcast(a, o.width), o.ref]
|
|
200
|
+
throw new SLError(`cannot combine a ${widthName(a.width)} with a ${widthName(o.width)}`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Aligns any number of operands of a component wise op to their widest.
|
|
205
|
+
*
|
|
206
|
+
* THE RESULT WIDTH IS THE WIDEST OPERAND, not the first one. `align2` gets that
|
|
207
|
+
* right for two operands because `bin` takes the max itself; the three and four
|
|
208
|
+
* operand ops used to take the width from one chosen operand, so
|
|
209
|
+
* `mix(0, aVec3, t)` produced a node typed float that held three components.
|
|
210
|
+
* The VM keeps every value in a float4 register and never noticed, while the
|
|
211
|
+
* HLSL emitter declared `float` and truncated, so the two backends rendered
|
|
212
|
+
* different pictures from one program. Anything component wise with more than
|
|
213
|
+
* two operands goes through here.
|
|
214
|
+
*/
|
|
215
|
+
function alignN(vs: Num[]): { refs: NodeRef[]; width: SLType } {
|
|
216
|
+
let width: SLType = 1
|
|
217
|
+
let owner: Builder | null = null
|
|
218
|
+
for (const v of vs) {
|
|
219
|
+
if (typeof v === "number") continue
|
|
220
|
+
if (owner === null) owner = v.owner
|
|
221
|
+
else if (v.owner !== owner) throw new SLError("a value from another program cannot be used in this one")
|
|
222
|
+
if (v.width > width) width = v.width
|
|
223
|
+
}
|
|
224
|
+
const b = owner ?? ctx()
|
|
225
|
+
const refs = vs.map((v) => {
|
|
226
|
+
if (typeof v === "number") return lift(b, v, width)
|
|
227
|
+
if (v.width === width) return v.ref
|
|
228
|
+
if (v.width === 1) return broadcast(v, width)
|
|
229
|
+
throw new SLError(`cannot combine a ${widthName(v.width)} with a ${widthName(width)}`)
|
|
230
|
+
})
|
|
231
|
+
return { refs, width }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** float -> vecN by repeating the component. */
|
|
235
|
+
function broadcast(v: Val, width: SLType): NodeRef {
|
|
236
|
+
return v.owner.add({ k: "swizzle", type: width, src: v.ref, chans: new Array(width).fill(0) })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function bin(op: SLOpCode, a: Val, o: Num): Val {
|
|
240
|
+
const [x, y] = align2(a, o)
|
|
241
|
+
const width = typeof o === "number" ? a.width : (Math.max(a.width, o.width) as SLType)
|
|
242
|
+
return mk(a.owner, a.owner.call(op, width, [x, y]), width)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function un(op: SLOpCode, a: Val): Val {
|
|
246
|
+
return mk(a.owner, a.owner.call(op, a.width, [a.ref]), a.width)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// MARK: the public surface
|
|
250
|
+
|
|
251
|
+
export interface ProgramInputs {
|
|
252
|
+
uv: Vec2
|
|
253
|
+
fragCoord: Vec2
|
|
254
|
+
resolution: Vec2
|
|
255
|
+
time: Float
|
|
256
|
+
aspect: Float
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Records a program. The function runs ONCE, here, not per pixel.
|
|
261
|
+
*
|
|
262
|
+
* It must return a Vec4: a program produces a colour.
|
|
263
|
+
*/
|
|
264
|
+
export function program(fn: (inputs: ProgramInputs) => Vec4): Program {
|
|
265
|
+
if (current !== null) throw new SLError("sl.program() cannot be nested")
|
|
266
|
+
const b = new Builder()
|
|
267
|
+
current = b
|
|
268
|
+
try {
|
|
269
|
+
const inputs = {} as ProgramInputs
|
|
270
|
+
for (const [name, width] of Object.entries(INPUTS)) {
|
|
271
|
+
const ref = b.add({ k: "input", type: width as SLType, name: name as InputName })
|
|
272
|
+
;(inputs as any)[name] = mk(b, ref, width as SLType)
|
|
273
|
+
}
|
|
274
|
+
const out = fn(inputs)
|
|
275
|
+
if (!(out instanceof Val)) throw new SLError("a program must return an sl value, not " + typeof out)
|
|
276
|
+
if (out.width !== TYPE.VEC4) {
|
|
277
|
+
throw new SLError(`a program must return a vec4, got a ${widthName(out.width)}. Wrap it: sl.vec4(value, 1)`)
|
|
278
|
+
}
|
|
279
|
+
const nodes: SLNode[] = b.nodes.slice()
|
|
280
|
+
return {
|
|
281
|
+
version: SL_IR_VERSION,
|
|
282
|
+
nodes,
|
|
283
|
+
result: out.ref,
|
|
284
|
+
uniforms: b.uniforms.slice(),
|
|
285
|
+
textures: b.textures.slice(),
|
|
286
|
+
hash: hashProgram(nodes, out.ref, b.uniforms, b.textures),
|
|
287
|
+
loops: b.loops.slice(),
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
current = null
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function float(v: Num): Float {
|
|
295
|
+
const b = ctx()
|
|
296
|
+
return typeof v === "number" ? mk(b, b.constant([v]), TYPE.FLOAT) : (v as Float)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Builds a wider value from narrower parts, which must add up exactly. */
|
|
300
|
+
function compose(width: SLType, parts: Num[]): Val {
|
|
301
|
+
const b = ctx()
|
|
302
|
+
// Wide parts are SPLIT into their components here, so COMPOSE only ever
|
|
303
|
+
// sees scalars.
|
|
304
|
+
//
|
|
305
|
+
// The VM keeps every value in a float4 register and took the x of each
|
|
306
|
+
// operand, which silently dropped everything after the first component:
|
|
307
|
+
// vec4(uv, 0, 1) rendered (uv.x, 0, 1, 0) instead of (uv.x, uv.y, 0, 1).
|
|
308
|
+
// Teaching the shader each part's width would need widths in an encoding
|
|
309
|
+
// that has no room for them, and would put the complexity in the half that
|
|
310
|
+
// is hardest to test. Splitting at record time costs a few extra swizzle
|
|
311
|
+
// nodes, which the register allocator reclaims immediately, and leaves the
|
|
312
|
+
// VM's COMPOSE trivially correct.
|
|
313
|
+
// A constructor of literals is ONE constant, not a COMPOSE of four.
|
|
314
|
+
//
|
|
315
|
+
// `vec4(0.1, 0.2, 0.3, 0.4)` was five instructions holding four registers
|
|
316
|
+
// at once, because every part became its own CONST and COMPOSE kept them
|
|
317
|
+
// all live. The IR has always been able to say it in one node: a const
|
|
318
|
+
// carries up to four components. A two stop `ramp` used five of the eight
|
|
319
|
+
// registers on nothing but its own stop colours, which put the language's
|
|
320
|
+
// headline colour tool within one or two registers of unusable in any
|
|
321
|
+
// program that also did something.
|
|
322
|
+
if (parts.length === width && parts.every((p) => typeof p === "number")) {
|
|
323
|
+
return mk(b, b.constant(parts as number[]), width)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const refs: NodeRef[] = []
|
|
327
|
+
let total = 0
|
|
328
|
+
for (const p of parts) {
|
|
329
|
+
if (typeof p === "number") { refs.push(b.constant([p])); total += 1; continue }
|
|
330
|
+
if (p.owner !== b) throw new SLError("a value from another program cannot be used in this one")
|
|
331
|
+
if (p.width === 1) { refs.push(p.ref); total += 1; continue }
|
|
332
|
+
for (let c = 0; c < p.width; c++) {
|
|
333
|
+
refs.push(b.add({ k: "swizzle", type: TYPE.FLOAT, src: p.ref, chans: [c] }))
|
|
334
|
+
total += 1
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (total !== width) {
|
|
338
|
+
throw new SLError(`vec${width} needs ${width} components, got ${total}`)
|
|
339
|
+
}
|
|
340
|
+
return mk(b, b.call(SLOP.COMPOSE, width, refs), width)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function vec2(...parts: Num[]): Vec2 { return compose(TYPE.VEC2, parts) as Vec2 }
|
|
344
|
+
export function vec3(...parts: Num[]): Vec3 { return compose(TYPE.VEC3, parts) as Vec3 }
|
|
345
|
+
export function vec4(...parts: Num[]): Vec4 { return compose(TYPE.VEC4, parts) as Vec4 }
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A one argument op, typed so the width survives.
|
|
349
|
+
*
|
|
350
|
+
* Overloads rather than a conditional return type: the conditional form widened
|
|
351
|
+
* `sl.sin(aFloat)` to `Float | Val`, so `let v = uv.x; v = sl.sin(v)` failed to
|
|
352
|
+
* typecheck. An author hitting that would reasonably conclude the types were
|
|
353
|
+
* decorative.
|
|
354
|
+
*/
|
|
355
|
+
function unary(op: SLOpCode) {
|
|
356
|
+
function f(v: number): Float
|
|
357
|
+
function f<T extends Val>(v: T): T
|
|
358
|
+
function f(v: Num): Val {
|
|
359
|
+
return un(op, typeof v === "number" ? (float(v) as Val) : v)
|
|
360
|
+
}
|
|
361
|
+
return f
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export const sin = unary(SLOP.SIN)
|
|
365
|
+
export const cos = unary(SLOP.COS)
|
|
366
|
+
export const tan = unary(SLOP.TAN)
|
|
367
|
+
export const asin = unary(SLOP.ASIN)
|
|
368
|
+
export const acos = unary(SLOP.ACOS)
|
|
369
|
+
export const exp = unary(SLOP.EXP)
|
|
370
|
+
export const log = unary(SLOP.LOG)
|
|
371
|
+
export const sqrt = unary(SLOP.SQRT)
|
|
372
|
+
export const sign = unary(SLOP.SIGN)
|
|
373
|
+
export const ceil = unary(SLOP.CEIL)
|
|
374
|
+
export const round = unary(SLOP.ROUND)
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Collapses a colour to a single brightness.
|
|
378
|
+
*
|
|
379
|
+
* NOT declared through `unary`, which preserves width. Luminance is one of the
|
|
380
|
+
* few ops whose result is narrower than its input, and having it return a Vec4
|
|
381
|
+
* meant `sl.vec4(sl.mix(lum, c.x, 0.5), c.y, c.z, c.w)` silently became seven
|
|
382
|
+
* components. The error surfaced two calls away from the cause, which is what
|
|
383
|
+
* width preserving by default costs when it is wrong.
|
|
384
|
+
*/
|
|
385
|
+
/**
|
|
386
|
+
* A colour as written (sRGB, the way CSS reads a hex) to the working space the
|
|
387
|
+
* target holds. `ramp` and `color` already apply it; call it yourself on a
|
|
388
|
+
* vec4 built from raw components that mean a colour. Alpha is left alone.
|
|
389
|
+
*/
|
|
390
|
+
export const toLinear = unary(SLOP.TO_LINEAR)
|
|
391
|
+
|
|
392
|
+
/** A hex colour as a vec4 in the working space: `parseColor` plus `toLinear`. */
|
|
393
|
+
export function color(hex: string): Vec4 {
|
|
394
|
+
const v = parseColor(hex)
|
|
395
|
+
return toLinear(vec4(v[0], v[1], v[2], v[3]))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function luminance(c: Num): Float {
|
|
399
|
+
const v = typeof c === "number" ? float(c) : c
|
|
400
|
+
return mk(v.owner, v.owner.call(SLOP.LUMINANCE, TYPE.FLOAT, [v.ref]), TYPE.FLOAT)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Hue, saturation and value to RGB.
|
|
405
|
+
*
|
|
406
|
+
* The opcode was already implemented in both backends and simply had no name
|
|
407
|
+
* out here, which is the kind of gap only writing something real finds: a demo
|
|
408
|
+
* wanted a hue uniform, and a ramp cannot have one because its stops are
|
|
409
|
+
* constants.
|
|
410
|
+
*/
|
|
411
|
+
export function hsv2rgb(c: Vec3): Vec3 {
|
|
412
|
+
return mk(c.owner, c.owner.call(SLOP.HSV2RGB, TYPE.VEC3, [c.ref]), TYPE.VEC3)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Cross and reflect, both vec3 only.
|
|
417
|
+
*
|
|
418
|
+
* The VM evaluates them as `cross(a.xyz, b.xyz)`, so a vec2 or vec4 would mean
|
|
419
|
+
* one thing in a compiled build and another in the interpreter. Refusing the
|
|
420
|
+
* other widths here is what keeps the two honest.
|
|
421
|
+
*/
|
|
422
|
+
export function cross(a: Vec3, b: Vec3): Vec3 {
|
|
423
|
+
if (a.width !== TYPE.VEC3 || b.width !== TYPE.VEC3) {
|
|
424
|
+
throw new SLError(`cross takes two vec3s, got a ${widthName(a.width)} and a ${widthName(b.width)}`)
|
|
425
|
+
}
|
|
426
|
+
const [x, y] = align2(a, b)
|
|
427
|
+
return mk(a.owner, a.owner.call(SLOP.CROSS, TYPE.VEC3, [x, y]), TYPE.VEC3)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function reflect(incident: Vec3, normal: Vec3): Vec3 {
|
|
431
|
+
if (incident.width !== TYPE.VEC3 || normal.width !== TYPE.VEC3) {
|
|
432
|
+
throw new SLError(
|
|
433
|
+
`reflect takes two vec3s, got a ${widthName(incident.width)} and a ${widthName(normal.width)}`,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
const [i, n] = align2(incident, normal)
|
|
437
|
+
return mk(incident.owner, incident.owner.call(SLOP.REFLECT, TYPE.VEC3, [i, n]), TYPE.VEC3)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* One range to another, linearly and without clamping.
|
|
442
|
+
*
|
|
443
|
+
* A MACRO, like `ramp`, and for the same reason: it expands into arithmetic
|
|
444
|
+
* both backends already have, so it needs no opcode, no second implementation
|
|
445
|
+
* in the emitter and no VM case. `SLOP.REMAP` stays a reserved number.
|
|
446
|
+
*
|
|
447
|
+
* Unclamped on purpose, which is what HLSL authors expect of the one liner they
|
|
448
|
+
* would otherwise write; wrap it in `saturate` when the ends matter.
|
|
449
|
+
*/
|
|
450
|
+
export function remap(v: Num, fromMin: Num, fromMax: Num, toMin: Num, toMax: Num): Val {
|
|
451
|
+
const x = typeof v === "number" ? float(v) : v
|
|
452
|
+
const t = x.sub(fromMin).div(difference(fromMax, fromMin))
|
|
453
|
+
return mix(toMin, toMax, t)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** `a - b` where either side may still be a plain number, so a constant stays one. */
|
|
457
|
+
function difference(a: Num, b: Num): Num {
|
|
458
|
+
if (typeof a === "number" && typeof b === "number") return a - b
|
|
459
|
+
return (typeof a === "number" ? float(a) : a).sub(b)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function atan2(y: Num, x: Num): Float {
|
|
463
|
+
const b = ctx()
|
|
464
|
+
const yy = typeof y === "number" ? float(y) : y
|
|
465
|
+
const [a, c] = align2(yy as Val, x)
|
|
466
|
+
return mk(b, b.call(SLOP.ATAN2, TYPE.FLOAT, [a, c]), TYPE.FLOAT)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Branchless selection. Both sides are evaluated, which is why v1 has this
|
|
471
|
+
* rather than an `if`: a real branch would have to survive both backends
|
|
472
|
+
* identically, and this does not.
|
|
473
|
+
*/
|
|
474
|
+
export function select(cond: Num, whenTrue: Num, whenFalse: Num): Val {
|
|
475
|
+
const b = ctx()
|
|
476
|
+
const c = typeof cond === "number" ? float(cond) : cond
|
|
477
|
+
const t = typeof whenTrue === "number" ? float(whenTrue) : whenTrue
|
|
478
|
+
const f = typeof whenFalse === "number" ? float(whenFalse) : whenFalse
|
|
479
|
+
// The condition is widened with the branches, not left at its own width.
|
|
480
|
+
// A register is a float4 whatever it holds, so a scalar condition sits
|
|
481
|
+
// there as (c, 0, 0, 0) and the VM's `step(0.5, cond)` answered 0 for
|
|
482
|
+
// components y, z and w, while the HLSL backend broadcasts a scalar
|
|
483
|
+
// itself. The two DISAGREED on every vector valued select, which is the one
|
|
484
|
+
// failure this design cannot tolerate.
|
|
485
|
+
const { refs, width } = alignN([c, t, f])
|
|
486
|
+
return mk(b, b.call(SLOP.SELECT, width, refs), width)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The interpolant is broadcast to the operand width before it crosses.
|
|
491
|
+
*
|
|
492
|
+
* A register is a float4 whatever it holds, so a scalar `t` sits there as
|
|
493
|
+
* (t, 0, 0, 0) and the shader's lerp ran per component against those zeros:
|
|
494
|
+
* a black to white ramp at its midpoint rendered (0.5, 0, 0, 1) instead of
|
|
495
|
+
* grey. Broadcasting here rather than in the shader keeps `t` free to be a
|
|
496
|
+
* genuine per component vector when an author wants one.
|
|
497
|
+
*/
|
|
498
|
+
export function mix(a: Num, bv: Num, t: Num): Val {
|
|
499
|
+
const { refs, width } = alignN([a, bv, t])
|
|
500
|
+
const b = ctx()
|
|
501
|
+
return mk(b, b.call(SLOP.MIX, width, refs), width)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function step(edge: Num, x: Num): Val {
|
|
505
|
+
const { refs, width } = alignN([edge, x])
|
|
506
|
+
const b = ctx()
|
|
507
|
+
return mk(b, b.call(SLOP.STEP, width, refs), width)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function smoothstep(e0: Num, e1: Num, x: Num): Val {
|
|
511
|
+
const { refs, width } = alignN([e0, e1, x])
|
|
512
|
+
const b = ctx()
|
|
513
|
+
return mk(b, b.call(SLOP.SMOOTHSTEP, width, refs), width)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Uniform defaults, in slot order, for a host that has to seed them. */
|
|
517
|
+
export function uniformDefaults(p: Program): number[] {
|
|
518
|
+
const out: number[] = []
|
|
519
|
+
for (const u of p.uniforms) {
|
|
520
|
+
for (let i = 0; i < 4; i++) out.push(u.value[i] ?? (i === 3 ? 1 : 0))
|
|
521
|
+
}
|
|
522
|
+
return out
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Noise, as superinstructions rather than graphs of primitives, and the same
|
|
527
|
+
* fields `fx.noise` draws: a value or simplex base, layered as fBm, or the
|
|
528
|
+
* turbulence and ridged variants built on simplex. A program has no seed;
|
|
529
|
+
* offset the input for a different field. Octaves are 1 to 4, as in fx.
|
|
530
|
+
*/
|
|
531
|
+
export function noise(p: Vec2): Float {
|
|
532
|
+
return mk(p.owner, p.owner.call(SLOP.NOISE, TYPE.FLOAT, [p.ref]), TYPE.FLOAT)
|
|
533
|
+
}
|
|
534
|
+
export function simplex(p: Vec2): Float {
|
|
535
|
+
return mk(p.owner, p.owner.call(SLOP.SIMPLEX, TYPE.FLOAT, [p.ref]), TYPE.FLOAT)
|
|
536
|
+
}
|
|
537
|
+
function octaveCount(name: string, octaves: number): number {
|
|
538
|
+
if (!Number.isInteger(octaves) || octaves < 1 || octaves > 4) {
|
|
539
|
+
throw new SLError(`${name} octaves must be a whole number from 1 to 4, got ${octaves}`)
|
|
540
|
+
}
|
|
541
|
+
return octaves
|
|
542
|
+
}
|
|
543
|
+
/** Layered noise. `base` picks the grid ("value", the default) or triangles ("simplex"). */
|
|
544
|
+
export function fbm(p: Vec2, octaves = 3, base: "value" | "simplex" = "value"): Float {
|
|
545
|
+
const kind = base === "simplex" ? 1 : 0
|
|
546
|
+
return mk(p.owner, p.owner.call(SLOP.FBM, TYPE.FLOAT, [p.ref], [octaveCount("fbm", octaves), kind]), TYPE.FLOAT)
|
|
547
|
+
}
|
|
548
|
+
/** Sum of |simplex| octaves: creases that stack into veins and licks. Fire, smoke, marble. */
|
|
549
|
+
export function turbulence(p: Vec2, octaves = 3): Float {
|
|
550
|
+
return mk(p.owner, p.owner.call(SLOP.TURBULENCE, TYPE.FLOAT, [p.ref], [octaveCount("turbulence", octaves)]), TYPE.FLOAT)
|
|
551
|
+
}
|
|
552
|
+
/** The turbulence crease made bright and squared: ridges, lightning, cracks. */
|
|
553
|
+
export function ridged(p: Vec2, octaves = 3): Float {
|
|
554
|
+
return mk(p.owner, p.owner.call(SLOP.RIDGED, TYPE.FLOAT, [p.ref], [octaveCount("ridged", octaves)]), TYPE.FLOAT)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** "#rgb", "#rrggbb" or "#rrggbbaa" to 0..1 components. Shared with fx. */
|
|
558
|
+
export function parseColor(hex: string): [number, number, number, number] {
|
|
559
|
+
try {
|
|
560
|
+
return parseHex(hex)
|
|
561
|
+
} catch (e) {
|
|
562
|
+
throw new SLError((e as Error).message)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Maps 0..1 through evenly spaced colour stops.
|
|
568
|
+
*
|
|
569
|
+
* A MACRO, not an opcode. It expands into the mixes and smoothsteps already in
|
|
570
|
+
* the table, which is worth more than a dedicated instruction would be: it needs
|
|
571
|
+
* no new encoding, no ramp uniforms competing for space with the program, and no
|
|
572
|
+
* second implementation in the HLSL emitter. Both backends get it right by
|
|
573
|
+
* getting `mix` right, and a ramp of any length works rather than however many
|
|
574
|
+
* stops an instruction could carry.
|
|
575
|
+
*
|
|
576
|
+
* This is the argument for the EDSL in miniature. A library function that
|
|
577
|
+
* composes from primitives costs one function here and nothing anywhere else.
|
|
578
|
+
*/
|
|
579
|
+
export function ramp(t: Num, stops: Array<string | [number, number, number, number]>): Vec4 {
|
|
580
|
+
if (stops.length < 2) throw new SLError(`a ramp needs at least 2 stops, got ${stops.length}`)
|
|
581
|
+
const tv = (typeof t === "number" ? float(t) : t).saturate()
|
|
582
|
+
// Each stop is built IMMEDIATELY BEFORE the mix that consumes it, not all
|
|
583
|
+
// of them up front. The graph is identical either way and so is the hash,
|
|
584
|
+
// which is a Merkle hash over the shape and not over storage order, but the
|
|
585
|
+
// register allocator walks the array: built up front, every stop stayed
|
|
586
|
+
// live until the last mix, and a four stop ramp reserved four of the VM's
|
|
587
|
+
// eight registers before the program did anything. That put the language's
|
|
588
|
+
// headline colour tool a couple of registers from unusable.
|
|
589
|
+
const colourAt = (i: number) => {
|
|
590
|
+
const v = typeof stops[i] === "string" ? parseColor(stops[i] as string) : (stops[i] as number[])
|
|
591
|
+
return vec4(v[0], v[1], v[2], v[3])
|
|
592
|
+
}
|
|
593
|
+
const spans = stops.length - 1
|
|
594
|
+
let out = colourAt(0)
|
|
595
|
+
for (let i = 0; i < spans; i++) {
|
|
596
|
+
// Local 0..1 across this span, clamped, so stops outside it contribute
|
|
597
|
+
// nothing and the chain reads as "each span paints over the last".
|
|
598
|
+
const local = (tv.mul(spans).sub(i) as Float).saturate()
|
|
599
|
+
out = mix(out, colourAt(i + 1), local) as Vec4
|
|
600
|
+
}
|
|
601
|
+
// The stops are sRGB as written and the mixes ran in that space, which is
|
|
602
|
+
// what reads as an even ramp; one conversion at the end puts the result in
|
|
603
|
+
// the target's working space. Same rule as fx's gradient and ramp.
|
|
604
|
+
return toLinear(out)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Signed distance to a shape, positive outside and negative inside.
|
|
609
|
+
*
|
|
610
|
+
* The 42 shapes are the same ones `fx` draws, from the same HLSL, so a hexagon
|
|
611
|
+
* means one thing across the whole library rather than two.
|
|
612
|
+
*
|
|
613
|
+
* const d = sl.sdf("hexagon", uv.sub(0.5), [0.3])
|
|
614
|
+
* return sl.vec4(sl.smoothstep(0.01, 0, d), 0, 0, 1)
|
|
615
|
+
*
|
|
616
|
+
* POSITION AND ROTATION ARE NOT PARAMETERS, deliberately. Transform the point
|
|
617
|
+
* before calling, as `uv.sub(centre)` above does. That is how signed distance
|
|
618
|
+
* code is normally written and it composes with everything else in the language.
|
|
619
|
+
*
|
|
620
|
+
* A shape takes up to `SL_SDF_PARAMS[kind]` parameters, six at most. Fewer
|
|
621
|
+
* fills zeros, which is all a program could pass before six shapes grew their
|
|
622
|
+
* fifth and sixth.
|
|
623
|
+
*
|
|
624
|
+
* Which parameters a shape takes is the shape's own business; `circle` wants a
|
|
625
|
+
* radius, `roundedBox` wants half extents and a corner. See SDF2D.cginc.
|
|
626
|
+
*/
|
|
627
|
+
export function sdf(kind: SlSdfKind, p: Vec2, params: number[] = []): Float {
|
|
628
|
+
const id = SL_SDF_SHAPES[kind]
|
|
629
|
+
if (id === undefined) throw new SLError(`"${kind}" is not a shape; see SL_SDF_SHAPES for the 42 names`)
|
|
630
|
+
// At least four, so a program that passed a shape more than it reads, which
|
|
631
|
+
// was harmless when every shape took four, still builds.
|
|
632
|
+
const most = Math.max(4, SL_SDF_PARAMS[kind])
|
|
633
|
+
if (params.length > most) {
|
|
634
|
+
throw new SLError(`sl.sdf("${kind}") takes at most ${most} parameters and was given ${params.length}`)
|
|
635
|
+
}
|
|
636
|
+
for (const v of params) {
|
|
637
|
+
if (!Number.isFinite(v)) throw new SLError(`sl.sdf parameters must be finite, got ${v}`)
|
|
638
|
+
}
|
|
639
|
+
// Four always, as before, and a fifth and sixth only when given: a program
|
|
640
|
+
// using four or fewer is the same node, and hashes the same, as it was.
|
|
641
|
+
const imm = [params[0] ?? 0, params[1] ?? 0, params[2] ?? 0, params[3] ?? 0, ...params.slice(4)]
|
|
642
|
+
return mk(p.owner, p.owner.call(SLOP.SDF, TYPE.FLOAT, [p.ref], [id, ...imm]), TYPE.FLOAT)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Distance to the nearest point of a jittered lattice. Cells, cracks, scales. */
|
|
646
|
+
export function voronoi(p: Vec2): Float {
|
|
647
|
+
return mk(p.owner, p.owner.call(SLOP.VORONOI, TYPE.FLOAT, [p.ref]), TYPE.FLOAT)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export const uniform = {
|
|
651
|
+
float(name: string, value = 0): Float {
|
|
652
|
+
const b = ctx()
|
|
653
|
+
return mk(b, b.uniform(name, TYPE.FLOAT, [value]), TYPE.FLOAT)
|
|
654
|
+
},
|
|
655
|
+
vec2(name: string, value: [number, number] = [0, 0]): Vec2 {
|
|
656
|
+
const b = ctx()
|
|
657
|
+
return mk(b, b.uniform(name, TYPE.VEC2, value), TYPE.VEC2)
|
|
658
|
+
},
|
|
659
|
+
vec3(name: string, value: [number, number, number] = [0, 0, 0]): Vec3 {
|
|
660
|
+
const b = ctx()
|
|
661
|
+
return mk(b, b.uniform(name, TYPE.VEC3, value), TYPE.VEC3)
|
|
662
|
+
},
|
|
663
|
+
vec4(name: string, value: [number, number, number, number] = [0, 0, 0, 1]): Vec4 {
|
|
664
|
+
const b = ctx()
|
|
665
|
+
return mk(b, b.uniform(name, TYPE.VEC4, value), TYPE.VEC4)
|
|
666
|
+
},
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export interface Texture {
|
|
670
|
+
sample(uv: Vec2): Vec4
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function texture(name: string): Texture {
|
|
674
|
+
const b = ctx()
|
|
675
|
+
const slot = b.texture(name)
|
|
676
|
+
return {
|
|
677
|
+
sample(uv: Vec2): Vec4 {
|
|
678
|
+
if (uv.owner !== b) throw new SLError("a value from another program cannot be used in this one")
|
|
679
|
+
return mk(b, b.call(SLOP.SAMPLE, TYPE.VEC4, [uv.ref], [slot]), TYPE.VEC4)
|
|
680
|
+
},
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* A loop that UNROLLS at record time. `n` is a JavaScript number, so the count
|
|
686
|
+
* is known while the graph is being built and both backends see straight line
|
|
687
|
+
* code.
|
|
688
|
+
*
|
|
689
|
+
* Honest about what it is: a macro, not a loop. It covers fbm, layered noise and
|
|
690
|
+
* small iterated distance fields, which is most of what 2D shaders loop for. A
|
|
691
|
+
* data dependent loop is out of scope, because in the VM it would need a nested
|
|
692
|
+
* bounded loop with a dynamic trip count while codegen would handle it fine, and
|
|
693
|
+
* the two backends agreeing is the property this whole design protects.
|
|
694
|
+
*/
|
|
695
|
+
export function repeat<T extends Val>(n: number, body: (i: number, acc: T) => T, seed: T): T {
|
|
696
|
+
if (!Number.isInteger(n) || n < 0 || n > 64) {
|
|
697
|
+
throw new SLError(`repeat count must be a whole number from 0 to 64, got ${n}`)
|
|
698
|
+
}
|
|
699
|
+
return unrolled(n, () => {
|
|
700
|
+
let acc = seed
|
|
701
|
+
for (let i = 0; i < n; i++) acc = body(i, acc)
|
|
702
|
+
return acc
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Records the nodes a body produced as one unrolled span.
|
|
708
|
+
*
|
|
709
|
+
* Diagnostic only: the instruction ceiling error uses the span to say which
|
|
710
|
+
* loop spent the budget, so the fix reads as "lower this count" rather than
|
|
711
|
+
* "fewer instructions". `repeat` is the EDSL's form of it and the text
|
|
712
|
+
* language's `for` is the other, and neither should be reaching into the
|
|
713
|
+
* Builder to push a span itself.
|
|
714
|
+
*/
|
|
715
|
+
export function unrolled<T>(count: number, body: () => T): T {
|
|
716
|
+
const b = ctx()
|
|
717
|
+
const start = b.nodes.length
|
|
718
|
+
const out = body()
|
|
719
|
+
b.loops.push({ count, start, end: b.nodes.length })
|
|
720
|
+
return out
|
|
721
|
+
}
|